Skip to content

feat(programs): archive / un-archive programs (soft-archive)#954

Open
jee7s wants to merge 1 commit into
mainfrom
feat/program-archive
Open

feat(programs): archive / un-archive programs (soft-archive)#954
jee7s wants to merge 1 commit into
mainfrom
feat/program-archive

Conversation

@jee7s

@jee7s jee7s commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

What & why

Adds a reversible soft-archive for programs so the board can retire a
mistaken / cancelled / duplicate / long-dead program from the active surfaces
without deleting it (which would destroy the participant history the ops surfaces
still need). Design: checkin-app/docs/designs/PROGRAM_ARCHIVE.md.

New field Program.archivedAt DateTime? (@sensitivity:internal). NULL = live;
non-null = archived.

Decisions (from the product interview)

  • Orthogonal to phase. phase/FINISHED is a lifecycle fact (did it run?);
    archived is a visibility/activity decision (should we still surface it?). A
    program in any phase can be archived. A separate nullable timestamp keeps both
    facts independently true and makes un-archive a trivial NULL set that restores
    the prior phase.
  • Board/sysadmin only, surfaced on the program-ops program page as an
    Archive / Un-archive button. It rides the existing PATCH /api/programs/[id] via
    an archived boolean gated to isSysadmin || isBoardMember (same gate the
    Shopify-identifier fields already use) — no new route, no new route-auth surface.
  • Hidden from default lists / pickers / dashboards (public catalog, program-ops
    list with an "Include archived" toggle, session & trends pickers, nav
    todo-counts) via a shared NOT_ARCHIVED where-fragment. includeArchived=true is
    board-gated; archivedAt is tier internal so it is stripped from the
    GET /api/programs/[id] response for everyone but board/sysadmin.
    • Deliberately not hidden: a member's own enrollment view (/api/programs/mine)
      — that's enrollment history, not a catalog.
  • New activity blocked (409, before any Shopify side effect): enrollment,
    payment-plan/scholarship request, and volunteer signup. The guard fires before
    any inventory hold/decrement, so a blocked action can never leak Shopify inventory
    (respects the Shopify single-pool capacity + scholarship lifecycle inventory (supersedes two-pool mirror) #930 single-pool capacity model). A maxParticipants edit still saves
    to the DB but is not pushed to Shopify while archived. Enrollment blocks even a board
    force-enroll override (archived is a hard freeze, not an over-ridable soft limit).
    • Not blocked: reading rosters/history, participant removal (an exit, not new
      activity), metadata/phase edits. Lead-mentor read scope is unchanged
      (buildCallerContext untouched) — history stays readable exactly as before.
  • Crons skip archived programs — full touched/not-touched table in the design doc.
    Changed: pending-participants, scholarship-grace-expiry, post-event/nightly
    post-event leg. Untouched: nightly facility-close leg, membership-renewals,
    person-bg-annual, trusted-adult-expiry.
  • No Shopify-side archive in this PR. Archiving a program should eventually also
    archive its Shopify listing / zero inventory — that's a sibling PR. Integration
    point (the archived transition in PATCH /api/programs/[id]) is noted in the doc.

Data model / migration

20260708020000_program_archive: one additive, nullable column, no backfill, no
index. NULL = "not archived", so every existing program keeps its current behavior.
Expand-only.

Testing

  • New programArchive.integration.test.ts (8 cases): board archive stamps
    archivedAt → hidden from default list (board can opt in; member can't) → blocked
    self-enroll (409, no row) → blocked board force-enroll override → blocked
    payment-plan with no -1 Shopify inventory side effect (dev-mock log asserted
    absent, inventoryHeldAt stays null) → blocked volunteer signup → un-archive
    restores list visibility + enrollment → cron-skip: pending-participants sweep
    leaves an archived program's stale row but kicks a live one.
  • Full pre-commit suite green: 206 suites / 1676 tests, plus tsc --noEmit and
    eslint --max-warnings 0.

🤖 Generated with Claude Code

Add Program.archivedAt (soft-archive, @sensitivity:internal), orthogonal to
ProgramPhase: FINISHED is a lifecycle fact, archived is a board visibility/
activity decision. Board/sysadmin archive & un-archive on the program-ops
program page (rides PATCH /api/programs/[id] via an `archived` flag).

Archived programs are hidden from default lists/pickers/dashboards (public
catalog, program-ops list with an "Include archived" toggle, session/trends
pickers, nav todo-counts), and frozen for new activity — enrollment,
payment-plan/scholarship requests, and volunteer signup return 409 before any
Shopify inventory hold/decrement (no inventory leak), and a maxParticipants
edit no longer pushes to Shopify. Crons skip archived programs
(pending-participants, scholarship-grace-expiry, post-event). Existing roster/
history stays readable; lead read-scope is unchanged. No Shopify-side archive
in this PR (sibling PR; integration point noted in the design doc).

Shared filter/guard in src/lib/program/archive.ts. See
docs/designs/PROGRAM_ARCHIVE.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@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