// Investor Flow — Conviction tier unlock alert producer. // // conviction_unlock: fires when a user earns a new sizing unlock (tier_a, // tier_a_star, etc.) from accumulated trading history. Watermark = last // earned_at per user, so re-reading the same unlock stays silent. // // ADR-0007: an unlock is an earned capability, not a buy signal. import type { DatabaseSync } from 'node:sqlite'; import type { Alert } from '../AlertEngine.ts'; import { createAlert, buildDedupKey } from '../AlertEngine.ts'; import type { AlertProducer } from './types.ts'; import { readComparisonState, writeComparisonState, getSubscribedUsers, persistAlert, isDuplicate } from './types.ts'; /** Human-readable tier names for the payload/unlock display. */ const UNLOCK_LABELS: Record = { tier_a: 'A tier', tier_a_star: 'A* tier', covered_income: 'covered income strategies', uncovered_income: 'uncovered income strategies', }; export const convictionUnlockProducer: AlertProducer = { alertType: 'conviction_unlock', frequency: 'batched', async check(db: DatabaseSync): Promise { const alerts: Alert[] = []; // Newest unlock per user. const rows = db.prepare( `SELECT s.user_id, s.unlock, s.earned_at, s.evidence FROM sizing_unlocks s JOIN (SELECT user_id, MAX(earned_at) AS max_earned FROM sizing_unlocks GROUP BY user_id) m ON s.user_id = m.user_id AND s.earned_at = m.max_earned ORDER BY s.earned_at DESC`, ).all() as Array<{ user_id: string; unlock: string; earned_at: string; evidence: string | null }>; for (const row of rows) { const userId = row.user_id; const prev = readComparisonState(db, userId, 'conviction_unlock'); const prevState = prev ? JSON.parse(prev.state) : { lastEarnedAt: null, lastUnlock: null }; if (prevState.lastEarnedAt === row.earned_at && prevState.lastUnlock === row.unlock) { continue; } // First observation records the watermark without firing (existing // unlocks at boot shouldn't page anyone). if (prevState.lastUnlock === null) { writeComparisonState(db, userId, 'conviction_unlock', { lastEarnedAt: row.earned_at, lastUnlock: row.unlock, }); continue; } // A genuinely new unlock → notify the earning user first (they are the // primary subscriber; a global subscription is honored too). const users = new Set(getSubscribedUsers(db, 'conviction_unlock', userId)); users.add(userId); for (const uid of users) { const eventId = `${userId}:${row.unlock}:${row.earned_at}`; const dedupKey = buildDedupKey('conviction_unlock', undefined, eventId); if (isDuplicate(db, dedupKey)) continue; const label = UNLOCK_LABELS[row.unlock] ?? row.unlock; const description = `Your trading history now qualifies for the ${label} conviction tier` + (row.evidence ? `. Evidence: ${row.evidence}` : '') + `. Sizing at this tier uses the higher conviction multiplier.`; const alert = createAlert( crypto.randomUUID(), uid, 'conviction_unlock', undefined, description, eventId, { unlock: row.unlock, earnedAt: row.earned_at, evidence: row.evidence }, ); persistAlert(db, alert); alerts.push(alert); } writeComparisonState(db, userId, 'conviction_unlock', { lastEarnedAt: row.earned_at, lastUnlock: row.unlock, }); } return alerts; }, };