diff --git a/services/api/src/services/admin/activity-repository.drizzle.ts b/services/api/src/services/admin/activity-repository.drizzle.ts index c600b0d9..5d1cef07 100644 --- a/services/api/src/services/admin/activity-repository.drizzle.ts +++ b/services/api/src/services/admin/activity-repository.drizzle.ts @@ -55,6 +55,7 @@ interface ActivityRowSelect { id: string ts: Date who: string | null + actorless: boolean where_label: string | null action: string | null event_type: string | null @@ -67,6 +68,7 @@ function toRecord(r: ActivityRowSelect): ActivitySourceRecord { id: r.id, ts: r.ts, who: r.who ?? "", + actorless: r.actorless, where: r.where_label ?? "", action: r.action, eventType: r.event_type, @@ -147,7 +149,7 @@ export function makeDrizzleActivityRepository(sql: Sql): ActivityRepository { if (wants("audit")) { branches.push(sql`( SELECT 'audit'::text AS source, a.id::text AS id, a.created_at AS ts, - u.display_name AS who, a.target AS where_label, + u.display_name AS who, a.actor_id IS NULL AS actorless, a.target AS where_label, a.action AS action, NULL::text AS event_type, a.target AS subject FROM audit_log a LEFT JOIN users u ON u.id = a.actor_id @@ -164,7 +166,7 @@ export function makeDrizzleActivityRepository(sql: Sql): ActivityRepository { if (wants("report")) { branches.push(sql`( SELECT 'report'::text AS source, r.id::text AS id, r.created_at AS ts, - ru.display_name AS who, j.name AS where_label, + ru.display_name AS who, false AS actorless, j.name AS where_label, NULL::text AS action, NULL::text AS event_type, r.category AS subject FROM reports r LEFT JOIN users ru ON ru.id = r.reporter_user_id @@ -180,7 +182,7 @@ export function makeDrizzleActivityRepository(sql: Sql): ActivityRepository { if (wants("cleanup")) { branches.push(sql`( SELECT 'cleanup'::text AS source, c.id::text AS id, c.created_at AS ts, - cu.display_name AS who, c.address AS where_label, + cu.display_name AS who, false AS actorless, c.address AS where_label, NULL::text AS action, NULL::text AS event_type, c.title AS subject FROM cleanups c LEFT JOIN users cu ON cu.id = c.organizer_user_id @@ -195,7 +197,7 @@ export function makeDrizzleActivityRepository(sql: Sql): ActivityRepository { if (wants("mail_event")) { branches.push(sql`( SELECT 'mail_event'::text AS source, e.id::text AS id, e.created_at AS ts, - t.org AS who, COALESCE(t.org, t.jurisdiction_geoid) AS where_label, + t.org AS who, false AS actorless, COALESCE(t.org, t.jurisdiction_geoid) AS where_label, NULL::text AS action, e.type AS event_type, t.subject AS subject FROM mail_events e LEFT JOIN mail_threads t ON t.id = e.thread_id diff --git a/services/api/src/services/admin/activity-service.ts b/services/api/src/services/admin/activity-service.ts index f28cbefb..5f70959e 100644 --- a/services/api/src/services/admin/activity-service.ts +++ b/services/api/src/services/admin/activity-service.ts @@ -20,6 +20,7 @@ export interface ActivitySourceRecord { id: string ts: Date who: string + actorless?: boolean where: string action?: string | null eventType?: string | null @@ -220,6 +221,13 @@ export function describeAuditAction(action: string): string { return map[action] ?? action } +const PUBLIC_AUDIT_ACTIONS: ReadonlySet = new Set(["discovery.contact_suggested"]) + +function auditActor(record: ActivitySourceRecord, action: string): string { + if (record.actorless !== true) return record.who || "Operator" + return PUBLIC_AUDIT_ACTIONS.has(action) ? "A neighbor" : "System" +} + export function classifyActivity(record: ActivitySourceRecord, ref: Date): ActivityItemDTO { const ts = relativeAgo(record.ts, ref) if (record.source === "report") { @@ -245,7 +253,7 @@ export function classifyActivity(record: ActivitySourceRecord, ref: Date): Activ } const action = record.action ?? "" const kind = classifyAuditAction(action) - return item(kind, record.who || "Operator", describeAuditAction(action), record.where, ts) + return item(kind, auditActor(record, action), describeAuditAction(action), record.where, ts) } function item( diff --git a/services/api/test/integration/admin-activity.test.ts b/services/api/test/integration/admin-activity.test.ts index 59c226c8..2fef075b 100644 --- a/services/api/test/integration/admin-activity.test.ts +++ b/services/api/test/integration/admin-activity.test.ts @@ -104,6 +104,7 @@ describe.skipIf(!pg)("admin activity repository (integration: real schema)", () // The audit row carries the actor name + action. const audit = records.find((r) => r.source === "audit")! expect(audit.who).toBe("Operator") + expect(audit.actorless).toBe(false) expect(audit.action).toBe("gov_claim.approved") // The report carries the category subject + the jurisdiction place. const report = records.find((r) => r.source === "report")! @@ -115,6 +116,20 @@ describe.skipIf(!pg)("admin activity repository (integration: real schema)", () expect(mail.eventType).toBe("bounced") }) + it("marks only an audit row with no actor as actorless", async () => { + await seedOneOfEachSource() + await h.sql` + INSERT INTO audit_log (actor_id, action, target) + VALUES (NULL, 'mail.reply_published_without_text', 'mail:1') + ` + + const { records } = await repo.list(listArgs()) + expect(records).toHaveLength(5) + expect(records.filter((r) => r.actorless).map((r) => r.action)).toEqual([ + "mail.reply_published_without_text", + ]) + }) + it("sort=oldest walks the same merged feed the other way", async () => { await seedOneOfEachSource() diff --git a/services/api/test/unit/admin-activity.test.ts b/services/api/test/unit/admin-activity.test.ts index 19552c9e..53391598 100644 --- a/services/api/test/unit/admin-activity.test.ts +++ b/services/api/test/unit/admin-activity.test.ts @@ -143,6 +143,19 @@ describe("classifyActivity (per source)", () => { .who, ).toBe("Operator") }) + + it("labels an audit row with no actor as the system, or a neighbor for a public suggestion", () => { + const who = (action: string, actorless = true) => + classifyActivity( + { source: "audit", id: "a", ts: NOW, who: "", actorless, where: "", action }, + NOW, + ).who + expect(who("mail.reply_published_without_text")).toBe("System") + expect(who("mail.send_failed")).toBe("System") + expect(who("report.routed")).toBe("System") + expect(who("discovery.contact_suggested")).toBe("A neighbor") + expect(who("mail.sent", false)).toBe("Operator") + }) }) describe("activity service wiring", () => {