From 8079cbe305e331307cf3b3063f7c5515612dd249 Mon Sep 17 00:00:00 2001 From: byteful Date: Sat, 26 Sep 2026 20:39:08 -0400 Subject: [PATCH 01/14] void-aware event hours upsert, no-op unchanged re-save --- docs/volunteer-hours-integrity.md | 25 ++- .../volunteer-hours-repository.drizzle.ts | 129 +++++------- .../volunteer-hours-repository.memory.ts | 44 ++-- .../integration/volunteer-hours-pg.test.ts | 199 +++++++++++++++++- .../test/unit/volunteer-hours-service.test.ts | 162 ++++++++++++++ 5 files changed, 454 insertions(+), 105 deletions(-) diff --git a/docs/volunteer-hours-integrity.md b/docs/volunteer-hours-integrity.md index b8e33812..1cfbd82c 100644 --- a/docs/volunteer-hours-integrity.md +++ b/docs/volunteer-hours-integrity.md @@ -1,10 +1,8 @@ # Volunteer-hours integrity (civfix-backend) **Audience:** internal (engineering + operators). Not served publicly. -**Last updated:** 2026-09-13 (0168: an event's status is a clock reading over -`scheduled_at`/`ends_at`, `cleanups.ends_at` is NOT NULL, and the retired -"mark completed" action is gone - so the window cap now falls back to `ends_at` -and the daily cap keys on the event's own time zone). +**Last updated:** 2026-09-26 (a host re-save only rewrites rows it changes, and +re-crediting a voided row revives it; see "Re-saving and voided rows"). Volunteer hours are the input to `POST /v1/me/volunteer-hours/certificates`, which mints a signed, publicly verifiable PDF transcript that residents hand to schools and courts. Hours are supplied by @@ -52,6 +50,25 @@ Rule (b) is enforced with `pg_advisory_xact_lock` per credited user, taken in so per-event lock, so two hosts crediting the same attendee on two different events serialize instead of both reading a stale sum. Sorted acquisition is what keeps a batch of concurrent credits deadlock-free. +## Re-saving and voided rows + +The host editor re-submits the whole sheet, so `logEventHours` is written to be idempotent per row: + +- **An unchanged live row is not rewritten.** Same hours, same jurisdiction, not voided: the row + keeps its `logged_by_user_id`, gets no `volunteer_hours_audit` entry, moves no rollup and rings + no one. A co-host re-saving the sheet therefore never silently takes over another host's + credits; changing a row's hours does, and that change is journaled with the co-host as actor. +- **A jurisdiction move is a change.** When the event now resolves to a different jurisdiction (or + none), a re-save with the same hours takes them out of the old rollup and adds them to the new one. +- **A voided row is treated as absent.** Every void path takes the row's hours out of + `user_jurisdiction_hours` when it voids it (0065 did it by recompute), so a re-credit reads its previous value as + `NULL`: the full hours go back into the rollup, the journal records `previous_hours = NULL`, the + attendee is notified as for a first credit, `voided_at` is cleared and `created_at` moves to the + re-credit time. + +`credited` in the response still counts every entry the host submitted, so the host's +confirmation reads the same whether or not a row changed. + ## What the anomaly items look like Both signals file through the ordinary moderation create path with `dedupeOpen: true`, so a repeatedly diff --git a/services/api/src/services/volunteer-hours-repository.drizzle.ts b/services/api/src/services/volunteer-hours-repository.drizzle.ts index f02c1837..7a784ca1 100644 --- a/services/api/src/services/volunteer-hours-repository.drizzle.ts +++ b/services/api/src/services/volunteer-hours-repository.drizzle.ts @@ -223,119 +223,94 @@ export function makeDrizzleVolunteerHoursRepository(sql: Sql): VolunteerHoursRep } } - const audit = await tx< + const journal = await tx< { user_id: string; previous_hours: number | null; new_hours: number }[] >` - INSERT INTO volunteer_hours_audit - (cleanup_id, user_id, actor_user_id, previous_hours, new_hours) - SELECT - ${args.cleanupId}, t.u, ${args.actorId}, prev.hours, t.h - FROM unnest(${userIds}::uuid[], ${hoursByRow}::float8[]) AS t(u, h) - LEFT JOIN volunteer_hours prev - ON prev.cleanup_id = ${args.cleanupId} - AND prev.source = 'event' - AND prev.user_id = t.u - RETURNING - user_id, - previous_hours::float8 AS previous_hours, - new_hours::float8 AS new_hours - ` - const changed = audit.map((r) => ({ - userId: r.user_id, - hours: r.new_hours, - previousHours: r.previous_hours, - })) - - if (args.geoid === null) { - const upserted = await tx<{ user_id: string }[]>` - WITH prev AS ( - SELECT user_id, hours AS old_hours, jurisdiction_geoid AS old_geoid - FROM volunteer_hours - WHERE cleanup_id = ${args.cleanupId} - AND source = 'event' - AND user_id = ANY(${userIds}::uuid[]) - ), - upsert AS ( - INSERT INTO volunteer_hours (user_id, hours, source, cleanup_id, jurisdiction_geoid, logged_by_user_id) - SELECT t.u, t.h, 'event', ${args.cleanupId}, NULL, ${args.actorId} - FROM unnest(${userIds}::uuid[], ${hoursByRow}::float8[]) AS t(u, h) - ON CONFLICT (cleanup_id, user_id) WHERE source = 'event' - DO UPDATE SET - hours = EXCLUDED.hours, - jurisdiction_geoid = EXCLUDED.jurisdiction_geoid, - logged_by_user_id = EXCLUDED.logged_by_user_id - RETURNING user_id - ), - reversal AS ( - INSERT INTO user_jurisdiction_hours (user_id, jurisdiction_geoid, total_hours) - SELECT p.user_id, p.old_geoid, -p.old_hours - FROM upsert up - JOIN prev p ON p.user_id = up.user_id - WHERE p.old_geoid IS NOT NULL - ON CONFLICT (user_id, jurisdiction_geoid) - DO UPDATE SET total_hours = user_jurisdiction_hours.total_hours + EXCLUDED.total_hours - RETURNING user_id - ) - SELECT user_id FROM upsert - ` - const anomalies = await detectHoursAnomalies(tx, { - actorId: args.actorId, - cleanupId: args.cleanupId, - userIds, - weeklyFlagHours: args.weeklyFlagHours ?? WEEKLY_HOURS_FLAG_DEFAULT, - }) - return { credited: upserted.length, changed, anomalies } - } - const upserted = await tx<{ user_id: string }[]>` WITH prev AS ( + -- A voided row already left user_jurisdiction_hours when it was voided, so it holds + -- nothing to reverse: treating it as absent keeps the rollup delta exact on revival and + -- journals the revival as a first credit, which is what rings the attendee. SELECT user_id, hours AS old_hours, jurisdiction_geoid AS old_geoid FROM volunteer_hours WHERE cleanup_id = ${args.cleanupId} AND source = 'event' AND user_id = ANY(${userIds}::uuid[]) + AND voided_at IS NULL ), upsert AS ( INSERT INTO volunteer_hours (user_id, hours, source, cleanup_id, jurisdiction_geoid, logged_by_user_id) - SELECT t.u, t.h, 'event', ${args.cleanupId}, ${args.geoid}, ${args.actorId} + SELECT t.u, t.h, 'event', ${args.cleanupId}, ${args.geoid}::text, ${args.actorId} FROM unnest(${userIds}::uuid[], ${hoursByRow}::float8[]) AS t(u, h) ON CONFLICT (cleanup_id, user_id) WHERE source = 'event' DO UPDATE SET hours = EXCLUDED.hours, jurisdiction_geoid = EXCLUDED.jurisdiction_geoid, - logged_by_user_id = EXCLUDED.logged_by_user_id - RETURNING user_id, hours + logged_by_user_id = EXCLUDED.logged_by_user_id, + created_at = CASE + WHEN volunteer_hours.voided_at IS NULL THEN volunteer_hours.created_at + ELSE now() + END, + voided_at = NULL + -- An unchanged live row is left alone so a co-host re-saving the whole sheet does not + -- re-attribute every credit to themselves, journal a no-op, or move the rollup. + WHERE volunteer_hours.voided_at IS NOT NULL + OR volunteer_hours.hours <> EXCLUDED.hours + OR volunteer_hours.jurisdiction_geoid IS DISTINCT FROM EXCLUDED.jurisdiction_geoid + RETURNING user_id, hours, jurisdiction_geoid + ), + journal AS ( + INSERT INTO volunteer_hours_audit + (cleanup_id, user_id, actor_user_id, previous_hours, new_hours) + SELECT ${args.cleanupId}, up.user_id, ${args.actorId}, p.old_hours, up.hours + FROM upsert up + LEFT JOIN prev p ON p.user_id = up.user_id + RETURNING user_id, previous_hours, new_hours ), deltas AS ( SELECT up.user_id, - ${args.geoid}::text AS geoid, + up.jurisdiction_geoid AS geoid, up.hours - COALESCE( - CASE WHEN p.old_geoid = ${args.geoid} THEN p.old_hours END, 0 + CASE WHEN p.old_geoid = up.jurisdiction_geoid THEN p.old_hours END, 0 ) AS delta FROM upsert up LEFT JOIN prev p ON p.user_id = up.user_id + WHERE up.jurisdiction_geoid IS NOT NULL UNION ALL - -- The event moved jurisdictions since this attendee was last credited: take the stale hours - -- back out of the jurisdiction it no longer belongs to. Disjoint from the branch above (that - -- one is always the CURRENT geoid), so no (user, geoid) pair is inserted twice. + -- The event moved jurisdictions (or out of coverage) since this attendee was last + -- credited: take the stale hours back out of the jurisdiction it no longer belongs to. + -- Disjoint from the branch above (that one is always the CURRENT geoid), so no + -- (user, geoid) pair is inserted twice. SELECT p.user_id, p.old_geoid, -p.old_hours FROM upsert up JOIN prev p ON p.user_id = up.user_id - WHERE p.old_geoid IS NOT NULL AND p.old_geoid <> ${args.geoid} + WHERE p.old_geoid IS NOT NULL + AND p.old_geoid IS DISTINCT FROM up.jurisdiction_geoid + ), + rollup AS ( + INSERT INTO user_jurisdiction_hours (user_id, jurisdiction_geoid, total_hours) + SELECT d.user_id, d.geoid, d.delta FROM deltas d + ON CONFLICT (user_id, jurisdiction_geoid) + DO UPDATE SET total_hours = user_jurisdiction_hours.total_hours + EXCLUDED.total_hours ) - INSERT INTO user_jurisdiction_hours (user_id, jurisdiction_geoid, total_hours) - SELECT d.user_id, d.geoid, d.delta FROM deltas d - ON CONFLICT (user_id, jurisdiction_geoid) - DO UPDATE SET total_hours = user_jurisdiction_hours.total_hours + EXCLUDED.total_hours - RETURNING user_id + SELECT + user_id, + previous_hours::float8 AS previous_hours, + new_hours::float8 AS new_hours + FROM journal ` + const changed = journal.map((r) => ({ + userId: r.user_id, + hours: r.new_hours, + previousHours: r.previous_hours, + })) const anomalies = await detectHoursAnomalies(tx, { actorId: args.actorId, cleanupId: args.cleanupId, userIds, weeklyFlagHours: args.weeklyFlagHours ?? WEEKLY_HOURS_FLAG_DEFAULT, }) - return { credited: new Set(upserted.map((r) => r.user_id)).size, changed, anomalies } + return { credited: args.entries.length, changed, anomalies } }) }, diff --git a/services/api/src/services/volunteer-hours-repository.memory.ts b/services/api/src/services/volunteer-hours-repository.memory.ts index 8601b9a2..b4e7e0bd 100644 --- a/services/api/src/services/volunteer-hours-repository.memory.ts +++ b/services/api/src/services/volunteer-hours-repository.memory.ts @@ -85,7 +85,6 @@ export interface InMemoryVolunteerHoursRepositoryOpts { } export class InMemoryVolunteerHoursRepository implements VolunteerHoursRepository { - private readonly eventLedger = new Map() private readonly rollup = new Map() private readonly users = new Map() private readonly jurisdictionNames = new Map() @@ -118,7 +117,9 @@ export class InMemoryVolunteerHoursRepository implements VolunteerHoursRepositor voidEntry(entryId: string): void { const row = this.entries.find((e) => e.id === entryId) - if (row) row.voidedAt = this.now() + if (row === undefined || row.voidedAt !== undefined) return + row.voidedAt = this.now() + if (row.geoid !== null) this.addRollup(row.userId, row.geoid, -row.hours) } seedLegacyReportEntry(userId: string, reportId: string, geoid: string | null, hours = 0.1): string { @@ -143,41 +144,38 @@ export class InMemoryVolunteerHoursRepository implements VolunteerHoursRepositor this.assertDailyCap(args) const changed: LogEventHoursResult["changed"] = [] for (const entry of args.entries) { - const key = `${args.cleanupId}|${entry.userId}` - const previous = this.eventLedger.get(key) ?? null - changed.push({ - userId: entry.userId, - hours: entry.hours, - previousHours: previous === null ? null : previous.hours, - }) - this.eventLedger.set(key, { hours: entry.hours, geoid: args.geoid }) - if (previous !== null && previous.geoid !== null && previous.geoid !== args.geoid) { - this.addRollup(entry.userId, previous.geoid, -previous.hours) - } - if (args.geoid !== null) { - const priorHere = previous !== null && previous.geoid === args.geoid ? previous.hours : 0 - this.addRollup(entry.userId, args.geoid, entry.hours - priorHere) - } + const hours = round2(entry.hours) const existing = this.entries.find( (e) => e.source === "event" && e.cleanupId === args.cleanupId && e.userId === entry.userId, ) - if (existing !== undefined) { - existing.hours = entry.hours - existing.geoid = args.geoid - existing.loggedByUserId = args.actorId - } else { + const live = existing !== undefined && existing.voidedAt === undefined ? existing : null + if (live !== null && live.hours === hours && live.geoid === args.geoid) continue + + changed.push({ userId: entry.userId, hours, previousHours: live === null ? null : live.hours }) + if (live !== null && live.geoid !== null) this.addRollup(entry.userId, live.geoid, -live.hours) + if (args.geoid !== null) this.addRollup(entry.userId, args.geoid, hours) + + if (existing === undefined) { this.entries.push({ id: this.newId(), userId: entry.userId, source: "event", - hours: entry.hours, + hours, createdAt: this.now(), cleanupId: args.cleanupId, reportId: null, geoid: args.geoid, loggedByUserId: args.actorId, }) + continue + } + if (existing.voidedAt !== undefined) { + delete existing.voidedAt + existing.createdAt = this.now() } + existing.hours = hours + existing.geoid = args.geoid + existing.loggedByUserId = args.actorId } return Promise.resolve({ credited: args.entries.length, diff --git a/services/api/test/integration/volunteer-hours-pg.test.ts b/services/api/test/integration/volunteer-hours-pg.test.ts index 5041ae5f..735603ed 100644 --- a/services/api/test/integration/volunteer-hours-pg.test.ts +++ b/services/api/test/integration/volunteer-hours-pg.test.ts @@ -254,7 +254,7 @@ describe.skipIf(!pg)("volunteer hours (integration)", () => { expect(rows[0]!.count).toBe(0) }) - it("M21: every upsert appends an immutable audit row carrying the previous + new value", async () => { + it("M21: every change appends an immutable audit row carrying the previous + new value", async () => { const org = await newUser("Audit Org") const alice = await newUser("Audit Alice") const cohost = await newUser("Audit Cohost") @@ -322,6 +322,203 @@ describe.skipIf(!pg)("volunteer hours (integration)", () => { }) + describe("void-aware upsert and no-op re-saves", () => { + async function rollupAt(userId: string, geoid: string): Promise { + const rows = await h.sql<{ total: number }[]>` + SELECT total_hours::float8 AS total FROM user_jurisdiction_hours + WHERE user_id = ${userId} AND jurisdiction_geoid = ${geoid} + ` + return rows[0]?.total ?? 0 + } + + /** Every rollup of this user equals the SUM of their live ledger rows in that jurisdiction. */ + async function driftFor(userId: string): Promise { + const rows = await h.sql<{ geoid: string }[]>` + SELECT ujh.jurisdiction_geoid AS geoid + FROM user_jurisdiction_hours ujh + WHERE ujh.user_id = ${userId} + AND ujh.total_hours <> COALESCE(( + SELECT SUM(vh.hours) FROM volunteer_hours vh + WHERE vh.user_id = ujh.user_id + AND vh.jurisdiction_geoid = ujh.jurisdiction_geoid + AND vh.voided_at IS NULL + ), 0) + ` + return rows.map((r) => r.geoid) + } + + async function eventRow(cleanupId: string, userId: string) { + const [row] = await h.sql< + { + hours: number + geoid: string | null + logged_by: string + voided_at: Date | null + created_at: Date + }[] + >` + SELECT hours::float8 AS hours, jurisdiction_geoid AS geoid, + logged_by_user_id AS logged_by, voided_at, created_at + FROM volunteer_hours + WHERE cleanup_id = ${cleanupId} AND source = 'event' AND user_id = ${userId} + ` + return row! + } + + async function auditFor(cleanupId: string, userId: string) { + return h.sql<{ actor: string; previous_hours: number | null; new_hours: number }[]>` + SELECT actor_user_id AS actor, previous_hours::float8 AS previous_hours, + new_hours::float8 AS new_hours + FROM volunteer_hours_audit + WHERE cleanup_id = ${cleanupId} AND user_id = ${userId} + ORDER BY created_at ASC, id ASC + ` + } + + /** Voids the way every void writer must: the row goes dark AND its hours leave the rollup. */ + async function voidCredit(cleanupId: string, userId: string): Promise { + await h.sql.begin(async (tx) => { + const [row] = await tx<{ hours: string; geoid: string | null }[]>` + UPDATE volunteer_hours + SET voided_at = now(), created_at = created_at - interval '2 days' + WHERE cleanup_id = ${cleanupId} AND source = 'event' AND user_id = ${userId} + AND voided_at IS NULL + RETURNING hours::text AS hours, jurisdiction_geoid AS geoid + ` + if (row!.geoid !== null) { + await tx` + UPDATE user_jurisdiction_hours + SET total_hours = total_hours - ${row!.hours}::numeric + WHERE user_id = ${userId} AND jurisdiction_geoid = ${row!.geoid} + ` + } + }) + } + + it("re-crediting a voided row revives it: exact rollup, null previous in the result and the journal", async () => { + const org = await newUser("Revive Org") + const alice = await newUser("Revive Alice") + const cleanupId = await newCleanup(org) + const repo = makeDrizzleVolunteerHoursRepository(h.sql) + + await repo.logEventHours({ actorId: org, cleanupId, geoid: GEOID, entries: [{ userId: alice, hours: 3 }] }) + await voidCredit(cleanupId, alice) + expect(await rollupFor(alice)).toBe(0) + const voided = await eventRow(cleanupId, alice) + + const revived = await repo.logEventHours({ + actorId: org, + cleanupId, + geoid: GEOID, + entries: [{ userId: alice, hours: 3 }], + }) + expect(revived.credited).toBe(1) + expect(revived.changed).toEqual([{ userId: alice, hours: 3, previousHours: null }]) + + const row = await eventRow(cleanupId, alice) + expect(row.voided_at).toBeNull() + expect(row.created_at.getTime()).toBeGreaterThan(voided.created_at.getTime()) + expect(await rollupFor(alice)).toBe(3) + expect(await driftFor(alice)).toEqual([]) + expect((await repo.totalsFor(alice)).totalHours).toBe(3) + expect((await auditFor(cleanupId, alice)).map((r) => [r.previous_hours, r.new_hours])).toEqual([ + [null, 3], + [null, 3], + ]) + }) + + it("a voided row revived after the event moved credits only the new jurisdiction", async () => { + const org = await newUser("Revive Moved Org") + const alice = await newUser("Revive Moved Alice") + const cleanupId = await newCleanup(org) + const repo = makeDrizzleVolunteerHoursRepository(h.sql) + + await repo.logEventHours({ actorId: org, cleanupId, geoid: GEOID, entries: [{ userId: alice, hours: 2 }] }) + await voidCredit(cleanupId, alice) + await repo.logEventHours({ + actorId: org, + cleanupId, + geoid: LA_COUNTY.geoid, + entries: [{ userId: alice, hours: 2 }], + }) + + expect(await rollupAt(alice, GEOID)).toBe(0) + expect(await rollupAt(alice, LA_COUNTY.geoid)).toBe(2) + expect(await driftFor(alice)).toEqual([]) + }) + + it("an unchanged re-save by a co-host does not re-attribute, journal or move the rollup", async () => { + const org = await newUser("Resave Org") + const cohost = await newUser("Resave Cohost") + const alice = await newUser("Resave Alice") + const cleanupId = await newCleanup(org) + const repo = makeDrizzleVolunteerHoursRepository(h.sql) + + await repo.logEventHours({ actorId: org, cleanupId, geoid: GEOID, entries: [{ userId: alice, hours: 2.5 }] }) + const before = await eventRow(cleanupId, alice) + + const resaved = await repo.logEventHours({ + actorId: cohost, + cleanupId, + geoid: GEOID, + entries: [{ userId: alice, hours: 2.5 }], + }) + expect(resaved.credited).toBe(1) + expect(resaved.changed).toEqual([]) + + const after = await eventRow(cleanupId, alice) + expect(after.logged_by).toBe(org) + expect(after.created_at.getTime()).toBe(before.created_at.getTime()) + expect(await auditFor(cleanupId, alice)).toHaveLength(1) + expect(await rollupFor(alice)).toBe(2.5) + }) + + it("a co-host who changes the hours takes over the attribution and is journaled", async () => { + const org = await newUser("Change Org") + const cohost = await newUser("Change Cohost") + const alice = await newUser("Change Alice") + const cleanupId = await newCleanup(org) + const repo = makeDrizzleVolunteerHoursRepository(h.sql) + + await repo.logEventHours({ actorId: org, cleanupId, geoid: GEOID, entries: [{ userId: alice, hours: 2 }] }) + const changed = await repo.logEventHours({ + actorId: cohost, + cleanupId, + geoid: GEOID, + entries: [{ userId: alice, hours: 3 }], + }) + expect(changed.changed).toEqual([{ userId: alice, hours: 3, previousHours: 2 }]) + expect((await eventRow(cleanupId, alice)).logged_by).toBe(cohost) + expect((await auditFor(cleanupId, alice)).map((r) => [r.actor, r.previous_hours, r.new_hours])).toEqual([ + [org, null, 2], + [cohost, 2, 3], + ]) + expect(await rollupFor(alice)).toBe(3) + expect(await driftFor(alice)).toEqual([]) + }) + + it("a same-hours re-save after the event moved re-homes the hours", async () => { + const org = await newUser("Rehome Org") + const alice = await newUser("Rehome Alice") + const cleanupId = await newCleanup(org) + const repo = makeDrizzleVolunteerHoursRepository(h.sql) + + await repo.logEventHours({ actorId: org, cleanupId, geoid: GEOID, entries: [{ userId: alice, hours: 2 }] }) + const moved = await repo.logEventHours({ + actorId: org, + cleanupId, + geoid: LA_COUNTY.geoid, + entries: [{ userId: alice, hours: 2 }], + }) + expect(moved.changed).toEqual([{ userId: alice, hours: 2, previousHours: 2 }]) + expect((await eventRow(cleanupId, alice)).geoid).toBe(LA_COUNTY.geoid) + expect(await rollupAt(alice, GEOID)).toBe(0) + expect(await rollupAt(alice, LA_COUNTY.geoid)).toBe(2) + expect(await driftFor(alice)).toEqual([]) + expect((await repo.totalsFor(alice)).totalHours).toBe(2) + }) + }) + async function newUserWithFlag(name: string, flag: boolean | null): Promise { const [u] = await h.sql<{ id: string }[]>` INSERT INTO users (display_name, show_volunteer_hours) diff --git a/services/api/test/unit/volunteer-hours-service.test.ts b/services/api/test/unit/volunteer-hours-service.test.ts index 56209465..09ccb054 100644 --- a/services/api/test/unit/volunteer-hours-service.test.ts +++ b/services/api/test/unit/volunteer-hours-service.test.ts @@ -1332,3 +1332,165 @@ describe("#110: logging hours refreshes the host console", () => { ).rejects.toMatchObject({ code: "VALIDATION" }) }) }) + +describe("volunteer hours: void-aware re-crediting and no-op re-saves", () => { + const doneEvent: CleanupHoursView = { + organizerUserId: HOST, + status: "done", + jurisdictionGeoid: GEOID_A, + title: "Ocean Beach sweep", + scheduledAt: SCHEDULED_AT, + endsAt: ENDS_AT, + completedAt: COMPLETED_AT, + timezone: null, + } + + function makeClock(start: string): { now: () => Date; advance: (ms: number) => void } { + let at = new Date(start).getTime() + return { + now: () => new Date(at), + advance: (ms) => { + at += ms + }, + } + } + + async function onlyEntry(repo: InMemoryVolunteerHoursRepository, userId: string) { + const page = await repo.listEntries({ userId, cursor: null, limit: 50 }) + expect(page.items).toHaveLength(1) + return page.items[0]! + } + + it("voiding takes the row's hours back out of its jurisdiction's rollup, once", async () => { + const repo = new InMemoryVolunteerHoursRepository() + await repo.logEventHours({ actorId: HOST, cleanupId: CLEANUP, geoid: GEOID_A, entries: flat([BOB], 3) }) + const entry = await onlyEntry(repo, BOB) + + repo.voidEntry(entry.id) + repo.voidEntry(entry.id) + + const totals = await repo.totalsFor(BOB) + expect(totals.totalHours).toBe(0) + expect(totals.byJurisdiction).toEqual([]) + const board = await makeService({ repo, view: null }).leaderboard(GEOID_A, { geoid: GEOID_A }) + expect(board.entries).toEqual([]) + }) + + it("re-crediting a voided row revives it as a first credit: exact rollup, null previous, a bell", async () => { + const clock = makeClock("2026-07-06T10:00:00.000Z") + const repo = new InMemoryVolunteerHoursRepository({ now: clock.now }) + repo.seedJurisdiction(GEOID_A, "San Francisco") + const notifier = makeNotifier() + const service = makeService({ repo, view: doneEvent, members: [HOST, BOB], notifier }) + + await service.logEventHours({ cleanupId: CLEANUP, actorId: HOST, entries: flat([BOB], 3) }) + const original = await onlyEntry(repo, BOB) + repo.voidEntry(original.id) + notifier.sent.length = 0 + clock.advance(3_600_000) + + const repoResult = await repo.logEventHours({ + actorId: HOST, + cleanupId: CLEANUP, + geoid: GEOID_A, + entries: flat([BOB], 3), + }) + expect(repoResult.changed).toEqual([{ userId: BOB, hours: 3, previousHours: null }]) + + const totals = await repo.totalsFor(BOB) + expect(totals.totalHours).toBe(3) + expect(totals.byJurisdiction).toEqual([{ geoid: GEOID_A, name: "San Francisco", hours: 3 }]) + const revived = await onlyEntry(repo, BOB) + expect(revived.id).toBe(original.id) + expect(revived.createdAt).toEqual(clock.now()) + + repo.voidEntry(revived.id) + const result = await service.logEventHours({ + cleanupId: CLEANUP, + actorId: HOST, + entries: flat([BOB], 2), + }) + expect(result.credited).toBe(1) + expect(notifier.sent.map((s) => [s.userId, s.vars.hours])).toEqual([[BOB, 2]]) + expect((await repo.totalsFor(BOB)).byJurisdiction).toEqual([ + { geoid: GEOID_A, name: "San Francisco", hours: 2 }, + ]) + }) + + it("an unchanged re-save by a co-host neither re-attributes the credit nor journals it", async () => { + const repo = new InMemoryVolunteerHoursRepository() + const notifier = makeNotifier() + const service = makeService({ + repo, + view: doneEvent, + members: [HOST, BOB, CAROL], + cohosts: [CAROL], + notifier, + }) + await service.logEventHours({ cleanupId: CLEANUP, actorId: HOST, entries: flat([BOB], 2) }) + notifier.sent.length = 0 + + const repoResult = await repo.logEventHours({ + actorId: CAROL, + cleanupId: CLEANUP, + geoid: GEOID_A, + entries: flat([BOB], 2), + }) + expect(repoResult).toMatchObject({ credited: 1, changed: [] }) + + const result = await service.logEventHours({ + cleanupId: CLEANUP, + actorId: CAROL, + entries: flat([BOB], 2), + }) + expect(result.credited).toBe(1) + expect(notifier.sent).toEqual([]) + expect((await onlyEntry(repo, BOB)).creditedBy?.id).toBe(HOST) + expect((await repo.totalsFor(BOB)).totalHours).toBe(2) + }) + + it("a co-host who CHANGES the hours takes over the attribution", async () => { + const repo = new InMemoryVolunteerHoursRepository() + const service = makeService({ + repo, + view: doneEvent, + members: [HOST, BOB, CAROL], + cohosts: [CAROL], + }) + await service.logEventHours({ cleanupId: CLEANUP, actorId: HOST, entries: flat([BOB], 2) }) + + const repoResult = await repo.logEventHours({ + actorId: CAROL, + cleanupId: CLEANUP, + geoid: GEOID_A, + entries: flat([BOB], 3.5), + }) + expect(repoResult.changed).toEqual([{ userId: BOB, hours: 3.5, previousHours: 2 }]) + expect((await onlyEntry(repo, BOB)).creditedBy?.id).toBe(CAROL) + expect((await repo.totalsFor(BOB)).totalHours).toBe(3.5) + }) + + it("a re-save after the event moved jurisdictions re-homes the unchanged hours", async () => { + const repo = new InMemoryVolunteerHoursRepository() + repo.seedJurisdiction(GEOID_A, "San Francisco") + repo.seedJurisdiction(GEOID_B, "Oakland") + await repo.logEventHours({ actorId: HOST, cleanupId: CLEANUP, geoid: GEOID_A, entries: flat([BOB], 2) }) + + const moved = await repo.logEventHours({ + actorId: HOST, + cleanupId: CLEANUP, + geoid: GEOID_B, + entries: flat([BOB], 2), + }) + expect(moved.changed).toEqual([{ userId: BOB, hours: 2, previousHours: 2 }]) + let totals = await repo.totalsFor(BOB) + expect(totals.totalHours).toBe(2) + expect(totals.byJurisdiction).toEqual([{ geoid: GEOID_B, name: "Oakland", hours: 2 }]) + expect((await onlyEntry(repo, BOB)).jurisdictionGeoid).toBe(GEOID_B) + + await repo.logEventHours({ actorId: HOST, cleanupId: CLEANUP, geoid: null, entries: flat([BOB], 2) }) + totals = await repo.totalsFor(BOB) + expect(totals.totalHours).toBe(2) + expect(totals.byJurisdiction).toEqual([]) + }) +}) From 84dc4a9f392ede47dcb23e311da4be16f4b2610c Mon Sep 17 00:00:00 2001 From: byteful Date: Sat, 26 Sep 2026 20:45:28 -0400 Subject: [PATCH 02/14] journal a voided-row revival as previous 0 --- docs/volunteer-hours-integrity.md | 11 ++++++----- .../services/volunteer-hours-repository.drizzle.ts | 13 ++++++++----- .../services/volunteer-hours-repository.memory.ts | 6 +++++- .../api/test/integration/volunteer-hours-pg.test.ts | 6 +++--- .../api/test/unit/volunteer-hours-service.test.ts | 4 ++-- 5 files changed, 24 insertions(+), 16 deletions(-) diff --git a/docs/volunteer-hours-integrity.md b/docs/volunteer-hours-integrity.md index 1cfbd82c..c84ad4fc 100644 --- a/docs/volunteer-hours-integrity.md +++ b/docs/volunteer-hours-integrity.md @@ -60,11 +60,12 @@ The host editor re-submits the whole sheet, so `logEventHours` is written to be credits; changing a row's hours does, and that change is journaled with the co-host as actor. - **A jurisdiction move is a change.** When the event now resolves to a different jurisdiction (or none), a re-save with the same hours takes them out of the old rollup and adds them to the new one. -- **A voided row is treated as absent.** Every void path takes the row's hours out of - `user_jurisdiction_hours` when it voids it (0065 did it by recompute), so a re-credit reads its previous value as - `NULL`: the full hours go back into the rollup, the journal records `previous_hours = NULL`, the - attendee is notified as for a first credit, `voided_at` is cleared and `created_at` moves to the - re-credit time. +- **A voided row holds 0 hours.** Every void path takes the row's hours out of + `user_jurisdiction_hours` when it voids it (0065 did it by recompute), so a re-credit reads its + previous value as `0` in no jurisdiction: the full hours go back into the rollup, the journal + records `previous_hours = 0` (not `NULL`, which 0053 reserves for a genuine first credit, so the + journal still shows the attendee was credited before), the attendee is notified because the + hours went up, `voided_at` is cleared and `created_at` moves to the re-credit time. `credited` in the response still counts every entry the host submitted, so the host's confirmation reads the same whether or not a row changed. diff --git a/services/api/src/services/volunteer-hours-repository.drizzle.ts b/services/api/src/services/volunteer-hours-repository.drizzle.ts index 7a784ca1..10276618 100644 --- a/services/api/src/services/volunteer-hours-repository.drizzle.ts +++ b/services/api/src/services/volunteer-hours-repository.drizzle.ts @@ -227,15 +227,18 @@ export function makeDrizzleVolunteerHoursRepository(sql: Sql): VolunteerHoursRep { user_id: string; previous_hours: number | null; new_hours: number }[] >` WITH prev AS ( - -- A voided row already left user_jurisdiction_hours when it was voided, so it holds - -- nothing to reverse: treating it as absent keeps the rollup delta exact on revival and - -- journals the revival as a first credit, which is what rings the attendee. - SELECT user_id, hours AS old_hours, jurisdiction_geoid AS old_geoid + -- A voided row already left user_jurisdiction_hours when it was voided, so it counts as + -- holding 0 hours in no jurisdiction: the revival's rollup delta is its full hours. It + -- still exists, so the journal records previous_hours = 0, keeping NULL (0053) for a + -- genuine first credit. + SELECT + user_id, + CASE WHEN voided_at IS NULL THEN hours ELSE 0 END AS old_hours, + CASE WHEN voided_at IS NULL THEN jurisdiction_geoid END AS old_geoid FROM volunteer_hours WHERE cleanup_id = ${args.cleanupId} AND source = 'event' AND user_id = ANY(${userIds}::uuid[]) - AND voided_at IS NULL ), upsert AS ( INSERT INTO volunteer_hours (user_id, hours, source, cleanup_id, jurisdiction_geoid, logged_by_user_id) diff --git a/services/api/src/services/volunteer-hours-repository.memory.ts b/services/api/src/services/volunteer-hours-repository.memory.ts index b4e7e0bd..36c2f890 100644 --- a/services/api/src/services/volunteer-hours-repository.memory.ts +++ b/services/api/src/services/volunteer-hours-repository.memory.ts @@ -151,7 +151,11 @@ export class InMemoryVolunteerHoursRepository implements VolunteerHoursRepositor const live = existing !== undefined && existing.voidedAt === undefined ? existing : null if (live !== null && live.hours === hours && live.geoid === args.geoid) continue - changed.push({ userId: entry.userId, hours, previousHours: live === null ? null : live.hours }) + changed.push({ + userId: entry.userId, + hours, + previousHours: existing === undefined ? null : (live?.hours ?? 0), + }) if (live !== null && live.geoid !== null) this.addRollup(entry.userId, live.geoid, -live.hours) if (args.geoid !== null) this.addRollup(entry.userId, args.geoid, hours) diff --git a/services/api/test/integration/volunteer-hours-pg.test.ts b/services/api/test/integration/volunteer-hours-pg.test.ts index 735603ed..e7985357 100644 --- a/services/api/test/integration/volunteer-hours-pg.test.ts +++ b/services/api/test/integration/volunteer-hours-pg.test.ts @@ -395,7 +395,7 @@ describe.skipIf(!pg)("volunteer hours (integration)", () => { }) } - it("re-crediting a voided row revives it: exact rollup, null previous in the result and the journal", async () => { + it("re-crediting a voided row revives it: exact rollup, previous 0 (not NULL) in the result and the journal", async () => { const org = await newUser("Revive Org") const alice = await newUser("Revive Alice") const cleanupId = await newCleanup(org) @@ -413,7 +413,7 @@ describe.skipIf(!pg)("volunteer hours (integration)", () => { entries: [{ userId: alice, hours: 3 }], }) expect(revived.credited).toBe(1) - expect(revived.changed).toEqual([{ userId: alice, hours: 3, previousHours: null }]) + expect(revived.changed).toEqual([{ userId: alice, hours: 3, previousHours: 0 }]) const row = await eventRow(cleanupId, alice) expect(row.voided_at).toBeNull() @@ -423,7 +423,7 @@ describe.skipIf(!pg)("volunteer hours (integration)", () => { expect((await repo.totalsFor(alice)).totalHours).toBe(3) expect((await auditFor(cleanupId, alice)).map((r) => [r.previous_hours, r.new_hours])).toEqual([ [null, 3], - [null, 3], + [0, 3], ]) }) diff --git a/services/api/test/unit/volunteer-hours-service.test.ts b/services/api/test/unit/volunteer-hours-service.test.ts index 09ccb054..c7e07710 100644 --- a/services/api/test/unit/volunteer-hours-service.test.ts +++ b/services/api/test/unit/volunteer-hours-service.test.ts @@ -1376,7 +1376,7 @@ describe("volunteer hours: void-aware re-crediting and no-op re-saves", () => { expect(board.entries).toEqual([]) }) - it("re-crediting a voided row revives it as a first credit: exact rollup, null previous, a bell", async () => { + it("re-crediting a voided row revives it: exact rollup, previous 0 (not a first credit), a bell", async () => { const clock = makeClock("2026-07-06T10:00:00.000Z") const repo = new InMemoryVolunteerHoursRepository({ now: clock.now }) repo.seedJurisdiction(GEOID_A, "San Francisco") @@ -1395,7 +1395,7 @@ describe("volunteer hours: void-aware re-crediting and no-op re-saves", () => { geoid: GEOID_A, entries: flat([BOB], 3), }) - expect(repoResult.changed).toEqual([{ userId: BOB, hours: 3, previousHours: null }]) + expect(repoResult.changed).toEqual([{ userId: BOB, hours: 3, previousHours: 0 }]) const totals = await repo.totalsFor(BOB) expect(totals.totalHours).toBe(3) From 081805d5a6dc36961cc781ebcf0f7a562359b1e3 Mon Sep 17 00:00:00 2001 From: byteful Date: Sat, 26 Sep 2026 20:54:07 -0400 Subject: [PATCH 03/14] extract volunteer hours credit core, rules and notify helper --- docs/volunteer-hours-integrity.md | 8 +- .../api/src/routes/volunteer-hours-wiring.ts | 35 ++++ .../api/src/routes/volunteer-hours.routes.ts | 32 +--- .../volunteer-hours-credit.drizzle.ts | 138 +++++++++++++++ .../volunteer-hours-repository.drizzle.ts | 138 +++------------ .../volunteer-hours-repository.memory.ts | 85 ++++++---- .../api/src/services/volunteer-hours-rules.ts | 80 +++++++++ .../src/services/volunteer-hours-service.ts | 157 ++++++++---------- .../volunteer-hours-integrity-pg.test.ts | 2 +- 9 files changed, 401 insertions(+), 274 deletions(-) create mode 100644 services/api/src/routes/volunteer-hours-wiring.ts create mode 100644 services/api/src/services/volunteer-hours-credit.drizzle.ts create mode 100644 services/api/src/services/volunteer-hours-rules.ts diff --git a/docs/volunteer-hours-integrity.md b/docs/volunteer-hours-integrity.md index c84ad4fc..4b635db2 100644 --- a/docs/volunteer-hours-integrity.md +++ b/docs/volunteer-hours-integrity.md @@ -26,11 +26,11 @@ Every step was legal, every hour landed on a transcript, and nothing was flagged | # | Rule | Where | Effect when tripped | |---|---|---|---| -| a | A credit is capped at the event's own window: `COALESCE(completed_at, ends_at) - scheduled_at` **+ 1 h grace**, never above `MAX_EVENT_HOURS` (24) | `volunteer-hours-service.ts` (`creditableHoursForEvent`, `eventDurationMs`) | `VALIDATION` naming the event's real length | -| b | One person may hold at most **24 h across every event scheduled on the same local calendar day**, read in the event's own IANA zone (`cleanups.timezone`, falling back to `DEFAULT_EVENT_TIME_ZONE`) | `volunteer-hours-repository.drizzle.ts`, inside the crediting transaction under a per-user advisory lock | `CONFLICT` naming what they already hold | +| a | A credit is capped at the event's own window: `COALESCE(completed_at, ends_at) - scheduled_at` **+ 1 h grace**, never above `MAX_EVENT_HOURS` (24) | `volunteer-hours-rules.ts` (`creditableHoursForEvent`, `eventDurationMs`, checked by `assertCreditableEventHours`) | `VALIDATION` naming the event's real length | +| b | One person may hold at most **24 h across every event scheduled on the same local calendar day**, read in the event's own IANA zone (`cleanups.timezone`, falling back to `DEFAULT_EVENT_TIME_ZONE`) | `sameDayEventHours` (`volunteer-hours-credit.drizzle.ts`) and `assertWithinDailyHoursCap` (`volunteer-hours-rules.ts`), inside the crediting transaction under a per-user advisory lock | `CONFLICT` naming what they already hold | | c | Reciprocity inside one event is refused **in both directions** — whoever credits second is the one refused (covers the organizer ↔ promoted-co-host swap) | same transaction | `CONFLICT` | | d | Two anomaly signals are **flagged to moderation, never blocked**: >60 h credited in a rolling 7 days, and A↔B crediting each other on *different* events within 30 days | detected in the transaction, filed by `volunteer-hours-anomaly.ts` | an open `moderation_items` row, `kind = 'pattern'`, `subject_type = 'user'` | -| e | Hours can be logged only **once the event has ended** (`now >= ends_at`), and never against an event whose window is **shorter than 15 minutes** | `volunteer-hours-service.ts` (`hasEventEnded`, then the `MIN_EVENT_DURATION_MS` check in `logEventHours`) | `CONFLICT` | +| e | Hours can be logged only **once the event has ended** (`now >= ends_at`), and never against an event whose window is **shorter than 15 minutes** | `assertEventCreditable` in `volunteer-hours-rules.ts` (`hasEventEnded`, then the `MIN_EVENT_DURATION_MS` check) | `CONFLICT` | Rule (e)'s 15-minute floor is really enforced at write time: `assertEventWindow` (`host/event-fields.ts`) refuses any create or update whose `ends_at` is less than @@ -49,6 +49,8 @@ by `assertEventWindow`'s 15 minute / 24 hour duration limits. Rule (b) is enforced with `pg_advisory_xact_lock` per credited user, taken in sorted order after the per-event lock, so two hosts crediting the same attendee on two different events serialize instead of both reading a stale sum. Sorted acquisition is what keeps a batch of concurrent credits deadlock-free. +Both locks are taken by `lockEventCredits` (`volunteer-hours-credit.drizzle.ts`), and the shared +upsert there, `writeEventCredits`, relies on the caller already holding them. ## Re-saving and voided rows diff --git a/services/api/src/routes/volunteer-hours-wiring.ts b/services/api/src/routes/volunteer-hours-wiring.ts new file mode 100644 index 00000000..6337dfcb --- /dev/null +++ b/services/api/src/routes/volunteer-hours-wiring.ts @@ -0,0 +1,35 @@ +import { NO_HOST_STANDING } from "@civfix/shared/host" +import type { Container } from "../di.js" +import { makeDrizzleCleanupRepository } from "../services/cleanup-repository.drizzle.js" +import { hostStandingOf } from "../services/host/host-standing.js" +import type { CleanupHoursLookup } from "../services/volunteer-hours-service.js" + +export function makeCleanupHoursLookup(container: Container): CleanupHoursLookup { + return { + async load(cleanupId: string) { + const record = await makeDrizzleCleanupRepository(container.getDb().sql).findCleanupById( + cleanupId, + null, + ) + if (!record) return null + return { + organizerUserId: record.organizerUserId, + status: record.status, + jurisdictionGeoid: record.jurisdictionGeoid, + title: record.title, + scheduledAt: record.scheduledAt, + endsAt: record.endsAt, + completedAt: record.completedAt, + timezone: record.timezone, + } + }, + listMemberIds: (cleanupId: string, limit: number) => + makeDrizzleCleanupRepository(container.getDb().sql).listMemberIds(cleanupId, limit), + roleOf: (cleanupId: string, userId: string) => + makeDrizzleCleanupRepository(container.getDb().sql).roleOf(cleanupId, userId), + async standingOf(cleanupId: string, userId: string) { + const resolved = await hostStandingOf(container.getDb().sql, cleanupId, userId) + return resolved?.standing ?? NO_HOST_STANDING + }, + } +} diff --git a/services/api/src/routes/volunteer-hours.routes.ts b/services/api/src/routes/volunteer-hours.routes.ts index 74cdfe66..e8faf089 100644 --- a/services/api/src/routes/volunteer-hours.routes.ts +++ b/services/api/src/routes/volunteer-hours.routes.ts @@ -26,15 +26,13 @@ import { import { toHoursAnomalyModerationItem } from "../services/volunteer-hours-anomaly.js" import { makeModerationService } from "../services/admin/moderation-service.js" import { makeDrizzleModerationRepository } from "../services/admin/moderation-repository.drizzle.js" -import { makeDrizzleCleanupRepository } from "../services/cleanup-repository.drizzle.js" import { makeInsightsGeneration } from "../services/host/host-analytics-cache.js" import { MEDIA_GET_URL_TTL_SEC } from "../services/media-intake-service.js" -import { hostStandingOf } from "../services/host/host-standing.js" -import { NO_HOST_STANDING } from "@civfix/shared/host" import { makeRouteNotificationService } from "../services/route-notifier.js" import type { NotificationService } from "../services/notification-service.js" import { route } from "../versioning/route.js" import { parse } from "./_validate.js" +import { makeCleanupHoursLookup } from "./volunteer-hours-wiring.js" export interface VolunteerHoursOverrides { repo: VolunteerHoursRepository @@ -87,33 +85,7 @@ export async function registerVolunteerHoursRoutes( function cleanupLookup(): CleanupHoursLookup { const overrides = app.volunteerOverrides if (overrides?.cleanups) return overrides.cleanups - return { - async load(cleanupId: string) { - const record = await makeDrizzleCleanupRepository(container.getDb().sql).findCleanupById( - cleanupId, - null, - ) - if (!record) return null - return { - organizerUserId: record.organizerUserId, - status: record.status, - jurisdictionGeoid: record.jurisdictionGeoid, - title: record.title, - scheduledAt: record.scheduledAt, - endsAt: record.endsAt, - completedAt: record.completedAt, - timezone: record.timezone, - } - }, - listMemberIds: (cleanupId: string, limit: number) => - makeDrizzleCleanupRepository(container.getDb().sql).listMemberIds(cleanupId, limit), - roleOf: (cleanupId: string, userId: string) => - makeDrizzleCleanupRepository(container.getDb().sql).roleOf(cleanupId, userId), - async standingOf(cleanupId: string, userId: string) { - const resolved = await hostStandingOf(container.getDb().sql, cleanupId, userId) - return resolved?.standing ?? NO_HOST_STANDING - }, - } + return makeCleanupHoursLookup(container) } function notifier(): Pick | undefined { diff --git a/services/api/src/services/volunteer-hours-credit.drizzle.ts b/services/api/src/services/volunteer-hours-credit.drizzle.ts new file mode 100644 index 00000000..f4cfe398 --- /dev/null +++ b/services/api/src/services/volunteer-hours-credit.drizzle.ts @@ -0,0 +1,138 @@ +import type { TransactionSql } from "../db/client.js" +import { DEFAULT_EVENT_TIME_ZONE } from "./host/event-fields.js" +import type { EventCreditChange, EventCreditWrite } from "./volunteer-hours-service.js" + +// Lock order is the 0065 rule shared by every volunteer_hours writer: the event lock, then the +// user locks in sorted order, before any row is read or written. +export async function lockEventCredits( + tx: TransactionSql, + cleanupId: string, + userIds: readonly string[], +): Promise { + await tx`SELECT pg_advisory_xact_lock(hashtext('volunteer_event:' || ${cleanupId}))` + const lockIds = [...new Set(userIds)].sort() + if (lockIds.length > 0) { + await tx` + SELECT pg_advisory_xact_lock(hashtext('volunteer_user:' || u)) + FROM unnest(${lockIds}::uuid[]) AS t(u) + ORDER BY u + ` + } +} + +export async function sameDayEventHours( + tx: TransactionSql, + cleanupId: string, + userIds: readonly string[], +): Promise> { + const rows = await tx<{ user_id: string; hours: number }[]>` + SELECT vh.user_id, COALESCE(SUM(vh.hours), 0)::float8 AS hours + FROM volunteer_hours vh + JOIN cleanups c ON c.id = vh.cleanup_id + WHERE vh.user_id = ANY(${userIds}::uuid[]) + AND vh.source = 'event' + AND vh.voided_at IS NULL + AND vh.cleanup_id <> ${cleanupId} + AND (c.scheduled_at AT TIME ZONE COALESCE(c.timezone, ${DEFAULT_EVENT_TIME_ZONE}))::date = ( + SELECT (scheduled_at AT TIME ZONE COALESCE(timezone, ${DEFAULT_EVENT_TIME_ZONE}))::date + FROM cleanups WHERE id = ${cleanupId} + ) + GROUP BY vh.user_id + ` + return new Map(rows.map((r) => [r.user_id, r.hours])) +} + +// The prev CTE reads each row before the upsert rewrites it, so the caller must already hold +// lockEventCredits for this event and these users; otherwise a concurrent writer's change is lost +// from the journal and the user_jurisdiction_hours delta. +export async function writeEventCredits( + tx: TransactionSql, + write: EventCreditWrite, +): Promise { + const userIds = write.entries.map((e) => e.userId) + const hoursByRow = write.entries.map((e) => e.hours) + const journal = await tx< + { user_id: string; previous_hours: number | null; new_hours: number }[] + >` + WITH prev AS ( + -- A voided row already left user_jurisdiction_hours when it was voided, so it counts as + -- holding 0 hours in no jurisdiction: the revival's rollup delta is its full hours. It + -- still exists, so the journal records previous_hours = 0, keeping NULL (0053) for a + -- genuine first credit. + SELECT + user_id, + CASE WHEN voided_at IS NULL THEN hours ELSE 0 END AS old_hours, + CASE WHEN voided_at IS NULL THEN jurisdiction_geoid END AS old_geoid + FROM volunteer_hours + WHERE cleanup_id = ${write.cleanupId} + AND source = 'event' + AND user_id = ANY(${userIds}::uuid[]) + ), + upsert AS ( + INSERT INTO volunteer_hours (user_id, hours, source, cleanup_id, jurisdiction_geoid, logged_by_user_id) + SELECT t.u, t.h, 'event', ${write.cleanupId}, ${write.geoid}::text, ${write.loggedByUserId} + FROM unnest(${userIds}::uuid[], ${hoursByRow}::float8[]) AS t(u, h) + ON CONFLICT (cleanup_id, user_id) WHERE source = 'event' + DO UPDATE SET + hours = EXCLUDED.hours, + jurisdiction_geoid = EXCLUDED.jurisdiction_geoid, + logged_by_user_id = EXCLUDED.logged_by_user_id, + created_at = CASE + WHEN volunteer_hours.voided_at IS NULL THEN volunteer_hours.created_at + ELSE now() + END, + voided_at = NULL + -- An unchanged live row is left alone so a co-host re-saving the whole sheet does not + -- re-attribute every credit to themselves, journal a no-op, or move the rollup. + WHERE volunteer_hours.voided_at IS NOT NULL + OR volunteer_hours.hours <> EXCLUDED.hours + OR volunteer_hours.jurisdiction_geoid IS DISTINCT FROM EXCLUDED.jurisdiction_geoid + RETURNING user_id, hours, jurisdiction_geoid + ), + journal AS ( + INSERT INTO volunteer_hours_audit + (cleanup_id, user_id, actor_user_id, previous_hours, new_hours) + SELECT ${write.cleanupId}, up.user_id, ${write.actorId}, p.old_hours, up.hours + FROM upsert up + LEFT JOIN prev p ON p.user_id = up.user_id + RETURNING user_id, previous_hours, new_hours + ), + deltas AS ( + SELECT + up.user_id, + up.jurisdiction_geoid AS geoid, + up.hours - COALESCE( + CASE WHEN p.old_geoid = up.jurisdiction_geoid THEN p.old_hours END, 0 + ) AS delta + FROM upsert up + LEFT JOIN prev p ON p.user_id = up.user_id + WHERE up.jurisdiction_geoid IS NOT NULL + UNION ALL + -- The event moved jurisdictions (or out of coverage) since this attendee was last + -- credited: take the stale hours back out of the jurisdiction it no longer belongs to. + -- Disjoint from the branch above (that one is always the CURRENT geoid), so no + -- (user, geoid) pair is inserted twice. + SELECT p.user_id, p.old_geoid, -p.old_hours + FROM upsert up + JOIN prev p ON p.user_id = up.user_id + WHERE p.old_geoid IS NOT NULL + AND p.old_geoid IS DISTINCT FROM up.jurisdiction_geoid + ), + rollup AS ( + INSERT INTO user_jurisdiction_hours (user_id, jurisdiction_geoid, total_hours) + SELECT d.user_id, d.geoid, d.delta FROM deltas d + ON CONFLICT (user_id, jurisdiction_geoid) + DO UPDATE SET total_hours = user_jurisdiction_hours.total_hours + EXCLUDED.total_hours + ) + SELECT + user_id, + previous_hours::float8 AS previous_hours, + new_hours::float8 AS new_hours + FROM journal + ` + return journal.map((r) => ({ + userId: r.user_id, + hours: r.new_hours, + previousHours: r.previous_hours, + })) +} diff --git a/services/api/src/services/volunteer-hours-repository.drizzle.ts b/services/api/src/services/volunteer-hours-repository.drizzle.ts index 10276618..a7913c33 100644 --- a/services/api/src/services/volunteer-hours-repository.drizzle.ts +++ b/services/api/src/services/volunteer-hours-repository.drizzle.ts @@ -9,7 +9,12 @@ import type { Queryable, Sql } from "../db/client.js" import { encodeTimeCursor, pageWith } from "../db/cursor-helpers.js" import { blockedPairExpr, hiddenIdentity } from "./hidden-identity.js" import { servedKeyExpr } from "./media-served-key.js" -import { DEFAULT_EVENT_TIME_ZONE } from "./host/event-fields.js" +import { + lockEventCredits, + sameDayEventHours, + writeEventCredits, +} from "./volunteer-hours-credit.drizzle.js" +import { assertWithinDailyHoursCap } from "./volunteer-hours-rules.js" import { DAILY_HOURS_CAP, EVENT_HOURS_MEMBER_CAP, @@ -170,17 +175,8 @@ export function makeDrizzleVolunteerHoursRepository(sql: Sql): VolunteerHoursRep async logEventHours(args: LogEventHoursArgs): Promise { if (args.entries.length === 0) return { credited: 0, changed: [], anomalies: [] } const userIds = args.entries.map((e) => e.userId) - const hoursByRow = args.entries.map((e) => e.hours) return sql.begin(async (tx) => { - await tx`SELECT pg_advisory_xact_lock(hashtext('volunteer_event:' || ${args.cleanupId}))` - const lockIds = [...new Set(userIds)].sort() - if (lockIds.length > 0) { - await tx` - SELECT pg_advisory_xact_lock(hashtext('volunteer_user:' || u)) - FROM unnest(${lockIds}::uuid[]) AS t(u) - ORDER BY u - ` - } + await lockEventCredits(tx, args.cleanupId, userIds) const reciprocal = await tx<{ logged_by_user_id: string }[]>` SELECT DISTINCT logged_by_user_id @@ -198,115 +194,19 @@ export function makeDrizzleVolunteerHoursRepository(sql: Sql): VolunteerHoursRep ) } - const sameDay = await tx<{ user_id: string; hours: number }[]>` - SELECT vh.user_id, COALESCE(SUM(vh.hours), 0)::float8 AS hours - FROM volunteer_hours vh - JOIN cleanups c ON c.id = vh.cleanup_id - WHERE vh.user_id = ANY(${userIds}::uuid[]) - AND vh.source = 'event' - AND vh.voided_at IS NULL - AND vh.cleanup_id <> ${args.cleanupId} - AND (c.scheduled_at AT TIME ZONE COALESCE(c.timezone, ${DEFAULT_EVENT_TIME_ZONE}))::date = ( - SELECT (scheduled_at AT TIME ZONE COALESCE(timezone, ${DEFAULT_EVENT_TIME_ZONE}))::date - FROM cleanups WHERE id = ${args.cleanupId} - ) - GROUP BY vh.user_id - ` - const dailyCapHours = args.dailyCapHours ?? DAILY_HOURS_CAP - const heldByUser = new Map(sameDay.map((r) => [r.user_id, r.hours])) - for (const entry of args.entries) { - const held = heldByUser.get(entry.userId) ?? 0 - if (held + entry.hours > dailyCapHours) { - throw AppError.conflict( - `That attendee already holds ${Math.round(held * 100) / 100} h for events on this date; the daily limit is ${dailyCapHours} h.`, - ) - } - } + assertWithinDailyHoursCap( + args.entries, + await sameDayEventHours(tx, args.cleanupId, userIds), + args.dailyCapHours ?? DAILY_HOURS_CAP, + ) - const journal = await tx< - { user_id: string; previous_hours: number | null; new_hours: number }[] - >` - WITH prev AS ( - -- A voided row already left user_jurisdiction_hours when it was voided, so it counts as - -- holding 0 hours in no jurisdiction: the revival's rollup delta is its full hours. It - -- still exists, so the journal records previous_hours = 0, keeping NULL (0053) for a - -- genuine first credit. - SELECT - user_id, - CASE WHEN voided_at IS NULL THEN hours ELSE 0 END AS old_hours, - CASE WHEN voided_at IS NULL THEN jurisdiction_geoid END AS old_geoid - FROM volunteer_hours - WHERE cleanup_id = ${args.cleanupId} - AND source = 'event' - AND user_id = ANY(${userIds}::uuid[]) - ), - upsert AS ( - INSERT INTO volunteer_hours (user_id, hours, source, cleanup_id, jurisdiction_geoid, logged_by_user_id) - SELECT t.u, t.h, 'event', ${args.cleanupId}, ${args.geoid}::text, ${args.actorId} - FROM unnest(${userIds}::uuid[], ${hoursByRow}::float8[]) AS t(u, h) - ON CONFLICT (cleanup_id, user_id) WHERE source = 'event' - DO UPDATE SET - hours = EXCLUDED.hours, - jurisdiction_geoid = EXCLUDED.jurisdiction_geoid, - logged_by_user_id = EXCLUDED.logged_by_user_id, - created_at = CASE - WHEN volunteer_hours.voided_at IS NULL THEN volunteer_hours.created_at - ELSE now() - END, - voided_at = NULL - -- An unchanged live row is left alone so a co-host re-saving the whole sheet does not - -- re-attribute every credit to themselves, journal a no-op, or move the rollup. - WHERE volunteer_hours.voided_at IS NOT NULL - OR volunteer_hours.hours <> EXCLUDED.hours - OR volunteer_hours.jurisdiction_geoid IS DISTINCT FROM EXCLUDED.jurisdiction_geoid - RETURNING user_id, hours, jurisdiction_geoid - ), - journal AS ( - INSERT INTO volunteer_hours_audit - (cleanup_id, user_id, actor_user_id, previous_hours, new_hours) - SELECT ${args.cleanupId}, up.user_id, ${args.actorId}, p.old_hours, up.hours - FROM upsert up - LEFT JOIN prev p ON p.user_id = up.user_id - RETURNING user_id, previous_hours, new_hours - ), - deltas AS ( - SELECT - up.user_id, - up.jurisdiction_geoid AS geoid, - up.hours - COALESCE( - CASE WHEN p.old_geoid = up.jurisdiction_geoid THEN p.old_hours END, 0 - ) AS delta - FROM upsert up - LEFT JOIN prev p ON p.user_id = up.user_id - WHERE up.jurisdiction_geoid IS NOT NULL - UNION ALL - -- The event moved jurisdictions (or out of coverage) since this attendee was last - -- credited: take the stale hours back out of the jurisdiction it no longer belongs to. - -- Disjoint from the branch above (that one is always the CURRENT geoid), so no - -- (user, geoid) pair is inserted twice. - SELECT p.user_id, p.old_geoid, -p.old_hours - FROM upsert up - JOIN prev p ON p.user_id = up.user_id - WHERE p.old_geoid IS NOT NULL - AND p.old_geoid IS DISTINCT FROM up.jurisdiction_geoid - ), - rollup AS ( - INSERT INTO user_jurisdiction_hours (user_id, jurisdiction_geoid, total_hours) - SELECT d.user_id, d.geoid, d.delta FROM deltas d - ON CONFLICT (user_id, jurisdiction_geoid) - DO UPDATE SET total_hours = user_jurisdiction_hours.total_hours + EXCLUDED.total_hours - ) - SELECT - user_id, - previous_hours::float8 AS previous_hours, - new_hours::float8 AS new_hours - FROM journal - ` - const changed = journal.map((r) => ({ - userId: r.user_id, - hours: r.new_hours, - previousHours: r.previous_hours, - })) + const changed = await writeEventCredits(tx, { + cleanupId: args.cleanupId, + geoid: args.geoid, + actorId: args.actorId, + loggedByUserId: args.actorId, + entries: args.entries, + }) const anomalies = await detectHoursAnomalies(tx, { actorId: args.actorId, cleanupId: args.cleanupId, diff --git a/services/api/src/services/volunteer-hours-repository.memory.ts b/services/api/src/services/volunteer-hours-repository.memory.ts index 36c2f890..20f352dd 100644 --- a/services/api/src/services/volunteer-hours-repository.memory.ts +++ b/services/api/src/services/volunteer-hours-repository.memory.ts @@ -23,6 +23,8 @@ import type { HoursVisibility, LeaderboardPage, ListEntriesArgs, + EventCreditChange, + EventCreditWrite, LogEventHoursArgs, LogEventHoursResult, MyVolunteerHoursTotals, @@ -31,6 +33,7 @@ import type { VolunteerHoursEntryView, VolunteerHoursRepository, } from "./volunteer-hours-service.js" +import { assertWithinDailyHoursCap } from "./volunteer-hours-rules.js" export interface MemoryLeaderboardUser { name: string @@ -142,14 +145,29 @@ export class InMemoryVolunteerHoursRepository implements VolunteerHoursRepositor logEventHours(args: LogEventHoursArgs): Promise { this.assertNoReciprocalCredit(args) this.assertDailyCap(args) - const changed: LogEventHoursResult["changed"] = [] - for (const entry of args.entries) { + const changed = this.writeEventCredits({ + cleanupId: args.cleanupId, + geoid: args.geoid, + actorId: args.actorId, + loggedByUserId: args.actorId, + entries: args.entries, + }) + return Promise.resolve({ + credited: args.entries.length, + changed, + anomalies: this.detectAnomalies(args), + }) + } + + private writeEventCredits(write: EventCreditWrite): EventCreditChange[] { + const changed: EventCreditChange[] = [] + for (const entry of write.entries) { const hours = round2(entry.hours) const existing = this.entries.find( - (e) => e.source === "event" && e.cleanupId === args.cleanupId && e.userId === entry.userId, + (e) => e.source === "event" && e.cleanupId === write.cleanupId && e.userId === entry.userId, ) const live = existing !== undefined && existing.voidedAt === undefined ? existing : null - if (live !== null && live.hours === hours && live.geoid === args.geoid) continue + if (live !== null && live.hours === hours && live.geoid === write.geoid) continue changed.push({ userId: entry.userId, @@ -157,7 +175,7 @@ export class InMemoryVolunteerHoursRepository implements VolunteerHoursRepositor previousHours: existing === undefined ? null : (live?.hours ?? 0), }) if (live !== null && live.geoid !== null) this.addRollup(entry.userId, live.geoid, -live.hours) - if (args.geoid !== null) this.addRollup(entry.userId, args.geoid, hours) + if (write.geoid !== null) this.addRollup(entry.userId, write.geoid, hours) if (existing === undefined) { this.entries.push({ @@ -166,10 +184,10 @@ export class InMemoryVolunteerHoursRepository implements VolunteerHoursRepositor source: "event", hours, createdAt: this.now(), - cleanupId: args.cleanupId, + cleanupId: write.cleanupId, reportId: null, - geoid: args.geoid, - loggedByUserId: args.actorId, + geoid: write.geoid, + loggedByUserId: write.loggedByUserId, }) continue } @@ -178,14 +196,10 @@ export class InMemoryVolunteerHoursRepository implements VolunteerHoursRepositor existing.createdAt = this.now() } existing.hours = hours - existing.geoid = args.geoid - existing.loggedByUserId = args.actorId + existing.geoid = write.geoid + existing.loggedByUserId = write.loggedByUserId } - return Promise.resolve({ - credited: args.entries.length, - changed, - anomalies: this.detectAnomalies(args), - }) + return changed } private assertNoReciprocalCredit(args: LogEventHoursArgs): void { @@ -210,27 +224,28 @@ export class InMemoryVolunteerHoursRepository implements VolunteerHoursRepositor } private assertDailyCap(args: LogEventHoursArgs): void { - const day = this.eventDay(args.cleanupId) - if (day === null) return - const dailyCapHours = args.dailyCapHours ?? DAILY_HOURS_CAP - for (const entry of args.entries) { - const held = this.entries - .filter( - (e) => - e.source === "event" && - e.userId === entry.userId && - e.voidedAt === undefined && - e.cleanupId !== null && - e.cleanupId !== args.cleanupId && - this.eventDay(e.cleanupId) === day, - ) - .reduce((sum, e) => sum + e.hours, 0) - if (held + entry.hours > dailyCapHours) { - throw AppError.conflict( - `That attendee already holds ${round2(held)} h for events on this date; the daily limit is ${dailyCapHours} h.`, - ) - } + const held = this.sameDayEventHours( + args.cleanupId, + args.entries.map((e) => e.userId), + ) + if (held === null) return + assertWithinDailyHoursCap(args.entries, held, args.dailyCapHours ?? DAILY_HOURS_CAP) + } + + private sameDayEventHours( + cleanupId: string, + userIds: readonly string[], + ): Map | null { + const day = this.eventDay(cleanupId) + if (day === null) return null + const held = new Map() + for (const e of this.entries) { + if (e.source !== "event" || e.voidedAt !== undefined || !userIds.includes(e.userId)) continue + if (e.cleanupId === null || e.cleanupId === cleanupId) continue + if (this.eventDay(e.cleanupId) !== day) continue + held.set(e.userId, (held.get(e.userId) ?? 0) + e.hours) } + return held } private eventDay(cleanupId: string): string | null { diff --git a/services/api/src/services/volunteer-hours-rules.ts b/services/api/src/services/volunteer-hours-rules.ts new file mode 100644 index 00000000..96b03baf --- /dev/null +++ b/services/api/src/services/volunteer-hours-rules.ts @@ -0,0 +1,80 @@ +import { AppError, MAX_EVENT_HOURS, MIN_EVENT_HOURS } from "@civfix/shared" +import type { CleanupStatus } from "@civfix/shared" +import { MIN_EVENT_DURATION_MS, eventWindowOf, hasEventEnded } from "./cleanup-rules.js" + +export const EVENT_WINDOW_GRACE_MS = 60 * 60 * 1000 + +export interface EventHoursWindow { + scheduledAt: Date + endsAt: Date + completedAt: Date | null +} + +export interface CreditableEvent extends EventHoursWindow { + status: CleanupStatus +} + +export interface EventCreditLimits { + durationMs: number + windowCap: number +} + +function round2(n: number): number { + return Math.round(n * 100) / 100 +} + +export function eventDurationMs(cleanup: EventHoursWindow): number { + const end = cleanup.completedAt ?? cleanup.endsAt + return end.getTime() - cleanup.scheduledAt.getTime() +} + +export function creditableHoursForEvent(cleanup: EventHoursWindow): number { + const windowMs = eventDurationMs(cleanup) + if (windowMs <= 0) return 0 + const hours = (windowMs + EVENT_WINDOW_GRACE_MS) / (60 * 60 * 1000) + return Math.min(MAX_EVENT_HOURS, Math.round(hours * 100) / 100) +} + +export function assertEventCreditable(cleanup: CreditableEvent, nowMs: number): EventCreditLimits { + if (cleanup.status === "cancelled") { + throw AppError.conflict("Volunteer hours can't be logged for a cancelled event.") + } + if (!hasEventEnded(eventWindowOf(cleanup), nowMs)) { + throw AppError.conflict("Volunteer hours can be logged once the event has ended.") + } + const durationMs = eventDurationMs(cleanup) + if (durationMs < MIN_EVENT_DURATION_MS) { + throw AppError.conflict( + `This event ran for less than ${MIN_EVENT_DURATION_MS / 60_000} minutes, so no volunteer hours can be logged against it.`, + ) + } + return { durationMs, windowCap: creditableHoursForEvent(cleanup) } +} + +export function assertCreditableEventHours(hours: number, limits: EventCreditLimits): void { + if (!(hours >= MIN_EVENT_HOURS) || hours > MAX_EVENT_HOURS) { + throw AppError.validation({ + entries: `hours must be at least ${MIN_EVENT_HOURS} and at most ${MAX_EVENT_HOURS}`, + }) + } + if (hours > limits.windowCap) { + throw AppError.validation({ + entries: `this event ran for ${round2(limits.durationMs / 3_600_000)} h, so at most ${limits.windowCap} h may be credited per attendee`, + }) + } +} + +export function assertWithinDailyHoursCap( + entries: readonly { userId: string; hours: number }[], + heldByUser: ReadonlyMap, + dailyCapHours: number, +): void { + for (const entry of entries) { + const held = heldByUser.get(entry.userId) ?? 0 + if (held + entry.hours > dailyCapHours) { + throw AppError.conflict( + `That attendee already holds ${round2(held)} h for events on this date; the daily limit is ${dailyCapHours} h.`, + ) + } + } +} diff --git a/services/api/src/services/volunteer-hours-service.ts b/services/api/src/services/volunteer-hours-service.ts index 99ea35fa..422b6b6e 100644 --- a/services/api/src/services/volunteer-hours-service.ts +++ b/services/api/src/services/volunteer-hours-service.ts @@ -1,10 +1,4 @@ -import { - AppError, - avatarGradient, - MAX_EVENT_HOURS, - MAX_EVENT_HOURS_ENTRIES, - MIN_EVENT_HOURS, -} from "@civfix/shared" +import { AppError, avatarGradient, MAX_EVENT_HOURS_ENTRIES } from "@civfix/shared" import type { CleanupMemberRole, CleanupStatus, @@ -26,8 +20,9 @@ import type { } from "@civfix/shared" import { can, type HostStanding } from "@civfix/shared/host" import { parseTimeCursor, type TimeCursor } from "../db/cursor-helpers.js" -import { MIN_EVENT_DURATION_MS, eventWindowOf, hasEventEnded } from "./cleanup-rules.js" +import type { MessageKey, MessageVars } from "../i18n/renderMessage.js" import { mapWithLimit, PRESIGN_CONCURRENCY } from "./media-presign.js" +import { assertCreditableEventHours, assertEventCreditable } from "./volunteer-hours-rules.js" import type { AffiliationLoader } from "./affiliation.js" import type { TopVolunteerRow } from "./host/analytics-repository.drizzle.js" import type { InsightsInvalidator } from "./host/host-analytics-cache.js" @@ -49,9 +44,6 @@ export const MAX_ORG_CHIPS_FETCH = 20 export const HOURS_NOTIFY_CONCURRENCY = 8 - -export const EVENT_WINDOW_GRACE_MS = 60 * 60 * 1000 - export const DAILY_HOURS_CAP = 24 export const WEEKLY_HOURS_FLAG_DEFAULT = 60 @@ -77,24 +69,6 @@ export interface HoursModerationSink { }): Promise } -export interface EventHoursWindow { - scheduledAt: Date - endsAt: Date - completedAt: Date | null -} - -export function creditableHoursForEvent(cleanup: EventHoursWindow): number { - const windowMs = eventDurationMs(cleanup) - if (windowMs <= 0) return 0 - const hours = (windowMs + EVENT_WINDOW_GRACE_MS) / (60 * 60 * 1000) - return Math.min(MAX_EVENT_HOURS, Math.round(hours * 100) / 100) -} - -export function eventDurationMs(cleanup: EventHoursWindow): number { - const end = cleanup.completedAt ?? cleanup.endsAt - return end.getTime() - cleanup.scheduledAt.getTime() -} - export interface LogEventHoursArgs { actorId: string cleanupId: string @@ -104,9 +78,23 @@ export interface LogEventHoursArgs { weeklyFlagHours?: number } +export interface EventCreditChange { + userId: string + hours: number + previousHours: number | null +} + +export interface EventCreditWrite { + cleanupId: string + geoid: string | null + actorId: string + loggedByUserId: string + entries: readonly EventHoursEntry[] +} + export interface LogEventHoursResult { credited: number - changed: { userId: string; hours: number; previousHours: number | null }[] + changed: EventCreditChange[] anomalies: VolunteerHoursAnomaly[] } @@ -378,6 +366,44 @@ function toEventHoursRow(entry: EventHoursLedgerEntry): { } } +export interface HoursCreditNotifierDeps { + notifier?: Pick + logger?: { warn(obj: unknown, msg?: string): void } +} + +export interface HoursCreditNotice { + titleKey: MessageKey + bodyKey: MessageKey + vars: MessageVars + link: string + logContext: Readonly> +} + +export async function notifyHoursCredited( + deps: HoursCreditNotifierDeps, + recipients: readonly { userId: string; hours: number }[], + notice: HoursCreditNotice, +): Promise { + const notifier = deps.notifier + if (notifier === undefined) return + await mapWithLimit(recipients, HOURS_NOTIFY_CONCURRENCY, async (recipient) => { + try { + await notifier.createNotification(recipient.userId, { + type: "hours_logged", + titleKey: notice.titleKey, + bodyKey: notice.bodyKey, + vars: { hours: round2(recipient.hours), ...notice.vars }, + link: notice.link, + }) + } catch (err) { + deps.logger?.warn( + { err, ...notice.logContext, userId: recipient.userId }, + "hours_logged notification failed (suppressed)", + ) + } + }) +} + export function makeVolunteerHoursService(deps: VolunteerHoursServiceDeps): VolunteerHoursService { async function organizationChips(views: readonly OrgHoursView[]): Promise { const presign = deps.presignOrgLogo @@ -413,35 +439,6 @@ export function makeVolunteerHoursService(deps: VolunteerHoursServiceDeps): Volu return { eventRole: await deps.cleanups.roleOf(cleanupId, userId), orgRole: null } } - async function notifyHoursLogged( - cleanup: { id: string; title: string }, - changed: LogEventHoursResult["changed"], - actorId: string, - ): Promise { - const notifier = deps.notifier - if (notifier === undefined) return - const recipients = changed.filter( - (c) => - c.userId !== actorId && (c.previousHours === null || c.hours > c.previousHours), - ) - await mapWithLimit(recipients, HOURS_NOTIFY_CONCURRENCY, async (c) => { - try { - await notifier.createNotification(c.userId, { - type: "hours_logged", - titleKey: "notification.hours_logged.title", - bodyKey: "notification.hours_logged.body", - vars: { hours: round2(c.hours), title: cleanup.title }, - link: `/cleanups/${cleanup.id}`, - }) - } catch (err) { - deps.logger?.warn( - { err, cleanupId: cleanup.id, userId: c.userId }, - "hours_logged notification failed (suppressed)", - ) - } - }) - } - async function reportAnomalies( anomalies: VolunteerHoursAnomaly[], cleanupId: string, @@ -574,19 +571,7 @@ export function makeVolunteerHoursService(deps: VolunteerHoursServiceDeps): Volu if (!can(actorStanding, "manage_event")) { throw AppError.forbidden("Only the event hosts can log volunteer hours.") } - if (cleanup.status === "cancelled") { - throw AppError.conflict("Volunteer hours can't be logged for a cancelled event.") - } - if (!hasEventEnded(eventWindowOf(cleanup), Date.now())) { - throw AppError.conflict("Volunteer hours can be logged once the event has ended.") - } - const durationMs = eventDurationMs(cleanup) - if (durationMs < MIN_EVENT_DURATION_MS) { - throw AppError.conflict( - `This event ran for less than ${MIN_EVENT_DURATION_MS / 60_000} minutes, so no volunteer hours can be logged against it.`, - ) - } - const windowCap = creditableHoursForEvent(cleanup) + const limits = assertEventCreditable(cleanup, Date.now()) if (input.entries.length > MAX_EVENT_HOURS_ENTRIES) { throw AppError.validation({ @@ -601,16 +586,7 @@ export function makeVolunteerHoursService(deps: VolunteerHoursServiceDeps): Volu "You can't log volunteer hours for yourself — another host must credit you.", ) } - if (!(entry.hours >= MIN_EVENT_HOURS) || entry.hours > MAX_EVENT_HOURS) { - throw AppError.validation({ - entries: `hours must be at least ${MIN_EVENT_HOURS} and at most ${MAX_EVENT_HOURS}`, - }) - } - if (entry.hours > windowCap) { - throw AppError.validation({ - entries: `this event ran for ${round2(durationMs / 3_600_000)} h, so at most ${windowCap} h may be credited per attendee`, - }) - } + assertCreditableEventHours(entry.hours, limits) if (seen.has(entry.userId)) { throw AppError.validation({ entries: `duplicate userId: ${entry.userId}` }) } @@ -636,10 +612,19 @@ export function makeVolunteerHoursService(deps: VolunteerHoursServiceDeps): Volu }) await deps.insightsInvalidator?.bumpInsightsGeneration(input.cleanupId) await reportAnomalies(result.anomalies, input.cleanupId) - await notifyHoursLogged( - { id: input.cleanupId, title: cleanup.title }, - result.changed, - input.actorId, + await notifyHoursCredited( + deps, + result.changed.filter( + (c) => + c.userId !== input.actorId && (c.previousHours === null || c.hours > c.previousHours), + ), + { + titleKey: "notification.hours_logged.title", + bodyKey: "notification.hours_logged.body", + vars: { title: cleanup.title }, + link: `/cleanups/${input.cleanupId}`, + logContext: { cleanupId: input.cleanupId }, + }, ) return { credited: result.credited } }, diff --git a/services/api/test/integration/volunteer-hours-integrity-pg.test.ts b/services/api/test/integration/volunteer-hours-integrity-pg.test.ts index 6ae4595e..3f1d6d71 100644 --- a/services/api/test/integration/volunteer-hours-integrity-pg.test.ts +++ b/services/api/test/integration/volunteer-hours-integrity-pg.test.ts @@ -188,7 +188,7 @@ describe.skipIf(!pg)("H9: volunteer-hours integrity bounds (integration)", () => }) it("keeps a legacy completed_at as the hours window and falls back to ends_at without one", async () => { - const { creditableHoursForEvent } = await import("../../src/services/volunteer-hours-service.js") + const { creditableHoursForEvent } = await import("../../src/services/volunteer-hours-rules.js") const org = await newUser("Window Org") const legacyId = randomUUID() From 348652e0b0a87b7c06c4b67b1d034a1f7e088ef4 Mon Sep 17 00:00:00 2001 From: byteful Date: Sat, 26 Sep 2026 21:22:36 -0400 Subject: [PATCH 04/14] operator credit, manual credit and void on the volunteer hours ledger --- ...2026-07-24-full-backend-security-review.md | 3 +- docs/volunteer-hours-integrity.md | 7 +- .../0182_volunteer_hours_operator_credit.sql | 81 +++ services/api/src/db/schema/volunteer-hours.ts | 26 +- services/api/src/services/admin/audit.ts | 3 +- .../certificate-repository.drizzle.ts | 19 +- .../services/certificate-repository.memory.ts | 17 +- .../api/src/services/certificate-service.ts | 16 +- .../volunteer-hours-credit.drizzle.ts | 109 ++- .../volunteer-hours-repository.drizzle.ts | 374 ++++++++++- .../volunteer-hours-repository.memory.ts | 321 ++++++++- .../api/src/services/volunteer-hours-rules.ts | 2 +- .../src/services/volunteer-hours-service.ts | 80 +++ .../integration/admin-user-hours-pg.test.ts | 535 +++++++++++++++ .../test/unit/volunteer-hours-entries.test.ts | 4 +- .../volunteer-hours-operator-ledger.test.ts | 631 ++++++++++++++++++ .../test/unit/volunteer-hours-service.test.ts | 10 +- 17 files changed, 2176 insertions(+), 62 deletions(-) create mode 100644 services/api/drizzle/0182_volunteer_hours_operator_credit.sql create mode 100644 services/api/test/integration/admin-user-hours-pg.test.ts create mode 100644 services/api/test/unit/volunteer-hours-operator-ledger.test.ts diff --git a/docs/security/2026-07-24-full-backend-security-review.md b/docs/security/2026-07-24-full-backend-security-review.md index 006c3486..797baad2 100644 --- a/docs/security/2026-07-24-full-backend-security-review.md +++ b/docs/security/2026-07-24-full-backend-security-review.md @@ -322,7 +322,7 @@ Everything an operator has to do by hand, in order, plus the two infra facts and node dist/db/migrate.js # == pnpm --filter @civfix/api db:migrate ``` -`drizzle/` holds **164 files**, `0000_extensions.sql` … `0181_mail_messages_auth_verdict.sql`. The nine rows +`drizzle/` holds **165 files**, `0000_extensions.sql` … `0182_volunteer_hours_operator_credit.sql`. The nine rows below are exactly what this change set adds — `0052`–`0060`, contiguous, no gaps — and everything from `0000` through `0051_social_posts.sql` predates it. (`0060` arrived later than the rest, with the feed redesign; it is listed here because this table is the single operator runbook. `0061`–`0064` arrived @@ -571,6 +571,7 @@ foreign key into `organizations` from `0105`. | `0179_address_resolution.sql` | the address-resolution overhaul: creates `geocode_cache` (a read-through cache of reverse geocodes keyed by the shared 5-decimal point key, with its `geocode_cache_resolved_at_idx`; derived public data only, no user/report/event reference, TTL applied on read rather than by a cron), adds `cleanups.address_source` (resolved | edited | manual) and backfills it to `manual` for every event that already has an address, and adds `reports.addr_source` + `reports.addr_precision`. All three CHECK constraints are `NOT VALID` (new columns, so they hold by construction) and are listed in the outstanding-VALIDATE set below. No new index on a hot table: both `reports` columns are plain row columns | `POST /map/resolve-address` and every create path still answer, but uncached (the cache swallows its own errors) and with no provenance persisted; a new client's event create fails on the missing `cleanups.address_source` column | | `0180_civfix_official_account.sql` | DATA only, no DDL (the Drizzle mirror is unchanged): creates the official `@civfix` account (`users.id` `00000000-0000-4000-8000-00000000c1f1`, display name `CivFix`, role `citizen`, no email, DMs closed) that admin-panel chat posts are authored by. First it frees the handle: any OTHER row holding `civfix` (citext, so any case) gets its auto-placeholder handle `user` + the first 12 hex digits of its id, and nothing else about that row changes - not its display name, and not `handle_changed_at`, so its owner may pick a new handle at once. The INSERT conflicts on the id only, so a placeholder or handle clash fails the file instead of skipping it. At most two `users` rows are touched: milliseconds, row locks only. `SessionService` refuses to mint or resolve a session for the id, and with no email and no OAuth identity no sign-in path can reach it | Admin-panel report and event chat posts fail on the `chat_messages.sender_id` foreign key once the code that authors them as the official account is deployed; nothing else reads the row | | `0181_mail_messages_auth_verdict.sql` | adds `mail_messages.auth_verdict` (nullable `text`, no CHECK: the app validates it and reads it back through `normalizeAuthVerdict`, the `0174` convention for this table), fills it for inbound rows that have none from `meta.authVerdict` on their `delivered` `mail_events` row (NULL-only, never an overwrite), and adds the partial `mail_messages_inbound_created_idx (created_at DESC, id DESC) WHERE direction = 'in'` for the admin inbox feed. `mail_messages` is a small operator-plane table, not on the hot-table list, so the index builds inside the migration's transaction in milliseconds | Inbound mail processing and every mail thread read fail on the missing `auth_verdict` column | +| `0182_volunteer_hours_operator_credit.sql` | adds four nullable columns to `volunteer_hours` for admin-panel credits and voids: `service_date` (`date`, the day a `manual` adjustment was performed), `credited_by_operator_id` and `voided_by_operator_id` (`uuid` FKs to `users`, admin plane only; the volunteer sees the CivFix official account as the creditor) and `void_reason` (`text`). Two CHECKs are added VALID through a `pg_constraint`-guarded `DO` block: `volunteer_hours_service_date_chk` (a service date on every `manual` row and on no other; there were zero manual rows before this file, 0065) and `volunteer_hours_void_reason_len_chk` (at most 1000 characters). `volunteer_hours` is small and not on the hot-table list, so the validating scan is milliseconds; the ADD COLUMNs are catalog-only. No index: the operator ledger read is served by `volunteer_hours_user_created_idx` (0062). PRIVACY: the reasons and operator ids stay on the admin plane and out of the data export | Volunteer-hours entry lists, transcripts and host hours logging fail on the missing `service_date` column (`42703`): the entry projections date manual rows by it and the daily cap sums manual rows by it | **Deferred to the NEXT release** (expand/contract, `docs/migrations-expand-contract.md`): 0.43.0 diff --git a/docs/volunteer-hours-integrity.md b/docs/volunteer-hours-integrity.md index 4b635db2..ebfb8fc4 100644 --- a/docs/volunteer-hours-integrity.md +++ b/docs/volunteer-hours-integrity.md @@ -27,7 +27,7 @@ Every step was legal, every hour landed on a transcript, and nothing was flagged | # | Rule | Where | Effect when tripped | |---|---|---|---| | a | A credit is capped at the event's own window: `COALESCE(completed_at, ends_at) - scheduled_at` **+ 1 h grace**, never above `MAX_EVENT_HOURS` (24) | `volunteer-hours-rules.ts` (`creditableHoursForEvent`, `eventDurationMs`, checked by `assertCreditableEventHours`) | `VALIDATION` naming the event's real length | -| b | One person may hold at most **24 h across every event scheduled on the same local calendar day**, read in the event's own IANA zone (`cleanups.timezone`, falling back to `DEFAULT_EVENT_TIME_ZONE`) | `sameDayEventHours` (`volunteer-hours-credit.drizzle.ts`) and `assertWithinDailyHoursCap` (`volunteer-hours-rules.ts`), inside the crediting transaction under a per-user advisory lock | `CONFLICT` naming what they already hold | +| b | One person may hold at most **24 h across every event scheduled on the same local calendar day**, read in the event's own IANA zone (`cleanups.timezone`, falling back to `DEFAULT_EVENT_TIME_ZONE`), **plus every live manual adjustment whose `service_date` is that day** | `sameDayEventHours` and `hoursHeldOnServiceDate` (`volunteer-hours-credit.drizzle.ts`) and `assertWithinDailyHoursCap` (`volunteer-hours-rules.ts`), inside the crediting transaction under a per-user advisory lock | `CONFLICT` naming what they already hold | | c | Reciprocity inside one event is refused **in both directions** — whoever credits second is the one refused (covers the organizer ↔ promoted-co-host swap) | same transaction | `CONFLICT` | | d | Two anomaly signals are **flagged to moderation, never blocked**: >60 h credited in a rolling 7 days, and A↔B crediting each other on *different* events within 30 days | detected in the transaction, filed by `volunteer-hours-anomaly.ts` | an open `moderation_items` row, `kind = 'pattern'`, `subject_type = 'user'` | | e | Hours can be logged only **once the event has ended** (`now >= ends_at`), and never against an event whose window is **shorter than 15 minutes** | `assertEventCreditable` in `volunteer-hours-rules.ts` (`hasEventEnded`, then the `MIN_EVENT_DURATION_MS` check) | `CONFLICT` | @@ -68,6 +68,11 @@ The host editor re-submits the whole sheet, so `logEventHours` is written to be records `previous_hours = 0` (not `NULL`, which 0053 reserves for a genuine first credit, so the journal still shows the attendee was credited before), the attendee is notified because the hours went up, `voided_at` is cleared and `created_at` moves to the re-credit time. +- **A change or revival takes over the row's attribution whole.** `logged_by_user_id`, `note` and + `credited_by_operator_id` (0182) come from the new write, and the previous void's + `voided_by_operator_id` and `void_reason` are cleared, so a host correcting an operator's credit + does not keep showing the operator's reason on a row the host now owns. `audit_log` and + `volunteer_hours_audit` keep that history. `credited` in the response still counts every entry the host submitted, so the host's confirmation reads the same whether or not a row changed. diff --git a/services/api/drizzle/0182_volunteer_hours_operator_credit.sql b/services/api/drizzle/0182_volunteer_hours_operator_credit.sql new file mode 100644 index 00000000..c337922a --- /dev/null +++ b/services/api/drizzle/0182_volunteer_hours_operator_credit.sql @@ -0,0 +1,81 @@ +-- ============================================================================= +-- 0182_volunteer_hours_operator_credit.sql +-- ----------------------------------------------------------------------------- +-- Operator credits and voids on the volunteer-hours ledger (admin panel). +-- +-- An operator can now write two kinds of ledger row and void any event or +-- manual row: +-- +-- source='event' a credit for a real, ended event, written through the same +-- void-aware upsert the host path uses. logged_by_user_id is +-- the CivFix official account (what the volunteer sees); +-- credited_by_operator_id is the human operator, which only +-- the admin plane reads. +-- source='manual' an adjustment for service outside any event. It has no +-- cleanup and no jurisdiction (so it never reaches the +-- user_jurisdiction_hours rollup or a leaderboard), and it +-- carries the date the service was performed. +-- +-- `note` (0035) holds the operator's credit reason. The void pair records who +-- voided a row and why; both stay on the admin plane and out of the data export. +-- +-- Constraints: +-- * service_date is present on every manual row and on no other row. There +-- are zero manual rows before this migration (0065 verified no writer +-- existed), so the CHECK is added VALID: volunteer_hours is small and not a +-- hot table (docs/out-of-band-indexes.md), and the scan is milliseconds. +-- * void_reason is bounded at 1000 characters, the same bound the admin +-- reason field enforces at the boundary (DECISIONS §32). +-- +-- No new index: the operator ledger read is keyset on (user_id, created_at DESC, +-- id DESC), which volunteer_hours_user_created_idx (0062) already serves, and +-- the new columns are never filtered on their own. ADD COLUMN of a nullable +-- column with no default is a catalog-only change. +-- +-- CANONICAL DDL: mirrored in src/db/schema/volunteer-hours.ts. +-- +-- Conventions: IF NOT EXISTS columns and pg_constraint-guarded constraints, so a +-- repeat apply is a no-op; one transaction per file (src/db/migrate.ts), no +-- BEGIN/COMMIT here. Forward-only, no down. +-- +-- Ordering rules: requires 0035_volunteer_hours.sql and +-- 0053_volunteer_hours_audit.sql. +-- ============================================================================= + +ALTER TABLE volunteer_hours + ADD COLUMN IF NOT EXISTS service_date date, + ADD COLUMN IF NOT EXISTS credited_by_operator_id uuid REFERENCES users (id), + ADD COLUMN IF NOT EXISTS voided_by_operator_id uuid REFERENCES users (id), + ADD COLUMN IF NOT EXISTS void_reason text; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'volunteer_hours_service_date_chk' AND conrelid = 'volunteer_hours'::regclass + ) THEN + ALTER TABLE volunteer_hours + ADD CONSTRAINT volunteer_hours_service_date_chk + CHECK ((source = 'manual') = (service_date IS NOT NULL)); + END IF; + + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'volunteer_hours_void_reason_len_chk' AND conrelid = 'volunteer_hours'::regclass + ) THEN + ALTER TABLE volunteer_hours + ADD CONSTRAINT volunteer_hours_void_reason_len_chk + CHECK (void_reason IS NULL OR char_length(void_reason) <= 1000); + END IF; +END $$; + +COMMENT ON COLUMN volunteer_hours.service_date IS + 'Manual rows only (required there, forbidden elsewhere): the date the service was performed. Transcripts date a manual row by this day.'; +COMMENT ON COLUMN volunteer_hours.credited_by_operator_id IS + 'The operator who wrote this credit from the admin panel; NULL for host credits. Admin plane only: the volunteer sees logged_by_user_id (the CivFix official account).'; +COMMENT ON COLUMN volunteer_hours.voided_by_operator_id IS + 'The operator who voided this row; set together with voided_at by the admin void path and cleared when a host or operator revives the row.'; +COMMENT ON COLUMN volunteer_hours.void_reason IS + 'Internal reason for an operator void. Never shown to the volunteer and never exported.'; +COMMENT ON COLUMN volunteer_hours_audit.new_hours IS + 'The credit AFTER this write. 0 records an operator void of the event row (the row itself keeps its hours, with voided_at set); a later revival journals previous_hours 0.'; diff --git a/services/api/src/db/schema/volunteer-hours.ts b/services/api/src/db/schema/volunteer-hours.ts index 23ebb2a3..d7fb161a 100644 --- a/services/api/src/db/schema/volunteer-hours.ts +++ b/services/api/src/db/schema/volunteer-hours.ts @@ -1,6 +1,16 @@ - import { sql } from "drizzle-orm" -import { index, numeric, pgTable, primaryKey, text, timestamp, uniqueIndex, uuid } from "drizzle-orm/pg-core" +import { + check, + date, + index, + numeric, + pgTable, + primaryKey, + text, + timestamp, + uniqueIndex, + uuid, +} from "drizzle-orm/pg-core" import type { VolunteerHoursSource } from "@civfix/shared" import { cleanups } from "./cleanups.js" import { jurisdictions } from "./jurisdictions.js" @@ -25,6 +35,10 @@ export const volunteerHours = pgTable( note: text("note"), voidedAt: timestamp("voided_at", { withTimezone: true }), createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + serviceDate: date("service_date"), + creditedByOperatorId: uuid("credited_by_operator_id").references(() => users.id), + voidedByOperatorId: uuid("voided_by_operator_id").references(() => users.id), + voidReason: text("void_reason"), }, (t) => [ index("volunteer_hours_user_idx").on(t.userId), @@ -35,6 +49,14 @@ export const volunteerHours = pgTable( uniqueIndex("volunteer_hours_event_uidx") .on(t.cleanupId, t.userId) .where(sql`${t.source} = 'event'`), + check( + "volunteer_hours_service_date_chk", + sql`(${t.source} = 'manual') = (${t.serviceDate} IS NOT NULL)`, + ), + check( + "volunteer_hours_void_reason_len_chk", + sql`${t.voidReason} IS NULL OR char_length(${t.voidReason}) <= 1000`, + ), ], ) diff --git a/services/api/src/services/admin/audit.ts b/services/api/src/services/admin/audit.ts index 11a4a36b..51d74924 100644 --- a/services/api/src/services/admin/audit.ts +++ b/services/api/src/services/admin/audit.ts @@ -1,4 +1,3 @@ - import type { Queryable } from "../../db/client.js" export type AdminAuditAction = @@ -28,6 +27,8 @@ export type AdminAuditAction = | "user.role_changed" | "user.verified" | "user.unverified" + | "user.hours_credited" + | "user.hours_voided" | "gov_claim.verified" | "gov_claim.approved" | "gov_claim.rejected" diff --git a/services/api/src/services/certificate-repository.drizzle.ts b/services/api/src/services/certificate-repository.drizzle.ts index c3924bf3..20624fe6 100644 --- a/services/api/src/services/certificate-repository.drizzle.ts +++ b/services/api/src/services/certificate-repository.drizzle.ts @@ -8,8 +8,9 @@ * of jsonb, which Postgres stores out of line in TOAST. A `SELECT *` (or any projection naming * `snapshot`) detoasts that blob on EVERY holder list read and on EVERY anonymous public verification — * the highest-traffic read in this feature, served to a school registrar who needs six scalars. The - * column is written once and read back by nothing; `service-hours-certificates-pg.test.ts` greps this - * source to keep it that way. + * column is written once and never projected; `service-hours-certificates-pg.test.ts` greps this + * source to keep it that way. The one read that looks inside it is `liveCodesListingEntry`, a + * containment filter run only when an operator voids a ledger entry, over one holder's live rows. * * The two unique indexes are surfaced as a typed `CertificateConflictError` rather than a leaked driver * error, because the service's recoveries are completely different: a `code` collision re-mints and @@ -20,6 +21,7 @@ import type { Sql } from "../db/client.js" import { CertificateConflictError, + type CertificateEntryCode, type CertificateHolder, type CertificateInsert, type CertificateRepository, @@ -254,5 +256,18 @@ export function makeDrizzleCertificateRepository(sql: Sql): CertificateRepositor locale: row.locale, } }, + + async liveCodesListingEntry(userId: string, entryId: string): Promise { + const rows = await sql<{ code: string; issued_at: Date }[]>` + SELECT code, issued_at + FROM service_hours_certificates + WHERE user_id = ${userId} + AND revoked_at IS NULL + AND snapshot->'rows' @> jsonb_build_array(jsonb_build_object('id', ${entryId}::text)) + ORDER BY issued_at DESC, id DESC + LIMIT 200 + ` + return rows.map((r) => ({ code: r.code, issuedAt: r.issued_at })) + }, } } diff --git a/services/api/src/services/certificate-repository.memory.ts b/services/api/src/services/certificate-repository.memory.ts index fc0b3102..25547504 100644 --- a/services/api/src/services/certificate-repository.memory.ts +++ b/services/api/src/services/certificate-repository.memory.ts @@ -16,12 +16,13 @@ * - reads hand back CLONES, so a test mutating a returned row cannot corrupt the store (the Drizzle * repo obviously cannot be corrupted that way). * - * `snapshot` is stored but never read back, exactly as in production. + * `snapshot` is read back only by `liveCodesListingEntry`, exactly as in production. */ import { randomUUID } from "node:crypto" import { CertificateConflictError, + type CertificateEntryCode, type CertificateHolder, type CertificateInsert, type CertificateRepository, @@ -141,6 +142,20 @@ export class InMemoryCertificateRepository implements CertificateRepository { return Promise.resolve() } + liveCodesListingEntry(userId: string, entryId: string): Promise { + const codes = [...this.rows.values()] + .filter( + (row) => + row.userId === userId && + row.revokedAt === null && + row.snapshot.rows.some((item) => item.id === entryId), + ) + .sort((a, b) => b.issuedAt.getTime() - a.issuedAt.getTime() || b.id.localeCompare(a.id)) + .slice(0, 200) + .map((row) => ({ code: row.code, issuedAt: row.issuedAt })) + return Promise.resolve(codes) + } + findHolder(userId: string): Promise { const holder = this.holders.get(userId) // A tombstoned account cannot issue, matching the Drizzle read's `deleted_at IS NULL` filter. diff --git a/services/api/src/services/certificate-service.ts b/services/api/src/services/certificate-service.ts index d9ad4e07..dc50b2d7 100644 --- a/services/api/src/services/certificate-service.ts +++ b/services/api/src/services/certificate-service.ts @@ -146,7 +146,10 @@ export interface CertificateInsert { periodStart: Date | null periodEnd: Date | null ledgerFingerprint: string - /** The exact rendered model, stored so the object is re-renderable. Written once, never read back. */ + /** + * The exact rendered model, stored so the object is re-renderable. Written once; only + * `liveCodesListingEntry` reads inside it. + */ snapshot: TranscriptModel r2Key: string documentSha256: string @@ -193,6 +196,17 @@ export interface CertificateRepository { at: Date }): Promise findHolder(userId: string): Promise + /** + * Live (unrevoked) certificates of `userId` whose frozen snapshot itemised ledger entry `entryId`, + * newest first. An operator void does not change an issued document, so the admin plane lists these + * for a revoke through `db:certificate:revoke`. + */ + liveCodesListingEntry(userId: string, entryId: string): Promise +} + +export interface CertificateEntryCode { + code: string + issuedAt: Date } // ---- service ------------------------------------------------------------------------------------ diff --git a/services/api/src/services/volunteer-hours-credit.drizzle.ts b/services/api/src/services/volunteer-hours-credit.drizzle.ts index f4cfe398..ff500b6e 100644 --- a/services/api/src/services/volunteer-hours-credit.drizzle.ts +++ b/services/api/src/services/volunteer-hours-credit.drizzle.ts @@ -1,3 +1,4 @@ +import type postgres from "postgres" import type { TransactionSql } from "../db/client.js" import { DEFAULT_EVENT_TIME_ZONE } from "./host/event-fields.js" import type { EventCreditChange, EventCreditWrite } from "./volunteer-hours-service.js" @@ -10,38 +11,83 @@ export async function lockEventCredits( userIds: readonly string[], ): Promise { await tx`SELECT pg_advisory_xact_lock(hashtext('volunteer_event:' || ${cleanupId}))` + await lockUserCredits(tx, userIds) +} + +// A writer with no event (a manual credit) takes only the user locks; a writer touching an event row +// must call lockEventCredits instead, so the event lock is never taken after a user lock. +export async function lockUserCredits( + tx: TransactionSql, + userIds: readonly string[], +): Promise { const lockIds = [...new Set(userIds)].sort() - if (lockIds.length > 0) { - await tx` - SELECT pg_advisory_xact_lock(hashtext('volunteer_user:' || u)) - FROM unnest(${lockIds}::uuid[]) AS t(u) - ORDER BY u - ` - } + if (lockIds.length === 0) return + await tx` + SELECT pg_advisory_xact_lock(hashtext('volunteer_user:' || u)) + FROM unnest(${lockIds}::uuid[]) AS t(u) + ORDER BY u + ` } -export async function sameDayEventHours( +// The daily cap counts every live credit dated to the day: event rows by their event's local date, +// manual rows by their service date. +async function creditedHoursOnDay( tx: TransactionSql, - cleanupId: string, userIds: readonly string[], + day: postgres.Fragment, + excludeCleanupId: string | null, ): Promise> { const rows = await tx<{ user_id: string; hours: number }[]>` - SELECT vh.user_id, COALESCE(SUM(vh.hours), 0)::float8 AS hours - FROM volunteer_hours vh - JOIN cleanups c ON c.id = vh.cleanup_id - WHERE vh.user_id = ANY(${userIds}::uuid[]) - AND vh.source = 'event' - AND vh.voided_at IS NULL - AND vh.cleanup_id <> ${cleanupId} - AND (c.scheduled_at AT TIME ZONE COALESCE(c.timezone, ${DEFAULT_EVENT_TIME_ZONE}))::date = ( - SELECT (scheduled_at AT TIME ZONE COALESCE(timezone, ${DEFAULT_EVENT_TIME_ZONE}))::date - FROM cleanups WHERE id = ${cleanupId} - ) - GROUP BY vh.user_id + WITH day AS (${day}), + held AS ( + SELECT vh.user_id, vh.hours + FROM volunteer_hours vh + JOIN cleanups c ON c.id = vh.cleanup_id + WHERE vh.user_id = ANY(${userIds}::uuid[]) + AND vh.source = 'event' + AND vh.voided_at IS NULL + AND vh.cleanup_id IS DISTINCT FROM ${excludeCleanupId}::uuid + AND (c.scheduled_at AT TIME ZONE COALESCE(c.timezone, ${DEFAULT_EVENT_TIME_ZONE}))::date + = (SELECT d FROM day) + UNION ALL + SELECT vh.user_id, vh.hours + FROM volunteer_hours vh + WHERE vh.user_id = ANY(${userIds}::uuid[]) + AND vh.source = 'manual' + AND vh.voided_at IS NULL + AND vh.service_date = (SELECT d FROM day) + ) + SELECT user_id, COALESCE(SUM(hours), 0)::float8 AS hours + FROM held + GROUP BY user_id ` return new Map(rows.map((r) => [r.user_id, r.hours])) } +export async function sameDayEventHours( + tx: TransactionSql, + cleanupId: string, + userIds: readonly string[], +): Promise> { + return creditedHoursOnDay( + tx, + userIds, + tx` + SELECT (scheduled_at AT TIME ZONE COALESCE(timezone, ${DEFAULT_EVENT_TIME_ZONE}))::date AS d + FROM cleanups WHERE id = ${cleanupId} + `, + cleanupId, + ) +} + +export async function hoursHeldOnServiceDate( + tx: TransactionSql, + userId: string, + serviceDate: string, +): Promise> { + return creditedHoursOnDay(tx, [userId], tx`SELECT ${serviceDate}::date AS d`, null) +} + // The prev CTE reads each row before the upsert rewrites it, so the caller must already hold // lockEventCredits for this event and these users; otherwise a concurrent writer's change is lost // from the journal and the user_jurisdiction_hours delta. @@ -51,9 +97,7 @@ export async function writeEventCredits( ): Promise { const userIds = write.entries.map((e) => e.userId) const hoursByRow = write.entries.map((e) => e.hours) - const journal = await tx< - { user_id: string; previous_hours: number | null; new_hours: number }[] - >` + const journal = await tx<{ user_id: string; previous_hours: number | null; new_hours: number }[]>` WITH prev AS ( -- A voided row already left user_jurisdiction_hours when it was voided, so it counts as -- holding 0 hours in no jurisdiction: the revival's rollup delta is its full hours. It @@ -69,14 +113,27 @@ export async function writeEventCredits( AND user_id = ANY(${userIds}::uuid[]) ), upsert AS ( - INSERT INTO volunteer_hours (user_id, hours, source, cleanup_id, jurisdiction_geoid, logged_by_user_id) - SELECT t.u, t.h, 'event', ${write.cleanupId}, ${write.geoid}::text, ${write.loggedByUserId} + INSERT INTO volunteer_hours ( + user_id, hours, source, cleanup_id, jurisdiction_geoid, logged_by_user_id, + note, credited_by_operator_id + ) + SELECT + t.u, t.h, 'event', ${write.cleanupId}, ${write.geoid}::text, ${write.loggedByUserId}, + ${write.note}::text, ${write.creditedByOperatorId}::uuid FROM unnest(${userIds}::uuid[], ${hoursByRow}::float8[]) AS t(u, h) ON CONFLICT (cleanup_id, user_id) WHERE source = 'event' DO UPDATE SET hours = EXCLUDED.hours, jurisdiction_geoid = EXCLUDED.jurisdiction_geoid, logged_by_user_id = EXCLUDED.logged_by_user_id, + -- A write that changes or revives the row takes over its attribution whole: a host's + -- correction of an operator credit must not keep showing the operator's reason, and a + -- revived row is live again, so the previous void's operator and reason no longer describe + -- it (audit_log and volunteer_hours_audit keep that history). + note = EXCLUDED.note, + credited_by_operator_id = EXCLUDED.credited_by_operator_id, + voided_by_operator_id = NULL, + void_reason = NULL, created_at = CASE WHEN volunteer_hours.voided_at IS NULL THEN volunteer_hours.created_at ELSE now() diff --git a/services/api/src/services/volunteer-hours-repository.drizzle.ts b/services/api/src/services/volunteer-hours-repository.drizzle.ts index a7913c33..092755a7 100644 --- a/services/api/src/services/volunteer-hours-repository.drizzle.ts +++ b/services/api/src/services/volunteer-hours-repository.drizzle.ts @@ -5,12 +5,16 @@ import type { OrgVerificationStatus, VolunteerHoursSource, } from "@civfix/shared" -import type { Queryable, Sql } from "../db/client.js" +import { CIVFIX_OFFICIAL_USER_ID, isOfficialAccount } from "../auth/official-account.js" +import type { Queryable, Sql, TransactionSql } from "../db/client.js" import { encodeTimeCursor, pageWith } from "../db/cursor-helpers.js" +import { writeAudit } from "./admin/audit.js" import { blockedPairExpr, hiddenIdentity } from "./hidden-identity.js" import { servedKeyExpr } from "./media-served-key.js" import { + hoursHeldOnServiceDate, lockEventCredits, + lockUserCredits, sameDayEventHours, writeEventCredits, } from "./volunteer-hours-credit.drizzle.js" @@ -19,7 +23,9 @@ import { DAILY_HOURS_CAP, EVENT_HOURS_MEMBER_CAP, ITEMISED_SOURCES, + MANUAL_CREDIT_REPEAT_WINDOW_MS, MAX_ORG_CHIPS_FETCH, + OPERATOR_LEDGER_MAX_LIMIT, RECIPROCAL_LOOKBACK_MS, WEEKLY_HOURS_FLAG_DEFAULT, } from "./volunteer-hours-service.js" @@ -33,23 +39,39 @@ import type { LogEventHoursArgs, LogEventHoursResult, MyVolunteerHoursTotals, + OperatorCreditResult, + OperatorEventCreditArgs, + OperatorLedgerArgs, + OperatorLedgerEntryView, + OperatorLedgerTotals, + OperatorManualCreditArgs, + OperatorVoidArgs, OrgHoursView, VolunteerHoursAnomaly, VolunteerHoursEntryView, VolunteerHoursRepository, + VoidedEntry, } from "./volunteer-hours-service.js" const MORE_PAGES = "more" const RECIPROCAL_LOOKBACK_INTERVAL = `${RECIPROCAL_LOOKBACK_MS / 1000} seconds` const WEEKLY_WINDOW_INTERVAL = "7 days" +const MANUAL_REPEAT_INTERVAL = `${MANUAL_CREDIT_REPEAT_WINDOW_MS / 1000} seconds` + +// Transcripts and entry lists date a row by when the service happened: the event's start, a manual +// row's service day (noon UTC, so no reader's time zone shifts it to a neighbouring day), and only +// failing both, when the row was written. +function occurredAtExpr(sql: Queryable) { + return sql`COALESCE(c.scheduled_at, (vh.service_date + time '12:00') AT TIME ZONE 'UTC', vh.created_at)` +} interface LedgerRow { id: string source: VolunteerHoursSource hours: number created_at: Date - scheduled_at: Date | null + occurred_at: Date cleanup_id: string | null cleanup_title: string | null reference_code: string | null @@ -89,7 +111,7 @@ function toEntryView(r: LedgerRow): VolunteerHoursEntryView { source: r.source, hours: r.hours, createdAt: r.created_at, - occurredAt: r.scheduled_at ?? r.created_at, + occurredAt: r.occurred_at, cleanupId: r.cleanup_id, cleanupTitle: r.cleanup_title, cleanupReferenceCode: r.reference_code, @@ -170,6 +192,95 @@ async function detectHoursAnomalies( return anomalies } +interface OperatorLedgerRow { + id: string + source: VolunteerHoursSource + hours: number + created_at: Date + occurred_at: Date + service_date: string | null + cleanup_id: string | null + cleanup_title: string | null + reference_code: string | null + jurisdiction_geoid: string | null + jurisdiction_name: string | null + creditor_id: string | null + creditor_name: string | null + creditor_handle: string | null + operator_id: string | null + operator_name: string | null + note: string | null + voided_at: Date | null + voided_by_id: string | null + voided_by_name: string | null + void_reason: string | null +} + +function toOperatorLedgerView(r: OperatorLedgerRow): OperatorLedgerEntryView { + return { + id: r.id, + source: r.source, + hours: r.hours, + occurredAt: r.occurred_at, + createdAt: r.created_at, + serviceDate: r.service_date, + event: + r.cleanup_id !== null + ? { id: r.cleanup_id, title: r.cleanup_title ?? "", referenceCode: r.reference_code } + : null, + jurisdiction: + r.jurisdiction_geoid !== null + ? { geoid: r.jurisdiction_geoid, name: r.jurisdiction_name } + : null, + creditedBy: + r.creditor_id !== null + ? { + id: r.creditor_id, + name: r.creditor_name ?? "", + handle: r.creditor_handle, + official: isOfficialAccount(r.creditor_id), + } + : null, + operator: r.operator_id !== null ? { id: r.operator_id, name: r.operator_name ?? "" } : null, + note: r.note, + voidedAt: r.voided_at, + voidedBy: r.voided_by_id !== null ? { id: r.voided_by_id, name: r.voided_by_name ?? "" } : null, + voidReason: r.void_reason, + } +} + +function round2(n: number): number { + return Math.round(n * 100) / 100 +} + +async function eventEntryId( + tx: TransactionSql, + cleanupId: string, + userId: string, +): Promise { + const rows = await tx<{ id: string }[]>` + SELECT id FROM volunteer_hours + WHERE cleanup_id = ${cleanupId} AND user_id = ${userId} AND source = 'event' + LIMIT 1 + ` + const id = rows[0]?.id + if (id === undefined) throw new Error("creditEventAsOperator: the upserted event row is missing") + return id +} + +async function refuseUnvoidable(tx: TransactionSql, args: OperatorVoidArgs): Promise { + const rows = await tx<{ source: VolunteerHoursSource; voided: boolean }[]>` + SELECT source, (voided_at IS NOT NULL) AS voided + FROM volunteer_hours + WHERE id = ${args.entryId} AND user_id = ${args.userId} + LIMIT 1 + ` + const row = rows[0] + if (row === undefined) throw AppError.notFound("Hours entry not found") + if (row.voided) throw AppError.conflict("That hours entry is already void.") + throw AppError.conflict("Report credits are retired and can't be voided.") +} + export function makeDrizzleVolunteerHoursRepository(sql: Sql): VolunteerHoursRepository { return { async logEventHours(args: LogEventHoursArgs): Promise { @@ -205,6 +316,8 @@ export function makeDrizzleVolunteerHoursRepository(sql: Sql): VolunteerHoursRep geoid: args.geoid, actorId: args.actorId, loggedByUserId: args.actorId, + note: null, + creditedByOperatorId: null, entries: args.entries, }) const anomalies = await detectHoursAnomalies(tx, { @@ -385,7 +498,7 @@ export function makeDrizzleVolunteerHoursRepository(sql: Sql): VolunteerHoursRep vh.source, vh.hours::float8 AS hours, vh.created_at, - c.scheduled_at, + ${occurredAtExpr(sql)} AS occurred_at, vh.cleanup_id, c.title AS cleanup_title, c.reference_code, @@ -414,8 +527,10 @@ export function makeDrizzleVolunteerHoursRepository(sql: Sql): VolunteerHoursRep async listEventHours(cleanupId: string, viewerId: string | null): Promise { const mine = viewerId !== null ? sql`AND vh.user_id = ${viewerId}::uuid` : sql`` - const rows = await sql<{ user_id: string; hours: number; created_at: Date }[]>` - SELECT vh.user_id, vh.hours::float8 AS hours, vh.created_at + const rows = await sql< + { user_id: string; hours: number; created_at: Date; logged_by_user_id: string | null }[] + >` + SELECT vh.user_id, vh.hours::float8 AS hours, vh.created_at, vh.logged_by_user_id FROM volunteer_hours vh WHERE vh.cleanup_id = ${cleanupId} AND vh.source = 'event' @@ -428,6 +543,7 @@ export function makeDrizzleVolunteerHoursRepository(sql: Sql): VolunteerHoursRep userId: r.user_id, hours: r.hours, loggedAt: r.created_at, + creditedByOfficial: isOfficialAccount(r.logged_by_user_id), })) if (viewerId === null) return { entries, anyLogged: entries.length > 0 } const probe = await sql<{ any_logged: boolean }[]>` @@ -464,7 +580,7 @@ export function makeDrizzleVolunteerHoursRepository(sql: Sql): VolunteerHoursRep vh.source, vh.hours::float8 AS hours, vh.created_at, - c.scheduled_at, + ${occurredAtExpr(sql)} AS occurred_at, vh.cleanup_id, c.title AS cleanup_title, c.reference_code, @@ -504,5 +620,249 @@ export function makeDrizzleVolunteerHoursRepository(sql: Sql): VolunteerHoursRep entryCount: countRows[0]?.count ?? items.length, } }, + + async creditEventAsOperator(args: OperatorEventCreditArgs): Promise { + return sql.begin(async (tx) => { + await lockEventCredits(tx, args.cleanupId, [args.userId]) + + const live = await tx<{ hours: number }[]>` + SELECT hours::float8 AS hours + FROM volunteer_hours + WHERE cleanup_id = ${args.cleanupId} + AND user_id = ${args.userId} + AND source = 'event' + AND voided_at IS NULL + LIMIT 1 + ` + const held = live[0] + if (held !== undefined) { + throw AppError.conflict( + `That volunteer already holds ${round2(held.hours)} h for this event; void that entry first.`, + ) + } + + const entries = [{ userId: args.userId, hours: args.hours }] + assertWithinDailyHoursCap( + entries, + await sameDayEventHours(tx, args.cleanupId, [args.userId]), + args.dailyCapHours ?? DAILY_HOURS_CAP, + ) + + await writeEventCredits(tx, { + cleanupId: args.cleanupId, + geoid: args.geoid, + actorId: args.operatorId, + loggedByUserId: CIVFIX_OFFICIAL_USER_ID, + note: args.reason, + creditedByOperatorId: args.operatorId, + entries, + }) + const entryId = await eventEntryId(tx, args.cleanupId, args.userId) + await writeAudit(tx, { + actorId: args.operatorId, + action: "user.hours_credited", + target: `user:${args.userId}`, + meta: { + entryId, + source: "event", + cleanupId: args.cleanupId, + hours: args.hours, + reason: args.reason, + }, + }) + return { entryId } + }) + }, + + async creditManual(args: OperatorManualCreditArgs): Promise { + return sql.begin(async (tx) => { + await lockUserCredits(tx, [args.userId]) + + const repeat = await tx<{ id: string }[]>` + SELECT id + FROM volunteer_hours + WHERE user_id = ${args.userId} + AND source = 'manual' + AND voided_at IS NULL + AND hours = ${args.hours}::numeric(6, 2) + AND service_date = ${args.serviceDate}::date + AND credited_by_operator_id = ${args.operatorId} + AND created_at > now() - ${MANUAL_REPEAT_INTERVAL}::interval + LIMIT 1 + ` + if (repeat.length > 0) { + throw AppError.conflict( + "That adjustment was recorded moments ago; check the ledger before adding it again.", + ) + } + + assertWithinDailyHoursCap( + [{ userId: args.userId, hours: args.hours }], + await hoursHeldOnServiceDate(tx, args.userId, args.serviceDate), + args.dailyCapHours ?? DAILY_HOURS_CAP, + ) + + const inserted = await tx<{ id: string }[]>` + INSERT INTO volunteer_hours ( + user_id, hours, source, logged_by_user_id, note, service_date, credited_by_operator_id + ) + VALUES ( + ${args.userId}, ${args.hours}, 'manual', ${CIVFIX_OFFICIAL_USER_ID}, ${args.reason}, + ${args.serviceDate}::date, ${args.operatorId} + ) + RETURNING id + ` + const entryId = inserted[0]?.id + if (entryId === undefined) throw new Error("creditManual: insert returned no row") + await writeAudit(tx, { + actorId: args.operatorId, + action: "user.hours_credited", + target: `user:${args.userId}`, + meta: { + entryId, + source: "manual", + hours: args.hours, + serviceDate: args.serviceDate, + reason: args.reason, + }, + }) + return { entryId } + }) + }, + + async voidEntry(args: OperatorVoidArgs): Promise { + return sql.begin(async (tx) => { + // user_id and cleanup_id never change on a row, so reading them before the locks is safe; + // the event lock has to be known up front because it must be taken before the user lock. + const owner = await tx<{ cleanup_id: string | null }[]>` + SELECT cleanup_id FROM volunteer_hours + WHERE id = ${args.entryId} AND user_id = ${args.userId} + LIMIT 1 + ` + const found = owner[0] + if (found === undefined) throw AppError.notFound("Hours entry not found") + if (found.cleanup_id !== null) { + await lockEventCredits(tx, found.cleanup_id, [args.userId]) + } else { + await lockUserCredits(tx, [args.userId]) + } + + const voided = await tx< + { + source: VolunteerHoursSource + cleanup_id: string | null + jurisdiction_geoid: string | null + hours: number + }[] + >` + UPDATE volunteer_hours + SET voided_at = now(), + voided_by_operator_id = ${args.operatorId}, + void_reason = ${args.reason} + WHERE id = ${args.entryId} + AND user_id = ${args.userId} + AND voided_at IS NULL + AND source <> 'report' + RETURNING source, cleanup_id, jurisdiction_geoid, hours::float8 AS hours + ` + const row = voided[0] + if (row === undefined) return refuseUnvoidable(tx, args) + + if (row.source === "event" && row.cleanup_id !== null) { + await tx` + INSERT INTO volunteer_hours_audit + (cleanup_id, user_id, actor_user_id, previous_hours, new_hours) + SELECT cleanup_id, user_id, ${args.operatorId}, hours, 0 + FROM volunteer_hours WHERE id = ${args.entryId} + ` + } + if (row.jurisdiction_geoid !== null) { + await tx` + UPDATE user_jurisdiction_hours ujh + SET total_hours = ujh.total_hours - vh.hours + FROM volunteer_hours vh + WHERE vh.id = ${args.entryId} + AND ujh.user_id = vh.user_id + AND ujh.jurisdiction_geoid = vh.jurisdiction_geoid + ` + } + await writeAudit(tx, { + actorId: args.operatorId, + action: "user.hours_voided", + target: `user:${args.userId}`, + meta: { + entryId: args.entryId, + source: row.source, + ...(row.cleanup_id !== null ? { cleanupId: row.cleanup_id } : {}), + hours: row.hours, + reason: args.reason, + }, + }) + return { id: args.entryId, source: row.source, cleanupId: row.cleanup_id, hours: row.hours } + }) + }, + + async listOperatorLedger( + args: OperatorLedgerArgs, + ): Promise<{ items: OperatorLedgerEntryView[]; nextCursor: string | null }> { + const limit = Math.min(Math.max(1, Math.floor(args.limit)), OPERATOR_LEDGER_MAX_LIMIT) + const keyset = + args.cursor !== null + ? sql`AND (vh.created_at, vh.id) < (${args.cursor.at}, ${args.cursor.id}::uuid)` + : sql`` + const rows = await sql` + SELECT + vh.id, + vh.source, + vh.hours::float8 AS hours, + vh.created_at, + ${occurredAtExpr(sql)} AS occurred_at, + vh.service_date::text AS service_date, + vh.cleanup_id, + c.title AS cleanup_title, + c.reference_code, + vh.jurisdiction_geoid, + j.name AS jurisdiction_name, + lb.id AS creditor_id, + lb.display_name AS creditor_name, + lb.handle AS creditor_handle, + op.id AS operator_id, + op.display_name AS operator_name, + vh.note, + vh.voided_at, + vb.id AS voided_by_id, + vb.display_name AS voided_by_name, + vh.void_reason + FROM volunteer_hours vh + LEFT JOIN cleanups c ON c.id = vh.cleanup_id + LEFT JOIN jurisdictions j ON j.geoid = vh.jurisdiction_geoid + LEFT JOIN users lb ON lb.id = vh.logged_by_user_id + LEFT JOIN users op ON op.id = vh.credited_by_operator_id + LEFT JOIN users vb ON vb.id = vh.voided_by_operator_id + WHERE vh.user_id = ${args.userId} + ${keyset} + ORDER BY vh.created_at DESC, vh.id DESC + LIMIT ${limit + 1} + ` + const { items, nextCursor } = pageWith(rows, limit, (last) => + encodeTimeCursor({ at: last.created_at, id: last.id }), + ) + return { items: items.map(toOperatorLedgerView), nextCursor } + }, + + async operatorLedgerTotals(userId: string): Promise { + const rows = await sql<{ live: number; voided: number }[]>` + SELECT + count(*) FILTER (WHERE voided_at IS NULL)::int AS live, + count(*) FILTER (WHERE voided_at IS NOT NULL)::int AS voided + FROM volunteer_hours + WHERE user_id = ${userId} + ` + return { + totalHours: await computeTotalHours(sql, userId), + liveEntries: rows[0]?.live ?? 0, + voidedEntries: rows[0]?.voided ?? 0, + } + }, } } diff --git a/services/api/src/services/volunteer-hours-repository.memory.ts b/services/api/src/services/volunteer-hours-repository.memory.ts index 20f352dd..d5148a1d 100644 --- a/services/api/src/services/volunteer-hours-repository.memory.ts +++ b/services/api/src/services/volunteer-hours-repository.memory.ts @@ -1,5 +1,6 @@ import { randomUUID } from "node:crypto" import { AppError, avatarGradient } from "@civfix/shared" +import { CIVFIX_OFFICIAL_USER_ID, isOfficialAccount } from "../auth/official-account.js" import type { LeaderboardEntryDTO, MyVolunteerHoursDTO, @@ -12,7 +13,9 @@ import { eventDayKey } from "./host/event-day.js" import { DAILY_HOURS_CAP, ITEMISED_SOURCES, + MANUAL_CREDIT_REPEAT_WINDOW_MS, MAX_ORG_CHIPS_FETCH, + OPERATOR_LEDGER_MAX_LIMIT, RECIPROCAL_LOOKBACK_MS, WEEKLY_HOURS_FLAG_DEFAULT, } from "./volunteer-hours-service.js" @@ -28,10 +31,18 @@ import type { LogEventHoursArgs, LogEventHoursResult, MyVolunteerHoursTotals, + OperatorCreditResult, + OperatorEventCreditArgs, + OperatorLedgerArgs, + OperatorLedgerEntryView, + OperatorLedgerTotals, + OperatorManualCreditArgs, + OperatorVoidArgs, OrgHoursView, VolunteerHoursAnomaly, VolunteerHoursEntryView, VolunteerHoursRepository, + VoidedEntry, } from "./volunteer-hours-service.js" import { assertWithinDailyHoursCap } from "./volunteer-hours-rules.js" @@ -73,9 +84,33 @@ interface LedgerEntry { reportId: string | null geoid: string | null loggedByUserId: string | null + note: string | null + serviceDate: string | null + creditedByOperatorId: string | null + voidedByOperatorId: string | null + voidReason: string | null voidedAt?: Date } +export interface RecordedHoursAudit { + actorId: string + action: string + target: string + meta: Record +} + +export interface RecordedHoursJournal { + cleanupId: string + userId: string + actorUserId: string + previousHours: number | null + newHours: number +} + +function serviceDayNoonUtc(serviceDate: string): Date { + return new Date(`${serviceDate}T12:00:00.000Z`) +} + function round2(n: number): number { return Math.round(n * 100) / 100 } @@ -94,6 +129,8 @@ export class InMemoryVolunteerHoursRepository implements VolunteerHoursRepositor private readonly cleanups = new Map() private readonly organizations = new Map() private readonly entries: LedgerEntry[] = [] + readonly audits: RecordedHoursAudit[] = [] + readonly journal: RecordedHoursJournal[] = [] private readonly now: () => Date private readonly newId: () => string @@ -118,7 +155,7 @@ export class InMemoryVolunteerHoursRepository implements VolunteerHoursRepositor this.organizations.set(org.id, org) } - voidEntry(entryId: string): void { + markVoided(entryId: string): void { const row = this.entries.find((e) => e.id === entryId) if (row === undefined || row.voidedAt !== undefined) return row.voidedAt = this.now() @@ -137,12 +174,17 @@ export class InMemoryVolunteerHoursRepository implements VolunteerHoursRepositor reportId, geoid, loggedByUserId: null, + note: null, + serviceDate: null, + creditedByOperatorId: null, + voidedByOperatorId: null, + voidReason: null, }) if (geoid !== null) this.addRollup(userId, geoid, hours) return id } - logEventHours(args: LogEventHoursArgs): Promise { + async logEventHours(args: LogEventHoursArgs): Promise { this.assertNoReciprocalCredit(args) this.assertDailyCap(args) const changed = this.writeEventCredits({ @@ -150,13 +192,15 @@ export class InMemoryVolunteerHoursRepository implements VolunteerHoursRepositor geoid: args.geoid, actorId: args.actorId, loggedByUserId: args.actorId, + note: null, + creditedByOperatorId: null, entries: args.entries, }) - return Promise.resolve({ + return { credited: args.entries.length, changed, anomalies: this.detectAnomalies(args), - }) + } } private writeEventCredits(write: EventCreditWrite): EventCreditChange[] { @@ -169,10 +213,14 @@ export class InMemoryVolunteerHoursRepository implements VolunteerHoursRepositor const live = existing !== undefined && existing.voidedAt === undefined ? existing : null if (live !== null && live.hours === hours && live.geoid === write.geoid) continue - changed.push({ + const previousHours = existing === undefined ? null : (live?.hours ?? 0) + changed.push({ userId: entry.userId, hours, previousHours }) + this.journal.push({ + cleanupId: write.cleanupId, userId: entry.userId, - hours, - previousHours: existing === undefined ? null : (live?.hours ?? 0), + actorUserId: write.actorId, + previousHours, + newHours: hours, }) if (live !== null && live.geoid !== null) this.addRollup(entry.userId, live.geoid, -live.hours) if (write.geoid !== null) this.addRollup(entry.userId, write.geoid, hours) @@ -188,6 +236,11 @@ export class InMemoryVolunteerHoursRepository implements VolunteerHoursRepositor reportId: null, geoid: write.geoid, loggedByUserId: write.loggedByUserId, + note: write.note, + serviceDate: null, + creditedByOperatorId: write.creditedByOperatorId, + voidedByOperatorId: null, + voidReason: null, }) continue } @@ -198,6 +251,10 @@ export class InMemoryVolunteerHoursRepository implements VolunteerHoursRepositor existing.hours = hours existing.geoid = write.geoid existing.loggedByUserId = write.loggedByUserId + existing.note = write.note + existing.creditedByOperatorId = write.creditedByOperatorId + existing.voidedByOperatorId = null + existing.voidReason = null } return changed } @@ -238,11 +295,25 @@ export class InMemoryVolunteerHoursRepository implements VolunteerHoursRepositor ): Map | null { const day = this.eventDay(cleanupId) if (day === null) return null + return this.creditedHoursOnDay(day, userIds, cleanupId) + } + + private creditedHoursOnDay( + day: string, + userIds: readonly string[], + excludeCleanupId: string | null, + ): Map { const held = new Map() for (const e of this.entries) { - if (e.source !== "event" || e.voidedAt !== undefined || !userIds.includes(e.userId)) continue - if (e.cleanupId === null || e.cleanupId === cleanupId) continue - if (this.eventDay(e.cleanupId) !== day) continue + if (e.voidedAt !== undefined || !userIds.includes(e.userId)) continue + if (e.source === "manual") { + if (e.serviceDate !== day) continue + } else if (e.source === "event") { + if (e.cleanupId === null || e.cleanupId === excludeCleanupId) continue + if (this.eventDay(e.cleanupId) !== day) continue + } else { + continue + } held.set(e.userId, (held.get(e.userId) ?? 0) + e.hours) } return held @@ -450,7 +521,12 @@ export class InMemoryVolunteerHoursRepository implements VolunteerHoursRepositor entries: rows .slice() .sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime() || b.id.localeCompare(a.id)) - .map((e) => ({ userId: e.userId, hours: round2(e.hours), loggedAt: e.createdAt })), + .map((e) => ({ + userId: e.userId, + hours: round2(e.hours), + loggedAt: e.createdAt, + creditedByOfficial: isOfficialAccount(e.loggedByUserId), + })), anyLogged: all.length > 0, }) } @@ -484,6 +560,227 @@ export class InMemoryVolunteerHoursRepository implements VolunteerHoursRepositor }) } + async creditEventAsOperator(args: OperatorEventCreditArgs): Promise { + const hours = round2(args.hours) + const live = this.entries.find( + (e) => + e.source === "event" && + e.cleanupId === args.cleanupId && + e.userId === args.userId && + e.voidedAt === undefined, + ) + if (live !== undefined) { + throw AppError.conflict( + `That volunteer already holds ${round2(live.hours)} h for this event; void that entry first.`, + ) + } + const entries = [{ userId: args.userId, hours }] + const held = this.sameDayEventHours(args.cleanupId, [args.userId]) + if (held !== null) { + assertWithinDailyHoursCap(entries, held, args.dailyCapHours ?? DAILY_HOURS_CAP) + } + this.writeEventCredits({ + cleanupId: args.cleanupId, + geoid: args.geoid, + actorId: args.operatorId, + loggedByUserId: CIVFIX_OFFICIAL_USER_ID, + note: args.reason, + creditedByOperatorId: args.operatorId, + entries, + }) + const row = this.entries.find( + (e) => e.source === "event" && e.cleanupId === args.cleanupId && e.userId === args.userId, + ) + if (row === undefined) + throw new Error("creditEventAsOperator: the upserted event row is missing") + this.audits.push({ + actorId: args.operatorId, + action: "user.hours_credited", + target: `user:${args.userId}`, + meta: { + entryId: row.id, + source: "event", + cleanupId: args.cleanupId, + hours, + reason: args.reason, + }, + }) + return { entryId: row.id } + } + + async creditManual(args: OperatorManualCreditArgs): Promise { + const hours = round2(args.hours) + const nowMs = this.now().getTime() + const repeat = this.entries.some( + (e) => + e.userId === args.userId && + e.source === "manual" && + e.voidedAt === undefined && + e.hours === hours && + e.serviceDate === args.serviceDate && + e.creditedByOperatorId === args.operatorId && + nowMs - e.createdAt.getTime() < MANUAL_CREDIT_REPEAT_WINDOW_MS, + ) + if (repeat) { + throw AppError.conflict( + "That adjustment was recorded moments ago; check the ledger before adding it again.", + ) + } + assertWithinDailyHoursCap( + [{ userId: args.userId, hours }], + this.creditedHoursOnDay(args.serviceDate, [args.userId], null), + args.dailyCapHours ?? DAILY_HOURS_CAP, + ) + const entryId = this.newId() + this.entries.push({ + id: entryId, + userId: args.userId, + source: "manual", + hours, + createdAt: this.now(), + cleanupId: null, + reportId: null, + geoid: null, + loggedByUserId: CIVFIX_OFFICIAL_USER_ID, + note: args.reason, + serviceDate: args.serviceDate, + creditedByOperatorId: args.operatorId, + voidedByOperatorId: null, + voidReason: null, + }) + this.audits.push({ + actorId: args.operatorId, + action: "user.hours_credited", + target: `user:${args.userId}`, + meta: { + entryId, + source: "manual", + hours, + serviceDate: args.serviceDate, + reason: args.reason, + }, + }) + return { entryId } + } + + async voidEntry(args: OperatorVoidArgs): Promise { + const row = this.entries.find((e) => e.id === args.entryId && e.userId === args.userId) + if (row === undefined) throw AppError.notFound("Hours entry not found") + if (row.voidedAt !== undefined) { + throw AppError.conflict("That hours entry is already void.") + } + if (row.source === "report") { + throw AppError.conflict("Report credits are retired and can't be voided.") + } + row.voidedAt = this.now() + row.voidedByOperatorId = args.operatorId + row.voidReason = args.reason + if (row.source === "event" && row.cleanupId !== null) { + this.journal.push({ + cleanupId: row.cleanupId, + userId: row.userId, + actorUserId: args.operatorId, + previousHours: row.hours, + newHours: 0, + }) + } + if (row.geoid !== null) this.addRollup(row.userId, row.geoid, -row.hours) + this.audits.push({ + actorId: args.operatorId, + action: "user.hours_voided", + target: `user:${args.userId}`, + meta: { + entryId: row.id, + source: row.source, + ...(row.cleanupId !== null ? { cleanupId: row.cleanupId } : {}), + hours: row.hours, + reason: args.reason, + }, + }) + return { id: row.id, source: row.source, cleanupId: row.cleanupId, hours: row.hours } + } + + listOperatorLedger( + args: OperatorLedgerArgs, + ): Promise<{ items: OperatorLedgerEntryView[]; nextCursor: string | null }> { + const limit = Math.min(Math.max(1, Math.floor(args.limit)), OPERATOR_LEDGER_MAX_LIMIT) + const rows = this.entries + .filter((e) => e.userId === args.userId) + .sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime() || b.id.localeCompare(a.id)) + .filter((e) => { + if (args.cursor === null) return true + const at = e.createdAt.getTime() + const anchor = args.cursor.at.getTime() + return at < anchor || (at === anchor && e.id < args.cursor.id) + }) + .slice(0, limit + 1) + const { items, nextCursor } = pageWith(rows, limit, (last) => + encodeTimeCursor({ at: last.createdAt, id: last.id }), + ) + return Promise.resolve({ items: items.map((e) => this.toOperatorView(e)), nextCursor }) + } + + operatorLedgerTotals(userId: string): Promise { + const mine = this.entries.filter((e) => e.userId === userId) + return Promise.resolve({ + totalHours: this.computeTotalHours(userId), + liveEntries: mine.filter((e) => e.voidedAt === undefined).length, + voidedEntries: mine.filter((e) => e.voidedAt !== undefined).length, + }) + } + + private toOperatorView(e: LedgerEntry): OperatorLedgerEntryView { + const meta = e.cleanupId !== null ? (this.cleanups.get(e.cleanupId) ?? null) : null + const nameOf = (id: string): string => this.users.get(id)?.name ?? "" + return { + id: e.id, + source: e.source, + hours: round2(e.hours), + occurredAt: this.occurredAt(e), + createdAt: e.createdAt, + serviceDate: e.serviceDate, + event: + e.cleanupId !== null + ? { + id: e.cleanupId, + title: meta?.title ?? "", + referenceCode: meta?.referenceCode ?? null, + } + : null, + jurisdiction: + e.geoid !== null + ? { geoid: e.geoid, name: this.jurisdictionNames.get(e.geoid) ?? null } + : null, + creditedBy: + e.loggedByUserId !== null + ? { + id: e.loggedByUserId, + name: nameOf(e.loggedByUserId), + handle: this.users.get(e.loggedByUserId)?.handle ?? null, + official: isOfficialAccount(e.loggedByUserId), + } + : null, + operator: + e.creditedByOperatorId !== null + ? { id: e.creditedByOperatorId, name: nameOf(e.creditedByOperatorId) } + : null, + note: e.note, + voidedAt: e.voidedAt ?? null, + voidedBy: + e.voidedByOperatorId !== null + ? { id: e.voidedByOperatorId, name: nameOf(e.voidedByOperatorId) } + : null, + voidReason: e.voidReason, + } + } + + private occurredAt(e: LedgerEntry): Date { + const scheduledAt = e.cleanupId !== null ? this.cleanups.get(e.cleanupId)?.scheduledAt : null + if (scheduledAt != null) return scheduledAt + if (e.serviceDate !== null) return serviceDayNoonUtc(e.serviceDate) + return e.createdAt + } + private aggregateVisible(userId: string): boolean { const user = this.users.get(userId) if (user?.deleted === true) return false @@ -498,7 +795,7 @@ export class InMemoryVolunteerHoursRepository implements VolunteerHoursRepositor source: e.source, hours: round2(e.hours), createdAt: e.createdAt, - occurredAt: meta?.scheduledAt ?? e.createdAt, + occurredAt: this.occurredAt(e), cleanupId: e.cleanupId, cleanupTitle: meta?.title ?? null, cleanupReferenceCode: meta?.referenceCode ?? null, diff --git a/services/api/src/services/volunteer-hours-rules.ts b/services/api/src/services/volunteer-hours-rules.ts index 96b03baf..5367664a 100644 --- a/services/api/src/services/volunteer-hours-rules.ts +++ b/services/api/src/services/volunteer-hours-rules.ts @@ -73,7 +73,7 @@ export function assertWithinDailyHoursCap( const held = heldByUser.get(entry.userId) ?? 0 if (held + entry.hours > dailyCapHours) { throw AppError.conflict( - `That attendee already holds ${round2(held)} h for events on this date; the daily limit is ${dailyCapHours} h.`, + `That volunteer already holds ${round2(held)} h on this date; the daily limit is ${dailyCapHours} h.`, ) } } diff --git a/services/api/src/services/volunteer-hours-service.ts b/services/api/src/services/volunteer-hours-service.ts index 422b6b6e..2800020d 100644 --- a/services/api/src/services/volunteer-hours-service.ts +++ b/services/api/src/services/volunteer-hours-service.ts @@ -89,9 +89,81 @@ export interface EventCreditWrite { geoid: string | null actorId: string loggedByUserId: string + note: string | null + creditedByOperatorId: string | null entries: readonly EventHoursEntry[] } +export const MANUAL_CREDIT_REPEAT_WINDOW_MS = 60_000 + +export const OPERATOR_LEDGER_MAX_LIMIT = 50 + +export interface OperatorEventCreditArgs { + operatorId: string + userId: string + cleanupId: string + geoid: string | null + hours: number + reason: string + dailyCapHours?: number +} + +export interface OperatorManualCreditArgs { + operatorId: string + userId: string + hours: number + serviceDate: string + reason: string + dailyCapHours?: number +} + +export interface OperatorCreditResult { + entryId: string +} + +export interface OperatorVoidArgs { + operatorId: string + userId: string + entryId: string + reason: string +} + +export interface VoidedEntry { + id: string + source: VolunteerHoursSource + cleanupId: string | null + hours: number +} + +export interface OperatorLedgerArgs { + userId: string + cursor: TimeCursor | null + limit: number +} + +export interface OperatorLedgerEntryView { + id: string + source: VolunteerHoursSource + hours: number + occurredAt: Date + createdAt: Date + serviceDate: string | null + event: { id: string; title: string; referenceCode: string | null } | null + jurisdiction: { geoid: string; name: string | null } | null + creditedBy: { id: string; name: string; handle: string | null; official: boolean } | null + operator: { id: string; name: string } | null + note: string | null + voidedAt: Date | null + voidedBy: { id: string; name: string } | null + voidReason: string | null +} + +export interface OperatorLedgerTotals { + totalHours: number + liveEntries: number + voidedEntries: number +} + export interface LogEventHoursResult { credited: number changed: EventCreditChange[] @@ -122,6 +194,7 @@ export interface EventHoursLedgerEntry { userId: string hours: number loggedAt: Date + creditedByOfficial: boolean } export interface EventHoursLedger { @@ -213,6 +286,13 @@ export interface VolunteerHoursRepository { listEventHours(cleanupId: string, viewerId: string | null): Promise hoursVisibilityFor(userId: string): Promise entriesForCertificate(args: EntriesForCertificateArgs): Promise + creditEventAsOperator(args: OperatorEventCreditArgs): Promise + creditManual(args: OperatorManualCreditArgs): Promise + voidEntry(args: OperatorVoidArgs): Promise + listOperatorLedger( + args: OperatorLedgerArgs, + ): Promise<{ items: OperatorLedgerEntryView[]; nextCursor: string | null }> + operatorLedgerTotals(userId: string): Promise } export interface CleanupHoursView { diff --git a/services/api/test/integration/admin-user-hours-pg.test.ts b/services/api/test/integration/admin-user-hours-pg.test.ts new file mode 100644 index 00000000..92f9f301 --- /dev/null +++ b/services/api/test/integration/admin-user-hours-pg.test.ts @@ -0,0 +1,535 @@ +import { afterAll, beforeAll, describe, expect, it } from "vitest" +import { randomUUID } from "node:crypto" +import { withPg, type PgHarness } from "../helpers/pg.js" +import { seedCleanup } from "../helpers/cleanups.js" +import { CIVFIX_OFFICIAL_USER_ID } from "../../src/auth/official-account.js" +import { parseTimeCursor } from "../../src/db/cursor-helpers.js" +import { buildTranscriptModel } from "../../src/services/certificate-model.js" +import { makeDrizzleCertificateRepository } from "../../src/services/certificate-repository.drizzle.js" +import { makeDrizzleVolunteerHoursRepository } from "../../src/services/volunteer-hours-repository.drizzle.js" +import { LA_CITY } from "../../src/db/seed-fixtures.js" + +const GEOID = LA_CITY.geoid +const PG_DEADLOCK = "40P01" + +const pg = await withPg() + +describe.skipIf(!pg)("operator volunteer-hours ledger (integration)", () => { + let h: PgHarness + + beforeAll(() => { + h = pg as PgHarness + }) + + afterAll(async () => { + await h.teardown() + }) + + async function newUser(name: string): Promise { + const [u] = await h.sql<{ id: string }[]>` + INSERT INTO users (display_name) VALUES (${name}) RETURNING id + ` + return u!.id + } + + async function newDoneCleanup(organizerId: string, scheduledAt: string): Promise { + const startsAt = new Date(scheduledAt) + const endsAt = new Date(startsAt.getTime() + 4 * 60 * 60 * 1000) + return await seedCleanup(h.sql, { + organizerUserId: organizerId, + title: "Operator ledger sweep", + lng: -118.25, + lat: 34.05, + scheduledAt: startsAt, + endsAt, + completedAt: endsAt, + status: "done", + jurisdictionGeoid: GEOID, + }) + } + + async function row(entryId: string) { + const [r] = await h.sql< + { + source: string + hours: number + cleanup_id: string | null + jurisdiction_geoid: string | null + logged_by_user_id: string | null + credited_by_operator_id: string | null + note: string | null + service_date: string | null + voided_at: Date | null + voided_by_operator_id: string | null + void_reason: string | null + }[] + >` + SELECT source, hours::float8 AS hours, cleanup_id, jurisdiction_geoid, logged_by_user_id, + credited_by_operator_id, note, service_date::text AS service_date, voided_at, + voided_by_operator_id, void_reason + FROM volunteer_hours WHERE id = ${entryId} + ` + return r! + } + + async function journalFor(cleanupId: string, userId: string) { + return h.sql<{ actor: string; previous_hours: number | null; new_hours: number }[]>` + SELECT actor_user_id AS actor, previous_hours::float8 AS previous_hours, + new_hours::float8 AS new_hours + FROM volunteer_hours_audit + WHERE cleanup_id = ${cleanupId} AND user_id = ${userId} + ORDER BY created_at ASC, id ASC + ` + } + + async function auditLogFor(userId: string) { + return h.sql<{ actor_id: string; action: string; meta: Record }[]>` + SELECT actor_id, action, meta FROM audit_log + WHERE target = ${`user:${userId}`} + ORDER BY created_at ASC, id ASC + ` + } + + async function rollupAt(userId: string): Promise { + const rows = await h.sql<{ total: number }[]>` + SELECT total_hours::float8 AS total FROM user_jurisdiction_hours + WHERE user_id = ${userId} AND jurisdiction_geoid = ${GEOID} + ` + return rows[0]?.total ?? 0 + } + + /** Rollup rows of these users that disagree with the SUM of their live ledger rows. */ + async function driftFor(userIds: string[]): Promise<{ user_id: string; geoid: string }[]> { + return h.sql<{ user_id: string; geoid: string }[]>` + SELECT ujh.user_id, ujh.jurisdiction_geoid AS geoid + FROM user_jurisdiction_hours ujh + WHERE ujh.user_id = ANY(${userIds}::uuid[]) + AND ujh.total_hours <> COALESCE(( + SELECT SUM(vh.hours) FROM volunteer_hours vh + WHERE vh.user_id = ujh.user_id + AND vh.jurisdiction_geoid = ujh.jurisdiction_geoid + AND vh.voided_at IS NULL + ), 0) + UNION ALL + SELECT vh.user_id, vh.jurisdiction_geoid + FROM volunteer_hours vh + WHERE vh.user_id = ANY(${userIds}::uuid[]) + AND vh.voided_at IS NULL + AND vh.jurisdiction_geoid IS NOT NULL + AND NOT EXISTS ( + SELECT 1 FROM user_jurisdiction_hours ujh + WHERE ujh.user_id = vh.user_id AND ujh.jurisdiction_geoid = vh.jurisdiction_geoid + ) + ` + } + + it("an operator event credit is attributed to CivFix, journaled and audited as the operator, atomically", async () => { + const host = await newUser("Op Host") + const alice = await newUser("Op Alice") + const operator = await newUser("Op Olive") + const cleanupId = await newDoneCleanup(host, "2026-07-04T17:00:00Z") + const repo = makeDrizzleVolunteerHoursRepository(h.sql) + + const { entryId } = await repo.creditEventAsOperator({ + operatorId: operator, + userId: alice, + cleanupId, + geoid: GEOID, + hours: 3, + reason: "Signed up after the event", + }) + + expect(await row(entryId)).toMatchObject({ + source: "event", + hours: 3, + cleanup_id: cleanupId, + jurisdiction_geoid: GEOID, + logged_by_user_id: CIVFIX_OFFICIAL_USER_ID, + credited_by_operator_id: operator, + note: "Signed up after the event", + service_date: null, + voided_at: null, + }) + expect(await rollupAt(alice)).toBe(3) + expect(await journalFor(cleanupId, alice)).toEqual([ + { actor: operator, previous_hours: null, new_hours: 3 }, + ]) + expect(await auditLogFor(alice)).toEqual([ + { + actor_id: operator, + action: "user.hours_credited", + meta: { + entryId, + source: "event", + cleanupId, + hours: 3, + reason: "Signed up after the event", + }, + }, + ]) + const hostSheet = await repo.listEventHours(cleanupId, null) + expect(hostSheet.entries.map((e) => [e.userId, e.creditedByOfficial])).toEqual([[alice, true]]) + + await expect( + repo.creditEventAsOperator({ + operatorId: operator, + userId: alice, + cleanupId, + geoid: GEOID, + hours: 1, + reason: "Again", + }), + ).rejects.toMatchObject({ code: "CONFLICT" }) + expect(await auditLogFor(alice)).toHaveLength(1) + }) + + it("void journals (h, 0), leaves the rollup exact, and a re-credit revives the row as previous 0", async () => { + const host = await newUser("Void Host") + const alice = await newUser("Void Alice") + const operator = await newUser("Void Olive") + const cleanupId = await newDoneCleanup(host, "2026-07-06T17:00:00Z") + const repo = makeDrizzleVolunteerHoursRepository(h.sql) + await repo.logEventHours({ + actorId: host, + cleanupId, + geoid: GEOID, + entries: [{ userId: alice, hours: 3 }], + }) + const [hostRow] = (await repo.listOperatorLedger({ userId: alice, cursor: null, limit: 10 })) + .items + + const voided = await repo.voidEntry({ + operatorId: operator, + userId: alice, + entryId: hostRow!.id, + reason: "Was not there", + }) + expect(voided).toEqual({ id: hostRow!.id, source: "event", cleanupId, hours: 3 }) + expect(await row(hostRow!.id)).toMatchObject({ + voided_by_operator_id: operator, + void_reason: "Was not there", + }) + expect((await row(hostRow!.id)).voided_at).toBeInstanceOf(Date) + expect(await rollupAt(alice)).toBe(0) + + await expect( + repo.voidEntry({ operatorId: operator, userId: alice, entryId: hostRow!.id, reason: "x" }), + ).rejects.toMatchObject({ code: "CONFLICT" }) + await expect( + repo.voidEntry({ operatorId: operator, userId: host, entryId: hostRow!.id, reason: "x" }), + ).rejects.toMatchObject({ code: "NOT_FOUND" }) + await expect( + repo.voidEntry({ operatorId: operator, userId: alice, entryId: randomUUID(), reason: "x" }), + ).rejects.toMatchObject({ code: "NOT_FOUND" }) + + const { entryId } = await repo.creditEventAsOperator({ + operatorId: operator, + userId: alice, + cleanupId, + geoid: GEOID, + hours: 2, + reason: "Came for the second half", + }) + expect(entryId).toBe(hostRow!.id) + expect(await row(entryId)).toMatchObject({ + voided_at: null, + voided_by_operator_id: null, + void_reason: null, + credited_by_operator_id: operator, + logged_by_user_id: CIVFIX_OFFICIAL_USER_ID, + }) + expect(await rollupAt(alice)).toBe(2) + expect(await journalFor(cleanupId, alice)).toEqual([ + { actor: host, previous_hours: null, new_hours: 3 }, + { actor: operator, previous_hours: 3, new_hours: 0 }, + { actor: operator, previous_hours: 0, new_hours: 2 }, + ]) + expect((await auditLogFor(alice)).map((a) => a.action)).toEqual([ + "user.hours_voided", + "user.hours_credited", + ]) + expect(await driftFor([alice])).toEqual([]) + }) + + it("a manual credit stores its service date, stays out of the rollup and dates the transcript row", async () => { + const alice = await newUser("Manual Alice") + const operator = await newUser("Manual Olive") + const repo = makeDrizzleVolunteerHoursRepository(h.sql) + + const { entryId } = await repo.creditManual({ + operatorId: operator, + userId: alice, + hours: 1.5, + serviceDate: "2026-06-30", + reason: "Library fair", + }) + + expect(await row(entryId)).toMatchObject({ + source: "manual", + cleanup_id: null, + jurisdiction_geoid: null, + service_date: "2026-06-30", + logged_by_user_id: CIVFIX_OFFICIAL_USER_ID, + credited_by_operator_id: operator, + note: "Library fair", + }) + expect(await rollupAt(alice)).toBe(0) + expect(await repo.totalHoursFor(alice)).toBe(1.5) + const serviceNoon = new Date("2026-06-30T12:00:00.000Z") + const [listed] = (await repo.listEntries({ userId: alice, cursor: null, limit: 10 })).items + expect(listed!.occurredAt).toEqual(serviceNoon) + const transcript = await repo.entriesForCertificate({ + userId: alice, + geoid: null, + from: null, + to: null, + limit: 10, + }) + expect(transcript.items.map((i) => i.occurredAt)).toEqual([serviceNoon]) + + await expect( + repo.creditManual({ + operatorId: operator, + userId: alice, + hours: 1.5, + serviceDate: "2026-06-30", + reason: "Library fair", + }), + ).rejects.toMatchObject({ code: "CONFLICT" }) + }) + + it("the CHECKs pin service_date to manual rows and bound void_reason", async () => { + const alice = await newUser("Check Alice") + await expect(h.sql` + INSERT INTO volunteer_hours (user_id, hours, source) VALUES (${alice}, 1, 'manual') + `).rejects.toMatchObject({ constraint_name: "volunteer_hours_service_date_chk" }) + await expect(h.sql` + INSERT INTO volunteer_hours (user_id, hours, source, service_date) + VALUES (${alice}, 1, 'event', '2026-07-01') + `).rejects.toMatchObject({ constraint_name: "volunteer_hours_service_date_chk" }) + await expect(h.sql` + INSERT INTO volunteer_hours (user_id, hours, source, service_date, void_reason) + VALUES (${alice}, 1, 'manual', '2026-07-01', ${"x".repeat(1001)}) + `).rejects.toMatchObject({ constraint_name: "volunteer_hours_void_reason_len_chk" }) + }) + + it("the daily cap sums event rows on the event's local date with manual rows on that service date", async () => { + const host = await newUser("Cap Host") + const alice = await newUser("Cap Alice") + const operator = await newUser("Cap Olive") + const morning = await newDoneCleanup(host, "2026-07-08T17:00:00Z") + const lateEvening = await newDoneCleanup(host, "2026-07-09T05:00:00Z") + const repo = makeDrizzleVolunteerHoursRepository(h.sql) + + await repo.creditManual({ + operatorId: operator, + userId: alice, + hours: 20, + serviceDate: "2026-07-08", + reason: "Warehouse sort", + }) + + await expect( + repo.logEventHours({ + actorId: host, + cleanupId: morning, + geoid: GEOID, + entries: [{ userId: alice, hours: 5 }], + }), + ).rejects.toMatchObject({ code: "CONFLICT" }) + await expect( + repo.creditEventAsOperator({ + operatorId: operator, + userId: alice, + cleanupId: lateEvening, + geoid: GEOID, + hours: 5, + reason: "Evening shift", + }), + ).rejects.toMatchObject({ code: "CONFLICT" }) + + await repo.logEventHours({ + actorId: host, + cleanupId: morning, + geoid: GEOID, + entries: [{ userId: alice, hours: 4 }], + }) + await expect( + repo.creditManual({ + operatorId: operator, + userId: alice, + hours: 0.5, + serviceDate: "2026-07-08", + reason: "Cleanup of the sort", + }), + ).rejects.toMatchObject({ code: "CONFLICT" }) + await repo.creditManual({ + operatorId: operator, + userId: alice, + hours: 20, + serviceDate: "2026-07-09", + reason: "Next day", + }) + }) + + it("concurrent host credits, operator credits and voids on one event never deadlock or drift", async () => { + const host = await newUser("Race Host") + const operator = await newUser("Race Olive") + const attendees = await Promise.all( + ["Race A", "Race B", "Race C", "Race D"].map((name) => newUser(name)), + ) + const [a, b, c, d] = attendees as [string, string, string, string] + const repo = makeDrizzleVolunteerHoursRepository(h.sql) + + for (let round = 0; round < 6; round++) { + const cleanupId = await newDoneCleanup( + host, + `2026-08-${String(round + 1).padStart(2, "0")}T17:00:00Z`, + ) + await repo.logEventHours({ + actorId: host, + cleanupId, + geoid: GEOID, + entries: [{ userId: b, hours: 2 }], + }) + const bRow = (await repo.listOperatorLedger({ userId: b, cursor: null, limit: 1 })).items[0]! + + const outcomes = await Promise.allSettled([ + repo.logEventHours({ + actorId: host, + cleanupId, + geoid: GEOID, + entries: [ + { userId: d, hours: 3 }, + { userId: a, hours: 1 }, + { userId: b, hours: 2.5 }, + ], + }), + repo.creditEventAsOperator({ + operatorId: operator, + userId: c, + cleanupId, + geoid: GEOID, + hours: 2, + reason: `round ${round}`, + }), + repo.voidEntry({ + operatorId: operator, + userId: b, + entryId: bRow.id, + reason: `round ${round}`, + }), + repo.creditManual({ + operatorId: operator, + userId: a, + hours: 1, + serviceDate: `2026-08-${String(round + 1).padStart(2, "0")}`, + reason: `round ${round}`, + }), + ]) + + // Whichever of the void and the host's re-save of B wins the locks, both succeed: the void + // takes whatever live hours B holds, and a later re-save revives the row. + const failures = outcomes.flatMap((o) => (o.status === "rejected" ? [o.reason] : [])) + expect(failures.map((err) => (err as { code?: string }).code)).not.toContain(PG_DEADLOCK) + expect(failures).toEqual([]) + } + + expect(await driftFor(attendees)).toEqual([]) + for (const userId of attendees) { + const totals = await repo.operatorLedgerTotals(userId) + const [ledger] = await h.sql<{ total: number }[]>` + SELECT COALESCE(SUM(hours), 0)::float8 AS total FROM volunteer_hours + WHERE user_id = ${userId} AND voided_at IS NULL AND source <> 'report' + ` + expect(totals.totalHours).toBe(ledger!.total) + } + }) + + it("pages the operator ledger by (created_at, id), voided rows included, with live and voided counts", async () => { + const alice = await newUser("Page Alice") + const operator = await newUser("Page Olive") + const repo = makeDrizzleVolunteerHoursRepository(h.sql) + const ids: string[] = [] + for (let day = 1; day <= 5; day++) { + const { entryId } = await repo.creditManual({ + operatorId: operator, + userId: alice, + hours: 1, + serviceDate: `2026-05-0${day}`, + reason: `day ${day}`, + }) + ids.unshift(entryId) + } + await repo.voidEntry({ operatorId: operator, userId: alice, entryId: ids[1]!, reason: "dup" }) + + const first = await repo.listOperatorLedger({ userId: alice, cursor: null, limit: 3 }) + const second = await repo.listOperatorLedger({ + userId: alice, + cursor: parseTimeCursor(first.nextCursor!), + limit: 3, + }) + expect([...first.items, ...second.items].map((i) => i.id)).toEqual(ids) + expect(second.nextCursor).toBeNull() + expect(first.items[1]).toMatchObject({ + voidedBy: { id: operator, name: "Page Olive" }, + voidReason: "dup", + operator: { id: operator, name: "Page Olive" }, + creditedBy: { id: CIVFIX_OFFICIAL_USER_ID, official: true }, + }) + expect(await repo.operatorLedgerTotals(alice)).toEqual({ + totalHours: 4, + liveEntries: 4, + voidedEntries: 1, + }) + }) + + it("finds the live certificates whose snapshot itemised an entry", async () => { + const alice = await newUser("Cert Alice") + const certs = makeDrizzleCertificateRepository(h.sql) + const entry = randomUUID() + + async function issue(code: string, entryIds: string[], issuedAt: string): Promise { + await certs.insert({ + id: randomUUID(), + userId: alice, + code, + locale: "en", + holderName: "Cert Alice", + holderHandle: null, + holderVerified: false, + totalHours: entryIds.length, + entryCount: entryIds.length, + periodStart: null, + periodEnd: null, + ledgerFingerprint: `fp-${code}`, + snapshot: buildTranscriptModel({ + holder: { userId: alice, displayName: "Cert Alice", handle: null }, + rows: entryIds.map((id) => ({ + id, + source: "event" as const, + hours: 1, + occurredAt: new Date(issuedAt), + })), + locale: "en", + }), + r2Key: `certificates/${code}.pdf`, + documentSha256: "0".repeat(64), + byteSize: 1, + issuedAt: new Date(issuedAt), + }) + } + + const suffix = randomUUID().slice(0, 6).toUpperCase() + await issue(`A${suffix}`, [entry], "2026-07-01T00:00:00Z") + await issue(`B${suffix}`, [randomUUID(), entry], "2026-07-02T00:00:00Z") + await issue(`C${suffix}`, [randomUUID()], "2026-07-03T00:00:00Z") + await issue(`D${suffix}`, [entry], "2026-07-04T00:00:00Z") + await certs.revoke(alice, `D${suffix}`, "ledger_corrected", new Date()) + + expect(await certs.liveCodesListingEntry(alice, entry)).toEqual([ + { code: `B${suffix}`, issuedAt: new Date("2026-07-02T00:00:00Z") }, + { code: `A${suffix}`, issuedAt: new Date("2026-07-01T00:00:00Z") }, + ]) + }) +}) diff --git a/services/api/test/unit/volunteer-hours-entries.test.ts b/services/api/test/unit/volunteer-hours-entries.test.ts index c8266509..b6319ea8 100644 --- a/services/api/test/unit/volunteer-hours-entries.test.ts +++ b/services/api/test/unit/volunteer-hours-entries.test.ts @@ -161,7 +161,7 @@ describe("hours ledger: getMyHoursEntries keyset paging", () => { expect(all.items.map((e) => e.source).sort()).toEqual(["event", "event"]) const voided = all.items.find((e) => e.eventTitle === "Voided")! - repo.voidEntry(voided.id) + repo.markVoided(voided.id) const after = await service.getMyHoursEntries(BOB, {}) expect(after.items.map((e) => e.source)).toEqual(["event"]) expect(after.items.map((e) => e.eventTitle)).toEqual(["Kept"]) @@ -514,7 +514,7 @@ describe("hours ledger: getEventHours scope matrix (C10)", () => { const ledger = await repo.listEventHours(EVENT, null) expect(ledger.entries).toHaveLength(2) const owned = await service.getMyHoursEntries(BOB, {}) - repo.voidEntry(owned.items[0]!.id) + repo.markVoided(owned.items[0]!.id) const after = await service.getEventHours(EVENT, HOST) expect(after.entries.map((e) => e.userId)).toEqual([CAROL]) diff --git a/services/api/test/unit/volunteer-hours-operator-ledger.test.ts b/services/api/test/unit/volunteer-hours-operator-ledger.test.ts new file mode 100644 index 00000000..8c0bdc11 --- /dev/null +++ b/services/api/test/unit/volunteer-hours-operator-ledger.test.ts @@ -0,0 +1,631 @@ +import { describe, expect, it } from "vitest" +import { CIVFIX_OFFICIAL_USER_ID } from "../../src/auth/official-account.js" +import { parseTimeCursor } from "../../src/db/cursor-helpers.js" +import { buildTranscriptModel } from "../../src/services/certificate-model.js" +import { InMemoryCertificateRepository } from "../../src/services/certificate-repository.memory.js" +import { InMemoryVolunteerHoursRepository } from "../../src/services/volunteer-hours-repository.memory.js" +import { + DAILY_HOURS_CAP, + MANUAL_CREDIT_REPEAT_WINDOW_MS, +} from "../../src/services/volunteer-hours-service.js" + +const HOST = "11111111-1111-1111-1111-111111111111" +const BOB = "22222222-2222-2222-2222-222222222222" +const CAROL = "33333333-3333-3333-3333-333333333333" +const OPERATOR = "99999999-9999-4999-8999-999999999999" +const OTHER_OPERATOR = "88888888-8888-4888-8888-888888888888" +const EVENT = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbb1" +const SAME_DAY_EVENT = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbb2" +const ORG = "cccccccc-cccc-4ccc-8ccc-cccccccccccc" +const REPORT = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" +const GEOID = "0644000" +const EVENT_DAY = "2026-07-04" +const SCHEDULED_AT = new Date(`${EVENT_DAY}T08:00:00.000Z`) + +function makeClock(start: string): { now: () => Date; advance: (ms: number) => void } { + let at = Date.parse(start) + return { + now: () => new Date(at), + advance: (ms) => { + at += ms + }, + } +} + +function makeRepo(clock = makeClock("2026-07-06T10:00:00.000Z")): { + repo: InMemoryVolunteerHoursRepository + clock: ReturnType +} { + let n = 0 + const repo = new InMemoryVolunteerHoursRepository({ + now: clock.now, + newId: () => `00000000-0000-4000-8000-${String(++n).padStart(12, "0")}`, + }) + repo.seedJurisdiction(GEOID, "Los Angeles") + repo.seedUser(HOST, { name: "Hana Host", handle: "hana", avatarUrl: null }) + repo.seedUser(BOB, { name: "Bob", handle: "bob", avatarUrl: null }) + repo.seedUser(OPERATOR, { name: "Olive Operator", handle: "olive", avatarUrl: null }) + repo.seedUser(CIVFIX_OFFICIAL_USER_ID, { name: "CivFix", handle: "civfix", avatarUrl: null }) + repo.seedOrganization({ id: ORG, slug: "beach-crew", name: "Beach Crew" }) + repo.seedCleanup(EVENT, { + title: "Ocean Beach sweep", + referenceCode: "EV-1", + scheduledAt: SCHEDULED_AT, + timezone: null, + organizationId: ORG, + }) + repo.seedCleanup(SAME_DAY_EVENT, { + title: "Afternoon sweep", + referenceCode: "EV-2", + scheduledAt: new Date(`${EVENT_DAY}T15:00:00.000Z`), + timezone: null, + }) + return { repo, clock } +} + +function eventCredit(hours: number, overrides: { userId?: string; reason?: string } = {}) { + return { + operatorId: OPERATOR, + userId: overrides.userId ?? BOB, + cleanupId: EVENT, + geoid: GEOID, + hours, + reason: overrides.reason ?? "Attended before signing up", + } +} + +function manualCredit(hours: number, serviceDate = "2026-07-05", operatorId = OPERATOR) { + return { + operatorId, + userId: BOB, + hours, + serviceDate, + reason: "Tabling at the library fair", + } +} + +describe("operator event credit", () => { + it("writes a real event row attributed to CivFix that counts everywhere an event credit does", async () => { + const { repo } = makeRepo() + + const { entryId } = await repo.creditEventAsOperator(eventCredit(3)) + + const totals = await repo.totalsFor(BOB) + expect(totals.totalHours).toBe(3) + expect(totals.byJurisdiction).toEqual([{ geoid: GEOID, name: "Los Angeles", hours: 3 }]) + expect(totals.byOrganization.map((o) => [o.organizationId, o.hours])).toEqual([[ORG, 3]]) + + const board = await repo.leaderboard(GEOID, 10, 0, null, false) + expect(board.entries.map((e) => [e.userId, e.hours])).toEqual([[BOB, 3]]) + + const publicList = await repo.listEntries({ + userId: BOB, + cursor: null, + limit: 10, + sources: ["event"], + }) + expect(publicList.items.map((e) => [e.id, e.cleanupId, e.creditedBy?.id])).toEqual([ + [entryId, EVENT, CIVFIX_OFFICIAL_USER_ID], + ]) + + const hostView = await repo.listEventHours(EVENT, null) + expect(hostView.entries).toEqual([ + expect.objectContaining({ userId: BOB, hours: 3, creditedByOfficial: true }), + ]) + }) + + it("journals and audits the human operator, not the official account", async () => { + const { repo } = makeRepo() + + const { entryId } = await repo.creditEventAsOperator(eventCredit(2.5)) + + expect(repo.journal).toEqual([ + { cleanupId: EVENT, userId: BOB, actorUserId: OPERATOR, previousHours: null, newHours: 2.5 }, + ]) + expect(repo.audits).toEqual([ + { + actorId: OPERATOR, + action: "user.hours_credited", + target: `user:${BOB}`, + meta: { + entryId, + source: "event", + cleanupId: EVENT, + hours: 2.5, + reason: "Attended before signing up", + }, + }, + ]) + }) + + it("refuses a second credit while the attendee holds a live row for the event", async () => { + const { repo } = makeRepo() + await repo.logEventHours({ + actorId: HOST, + cleanupId: EVENT, + geoid: GEOID, + entries: [{ userId: BOB, hours: 3 }], + }) + + await expect(repo.creditEventAsOperator(eventCredit(2))).rejects.toMatchObject({ + code: "CONFLICT", + message: expect.stringContaining("already holds 3 h for this event; void that entry first"), + }) + expect((await repo.totalsFor(BOB)).totalHours).toBe(3) + expect(repo.audits).toEqual([]) + }) + + it("holds the daily cap across events and manual adjustments on the event's local date", async () => { + const { repo } = makeRepo() + await repo.creditManual(manualCredit(20, EVENT_DAY)) + + await expect(repo.creditEventAsOperator(eventCredit(5))).rejects.toMatchObject({ + code: "CONFLICT", + message: expect.stringContaining( + `already holds 20 h on this date; the daily limit is ${DAILY_HOURS_CAP} h`, + ), + }) + await repo.creditEventAsOperator(eventCredit(4)) + expect((await repo.totalsFor(BOB)).totalHours).toBe(24) + }) + + it("applies the same combined cap to a host's credit", async () => { + const { repo } = makeRepo() + await repo.creditManual(manualCredit(22, EVENT_DAY)) + + await expect( + repo.logEventHours({ + actorId: HOST, + cleanupId: SAME_DAY_EVENT, + geoid: GEOID, + entries: [{ userId: BOB, hours: 3 }], + }), + ).rejects.toMatchObject({ code: "CONFLICT" }) + await repo.logEventHours({ + actorId: HOST, + cleanupId: SAME_DAY_EVENT, + geoid: GEOID, + entries: [{ userId: BOB, hours: 2 }], + }) + }) + + it("revives a voided row in place: same id, exact rollup, new timestamp and attribution", async () => { + const { repo, clock } = makeRepo() + await repo.logEventHours({ + actorId: HOST, + cleanupId: EVENT, + geoid: GEOID, + entries: [{ userId: BOB, hours: 3 }], + }) + const [hostRow] = (await repo.listOperatorLedger({ userId: BOB, cursor: null, limit: 10 })) + .items + await repo.voidEntry({ + operatorId: OTHER_OPERATOR, + userId: BOB, + entryId: hostRow!.id, + reason: "Wrong person", + }) + clock.advance(3_600_000) + + const { entryId } = await repo.creditEventAsOperator(eventCredit(2)) + + expect(entryId).toBe(hostRow!.id) + const totals = await repo.totalsFor(BOB) + expect(totals.totalHours).toBe(2) + expect(totals.byJurisdiction).toEqual([{ geoid: GEOID, name: "Los Angeles", hours: 2 }]) + const [revived] = (await repo.listOperatorLedger({ userId: BOB, cursor: null, limit: 10 })) + .items + expect(revived).toMatchObject({ + id: entryId, + hours: 2, + createdAt: clock.now(), + creditedBy: { id: CIVFIX_OFFICIAL_USER_ID, official: true }, + operator: { id: OPERATOR, name: "Olive Operator" }, + note: "Attended before signing up", + voidedAt: null, + voidedBy: null, + voidReason: null, + }) + expect(repo.journal.map((j) => [j.actorUserId, j.previousHours, j.newHours])).toEqual([ + [HOST, null, 3], + [OTHER_OPERATOR, 3, 0], + [OPERATOR, 0, 2], + ]) + }) +}) + +describe("host writes over an operator credit", () => { + it("an unchanged host re-save keeps the operator's attribution", async () => { + const { repo } = makeRepo() + await repo.creditEventAsOperator(eventCredit(3)) + + const result = await repo.logEventHours({ + actorId: HOST, + cleanupId: EVENT, + geoid: GEOID, + entries: [{ userId: BOB, hours: 3 }], + }) + + expect(result.changed).toEqual([]) + const [row] = (await repo.listOperatorLedger({ userId: BOB, cursor: null, limit: 10 })).items + expect(row).toMatchObject({ creditedBy: { official: true }, operator: { id: OPERATOR } }) + }) + + it("a host change takes the row over: the operator and the reason no longer describe it", async () => { + const { repo } = makeRepo() + await repo.creditEventAsOperator(eventCredit(3)) + + await repo.logEventHours({ + actorId: HOST, + cleanupId: EVENT, + geoid: GEOID, + entries: [{ userId: BOB, hours: 2 }], + }) + + const [row] = (await repo.listOperatorLedger({ userId: BOB, cursor: null, limit: 10 })).items + expect(row).toMatchObject({ + hours: 2, + creditedBy: { id: HOST, official: false }, + operator: null, + note: null, + }) + expect((await repo.listEventHours(EVENT, null)).entries[0]!.creditedByOfficial).toBe(false) + }) + + it("a host may re-credit an attendee whose entry an operator voided", async () => { + const { repo } = makeRepo() + const { entryId } = await repo.creditEventAsOperator(eventCredit(3)) + await repo.voidEntry({ operatorId: OPERATOR, userId: BOB, entryId, reason: "Duplicate" }) + + const result = await repo.logEventHours({ + actorId: HOST, + cleanupId: EVENT, + geoid: GEOID, + entries: [{ userId: BOB, hours: 3 }], + }) + + expect(result.changed).toEqual([{ userId: BOB, hours: 3, previousHours: 0 }]) + const [row] = (await repo.listOperatorLedger({ userId: BOB, cursor: null, limit: 10 })).items + expect(row).toMatchObject({ voidedAt: null, voidedBy: null, voidReason: null, operator: null }) + expect((await repo.totalsFor(BOB)).byJurisdiction).toEqual([ + { geoid: GEOID, name: "Los Angeles", hours: 3 }, + ]) + }) +}) + +describe("operator manual credit", () => { + it("has no jurisdiction, counts in the total only, and is dated by its service date", async () => { + const { repo } = makeRepo() + + const { entryId } = await repo.creditManual(manualCredit(1.5, "2026-06-30")) + + const totals = await repo.totalsFor(BOB) + expect(totals.totalHours).toBe(1.5) + expect(totals.byJurisdiction).toEqual([]) + expect((await repo.leaderboard(GEOID, 10, 0, null, false)).entries).toEqual([]) + + const serviceNoon = new Date("2026-06-30T12:00:00.000Z") + const [listed] = (await repo.listEntries({ userId: BOB, cursor: null, limit: 10 })).items + expect(listed).toMatchObject({ id: entryId, source: "manual", jurisdictionGeoid: null }) + expect(listed!.occurredAt).toEqual(serviceNoon) + const transcript = await repo.entriesForCertificate({ + userId: BOB, + geoid: null, + from: null, + to: null, + limit: 10, + }) + expect(transcript.items.map((i) => [i.id, i.occurredAt])).toEqual([[entryId, serviceNoon]]) + + expect(repo.audits).toEqual([ + { + actorId: OPERATOR, + action: "user.hours_credited", + target: `user:${BOB}`, + meta: { + entryId, + source: "manual", + hours: 1.5, + serviceDate: "2026-06-30", + reason: "Tabling at the library fair", + }, + }, + ]) + expect(repo.journal).toEqual([]) + }) + + it("refuses the same adjustment from the same operator inside the double-submit window", async () => { + const { repo, clock } = makeRepo() + await repo.creditManual(manualCredit(2)) + + await expect(repo.creditManual(manualCredit(2))).rejects.toMatchObject({ code: "CONFLICT" }) + await repo.creditManual(manualCredit(2.5)) + await repo.creditManual(manualCredit(2, "2026-07-01")) + await repo.creditManual(manualCredit(2, "2026-07-05", OTHER_OPERATOR)) + + clock.advance(MANUAL_CREDIT_REPEAT_WINDOW_MS) + await repo.creditManual(manualCredit(2)) + expect((await repo.totalsFor(BOB)).totalHours).toBe(10.5) + }) + + it("caps the service date across manual rows and event rows dated that day", async () => { + const { repo } = makeRepo() + await repo.logEventHours({ + actorId: HOST, + cleanupId: EVENT, + geoid: GEOID, + entries: [{ userId: BOB, hours: 10 }], + }) + await repo.creditManual(manualCredit(10, EVENT_DAY)) + + await expect(repo.creditManual(manualCredit(4.5, EVENT_DAY))).rejects.toMatchObject({ + code: "CONFLICT", + message: expect.stringContaining("already holds 20 h on this date"), + }) + await repo.creditManual(manualCredit(4, EVENT_DAY)) + await repo.creditManual(manualCredit(20, "2026-07-05")) + }) +}) + +describe("operator void", () => { + it("voids an event row: out of the rollup, journaled (h, 0), audited with the reason", async () => { + const { repo } = makeRepo() + await repo.logEventHours({ + actorId: HOST, + cleanupId: EVENT, + geoid: GEOID, + entries: [{ userId: BOB, hours: 3 }], + }) + const [row] = (await repo.listOperatorLedger({ userId: BOB, cursor: null, limit: 10 })).items + + const voided = await repo.voidEntry({ + operatorId: OPERATOR, + userId: BOB, + entryId: row!.id, + reason: "Did not attend", + }) + + expect(voided).toEqual({ id: row!.id, source: "event", cleanupId: EVENT, hours: 3 }) + const totals = await repo.totalsFor(BOB) + expect(totals.totalHours).toBe(0) + expect(totals.byJurisdiction).toEqual([]) + expect((await repo.listEventHours(EVENT, null)).entries).toEqual([]) + expect(repo.journal.at(-1)).toEqual({ + cleanupId: EVENT, + userId: BOB, + actorUserId: OPERATOR, + previousHours: 3, + newHours: 0, + }) + expect(repo.audits.at(-1)).toEqual({ + actorId: OPERATOR, + action: "user.hours_voided", + target: `user:${BOB}`, + meta: { + entryId: row!.id, + source: "event", + cleanupId: EVENT, + hours: 3, + reason: "Did not attend", + }, + }) + }) + + it("voids a manual row without journaling it", async () => { + const { repo } = makeRepo() + const { entryId } = await repo.creditManual(manualCredit(2)) + + const voided = await repo.voidEntry({ + operatorId: OPERATOR, + userId: BOB, + entryId, + reason: "Typo", + }) + + expect(voided).toEqual({ id: entryId, source: "manual", cleanupId: null, hours: 2 }) + expect((await repo.totalsFor(BOB)).totalHours).toBe(0) + expect(repo.journal).toEqual([]) + }) + + it("answers NOT_FOUND for an unknown entry and for another user's entry", async () => { + const { repo } = makeRepo() + const { entryId } = await repo.creditManual(manualCredit(2)) + + await expect( + repo.voidEntry({ operatorId: OPERATOR, userId: CAROL, entryId, reason: "x" }), + ).rejects.toMatchObject({ code: "NOT_FOUND" }) + await expect( + repo.voidEntry({ operatorId: OPERATOR, userId: BOB, entryId: EVENT, reason: "x" }), + ).rejects.toMatchObject({ code: "NOT_FOUND" }) + expect((await repo.totalsFor(BOB)).totalHours).toBe(2) + }) + + it("refuses a second void and a retired report credit as CONFLICT", async () => { + const { repo } = makeRepo() + const { entryId } = await repo.creditManual(manualCredit(2)) + await repo.voidEntry({ operatorId: OPERATOR, userId: BOB, entryId, reason: "Typo" }) + const reportRow = repo.seedLegacyReportEntry(BOB, REPORT, GEOID) + repo.markVoided(reportRow) + + await expect( + repo.voidEntry({ operatorId: OPERATOR, userId: BOB, entryId, reason: "again" }), + ).rejects.toMatchObject({ code: "CONFLICT", message: expect.stringContaining("already void") }) + await expect( + repo.voidEntry({ operatorId: OPERATOR, userId: BOB, entryId: reportRow, reason: "x" }), + ).rejects.toMatchObject({ code: "CONFLICT" }) + expect(repo.audits.filter((a) => a.action === "user.hours_voided")).toHaveLength(1) + }) + + it("refuses to void a live report credit", async () => { + const { repo } = makeRepo() + const reportRow = repo.seedLegacyReportEntry(BOB, REPORT, GEOID) + + await expect( + repo.voidEntry({ operatorId: OPERATOR, userId: BOB, entryId: reportRow, reason: "x" }), + ).rejects.toMatchObject({ code: "CONFLICT", message: expect.stringContaining("retired") }) + }) +}) + +describe("operator ledger read", () => { + it("lists voided rows too, newest first, keyset-paged, with the admin-plane attribution", async () => { + const { repo, clock } = makeRepo() + await repo.logEventHours({ + actorId: HOST, + cleanupId: SAME_DAY_EVENT, + geoid: GEOID, + entries: [{ userId: BOB, hours: 1 }], + }) + clock.advance(1_000) + const event = await repo.creditEventAsOperator(eventCredit(3)) + clock.advance(1_000) + const manual = await repo.creditManual(manualCredit(2)) + clock.advance(1_000) + await repo.voidEntry({ + operatorId: OTHER_OPERATOR, + userId: BOB, + entryId: manual.entryId, + reason: "Typo", + }) + + const first = await repo.listOperatorLedger({ userId: BOB, cursor: null, limit: 2 }) + expect(first.items.map((i) => i.id)).toEqual([manual.entryId, event.entryId]) + expect(first.items[0]).toMatchObject({ + source: "manual", + serviceDate: "2026-07-05", + event: null, + jurisdiction: null, + creditedBy: { id: CIVFIX_OFFICIAL_USER_ID, name: "CivFix", handle: "civfix", official: true }, + operator: { id: OPERATOR, name: "Olive Operator" }, + note: "Tabling at the library fair", + voidedBy: { id: OTHER_OPERATOR, name: "" }, + voidReason: "Typo", + }) + expect(first.items[0]!.voidedAt).toBeInstanceOf(Date) + expect(first.items[1]).toMatchObject({ + event: { id: EVENT, title: "Ocean Beach sweep", referenceCode: "EV-1" }, + jurisdiction: { geoid: GEOID, name: "Los Angeles" }, + occurredAt: SCHEDULED_AT, + voidedAt: null, + }) + expect(first.nextCursor).not.toBeNull() + + const second = await repo.listOperatorLedger({ + userId: BOB, + cursor: parseTimeCursor(first.nextCursor!), + limit: 2, + }) + expect(second.items.map((i) => [i.source, i.creditedBy?.id, i.operator])).toEqual([ + ["event", HOST, null], + ]) + expect(second.nextCursor).toBeNull() + }) + + it("never returns more than the ledger page cap", async () => { + const { repo, clock } = makeRepo() + for (let day = 1; day <= 60; day++) { + await repo.creditManual( + manualCredit(0.25, `2026-05-${String((day % 28) + 1).padStart(2, "0")}`), + ) + clock.advance(MANUAL_CREDIT_REPEAT_WINDOW_MS) + } + + const page = await repo.listOperatorLedger({ userId: BOB, cursor: null, limit: 500 }) + expect(page.items).toHaveLength(50) + expect(page.nextCursor).not.toBeNull() + }) + + it("totals match the profile total and count live and voided entries", async () => { + const { repo } = makeRepo() + await repo.creditEventAsOperator(eventCredit(3)) + const manual = await repo.creditManual(manualCredit(2)) + await repo.creditManual(manualCredit(1, "2026-07-01")) + await repo.voidEntry({ + operatorId: OPERATOR, + userId: BOB, + entryId: manual.entryId, + reason: "Typo", + }) + + const totals = await repo.operatorLedgerTotals(BOB) + expect(totals).toEqual({ totalHours: 4, liveEntries: 2, voidedEntries: 1 }) + expect(totals.totalHours).toBe((await repo.totalsFor(BOB)).totalHours) + }) +}) + +describe("certificates listing a ledger entry", () => { + function certificate( + certs: InMemoryCertificateRepository, + input: { userId: string; code: string; entryIds: string[]; issuedAt: string }, + ) { + const snapshot = buildTranscriptModel({ + holder: { userId: input.userId, displayName: "Bob", handle: "bob" }, + rows: input.entryIds.map((id) => ({ + id, + source: "event" as const, + hours: 1, + occurredAt: SCHEDULED_AT, + })), + locale: "en", + }) + return certs.insert({ + id: `${input.code}-id`, + userId: input.userId, + code: input.code, + locale: "en", + holderName: "Bob", + holderHandle: "bob", + holderVerified: false, + totalHours: input.entryIds.length, + entryCount: input.entryIds.length, + periodStart: null, + periodEnd: null, + ledgerFingerprint: `fp-${input.code}`, + snapshot, + r2Key: `certs/${input.code}.pdf`, + documentSha256: "0".repeat(64), + byteSize: 1, + issuedAt: new Date(input.issuedAt), + }) + } + + it("names only the holder's live certificates whose snapshot itemised the entry, newest first", async () => { + const certs = new InMemoryCertificateRepository() + const entry = "00000000-0000-4000-8000-0000000000e1" + await certificate(certs, { + userId: BOB, + code: "OLD", + entryIds: [entry], + issuedAt: "2026-07-01T00:00:00Z", + }) + await certificate(certs, { + userId: BOB, + code: "NEW", + entryIds: ["x", entry], + issuedAt: "2026-07-03T00:00:00Z", + }) + await certificate(certs, { + userId: BOB, + code: "GONE", + entryIds: [entry], + issuedAt: "2026-07-02T00:00:00Z", + }) + await certificate(certs, { + userId: BOB, + code: "OTHER", + entryIds: ["x"], + issuedAt: "2026-07-04T00:00:00Z", + }) + await certificate(certs, { + userId: CAROL, + code: "CAROL", + entryIds: [entry], + issuedAt: "2026-07-05T00:00:00Z", + }) + await certs.revoke(BOB, "GONE", "holder", new Date("2026-07-06T00:00:00Z")) + + expect(await certs.liveCodesListingEntry(BOB, entry)).toEqual([ + { code: "NEW", issuedAt: new Date("2026-07-03T00:00:00Z") }, + { code: "OLD", issuedAt: new Date("2026-07-01T00:00:00Z") }, + ]) + }) +}) diff --git a/services/api/test/unit/volunteer-hours-service.test.ts b/services/api/test/unit/volunteer-hours-service.test.ts index c7e07710..f91c5df6 100644 --- a/services/api/test/unit/volunteer-hours-service.test.ts +++ b/services/api/test/unit/volunteer-hours-service.test.ts @@ -1178,7 +1178,7 @@ describe("#110: hours grouped by the organization that hosted the event", () => const page = await repo.listEntries({ userId: BOB, cursor: null, limit: 50 }) const orgAEntry = page.items.find((item) => item.cleanupId === CLEANUP_A1) expect(orgAEntry).toBeDefined() - repo.voidEntry(orgAEntry!.id) + repo.markVoided(orgAEntry!.id) const hours = await makeService({ repo, view: null }).getMyHours(BOB) expect(hours.byOrganization.map((row) => row.organization.id)).toEqual([ORG_B]) @@ -1366,8 +1366,8 @@ describe("volunteer hours: void-aware re-crediting and no-op re-saves", () => { await repo.logEventHours({ actorId: HOST, cleanupId: CLEANUP, geoid: GEOID_A, entries: flat([BOB], 3) }) const entry = await onlyEntry(repo, BOB) - repo.voidEntry(entry.id) - repo.voidEntry(entry.id) + repo.markVoided(entry.id) + repo.markVoided(entry.id) const totals = await repo.totalsFor(BOB) expect(totals.totalHours).toBe(0) @@ -1385,7 +1385,7 @@ describe("volunteer hours: void-aware re-crediting and no-op re-saves", () => { await service.logEventHours({ cleanupId: CLEANUP, actorId: HOST, entries: flat([BOB], 3) }) const original = await onlyEntry(repo, BOB) - repo.voidEntry(original.id) + repo.markVoided(original.id) notifier.sent.length = 0 clock.advance(3_600_000) @@ -1404,7 +1404,7 @@ describe("volunteer hours: void-aware re-crediting and no-op re-saves", () => { expect(revived.id).toBe(original.id) expect(revived.createdAt).toEqual(clock.now()) - repo.voidEntry(revived.id) + repo.markVoided(revived.id) const result = await service.logEventHours({ cleanupId: CLEANUP, actorId: HOST, From 4528d98458b22de3c73255e3cd51958e6b9fbfcc Mon Sep 17 00:00:00 2001 From: byteful Date: Sat, 26 Sep 2026 21:33:37 -0400 Subject: [PATCH 05/14] keep operator ledger slice to writers, fix manual service date anchor and test inserts --- .../certificate-repository.drizzle.ts | 19 +- .../services/certificate-repository.memory.ts | 17 +- .../api/src/services/certificate-service.ts | 16 +- .../volunteer-hours-repository.drizzle.ts | 149 ++---------- .../volunteer-hours-repository.memory.ts | 94 +------- .../src/services/volunteer-hours-service.ts | 36 --- .../integration/admin-user-hours-pg.test.ts | 125 ++-------- .../integration/volunteer-hours-pg.test.ts | 5 +- .../volunteer-hours-operator-ledger.test.ts | 215 ++---------------- 9 files changed, 76 insertions(+), 600 deletions(-) diff --git a/services/api/src/services/certificate-repository.drizzle.ts b/services/api/src/services/certificate-repository.drizzle.ts index 20624fe6..c3924bf3 100644 --- a/services/api/src/services/certificate-repository.drizzle.ts +++ b/services/api/src/services/certificate-repository.drizzle.ts @@ -8,9 +8,8 @@ * of jsonb, which Postgres stores out of line in TOAST. A `SELECT *` (or any projection naming * `snapshot`) detoasts that blob on EVERY holder list read and on EVERY anonymous public verification — * the highest-traffic read in this feature, served to a school registrar who needs six scalars. The - * column is written once and never projected; `service-hours-certificates-pg.test.ts` greps this - * source to keep it that way. The one read that looks inside it is `liveCodesListingEntry`, a - * containment filter run only when an operator voids a ledger entry, over one holder's live rows. + * column is written once and read back by nothing; `service-hours-certificates-pg.test.ts` greps this + * source to keep it that way. * * The two unique indexes are surfaced as a typed `CertificateConflictError` rather than a leaked driver * error, because the service's recoveries are completely different: a `code` collision re-mints and @@ -21,7 +20,6 @@ import type { Sql } from "../db/client.js" import { CertificateConflictError, - type CertificateEntryCode, type CertificateHolder, type CertificateInsert, type CertificateRepository, @@ -256,18 +254,5 @@ export function makeDrizzleCertificateRepository(sql: Sql): CertificateRepositor locale: row.locale, } }, - - async liveCodesListingEntry(userId: string, entryId: string): Promise { - const rows = await sql<{ code: string; issued_at: Date }[]>` - SELECT code, issued_at - FROM service_hours_certificates - WHERE user_id = ${userId} - AND revoked_at IS NULL - AND snapshot->'rows' @> jsonb_build_array(jsonb_build_object('id', ${entryId}::text)) - ORDER BY issued_at DESC, id DESC - LIMIT 200 - ` - return rows.map((r) => ({ code: r.code, issuedAt: r.issued_at })) - }, } } diff --git a/services/api/src/services/certificate-repository.memory.ts b/services/api/src/services/certificate-repository.memory.ts index 25547504..fc0b3102 100644 --- a/services/api/src/services/certificate-repository.memory.ts +++ b/services/api/src/services/certificate-repository.memory.ts @@ -16,13 +16,12 @@ * - reads hand back CLONES, so a test mutating a returned row cannot corrupt the store (the Drizzle * repo obviously cannot be corrupted that way). * - * `snapshot` is read back only by `liveCodesListingEntry`, exactly as in production. + * `snapshot` is stored but never read back, exactly as in production. */ import { randomUUID } from "node:crypto" import { CertificateConflictError, - type CertificateEntryCode, type CertificateHolder, type CertificateInsert, type CertificateRepository, @@ -142,20 +141,6 @@ export class InMemoryCertificateRepository implements CertificateRepository { return Promise.resolve() } - liveCodesListingEntry(userId: string, entryId: string): Promise { - const codes = [...this.rows.values()] - .filter( - (row) => - row.userId === userId && - row.revokedAt === null && - row.snapshot.rows.some((item) => item.id === entryId), - ) - .sort((a, b) => b.issuedAt.getTime() - a.issuedAt.getTime() || b.id.localeCompare(a.id)) - .slice(0, 200) - .map((row) => ({ code: row.code, issuedAt: row.issuedAt })) - return Promise.resolve(codes) - } - findHolder(userId: string): Promise { const holder = this.holders.get(userId) // A tombstoned account cannot issue, matching the Drizzle read's `deleted_at IS NULL` filter. diff --git a/services/api/src/services/certificate-service.ts b/services/api/src/services/certificate-service.ts index dc50b2d7..d9ad4e07 100644 --- a/services/api/src/services/certificate-service.ts +++ b/services/api/src/services/certificate-service.ts @@ -146,10 +146,7 @@ export interface CertificateInsert { periodStart: Date | null periodEnd: Date | null ledgerFingerprint: string - /** - * The exact rendered model, stored so the object is re-renderable. Written once; only - * `liveCodesListingEntry` reads inside it. - */ + /** The exact rendered model, stored so the object is re-renderable. Written once, never read back. */ snapshot: TranscriptModel r2Key: string documentSha256: string @@ -196,17 +193,6 @@ export interface CertificateRepository { at: Date }): Promise findHolder(userId: string): Promise - /** - * Live (unrevoked) certificates of `userId` whose frozen snapshot itemised ledger entry `entryId`, - * newest first. An operator void does not change an issued document, so the admin plane lists these - * for a revoke through `db:certificate:revoke`. - */ - liveCodesListingEntry(userId: string, entryId: string): Promise -} - -export interface CertificateEntryCode { - code: string - issuedAt: Date } // ---- service ------------------------------------------------------------------------------------ diff --git a/services/api/src/services/volunteer-hours-repository.drizzle.ts b/services/api/src/services/volunteer-hours-repository.drizzle.ts index 092755a7..00358aec 100644 --- a/services/api/src/services/volunteer-hours-repository.drizzle.ts +++ b/services/api/src/services/volunteer-hours-repository.drizzle.ts @@ -5,7 +5,7 @@ import type { OrgVerificationStatus, VolunteerHoursSource, } from "@civfix/shared" -import { CIVFIX_OFFICIAL_USER_ID, isOfficialAccount } from "../auth/official-account.js" +import { CIVFIX_OFFICIAL_USER_ID } from "../auth/official-account.js" import type { Queryable, Sql, TransactionSql } from "../db/client.js" import { encodeTimeCursor, pageWith } from "../db/cursor-helpers.js" import { writeAudit } from "./admin/audit.js" @@ -25,7 +25,6 @@ import { ITEMISED_SOURCES, MANUAL_CREDIT_REPEAT_WINDOW_MS, MAX_ORG_CHIPS_FETCH, - OPERATOR_LEDGER_MAX_LIMIT, RECIPROCAL_LOOKBACK_MS, WEEKLY_HOURS_FLAG_DEFAULT, } from "./volunteer-hours-service.js" @@ -41,9 +40,6 @@ import type { MyVolunteerHoursTotals, OperatorCreditResult, OperatorEventCreditArgs, - OperatorLedgerArgs, - OperatorLedgerEntryView, - OperatorLedgerTotals, OperatorManualCreditArgs, OperatorVoidArgs, OrgHoursView, @@ -60,10 +56,20 @@ const WEEKLY_WINDOW_INTERVAL = "7 days" const MANUAL_REPEAT_INTERVAL = `${MANUAL_CREDIT_REPEAT_WINDOW_MS / 1000} seconds` // Transcripts and entry lists date a row by when the service happened: the event's start, a manual -// row's service day (noon UTC, so no reader's time zone shifts it to a neighbouring day), and only -// failing both, when the row was written. +// row's service day, and only failing both, when the row was written. A manual row has no +// jurisdiction and so no zone of its own; it is anchored at noon UTC on its date so that no reader +// within ±11 h of UTC sees it on a neighbouring day, whatever the session time zone. function occurredAtExpr(sql: Queryable) { - return sql`COALESCE(c.scheduled_at, (vh.service_date + time '12:00') AT TIME ZONE 'UTC', vh.created_at)` + return sql`COALESCE( + c.scheduled_at, + make_timestamptz( + extract(year FROM vh.service_date)::int, + extract(month FROM vh.service_date)::int, + extract(day FROM vh.service_date)::int, + 12, 0, 0, 'UTC' + ), + vh.created_at + )` } interface LedgerRow { @@ -192,63 +198,6 @@ async function detectHoursAnomalies( return anomalies } -interface OperatorLedgerRow { - id: string - source: VolunteerHoursSource - hours: number - created_at: Date - occurred_at: Date - service_date: string | null - cleanup_id: string | null - cleanup_title: string | null - reference_code: string | null - jurisdiction_geoid: string | null - jurisdiction_name: string | null - creditor_id: string | null - creditor_name: string | null - creditor_handle: string | null - operator_id: string | null - operator_name: string | null - note: string | null - voided_at: Date | null - voided_by_id: string | null - voided_by_name: string | null - void_reason: string | null -} - -function toOperatorLedgerView(r: OperatorLedgerRow): OperatorLedgerEntryView { - return { - id: r.id, - source: r.source, - hours: r.hours, - occurredAt: r.occurred_at, - createdAt: r.created_at, - serviceDate: r.service_date, - event: - r.cleanup_id !== null - ? { id: r.cleanup_id, title: r.cleanup_title ?? "", referenceCode: r.reference_code } - : null, - jurisdiction: - r.jurisdiction_geoid !== null - ? { geoid: r.jurisdiction_geoid, name: r.jurisdiction_name } - : null, - creditedBy: - r.creditor_id !== null - ? { - id: r.creditor_id, - name: r.creditor_name ?? "", - handle: r.creditor_handle, - official: isOfficialAccount(r.creditor_id), - } - : null, - operator: r.operator_id !== null ? { id: r.operator_id, name: r.operator_name ?? "" } : null, - note: r.note, - voidedAt: r.voided_at, - voidedBy: r.voided_by_id !== null ? { id: r.voided_by_id, name: r.voided_by_name ?? "" } : null, - voidReason: r.void_reason, - } -} - function round2(n: number): number { return Math.round(n * 100) / 100 } @@ -527,10 +476,8 @@ export function makeDrizzleVolunteerHoursRepository(sql: Sql): VolunteerHoursRep async listEventHours(cleanupId: string, viewerId: string | null): Promise { const mine = viewerId !== null ? sql`AND vh.user_id = ${viewerId}::uuid` : sql`` - const rows = await sql< - { user_id: string; hours: number; created_at: Date; logged_by_user_id: string | null }[] - >` - SELECT vh.user_id, vh.hours::float8 AS hours, vh.created_at, vh.logged_by_user_id + const rows = await sql<{ user_id: string; hours: number; created_at: Date }[]>` + SELECT vh.user_id, vh.hours::float8 AS hours, vh.created_at FROM volunteer_hours vh WHERE vh.cleanup_id = ${cleanupId} AND vh.source = 'event' @@ -543,7 +490,6 @@ export function makeDrizzleVolunteerHoursRepository(sql: Sql): VolunteerHoursRep userId: r.user_id, hours: r.hours, loggedAt: r.created_at, - creditedByOfficial: isOfficialAccount(r.logged_by_user_id), })) if (viewerId === null) return { entries, anyLogged: entries.length > 0 } const probe = await sql<{ any_logged: boolean }[]>` @@ -801,68 +747,5 @@ export function makeDrizzleVolunteerHoursRepository(sql: Sql): VolunteerHoursRep return { id: args.entryId, source: row.source, cleanupId: row.cleanup_id, hours: row.hours } }) }, - - async listOperatorLedger( - args: OperatorLedgerArgs, - ): Promise<{ items: OperatorLedgerEntryView[]; nextCursor: string | null }> { - const limit = Math.min(Math.max(1, Math.floor(args.limit)), OPERATOR_LEDGER_MAX_LIMIT) - const keyset = - args.cursor !== null - ? sql`AND (vh.created_at, vh.id) < (${args.cursor.at}, ${args.cursor.id}::uuid)` - : sql`` - const rows = await sql` - SELECT - vh.id, - vh.source, - vh.hours::float8 AS hours, - vh.created_at, - ${occurredAtExpr(sql)} AS occurred_at, - vh.service_date::text AS service_date, - vh.cleanup_id, - c.title AS cleanup_title, - c.reference_code, - vh.jurisdiction_geoid, - j.name AS jurisdiction_name, - lb.id AS creditor_id, - lb.display_name AS creditor_name, - lb.handle AS creditor_handle, - op.id AS operator_id, - op.display_name AS operator_name, - vh.note, - vh.voided_at, - vb.id AS voided_by_id, - vb.display_name AS voided_by_name, - vh.void_reason - FROM volunteer_hours vh - LEFT JOIN cleanups c ON c.id = vh.cleanup_id - LEFT JOIN jurisdictions j ON j.geoid = vh.jurisdiction_geoid - LEFT JOIN users lb ON lb.id = vh.logged_by_user_id - LEFT JOIN users op ON op.id = vh.credited_by_operator_id - LEFT JOIN users vb ON vb.id = vh.voided_by_operator_id - WHERE vh.user_id = ${args.userId} - ${keyset} - ORDER BY vh.created_at DESC, vh.id DESC - LIMIT ${limit + 1} - ` - const { items, nextCursor } = pageWith(rows, limit, (last) => - encodeTimeCursor({ at: last.created_at, id: last.id }), - ) - return { items: items.map(toOperatorLedgerView), nextCursor } - }, - - async operatorLedgerTotals(userId: string): Promise { - const rows = await sql<{ live: number; voided: number }[]>` - SELECT - count(*) FILTER (WHERE voided_at IS NULL)::int AS live, - count(*) FILTER (WHERE voided_at IS NOT NULL)::int AS voided - FROM volunteer_hours - WHERE user_id = ${userId} - ` - return { - totalHours: await computeTotalHours(sql, userId), - liveEntries: rows[0]?.live ?? 0, - voidedEntries: rows[0]?.voided ?? 0, - } - }, } } diff --git a/services/api/src/services/volunteer-hours-repository.memory.ts b/services/api/src/services/volunteer-hours-repository.memory.ts index d5148a1d..a4943371 100644 --- a/services/api/src/services/volunteer-hours-repository.memory.ts +++ b/services/api/src/services/volunteer-hours-repository.memory.ts @@ -1,6 +1,6 @@ import { randomUUID } from "node:crypto" import { AppError, avatarGradient } from "@civfix/shared" -import { CIVFIX_OFFICIAL_USER_ID, isOfficialAccount } from "../auth/official-account.js" +import { CIVFIX_OFFICIAL_USER_ID } from "../auth/official-account.js" import type { LeaderboardEntryDTO, MyVolunteerHoursDTO, @@ -15,7 +15,6 @@ import { ITEMISED_SOURCES, MANUAL_CREDIT_REPEAT_WINDOW_MS, MAX_ORG_CHIPS_FETCH, - OPERATOR_LEDGER_MAX_LIMIT, RECIPROCAL_LOOKBACK_MS, WEEKLY_HOURS_FLAG_DEFAULT, } from "./volunteer-hours-service.js" @@ -33,9 +32,6 @@ import type { MyVolunteerHoursTotals, OperatorCreditResult, OperatorEventCreditArgs, - OperatorLedgerArgs, - OperatorLedgerEntryView, - OperatorLedgerTotals, OperatorManualCreditArgs, OperatorVoidArgs, OrgHoursView, @@ -74,7 +70,7 @@ export interface MemoryOrganization { suspended?: boolean } -interface LedgerEntry { +export interface LedgerEntry { id: string userId: string source: VolunteerHoursSource @@ -184,6 +180,11 @@ export class InMemoryVolunteerHoursRepository implements VolunteerHoursRepositor return id } + ledgerRow(entryId: string): LedgerEntry | null { + const row = this.entries.find((e) => e.id === entryId) + return row === undefined ? null : { ...row } + } + async logEventHours(args: LogEventHoursArgs): Promise { this.assertNoReciprocalCredit(args) this.assertDailyCap(args) @@ -521,12 +522,7 @@ export class InMemoryVolunteerHoursRepository implements VolunteerHoursRepositor entries: rows .slice() .sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime() || b.id.localeCompare(a.id)) - .map((e) => ({ - userId: e.userId, - hours: round2(e.hours), - loggedAt: e.createdAt, - creditedByOfficial: isOfficialAccount(e.loggedByUserId), - })), + .map((e) => ({ userId: e.userId, hours: round2(e.hours), loggedAt: e.createdAt })), anyLogged: all.length > 0, }) } @@ -700,80 +696,6 @@ export class InMemoryVolunteerHoursRepository implements VolunteerHoursRepositor return { id: row.id, source: row.source, cleanupId: row.cleanupId, hours: row.hours } } - listOperatorLedger( - args: OperatorLedgerArgs, - ): Promise<{ items: OperatorLedgerEntryView[]; nextCursor: string | null }> { - const limit = Math.min(Math.max(1, Math.floor(args.limit)), OPERATOR_LEDGER_MAX_LIMIT) - const rows = this.entries - .filter((e) => e.userId === args.userId) - .sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime() || b.id.localeCompare(a.id)) - .filter((e) => { - if (args.cursor === null) return true - const at = e.createdAt.getTime() - const anchor = args.cursor.at.getTime() - return at < anchor || (at === anchor && e.id < args.cursor.id) - }) - .slice(0, limit + 1) - const { items, nextCursor } = pageWith(rows, limit, (last) => - encodeTimeCursor({ at: last.createdAt, id: last.id }), - ) - return Promise.resolve({ items: items.map((e) => this.toOperatorView(e)), nextCursor }) - } - - operatorLedgerTotals(userId: string): Promise { - const mine = this.entries.filter((e) => e.userId === userId) - return Promise.resolve({ - totalHours: this.computeTotalHours(userId), - liveEntries: mine.filter((e) => e.voidedAt === undefined).length, - voidedEntries: mine.filter((e) => e.voidedAt !== undefined).length, - }) - } - - private toOperatorView(e: LedgerEntry): OperatorLedgerEntryView { - const meta = e.cleanupId !== null ? (this.cleanups.get(e.cleanupId) ?? null) : null - const nameOf = (id: string): string => this.users.get(id)?.name ?? "" - return { - id: e.id, - source: e.source, - hours: round2(e.hours), - occurredAt: this.occurredAt(e), - createdAt: e.createdAt, - serviceDate: e.serviceDate, - event: - e.cleanupId !== null - ? { - id: e.cleanupId, - title: meta?.title ?? "", - referenceCode: meta?.referenceCode ?? null, - } - : null, - jurisdiction: - e.geoid !== null - ? { geoid: e.geoid, name: this.jurisdictionNames.get(e.geoid) ?? null } - : null, - creditedBy: - e.loggedByUserId !== null - ? { - id: e.loggedByUserId, - name: nameOf(e.loggedByUserId), - handle: this.users.get(e.loggedByUserId)?.handle ?? null, - official: isOfficialAccount(e.loggedByUserId), - } - : null, - operator: - e.creditedByOperatorId !== null - ? { id: e.creditedByOperatorId, name: nameOf(e.creditedByOperatorId) } - : null, - note: e.note, - voidedAt: e.voidedAt ?? null, - voidedBy: - e.voidedByOperatorId !== null - ? { id: e.voidedByOperatorId, name: nameOf(e.voidedByOperatorId) } - : null, - voidReason: e.voidReason, - } - } - private occurredAt(e: LedgerEntry): Date { const scheduledAt = e.cleanupId !== null ? this.cleanups.get(e.cleanupId)?.scheduledAt : null if (scheduledAt != null) return scheduledAt diff --git a/services/api/src/services/volunteer-hours-service.ts b/services/api/src/services/volunteer-hours-service.ts index 2800020d..b8d2c554 100644 --- a/services/api/src/services/volunteer-hours-service.ts +++ b/services/api/src/services/volunteer-hours-service.ts @@ -96,8 +96,6 @@ export interface EventCreditWrite { export const MANUAL_CREDIT_REPEAT_WINDOW_MS = 60_000 -export const OPERATOR_LEDGER_MAX_LIMIT = 50 - export interface OperatorEventCreditArgs { operatorId: string userId: string @@ -135,35 +133,6 @@ export interface VoidedEntry { hours: number } -export interface OperatorLedgerArgs { - userId: string - cursor: TimeCursor | null - limit: number -} - -export interface OperatorLedgerEntryView { - id: string - source: VolunteerHoursSource - hours: number - occurredAt: Date - createdAt: Date - serviceDate: string | null - event: { id: string; title: string; referenceCode: string | null } | null - jurisdiction: { geoid: string; name: string | null } | null - creditedBy: { id: string; name: string; handle: string | null; official: boolean } | null - operator: { id: string; name: string } | null - note: string | null - voidedAt: Date | null - voidedBy: { id: string; name: string } | null - voidReason: string | null -} - -export interface OperatorLedgerTotals { - totalHours: number - liveEntries: number - voidedEntries: number -} - export interface LogEventHoursResult { credited: number changed: EventCreditChange[] @@ -194,7 +163,6 @@ export interface EventHoursLedgerEntry { userId: string hours: number loggedAt: Date - creditedByOfficial: boolean } export interface EventHoursLedger { @@ -289,10 +257,6 @@ export interface VolunteerHoursRepository { creditEventAsOperator(args: OperatorEventCreditArgs): Promise creditManual(args: OperatorManualCreditArgs): Promise voidEntry(args: OperatorVoidArgs): Promise - listOperatorLedger( - args: OperatorLedgerArgs, - ): Promise<{ items: OperatorLedgerEntryView[]; nextCursor: string | null }> - operatorLedgerTotals(userId: string): Promise } export interface CleanupHoursView { diff --git a/services/api/test/integration/admin-user-hours-pg.test.ts b/services/api/test/integration/admin-user-hours-pg.test.ts index 92f9f301..1bdc5343 100644 --- a/services/api/test/integration/admin-user-hours-pg.test.ts +++ b/services/api/test/integration/admin-user-hours-pg.test.ts @@ -3,9 +3,6 @@ import { randomUUID } from "node:crypto" import { withPg, type PgHarness } from "../helpers/pg.js" import { seedCleanup } from "../helpers/cleanups.js" import { CIVFIX_OFFICIAL_USER_ID } from "../../src/auth/official-account.js" -import { parseTimeCursor } from "../../src/db/cursor-helpers.js" -import { buildTranscriptModel } from "../../src/services/certificate-model.js" -import { makeDrizzleCertificateRepository } from "../../src/services/certificate-repository.drizzle.js" import { makeDrizzleVolunteerHoursRepository } from "../../src/services/volunteer-hours-repository.drizzle.js" import { LA_CITY } from "../../src/db/seed-fixtures.js" @@ -72,6 +69,14 @@ describe.skipIf(!pg)("operator volunteer-hours ledger (integration)", () => { return r! } + async function eventEntryId(cleanupId: string, userId: string): Promise { + const [r] = await h.sql<{ id: string }[]>` + SELECT id FROM volunteer_hours + WHERE cleanup_id = ${cleanupId} AND user_id = ${userId} AND source = 'event' + ` + return r!.id + } + async function journalFor(cleanupId: string, userId: string) { return h.sql<{ actor: string; previous_hours: number | null; new_hours: number }[]>` SELECT actor_user_id AS actor, previous_hours::float8 AS previous_hours, @@ -168,7 +173,7 @@ describe.skipIf(!pg)("operator volunteer-hours ledger (integration)", () => { }, ]) const hostSheet = await repo.listEventHours(cleanupId, null) - expect(hostSheet.entries.map((e) => [e.userId, e.creditedByOfficial])).toEqual([[alice, true]]) + expect(hostSheet.entries.map((e) => [e.userId, e.hours])).toEqual([[alice, 3]]) await expect( repo.creditEventAsOperator({ @@ -195,28 +200,27 @@ describe.skipIf(!pg)("operator volunteer-hours ledger (integration)", () => { geoid: GEOID, entries: [{ userId: alice, hours: 3 }], }) - const [hostRow] = (await repo.listOperatorLedger({ userId: alice, cursor: null, limit: 10 })) - .items + const hostRowId = await eventEntryId(cleanupId, alice) const voided = await repo.voidEntry({ operatorId: operator, userId: alice, - entryId: hostRow!.id, + entryId: hostRowId, reason: "Was not there", }) - expect(voided).toEqual({ id: hostRow!.id, source: "event", cleanupId, hours: 3 }) - expect(await row(hostRow!.id)).toMatchObject({ + expect(voided).toEqual({ id: hostRowId, source: "event", cleanupId, hours: 3 }) + expect(await row(hostRowId)).toMatchObject({ voided_by_operator_id: operator, void_reason: "Was not there", }) - expect((await row(hostRow!.id)).voided_at).toBeInstanceOf(Date) + expect((await row(hostRowId)).voided_at).toBeInstanceOf(Date) expect(await rollupAt(alice)).toBe(0) await expect( - repo.voidEntry({ operatorId: operator, userId: alice, entryId: hostRow!.id, reason: "x" }), + repo.voidEntry({ operatorId: operator, userId: alice, entryId: hostRowId, reason: "x" }), ).rejects.toMatchObject({ code: "CONFLICT" }) await expect( - repo.voidEntry({ operatorId: operator, userId: host, entryId: hostRow!.id, reason: "x" }), + repo.voidEntry({ operatorId: operator, userId: host, entryId: hostRowId, reason: "x" }), ).rejects.toMatchObject({ code: "NOT_FOUND" }) await expect( repo.voidEntry({ operatorId: operator, userId: alice, entryId: randomUUID(), reason: "x" }), @@ -230,7 +234,7 @@ describe.skipIf(!pg)("operator volunteer-hours ledger (integration)", () => { hours: 2, reason: "Came for the second half", }) - expect(entryId).toBe(hostRow!.id) + expect(entryId).toBe(hostRowId) expect(await row(entryId)).toMatchObject({ voided_at: null, voided_by_operator_id: null, @@ -392,7 +396,7 @@ describe.skipIf(!pg)("operator volunteer-hours ledger (integration)", () => { geoid: GEOID, entries: [{ userId: b, hours: 2 }], }) - const bRow = (await repo.listOperatorLedger({ userId: b, cursor: null, limit: 1 })).items[0]! + const bRowId = await eventEntryId(cleanupId, b) const outcomes = await Promise.allSettled([ repo.logEventHours({ @@ -416,7 +420,7 @@ describe.skipIf(!pg)("operator volunteer-hours ledger (integration)", () => { repo.voidEntry({ operatorId: operator, userId: b, - entryId: bRow.id, + entryId: bRowId, reason: `round ${round}`, }), repo.creditManual({ @@ -437,99 +441,12 @@ describe.skipIf(!pg)("operator volunteer-hours ledger (integration)", () => { expect(await driftFor(attendees)).toEqual([]) for (const userId of attendees) { - const totals = await repo.operatorLedgerTotals(userId) + const total = await repo.totalHoursFor(userId) const [ledger] = await h.sql<{ total: number }[]>` SELECT COALESCE(SUM(hours), 0)::float8 AS total FROM volunteer_hours WHERE user_id = ${userId} AND voided_at IS NULL AND source <> 'report' ` - expect(totals.totalHours).toBe(ledger!.total) - } - }) - - it("pages the operator ledger by (created_at, id), voided rows included, with live and voided counts", async () => { - const alice = await newUser("Page Alice") - const operator = await newUser("Page Olive") - const repo = makeDrizzleVolunteerHoursRepository(h.sql) - const ids: string[] = [] - for (let day = 1; day <= 5; day++) { - const { entryId } = await repo.creditManual({ - operatorId: operator, - userId: alice, - hours: 1, - serviceDate: `2026-05-0${day}`, - reason: `day ${day}`, - }) - ids.unshift(entryId) - } - await repo.voidEntry({ operatorId: operator, userId: alice, entryId: ids[1]!, reason: "dup" }) - - const first = await repo.listOperatorLedger({ userId: alice, cursor: null, limit: 3 }) - const second = await repo.listOperatorLedger({ - userId: alice, - cursor: parseTimeCursor(first.nextCursor!), - limit: 3, - }) - expect([...first.items, ...second.items].map((i) => i.id)).toEqual(ids) - expect(second.nextCursor).toBeNull() - expect(first.items[1]).toMatchObject({ - voidedBy: { id: operator, name: "Page Olive" }, - voidReason: "dup", - operator: { id: operator, name: "Page Olive" }, - creditedBy: { id: CIVFIX_OFFICIAL_USER_ID, official: true }, - }) - expect(await repo.operatorLedgerTotals(alice)).toEqual({ - totalHours: 4, - liveEntries: 4, - voidedEntries: 1, - }) - }) - - it("finds the live certificates whose snapshot itemised an entry", async () => { - const alice = await newUser("Cert Alice") - const certs = makeDrizzleCertificateRepository(h.sql) - const entry = randomUUID() - - async function issue(code: string, entryIds: string[], issuedAt: string): Promise { - await certs.insert({ - id: randomUUID(), - userId: alice, - code, - locale: "en", - holderName: "Cert Alice", - holderHandle: null, - holderVerified: false, - totalHours: entryIds.length, - entryCount: entryIds.length, - periodStart: null, - periodEnd: null, - ledgerFingerprint: `fp-${code}`, - snapshot: buildTranscriptModel({ - holder: { userId: alice, displayName: "Cert Alice", handle: null }, - rows: entryIds.map((id) => ({ - id, - source: "event" as const, - hours: 1, - occurredAt: new Date(issuedAt), - })), - locale: "en", - }), - r2Key: `certificates/${code}.pdf`, - documentSha256: "0".repeat(64), - byteSize: 1, - issuedAt: new Date(issuedAt), - }) + expect(total).toBe(ledger!.total) } - - const suffix = randomUUID().slice(0, 6).toUpperCase() - await issue(`A${suffix}`, [entry], "2026-07-01T00:00:00Z") - await issue(`B${suffix}`, [randomUUID(), entry], "2026-07-02T00:00:00Z") - await issue(`C${suffix}`, [randomUUID()], "2026-07-03T00:00:00Z") - await issue(`D${suffix}`, [entry], "2026-07-04T00:00:00Z") - await certs.revoke(alice, `D${suffix}`, "ledger_corrected", new Date()) - - expect(await certs.liveCodesListingEntry(alice, entry)).toEqual([ - { code: `B${suffix}`, issuedAt: new Date("2026-07-02T00:00:00Z") }, - { code: `A${suffix}`, issuedAt: new Date("2026-07-01T00:00:00Z") }, - ]) }) }) diff --git a/services/api/test/integration/volunteer-hours-pg.test.ts b/services/api/test/integration/volunteer-hours-pg.test.ts index e7985357..d8ab8b94 100644 --- a/services/api/test/integration/volunteer-hours-pg.test.ts +++ b/services/api/test/integration/volunteer-hours-pg.test.ts @@ -539,14 +539,17 @@ describe.skipIf(!pg)("volunteer hours (integration)", () => { loggedBy?: string | null voided?: boolean }): Promise { + // volunteer_hours_service_date_chk requires a service date on exactly the manual rows. + const serviceDate = args.source === "manual" ? args.createdAt.slice(0, 10) : null await h.sql` INSERT INTO volunteer_hours (id, user_id, hours, source, cleanup_id, report_id, jurisdiction_geoid, logged_by_user_id, - created_at, voided_at) + service_date, created_at, voided_at) VALUES ( ${args.id}, ${args.userId}, ${args.hours}, ${args.source}, ${args.cleanupId ?? null}, ${args.reportId ?? null}, ${args.geoid ?? null}, ${args.loggedBy ?? null}, + ${serviceDate}::date, ${args.createdAt}::timestamptz, ${args.voided === true ? args.createdAt : null} ) diff --git a/services/api/test/unit/volunteer-hours-operator-ledger.test.ts b/services/api/test/unit/volunteer-hours-operator-ledger.test.ts index 8c0bdc11..1d3e4eb2 100644 --- a/services/api/test/unit/volunteer-hours-operator-ledger.test.ts +++ b/services/api/test/unit/volunteer-hours-operator-ledger.test.ts @@ -1,8 +1,5 @@ import { describe, expect, it } from "vitest" import { CIVFIX_OFFICIAL_USER_ID } from "../../src/auth/official-account.js" -import { parseTimeCursor } from "../../src/db/cursor-helpers.js" -import { buildTranscriptModel } from "../../src/services/certificate-model.js" -import { InMemoryCertificateRepository } from "../../src/services/certificate-repository.memory.js" import { InMemoryVolunteerHoursRepository } from "../../src/services/volunteer-hours-repository.memory.js" import { DAILY_HOURS_CAP, @@ -109,9 +106,7 @@ describe("operator event credit", () => { ]) const hostView = await repo.listEventHours(EVENT, null) - expect(hostView.entries).toEqual([ - expect.objectContaining({ userId: BOB, hours: 3, creditedByOfficial: true }), - ]) + expect(hostView.entries.map((e) => [e.userId, e.hours])).toEqual([[BOB, 3]]) }) it("journals and audits the human operator, not the official account", async () => { @@ -197,8 +192,7 @@ describe("operator event credit", () => { geoid: GEOID, entries: [{ userId: BOB, hours: 3 }], }) - const [hostRow] = (await repo.listOperatorLedger({ userId: BOB, cursor: null, limit: 10 })) - .items + const [hostRow] = (await repo.listEntries({ userId: BOB, cursor: null, limit: 10 })).items await repo.voidEntry({ operatorId: OTHER_OPERATOR, userId: BOB, @@ -213,19 +207,16 @@ describe("operator event credit", () => { const totals = await repo.totalsFor(BOB) expect(totals.totalHours).toBe(2) expect(totals.byJurisdiction).toEqual([{ geoid: GEOID, name: "Los Angeles", hours: 2 }]) - const [revived] = (await repo.listOperatorLedger({ userId: BOB, cursor: null, limit: 10 })) - .items - expect(revived).toMatchObject({ - id: entryId, + expect(repo.ledgerRow(entryId)).toMatchObject({ hours: 2, createdAt: clock.now(), - creditedBy: { id: CIVFIX_OFFICIAL_USER_ID, official: true }, - operator: { id: OPERATOR, name: "Olive Operator" }, + loggedByUserId: CIVFIX_OFFICIAL_USER_ID, + creditedByOperatorId: OPERATOR, note: "Attended before signing up", - voidedAt: null, - voidedBy: null, + voidedByOperatorId: null, voidReason: null, }) + expect(repo.ledgerRow(entryId)?.voidedAt).toBeUndefined() expect(repo.journal.map((j) => [j.actorUserId, j.previousHours, j.newHours])).toEqual([ [HOST, null, 3], [OTHER_OPERATOR, 3, 0], @@ -237,7 +228,7 @@ describe("operator event credit", () => { describe("host writes over an operator credit", () => { it("an unchanged host re-save keeps the operator's attribution", async () => { const { repo } = makeRepo() - await repo.creditEventAsOperator(eventCredit(3)) + const { entryId } = await repo.creditEventAsOperator(eventCredit(3)) const result = await repo.logEventHours({ actorId: HOST, @@ -247,13 +238,15 @@ describe("host writes over an operator credit", () => { }) expect(result.changed).toEqual([]) - const [row] = (await repo.listOperatorLedger({ userId: BOB, cursor: null, limit: 10 })).items - expect(row).toMatchObject({ creditedBy: { official: true }, operator: { id: OPERATOR } }) + expect(repo.ledgerRow(entryId)).toMatchObject({ + loggedByUserId: CIVFIX_OFFICIAL_USER_ID, + creditedByOperatorId: OPERATOR, + }) }) it("a host change takes the row over: the operator and the reason no longer describe it", async () => { const { repo } = makeRepo() - await repo.creditEventAsOperator(eventCredit(3)) + const { entryId } = await repo.creditEventAsOperator(eventCredit(3)) await repo.logEventHours({ actorId: HOST, @@ -262,14 +255,12 @@ describe("host writes over an operator credit", () => { entries: [{ userId: BOB, hours: 2 }], }) - const [row] = (await repo.listOperatorLedger({ userId: BOB, cursor: null, limit: 10 })).items - expect(row).toMatchObject({ + expect(repo.ledgerRow(entryId)).toMatchObject({ hours: 2, - creditedBy: { id: HOST, official: false }, - operator: null, + loggedByUserId: HOST, + creditedByOperatorId: null, note: null, }) - expect((await repo.listEventHours(EVENT, null)).entries[0]!.creditedByOfficial).toBe(false) }) it("a host may re-credit an attendee whose entry an operator voided", async () => { @@ -285,8 +276,12 @@ describe("host writes over an operator credit", () => { }) expect(result.changed).toEqual([{ userId: BOB, hours: 3, previousHours: 0 }]) - const [row] = (await repo.listOperatorLedger({ userId: BOB, cursor: null, limit: 10 })).items - expect(row).toMatchObject({ voidedAt: null, voidedBy: null, voidReason: null, operator: null }) + expect(repo.ledgerRow(entryId)).toMatchObject({ + voidedByOperatorId: null, + voidReason: null, + creditedByOperatorId: null, + }) + expect(repo.ledgerRow(entryId)?.voidedAt).toBeUndefined() expect((await repo.totalsFor(BOB)).byJurisdiction).toEqual([ { geoid: GEOID, name: "Los Angeles", hours: 3 }, ]) @@ -376,7 +371,7 @@ describe("operator void", () => { geoid: GEOID, entries: [{ userId: BOB, hours: 3 }], }) - const [row] = (await repo.listOperatorLedger({ userId: BOB, cursor: null, limit: 10 })).items + const [row] = (await repo.listEntries({ userId: BOB, cursor: null, limit: 10 })).items const voided = await repo.voidEntry({ operatorId: OPERATOR, @@ -465,167 +460,3 @@ describe("operator void", () => { ).rejects.toMatchObject({ code: "CONFLICT", message: expect.stringContaining("retired") }) }) }) - -describe("operator ledger read", () => { - it("lists voided rows too, newest first, keyset-paged, with the admin-plane attribution", async () => { - const { repo, clock } = makeRepo() - await repo.logEventHours({ - actorId: HOST, - cleanupId: SAME_DAY_EVENT, - geoid: GEOID, - entries: [{ userId: BOB, hours: 1 }], - }) - clock.advance(1_000) - const event = await repo.creditEventAsOperator(eventCredit(3)) - clock.advance(1_000) - const manual = await repo.creditManual(manualCredit(2)) - clock.advance(1_000) - await repo.voidEntry({ - operatorId: OTHER_OPERATOR, - userId: BOB, - entryId: manual.entryId, - reason: "Typo", - }) - - const first = await repo.listOperatorLedger({ userId: BOB, cursor: null, limit: 2 }) - expect(first.items.map((i) => i.id)).toEqual([manual.entryId, event.entryId]) - expect(first.items[0]).toMatchObject({ - source: "manual", - serviceDate: "2026-07-05", - event: null, - jurisdiction: null, - creditedBy: { id: CIVFIX_OFFICIAL_USER_ID, name: "CivFix", handle: "civfix", official: true }, - operator: { id: OPERATOR, name: "Olive Operator" }, - note: "Tabling at the library fair", - voidedBy: { id: OTHER_OPERATOR, name: "" }, - voidReason: "Typo", - }) - expect(first.items[0]!.voidedAt).toBeInstanceOf(Date) - expect(first.items[1]).toMatchObject({ - event: { id: EVENT, title: "Ocean Beach sweep", referenceCode: "EV-1" }, - jurisdiction: { geoid: GEOID, name: "Los Angeles" }, - occurredAt: SCHEDULED_AT, - voidedAt: null, - }) - expect(first.nextCursor).not.toBeNull() - - const second = await repo.listOperatorLedger({ - userId: BOB, - cursor: parseTimeCursor(first.nextCursor!), - limit: 2, - }) - expect(second.items.map((i) => [i.source, i.creditedBy?.id, i.operator])).toEqual([ - ["event", HOST, null], - ]) - expect(second.nextCursor).toBeNull() - }) - - it("never returns more than the ledger page cap", async () => { - const { repo, clock } = makeRepo() - for (let day = 1; day <= 60; day++) { - await repo.creditManual( - manualCredit(0.25, `2026-05-${String((day % 28) + 1).padStart(2, "0")}`), - ) - clock.advance(MANUAL_CREDIT_REPEAT_WINDOW_MS) - } - - const page = await repo.listOperatorLedger({ userId: BOB, cursor: null, limit: 500 }) - expect(page.items).toHaveLength(50) - expect(page.nextCursor).not.toBeNull() - }) - - it("totals match the profile total and count live and voided entries", async () => { - const { repo } = makeRepo() - await repo.creditEventAsOperator(eventCredit(3)) - const manual = await repo.creditManual(manualCredit(2)) - await repo.creditManual(manualCredit(1, "2026-07-01")) - await repo.voidEntry({ - operatorId: OPERATOR, - userId: BOB, - entryId: manual.entryId, - reason: "Typo", - }) - - const totals = await repo.operatorLedgerTotals(BOB) - expect(totals).toEqual({ totalHours: 4, liveEntries: 2, voidedEntries: 1 }) - expect(totals.totalHours).toBe((await repo.totalsFor(BOB)).totalHours) - }) -}) - -describe("certificates listing a ledger entry", () => { - function certificate( - certs: InMemoryCertificateRepository, - input: { userId: string; code: string; entryIds: string[]; issuedAt: string }, - ) { - const snapshot = buildTranscriptModel({ - holder: { userId: input.userId, displayName: "Bob", handle: "bob" }, - rows: input.entryIds.map((id) => ({ - id, - source: "event" as const, - hours: 1, - occurredAt: SCHEDULED_AT, - })), - locale: "en", - }) - return certs.insert({ - id: `${input.code}-id`, - userId: input.userId, - code: input.code, - locale: "en", - holderName: "Bob", - holderHandle: "bob", - holderVerified: false, - totalHours: input.entryIds.length, - entryCount: input.entryIds.length, - periodStart: null, - periodEnd: null, - ledgerFingerprint: `fp-${input.code}`, - snapshot, - r2Key: `certs/${input.code}.pdf`, - documentSha256: "0".repeat(64), - byteSize: 1, - issuedAt: new Date(input.issuedAt), - }) - } - - it("names only the holder's live certificates whose snapshot itemised the entry, newest first", async () => { - const certs = new InMemoryCertificateRepository() - const entry = "00000000-0000-4000-8000-0000000000e1" - await certificate(certs, { - userId: BOB, - code: "OLD", - entryIds: [entry], - issuedAt: "2026-07-01T00:00:00Z", - }) - await certificate(certs, { - userId: BOB, - code: "NEW", - entryIds: ["x", entry], - issuedAt: "2026-07-03T00:00:00Z", - }) - await certificate(certs, { - userId: BOB, - code: "GONE", - entryIds: [entry], - issuedAt: "2026-07-02T00:00:00Z", - }) - await certificate(certs, { - userId: BOB, - code: "OTHER", - entryIds: ["x"], - issuedAt: "2026-07-04T00:00:00Z", - }) - await certificate(certs, { - userId: CAROL, - code: "CAROL", - entryIds: [entry], - issuedAt: "2026-07-05T00:00:00Z", - }) - await certs.revoke(BOB, "GONE", "holder", new Date("2026-07-06T00:00:00Z")) - - expect(await certs.liveCodesListingEntry(BOB, entry)).toEqual([ - { code: "NEW", issuedAt: new Date("2026-07-03T00:00:00Z") }, - { code: "OLD", issuedAt: new Date("2026-07-01T00:00:00Z") }, - ]) - }) -}) From 18f309769eefc2734f60495872d7e3121c58ae84 Mon Sep 17 00:00:00 2001 From: byteful Date: Sat, 26 Sep 2026 21:44:43 -0400 Subject: [PATCH 06/14] pin shared snapshot --- pnpm-lock.yaml | 14 +++++++------- services/api/package.json | 2 +- services/media-worker/package.json | 2 +- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index aee991b1..5fe519b9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -65,8 +65,8 @@ importers: specifier: ^3.700.0 version: 3.1057.0 '@civfix/shared': - specifier: ^0.57.0 - version: 0.57.0 + specifier: 0.58.0-b-feat-admin-user-hours-contract.g47d8a38 + version: 0.58.0-b-feat-admin-user-hours-contract.g47d8a38 '@fastify/cookie': specifier: ^11.0.1 version: 11.0.2 @@ -213,8 +213,8 @@ importers: specifier: workspace:* version: link:../api '@civfix/shared': - specifier: ^0.57.0 - version: 0.57.0 + specifier: 0.58.0-b-feat-admin-user-hours-contract.g47d8a38 + version: 0.58.0-b-feat-admin-user-hours-contract.g47d8a38 '@sentry/node': specifier: ^10.73.0 version: 10.73.0(@opentelemetry/core@2.11.0(@opentelemetry/api@1.9.1)) @@ -417,8 +417,8 @@ packages: '@balena/dockerignore@1.0.2': resolution: {integrity: sha512-wMue2Sy4GAVTk6Ic4tJVcnfdau+gx2EnG7S+uAEe+TWJFqE4YoWN4/H8MSLj4eYJKxGg26lZwboEniNiNwZQ6Q==} - '@civfix/shared@0.57.0': - resolution: {integrity: sha512-v4IFb4pR9hbnf+AueRmL7GLB8DIhG9c9ZJjAnXKK3Yqjsv0Mbmhj0Khmb27YRgKL1Aoe//7nLiFkgj11Qrg7GQ==} + '@civfix/shared@0.58.0-b-feat-admin-user-hours-contract.g47d8a38': + resolution: {integrity: sha512-J2l/KZ19Nn98wV8iS1EF3lUmCUUTdElMxnRH3bO+ij/RhGMo02O35u22Ly8nvt7rMC0qf4DNH2BZo9z23l5tLQ==} engines: {node: '>=20'} '@derhuerst/http-basic@8.2.4': @@ -4450,7 +4450,7 @@ snapshots: '@balena/dockerignore@1.0.2': {} - '@civfix/shared@0.57.0': + '@civfix/shared@0.58.0-b-feat-admin-user-hours-contract.g47d8a38': dependencies: zod: 3.25.76 diff --git a/services/api/package.json b/services/api/package.json index 223d63aa..ea55f268 100644 --- a/services/api/package.json +++ b/services/api/package.json @@ -60,7 +60,7 @@ "dependencies": { "@aws-sdk/client-s3": "^3.700.0", "@aws-sdk/s3-request-presigner": "^3.700.0", - "@civfix/shared": "^0.57.0", + "@civfix/shared": "0.58.0-b-feat-admin-user-hours-contract.g47d8a38", "@fastify/cookie": "^11.0.1", "@fastify/cors": "^10.0.1", "@fastify/helmet": "^12.0.1", diff --git a/services/media-worker/package.json b/services/media-worker/package.json index 25093f8a..72ee8b40 100644 --- a/services/media-worker/package.json +++ b/services/media-worker/package.json @@ -23,7 +23,7 @@ "@aws-sdk/client-s3": "^3.700.0", "@aws-sdk/s3-request-presigner": "^3.700.0", "@civfix/api": "workspace:*", - "@civfix/shared": "^0.57.0", + "@civfix/shared": "0.58.0-b-feat-admin-user-hours-contract.g47d8a38", "@sentry/node": "^10.73.0", "@smithy/node-http-handler": "4.7.5", "drizzle-orm": "^0.45.2", From aa6bfb779fef00e6db796222504b49f02eafc44e Mon Sep 17 00:00:00 2001 From: byteful Date: Sat, 26 Sep 2026 21:46:02 -0400 Subject: [PATCH 07/14] operator ledger read and certificate entry lookup --- .../certificate-repository.drizzle.ts | 19 +- .../services/certificate-repository.memory.ts | 17 +- .../api/src/services/certificate-service.ts | 16 +- .../volunteer-hours-repository.drizzle.ts | 133 +++++++++++++- .../volunteer-hours-repository.memory.ts | 87 ++++++++- .../src/services/volunteer-hours-service.ts | 36 ++++ .../integration/admin-user-hours-pg.test.ts | 94 +++++++++- .../volunteer-hours-operator-ledger.test.ts | 172 +++++++++++++++++- 8 files changed, 563 insertions(+), 11 deletions(-) diff --git a/services/api/src/services/certificate-repository.drizzle.ts b/services/api/src/services/certificate-repository.drizzle.ts index c3924bf3..20624fe6 100644 --- a/services/api/src/services/certificate-repository.drizzle.ts +++ b/services/api/src/services/certificate-repository.drizzle.ts @@ -8,8 +8,9 @@ * of jsonb, which Postgres stores out of line in TOAST. A `SELECT *` (or any projection naming * `snapshot`) detoasts that blob on EVERY holder list read and on EVERY anonymous public verification — * the highest-traffic read in this feature, served to a school registrar who needs six scalars. The - * column is written once and read back by nothing; `service-hours-certificates-pg.test.ts` greps this - * source to keep it that way. + * column is written once and never projected; `service-hours-certificates-pg.test.ts` greps this + * source to keep it that way. The one read that looks inside it is `liveCodesListingEntry`, a + * containment filter run only when an operator voids a ledger entry, over one holder's live rows. * * The two unique indexes are surfaced as a typed `CertificateConflictError` rather than a leaked driver * error, because the service's recoveries are completely different: a `code` collision re-mints and @@ -20,6 +21,7 @@ import type { Sql } from "../db/client.js" import { CertificateConflictError, + type CertificateEntryCode, type CertificateHolder, type CertificateInsert, type CertificateRepository, @@ -254,5 +256,18 @@ export function makeDrizzleCertificateRepository(sql: Sql): CertificateRepositor locale: row.locale, } }, + + async liveCodesListingEntry(userId: string, entryId: string): Promise { + const rows = await sql<{ code: string; issued_at: Date }[]>` + SELECT code, issued_at + FROM service_hours_certificates + WHERE user_id = ${userId} + AND revoked_at IS NULL + AND snapshot->'rows' @> jsonb_build_array(jsonb_build_object('id', ${entryId}::text)) + ORDER BY issued_at DESC, id DESC + LIMIT 200 + ` + return rows.map((r) => ({ code: r.code, issuedAt: r.issued_at })) + }, } } diff --git a/services/api/src/services/certificate-repository.memory.ts b/services/api/src/services/certificate-repository.memory.ts index fc0b3102..25547504 100644 --- a/services/api/src/services/certificate-repository.memory.ts +++ b/services/api/src/services/certificate-repository.memory.ts @@ -16,12 +16,13 @@ * - reads hand back CLONES, so a test mutating a returned row cannot corrupt the store (the Drizzle * repo obviously cannot be corrupted that way). * - * `snapshot` is stored but never read back, exactly as in production. + * `snapshot` is read back only by `liveCodesListingEntry`, exactly as in production. */ import { randomUUID } from "node:crypto" import { CertificateConflictError, + type CertificateEntryCode, type CertificateHolder, type CertificateInsert, type CertificateRepository, @@ -141,6 +142,20 @@ export class InMemoryCertificateRepository implements CertificateRepository { return Promise.resolve() } + liveCodesListingEntry(userId: string, entryId: string): Promise { + const codes = [...this.rows.values()] + .filter( + (row) => + row.userId === userId && + row.revokedAt === null && + row.snapshot.rows.some((item) => item.id === entryId), + ) + .sort((a, b) => b.issuedAt.getTime() - a.issuedAt.getTime() || b.id.localeCompare(a.id)) + .slice(0, 200) + .map((row) => ({ code: row.code, issuedAt: row.issuedAt })) + return Promise.resolve(codes) + } + findHolder(userId: string): Promise { const holder = this.holders.get(userId) // A tombstoned account cannot issue, matching the Drizzle read's `deleted_at IS NULL` filter. diff --git a/services/api/src/services/certificate-service.ts b/services/api/src/services/certificate-service.ts index d9ad4e07..dc50b2d7 100644 --- a/services/api/src/services/certificate-service.ts +++ b/services/api/src/services/certificate-service.ts @@ -146,7 +146,10 @@ export interface CertificateInsert { periodStart: Date | null periodEnd: Date | null ledgerFingerprint: string - /** The exact rendered model, stored so the object is re-renderable. Written once, never read back. */ + /** + * The exact rendered model, stored so the object is re-renderable. Written once; only + * `liveCodesListingEntry` reads inside it. + */ snapshot: TranscriptModel r2Key: string documentSha256: string @@ -193,6 +196,17 @@ export interface CertificateRepository { at: Date }): Promise findHolder(userId: string): Promise + /** + * Live (unrevoked) certificates of `userId` whose frozen snapshot itemised ledger entry `entryId`, + * newest first. An operator void does not change an issued document, so the admin plane lists these + * for a revoke through `db:certificate:revoke`. + */ + liveCodesListingEntry(userId: string, entryId: string): Promise +} + +export interface CertificateEntryCode { + code: string + issuedAt: Date } // ---- service ------------------------------------------------------------------------------------ diff --git a/services/api/src/services/volunteer-hours-repository.drizzle.ts b/services/api/src/services/volunteer-hours-repository.drizzle.ts index 00358aec..976407ff 100644 --- a/services/api/src/services/volunteer-hours-repository.drizzle.ts +++ b/services/api/src/services/volunteer-hours-repository.drizzle.ts @@ -5,7 +5,7 @@ import type { OrgVerificationStatus, VolunteerHoursSource, } from "@civfix/shared" -import { CIVFIX_OFFICIAL_USER_ID } from "../auth/official-account.js" +import { CIVFIX_OFFICIAL_USER_ID, isOfficialAccount } from "../auth/official-account.js" import type { Queryable, Sql, TransactionSql } from "../db/client.js" import { encodeTimeCursor, pageWith } from "../db/cursor-helpers.js" import { writeAudit } from "./admin/audit.js" @@ -25,6 +25,7 @@ import { ITEMISED_SOURCES, MANUAL_CREDIT_REPEAT_WINDOW_MS, MAX_ORG_CHIPS_FETCH, + OPERATOR_LEDGER_MAX_LIMIT, RECIPROCAL_LOOKBACK_MS, WEEKLY_HOURS_FLAG_DEFAULT, } from "./volunteer-hours-service.js" @@ -40,6 +41,9 @@ import type { MyVolunteerHoursTotals, OperatorCreditResult, OperatorEventCreditArgs, + OperatorLedgerArgs, + OperatorLedgerEntryView, + OperatorLedgerTotals, OperatorManualCreditArgs, OperatorVoidArgs, OrgHoursView, @@ -198,6 +202,63 @@ async function detectHoursAnomalies( return anomalies } +interface OperatorLedgerRow { + id: string + source: VolunteerHoursSource + hours: number + created_at: Date + occurred_at: Date + service_date: string | null + cleanup_id: string | null + cleanup_title: string | null + reference_code: string | null + jurisdiction_geoid: string | null + jurisdiction_name: string | null + creditor_id: string | null + creditor_name: string | null + creditor_handle: string | null + operator_id: string | null + operator_name: string | null + note: string | null + voided_at: Date | null + voided_by_id: string | null + voided_by_name: string | null + void_reason: string | null +} + +function toOperatorLedgerView(r: OperatorLedgerRow): OperatorLedgerEntryView { + return { + id: r.id, + source: r.source, + hours: r.hours, + occurredAt: r.occurred_at, + createdAt: r.created_at, + serviceDate: r.service_date, + event: + r.cleanup_id !== null + ? { id: r.cleanup_id, title: r.cleanup_title ?? "", referenceCode: r.reference_code } + : null, + jurisdiction: + r.jurisdiction_geoid !== null + ? { geoid: r.jurisdiction_geoid, name: r.jurisdiction_name } + : null, + creditedBy: + r.creditor_id !== null + ? { + id: r.creditor_id, + name: r.creditor_name ?? "", + handle: r.creditor_handle, + official: isOfficialAccount(r.creditor_id), + } + : null, + operator: r.operator_id !== null ? { id: r.operator_id, name: r.operator_name ?? "" } : null, + note: r.note, + voidedAt: r.voided_at, + voidedBy: r.voided_by_id !== null ? { id: r.voided_by_id, name: r.voided_by_name ?? "" } : null, + voidReason: r.void_reason, + } +} + function round2(n: number): number { return Math.round(n * 100) / 100 } @@ -476,8 +537,10 @@ export function makeDrizzleVolunteerHoursRepository(sql: Sql): VolunteerHoursRep async listEventHours(cleanupId: string, viewerId: string | null): Promise { const mine = viewerId !== null ? sql`AND vh.user_id = ${viewerId}::uuid` : sql`` - const rows = await sql<{ user_id: string; hours: number; created_at: Date }[]>` - SELECT vh.user_id, vh.hours::float8 AS hours, vh.created_at + const rows = await sql< + { user_id: string; hours: number; created_at: Date; logged_by_user_id: string | null }[] + >` + SELECT vh.user_id, vh.hours::float8 AS hours, vh.created_at, vh.logged_by_user_id FROM volunteer_hours vh WHERE vh.cleanup_id = ${cleanupId} AND vh.source = 'event' @@ -490,6 +553,7 @@ export function makeDrizzleVolunteerHoursRepository(sql: Sql): VolunteerHoursRep userId: r.user_id, hours: r.hours, loggedAt: r.created_at, + creditedByOfficial: isOfficialAccount(r.logged_by_user_id), })) if (viewerId === null) return { entries, anyLogged: entries.length > 0 } const probe = await sql<{ any_logged: boolean }[]>` @@ -747,5 +811,68 @@ export function makeDrizzleVolunteerHoursRepository(sql: Sql): VolunteerHoursRep return { id: args.entryId, source: row.source, cleanupId: row.cleanup_id, hours: row.hours } }) }, + + async listOperatorLedger( + args: OperatorLedgerArgs, + ): Promise<{ items: OperatorLedgerEntryView[]; nextCursor: string | null }> { + const limit = Math.min(Math.max(1, Math.floor(args.limit)), OPERATOR_LEDGER_MAX_LIMIT) + const keyset = + args.cursor !== null + ? sql`AND (vh.created_at, vh.id) < (${args.cursor.at}, ${args.cursor.id}::uuid)` + : sql`` + const rows = await sql` + SELECT + vh.id, + vh.source, + vh.hours::float8 AS hours, + vh.created_at, + ${occurredAtExpr(sql)} AS occurred_at, + vh.service_date::text AS service_date, + vh.cleanup_id, + c.title AS cleanup_title, + c.reference_code, + vh.jurisdiction_geoid, + j.name AS jurisdiction_name, + lb.id AS creditor_id, + lb.display_name AS creditor_name, + lb.handle AS creditor_handle, + op.id AS operator_id, + op.display_name AS operator_name, + vh.note, + vh.voided_at, + vb.id AS voided_by_id, + vb.display_name AS voided_by_name, + vh.void_reason + FROM volunteer_hours vh + LEFT JOIN cleanups c ON c.id = vh.cleanup_id + LEFT JOIN jurisdictions j ON j.geoid = vh.jurisdiction_geoid + LEFT JOIN users lb ON lb.id = vh.logged_by_user_id + LEFT JOIN users op ON op.id = vh.credited_by_operator_id + LEFT JOIN users vb ON vb.id = vh.voided_by_operator_id + WHERE vh.user_id = ${args.userId} + ${keyset} + ORDER BY vh.created_at DESC, vh.id DESC + LIMIT ${limit + 1} + ` + const { items, nextCursor } = pageWith(rows, limit, (last) => + encodeTimeCursor({ at: last.created_at, id: last.id }), + ) + return { items: items.map(toOperatorLedgerView), nextCursor } + }, + + async operatorLedgerTotals(userId: string): Promise { + const rows = await sql<{ live: number; voided: number }[]>` + SELECT + count(*) FILTER (WHERE voided_at IS NULL)::int AS live, + count(*) FILTER (WHERE voided_at IS NOT NULL)::int AS voided + FROM volunteer_hours + WHERE user_id = ${userId} + ` + return { + totalHours: await computeTotalHours(sql, userId), + liveEntries: rows[0]?.live ?? 0, + voidedEntries: rows[0]?.voided ?? 0, + } + }, } } diff --git a/services/api/src/services/volunteer-hours-repository.memory.ts b/services/api/src/services/volunteer-hours-repository.memory.ts index a4943371..0be8db6f 100644 --- a/services/api/src/services/volunteer-hours-repository.memory.ts +++ b/services/api/src/services/volunteer-hours-repository.memory.ts @@ -1,6 +1,6 @@ import { randomUUID } from "node:crypto" import { AppError, avatarGradient } from "@civfix/shared" -import { CIVFIX_OFFICIAL_USER_ID } from "../auth/official-account.js" +import { CIVFIX_OFFICIAL_USER_ID, isOfficialAccount } from "../auth/official-account.js" import type { LeaderboardEntryDTO, MyVolunteerHoursDTO, @@ -15,6 +15,7 @@ import { ITEMISED_SOURCES, MANUAL_CREDIT_REPEAT_WINDOW_MS, MAX_ORG_CHIPS_FETCH, + OPERATOR_LEDGER_MAX_LIMIT, RECIPROCAL_LOOKBACK_MS, WEEKLY_HOURS_FLAG_DEFAULT, } from "./volunteer-hours-service.js" @@ -32,6 +33,9 @@ import type { MyVolunteerHoursTotals, OperatorCreditResult, OperatorEventCreditArgs, + OperatorLedgerArgs, + OperatorLedgerEntryView, + OperatorLedgerTotals, OperatorManualCreditArgs, OperatorVoidArgs, OrgHoursView, @@ -522,7 +526,12 @@ export class InMemoryVolunteerHoursRepository implements VolunteerHoursRepositor entries: rows .slice() .sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime() || b.id.localeCompare(a.id)) - .map((e) => ({ userId: e.userId, hours: round2(e.hours), loggedAt: e.createdAt })), + .map((e) => ({ + userId: e.userId, + hours: round2(e.hours), + loggedAt: e.createdAt, + creditedByOfficial: isOfficialAccount(e.loggedByUserId), + })), anyLogged: all.length > 0, }) } @@ -696,6 +705,80 @@ export class InMemoryVolunteerHoursRepository implements VolunteerHoursRepositor return { id: row.id, source: row.source, cleanupId: row.cleanupId, hours: row.hours } } + listOperatorLedger( + args: OperatorLedgerArgs, + ): Promise<{ items: OperatorLedgerEntryView[]; nextCursor: string | null }> { + const limit = Math.min(Math.max(1, Math.floor(args.limit)), OPERATOR_LEDGER_MAX_LIMIT) + const rows = this.entries + .filter((e) => e.userId === args.userId) + .sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime() || b.id.localeCompare(a.id)) + .filter((e) => { + if (args.cursor === null) return true + const at = e.createdAt.getTime() + const anchor = args.cursor.at.getTime() + return at < anchor || (at === anchor && e.id < args.cursor.id) + }) + .slice(0, limit + 1) + const { items, nextCursor } = pageWith(rows, limit, (last) => + encodeTimeCursor({ at: last.createdAt, id: last.id }), + ) + return Promise.resolve({ items: items.map((e) => this.toOperatorView(e)), nextCursor }) + } + + operatorLedgerTotals(userId: string): Promise { + const mine = this.entries.filter((e) => e.userId === userId) + return Promise.resolve({ + totalHours: this.computeTotalHours(userId), + liveEntries: mine.filter((e) => e.voidedAt === undefined).length, + voidedEntries: mine.filter((e) => e.voidedAt !== undefined).length, + }) + } + + private toOperatorView(e: LedgerEntry): OperatorLedgerEntryView { + const meta = e.cleanupId !== null ? (this.cleanups.get(e.cleanupId) ?? null) : null + const nameOf = (id: string): string => this.users.get(id)?.name ?? "" + return { + id: e.id, + source: e.source, + hours: round2(e.hours), + occurredAt: this.occurredAt(e), + createdAt: e.createdAt, + serviceDate: e.serviceDate, + event: + e.cleanupId !== null + ? { + id: e.cleanupId, + title: meta?.title ?? "", + referenceCode: meta?.referenceCode ?? null, + } + : null, + jurisdiction: + e.geoid !== null + ? { geoid: e.geoid, name: this.jurisdictionNames.get(e.geoid) ?? null } + : null, + creditedBy: + e.loggedByUserId !== null + ? { + id: e.loggedByUserId, + name: nameOf(e.loggedByUserId), + handle: this.users.get(e.loggedByUserId)?.handle ?? null, + official: isOfficialAccount(e.loggedByUserId), + } + : null, + operator: + e.creditedByOperatorId !== null + ? { id: e.creditedByOperatorId, name: nameOf(e.creditedByOperatorId) } + : null, + note: e.note, + voidedAt: e.voidedAt ?? null, + voidedBy: + e.voidedByOperatorId !== null + ? { id: e.voidedByOperatorId, name: nameOf(e.voidedByOperatorId) } + : null, + voidReason: e.voidReason, + } + } + private occurredAt(e: LedgerEntry): Date { const scheduledAt = e.cleanupId !== null ? this.cleanups.get(e.cleanupId)?.scheduledAt : null if (scheduledAt != null) return scheduledAt diff --git a/services/api/src/services/volunteer-hours-service.ts b/services/api/src/services/volunteer-hours-service.ts index b8d2c554..2800020d 100644 --- a/services/api/src/services/volunteer-hours-service.ts +++ b/services/api/src/services/volunteer-hours-service.ts @@ -96,6 +96,8 @@ export interface EventCreditWrite { export const MANUAL_CREDIT_REPEAT_WINDOW_MS = 60_000 +export const OPERATOR_LEDGER_MAX_LIMIT = 50 + export interface OperatorEventCreditArgs { operatorId: string userId: string @@ -133,6 +135,35 @@ export interface VoidedEntry { hours: number } +export interface OperatorLedgerArgs { + userId: string + cursor: TimeCursor | null + limit: number +} + +export interface OperatorLedgerEntryView { + id: string + source: VolunteerHoursSource + hours: number + occurredAt: Date + createdAt: Date + serviceDate: string | null + event: { id: string; title: string; referenceCode: string | null } | null + jurisdiction: { geoid: string; name: string | null } | null + creditedBy: { id: string; name: string; handle: string | null; official: boolean } | null + operator: { id: string; name: string } | null + note: string | null + voidedAt: Date | null + voidedBy: { id: string; name: string } | null + voidReason: string | null +} + +export interface OperatorLedgerTotals { + totalHours: number + liveEntries: number + voidedEntries: number +} + export interface LogEventHoursResult { credited: number changed: EventCreditChange[] @@ -163,6 +194,7 @@ export interface EventHoursLedgerEntry { userId: string hours: number loggedAt: Date + creditedByOfficial: boolean } export interface EventHoursLedger { @@ -257,6 +289,10 @@ export interface VolunteerHoursRepository { creditEventAsOperator(args: OperatorEventCreditArgs): Promise creditManual(args: OperatorManualCreditArgs): Promise voidEntry(args: OperatorVoidArgs): Promise + listOperatorLedger( + args: OperatorLedgerArgs, + ): Promise<{ items: OperatorLedgerEntryView[]; nextCursor: string | null }> + operatorLedgerTotals(userId: string): Promise } export interface CleanupHoursView { diff --git a/services/api/test/integration/admin-user-hours-pg.test.ts b/services/api/test/integration/admin-user-hours-pg.test.ts index 1bdc5343..8cb85d96 100644 --- a/services/api/test/integration/admin-user-hours-pg.test.ts +++ b/services/api/test/integration/admin-user-hours-pg.test.ts @@ -3,6 +3,9 @@ import { randomUUID } from "node:crypto" import { withPg, type PgHarness } from "../helpers/pg.js" import { seedCleanup } from "../helpers/cleanups.js" import { CIVFIX_OFFICIAL_USER_ID } from "../../src/auth/official-account.js" +import { parseTimeCursor } from "../../src/db/cursor-helpers.js" +import { buildTranscriptModel } from "../../src/services/certificate-model.js" +import { makeDrizzleCertificateRepository } from "../../src/services/certificate-repository.drizzle.js" import { makeDrizzleVolunteerHoursRepository } from "../../src/services/volunteer-hours-repository.drizzle.js" import { LA_CITY } from "../../src/db/seed-fixtures.js" @@ -173,7 +176,9 @@ describe.skipIf(!pg)("operator volunteer-hours ledger (integration)", () => { }, ]) const hostSheet = await repo.listEventHours(cleanupId, null) - expect(hostSheet.entries.map((e) => [e.userId, e.hours])).toEqual([[alice, 3]]) + expect(hostSheet.entries.map((e) => [e.userId, e.hours, e.creditedByOfficial])).toEqual([ + [alice, 3, true], + ]) await expect( repo.creditEventAsOperator({ @@ -449,4 +454,91 @@ describe.skipIf(!pg)("operator volunteer-hours ledger (integration)", () => { expect(total).toBe(ledger!.total) } }) + + it("pages the operator ledger by (created_at, id), voided rows included, with live and voided counts", async () => { + const alice = await newUser("Page Alice") + const operator = await newUser("Page Olive") + const repo = makeDrizzleVolunteerHoursRepository(h.sql) + const ids: string[] = [] + for (let day = 1; day <= 5; day++) { + const { entryId } = await repo.creditManual({ + operatorId: operator, + userId: alice, + hours: 1, + serviceDate: `2026-05-0${day}`, + reason: `day ${day}`, + }) + ids.unshift(entryId) + } + await repo.voidEntry({ operatorId: operator, userId: alice, entryId: ids[1]!, reason: "dup" }) + + const first = await repo.listOperatorLedger({ userId: alice, cursor: null, limit: 3 }) + const second = await repo.listOperatorLedger({ + userId: alice, + cursor: parseTimeCursor(first.nextCursor!), + limit: 3, + }) + expect([...first.items, ...second.items].map((i) => i.id)).toEqual(ids) + expect(second.nextCursor).toBeNull() + expect(first.items[1]).toMatchObject({ + voidedBy: { id: operator, name: "Page Olive" }, + voidReason: "dup", + operator: { id: operator, name: "Page Olive" }, + creditedBy: { id: CIVFIX_OFFICIAL_USER_ID, official: true }, + }) + expect(await repo.operatorLedgerTotals(alice)).toEqual({ + totalHours: 4, + liveEntries: 4, + voidedEntries: 1, + }) + }) + + it("finds the live certificates whose snapshot itemised an entry", async () => { + const alice = await newUser("Cert Alice") + const certs = makeDrizzleCertificateRepository(h.sql) + const entry = randomUUID() + + async function issue(code: string, entryIds: string[], issuedAt: string): Promise { + await certs.insert({ + id: randomUUID(), + userId: alice, + code, + locale: "en", + holderName: "Cert Alice", + holderHandle: null, + holderVerified: false, + totalHours: entryIds.length, + entryCount: entryIds.length, + periodStart: null, + periodEnd: null, + ledgerFingerprint: `fp-${code}`, + snapshot: buildTranscriptModel({ + holder: { userId: alice, displayName: "Cert Alice", handle: null }, + rows: entryIds.map((id) => ({ + id, + source: "event" as const, + hours: 1, + occurredAt: new Date(issuedAt), + })), + locale: "en", + }), + r2Key: `certificates/${code}.pdf`, + documentSha256: "0".repeat(64), + byteSize: 1, + issuedAt: new Date(issuedAt), + }) + } + + const suffix = randomUUID().slice(0, 6).toUpperCase() + await issue(`A${suffix}`, [entry], "2026-07-01T00:00:00Z") + await issue(`B${suffix}`, [randomUUID(), entry], "2026-07-02T00:00:00Z") + await issue(`C${suffix}`, [randomUUID()], "2026-07-03T00:00:00Z") + await issue(`D${suffix}`, [entry], "2026-07-04T00:00:00Z") + await certs.revoke(alice, `D${suffix}`, "ledger_corrected", new Date()) + + expect(await certs.liveCodesListingEntry(alice, entry)).toEqual([ + { code: `B${suffix}`, issuedAt: new Date("2026-07-02T00:00:00Z") }, + { code: `A${suffix}`, issuedAt: new Date("2026-07-01T00:00:00Z") }, + ]) + }) }) diff --git a/services/api/test/unit/volunteer-hours-operator-ledger.test.ts b/services/api/test/unit/volunteer-hours-operator-ledger.test.ts index 1d3e4eb2..2907d8ba 100644 --- a/services/api/test/unit/volunteer-hours-operator-ledger.test.ts +++ b/services/api/test/unit/volunteer-hours-operator-ledger.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from "vitest" import { CIVFIX_OFFICIAL_USER_ID } from "../../src/auth/official-account.js" +import { parseTimeCursor } from "../../src/db/cursor-helpers.js" +import { buildTranscriptModel } from "../../src/services/certificate-model.js" +import { InMemoryCertificateRepository } from "../../src/services/certificate-repository.memory.js" import { InMemoryVolunteerHoursRepository } from "../../src/services/volunteer-hours-repository.memory.js" import { DAILY_HOURS_CAP, @@ -106,7 +109,9 @@ describe("operator event credit", () => { ]) const hostView = await repo.listEventHours(EVENT, null) - expect(hostView.entries.map((e) => [e.userId, e.hours])).toEqual([[BOB, 3]]) + expect(hostView.entries).toEqual([ + expect.objectContaining({ userId: BOB, hours: 3, creditedByOfficial: true }), + ]) }) it("journals and audits the human operator, not the official account", async () => { @@ -261,6 +266,7 @@ describe("host writes over an operator credit", () => { creditedByOperatorId: null, note: null, }) + expect((await repo.listEventHours(EVENT, null)).entries[0]!.creditedByOfficial).toBe(false) }) it("a host may re-credit an attendee whose entry an operator voided", async () => { @@ -460,3 +466,167 @@ describe("operator void", () => { ).rejects.toMatchObject({ code: "CONFLICT", message: expect.stringContaining("retired") }) }) }) + +describe("operator ledger read", () => { + it("lists voided rows too, newest first, keyset-paged, with the admin-plane attribution", async () => { + const { repo, clock } = makeRepo() + await repo.logEventHours({ + actorId: HOST, + cleanupId: SAME_DAY_EVENT, + geoid: GEOID, + entries: [{ userId: BOB, hours: 1 }], + }) + clock.advance(1_000) + const event = await repo.creditEventAsOperator(eventCredit(3)) + clock.advance(1_000) + const manual = await repo.creditManual(manualCredit(2)) + clock.advance(1_000) + await repo.voidEntry({ + operatorId: OTHER_OPERATOR, + userId: BOB, + entryId: manual.entryId, + reason: "Typo", + }) + + const first = await repo.listOperatorLedger({ userId: BOB, cursor: null, limit: 2 }) + expect(first.items.map((i) => i.id)).toEqual([manual.entryId, event.entryId]) + expect(first.items[0]).toMatchObject({ + source: "manual", + serviceDate: "2026-07-05", + event: null, + jurisdiction: null, + creditedBy: { id: CIVFIX_OFFICIAL_USER_ID, name: "CivFix", handle: "civfix", official: true }, + operator: { id: OPERATOR, name: "Olive Operator" }, + note: "Tabling at the library fair", + voidedBy: { id: OTHER_OPERATOR, name: "" }, + voidReason: "Typo", + }) + expect(first.items[0]!.voidedAt).toBeInstanceOf(Date) + expect(first.items[1]).toMatchObject({ + event: { id: EVENT, title: "Ocean Beach sweep", referenceCode: "EV-1" }, + jurisdiction: { geoid: GEOID, name: "Los Angeles" }, + occurredAt: SCHEDULED_AT, + voidedAt: null, + }) + expect(first.nextCursor).not.toBeNull() + + const second = await repo.listOperatorLedger({ + userId: BOB, + cursor: parseTimeCursor(first.nextCursor!), + limit: 2, + }) + expect(second.items.map((i) => [i.source, i.creditedBy?.id, i.operator])).toEqual([ + ["event", HOST, null], + ]) + expect(second.nextCursor).toBeNull() + }) + + it("never returns more than the ledger page cap", async () => { + const { repo, clock } = makeRepo() + for (let day = 1; day <= 60; day++) { + await repo.creditManual( + manualCredit(0.25, `2026-05-${String((day % 28) + 1).padStart(2, "0")}`), + ) + clock.advance(MANUAL_CREDIT_REPEAT_WINDOW_MS) + } + + const page = await repo.listOperatorLedger({ userId: BOB, cursor: null, limit: 500 }) + expect(page.items).toHaveLength(50) + expect(page.nextCursor).not.toBeNull() + }) + + it("totals match the profile total and count live and voided entries", async () => { + const { repo } = makeRepo() + await repo.creditEventAsOperator(eventCredit(3)) + const manual = await repo.creditManual(manualCredit(2)) + await repo.creditManual(manualCredit(1, "2026-07-01")) + await repo.voidEntry({ + operatorId: OPERATOR, + userId: BOB, + entryId: manual.entryId, + reason: "Typo", + }) + + const totals = await repo.operatorLedgerTotals(BOB) + expect(totals).toEqual({ totalHours: 4, liveEntries: 2, voidedEntries: 1 }) + expect(totals.totalHours).toBe((await repo.totalsFor(BOB)).totalHours) + }) +}) + +describe("certificates listing a ledger entry", () => { + function certificate( + certs: InMemoryCertificateRepository, + input: { userId: string; code: string; entryIds: string[]; issuedAt: string }, + ) { + const snapshot = buildTranscriptModel({ + holder: { userId: input.userId, displayName: "Bob", handle: "bob" }, + rows: input.entryIds.map((id) => ({ + id, + source: "event" as const, + hours: 1, + occurredAt: SCHEDULED_AT, + })), + locale: "en", + }) + return certs.insert({ + id: `${input.code}-id`, + userId: input.userId, + code: input.code, + locale: "en", + holderName: "Bob", + holderHandle: "bob", + holderVerified: false, + totalHours: input.entryIds.length, + entryCount: input.entryIds.length, + periodStart: null, + periodEnd: null, + ledgerFingerprint: `fp-${input.code}`, + snapshot, + r2Key: `certs/${input.code}.pdf`, + documentSha256: "0".repeat(64), + byteSize: 1, + issuedAt: new Date(input.issuedAt), + }) + } + + it("names only the holder's live certificates whose snapshot itemised the entry, newest first", async () => { + const certs = new InMemoryCertificateRepository() + const entry = "00000000-0000-4000-8000-0000000000e1" + await certificate(certs, { + userId: BOB, + code: "OLD", + entryIds: [entry], + issuedAt: "2026-07-01T00:00:00Z", + }) + await certificate(certs, { + userId: BOB, + code: "NEW", + entryIds: ["x", entry], + issuedAt: "2026-07-03T00:00:00Z", + }) + await certificate(certs, { + userId: BOB, + code: "GONE", + entryIds: [entry], + issuedAt: "2026-07-02T00:00:00Z", + }) + await certificate(certs, { + userId: BOB, + code: "OTHER", + entryIds: ["x"], + issuedAt: "2026-07-04T00:00:00Z", + }) + await certificate(certs, { + userId: CAROL, + code: "CAROL", + entryIds: [entry], + issuedAt: "2026-07-05T00:00:00Z", + }) + await certs.revoke(BOB, "GONE", "holder", new Date("2026-07-06T00:00:00Z")) + + expect(await certs.liveCodesListingEntry(BOB, entry)).toEqual([ + { code: "NEW", issuedAt: new Date("2026-07-03T00:00:00Z") }, + { code: "OLD", issuedAt: new Date("2026-07-01T00:00:00Z") }, + ]) + }) +}) From dd30bc020370dbb2e0427401f6801be9f039f258 Mon Sep 17 00:00:00 2001 From: byteful Date: Sat, 26 Sep 2026 21:58:10 -0400 Subject: [PATCH 08/14] admin credit, void and ledger read for user hours --- services/api/src/i18n/messages/de.ts | 4 + services/api/src/i18n/messages/en.ts | 6 + services/api/src/i18n/messages/es.ts | 4 + services/api/src/i18n/messages/ko.ts | 3 + services/api/src/routes/admin/index.ts | 2 + .../api/src/routes/admin/user-hours.routes.ts | 113 ++++ .../src/services/admin/activity-service.ts | 3 + .../admin/admin-user-hours-service.ts | 243 +++++++++ .../admin/admin-user-repository.drizzle.ts | 9 + .../admin/admin-user-repository.memory.ts | 6 + .../src/services/admin/admin-user-service.ts | 6 + services/api/src/services/admin/audit.ts | 2 + .../api/src/services/data-export-service.ts | 5 +- .../volunteer-hours-repository.drizzle.ts | 2 +- .../volunteer-hours-repository.memory.ts | 2 +- .../api/src/services/volunteer-hours-rules.ts | 10 +- .../src/services/volunteer-hours-service.ts | 18 +- services/api/test/unit/admin-activity.test.ts | 6 + .../test/unit/admin-user-hours-routes.test.ts | 267 ++++++++++ .../unit/admin-user-hours-service.test.ts | 503 ++++++++++++++++++ services/api/test/unit/data-export.test.ts | 24 + services/api/test/unit/route-coverage.test.ts | 4 +- .../test/unit/volunteer-hours-entries.test.ts | 2 +- 23 files changed, 1227 insertions(+), 17 deletions(-) create mode 100644 services/api/src/routes/admin/user-hours.routes.ts create mode 100644 services/api/src/services/admin/admin-user-hours-service.ts create mode 100644 services/api/test/unit/admin-user-hours-routes.test.ts create mode 100644 services/api/test/unit/admin-user-hours-service.test.ts diff --git a/services/api/src/i18n/messages/de.ts b/services/api/src/i18n/messages/de.ts index bf7d415c..ad709192 100644 --- a/services/api/src/i18n/messages/de.ts +++ b/services/api/src/i18n/messages/de.ts @@ -67,6 +67,10 @@ export const de: Partial> = { "notification.hours_logged.title": "Ehrenamtsstunden gutgeschrieben", "notification.hours_logged.body": "Für {{title}} wurden dir {{hours}} Stunden gutgeschrieben.", + "notification.hours_adjusted.title": "Ehrenamtsstunden gutgeschrieben", + "notification.hours_adjusted.body": + "CivFix hat deinem Ehrenamtskonto {{hours}} Stunden gutgeschrieben.", + "notification.cleanup_slot.removed.title": "Deine Rolle im Event hat sich geändert", "notification.cleanup_slot.removed.body": 'Die Rolle "{{slot}}" wurde aus {{title}} entfernt.', "notification.cleanup_slot.moved.title": "Deine Schichtzeit hat sich geändert", diff --git a/services/api/src/i18n/messages/en.ts b/services/api/src/i18n/messages/en.ts index 1b42a0fa..2df9b833 100644 --- a/services/api/src/i18n/messages/en.ts +++ b/services/api/src/i18n/messages/en.ts @@ -95,6 +95,12 @@ export const en = { "notification.hours_logged.title": "Service hours credited", "notification.hours_logged.body": "{{hours}} hours were credited for {{title}}.", + // Manual service-hours credit bell (type 'hours_logged', DECISIONS §58): the CivFix team credited + // hours for work outside any event, so there is no event title to name. Fired by the admin hours + // service on a manual credit only; a void is silent. {{hours}} = the credited amount. + "notification.hours_adjusted.title": "Service hours credited", + "notification.hours_adjusted.body": "CivFix credited {{hours}} hours to your volunteer record.", + // Event-slot bell (P4/B34, type 'cleanup_slot'): the host edited the event's roles and the one you had // claimed no longer exists, so you are back in the crew with no role. Fired by cleanup-service's slot // reconciliation; the actor is excluded. {{slot}} = the removed role's title, {{title}} = the event. diff --git a/services/api/src/i18n/messages/es.ts b/services/api/src/i18n/messages/es.ts index ae977290..b331ae75 100644 --- a/services/api/src/i18n/messages/es.ts +++ b/services/api/src/i18n/messages/es.ts @@ -66,6 +66,10 @@ export const es: Partial> = { "notification.hours_logged.title": "Horas de servicio acreditadas", "notification.hours_logged.body": "Se acreditaron {{hours}} horas por {{title}}.", + "notification.hours_adjusted.title": "Horas de servicio acreditadas", + "notification.hours_adjusted.body": + "CivFix acreditó {{hours}} horas a tu registro de voluntariado.", + "notification.cleanup_slot.removed.title": "Tu rol en el evento cambió", "notification.cleanup_slot.removed.body": 'Se eliminó el rol "{{slot}}" de {{title}}.', "notification.cleanup_slot.moved.title": "El horario de tu turno cambió", diff --git a/services/api/src/i18n/messages/ko.ts b/services/api/src/i18n/messages/ko.ts index 74726104..0e39563f 100644 --- a/services/api/src/i18n/messages/ko.ts +++ b/services/api/src/i18n/messages/ko.ts @@ -66,6 +66,9 @@ export const ko: Partial> = { "notification.hours_logged.title": "봉사 시간이 인정되었어요", "notification.hours_logged.body": "{{title}} 활동으로 {{hours}}시간이 인정되었어요.", + "notification.hours_adjusted.title": "봉사 시간이 인정되었어요", + "notification.hours_adjusted.body": "CivFix에서 봉사 기록에 {{hours}}시간을 인정했어요.", + "notification.cleanup_slot.removed.title": "이벤트 역할이 변경되었어요", "notification.cleanup_slot.removed.body": '{{title}}에서 "{{slot}}" 역할이 삭제되었어요.', "notification.cleanup_slot.moved.title": "교대 시간이 변경되었어요", diff --git a/services/api/src/routes/admin/index.ts b/services/api/src/routes/admin/index.ts index d43f7a27..b92f6990 100644 --- a/services/api/src/routes/admin/index.ts +++ b/services/api/src/routes/admin/index.ts @@ -10,6 +10,7 @@ import { registerAdminReportsRoutes } from "./reports.routes.js" import { registerAdminReportChatRoutes } from "./report-chat.routes.js" import { registerAdminEventsRoutes } from "./events.routes.js" import { registerAdminUsersRoutes } from "./users.routes.js" +import { registerAdminUserHoursRoutes } from "./user-hours.routes.js" import { registerAdminGovRoutes } from "./gov.routes.js" import { registerAdminModerationRoutes } from "./moderation.routes.js" import { registerAdminMailRoutes } from "./mail.routes.js" @@ -40,6 +41,7 @@ export async function registerAdminRoutes( await registerAdminReportChatRoutes(operator, container) await registerAdminEventsRoutes(operator, container) await registerAdminUsersRoutes(operator, container) + await registerAdminUserHoursRoutes(operator, container) await registerAdminGovRoutes(operator, container) await registerAdminModerationRoutes(operator, container) await registerAdminMailRoutes(operator, container) diff --git a/services/api/src/routes/admin/user-hours.routes.ts b/services/api/src/routes/admin/user-hours.routes.ts new file mode 100644 index 00000000..68e8c6bf --- /dev/null +++ b/services/api/src/routes/admin/user-hours.routes.ts @@ -0,0 +1,113 @@ +import { + AdminCreditUserHoursRequestSchema, + AdminUserHoursQuerySchema, + AdminVoidUserHoursRequestSchema, + type AdminCreditUserHoursResponse, + type AdminUserHoursResponse, + type AdminVoidUserHoursResponse, +} from "@civfix/shared" +import type { FastifyInstance } from "fastify" +import type { Container } from "../../di.js" +import { requireOperator } from "../../auth/admin-guard.js" +import { perIdentity } from "../../plugins/rate-limit.js" +import { route } from "../../versioning/route.js" +import { + idParam, + overridableService, + parse, + parseBodyWithId, + twoIdParams, +} from "./_route-utils.js" +import { auditRead } from "./_audit-read.js" +import { + makeAdminUserHoursService, + type AdminUserHoursServiceDeps, +} from "../../services/admin/admin-user-hours-service.js" +import { makeDrizzleAdminUserRepository } from "../../services/admin/admin-user-repository.drizzle.js" +import { makeInsightsGeneration } from "../../services/host/host-analytics-cache.js" +import { makeRouteNotificationService } from "../../services/route-notifier.js" +import { makeCleanupHoursLookup } from "../volunteer-hours-wiring.js" + +export type AdminUserHoursRouteOverrides = Omit + +declare module "fastify" { + interface FastifyInstance { + adminUserHoursOverrides?: AdminUserHoursRouteOverrides + } +} + +export const ADMIN_USER_HOURS_WRITE_RATE_LIMIT = perIdentity({ + max: 30, + timeWindow: "1 minute", + skipOnError: false, +}) + +export async function registerAdminUserHoursRoutes( + app: FastifyInstance, + container: Container, +): Promise { + const csrfProtect = container.csrf.protect + + const service = overridableService( + app, + "adminUserHoursOverrides", + (overrides) => makeAdminUserHoursService({ ...overrides, logger: app.log }), + () => + makeAdminUserHoursService({ + hours: container.getVolunteerHoursRepo(), + certificates: container.getCertificateRepo(), + accounts: makeDrizzleAdminUserRepository(container.getDb().sql), + events: makeCleanupHoursLookup(container), + insightsInvalidator: makeInsightsGeneration({ + cache: container.getCache(), + logger: app.log, + }), + notifier: makeRouteNotificationService(container, app.log), + logger: app.log, + }), + ) + + route(app, "getUserHours", async (request, reply) => { + const { id } = idParam(request) + const query = parse(AdminUserHoursQuerySchema, { ...(request.query as object), id }) + const payload: AdminUserHoursResponse = await service().getUserHours(query) + await auditRead(request, container, requireOperator(request), { + action: "user.hours_viewed", + target: `user:${id}`, + meta: { returned: payload.items.length, cursor: query.cursor ?? null }, + }) + reply.status(200).send(payload) + }) + + route( + app, + "creditUserHours", + { preHandler: csrfProtect, config: { rateLimit: ADMIN_USER_HOURS_WRITE_RATE_LIMIT } }, + async (request, reply) => { + const operatorId = requireOperator(request) + const { body } = parseBodyWithId(AdminCreditUserHoursRequestSchema, request) + const payload: AdminCreditUserHoursResponse = await service().creditUserHours( + operatorId, + body, + ) + reply.status(200).send(payload) + }, + ) + + route( + app, + "voidUserHours", + { preHandler: csrfProtect, config: { rateLimit: ADMIN_USER_HOURS_WRITE_RATE_LIMIT } }, + async (request, reply) => { + const operatorId = requireOperator(request) + const { id, entryId } = twoIdParams(request, "entryId") + const body = parse(AdminVoidUserHoursRequestSchema, { + ...(request.body as object), + id, + entryId, + }) + const payload: AdminVoidUserHoursResponse = await service().voidUserHours(operatorId, body) + reply.status(200).send(payload) + }, + ) +} diff --git a/services/api/src/services/admin/activity-service.ts b/services/api/src/services/admin/activity-service.ts index 5f70959e..467fa7bc 100644 --- a/services/api/src/services/admin/activity-service.ts +++ b/services/api/src/services/admin/activity-service.ts @@ -193,6 +193,8 @@ export function describeAuditAction(action: string): string { "user.unverified": "Removed an account's verification", "user.report_verified": "Granted report-verified status", "user.report_unverified": "Revoked report-verified status", + "user.hours_credited": "Credited volunteer hours", + "user.hours_voided": "Voided volunteer hours", "message.removed": "Removed a message", "gov_claim.verified": "Verified a gov claim check", "gov_claim.approved": "Approved a gov claim", @@ -215,6 +217,7 @@ export function describeAuditAction(action: string): string { // only matter if a row reaches the classifier another way (a fake, or a future feed that includes them). "user.detail_viewed": "Viewed an account", "user.messages_viewed": "Viewed an account's messages", + "user.hours_viewed": "Viewed an account's hours", "inbox.message_viewed": "Viewed an inbox message", "mail.thread_viewed": "Viewed a mail thread", } diff --git a/services/api/src/services/admin/admin-user-hours-service.ts b/services/api/src/services/admin/admin-user-hours-service.ts new file mode 100644 index 00000000..d74e505f --- /dev/null +++ b/services/api/src/services/admin/admin-user-hours-service.ts @@ -0,0 +1,243 @@ +import { AppError } from "@civfix/shared" +import type { + AdminCreditUserHoursRequest, + AdminCreditUserHoursResponse, + AdminUserHoursEntryDTO, + AdminUserHoursQuery, + AdminUserHoursResponse, + AdminVoidUserHoursRequest, + AdminVoidUserHoursResponse, +} from "@civfix/shared" +import { + assertTargetIsNotOfficialAccount, + assertTargetIsNotOperatorRole, +} from "../../auth/operator-target.js" +import { parseTimeCursor } from "../../db/cursor-helpers.js" +import type { CertificateRepository } from "../certificate-service.js" +import { eventDayKey } from "../host/event-day.js" +import { DEFAULT_EVENT_TIME_ZONE } from "../host/event-fields.js" +import type { InsightsInvalidator } from "../host/host-analytics-cache.js" +import type { NotificationService } from "../notification-service.js" +import { assertCreditableEventHours, assertEventCreditable } from "../volunteer-hours-rules.js" +import { + DAILY_HOURS_CAP, + HOURS_ENTRIES_DEFAULT_LIMIT, + notifyHoursCredited, + type CleanupHoursLookup, + type OperatorLedgerEntryView, + type VolunteerHoursRepository, +} from "../volunteer-hours-service.js" +import type { AdminAccountTarget, AdminUserRepository } from "./admin-user-service.js" + +// A credited user who is not on the event roster cannot open the event page, so both bells link to +// the profile, where the Hours tab lists the new entry. +export const HOURS_CREDIT_NOTICE_LINK = "/profile" + +export interface AdminUserHoursServiceDeps { + hours: VolunteerHoursRepository + certificates: Pick + accounts: Pick + events: Pick + insightsInvalidator?: InsightsInvalidator + notifier?: Pick + logger?: { warn(obj: unknown, msg?: string): void } + now?: () => Date +} + +export interface AdminUserHoursService { + getUserHours(query: AdminUserHoursQuery): Promise + creditUserHours( + operatorId: string, + request: AdminCreditUserHoursRequest, + ): Promise + voidUserHours( + operatorId: string, + request: AdminVoidUserHoursRequest, + ): Promise +} + +function round2(n: number): number { + return Math.round(n * 100) / 100 +} + +export function toAdminUserHoursEntryDTO(view: OperatorLedgerEntryView): AdminUserHoursEntryDTO { + return { + id: view.id, + source: view.source, + hours: round2(view.hours), + occurredAt: view.occurredAt.toISOString(), + creditedAt: view.createdAt.toISOString(), + serviceDate: view.serviceDate, + event: + view.event === null + ? null + : { id: view.event.id, title: view.event.title, referenceCode: view.event.referenceCode }, + jurisdiction: + view.jurisdiction === null + ? null + : { geoid: view.jurisdiction.geoid, name: view.jurisdiction.name }, + creditedBy: + view.creditedBy === null + ? null + : { + id: view.creditedBy.id, + name: view.creditedBy.name, + handle: view.creditedBy.handle, + official: view.creditedBy.official, + }, + operator: view.operator === null ? null : { id: view.operator.id, name: view.operator.name }, + note: view.note, + voidedAt: view.voidedAt === null ? null : view.voidedAt.toISOString(), + voidedBy: view.voidedBy === null ? null : { id: view.voidedBy.id, name: view.voidedBy.name }, + voidReason: view.voidReason, + voidable: view.voidedAt === null && view.source !== "report", + } +} + +export function makeAdminUserHoursService(deps: AdminUserHoursServiceDeps): AdminUserHoursService { + const now = deps.now ?? (() => new Date()) + + async function accountOf(userId: string): Promise { + const target = await deps.accounts.findAccountTarget(userId) + if (target === null) throw AppError.notFound("User not found") + return target + } + + async function assertCreditable(operatorId: string, userId: string): Promise { + assertTargetIsNotOfficialAccount(userId, "credit hours to") + if (userId === operatorId) { + throw AppError.forbidden("You can't credit volunteer hours to yourself.") + } + const target = await accountOf(userId) + assertTargetIsNotOperatorRole(target.role, "credit hours to") + if (target.deletedAt !== null) { + throw AppError.conflict("This account was deleted, so no hours can be credited to it.") + } + } + + // A deleted account keeps its ledger (erasure unlists, it does not delete), and a bad credit on it + // must stay correctable, so voiding skips the tombstone check that crediting applies. + async function assertVoidable(userId: string): Promise { + assertTargetIsNotOfficialAccount(userId, "void the hours of") + const target = await accountOf(userId) + assertTargetIsNotOperatorRole(target.role, "void the hours of") + } + + function assertServiceDateNotFuture(serviceDate: string): void { + const today = eventDayKey(now(), DEFAULT_EVENT_TIME_ZONE) + if (serviceDate > today) { + throw AppError.validation({ serviceDate: "The service date can't be in the future." }) + } + } + + async function creditEvent( + operatorId: string, + request: Extract, + ): Promise { + const event = await deps.events.load(request.eventId) + if (event === null) throw AppError.notFound("Event not found") + const limits = assertEventCreditable(event, now().getTime()) + assertCreditableEventHours(request.hours, limits, "hours") + const hours = round2(request.hours) + const { entryId } = await deps.hours.creditEventAsOperator({ + operatorId, + userId: request.id, + cleanupId: request.eventId, + geoid: event.jurisdictionGeoid, + hours, + reason: request.reason, + dailyCapHours: DAILY_HOURS_CAP, + }) + await deps.insightsInvalidator?.bumpInsightsGeneration(request.eventId) + await notifyHoursCredited(deps, [{ userId: request.id, hours }], { + titleKey: "notification.hours_logged.title", + bodyKey: "notification.hours_logged.body", + vars: { title: event.title }, + link: HOURS_CREDIT_NOTICE_LINK, + logContext: { cleanupId: request.eventId, entryId }, + }) + return entryId + } + + async function creditManual( + operatorId: string, + request: Extract, + ): Promise { + assertServiceDateNotFuture(request.serviceDate) + const hours = round2(request.hours) + const { entryId } = await deps.hours.creditManual({ + operatorId, + userId: request.id, + hours, + serviceDate: request.serviceDate, + reason: request.reason, + dailyCapHours: DAILY_HOURS_CAP, + }) + await notifyHoursCredited(deps, [{ userId: request.id, hours }], { + titleKey: "notification.hours_adjusted.title", + bodyKey: "notification.hours_adjusted.body", + vars: {}, + link: HOURS_CREDIT_NOTICE_LINK, + logContext: { entryId }, + }) + return entryId + } + + return { + async getUserHours(query: AdminUserHoursQuery): Promise { + await accountOf(query.id) + const [page, totals] = await Promise.all([ + deps.hours.listOperatorLedger({ + userId: query.id, + cursor: parseTimeCursor(query.cursor), + limit: query.limit ?? HOURS_ENTRIES_DEFAULT_LIMIT, + }), + deps.hours.operatorLedgerTotals(query.id), + ]) + return { + items: page.items.map(toAdminUserHoursEntryDTO), + nextCursor: page.nextCursor, + totals, + } + }, + + async creditUserHours( + operatorId: string, + request: AdminCreditUserHoursRequest, + ): Promise { + await assertCreditable(operatorId, request.id) + const entryId = + request.kind === "event" + ? await creditEvent(operatorId, request) + : await creditManual(operatorId, request) + return { entryId, totalHours: await deps.hours.totalHoursFor(request.id) } + }, + + async voidUserHours( + operatorId: string, + request: AdminVoidUserHoursRequest, + ): Promise { + await assertVoidable(request.id) + const voided = await deps.hours.voidEntry({ + operatorId, + userId: request.id, + entryId: request.entryId, + reason: request.reason, + }) + if (voided.source === "event" && voided.cleanupId !== null) { + await deps.insightsInvalidator?.bumpInsightsGeneration(voided.cleanupId) + } + const certificates = await deps.certificates.liveCodesListingEntry( + request.id, + request.entryId, + ) + return { + ok: true, + affectedCertificates: certificates.map((c) => ({ + code: c.code, + issuedAt: c.issuedAt.toISOString(), + })), + } + }, + } +} diff --git a/services/api/src/services/admin/admin-user-repository.drizzle.ts b/services/api/src/services/admin/admin-user-repository.drizzle.ts index 6ff30f73..ad696e39 100644 --- a/services/api/src/services/admin/admin-user-repository.drizzle.ts +++ b/services/api/src/services/admin/admin-user-repository.drizzle.ts @@ -5,6 +5,7 @@ import { clampLimit, decodeCursor } from "./pagination.js" import { paginate } from "../../db/cursor-helpers.js" import { andAll, ilikeAnyOf, type SqlFragment } from "./sql-fragments.js" import type { + AdminAccountTarget, AdminUserOrganizationRecord, AdminUserRecord, AdminUserRepository, @@ -253,6 +254,14 @@ export function makeDrizzleAdminUserRepository(sql: Sql): AdminUserRepository { return rows.length > 0 }, + async findAccountTarget(id: string): Promise { + const rows = await sql<{ role: Role; deleted_at: Date | null }[]>` + SELECT role, deleted_at FROM users WHERE id = ${id} + ` + const row = rows[0] + return row === undefined ? null : { role: row.role, deletedAt: row.deleted_at } + }, + async getUser(id: string): Promise { const rows = (await userSelect( sql, diff --git a/services/api/src/services/admin/admin-user-repository.memory.ts b/services/api/src/services/admin/admin-user-repository.memory.ts index 6159b707..5f68c6e9 100644 --- a/services/api/src/services/admin/admin-user-repository.memory.ts +++ b/services/api/src/services/admin/admin-user-repository.memory.ts @@ -16,6 +16,7 @@ import { randomUUID } from "node:crypto" import { pageInMemoryById } from "./pagination.js" import type { + AdminAccountTarget, AdminUserOrganizationRecord, AdminUserRecord, AdminUserRepository, @@ -171,6 +172,11 @@ export class InMemoryAdminUserRepository implements AdminUserRepository { return this.users.has(id) } + async findAccountTarget(id: string): Promise { + const r = this.users.get(id) + return r ? { role: r.role, deletedAt: r.deletedAt } : null + } + async getUser(id: string): Promise { const r = this.users.get(id) // Recompute the messages count from the seeded sub-activity (the detail's Messages tab badge), mirroring diff --git a/services/api/src/services/admin/admin-user-service.ts b/services/api/src/services/admin/admin-user-service.ts index 21b2388b..6c99a74b 100644 --- a/services/api/src/services/admin/admin-user-service.ts +++ b/services/api/src/services/admin/admin-user-service.ts @@ -96,10 +96,16 @@ export interface ListUsersArgs { limit: number } +export interface AdminAccountTarget { + role: Role + deletedAt: Date | null +} + export interface AdminUserRepository { listUsers(args: ListUsersArgs): Promise<{ records: AdminUserRecord[]; nextCursor: string | null }> countByFacet(args: { q: string | null }): Promise userExists(id: string): Promise + findAccountTarget(id: string): Promise getUser(id: string): Promise listUserReports( id: string, diff --git a/services/api/src/services/admin/audit.ts b/services/api/src/services/admin/audit.ts index 51d74924..1d648304 100644 --- a/services/api/src/services/admin/audit.ts +++ b/services/api/src/services/admin/audit.ts @@ -48,6 +48,7 @@ export type AdminAuditAction = | "inbox.status_changed" | "user.detail_viewed" | "user.messages_viewed" + | "user.hours_viewed" | "inbox.message_viewed" | "mail.thread_viewed" | "media.viewed" @@ -101,6 +102,7 @@ export type AdminAuditAction = export const AUDIT_READ_ACTIONS: readonly AdminAuditAction[] = [ "user.detail_viewed", "user.messages_viewed", + "user.hours_viewed", "inbox.message_viewed", "mail.thread_viewed", "media.viewed", diff --git a/services/api/src/services/data-export-service.ts b/services/api/src/services/data-export-service.ts index 2e66d593..4a678534 100644 --- a/services/api/src/services/data-export-service.ts +++ b/services/api/src/services/data-export-service.ts @@ -164,11 +164,14 @@ export function makeDataExportService(deps: DataExportServiceDeps): DataExportSe jurisdiction_geoid: string | null hours: number logged_by_user_id: string | null + service_date: string | null created_at: Date + voided_at: Date | null }[] >` SELECT id, source, report_id, cleanup_id, jurisdiction_geoid, - hours::float8 AS hours, logged_by_user_id, created_at + hours::float8 AS hours, logged_by_user_id, service_date::text AS service_date, + created_at, voided_at FROM volunteer_hours WHERE user_id = ${userId} ORDER BY created_at DESC diff --git a/services/api/src/services/volunteer-hours-repository.drizzle.ts b/services/api/src/services/volunteer-hours-repository.drizzle.ts index 976407ff..6f7409fb 100644 --- a/services/api/src/services/volunteer-hours-repository.drizzle.ts +++ b/services/api/src/services/volunteer-hours-repository.drizzle.ts @@ -247,7 +247,7 @@ function toOperatorLedgerView(r: OperatorLedgerRow): OperatorLedgerEntryView { ? { id: r.creditor_id, name: r.creditor_name ?? "", - handle: r.creditor_handle, + handle: r.creditor_handle ?? "", official: isOfficialAccount(r.creditor_id), } : null, diff --git a/services/api/src/services/volunteer-hours-repository.memory.ts b/services/api/src/services/volunteer-hours-repository.memory.ts index 0be8db6f..44e475e7 100644 --- a/services/api/src/services/volunteer-hours-repository.memory.ts +++ b/services/api/src/services/volunteer-hours-repository.memory.ts @@ -761,7 +761,7 @@ export class InMemoryVolunteerHoursRepository implements VolunteerHoursRepositor ? { id: e.loggedByUserId, name: nameOf(e.loggedByUserId), - handle: this.users.get(e.loggedByUserId)?.handle ?? null, + handle: this.users.get(e.loggedByUserId)?.handle ?? "", official: isOfficialAccount(e.loggedByUserId), } : null, diff --git a/services/api/src/services/volunteer-hours-rules.ts b/services/api/src/services/volunteer-hours-rules.ts index 5367664a..7a52ded3 100644 --- a/services/api/src/services/volunteer-hours-rules.ts +++ b/services/api/src/services/volunteer-hours-rules.ts @@ -51,15 +51,19 @@ export function assertEventCreditable(cleanup: CreditableEvent, nowMs: number): return { durationMs, windowCap: creditableHoursForEvent(cleanup) } } -export function assertCreditableEventHours(hours: number, limits: EventCreditLimits): void { +export function assertCreditableEventHours( + hours: number, + limits: EventCreditLimits, + field = "entries", +): void { if (!(hours >= MIN_EVENT_HOURS) || hours > MAX_EVENT_HOURS) { throw AppError.validation({ - entries: `hours must be at least ${MIN_EVENT_HOURS} and at most ${MAX_EVENT_HOURS}`, + [field]: `hours must be at least ${MIN_EVENT_HOURS} and at most ${MAX_EVENT_HOURS}`, }) } if (hours > limits.windowCap) { throw AppError.validation({ - entries: `this event ran for ${round2(limits.durationMs / 3_600_000)} h, so at most ${limits.windowCap} h may be credited per attendee`, + [field]: `this event ran for ${round2(limits.durationMs / 3_600_000)} h, so at most ${limits.windowCap} h may be credited per attendee`, }) } } diff --git a/services/api/src/services/volunteer-hours-service.ts b/services/api/src/services/volunteer-hours-service.ts index 2800020d..e497148d 100644 --- a/services/api/src/services/volunteer-hours-service.ts +++ b/services/api/src/services/volunteer-hours-service.ts @@ -1,4 +1,9 @@ -import { AppError, avatarGradient, MAX_EVENT_HOURS_ENTRIES } from "@civfix/shared" +import { + ADMIN_USER_HOURS_PAGE_MAX, + AppError, + avatarGradient, + MAX_EVENT_HOURS_ENTRIES, +} from "@civfix/shared" import type { CleanupMemberRole, CleanupStatus, @@ -96,7 +101,7 @@ export interface EventCreditWrite { export const MANUAL_CREDIT_REPEAT_WINDOW_MS = 60_000 -export const OPERATOR_LEDGER_MAX_LIMIT = 50 +export const OPERATOR_LEDGER_MAX_LIMIT = ADMIN_USER_HOURS_PAGE_MAX export interface OperatorEventCreditArgs { operatorId: string @@ -150,7 +155,7 @@ export interface OperatorLedgerEntryView { serviceDate: string | null event: { id: string; title: string; referenceCode: string | null } | null jurisdiction: { geoid: string; name: string | null } | null - creditedBy: { id: string; name: string; handle: string | null; official: boolean } | null + creditedBy: { id: string; name: string; handle: string; official: boolean } | null operator: { id: string; name: string } | null note: string | null voidedAt: Date | null @@ -434,15 +439,12 @@ export function toVolunteerHoursEntryDTO(view: VolunteerHoursEntryView): Volunte } } -function toEventHoursRow(entry: EventHoursLedgerEntry): { - userId: string - hours: number - loggedAt: string -} { +function toEventHoursRow(entry: EventHoursLedgerEntry): EventHoursResponse["entries"][number] { return { userId: entry.userId, hours: round2(entry.hours), loggedAt: entry.loggedAt.toISOString(), + creditedByOfficial: entry.creditedByOfficial, } } diff --git a/services/api/test/unit/admin-activity.test.ts b/services/api/test/unit/admin-activity.test.ts index 53391598..24d54e25 100644 --- a/services/api/test/unit/admin-activity.test.ts +++ b/services/api/test/unit/admin-activity.test.ts @@ -55,6 +55,12 @@ describe("describeAuditAction", () => { ) expect(describeAuditAction("mail.reply_published")).toBe("Published a city reply") }) + + it("labels the volunteer hours ledger actions", () => { + expect(describeAuditAction("user.hours_credited")).toBe("Credited volunteer hours") + expect(describeAuditAction("user.hours_voided")).toBe("Voided volunteer hours") + expect(describeAuditAction("user.hours_viewed")).toBe("Viewed an account's hours") + }) }) describe("classifyActivity (per source)", () => { diff --git a/services/api/test/unit/admin-user-hours-routes.test.ts b/services/api/test/unit/admin-user-hours-routes.test.ts new file mode 100644 index 00000000..6cfb74a1 --- /dev/null +++ b/services/api/test/unit/admin-user-hours-routes.test.ts @@ -0,0 +1,267 @@ +import { afterEach, describe, expect, it } from "vitest" +import type { FastifyInstance } from "fastify" +import { FakeMailer } from "@civfix/shared/fakes" +import { buildServer } from "../../src/server.js" +import { loadEnv } from "../../src/env.js" +import { InMemoryCacheClient } from "../../src/auth/cache.js" +import { makeInMemoryStores } from "../../src/auth/stores.js" +import { buildAuthServices } from "../../src/auth/auth-services.js" +import { makeCsrf } from "../../src/auth/csrf.js" +import { CIVFIX_OFFICIAL_USER_ID } from "../../src/auth/official-account.js" +import { StubJwksVerifier } from "../helpers/auth.js" +import type { WriteAuditInput } from "../../src/services/admin/audit.js" +import { InMemoryAdminUserRepository } from "../../src/services/admin/admin-user-repository.memory.js" +import { InMemoryCertificateRepository } from "../../src/services/certificate-repository.memory.js" +import type { NotificationService } from "../../src/services/notification-service.js" +import { InMemoryVolunteerHoursRepository } from "../../src/services/volunteer-hours-repository.memory.js" +import type { CleanupHoursView } from "../../src/services/volunteer-hours-service.js" + +const OPERATOR_EMAIL = "ops@civfix.org" +const BOB = "22222222-2222-4222-8222-222222222222" +const EVENT = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbb1" +const GEOID = "0644000" + +const ENDED_EVENT: CleanupHoursView = { + organizerUserId: "11111111-1111-4111-8111-111111111111", + status: "done", + jurisdictionGeoid: GEOID, + title: "Ocean Beach sweep", + scheduledAt: new Date("2026-07-04T08:00:00.000Z"), + endsAt: new Date("2026-07-04T12:00:00.000Z"), + completedAt: null, + timezone: null, +} + +interface Harness { + app: FastifyInstance + hours: InMemoryVolunteerHoursRepository + reads: WriteAuditInput[] + notices: (string | undefined)[] + operatorId: string + operatorToken: string + citizenToken: string +} + +let app: FastifyInstance | undefined +afterEach(async () => { + await app?.close() + app = undefined +}) + +async function makeHarness(): Promise { + const stores = makeInMemoryStores() + const services = buildAuthServices({ + stores, + cache: new InMemoryCacheClient(() => Date.now()), + mailer: new FakeMailer(), + oauthConfig: {}, + verifier: new StubJwksVerifier(), + now: () => Date.now(), + }) + app = await buildServer({ + env: loadEnv({ NODE_ENV: "test", ADMIN_EMAILS: OPERATOR_EMAIL }), + authServices: services, + }) + + const hours = new InMemoryVolunteerHoursRepository() + hours.seedJurisdiction(GEOID, "Los Angeles") + hours.seedUser(CIVFIX_OFFICIAL_USER_ID, { name: "CivFix", handle: "civfix", avatarUrl: null }) + hours.seedCleanup(EVENT, { + title: ENDED_EVENT.title, + referenceCode: "EV-1", + scheduledAt: ENDED_EVENT.scheduledAt, + }) + const accounts = new InMemoryAdminUserRepository() + accounts.seedUser({ id: BOB, name: "Bob", handle: "bob" }) + const notices: (string | undefined)[] = [] + app.adminUserHoursOverrides = { + hours, + certificates: new InMemoryCertificateRepository(), + accounts, + events: { load: (id) => Promise.resolve(id === EVENT ? ENDED_EVENT : null) }, + notifier: { + createNotification: (_userId, input) => { + notices.push(input.bodyKey) + return Promise.resolve({} as Awaited>) + }, + }, + } + const reads: WriteAuditInput[] = [] + app.adminReadAuditOverrides = { + sink: (input) => { + reads.push(input) + return Promise.resolve() + }, + } + + const operator = await stores.users.create(OPERATOR_EMAIL, { + displayName: "Ops", + role: "operator", + emailVerified: true, + }) + const citizen = await stores.users.create("neighbor@example.com", { + displayName: "Neighbor", + role: "citizen", + emailVerified: true, + }) + return { + app, + hours, + reads, + notices, + operatorId: operator.id, + operatorToken: await services.sessions.createSession(operator.id, ["operator"]), + citizenToken: await services.sessions.createSession(citizen.id, ["citizen"]), + } +} + +function bearer(token: string): Record { + return { authorization: `Bearer ${token}` } +} + +const MANUAL_BODY = { + kind: "manual", + hours: 2, + serviceDate: "2026-07-05", + reason: "Tabling at the library fair", +} + +describe("admin user hours routes", () => { + it("credits, reads (audited) and voids through the operator console", async () => { + const h = await makeHarness() + + const credited = await h.app.inject({ + method: "POST", + url: `/v1/admin/users/${BOB}/hours`, + headers: bearer(h.operatorToken), + payload: { kind: "event", eventId: EVENT, hours: 3, reason: "Signed up at the event" }, + }) + expect(credited.statusCode).toBe(200) + const { entryId, totalHours } = credited.json() as { entryId: string; totalHours: number } + expect(totalHours).toBe(3) + expect(h.notices).toEqual(["notification.hours_logged.body"]) + expect(h.hours.audits).toEqual([ + expect.objectContaining({ actorId: h.operatorId, action: "user.hours_credited" }), + ]) + + const read = await h.app.inject({ + method: "GET", + url: `/v1/admin/users/${BOB}/hours?limit=10`, + headers: bearer(h.operatorToken), + }) + expect(read.statusCode).toBe(200) + expect(read.json()).toMatchObject({ + items: [{ id: entryId, source: "event", voidable: true, operator: { id: h.operatorId } }], + totals: { totalHours: 3, liveEntries: 1, voidedEntries: 0 }, + }) + expect(h.reads).toEqual([ + { + actorId: h.operatorId, + action: "user.hours_viewed", + target: `user:${BOB}`, + meta: { returned: 1, cursor: null }, + }, + ]) + + const voided = await h.app.inject({ + method: "POST", + url: `/v1/admin/users/${BOB}/hours/${entryId}/void`, + headers: bearer(h.operatorToken), + payload: { reason: "Wrong person" }, + }) + expect(voided.statusCode).toBe(200) + expect(voided.json()).toEqual({ ok: true, affectedCertificates: [] }) + expect(h.notices).toHaveLength(1) + }) + + it("addresses the account named in the path, whatever id the body claims", async () => { + const h = await makeHarness() + const res = await h.app.inject({ + method: "POST", + url: `/v1/admin/users/${BOB}/hours`, + headers: bearer(h.operatorToken), + payload: { ...MANUAL_BODY, id: h.operatorId }, + }) + expect(res.statusCode).toBe(200) + expect(h.hours.audits[0]).toMatchObject({ target: `user:${BOB}` }) + }) + + it("422s a body that matches neither credit kind, or carries the other kind's fields", async () => { + const h = await makeHarness() + const bad = [ + { ...MANUAL_BODY, kind: "bonus" }, + { ...MANUAL_BODY, eventId: EVENT }, + { kind: "event", hours: 1, reason: "x" }, + { ...MANUAL_BODY, serviceDate: "1999-12-31" }, + { ...MANUAL_BODY, hours: 25 }, + { ...MANUAL_BODY, reason: " " }, + ] + for (const payload of bad) { + const res = await h.app.inject({ + method: "POST", + url: `/v1/admin/users/${BOB}/hours`, + headers: bearer(h.operatorToken), + payload, + }) + expect(res.statusCode, JSON.stringify(payload)).toBe(422) + } + const noReason = await h.app.inject({ + method: "POST", + url: `/v1/admin/users/${BOB}/hours/${EVENT}/void`, + headers: bearer(h.operatorToken), + payload: {}, + }) + expect(noReason.statusCode).toBe(422) + const badEntry = await h.app.inject({ + method: "POST", + url: `/v1/admin/users/${BOB}/hours/not-a-uuid/void`, + headers: bearer(h.operatorToken), + payload: { reason: "x" }, + }) + expect(badEntry.statusCode).toBe(422) + expect(h.hours.audits).toEqual([]) + }) + + it("is operator-only: 401 anonymous, 403 citizen, on the read and both writes", async () => { + const h = await makeHarness() + const calls = [ + { method: "GET" as const, url: `/v1/admin/users/${BOB}/hours` }, + { method: "POST" as const, url: `/v1/admin/users/${BOB}/hours`, payload: MANUAL_BODY }, + { + method: "POST" as const, + url: `/v1/admin/users/${BOB}/hours/${EVENT}/void`, + payload: { reason: "x" }, + }, + ] + for (const call of calls) { + expect((await h.app.inject(call)).statusCode, `${call.url} anonymous`).toBe(401) + const citizen = await h.app.inject({ ...call, headers: bearer(h.citizenToken) }) + expect(citizen.statusCode, `${call.url} citizen`).toBe(403) + } + expect(h.hours.audits).toEqual([]) + expect(h.reads).toEqual([]) + }) + + it("requires the session-bound CSRF token on a cookie session's writes", async () => { + const h = await makeHarness() + const cookie = `civfix_session=${h.operatorToken}` + for (const call of [ + { url: `/v1/admin/users/${BOB}/hours`, payload: MANUAL_BODY }, + { url: `/v1/admin/users/${BOB}/hours/${EVENT}/void`, payload: { reason: "x" } }, + ]) { + const res = await h.app.inject({ method: "POST", headers: { cookie }, ...call }) + expect(res.statusCode, call.url).toBe(403) + expect(res.json()).toMatchObject({ message: "CSRF token missing or invalid." }) + } + expect(h.hours.audits).toEqual([]) + + const csrf = makeCsrf(loadEnv({ NODE_ENV: "test", ADMIN_EMAILS: OPERATOR_EMAIL })) + const res = await h.app.inject({ + method: "POST", + url: `/v1/admin/users/${BOB}/hours`, + headers: { cookie, "x-csrf-token": await csrf.tokenForSession(h.operatorToken) }, + payload: MANUAL_BODY, + }) + expect(res.statusCode).toBe(200) + }) +}) diff --git a/services/api/test/unit/admin-user-hours-service.test.ts b/services/api/test/unit/admin-user-hours-service.test.ts new file mode 100644 index 00000000..94a00665 --- /dev/null +++ b/services/api/test/unit/admin-user-hours-service.test.ts @@ -0,0 +1,503 @@ +import { describe, expect, it } from "vitest" +import { AdminUserHoursResponseSchema, type AdminCreditUserHoursRequest } from "@civfix/shared" +import { CIVFIX_OFFICIAL_USER_ID } from "../../src/auth/official-account.js" +import { InMemoryAdminUserRepository } from "../../src/services/admin/admin-user-repository.memory.js" +import { + HOURS_CREDIT_NOTICE_LINK, + makeAdminUserHoursService, + type AdminUserHoursService, +} from "../../src/services/admin/admin-user-hours-service.js" +import { buildTranscriptModel } from "../../src/services/certificate-model.js" +import { InMemoryCertificateRepository } from "../../src/services/certificate-repository.memory.js" +import type { NotificationService } from "../../src/services/notification-service.js" +import { InMemoryVolunteerHoursRepository } from "../../src/services/volunteer-hours-repository.memory.js" +import type { CleanupHoursView } from "../../src/services/volunteer-hours-service.js" + +const HOST = "11111111-1111-4111-8111-111111111111" +const BOB = "22222222-2222-4222-8222-222222222222" +const OPERATOR = "99999999-9999-4999-8999-999999999999" +const OTHER_OPERATOR = "88888888-8888-4888-8888-888888888888" +const UNKNOWN = "77777777-7777-4777-8777-777777777777" +const EVENT = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbb1" +const REPORT = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" +const GEOID = "0644000" +const SCHEDULED_AT = new Date("2026-07-04T08:00:00.000Z") +const NOW = "2026-07-06T10:00:00.000Z" + +function endedEvent(patch: Partial = {}): CleanupHoursView { + return { + organizerUserId: HOST, + status: "done", + jurisdictionGeoid: GEOID, + title: "Ocean Beach sweep", + scheduledAt: SCHEDULED_AT, + endsAt: new Date("2026-07-04T12:00:00.000Z"), + completedAt: null, + timezone: null, + ...patch, + } +} + +interface SentNotice { + userId: string + type: string + titleKey: string | undefined + bodyKey: string | undefined + vars: Record + link: string | undefined +} + +interface Harness { + svc: AdminUserHoursService + hours: InMemoryVolunteerHoursRepository + accounts: InMemoryAdminUserRepository + certificates: InMemoryCertificateRepository + sent: SentNotice[] + bumps: string[] + warnings: unknown[] + setEvent(view: CleanupHoursView | null): void + setNow(iso: string): void +} + +function harness(opts: { notifierFails?: boolean } = {}): Harness { + let at = Date.parse(NOW) + const now = () => new Date(at) + let n = 0 + const hours = new InMemoryVolunteerHoursRepository({ + now, + newId: () => `00000000-0000-4000-8000-${String(++n).padStart(12, "0")}`, + }) + hours.seedJurisdiction(GEOID, "Los Angeles") + hours.seedUser(HOST, { name: "Hana Host", handle: "hana", avatarUrl: null }) + hours.seedUser(BOB, { name: "Bob", handle: "bob", avatarUrl: null }) + hours.seedUser(OPERATOR, { name: "Olive Operator", handle: "olive", avatarUrl: null }) + hours.seedUser(CIVFIX_OFFICIAL_USER_ID, { name: "CivFix", handle: "civfix", avatarUrl: null }) + hours.seedCleanup(EVENT, { + title: "Ocean Beach sweep", + referenceCode: "EV-1", + scheduledAt: SCHEDULED_AT, + timezone: null, + }) + + const accounts = new InMemoryAdminUserRepository() + accounts.seedUser({ id: BOB, name: "Bob", handle: "bob" }) + accounts.seedUser({ id: HOST, name: "Hana Host", handle: "hana" }) + accounts.seedUser({ id: OPERATOR, name: "Olive Operator", role: "operator" }) + accounts.seedUser({ id: OTHER_OPERATOR, name: "Otto Operator", role: "operator" }) + accounts.seedUser({ id: CIVFIX_OFFICIAL_USER_ID, name: "CivFix", handle: "civfix" }) + + const certificates = new InMemoryCertificateRepository() + const sent: SentNotice[] = [] + const bumps: string[] = [] + const warnings: unknown[] = [] + let event: CleanupHoursView | null = endedEvent() + + const notifier: Pick = { + createNotification: (userId, input) => { + if (opts.notifierFails === true) return Promise.reject(new Error("push outage")) + sent.push({ + userId, + type: input.type, + titleKey: input.titleKey, + bodyKey: input.bodyKey, + vars: (input.vars ?? {}) as Record, + link: input.link ?? undefined, + }) + return Promise.resolve({} as Awaited>) + }, + } + + const svc = makeAdminUserHoursService({ + hours, + certificates, + accounts, + events: { load: () => Promise.resolve(event) }, + insightsInvalidator: { + bumpInsightsGeneration: (cleanupId) => { + bumps.push(cleanupId) + return Promise.resolve() + }, + }, + notifier, + logger: { warn: (obj) => warnings.push(obj) }, + now, + }) + return { + svc, + hours, + accounts, + certificates, + sent, + bumps, + warnings, + setEvent: (view) => { + event = view + }, + setNow: (iso) => { + at = Date.parse(iso) + }, + } +} + +function eventCredit( + hours: number, + patch: Partial> = {}, +): AdminCreditUserHoursRequest { + return { + id: BOB, + kind: "event", + eventId: EVENT, + hours, + reason: "Signed up at the event", + ...patch, + } +} + +function manualCredit( + hours: number, + serviceDate = "2026-07-05", + patch: Partial> = {}, +): AdminCreditUserHoursRequest { + return { + id: BOB, + kind: "manual", + hours, + serviceDate, + reason: "Tabling at the library fair", + ...patch, + } +} + +describe("getUserHours", () => { + it("lists every row, voided and retired ones included, with nulls present rather than omitted", async () => { + const h = harness() + h.hours.seedLegacyReportEntry(BOB, REPORT, GEOID, 0.1) + const event = await h.svc.creditUserHours(OPERATOR, eventCredit(3)) + const manual = await h.svc.creditUserHours(OPERATOR, manualCredit(2)) + await h.svc.voidUserHours(OTHER_OPERATOR, { id: BOB, entryId: manual.entryId, reason: "Typo" }) + + const res = await h.svc.getUserHours({ id: BOB }) + + expect(AdminUserHoursResponseSchema.parse(res)).toEqual(res) + expect(res.totals).toEqual({ totalHours: 3.1, liveEntries: 2, voidedEntries: 1 }) + expect(res.nextCursor).toBeNull() + const [voidedManual, eventRow, reportRow] = res.items + expect(voidedManual).toEqual({ + id: manual.entryId, + source: "manual", + hours: 2, + occurredAt: "2026-07-05T12:00:00.000Z", + creditedAt: NOW, + serviceDate: "2026-07-05", + event: null, + jurisdiction: null, + creditedBy: { + id: CIVFIX_OFFICIAL_USER_ID, + name: "CivFix", + handle: "civfix", + official: true, + }, + operator: { id: OPERATOR, name: "Olive Operator" }, + note: "Tabling at the library fair", + voidedAt: NOW, + voidedBy: { id: OTHER_OPERATOR, name: "" }, + voidReason: "Typo", + voidable: false, + }) + expect(eventRow).toMatchObject({ + id: event.entryId, + source: "event", + serviceDate: null, + event: { id: EVENT, title: "Ocean Beach sweep", referenceCode: "EV-1" }, + jurisdiction: { geoid: GEOID, name: "Los Angeles" }, + occurredAt: SCHEDULED_AT.toISOString(), + voidedAt: null, + voidedBy: null, + voidReason: null, + voidable: true, + }) + expect(reportRow).toMatchObject({ + source: "report", + creditedBy: null, + operator: null, + note: null, + voidable: false, + }) + }) + + it("pages with the requested limit and a cursor", async () => { + const h = harness() + await h.svc.creditUserHours(OPERATOR, manualCredit(1, "2026-07-01")) + await h.svc.creditUserHours(OPERATOR, manualCredit(1, "2026-07-02")) + await h.svc.creditUserHours(OPERATOR, manualCredit(1, "2026-07-03")) + + const first = await h.svc.getUserHours({ id: BOB, limit: 2 }) + expect(first.items.map((i) => i.serviceDate)).toEqual(["2026-07-03", "2026-07-02"]) + const second = await h.svc.getUserHours({ id: BOB, limit: 2, cursor: first.nextCursor! }) + expect(second.items.map((i) => i.serviceDate)).toEqual(["2026-07-01"]) + expect(second.nextCursor).toBeNull() + }) + + it("is NOT_FOUND for an unknown account", async () => { + const h = harness() + await expect(h.svc.getUserHours({ id: UNKNOWN })).rejects.toMatchObject({ code: "NOT_FOUND" }) + }) +}) + +describe("creditUserHours: event credit", () => { + it("credits a non-member as CivFix, bells them with the event title and bumps the event's insights", async () => { + const h = harness() + + const res = await h.svc.creditUserHours(OPERATOR, eventCredit(3.333)) + + expect(res).toEqual({ entryId: expect.any(String), totalHours: 3.33 }) + expect(h.hours.ledgerRow(res.entryId)).toMatchObject({ + source: "event", + hours: 3.33, + geoid: GEOID, + loggedByUserId: CIVFIX_OFFICIAL_USER_ID, + creditedByOperatorId: OPERATOR, + note: "Signed up at the event", + }) + expect(h.hours.audits).toEqual([ + expect.objectContaining({ actorId: OPERATOR, action: "user.hours_credited" }), + ]) + expect(h.bumps).toEqual([EVENT]) + expect(h.sent).toEqual([ + { + userId: BOB, + type: "hours_logged", + titleKey: "notification.hours_logged.title", + bodyKey: "notification.hours_logged.body", + vars: { hours: 3.33, title: "Ocean Beach sweep" }, + link: HOURS_CREDIT_NOTICE_LINK, + }, + ]) + expect(HOURS_CREDIT_NOTICE_LINK).toBe("/profile") + }) + + it("is NOT_FOUND for an unknown event", async () => { + const h = harness() + h.setEvent(null) + await expect(h.svc.creditUserHours(OPERATOR, eventCredit(1))).rejects.toMatchObject({ + code: "NOT_FOUND", + }) + }) + + it("refuses a cancelled event, an event that has not ended and one that ran under 15 minutes", async () => { + const h = harness() + h.setEvent(endedEvent({ status: "cancelled" })) + await expect(h.svc.creditUserHours(OPERATOR, eventCredit(1))).rejects.toMatchObject({ + code: "CONFLICT", + }) + + h.setEvent(endedEvent({ endsAt: new Date("2026-07-06T12:00:00.000Z") })) + await expect(h.svc.creditUserHours(OPERATOR, eventCredit(1))).rejects.toMatchObject({ + code: "CONFLICT", + }) + + h.setEvent(endedEvent({ endsAt: new Date("2026-07-04T08:10:00.000Z") })) + await expect(h.svc.creditUserHours(OPERATOR, eventCredit(0.1))).rejects.toMatchObject({ + code: "CONFLICT", + }) + expect(h.sent).toEqual([]) + expect(h.bumps).toEqual([]) + }) + + it("names the exact window cap on the hours field", async () => { + const h = harness() + await expect(h.svc.creditUserHours(OPERATOR, eventCredit(5.5))).rejects.toMatchObject({ + code: "VALIDATION", + fields: { hours: "this event ran for 4 h, so at most 5 h may be credited per attendee" }, + }) + }) + + it("is a CONFLICT while the user already holds a live row for the event", async () => { + const h = harness() + await h.svc.creditUserHours(OPERATOR, eventCredit(2)) + await expect(h.svc.creditUserHours(OPERATOR, eventCredit(1))).rejects.toMatchObject({ + code: "CONFLICT", + }) + expect(h.sent).toHaveLength(1) + }) +}) + +describe("creditUserHours: manual credit", () => { + it("credits a dated adjustment with no jurisdiction, bells with the adjustment keys and bumps nothing", async () => { + const h = harness() + + const res = await h.svc.creditUserHours(OPERATOR, manualCredit(1.5)) + + expect(res.totalHours).toBe(1.5) + expect(h.hours.ledgerRow(res.entryId)).toMatchObject({ + source: "manual", + geoid: null, + serviceDate: "2026-07-05", + loggedByUserId: CIVFIX_OFFICIAL_USER_ID, + creditedByOperatorId: OPERATOR, + }) + expect(h.bumps).toEqual([]) + expect(h.sent).toEqual([ + { + userId: BOB, + type: "hours_logged", + titleKey: "notification.hours_adjusted.title", + bodyKey: "notification.hours_adjusted.body", + vars: { hours: 1.5 }, + link: "/profile", + }, + ]) + }) + + it("refuses a service date after today in the default event time zone", async () => { + const h = harness() + h.setNow("2026-07-07T03:00:00.000Z") + + await expect( + h.svc.creditUserHours(OPERATOR, manualCredit(1, "2026-07-07")), + ).rejects.toMatchObject({ + code: "VALIDATION", + fields: { serviceDate: "The service date can't be in the future." }, + }) + const today = await h.svc.creditUserHours(OPERATOR, manualCredit(1, "2026-07-06")) + expect(h.hours.ledgerRow(today.entryId)?.serviceDate).toBe("2026-07-06") + }) + + it("keeps the credit when the bell fails, and logs the failure", async () => { + const h = harness({ notifierFails: true }) + const res = await h.svc.creditUserHours(OPERATOR, manualCredit(1)) + expect(h.hours.ledgerRow(res.entryId)).not.toBeNull() + expect(h.warnings).toHaveLength(1) + }) +}) + +describe("creditUserHours: target guards", () => { + it("refuses the official account, an operator account and the operator themselves", async () => { + const h = harness() + await expect( + h.svc.creditUserHours(OPERATOR, manualCredit(1, "2026-07-05", { id: CIVFIX_OFFICIAL_USER_ID })), + ).rejects.toMatchObject({ code: "FORBIDDEN" }) + await expect( + h.svc.creditUserHours(OPERATOR, manualCredit(1, "2026-07-05", { id: OTHER_OPERATOR })), + ).rejects.toMatchObject({ code: "FORBIDDEN" }) + await expect( + h.svc.creditUserHours(OPERATOR, manualCredit(1, "2026-07-05", { id: OPERATOR })), + ).rejects.toMatchObject({ + code: "FORBIDDEN", + message: "You can't credit volunteer hours to yourself.", + }) + }) + + it("is NOT_FOUND for an unknown account and a CONFLICT for a deleted one", async () => { + const h = harness() + await expect( + h.svc.creditUserHours(OPERATOR, eventCredit(1, { id: UNKNOWN })), + ).rejects.toMatchObject({ code: "NOT_FOUND" }) + + h.accounts.seedUser({ id: UNKNOWN, deletedAt: new Date("2026-07-01T00:00:00.000Z") }) + await expect( + h.svc.creditUserHours(OPERATOR, eventCredit(1, { id: UNKNOWN })), + ).rejects.toMatchObject({ code: "CONFLICT" }) + expect(h.hours.audits).toEqual([]) + }) + + it("credits a suspended or banned account", async () => { + const h = harness() + h.accounts.seedUser({ id: BOB, accountStatus: "banned" }) + const res = await h.svc.creditUserHours(OPERATOR, manualCredit(1)) + expect(res.totalHours).toBe(1) + + h.accounts.seedUser({ id: HOST, accountStatus: "suspended" }) + const host = await h.svc.creditUserHours(OPERATOR, manualCredit(1, "2026-07-05", { id: HOST })) + expect(host.totalHours).toBe(1) + }) +}) + +describe("voidUserHours", () => { + it("voids an event row without a bell, bumps the event's insights and lists the live certificates that itemised it", async () => { + const h = harness() + const { entryId } = await h.svc.creditUserHours(OPERATOR, eventCredit(3)) + const snapshot = buildTranscriptModel({ + holder: { userId: BOB, displayName: "Bob", handle: "bob" }, + rows: [{ id: entryId, source: "event" as const, hours: 3, occurredAt: SCHEDULED_AT }], + locale: "en", + }) + await h.certificates.insert({ + id: "cert-1", + userId: BOB, + code: "ABCD2345", + locale: "en", + holderName: "Bob", + holderHandle: "bob", + holderVerified: false, + totalHours: 3, + entryCount: 1, + periodStart: null, + periodEnd: null, + ledgerFingerprint: "fp-1", + snapshot, + r2Key: "certs/ABCD2345.pdf", + documentSha256: "0".repeat(64), + byteSize: 1, + issuedAt: new Date("2026-07-05T09:00:00.000Z"), + }) + h.sent.length = 0 + h.bumps.length = 0 + + const res = await h.svc.voidUserHours(OPERATOR, { id: BOB, entryId, reason: "Wrong person" }) + + expect(res).toEqual({ + ok: true, + affectedCertificates: [{ code: "ABCD2345", issuedAt: "2026-07-05T09:00:00.000Z" }], + }) + expect(h.sent).toEqual([]) + expect(h.bumps).toEqual([EVENT]) + expect((await h.svc.getUserHours({ id: BOB })).totals.totalHours).toBe(0) + expect(h.hours.audits.map((a) => a.action)).toEqual(["user.hours_credited", "user.hours_voided"]) + }) + + it("does not bump any event's insights for a manual row and returns no certificates when none listed it", async () => { + const h = harness() + const { entryId } = await h.svc.creditUserHours(OPERATOR, manualCredit(2)) + h.sent.length = 0 + + const res = await h.svc.voidUserHours(OPERATOR, { id: BOB, entryId, reason: "Duplicate" }) + + expect(res).toEqual({ ok: true, affectedCertificates: [] }) + expect(h.bumps).toEqual([]) + expect(h.sent).toEqual([]) + }) + + it("voids on a deleted account but never on an operator or the official account", async () => { + const h = harness() + const { entryId } = await h.svc.creditUserHours(OPERATOR, manualCredit(2)) + h.accounts.seedUser({ id: BOB, deletedAt: new Date("2026-07-06T09:00:00.000Z") }) + + await expect( + h.svc.voidUserHours(OPERATOR, { id: BOB, entryId, reason: "Bad credit" }), + ).resolves.toMatchObject({ ok: true }) + + await expect( + h.svc.voidUserHours(OPERATOR, { id: OTHER_OPERATOR, entryId, reason: "x" }), + ).rejects.toMatchObject({ code: "FORBIDDEN" }) + await expect( + h.svc.voidUserHours(OPERATOR, { id: CIVFIX_OFFICIAL_USER_ID, entryId, reason: "x" }), + ).rejects.toMatchObject({ code: "FORBIDDEN" }) + await expect( + h.svc.voidUserHours(OPERATOR, { id: UNKNOWN, entryId, reason: "x" }), + ).rejects.toMatchObject({ code: "NOT_FOUND" }) + }) + + it("scopes the entry to the account named in the path and refuses a second void", async () => { + const h = harness() + const { entryId } = await h.svc.creditUserHours(OPERATOR, manualCredit(2)) + + await expect( + h.svc.voidUserHours(OPERATOR, { id: HOST, entryId, reason: "x" }), + ).rejects.toMatchObject({ code: "NOT_FOUND" }) + await h.svc.voidUserHours(OPERATOR, { id: BOB, entryId, reason: "x" }) + await expect( + h.svc.voidUserHours(OPERATOR, { id: BOB, entryId, reason: "x" }), + ).rejects.toMatchObject({ code: "CONFLICT" }) + }) +}) diff --git a/services/api/test/unit/data-export.test.ts b/services/api/test/unit/data-export.test.ts index 173b5222..480ab1e6 100644 --- a/services/api/test/unit/data-export.test.ts +++ b/services/api/test/unit/data-export.test.ts @@ -111,7 +111,9 @@ describe("data export", () => { jurisdiction_geoid: "0600000", hours: 2, logged_by_user_id: null, + service_date: null, created_at: new Date("2026-02-02T00:00:00.000Z"), + voided_at: new Date("2026-02-03T00:00:00.000Z"), }, ], }, @@ -123,6 +125,28 @@ describe("data export", () => { expect((parsed.posts as { body: string }[])[0]!.body).toBe("hello neighbors") expect(Array.isArray(parsed.volunteerHours)).toBe(true) expect((parsed.volunteerHours as { hours: number }[])[0]!.hours).toBe(2) + expect(parsed.volunteerHours).toEqual([ + expect.objectContaining({ service_date: null, voided_at: "2026-02-03T00:00:00.000Z" }), + ]) + }) + + it("exports each hours row's void time and service date, never the operator-plane columns", async () => { + const users = new InMemoryUserStore() + const user = userRecord() + users.seed(user.email, user) + const fake = makeFakeSql() + const service = makeDataExportService({ + sql: fake.sql as unknown as Sql, + mailer: new FakeMailer(), + users, + fromNoReply: FROM, + supportEmail: SUPPORT, + }) + await service.exportData(USER_ID) + const hoursQuery = fake.statements.find((st) => /FROM volunteer_hours/.test(st.sql))?.sql ?? "" + expect(hoursQuery).toMatch(/\bvoided_at\b/) + expect(hoursQuery).toMatch(/\bservice_date\b/) + expect(hoursQuery).not.toMatch(/\bnote\b|void_reason|_operator_id/) }) it("bounds the attachment to the byte budget and records byte-truncated sections (F018)", async () => { diff --git a/services/api/test/unit/route-coverage.test.ts b/services/api/test/unit/route-coverage.test.ts index 819e157e..32c7e05d 100644 --- a/services/api/test/unit/route-coverage.test.ts +++ b/services/api/test/unit/route-coverage.test.ts @@ -233,8 +233,8 @@ describe("route-coverage: every shared endpoint is registered (offline boot smok }) } - it("covers ALL 333 endpoints in the registry (no endpoint skipped)", () => { - expect(Object.keys(endpoints).length).toBe(333) + it("covers ALL 336 endpoints in the registry (no endpoint skipped)", () => { + expect(Object.keys(endpoints).length).toBe(336) }) it("the discriminator is not vacuous: a bogus path IS detected as route-missing", async () => { diff --git a/services/api/test/unit/volunteer-hours-entries.test.ts b/services/api/test/unit/volunteer-hours-entries.test.ts index b6319ea8..120f77a9 100644 --- a/services/api/test/unit/volunteer-hours-entries.test.ts +++ b/services/api/test/unit/volunteer-hours-entries.test.ts @@ -459,7 +459,7 @@ describe("hours ledger: getEventHours scope matrix (C10)", () => { const res = await service.getEventHours(EVENT, CAROL) expect(res.scope).toBe("self") expect(res.entries).toEqual([ - { userId: CAROL, hours: 1.5, loggedAt: res.entries[0]!.loggedAt }, + { userId: CAROL, hours: 1.5, loggedAt: res.entries[0]!.loggedAt, creditedByOfficial: false }, ]) expect(res.anyLogged).toBe(true) }) From d698a9afd3c63a7d890381a8740d943913e68b95 Mon Sep 17 00:00:00 2001 From: byteful Date: Sat, 26 Sep 2026 21:58:10 -0400 Subject: [PATCH 09/14] document operator hours credits and voids --- docs/erasure-behavior.md | 7 ++++ docs/volunteer-hours-integrity.md | 57 ++++++++++++++++++++++++++++--- 2 files changed, 59 insertions(+), 5 deletions(-) diff --git a/docs/erasure-behavior.md b/docs/erasure-behavior.md index 23d07791..a838d03c 100644 --- a/docs/erasure-behavior.md +++ b/docs/erasure-behavior.md @@ -79,6 +79,7 @@ response can be answered truthfully. It is the source of record for the | `donations` | **Kept.** `user_id` NULLed and `profile_unlinked_at` stamped immediately. On a CHARGED donation `donor_email` / `donor_name` survive until `charged_at + 7 years`; on one that never charged they are NULLed at once. ⚖️ DECISION below. | | `org_payouts` | **Kept, with the FK intact.** The row records that an organization moved its own money, not anything about the person who pressed the button; `requested_by` declares `ON DELETE SET NULL`, but civfix erasure is a SOFT delete, so that action never fires and the actor stays the tombstoned account. There is no contact detail in the table to scrub. | | `cleanup_slot_claims` (which signup slot they took, P9) | **Kept**. The row is `(cleanup_id, user_id, slot_id, claimed_at)` — roster data with no free-text PII, held exactly like the `cleanup_members` row it accompanies, and with no `ON DELETE CASCADE` to `users` by design (`drizzle/0063_cleanup_slots.sql`). The attendee/roster read joins `users` with `deleted_at IS NULL`, so a tombstoned claimant disappears from the visible roster; the row still counts toward the slot's `claimed` total. | +| `volunteer_hours` (the hours ledger) | **Kept, untouched**, like reports: the rows are the record of work done at other people's events, and the leaderboard and public hours reads already hide a tombstoned account. The operator-credit columns from 0182 stay with their row: `service_date`, `credited_by_operator_id`, `voided_by_operator_id`, and the operator's free-text `note` (credit reason) and `void_reason`. The two operator ids name the operator, not the departing person, and the two reasons are admin-plane notes that no user-facing read or export carries. An operator can still void a deleted account's entries (`POST /v1/admin/users/:id/hours/:entryId/void`) but can no longer credit them. | | `service_hours_certificates` (issued PDF transcripts, P5) | **Revoked + scrubbed**, rows kept, **R2 objects deleted**. See the next section. | ### The host-transfer ladder @@ -311,6 +312,12 @@ From the no-reply mailbox); it now directs the user to the monitored support/DSA address. Excluded/redacted as before: push-token secrets (`[REDACTED]`), certificate `snapshot`/`r2_key`, and all OTP/session/OAuth secrets. +Each exported `volunteer_hours` row also carries `voided_at` and `service_date` (0182), so the +holder can see which entries were voided and the day a manual adjustment was for. The export never +includes the operator-plane columns: the credit `note`, the `void_reason`, and the +`credited_by_operator_id` / `voided_by_operator_id` of the operator who acted (DECISIONS §58 in the +shared contract). An operator credit's `logged_by_user_id` is the CivFix official account. + ### F018 — bounded, off-request-path export assembly Export assembly moved off the request path into a `data.export` pg-boss job diff --git a/docs/volunteer-hours-integrity.md b/docs/volunteer-hours-integrity.md index ebfb8fc4..03211969 100644 --- a/docs/volunteer-hours-integrity.md +++ b/docs/volunteer-hours-integrity.md @@ -1,8 +1,9 @@ # Volunteer-hours integrity (civfix-backend) **Audience:** internal (engineering + operators). Not served publicly. -**Last updated:** 2026-09-26 (a host re-save only rewrites rows it changes, and -re-crediting a voided row revives it; see "Re-saving and voided rows"). +**Last updated:** 2026-09-26 (operators credit and void hours from the admin console; see +"Operator credits and voids". A host re-save only rewrites rows it changes, and re-crediting a +voided row revives it; see "Re-saving and voided rows"). Volunteer hours are the input to `POST /v1/me/volunteer-hours/certificates`, which mints a signed, publicly verifiable PDF transcript that residents hand to schools and courts. Hours are supplied by @@ -77,6 +78,46 @@ The host editor re-submits the whole sheet, so `logEventHours` is written to be `credited` in the response still counts every entry the host submitted, so the host's confirmation reads the same whether or not a row changed. +## Operator credits and voids + +The admin console's Hours tab (DECISIONS §58 in the shared contract) is the one writer besides the +host sheet. It runs through `admin-user-hours-service.ts` and the repository methods +`creditEventAsOperator`, `creditManual` and `voidEntry`, each one transaction that writes its +`audit_log` row (`user.hours_credited` / `user.hours_voided`, actor = the operator, meta = entry, +hours and reason) before it commits. + +- **`POST /v1/admin/users/:id/hours` with `kind: "event"`** credits a person who is not on the + roster, typically someone who attended before they had the app. It obeys rules (a), (b) and (e) + exactly as the host path does (the same `assertEventCreditable` / `assertCreditableEventHours` + and the same locked daily-cap read); only roster membership is skipped. A live row for that + person and event is a `CONFLICT`: the operator voids it first. The row is written through the + shared `writeEventCredits` upsert with `logged_by_user_id` = the CivFix official account, so the + user, the event, the public history and the leaderboard see "credited by CivFix"; the operator + lands in `credited_by_operator_id` and in the `volunteer_hours_audit` journal as actor. +- **`kind: "manual"`** is work outside any event. The row has no `cleanup_id` and no + jurisdiction, so it counts toward the person's total and toward no leaderboard, and it carries a + `service_date`: required on manual rows and refused on every other source (the 0182 CHECK), + on or after 2000-01-01 (the contract's floor), and never after today as read in + `DEFAULT_EVENT_TIME_ZONE`. The service date is what dates the row on the ledger and on a + transcript. Rule (b) counts manual rows on their `service_date` together with event rows on + their local event day, so an operator adjustment cannot push a day past 24 h, in either order. + A second identical manual credit (same person, hours, date and operator) within 60 s is a + `CONFLICT`, which absorbs a double-submitted form. +- **`POST /v1/admin/users/:id/hours/:entryId/void`** is the "admin void path" and the only + correction: entries are never edited in place, so a wrong credit is voided with a reason and a + new one credited. The entry must belong to the account in the path (`NOT_FOUND` otherwise), a + second void is a `CONFLICT`, and a retired `source = 'report'` row is never voidable. The void + takes the hours out of `user_jurisdiction_hours` and, for an event row, journals + `previous_hours = hours, new_hours = 0`. A host may credit the person again afterwards, which + revives the row as described above. + +Neither operator write runs rule (c) or the rule (d) detectors. Both exist to catch accounts +crediting *each other*; an operator credit is attributed to CivFix, which can be in no such +exchange, and the act is already an audited operator action with a written reason, so filing it to +the moderation queue that same operator works would add noise and no oversight. The operator's +reason and identity stay on the admin plane: no user-facing read and no data export carries +`note`, `void_reason` or either operator column. + ## What the anomaly items look like Both signals file through the ordinary moderation create path with `dedupeOpen: true`, so a repeatedly @@ -152,9 +193,15 @@ Issuance (`certificate-service.ts` → `entriesForCertificate`) already excludes rows and `source = 'report'` rows. There is **no per-row "flagged" or "pending moderation" state** on `volunteer_hours`, and none was added: a moderation signal is a suspicion about a pattern, not a verdict on a row, and a silently-omitted row would make the printed total disagree with the ledger the holder -can see. The operator remedy for confirmed abuse is the existing one — void the rows -(`voided_at`), which removes them from every future certificate, and revoke any certificate already -issued over them (`POST /v1/me/volunteer-hours/certificates/:code/revoke`, or the admin void path). +can see. The operator remedy for confirmed abuse is to void the rows from the admin console +(`POST /v1/admin/users/:id/hours/:entryId/void`), which removes them from every future certificate. +A certificate already issued is a frozen snapshot and keeps verifying with the old total, so the void +response lists the holder's live certificates whose snapshot itemised the entry +(`affectedCertificates`, read by `CertificateRepository.liveCodesListingEntry`). Revoking those stays +a deliberate, separate step: the holder can revoke their own +(`POST /v1/me/volunteer-hours/certificates/:code/revoke`), and an operator runs +`pnpm --filter @civfix/api db:certificate:revoke --reason ledger_corrected` +(`services/api/scripts/revoke-certificate.ts`, `--dry-run` first). The console never revokes one. ## Configuration From 898cc59463d4f1dd5833a877fdddcc428aa4b6af Mon Sep 17 00:00:00 2001 From: byteful Date: Sat, 26 Sep 2026 21:59:05 -0400 Subject: [PATCH 10/14] format admin hours files --- services/api/src/routes/admin/user-hours.routes.ts | 8 +------- .../api/test/unit/admin-user-hours-service.test.ts | 10 ++++++++-- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/services/api/src/routes/admin/user-hours.routes.ts b/services/api/src/routes/admin/user-hours.routes.ts index 68e8c6bf..0214f08a 100644 --- a/services/api/src/routes/admin/user-hours.routes.ts +++ b/services/api/src/routes/admin/user-hours.routes.ts @@ -11,13 +11,7 @@ import type { Container } from "../../di.js" import { requireOperator } from "../../auth/admin-guard.js" import { perIdentity } from "../../plugins/rate-limit.js" import { route } from "../../versioning/route.js" -import { - idParam, - overridableService, - parse, - parseBodyWithId, - twoIdParams, -} from "./_route-utils.js" +import { idParam, overridableService, parse, parseBodyWithId, twoIdParams } from "./_route-utils.js" import { auditRead } from "./_audit-read.js" import { makeAdminUserHoursService, diff --git a/services/api/test/unit/admin-user-hours-service.test.ts b/services/api/test/unit/admin-user-hours-service.test.ts index 94a00665..3f4b4075 100644 --- a/services/api/test/unit/admin-user-hours-service.test.ts +++ b/services/api/test/unit/admin-user-hours-service.test.ts @@ -375,7 +375,10 @@ describe("creditUserHours: target guards", () => { it("refuses the official account, an operator account and the operator themselves", async () => { const h = harness() await expect( - h.svc.creditUserHours(OPERATOR, manualCredit(1, "2026-07-05", { id: CIVFIX_OFFICIAL_USER_ID })), + h.svc.creditUserHours( + OPERATOR, + manualCredit(1, "2026-07-05", { id: CIVFIX_OFFICIAL_USER_ID }), + ), ).rejects.toMatchObject({ code: "FORBIDDEN" }) await expect( h.svc.creditUserHours(OPERATOR, manualCredit(1, "2026-07-05", { id: OTHER_OPERATOR })), @@ -453,7 +456,10 @@ describe("voidUserHours", () => { expect(h.sent).toEqual([]) expect(h.bumps).toEqual([EVENT]) expect((await h.svc.getUserHours({ id: BOB })).totals.totalHours).toBe(0) - expect(h.hours.audits.map((a) => a.action)).toEqual(["user.hours_credited", "user.hours_voided"]) + expect(h.hours.audits.map((a) => a.action)).toEqual([ + "user.hours_credited", + "user.hours_voided", + ]) }) it("does not bump any event's insights for a manual row and returns no certificates when none listed it", async () => { From ffb48f18e0719c3692b9fe5482c8673483e89961 Mon Sep 17 00:00:00 2001 From: byteful Date: Sat, 26 Sep 2026 22:20:31 -0400 Subject: [PATCH 11/14] voidable honours account guards, precise ledger cursor --- .../services/admin/admin-user-hours-service.ts | 14 ++++++++++---- .../volunteer-hours-repository.drizzle.ts | 8 +++++--- .../api/src/services/volunteer-hours-service.ts | 4 ++-- .../test/unit/admin-user-hours-service.test.ts | 15 +++++++++++++++ 4 files changed, 32 insertions(+), 9 deletions(-) diff --git a/services/api/src/services/admin/admin-user-hours-service.ts b/services/api/src/services/admin/admin-user-hours-service.ts index d74e505f..0b7c1f41 100644 --- a/services/api/src/services/admin/admin-user-hours-service.ts +++ b/services/api/src/services/admin/admin-user-hours-service.ts @@ -8,9 +8,11 @@ import type { AdminVoidUserHoursRequest, AdminVoidUserHoursResponse, } from "@civfix/shared" +import { isOfficialAccount } from "../../auth/official-account.js" import { assertTargetIsNotOfficialAccount, assertTargetIsNotOperatorRole, + isOperatorRole, } from "../../auth/operator-target.js" import { parseTimeCursor } from "../../db/cursor-helpers.js" import type { CertificateRepository } from "../certificate-service.js" @@ -60,7 +62,10 @@ function round2(n: number): number { return Math.round(n * 100) / 100 } -export function toAdminUserHoursEntryDTO(view: OperatorLedgerEntryView): AdminUserHoursEntryDTO { +export function toAdminUserHoursEntryDTO( + view: OperatorLedgerEntryView, + accountAllowsVoid: boolean, +): AdminUserHoursEntryDTO { return { id: view.id, source: view.source, @@ -90,7 +95,7 @@ export function toAdminUserHoursEntryDTO(view: OperatorLedgerEntryView): AdminUs voidedAt: view.voidedAt === null ? null : view.voidedAt.toISOString(), voidedBy: view.voidedBy === null ? null : { id: view.voidedBy.id, name: view.voidedBy.name }, voidReason: view.voidReason, - voidable: view.voidedAt === null && view.source !== "report", + voidable: accountAllowsVoid && view.voidedAt === null && view.source !== "report", } } @@ -185,7 +190,8 @@ export function makeAdminUserHoursService(deps: AdminUserHoursServiceDeps): Admi return { async getUserHours(query: AdminUserHoursQuery): Promise { - await accountOf(query.id) + const target = await accountOf(query.id) + const accountAllowsVoid = !isOfficialAccount(query.id) && !isOperatorRole(target.role) const [page, totals] = await Promise.all([ deps.hours.listOperatorLedger({ userId: query.id, @@ -195,7 +201,7 @@ export function makeAdminUserHoursService(deps: AdminUserHoursServiceDeps): Admi deps.hours.operatorLedgerTotals(query.id), ]) return { - items: page.items.map(toAdminUserHoursEntryDTO), + items: page.items.map((view) => toAdminUserHoursEntryDTO(view, accountAllowsVoid)), nextCursor: page.nextCursor, totals, } diff --git a/services/api/src/services/volunteer-hours-repository.drizzle.ts b/services/api/src/services/volunteer-hours-repository.drizzle.ts index 6f7409fb..23841181 100644 --- a/services/api/src/services/volunteer-hours-repository.drizzle.ts +++ b/services/api/src/services/volunteer-hours-repository.drizzle.ts @@ -7,7 +7,7 @@ import type { } from "@civfix/shared" import { CIVFIX_OFFICIAL_USER_ID, isOfficialAccount } from "../auth/official-account.js" import type { Queryable, Sql, TransactionSql } from "../db/client.js" -import { encodeTimeCursor, pageWith } from "../db/cursor-helpers.js" +import { cursorAtSql, cursorInstantSql, encodeTimeCursor, pageWith } from "../db/cursor-helpers.js" import { writeAudit } from "./admin/audit.js" import { blockedPairExpr, hiddenIdentity } from "./hidden-identity.js" import { servedKeyExpr } from "./media-served-key.js" @@ -207,6 +207,7 @@ interface OperatorLedgerRow { source: VolunteerHoursSource hours: number created_at: Date + cursor_at: string occurred_at: Date service_date: string | null cleanup_id: string | null @@ -818,7 +819,7 @@ export function makeDrizzleVolunteerHoursRepository(sql: Sql): VolunteerHoursRep const limit = Math.min(Math.max(1, Math.floor(args.limit)), OPERATOR_LEDGER_MAX_LIMIT) const keyset = args.cursor !== null - ? sql`AND (vh.created_at, vh.id) < (${args.cursor.at}, ${args.cursor.id}::uuid)` + ? sql`AND (vh.created_at, vh.id) < (${cursorAtSql(sql, args.cursor)}, ${args.cursor.id}::uuid)` : sql`` const rows = await sql` SELECT @@ -826,6 +827,7 @@ export function makeDrizzleVolunteerHoursRepository(sql: Sql): VolunteerHoursRep vh.source, vh.hours::float8 AS hours, vh.created_at, + ${cursorInstantSql(sql, sql`vh.created_at`)} AS cursor_at, ${occurredAtExpr(sql)} AS occurred_at, vh.service_date::text AS service_date, vh.cleanup_id, @@ -855,7 +857,7 @@ export function makeDrizzleVolunteerHoursRepository(sql: Sql): VolunteerHoursRep LIMIT ${limit + 1} ` const { items, nextCursor } = pageWith(rows, limit, (last) => - encodeTimeCursor({ at: last.created_at, id: last.id }), + encodeTimeCursor({ at: last.cursor_at, id: last.id }), ) return { items: items.map(toOperatorLedgerView), nextCursor } }, diff --git a/services/api/src/services/volunteer-hours-service.ts b/services/api/src/services/volunteer-hours-service.ts index e497148d..729c754a 100644 --- a/services/api/src/services/volunteer-hours-service.ts +++ b/services/api/src/services/volunteer-hours-service.ts @@ -24,7 +24,7 @@ import type { VolunteerHoursSource, } from "@civfix/shared" import { can, type HostStanding } from "@civfix/shared/host" -import { parseTimeCursor, type TimeCursor } from "../db/cursor-helpers.js" +import { parseTimeCursor, type DecodedTimeCursor, type TimeCursor } from "../db/cursor-helpers.js" import type { MessageKey, MessageVars } from "../i18n/renderMessage.js" import { mapWithLimit, PRESIGN_CONCURRENCY } from "./media-presign.js" import { assertCreditableEventHours, assertEventCreditable } from "./volunteer-hours-rules.js" @@ -142,7 +142,7 @@ export interface VoidedEntry { export interface OperatorLedgerArgs { userId: string - cursor: TimeCursor | null + cursor: DecodedTimeCursor | null limit: number } diff --git a/services/api/test/unit/admin-user-hours-service.test.ts b/services/api/test/unit/admin-user-hours-service.test.ts index 3f4b4075..08aa73cd 100644 --- a/services/api/test/unit/admin-user-hours-service.test.ts +++ b/services/api/test/unit/admin-user-hours-service.test.ts @@ -225,6 +225,21 @@ describe("getUserHours", () => { }) }) + it("marks no entry voidable on an account the void path refuses", async () => { + const h = harness() + await h.hours.creditManual({ + operatorId: OTHER_OPERATOR, + userId: OPERATOR, + hours: 1, + serviceDate: "2026-07-01", + reason: "Seeded before the operator role was granted", + }) + + const res = await h.svc.getUserHours({ id: OPERATOR }) + expect(res.items).toHaveLength(1) + expect(res.items[0]?.voidable).toBe(false) + }) + it("pages with the requested limit and a cursor", async () => { const h = harness() await h.svc.creditUserHours(OPERATOR, manualCredit(1, "2026-07-01")) From de9813d60dff9a352cfa8d4e01a7569f54792fc7 Mon Sep 17 00:00:00 2001 From: byteful Date: Sat, 26 Sep 2026 22:42:06 -0400 Subject: [PATCH 12/14] isolate moved-event hours tests from the leaderboard fixtures --- .../integration/volunteer-hours-pg.test.ts | 26 +++++++++++++++---- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/services/api/test/integration/volunteer-hours-pg.test.ts b/services/api/test/integration/volunteer-hours-pg.test.ts index e7985357..80f8edd2 100644 --- a/services/api/test/integration/volunteer-hours-pg.test.ts +++ b/services/api/test/integration/volunteer-hours-pg.test.ts @@ -427,7 +427,22 @@ describe.skipIf(!pg)("volunteer hours (integration)", () => { ]) }) + // The leaderboard tests assert exact per-jurisdiction rosters on the shared seeds, so a moved + // event lands in a jurisdiction no other test reads. + const MOVED_GEOID = "TESTHOURSMOVED" + async function ensureMovedJurisdiction(): Promise { + await h.sql` + INSERT INTO jurisdictions (geoid, name, layer, priority, geom) + VALUES ( + ${MOVED_GEOID}, 'Moved Event Jurisdiction', 'county', 5, + ST_Multi(ST_SetSRID(ST_GeomFromText('POLYGON((-117 33,-117 33.1,-117.1 33.1,-117.1 33,-117 33))'), 4326)) + ) + ON CONFLICT (geoid) DO NOTHING + ` + } + it("a voided row revived after the event moved credits only the new jurisdiction", async () => { + await ensureMovedJurisdiction() const org = await newUser("Revive Moved Org") const alice = await newUser("Revive Moved Alice") const cleanupId = await newCleanup(org) @@ -438,12 +453,12 @@ describe.skipIf(!pg)("volunteer hours (integration)", () => { await repo.logEventHours({ actorId: org, cleanupId, - geoid: LA_COUNTY.geoid, + geoid: MOVED_GEOID, entries: [{ userId: alice, hours: 2 }], }) expect(await rollupAt(alice, GEOID)).toBe(0) - expect(await rollupAt(alice, LA_COUNTY.geoid)).toBe(2) + expect(await rollupAt(alice, MOVED_GEOID)).toBe(2) expect(await driftFor(alice)).toEqual([]) }) @@ -498,6 +513,7 @@ describe.skipIf(!pg)("volunteer hours (integration)", () => { }) it("a same-hours re-save after the event moved re-homes the hours", async () => { + await ensureMovedJurisdiction() const org = await newUser("Rehome Org") const alice = await newUser("Rehome Alice") const cleanupId = await newCleanup(org) @@ -507,13 +523,13 @@ describe.skipIf(!pg)("volunteer hours (integration)", () => { const moved = await repo.logEventHours({ actorId: org, cleanupId, - geoid: LA_COUNTY.geoid, + geoid: MOVED_GEOID, entries: [{ userId: alice, hours: 2 }], }) expect(moved.changed).toEqual([{ userId: alice, hours: 2, previousHours: 2 }]) - expect((await eventRow(cleanupId, alice)).geoid).toBe(LA_COUNTY.geoid) + expect((await eventRow(cleanupId, alice)).geoid).toBe(MOVED_GEOID) expect(await rollupAt(alice, GEOID)).toBe(0) - expect(await rollupAt(alice, LA_COUNTY.geoid)).toBe(2) + expect(await rollupAt(alice, MOVED_GEOID)).toBe(2) expect(await driftFor(alice)).toEqual([]) expect((await repo.totalsFor(alice)).totalHours).toBe(2) }) From 94933aaca43be75b9a83170ce2d9618335a9c6d3 Mon Sep 17 00:00:00 2001 From: byteful Date: Tue, 29 Sep 2026 22:53:27 -0400 Subject: [PATCH 13/14] adopt shared 0.58.0 --- pnpm-lock.yaml | 14 +++++++------- services/api/package.json | 2 +- services/media-worker/package.json | 2 +- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c2c1bf4a..f6cea054 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -65,8 +65,8 @@ importers: specifier: ^3.700.0 version: 3.1057.0 '@civfix/shared': - specifier: 0.58.0-b-feat-admin-user-hours-contract.g47d8a38 - version: 0.58.0-b-feat-admin-user-hours-contract.g47d8a38 + specifier: ^0.58.0 + version: 0.58.0 '@fastify/cookie': specifier: ^11.0.1 version: 11.0.2 @@ -213,8 +213,8 @@ importers: specifier: workspace:* version: link:../api '@civfix/shared': - specifier: 0.58.0-b-feat-admin-user-hours-contract.g47d8a38 - version: 0.58.0-b-feat-admin-user-hours-contract.g47d8a38 + specifier: ^0.58.0 + version: 0.58.0 '@sentry/node': specifier: ^10.73.0 version: 10.73.0(@opentelemetry/core@2.11.0(@opentelemetry/api@1.9.1)) @@ -417,8 +417,8 @@ packages: '@balena/dockerignore@1.0.2': resolution: {integrity: sha512-wMue2Sy4GAVTk6Ic4tJVcnfdau+gx2EnG7S+uAEe+TWJFqE4YoWN4/H8MSLj4eYJKxGg26lZwboEniNiNwZQ6Q==} - '@civfix/shared@0.58.0-b-feat-admin-user-hours-contract.g47d8a38': - resolution: {integrity: sha512-J2l/KZ19Nn98wV8iS1EF3lUmCUUTdElMxnRH3bO+ij/RhGMo02O35u22Ly8nvt7rMC0qf4DNH2BZo9z23l5tLQ==} + '@civfix/shared@0.58.0': + resolution: {integrity: sha512-TCbW3KSw3tYb58m4MqcHDMY0sK1eIOwZ5zpfc9dEVVCwjLtmFnTpzfojtrFVrqt/wJbV2u4dhftWOIKWk1kTrQ==} engines: {node: '>=20'} '@derhuerst/http-basic@8.2.4': @@ -4450,7 +4450,7 @@ snapshots: '@balena/dockerignore@1.0.2': {} - '@civfix/shared@0.58.0-b-feat-admin-user-hours-contract.g47d8a38': + '@civfix/shared@0.58.0': dependencies: zod: 3.25.76 diff --git a/services/api/package.json b/services/api/package.json index 6589a332..dcba7bb4 100644 --- a/services/api/package.json +++ b/services/api/package.json @@ -60,7 +60,7 @@ "dependencies": { "@aws-sdk/client-s3": "^3.700.0", "@aws-sdk/s3-request-presigner": "^3.700.0", - "@civfix/shared": "0.58.0-b-feat-admin-user-hours-contract.g47d8a38", + "@civfix/shared": "^0.58.0", "@fastify/cookie": "^11.0.1", "@fastify/cors": "^10.0.1", "@fastify/helmet": "^12.0.1", diff --git a/services/media-worker/package.json b/services/media-worker/package.json index 5ea10c61..0ab5d800 100644 --- a/services/media-worker/package.json +++ b/services/media-worker/package.json @@ -23,7 +23,7 @@ "@aws-sdk/client-s3": "^3.700.0", "@aws-sdk/s3-request-presigner": "^3.700.0", "@civfix/api": "workspace:*", - "@civfix/shared": "0.58.0-b-feat-admin-user-hours-contract.g47d8a38", + "@civfix/shared": "^0.58.0", "@sentry/node": "^10.73.0", "@smithy/node-http-handler": "4.7.5", "drizzle-orm": "^0.45.2", From 14c9f80325143d95facce2161b862f54c62ff0b7 Mon Sep 17 00:00:00 2001 From: byteful Date: Tue, 29 Sep 2026 22:56:18 -0400 Subject: [PATCH 14/14] register admin hours rate limit in scoping policy test --- services/api/test/unit/rate-limit-plugin.test.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/services/api/test/unit/rate-limit-plugin.test.ts b/services/api/test/unit/rate-limit-plugin.test.ts index ef08fa86..b18f5605 100644 --- a/services/api/test/unit/rate-limit-plugin.test.ts +++ b/services/api/test/unit/rate-limit-plugin.test.ts @@ -127,6 +127,7 @@ import { ADMIN_MAIL_REPLY_PUBLISH_RATE_LIMIT, ADMIN_OUTBOUND_MAIL_RATE_LIMIT, } from "../../src/routes/admin/mail.routes.js" +import { ADMIN_USER_HOURS_WRITE_RATE_LIMIT } from "../../src/routes/admin/user-hours.routes.js" import { PUSH_TOKEN_RATE_LIMIT } from "../../src/routes/notifications.routes.js" import { FOLLOW_RATE_LIMIT, FOLLOW_SUGGESTIONS_RATE_LIMIT } from "../../src/routes/social.routes.js" import { DATA_EXPORT_RATE_LIMIT, LIST_BLOCKS_RATE_LIMIT } from "../../src/routes/users.routes.js" @@ -480,6 +481,7 @@ const IDENTITY_SCOPED_LIMITS = { PUSH_TOKEN_RATE_LIMIT, ADMIN_OUTBOUND_MAIL_RATE_LIMIT, ADMIN_MAIL_REPLY_PUBLISH_RATE_LIMIT, + ADMIN_USER_HOURS_WRITE_RATE_LIMIT, FOLLOW_RATE_LIMIT, FOLLOW_SUGGESTIONS_RATE_LIMIT, DATA_EXPORT_RATE_LIMIT, @@ -607,6 +609,11 @@ describe("rate limiter: route bucket scoping policy (CVX-012)", () => { expect(ROUTE_REPORT_RATE_LIMIT.skipOnError).toBe(false) }) + it("bounds the operator hours credits and voids and fails CLOSED", () => { + expect(ADMIN_USER_HOURS_WRITE_RATE_LIMIT.max).toBe(30) + expect(ADMIN_USER_HOURS_WRITE_RATE_LIMIT.skipOnError).toBe(false) + }) + it("bounds the operator report mutations (status / remove / flag / verdict) and fails CLOSED", () => { expect(ADMIN_REPORT_MUTATION_RATE_LIMIT.max).toBe(60) expect(ADMIN_REPORT_MUTATION_RATE_LIMIT.skipOnError).toBe(false)