Staff Invitations & Waitlist — Implementation Plan
Status: Phases 1–5 SHIPPED (#807, #808, and the waitlist-status-model recovery #814). Scope agreed with Aaron 2026-06-10; supersedes the earlier "resource create flow next" ordering — resources are rarely created manually; invitations (lockout + waitlist) are the regularly-created records, so they went first. Resource create + tour create remain the only outstanding items (see Deferred). One correction reconciled 2026-06-18: create flows shipped as sheets, not modals (decision #3 below).
Context (verified against live code 2026-06-10)
Both staff-initiated flows share one record: CheckoutInvitation (ADR-008 rename of CheckoutSession; DB table still checkout_sessions). No status column — derived: cancelledAt set → cancelled, usedAt set → used, neither → active. Payments link to it; Reservation/WaitlistEntry link only through Payment.
- Lockout invitation: staff submit (
/staff/reservations/lockout/new, legacyLockoutFormpage) →POST /api/checkout/sessioncreates only a CheckoutInvitation. Reservation (startTime: null) + pending Payment + Stripe subscription appear when the customer initializes checkout; webhook (SubscriptionCreateHandler) marks Payment PAID, setsusedAt, sets startTime, fires activation. Pre-checkout invitations are invisible to staff —GET /api/checkout/session?type=monthlyis fully functional (list/filter/cancel/send-invitation) but no UI consumes it (useCheckoutInvitationshardcodestype: 'waitlist'). - Waitlist invitation: staff submit (
/staff/waitlist/new, legacyWaitlistFormpage) →POST /api/staff/waitlist/quick-signupcreates WaitlistEntry (PENDING) + Payment (PENDING) + CheckoutInvitation atomically, emails the link. Visible in the legacy Invitations tab. - Known gaps: waitlist invitations never get
usedAtset (only the lockout webhook path sets it — the "Used" filter is dead for waitlist); CheckoutInvitation has no expiry; WaitlistEntry 30-day expiry (process-expired) is a manual endpoint, not cron'd; a customer-initialized-but-unpaid lockout shows in the reservations table as UPCOMING.
Decisions (Aaron, 2026-06-10)
- Unified invitations surface — one shared table + detail over
GET /api/checkout/sessionwith a type filter, mounted per-context (waitlist tab filtered towaitlist, reservations tab filtered tomonthly). Not two bespoke UIs. - Honest "Pending payment" status — initialized-but-unpaid lockout reservations stop masquerading as UPCOMING.
- Create flows are in-context overlays with a result state — form → invitation result (link + QR + "email sent"), not redirect-to-detail. As-built: both shipped as
TakeoverSheetsheets (CreateLockoutSheet,CreateWaitlistSheet), not the CreateUserModal-style modals this decision originally proposed. Lockout was a sheet from the start (#808, reusing theManageSubscriptionSheetshell); waitlist started as aCrudModalin #808 and moved toCreateWaitlistSheetwhen the single-table waitlist UI landed (#814), so the two create surfaces match. - Waitlist detail + status table views are in scope — waitlist joins the redesign (was outside staff-ui-redesign v1's three-entity lock).
Phases
Phase 1 — Unified invitations surface (IMPLEMENTED 2026-06-10, #807)
- Generalize
useCheckoutInvitations(drop hardcodedtype: 'waitlist'; accept type/status/search params). - Shared
InvitationsTable(status chip from derived status, type, recipient, created, actions: copy link, resend email, cancel) + invitation detail view (params summary, linked payment/entry/reservation when they exist, cancel/resend). - Mount: waitlist Invitations tab (replaces
WaitlistInvitationsTabinternals) + new Invitations tab on reservations filtered tomonthly. - API fix shipped with this phase (truthfulness prerequisite): set
CheckoutInvitation.usedAtwhen a waitlist deposit pays (PaymentIntentHandler, mirroringSubscriptionCreateHandler.ts:215). Without it the unified status filter lies for waitlist.
Phase 2 — Create flows (the daily-use payoff) (IMPLEMENTED 2026-06-10, #808; waitlist moved to a sheet with #814)
CreateLockoutSheet—TakeoverSheet(extracted fromManageSubscriptionSheet's bespoke shell, now shared) hostingLockoutFormcreate-mode, submit toPOST /api/checkout/session, success state = invitation panel (URL, QR, send-email action). Retired/staff/reservations/lockout/new+CreateLockoutPage. Wired inReservationsClient.tsx.CreateWaitlistSheet— same takeover shell; existing-user-or-stub fields + location/resource/first-available prefs, submit toPOST /api/staff/waitlist/quick-signup, same invitation result panel. Retired/staff/waitlist/new+CreateWaitlistPage. (Shipped first as aCrudModalin #808, then converted to the sheet in #814 to match the lockout surface.) Wired inWaitlistClient.tsx.- The invitation-result panel is one shared component (used by both, linkable to Phase 1 detail).
Phase 3 — Honest pending-payment status (IMPLEMENTED 2026-06-10)
reservation-status.tsderivespending_payment/PENDING_PAYMENTwhenstartTimeis null ANDhasPaidPayment === false— the explicit-false gate matters because future-dated PAID lockouts also carry nullstartTime, and payloads without payment data (signal absent) keep prior behavior.- List API exposes
hasPaidPaymenton the staff path and acceptsstatus=pending_payment(cancelledAt null + startTime null + no PAID payment); filter option added; warning chip on table + detail header; calendar unaffected (skips null-startTime rows).
Phase 4 — Waitlist entries rebuild (IMPLEMENTED 2026-06-10)
WaitlistEntryDetail(EntityHeader, Details/Deposit/System panes, Activity overwaitlists) replacesEditWaitlistPageat/staff/waitlist/[id]; preferences edit viaWaitlistPreferencesModal(CrudModal); fulfill/cancel/delete are header actions with confirm modals (cancel/delete type-to-confirm; copy distinguishes refund vs no-refund).WaitlistFormis create-only; preference schema/payload/cascade extracted toWaitlistPreferenceFieldsand shared with the modal so create and edit can't drift.- API seam fix shipped with this phase:
PATCH /waitlist/[id]previously coercednoteswith|| undefinedso notes could never be cleared; explicit null now persists (empty string still normalizes to null). - Entries list already conformed (EntityTable + status/location/resource filters); left as-is. Its row-level
window.confirmdelete/fulfill matches the current reservations-list convention — sweep both if that convention changes.
Phase 5 — Data-layer hygiene (IMPLEMENTED 2026-06-10)
WaitlistEntryService.processExpired(already fully implemented: unpaid PENDING >30d per business policy, Stripe PI cancellation, succeeded-PI guard) is now wired to a daily Vercel cron (/api/cron/process-expired-waitlist, 11:00 UTC). The manual staff endpoint remains.- Failed or expired waitlist deposits now stamp
cancelledAton the linked CheckoutInvitation (handlePaymentIntentFailedwaitlist branch +processExpired; guarded, never overwrites). Scoped to waitlist only — reservation-linked invitations stay active so the lockout payment-retry path keeps working (covered by test). Frozen-path change, made test-first. GET /api/checkout/sessionsearch now filters in SQL (ILIKE over params JSON + joined user/location/resource names) before pagination;totalis accurate and pages past 1 work.- ~~Open decision~~ RESOLVED — yes, lockout invitations expire. Answered by the reservation-invited-status arc (#815): a monthly/lockout invitation now creates an
INVITEDreservation that holds the studio and expires after 7 days (reservation →EXPIRED, invitation cancelled, studio released). Staff are notified on expiry (#834,notify-staff-invitation-expiry). Resending the invite resets the 7-day clock.
Deferred (the only outstanding work — verified against code 2026-06-18)
- Resource create — NOT built;
CreateResourcePage+ResourceForm+/staff/resources/newall still live. Middle-path agreed: a create surface (name/type/location + optional collapsed Dimensions section) → redirect to detail where the Details/Physical/Pricing modals finish the job. Follow the sheet convention the lockout/waitlist creates landed on. DeletesCreateResourcePage+ResourceForm+/resources/newon cutover. Note:useCreateResourcemust throwApiMutationError(today throws a bare Error andCreateResourcePagebypasses it with raw fetch). - Tour create flow (from the original backlog) — NOT built; slot alongside the Resource create work.