Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
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
7 changes: 7 additions & 0 deletions docs/erasure-behavior.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
57 changes: 52 additions & 5 deletions docs/volunteer-hours-integrity.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 <code> --reason ledger_corrected`
(`services/api/scripts/revoke-certificate.ts`, `--dry-run` first). The console never revokes one.

## Configuration

Expand Down
14 changes: 7 additions & 7 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion services/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
4 changes: 4 additions & 0 deletions services/api/src/i18n/messages/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,10 @@ export const de: Partial<Record<MessageKey, string>> = {
"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",
Expand Down
6 changes: 6 additions & 0 deletions services/api/src/i18n/messages/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 4 additions & 0 deletions services/api/src/i18n/messages/es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,10 @@ export const es: Partial<Record<MessageKey, string>> = {
"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ó",
Expand Down
3 changes: 3 additions & 0 deletions services/api/src/i18n/messages/ko.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,9 @@ export const ko: Partial<Record<MessageKey, string>> = {
"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": "교대 시간이 변경되었어요",
Expand Down
2 changes: 2 additions & 0 deletions services/api/src/routes/admin/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)
Expand Down
107 changes: 107 additions & 0 deletions services/api/src/routes/admin/user-hours.routes.ts
Original file line number Diff line number Diff line change
@@ -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<AdminUserHoursServiceDeps, "logger">

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<void> {
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)
},
)
}
3 changes: 3 additions & 0 deletions services/api/src/services/admin/activity-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
}
Expand Down
Loading
Loading