From 0bc7b636e5a504508d6a909c3c830b4983f00fff Mon Sep 17 00:00:00 2001 From: Investor Flow Build Date: Wed, 12 Aug 2026 22:39:19 -0400 Subject: [PATCH] fix: dedup fund_capture alerts for same-date sibling records and add clear-all fund_capture alerts for a symbol with multiple capture records sharing one as_of re-fired every batch cycle: the single comparison-state slot ping-ponged between sibling evidence URLs, and the pre-check dedup key never matched the stored buildDedupKey format. Track a seen-set of URLs in comparison state and align dedup-key construction with buildDedupKey. Also add alerts.clearAll (hard delete) with an immediate-refresh bell. --- .../__tests__/mirrorAlertProducers.test.ts | 56 +++++++++++ .../alerts/producers/mirrorAlertProducers.ts | 27 ++++-- app/server/src/trpc/router.ts | 8 ++ app/src/app/alerts/page.tsx | 97 ++++++++++++++++--- app/src/components/NotificationBell.tsx | 17 +++- app/src/lib/trpc.ts | 1 + 6 files changed, 185 insertions(+), 21 deletions(-) diff --git a/app/server/src/alerts/__tests__/mirrorAlertProducers.test.ts b/app/server/src/alerts/__tests__/mirrorAlertProducers.test.ts index b76b99f..a6fa5c8 100644 --- a/app/server/src/alerts/__tests__/mirrorAlertProducers.test.ts +++ b/app/server/src/alerts/__tests__/mirrorAlertProducers.test.ts @@ -29,6 +29,10 @@ function subscribe(db: DatabaseSync, userId: string, type: string, symbol: strin ).run(`sub-${userId}-${type}-${symbol ?? 'x'}`, userId, symbol, type, new Date().toISOString()); } +function alertEventCount(db: DatabaseSync, type: string): number { + return (db.prepare('SELECT COUNT(*) AS n FROM alert_events WHERE type = ?').get(type) as { n: number }).n; +} + const hasAdvice = (s: string) => /you should\b|^\s*(buy|sell)\s/i.test(s); test('fund_capture: fires once per subscribed user, dedupes, mechanical framing', async () => { @@ -51,6 +55,58 @@ test('fund_capture: fires once per subscribed user, dedupes, mechanical framing' assert.equal(second.length, 0); // no new post → no re-fire }); +test('fund_capture: multiple same-date capture records for one symbol fire once each, never re-fire', async () => { + const db = freshDb(); + const fund = upsertTrackedFund(db, { ci_key: '0002096493', fund_name: 'Alpine Fox Capital', manager_name: 'M' }); + subscribe(db, 'u1', 'fund_capture', 'INOD'); + + // Mirrors the real-world flood: 3 distinct X posts for the same symbol share + // the same as_of, so the liveBook MAX(as_of) join returns all 3 as "current". + for (const url of [ + 'https://x.com/fund/status/2069061783432872271', + 'https://x.com/fund/status/2069061809240486316', + 'https://x.com/fund/status/2069062138937884689', + ]) { + insertPositionRecord(db, { + fund_id: fund.id, symbol: 'INOD', as_of: '2026-06-22', source: 'capture', evidence_url: url, + }); + } + + const first = await fundCaptureProducer.check(db); + assert.equal(first.length, 3, 'one alert per distinct capture record'); + + const second = await fundCaptureProducer.check(db); + assert.equal(second.length, 0, 're-check must not re-fire despite the comparison-state slot cycling'); + + const third = await fundCaptureProducer.check(db); + assert.equal(third.length, 0); + assert.equal(alertEventCount(db, 'fund_capture'), 3); +}); + +test('fund_capture: clearing alert events must not re-arm re-firing for same-date siblings', async () => { + const db = freshDb(); + const fund = upsertTrackedFund(db, { ci_key: '0002096493', fund_name: 'Alpine Fox Capital', manager_name: 'M' }); + subscribe(db, 'u1', 'fund_capture', 'INOD'); + + for (const url of [ + 'https://x.com/fund/status/2069061783432872271', + 'https://x.com/fund/status/2069061809240486316', + 'https://x.com/fund/status/2069062138937884689', + ]) { + insertPositionRecord(db, { + fund_id: fund.id, symbol: 'INOD', as_of: '2026-06-22', source: 'capture', evidence_url: url, + }); + } + + assert.equal((await fundCaptureProducer.check(db)).length, 3); + // User clears their alert feed (the "Clear all" button deletes alert_events). + db.prepare('DELETE FROM alert_events').run(); + // Producers must not re-fire: comparison state remembers the seen URLs even + // with an empty alert_events table. + assert.equal((await fundCaptureProducer.check(db)).length, 0, 'clearing events must not re-arm re-firing'); + assert.equal(alertEventCount(db, 'fund_capture'), 0); +}); + test('mirror_diff: fires on a book change, mechanical framing, dedupes', async () => { const db = freshDb(); const fund = upsertTrackedFund(db, { ci_key: '0002096493', fund_name: 'Fund X', manager_name: 'M' }); diff --git a/app/server/src/alerts/producers/mirrorAlertProducers.ts b/app/server/src/alerts/producers/mirrorAlertProducers.ts index d84246a..0f2e976 100644 --- a/app/server/src/alerts/producers/mirrorAlertProducers.ts +++ b/app/server/src/alerts/producers/mirrorAlertProducers.ts @@ -11,7 +11,7 @@ import type { DatabaseSync } from 'node:sqlite'; import type { Alert } from '../AlertEngine.ts'; -import { createAlert } from '../AlertEngine.ts'; +import { createAlert, buildDedupKey } from '../AlertEngine.ts'; import type { AlertProducer } from './types.ts'; import { readComparisonState, writeComparisonState, getSubscribedUsers, persistAlert, isDuplicate } from './types.ts'; import { liveBook } from '../../db/fundRepository.ts'; @@ -31,15 +31,25 @@ export const fundCaptureProducer: AlertProducer = { for (const pos of book) { if (pos.source !== 'capture' && pos.source !== 'claim') continue; const key = `fund_capture:${fund.id}:${pos.symbol}`; + // Each distinct evidence_url (post id) is one capture. liveBook can + // return multiple records for a symbol sharing the same as_of, so we + // track a SET of already-alerted URLs rather than a single slot — + // a single slot ping-pongs between sibling records and re-fires. const prev = readComparisonState(db, key, 'fund_capture'); - const prevId = prev ? (JSON.parse(prev.state).latestKey as string | undefined) : null; - // Use evidence_url as the stable id (post id) — each new post is a new capture. + // Seed with any previously-alerted URLs (both the current "seen" array + // and the legacy single-slot "latestKey") so sibling records that share + // an as_of stop ping-ponging instead of re-firing. + const prevState = prev ? (JSON.parse(prev.state) as Record) : {}; + const seen = new Set( + (prevState.seen as string[] | undefined) ?? + (prevState.latestKey ? [prevState.latestKey as string] : []), + ); const latestKey = pos.evidence_url ?? `${pos.as_of}:${pos.shares}`; - if (latestKey === prevId) continue; + if (seen.has(latestKey)) continue; const users = getSubscribedUsers(db, 'fund_capture', pos.symbol); for (const userId of users) { const dedup = `fund_capture:${userId}:${fund.id}:${latestKey}`; - if (isDuplicate(db, dedup)) continue; + if (isDuplicate(db, buildDedupKey('fund_capture', pos.symbol, dedup))) continue; const shares = pos.shares != null ? `${rnd(pos.shares).toLocaleString('en-US')} shares` : 'a disclosed position'; const alert = createAlert( crypto.randomUUID(), userId, 'fund_capture', pos.symbol, @@ -49,7 +59,8 @@ export const fundCaptureProducer: AlertProducer = { persistAlert(db, alert); alerts.push(alert); } - writeComparisonState(db, key, 'fund_capture', { latestKey }); + seen.add(latestKey); + writeComparisonState(db, key, 'fund_capture', { seen: Array.from(seen) }); } } return alerts; @@ -87,7 +98,7 @@ export const fund13FRecordProducer: AlertProducer = { } for (const [userId, sym] of userSet) { const dedup = `fund_13f:${userId}:${f.id}:${latest.accession}`; - if (isDuplicate(db, dedup)) continue; + if (isDuplicate(db, buildDedupKey('fund_13f', sym, dedup))) continue; const alert = createAlert( crypto.randomUUID(), userId, 'fund_13f', sym, `${f.fund_name} filed its latest 13F for ${latest.reported_quarter}.`, @@ -123,7 +134,7 @@ export const mirrorDiffProducer: AlertProducer = { if (sig === prevSig) continue; for (const userId of userIds) { const dedup = `mirror_diff:${userId}:${fund.id}:${pos.symbol}:${sig}`; - if (isDuplicate(db, dedup)) continue; + if (isDuplicate(db, buildDedupKey('mirror_diff', pos.symbol, dedup))) continue; const delta = pos.shares != null ? `To match your mirror target for ${pos.symbol}, the delta is ${rnd(pos.shares).toLocaleString('en-US')} shares as disclosed ${pos.as_of}.` : `The ${pos.symbol} position in the mirror target updated as of ${pos.as_of}.`; diff --git a/app/server/src/trpc/router.ts b/app/server/src/trpc/router.ts index fe044fa..f1fad70 100644 --- a/app/server/src/trpc/router.ts +++ b/app/server/src/trpc/router.ts @@ -2085,6 +2085,14 @@ const alertsRouter = router({ return { ok: true }; }), + /** Permanently delete all alert events for the current user. */ + clearAll: protectedProcedure + .mutation(async ({ ctx }) => { + const userId = ctx.userId as string; + ctx.db.prepare('DELETE FROM alert_events WHERE user_id = ?').run(userId); + return { ok: true }; + }), + /** Get unacknowledged alert count. */ unackedCount: protectedProcedure .query(async ({ ctx }) => { diff --git a/app/src/app/alerts/page.tsx b/app/src/app/alerts/page.tsx index 439e1fd..9dc0953 100644 --- a/app/src/app/alerts/page.tsx +++ b/app/src/app/alerts/page.tsx @@ -1,5 +1,5 @@ "use client"; -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { ExternalLink as ExternalLinkIcon } from "lucide-react"; import { LayoutShell } from "@/components/LayoutShell"; import { FeatureGate } from "@/components/FeatureGate"; @@ -19,6 +19,10 @@ function AlertsPageInner() { const [alertType, setAlertType] = useState("informed_buy"); const [symbol, setSymbol] = useState(""); const [loading, setLoading] = useState(true); + const [clearing, setClearing] = useState(false); + const [confirmClear, setConfirmClear] = useState(false); + const [totalCount, setTotalCount] = useState(null); + const confirmTimer = useRef | null>(null); const loadEvents = async () => { try { @@ -27,6 +31,13 @@ function AlertsPageInner() { } catch { /* ignore */ } }; + const loadTotal = async () => { + try { + const data = await api.alerts.unackedCount(); + setTotalCount(data.count); + } catch { /* ignore */ } + }; + const loadSubscriptions = async () => { try { const data = await api.alerts.listSubscriptions(); @@ -42,7 +53,26 @@ function AlertsPageInner() { }; useEffect(() => { - Promise.all([loadEvents(), loadSubscriptions(), loadTypeToggles()]).finally(() => setLoading(false)); + let cancelled = false; + api.alerts.list(100).then( + (data) => { if (!cancelled) setEvents(data); }, + () => {}, + ); + api.alerts.listSubscriptions().then( + (data) => { if (!cancelled) setSubscriptions(data); }, + () => {}, + ); + api.alerts.listTypes().then( + (data) => { if (!cancelled) setTypeToggles(data); }, + () => {}, + ); + api.alerts.unackedCount().then( + (data) => { if (!cancelled) setTotalCount(data.count); }, + () => {}, + ).finally(() => { + if (!cancelled) setLoading(false); + }); + return () => { cancelled = true; }; }, []); const acknowledge = async (id: string) => { @@ -53,6 +83,28 @@ function AlertsPageInner() { const acknowledgeAll = async () => { await api.alerts.acknowledgeAll(); loadEvents(); + loadTotal(); + window.dispatchEvent(new CustomEvent("alert-dismissed")); + }; + + const clearAll = async () => { + if (confirmTimer.current) clearTimeout(confirmTimer.current); + setClearing(true); + await api.alerts.clearAll(); + setClearing(false); + setConfirmClear(false); + setEvents([]); + setTotalCount(0); + loadSubscriptions(); + loadTypeToggles(); + window.dispatchEvent(new CustomEvent("alert-dismissed")); + }; + + const requestClearAll = () => { + setConfirmClear(true); + confirmTimer.current = setTimeout(() => { + setConfirmClear(false); + }, 4000); }; const addSubscription = async () => { @@ -110,15 +162,38 @@ function AlertsPageInner() { ) : tab === "events" ? (
- {unacknowledged.length > 0 && ( - - )} +
+ {unacknowledged.length > 0 && ( + + )} + {totalCount != null && totalCount > 0 && ( + <> + + {confirmClear && ( + This permanently deletes all alert events. + )} + + )} +
{events.length === 0 ? (

No alerts yet.

) : ( diff --git a/app/src/components/NotificationBell.tsx b/app/src/components/NotificationBell.tsx index 6fd75e0..ca9146f 100644 --- a/app/src/components/NotificationBell.tsx +++ b/app/src/components/NotificationBell.tsx @@ -31,11 +31,24 @@ export function NotificationBell() { }, []); useEffect(() => { - void fetchAlerts(); - }, [fetchAlerts]); + api.alerts.list(20).then( + (evts) => setAlerts(evts), + () => {}, + ); + api.alerts.unackedCount().then( + (r) => setUnackedCount(r.count), + () => {}, + ); + }, []); useVisibilityAwarePoll(fetchAlerts, POLL_INTERVAL); + useEffect(() => { + const handler = () => void fetchAlerts(); + window.addEventListener("alert-dismissed", handler); + return () => window.removeEventListener("alert-dismissed", handler); + }, [fetchAlerts]); + useEffect(() => { const handler = (e: MouseEvent) => { if (ref.current && !ref.current.contains(e.target as Node)) { diff --git a/app/src/lib/trpc.ts b/app/src/lib/trpc.ts index 869bd21..5f6aa0d 100644 --- a/app/src/lib/trpc.ts +++ b/app/src/lib/trpc.ts @@ -1247,6 +1247,7 @@ export const api = { acknowledge: (alertId: string) => trpcMutate<{ ok: boolean }>("alerts.acknowledge", { alertId }), unackedCount: () => trpcQuery<{ count: number }>("alerts.unackedCount"), acknowledgeAll: () => trpcMutate<{ ok: boolean }>("alerts.acknowledgeAll", {}), + clearAll: () => trpcMutate<{ ok: boolean }>("alerts.clearAll", {}), createSubscription: (input: { alertType: string; watchlistId?: string; symbol?: string; params?: string }) => trpcMutate("alerts.createSubscription", input), listSubscriptions: (symbol?: string) =>