From 9359c37ee164e35e7b258a283c299ea2deed9f46 Mon Sep 17 00:00:00 2001 From: Jeff Erickson <16201464+jee7s@users.noreply.github.com> Date: Tue, 7 Jul 2026 08:44:28 -0500 Subject: [PATCH] feat(my-programs): time-scoped, audited emergency-contact access for leads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A program lead mentor gets a dedicated surface to view the emergency contacts of families in a program they lead — but only while the program is running (startAt−7d .. endAt+7d) and with every view recorded. - New route GET /api/my-programs/programs/[programId]/emergency-contacts (withAuth + explicit field shaping — the strict idiom, no new boundary/ registry change). Off-roster, out-of-window, or null-date programs return a clean 403 whose message explains the time-scoping; null dates fail closed. - Audit: one AuditLog READ row per household whose contacts are viewed (not per contact). Riding the existing AuditLog needs one additive enum value — AuditAction.READ (migration 20260709030000_lead_ec_access, expand-only). - UI: a "Contacts" subtab on /my-programs listing the caller's led programs; picking one shows the households + contacts in-window, or the 403 message. - Board/sysadmin are untouched (they keep /api/safety/emergency-contacts and its logging); this adds no audit noise there. - Window math is a pure, unit-tested helper with the buffer as a constant (not config). Route registered in routeAuthDrift EDGE_INCLUDE_ALLOWLIST (it reads ProgramParticipant to derive the roster households). Design: checkin-app/docs/designs/LEAD_EMERGENCY_CONTACT_ACCESS.md Deferred (noted in the doc): tightening the existing /api/programs/[id] lead EC exposure to the same window is a separate boundary PR. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01RsD1zGYyqQFZaHT2wfkSB7 --- .../designs/LEAD_EMERGENCY_CONTACT_ACCESS.md | 148 +++++++++++++++ .../migration.sql | 5 + checkin-app/prisma/schema.prisma | 4 + ...msEmergencyContactsAPI.integration.test.ts | 168 ++++++++++++++++++ .../[programId]/emergency-contacts/route.ts | 109 ++++++++++++ .../src/app/my-programs/contacts/page.tsx | 117 ++++++++++++ checkin-app/src/app/my-programs/layout.tsx | 3 + checkin-app/src/components/pageRegistry.ts | 1 + .../__tests__/leadAccess.test.ts | 44 +++++ .../src/lib/emergencyContacts/leadAccess.ts | 45 +++++ .../tests/security/routeAuthDrift.test.ts | 1 + 11 files changed, 645 insertions(+) create mode 100644 checkin-app/docs/designs/LEAD_EMERGENCY_CONTACT_ACCESS.md create mode 100644 checkin-app/prisma/migrations/20260709030000_lead_ec_access/migration.sql create mode 100644 checkin-app/src/app/__tests__/myProgramsEmergencyContactsAPI.integration.test.ts create mode 100644 checkin-app/src/app/api/my-programs/programs/[programId]/emergency-contacts/route.ts create mode 100644 checkin-app/src/app/my-programs/contacts/page.tsx create mode 100644 checkin-app/src/lib/emergencyContacts/__tests__/leadAccess.test.ts create mode 100644 checkin-app/src/lib/emergencyContacts/leadAccess.ts diff --git a/checkin-app/docs/designs/LEAD_EMERGENCY_CONTACT_ACCESS.md b/checkin-app/docs/designs/LEAD_EMERGENCY_CONTACT_ACCESS.md new file mode 100644 index 000000000..ef5e071f7 --- /dev/null +++ b/checkin-app/docs/designs/LEAD_EMERGENCY_CONTACT_ACCESS.md @@ -0,0 +1,148 @@ +# Time-scoped emergency-contact access for program leaders + +**Status:** Implemented (this PR). Working name: **Lead EC access**. +**Date:** 2026-07-07 + +## Problem + +A program lead mentor needs a family's emergency contacts to do their job — but +only while the program is actually running, and only for the families in *their* +program. Today a lead can pull emergency contacts for any household with a child +in any program they lead, at any time, through the program-manage roster +(`GET /api/programs/[id]`, which grants leads `their_program_households:personal` +on `EmergencyContact`). There is no time limit and no record that the access +happened. + +Product decision (interview): give leads a **dedicated, time-scoped, audited** +surface for emergency contacts, so access is bounded to the program's active +window and every view leaves a trail. + +## Decisions (with rationale) + +### D1 — Access is scoped to *the program's dates + a buffer* +A lead may view a program's emergency contacts only while `now` is within +`[startAt − BUFFER, endAt + BUFFER]`. The buffer covers the ramp-up before the +first session and the wind-down after the last (a parent who needs a call the +week after camp ends). + +`BUFFER = LEAD_EC_ACCESS_BUFFER_DAYS = 7` days each side. This is a **constant** +(`src/lib/emergencyContacts/leadAccess.ts`), not configuration — the policy is a +fixed product decision, not an operator knob, so it lives in code where it is +reviewed, not in `BoardSettings`. + +### D2 — Null dates fail CLOSED +If either `startAt` or `endAt` is null, no window can be computed, so lead access +is **denied** (403). A program with no schedule is not "running", and we never +default an unbounded window open. Board/sysadmin still reach the contacts through +their existing route (D4). + +### D3 — Off-roster and out-of-window return a clean 403 +- **Off-roster** (caller is not the program's `leadMentorId`): 403 — this is the + lead surface; board/sysadmin use their own route. +- **Out-of-window / null dates**: 403 with a message that *explains the + time-scoping* (names the program's dates and the ±7-day rule), so a lead who + hits it understands why and what to do instead (ask a board member). + +### D4 — Board/sysadmin are untouched +`GET /api/safety/emergency-contacts` (sysadmin/board/keyholder) keeps its current +behavior and its current logging. This feature adds **no** audit noise there — the +read-access trail is specifically for *lead* access, which is the newly-granted, +time-bounded capability worth recording. + +### D5 — Every lead view is audit-logged, once per household (not per contact) +Each successful view writes **one `AuditLog` row per household** whose contacts +were returned: `action = READ`, `tableName = "EmergencyContact"`, +`affectedEntityId = householdId`, `secondaryAffectedEntity = programId`, +`actorId = the lead`. Riding the existing `AuditLog` (per the brief) means the +one schema change is a new `READ` value on the `AuditAction` enum — there was no +existing action that honestly represents a read. Granularity is per-household +("who saw this family's contacts") — a per-contact row would be noise, and +per-whole-request would lose which family was viewed. + +### D6 — New scoped route, not an extension of `/api/programs/[id]` +This ships as a **new route** under `/api/my-programs/`, not as an authz change to +the existing program-manage roster. Why: +- A dedicated endpoint can return the **clean 403 with a time-scoping message** + that D3 requires; the manage roster returns a whole program object and could + only silently omit contacts. +- The time-window + per-household audit are the endpoint's whole purpose; + bolting them onto the shared `their_program_households:personal` scope in the + CODEOWNERS-gated boundary layer would time-scope *unrelated* consumers of that + scope (e.g. `GET /api/trusted-adults/operational`) and would have to ship as a + separate boundary-isolation PR (`security-boundary-isolation.yml`). +- It keeps this PR a single, reviewable feature with **no `src/security/**` + boundary change. + +**Known related surface (deliberately deferred):** the existing +`GET /api/programs/[id]` manage roster still exposes a lead's program households' +emergency contacts *without* a time window (unchanged here). Tightening that path +to route all lead EC access through this window is a follow-up **boundary PR** +(it edits registry grants / scope bindings) and is out of scope for this feature +diff. This route is the sanctioned time-scoped + audited surface the product +decision calls for; the manage-roster tightening is tracked as its own change. + +### D7 — Lead only (not core volunteers) +The decision names the **program lead mentor**. Core volunteers are not granted +this surface (they can still be added later). The gate is `leadMentorId === caller`. + +## Data model + +No new tables or columns. One enum value: + +``` +enum AuditAction { CREATE EDIT DELETE BECOME_ADMIN READ } // + READ +``` + +Migration `20260709030000_lead_ec_access` is a single additive +`ALTER TYPE "AuditAction" ADD VALUE 'READ'` (expand-only; the value is not +referenced in the same migration, so `ADD VALUE` is safe — same pattern as +`20260706070000_membership_application_archive`). + +## Flow + +`GET /api/my-programs/programs/[programId]/emergency-contacts` (withAuth, session +required; explicit response shaping — the strict idiom, no scope-stripper reliance): + +1. Load the program (`id, name, leadMentorId, startAt, endAt`, and its + participants' `person → {id, name, householdId}`). 404 if missing. +2. **Off-roster gate:** `leadMentorId !== caller` → 403. +3. **Window gate:** `isWithinLeadAccessWindow(now, startAt, endAt)` false → 403 + with the time-scoping message. Null dates return false (D2). +4. Derive the distinct households of the program's participants; load each + household's **valid** emergency contacts (`conflictParticipantId = null`, name + and phone present), selecting only `{id, name, phone, email, relationship}` — + no internal fields (`phoneDigits`, `conflictParticipantId`, timestamps) ever + leave. +5. **Audit:** one `READ`/`EmergencyContact` row per household (D5). +6. Return `{ program, households: [{ householdId, householdName, participants[], + contacts[] }] }`. + +**UI:** a "Contacts" subtab on `/my-programs`. It lists the programs the caller +leads (from the `todo-counts` `lead` bucket already fetched by the nav); picking +one calls the route. In-window → the households + contacts render; out-of-window +or null-dates → the API's 403 message is shown inline as the time-scoping +explanation. (Sibling work on `feat/my-programs-info` is extending the roster +views; this tab is self-contained and only reads the new endpoint.) + +## Prod-safety + +- **Additive migration**, expand-only; no columns/data touched; `ADD VALUE` is + not referenced in-migration (safe). No contract step. +- **No boundary change** (`src/security/**` untouched) → no + `security-boundary-isolation.yml` violation; single feature PR. +- **routeAuthDrift:** the GET reads `Program.participants` (ProgramParticipant is + an edge-sensitive model), so the route is registered in + `EDGE_INCLUDE_ALLOWLIST` with a justification (admission-gated to the lead + + query-shaped to their in-window roster households). +- Fail-closed on null dates; explicit field selection (defense in depth over the + `EmergencyContact` fail-closed scope binding, which still strips these rows for + any non-lead caller). + +## Deliberately deferred + +- Tightening `GET /api/programs/[id]`'s lead EC exposure to the same window (own + boundary PR — D6). +- Core-volunteer access (D7). +- Making the buffer or the window per-program configurable (it's a constant by + decision — D1). +- Auditing board/sysadmin reads on `/api/safety/emergency-contacts` (D4). diff --git a/checkin-app/prisma/migrations/20260709030000_lead_ec_access/migration.sql b/checkin-app/prisma/migrations/20260709030000_lead_ec_access/migration.sql new file mode 100644 index 000000000..ab5f4c1d1 --- /dev/null +++ b/checkin-app/prisma/migrations/20260709030000_lead_ec_access/migration.sql @@ -0,0 +1,5 @@ +-- Read-access audit action. Used by the time-scoped lead emergency-contact view +-- (one READ/EmergencyContact AuditLog row per household a program lead views). +-- The new enum value is not referenced in this same migration, so ADD VALUE is +-- safe here. Additive/expand-only: no columns, no data touched, no index change. +ALTER TYPE "AuditAction" ADD VALUE 'READ'; diff --git a/checkin-app/prisma/schema.prisma b/checkin-app/prisma/schema.prisma index 61bf49d0d..00d156f22 100644 --- a/checkin-app/prisma/schema.prisma +++ b/checkin-app/prisma/schema.prisma @@ -32,6 +32,10 @@ enum AuditAction { EDIT DELETE BECOME_ADMIN + // A read/view of sensitive data worth a durable trail. Used by the time-scoped + // lead emergency-contact view (docs/designs/LEAD_EMERGENCY_CONTACT_ACCESS.md): + // one READ row per household whose contacts a program lead viewed. + READ } /// How a visit arrival/departure was recorded. diff --git a/checkin-app/src/app/__tests__/myProgramsEmergencyContactsAPI.integration.test.ts b/checkin-app/src/app/__tests__/myProgramsEmergencyContactsAPI.integration.test.ts new file mode 100644 index 000000000..b0db53397 --- /dev/null +++ b/checkin-app/src/app/__tests__/myProgramsEmergencyContactsAPI.integration.test.ts @@ -0,0 +1,168 @@ +/** + * @jest-environment node + */ +/** + * Integration tests for the time-scoped lead emergency-contact view + * (GET /api/my-programs/programs/[programId]/emergency-contacts). + * See docs/designs/LEAD_EMERGENCY_CONTACT_ACCESS.md. + * + * Matrix: lead in-window sees ONLY the program's roster households' contacts; + * out-of-window → 403; null dates → 403 (fail closed); off-roster/non-lead → 403; + * board unaffected (their /safety route still serves); one audit row per household + * per view (not per contact). + */ + +import { GET as EC } from "@/app/api/my-programs/programs/[programId]/emergency-contacts/route"; +import { GET as SAFETY } from "@/app/api/safety/emergency-contacts/route"; +import prisma from "@/lib/prisma"; +import { LEAD_EC_ACCESS_BUFFER_DAYS } from "@/lib/emergencyContacts/leadAccess"; +import { getServerSession } from "next-auth/next"; + +jest.mock("next-auth/next", () => ({ getServerSession: jest.fn() })); +jest.mock("@/lib/email", () => ({ sendEmail: jest.fn().mockResolvedValue(true) })); + +const TAG = "lead-ec-access-test"; +const DAY = 86_400_000; + +function as(id: number, householdId: number, roles: { isBoardMember?: boolean; isSysadmin?: boolean; isKeyholder?: boolean } = {}) { + (getServerSession as jest.Mock).mockResolvedValue({ + user: { id, householdId, isSysadmin: false, isBoardMember: false, isKeyholder: false, isBackgroundCheckReviewer: false, ...roles }, + }); +} +function get(url: string) { + return new Request(`http://localhost:4000${url}`) as never; +} +const ctx = (programId: number) => ({ params: Promise.resolve({ programId: String(programId) }) }); +async function ec(programId: number) { + return EC(get(`/api/my-programs/programs/${programId}/emergency-contacts`), ctx(programId)); +} + +async function makeHousehold(label: string, contacts: { name: string; phone: string }[]) { + const hh = await prisma.household.create({ data: { name: `${label} ${TAG}` } }); + const child = await prisma.person.create({ data: { name: `${label} child`, householdId: hh.id } }); + for (const c of contacts) { + await prisma.emergencyContact.create({ + data: { householdId: hh.id, name: c.name, phone: c.phone, phoneDigits: c.phone.replace(/\D/g, ""), priority: 0 }, + }); + } + return { hhId: hh.id, childId: child.id }; +} + +describe("Lead emergency-contact access (time-scoped)", () => { + const now = Date.now(); + let leadId = 0, leadHh = 0, boardId = 0, boardHh = 0, otherLeadId = 0, otherLeadHh = 0; + let inWindowProg = 0, outWindowProg = 0, nullDatesProg = 0, otherProg = 0, auditProg = 0; + let hhA = 0, hhB = 0, hhD = 0, hhAudit = 0; + + async function wipe() { + const hhs = await prisma.household.findMany({ where: { name: { contains: TAG } }, select: { id: true } }); + const ids = hhs.map(h => h.id); + if (ids.length) { + const people = await prisma.person.findMany({ where: { householdId: { in: ids } }, select: { id: true } }); + await prisma.programParticipant.deleteMany({ where: { personId: { in: people.map(p => p.id) } } }); + await prisma.program.deleteMany({ where: { name: { contains: TAG } } }); + await prisma.auditLog.deleteMany({ where: { tableName: "EmergencyContact", action: "READ", affectedEntityId: { in: ids } } }); + await prisma.person.deleteMany({ where: { householdId: { in: ids } } }); + await prisma.household.deleteMany({ where: { id: { in: ids } } }); // cascades emergencyContacts + } + } + + beforeAll(async () => { + await wipe(); + leadHh = (await prisma.household.create({ data: { name: `Lead HH ${TAG}` } })).id; + leadId = (await prisma.person.create({ data: { name: "Lead", householdId: leadHh } })).id; + boardHh = (await prisma.household.create({ data: { name: `Board HH ${TAG}` } })).id; + boardId = (await prisma.person.create({ data: { name: "Board", isBoardMember: true, householdId: boardHh } })).id; + otherLeadHh = (await prisma.household.create({ data: { name: `Other Lead HH ${TAG}` } })).id; + otherLeadId = (await prisma.person.create({ data: { name: "Other Lead", householdId: otherLeadHh } })).id; + + const A = await makeHousehold("A", [{ name: "A-Gran", phone: "555-0101" }, { name: "A-Aunt", phone: "555-0102" }]); + const B = await makeHousehold("B", [{ name: "B-Gran", phone: "555-0201" }]); + const C = await makeHousehold("C", [{ name: "C-Gran", phone: "555-0301" }]); + const D = await makeHousehold("D", [{ name: "D-Gran", phone: "555-0401" }]); + const AUD = await makeHousehold("AUD", [ + { name: "Aud1", phone: "555-0501" }, { name: "Aud2", phone: "555-0502" }, { name: "Aud3", phone: "555-0503" }, + ]); + hhA = A.hhId; hhB = B.hhId; hhD = D.hhId; hhAudit = AUD.hhId; + + const mk = async (name: string, leadMentorId: number, startAt: Date | null, endAt: Date | null, childId: number) => { + const p = await prisma.program.create({ data: { name: `${name} ${TAG}`, leadMentorId, startAt, endAt } }); + await prisma.programParticipant.create({ data: { programId: p.id, personId: childId, status: "ACTIVE" } }); + return p.id; + }; + inWindowProg = await mk("InWindow", leadId, new Date(now - 3 * DAY), new Date(now + 3 * DAY), A.childId); + outWindowProg = await mk("OutWindow", leadId, new Date(now - 60 * DAY), new Date(now - 40 * DAY), B.childId); + nullDatesProg = await mk("NullDates", leadId, null, null, C.childId); + otherProg = await mk("OtherLead", otherLeadId, new Date(now - 3 * DAY), new Date(now + 3 * DAY), D.childId); + auditProg = await mk("Audit", leadId, new Date(now - 3 * DAY), new Date(now + 3 * DAY), AUD.childId); + }); + + afterAll(async () => { + await wipe(); + await prisma.$disconnect(); + }); + + it("in-window: the lead sees the program's roster household contacts, and ONLY those", async () => { + as(leadId, leadHh); + const res = await ec(inWindowProg); + expect(res.status).toBe(200); + const body = await res.json(); + const hhIds = body.households.map((h: { householdId: number }) => h.householdId); + expect(hhIds).toEqual([hhA]); // only the in-window program's household + expect(hhIds).not.toContain(hhB); // out-of-window program's household + expect(hhIds).not.toContain(hhD); // another lead's household + const hh = body.households[0]; + expect(hh.contacts.map((c: { name: string }) => c.name).sort()).toEqual(["A-Aunt", "A-Gran"]); + // Explicit shaping: no internal fields leak. + expect(hh.contacts[0].phoneDigits).toBeUndefined(); + expect(hh.contacts[0].conflictParticipantId).toBeUndefined(); + }); + + it("out-of-window: 403 with a time-scoping message", async () => { + as(leadId, leadHh); + const res = await ec(outWindowProg); + expect(res.status).toBe(403); + expect((await res.json()).error).toMatch(new RegExp(`${LEAD_EC_ACCESS_BUFFER_DAYS} days`)); + }); + + it("null dates: fail closed with 403", async () => { + as(leadId, leadHh); + const res = await ec(nullDatesProg); + expect(res.status).toBe(403); + expect((await res.json()).error).toMatch(/no scheduled start and end dates/i); + }); + + it("off-roster: a lead cannot view a program they don't lead", async () => { + as(leadId, leadHh); + const res = await ec(otherProg); + expect(res.status).toBe(403); + expect((await res.json()).error).toMatch(/programs you lead/i); + }); + + it("non-lead board member is 403 on this route but unaffected on /safety", async () => { + as(boardId, boardHh, { isBoardMember: true }); + expect((await ec(inWindowProg)).status).toBe(403); + // Board's own route still serves emergency contacts. + const safety = await SAFETY(get("/api/safety/emergency-contacts")); + expect(safety.status).toBe(200); + expect(Array.isArray((await safety.json()).households)).toBe(true); + }); + + it("audits one READ row per household per view — not per contact", async () => { + const countRows = () => + prisma.auditLog.count({ where: { action: "READ", tableName: "EmergencyContact", affectedEntityId: hhAudit, actorId: leadId } }); + expect(await countRows()).toBe(0); + + as(leadId, leadHh); + expect((await ec(auditProg)).status).toBe(200); + expect(await countRows()).toBe(1); // one view → one row, though the household has 3 contacts + + const row = await prisma.auditLog.findFirst({ + where: { action: "READ", tableName: "EmergencyContact", affectedEntityId: hhAudit, actorId: leadId }, + }); + expect(row?.secondaryAffectedEntity).toBe(auditProg); + + expect((await ec(auditProg)).status).toBe(200); + expect(await countRows()).toBe(2); // each view is audited + }); +}); diff --git a/checkin-app/src/app/api/my-programs/programs/[programId]/emergency-contacts/route.ts b/checkin-app/src/app/api/my-programs/programs/[programId]/emergency-contacts/route.ts new file mode 100644 index 000000000..1079468e6 --- /dev/null +++ b/checkin-app/src/app/api/my-programs/programs/[programId]/emergency-contacts/route.ts @@ -0,0 +1,109 @@ +import { NextResponse } from "next/server"; +import prisma from "@/lib/prisma"; +import { withAuth } from "@/lib/auth"; +import { logBackendError } from "@/lib/logger"; +import { apiError } from "@/lib/api-response"; +import { isWithinLeadAccessWindow, timeScopingMessage } from "@/lib/emergencyContacts/leadAccess"; + +/** + * GET /api/my-programs/programs/[programId]/emergency-contacts + * + * Time-scoped, audited emergency-contact access for a program's LEAD mentor. + * See docs/designs/LEAD_EMERGENCY_CONTACT_ACCESS.md. + * + * - Only the program's leadMentor may call it (off-roster → 403). Board/sysadmin + * keep the un-windowed /api/safety/emergency-contacts route (untouched). + * - Access is limited to [startAt − 7d, endAt + 7d]; null dates fail closed. + * Outside the window (or null dates) → 403 with a message explaining the scoping. + * - On success, one AuditLog READ row is written per household whose contacts are + * returned (per household-contacts view, NOT per contact). + * + * Explicit response shaping (the strict idiom): only public/personal contact + * fields leave; internal fields (phoneDigits, conflictParticipantId, timestamps) + * are never selected. + */ +export const GET = withAuth( + {}, + async (_req, auth, { params }: { params: Promise<{ programId: string }> }) => { + if (auth.type !== "session") return apiError("Unauthorized", 401); + const programId = parseInt((await params).programId, 10); + if (isNaN(programId)) return apiError("Invalid program id", 400); + + try { + const program = await prisma.program.findUnique({ + where: { id: programId }, + select: { + id: true, + name: true, + leadMentorId: true, + startAt: true, + endAt: true, + participants: { + select: { person: { select: { id: true, name: true, householdId: true } } }, + }, + }, + }); + if (!program) return apiError("Program not found", 404); + + // Off-roster: this is the lead surface only. + if (program.leadMentorId !== auth.user.id) { + return apiError("You can only view emergency contacts for programs you lead.", 403); + } + + // Time-scope: fail closed on null dates, 403 outside the window. + if (!isWithinLeadAccessWindow(new Date(), program.startAt, program.endAt)) { + return apiError(timeScopingMessage(program.startAt, program.endAt), 403); + } + + // Distinct households of the program's participants (siblings share one). + const householdToPeople = new Map(); + for (const p of program.participants) { + const list = householdToPeople.get(p.person.householdId) ?? []; + if (p.person.name) list.push(p.person.name); + householdToPeople.set(p.person.householdId, list); + } + const householdIds = [...householdToPeople.keys()]; + + const households = householdIds.length + ? await prisma.household.findMany({ + where: { id: { in: householdIds } }, + select: { + id: true, + name: true, + emergencyContacts: { + where: { conflictParticipantId: null, name: { not: "" }, phone: { not: "" } }, + orderBy: [{ priority: "asc" }, { id: "asc" }], + select: { id: true, name: true, phone: true, email: true, relationship: true }, + }, + }, + }) + : []; + + // Audit: one READ row per household viewed (not per contact). + if (households.length) { + await prisma.auditLog.createMany({ + data: households.map(h => ({ + actorId: auth.user.id, + action: "READ" as const, + tableName: "EmergencyContact", + affectedEntityId: h.id, + secondaryAffectedEntity: program.id, + })), + }); + } + + return NextResponse.json({ + program: { id: program.id, name: program.name }, + households: households.map(h => ({ + householdId: h.id, + householdName: h.name, + participants: householdToPeople.get(h.id) ?? [], + contacts: h.emergencyContacts, + })), + }); + } catch (error) { + await logBackendError(error, "GET /api/my-programs/programs/[programId]/emergency-contacts"); + return apiError("Internal Server Error fetching emergency contacts.", 500); + } + }, +); diff --git a/checkin-app/src/app/my-programs/contacts/page.tsx b/checkin-app/src/app/my-programs/contacts/page.tsx new file mode 100644 index 000000000..84fe2c051 --- /dev/null +++ b/checkin-app/src/app/my-programs/contacts/page.tsx @@ -0,0 +1,117 @@ +"use client"; + +import { useState } from "react"; +import { useSession } from "next-auth/react"; +import { Alert, Anchor, Button, Card, Group, Loader, Stack, Text, Title } from "@mantine/core"; +import { IconAlertTriangle, IconPhone } from "@tabler/icons-react"; +import { useTodoCounts } from "@/hooks/useTodoCounts"; + +type Contact = { id: number; name: string; phone: string; email: string | null; relationship: string | null }; +type HouseholdContacts = { householdId: number; householdName: string; participants: string[]; contacts: Contact[] }; + +/** + * "Contacts" subtab of staff "My Programs": time-scoped emergency-contact access. + * Lists the caller's led programs (from the nav todo-counts) and, on demand, + * fetches each program's roster households + emergency contacts. When the program + * is outside its access window (or has no dates), the API returns a 403 whose + * message explains the time-scoping — shown inline here. + * See docs/designs/LEAD_EMERGENCY_CONTACT_ACCESS.md. + */ +export default function MyProgramsContacts() { + const { status } = useSession(); + const counts = useTodoCounts(status === "authenticated"); + const programs = counts?.lead?.programs ?? []; + + if (programs.length === 0) return null; // layout shows loader / redirects non-leads + + return ( + + + View emergency contacts for the families in a program you lead. Access is limited to the + program's dates (plus a week either side); outside that window, ask a board member. + + {programs.map((p) => ( + + ))} + + ); +} + +type State = + | { kind: "idle" } + | { kind: "loading" } + | { kind: "loaded"; households: HouseholdContacts[] } + | { kind: "denied"; message: string }; + +function ProgramContacts({ programId, name }: { programId: number; name: string }) { + const [state, setState] = useState({ kind: "idle" }); + + async function load() { + setState({ kind: "loading" }); + try { + const res = await fetch(`/api/my-programs/programs/${programId}/emergency-contacts`); + const body = await res.json(); + if (res.ok) setState({ kind: "loaded", households: body.households ?? [] }); + else setState({ kind: "denied", message: body.error ?? "Emergency contacts aren't available for this program." }); + } catch { + setState({ kind: "denied", message: "Couldn't load emergency contacts. Try again." }); + } + } + + return ( + + + {name} + {state.kind !== "loaded" && ( + + )} + + + {state.kind === "denied" && ( + } variant="light"> + {state.message} + + )} + + {state.kind === "loading" && } + + {state.kind === "loaded" && + (state.households.length === 0 ? ( + No participants enrolled in this program yet. + ) : ( + + {state.households.map((h) => ( +
+ {h.householdName} + {h.participants.length > 0 && ( + Participant{h.participants.length > 1 ? "s" : ""}: {h.participants.join(", ")} + )} + {h.contacts.length === 0 ? ( + No emergency contacts on file. + ) : ( + h.contacts.map((c) => ( + + + {c.name} + {c.relationship ? ` (${c.relationship})` : ""} + + {c.phone} + {c.email && {c.email}} + + )) + )} +
+ ))} +
+ ))} +
+ ); +} diff --git a/checkin-app/src/app/my-programs/layout.tsx b/checkin-app/src/app/my-programs/layout.tsx index 6a4297195..f5daccdc6 100644 --- a/checkin-app/src/app/my-programs/layout.tsx +++ b/checkin-app/src/app/my-programs/layout.tsx @@ -43,6 +43,7 @@ export default function MyProgramsLayout({ children }: { children: React.ReactNo const TABS = [ { value: "/my-programs/attendance", label: "Attendance" }, { value: "/my-programs/conflicts", label: "Conflicts" }, + { value: "/my-programs/contacts", label: "Contacts" }, ]; const active = TABS.filter((t) => pathname === t.value || pathname.startsWith(`${t.value}/`)) @@ -53,6 +54,7 @@ export default function MyProgramsLayout({ children }: { children: React.ReactNo const subtitle: Record = { "/my-programs/attendance": "Manage Attendance for Programs you lead", "/my-programs/conflicts": "Resolve Duplicate or overlapping check-ins for your programs", + "/my-programs/contacts": "View emergency contacts for families in your programs (while they're running)", }; return ( @@ -71,6 +73,7 @@ export default function MyProgramsLayout({ children }: { children: React.ReactNo > Conflicts + Contacts {subtitle[active] && {subtitle[active]}} diff --git a/checkin-app/src/components/pageRegistry.ts b/checkin-app/src/components/pageRegistry.ts index 7cf3b786e..95fef1480 100644 --- a/checkin-app/src/components/pageRegistry.ts +++ b/checkin-app/src/components/pageRegistry.ts @@ -55,6 +55,7 @@ export const PAGES: PageEntry[] = [ // "My Programs" tab above. { href: '/my-programs/attendance', label: 'My Programs (as Volunteer)', section: 'Personal', keywords: 'lead mentor program staff attendance', visible: LEADS_PROGRAM }, { href: '/my-programs/conflicts', label: 'Attendance Conflicts', section: 'Personal', keywords: 'lead mentor duplicate overlapping visit attendance', visible: LEADS_PROGRAM }, + { href: '/my-programs/contacts', label: 'Program Emergency Contacts', section: 'Personal', keywords: 'lead mentor emergency contacts family phone program', visible: LEADS_PROGRAM }, // Stays visible to all members: also the Join/renewal entry for new applicants // who aren't a household lead yet (gating it on lead would break joining). { href: '/membership', label: 'Membership Application', section: 'Personal', keywords: 'join intake', visible: SIGNED_IN }, diff --git a/checkin-app/src/lib/emergencyContacts/__tests__/leadAccess.test.ts b/checkin-app/src/lib/emergencyContacts/__tests__/leadAccess.test.ts new file mode 100644 index 000000000..f40142e9c --- /dev/null +++ b/checkin-app/src/lib/emergencyContacts/__tests__/leadAccess.test.ts @@ -0,0 +1,44 @@ +import { isWithinLeadAccessWindow, LEAD_EC_ACCESS_BUFFER_DAYS, timeScopingMessage } from "../leadAccess"; + +const DAY = 86_400_000; +const start = new Date("2026-06-01T00:00:00Z"); +const end = new Date("2026-06-30T00:00:00Z"); +const at = (d: Date, deltaDays: number) => new Date(d.getTime() + deltaDays * DAY); + +describe("isWithinLeadAccessWindow", () => { + it("is in-window during the program", () => { + expect(isWithinLeadAccessWindow(at(start, 5), start, end)).toBe(true); + }); + + it("is in-window inside the buffer before start and after end", () => { + expect(isWithinLeadAccessWindow(at(start, -LEAD_EC_ACCESS_BUFFER_DAYS + 1), start, end)).toBe(true); + expect(isWithinLeadAccessWindow(at(end, LEAD_EC_ACCESS_BUFFER_DAYS - 1), start, end)).toBe(true); + }); + + it("includes the exact buffer edges", () => { + expect(isWithinLeadAccessWindow(at(start, -LEAD_EC_ACCESS_BUFFER_DAYS), start, end)).toBe(true); + expect(isWithinLeadAccessWindow(at(end, LEAD_EC_ACCESS_BUFFER_DAYS), start, end)).toBe(true); + }); + + it("is out-of-window just beyond the buffer on either side", () => { + expect(isWithinLeadAccessWindow(at(start, -LEAD_EC_ACCESS_BUFFER_DAYS - 1), start, end)).toBe(false); + expect(isWithinLeadAccessWindow(at(end, LEAD_EC_ACCESS_BUFFER_DAYS + 1), start, end)).toBe(false); + }); + + it("fails closed when either date is null", () => { + expect(isWithinLeadAccessWindow(at(start, 5), null, end)).toBe(false); + expect(isWithinLeadAccessWindow(at(start, 5), start, null)).toBe(false); + expect(isWithinLeadAccessWindow(at(start, 5), null, null)).toBe(false); + }); +}); + +describe("timeScopingMessage", () => { + it("explains the null-dates case", () => { + expect(timeScopingMessage(null, end)).toMatch(/no\s+scheduled start and end dates/i); + }); + it("names the window and the buffer for a dated program", () => { + const msg = timeScopingMessage(start, end); + expect(msg).toContain(String(LEAD_EC_ACCESS_BUFFER_DAYS)); + expect(msg).toMatch(/only available/i); + }); +}); diff --git a/checkin-app/src/lib/emergencyContacts/leadAccess.ts b/checkin-app/src/lib/emergencyContacts/leadAccess.ts new file mode 100644 index 000000000..73ff32ed0 --- /dev/null +++ b/checkin-app/src/lib/emergencyContacts/leadAccess.ts @@ -0,0 +1,45 @@ +/** + * Time-scoping for a program lead's emergency-contact access. + * See docs/designs/LEAD_EMERGENCY_CONTACT_ACCESS.md. + */ + +// ponytail: a constant, not config. The window is a fixed product decision, not +// an operator knob — it lives in code where it's reviewed, not in BoardSettings. +export const LEAD_EC_ACCESS_BUFFER_DAYS = 7; + +const DAY_MS = 86_400_000; + +/** + * A lead may reach a program's emergency contacts only while the program is + * "live": from BUFFER days before startAt through BUFFER days after endAt. + * + * Null start OR end ⇒ no window can be computed ⇒ access DENIED (fail closed). + */ +export function isWithinLeadAccessWindow( + now: Date, + startAt: Date | null, + endAt: Date | null, + bufferDays: number = LEAD_EC_ACCESS_BUFFER_DAYS, +): boolean { + if (!startAt || !endAt) return false; + const buffer = bufferDays * DAY_MS; + const t = now.getTime(); + return t >= startAt.getTime() - buffer && t <= endAt.getTime() + buffer; +} + +/** The 403 body explaining why a lead can't see this program's contacts right now. */ +export function timeScopingMessage(startAt: Date | null, endAt: Date | null): string { + if (!startAt || !endAt) { + return ( + "Emergency contacts aren't available for this program because it has no " + + "scheduled start and end dates. Ask a board member if you need them." + ); + } + const fmt = (d: Date) => + d.toLocaleDateString("en-US", { timeZone: "America/Chicago", year: "numeric", month: "short", day: "numeric" }); + return ( + `Emergency contacts for this program are only available from ${LEAD_EC_ACCESS_BUFFER_DAYS} ` + + `days before it starts until ${LEAD_EC_ACCESS_BUFFER_DAYS} days after it ends ` + + `(${fmt(startAt)}–${fmt(endAt)}). Outside that window, ask a board member.` + ); +} diff --git a/checkin-app/tests/security/routeAuthDrift.test.ts b/checkin-app/tests/security/routeAuthDrift.test.ts index a99ce73b1..83ea69ab6 100644 --- a/checkin-app/tests/security/routeAuthDrift.test.ts +++ b/checkin-app/tests/security/routeAuthDrift.test.ts @@ -315,6 +315,7 @@ const EDGE_INCLUDE_ALLOWLIST: Record = { 'membership-audit/compliance': 'admin-role-gated — withAuth roles [sysadmin, board]; reads ProgramParticipant/Volunteer to flag people needing a background check', 'membership-ops/households': 'admin-role-gated — withAuth roles [sysadmin, board]; ?id= branch includes each member ProgramParticipant for the household detail view', 'membership-ops/participants/merge/analyze': 'admin-role-gated — sysadmin/board', + 'my-programs/programs/[programId]/emergency-contacts': "admission-gated + query-shaped — withAuth; only the program's lead mentor, within the program's date window (±7d), sees its roster households' emergency contacts. Reads ProgramParticipant to derive those households, then 403s off-roster/out-of-window (LEAD_EMERGENCY_CONTACT_ACCESS.md).", 'nav/todo-counts': 'query-shaped — counts scoped to caller (own household + programs the caller leads)', 'profile': "query-shaped — authorize 'self'; visits are the caller's own", 'profile/visits': 'query-shaped — self only (where participantId = caller)',