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 diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 903674b3..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.57.0 - version: 0.57.0 + 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.57.0 - version: 0.57.0 + 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.57.0': - resolution: {integrity: sha512-v4IFb4pR9hbnf+AueRmL7GLB8DIhG9c9ZJjAnXKK3Yqjsv0Mbmhj0Khmb27YRgKL1Aoe//7nLiFkgj11Qrg7GQ==} + '@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.57.0': + '@civfix/shared@0.58.0': dependencies: zod: 3.25.76 diff --git a/services/api/package.json b/services/api/package.json index 7ac8d119..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.57.0", + "@civfix/shared": "^0.58.0", "@fastify/cookie": "^11.0.1", "@fastify/cors": "^10.0.1", "@fastify/helmet": "^12.0.1", 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..0214f08a --- /dev/null +++ b/services/api/src/routes/admin/user-hours.routes.ts @@ -0,0 +1,107 @@ +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..0b7c1f41 --- /dev/null +++ b/services/api/src/services/admin/admin-user-hours-service.ts @@ -0,0 +1,249 @@ +import { AppError } from "@civfix/shared" +import type { + AdminCreditUserHoursRequest, + AdminCreditUserHoursResponse, + AdminUserHoursEntryDTO, + AdminUserHoursQuery, + AdminUserHoursResponse, + 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" +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, + accountAllowsVoid: boolean, +): 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: accountAllowsVoid && 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 { + 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, + cursor: parseTimeCursor(query.cursor), + limit: query.limit ?? HOURS_ENTRIES_DEFAULT_LIMIT, + }), + deps.hours.operatorLedgerTotals(query.id), + ]) + return { + items: page.items.map((view) => toAdminUserHoursEntryDTO(view, accountAllowsVoid)), + 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/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/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 00358aec..23841181 100644 --- a/services/api/src/services/volunteer-hours-repository.drizzle.ts +++ b/services/api/src/services/volunteer-hours-repository.drizzle.ts @@ -5,9 +5,9 @@ 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 { 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" @@ -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,64 @@ async function detectHoursAnomalies( return anomalies } +interface OperatorLedgerRow { + id: string + source: VolunteerHoursSource + hours: number + created_at: Date + cursor_at: string + 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 +538,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 +554,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 +812,69 @@ 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) < (${cursorAtSql(sql, args.cursor)}, ${args.cursor.id}::uuid)` + : sql`` + const rows = await sql` + SELECT + vh.id, + 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, + 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.cursor_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..44e475e7 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 ?? "", + 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-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 b8d2c554..729c754a 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, @@ -19,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" @@ -96,6 +101,8 @@ export interface EventCreditWrite { export const MANUAL_CREDIT_REPEAT_WINDOW_MS = 60_000 +export const OPERATOR_LEDGER_MAX_LIMIT = ADMIN_USER_HOURS_PAGE_MAX + export interface OperatorEventCreditArgs { operatorId: string userId: string @@ -133,6 +140,35 @@ export interface VoidedEntry { hours: number } +export interface OperatorLedgerArgs { + userId: string + cursor: DecodedTimeCursor | 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; 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 +199,7 @@ export interface EventHoursLedgerEntry { userId: string hours: number loggedAt: Date + creditedByOfficial: boolean } export interface EventHoursLedger { @@ -257,6 +294,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 { @@ -398,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/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/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..08aa73cd --- /dev/null +++ b/services/api/test/unit/admin-user-hours-service.test.ts @@ -0,0 +1,524 @@ +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("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")) + 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/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) 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) }) 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") }, + ]) + }) +}) diff --git a/services/media-worker/package.json b/services/media-worker/package.json index 50dea7b6..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.57.0", + "@civfix/shared": "^0.58.0", "@sentry/node": "^10.73.0", "@smithy/node-http-handler": "4.7.5", "drizzle-orm": "^0.45.2",