diff --git a/src/__tests__/signal-reviewed-since.test.ts b/src/__tests__/signal-reviewed-since.test.ts new file mode 100644 index 0000000..ad88a49 --- /dev/null +++ b/src/__tests__/signal-reviewed-since.test.ts @@ -0,0 +1,103 @@ +/** + * GET /api/signals — `reviewed_since` filter semantics (issue #819) + * + * `since` filters on `created_at`. `reviewed_since` filters on `reviewed_at`. + * Callers that compute review-window metrics (editor activity, queue velocity) + * must use `reviewed_since`, not `since`, or they silently miss signals created + * before the window but reviewed inside it. + */ +import { describe, it, expect } from "vitest"; +import { SELF } from "cloudflare:test"; + +const REPORTER = "bc1qreviewed819testaddr000000000000000000000"; +const BEAT = "agent-social"; + +async function seed(body: Record) { + const res = await SELF.fetch("http://example.com/api/test-seed", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + expect(res.status).toBe(200); +} + +async function listSignals(query: string) { + const res = await SELF.fetch(`http://example.com/api/signals${query}`); + expect(res.status).toBe(200); + const body = await res.json<{ signals: { id: string }[] }>(); + return body.signals.map((s) => s.id); +} + +describe("GET /api/signals — reviewed_since filter", () => { + it("includes a signal created before the window but reviewed inside it", async () => { + // Signal created 2 days ago, reviewed 1 hour ago. + // `since=yesterday` should NOT include it; `reviewed_since=yesterday` should. + await seed({ + signals: [ + { + id: "rs-819-old-created-new-reviewed", + beat_slug: BEAT, + btc_address: REPORTER, + headline: "Created old, reviewed new", + sources: "[]", + created_at: "2026-05-06T10:00:00.000Z", + status: "approved", + reviewed_at: "2026-05-08T10:00:00.000Z", + }, + { + id: "rs-819-new-created-new-reviewed", + beat_slug: BEAT, + btc_address: REPORTER, + headline: "Created new, reviewed new", + sources: "[]", + created_at: "2026-05-08T09:00:00.000Z", + status: "approved", + reviewed_at: "2026-05-08T10:30:00.000Z", + }, + ], + }); + + const windowStart = "2026-05-07T00:00:00.000Z"; + + // `since` (created_at lower-bound): only the new-created signal is visible + const bySince = await listSignals(`?since=${windowStart}&status=approved&beat=${BEAT}`); + expect(bySince).toContain("rs-819-new-created-new-reviewed"); + expect(bySince).not.toContain("rs-819-old-created-new-reviewed"); + + // `reviewed_since` (reviewed_at lower-bound): both signals are visible + const byReviewedSince = await listSignals( + `?reviewed_since=${windowStart}&status=approved&beat=${BEAT}` + ); + expect(byReviewedSince).toContain("rs-819-old-created-new-reviewed"); + expect(byReviewedSince).toContain("rs-819-new-created-new-reviewed"); + }); + + it("excludes a signal reviewed before the window even if created inside it", async () => { + await seed({ + signals: [ + { + id: "rs-819-new-created-old-reviewed", + beat_slug: BEAT, + btc_address: REPORTER, + headline: "Created new, reviewed old", + sources: "[]", + created_at: "2026-05-08T08:00:00.000Z", + status: "approved", + reviewed_at: "2026-05-06T12:00:00.000Z", + }, + ], + }); + + const windowStart = "2026-05-07T00:00:00.000Z"; + + // Appears under `since` (created in window) + const bySince = await listSignals(`?since=${windowStart}&status=approved&beat=${BEAT}`); + expect(bySince).toContain("rs-819-new-created-old-reviewed"); + + // Absent under `reviewed_since` (reviewed before window) + const byReviewedSince = await listSignals( + `?reviewed_since=${windowStart}&status=approved&beat=${BEAT}` + ); + expect(byReviewedSince).not.toContain("rs-819-new-created-old-reviewed"); + }); +}); diff --git a/src/lib/do-client.ts b/src/lib/do-client.ts index a80320b..7cf657b 100644 --- a/src/lib/do-client.ts +++ b/src/lib/do-client.ts @@ -159,6 +159,12 @@ export interface SignalFilters { agent?: string; tag?: string; since?: string; + /** + * ISO timestamp lower bound on `reviewed_at`. Use when callers want signals + * reviewed inside a time window, regardless of when they were created. + * Distinct from `since` which filters on `created_at`. + */ + reviewed_since?: string; /** UTC calendar day (YYYY-MM-DD) — filters to signals within that day's UTC boundaries. */ date?: string; status?: string; @@ -204,6 +210,7 @@ export async function listSignalsPage( if (filters.agent) params.set("agent", filters.agent); if (filters.tag) params.set("tag", filters.tag); if (filters.since) params.set("since", filters.since); + if (filters.reviewed_since) params.set("reviewed_since", filters.reviewed_since); if (filters.date) params.set("date", filters.date); if (filters.status) params.set("status", filters.status); if (filters.include_pending) params.set("include_pending", "true"); diff --git a/src/objects/news-do.ts b/src/objects/news-do.ts index 81ad21e..e53866c 100644 --- a/src/objects/news-do.ts +++ b/src/objects/news-do.ts @@ -77,6 +77,8 @@ interface SignalListFilters { beat: string | null; agent: string | null; since: string | null; + /** Lower bound on `reviewed_at` (ISO string). Filters to signals reviewed after this timestamp. */ + reviewed_since: string | null; tag: string | null; status: string | null; dateStart: string | null; @@ -124,6 +126,10 @@ function buildSignalListWhere(filters: SignalListFilters): { whereSql: string; p clauses.push("s.created_at > ?"); params.push(filters.since); } + if (filters.reviewed_since) { + clauses.push("s.reviewed_at > ?"); + params.push(filters.reviewed_since); + } if (filters.tag) { clauses.push("s.id IN (SELECT signal_id FROM signal_tags WHERE tag = ?)"); params.push(filters.tag); @@ -2571,6 +2577,7 @@ export class NewsDO extends DurableObject { const beat = c.req.query("beat") ?? null; const agent = c.req.query("agent") ?? null; const since = c.req.query("since") ?? null; + const reviewed_since = c.req.query("reviewed_since") ?? null; const tag = c.req.query("tag") ?? null; const status = c.req.query("status") ?? null; const includePending = c.req.query("include_pending") === "true"; @@ -2595,6 +2602,7 @@ export class NewsDO extends DurableObject { beat, agent, since: dateParam ? null : since, + reviewed_since, tag, status, dateStart,