diff --git a/AGENTS.md b/AGENTS.md index 96c968891..38deb94b3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -117,6 +117,7 @@ Read these before changing the relevant area — start here, then follow links. - `DEPLOY_MIGRATION_ORDER_OF_OPERATIONS.md` — infra rules + order of operations for schema migrations vs. deploys (read before writing a migration); `.claude/skills/migration-safety/` fires this as a checklist whenever a migration is being built. - `MIGRATION_COALESCE_FLOW.md` — the pre-release migration-coalesce policy + script (`scripts/coalesce-migrations.ts`), the release gate (at most 1 new migration per release), and the dev/prod ledger-reconcile procedure. - `PROGRAM_CAPACITY_AND_SCHOLARSHIPS.md` — single-pool Shopify capacity model + the scholarship hold-ledger state machine (read before touching `Program`/`ProgramParticipant` capacity or payment-plan logic). +- `designs/PROGRAM_ARCHIVE.md` — soft-archive for programs (`Program.archivedAt`): hides from default lists/pickers/dashboards, blocks new activity, crons skip archived (read before touching program list/cron/enroll filters). **Subprojects** (each has its own `README.md`) - `client/` — the Raspberry-Pi kiosk client (Python). diff --git a/checkin-app/docs/designs/PROGRAM_ARCHIVE.md b/checkin-app/docs/designs/PROGRAM_ARCHIVE.md new file mode 100644 index 000000000..a61cd6501 --- /dev/null +++ b/checkin-app/docs/designs/PROGRAM_ARCHIVE.md @@ -0,0 +1,147 @@ +# Archiving / Un-archiving Programs + +**Status:** Shipped on branch `feat/program-archive` (this PR). +**Product decisions:** 2026-07-08 (soft-archive semantics, board/sysadmin-gated). + +## 1. Problem + +The program-ops list, the public catalog, dashboards, and every program picker +show *every* `Program` row forever. Mistaken, cancelled, duplicated, or long-dead +programs accumulate and clutter those surfaces, and there is no way for the board +to retire one without deleting it (which would destroy the participant history the +ops surfaces still need to read). We want a reversible "put this away" action that +hides a program from the active surfaces and freezes new activity on it, without +touching its data. + +## 2. Decision — a soft-archive flag, orthogonal to phase + +`Program.archivedAt DateTime?` (`@sensitivity:internal`). NULL = live (every +existing row, unchanged); non-null = archived. + +### Why a new field and not a `ProgramPhase` value + +`phase` (`PLANNING → UPCOMING → RUNNING → FINISHED`) is a **lifecycle fact**: it +records where a program is in its natural run. `FINISHED` means "this program ran +and ended." Archiving is a **visibility/activity decision** the board makes — "stop +surfacing this and stop accepting new activity on it" — and it is **orthogonal to +phase**: a program in *any* phase can be archived (a mistaken `PLANNING` draft, an +`UPCOMING` program that got cancelled before it started, a `RUNNING` program pulled +early, a `FINISHED` program being tidied away). Folding archive into phase would +conflate "did it run?" with "should we show it?" and force lossy transitions (a +cancelled upcoming program is not `FINISHED`). A separate nullable timestamp keeps +both facts independently true and makes un-archiving a trivial `NULL` set that +restores the program to exactly its prior phase. + +### Who / where + +**Board and sysadmin only.** Archive and un-archive are surfaced on the +**program-ops program page** (`/program-ops/programs/[id]`) as an Archive / +Un-archive button, and ride the existing `PATCH /api/programs/[id]` via an +`archived` boolean gated to `isSysadmin || isBoardMember` (the same gate the +Shopify-identifier fields already use on that route — no new endpoint, no new +route-auth surface). Lead mentors cannot archive. + +## 3. What archiving does + +### 3a. Hidden from default lists / pickers / dashboards + +A shared where-fragment `NOT_ARCHIVED = { archivedAt: null }` +(`src/lib/program/archive.ts`) is applied to the program-reading surfaces: + +| Surface | Route / query | Behavior | +|---|---|---| +| Public catalog | `GET /api/programs` | archived hidden by default | +| Program-ops list | `GET /api/programs` (same route) | hidden by default; **"Include archived" toggle** re-includes them (`?includeArchived=true`, board/sysadmin-gated) | +| Session picker | `program-ops/sessions/new` → `GET /api/programs` | hidden (via the route) | +| Facility trends picker | `facility-ops/trends` → `GET /api/programs` | hidden (via the route) | +| Dashboard "active programs" count | `GET /api/nav/todo-counts` (`phase: RUNNING`) | archived excluded | +| Lead-surface todos | `GET /api/nav/todo-counts` (`leadMentorId`) | archived programs raise no lead todos | + +`includeArchived=true` is only honored for board/sysadmin — a plain caller cannot +pass it to unhide archived rows, and `archivedAt` is tier `internal` so the +stripped `GET /api/programs/[id]` surface only returns it to board/sysadmin anyway. + +**Deliberately NOT hidden:** a member's own enrollment view (`GET /api/programs/mine`) +— that is enrollment *history*, not a catalog/dashboard/picker; a family should +still see a program they are enrolled in even after the board archives it. + +### 3b. New activity blocked (4xx, before any Shopify side effect) + +Archiving freezes the seat-consuming / seat-mutating actions. The shared guard +`programArchivedError(program)` (`src/lib/program/archive.ts`) returns a +**409 Conflict** (state conflict, not an over-ridable soft limit), and it fires +**before** any Shopify inventory hold/decrement, so a blocked action can never leak +inventory (respecting the single-pool capacity model, `PROGRAM_CAPACITY_AND_SCHOLARSHIPS.md`): + +| Action | Route | Guard placement | +|---|---|---| +| Enrollment | `POST /api/programs/[id]/participants` | right after the program lookup, **before** the capacity-lock transaction. Blocks even a board "force-enroll" override — archived is a hard freeze, unlike closed/age/capacity which override may bypass; unarchive first. | +| Payment-plan / scholarship request | `POST /api/programs/[id]/request-payment-plan` | **before** the `-1` hold decrement (`adjustProgramInventory`), so no phantom hold on an archived program. | +| Volunteer signup | `POST /api/programs/[id]/volunteers` (POST) | after the program lookup, before the assignment insert. | +| Capacity edit pushing to Shopify | `PATCH /api/programs/[id]` | the `maxParticipants` → `adjustProgramInventory` propagation is skipped when the (resulting) program is archived; the DB value still saves, with a warning telling ops to un-archive to sync. | + +**Not blocked (deliberate):** reading rosters/history; participant *removal* +(withdrawal is an exit, not new activity — and a board may still need to tidy a +roster); metadata edits and phase/enrollment changes via PATCH (only the +Shopify-capacity push is frozen); event/session creation (not in the decided +blocked set — and the archived program is absent from the session-creation picker +anyway). Lead-mentor read access to their archived program's roster is preserved: +`buildCallerContext` (`src/security/access-resolvers.ts`) is **left untouched**, so +archiving is a visibility/activity decision, not an ACL change — history stays +readable exactly as before. + +### 3c. Crons skip archived programs + +The cron sweeps that act *on* programs add `program: NOT_ARCHIVED` to their query. +Full touched / not-touched table: + +| Cron | Touches programs? | Change | +|---|---|---| +| `cron/pending-participants` (non-payment kick) | yes — kicks `PENDING` non-payers | `program: NOT_ARCHIVED` — an archived program's pending rows are not swept | +| `cron/scholarship-grace-expiry` (release path c) | yes — auto-withdraws denied holds | `program: NOT_ARCHIVED` — no auto-withdraw / +1 on archived programs | +| `cron/post-event` + `cron/nightly` (post-event leg) | yes — post-event emails per `event.program` | `program: NOT_ARCHIVED` added in `lib/postEventEmails.ts` — no post-event nag for archived programs | +| `cron/nightly` (facility-close leg) | no — operates on abandoned `Visit`s | untouched | +| `cron/membership-renewals` | no | untouched | +| `cron/person-bg-annual` | no | untouched | +| `cron/trusted-adult-expiry` | no | untouched | + +## 4. Data model + +``` +Program.archivedAt DateTime? // NULL = live; timestamp = archived-at instant +``` + +- Migration `20260708020000_program_archive`: a single additive, nullable column. + No backfill, no data touched, no index — NULL is exactly "not archived", so every + existing program keeps its current behavior. Expand-only (no contract step needed; + nothing is being removed). +- Un-archive sets the column back to `NULL`; the archive timestamp is **not** + re-stamped when PATCH is saved on an already-archived program (only the + `NULL → now` and `→ NULL` transitions write), so the original archive instant is + preserved across unrelated edits. +- The change is audited by the existing PATCH `EDIT` audit-log entry (old/new + `archivedAt` captured) — no bespoke audit action. + +## 5. Prod safety + +- Additive nullable migration; no lock-heavy backfill, safe under the repo's + expand-contract convention (`DEPLOY_MIGRATION_ORDER_OF_OPERATIONS.md`). +- No Shopify calls are made on archive/un-archive in this PR (see §6). Archiving a + program does not touch its live listing or inventory here. +- The block guard fires before any inventory mutation, so an archived program can + never be pushed into an over-/under-sell during the freeze. + +## 6. Deliberately deferred + +- **Shopify-side archiving.** Archiving a program *should* eventually also archive + its Shopify listing (and/or zero its inventory so the store stops selling seats + for a retired program). That is handled by a **sibling PR**; this PR intentionally + makes **no** Shopify-side change on archive/un-archive. Integration point: the + `archived` transition in `PATCH /api/programs/[id]` is where a future Shopify + archive/inventory-zero call would hang. +- **Bulk archive** (archive many programs at once) — not requested; single-program + action only. +- **Auto-archive** (e.g. archive N months after `FINISHED`) — not requested; the + board archives explicitly. + + diff --git a/checkin-app/prisma/migrations/20260708020000_program_archive/migration.sql b/checkin-app/prisma/migrations/20260708020000_program_archive/migration.sql new file mode 100644 index 000000000..a6924e866 --- /dev/null +++ b/checkin-app/prisma/migrations/20260708020000_program_archive/migration.sql @@ -0,0 +1,6 @@ +-- Additive, nullable: soft-archive for programs (board/sysadmin decision to +-- retire a program from active surfaces and freeze new activity on it). NULL = +-- not archived, so every existing row keeps its current behavior untouched. No +-- backfill, no index; orthogonal to Program.phase. See +-- docs/designs/PROGRAM_ARCHIVE.md. +ALTER TABLE "Program" ADD COLUMN "archivedAt" TIMESTAMP(3); diff --git a/checkin-app/prisma/schema.prisma b/checkin-app/prisma/schema.prisma index dbe33b0bf..05b492a54 100644 --- a/checkin-app/prisma/schema.prisma +++ b/checkin-app/prisma/schema.prisma @@ -762,6 +762,17 @@ model Program { /// @sensitivity:public shopifyVariantId String? + /// Soft-archive timestamp. NULL = live; non-null = archived. When archived the + /// program is hidden from default lists/pickers/dashboards and frozen for NEW + /// activity (enrollment, payment-plan/scholarship requests, volunteer signup, + /// capacity→Shopify pushes); crons skip it. Orthogonal to `phase` — a program in + /// ANY phase can be archived: `phase`/FINISHED is a lifecycle fact (did it run?), + /// archived is a board visibility/activity decision (should we still surface it?). + /// Existing participant history stays readable through ops surfaces; board/sysadmin + /// set/clear it on the program-ops program page. See docs/designs/PROGRAM_ARCHIVE.md. + /// @sensitivity:internal + archivedAt DateTime? + volunteers ProgramVolunteer[] participants ProgramParticipant[] fees Fee[] diff --git a/checkin-app/src/app/__tests__/programArchive.integration.test.ts b/checkin-app/src/app/__tests__/programArchive.integration.test.ts new file mode 100644 index 000000000..994982c0a --- /dev/null +++ b/checkin-app/src/app/__tests__/programArchive.integration.test.ts @@ -0,0 +1,207 @@ +/** + * @jest-environment node + */ +/** + * Integration tests for program soft-archive (docs/designs/PROGRAM_ARCHIVE.md): + * archive → hidden from default lists → blocks new activity (enroll / payment-plan / + * volunteer, 4xx with NO Shopify inventory side effect) → un-archive restores. + * Plus one cron-skip case: the pending-participants sweep skips archived programs. + */ + +import { GET as ListGET } from '@/app/api/programs/route'; +import { PATCH as ProgramPATCH } from '@/app/api/programs/[id]/route'; +import { POST as EnrollPOST } from '@/app/api/programs/[id]/participants/route'; +import { POST as PlanPOST } from '@/app/api/programs/[id]/request-payment-plan/route'; +import { POST as VolPOST } from '@/app/api/programs/[id]/volunteers/route'; +import { GET as PendingCron } from '@/app/api/cron/pending-participants/route'; +import prisma from '@/lib/prisma'; +import { getServerSession } from 'next-auth/next'; + +jest.mock('next-auth/next', () => ({ getServerSession: jest.fn() })); +jest.mock('@/lib/notifications', () => ({ + sendNotification: jest.fn(), + notifyNewProgramAnnounced: jest.fn(), +})); + +const asSession = (user: Record | null) => + (getServerSession as jest.Mock).mockResolvedValue(user ? { user } : null); + +const params = (id: number) => ({ params: Promise.resolve({ id: id.toString() }) }) as unknown as never; +const nreq = (path: string, method: string, body?: unknown) => + new Request(`http://localhost:4000${path}`, { + method, + headers: { 'Content-Type': 'application/json' }, + ...(body !== undefined ? { body: JSON.stringify(body) } : {}), + }) as unknown as import('next/server').NextRequest; + +describe('Program archive integration', () => { + let boardId: number; + let commonId: number; + let programId: number; + + beforeAll(async () => { + const leaked = await prisma.person.findMany({ where: { email: { contains: 'prog-archive-test' } }, select: { id: true } }); + const leakedIds = leaked.map((u) => u.id); + await prisma.programParticipant.deleteMany({ where: { personId: { in: leakedIds } } }); + await prisma.auditLog.deleteMany({ where: { actorId: { in: leakedIds } } }); + await prisma.person.deleteMany({ where: { id: { in: leakedIds } } }); + await prisma.program.deleteMany({ where: { name: { contains: 'Prog Archive Test' } } }); + + const board = await prisma.person.create({ + data: { email: 'board-prog-archive-test@example.com', name: 'Board', household: { create: { name: 'HH' } } }, + }); + boardId = board.id; + const common = await prisma.person.create({ + data: { + email: 'common-prog-archive-test@example.com', + name: 'Common', + dateOfBirth: new Date(Date.now() - 25 * 31556952000), + household: { create: { name: 'HH' } }, + }, + }); + commonId = common.id; + + const program = await prisma.program.create({ + data: { + name: 'Prog Archive Test Main', + phase: 'RUNNING', + enrollmentStatus: 'OPEN', + nonOrgMemberPriceCents: 1500, + shopifyVariantId: 'dev-mock-variant-prog-archive', + }, + }); + programId = program.id; + }); + + afterAll(async () => { + const ids = [boardId, commonId].filter((x) => x !== undefined); + await prisma.programParticipant.deleteMany({ where: { personId: { in: ids } } }); + await prisma.auditLog.deleteMany({ where: { actorId: { in: ids } } }); + await prisma.program.deleteMany({ where: { name: { contains: 'Prog Archive Test' } } }); + await prisma.person.deleteMany({ where: { id: { in: ids } } }); + }); + + it('board archive via PATCH stamps archivedAt', async () => { + asSession({ id: boardId, isBoardMember: true }); + const res = await ProgramPATCH(nreq(`/api/programs/${programId}`, 'PATCH', { archived: true }), params(programId)); + expect(res.status).toBe(200); + const row = await prisma.program.findUnique({ where: { id: programId } }); + expect(row?.archivedAt).not.toBeNull(); + }); + + it('hides the archived program from the default list, but a board can opt in', async () => { + asSession({ id: commonId }); // plain member + const hiddenRes = await ListGET(nreq('/api/programs', 'GET')); + const hidden = (await hiddenRes.json()) as { id: number }[]; + expect(hidden.some((p) => p.id === programId)).toBe(false); + + asSession({ id: boardId, isBoardMember: true }); + const shownRes = await ListGET(nreq('/api/programs?includeArchived=true', 'GET')); + const shown = (await shownRes.json()) as { id: number }[]; + expect(shown.some((p) => p.id === programId)).toBe(true); + + // includeArchived is board-gated: a plain member cannot use it to unhide. + asSession({ id: commonId }); + const gatedRes = await ListGET(nreq('/api/programs?includeArchived=true', 'GET')); + const gated = (await gatedRes.json()) as { id: number }[]; + expect(gated.some((p) => p.id === programId)).toBe(false); + }); + + it('blocks self-enrollment on an archived program (409, no participant row)', async () => { + asSession({ id: commonId }); + const res = await EnrollPOST(nreq(`/api/programs/${programId}/participants`, 'POST', { participantId: commonId }), params(programId)); + expect(res.status).toBe(409); + expect((await res.json()).error).toMatch(/archived/i); + const row = await prisma.programParticipant.findUnique({ where: { programId_personId: { programId, personId: commonId } } }); + expect(row).toBeNull(); + }); + + it('blocks even a board force-enroll override on an archived program (hard freeze)', async () => { + asSession({ id: boardId, isBoardMember: true }); + const res = await EnrollPOST(nreq(`/api/programs/${programId}/participants`, 'POST', { participantId: commonId, override: true }), params(programId)); + expect(res.status).toBe(409); + const row = await prisma.programParticipant.findUnique({ where: { programId_personId: { programId, personId: commonId } } }); + expect(row).toBeNull(); + }); + + it('blocks a payment-plan request with NO Shopify inventory side effect (-1 never fires)', async () => { + // Pre-existing PENDING enrollment (from before the archive), no hold yet. + await prisma.programParticipant.create({ + data: { programId, personId: commonId, status: 'PENDING', pendingSince: new Date() }, + }); + const prevEnv = process.env.CHECKIN_ENV; + process.env.CHECKIN_ENV = 'local'; // arms the Shopify dev-mock "Would adjust inventory" log + const logSpy = jest.spyOn(console, 'log').mockImplementation(() => {}); + try { + asSession({ id: boardId, isBoardMember: true }); + // CHECKIN_ENV=local hijacks cookie-less requests as kiosk -> send a cookie. + const req = new Request(`http://localhost:4000/api/programs/${programId}/request-payment-plan`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', cookie: 'session=test' }, + body: JSON.stringify({ participantId: commonId }), + }) as unknown as import('next/server').NextRequest; + const res = await PlanPOST(req, params(programId)); + expect(res.status).toBe(409); + expect(logSpy).not.toHaveBeenCalledWith(expect.stringContaining('Would adjust inventory')); + const row = await prisma.programParticipant.findUnique({ where: { programId_personId: { programId, personId: commonId } } }); + expect(row?.inventoryHeldAt).toBeNull(); // no phantom hold + } finally { + logSpy.mockRestore(); + process.env.CHECKIN_ENV = prevEnv; + await prisma.programParticipant.deleteMany({ where: { programId, personId: commonId } }); + } + }); + + it('blocks volunteer signup on an archived program (409)', async () => { + asSession({ id: boardId, isBoardMember: true }); + const res = await VolPOST(nreq(`/api/programs/${programId}/volunteers`, 'POST', { participantId: commonId }), params(programId)); + expect(res.status).toBe(409); + const row = await prisma.programVolunteer.findUnique({ where: { programId_personId: { programId, personId: commonId } } }); + expect(row).toBeNull(); + }); + + it('un-archive restores the list visibility and enrollment', async () => { + asSession({ id: boardId, isBoardMember: true }); + const unarch = await ProgramPATCH(nreq(`/api/programs/${programId}`, 'PATCH', { archived: false }), params(programId)); + expect(unarch.status).toBe(200); + expect((await prisma.program.findUnique({ where: { id: programId } }))?.archivedAt).toBeNull(); + + // Back in the default list. + asSession({ id: commonId }); + const listRes = await ListGET(nreq('/api/programs', 'GET')); + const list = (await listRes.json()) as { id: number }[]; + expect(list.some((p) => p.id === programId)).toBe(true); + + // Enrollment works again. + const enroll = await EnrollPOST(nreq(`/api/programs/${programId}/participants`, 'POST', { participantId: commonId }), params(programId)); + expect(enroll.status).toBe(200); + expect((await enroll.json()).enrollment.status).toBe('PENDING'); + }); + + it('pending-participants cron skips archived programs but sweeps live ones', async () => { + const DAY_MS = 24 * 60 * 60 * 1000; + const stale = new Date(Date.now() - 8 * DAY_MS); + const archived = await prisma.program.create({ + data: { name: 'Prog Archive Test Cron Archived', phase: 'UPCOMING', enrollmentStatus: 'OPEN', archivedAt: new Date() }, + }); + const live = await prisma.program.create({ + data: { name: 'Prog Archive Test Cron Live', phase: 'UPCOMING', enrollmentStatus: 'OPEN' }, + }); + await prisma.programParticipant.create({ data: { programId: archived.id, personId: commonId, status: 'PENDING', pendingSince: stale } }); + await prisma.programParticipant.create({ data: { programId: live.id, personId: boardId, status: 'PENDING', pendingSince: stale } }); + + process.env.CRON_SECRET = 'test-secret'; + const res = await PendingCron(new Request('http://localhost:4000/api/cron/pending-participants', { + method: 'GET', + headers: { authorization: 'Bearer test-secret' }, + }) as unknown as Request); + expect(res.status).toBe(200); + + // Archived program's stale row survives; the live program's does not. + expect(await prisma.programParticipant.findUnique({ where: { programId_personId: { programId: archived.id, personId: commonId } } })).not.toBeNull(); + expect(await prisma.programParticipant.findUnique({ where: { programId_personId: { programId: live.id, personId: boardId } } })).toBeNull(); + + await prisma.programParticipant.deleteMany({ where: { programId: { in: [archived.id, live.id] } } }); + await prisma.program.deleteMany({ where: { id: { in: [archived.id, live.id] } } }); + }); +}); diff --git a/checkin-app/src/app/api/cron/pending-participants/route.ts b/checkin-app/src/app/api/cron/pending-participants/route.ts index fef75d4be..474d3206b 100644 --- a/checkin-app/src/app/api/cron/pending-participants/route.ts +++ b/checkin-app/src/app/api/cron/pending-participants/route.ts @@ -3,6 +3,7 @@ import { logger } from "@/lib/logger"; import { withCron } from "@/lib/cronAuth"; import prisma from "@/lib/prisma"; import { withdrawAndReleaseHold } from "@/lib/program/capacity"; +import { NOT_ARCHIVED } from "@/lib/program/archive"; export const GET = withCron(async () => { const now = new Date(); @@ -15,7 +16,10 @@ export const GET = withCron(async () => { // not this 7-day clock — a denial never resets pendingSince, so // without this exclusion they'd be kicked the night after denial. paymentPlanDeniedAt: null, - pendingSince: { not: null } + pendingSince: { not: null }, + // Archived programs are frozen — the non-payment kick skips them + // (PROGRAM_ARCHIVE.md cron table). + program: NOT_ARCHIVED, }, include: { person: true, diff --git a/checkin-app/src/app/api/cron/scholarship-grace-expiry/route.ts b/checkin-app/src/app/api/cron/scholarship-grace-expiry/route.ts index 8055fd94b..0ef2eace9 100644 --- a/checkin-app/src/app/api/cron/scholarship-grace-expiry/route.ts +++ b/checkin-app/src/app/api/cron/scholarship-grace-expiry/route.ts @@ -3,6 +3,7 @@ import { logger } from "@/lib/logger"; import { withCron } from "@/lib/cronAuth"; import prisma from "@/lib/prisma"; import { withdrawAndReleaseHold } from "@/lib/program/capacity"; +import { NOT_ARCHIVED } from "@/lib/program/archive"; const SYSTEM_ACTOR = 0; const DAY_MS = 24 * 60 * 60 * 1000; @@ -36,6 +37,9 @@ export const GET = withCron(async () => { isPaymentPlanRequested: false, inventoryHeldAt: { not: null }, paymentPlanDeniedAt: { not: null, lte: cutoff }, + // Archived programs are frozen — the grace-expiry sweep skips them + // (PROGRAM_ARCHIVE.md cron table). + program: NOT_ARCHIVED, }, include: { program: true, person: true }, }); diff --git a/checkin-app/src/app/api/nav/todo-counts/route.ts b/checkin-app/src/app/api/nav/todo-counts/route.ts index f9cde6d01..18f98ca99 100644 --- a/checkin-app/src/app/api/nav/todo-counts/route.ts +++ b/checkin-app/src/app/api/nav/todo-counts/route.ts @@ -8,6 +8,7 @@ import { getLeadConflicts } from "@/lib/attendanceConflicts"; import { pickAddress, validateAddress } from "@/lib/address"; import { openConfigIssues } from "@/lib/configHealth"; import { PROGRAM_CHECKOUT_BROKEN_WHERE } from "@/lib/programCheckout"; +import { NOT_ARCHIVED } from "@/lib/program/archive"; import { apiError } from "@/lib/api-response"; import { BROKEN_HOUSEHOLD_WHERE, UNCLAIMED_OR_BROKEN_HOUSEHOLD_WHERE } from "@/lib/household/filters"; @@ -245,7 +246,7 @@ export const GET = withAuth({}, async (_req, auth) => { user.householdId ? prisma.visit.count({ where: { departedAt: null, person: { householdId: user.householdId } } }) : Promise.resolve(0), - prisma.program.count({ where: { phase: "RUNNING" } }), + prisma.program.count({ where: { phase: "RUNNING", ...NOT_ARCHIVED } }), ]); const result: TodoCounts = { @@ -261,7 +262,8 @@ export const GET = withAuth({}, async (_req, auth) => { // leads. Surfaced in-app additively; the email keeps firing. Each item deep- // links to the existing confirm screen — no new capability, no new PII. const ledPrograms = await prisma.program.findMany({ - where: { leadMentorId: user.id }, + // Archived programs raise no lead-surface todos (PROGRAM_ARCHIVE.md). + where: { leadMentorId: user.id, ...NOT_ARCHIVED }, select: { id: true, name: true }, }); if (ledPrograms.length > 0) { diff --git a/checkin-app/src/app/api/programs/[id]/participants/route.ts b/checkin-app/src/app/api/programs/[id]/participants/route.ts index 0e869b9f0..3e072acd8 100644 --- a/checkin-app/src/app/api/programs/[id]/participants/route.ts +++ b/checkin-app/src/app/api/programs/[id]/participants/route.ts @@ -4,6 +4,7 @@ import { withAuth } from "@/lib/auth"; import prisma from "@/lib/prisma"; import { sendNotification } from "@/lib/notifications"; import { lockProgramAndCheckCapacity, ProgramCapacityError, withdrawAndReleaseHold } from "@/lib/program/capacity"; +import { programArchivedError } from "@/lib/program/archive"; import { checkProgramAge } from "@/lib/programAge"; import { apiError } from "@/lib/api-response"; @@ -33,6 +34,13 @@ export const POST = withAuth({}, async (req, auth, { params }: { params: Promise return apiError("Program not found", 404); } + // Archived programs are frozen for new activity. Guard BEFORE any + // capacity lock / hold — even a board force-enroll override is blocked + // (archived is a hard freeze, not an over-ridable soft limit; unarchive + // first). See docs/designs/PROGRAM_ARCHIVE.md. + const archivedErr = programArchivedError(currentProgram); + if (archivedErr) return archivedErr; + const currentUserId = auth.user.id; const isSelfEnrollment = currentUserId === participantId; const isSysAdminOrBoard = auth.user.isSysadmin || auth.user.isBoardMember; diff --git a/checkin-app/src/app/api/programs/[id]/request-payment-plan/route.ts b/checkin-app/src/app/api/programs/[id]/request-payment-plan/route.ts index dac4e7b55..caa5c7072 100644 --- a/checkin-app/src/app/api/programs/[id]/request-payment-plan/route.ts +++ b/checkin-app/src/app/api/programs/[id]/request-payment-plan/route.ts @@ -4,6 +4,7 @@ import { withAuth } from "@/lib/auth"; import prisma from "@/lib/prisma"; import { apiError } from "@/lib/api-response"; import { adjustProgramInventory } from "@/lib/shopify"; +import { programArchivedError } from "@/lib/program/archive"; export const POST = withAuth({}, async (req, auth, { params }: { params: Promise<{ id: string }> }) => { if (auth.type !== 'session') return apiError("Unauthorized", 401); @@ -68,6 +69,13 @@ export const POST = withAuth({}, async (req, auth, { params }: { params: Promise return apiError("This enrollment is not awaiting payment", 409); } + // Archived programs are frozen for new activity. Guard BEFORE the hold + // decrement (-1) below so an archived program can't leak a Shopify seat. + if (participant.program) { + const archivedErr = programArchivedError(participant.program); + if (archivedErr) return archivedErr; + } + // Hold-ledger (product decision 2026-07-06): APPLICATION takes a seat out // of Shopify's pool (-1), stamping inventoryHeldAt so every later release // path (withdrawal / payment / grace-expiry) or approval knows there's a diff --git a/checkin-app/src/app/api/programs/[id]/route.ts b/checkin-app/src/app/api/programs/[id]/route.ts index 2aa607a4a..9d5d29cc4 100644 --- a/checkin-app/src/app/api/programs/[id]/route.ts +++ b/checkin-app/src/app/api/programs/[id]/route.ts @@ -120,7 +120,17 @@ export const PATCH = withAuth({}, async (req, auth, ctx: { params: Promise<{ id: const body = await req.json(); let { leadMentorId } = body; - const { name, startAt, endAt, orgMemberOnly, phase, enrollmentStatus, minAge, maxAge, maxParticipants, leadMentorNotificationSettings, memberPrice, nonMemberPrice, shopifyProductId, shopifyVariantId, shopifyOrgMemberVariantId, shopifyNonOrgMemberVariantId } = body; + const { name, startAt, endAt, orgMemberOnly, phase, enrollmentStatus, minAge, maxAge, maxParticipants, leadMentorNotificationSettings, memberPrice, nonMemberPrice, shopifyProductId, shopifyVariantId, shopifyOrgMemberVariantId, shopifyNonOrgMemberVariantId, archived } = body; + + // Archive / un-archive is board/sysadmin-only (same gate as the Shopify + // identifiers). Only the NULL<->now transitions write, so re-saving an + // already-archived program never re-stamps the original archive instant. + // See docs/designs/PROGRAM_ARCHIVE.md. + let archivedAtUpdate: Date | null | undefined; + if (isSysAdminOrBoard && archived !== undefined) { + if (archived && !currentProgram.archivedAt) archivedAtUpdate = new Date(); + else if (!archived) archivedAtUpdate = null; + } if (body.hasOwnProperty('leadMentorId')) { if (!leadMentorId) { @@ -159,6 +169,7 @@ export const PATCH = withAuth({}, async (req, auth, ctx: { params: Promise<{ id: ...(isSysAdminOrBoard && shopifyVariantId !== undefined && { shopifyVariantId: shopifyVariantId || null }), ...(isSysAdminOrBoard && shopifyOrgMemberVariantId !== undefined && { shopifyOrgMemberVariantId: shopifyOrgMemberVariantId || null }), ...(isSysAdminOrBoard && shopifyNonOrgMemberVariantId !== undefined && { shopifyNonOrgMemberVariantId: shopifyNonOrgMemberVariantId || null }), + ...(archivedAtUpdate !== undefined && { archivedAt: archivedAtUpdate }), }; const updatedProgram = await prisma.program.update({ @@ -194,7 +205,11 @@ export const PATCH = withAuth({}, async (req, auth, ctx: { params: Promise<{ id: const newMax = updatedProgram.maxParticipants; const hasShopifyVariant = !!(updatedProgram.shopifyVariantId || updatedProgram.shopifyOrgMemberVariantId || updatedProgram.shopifyNonOrgMemberVariantId); - if (oldMax !== newMax && hasShopifyVariant) { + // Archived programs are frozen: a capacity edit still saves to the DB but + // is NOT pushed to Shopify (new-activity block, PROGRAM_ARCHIVE.md). + if (oldMax !== newMax && hasShopifyVariant && updatedProgram.archivedAt) { + warning = "Program is archived — capacity saved, but not pushed to Shopify. Un-archive to sync inventory."; + } else if (oldMax !== newMax && hasShopifyVariant) { if (oldMax !== null && newMax !== null) { const ok = await adjustProgramInventory(updatedProgram, newMax - oldMax); if (!ok) { diff --git a/checkin-app/src/app/api/programs/[id]/volunteers/route.ts b/checkin-app/src/app/api/programs/[id]/volunteers/route.ts index b425e59ef..388fe1454 100644 --- a/checkin-app/src/app/api/programs/[id]/volunteers/route.ts +++ b/checkin-app/src/app/api/programs/[id]/volunteers/route.ts @@ -3,6 +3,7 @@ import { logger } from "@/lib/logger"; import { withAuth } from "@/lib/auth"; import prisma from "@/lib/prisma"; import { apiError } from "@/lib/api-response"; +import { programArchivedError } from "@/lib/program/archive"; export const POST = withAuth({}, async (req, auth, { params }: { params: Promise<{ id: string }> }) => { if (auth.type !== 'session') return apiError("Unauthorized", 401); @@ -34,6 +35,10 @@ export const POST = withAuth({}, async (req, auth, { params }: { params: Promise return apiError("Forbidden: Not authorized to assign volunteers", 403); } + // Archived programs are frozen for new activity (see PROGRAM_ARCHIVE.md). + const archivedErr = programArchivedError(currentProgram); + if (archivedErr) return archivedErr; + // ponytail: no eligibility gate (age/membership) on volunteers — they're // staff/mentors, not enrollees. ProgramVolunteer has only isCore; any // participant is assignable. Add a gate here only if product asks for one. diff --git a/checkin-app/src/app/api/programs/route.ts b/checkin-app/src/app/api/programs/route.ts index 5e12894c1..b7ae18766 100644 --- a/checkin-app/src/app/api/programs/route.ts +++ b/checkin-app/src/app/api/programs/route.ts @@ -8,6 +8,7 @@ import { isActiveOrgMember } from "@/lib/orgMembership"; import { dollarsToCentsOrNull } from "@inventory/money"; import { apiError } from "@/lib/api-response"; import { validateProgramAgeBounds } from "@/lib/programAge"; +import { NOT_ARCHIVED } from "@/lib/program/archive"; // GET is the PUBLIC program catalog — anonymous callers legitimately get the // non-draft, non-orgMemberOnly list (asserted by programsAPI.integration.test.ts), @@ -36,6 +37,16 @@ export async function GET(req: Request) { const andClauses: Record[] = []; + // Archived programs are hidden from every default listing (public catalog, + // program-ops list, pickers). Only board/sysadmin may opt back in with + // ?includeArchived=true (the ops "include archived" toggle) — a plain + // caller cannot pass it to unhide retired programs. See PROGRAM_ARCHIVE.md. + const includeArchived = searchParams.get("includeArchived") === "true" + && !!user && (user.isSysadmin || user.isBoardMember); + if (!includeArchived) { + andClauses.push(NOT_ARCHIVED); + } + if (activeOnly) { andClauses.push({ OR: [ diff --git a/checkin-app/src/app/program-ops/programs/[id]/page.tsx b/checkin-app/src/app/program-ops/programs/[id]/page.tsx index 12f2ad350..ec2b9a3a2 100644 --- a/checkin-app/src/app/program-ops/programs/[id]/page.tsx +++ b/checkin-app/src/app/program-ops/programs/[id]/page.tsx @@ -45,6 +45,7 @@ export type ProgramDetail = { shopifyProductId: string | null; shopifyOrgMemberVariantId: string | null; shopifyNonOrgMemberVariantId: string | null; + archivedAt: string | null; }; export type ParticipantOption = { id: number; name: string | null; email: string; dateOfBirth?: string | null }; @@ -201,6 +202,33 @@ export default function ProgramDetailsPage({ params }: { params: Promise<{ id: s } }; + // Archive / un-archive (board/sysadmin only). Rides the existing PATCH via the + // `archived` boolean; the backend gates it and skips Shopify. See PROGRAM_ARCHIVE.md. + const handleToggleArchive = async () => { + if (!program) return; + const archiving = !program.archivedAt; + setSaving(true); + try { + const res = await fetch(`/api/programs/${id}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ archived: archiving }), + }); + if (res.ok) { + notifications.show({ color: archiving ? 'gray' : 'green', message: archiving ? 'Program archived.' : 'Program un-archived.' }); + notifyNavRefresh(); + fetchProgram(); + } else { + const data = await res.json().catch(() => ({})); + notifications.show({ color: 'red', message: data.error || 'Failed to update archive state.', autoClose: false }); + } + } catch { + notifications.show({ color: 'red', message: 'Network error.', autoClose: false }); + } finally { + setSaving(false); + } + }; + const handleSyncShopify = async () => { setSyncing(true); try { @@ -275,10 +303,31 @@ export default function ProgramDetailsPage({ params }: { params: Promise<{ id: s {program.name} {phaseBadge && {phaseBadge.label}} + {program.archivedAt && Archived} + + + {isSysAdminOrBoard && ( + + )} + - + {program.archivedAt && ( + + It is hidden from the public catalog, pickers, and dashboards, and frozen for new activity + (enrollment, payment plans, volunteer signup, Shopify capacity pushes). Existing roster and + history stay readable. Un-archive to make it active again. + + )} + setActiveTab((v as typeof activeTab) ?? 'general')}> diff --git a/checkin-app/src/app/program-ops/programs/page.tsx b/checkin-app/src/app/program-ops/programs/page.tsx index 66504554d..1f67b4d4c 100644 --- a/checkin-app/src/app/program-ops/programs/page.tsx +++ b/checkin-app/src/app/program-ops/programs/page.tsx @@ -20,6 +20,7 @@ type Program = { shopifyVariantId?: string | null; shopifyOrgMemberVariantId?: string | null; shopifyNonOrgMemberVariantId?: string | null; + archivedAt?: string | null; _count?: { participants?: number; events?: number }; }; @@ -52,10 +53,11 @@ export default function AdminProgramsIndex() { const [loading, setLoading] = useState(true); const [activeOnly, setActiveOnly] = useState(false); const [publicOnly, setPublicOnly] = useState(false); + const [includeArchived, setIncludeArchived] = useState(false); const router = useRouter(); useEffect(() => { - fetch('/api/programs') + fetch(`/api/programs${includeArchived ? '?includeArchived=true' : ''}`) .then(res => res.json()) .then(data => { if (Array.isArray(data)) { @@ -67,7 +69,7 @@ export default function AdminProgramsIndex() { console.error(err); setLoading(false); }); - }, []); + }, [includeArchived]); const visiblePrograms = programs.filter( (p) => @@ -80,7 +82,8 @@ export default function AdminProgramsIndex() { header: "Program", render: (p) => ( - {p.name} + {p.name} + {p.archivedAt && Archived} {isProgramCheckoutBroken(p) && ( ⚠️ Broken checkout @@ -165,6 +168,11 @@ export default function AdminProgramsIndex() { checked={publicOnly} onChange={(e) => setPublicOnly(e.currentTarget.checked)} /> + setIncludeArchived(e.currentTarget.checked)} + />