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.
This commit is contained in:
@@ -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' });
|
||||
|
||||
@@ -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<string, unknown>) : {};
|
||||
const seen = new Set<string>(
|
||||
(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}.`;
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
+86
-11
@@ -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<number | null>(null);
|
||||
const confirmTimer = useRef<ReturnType<typeof setTimeout> | 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() {
|
||||
</div>
|
||||
) : tab === "events" ? (
|
||||
<div>
|
||||
{unacknowledged.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={acknowledgeAll}
|
||||
className="mb-4 min-h-[44px] rounded-lg border border-line px-4 py-2 text-sm font-medium text-fg-muted hover:text-fg active:bg-surface-sunken"
|
||||
>
|
||||
Acknowledge all ({unacknowledged.length})
|
||||
</button>
|
||||
)}
|
||||
<div className="mb-4 flex flex-col sm:flex-row sm:items-center gap-2">
|
||||
{unacknowledged.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={acknowledgeAll}
|
||||
className="min-h-[44px] rounded-lg border border-line px-4 py-2 text-sm font-medium text-fg-muted hover:text-fg active:bg-surface-sunken"
|
||||
>
|
||||
Acknowledge all{totalCount != null ? ` (${totalCount.toLocaleString()})` : ""}
|
||||
</button>
|
||||
)}
|
||||
{totalCount != null && totalCount > 0 && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={confirmClear ? clearAll : requestClearAll}
|
||||
disabled={clearing}
|
||||
className={`min-h-[44px] rounded-lg border px-4 py-2 text-sm font-medium transition-colors ${
|
||||
confirmClear
|
||||
? "border-danger bg-danger text-white hover:bg-danger/80"
|
||||
: "border-red-500/40 text-danger hover:bg-red-950/30"
|
||||
} disabled:opacity-50`}
|
||||
>
|
||||
{confirmClear
|
||||
? clearing ? "Deleting..." : "Confirm clear all?"
|
||||
: `Clear all (${totalCount.toLocaleString()})`}
|
||||
</button>
|
||||
{confirmClear && (
|
||||
<span className="text-xs text-fg-faint">This permanently deletes all alert events.</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{events.length === 0 ? (
|
||||
<p className="text-fg-faint text-sm">No alerts yet.</p>
|
||||
) : (
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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<AlertSubscriptionRow>("alerts.createSubscription", input),
|
||||
listSubscriptions: (symbol?: string) =>
|
||||
|
||||
Reference in New Issue
Block a user