From c723b32973d592082bdc047d5502c1bfca754c1d Mon Sep 17 00:00:00 2001 From: byteful Date: Sat, 26 Sep 2026 20:38:59 -0400 Subject: [PATCH 1/4] admin user hours contract --- .changeset/admin-user-hours.md | 5 + packages/shared/DECISIONS.md | 53 ++++ .../shared/__tests__/admin-user-hours.test.ts | 290 ++++++++++++++++++ packages/shared/__tests__/client.test.ts | 6 +- packages/shared/src/client/endpoints.ts | 42 ++- packages/shared/src/schemas/admin/common.ts | 7 + packages/shared/src/schemas/admin/index.ts | 1 + packages/shared/src/schemas/admin/orgs.ts | 9 +- .../shared/src/schemas/admin/user-hours.ts | 174 +++++++++++ packages/shared/src/schemas/volunteer.ts | 5 + 10 files changed, 585 insertions(+), 7 deletions(-) create mode 100644 .changeset/admin-user-hours.md create mode 100644 packages/shared/__tests__/admin-user-hours.test.ts create mode 100644 packages/shared/src/schemas/admin/user-hours.ts diff --git a/.changeset/admin-user-hours.md b/.changeset/admin-user-hours.md new file mode 100644 index 00000000..8d1e59bf --- /dev/null +++ b/.changeset/admin-user-hours.md @@ -0,0 +1,5 @@ +--- +"@civfix/shared": minor +--- + +Operator volunteer-hours corrections on the admin users surface: `getUserHours` (`GET /admin/users/:id/hours`, the user's whole ledger with voided rows and totals), `creditUserHours` (`POST /admin/users/:id/hours`, a strict union on `kind` — an `event` credit for a real event or a `manual` adjustment with a `serviceDate`) and `voidUserHours` (`POST /admin/users/:id/hours/:entryId/void`, which returns the live certificates that still list the entry), in a new `adminUserHoursEndpoints` registry group (333 → 336 endpoints). `AdminReasonSchema` is now exported from the admin common schemas and shared with the org writes. `EventHoursResponse.entries[].creditedByOfficial` is a new optional boolean. Additive. See DECISIONS §58. diff --git a/packages/shared/DECISIONS.md b/packages/shared/DECISIONS.md index bd3024c2..3ea4a583 100644 --- a/packages/shared/DECISIONS.md +++ b/packages/shared/DECISIONS.md @@ -2037,3 +2037,56 @@ under `/admin`; civfix-backend's `test/unit/route-coverage.test.ts` moves with i 0.57.0 for the endpoints and the new fields, and civfix-admin adopts it for the unified Inbox and the review action; `services/media-worker` bumps alongside the api and civfix-govt-web with the routine propagation. No seam or fake changes. + +## 58. An operator corrects a user's hours through the ledger, as CivFix, and a void is the only correction (0.58.0) + +People who attend a CivFix-sponsored event before they have the app sign up during or after it, and +nothing could credit them: the host's `logEventHours` only credits the event roster, the ledger's +`manual` source had no writer, and the "admin void" the integrity notes relied on did not exist. The +users surface gains three endpoints: `getUserHours` (`GET /admin/users/:id/hours`), `creditUserHours` +(`POST /admin/users/:id/hours`, csrf) and `voidUserHours` (`POST /admin/users/:id/hours/:entryId/void`, +csrf). + +**A credit is a ledger row attributed to CivFix; the human stays on the admin plane.** An operator +credit writes an ordinary `volunteer_hours` row whose creditor is the CivFix official account (§56), so +the user, the event page, the public history and the leaderboard all see "credited by CivFix" and +nothing else. The acting operator and the mandatory `reason` (`AdminReasonSchema`, the §32 rule, now +exported from `schemas/admin/common.ts` so the org writes and the hours writes share one definition) +are stored beside the row and written to the audit log in the same transaction. They appear only in +`AdminUserHoursEntryDTO` (`operator`, `note`, `voidedBy`, `voidReason`); no user-facing read and no +data export carries them. The user is notified of a credit and not of a void. + +**`creditUserHours` is a union on `kind`.** Both members are `.strict()` and bound `hours` to +`MIN_EVENT_HOURS`..`MAX_EVENT_HOURS`. `kind: "event"` names a real event and yields a genuine +`source: "event"` row: it obeys every rule the host path obeys (the event has ended, is not +cancelled, ran at least 15 minutes, the event's creditable window, the daily cap) except roster +membership, which is the whole point. A live row for that user and event is a CONFLICT; the operator +voids it first. `kind: "manual"` is work outside any event and carries a `serviceDate` (`YYYY-MM-DD`, +`ServiceDateSchema`). The server refuses a future date, because "today" depends on a time zone the +contract does not carry. A manual row has no jurisdiction: it counts toward the user's total and toward +no leaderboard, and its `occurredAt` (on the user's ledger and on a transcript) is the service date. +The response returns the new `entryId` and the user's recomputed `totalHours`. + +**A void is the only correction.** Entries are never edited in place: a wrong credit is voided with a +reason and a new one is credited. `voidUserHours` refuses an already-void entry with CONFLICT and never +touches a historical `source: "report"` row (`voidable: false`), which only a migration may change. A +host may credit a person again after an operator voided their event row; the row is revived rather than +duplicated. Issued certificates are frozen snapshots and keep verifying with the old total, so the void +response lists the live certificates whose snapshot contains the entry (`affectedCertificates`). The +operator revokes them with the backend's `db:certificate:revoke` CLI; the console never revokes one. + +**The ledger read shows everything.** `getUserHours` pages the user's rows newest first, voided rows +included, 1-50 per page under a keyset cursor. `totals.totalHours` is computed exactly as the user's +profile total, so the tab and the profile can never disagree; `liveEntries` and `voidedEntries` count +the rows. `EventHoursResponse.entries[].creditedByOfficial` is a new optional boolean so the host's +hours block can label an operator-credited non-member "Credited by CivFix" instead of "Former +attendee"; absent means false. + +**Registry and delivery set.** The three endpoints are a sixth group, `adminUserHoursEndpoints`, +spread into `endpoints` for the TS7056 reason §52 gives. The registry moves 333 to 336, 109 of them +under `/admin`; civfix-backend's `test/unit/route-coverage.test.ts` moves with it. Additive: every +change is a new endpoint, a new schema, or an optional field. civfix-backend `services/api` adopts +0.58.0 to serve the endpoints and set the flag, `services/media-worker` bumps alongside it, and +civfix-admin adopts it for the Hours tab. civfix-app's web and mobile read `creditedByOfficial` with no +adoption step. civfix-govt-web reads nothing new and stays on its tokens-only `^0.24.2` pin; closing that gap is a +separate, still-open decision. No seam or fake changes. diff --git a/packages/shared/__tests__/admin-user-hours.test.ts b/packages/shared/__tests__/admin-user-hours.test.ts new file mode 100644 index 00000000..da56defa --- /dev/null +++ b/packages/shared/__tests__/admin-user-hours.test.ts @@ -0,0 +1,290 @@ +import { describe, it, expect, vi } from "vitest" +import { createApiClient } from "../src/client/client.js" +import { endpoints } from "../src/client/endpoints.js" +import { AdminReasonSchema } from "../src/schemas/admin/common.js" +import { AdminSetOrgSuspendedRequestSchema } from "../src/schemas/admin/orgs.js" +import { + AdminCreditUserHoursRequestSchema, + AdminCreditUserHoursResponseSchema, + AdminUserHoursEntryDTOSchema, + AdminUserHoursQuerySchema, + AdminUserHoursResponseSchema, + AdminVoidUserHoursRequestSchema, + AdminVoidUserHoursResponseSchema, + ServiceDateSchema, +} from "../src/schemas/admin/user-hours.js" +import { + EventHoursResponseSchema, + MAX_EVENT_HOURS, + MIN_EVENT_HOURS, +} from "../src/schemas/volunteer.js" + +const USER = "123e4567-e89b-12d3-a456-426614174000" +const EVENT = "223e4567-e89b-12d3-a456-426614174000" +const ENTRY = "323e4567-e89b-12d3-a456-426614174000" +const OFFICIAL = "423e4567-e89b-12d3-a456-426614174000" +const OPERATOR = "523e4567-e89b-12d3-a456-426614174000" + +const eventCredit = { + id: USER, + kind: "event", + eventId: EVENT, + hours: 2.5, + reason: "Attended before signing up", +} as const + +const manualCredit = { + id: USER, + kind: "manual", + hours: 1.25, + serviceDate: "2026-09-20", + reason: "Staffed the supply table", +} as const + +const ledgerEntry = { + id: ENTRY, + source: "event", + hours: 2.5, + occurredAt: "2026-09-19T16:00:00.000Z", + creditedAt: "2026-09-25T18:30:00.000Z", + serviceDate: null, + event: { id: EVENT, title: "Echo Park cleanup", referenceCode: "EV-1234" }, + jurisdiction: { geoid: "0644000", name: "Los Angeles" }, + creditedBy: { id: OFFICIAL, name: "CivFix", handle: "civfix", official: true }, + operator: { id: OPERATOR, name: "Ada Operator" }, + note: "Attended before signing up", + voidedAt: null, + voidedBy: null, + voidReason: null, + voidable: true, +} + +function jsonResponse(body: unknown) { + return new Response(JSON.stringify(body), { + status: 200, + headers: { "content-type": "application/json" }, + }) +} + +describe("admin user-hours registry", () => { + it("registers the ledger read and both writes under the operator-gated users surface", () => { + expect(endpoints.getUserHours).toMatchObject({ + method: "GET", + path: "/admin/users/:id/hours", + auth: "required", + csrf: false, + version: "v1", + }) + expect(endpoints.getUserHours.request).toBe(AdminUserHoursQuerySchema) + expect(endpoints.getUserHours.response).toBe(AdminUserHoursResponseSchema) + + expect(endpoints.creditUserHours).toMatchObject({ + method: "POST", + path: "/admin/users/:id/hours", + auth: "required", + csrf: true, + version: "v1", + }) + expect(endpoints.creditUserHours.request).toBe(AdminCreditUserHoursRequestSchema) + expect(endpoints.creditUserHours.response).toBe(AdminCreditUserHoursResponseSchema) + + expect(endpoints.voidUserHours).toMatchObject({ + method: "POST", + path: "/admin/users/:id/hours/:entryId/void", + auth: "required", + csrf: true, + version: "v1", + }) + expect(endpoints.voidUserHours.request).toBe(AdminVoidUserHoursRequestSchema) + expect(endpoints.voidUserHours.response).toBe(AdminVoidUserHoursResponseSchema) + }) + + it("fills the path from either credit member and from the void's two ids", async () => { + const calls: Array<{ url: string; init: RequestInit }> = [] + const fetchImpl = vi.fn(async (url: string | URL | Request, init?: RequestInit) => { + calls.push({ url: String(url), init: init ?? {} }) + if (init?.method === "GET") { + return jsonResponse({ + items: [], + nextCursor: null, + totals: { totalHours: 0, liveEntries: 0, voidedEntries: 0 }, + }) + } + if (String(url).endsWith("/void")) return jsonResponse({ ok: true, affectedCertificates: [] }) + return jsonResponse({ entryId: ENTRY, totalHours: 2.5 }) + }) as unknown as typeof fetch + const warn = vi.spyOn(console, "warn") + const client = createApiClient({ + baseURL: "https://api.civfix.test", + fetchImpl, + getCsrfToken: () => "csrf-1", + }) + + await client.creditUserHours(eventCredit) + await client.creditUserHours(manualCredit) + await client.voidUserHours({ id: USER, entryId: ENTRY, reason: "Duplicate" }) + await client.getUserHours({ id: USER, limit: 20, cursor: "c1" }) + + expect(calls[0]!.url).toBe(`https://api.civfix.test/v1/admin/users/${USER}/hours`) + expect(JSON.parse(String(calls[0]!.init.body))).toEqual(eventCredit) + expect(calls[1]!.url).toBe(`https://api.civfix.test/v1/admin/users/${USER}/hours`) + expect(JSON.parse(String(calls[1]!.init.body))).toEqual(manualCredit) + expect(calls[2]!.url).toBe( + `https://api.civfix.test/v1/admin/users/${USER}/hours/${ENTRY}/void`, + ) + for (const call of calls.slice(0, 3)) { + expect((call.init.headers as Record)["x-csrf-token"]).toBe("csrf-1") + } + expect(calls[3]!.url).toBe( + `https://api.civfix.test/v1/admin/users/${USER}/hours?limit=20&cursor=c1`, + ) + expect(warn).not.toHaveBeenCalled() + warn.mockRestore() + }) +}) + +describe("AdminCreditUserHoursRequest", () => { + it("parses each member by its kind", () => { + expect(AdminCreditUserHoursRequestSchema.parse(eventCredit)).toEqual(eventCredit) + expect(AdminCreditUserHoursRequestSchema.parse(manualCredit)).toEqual(manualCredit) + }) + + it("rejects an unknown kind and a missing kind", () => { + expect(AdminCreditUserHoursRequestSchema.safeParse({ ...manualCredit, kind: "report" }).success).toBe(false) + const { kind: _kind, ...noKind } = manualCredit + expect(AdminCreditUserHoursRequestSchema.safeParse(noKind).success).toBe(false) + }) + + it("keeps each member strict so one kind cannot carry the other's field", () => { + expect( + AdminCreditUserHoursRequestSchema.safeParse({ ...eventCredit, serviceDate: "2026-09-20" }).success, + ).toBe(false) + expect(AdminCreditUserHoursRequestSchema.safeParse({ ...manualCredit, eventId: EVENT }).success).toBe(false) + expect(AdminCreditUserHoursRequestSchema.safeParse({ ...eventCredit, userId: USER }).success).toBe(false) + }) + + it("requires the event on an event credit and the service date on a manual one", () => { + const { eventId: _eventId, ...noEvent } = eventCredit + expect(AdminCreditUserHoursRequestSchema.safeParse(noEvent).success).toBe(false) + const { serviceDate: _serviceDate, ...noDate } = manualCredit + expect(AdminCreditUserHoursRequestSchema.safeParse(noDate).success).toBe(false) + expect(AdminCreditUserHoursRequestSchema.safeParse({ ...eventCredit, eventId: "EV-1234" }).success).toBe(false) + }) + + it("bounds hours to the event-hours range on both members", () => { + for (const base of [eventCredit, manualCredit]) { + expect(AdminCreditUserHoursRequestSchema.safeParse({ ...base, hours: MIN_EVENT_HOURS }).success).toBe(true) + expect(AdminCreditUserHoursRequestSchema.safeParse({ ...base, hours: MAX_EVENT_HOURS }).success).toBe(true) + expect(AdminCreditUserHoursRequestSchema.safeParse({ ...base, hours: 0 }).success).toBe(false) + expect(AdminCreditUserHoursRequestSchema.safeParse({ ...base, hours: 0.001 }).success).toBe(false) + expect(AdminCreditUserHoursRequestSchema.safeParse({ ...base, hours: 24.01 }).success).toBe(false) + expect(AdminCreditUserHoursRequestSchema.safeParse({ ...base, hours: -1 }).success).toBe(false) + expect(AdminCreditUserHoursRequestSchema.safeParse({ ...base, hours: "2" }).success).toBe(false) + } + }) + + it("trims the reason and requires 1-1000 characters of it", () => { + const parsed = AdminCreditUserHoursRequestSchema.parse({ ...eventCredit, reason: " late signup " }) + expect(parsed.reason).toBe("late signup") + expect(AdminCreditUserHoursRequestSchema.safeParse({ ...eventCredit, reason: " " }).success).toBe(false) + expect(AdminCreditUserHoursRequestSchema.safeParse({ ...manualCredit, reason: "" }).success).toBe(false) + expect(AdminCreditUserHoursRequestSchema.safeParse({ ...manualCredit, reason: "x".repeat(1000) }).success).toBe(true) + expect(AdminCreditUserHoursRequestSchema.safeParse({ ...manualCredit, reason: "x".repeat(1001) }).success).toBe(false) + }) +}) + +describe("ServiceDate", () => { + it("accepts a calendar date and nothing with a time or a loose format", () => { + expect(ServiceDateSchema.safeParse("2026-09-20").success).toBe(true) + expect(ServiceDateSchema.safeParse("2028-02-29").success).toBe(true) + for (const bad of ["2026-9-20", "2026-02-30", "2026-13-01", "2026-09-20T00:00:00Z", "20/09/2026", ""]) { + expect(ServiceDateSchema.safeParse(bad).success).toBe(false) + } + }) +}) + +describe("AdminVoidUserHoursRequest", () => { + it("is strict, takes uuids for both path params and requires a reason", () => { + const body = { id: USER, entryId: ENTRY, reason: " Duplicate credit " } + expect(AdminVoidUserHoursRequestSchema.parse(body).reason).toBe("Duplicate credit") + expect(AdminVoidUserHoursRequestSchema.safeParse({ ...body, entryId: "1" }).success).toBe(false) + expect(AdminVoidUserHoursRequestSchema.safeParse({ ...body, reason: " " }).success).toBe(false) + expect(AdminVoidUserHoursRequestSchema.safeParse({ ...body, hours: 1 }).success).toBe(false) + }) + + it("returns the live certificates that still list the entry", () => { + const parsed = AdminVoidUserHoursResponseSchema.parse({ + ok: true, + affectedCertificates: [{ code: "ABCD2345EFGH", issuedAt: "2026-09-01T12:00:00Z" }], + }) + expect(parsed.affectedCertificates[0]!.issuedAt).toBe("2026-09-01T12:00:00.000Z") + expect(AdminVoidUserHoursResponseSchema.safeParse({ ok: true }).success).toBe(false) + }) +}) + +describe("AdminUserHoursQuery", () => { + it("coerces the query-string limit and bounds it to 1-50", () => { + expect(AdminUserHoursQuerySchema.parse({ id: USER, limit: "20" }).limit).toBe(20) + expect(AdminUserHoursQuerySchema.safeParse({ id: USER, limit: "0" }).success).toBe(false) + expect(AdminUserHoursQuerySchema.safeParse({ id: USER, limit: "51" }).success).toBe(false) + expect(AdminUserHoursQuerySchema.safeParse({ id: USER, filter: "voided" }).success).toBe(false) + expect(AdminUserHoursQuerySchema.safeParse({ id: "not-a-uuid" }).success).toBe(false) + }) +}) + +describe("AdminUserHoursResponse", () => { + it("parses a page of live, voided and manual entries with the totals", () => { + const voided = { + ...ledgerEntry, + voidedAt: "2026-09-26T09:00:00Z", + voidedBy: { id: OPERATOR, name: "Ada Operator" }, + voidReason: "Wrong event", + voidable: false, + } + const manual = { + ...ledgerEntry, + id: EVENT, + source: "manual", + serviceDate: "2026-09-20", + occurredAt: "2026-09-20T12:00:00.000Z", + event: null, + jurisdiction: null, + } + const parsed = AdminUserHoursResponseSchema.parse({ + items: [ledgerEntry, voided, manual], + nextCursor: null, + totals: { totalHours: 3.75, liveEntries: 2, voidedEntries: 1 }, + }) + expect(parsed.items).toHaveLength(3) + expect(parsed.items[1]!.voidedAt).toBe("2026-09-26T09:00:00.000Z") + expect(parsed.totals.totalHours).toBe(3.75) + }) + + it("requires the totals and rejects an entry with an unknown field", () => { + expect(AdminUserHoursResponseSchema.safeParse({ items: [], nextCursor: null }).success).toBe(false) + expect(AdminUserHoursEntryDTOSchema.safeParse({ ...ledgerEntry, extra: 1 }).success).toBe(false) + expect(AdminUserHoursEntryDTOSchema.safeParse({ ...ledgerEntry, source: "donation" }).success).toBe(false) + }) +}) + +describe("EventHoursResponse.creditedByOfficial", () => { + it("is optional so an older server still parses", () => { + const row = { userId: USER, hours: 2, loggedAt: "2026-09-19T18:00:00Z" } + const older = EventHoursResponseSchema.parse({ scope: "all", entries: [row] }) + expect(older.entries[0]!.creditedByOfficial).toBeUndefined() + const newer = EventHoursResponseSchema.parse({ + scope: "all", + entries: [{ ...row, creditedByOfficial: true }], + }) + expect(newer.entries[0]!.creditedByOfficial).toBe(true) + }) +}) + +describe("AdminReasonSchema", () => { + it("is the one reason rule the org writes and the hours writes share", () => { + expect(AdminReasonSchema.parse(" why ")).toBe("why") + expect( + AdminSetOrgSuspendedRequestSchema.safeParse({ id: USER, suspended: true, reason: " " }).success, + ).toBe(false) + }) +}) diff --git a/packages/shared/__tests__/client.test.ts b/packages/shared/__tests__/client.test.ts index ab44187d..3995b888 100644 --- a/packages/shared/__tests__/client.test.ts +++ b/packages/shared/__tests__/client.test.ts @@ -22,7 +22,7 @@ function jsonResponse( describe("endpoint registry", () => { it("covers the full Phase 1 + admin surface with unique paths per method", () => { const names = Object.keys(endpoints) - expect(names.length).toBe(333) + expect(names.length).toBe(336) const seen = new Set() for (const name of names) { const e = endpoints[name as keyof typeof endpoints] @@ -33,11 +33,11 @@ describe("endpoint registry", () => { } }) - it("registers exactly 106 admin endpoints under /admin", () => { + it("registers exactly 109 admin endpoints under /admin", () => { const adminNames = Object.keys(endpoints).filter((n) => endpoints[n as keyof typeof endpoints].path.startsWith("/admin"), ) - expect(adminNames.length).toBe(106) + expect(adminNames.length).toBe(109) }) it("registers GET /geo/approximate as an optional, csrf-free v1 endpoint", () => { diff --git a/packages/shared/src/client/endpoints.ts b/packages/shared/src/client/endpoints.ts index bbb8979d..df8022df 100644 --- a/packages/shared/src/client/endpoints.ts +++ b/packages/shared/src/client/endpoints.ts @@ -600,6 +600,14 @@ import { AdminGetMediaRequestSchema, AdminGetMediaResponseSchema, } from "../schemas/admin/media.js" +import { + AdminCreditUserHoursRequestSchema, + AdminCreditUserHoursResponseSchema, + AdminUserHoursQuerySchema, + AdminUserHoursResponseSchema, + AdminVoidUserHoursRequestSchema, + AdminVoidUserHoursResponseSchema, +} from "../schemas/admin/user-hours.js" export type HttpMethod = "GET" | "POST" | "PUT" | "DELETE" | "PATCH" export type EndpointAuth = "public" | "optional" | "required" @@ -3695,16 +3703,48 @@ export const adminInboxEndpoints = { }), } as const +export const adminUserHoursEndpoints = { + getUserHours: def({ + method: "GET", + path: "/admin/users/:id/hours", + request: AdminUserHoursQuerySchema, + response: AdminUserHoursResponseSchema, + auth: "required", + csrf: false, + version: "v1", + }), + creditUserHours: def({ + method: "POST", + path: "/admin/users/:id/hours", + request: AdminCreditUserHoursRequestSchema, + response: AdminCreditUserHoursResponseSchema, + auth: "required", + csrf: true, + version: "v1", + }), + voidUserHours: def({ + method: "POST", + path: "/admin/users/:id/hours/:entryId/void", + request: AdminVoidUserHoursRequestSchema, + response: AdminVoidUserHoursResponseSchema, + auth: "required", + csrf: true, + version: "v1", + }), +} as const + export const endpoints: typeof coreEndpoints & typeof hostEndpoints & typeof hostAdminEndpoints & typeof adminReportChatEndpoints & - typeof adminInboxEndpoints = { + typeof adminInboxEndpoints & + typeof adminUserHoursEndpoints = { ...coreEndpoints, ...hostEndpoints, ...hostAdminEndpoints, ...adminReportChatEndpoints, ...adminInboxEndpoints, + ...adminUserHoursEndpoints, } export type Endpoints = typeof endpoints diff --git a/packages/shared/src/schemas/admin/common.ts b/packages/shared/src/schemas/admin/common.ts index f216d53d..6222aed4 100644 --- a/packages/shared/src/schemas/admin/common.ts +++ b/packages/shared/src/schemas/admin/common.ts @@ -289,6 +289,13 @@ export const AdminActorRefSchema = z .strict() export type AdminActorRef = z.infer +/** + * The mandatory operator reason an audited admin mutation writes to the audit log (DECISIONS §32). + * Trimmed before the length check so a whitespace-only reason cannot satisfy the requirement. + */ +export const AdminReasonSchema = z.string().trim().min(1).max(1000) +export type AdminReason = z.infer + /** A simple ok-acknowledgement for admin mutations that return no resource body. */ export const AdminOkResponseSchema = z.object({ ok: z.literal(true) }).strict() export type AdminOkResponse = z.infer diff --git a/packages/shared/src/schemas/admin/index.ts b/packages/shared/src/schemas/admin/index.ts index a5532f51..34fb7519 100644 --- a/packages/shared/src/schemas/admin/index.ts +++ b/packages/shared/src/schemas/admin/index.ts @@ -12,6 +12,7 @@ export * from "./pages.js" export * from "./media.js" export * from "./hosts.js" export * from "./users.js" +export * from "./user-hours.js" export * from "./gov.js" export * from "./moderation.js" export * from "./mail.js" diff --git a/packages/shared/src/schemas/admin/orgs.ts b/packages/shared/src/schemas/admin/orgs.ts index 4b549e09..7799e5c5 100644 --- a/packages/shared/src/schemas/admin/orgs.ts +++ b/packages/shared/src/schemas/admin/orgs.ts @@ -16,7 +16,12 @@ import { MAX_ORG_NAME, OrgSlugSchema, } from "../host/organizations.js" -import { AdminActorRefSchema, AdminListQuerySchema, AdminOkResponseSchema } from "./common.js" +import { + AdminActorRefSchema, + AdminListQuerySchema, + AdminOkResponseSchema, + AdminReasonSchema, +} from "./common.js" import { AdminEventListItemDTOSchema } from "./events.js" @@ -105,8 +110,6 @@ export type DecideOrgVerificationResponse = z.infer + +/** A calendar date (`YYYY-MM-DD`) with no time zone: the day a manual credit's service happened. */ +export const ServiceDateSchema = z.string().date() +export type ServiceDate = z.infer + +const AdminHoursPersonRefSchema = z + .object({ + id: IdSchema, + name: z.string(), + }) + .strict() + +export const AdminUserHoursEntryDTOSchema = z + .object({ + id: IdSchema, + source: VolunteerHoursSourceSchema, + hours: z.number().nonnegative(), + occurredAt: ISODateSchema, + creditedAt: ISODateSchema, + /** Set on manual rows only. */ + serviceDate: ServiceDateSchema.nullable(), + event: z + .object({ + id: IdSchema, + title: z.string(), + referenceCode: z.string().nullable(), + }) + .strict() + .nullable(), + /** Null for manual rows, which count toward the total but toward no jurisdiction's leaderboard. */ + jurisdiction: z + .object({ + geoid: z.string(), + name: z.string().nullable(), + }) + .strict() + .nullable(), + /** The account the user sees as the creditor; `official` marks the CivFix official account. */ + creditedBy: z + .object({ + id: IdSchema, + name: z.string(), + handle: z.string(), + official: z.boolean(), + }) + .strict() + .nullable(), + /** The human operator behind an official credit. Never projected to any user-facing surface. */ + operator: AdminHoursPersonRefSchema.nullable(), + /** The operator's credit reason. Internal: excluded from the user's reads and data export. */ + note: z.string().nullable(), + voidedAt: ISODateSchema.nullable(), + voidedBy: AdminHoursPersonRefSchema.nullable(), + voidReason: z.string().nullable(), + /** False for voided rows and for historical report credits, which only a migration may touch. */ + voidable: z.boolean(), + }) + .strict() +export type AdminUserHoursEntryDTO = z.infer + +/** `totalHours` is computed exactly as the user's profile total, so the two can never disagree. */ +export const AdminUserHoursTotalsSchema = z + .object({ + totalHours: z.number().nonnegative(), + liveEntries: z.number().int().nonnegative(), + voidedEntries: z.number().int().nonnegative(), + }) + .strict() +export type AdminUserHoursTotals = z.infer + +export const AdminUserHoursResponseSchema = pageResponse(AdminUserHoursEntryDTOSchema).extend({ + totals: AdminUserHoursTotalsSchema, +}) +export type AdminUserHoursResponse = z.infer + +// --------------------------------------------------------------------------- +// Credit +// --------------------------------------------------------------------------- + +const AdminCreditHoursSchema = z.number().min(MIN_EVENT_HOURS).max(MAX_EVENT_HOURS) + +/** + * `kind` picks the ledger source. An event credit obeys every event rule except roster membership; + * a manual credit has no event and no jurisdiction, so it carries the service date instead. The + * server refuses a `serviceDate` in the future: "today" depends on a time zone the contract lacks. + */ +export const AdminCreditUserHoursRequestSchema = z.discriminatedUnion("kind", [ + z + .object({ + id: IdSchema, + kind: z.literal("event"), + eventId: IdSchema, + hours: AdminCreditHoursSchema, + reason: AdminReasonSchema, + }) + .strict(), + z + .object({ + id: IdSchema, + kind: z.literal("manual"), + hours: AdminCreditHoursSchema, + serviceDate: ServiceDateSchema, + reason: AdminReasonSchema, + }) + .strict(), +]) +export type AdminCreditUserHoursRequest = z.infer + +export const AdminCreditUserHoursResponseSchema = z + .object({ + entryId: IdSchema, + totalHours: z.number().nonnegative(), + }) + .strict() +export type AdminCreditUserHoursResponse = z.infer + +// --------------------------------------------------------------------------- +// Void +// --------------------------------------------------------------------------- + +export const AdminVoidUserHoursRequestSchema = z + .object({ + id: IdSchema, + entryId: IdSchema, + reason: AdminReasonSchema, + }) + .strict() +export type AdminVoidUserHoursRequest = z.infer + +/** + * The live certificates whose frozen snapshot lists the voided entry. They keep verifying with the + * old total, so the operator is shown them to revoke through the CLI. + */ +export const AdminVoidUserHoursResponseSchema = z + .object({ + ok: z.literal(true), + affectedCertificates: z.array( + z + .object({ + code: z.string(), + issuedAt: ISODateSchema, + }) + .strict(), + ), + }) + .strict() +export type AdminVoidUserHoursResponse = z.infer diff --git a/packages/shared/src/schemas/volunteer.ts b/packages/shared/src/schemas/volunteer.ts index af731f46..8d835c7e 100644 --- a/packages/shared/src/schemas/volunteer.ts +++ b/packages/shared/src/schemas/volunteer.ts @@ -242,6 +242,11 @@ export const EventHoursResponseSchema = z.object({ userId: IdSchema, hours: z.number().nonnegative(), loggedAt: ISODateSchema, + /** + * 0.58.0: true when an operator credited this row as the CivFix official account (DECISIONS + * §58), so the host can label a non-member's row "Credited by CivFix". Absent means false. + */ + creditedByOfficial: z.boolean().optional(), }), ) .default([]), From 3e570e0fb0ac38ce33d9829732e1d639dfedb253 Mon Sep 17 00:00:00 2001 From: byteful Date: Sat, 26 Sep 2026 20:45:32 -0400 Subject: [PATCH 2/4] service date floor and drop banner comments --- packages/shared/DECISIONS.md | 5 ++-- .../shared/__tests__/admin-user-hours.test.ts | 9 +++++++ .../shared/src/schemas/admin/user-hours.ts | 27 +++++++++---------- 3 files changed, 25 insertions(+), 16 deletions(-) diff --git a/packages/shared/DECISIONS.md b/packages/shared/DECISIONS.md index 3ea4a583..93934f55 100644 --- a/packages/shared/DECISIONS.md +++ b/packages/shared/DECISIONS.md @@ -2062,8 +2062,9 @@ data export carries them. The user is notified of a credit and not of a void. cancelled, ran at least 15 minutes, the event's creditable window, the daily cap) except roster membership, which is the whole point. A live row for that user and event is a CONFLICT; the operator voids it first. `kind: "manual"` is work outside any event and carries a `serviceDate` (`YYYY-MM-DD`, -`ServiceDateSchema`). The server refuses a future date, because "today" depends on a time zone the -contract does not carry. A manual row has no jurisdiction: it counts toward the user's total and toward +`ServiceDateSchema`, floored at `SERVICE_DATE_MIN`, 2000-01-01, so a year Postgres cannot store is a +VALIDATION error). The server refuses a future date, because "today" depends on a time zone the +contract does not carry. The floor binds the request only: the ledger DTO parses any calendar date. A manual row has no jurisdiction: it counts toward the user's total and toward no leaderboard, and its `occurredAt` (on the user's ledger and on a transcript) is the service date. The response returns the new `entryId` and the user's recomputed `totalHours`. diff --git a/packages/shared/__tests__/admin-user-hours.test.ts b/packages/shared/__tests__/admin-user-hours.test.ts index da56defa..54287d3c 100644 --- a/packages/shared/__tests__/admin-user-hours.test.ts +++ b/packages/shared/__tests__/admin-user-hours.test.ts @@ -11,6 +11,7 @@ import { AdminUserHoursResponseSchema, AdminVoidUserHoursRequestSchema, AdminVoidUserHoursResponseSchema, + SERVICE_DATE_MIN, ServiceDateSchema, } from "../src/schemas/admin/user-hours.js" import { @@ -201,6 +202,14 @@ describe("ServiceDate", () => { expect(ServiceDateSchema.safeParse(bad).success).toBe(false) } }) + + it("refuses dates before the floor, so year 0000 never reaches Postgres", () => { + expect(ServiceDateSchema.safeParse(SERVICE_DATE_MIN).success).toBe(true) + for (const early of ["0000-01-01", "0001-01-01", "1900-06-15", "1999-12-31"]) { + expect(ServiceDateSchema.safeParse(early).success).toBe(false) + expect(AdminCreditUserHoursRequestSchema.safeParse({ ...manualCredit, serviceDate: early }).success).toBe(false) + } + }) }) describe("AdminVoidUserHoursRequest", () => { diff --git a/packages/shared/src/schemas/admin/user-hours.ts b/packages/shared/src/schemas/admin/user-hours.ts index 9104e941..87c635f3 100644 --- a/packages/shared/src/schemas/admin/user-hours.ts +++ b/packages/shared/src/schemas/admin/user-hours.ts @@ -10,10 +10,6 @@ import { AdminReasonSchema } from "./common.js" * the human operator and the reason exist only on this plane. */ -// --------------------------------------------------------------------------- -// Ledger read -// --------------------------------------------------------------------------- - export const ADMIN_USER_HOURS_PAGE_MAX = 50 /** `id` fills the :id path param; the client omits it from the serialized query. */ @@ -27,7 +23,18 @@ export const AdminUserHoursQuerySchema = z export type AdminUserHoursQuery = z.infer /** A calendar date (`YYYY-MM-DD`) with no time zone: the day a manual credit's service happened. */ -export const ServiceDateSchema = z.string().date() +const CalendarDateSchema = z.string().date() + +/** + * `z.string().date()` accepts any four-digit year, including 0000, which Postgres `date` rejects; + * without a floor such a value would reach the insert and fail as a 500 instead of a VALIDATION error. + * The floor lives on the credit request only, so a stored row keeps parsing if it is ever raised. + */ +export const SERVICE_DATE_MIN = "2000-01-01" + +export const ServiceDateSchema = CalendarDateSchema.refine((date) => date >= SERVICE_DATE_MIN, { + message: `Service date must be on or after ${SERVICE_DATE_MIN}`, +}) export type ServiceDate = z.infer const AdminHoursPersonRefSchema = z @@ -45,7 +52,7 @@ export const AdminUserHoursEntryDTOSchema = z occurredAt: ISODateSchema, creditedAt: ISODateSchema, /** Set on manual rows only. */ - serviceDate: ServiceDateSchema.nullable(), + serviceDate: CalendarDateSchema.nullable(), event: z .object({ id: IdSchema, @@ -100,10 +107,6 @@ export const AdminUserHoursResponseSchema = pageResponse(AdminUserHoursEntryDTOS }) export type AdminUserHoursResponse = z.infer -// --------------------------------------------------------------------------- -// Credit -// --------------------------------------------------------------------------- - const AdminCreditHoursSchema = z.number().min(MIN_EVENT_HOURS).max(MAX_EVENT_HOURS) /** @@ -141,10 +144,6 @@ export const AdminCreditUserHoursResponseSchema = z .strict() export type AdminCreditUserHoursResponse = z.infer -// --------------------------------------------------------------------------- -// Void -// --------------------------------------------------------------------------- - export const AdminVoidUserHoursRequestSchema = z .object({ id: IdSchema, From 47d8a38d43365a3a83d2b59fbea5ccb6905e2afa Mon Sep 17 00:00:00 2001 From: byteful Date: Sat, 26 Sep 2026 20:50:26 -0400 Subject: [PATCH 3/4] shared 0.58.0 --- .changeset/admin-user-hours.md | 5 ----- packages/shared/CHANGELOG.md | 6 ++++++ packages/shared/package.json | 2 +- 3 files changed, 7 insertions(+), 6 deletions(-) delete mode 100644 .changeset/admin-user-hours.md diff --git a/.changeset/admin-user-hours.md b/.changeset/admin-user-hours.md deleted file mode 100644 index 8d1e59bf..00000000 --- a/.changeset/admin-user-hours.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@civfix/shared": minor ---- - -Operator volunteer-hours corrections on the admin users surface: `getUserHours` (`GET /admin/users/:id/hours`, the user's whole ledger with voided rows and totals), `creditUserHours` (`POST /admin/users/:id/hours`, a strict union on `kind` — an `event` credit for a real event or a `manual` adjustment with a `serviceDate`) and `voidUserHours` (`POST /admin/users/:id/hours/:entryId/void`, which returns the live certificates that still list the entry), in a new `adminUserHoursEndpoints` registry group (333 → 336 endpoints). `AdminReasonSchema` is now exported from the admin common schemas and shared with the org writes. `EventHoursResponse.entries[].creditedByOfficial` is a new optional boolean. Additive. See DECISIONS §58. diff --git a/packages/shared/CHANGELOG.md b/packages/shared/CHANGELOG.md index 82ca5278..cfaff7e4 100644 --- a/packages/shared/CHANGELOG.md +++ b/packages/shared/CHANGELOG.md @@ -1,5 +1,11 @@ # @civfix/shared +## 0.58.0 + +### Minor Changes + +- Operator volunteer-hours corrections on the admin users surface: `getUserHours` (`GET /admin/users/:id/hours`, the user's whole ledger with voided rows and totals), `creditUserHours` (`POST /admin/users/:id/hours`, a strict union on `kind` — an `event` credit for a real event or a `manual` adjustment with a `serviceDate`) and `voidUserHours` (`POST /admin/users/:id/hours/:entryId/void`, which returns the live certificates that still list the entry), in a new `adminUserHoursEndpoints` registry group (333 → 336 endpoints). `AdminReasonSchema` is now exported from the admin common schemas and shared with the org writes. `EventHoursResponse.entries[].creditedByOfficial` is a new optional boolean. Additive. See DECISIONS §58. + ## 0.57.0 ### Minor Changes diff --git a/packages/shared/package.json b/packages/shared/package.json index 844d33f9..51d9bbf6 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -1,6 +1,6 @@ { "name": "@civfix/shared", - "version": "0.57.0", + "version": "0.58.0", "description": "civfix shared contract package: zod schemas, domain types, vendor interfaces, in-memory fakes, design tokens, and a typed API client. Single source of truth for backend, web, and mobile.", "license": "AGPL-3.0-only", "private": false, From 7b8532b3eaba3f4ab0b5d66dc1639222a0e67a55 Mon Sep 17 00:00:00 2001 From: Rudra Date: Sun, 27 Sep 2026 02:16:25 -0400 Subject: [PATCH 4/4] Credited by CivFix label on event hours (#60) --- packages/ui/src/bodies/EventHoursBlock.tsx | 9 ++++--- .../__tests__/eventLifecycleSurfaces.test.ts | 6 +++++ .../src/bodies/__tests__/hoursEntries.test.ts | 25 +++++++++++++++++++ packages/ui/src/bodies/hoursEntries.ts | 16 ++++++++++++ .../ui/src/i18n/locales/de/event-detail.json | 1 + .../ui/src/i18n/locales/en/event-detail.json | 1 + .../ui/src/i18n/locales/es/event-detail.json | 1 + .../ui/src/i18n/locales/ko/event-detail.json | 1 + 8 files changed, 57 insertions(+), 3 deletions(-) diff --git a/packages/ui/src/bodies/EventHoursBlock.tsx b/packages/ui/src/bodies/EventHoursBlock.tsx index f2cfa3a1..57a16f90 100644 --- a/packages/ui/src/bodies/EventHoursBlock.tsx +++ b/packages/ui/src/bodies/EventHoursBlock.tsx @@ -11,7 +11,7 @@ import { formatHoursDisplay } from "./formatHours" import { hoursReceiptState, type HoursReceiptState } from "./eventLifecycle" import { slotWindow } from "./eventSlotsModel" import { LogHoursEditor, type LoggedHoursEntry } from "./LogHoursEditor" -import type { HoursCleanup } from "./hoursEntries" +import { hoursRowSubject, type HoursCleanup } from "./hoursEntries" export interface EventHoursBlockProps { cleanupId: string @@ -195,12 +195,15 @@ function HoursSummaryCard({ {rosterSettled ? ( {entries.map((entry) => { - const attendee = byId.get(entry.userId) + const subject = hoursRowSubject(entry, byId) + const attendee = subject.kind === "attendee" ? subject.attendee : undefined const name = attendee ? user && attendee.id === user.id ? t("going.you") : attendee.name - : t("log_hours.row_unknown") + : subject.kind === "official" + ? t("log_hours.row_official") + : t("log_hours.row_unknown") return ( { expect(code(hoursBlock)).toContain("log_hours.row_unknown") }) + it("labels an operator-credited non-member as credited by CivFix, not as a former attendee", () => { + const source = code(hoursBlock) + expect(source).toContain("hoursRowSubject(entry, byId)") + expect(source).toContain("log_hours.row_official") + }) + it("uses the WCAG-safe coral ink token and never the fill token for text", () => { expect(code(hoursBlock)).not.toMatch(/theme\.colors\.accent\b(?!Text)/) }) diff --git a/packages/ui/src/bodies/__tests__/hoursEntries.test.ts b/packages/ui/src/bodies/__tests__/hoursEntries.test.ts index 56ef7c6f..ff0a7c60 100644 --- a/packages/ui/src/bodies/__tests__/hoursEntries.test.ts +++ b/packages/ui/src/bodies/__tests__/hoursEntries.test.ts @@ -4,6 +4,7 @@ import type { EventSlotDTO } from "@civfix/shared" import { parseHoursDraft, hoursDraftValid, + hoursRowSubject, buildHoursEntries, plannedEventHours, seedHoursDrafts, @@ -269,3 +270,27 @@ describe("suggestedHoursFor", () => { expect(hoursDraftValid(formatHours(suggestion?.hours ?? 0), MAX)).toBe(true) }) }) + +describe("hoursRowSubject", () => { + const roster = new Map([["a", { id: "a", name: "Ada" }]]) + + it("names a roster member even when an operator credited their row", () => { + expect(hoursRowSubject({ userId: "a" }, roster)).toEqual({ + kind: "attendee", + attendee: { id: "a", name: "Ada" }, + }) + expect(hoursRowSubject({ userId: "a", creditedByOfficial: true }, roster).kind).toBe("attendee") + }) + + it("attributes an operator-credited non-member to CivFix rather than calling them a former attendee", () => { + expect(hoursRowSubject({ userId: "z", creditedByOfficial: true }, roster)).toEqual({ kind: "official" }) + }) + + it("keeps the former-attendee label for a host-credited row whose user left the roster", () => { + expect(hoursRowSubject({ userId: "z", creditedByOfficial: false }, roster)).toEqual({ kind: "former" }) + }) + + it("treats an absent flag from an older server as a host credit", () => { + expect(hoursRowSubject({ userId: "z" }, roster)).toEqual({ kind: "former" }) + }) +}) diff --git a/packages/ui/src/bodies/hoursEntries.ts b/packages/ui/src/bodies/hoursEntries.ts index b4d95e2c..97838be5 100644 --- a/packages/ui/src/bodies/hoursEntries.ts +++ b/packages/ui/src/bodies/hoursEntries.ts @@ -72,3 +72,19 @@ export function seedHoursDrafts( for (const entry of entries) drafts[entry.userId] = formatHours(entry.hours) return drafts } + +export type HoursRowSubject = + | { kind: "attendee"; attendee: A } + | { kind: "official" } + | { kind: "former" } + +export function hoursRowSubject( + entry: { userId: string; creditedByOfficial?: boolean }, + rosterById: ReadonlyMap, +): HoursRowSubject { + const attendee = rosterById.get(entry.userId) + if (attendee !== undefined) return { kind: "attendee", attendee } + // An operator can credit someone who never joined the roster; calling them a "former attendee" + // would tell the host something false about who was at their event. + return entry.creditedByOfficial === true ? { kind: "official" } : { kind: "former" } +} diff --git a/packages/ui/src/i18n/locales/de/event-detail.json b/packages/ui/src/i18n/locales/de/event-detail.json index f3eb1901..b0ae084b 100644 --- a/packages/ui/src/i18n/locales/de/event-detail.json +++ b/packages/ui/src/i18n/locales/de/event-detail.json @@ -107,6 +107,7 @@ "summary_edit_a11y": "Erfasste Freiwilligenstunden bearbeiten", "row_logged": "erfasst {{ago}}", "row_unknown": "Ehemalige teilnehmende Person", + "row_official": "Von CivFix gutgeschrieben", "blank_hint": "Lass eine Zeile leer, wenn jemand nicht da war.", "blank_hint_edit": "Gib eine neue Zahl ein, um bereits gutgeschriebene Stunden zu korrigieren. Eine leere Zeile wird übersprungen: sie zu leeren entfernt bereits gutgeschriebene Stunden nicht.", "self_note": "Deine eigenen Stunden stehen hier nicht. Eine andere gastgebende Person muss sie dir gutschreiben.", diff --git a/packages/ui/src/i18n/locales/en/event-detail.json b/packages/ui/src/i18n/locales/en/event-detail.json index e0728488..087f96c7 100644 --- a/packages/ui/src/i18n/locales/en/event-detail.json +++ b/packages/ui/src/i18n/locales/en/event-detail.json @@ -107,6 +107,7 @@ "summary_edit_a11y": "Edit the volunteer hours you logged", "row_logged": "logged {{ago}}", "row_unknown": "Former attendee", + "row_official": "Credited by CivFix", "blank_hint": "Leave a row blank for anyone who did not attend.", "blank_hint_edit": "Type a new number to correct hours already credited. A blank row is skipped: clearing it does not remove hours someone already has.", "self_note": "Your own hours aren't listed here. Another host has to credit you.", diff --git a/packages/ui/src/i18n/locales/es/event-detail.json b/packages/ui/src/i18n/locales/es/event-detail.json index 8c764165..e9e88423 100644 --- a/packages/ui/src/i18n/locales/es/event-detail.json +++ b/packages/ui/src/i18n/locales/es/event-detail.json @@ -107,6 +107,7 @@ "summary_edit_a11y": "Editar las horas de voluntariado registradas", "row_logged": "registrado {{ago}}", "row_unknown": "Antiguo asistente", + "row_official": "Acreditado por CivFix", "blank_hint": "Deja una fila en blanco para quien no haya asistido.", "blank_hint_edit": "Escribe un número nuevo para corregir las horas ya acreditadas. Una fila en blanco se omite: borrarla no elimina las horas que ya se acreditaron.", "self_note": "Tus propias horas no aparecen aquí. Otra persona anfitriona tiene que acreditártelas.", diff --git a/packages/ui/src/i18n/locales/ko/event-detail.json b/packages/ui/src/i18n/locales/ko/event-detail.json index 457d490c..5473aba7 100644 --- a/packages/ui/src/i18n/locales/ko/event-detail.json +++ b/packages/ui/src/i18n/locales/ko/event-detail.json @@ -107,6 +107,7 @@ "summary_edit_a11y": "기록한 봉사 시간 수정", "row_logged": "{{ago}} 기록", "row_unknown": "이전 참석자", + "row_official": "CivFix에서 인정", "blank_hint": "참석하지 않은 사람은 비워 두세요.", "blank_hint_edit": "이미 인정된 시간을 수정하려면 새 숫자를 입력하세요. 빈 칸은 건너뜁니다. 칸을 비워도 이미 인정된 시간은 사라지지 않습니다.", "self_note": "본인의 시간은 여기에 표시되지 않습니다. 다른 주최자가 인정해 주어야 합니다.",