Skip to content

feat(my-programs): time-scoped, audited emergency-contact access for leads#963

Open
jee7s wants to merge 1 commit into
mainfrom
feat/lead-emergency-contact-access
Open

feat(my-programs): time-scoped, audited emergency-contact access for leads#963
jee7s wants to merge 1 commit into
mainfrom
feat/lead-emergency-contact-access

Conversation

@jee7s

@jee7s jee7s commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

What & why

A program lead mentor needs a family's emergency contacts to do their job — but
only while the program is running, only for their program's families, and with
a record that the access happened. Today a lead can pull emergency contacts for
any household in any program they lead, at any time, via the manage roster
(GET /api/programs/[id]), with no time limit and no audit.

This adds a dedicated, time-scoped, audited lead surface for emergency
contacts, per the product decision.

Design doc: checkin-app/docs/designs/LEAD_EMERGENCY_CONTACT_ACCESS.md.

Decisions

  • Window = program dates ± 7 days. A lead may view a program's contacts only
    while now ∈ [startAt−7d, endAt+7d]. The buffer is a constant
    (LEAD_EC_ACCESS_BUFFER_DAYS), not config — a fixed policy, reviewed in code.
  • Null dates fail closed. No dates ⇒ no window ⇒ 403.
  • Clean 403 with an explanation for off-roster (not the program's lead),
    out-of-window, or null-date requests; the message names the window and the rule.
  • Audit-logged, one AuditLog READ row per household whose contacts are
    viewed (not per contact): action=READ, tableName=EmergencyContact,
    affectedEntityId=householdId, secondaryAffectedEntity=programId.
  • Board/sysadmin untouched — they keep /api/safety/emergency-contacts and
    its logging; no audit noise added there.
  • New route, not a boundary change. Uses withAuth + explicit field shaping
    (the strict idiom, like /api/safety and /api/household/emergency-contacts),
    so there is no src/security/** change and it ships as one feature PR. Why
    a new route over extending /api/programs/[id]: only a dedicated endpoint can
    return the clean per-program 403, and time-scoping the shared
    their_program_households scope would perturb unrelated consumers and require a
    separate boundary-isolation PR. (Tightening the existing manage-roster EC
    exposure to the same window is called out as deferred in the doc.)
  • Lead only (not core volunteers), per the decision.

Data model

No new tables/columns. One additive enum value AuditAction.READ
(migration 20260709030000_lead_ec_access, expand-only ALTER TYPE ... ADD VALUE).
routeAuthDrift EDGE_INCLUDE_ALLOWLIST gains the new route (it reads
ProgramParticipant to derive the roster households).

Surface

A Contacts subtab on /my-programs lists the caller's led programs; picking
one shows the households + emergency contacts when in-window, or the API's
time-scoping 403 message when not.

Testing

  • Unit (leadAccess.test.ts): window math — in-window, buffer edges (inclusive),
    just past the buffer, null-dates fail closed, plus the message builder.
  • Integration (myProgramsEmergencyContactsAPI.integration.test.ts, 6 cases):
    lead in-window sees the program's roster households' contacts and only those
    (no internal fields leak); out-of-window → 403 w/ message; null dates → 403;
    off-roster → 403; non-lead board member → 403 here but 200 on /safety;
    one READ audit row per household per view (a 3-contact household → 1 row;
    each view audited).
  • npx tsc --noEmit clean; eslint --max-warnings 0 clean; full pre-commit suite
    (1714 tests) green under the shared-DB CI lock.

Deferred

Tightening /api/programs/[id]'s lead EC exposure to the same window (a boundary
PR); core-volunteer access.

🤖 Generated with Claude Code

…leads

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RsD1zGYyqQFZaHT2wfkSB7
@jee7s jee7s added the post-first-release Deferred until after the first production release label Jul 7, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

post-first-release Deferred until after the first production release

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant