Skip to content

v1.9.0: structured touch-events table (Pro-reporting foundation) - #69

Merged
Atroci merged 2 commits into
mainfrom
agent-v1-9-0-events-table
Aug 16, 2026
Merged

v1.9.0: structured touch-events table (Pro-reporting foundation)#69
Atroci merged 2 commits into
mainfrom
agent-v1-9-0-events-table

Conversation

@Atroci

@Atroci Atroci commented Aug 16, 2026

Copy link
Copy Markdown
Contributor

Summary

Builds the events table ROADMAP.md calls "the most important architectural decision in this roadmap" -- the foundation every Pro reporting feature (attribution dashboard, LTV, conversion recovery, call tracking) depends on. Foundation only: no reporting UI, LTV, conversion recovery, CRM mapping, or call-tracking intake is built here -- those are separate, later roadmap items.

  • New, permanent table {prefix}clicutcl_touch_events, separate from the existing clicutcl_events (JSON-blob admin Logs / GDPR table) -- that table is unrenamed and unrepurposed, both coexist permanently.

  • Columns mapped to real fields the canonical event pipeline (EventV2) already produces, not the roadmap's schema verbatim:

    id, blog_id, visitor_id, session_id, event_name, funnel_stage, source_channel, touch_source, touch_medium, touch_campaign, ft_source, ft_medium, ft_campaign, order_id, amount, currency, created_at

    Indexes: PRIMARY KEY (id), KEY on visitor_id, order_id, blog_id, created_at.

    • visitor_id is pseudonymous only: resolved hashed_email (SHA-256) when identity is available, else the session ID -- never a raw email or IP.
    • touch_source/touch_medium/touch_campaign replace the roadmap's generic source/medium/campaign columns, since the pipeline has no such field to map onto directly -- derived per-field as "current touch" (last-touch when present, else first-touch).
    • funnel_stage (top/mid/bottom/unknown) stands in for the roadmap's invented event_type (touch/conversion/renewal/call) typing -- it's the real coarse-category field the pipeline already computes.

Write path

Touch_Events_Store::record() is called from the very top of Dispatcher::dispatch() -- the single function every event source (browser events, WooCommerce purchases/milestones, form submissions, webhooks, lifecycle updates) already funnels through -- placed ahead of the is_enabled()/endpoint gates so it fires for free users regardless of whether server-side delivery is configured, per the roadmap ("collects silently on every touch event and conversion").

Consent gating reuses the existing Dispatcher::consent_allows() (no new consent logic): when marketing consent is required and not granted, the write is skipped entirely rather than persisting a partial/anonymous row.

Confirmed Queue::process_row() (the failed-delivery retry path) calls the adapter directly and never re-enters Dispatcher::dispatch(), so retries do not produce duplicate touch-event rows.

Lifecycle

  • Installer::DB_VERSION 2 -> 3, following the exact clicutcl_queue dbDelta/readiness-option pattern. maybe_upgrade() (already invoked on every boot via Queue::register()) picks up existing installs automatically.
  • Retention: 90 days by default (matching the cookie-retention convention), wired into the existing clicutcl_daily_cleanup cron in Cleanup::run_cleanup(), same LIMIT-bounded batch-delete style as the other two tables. Left a ponytail: comment noting this table can out-insert the 1000/day ceiling on a busy site (it takes a row per browser event, not just form submissions) -- bump the LIMIT or loop the delete if that happens.
  • uninstall.php drops the new table alongside clicutcl_queue/clicutcl_events, respecting clicutcl_preserve_data_on_uninstall.

Privacy / GDPR

Privacy_Handler now exports and erases clicutcl_touch_events rows matched on visitor_id = <hashed_email> -- an exact-value match against the same SHA-256 format Identity_Resolver and the queue's own eraser already use. Export paginates the new table independently of clicutcl_events (this table accumulates a row per browser event, so a single visitor can exceed the 50-row page size that was safe for the old table); done requires both sources exhausted.

Documented limitation: rows whose visitor_id fell back to a session ID (no identity was ever resolved for that visitor) aren't reachable by an email-keyed erasure request -- inherent to hashed-only matching, same limitation the queue eraser already has.

Docs

docs/architecture/DATA-MODEL.md and docs/guides/SECURITY-PRIVACY.md updated to describe the new table, its columns, and GDPR coverage.

Version

Minor bump 1.8.19 -> 1.9.0 per RELEASING.md's versioning table -- a new persistent table plus an always-on background write path is a backward-compatible new feature, not a patch, despite zero free-tier UI surface. readme.txt Stable tag intentionally untouched.

Test plan

  • CI: PHPCS zero warnings
  • CI: PHPUnit on PHP 8.1/8.2/8.3 -- tests/unit/TouchEventsStoreTest.php covers Touch_Events_Store::build_row()'s consent gate, both attribution shapes (WooCommerce's nested first_touch/last_touch vs. browser/form flat ft_*/lt_* keys), first/last-touch derivation, visitor-ID precedence, and order/commerce field mapping -- the pure-logic slice reachable without a live $wpdb/DB harness, same constraint the existing suite operates under. insert() itself is not exercised (requires a live DB), same gap documented for class-queue.php.
  • Note: PHPCS/PHPUnit were not run locally -- no PHP runtime available in this worker environment. CI is the gate for both; flagging explicitly rather than implying it was checked.
  • Manual: fresh install creates the table; existing 1.8.19 install upgrades via maybe_upgrade(); a purchase/form-submit/browser event writes a row with correct attribution derivation; consent-denied write is skipped; export/erase personal-data request covers the new table; uninstall drops it (and doesn't, when clicutcl_preserve_data_on_uninstall is set).

🤖 Generated with Claude Code

Atroci and others added 2 commits August 16, 2026 23:02
Adds a new, permanent table `{prefix}clicutcl_touch_events`, separate from
the existing `clicutcl_events` JSON-blob log, which stays unrenamed and
unrepurposed. Columns map to real fields the canonical event pipeline
(EventV2) already produces, not the roadmap's schema verbatim: visitor_id
is pseudonymous (hashed_email else session_id, never raw PII), and
touch_source/medium/campaign derive "current touch" (last-touch else
first-touch) since the pipeline has no generic source/medium/campaign
field to map onto directly.

Write path: Touch_Events_Store::record() is called from the top of
Dispatcher::dispatch() -- the single function every event source already
funnels through -- ahead of the is_enabled()/endpoint gates, so it fires
for free users regardless of server-side delivery configuration. Consent
gating reuses the existing Dispatcher::consent_allows(); the write is
skipped entirely when consent is required and denied.

Lifecycle: DB_VERSION 2 -> 3 in Installer, following the existing
clicutcl_queue dbDelta/readiness pattern; 90-day retention wired into the
existing daily cleanup cron; table drop added to uninstall.php respecting
clicutcl_preserve_data_on_uninstall.

Privacy: Privacy_Handler now exports and erases clicutcl_touch_events rows
matched on visitor_id = hashed_email (exact match), paginated
independently of the legacy events table since this one accumulates a row
per browser event.

Confirmed Queue::process_row() (retry path) calls the adapter directly and
never re-enters Dispatcher::dispatch(), so failed-delivery retries do not
double-write touch rows.

Out of scope: the Pro attribution dashboard, LTV, conversion recovery, CRM
field mapping, and call-tracking webhook intake -- later roadmap items
that depend on this table but aren't built here.

PHPCS and PHPUnit were not run locally (no PHP runtime in this worker
environment); CI is the gate for both. tests/unit/TouchEventsStoreTest.php
covers Touch_Events_Store::build_row()'s pure logic -- consent gate, both
attribution shapes, first/last-touch derivation, visitor-ID precedence,
order/commerce mapping -- the same DB-less slice the existing suite
already operates under.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Caught during review verification via podman (php:8.1-cli + phpcs.xml.dist):
Generic.Commenting.DocComment.LongNotCapital on the class-level doc block.
Cosmetic only, no behavior change. PHPCS now 83/83 clean, PHPUnit 72/72 passing
(both independently re-verified locally, not just claimed).
@Atroci
Atroci merged commit a0fc8c3 into main Aug 16, 2026
7 of 8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant