diff --git a/202-account/account.php b/202-account/account.php index 5c3b4071..331b4d9d 100755 --- a/202-account/account.php +++ b/202-account/account.php @@ -2,6 +2,7 @@ declare(strict_types=1); include_once(str_repeat("../", 1) . '202-config/connect.php'); +require_once dirname(__DIR__) . '/202-config/Messaging/ConsentPolicy.class.php'; AUTH::require_user(); @@ -263,6 +264,17 @@ $_SESSION['user_pref_ad_settings'] = $mysql['user_pref_ad_settings']; registerDailyEmail($mysql['user_daily_email'], $mysql['user_timezone'], $html['install_hash']); + // Consent toggles — always route through ConsentPolicy (the single + // chokepoint); never write the raw consent columns here. A failure + // must not break the settings save, so log and continue. + $consentUserId = (int) $_SESSION['user_id']; + if (!ConsentPolicy::record($db, $consentUserId, 'analytics', !empty($_POST['analytics_consent']) ? 'granted' : 'denied', 'settings')) { + error_log('[Consent] failed to record analytics consent for user ' . $consentUserId); + } + if (!ConsentPolicy::record($db, $consentUserId, 'email_marketing', !empty($_POST['email_marketing_consent']) ? 'granted' : 'denied', 'settings')) { + error_log('[Consent] failed to record email marketing consent for user ' . $consentUserId); + } + //try to set non expiring cache for values that are used in redirects if (!empty($memcacheWorking)) { $tid = $mysql['user_id']; @@ -821,6 +833,31 @@ + +
+ +
+
+ +
+
+ +
+
+
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()); + } + ?> + + + +