From 7c97349adbe7ea861d2bb8dbb3a3a884bb23d1a3 Mon Sep 17 00:00:00 2001 From: Jeff Erickson <16201464+jee7s@users.noreply.github.com> Date: Mon, 6 Jul 2026 22:57:31 -0500 Subject: [PATCH] feat(household): archive / un-archive families (households) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Soft-archive for households (UI "families"): a nullable Household.archivedAt stamp sets a family aside without deleting anything. Archived households are hidden from default board lists/searches/pickers (the membership-ops households view gets an "include archived" toggle), skipped by notification fan-outs and crons (renewal + person-BG sweeps, new-program blast, audit/compliance chase lists, nav badges), and their members are blocked from starting new activity — program enrollment, check-in/scan, and membership application — through one shared guard (assertHouseholdActive), returning a clear 4xx. Board/sysadmin only, surfaced as a toggle in the households view alongside grant/revoke/deny. Who/when is captured in the AuditLog (no extra column). History is fully intact; un-archive clears the stamp and restores everything. See checkin-app/docs/designs/HOUSEHOLD_ARCHIVE.md for the decisions and the full touched/not-touched query sweep. Co-Authored-By: Claude Fable 5 --- .../programSignupIntegration.test.ts | 2 + checkin-app/docs/designs/HOUSEHOLD_ARCHIVE.md | 162 +++++++++++++++ .../migration.sql | 6 + checkin-app/prisma/schema.prisma | 8 + .../householdArchive.integration.test.ts | 185 ++++++++++++++++++ .../api/membership-audit/compliance/route.ts | 8 +- .../api/membership-ops/households/route.ts | 22 ++- .../[id]/eligible-participants/route.ts | 4 + .../api/programs/[id]/participants/route.ts | 10 + .../app/api/scan/__tests__/scanRoute.test.ts | 4 + checkin-app/src/app/api/scan/route.ts | 10 + .../app/membership-ops/households/page.tsx | 65 +++++- .../src/lib/emergencyContacts/service.ts | 1 + checkin-app/src/lib/household/archive.ts | 61 ++++++ checkin-app/src/lib/household/filters.ts | 17 +- checkin-app/src/lib/membership/intake.ts | 8 +- .../src/lib/membership/intakeResponse.ts | 1 + .../src/lib/membership/personBgTriggers.ts | 4 +- checkin-app/src/lib/membership/renewal.ts | 6 +- checkin-app/src/lib/notifications.ts | 4 +- .../src/security/generated/classifications.ts | 1 + 21 files changed, 569 insertions(+), 20 deletions(-) create mode 100644 checkin-app/docs/designs/HOUSEHOLD_ARCHIVE.md create mode 100644 checkin-app/prisma/migrations/20260708010000_household_archive/migration.sql create mode 100644 checkin-app/src/app/__tests__/householdArchive.integration.test.ts create mode 100644 checkin-app/src/lib/household/archive.ts diff --git a/checkin-app/__tests__/programSignupIntegration.test.ts b/checkin-app/__tests__/programSignupIntegration.test.ts index 68daabb24..4b77cdcdc 100644 --- a/checkin-app/__tests__/programSignupIntegration.test.ts +++ b/checkin-app/__tests__/programSignupIntegration.test.ts @@ -55,6 +55,8 @@ jest.mock('@/lib/prisma', () => { household: { create: jest.fn(), delete: jest.fn(), + // Archived-household enroll guard (assertHouseholdActive); null == active. + findUnique: jest.fn().mockResolvedValue(null), }, membership: { create: jest.fn(), diff --git a/checkin-app/docs/designs/HOUSEHOLD_ARCHIVE.md b/checkin-app/docs/designs/HOUSEHOLD_ARCHIVE.md new file mode 100644 index 000000000..9e799e0dd --- /dev/null +++ b/checkin-app/docs/designs/HOUSEHOLD_ARCHIVE.md @@ -0,0 +1,162 @@ +# Archiving / un-archiving Families (households) + +**Status:** BUILT (2026-07-08). Additive, nullable stamp + a shared "active +household" filter + three write guards + a board/sysadmin toggle in the +membership-ops households view. +**Scope:** `checkin-app/` (paths below are relative to `checkin-app/`). + +--- + +## 1. Problem + +A household (UI: "family") that has left the Treehouse — moved away, aged out, +duplicate created in error — clutters every board list, still receives renewal +reminders, and its members can still enroll, scan, and start applications. +Deleting is not an option: attendance, membership, background-check, and audit +history must stay intact for compliance. We need a **soft archive**: set the +household aside so it drops out of the day-to-day surfaces and its members can't +start new activity, while every historical row stays exactly where it is and a +single action fully restores it. + +Archive is **not** the same as membership **Deny** (`OrgMembershipStatus.DENIED`, +which blocks login for every member at the auth layer). Archive is softer and +orthogonal: an archived household's members can still **log in and view** their +own history — they just can't **start new activity** (enroll, scan, apply), and +the household disappears from board-facing default lists. The two can coexist. + +## 2. Decisions (from the product interview) + rationale + +| Decision | Rationale | +|---|---| +| **Soft archive via a nullable `Household.archivedAt DateTime?` stamp** (`@sensitivity:internal`). | One nullable column; presence of a timestamp == archived, and it records *when*. Additive/nullable → expand-only migration, safe on the live DB. | +| **Who archived is captured in `AuditLog`, not a second column.** | The audit log already records `actorId` + before/after on every board action (mirrors the household `Deny`/membership-archive rows). No new column earns its keep. | +| **Board / sysadmin only**, surfaced in the **membership-ops households** view (where households are already managed). | Same role gate (`isSysadmin`/`isBoardMember`) and same screen as grant/revoke/deny — no new nav, no new permission concept. | +| **Archived households are HIDDEN from default lists / searches / pickers.** The households management list gets an **"include archived"** toggle to find them for un-archive. | Keeps the board's daily surfaces clean; the toggle is the one place you go to bring a family back. | +| **Archived households' members are BLOCKED from new activity** — program enrollment, check-in/scan, starting a membership application — guarded at the **shared service/route level**. | One guard per write choke-point, not per caller, so a future caller can't forget it. Returns a clear 4xx ("household is archived"), never a 500. | +| **Notification fan-outs & crons SKIP archived households** (renewal sweep, person-BG sweep, new-program blast, audit/compliance chase lists, nav badges). | An archived family shouldn't be emailed or chased. Enforced by composing one shared `archivedAt: null` filter into the existing predicates. | +| **History fully intact; un-archive restores everything. No purge.** | Un-archive is just clearing the stamp — every enrollment, visit, membership, and audit row is untouched, so restore is total and instant. | + +## 3. Data model + +```prisma +model Household { + ... + /// Soft-archive stamp: non-null == the household is archived (set aside). + /// Hides it from default board lists/searches/pickers, skips it in + /// notification fan-outs + crons, and blocks its members from starting new + /// activity (enroll / scan / membership application). History is untouched; + /// clearing this restores everything. Who archived is in the AuditLog row, + /// not a column. @sensitivity:internal + archivedAt DateTime? +} +``` + +Migration `20260708010000_household_archive` — additive, nullable, no backfill: + +```sql +ALTER TABLE "Household" ADD COLUMN "archivedAt" TIMESTAMP(3); +``` + +## 4. The shared "active household" filter + +`src/lib/household/filters.ts` gains the single source of truth for "not +archived", and folds it into the existing broken/unclaimed predicates so every +audit list + nav badge that already used them skips archived for free: + +```ts +export const ACTIVE_HOUSEHOLD_WHERE: Prisma.HouseholdWhereInput = { archivedAt: null }; +/** Person-scoped: their household isn't archived. For person list/fan-out queries. */ +export const ACTIVE_HOUSEHOLD_PERSON_WHERE: Prisma.PersonWhereInput = { household: { archivedAt: null } }; +``` + +`BROKEN_HOUSEHOLD_WHERE` and `UNCLAIMED_OR_BROKEN_HOUSEHOLD_WHERE` are extended +with `archivedAt: null`, so the four call sites that consume them (broken- +households list, unclaimed list, and both nav todo-counts) drop archived with no +per-site edit. + +## 5. Write guards (block new activity) + +`src/lib/household/archive.ts`: + +- `HouseholdArchivedError` — thrown by the guard; each route maps it to **409** + with the message *"This household is archived."* +- `assertHouseholdActive(db, householdId)` — one PK lookup; throws if archived. +- `setHouseholdArchived(householdId, archived, actorId)` — the mutation: + idempotent (archiving an already-archived household is a no-op), stamps/clears + `archivedAt`, and writes an `AuditLog` `EDIT` row (`tableName: "Household"`, + before/after `{ archivedAt }`) so who/when is recorded. + +Three write choke-points call the guard (the exact points the interview named): + +| Write | File | Guard | +|---|---|---| +| Program enrollment POST | `api/programs/[id]/participants/route.ts` | `assertHouseholdActive` on the enrollee's household → 409 | +| Check-in / scan POST | `api/scan/route.ts` | `assertHouseholdActive` on the scanned person's household → 409 | +| Start membership application | `lib/membership/intake.ts` `startIntake` | reuses the already-loaded `household.archivedAt` → `IntakeError("archived")` → 409 (same invariant, intake's own error channel; no redundant query) | + +## 6. The sweep — every household/person query that feeds a list, cron, or fan-out + +Root-cause altitude: prefer composing the one shared filter over sprinkling +`archivedAt: null`. Decided case-by-case: + +### Touched (archived now excluded) + +| Query | File | Why | +|---|---|---| +| Households management list (GET) | `api/membership-ops/households/route.ts` | Default hides archived; `?includeArchived=1` shows them (the un-archive surface). `archivedAt` added to the row select for the badge. | +| Broken-households list + nav count | `filters.ts` `BROKEN_HOUSEHOLD_WHERE` | Archived family shouldn't be flagged "broken/needs a lead". | +| Unclaimed households list + nav count | `filters.ts` `UNCLAIMED_OR_BROKEN_HOUSEHOLD_WHERE` | Same — no point chasing an archived family to claim. | +| Households-missing-contact list + nav count | `lib/emergencyContacts/service.ts` `findHouseholdsMissingValidContact` | Compliance chase list; archived is out of scope. | +| Membership compliance dashboard | `api/membership-audit/compliance/route.ts` | Stale-BG / revoked / denied buckets skip archived. | +| Enroll picker ("eligible participants") | `api/programs/[id]/eligible-participants/route.ts` | Archived members can't be enrolled, so don't offer them. | +| Renewal sweep + bulk-renewal | `lib/membership/renewal.ts` (`runRenewalSweep`, `openRenewalsForAllActive`) | **Representative cron** — no renewal process opened, no reminder emailed, for archived households. | +| Person-BG annual sweep | `lib/membership/personBgTriggers.ts` `runPersonBgAnnualSweep` | No new BG obligation for an archived family's members. | +| New-program announcement blast | `lib/notifications.ts` `notifyNewProgramAnnounced` | Don't email archived families about new programs. | + +### Deliberately NOT touched (and why) + +| Query | File | Why left alone | +|---|---|---| +| Household detail GET (by id) | `api/membership-ops/households/[id]/route.ts` | Direct-id detail view; must stay reachable to *un-archive* from it. | +| Emergency-contacts safety board | `api/safety/emergency-contacts/route.ts` | **Safety.** Archive does not force-checkout; a member checked in *before* archiving still has an open visit. Hiding the household could hide a person physically in the building from the emergency board. Presence (`isPresent`) already scopes what matters. | +| Pending-participant kick / final-warning | `api/cron/pending-participants/route.ts` | Freeing a program seat held by an archived household is correct; the "emails" here are `logger.info` stubs (no real fan-out). | +| Scholarship-grace-expiry auto-withdraw | `api/cron/scholarship-grace-expiry/route.ts` | Same — releasing a held seat is always right. | +| Nightly facility-close sweep | `api/cron/nightly/route.ts` | Checks out abandoned visits; archived members are blocked from *new* check-ins, and closing an already-open visit is always correct. | +| `emailHouseholdLeads(householdId, …)` | `lib/emailRecipients.ts` | Single-household send; the iterating callers (renewal) are already filtered upstream, so no double-guard needed. | +| `emailBoardMembers`, `notifyReviewers` | `lib/emailRecipients.ts`, `review.ts` | Board/reviewer fan-outs, not household-scoped. | +| `sendNotification`, `sendCheckinNotifications` | `lib/notifications.ts` | Fired by enroll/scan, which are already blocked for archived households. | +| `beginRenewal` / `submitIntake` | `lib/membership/renewal.ts`, `intake.ts` | *Continue* an in-flight process that predates archiving; the sweep guard stops any *new* one from opening. Un-archive is the intended path to resume. | + +## 7. UI + +`membership-ops/households/page.tsx` (board/sysadmin): +- An **"Include archived"** checkbox that refetches with `?includeArchived=1`. +- An **Archived** badge on archived rows. +- **Archive** / **Un-archive** buttons in the row actions (calls the households + POST with `{ householdId, archive: boolean }`). + +The households POST route already gates `isSysadmin`/`isBoardMember` and handles +the sibling `active`/`deny` toggles; archive is one more branch there (no new +route, no new `routeAuthDrift` entry, no new permission concept). + +## 8. Prod safety + +- **Migration is expand-only** (one additive, nullable column, no backfill) — + safe under the rolling deploy: draining old tasks simply ignore the new column. +- **No destructive drop**; un-archive is a plain `UPDATE … SET archivedAt = NULL`. +- **Guards fail closed with a 4xx**, never a 500, and never fail a scan/enroll on + a best-effort path — the archived check is a cheap indexed PK read. +- **Security:** `archivedAt` is `@sensitivity:internal`; `classifications.ts` + regenerated by `prisma generate` (never hand-edited). No new API route (archive + rides the existing sanctioned households POST), so no `routeAuthDrift` change. + +## 9. Deliberately deferred + +- **No auto-archive** (e.g. sweep long-inactive households). Manual board action + only — archiving is a judgement call. +- **No force-checkout on archive** — see the safety-board rationale in §6. +- **No bulk archive UI** — one household at a time; add a multi-select later if + the board asks. +- **No archive reason / note field** — YAGNI; the audit row records who + when. +- `beginRenewal`/`submitIntake` continuation is not blocked (§6) — revisit only + if a real case of "archived mid-renewal must hard-stop" appears. diff --git a/checkin-app/prisma/migrations/20260708010000_household_archive/migration.sql b/checkin-app/prisma/migrations/20260708010000_household_archive/migration.sql new file mode 100644 index 000000000..d5bf6d7a7 --- /dev/null +++ b/checkin-app/prisma/migrations/20260708010000_household_archive/migration.sql @@ -0,0 +1,6 @@ +-- Additive, nullable: soft-archive stamp for a household (family). +-- Non-null == archived (set aside): hidden from default board lists, skipped in +-- crons/fan-outs, members blocked from new activity. History untouched; clearing +-- it restores everything. No backfill (nullable). See +-- docs/designs/HOUSEHOLD_ARCHIVE.md. +ALTER TABLE "Household" ADD COLUMN "archivedAt" TIMESTAMP(3); diff --git a/checkin-app/prisma/schema.prisma b/checkin-app/prisma/schema.prisma index be3013f03..e28490a45 100644 --- a/checkin-app/prisma/schema.prisma +++ b/checkin-app/prisma/schema.prisma @@ -190,6 +190,14 @@ model Household { /// @sensitivity:pii intakeNotes String? + /// Soft-archive stamp: non-null == the household is archived (set aside). + /// Hides it from default board lists/searches/pickers, skips it in + /// notification fan-outs + crons, and blocks its members from starting new + /// activity (enroll / scan / membership application). History is untouched; + /// clearing this restores everything. Who archived is in the AuditLog row, + /// not a column (see docs/designs/HOUSEHOLD_ARCHIVE.md). @sensitivity:internal + archivedAt DateTime? + householdMembers Person[] orgMembership OrgMembership? trustedAdults TrustedAdult[] diff --git a/checkin-app/src/app/__tests__/householdArchive.integration.test.ts b/checkin-app/src/app/__tests__/householdArchive.integration.test.ts new file mode 100644 index 000000000..a129d172f --- /dev/null +++ b/checkin-app/src/app/__tests__/householdArchive.integration.test.ts @@ -0,0 +1,185 @@ +/** + * @jest-environment node + */ +/** + * Integration coverage for household soft-archive (docs/designs/HOUSEHOLD_ARCHIVE.md): + * archive → hidden-from-list → blocked-enrollment → blocked-intake → un-archive-restores, + * plus the representative cron-skip (the renewal sweep ignores archived households). + * Real DB; auth mocked; email/notification side effects stubbed. + */ +import { GET as householdsGET, POST as householdsPOST } from '@/app/api/membership-ops/households/route'; +import { POST as enrollPOST } from '@/app/api/programs/[id]/participants/route'; +import prisma from '@/lib/prisma'; +import { getServerSession } from 'next-auth/next'; +import { runRenewalSweep } from '@/lib/membership/renewal'; +import { startIntake, IntakeError } from '@/lib/membership/intake'; + +jest.mock('next-auth/next', () => ({ getServerSession: jest.fn() })); +jest.mock('@/lib/notifications', () => ({ sendNotification: jest.fn().mockResolvedValue(undefined) })); +jest.mock('@/lib/emailRecipients', () => ({ + emailHouseholdLeads: jest.fn().mockResolvedValue(undefined), + emailBoardMembers: jest.fn().mockResolvedValue(undefined), +})); + +const TAG = 'hh-archive-test'; +const asAdmin = (id: number) => + (getServerSession as jest.Mock).mockResolvedValue({ user: { id, isSysadmin: true, isBoardMember: false, householdId: 0 } }); +const asUser = (id: number, householdId: number) => + (getServerSession as jest.Mock).mockResolvedValue({ user: { id, isSysadmin: false, isBoardMember: false, householdId } }); + +function req(body?: unknown, url = 'http://localhost/api/membership-ops/households') { + return new Request(url, body === undefined ? {} : { method: 'POST', body: JSON.stringify(body) }) as unknown as import('next/server').NextRequest; +} + +describe('Household archive — integration', () => { + let adminId: number; + let archLeadId: number; + let archHouseholdId: number; + let activeHouseholdId: number; + let programId: number; + + beforeAll(async () => { + const admin = await prisma.person.create({ + data: { name: 'Admin', email: `admin-${TAG}@example.com`, isSysadmin: true, household: { create: { name: `Admin HH ${TAG}` } } }, + }); + adminId = admin.id; + + const archLead = await prisma.person.create({ + data: { + name: 'Arch Lead', email: `arch-lead-${TAG}@example.com`, isHouseholdLead: true, + dateOfBirth: new Date('1990-01-01'), + household: { create: { name: `Archive HH ${TAG}` } }, + }, + }); + archLeadId = archLead.id; + archHouseholdId = archLead.householdId; + await prisma.orgMembership.create({ data: { householdId: archHouseholdId, status: 'ACTIVE' } }); + + const activeLead = await prisma.person.create({ + data: { + name: 'Active Lead', email: `active-lead-${TAG}@example.com`, isHouseholdLead: true, + dateOfBirth: new Date('1990-01-01'), + household: { create: { name: `Active HH ${TAG}` } }, + }, + }); + activeHouseholdId = activeLead.householdId; + await prisma.orgMembership.create({ data: { householdId: activeHouseholdId, status: 'ACTIVE' } }); + + const program = await prisma.program.create({ + data: { name: `Program ${TAG}`, enrollmentStatus: 'OPEN', orgMemberPriceCents: null, nonOrgMemberPriceCents: null }, + }); + programId = program.id; + }); + + afterAll(async () => { + const hhIds = [archHouseholdId, activeHouseholdId]; + await prisma.programParticipant.deleteMany({ where: { programId } }); + await prisma.program.deleteMany({ where: { name: { contains: TAG } } }); + await prisma.orgMembershipProcess.deleteMany({ where: { orgMembership: { householdId: { in: hhIds } } } }); + await prisma.orgMembership.deleteMany({ where: { householdId: { in: hhIds } } }); + await prisma.auditLog.deleteMany({ where: { tableName: 'Household', affectedEntityId: { in: hhIds } } }); + await prisma.person.deleteMany({ where: { email: { contains: TAG } } }); + await prisma.boardSettings.deleteMany({ where: { id: 1 } }); + await prisma.household.deleteMany({ where: { name: { contains: TAG } } }); + }); + + it('POST { archive:true } stamps archivedAt and writes an audit row', async () => { + asAdmin(adminId); + const res = await householdsPOST(req({ householdId: archHouseholdId, archive: true })); + expect(res.status).toBe(200); + + const hh = await prisma.household.findUnique({ where: { id: archHouseholdId } }); + expect(hh?.archivedAt).toBeInstanceOf(Date); + + const audit = await prisma.auditLog.findFirst({ + where: { tableName: 'Household', affectedEntityId: archHouseholdId, actorId: adminId }, + orderBy: { id: 'desc' }, + }); + expect(audit).not.toBeNull(); + expect((audit!.newData as { archivedAt: string | null }).archivedAt).not.toBeNull(); + + // Idempotent: re-archiving an already-archived household is a no-op (no second audit row). + const auditCountBefore = await prisma.auditLog.count({ where: { tableName: 'Household', affectedEntityId: archHouseholdId } }); + const again = await householdsPOST(req({ householdId: archHouseholdId, archive: true })); + expect(again.status).toBe(200); + const auditCountAfter = await prisma.auditLog.count({ where: { tableName: 'Household', affectedEntityId: archHouseholdId } }); + expect(auditCountAfter).toBe(auditCountBefore); + }); + + it('hides the archived household from the default list, shows it with includeArchived=1', async () => { + asAdmin(adminId); + + const defaultRes = await householdsGET(req()); + const defaultIds = (await defaultRes.json()).households.map((h: { id: number }) => h.id); + expect(defaultIds).not.toContain(archHouseholdId); + expect(defaultIds).toContain(activeHouseholdId); + + const inclRes = await householdsGET(req(undefined, 'http://localhost/api/membership-ops/households?includeArchived=1')); + const inclIds = (await inclRes.json()).households.map((h: { id: number }) => h.id); + expect(inclIds).toContain(archHouseholdId); + }); + + it('blocks enrollment for a member of an archived household (409)', async () => { + asUser(archLeadId, archHouseholdId); + const res = await enrollPOST( + req({ participantId: archLeadId }, `http://localhost/api/programs/${programId}/participants`), + { params: Promise.resolve({ id: String(programId) }) }, + ); + expect(res.status).toBe(409); + const count = await prisma.programParticipant.count({ where: { programId, personId: archLeadId } }); + expect(count).toBe(0); + }); + + it('blocks starting a membership application for an archived household', async () => { + const err = await startIntake(archLeadId).then(() => null, (e) => e); + expect(err).toBeInstanceOf(IntakeError); + expect(err.code).toBe('archived'); + }); + + it('renewal sweep skips the archived household but opens for the active one', async () => { + // Boundary ~30 days out so the 2-month renewal window is open now. + const boundary = new Date(); + boundary.setUTCDate(boundary.getUTCDate() + 30); + await prisma.boardSettings.upsert({ + where: { id: 1 }, + create: { id: 1, orgMembershipYearBoundary: boundary }, + update: { orgMembershipYearBoundary: boundary }, + }); + + await runRenewalSweep(new Date()); + + const archProc = await prisma.orgMembershipProcess.findFirst({ + where: { kind: 'RENEWAL', orgMembership: { householdId: archHouseholdId } }, + }); + const activeProc = await prisma.orgMembershipProcess.findFirst({ + where: { kind: 'RENEWAL', orgMembership: { householdId: activeHouseholdId } }, + }); + expect(archProc).toBeNull(); + expect(activeProc).not.toBeNull(); + }); + + it('un-archive clears archivedAt (+ audit) and restores enrollment', async () => { + asAdmin(adminId); + const res = await householdsPOST(req({ householdId: archHouseholdId, archive: false })); + expect(res.status).toBe(200); + + const hh = await prisma.household.findUnique({ where: { id: archHouseholdId } }); + expect(hh?.archivedAt).toBeNull(); + + const audit = await prisma.auditLog.findFirst({ + where: { tableName: 'Household', affectedEntityId: archHouseholdId, actorId: adminId }, + orderBy: { id: 'desc' }, + }); + expect((audit!.newData as { archivedAt: string | null }).archivedAt).toBeNull(); + + // Enrollment now succeeds — the un-archive fully restored activity. + asUser(archLeadId, archHouseholdId); + const enrollRes = await enrollPOST( + req({ participantId: archLeadId }, `http://localhost/api/programs/${programId}/participants`), + { params: Promise.resolve({ id: String(programId) }) }, + ); + expect(enrollRes.status).toBe(200); + const count = await prisma.programParticipant.count({ where: { programId, personId: archLeadId } }); + expect(count).toBe(1); + }); +}); diff --git a/checkin-app/src/app/api/membership-audit/compliance/route.ts b/checkin-app/src/app/api/membership-audit/compliance/route.ts index 7e3e5a01b..541a89602 100644 --- a/checkin-app/src/app/api/membership-audit/compliance/route.ts +++ b/checkin-app/src/app/api/membership-audit/compliance/route.ts @@ -3,6 +3,7 @@ import prisma from "@/lib/prisma"; import { withAuth } from "@/lib/auth"; import { householdBgIsFresh, nextBoundary } from "@/lib/membership/renewal"; import { bgFreshThreshold, personBgVerdict } from "@/lib/membership/personBgCheck"; +import { ACTIVE_HOUSEHOLD_WHERE, ACTIVE_HOUSEHOLD_PERSON_WHERE } from "@/lib/household/filters"; export const dynamic = "force-dynamic"; @@ -45,7 +46,7 @@ export const GET = withAuth({ roles: ["isSysadmin", "isBoardMember"] }, async () // bgRecheckMonths = 0 it always returns false, so skip the whole bucket. if (bgRecheckMonths > 0) { const active = await prisma.orgMembership.findMany({ - where: { status: "ACTIVE" }, + where: { status: "ACTIVE", household: ACTIVE_HOUSEHOLD_WHERE }, select: { householdId: true }, }); for (const m of active) { @@ -56,14 +57,14 @@ export const GET = withAuth({ roles: ["isSysadmin", "isBoardMember"] }, async () // 2. REVOKED / DENIED memberships — tag which. const revokedDenied = await prisma.orgMembership.findMany({ - where: { status: { in: ["REVOKED", "DENIED"] } }, + where: { status: { in: ["REVOKED", "DENIED"] }, household: ACTIVE_HOUSEHOLD_WHERE }, select: { householdId: true, status: true }, }); for (const m of revokedDenied) add(m.householdId, m.status); // 3. Stuck at BG clearance — paid but never cleared. const stuck = await prisma.orgMembershipProcess.findMany({ - where: { status: "PENDING_BG_CLEARANCE" }, + where: { status: "PENDING_BG_CLEARANCE", orgMembership: { household: ACTIVE_HOUSEHOLD_WHERE } }, select: { orgMembership: { select: { householdId: true } } }, }); // PENDING_BG_CLEARANCE is a household-process status; orgMembership is always set. @@ -89,6 +90,7 @@ export const GET = withAuth({ roles: ["isSysadmin", "isBoardMember"] }, async () const threshold = bgFreshThreshold(boundary, bgRecheckMonths); const people = await prisma.person.findMany({ where: { + ...ACTIVE_HOUSEHOLD_PERSON_WHERE, OR: [ { programParticipants: { some: {} } }, { programVolunteers: { some: {} } }, diff --git a/checkin-app/src/app/api/membership-ops/households/route.ts b/checkin-app/src/app/api/membership-ops/households/route.ts index 0ed583265..202e5b3d1 100644 --- a/checkin-app/src/app/api/membership-ops/households/route.ts +++ b/checkin-app/src/app/api/membership-ops/households/route.ts @@ -4,6 +4,8 @@ import prisma from "@/lib/prisma"; import { withAuth } from "@/lib/auth"; import { apiError } from "@/lib/api-response"; import { hasHouseholdConflict } from "@/lib/conflictOfInterest"; +import { ACTIVE_HOUSEHOLD_WHERE } from "@/lib/household/filters"; +import { setHouseholdArchived } from "@/lib/household/archive"; export const dynamic = 'force-dynamic'; @@ -26,6 +28,10 @@ export const GET = withAuth( const url = new URL(req.url); const id = url.searchParams.get('id'); const q = url.searchParams.get('q') || ''; + // Archived households are hidden by default; the households view's + // "include archived" toggle passes includeArchived=1 to find them for + // un-archive. A direct id lookup (below) always resolves, archived or not. + const includeArchived = url.searchParams.get('includeArchived') === '1'; if (id) { const household = await prisma.household.findUnique({ @@ -56,13 +62,15 @@ export const GET = withAuth( return NextResponse.json({ household: { ...withFlatContact(household), householdLeads } }); } + const archivedFilter = includeArchived ? {} : ACTIVE_HOUSEHOLD_WHERE; const whereClause = q ? { + ...archivedFilter, OR: [ { name: { contains: q, mode: 'insensitive' as const } }, { householdMembers: { some: { name: { contains: q, mode: 'insensitive' as const } } } }, { householdMembers: { some: { email: { contains: q, mode: 'insensitive' as const } } } }, ] - } : {}; + } : archivedFilter; const households = await prisma.household.findMany({ where: whereClause, @@ -97,12 +105,22 @@ export const POST = withAuth( async (req, auth) => { try { const body = await req.json(); - const { householdId, active, deny } = body; + const { householdId, active, deny, archive } = body; if (!householdId) { return apiError("Household ID is required", 400); } + // Soft-archive toggle — a household-level "set aside" action, sibling to + // deny (both are board/sysadmin household-state toggles with an audit row). + // Unlike deny, archive doesn't block login; it hides the family from board + // lists/crons and blocks its members from starting new activity. + if (typeof archive === "boolean") { + if (auth.type !== 'session') return apiError("Unauthorized", 401); + const result = await setHouseholdArchived(householdId, archive, auth.user.id); + return NextResponse.json({ success: true, archivedAt: result.archivedAt }); + } + const existingMembership = await prisma.orgMembership.findUnique({ where: { householdId } }); diff --git a/checkin-app/src/app/api/programs/[id]/eligible-participants/route.ts b/checkin-app/src/app/api/programs/[id]/eligible-participants/route.ts index 071e72f55..ecc889a24 100644 --- a/checkin-app/src/app/api/programs/[id]/eligible-participants/route.ts +++ b/checkin-app/src/app/api/programs/[id]/eligible-participants/route.ts @@ -1,6 +1,7 @@ import prisma from "@/lib/prisma"; import { Prisma } from "@/generated/prisma/client"; import { ACTIVE_ORG_MEMBER_PERSON_WHERE } from "@/lib/orgMembership"; +import { ACTIVE_HOUSEHOLD_PERSON_WHERE } from "@/lib/household/filters"; import { handler, notFound, badRequest } from "@/security/handler"; // Admission + field stripping declared in src/security/registry.ts under @@ -22,6 +23,9 @@ export const GET = handler<{ id: string }>('GET /api/programs/[id]/eligible-part const q = new URL(req.url).searchParams.get("q") || ""; const andClauses: Prisma.PersonWhereInput[] = [ + // Archived-household members can't be enrolled (blocked at the enroll POST), + // so don't offer them as candidates in the add-participant picker. + ACTIVE_HOUSEHOLD_PERSON_WHERE, { NOT: { OR: [ 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..805d5c57f 100644 --- a/checkin-app/src/app/api/programs/[id]/participants/route.ts +++ b/checkin-app/src/app/api/programs/[id]/participants/route.ts @@ -6,6 +6,7 @@ import { sendNotification } from "@/lib/notifications"; import { lockProgramAndCheckCapacity, ProgramCapacityError, withdrawAndReleaseHold } from "@/lib/program/capacity"; import { checkProgramAge } from "@/lib/programAge"; import { apiError } from "@/lib/api-response"; +import { assertHouseholdActive, HouseholdArchivedError } from "@/lib/household/archive"; export const POST = withAuth({}, async (req, auth, { params }: { params: Promise<{ id: string }> }) => { if (auth.type !== 'session') return apiError("Unauthorized", 401); @@ -42,6 +43,12 @@ export const POST = withAuth({}, async (req, auth, { params }: { params: Promise select: { dateOfBirth: true, isDeclaredAdult: true, householdId: true } }); + // Block enrolling a member of an archived household (shared guard) — a 4xx, + // not a 500. Applies to everyone, including a board override. + if (participantData?.householdId) { + await assertHouseholdActive(prisma, participantData.householdId); + } + let isHouseholdLead = false; if (participantData?.householdId) { const leadRecord = await prisma.person.findFirst({ @@ -148,6 +155,9 @@ export const POST = withAuth({}, async (req, auth, { params }: { params: Promise return NextResponse.json({ success: true, enrollment }); } catch (error) { + if (error instanceof HouseholdArchivedError) { + return apiError(error.message, 409); + } if (error instanceof ProgramCapacityError) { return NextResponse.json({ error: "Program has reached maximum capacity.", requiresOverride: true }, { status: 400 }); } diff --git a/checkin-app/src/app/api/scan/__tests__/scanRoute.test.ts b/checkin-app/src/app/api/scan/__tests__/scanRoute.test.ts index b61fa82ec..f0d9014f8 100644 --- a/checkin-app/src/app/api/scan/__tests__/scanRoute.test.ts +++ b/checkin-app/src/app/api/scan/__tests__/scanRoute.test.ts @@ -14,6 +14,10 @@ jest.mock('@/lib/prisma', () => { person: { findUnique: jest.fn(), }, + // Archived-household guard (assertHouseholdActive); null == active by default. + household: { + findUnique: jest.fn().mockResolvedValue(null), + }, rawBadgeLog: { create: jest.fn(), findFirst: jest.fn(), diff --git a/checkin-app/src/app/api/scan/route.ts b/checkin-app/src/app/api/scan/route.ts index 9568210e3..293d1fb32 100644 --- a/checkin-app/src/app/api/scan/route.ts +++ b/checkin-app/src/app/api/scan/route.ts @@ -4,6 +4,7 @@ import { apiError } from "@/lib/api-response"; import { processCheckin, processCheckout, finalizeFacilityClose } from "@/lib/scan-service"; import { config } from "@/lib/config"; import { withKiosk } from "@/lib/kioskAuth"; +import { assertHouseholdActive, HouseholdArchivedError } from "@/lib/household/archive"; // High cap: kiosks burst and a whole facility may share one NAT IP. withKiosk // reads the raw body, authenticates it (kiosk signature OR session), rejects @@ -58,6 +59,15 @@ export const POST = withKiosk( } } + // Block check-in for a member of an archived household (shared guard). This + // route has no catch (errors → withKiosk 500), so map the guard to a 409 here. + try { + await assertHouseholdActive(prisma, participant.householdId); + } catch (e) { + if (e instanceof HouseholdArchivedError) return apiError(e.message, 409); + throw e; + } + // Steps 4–6 (debounce read → record event → find visit → check-in/out) // are a read-modify-write on this participant's visit state. Without // serialization, two near-simultaneous scans for the same participant diff --git a/checkin-app/src/app/membership-ops/households/page.tsx b/checkin-app/src/app/membership-ops/households/page.tsx index 5bdb9de6b..e19007610 100644 --- a/checkin-app/src/app/membership-ops/households/page.tsx +++ b/checkin-app/src/app/membership-ops/households/page.tsx @@ -3,7 +3,7 @@ import { useState, useEffect, useCallback } from 'react'; import { useRouter } from 'next/navigation'; import { useRequireRole } from '@/hooks/useRequireRole'; -import { Badge, Button, Group, List, Modal, Stack, Table, Text, TextInput, Tooltip } from '@mantine/core'; +import { Badge, Button, Checkbox, Group, List, Modal, Stack, Table, Text, TextInput, Tooltip } from '@mantine/core'; import { useDisclosure } from '@mantine/hooks'; import { notifications } from '@mantine/notifications'; import { AlertBanner } from '@/components/admin/AlertBanner'; @@ -14,6 +14,7 @@ import { PageLoader } from "@/components/ui/PageLoader"; type Household = { id: number; name?: string | null; + archivedAt?: string | null; orgMembership?: { status: string } | null; householdMembers?: { id: number; name?: string | null; email?: string | null; isBoardMember?: boolean; emailUndeliverableAt?: string | null }[] | null; }; @@ -27,12 +28,14 @@ export default function AdminHouseholdsPage() { const [error, setError] = useState(""); const [editHouseholdId, setEditHouseholdId] = useState(null); const [filter, setFilter] = useState(""); + const [includeArchived, setIncludeArchived] = useState(false); const [confirmDenyOpened, { open: openConfirmDeny, close: closeConfirmDeny }] = useDisclosure(false); const [pendingDenyHouseholdId, setPendingDenyHouseholdId] = useState(null); const fetchHouseholds = useCallback(async () => { try { - const res = await fetch('/api/membership-ops/households'); + // Archived households are hidden server-side by default; the toggle asks for them. + const res = await fetch(`/api/membership-ops/households${includeArchived ? '?includeArchived=1' : ''}`); if (res.ok) { const data = await res.json(); setHouseholds(data.households); @@ -44,7 +47,7 @@ export default function AdminHouseholdsPage() { } finally { setLoading(false); } - }, []); + }, [includeArchived]); useEffect(() => { if (ready) fetchHouseholds(); @@ -108,6 +111,27 @@ export default function AdminHouseholdsPage() { await doSetDenied(householdId, true); }; + // Archive / un-archive: a soft "set aside" (distinct from Deny — no login block). + // Hides the family from board lists/crons and blocks its members from new activity. + const setArchived = async (householdId: number, archive: boolean) => { + try { + const res = await fetch('/api/membership-ops/households', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ householdId, archive }), + }); + if (res.ok) { + fetchHouseholds(); + notifications.show({ color: 'green', message: archive ? 'Household archived.' : 'Household un-archived.' }); + } else { + const data = await res.json().catch(() => ({})); + notifications.show({ color: 'red', message: data.error || 'Failed to update archive status.', autoClose: false }); + } + } catch { + notifications.show({ color: 'red', message: 'Network error.', autoClose: false }); + } + }; + if (authLoading || loading) { return ; } @@ -137,12 +161,20 @@ export default function AdminHouseholdsPage() { - setFilter(e.currentTarget.value)} - maw={400} - /> + + setFilter(e.currentTarget.value)} + maw={400} + style={{ flex: 1 }} + /> + setIncludeArchived(e.currentTarget.checked)} + /> + @@ -166,6 +198,7 @@ export default function AdminHouseholdsPage() { const ownGrantBlocked = !hasActiveMembership && me?.isSysadmin !== true && sharesHousehold(me?.householdId, household.id); const hasBrokenEmail = household.householdMembers?.some((p) => p.emailUndeliverableAt) ?? false; + const isArchived = !!household.archivedAt; return ( @@ -179,6 +212,11 @@ export default function AdminHouseholdsPage() { > {household.name || `Household #${household.id}`} + {isArchived && ( + + Archived + + )} {hasBrokenEmail && ( @@ -231,6 +269,15 @@ export default function AdminHouseholdsPage() { > + Add Participant + {isDenied ? (