Stu Mason
Stu Mason

Activity

StuMason/cleanconnect
TidyLinker.com
TypeScript
Pull Request Merged

PR #161 merged: feat: multi-day range bookings

Summary

Adele books Jamie 5am–9am Mon–Fri for 2 weeks in one form, sends one request, Jamie accepts once. Implements Option 1 (Booking series) from scratch/plans/plan-multi-day-range-bookings.md — parent BookingSeries row + N child JobPosting/Quote rows. Single-day booking flow is untouched.

What shipped

Backend

  • booking_series table + job_postings.booking_series_id FK migrations
  • BookingSeries model (uses HasUid + SoftDeletes) with client/cleaner/jobPostings relations and a BookingSeriesStatus enum (pending / accepted / declined / partially_cancelled / completed / cancelled)
  • Actions: CreateBookingSeries, AcceptBookingSeries (accept all or pass accept_job_ids to partially accept and decline the rest), DeclineBookingSeries, CancelBookingSeriesRemaining
  • CreateDirectBooking refactored — new createJobAndQuote(...) helper so single-day and series share rate/description/services logic without duplicating it
  • CancelJobPosting now rolls up to the parent series after a per-day cancel (Pending/Accepted → partially_cancelled while children remain, → cancelled when none do)
  • ReleaseHeldBookings now handles held series (one BookingSeriesRequested + one notification per series) alongside held single bookings
  • New BookingSeriesRequested and BookingSeriesAccepted events (broadcast) and matching BookingSeriesRequestedNotification / BookingSeriesAcceptedNotification
  • BookingSeriesRequest form request with date-range cap (90 days), day-of-week mask validation, unavailable_dates surfaced as a validation error so the UI can show which specific days clashed, and a series-scoped duplicate-pending check (single-day pending no longer blocks a series request, per plan §6)
  • Client + Cleaner BookingSeriesControllers, policy, BookingSeriesData DTO, routes (client.booking-series.{store,show,destroy} and cleaner.booking-series.{show,accept,decline}) — book-series endpoint is throttle:10,1 to discourage abuse from the loosened pending guard

Frontend

  • booking-dialog.tsx gains a Single day / Multiple days tab. Multi-day mode shows a DateRangePicker (new component using react-day-picker mode="range"), seven Mon–Sun chips (defaulting to weekdays), a live "N bookings, £total" preview, and a 24h time field that applies to every day
  • <AlertError> summary at the top of the dialog so 422s are surfaced (per the recently-shipped form-error pattern — the project's component is alert-error.tsx, not the FormErrorSurface name in the brief)
  • New Client/BookingSeries/Show.tsx and Cleaner/BookingSeries/Show.tsx pages — cleaner side has Accept all / Pick days / Decline all
  • Client/Jobs/Index.tsx now groups jobs sharing a booking_series_id into one "Series" row that links through to the series page; single-day jobs render as before
  • StatusBadge gains a partially_cancelled orange variant

Notifications

  • One BookingSeriesRequestedNotification per series (not N), with deep-link to /cleaner/booking-series/{uid}
  • BookingSeriesAcceptedNotification covers both full and partial acceptance; per-job DirectBookingAccepted listeners still fire so reminders/payment authorisation keep working

Tests

  • tests/Feature/BookingSeries/CreateBookingSeriesTest.php (6 tests): happy-path 10-child Mon–Fri series, day-of-week mask honoured when start is a Saturday, zero-date range → 422, >60 days → 422, unverified client → series held + no notifications, cleaner unavailable on subset of dates → unavailable_dates error with no rows created
  • tests/Feature/BookingSeries/AcceptBookingSeriesTest.php (5 tests): accept-all sets every quote to accepted and fires both BookingSeriesAccepted (×1) and DirectBookingAccepted (×10); partial accept declines the rest and flips series to partially_cancelled; non-owner cleaner gets 403; client cannot accept their own series; decline cascades to every child
  • tests/Feature/BookingSeries/CancelBookingDayTest.php (3 tests): cancelling one day leaves the other 9 intact and flips series to partially_cancelled; "cancel remaining" skips in-progress children but cancels every other live day; cancelling every live day → series cancelled
  • tests/Feature/BookingSeries/BookingSeriesAvailabilityTest.php (1 test): getBookedSlots continues to return per-day rows for series children — confirms no calendar regression

All 15 new tests pass plus all 37 existing DirectBooking tests still pass.

What's deferred (open questions from plan §10)

  • Acceptance default UX: shipped as "Accept all in one click, with a Pick days expander", per plan §10 (1). Confirm with Adele + Jamie before/after Stu sees it.
  • Cancellation refunds (§10.2): per-day cancel reuses the existing single-day path — no series-level "refund all in one click". Plan §9 flagged this as out of scope; if Adele or Stu want bulk refund, that's a follow-up.
  • Payment timing (§10.3): per-day on completion, same as today. The plan keeps the door open to upfront series payment but explicitly defers it; no Stripe changes here.
  • Message thread scope (§10.4): one thread per series, anchored on the first child job (the cheap option). A future MessageThread.subject polymorphism would let the series own the thread directly.
  • Series-level edits (§10.5): not supported — client cancels the relevant days and re-books.
  • Naming (§10.7): copy currently uses "Booking series" and "Series" pill — easy to change once Stu picks a final term.
  • Notification fatigue (§9): per-job reminders still fan out N times for a series. Should honour a digest preference; out of scope here.
  • Calendar collisions after acceptance (§9): if a cleaner becomes unavailable on day N after accepting, day N's job stays Pending — same gap as today's single-day flow.

Test plan

  • php artisan migrate:fresh --seed runs cleanly
  • As a client, open a cleaner profile, click Request Booking, toggle Multiple days, pick a 2-week range, untick Sat/Sun, fill the form, send — series row appears on /client/jobs with "10 bookings"
  • Try a Sat-only range with the mask set to weekdays — see the "no dates fall inside that range" error in the dialog
  • As the cleaner, open /cleaner/booking-series/{uid}, click Accept all — every day flips to confirmed and a BookingSeriesAccepted notification lands on the client
  • On the cleaner side, hit Pick days, untick a few, hit Accept-and-decline-rest — verify the picked days are accepted and the rest are cancelled
  • As the client, cancel one day from /client/jobs/{day} — series row shows "Partially cancelled" and other days stay live
  • As the client, click Cancel remaining on the series page — every still-cancellable day is cancelled; in-progress days stay
  • As an unverified client, submit a series — see "verify your email" notice, no cleaner notification fires; after verifying, series is released and one notification arrives

Notes for review

  • Wayfinder TypeScript files (resources/js/actions/...) are git-ignored and regenerated on build/php artisan wayfinder:generate. The PR includes php artisan wayfinder:generate-ready route definitions in routes/web.php.
  • The booking series duplicate-pending guard is intentionally narrower than the single-day one (per plan §6). The book-series route is throttled at 10/min to compensate.
+3412
additions
-132
deletions
36
files changed