Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions packages/shared/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
54 changes: 54 additions & 0 deletions packages/shared/DECISIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -2037,3 +2037,57 @@ 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`, 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`.

**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.
299 changes: 299 additions & 0 deletions packages/shared/__tests__/admin-user-hours.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,299 @@
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,
SERVICE_DATE_MIN,
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<string, string>)["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)
}
})

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", () => {
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)
})
})
6 changes: 3 additions & 3 deletions packages/shared/__tests__/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>()
for (const name of names) {
const e = endpoints[name as keyof typeof endpoints]
Expand All @@ -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", () => {
Expand Down
Loading
Loading