Reservation INVITED Status — Lockout Invitations Adopt the Waitlist Model
Status: Phases 1+2 SHIPPED (#815, single PR per the no-stacking lesson); migration + cutover deployed to prod (backfill recent → INVITED, stale-invitation report + sign-off + cancel sequence run). Phase 3 docs largely done (business/lockouts + engineering reservation pages reflect the lifecycle). Of the Phase 3 backlog, the staff expiry notification SHIPPED (#834); the rest remains open. Builds on the waitlist status model (docs/features/waitlist-status-model/plan.md) — same shape: record exists from invitation, checkout promotes it, single-table staff UI.
Decisions (Aaron, 2026-06-11)
- Reservation created at invitation time. Staff creating a lockout invitation creates the Reservation row immediately in an
INVITEDstate.initialize-lockoutbecomes promote-not-create. This is what makes the UI collapse possible — pre-checkout, the reservation (not the invitation) is the record staff interact with. - The invitation holds the studio. An INVITED reservation blocks the resource exactly like an active one. No double-inviting a studio; staff cancel releases it.
- Expiry: 7 days. A hold this strong needs tight expiry. At expiry: reservation →
EXPIRED, linked invitation cancelled, studio released, staff notified (a prospect went cold — that's actionable). - Resending the invitation email resets the 7-day clock.
Why this is 10× the waitlist version
A waitlist entry is a leaf record. A reservation row is load-bearing: resource availability, conflict checks, calendars, access codes, Acuity, subscription billing, dashboards, analytics views, and member surfaces all key off it. Every reservation consumer must decide what INVITED means to it. The hold decision (#2) simplifies most of the sweep — availability and conflict paths treat INVITED as occupying — but billing, side effects, access codes, member views, and analytics must all exclude it.
Design decisions (locked with Aaron 2026-06-11)
- Enum. Reservation has no status enum today — lifecycle is derived (
stripeSubscriptionStatus,cancelledAt,startTime/billingStartDate; web derives upcoming/current/past/cancelled/pending_payment). We addstatus ReservationStatuswith valuesINVITED | CONFIRMED | EXPIRED, defaultCONFIRMED, backfill all existing rowsCONFIRMED. INVITED/EXPIRED short-circuit the derived display states; CONFIRMED rows keep today's derivation unchanged. - Cutover for existing open invitations — review before cancel. Prod has ~665 monthly invitations, 457 used, ~208 open — most months old and dead. Backfill creates INVITED reservations only for invitations created in the last 7 days. The stale rest are NOT auto-cancelled in the migration: a report script lists them (recipient, studio, age, custom amount) for Aaron's review first; the mass-cancel runs as a separate script after his sign-off, with one digest notification, not per-invitation emails.
- Member visibility: yes. INVITED reservations appear in the member's account, mirroring the waitlist "Invitation sent" card.
- Scope: monthly/lockout invitations only. Hourly is always self-service — hourly checkout sessions are not staff invitations and keep their flow untouched. Migration invitations (separate
migration_invitationstable) likewise.
Phase 1 — Schema + status semantics (API) (SHIPPED #815)
ReservationStatusenum (INVITED | CONFIRMED | EXPIRED),statuscolumn defaultCONFIRMED,inviteExpiresAt DateTime?. Separate migrations: enum/column → backfill (existing rowsCONFIRMED; open invitations ≤7 days old → INVITED reservations). Stale-invitation cleanup is scripts, not migration: report script for Aaron's review, then cancel script after sign-off (per decision 2). Same migrate-prod-before-merge choreography as the waitlist stack.- Staff lockout-invitation creation (
POST /checkout/session, type monthly) creates the Reservation (INVITED, staff-set params: resource, user/recipient, startDate, custom amounts, insurance, access code intent) + links the invitation;inviteExpiresAt = now + 7d. mgId allocated at creation (accepted: unaccepted invitations consume mgIds, same as waitlist). initialize-lockoutwithcheckoutInvitationId: find the INVITED reservation and promote (create subscription, stamp billing fields, status → CONFIRMED on payment per existing webhook flow) instead of creating. Self-serve/non-invitation checkouts still create at initialize — two creation paths, deliberate. Frozen money path: test-first, phased, reviewed.- Side effects (access codes, Acuity, availability recompute, emails) stay post-payment — unchanged; they already don't run at creation.
- Availability/conflict sweep: INVITED occupies the resource (
ResourceAvailabilityService, initialize conflict checks, calendars). Excludes: billing/proration, access-code activation, Acuity, member "current reservations" counts (separate display), dashboards (INVITED counted separately, not as active), analytics views. - Cancel cascades both ways: cancel invitation ↔ expire/cancel INVITED reservation (mirror waitlist; consider centralizing as the service method the waitlist version deferred).
- Expiry cron (
process-expired-reservation-invitations, mirror waitlist cron):status = INVITED AND inviteExpiresAt < now→ EXPIRED + invitation cancelled + studio released + staff notification. Resend-email endpoint resetsinviteExpiresAt.
Phase 2 — Single-table UI (web) (SHIPPED #815)
/staff/reservationsdrops the Invitations tab; one reservations table, status filter gains Invited/Expired; copy-link + resend-email row actions on INVITED rows (list payload enriched with invitation id/url — same payments/params linkage pattern as waitlist).- Lockout invitation detail collapses into reservation detail:
/staff/invitations/[id]redirects monthly-type invitations with a resolvable reservation (hourly + legacy fall back toInvitationDetail). Reservation detail gains copy-link/resend header actions + invitation status when INVITED; derived Payment pending chip (INVITED + payment PROCESSING) comes free from the waitlist work. - Member account shows INVITED reservations ("Invitation sent" card, mirroring waitlist).
Phase 3 — Docs + sweep + deploy (SHIPPED #815)
- Business + engineering reservation docs rewritten for the lifecycle (
docs/business/lockouts/index.md,docs/engineering/domains/reservations/); expiry policy documented — done. - Test sweep across reservation fixtures (the big one — reservations are the largest test surface in the repo) — done with #815.
- Deploy: enum/backfill migrations run on prod immediately before merge; cutover scripts (report → sign-off → cancel-stale) executed — done.
Phase 3 backlog (from build + /simplify reviews, 2026-06-11)
- ~~Staff notification email on expiry~~ SHIPPED #834 (
notify-staff-invitation-expiry) — the expiry cron now notifies staff instead of expiring silently. - ~~Member-facing "Invitation sent" card~~ SHIPPED — INVITED reservations now render in the account-overview reservations table ("Current" tab) with an "Invitation sent" status, the expiry date, and a "Complete checkout" link.
listForUsergained an opt-inincludeInvitedflag (the member overview is the only caller passing it); display statusinvitedadded toReservationCarouselReservationData. Surfaced as a table row, not a literal card — the live member surface is the reservations table, not the (dead) carousel/WaitlistCard. ReservationInvitationServiceconsolidating create/cancel/expire/promote (lifecycle currently spans the session route, cancel route, ReservationService cron fn, and initialize-lockout — consolidation deferred to avoid re-opening the reviewed money path before the cutover; revisit as a focused refactor PR after this ships, with the existing test suite as the net).- Web display-status debt: extend
ReservationDisplayStatuswith invited/expired and retire the pending_payment shim + the dualstatus/reservationStatusdetail fields; migrate calendar/popover consumers. - Convert the other 8 cron routes to the shared
createCronHandlerfactory; codebase-wide toast-action helper (13+ call sites). - Lockout form deposit preview full parity via
POST /checkout/preview(client now mirrors the eligible statuses but still can't see the redeemed-deposit payment linkage; do this in the planned lockout form rebuild).
Done 2026-06-11 (this branch): occupied-studio invite guard (409 unless the tenant is departing); studio reference invited status; TableCells.StatusBadge colorMap fix; lockout form deposit preview recognizes INVITED/PENDING entries.
Out of scope
- Hourly invitation flows; migration invitations; pseudo-rows or union lists (the whole point is real rows); changing self-serve checkout creation timing.