diff --git a/apps/backend/internal/bookings/customer_service.go b/apps/backend/internal/bookings/customer_service.go index 3bc02d6..50d8f50 100644 --- a/apps/backend/internal/bookings/customer_service.go +++ b/apps/backend/internal/bookings/customer_service.go @@ -73,6 +73,12 @@ func (s *CustomerService) managementURL(booking db.BookingRecord) string { } func (s *CustomerService) descriptors(ctx context.Context, booking db.BookingRecord) (serviceName string, locationName string) { + if booking.ServiceID == nil && booking.ClassSessionID != nil { + // Class bookings carry no service; the session title is the label. + if session, err := s.repo.GetClassSessionByID(ctx, *booking.ClassSessionID); err == nil { + serviceName = session.Title + } + } if booking.ServiceID != nil { if services, err := s.repo.ListServicesByTenant(ctx, booking.TenantID); err == nil { for _, service := range services { @@ -119,6 +125,10 @@ func (s *CustomerService) GetBookingByReference(ctx context.Context, reference s CustomerEmail: booking.CustomerEmail, Service: serviceName, BusinessName: tenant.Name, + TenantSlug: tenant.Slug, + ServiceID: booking.ServiceID, + StaffID: booking.StaffID, + ClassSessionID: booking.ClassSessionID, StartsAt: booking.StartsAt, EndsAt: booking.EndsAt, Location: locationName, @@ -142,6 +152,18 @@ func (s *CustomerService) RescheduleBooking(ctx context.Context, reference strin return ErrBookingCancelled } + // Only live bookings may move: waitlisted entries sit in a queue tied to + // their session, and completed bookings are history. + if booking.Status == "waitlisted" || booking.Status == "completed" { + return ErrInvalidReschedule + } + + // Class bookings are bound to their session window; moving to another + // session means cancelling and booking again. + if booking.ClassSessionID != nil { + return ErrInvalidReschedule + } + newStartsAt, err := time.Parse(time.RFC3339, req.NewStartsAt) if err != nil { return ErrInvalidReschedule @@ -174,16 +196,14 @@ func (s *CustomerService) RescheduleBooking(ctx context.Context, reference strin } } - // Appointments must land inside bookable hours and off blocked days; - // class bookings keep their session-defined window. - if booking.ClassSessionID == nil { - tenant, err := s.repo.GetTenantByID(ctx, booking.TenantID) - if err != nil { - return err - } - if err := validateAppointmentTarget(ctx, s.repo, tenant, booking.StaffID, booking.LocationID, newStartsAt, newEndsAt); err != nil { - return ErrInvalidReschedule - } + // Appointments must land inside bookable hours and off blocked days. + // Class bookings never reach this point — rejected above. + tenant, err := s.repo.GetTenantByID(ctx, booking.TenantID) + if err != nil { + return err + } + if err := validateAppointmentTarget(ctx, s.repo, tenant, booking.StaffID, booking.LocationID, newStartsAt, newEndsAt); err != nil { + return ErrInvalidReschedule } if err := s.repo.RescheduleBooking(ctx, booking.ID, newStartsAt, newEndsAt); err != nil { diff --git a/apps/backend/internal/bookings/customer_service_test.go b/apps/backend/internal/bookings/customer_service_test.go index cdd5cf8..761dd7f 100644 --- a/apps/backend/internal/bookings/customer_service_test.go +++ b/apps/backend/internal/bookings/customer_service_test.go @@ -111,3 +111,41 @@ func TestRescheduleRejectsConflictingSlot(t *testing.T) { t.Fatalf("expected ErrBookingConflict, got %v", err) } } + +func TestRescheduleRejectsClassWaitlistedAndCompleted(t *testing.T) { + repo := db.NewMemoryRepository() + svc := NewCustomerService(repo, nil, "http://localhost:3000") + sessionID := "4bf74c12-44dd-45ca-86bb-b104f16f2435" + future := time.Now().Add(48 * time.Hour) + + cases := []struct { + name string + status string + sessionID *string + }{ + {"class booking", "confirmed", &sessionID}, + {"waitlisted booking", "waitlisted", &sessionID}, + {"completed booking", "completed", nil}, + } + for i, tc := range cases { + _, err := repo.CreateBooking(context.Background(), db.CreateBookingParams{ + TenantID: "5d6b3551-0a3e-4b86-bdf0-e9df20a47148", + ClassSessionID: tc.sessionID, + StartsAt: future, + EndsAt: future.Add(time.Hour), + Status: tc.status, + Reference: "BOK-RS-" + string(rune('A'+i)), + ManageToken: "tok", + }) + if err != nil { + t.Fatalf("%s: create booking: %v", tc.name, err) + } + err = svc.RescheduleBooking(context.Background(), "BOK-RS-"+string(rune('A'+i)), domain.RescheduleBookingRequest{ + NewStartsAt: future.Add(2 * time.Hour).Format(time.RFC3339), + NewEndsAt: future.Add(3 * time.Hour).Format(time.RFC3339), + }, "tok") + if err != ErrInvalidReschedule { + t.Fatalf("%s: expected ErrInvalidReschedule, got %v", tc.name, err) + } + } +} diff --git a/apps/backend/internal/domain/models.go b/apps/backend/internal/domain/models.go index 87aeff4..1626609 100644 --- a/apps/backend/internal/domain/models.go +++ b/apps/backend/internal/domain/models.go @@ -490,6 +490,12 @@ type CustomerBookingView struct { CustomerEmail string `json:"customerEmail"` Service string `json:"service"` BusinessName string `json:"businessName"` + TenantSlug string `json:"tenantSlug,omitempty"` + ServiceID *string `json:"serviceId,omitempty"` + StaffID *string `json:"staffId,omitempty"` + // ClassSessionID identifies session-bound bookings; they cannot be + // rescheduled to arbitrary times (see RescheduleBooking). + ClassSessionID *string `json:"classSessionId,omitempty"` StartsAt time.Time `json:"startsAt"` EndsAt time.Time `json:"endsAt"` Location string `json:"location"` diff --git a/apps/backend/openapi/bookra.openapi.yaml b/apps/backend/openapi/bookra.openapi.yaml index 4e0ad4d..5a40d63 100644 --- a/apps/backend/openapi/bookra.openapi.yaml +++ b/apps/backend/openapi/bookra.openapi.yaml @@ -346,6 +346,10 @@ paths: responses: "200": description: Booking details + content: + application/json: + schema: + $ref: "#/components/schemas/CustomerBookingView" "403": description: Invalid manage token "404": @@ -360,29 +364,37 @@ paths: required: true schema: type: string + - in: query + name: token + required: true + schema: + type: string + description: Manage token issued at booking time requestBody: required: true content: application/json: schema: type: object - required: [token, startsAt] + required: [newStartsAt, newEndsAt] properties: - token: - type: string - startsAt: + newStartsAt: type: string format: date-time - endsAt: + newEndsAt: type: string format: date-time + reason: + type: string responses: "200": description: Booking rescheduled + "400": + description: Invalid new window, non-reschedulable status, or class booking "403": description: Invalid manage token "409": - description: New slot conflicts + description: New slot conflicts or booking already cancelled /v1/public/bookings/{reference}/cancel: post: tags: [Public Booking] @@ -1770,6 +1782,46 @@ components: waitlistPosition: type: integer description: 1-based queue spot for waitlisted class bookings; absent otherwise + CustomerBookingView: + type: object + properties: + reference: + type: string + customerName: + type: string + customerEmail: + type: string + service: + type: string + businessName: + type: string + tenantSlug: + type: string + serviceId: + type: string + format: uuid + staffId: + type: string + format: uuid + classSessionId: + type: string + format: uuid + description: Present for session-bound bookings, which cannot be rescheduled to arbitrary times + startsAt: + type: string + format: date-time + endsAt: + type: string + format: date-time + location: + type: string + status: + type: string + notes: + type: string + waitlistPosition: + type: integer + description: 1-based queue spot for waitlisted class bookings; absent otherwise DashboardBookingRequest: type: object required: [customerName, customerEmail, startsAt] diff --git a/apps/frontend/src/routes/booking-manage-route.tsx b/apps/frontend/src/routes/booking-manage-route.tsx index 908bc29..efc370e 100644 --- a/apps/frontend/src/routes/booking-manage-route.tsx +++ b/apps/frontend/src/routes/booking-manage-route.tsx @@ -1,5 +1,5 @@ import { useParams, useSearchParams } from "@solidjs/router"; -import { createSignal, createResource, Show } from "solid-js"; +import { createSignal, createResource, For, Show } from "solid-js"; import { apiClient } from "../lib/api-client"; import { useI18n } from "../providers/i18n-provider"; import { BookraCharacter } from "../components/bookra-character"; @@ -12,7 +12,8 @@ export function BookingManageRoute() { const [isRescheduling, setIsRescheduling] = createSignal(false); const [isCancelling, setIsCancelling] = createSignal(false); const [newDate, setNewDate] = createSignal(""); - const [newTime, setNewTime] = createSignal(""); + const [selectedSlot, setSelectedSlot] = createSignal<{ startsAt: string; endsAt: string } | null>(null); + const [rescheduleBusy, setRescheduleBusy] = createSignal(false); const [message, setMessage] = createSignal(""); const [actionSuccess, setActionSuccess] = createSignal(null); const [actionError, setActionError] = createSignal(null); @@ -20,7 +21,7 @@ export function BookingManageRoute() { const token = () => searchParams.token || ""; const reference = () => params.reference || ""; - const [booking] = createResource(token, async (t) => { + const [booking, { refetch: refetchBooking }] = createResource(token, async (t) => { if (!t) return null; const ref = reference(); if (!ref) return null; @@ -49,12 +50,30 @@ export function BookingManageRoute() { }; }); + const [slots] = createResource( + () => [booking()?.tenantSlug, newDate(), booking()?.serviceId, booking()?.staffId] as const, + async ([slug, date, serviceId, staffId]) => { + if (!slug || !date) return []; + const query = new URLSearchParams({ date }); + if (serviceId) query.set("serviceId", serviceId); + if (staffId) query.set("staffId", staffId); + try { + const response = await (apiClient as any).GET(`/v1/public/tenants/${slug}/availability?${query}`); + const list = (response.data?.slots ?? []) as any[]; + return list.filter((s) => s.mode === "appointment"); + } catch { + return []; + } + }, + ); + const handleReschedule = async () => { setActionSuccess(null); setActionError(null); - - if (!newDate() || !newTime()) { - setActionError(i18n.locale() === 'cs' ? 'Vyberte prosím nové datum a čas' : 'Please select a new date and time'); + + const slot = selectedSlot(); + if (!slot) { + setActionError(i18n.locale() === 'cs' ? 'Vyberte prosím volný termín' : 'Please pick an available slot'); return; } @@ -65,32 +84,36 @@ export function BookingManageRoute() { return; } - // Calculate new start and end times - const newStart = new Date(`${newDate()}T${newTime()}`); - const newEnd = new Date(newStart.getTime() + 60 * 60 * 1000); // Default 1 hour duration - + setRescheduleBusy(true); try { const response = await (apiClient as any).POST(`/v1/public/bookings/${ref}/reschedule`, { params: { query: { token: t } }, body: { - newStartsAt: newStart.toISOString(), - newEndsAt: newEnd.toISOString(), + newStartsAt: slot.startsAt, + newEndsAt: slot.endsAt, reason: message() } }); if (response.error) { - setActionError(i18n.locale() === 'cs' ? 'Změna termínu selhala' : 'Reschedule failed'); + setActionError( + (response as any).response?.status === 409 + ? (i18n.locale() === 'cs' ? 'Tento termín byl právě obsazen, vyberte jiný' : 'That slot was just taken — please pick another') + : (i18n.locale() === 'cs' ? 'Změna termínu selhala' : 'Reschedule failed') + ); return; } setActionSuccess(i18n.locale() === 'cs' ? 'Rezervace byla přeplánována' : 'Booking rescheduled successfully'); setIsRescheduling(false); setNewDate(""); - setNewTime(""); + setSelectedSlot(null); setMessage(""); + void refetchBooking(); } catch (err) { setActionError(i18n.locale() === 'cs' ? 'Změna termínu selhala' : 'Reschedule failed'); + } finally { + setRescheduleBusy(false); } }; @@ -313,33 +336,44 @@ export function BookingManageRoute() { {/* Action Buttons */} -
- - -
+ +
+ + + + +
+ +

+ {i18n.locale() === 'cs' + ? 'Termín lekce je pevně daný. Pro jiný termín rezervaci zrušte a objednejte jinou lekci.' + : 'Class times are fixed. To switch sessions, cancel this booking and book another class.'} +

+
+
@@ -393,28 +427,54 @@ export function BookingManageRoute() {
-
-
- - setNewDate(e.currentTarget.value)} - /> -
-
- - setNewTime(e.currentTarget.value)} - /> -
+
+ + { setNewDate(e.currentTarget.value); setSelectedSlot(null); }} + />
+ +
+ + …
+ }> + 0} fallback={ +

+ {i18n.locale() === 'cs' ? 'V tento den nejsou volné termíny.' : 'No available times on this day.'} +

+ }> +
+ + {(slot: any) => { + const isSelected = () => selectedSlot()?.startsAt === slot.startsAt; + return ( + + ); + }} + +
+
+
+
+
diff --git a/packages/api-client/src/generated/types.ts b/packages/api-client/src/generated/types.ts index 7645620..cf682ca 100644 --- a/packages/api-client/src/generated/types.ts +++ b/packages/api-client/src/generated/types.ts @@ -1120,6 +1120,32 @@ export interface components { notes?: string; phone?: string; }; + CustomerBookingView: { + businessName?: string; + /** + * Format: uuid + * @description Present for session-bound bookings, which cannot be rescheduled to arbitrary times + */ + classSessionId?: string; + customerEmail?: string; + customerName?: string; + /** Format: date-time */ + endsAt?: string; + location?: string; + notes?: string; + reference?: string; + service?: string; + /** Format: uuid */ + serviceId?: string; + /** Format: uuid */ + staffId?: string; + /** Format: date-time */ + startsAt?: string; + status?: string; + tenantSlug?: string; + /** @description 1-based queue spot for waitlisted class bookings; absent otherwise */ + waitlistPosition?: number; + }; DashboardBookingRequest: { /** Format: email */ customerEmail: string; @@ -3186,7 +3212,9 @@ export interface operations { headers: { [name: string]: unknown; }; - content?: never; + content: { + "application/json": components["schemas"]["CustomerBookingView"]; + }; }; /** @description Invalid manage token */ 403: { @@ -3239,7 +3267,10 @@ export interface operations { }; rescheduleBooking: { parameters: { - query?: never; + query: { + /** @description Manage token issued at booking time */ + token: string; + }; header?: never; path: { reference: string; @@ -3250,10 +3281,10 @@ export interface operations { content: { "application/json": { /** Format: date-time */ - endsAt?: string; + newEndsAt: string; /** Format: date-time */ - startsAt: string; - token: string; + newStartsAt: string; + reason?: string; }; }; }; @@ -3265,6 +3296,13 @@ export interface operations { }; content?: never; }; + /** @description Invalid new window, non-reschedulable status, or class booking */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; /** @description Invalid manage token */ 403: { headers: { @@ -3272,7 +3310,7 @@ export interface operations { }; content?: never; }; - /** @description New slot conflicts */ + /** @description New slot conflicts or booking already cancelled */ 409: { headers: { [name: string]: unknown; diff --git a/project.md b/project.md index f253a84..9a84eca 100644 --- a/project.md +++ b/project.md @@ -138,6 +138,14 @@ - demo mode ships a seeded session with a populated roster; mutation attempts surface the demo notice instead of failing - verified live in demo mode: seeded a full session via the public booking API, roster listed 4 confirmed + 1 waitlisted, promote rejected with 409, `allowOverCapacity` promoted and drained the queue; browser-checked CS/EN rendering with a clean console - remaining: live staging validation on Neon + pgx repository path +- `[done/partial]` Milestone 21: Customer self-service rebooking + - `CustomerBookingView` now carries `tenantSlug`, `serviceId`, `staffId`, `classSessionId` — everything the manage page needs to query real availability; class bookings resolve the session title as their service label instead of rendering blank + - `RescheduleBooking` hardened: rejects `waitlisted`/`completed` bookings and any class booking (session-bound times cannot move to arbitrary windows; switching sessions is cancel-and-rebook); the previously dead `ClassSessionID == nil` branch collapsed into a straight appointment validation + - manage page reschedule dialog replaced free-form date+time inputs with a real slot picker: pick a date → `GET /v1/public/tenants/{slug}/availability` (filtered by the booking's service/staff) → time buttons; the slot carries its own duration so the hardcoded 60-minute assumption is gone; 409 surfaces "slot just taken" instead of a generic failure + - action buttons are now status-gated: reschedule shows only for confirmed/pending appointments, cancel for any live booking, and class bookings get an explanatory note instead of a broken button + - `CustomerBookingView` schema added to OpenAPI and referenced from `GET /v1/public/bookings/{reference}`; the reschedule contract documents the class-booking 400 and slot-conflict 409 + - verified live: booked an appointment via public API, rescheduled through the real manage page in a browser (slot grid → 200 → card shows new time); class booking reschedule returns 400 + - remaining: live staging validation on Neon + pgx repository path ## Overview Bookra is a Czech-first booking SaaS for small local service businesses. It targets salons, massage therapists, small clinics, repair shops, and small studios/clubs that need a simple public booking flow, internal schedule management, reminders, and basic business reporting without becoming a full ERP. @@ -388,7 +396,7 @@ Key entities: Shortlist of worthwhile directions once v1 stabilizes — kept deliberately small, in line with the "no feature sprawl" principle: - **Activity/audit log**: a per-tenant feed of who changed what (booking created/cancelled/promoted, settings edits). Natural fit for the notification dropdown and useful for multi-staff accountability. -- **Customer self-service rebooking**: let customers move to a different free slot from the manage page instead of cancel-and-rebook; the reschedule path already validates targets, so this is mostly UI plus a picker. +- **Session-to-session move for classes**: M21 covered appointment rebooking; class customers currently must cancel and book another session. A "move to another session of the same template" flow (target session picker + capacity/waitlist handling) would close the gap. - **Calendar integrations**: ICS feed per tenant/staff and optional Google Calendar sync; high demand for salons, moderate complexity. Start with a read-only `.ics` subscription URL — zero third-party auth needed. - **Recurring class schedules**: templates currently schedule sessions one at a time; a weekly recurrence rule with per-session overrides would cut setup time for studios. - **Booking notes intake**: structured intake fields per service (e.g. "first visit?", "injuries?") instead of the single free-text notes field — still schema-light.