169 lines
11 KiB
Markdown
169 lines
11 KiB
Markdown
# Booking Lifecycle — Design Spec
|
||
|
||
Status: **draft for sign-off** · Date: 2026-05-21 · Phase: lifecycle state model + admin overview UI + email service shell + daily cron.
|
||
|
||
Decisions locked with the owner:
|
||
- **Milestone columns** model (lean status enum; contract/deposit tracked as columns + the existing `invoice` row) — not extra statuses, not a long-running workflow.
|
||
- **Admin records everything.** No client-facing UI this phase.
|
||
- **Deadline anchor = contract email send date** (the 1-year-before-start mark = day 0).
|
||
- **On 14-day unpaid: flag for admin review only.** No auto-cancel, no auto-revert; soft reservation stays.
|
||
- Email = **service shell** (named methods that log + no-op). Real templates later.
|
||
|
||
---
|
||
|
||
## 1. Status enum (the "bucket")
|
||
|
||
Reshaped from the current 7 states down to 4 active + 1 terminal. `form_received` and `deposit_paid` are **removed** — those moments become milestone columns / derived facts.
|
||
|
||
| State | Meaning |
|
||
|---|---|
|
||
| `enquiry` | Client submitted an enquiry; awaiting admin approve/decline. |
|
||
| `reserved` | Dates soft-reserved (block others). The whole contract/deposit dance happens **inside** this state. |
|
||
| `confirmed` | Admin recorded deposit received → reservation guaranteed. |
|
||
| `completed` | Event has taken place. |
|
||
| `cancelled` | Terminal. Declined enquiry, or cancelled at any non-terminal stage. |
|
||
|
||
### Transitions (server-enforced, audited)
|
||
|
||
| From | To | Driver |
|
||
|---|---|---|
|
||
| `enquiry` | `reserved` | MANUAL — admin approves |
|
||
| `enquiry` | `cancelled` | MANUAL — admin declines |
|
||
| `reserved` | `confirmed` | MANUAL — admin records deposit received |
|
||
| `reserved` | `cancelled` | MANUAL — admin cancels (e.g. after overdue flag, or contract declined) |
|
||
| `confirmed` | `completed` | AUTOMATIC — daily cron when `endDate < today` (admin may also force) |
|
||
| `confirmed` | `cancelled` | MANUAL — late cancellation (refunds out of scope) |
|
||
| `completed` / `cancelled` | — | terminal |
|
||
|
||
`cancelled` reachable from every non-terminal state. Every transition writes an `auditLog` row (existing format).
|
||
|
||
---
|
||
|
||
## 2. New milestone columns on `booking` (migration 0006)
|
||
|
||
All nullable ISO-8601 TEXT (matching `created_at` style). camelCase ↔ snake_case.
|
||
|
||
| Column (TS) | Type | Meaning |
|
||
|---|---|---|
|
||
| `contractSentAt` | TEXT NULL | When contract+deposit email was sent. **Day-0 anchor** + cron idempotency guard. |
|
||
| `contractResponse` | TEXT NULL | `'approved' \| 'declined' \| null` (null = pending). Admin-recorded. |
|
||
| `contractResponseAt` | TEXT NULL | When admin recorded the response. |
|
||
| `depositReminderSentAt` | TEXT NULL | Day-7 reminder sent (idempotency guard). |
|
||
| `flaggedAt` | TEXT NULL | Set when something needs admin review (deposit overdue / short window / contract declined). |
|
||
| `flagReason` | TEXT NULL | `'deposit_overdue' \| 'short_window' \| 'contract_declined'`. |
|
||
|
||
**Deposit lives on the existing `invoice` table** (no duplicate columns):
|
||
- Deposit invoice: `kind='deposit'`, created by cron R1.
|
||
- Due date = `invoice.dueOn` = `contractSentAt + 14d`.
|
||
- Received = `invoice.paidOn IS NOT NULL` (+ `status='paid'`).
|
||
|
||
On contract approval, also stamp the existing `agbAcceptedAt` / `agbYear` (the contract IS the AGB acceptance).
|
||
|
||
**Data remap** (migration + seed): `form_received → reserved`; `deposit_paid → confirmed` (with its deposit invoice marked paid). Migration includes `UPDATE booking SET status=... WHERE status IN ('form_received','deposit_paid')` for safety on any non-reset DB.
|
||
|
||
---
|
||
|
||
## 3. Daily cron — `booking-lifecycle-daily`
|
||
|
||
`wireScheduler({ name: 'booking-lifecycle-daily', schedule: '0 6 * * *', func })`. All date math in **Europe/Berlin** local calendar days. Idempotent (safe to run twice) via the `*SentAt IS NULL` / `flaggedAt IS NULL` guards.
|
||
|
||
For each booking where `status NOT IN ('cancelled','completed')`:
|
||
|
||
```
|
||
R1 Contract dispatch (≈1yr before start, evaluated daily with <=, not ==)
|
||
if reserved and contractSentAt is null and (startDate - today) <= 365d:
|
||
create deposit invoice (dueOn = today + 14d)
|
||
email.sendContractAndDepositEmail(bookingId)
|
||
set contractSentAt = now
|
||
|
||
R2 Day-7 reminder
|
||
if reserved and contractSentAt set and depositReminderSentAt is null
|
||
and deposit unpaid and (today - contractSentAt) >= 7d:
|
||
email.sendDepositReminderEmail(bookingId)
|
||
set depositReminderSentAt = now
|
||
|
||
R3 Day-14 expiry → FLAG ONLY
|
||
if reserved and contractSentAt set and deposit unpaid
|
||
and flaggedAt is null and (today - contractSentAt) >= 14d:
|
||
set flaggedAt = now, flagReason = 'deposit_overdue'
|
||
|
||
R4 Auto-complete
|
||
if confirmed and endDate < today:
|
||
setBookingStatus(bookingId, 'completed') // system
|
||
```
|
||
|
||
Using `<=` for R1 means a booking approved *inside* the 1-year window sends on the next daily tick (handles "approved late → send soon" for free). If `startDate` is < ~14d away at approval, flag `short_window` instead of running a deadline that lands past the event.
|
||
|
||
---
|
||
|
||
## 4. Email service shell
|
||
|
||
A singleton `email` service (registered in `services.ts`, added to `SingletonServices`), each method logs + no-ops for now.
|
||
|
||
| Method | Fires when | Driver |
|
||
|---|---|---|
|
||
| `sendEnquiryReceivedEmail(bookingId)` | enquiry submitted | enquiry-create RPC |
|
||
| `sendEnquiryDeclinedEmail(bookingId)` | admin declines enquiry | `setBookingStatus enquiry→cancelled` |
|
||
| `sendReservationConfirmedEmail(bookingId)` | admin approves → reserved | `setBookingStatus enquiry→reserved` |
|
||
| `sendContractAndDepositEmail(bookingId)` | day 0 (cron R1) | cron |
|
||
| `sendDepositReminderEmail(bookingId)` | day 7 unpaid (cron R2) | cron |
|
||
| `sendBookingConfirmedEmail(bookingId)` | admin records deposit received | `recordDepositReceived` |
|
||
| `sendCancellationEmail(bookingId)` | admin cancels a non-enquiry booking | `setBookingStatus → cancelled` |
|
||
|
||
Day-14 expiry sends **no** client email (flag only).
|
||
|
||
---
|
||
|
||
## 5. Backend RPCs
|
||
|
||
| Action | RPC | New/reuse | Effects |
|
||
|---|---|---|---|
|
||
| Approve enquiry | `setBookingStatus(id,'reserved')` | reshape | + reservation email; flag `short_window` if start < ~14d |
|
||
| Decline enquiry | `setBookingStatus(id,'cancelled')` | reshape | + declined email |
|
||
| Record contract response | `recordContractResponse(id, 'approved'\|'declined')` | **NEW** | sets `contractResponse(_at)`; approved → `agbAcceptedAt`/`agbYear`; declined → `flaggedAt`/`flagReason='contract_declined'` |
|
||
| Mark deposit received | `recordDepositReceived(id)` | **NEW** | `invoice.paidOn`+`status='paid'`; clear `flaggedAt`; → `confirmed`; + confirmed email |
|
||
| Cancel (any stage) | `setBookingStatus(id,'cancelled')` | reshape | deposit invoice → `cancelled`; + cancellation email |
|
||
| Mark completed (override) | `setBookingStatus(id,'completed')` | reshape | normally cron R4 |
|
||
| (Optional) Send contract now | `triggerContractEmail(id)` | **NEW (small)** | runs R1 immediately — useful for testing + early sends; otherwise the 1-yr wait blocks all manual flows |
|
||
|
||
`setBookingStatus` fires the email matching the transition it owns; milestone side-effects (deposit, contract response, AGB) live in the dedicated `record*` RPCs. All `record*` RPCs write audit rows.
|
||
|
||
`getAdminBookingDetail` output extends with: `contractSentAt`, `contractResponse`, `contractResponseAt`, `depositReminderSentAt`, `flaggedAt`, `flagReason`, and a deposit-invoice summary (`dueOn`, `paidOn`) to drive the UI time cues.
|
||
|
||
### `nextAction(booking, depositInvoice)` — derived, drives the UI CTA
|
||
|
||
Priority order: enquiry→"Approve or decline" (admin) · flagged→`flagReason` (admin) · reserved+no contractSentAt→"Awaiting 1-yr mark" (system) · contractSentAt+no response→"Client to sign contract" (client) · approved+unpaid→"Client to pay deposit" (client, due `invoice.dueOn`) · declined→"Cancel — contract declined" (admin) · confirmed→"Awaiting event" (system) · terminal→none.
|
||
|
||
---
|
||
|
||
## 6. Admin overview UI — `BookingStatusPanel`
|
||
|
||
New `<Paper>` panel mounted **above the Tabs**, below the header, in `admin.bookings.$bookingId.tsx`. Header `Badge` swapped for `StatusPill`. Lift the transition map into `lib/status.ts` (`BOOKING_TRANSITIONS`) and reuse in both the panel and the existing `StatusChanger`; trim `BookingStatus` type + `BOOKING_STATUS` palette to the 5 states.
|
||
|
||
Layout: `SimpleGrid cols={{ base:1, md:2 }}` — **Timeline left**, **Next-action + transitions right**.
|
||
|
||
- **Timeline (Mantine `Timeline`)** shows milestone progression (richer than the enum): `Enquiry → Reserved → Contract sent → Contract signed → Deposit paid → Confirmed → Completed`, each completed step with its date; current = plum ring; upcoming = gray hollow; `cancelled` = terminal red item replacing the unreached tail.
|
||
- **Next-action `Alert`** — single most important outstanding item from `nextAction()`, color/icon by urgency, with the inline action button + "Owner: Admin/Organiser".
|
||
- **Transition controls** — one `Button` per valid transition (data-driven off `BOOKING_TRANSITIONS`); destructive (cancel/decline) separated below a `Divider`, red outline; reuse the existing confirm `Modal` for terminal transitions.
|
||
- **Time cues** (from `contractSentAt`): "Day X of 14" + `Progress` bar (plum ≤6, yellow 7–13, red ≥14 unpaid); reminder-sent `Badge`; overdue → prominent red `Alert` at top of panel + "Needs review" pill by the header, booking stays `reserved`.
|
||
- **Empty/early states**: contract not yet due (start >1yr out) → gray info alert "Contract email scheduled ~{date}"; cancelled/completed → terminal note, no actions; reserved but no deposit invoice yet → link to invoices tab.
|
||
|
||
i18n under `pages.adminBooking.status.*` (panelTitle, nextAction.*, actions.*, cues.*, empty.*, party.*), reusing existing `pages.bookingStatus.label.*`. Mirror en + de. Responsive: timeline stacks above actions on `base`; transition buttons `fullWidth` on narrow.
|
||
|
||
---
|
||
|
||
## 7. Build order
|
||
|
||
1. Migration 0006 (milestone columns + status remap) + seed remap → `db reset` + regen.
|
||
2. `lib/status.ts` trim + `BOOKING_TRANSITIONS`; update `set-booking-status` enum/transitions/emails.
|
||
3. Email service shell (`services/email.ts`) + register + `SingletonServices` type.
|
||
4. New RPCs: `recordContractResponse`, `recordDepositReceived`, `triggerContractEmail`; extend `getAdminBookingDetail`.
|
||
5. Daily cron `booking-lifecycle-daily` + scheduler wiring.
|
||
6. `BookingStatusPanel` component + mount + header pill swap + i18n (en/de).
|
||
7. Typecheck, smoke-test cron + transitions.
|
||
|
||
## Open points (defaults chosen; flag if you disagree)
|
||
- **Auto-complete** `confirmed → completed` via cron when the event has passed (admin can still force). Default: **yes, automatic.**
|
||
- **`triggerContractEmail`** manual admin action included (otherwise the 1-year wait blocks all manual/testing flows). Default: **include.**
|
||
- `deposit_paid` seed/data rows remap to **`confirmed`** (deposit was received).
|