Files
investor-flow/app/server/src/alerts/producers/unlockProducer.ts
T

97 lines
3.6 KiB
TypeScript
Raw Normal View History

// 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<string, string> = {
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<Alert[]> {
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;
},
};