diff --git a/artifacts/api-server/src/lib/moderation.ts b/artifacts/api-server/src/lib/moderation.ts index 5dca1a3..ca9d665 100644 --- a/artifacts/api-server/src/lib/moderation.ts +++ b/artifacts/api-server/src/lib/moderation.ts @@ -134,7 +134,7 @@ export class ModerationService { /** * Review a single pending event with Claude. * Returns "approve" (event is injected into strfry + status → auto_approved) - * or "flag" (status stays pending — admin must decide). + * or "flag" (status → flagged — admin must decide). */ async autoReview(eventId: string): Promise { const rows = await db @@ -176,10 +176,10 @@ export class ModerationService { if (result.decision === "approve") { await this.decide(eventId, "auto_approved", result.reason, "timmy_ai"); } else { - // Update reason but leave status as "pending" for admin + // Transition to "flagged" so processPending() won't re-review this event await db .update(relayEventQueue) - .set({ reviewReason: result.reason, reviewedBy: "timmy_ai" }) + .set({ status: "flagged", reviewReason: result.reason, reviewedBy: "timmy_ai" }) .where(eq(relayEventQueue.eventId, eventId)); logger.info("moderation: event flagged for admin review", { diff --git a/artifacts/api-server/src/routes/admin-relay.ts b/artifacts/api-server/src/routes/admin-relay.ts index d268312..df95fbe 100644 --- a/artifacts/api-server/src/routes/admin-relay.ts +++ b/artifacts/api-server/src/routes/admin-relay.ts @@ -106,6 +106,7 @@ router.get("/admin/relay/stats", requireAdmin, async (_req: Request, res: Respon res.json({ pending: statusMap["pending"] ?? 0, + flagged: statusMap["flagged"] ?? 0, approved: statusMap["approved"] ?? 0, autoApproved: statusMap["auto_approved"] ?? 0, rejected: statusMap["rejected"] ?? 0, diff --git a/lib/db/migrations/0007_moderation_flagged_status.sql b/lib/db/migrations/0007_moderation_flagged_status.sql new file mode 100644 index 0000000..649ed33 --- /dev/null +++ b/lib/db/migrations/0007_moderation_flagged_status.sql @@ -0,0 +1,9 @@ +-- Migration: Add 'flagged' status to relay_event_queue +-- Fixes infinite re-review loop (#27): AI-flagged events now transition to +-- status='flagged' instead of staying 'pending', so processPending() skips them. +-- +-- The status column is plain TEXT (not a Postgres enum), so no ALTER TYPE is +-- needed. This migration adds an index for admin queries on flagged events. + +CREATE INDEX IF NOT EXISTS idx_relay_event_queue_status + ON relay_event_queue(status); diff --git a/lib/db/src/schema/relay-event-queue.ts b/lib/db/src/schema/relay-event-queue.ts index f601fcc..46355bd 100644 --- a/lib/db/src/schema/relay-event-queue.ts +++ b/lib/db/src/schema/relay-event-queue.ts @@ -3,7 +3,7 @@ import { nostrIdentities } from "./nostr-identities"; // ── Status + reviewer types ─────────────────────────────────────────────────── -export const QUEUE_STATUSES = ["pending", "approved", "rejected", "auto_approved"] as const; +export const QUEUE_STATUSES = ["pending", "approved", "rejected", "auto_approved", "flagged"] as const; export type QueueStatus = (typeof QUEUE_STATUSES)[number]; export const QUEUE_REVIEWERS = ["timmy_ai", "admin"] as const;