Skip to content
Open
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
135 changes: 135 additions & 0 deletions checkin-app/docs/designs/BADGE_PRINT_TRACKING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
# Badge Print Tracking

## Problem

Physical ID badges are printed for people who come to the facility, but nothing
records **that** a badge was printed or **when**. Ops has no way to answer "who
has / hasn't had a badge printed this year?" — so reprints, new-member badges,
and the yearly refresh are tracked by memory and spreadsheets. The
`/facility-ops/print-badges` page can generate a badge PDF, but generating a PDF
is not the same as a badge physically existing in someone's hand.

We want a durable, queryable record of badge prints and a report that surfaces
the gap (active people this year with no printed badge).

## Decisions (from the product interview)

- **Mark-printed + report, not a print/render flow.** Ops explicitly marks a
badge printed; we do **not** auto-record a print when someone generates a badge
PDF on the Print ID Badges page. Recording is a deliberate human act ("I
printed and handed out this badge"), decoupled from PDF generation. *Rejected:*
auto-recording on PDF generation — it conflates "made a file" with "a badge
exists", and double-counts every preview/reprint.
- **Not derived from badge scans.** A `RawBadgeLog` scan means a badge was *used*,
which implies one was printed *at some point*, but says nothing about **when**
it was printed or by whom, and can't represent "printed but never scanned yet"
(a brand-new member). *Rejected:* inferring print status from scan activity.
- **Multiple prints per person over time are normal** — reprints (lost/damaged
badge) and the yearly refresh each produce a new `BadgePrint` row. There is no
uniqueness constraint; the report de-duplicates to one row per person (most
recent print + a count).
- **Report by calendar year (v1).** A year picker drives two lists: who was
printed that year (with dates + who recorded it) and the gap list (active people
that year with no print). *Deferred:* membership-year boundaries — see below.
- **Board/sysadmin gated**, exactly like the sibling facility-badge surfaces
(`/facility-ops/badges`, `/facility-ops/print-badges`, `/facility-ops/trends`).

## Data model

New `BadgePrint` model (sibling of `RawBadgeLog` — both are physical-badge
lifecycle records):

| field | type | sensitivity | notes |
|---------------|------------|-------------|----------------------------------------|
| `id` | Int PK | internal | matches `RawBadgeLog.id` |
| `personId` | Int FK | public | badge subject (person linkage) |
| `printedAt` | DateTime | internal | defaults to now(); ops metadata |
| `printedById` | Int FK | internal | actor (the ops person who recorded it) |
| `note` | String? | internal | optional ("reprint — lost badge") |

Indexes: `[personId, printedAt]` (a person's print history) and `[printedAt]`
(the year-window report scan). Additive, nullable-friendly, new-table-only —
migration `20260709020000_badge_print_tracking`.

`BadgePrint` is **not** registered as an edge-sensitive model
(`ProgramParticipant`/`Volunteer`/`RSVP`/`Visit`). Those leak "who is
enrolled/present" by row existence; a badge-print record is ops metadata behind a
sysadmin/board gate, not a membership edge. The routes hand-shape a tight select
and return via `NextResponse.json`, same as `facility/badges` and
`facility/trends`.

## Flows

### Mark printed (single + bulk) — `POST /api/facility/badge-prints`

Body `{ personIds: number[], note?: string }`. Creates one `BadgePrint` per id
with `printedById = caller`, `printedAt = now()`. A single-person mark is just a
one-element array; the bulk path is the same call with many ids. No dedup — a
second call for the same person is a legitimate reprint and creates a second row.
Guards: non-empty integer array, max 500 ids/call, note trimmed to 500 chars, FK
violation → 400.

The UI surface is a new **Badge Prints** tab in Facility Ops (next to Print ID
Badges — you print, then record). The gap list has row checkboxes and a "Mark
selected printed" button, which covers both single (select one) and bulk (select
many) from one control — the same checkbox idiom the Print ID Badges page uses.

*Placement rationale:* Facility Ops, not Membership Ops. Badge printing is a
physical-facility concern and a direct sibling of the existing Raw Badge Events
and Print ID Badges tabs (same `isSysadmin|isBoardMember` gate). Membership Ops is
about the membership lifecycle (applications, households, reviews) — a badge
print is not a membership state change.

### Report — `GET /api/facility/badge-prints?year=YYYY`

Returns `{ year, printed[], gaps[] }`:

- **printed**: `BadgePrint` rows in the year window, grouped by person → one entry
per person with `lastPrintedAt`, the recorder's name, and a `count` (so a
reprinted person shows once, count 2).
- **gaps**: people in the "needs a badge this year" population who have **no**
print in the year window.

**"Needs a badge this year" population.** Defined as *people with at least one
`Visit` in the year* — i.e. anyone who physically checked in during year X. This
is a defensible, existing-data definition: if you came to the facility this year
you should have a badge, and if you never came you don't need one printed. It
reads `Visit` only as a `where` filter (`visits: { some: { arrivedAt } }`), so no
`Visit` rows are returned and the route auth drift-guard's edge rule does not
apply (a where-only edge relation returns no rows). *Considered and not chosen:*
"active members" (org-membership status lives on `Household`, and members who
never show up don't need a printed badge) and `RawBadgeLog` activity (the raw scan
feed; `Visit` is the higher-level "was here" signal derived from it).

### Year window

`calendarYearWindow(year)` returns `[Jan 1 YYYY 00:00 UTC, Jan 1 YYYY+1 00:00
UTC)` — a half-open UTC interval, used for both the print scan and the visit
population filter. Pure and unit-tested.

*v1 simplification (documented ceiling):* the boundary is UTC, while the org runs
in US Central (`APP_TIMEZONE`). A badge printed in the last few evening hours of
Dec 31 Central lands in the next UTC year. At year granularity for an internal
ops report this is acceptable. **Membership-year alternative:** the richer version
keys the window to the membership year (e.g. renewal anniversary or a
board-configured Sept–Aug program year) instead of the calendar year, and/or
computes the boundary in `APP_TIMEZONE`. Deferred until ops asks for it.

## Prod safety

- Additive migration: one new table, two FKs, two indexes. No column changes, no
backfill, no data loss — safe under expand-contract.
- All access behind `withAuth({ roles: ['isSysadmin','isBoardMember'] })`; 401/403
covered by integration tests.
- Best-effort/external calls: none.

## Deliberately deferred

- Membership-year / timezone-aware boundaries (see Year window above).
- Editing/deleting a `BadgePrint` row (mistakes are corrected by a compensating
note-bearing reprint entry, or a follow-up; the table is append-only for now).
- Registering the report route through the security handler/registry — the
role-gated `NextResponse.json` shape matches the neighboring facility routes;
no per-field tiering surface is exposed to non-admins.
- Per-person print history UI (the data model supports it via the
`[personId, printedAt]` index; no screen for it yet).
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
-- Badge print tracking: a durable record of physical ID badges printed for a
-- person. New table only (additive, no data loss). See
-- docs/designs/BADGE_PRINT_TRACKING.md.
CREATE TABLE "BadgePrint" (
"id" SERIAL NOT NULL,
"personId" INTEGER NOT NULL,
"printedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"printedById" INTEGER NOT NULL,
"note" TEXT,

CONSTRAINT "BadgePrint_pkey" PRIMARY KEY ("id")
);

-- CreateIndex
CREATE INDEX "BadgePrint_personId_printedAt_idx" ON "BadgePrint"("personId", "printedAt");

-- CreateIndex
CREATE INDEX "BadgePrint_printedAt_idx" ON "BadgePrint"("printedAt");

-- AddForeignKey
ALTER TABLE "BadgePrint" ADD CONSTRAINT "BadgePrint_personId_fkey" FOREIGN KEY ("personId") REFERENCES "Person"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

-- AddForeignKey
ALTER TABLE "BadgePrint" ADD CONSTRAINT "BadgePrint_printedById_fkey" FOREIGN KEY ("printedById") REFERENCES "Person"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
25 changes: 25 additions & 0 deletions checkin-app/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,8 @@ model Person {
feePayments FeePayment[]
rsvps RSVP[]
rawBadgeLogs RawBadgeLog[]
badgePrints BadgePrint[] @relation("BadgePrintSubject")
badgePrintsRecorded BadgePrint[] @relation("BadgePrintActor")
visits Visit[]
eventsConfirmedBy Event[] @relation("EventAttendanceConfirmer")

Expand Down Expand Up @@ -923,6 +925,29 @@ model RawBadgeLog {
@@index([personId, timestamp]) // Double-badge detection in /api/scan
}

// A physical ID badge was printed for a person and handed out. Sibling of
// RawBadgeLog (both are physical-badge lifecycle records). Ops marks these
// explicitly — see docs/designs/BADGE_PRINT_TRACKING.md. Append-only; multiple
// rows per person are normal (reprints, yearly refresh).
model BadgePrint {
/// @sensitivity:internal
id Int @id @default(autoincrement())
/// @sensitivity:public
personId Int
/// @sensitivity:internal
printedAt DateTime @default(now())
/// @sensitivity:internal
printedById Int
/// @sensitivity:internal
note String?

person Person @relation("BadgePrintSubject", fields: [personId], references: [id])
printedBy Person @relation("BadgePrintActor", fields: [printedById], references: [id])

@@index([personId, printedAt]) // A person's print history
@@index([printedAt]) // Year-window report scan
}

model Visit {
/// @sensitivity:public
id Int @id @default(autoincrement())
Expand Down
2 changes: 2 additions & 0 deletions checkin-app/src/__tests__/authzRegistry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,8 @@ const AUTHZ_TESTED = new Set<string>([
'finance-ops/membership-payment-plans',
'admin/settings/localization',
'events',
// GET + POST deny-path (401 anon / 403 plain user) in badge-prints/__tests__/route.integration.test.ts.
'facility/badge-prints',
'facility/badges',
'facility/trends',
'facility/visits',
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
/**
* @jest-environment node
*/
/**
* Integration tests for /api/facility/badge-prints (report GET + mark-printed
* POST). Real Postgres. Covers: authz 401/403, single + bulk mark, the report's
* printed/gap lists correct across a calendar-year boundary, and the
* duplicates/reprint behavior (multiple rows per person, de-duped in the report).
*/
import { GET, POST } from "@/app/api/facility/badge-prints/route";
import prisma from "@/lib/prisma";
import { getServerSession } from "next-auth/next";

jest.mock("next-auth/next", () => ({ getServerSession: jest.fn() }));

const TAG = "badge-prints-test";

describe("facility/badge-prints API", () => {
let adminId: number;
let householdId: number;
let visitor2025Id: number; // visited in 2025 only
let visitor2026Id: number; // visited in 2026, no print -> a 2026 gap
let printedId: number; // visited + printed in 2026

const as = (user: object | null) =>
(getServerSession as jest.Mock).mockResolvedValue(user === null ? null : { user });

const getReport = async (query = "") => {
const req = new Request(`http://localhost/api/facility/badge-prints${query}`, { method: "GET" });
return GET(req as never);
};
const post = async (body: unknown) => {
const req = new Request("http://localhost/api/facility/badge-prints", {
method: "POST",
body: JSON.stringify(body),
});
return POST(req as never);
};

beforeAll(async () => {
const admin = await prisma.person.create({
data: { email: `admin-${TAG}@example.com`, name: `Admin ${TAG}`, isSysadmin: true, household: { create: { name: `HH ${TAG}` } } },
});
adminId = admin.id;
householdId = admin.householdId;

const mk = async (label: string) =>
(await prisma.person.create({ data: { email: `${label}-${TAG}@example.com`, name: `${label} ${TAG}`, householdId } })).id;
visitor2025Id = await mk("v2025");
visitor2026Id = await mk("v2026");
printedId = await mk("printed");

// Visits: 2025-only person, and two 2026 visitors.
await prisma.visit.createMany({
data: [
{ personId: visitor2025Id, arrivedAt: new Date(Date.UTC(2025, 5, 1)), arrivedVia: "WEB" },
{ personId: visitor2026Id, arrivedAt: new Date(Date.UTC(2026, 5, 1)), arrivedVia: "WEB" },
{ personId: printedId, arrivedAt: new Date(Date.UTC(2026, 5, 1)), arrivedVia: "WEB" },
],
});
});

afterAll(async () => {
const ids = [adminId, visitor2025Id, visitor2026Id, printedId];
await prisma.badgePrint.deleteMany({ where: { personId: { in: ids } } });
await prisma.visit.deleteMany({ where: { personId: { in: ids } } });
await prisma.person.deleteMany({ where: { id: { in: ids } } });
await prisma.household.deleteMany({ where: { id: householdId } });
await prisma.$disconnect();
});

beforeEach(() => jest.clearAllMocks());

// ---- authz ----------------------------------------------------------------
it("GET 401 unauthenticated, 403 for a plain user", async () => {
as(null);
expect((await getReport("?year=2026")).status).toBe(401);
as({ id: visitor2026Id, isSysadmin: false, isBoardMember: false });
expect((await getReport("?year=2026")).status).toBe(403);
});
it("POST 401 unauthenticated, 403 for a plain user", async () => {
as(null);
expect((await post({ personIds: [printedId] })).status).toBe(401);
as({ id: visitor2026Id, isSysadmin: false, isBoardMember: false });
expect((await post({ personIds: [printedId] })).status).toBe(403);
});

// ---- mark printed: single + bulk ------------------------------------------
it("marks a single person printed with the caller as actor", async () => {
as({ id: adminId, isSysadmin: true });
const res = await post({ personIds: [printedId], note: " new member " });
expect(res.status).toBe(200);
expect((await res.json()).created).toBe(1);

const rows = await prisma.badgePrint.findMany({ where: { personId: printedId } });
expect(rows).toHaveLength(1);
expect(rows[0].printedById).toBe(adminId);
expect(rows[0].note).toBe("new member"); // trimmed
});

it("bulk-marks several people in one call (board member allowed)", async () => {
as({ id: adminId, isBoardMember: true });
const res = await post({ personIds: [visitor2025Id, visitor2026Id] });
expect(res.status).toBe(200);
expect((await res.json()).created).toBe(2);
expect(await prisma.badgePrint.count({ where: { personId: { in: [visitor2025Id, visitor2026Id] } } })).toBe(2);
});

it("rejects an empty / non-array personIds with 400", async () => {
as({ id: adminId, isSysadmin: true });
expect((await post({ personIds: [] })).status).toBe(400);
expect((await post({})).status).toBe(400);
});

// ---- report: year boundaries + gaps ---------------------------------------
it("reports printed + gaps correctly, respecting the calendar-year boundary", async () => {
// Reset to a known state: printed person got a 2026 print; visitor2026 has
// none; give visitor2025 a print stamped in 2025 (out of the 2026 window).
await prisma.badgePrint.deleteMany({
where: { personId: { in: [visitor2025Id, visitor2026Id, printedId] } },
});
await prisma.badgePrint.create({
data: { personId: printedId, printedById: adminId, printedAt: new Date(Date.UTC(2026, 2, 1)) },
});
await prisma.badgePrint.create({
data: { personId: visitor2025Id, printedById: adminId, printedAt: new Date(Date.UTC(2025, 11, 31, 23, 0)) },
});

as({ id: adminId, isSysadmin: true });
const data = await (await getReport("?year=2026")).json();

const printedIds = data.printed.map((p: { personId: number }) => p.personId);
const gapIds = data.gaps.map((g: { personId: number }) => g.personId);

// printedId: printed in 2026 -> printed list, not a gap.
expect(printedIds).toContain(printedId);
expect(gapIds).not.toContain(printedId);
// visitor2026: visited 2026, no 2026 print -> gap.
expect(gapIds).toContain(visitor2026Id);
expect(printedIds).not.toContain(visitor2026Id);
// visitor2025: no 2026 visit -> not in the 2026 population at all; its 2025
// print is out of the 2026 window.
expect(printedIds).not.toContain(visitor2025Id);
expect(gapIds).not.toContain(visitor2025Id);

// The 2025 print shows up under year=2025 for visitor2025.
const data2025 = await (await getReport("?year=2025")).json();
expect(data2025.printed.map((p: { personId: number }) => p.personId)).toContain(visitor2025Id);
});

// ---- duplicates / reprints ------------------------------------------------
it("allows reprints (multiple rows) and de-dupes them in the report with a count", async () => {
await prisma.badgePrint.deleteMany({ where: { personId: printedId } });
as({ id: adminId, isSysadmin: true });
await post({ personIds: [printedId], note: "first" });
await post({ personIds: [printedId], note: "reprint" });
expect(await prisma.badgePrint.count({ where: { personId: printedId } })).toBe(2);

const data = await (await getReport("?year=2026")).json();
const entry = data.printed.find((p: { personId: number }) => p.personId === printedId);
expect(entry.count).toBe(2); // two prints, one report row
});
});
Loading
Loading