diff --git a/202-account/ajax/messaging/consent.php b/202-account/ajax/messaging/consent.php
new file mode 100644
index 00000000..10f42f7e
--- /dev/null
+++ b/202-account/ajax/messaging/consent.php
@@ -0,0 +1,33 @@
+ false, 'error' => 'invalid token']);
+ exit;
+}
+
+$flag = isset($_POST['flag']) ? (string) $_POST['flag'] : '';
+$state = isset($_POST['state']) ? (string) $_POST['state'] : '';
+// Column is varchar(32) — clamp instead of relying on silent truncation.
+$source = substr(isset($_POST['source']) ? (string) $_POST['source'] : 'settings', 0, 32);
+
+if (!in_array($flag, ['analytics', 'email_marketing'], true)
+ || !in_array($state, ['granted', 'denied'], true)) {
+ http_response_code(400);
+ echo json_encode(['ok' => false, 'error' => 'bad_args']);
+ exit;
+}
+
+// Single chokepoint — ConsentPolicy owns every raw consent-column write.
+$ok = ConsentPolicy::record($db, (int) $messagingUserId, $flag, $state, $source);
+echo json_encode(['ok' => (bool) $ok]);
diff --git a/202-account/ajax/messaging/track.php b/202-account/ajax/messaging/track.php
index d810a663..12b75ec5 100644
--- a/202-account/ajax/messaging/track.php
+++ b/202-account/ajax/messaging/track.php
@@ -5,6 +5,7 @@
include_once(str_repeat('../', 3) . '202-config/connect.php');
require __DIR__ . '/_auth.php';
+require_once dirname(__DIR__, 3) . '/202-config/Messaging/Analytics.class.php';
header('Content-Type: application/json');
@@ -15,12 +16,10 @@
exit;
}
-$userId = $messagingUserId;
-$service = MessagingService::forUser($userId);
-
-if ($service === null) {
- http_response_code(409);
- echo json_encode(['ok' => false, 'error' => 'messaging unavailable for this account']);
+// --- analytics consent gate: client events are analytics-tier ---
+require_once dirname(__DIR__, 3) . '/202-config/Messaging/ConsentPolicy.class.php';
+if (!ConsentPolicy::analyticsAllowed($db, $messagingUserId)) {
+ echo json_encode(['ok' => true, 'recorded' => false, 'reason' => 'analytics_not_consented']);
exit;
}
@@ -28,29 +27,29 @@
// 1. Custom attributes for segmentation: Prosper202Messenger('update', {...})
if (isset($_POST['update']) && $_POST['update'] !== '') {
- $attributes = json_decode((string) $_POST['update'], true);
+ $decoded = json_decode((string) $_POST['update'], true);
// Reject malformed input explicitly rather than silently ignoring it (CLAUDE.md #4).
- if (json_last_error() !== JSON_ERROR_NONE || !is_array($attributes)) {
+ if (json_last_error() !== JSON_ERROR_NONE || !is_array($decoded)) {
http_response_code(400);
- echo json_encode(['ok' => false, 'error' => 'update must be a JSON object']);
+ echo json_encode(['ok' => false, 'error' => 'invalid_update_json']);
exit;
}
- $service->updateAttributes($attributes);
+ Analytics::attr($decoded, 'analytics');
$handled = true;
}
// 2. Behavioural event: Prosper202Messenger('trackEvent', name, metadata)
if (isset($_POST['event_name']) && trim((string) $_POST['event_name']) !== '') {
- $metadata = null;
+ $meta = [];
if (isset($_POST['metadata']) && $_POST['metadata'] !== '') {
- $metadata = json_decode((string) $_POST['metadata'], true);
- if (json_last_error() !== JSON_ERROR_NONE || !is_array($metadata)) {
+ $meta = json_decode((string) $_POST['metadata'], true);
+ if (json_last_error() !== JSON_ERROR_NONE || !is_array($meta)) {
http_response_code(400);
- echo json_encode(['ok' => false, 'error' => 'metadata must be a JSON object']);
+ echo json_encode(['ok' => false, 'error' => 'invalid_metadata_json']);
exit;
}
}
- $service->recordEvent((string) $_POST['event_name'], $metadata);
+ Analytics::event((string) $_POST['event_name'], $meta, 'analytics');
$handled = true;
}
diff --git a/202-account/api-integrations.php b/202-account/api-integrations.php
index 3436bbca..ca75e1de 100755
--- a/202-account/api-integrations.php
+++ b/202-account/api-integrations.php
@@ -339,6 +339,13 @@ function lpo_ctx_pref_cache_bust($userId)
// status and config writes could otherwise cache active-with-
// stale-config for 3 minutes (no t202ctx until TTL expiry)
lpo_ctx_pref_cache_bust($lpo_user_id);
+
+ // Milestone event after pairComplete() succeeded and pairing state
+ // is persisted. Analytics tier → self-gates on consent; Analytics
+ // swallows its own failures — never blocks the redirect.
+ require_once dirname(__DIR__) . '/202-config/Messaging/Analytics.class.php';
+ Analytics::event('lpo_paired', [], 'analytics');
+
header('Location: ' . get_absolute_url() . '202-account/api-integrations.php?lpo=connected#lpo');
die();
}
diff --git a/202-account/disclosure.php b/202-account/disclosure.php
new file mode 100644
index 00000000..dc8dcd73
--- /dev/null
+++ b/202-account/disclosure.php
@@ -0,0 +1,85 @@
+
+
+
+
+
+
+
Why we collect this
+
When product analytics is on, we use your Prosper202 usage to help you earn more —
+ surfacing workflow tips and matching you with specially-sourced, higher-paying offers relevant to
+ what you actually promote. Everything we send you based on this data is bounded to
+ money-making, workflow, and offer-matching. Never generic promotion.
+
+
+
+
What is sent to Prosper202
+
+ - Traffic and results: clicks, conversions, income, cost, and net over the last
+ 30 days; top countries and device mix.
+ - Your campaign setup: affiliate network names, traffic source names, campaign
+ names, and the campaign destination (template) URLs you entered at setup — with any email or
+ phone values redacted before sending.
+ - Offer performance: per-campaign payout, currency, EPC, conversion rate, and
+ clicks.
+ - Account-level revenue aggregates: if you use Customer LTV, only account totals
+ (total revenue, MRR/ARR, customer count, average LTV, active subscriptions).
+ - Product usage: pages viewed inside Prosper202 and setup milestones (e.g.
+ campaign created, tracking link generated, integration connected).
+
+
We never share this data with third parties.
+
+
+
+
What never leaves your install
+
+ - Your visitors' data. We track your product usage as the account holder — never
+ your traffic's personal data. (Your visitors' click-data privacy is governed separately by the
+ Privacy Option above it in Settings.)
+ - Your customers' data. Customer LTV records — names, aliases, emails,
+ per-customer revenue, custom fields — never sync. Only the account-level aggregates listed
+ above do.
+
+
+
+
+
Consent and the off switch
+
+ - Product analytics is on by default outside the EU/UK, and held behind a one-time consent
+ prompt for EU/UK account holders. You can turn it off (or back on) anytime in
+ Account Settings.
+ - Switching it off stops collection and syncing immediately, and deletes the analytics data on
+ your install that has not yet been delivered (queued events and the computed usage profile).
+ - A small essential tier stays on for operational use (account lifecycle, login,
+ and support messaging delivery) — it carries no campaign or revenue analytics.
+ - Money-making offers & tips by email is a separate, opt-in consent. It is
+ never inferred from the analytics setting, and you can unsubscribe anytime.
+
+
+
+
+
diff --git a/202-account/user-management.php b/202-account/user-management.php
index 290db42e..94f1592c 100755
--- a/202-account/user-management.php
+++ b/202-account/user-management.php
@@ -217,6 +217,21 @@
// Only run pref_sql when creating a new user (not editing)
if (!$editing) {
$pref_result = _mysqli_query($pref_sql);
+
+ // Essential-tier lifecycle event (spec §7.3) for the NEW account —
+ // recorded via MessagingService directly because the Analytics
+ // facade would attach it to the creating admin's session user.
+ // Essential tier needs no consent; failures log + swallow.
+ // $user_id is $db->insert_id: 0 when the user INSERT failed, so
+ // guard against recording an event for a user that was never made.
+ if ((int) $user_id > 0) {
+ try {
+ require_once dirname(__DIR__) . '/202-config/Messaging/MessagingService.class.php';
+ (new MessagingService($db, (int) $user_id, []))->recordEvent('account_created', [], 'essential');
+ } catch (Throwable $analytics_e) {
+ error_log('[Analytics] account_created event failed: ' . $analytics_e->getMessage());
+ }
+ }
}
$add_success = true;
diff --git a/202-config/Database/Tables/CoreTables.php b/202-config/Database/Tables/CoreTables.php
index 64dd684e..b90c3975 100644
--- a/202-config/Database/Tables/CoreTables.php
+++ b/202-config/Database/Tables/CoreTables.php
@@ -176,6 +176,8 @@ public static function messagingSync(): SchemaDefinition
);
}
+ // 202_messaging_events: queued behavioural events; `tier` marks
+ // essential (always sent) vs analytics (consent-gated) events.
public static function messagingEvents(): SchemaDefinition
{
return SchemaBuilder::fromRawSql(
@@ -187,6 +189,7 @@ public static function messagingEvents(): SchemaDefinition
`metadata` json DEFAULT NULL,
`occurred_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
`client_token` varchar(64) NOT NULL,
+ `tier` enum('essential','analytics') NOT NULL DEFAULT 'analytics',
`delivery_status` enum('pending','sent','failed') NOT NULL DEFAULT 'pending',
`sync_attempts` int(11) NOT NULL DEFAULT '0',
PRIMARY KEY (`id`),
diff --git a/202-config/Database/Tables/UserTables.php b/202-config/Database/Tables/UserTables.php
index c7ee1be2..ca7f7ca5 100644
--- a/202-config/Database/Tables/UserTables.php
+++ b/202-config/Database/Tables/UserTables.php
@@ -140,6 +140,13 @@ public static function usersPref(): SchemaDefinition
`lpo_status` varchar(16) NOT NULL DEFAULT '',
`lpo_bridge_config` text DEFAULT NULL,
`lpo_ctx_kw` tinyint(1) NOT NULL DEFAULT '1',
+ `analytics_consent` enum('granted','denied','unset') NOT NULL DEFAULT 'unset',
+ `analytics_consent_at` datetime DEFAULT NULL,
+ `analytics_consent_source` varchar(32) DEFAULT NULL,
+ `email_marketing_consent` enum('granted','denied','unset') NOT NULL DEFAULT 'unset',
+ `email_marketing_consent_at` datetime DEFAULT NULL,
+ `eu_consent_prompt_seen` tinyint(1) NOT NULL DEFAULT '0',
+ `analytics_geo_is_eu` tinyint(1) DEFAULT NULL,
PRIMARY KEY (`user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci"
);
diff --git a/202-config/Ltv/MysqlIntegrationRepository.php b/202-config/Ltv/MysqlIntegrationRepository.php
index 5a5b2c64..69fd0632 100644
--- a/202-config/Ltv/MysqlIntegrationRepository.php
+++ b/202-config/Ltv/MysqlIntegrationRepository.php
@@ -80,7 +80,15 @@ public function create(int $userId, string $provider, string $name = '', ?array
);
$this->conn->bind($stmt, 'isssii', [$userId, $provider, $name, $encodedConfig, $now, $now]);
- return $this->conn->executeInsert($stmt);
+ $integrationId = $this->conn->executeInsert($stmt);
+
+ // Milestone event after the INSERT succeeded (executeInsert throws on
+ // failure). Provider name is config, not customer data. Analytics tier
+ // → self-gates on consent; Analytics swallows its own failures.
+ require_once dirname(__DIR__) . '/Messaging/Analytics.class.php';
+ \Analytics::event('ltv_integration_connected', ['provider' => (string) $provider], 'analytics');
+
+ return $integrationId;
}
public function delete(int $userId, int $integrationId): void
diff --git a/202-config/Messaging/Analytics.class.php b/202-config/Messaging/Analytics.class.php
new file mode 100644
index 00000000..ef4be3af
--- /dev/null
+++ b/202-config/Messaging/Analytics.class.php
@@ -0,0 +1,72 @@
+recordEvent($name, $meta, $tier);
+ });
+ }
+
+ public static function attr(array $attributes, string $tier = 'analytics'): void
+ {
+ self::guarded(function (mysqli $db, int $uid) use ($attributes, $tier) {
+ if (!self::wouldRecord($db, $uid, self::normalizeTier($tier))) {
+ return;
+ }
+ $service = new MessagingService($db, $uid, []);
+ $service->updateAttributes($attributes);
+ });
+ }
+
+ /** Thin testable wrapper: would a write of this tier be recorded for this user? */
+ public static function wouldRecord(mysqli $db, int $userId, string $tier): bool
+ {
+ if (self::normalizeTier($tier) === 'essential') {
+ return true;
+ }
+ return ConsentPolicy::analyticsAllowed($db, $userId);
+ }
+
+ private static function guarded(callable $fn): void
+ {
+ try {
+ $db = $GLOBALS['db'] ?? null;
+ $uid = (int) ($_SESSION['user_id'] ?? 0);
+ if (!($db instanceof mysqli) || $uid <= 0) { return; }
+ $fn($db, $uid);
+ } catch (\Throwable $e) {
+ // Never let tracking break a host page. Log if a logger exists; otherwise swallow.
+ if (function_exists('error_log')) {
+ error_log('[Analytics] ' . $e->getMessage());
+ }
+ }
+ }
+}
diff --git a/202-config/Messaging/CENTRAL-API.md b/202-config/Messaging/CENTRAL-API.md
deleted file mode 100644
index 5ba1a1ce..00000000
--- a/202-config/Messaging/CENTRAL-API.md
+++ /dev/null
@@ -1,346 +0,0 @@
-# Prosper202 Messaging — Central Server API Contract
-
-This document defines the HTTP API that the **central server** (`my.tracking202.com`)
-must implement so that self-hosted Prosper202 installs (the *client*) can deliver an
-Intercom-style messenger to their users.
-
-The client side (this repository) is implemented in:
-
-- `202-config/Messaging/MessagingClient.class.php` — HTTP transport
-- `202-config/Messaging/MessagingService.class.php` — local cache + sync orchestration
-- `202-account/ajax/messaging/*.php` — browser endpoints
-- `202-js/messenger.js`, `202-css/messenger.css` — the floating widget
-- `202-cronjobs/sync-messaging.php` — proactive background delivery
-
-Because each Prosper202 install is self-hosted and can only make **outbound** HTTPS
-requests, all communication is **client-initiated (pull)**. The central server never
-connects to an install. The client polls; the central server queues.
-
----
-
-## Base URL
-
-```
-https://my.tracking202.com/api/v3/messaging
-```
-
-Configured client-side as `MESSAGING_API_URL` in `202-config/connect.php`.
-
-## Transport
-
-- All requests are `POST` with a JSON body and `Content-Type: application/json`.
-- All responses are JSON with `Content-Type: application/json` and HTTP `200` on success.
-- Non-`200` responses are treated as failures; the client keeps its local cache and retries later.
-- TLS certificate verification is enforced (`CURLOPT_SSL_VERIFYPEER`/`VERIFYHOST`). Use a valid certificate.
-
-## Authentication & identity
-
-Every request body includes an `identity` object. The central server authenticates the
-install with `api_key` + `install_hash` and uses the remaining fields for **targeting**
-(broadcasts to a customer/cohort) and **routing** (which user a conversation belongs to).
-
-```json
-{
- "identity": {
- "install_hash": "32-char install identifier",
- "api_key": "p202_customer_api_key for this install",
- "user_id": 123,
- "user_email": "user@example.com",
- "registered_at": "2024-01-02 03:04:05",
- "attributes": {
- "plan": "pro",
- "monthly_clicks": 18204,
- "trackers_created": 12
- }
- }
-}
-```
-
-| Field | Source (client) | Purpose |
-|----------------|------------------------------------------|--------------------------------------|
-| `install_hash` | `202_users.install_hash` | Identifies the installation |
-| `api_key` | `202_users.p202_customer_api_key` | Authenticates the customer account |
-| `user_id` | local `202_users.user_id` | Routes conversations to a user |
-| `user_email` | `202_users.user_email` | Targeting / display on central side |
-| `registered_at`| `202_users.user_time_register` | Cohort targeting (e.g. "new users") |
-| `attributes` | `202_messaging_attributes` snapshot | **Custom attributes for segmentation** |
-
-`attributes` is the latest snapshot of custom attributes set by page JavaScript via
-`Prosper202Messenger('update', {...})` (see *Client JavaScript API* below). It is sent
-on **every** request so the central server always has fresh data to segment audiences on.
-Values are scalars (string/number/bool); nested objects are not guaranteed.
-
-If `api_key`/`install_hash` do not validate, respond `401`. The client degrades
-gracefully (shows cached data, no error to the user).
-
----
-
-## Targeting model (server-side)
-
-Targeting is entirely the central server's responsibility. Both modes Intercom offers
-are supported by the same `pull` response:
-
-- **Broadcast / one-way** — the server creates a `type: "broadcast"` conversation for
- every user matching an audience (all users, a plan, a signup cohort, a specific
- `user_id`, etc.). The user may reply, which upgrades it into a two-way thread.
-- **Two-way conversation** — the server creates/continues a `type: "conversation"`
- thread for a specific user, and support agents reply into it.
-
-The client does not filter or target; it simply renders whatever `pull` returns for the
-identified user.
-
----
-
-## Endpoints
-
-### `POST /pull`
-
-Returns all conversations and messages visible to the identified user. The client
-upserts the result into its local cache keyed by `external_id`, so the endpoint may
-return either full state or only changes since `cursor`.
-
-**Request**
-
-```json
-{
- "identity": { ... },
- "cursor": "opaque string from previous pull, or null on first sync"
-}
-```
-
-**Response**
-
-```json
-{
- "ok": true,
- "server_time": "2026-06-16 07:00:00",
- "cursor": "opaque-cursor-to-send-next-time",
- "conversations": [
- {
- "external_id": "conv_abc123",
- "type": "conversation", // "conversation" | "broadcast"
- "subject": "Welcome to Prosper202",
- "status": "open", // "open" | "closed"
- "last_message_at": "2026-06-16 06:59:00",
- "messages": [
- {
- "external_id": "msg_001",
- "direction": "inbound", // "inbound" (team→user) | "outbound" (user→team)
- "author": "team", // "team" | "system" | "user"
- "body": "Hi! Need a hand getting set up?",
- "created_at": "2026-06-16 06:59:00"
- }
- ]
- }
- ],
- "deleted_conversation_ids": ["conv_retracted1"]
-}
-```
-
-Notes:
-- `deleted_conversation_ids` (optional) is an array of conversation `external_id`s the
- server has retracted/removed; the client deletes those conversations and their messages
- from its local cache. Omit it (or send `[]`) when there is nothing to delete.
-- `direction` is from the **user's** perspective: `inbound` = received, `outbound` = sent.
-- `body` is plain text. The client HTML-escapes it before rendering. Do not send HTML.
-- `cursor` is opaque to the client and echoed back on the next `pull`. Use it to return
- only deltas; returning full state every time is also valid (the client upserts).
-- Messages the user already sent (echoed back with a real `external_id`) let the client
- reconcile its locally-queued copies via `client_token` (see `send`).
-
-### `POST /send`
-
-Delivers a message the user composed in the widget.
-
-**Request**
-
-```json
-{
- "identity": { ... },
- "conversation_external_id": "conv_abc123 or null to start a new thread",
- "body": "plain text the user typed",
- "client_token": "uuid generated by the client for idempotency"
-}
-```
-
-**Response**
-
-```json
-{
- "ok": true,
- "conversation": {
- "external_id": "conv_abc123",
- "type": "conversation",
- "subject": "Welcome to Prosper202",
- "status": "open"
- },
- "message": {
- "external_id": "msg_042",
- "client_token": "the uuid from the request",
- "direction": "outbound",
- "author": "user",
- "body": "plain text the user typed",
- "created_at": "2026-06-16 07:01:00"
- }
-}
-```
-
-- `client_token` **must** be echoed back so the client can match the server's canonical
- message to the optimistic local copy and avoid duplicates. Treat repeated
- `client_token`s as idempotent (return the same message).
-- If `conversation_external_id` is null, create a new `conversation` thread and return it.
-
-### `POST /read`
-
-Reports which inbound messages the user has read (for agent-side read receipts).
-
-**Request**
-
-```json
-{
- "identity": { ... },
- "message_external_ids": ["msg_001", "msg_002"]
-}
-```
-
-**Response**
-
-```json
-{ "ok": true }
-```
-
-This is advisory; the client also tracks read state locally for its unread badge.
-
-### `POST /track`
-
-Delivers custom attributes and behavioural events for **segmentation**. The client
-batches these (queued locally, flushed on sync) so a flush may carry the latest attribute
-snapshot plus several events at once.
-
-**Request**
-
-```json
-{
- "identity": { ... },
- "attributes": {
- "plan": "pro",
- "monthly_clicks": 18204
- },
- "events": [
- {
- "name": "created_tracker",
- "metadata": { "tracker_id": 42, "source": "google" },
- "occurred_at": "2026-06-16 07:05:00",
- "client_token": "uuid for idempotency"
- }
- ]
-}
-```
-
-**Response**
-
-```json
-{ "ok": true }
-```
-
-- `attributes` mirrors what is sent inside `identity.attributes`; it is included here too
- so a dedicated flush can update the central profile even when no message is pulled.
-- Each event has a `client_token`; treat repeated tokens as idempotent so retries do not
- double-count.
-- The central server stores attributes on the user profile and records events on a
- timeline, both usable to define audiences for broadcasts.
-
----
-
-## Client JavaScript API
-
-Page code on a Prosper202 install can feed the messenger exactly the way Intercom's
-JavaScript API works — a command queue on a single global function. The widget
-(`202-js/messenger.js`) installs this before it finishes loading, so calls made early
-are buffered and replayed.
-
-```js
-// Set/merge custom attributes on the current user (for segmentation):
-Prosper202Messenger('update', { plan: 'pro', monthly_clicks: 18204 });
-
-// Record a behavioural event (optionally with metadata):
-Prosper202Messenger('trackEvent', 'created_tracker', { tracker_id: 42 });
-
-// Control the widget:
-Prosper202Messenger('show'); // open the panel
-Prosper202Messenger('hide'); // close the panel
-Prosper202Messenger('toggle'); // toggle the panel
-```
-
-`update` and `trackEvent` POST to `202-account/ajax/messaging/track.php`, which persists
-the data locally (`202_messaging_attributes`, `202_messaging_events`) and forwards it to
-`POST /track` on the next sync. Attribute values should be scalars.
-
----
-
-## Error handling
-
-| Situation | Server response | Client behavior |
-|-----------------------------------|-----------------|-----------------------------------------|
-| Bad/missing auth | `401` | Silent; keep cache; retry next cycle |
-| Malformed request | `400` | Logged; retried with backoff |
-| Transient server error | `5xx` | Retried with exponential backoff |
-| Success | `200` + `ok:true`| Cache updated |
-
-The client never surfaces raw transport errors to end users; the widget simply shows the
-last successfully cached state.
-
----
-
-## Known limitations & operational notes
-
-These are scale/architecture concerns, not bugs. For a typical self-hosted install (one
-operator or a small team, with a responsive central server) neither is noticeable. They
-matter for large multi-user installs or when the central server is slow/unreachable, and
-each needs an infrastructure or contract change to resolve — deliberately out of scope for
-the client-side implementation.
-
-### 1. The inbox poll syncs synchronously
-
-`202-account/ajax/messaging/inbox.php` calls `MessagingService::sync()` on every poll
-(~every 25s per open widget), which makes the outbound HTTPS calls to this API. It is
-throttled by `MESSAGING_SYNC_THROTTLE` (default 20s), and a failed sync still stamps
-`last_sync` so a down server is not re-hit within the window. But because the poll interval
-is longer than the throttle, most polls do reach the network, and PHP holds a web worker
-for the duration of that call. If this API is slow or down, each poll blocks up to
-~21s (10s timeout × 2 attempts + 1s pause); the browser degrades gracefully (it shows
-cached state), but enough concurrent users polling into a slow upstream can exhaust the
-front-end worker pool.
-
-- **Mitigations in place:** throttle + capped retries (2 attempts, 1s backoff); the widget
- never blocks the page and always renders the last cached state.
-- **Proper fix (ops/infra):** make `inbox.php` read the local cache only, and move *all*
- network sync into the `202-cronjobs/sync-messaging.php` cron (run it frequently and
- monitor it). PHP has no built-in async, so a non-blocking request path requires either a
- job runner/queue that owns the outbound calls, or `fastcgi_finish_request()` to flush the
- response before syncing (still occupies a worker). This is a deployment decision, so it
- belongs in the install runbook rather than the client code.
-
-### 2. The cron syncs users sequentially (no batch pull)
-
-`202-cronjobs/sync-messaging.php` iterates active users and calls
-`MessagingService::forUser($id)->sync(true)` one at a time. Each `sync()` is several HTTP
-round-trips (flush events, push pending messages, report receipts, pull), so total runtime
-is `N users × (several sequential calls)`. At hundreds/thousands of users the cron runtime
-balloons, one slow/timing-out user stalls the rest, and delivery latency grows.
-
-- **Proper fix (requires a central-API change):** add a **batched pull** so the cron can
- fetch many users in one request, e.g.
-
- ```
- POST /pull/batch
- { "install_hash": "...", "api_key": "...",
- "users": [ { "user_id": 1, "cursor": "..." }, { "user_id": 2, "cursor": "..." } ] }
- -> { "ok": true, "results": [ { "user_id": 1, "cursor": "...", "conversations": [...] }, ... ] }
- ```
-
- The cron would then page through users (e.g. 100 per request) instead of one request per
- user. Client-only alternatives (`curl_multi` parallelism, sharding users across cron
- workers) add complexity and still issue N requests at this server, so the real win is
- server-side batch support. **This endpoint is not yet implemented** — until it exists, the
- cron uses the per-user `POST /pull` above.
diff --git a/202-config/Messaging/ConsentPolicy.class.php b/202-config/Messaging/ConsentPolicy.class.php
new file mode 100644
index 00000000..dcd853a1
--- /dev/null
+++ b/202-config/Messaging/ConsentPolicy.class.php
@@ -0,0 +1,251 @@
+ 'unset',
+ 'analytics_source' => null,
+ 'analytics_at' => null,
+ 'email_marketing' => 'unset',
+ 'email_marketing_at' => null,
+ ];
+ try {
+ $stmt = $db->prepare(
+ "SELECT `analytics_consent`, `analytics_consent_source`, `analytics_consent_at`,
+ `email_marketing_consent`, `email_marketing_consent_at`
+ FROM `202_users_pref` WHERE `user_id` = ? LIMIT 1"
+ );
+ if ($stmt === false) {
+ error_log('[ConsentPolicy] exportForSync prepare failed: ' . $db->error);
+ return $unset;
+ }
+ $stmt->bind_param('i', $userId);
+ if (!$stmt->execute()) {
+ error_log('[ConsentPolicy] exportForSync execute failed: ' . $stmt->error);
+ $stmt->close();
+ return $unset;
+ }
+ $res = $stmt->get_result();
+ if ($res === false) {
+ // Fetch FAILURE, not a missing row (see docblock).
+ error_log('[ConsentPolicy] exportForSync get_result failed: ' . $stmt->error);
+ $stmt->close();
+ return $unset;
+ }
+ $row = $res->fetch_assoc();
+ $stmt->close();
+ } catch (\Throwable $e) {
+ error_log('[ConsentPolicy] exportForSync failed: ' . $e->getMessage());
+ return $unset;
+ }
+ // Genuine missing row: the user truly never answered — same
+ // fail-closed all-unset shape, via the no-row path.
+ if (!$row) { return $unset; }
+
+ $states = ['granted', 'denied', 'unset'];
+ return [
+ 'analytics' => in_array($row['analytics_consent'] ?? '', $states, true)
+ ? (string) $row['analytics_consent'] : 'unset',
+ 'analytics_source' => isset($row['analytics_consent_source']) ? (string) $row['analytics_consent_source'] : null,
+ 'analytics_at' => isset($row['analytics_consent_at']) ? (string) $row['analytics_consent_at'] : null,
+ 'email_marketing' => in_array($row['email_marketing_consent'] ?? '', $states, true)
+ ? (string) $row['email_marketing_consent'] : 'unset',
+ 'email_marketing_at' => isset($row['email_marketing_consent_at']) ? (string) $row['email_marketing_consent_at'] : null,
+ ];
+ }
+
+ public static function needsEuPrompt(mysqli $db, int $userId): bool
+ {
+ $row = self::loadPref($db, $userId);
+ // Lookup failure → no prompt (fail closed; analytics is held anyway).
+ return $row !== null
+ && $row['is_eu'] === true
+ && $row['analytics_consent'] === 'unset'
+ && $row['prompt_seen'] === false;
+ }
+
+ public static function record(mysqli $db, int $userId, string $flag, string $state, string $source): bool
+ {
+ if (!in_array($flag, ['analytics','email_marketing'], true)) { return false; }
+ if (!in_array($state, ['granted','denied'], true)) { return false; }
+ try {
+ if ($flag === 'analytics') {
+ $sql = "UPDATE `202_users_pref`
+ SET `analytics_consent` = ?, `analytics_consent_at` = NOW(),
+ `analytics_consent_source` = ?, `eu_consent_prompt_seen` = 1
+ WHERE `user_id` = ?";
+ $stmt = $db->prepare($sql);
+ if ($stmt === false) {
+ error_log('[ConsentPolicy] record prepare failed: ' . $db->error);
+ return false;
+ }
+ $stmt->bind_param('ssi', $state, $source, $userId);
+ } else {
+ $sql = "UPDATE `202_users_pref`
+ SET `email_marketing_consent` = ?, `email_marketing_consent_at` = NOW()
+ WHERE `user_id` = ?";
+ $stmt = $db->prepare($sql);
+ if ($stmt === false) {
+ error_log('[ConsentPolicy] record prepare failed: ' . $db->error);
+ return false;
+ }
+ $stmt->bind_param('si', $state, $userId);
+ }
+ $ok = $stmt->execute();
+ if (!$ok) {
+ error_log('[ConsentPolicy] record execute failed: ' . $stmt->error);
+ }
+ $stmt->close();
+ } catch (\Throwable $e) {
+ error_log('[ConsentPolicy] record failed: ' . $e->getMessage());
+ return false;
+ }
+
+ if ($ok && $flag === 'analytics' && $state === 'denied') {
+ // Revocation must also stop previously collected analytics data
+ // from ever leaving the install (spec §9): purge undelivered
+ // analytics-tier events and the attribute snapshot. Best-effort —
+ // the transport boundary re-checks consent regardless.
+ try {
+ require_once __DIR__ . '/MessagingService.class.php';
+ if (!MessagingService::purgeAnalyticsData($db, $userId)) {
+ error_log('[ConsentPolicy] analytics purge on denial incomplete for user ' . $userId);
+ }
+ } catch (\Throwable $e) {
+ error_log('[ConsentPolicy] analytics purge on denial failed: ' . $e->getMessage());
+ }
+ }
+
+ return (bool) $ok;
+ }
+
+ public static function rememberGeo(mysqli $db, int $userId, bool $isEu): bool
+ {
+ try {
+ $stmt = $db->prepare("UPDATE `202_users_pref` SET `analytics_geo_is_eu` = ? WHERE `user_id` = ?");
+ if ($stmt === false) {
+ error_log('[ConsentPolicy] rememberGeo prepare failed: ' . $db->error);
+ return false;
+ }
+ $v = $isEu ? 1 : 0;
+ $stmt->bind_param('ii', $v, $userId);
+ $ok = $stmt->execute();
+ if (!$ok) {
+ error_log('[ConsentPolicy] rememberGeo execute failed: ' . $stmt->error);
+ }
+ $stmt->close();
+ return (bool) $ok;
+ } catch (\Throwable $e) {
+ error_log('[ConsentPolicy] rememberGeo failed: ' . $e->getMessage());
+ return false;
+ }
+ }
+
+ /**
+ * Load the stored consent state. Returns NULL on any lookup failure so
+ * callers fail CLOSED (spec §6: failure is never an accidental "granted").
+ * A genuine missing row is NOT a failure: it returns the documented
+ * defaults (consent unset, unknown geo → non-EU).
+ *
+ * @return array{analytics_consent:string,email_marketing_consent:string,is_eu:bool,prompt_seen:bool}|null
+ */
+ private static function loadPref(mysqli $db, int $userId): ?array
+ {
+ $default = [
+ 'analytics_consent' => 'unset',
+ 'email_marketing_consent' => 'unset',
+ 'is_eu' => false, // unknown geo → treat as non-EU (Global Constraints)
+ 'prompt_seen' => false,
+ ];
+ try {
+ $stmt = $db->prepare(
+ "SELECT `analytics_consent`, `email_marketing_consent`, `analytics_geo_is_eu`, `eu_consent_prompt_seen`
+ FROM `202_users_pref` WHERE `user_id` = ? LIMIT 1"
+ );
+ if ($stmt === false) {
+ error_log('[ConsentPolicy] loadPref prepare failed: ' . $db->error);
+ return null;
+ }
+ $stmt->bind_param('i', $userId);
+ if (!$stmt->execute()) {
+ error_log('[ConsentPolicy] loadPref execute failed: ' . $stmt->error);
+ $stmt->close();
+ return null;
+ }
+ $res = $stmt->get_result();
+ if ($res === false) {
+ error_log('[ConsentPolicy] loadPref get_result failed: ' . $stmt->error);
+ $stmt->close();
+ return null;
+ }
+ $row = $res->fetch_assoc();
+ $stmt->close();
+ } catch (\Throwable $e) {
+ error_log('[ConsentPolicy] loadPref failed: ' . $e->getMessage());
+ return null;
+ }
+ if (!$row) { return $default; }
+ return [
+ 'analytics_consent' => $row['analytics_consent'] ?? 'unset',
+ 'email_marketing_consent' => $row['email_marketing_consent'] ?? 'unset',
+ // NULL geo (unknown) → false per Global Constraints
+ 'is_eu' => ((int) ($row['analytics_geo_is_eu'] ?? 0)) === 1,
+ 'prompt_seen' => ((int) ($row['eu_consent_prompt_seen'] ?? 0)) === 1,
+ ];
+ }
+}
diff --git a/202-config/Messaging/MOCK-SERVER.md b/202-config/Messaging/MOCK-SERVER.md
index 89dc242d..038d91e2 100644
--- a/202-config/Messaging/MOCK-SERVER.md
+++ b/202-config/Messaging/MOCK-SERVER.md
@@ -2,7 +2,7 @@
`mock-server.php` is a dependency-free stand-in for the central
`my.tracking202.com` messaging API, so you can click through the messenger
-widget locally. It implements the contract in `CENTRAL-API.md`.
+widget locally. It implements the central messaging API contract.
## Run it
diff --git a/202-config/Messaging/MessagingClient.class.php b/202-config/Messaging/MessagingClient.class.php
index 59a722f4..b45cdc9a 100644
--- a/202-config/Messaging/MessagingClient.class.php
+++ b/202-config/Messaging/MessagingClient.class.php
@@ -10,7 +10,8 @@
* (the install can only make outbound requests), so this class only ever POSTs
* and reads the JSON response.
*
- * The contract implemented here is documented in 202-config/Messaging/CENTRAL-API.md.
+ * The contract implemented here is exercised end-to-end by the local mock server
+ * (202-config/Messaging/mock-server.php, see MOCK-SERVER.md).
*
* Every method returns a decoded associative array on success, or null on any
* transport/parse/HTTP failure. Callers must treat null as "could not reach the
@@ -87,16 +88,24 @@ public function markRead(array $identity, array $externalIds): ?array
*
* @param array $identity Identity payload.
* @param array $attributes Latest custom-attribute snapshot.
- * @param array
$events Queued events (name/metadata/occurred_at/client_token).
+ * @param array $events Queued events (name/metadata/occurred_at/client_token/tier).
+ * @param array|null $consent Consent block (receiver spec §6 delta); when
+ * non-null it is sent as a TOP-LEVEL sibling of
+ * identity/attributes/events. Null preserves the
+ * pre-delta wire shape (backward-compatible).
* @return array|null Decoded response, or null on failure.
*/
- public function track(array $identity, array $attributes, array $events): ?array
+ public function track(array $identity, array $attributes, array $events, ?array $consent = null): ?array
{
- return $this->postJson('track', [
+ $payload = [
'identity' => $identity,
'attributes' => (object) $attributes,
'events' => array_values($events),
- ]);
+ ];
+ if ($consent !== null) {
+ $payload['consent'] = $consent;
+ }
+ return $this->postJson('track', $payload);
}
/**
diff --git a/202-config/Messaging/MessagingService.class.php b/202-config/Messaging/MessagingService.class.php
index 7ea978ce..f6027a35 100644
--- a/202-config/Messaging/MessagingService.class.php
+++ b/202-config/Messaging/MessagingService.class.php
@@ -3,6 +3,7 @@
declare(strict_types=1);
include_once(__DIR__ . '/MessagingClient.class.php');
+include_once(__DIR__ . '/ConsentPolicy.class.php');
/**
* MessagingService
@@ -30,6 +31,10 @@ class MessagingService
/** @var array */
private array $identity;
private ?MessagingClient $client = null;
+ /** Cached consent decision for this instance (one lookup per sync run). */
+ private ?bool $analyticsSyncAllowed = null;
+ /** Cached consent export for this instance (one lookup per sync run). */
+ private ?array $consentExport = null;
/**
* @param array $identity Identity payload for the central API.
@@ -114,6 +119,71 @@ private function client(): MessagingClient
return $this->client ??= new MessagingClient();
}
+ /**
+ * Whether analytics-tier data may leave this install for this user
+ * (spec §9). Checked at the TRANSPORT boundary — not just at enqueue
+ * time — so revoking consent stops data that was collected earlier.
+ * Fails CLOSED: ConsentPolicy already resolves lookup failures to false,
+ * and any unexpected throw here must never become an accidental "granted".
+ * Cached per instance so a sync run performs one consent read.
+ */
+ private function analyticsSyncAllowed(): bool
+ {
+ if ($this->analyticsSyncAllowed === null) {
+ try {
+ $this->analyticsSyncAllowed = ConsentPolicy::analyticsAllowed($this->db, $this->userId);
+ } catch (Throwable $e) {
+ error_log('MessagingService: consent lookup failed, failing closed: ' . $e->getMessage());
+ $this->analyticsSyncAllowed = false;
+ }
+ }
+ return $this->analyticsSyncAllowed;
+ }
+
+ /**
+ * Consent state as sent to the central server (receiver spec §6 delta).
+ * Operational bookkeeping: attached for ALL users — including
+ * analytics-denied ones, since the server must learn a user is denied —
+ * so it is NOT gated on analyticsSyncAllowed(). ConsentPolicy stays the
+ * only reader of the raw consent columns; exportForSync never throws and
+ * fails closed to the all-unset shape. Cached per instance like
+ * analyticsSyncAllowed() so a sync run performs one export read.
+ *
+ * @return array
+ */
+ private function consentExport(): array
+ {
+ if ($this->consentExport === null) {
+ $this->consentExport = ConsentPolicy::exportForSync($this->db, $this->userId);
+ }
+ return $this->consentExport;
+ }
+
+ /**
+ * Identity payload as sent over the wire. The stored attribute snapshot
+ * is analytics-tier data (the offer profile + client custom attributes),
+ * so it is stripped for non-consented users — only essential identity
+ * fields (install hash, email, registration date) may leave the install
+ * (spec §9). The consent export always rides along (spec §6). Every
+ * client() call site must use this, never raw identity.
+ *
+ * @return array
+ */
+ private function outboundIdentity(): array
+ {
+ $identity = $this->identity;
+ if (!$this->analyticsSyncAllowed()) {
+ unset($identity['attributes']);
+ }
+ // Deliberately outside the strip above: the denied state itself must
+ // reach the server (spec §6 — absence reads as "unset", not "denied",
+ // and an explicit denial is what stops server-side email marketing).
+ // The export is total (fail-closed all-unset on any lookup failure),
+ // so the attach is unconditional.
+ $identity['consent'] = $this->consentExport();
+ return $identity;
+ }
+
// ---------------------------------------------------------------------
// Sync orchestration
// ---------------------------------------------------------------------
@@ -142,7 +212,7 @@ public function sync(bool $force = false): bool
$this->reportReadReceipts();
$cursor = $this->getCursor();
- $response = $this->client()->pull($this->identity, $cursor);
+ $response = $this->client()->pull($this->outboundIdentity(), $cursor);
if ($response === null) {
$this->recordSyncError('pull failed');
@@ -201,7 +271,7 @@ private function applyPull(array $response): void
}
// Optional: the server may signal conversations it has removed/retracted
- // so the local cache doesn't keep them forever (see CENTRAL-API.md).
+ // so the local cache doesn't keep them forever (per the central API contract).
if (isset($response['deleted_conversation_ids']) && is_array($response['deleted_conversation_ids'])) {
$this->deleteConversations($response['deleted_conversation_ids']);
}
@@ -556,7 +626,7 @@ private function pushMessage(int $messageId): bool
: $this->getConversationExternalId($conversationId);
$response = $this->client()->send(
- $this->identity,
+ $this->outboundIdentity(),
$sendConvId,
(string) $message['body'],
(string) $message['client_token']
@@ -721,7 +791,7 @@ private function reportReadReceipts(): void
return;
}
- $response = $this->client()->markRead($this->identity, array_values($rows));
+ $response = $this->client()->markRead($this->outboundIdentity(), array_values($rows));
if ($response === null) {
return; // try again next sync
}
@@ -742,9 +812,27 @@ private function reportReadReceipts(): void
// Events & attributes (segmentation)
// ---------------------------------------------------------------------
+ /**
+ * Can this value be persisted as an attribute? Scalars, null, and
+ * JSON-encodable arrays are accepted — the offer profile carries nested
+ * structures (networks[], top_geos[], device_mix{}, offers[], ltv{}).
+ * Objects and resources are rejected (and the rejection is logged by
+ * updateAttributes — never silently dropped, CLAUDE.md #4).
+ *
+ * @param mixed $value
+ */
+ public static function isPersistableAttributeValue($value): bool
+ {
+ return is_scalar($value) || $value === null || is_array($value);
+ }
+
/**
* Merge custom attributes into the stored snapshot and mark it for delivery.
*
+ * Values may be scalars, null, or nested arrays (see
+ * isPersistableAttributeValue); everything is serialized as one JSON
+ * document into 202_messaging_attributes.data.
+ *
* @param array $attributes
*/
public function updateAttributes(array $attributes): void
@@ -754,10 +842,13 @@ public function updateAttributes(array $attributes): void
}
$current = $this->getAttributes();
- // Scalars only — nested structures are not part of the contract.
foreach ($attributes as $key => $value) {
- if (is_scalar($value) || $value === null) {
+ if (self::isPersistableAttributeValue($value)) {
$current[(string) $key] = $value;
+ } else {
+ // Loud rejection: silently dropping a key would be invisible
+ // data loss for the caller.
+ error_log("MessagingService: updateAttributes rejected non-persistable value for key '{$key}' (" . gettype($value) . ')');
}
}
@@ -790,14 +881,23 @@ public function updateAttributes(array $attributes): void
* Record a behavioural event for later delivery to the central server.
*
* @param array|null $metadata
+ * @param string $tier 'essential' or 'analytics' (202_messaging_events.tier).
*/
- public function recordEvent(string $name, ?array $metadata = null): void
+ public function recordEvent(string $name, ?array $metadata = null, string $tier = 'analytics'): void
{
$name = trim($name);
if ($name === '') {
return;
}
+ // Reject unknown tiers loudly: silently coercing would persist data
+ // as consent-gated 'analytics' without ConsentPolicy ever being
+ // consulted. Callers must normalize (see Analytics::normalizeTier).
+ if (!in_array($tier, ['essential', 'analytics'], true)) {
+ error_log("MessagingService: recordEvent rejected unknown tier '{$tier}' for event '{$name}'");
+ return;
+ }
+
$metaJson = null;
if ($metadata !== null && $metadata !== []) {
$encoded = json_encode($metadata);
@@ -811,14 +911,14 @@ public function recordEvent(string $name, ?array $metadata = null): void
$token = $this->generateToken();
$now = date('Y-m-d H:i:s');
$sql = "INSERT INTO 202_messaging_events
- (user_id, event_name, metadata, occurred_at, client_token, delivery_status)
- VALUES (?, ?, ?, ?, ?, 'pending')";
+ (user_id, event_name, metadata, occurred_at, client_token, delivery_status, tier)
+ VALUES (?, ?, ?, ?, ?, 'pending', ?)";
$stmt = $this->db->prepare($sql);
if (!$stmt) {
error_log('MessagingService: prepare recordEvent failed');
return;
}
- $stmt->bind_param('issss', $this->userId, $name, $metaJson, $now, $token);
+ $stmt->bind_param('isssss', $this->userId, $name, $metaJson, $now, $token, $tier);
if (!$stmt->execute()) {
error_log('MessagingService: recordEvent failed');
}
@@ -827,15 +927,29 @@ public function recordEvent(string $name, ?array $metadata = null): void
/**
* Deliver the attribute snapshot (if changed) plus any pending events.
+ *
+ * Consent boundary (spec §9): only essential-tier rows sync for
+ * non-consented users; analytics rows (events + the attribute/offer
+ * snapshot) sync only when analyticsAllowed. Enforced HERE — at flush
+ * time, not just enqueue time — so a user who revokes consent stops
+ * analytics rows queued while consent was still in effect.
*/
private function flushEvents(): void
{
+ $analyticsAllowed = $this->analyticsSyncAllowed();
+
$events = [];
- $sql = "SELECT id, event_name, metadata, occurred_at, client_token
+ $sql = "SELECT id, event_name, metadata, occurred_at, client_token, tier
FROM 202_messaging_events
WHERE user_id = ? AND delivery_status = 'pending'
- AND sync_attempts < ?
- ORDER BY id ASC
+ AND sync_attempts < ?";
+ if (!$analyticsAllowed) {
+ // Analytics rows stay local; if consent is later granted again
+ // they are still 'pending' and flush then. Denial purges them
+ // outright (ConsentPolicy::record → purgeAnalyticsData).
+ $sql .= " AND tier = 'essential'";
+ }
+ $sql .= " ORDER BY id ASC
LIMIT 100";
$stmt = $this->db->prepare($sql);
if ($stmt) {
@@ -855,13 +969,19 @@ private function flushEvents(): void
'metadata' => $metadata,
'occurred_at' => $row['occurred_at'],
'client_token' => $row['client_token'],
+ // Propagate the tier so the central server can keep
+ // essential and analytics rows separable (spec §5/§13).
+ 'tier' => (string) $row['tier'],
];
}
}
$stmt->close();
}
- $attributesDirty = $this->areAttributesDirty();
+ // The attribute snapshot is analytics-tier; for non-consented users it
+ // is neither counted as pending work nor transmitted. The dirty flag
+ // survives so a later re-grant delivers a fresh snapshot.
+ $attributesDirty = $analyticsAllowed && $this->areAttributesDirty();
if ($events === [] && !$attributesDirty) {
return; // nothing to flush
@@ -873,7 +993,10 @@ private function flushEvents(): void
return $e;
}, $events);
- $response = $this->client()->track($this->identity, $this->getAttributes(), $payloadEvents);
+ $attributes = $analyticsAllowed ? $this->getAttributes() : [];
+ // The consent block is passed top-level for ALL users (spec §6): a
+ // denied user's flush still tells the server the user is denied.
+ $response = $this->client()->track($this->outboundIdentity(), $attributes, $payloadEvents, $this->consentExport());
if ($response === null) {
// Bump attempts so poison events eventually stop being retried.
@@ -892,6 +1015,80 @@ private function flushEvents(): void
}
}
+ /**
+ * Has an event with this name ever been recorded for this user?
+ * Used for one-shot lifecycle events (e.g. first_click_received).
+ */
+ public function hasEvent(string $name): bool
+ {
+ $sql = "SELECT id FROM 202_messaging_events WHERE user_id = ? AND event_name = ? LIMIT 1";
+ $stmt = $this->db->prepare($sql);
+ if (!$stmt) {
+ error_log('MessagingService: prepare hasEvent failed');
+ // Err on "already recorded" so a DB failure can never cause a
+ // duplicate one-shot event.
+ return true;
+ }
+ $stmt->bind_param('is', $this->userId, $name);
+ if (!$stmt->execute()) {
+ error_log('MessagingService: hasEvent failed');
+ $stmt->close();
+ return true;
+ }
+ $exists = (bool) $stmt->get_result()->fetch_assoc();
+ $stmt->close();
+ return $exists;
+ }
+
+ /**
+ * Remove analytics-tier data that has not yet left the install. Called by
+ * ConsentPolicy::record() when a user denies analytics consent, so
+ * revocation stops previously collected data from ever syncing (spec §9).
+ *
+ * The whole attribute snapshot is analytics-tier (the offer profile plus
+ * client custom attributes — essential identity lives on 202_users, not
+ * here), so the snapshot row is deleted outright. Undelivered analytics
+ * events (pending or failed) are deleted; already-'sent' rows are only a
+ * local delivery log and cannot transmit again.
+ *
+ * @return bool False when any delete failed (caller logs).
+ */
+ public static function purgeAnalyticsData(mysqli $db, int $userId): bool
+ {
+ $ok = true;
+
+ $stmt = $db->prepare(
+ "DELETE FROM 202_messaging_events
+ WHERE user_id = ? AND tier = 'analytics' AND delivery_status != 'sent'"
+ );
+ if (!$stmt) {
+ error_log('MessagingService: purge events prepare failed: ' . $db->error);
+ $ok = false;
+ } else {
+ $stmt->bind_param('i', $userId);
+ if (!$stmt->execute()) {
+ error_log('MessagingService: purge events failed: ' . $stmt->error);
+ $ok = false;
+ }
+ $stmt->close();
+ }
+
+ $stmt = $db->prepare("DELETE FROM 202_messaging_attributes WHERE user_id = ?");
+ if (!$stmt) {
+ error_log('MessagingService: purge attributes prepare failed: ' . $db->error);
+ $ok = false;
+ } else {
+ $stmt->bind_param('i', $userId);
+ if (!$stmt->execute()) {
+ error_log('MessagingService: purge attributes failed: ' . $stmt->error);
+ $ok = false;
+ }
+ $stmt->close();
+ }
+
+ return $ok;
+ }
+
private function incrementEventAttempts(int $eventId): void
{
$sql = "UPDATE 202_messaging_events
diff --git a/202-config/Messaging/OfferProfile.class.php b/202-config/Messaging/OfferProfile.class.php
new file mode 100644
index 00000000..44267252
--- /dev/null
+++ b/202-config/Messaging/OfferProfile.class.php
@@ -0,0 +1,283 @@
+ (string) ($r['aff_campaign_name'] ?? ''),
+ 'url' => $url,
+ 'domain' => self::domain($url),
+ 'network' => (string) ($r['aff_network_name'] ?? ''),
+ 'payout' => (float) ($r['aff_campaign_payout'] ?? 0),
+ 'currency' => (string) ($r['aff_campaign_currency'] ?? ''),
+ 'epc' => $clicks > 0 ? round($income / $clicks, 4) : 0.0,
+ 'conv_rate' => $clicks > 0 ? round($leads / $clicks, 4) : 0.0,
+ 'clicks_30d'=> (int) $clicks,
+ ];
+ }
+
+ public static function planLimitPct(int $used, int $limit): int
+ {
+ if ($limit <= 0) { return 0; }
+ return (int) floor($used / $limit * 100);
+ }
+
+ public static function nearPlanLimit(int $pct): bool
+ {
+ return $pct >= self::NEAR_LIMIT_PCT;
+ }
+
+ /**
+ * Pure: bucket a 30-day login count into a cadence label (spec §8).
+ * Derived from the essential-tier 'login' events this install records,
+ * so fresh installs read 'dormant' until logins accumulate.
+ */
+ public static function loginCadence(int $logins30d): string
+ {
+ if ($logins30d <= 0) { return 'dormant'; }
+ if ($logins30d >= 20) { return 'daily'; }
+ if ($logins30d >= 4) { return 'weekly'; }
+ return 'occasional';
+ }
+
+ private static function domain(string $url): string
+ {
+ $h = parse_url($url, PHP_URL_HOST);
+ return is_string($h) ? $h : '';
+ }
+
+ /**
+ * Full profile. $now is the upper time bound (unix); window is 30 days.
+ * Each query checks its return value; on any failure the partial-safe
+ * defaults are returned (never throws).
+ */
+ public static function compute(mysqli $db, int $userId, int $now): array
+ {
+ $since = $now - 30 * 86400;
+ $out = [
+ 'clicks_30d'=>0,'conversions_30d'=>0,'income_30d'=>0.0,'cost_30d'=>0.0,'net_30d'=>0.0,
+ 'active_campaigns'=>0,'active_trackers'=>0,'plan_limit_pct'=>0,'near_plan_limit'=>false,
+ 'first_click_at'=>null,'days_since_signup'=>0,'login_cadence'=>'unknown',
+ 'networks'=>[],'traffic_source_types'=>[],'top_geos'=>[],
+ 'device_mix'=>['mobile'=>0,'desktop'=>0,'tablet'=>0],'offers'=>[],
+ ];
+
+ // --- 30d totals from the report cube ---
+ $stmt = $db->prepare(
+ "SELECT COALESCE(SUM(clicks),0) c, COALESCE(SUM(leads),0) l,
+ COALESCE(SUM(income),0) inc, COALESCE(SUM(cost),0) cost
+ FROM `202_dataengine`
+ WHERE user_id = ? AND click_time >= ? AND click_time <= ?"
+ );
+ if ($stmt !== false) {
+ $stmt->bind_param('iii', $userId, $since, $now);
+ if ($stmt->execute()) {
+ $row = $stmt->get_result()->fetch_assoc() ?: [];
+ $out['clicks_30d'] = (int) ($row['c'] ?? 0);
+ $out['conversions_30d'] = (int) ($row['l'] ?? 0);
+ $out['income_30d'] = (float) ($row['inc'] ?? 0);
+ $out['cost_30d'] = (float) ($row['cost'] ?? 0);
+ $out['net_30d'] = $out['income_30d'] - $out['cost_30d'];
+ }
+ $stmt->close();
+ }
+
+ // --- lifecycle (spec §8): first click ever, signup age, login cadence ---
+ $stmt = $db->prepare(
+ "SELECT MIN(click_time) fc FROM `202_dataengine` WHERE user_id = ? AND clicks > 0"
+ );
+ if ($stmt !== false) {
+ $stmt->bind_param('i', $userId);
+ if ($stmt->execute()) {
+ $row = $stmt->get_result()->fetch_assoc() ?: [];
+ // MIN() is NULL when the user has no clicks yet (isset() excludes it).
+ $out['first_click_at'] = isset($row['fc']) ? (int) $row['fc'] : null;
+ }
+ $stmt->close();
+ }
+
+ $stmt = $db->prepare("SELECT user_time_register FROM `202_users` WHERE user_id = ? LIMIT 1");
+ if ($stmt !== false) {
+ $stmt->bind_param('i', $userId);
+ if ($stmt->execute()) {
+ $row = $stmt->get_result()->fetch_assoc() ?: [];
+ if ((int) ($row['user_time_register'] ?? 0) > 0) {
+ $out['days_since_signup'] = max(0, (int) floor(($now - (int) $row['user_time_register']) / 86400));
+ }
+ }
+ $stmt->close();
+ }
+
+ // Cadence from the essential-tier login events this install records
+ // (202_messaging_events). No historical login log exists, so this
+ // reads 'dormant' until logins accumulate post-upgrade.
+ $stmt = $db->prepare(
+ "SELECT COUNT(*) c FROM `202_messaging_events`
+ WHERE user_id = ? AND event_name = 'login' AND tier = 'essential' AND occurred_at >= ?"
+ );
+ if ($stmt !== false) {
+ $sinceDt = date('Y-m-d H:i:s', $since);
+ $stmt->bind_param('is', $userId, $sinceDt);
+ if ($stmt->execute()) {
+ $row = $stmt->get_result()->fetch_assoc() ?: [];
+ $out['login_cadence'] = self::loginCadence((int) ($row['c'] ?? 0));
+ }
+ $stmt->close();
+ }
+
+ // --- networks ---
+ $out['networks'] = self::col($db,
+ "SELECT aff_network_name FROM `202_aff_networks`
+ WHERE user_id = ? AND aff_network_deleted = 0", $userId);
+
+ // --- traffic source types ---
+ $out['traffic_source_types'] = self::col($db,
+ "SELECT ppc_network_name FROM `202_ppc_networks`
+ WHERE user_id = ? AND ppc_network_deleted = 0", $userId);
+
+ // --- active campaigns ---
+ $out['active_campaigns'] = count(self::col($db,
+ "SELECT aff_campaign_id FROM `202_aff_campaigns`
+ WHERE user_id = ? AND aff_campaign_deleted = 0", $userId));
+
+ // --- active trackers (tracking links whose campaign is not deleted) ---
+ $out['active_trackers'] = count(self::col($db,
+ "SELECT t.tracker_id FROM `202_trackers` t
+ JOIN `202_aff_campaigns` cmp ON cmp.aff_campaign_id = t.aff_campaign_id
+ AND cmp.aff_campaign_deleted = 0
+ WHERE t.user_id = ?", $userId));
+
+ // --- top geos (top 5 countries by clicks, 30d) ---
+ $out['top_geos'] = self::col($db,
+ "SELECT c.country_code
+ FROM `202_dataengine` d
+ JOIN `202_locations_country` c ON c.country_id = d.country_id
+ WHERE d.user_id = ? AND d.click_time >= ?
+ GROUP BY c.country_code
+ ORDER BY SUM(d.clicks) DESC
+ LIMIT 5", $userId, $since);
+
+ // --- device mix (clicks by device_type, 30d; 1=desktop 2=mobile 3=tablet) ---
+ $stmt = $db->prepare(
+ "SELECT m.device_type, COALESCE(SUM(d.clicks),0) clicks
+ FROM `202_dataengine` d
+ JOIN `202_device_models` m ON m.device_id = d.device_id
+ WHERE d.user_id = ? AND d.click_time >= ?
+ GROUP BY m.device_type"
+ );
+ if ($stmt !== false) {
+ $stmt->bind_param('ii', $userId, $since);
+ if ($stmt->execute()) {
+ $res = $stmt->get_result();
+ while ($r = $res->fetch_assoc()) {
+ $clicks = (int) $r['clicks'];
+ switch ((int) $r['device_type']) {
+ case self::DEVICE_TYPE_DESKTOP: $out['device_mix']['desktop'] = $clicks; break;
+ case self::DEVICE_TYPE_MOBILE: $out['device_mix']['mobile'] = $clicks; break;
+ case self::DEVICE_TYPE_TABLET: $out['device_mix']['tablet'] = $clicks; break;
+ // other types (bots) intentionally excluded from the mix
+ }
+ }
+ }
+ $stmt->close();
+ }
+
+ // --- offers (full detail, 30d aggregates per campaign) ---
+ $sql = "SELECT cmp.aff_campaign_name, cmp.aff_campaign_url, cmp.aff_campaign_payout,
+ cmp.aff_campaign_currency, net.aff_network_name,
+ COALESCE(SUM(d.clicks),0) clicks, COALESCE(SUM(d.income),0) income,
+ COALESCE(SUM(d.leads),0) leads
+ FROM `202_aff_campaigns` cmp
+ LEFT JOIN `202_aff_networks` net ON net.aff_network_id = cmp.aff_network_id
+ LEFT JOIN `202_dataengine` d
+ ON d.aff_campaign_id = cmp.aff_campaign_id AND d.click_time >= ?
+ WHERE cmp.user_id = ? AND cmp.aff_campaign_deleted = 0
+ GROUP BY cmp.aff_campaign_id";
+ $stmt = $db->prepare($sql);
+ if ($stmt !== false) {
+ $stmt->bind_param('ii', $since, $userId);
+ if ($stmt->execute()) {
+ $res = $stmt->get_result();
+ while ($r = $res->fetch_assoc()) {
+ $out['offers'][] = self::buildOfferRow($r);
+ }
+ }
+ $stmt->close();
+ }
+
+ // --- LTV account-level aggregates (privacy boundary: aggregates ONLY,
+ // never per-customer rows/names/aliases — see Global Constraints) ---
+ $out['ltv'] = ['total_revenue'=>0.0,'mrr'=>0.0,'arr'=>0.0,'customers'=>0,
+ 'avg_ltv'=>0.0,'active_subscriptions'=>0,'uses_ltv'=>false];
+ try {
+ // MysqlLtvRepository::summary() returns customers, total_revenue,
+ // avg_ltv, mrr, active_subscriptions; mrr() returns mrr/arr/churn.
+ // Constructed the way existing LTV callers do (Connection wrapper,
+ // all-time LtvQuery with no time bounds).
+ $repo = new \Prosper202\Ltv\MysqlLtvRepository(new \Prosper202\Database\Connection($db));
+ $summary = $repo->summary(new \Prosper202\Ltv\LtvQuery($userId));
+ $mrr = $repo->mrr($userId);
+ $out['ltv'] = [
+ 'total_revenue' => (float) ($summary['total_revenue'] ?? 0),
+ 'mrr' => (float) ($mrr['mrr'] ?? 0),
+ 'arr' => (float) ($mrr['arr'] ?? 0),
+ 'customers' => (int) ($summary['customers'] ?? 0),
+ 'avg_ltv' => (float) ($summary['avg_ltv'] ?? 0),
+ 'active_subscriptions' => (int) ($summary['active_subscriptions'] ?? 0),
+ 'uses_ltv' => ((int) ($summary['customers'] ?? 0)) > 0,
+ ];
+ } catch (\Throwable $e) {
+ error_log('[OfferProfile] ltv aggregates unavailable: ' . $e->getMessage());
+ }
+
+ // --- LPO activation flag ---
+ $stmt = $db->prepare("SELECT lpo_status, lpo_site_key FROM `202_users_pref` WHERE user_id = ? LIMIT 1");
+ $out['lpo_active'] = false;
+ if ($stmt !== false) {
+ $stmt->bind_param('i', $userId);
+ if ($stmt->execute()) {
+ $r = $stmt->get_result()->fetch_assoc() ?: [];
+ $out['lpo_active'] = (($r['lpo_status'] ?? '') === 'active') && (($r['lpo_site_key'] ?? '') !== '');
+ }
+ $stmt->close();
+ }
+
+ // plan_limit_pct / near_plan_limit: this self-hosted schema has no
+ // plan/click-allowance record to compare against, so these stay at
+ // their defaults (0 / false) rather than inventing a column. Logged
+ // so the defaults aren't mistaken for a computed "0% of limit".
+ error_log('[OfferProfile] plan_limit_pct/near_plan_limit left at defaults: no plan allowance source in schema');
+
+ return $out;
+ }
+
+ /** @return string[] first column of a (user_id[, since]) query */
+ private static function col(mysqli $db, string $sql, int $userId, ?int $since = null): array
+ {
+ $stmt = $db->prepare($sql);
+ if ($stmt === false) { return []; }
+ if ($since === null) { $stmt->bind_param('i', $userId); }
+ else { $stmt->bind_param('ii', $userId, $since); }
+ if (!$stmt->execute()) { $stmt->close(); return []; }
+ $res = $stmt->get_result();
+ $vals = [];
+ while ($row = $res->fetch_row()) { $vals[] = (string) $row[0]; }
+ $stmt->close();
+ return $vals;
+ }
+}
diff --git a/202-config/Messaging/UrlScrubber.class.php b/202-config/Messaging/UrlScrubber.class.php
new file mode 100644
index 00000000..7b409578
--- /dev/null
+++ b/202-config/Messaging/UrlScrubber.class.php
@@ -0,0 +1,37 @@
+ $v) {
+ if (!is_string($v)) { continue; }
+ // parse_str already urldecodes ('+' becomes a space); trim so a
+ // leading space from an encoded '+' doesn't defeat the anchors.
+ $decoded = trim(rawurldecode($v));
+ if (preg_match(self::EMAIL, $decoded) || preg_match(self::PHONE, $decoded)) {
+ $params[$k] = '[redacted]';
+ $changed = true;
+ }
+ }
+ if (!$changed) { return $url; }
+ // http_build_query urlencodes {macros}; only touch params if we actually redacted,
+ // and rebuild only the query portion to preserve the rest of the URL.
+ $rebuilt = http_build_query($params);
+ $base = strtok($url, '?');
+ $frag = parse_url($url, PHP_URL_FRAGMENT);
+ return $base . '?' . $rebuilt . ($frag !== null && $frag !== false ? '#' . $frag : '');
+ }
+}
diff --git a/202-config/Messaging/mock-server.php b/202-config/Messaging/mock-server.php
index e03eb0fa..ca2f8569 100644
--- a/202-config/Messaging/mock-server.php
+++ b/202-config/Messaging/mock-server.php
@@ -7,7 +7,7 @@
*
* A self-contained, dependency-free stand-in for the my.tracking202.com messaging
* API so you can click through the Prosper202 messenger widget locally. It
- * implements the contract in 202-config/Messaging/CENTRAL-API.md:
+ * implements the central messaging API contract:
* POST /messaging/pull
* POST /messaging/send
* POST /messaging/read
diff --git a/202-config/connect.php b/202-config/connect.php
index 8e3b53bd..e97a0ceb 100644
--- a/202-config/connect.php
+++ b/202-config/connect.php
@@ -80,7 +80,7 @@ function withWritableSession(callable $callback): void
DEFINE('TRACKING202_ADS_URL', 'https://ads.tracking202.com');
// Messaging API configuration (Intercom-style messenger).
-// Central server contract: 202-config/Messaging/CENTRAL-API.md
+// Central server contract: exercised by 202-config/Messaging/mock-server.php (see MOCK-SERVER.md).
// Both values are overridable via environment variables so a developer can point
// the app at the local mock server (202-config/Messaging/mock-server.php) without
// editing tracked config, e.g. MESSAGING_API_URL=http://127.0.0.1:8787/messaging
diff --git a/202-config/functions-auth.php b/202-config/functions-auth.php
index d9290b89..650f4bd8 100755
--- a/202-config/functions-auth.php
+++ b/202-config/functions-auth.php
@@ -266,6 +266,74 @@ public static function begin_user_session(array $user_row): void
}
self::$sessionHeartbeatRefreshed = true;
+
+ // Persist the account holder's EU status, looked up from the client IP
+ // (no session flag carries this — it must be computed here). Only a
+ // definitive lookup is persisted so an unknown result never flattens a
+ // previously known value. Tracking must never break login: guard the
+ // connection and swallow failures.
+ try {
+ require_once __DIR__ . '/Messaging/ConsentPolicy.class.php';
+ $db = $GLOBALS['db'] ?? null;
+ if ($db instanceof \mysqli && !empty($_SESSION['user_id'])) {
+ $is_eu = self::detect_client_is_eu();
+ if ($is_eu !== null && !ConsentPolicy::rememberGeo($db, (int) $_SESSION['user_id'], $is_eu)) {
+ error_log('[ConsentPolicy] rememberGeo at login failed for user ' . (int) $_SESSION['user_id']);
+ }
+ }
+ } catch (\Throwable $e) {
+ error_log('[ConsentPolicy] rememberGeo at login failed: ' . $e->getMessage());
+ }
+
+ // Essential-tier lifecycle event (spec §7.3): login always flows, no
+ // consent gate. Analytics resolves $db/$_SESSION (established above),
+ // never throws, and logs + swallows its own failures — a tracking
+ // problem must never break login.
+ try {
+ require_once __DIR__ . '/Messaging/Analytics.class.php';
+ \Analytics::event('login', [], 'essential');
+ } catch (\Throwable $e) {
+ error_log('[Analytics] login event failed: ' . $e->getMessage());
+ }
+ }
+
+ /**
+ * Whether the current request's client IP is inside the European Union,
+ * per the bundled GeoLite2 database. Uses the GeoIp2 reader directly:
+ * getGeoData() lives in connect2.php, which the UI/login bootstrap
+ * (connect.php) never includes, so it does not exist on this path.
+ * Returns true/false only when the lookup succeeded and resolved a
+ * country; null when geo data is unavailable or inconclusive so callers
+ * can skip persisting instead of overwriting a known value.
+ */
+ private static function detect_client_is_eu(): ?bool
+ {
+ if (!class_exists(\GeoIp2\Database\Reader::class)) {
+ return null;
+ }
+
+ $mmdb = __DIR__ . '/geo/GeoLite2-City.mmdb';
+ if (!is_readable($mmdb)) {
+ return null;
+ }
+
+ $reader = null;
+ try {
+ $reader = new \GeoIp2\Database\Reader($mmdb);
+ $record = $reader->city(self::client_ip());
+ // Only trust the EU flag when the country actually resolved;
+ // isInEuropeanUnion defaults to false on absent data.
+ if (($record->country->isoCode ?? null) === null) {
+ return null;
+ }
+ return (bool) $record->country->isInEuropeanUnion;
+ } catch (\Throwable) {
+ return null; // Private/unroutable IP, bad DB, etc. → inconclusive.
+ } finally {
+ if ($reader !== null) {
+ $reader->close();
+ }
+ }
}
/**
diff --git a/202-config/functions-upgrade.php b/202-config/functions-upgrade.php
index d9d541dc..65064492 100755
--- a/202-config/functions-upgrade.php
+++ b/202-config/functions-upgrade.php
@@ -3883,10 +3883,57 @@ public static function upgrade_databases($time_from)
}
}
+ if ($prosper202_version == '1.9.74') {
+
+ // Consent + product-analytics columns (additive, idempotent): two
+ // independent account-holder consent flags (analytics, email
+ // marketing) with audit metadata, the one-time EU prompt marker,
+ // the persisted EU geo flag for cron use, and the
+ // essential/analytics tier marker on queued messaging events.
+ // Guarded ALTERs so a partial failure retries cleanly on the
+ // next run.
+ $consent_ok = true;
+ foreach ([
+ ['202_users_pref', 'analytics_consent',
+ "ALTER TABLE `202_users_pref` ADD COLUMN `analytics_consent` enum('granted','denied','unset') NOT NULL DEFAULT 'unset'"],
+ ['202_users_pref', 'analytics_consent_at',
+ "ALTER TABLE `202_users_pref` ADD COLUMN `analytics_consent_at` datetime DEFAULT NULL"],
+ ['202_users_pref', 'analytics_consent_source',
+ "ALTER TABLE `202_users_pref` ADD COLUMN `analytics_consent_source` varchar(32) DEFAULT NULL"],
+ ['202_users_pref', 'email_marketing_consent',
+ "ALTER TABLE `202_users_pref` ADD COLUMN `email_marketing_consent` enum('granted','denied','unset') NOT NULL DEFAULT 'unset'"],
+ ['202_users_pref', 'email_marketing_consent_at',
+ "ALTER TABLE `202_users_pref` ADD COLUMN `email_marketing_consent_at` datetime DEFAULT NULL"],
+ ['202_users_pref', 'eu_consent_prompt_seen',
+ "ALTER TABLE `202_users_pref` ADD COLUMN `eu_consent_prompt_seen` tinyint(1) NOT NULL DEFAULT '0'"],
+ ['202_users_pref', 'analytics_geo_is_eu',
+ "ALTER TABLE `202_users_pref` ADD COLUMN `analytics_geo_is_eu` tinyint(1) DEFAULT NULL"],
+ ['202_messaging_events', 'tier',
+ "ALTER TABLE `202_messaging_events` ADD COLUMN `tier` enum('essential','analytics') NOT NULL DEFAULT 'analytics' AFTER `client_token`"],
+ ] as [$consent_table, $consent_column, $consent_sql]) {
+ $check = _upgrade_query("SHOW COLUMNS FROM `{$consent_table}` LIKE '{$consent_column}'");
+ $exists = ($check instanceof mysqli_result) && $check->num_rows > 0;
+ if (!$exists && _upgrade_query($consent_sql) === false) {
+ $consent_ok = false;
+ error_log("Prosper202 upgrade: failed consent/analytics alter on {$consent_table}.{$consent_column}");
+ }
+ }
+
+ if ($consent_ok) {
+ if (_upgrade_query("UPDATE 202_version SET version='1.9.75'") !== false) {
+ $prosper202_version = '1.9.75';
+ } else {
+ error_log('Prosper202 upgrade: added consent/analytics columns but failed to persist version 1.9.75; leaving version at 1.9.74 so the next run retries.');
+ }
+ } else {
+ error_log('Prosper202 upgrade: consent/analytics column migration incomplete; leaving version at 1.9.74 so the next run retries.');
+ }
+ }
+
//This will enable p202 to downgrade to this version if installed over a newer version
- if (version_compare((string) $prosper202_version, '1.9.74', '>')) {
+ if (version_compare((string) $prosper202_version, '1.9.75', '>')) {
- $prosper202_version = '1.9.74';
+ $prosper202_version = '1.9.75';
$sql = "UPDATE 202_version SET version='" . $prosper202_version . "'";
$result = _upgrade_query($sql);
}
diff --git a/202-config/template.php b/202-config/template.php
index 59d2c0b0..5b9e5f51 100755
--- a/202-config/template.php
+++ b/202-config/template.php
@@ -501,6 +501,30 @@ function template_bottom()
});
+ $p202_route], 'analytics');
+
+ // One-time EU consent prompt. ConsentPolicy is the only consent
+ // reader; a consent lookup failure must never break the page.
+ require_once __DIR__ . '/Messaging/ConsentPolicy.class.php';
+ try {
+ $p202_consent_db = $GLOBALS['db'] ?? null;
+ $p202_needs_prompt = ($p202_consent_db instanceof mysqli)
+ && ConsentPolicy::needsEuPrompt($p202_consent_db, (int) $_SESSION['user_id']);
+ } catch (\Throwable $p202_consent_e) {
+ $p202_needs_prompt = false;
+ error_log('[Consent] ' . $p202_consent_e->getMessage());
+ }
+ ?>
+
+
+
+