Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
148 changes: 148 additions & 0 deletions checkin-app/docs/designs/LEAD_EMERGENCY_CONTACT_ACCESS.md
Original file line number Diff line number Diff line change
@@ -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).
Original file line number Diff line number Diff line change
@@ -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';
4 changes: 4 additions & 0 deletions checkin-app/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
@@ -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
});
});
Loading
Loading