v1.9.0: structured touch-events table (Pro-reporting foundation) - #69
Merged
Conversation
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).
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 existingclicutcl_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_atIndexes:
PRIMARY KEY (id),KEYonvisitor_id,order_id,blog_id,created_at.visitor_idis pseudonymous only: resolvedhashed_email(SHA-256) when identity is available, else the session ID -- never a raw email or IP.touch_source/touch_medium/touch_campaignreplace the roadmap's genericsource/medium/campaigncolumns, 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 inventedevent_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 ofDispatcher::dispatch()-- the single function every event source (browser events, WooCommerce purchases/milestones, form submissions, webhooks, lifecycle updates) already funnels through -- placed ahead of theis_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-entersDispatcher::dispatch(), so retries do not produce duplicate touch-event rows.Lifecycle
Installer::DB_VERSION2 -> 3, following the exactclicutcl_queuedbDelta/readiness-option pattern.maybe_upgrade()(already invoked on every boot viaQueue::register()) picks up existing installs automatically.clicutcl_daily_cleanupcron inCleanup::run_cleanup(), sameLIMIT-bounded batch-delete style as the other two tables. Left aponytail: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.phpdrops the new table alongsideclicutcl_queue/clicutcl_events, respectingclicutcl_preserve_data_on_uninstall.Privacy / GDPR
Privacy_Handlernow exports and erasesclicutcl_touch_eventsrows matched onvisitor_id = <hashed_email>-- an exact-value match against the same SHA-256 formatIdentity_Resolverand the queue's own eraser already use. Export paginates the new table independently ofclicutcl_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);donerequires both sources exhausted.Documented limitation: rows whose
visitor_idfell 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.mdanddocs/guides/SECURITY-PRIVACY.mdupdated to describe the new table, its columns, and GDPR coverage.Version
Minor bump
1.8.19->1.9.0perRELEASING.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.txtStable tagintentionally untouched.Test plan
tests/unit/TouchEventsStoreTest.phpcoversTouch_Events_Store::build_row()'s consent gate, both attribution shapes (WooCommerce's nestedfirst_touch/last_touchvs. browser/form flatft_*/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 forclass-queue.php.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, whenclicutcl_preserve_data_on_uninstallis set).🤖 Generated with Claude Code