Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
5578350
feat(analytics): add consent + event-tier columns (additive migration)
tracking202 Jul 16, 2026
e4a5841
feat(analytics): add ConsentPolicy single-chokepoint gate
tracking202 Jul 16, 2026
cf10b48
feat(analytics): add UrlScrubber PII safety net for offer URLs
tracking202 Jul 16, 2026
5098430
feat(analytics): add gated Analytics facade over MessagingService
tracking202 Jul 16, 2026
3a53b04
fix(analytics): close unknown-tier consent bypass in Analytics facade
tracking202 Jul 16, 2026
da84472
fix(analytics): gate client track.php through ConsentPolicy, reject b…
tracking202 Jul 16, 2026
52b71e8
feat(analytics): server-side page_viewed + persist account EU geo at …
tracking202 Jul 16, 2026
c278a31
fix(analytics): compute EU geo from client IP at login instead of dea…
tracking202 Jul 16, 2026
5774cbd
feat(analytics): emit milestone events incl. LTV/LPO activation
tracking202 Jul 16, 2026
c25aab5
feat(analytics): add OfferProfile volume + offer-matching + LTV/LPO s…
tracking202 Jul 16, 2026
36a5f5e
feat(analytics): compute + sync offer profile in cron, consent-gated
tracking202 Jul 16, 2026
42e65d3
fix(analytics): persist nested offer-profile attributes, reject non-J…
tracking202 Jul 16, 2026
c772a4c
feat(analytics): consent settings toggles, recording endpoint, one-ti…
tracking202 Jul 16, 2026
9317397
fix(analytics): enforce consent at the sync boundary, fail closed on …
tracking202 Jul 16, 2026
7bcc1b5
fix(analytics): ship a real disclosure page; fix the dead Learn-more …
tracking202 Jul 16, 2026
06f5201
feat(analytics): implement the essential tier + missing spec-8 lifecy…
tracking202 Jul 16, 2026
f6cf651
test(analytics): DB-gated behavioral verification of the consent subs…
tracking202 Jul 16, 2026
a01e1f6
feat(analytics): transmit consent block to central server (receiver s…
tracking202 Jul 16, 2026
259c996
docs(messaging): sync CENTRAL-API contract with tier, consent block, …
tracking202 Jul 17, 2026
0165543
docs(messaging): relocate central API contract to internal docs repo
tracking202 Jul 17, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions 202-account/account.php
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down Expand Up @@ -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'];
Expand Down Expand Up @@ -821,6 +833,31 @@
</select>
</div>
</div>
<?php
// Checkbox state comes from ConsentPolicy (the only consent reader)
// so 'unset' reflects the effective default rather than the raw column.
$analyticsChecked = ConsentPolicy::analyticsAllowed($db, (int) $_SESSION['user_id']) ? 'checked' : '';
$emailChecked = ConsentPolicy::emailMarketingAllowed($db, (int) $_SESSION['user_id']) ? 'checked' : '';
?>
<div class="form-group">
<label class="col-xs-4 control-label">Data &amp; Communication:</label>
<div class="col-xs-8">
<div class="checkbox-modern">
<label><input type="checkbox" name="analytics_consent" value="1" <?php echo $analyticsChecked; ?>>
<span><strong>Product analytics &amp; personalized help</strong> — lets us surface workflow tips and match
you with specially-sourced, higher-paying offers relevant to what you promote. Your usage data —
including traffic stats, revenue numbers, and campaign names + destination links — is sent to
Prosper202. We never share it with third parties. <a href="<?php echo get_absolute_url(); ?>202-account/disclosure.php" target="_blank" rel="noopener">Learn more</a>.</span>
</label>
</div>
<div class="checkbox-modern">
<label><input type="checkbox" name="email_marketing_consent" value="1" <?php echo $emailChecked; ?>>
<span><strong>Money-making offers &amp; tips by email</strong> — occasional emails about higher-paying
offers and workflow improvements. You can unsubscribe anytime.</span>
</label>
</div>
</div>
</div>
<div class="form-group">
<label for="cloak_referer" class="col-xs-4 control-label">* Cloaked Referer:</label>
<div class="col-xs-8">
Expand Down
33 changes: 33 additions & 0 deletions 202-account/ajax/messaging/consent.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<?php

declare(strict_types=1);

include_once(str_repeat('../', 3) . '202-config/connect.php');

require __DIR__ . '/_auth.php';
require_once dirname(__DIR__, 3) . '/202-config/Messaging/ConsentPolicy.class.php';

header('Content-Type: application/json');

// Shared guarded helper: fails closed when either token side is empty.
if (!AUTH::check_csrf_token()) {
http_response_code(403);
echo json_encode(['ok' => 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]);
29 changes: 14 additions & 15 deletions 202-account/ajax/messaging/track.php
Original file line number Diff line number Diff line change
Expand Up @@ -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');

Expand All @@ -15,42 +16,40 @@
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;
}

$handled = false;

// 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;
}

Expand Down
7 changes: 7 additions & 0 deletions 202-account/api-integrations.php
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
Expand Down
85 changes: 85 additions & 0 deletions 202-account/disclosure.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
<?php

/**
* Product analytics & communication disclosure.
*
* The "Learn more" target for the consent surfaces (account settings toggles,
* EU consent prompt). Copy must stay truthful to the spec's D6/D7 bounds:
* analytics sends usage including traffic stats, revenue numbers, and campaign
* names + destination template URLs; marketing is money/workflow/offer-matching
* only; visitors' PII and per-customer LTV data never leave the install.
*
* Login is required, but NOT a valid license (like the messaging endpoints):
* a privacy disclosure must stay reachable even when the license check fails.
*/

declare(strict_types=1);
include_once(str_repeat("../", 1) . '202-config/connect.php');

AUTH::require_user('', false);

template_top('Product Analytics Disclosure');

?>

<div style="max-width: 860px; margin: 24px auto 48px; padding: 0 16px;">
<div class="page-header">
<h4>Product analytics &amp; personalized help</h4>
<p>What leaves your install, what never does, and how to switch it off.</p>
</div>

<div class="card-modern" style="padding: 24px 28px; margin-bottom: 16px;">
<h5>Why we collect this</h5>
<p>When product analytics is on, we use your Prosper202 usage to help you <strong>earn more</strong> —
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.</p>
</div>

<div class="card-modern" style="padding: 24px 28px; margin-bottom: 16px;">
<h5>What is sent to Prosper202</h5>
<ul>
<li><strong>Traffic and results:</strong> clicks, conversions, income, cost, and net over the last
30 days; top countries and device mix.</li>
<li><strong>Your campaign setup:</strong> 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.</li>
<li><strong>Offer performance:</strong> per-campaign payout, currency, EPC, conversion rate, and
clicks.</li>
<li><strong>Account-level revenue aggregates:</strong> if you use Customer LTV, only account totals
(total revenue, MRR/ARR, customer count, average LTV, active subscriptions).</li>
<li><strong>Product usage:</strong> pages viewed inside Prosper202 and setup milestones (e.g.
campaign created, tracking link generated, integration connected).</li>
</ul>
<p>We never share this data with third parties.</p>
</div>

<div class="card-modern" style="padding: 24px 28px; margin-bottom: 16px;">
<h5>What never leaves your install</h5>
<ul>
<li><strong>Your visitors' data.</strong> 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.)</li>
<li><strong>Your customers' data.</strong> Customer LTV records — names, aliases, emails,
per-customer revenue, custom fields — never sync. Only the account-level aggregates listed
above do.</li>
</ul>
</div>

<div class="card-modern" style="padding: 24px 28px; margin-bottom: 16px;">
<h5>Consent and the off switch</h5>
<ul>
<li>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
<a href="<?php echo get_absolute_url(); ?>202-account/account.php">Account Settings</a>.</li>
<li>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).</li>
<li>A small <strong>essential</strong> tier stays on for operational use (account lifecycle, login,
and support messaging delivery) — it carries no campaign or revenue analytics.</li>
<li><strong>Money-making offers &amp; tips by email</strong> is a separate, opt-in consent. It is
never inferred from the analytics setting, and you can unsubscribe anytime.</li>
</ul>
</div>
</div>

<?php template_bottom(); ?>
15 changes: 15 additions & 0 deletions 202-account/user-management.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
3 changes: 3 additions & 0 deletions 202-config/Database/Tables/CoreTables.php
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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`),
Expand Down
7 changes: 7 additions & 0 deletions 202-config/Database/Tables/UserTables.php
Original file line number Diff line number Diff line change
Expand Up @@ -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"
);
Expand Down
10 changes: 9 additions & 1 deletion 202-config/Ltv/MysqlIntegrationRepository.php
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Comment on lines +88 to +89

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Record the LTV milestone for API-created integrations

When an integration is created through api/v3 (whose LtvController::createIntegration() calls this repository with the API-authenticated user ID), this call silently does nothing: Analytics::event() derives its user solely from $_SESSION['user_id'], and API requests do not establish that dashboard session. Consequently, consented users creating LTV integrations through the public API never enqueue ltv_integration_connected; pass the repository's $userId and connection through a context-independent recording path instead.

Useful? React with 👍 / 👎.


return $integrationId;
}

public function delete(int $userId, int $integrationId): void
Expand Down
72 changes: 72 additions & 0 deletions 202-config/Messaging/Analytics.class.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
<?php
// 202-config/Messaging/Analytics.class.php
require_once __DIR__ . '/ConsentPolicy.class.php';
require_once __DIR__ . '/MessagingService.class.php';

final class Analytics
{
/** Pure gate, delegates to ConsentPolicy. Exposed for testing. */
public static function gate(string $stored, bool $isEu, string $tier): bool
{
return ConsentPolicy::decide($stored, $isEu, $tier);
}

/**
* Normalize a tier with the same semantics as ConsentPolicy::decide:
* anything that is not exactly 'essential' is treated as 'analytics'
* and therefore consent-gated. Never fail open on a typo or a future
* tier name. Exposed for testing.
*/
public static function normalizeTier(string $tier): string
{
return $tier === 'essential' ? 'essential' : 'analytics';
}

public static function event(string $name, array $meta = [], string $tier = 'analytics'): void
{
self::guarded(function (mysqli $db, int $uid) use ($name, $meta, $tier) {
$tier = self::normalizeTier($tier);
if (!self::wouldRecord($db, $uid, $tier)) {
return;
}
$service = new MessagingService($db, $uid, []);
// MessagingService::recordEvent persists to 202_messaging_events; pass tier through.
$service->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());
}
}
}
}
Loading
Loading