Skip to content

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, legacy LockoutForm page) → POST /api/checkout/session creates only a CheckoutInvitation. Reservation (startTime: null) + pending Payment + Stripe subscription appear when the customer initializes checkout; webhook (SubscriptionCreateHandler) marks Payment PAID, sets usedAt, sets startTime, fires activation. Pre-checkout invitations are invisible to staffGET /api/checkout/session?type=monthly is fully functional (list/filter/cancel/send-invitation) but no UI consumes it (useCheckoutInvitations hardcodes type: 'waitlist').
  • Waitlist invitation: staff submit (/staff/waitlist/new, legacy WaitlistForm page) → POST /api/staff/waitlist/quick-signup creates WaitlistEntry (PENDING) + Payment (PENDING) + CheckoutInvitation atomically, emails the link. Visible in the legacy Invitations tab.
  • Known gaps: waitlist invitations never get usedAt set (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)

  1. Unified invitations surface — one shared table + detail over GET /api/checkout/session with a type filter, mounted per-context (waitlist tab filtered to waitlist, reservations tab filtered to monthly). Not two bespoke UIs.
  2. Honest "Pending payment" status — initialized-but-unpaid lockout reservations stop masquerading as UPCOMING.
  3. 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 TakeoverSheet sheets (CreateLockoutSheet, CreateWaitlistSheet), not the CreateUserModal-style modals this decision originally proposed. Lockout was a sheet from the start (#808, reusing the ManageSubscriptionSheet shell); waitlist started as a CrudModal in #808 and moved to CreateWaitlistSheet when the single-table waitlist UI landed (#814), so the two create surfaces match.
  4. 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 hardcoded type: '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 WaitlistInvitationsTab internals) + new Invitations tab on reservations filtered to monthly.
  • API fix shipped with this phase (truthfulness prerequisite): set CheckoutInvitation.usedAt when a waitlist deposit pays (PaymentIntentHandler, mirroring SubscriptionCreateHandler.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)

  • CreateLockoutSheetTakeoverSheet (extracted from ManageSubscriptionSheet's bespoke shell, now shared) hosting LockoutForm create-mode, submit to POST /api/checkout/session, success state = invitation panel (URL, QR, send-email action). Retired /staff/reservations/lockout/new + CreateLockoutPage. Wired in ReservationsClient.tsx.
  • CreateWaitlistSheet — same takeover shell; existing-user-or-stub fields + location/resource/first-available prefs, submit to POST /api/staff/waitlist/quick-signup, same invitation result panel. Retired /staff/waitlist/new + CreateWaitlistPage. (Shipped first as a CrudModal in #808, then converted to the sheet in #814 to match the lockout surface.) Wired in WaitlistClient.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.ts derives pending_payment/PENDING_PAYMENT when startTime is null AND hasPaidPayment === false — the explicit-false gate matters because future-dated PAID lockouts also carry null startTime, and payloads without payment data (signal absent) keep prior behavior.
  • List API exposes hasPaidPayment on the staff path and accepts status=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 over waitlists) replaces EditWaitlistPage at /staff/waitlist/[id]; preferences edit via WaitlistPreferencesModal (CrudModal); fulfill/cancel/delete are header actions with confirm modals (cancel/delete type-to-confirm; copy distinguishes refund vs no-refund).
  • WaitlistForm is create-only; preference schema/payload/cascade extracted to WaitlistPreferenceFields and shared with the modal so create and edit can't drift.
  • API seam fix shipped with this phase: PATCH /waitlist/[id] previously coerced notes with || undefined so 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.confirm delete/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 cancelledAt on the linked CheckoutInvitation (handlePaymentIntentFailed waitlist 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/session search now filters in SQL (ILIKE over params JSON + joined user/location/resource names) before pagination; total is 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 INVITED reservation 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/new all 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. Deletes CreateResourcePage + ResourceForm + /resources/new on cutover. Note: useCreateResource must throw ApiMutationError (today throws a bare Error and CreateResourcePage bypasses it with raw fetch).
  • Tour create flow (from the original backlog) — NOT built; slot alongside the Resource create work.