From 82a933f6cd891971658004622bd39d0bf5d6912e Mon Sep 17 00:00:00 2001 From: NuklearRabbit <145918611+NuklearRabbit@users.noreply.github.com> Date: Mon, 10 Aug 2026 22:23:11 +0200 Subject: [PATCH] M33: enforce booking readiness workflow --- PROJECT_STATE.md | 12 +++ backend/app/api/routers/bookings.py | 37 +++++++++ backend/app/models/booking.py | 2 +- backend/app/schemas.py | 6 +- backend/tests/test_bookings.py | 38 +++++++++- frontend/src/api/client.ts | 36 ++++++--- frontend/src/i18n/brusselsDateTime.ts | 46 ++++++++++++ frontend/src/i18n/locales/en-GB/bookings.json | 10 +++ frontend/src/i18n/locales/fr-BE/bookings.json | 10 +++ frontend/src/i18n/locales/nl-BE/bookings.json | 10 +++ frontend/src/pages/BookingCreate.tsx | 75 +++++++++++-------- frontend/src/pages/BookingDetail.tsx | 34 ++++++++- 12 files changed, 273 insertions(+), 43 deletions(-) create mode 100644 frontend/src/i18n/brusselsDateTime.ts diff --git a/PROJECT_STATE.md b/PROJECT_STATE.md index d797fc0..3dad520 100644 --- a/PROJECT_STATE.md +++ b/PROJECT_STATE.md @@ -2750,3 +2750,15 @@ evidence yet." all five scenarios ready; the last action after reset was the read-only Hub proof. - Exact next action: none for the requested scope; keep the public synthetic demo online and monitor its existing health, backup and integration evidence surfaces. + +## M33 — explicit booking readiness and Brussels-safe planning (2026-08-10) + +- New reservations now default to incomplete requirements. Checkout remains unavailable + until an operator records a deliberate requirements confirmation; that transition and + its bounded evidence are persisted in the audit trail. +- Booking creation and availability use Europe/Brussels wall-clock conversion independent + of the visitor's browser timezone. Customer and vehicle searches cancel stale requests, + use a bounded timeout and expose server-side vehicle filtering up to 50 results. +- Validation: isolated booking API suite **12 passed**; backend Ruff clean; frontend + TypeScript lint and production build passed. +- Exact next action: add database invariants/indexes and harden the shared public demo reset. diff --git a/backend/app/api/routers/bookings.py b/backend/app/api/routers/bookings.py index c6e46dc..365d999 100644 --- a/backend/app/api/routers/bookings.py +++ b/backend/app/api/routers/bookings.py @@ -20,6 +20,7 @@ from app.schemas import ( CancelBookingRequest, CheckoutBookingRequest, CheckoutBookingResult, + CompleteBookingRequirementsRequest, CreateBookingRequest, CurrentUser, NextBookingRisk, @@ -342,6 +343,42 @@ def get_booking( return _to_out(booking, customer, vehicle) +@router.post("/{public_ref}/complete-requirements", response_model=BookingOut) +def complete_booking_requirements( + public_ref: str, + body: CompleteBookingRequirementsRequest, + db: Session = Depends(get_db), + user: CurrentUser = Depends(get_current_user), +) -> BookingOut: + booking = db.scalar(select(Booking).where(Booking.public_ref == public_ref).with_for_update()) + if booking is None: + raise HTTPException(status_code=404, detail="Booking not found") + if booking.status != "reserved": + raise HTTPException( + status_code=409, + detail="Requirements can only be confirmed for a reserved booking", + ) + customer = db.get(Customer, booking.customer_id) + vehicle = db.get(Vehicle, booking.vehicle_id) + if customer is None or vehicle is None: + raise HTTPException(status_code=500, detail="Booking references a missing record") + if not booking.requirements_complete: + booking.requirements_complete = True + record_audit_event( + db, + actor_type="user", + actor_label=user.display_name, + action="booking_requirements_completed", + entity_type="booking", + entity_id=booking.id, + before={"requirements_complete": False}, + after={"requirements_complete": True}, + metadata={"confirmation": body.confirmation.strip()}, + ) + db.commit() + return _to_out(booking, customer, vehicle) + + @router.post("/{public_ref}/cancel", response_model=BookingOut) def cancel_booking( public_ref: str, diff --git a/backend/app/models/booking.py b/backend/app/models/booking.py index 939d851..44114c1 100644 --- a/backend/app/models/booking.py +++ b/backend/app/models/booking.py @@ -26,4 +26,4 @@ class Booking(UUIDPrimaryKeyMixin, TimestampMixin, Base): status: Mapped[str] = mapped_column(String(20), nullable=False) start_odometer_km: Mapped[int | None] = mapped_column(Integer) end_odometer_km: Mapped[int | None] = mapped_column(Integer) - requirements_complete: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) + requirements_complete: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 2ee13e5..8161244 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -92,7 +92,11 @@ class CreateBookingRequest(BaseModel): vehicle_ref: str = Field(min_length=3, max_length=20) starts_at: datetime ends_at: datetime - requirements_complete: bool = True + requirements_complete: bool = False + + +class CompleteBookingRequirementsRequest(BaseModel): + confirmation: str = Field(min_length=3, max_length=500) class CustomerOptionOut(BaseModel): diff --git a/backend/tests/test_bookings.py b/backend/tests/test_bookings.py index 0270a09..d9b8d7e 100644 --- a/backend/tests/test_bookings.py +++ b/backend/tests/test_bookings.py @@ -73,6 +73,37 @@ def test_create_booking_rejects_overlap_and_audits_valid_booking(ops_client): body = created.json() assert body["status"] == "reserved" assert body["vehicle_ref"] == available_vehicle + assert body["requirements_complete"] is False + + +def test_booking_requirements_are_explicit_and_audited(ops_client): + window = {"starts_at": "2031-09-01T10:00:00Z", "ends_at": "2031-09-02T12:00:00Z"} + vehicle = ops_client.get("/api/v1/bookings/availability", params=window).json()[0] + booking = ops_client.post( + "/api/v1/bookings", + json={"customer_ref": "CUS-0001", "vehicle_ref": vehicle["public_ref"], **window}, + ).json() + checkout = ops_client.post( + f"/api/v1/bookings/{booking['public_ref']}/checkout", + json={ + "start_odometer_km": 100000, + "fuel_level_percent": 90, + "cleanliness_ok": True, + "damage_reported": False, + "technical_warning": False, + }, + ) + assert checkout.status_code == 409 + + confirmed = ops_client.post( + f"/api/v1/bookings/{booking['public_ref']}/complete-requirements", + json={"confirmation": "Licence and rental conditions checked"}, + ) + assert confirmed.status_code == 200 + assert confirmed.json()["requirements_complete"] is True + audit = ops_client.get("/api/v1/audit", params={"action": "booking_requirements_completed"}) + assert audit.status_code == 200 + assert any(item["entity_ref"] == booking["public_ref"] for item in audit.json()) def test_customer_search_returns_canonical_customers(ops_client): @@ -158,7 +189,12 @@ def test_checkout_records_inspection_and_activates_safe_booking(ops_client): vehicle = ops_client.get(f"/api/v1/vehicles/{vehicle_option['public_ref']}").json() booking = ops_client.post( "/api/v1/bookings", - json={"customer_ref": "CUS-0001", "vehicle_ref": vehicle["public_ref"], **window}, + json={ + "customer_ref": "CUS-0001", + "vehicle_ref": vehicle["public_ref"], + "requirements_complete": True, + **window, + }, ).json() response = ops_client.post( f"/api/v1/bookings/{booking['public_ref']}/checkout", diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 757d274..49c73fe 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -12,15 +12,33 @@ export function onUnauthorized(listener: UnauthorizedListener): () => void { return () => unauthorizedListeners.delete(listener); } +const DEFAULT_TIMEOUT_MS = 15_000; + async function request(path: string, init?: RequestInit): Promise { - const response = await fetch(`${API_BASE}${path}`, { - ...init, - credentials: "include", - headers: { - "Content-Type": "application/json", - ...(init?.headers ?? {}), - }, - }); + const timeoutController = new AbortController(); + const timeout = window.setTimeout(() => timeoutController.abort("timeout"), DEFAULT_TIMEOUT_MS); + const signal = init?.signal + ? AbortSignal.any([init.signal, timeoutController.signal]) + : timeoutController.signal; + let response: Response; + try { + response = await fetch(`${API_BASE}${path}`, { + ...init, + signal, + credentials: "include", + headers: { + "Content-Type": "application/json", + ...(init?.headers ?? {}), + }, + }); + } catch (error) { + if (timeoutController.signal.aborted && !init?.signal?.aborted) { + throw new ApiError(408, "REQUEST_TIMEOUT", "The request took too long.", ""); + } + throw error; + } finally { + window.clearTimeout(timeout); + } if (!response.ok) { let body: { error?: { code: string; message: string; correlation_id: string } } | undefined; @@ -48,7 +66,7 @@ async function request(path: string, init?: RequestInit): Promise { } export const api = { - get: (path: string) => request(path), + get: (path: string, init?: Pick) => request(path, init), post: (path: string, body?: unknown, headers?: Record) => request(path, { method: "POST", body: body ? JSON.stringify(body) : undefined, headers }), patch: (path: string, body: unknown) => diff --git a/frontend/src/i18n/brusselsDateTime.ts b/frontend/src/i18n/brusselsDateTime.ts new file mode 100644 index 0000000..93e0523 --- /dev/null +++ b/frontend/src/i18n/brusselsDateTime.ts @@ -0,0 +1,46 @@ +const BRUSSELS_TIME_ZONE = "Europe/Brussels"; + +const partsFormatter = new Intl.DateTimeFormat("en-CA", { + timeZone: BRUSSELS_TIME_ZONE, + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hourCycle: "h23", +}); + +function partsAt(value: Date): Record { + return Object.fromEntries( + partsFormatter.formatToParts(value) + .filter((part) => part.type !== "literal") + .map((part) => [part.type, part.value]), + ); +} + +export function toBrusselsDateTimeLocal(value: Date): string { + const parts = partsAt(value); + return `${parts.year}-${parts.month}-${parts.day}T${parts.hour}:${parts.minute}`; +} + +export function brusselsDateTimeFromNow(hours: number): string { + const value = new Date(Date.now() + hours * 60 * 60 * 1000); + value.setUTCMinutes(0, 0, 0); + return toBrusselsDateTimeLocal(value); +} + +/** Convert a Fleet Ops Europe/Brussels wall-clock value to an unambiguous UTC instant. */ +export function brusselsLocalToIso(value: string): string { + const match = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})$/.exec(value); + if (!match) throw new Error("Invalid Brussels date-time"); + const [, year, month, day, hour, minute] = match; + const wallClockUtc = Date.UTC(+year, +month - 1, +day, +hour, +minute); + let candidate = wallClockUtc; + for (let iteration = 0; iteration < 3; iteration += 1) { + const parts = partsAt(new Date(candidate)); + const represented = Date.UTC(+parts.year, +parts.month - 1, +parts.day, +parts.hour, +parts.minute); + candidate += wallClockUtc - represented; + } + return new Date(candidate).toISOString(); +} diff --git a/frontend/src/i18n/locales/en-GB/bookings.json b/frontend/src/i18n/locales/en-GB/bookings.json index 4a24604..8d674f5 100644 --- a/frontend/src/i18n/locales/en-GB/bookings.json +++ b/frontend/src/i18n/locales/en-GB/bookings.json @@ -53,6 +53,9 @@ "searchFirst": "Search for a customer first", "chooseCustomer": "Select a customer", "vehicle": "Available vehicle", + "vehicleSearch": "Search vehicle", + "vehiclePlaceholder": "Reference, make, model, registration or location", + "locationUnknown": "location unknown", "loadingVehicles": "Checking availability…", "chooseVehicle": "Select a vehicle", "noVehicles": "No vehicle available for this window", @@ -102,6 +105,13 @@ "startOdometerPending": "Not yet recorded — trip hasn't started yet", "endOdometerPending": "Not yet recorded — not yet closed", "requirementsComplete": "Requirements complete", + "requirementsAction": "Check requirements", + "requirementsActionDetail": "The departure inspection remains locked until the driving licence and rental requirements are explicitly confirmed.", + "requirementsConfirmation": "Check evidence", + "requirementsConfirmationPlaceholder": "Record what was checked", + "requirementsConfirm": "Confirm requirements", + "requirementsSaving": "Confirming…", + "requirementsFailed": "The requirements could not be confirmed.", "yes": "Yes", "no": "No", "cancelAction": "Cancel booking", diff --git a/frontend/src/i18n/locales/fr-BE/bookings.json b/frontend/src/i18n/locales/fr-BE/bookings.json index e2a8edb..2b0c9ba 100644 --- a/frontend/src/i18n/locales/fr-BE/bookings.json +++ b/frontend/src/i18n/locales/fr-BE/bookings.json @@ -53,6 +53,9 @@ "searchFirst": "Recherchez d'abord un client", "chooseCustomer": "Sélectionnez un client", "vehicle": "Véhicule disponible", + "vehicleSearch": "Rechercher un véhicule", + "vehiclePlaceholder": "Référence, marque, modèle, plaque ou site", + "locationUnknown": "site inconnu", "loadingVehicles": "Vérification des disponibilités…", "chooseVehicle": "Sélectionnez un véhicule", "noVehicles": "Aucun véhicule disponible pour cette période", @@ -102,6 +105,13 @@ "startOdometerPending": "Pas encore enregistré — trajet pas encore commencé", "endOdometerPending": "Pas encore enregistré — pas encore clôturé", "requirementsComplete": "Exigences complètes", + "requirementsAction": "Contrôler les exigences", + "requirementsActionDetail": "L’inspection de départ reste verrouillée jusqu’à la confirmation explicite du permis et des exigences de location.", + "requirementsConfirmation": "Preuve du contrôle", + "requirementsConfirmationPlaceholder": "Notez ce qui a été contrôlé", + "requirementsConfirm": "Confirmer les exigences", + "requirementsSaving": "Confirmation…", + "requirementsFailed": "Les exigences n’ont pas pu être confirmées.", "yes": "Oui", "no": "Non", "cancelAction": "Annuler la réservation", diff --git a/frontend/src/i18n/locales/nl-BE/bookings.json b/frontend/src/i18n/locales/nl-BE/bookings.json index 62f6b25..38da637 100644 --- a/frontend/src/i18n/locales/nl-BE/bookings.json +++ b/frontend/src/i18n/locales/nl-BE/bookings.json @@ -53,6 +53,9 @@ "searchFirst": "Zoek eerst een klant", "chooseCustomer": "Selecteer een klant", "vehicle": "Beschikbaar voertuig", + "vehicleSearch": "Voertuig zoeken", + "vehiclePlaceholder": "Referentie, merk, model, nummerplaat of locatie", + "locationUnknown": "locatie onbekend", "loadingVehicles": "Beschikbaarheid controleren…", "chooseVehicle": "Selecteer een voertuig", "noVehicles": "Geen beschikbaar voertuig in deze periode", @@ -102,6 +105,13 @@ "startOdometerPending": "Nog niet vastgelegd — rit nog niet gestart", "endOdometerPending": "Nog niet vastgelegd — nog niet afgesloten", "requirementsComplete": "Vereisten volledig", + "requirementsAction": "Vereisten controleren", + "requirementsActionDetail": "De vertrekinspectie blijft vergrendeld totdat rijbewijs en huurvereisten expliciet zijn bevestigd.", + "requirementsConfirmation": "Controlebewijs", + "requirementsConfirmationPlaceholder": "Noteer wat gecontroleerd werd", + "requirementsConfirm": "Vereisten bevestigen", + "requirementsSaving": "Bevestigen…", + "requirementsFailed": "De vereisten konden niet worden bevestigd.", "yes": "Ja", "no": "Nee", "cancelAction": "Boeking annuleren", diff --git a/frontend/src/pages/BookingCreate.tsx b/frontend/src/pages/BookingCreate.tsx index 8608f40..0f664a5 100644 --- a/frontend/src/pages/BookingCreate.tsx +++ b/frontend/src/pages/BookingCreate.tsx @@ -6,13 +6,7 @@ import { describeApiError, type ApiErrorInfo } from "../api/errorMessages"; import type { AvailableVehicle, Booking, CustomerOption } from "../api/types"; import { ApiErrorNotice, PageHeader } from "../components/PageChrome"; import { Icon } from "../components/Icons"; - -function localDateTime(hoursFromNow: number): string { - const value = new Date(Date.now() + hoursFromNow * 60 * 60 * 1000); - value.setMinutes(0, 0, 0); - const offset = value.getTimezoneOffset() * 60_000; - return new Date(value.getTime() - offset).toISOString().slice(0, 16); -} +import { brusselsDateTimeFromNow, brusselsLocalToIso } from "../i18n/brusselsDateTime"; export function BookingCreate() { const { t } = useTranslation(["bookings", "errors"]); @@ -20,17 +14,18 @@ export function BookingCreate() { const [customerQuery, setCustomerQuery] = useState(""); const [customers, setCustomers] = useState([]); const [customerRef, setCustomerRef] = useState(""); - const [startsAt, setStartsAt] = useState(() => localDateTime(2)); - const [endsAt, setEndsAt] = useState(() => localDateTime(26)); + const [startsAt, setStartsAt] = useState(() => brusselsDateTimeFromNow(2)); + const [endsAt, setEndsAt] = useState(() => brusselsDateTimeFromNow(26)); const [vehicles, setVehicles] = useState([]); const [vehicleRef, setVehicleRef] = useState(""); - const [requirementsComplete, setRequirementsComplete] = useState(true); + const [requirementsComplete, setRequirementsComplete] = useState(false); + const [vehicleQuery, setVehicleQuery] = useState(""); const [loadingVehicles, setLoadingVehicles] = useState(false); const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(null); const windowValid = useMemo( - () => Boolean(startsAt && endsAt && new Date(endsAt) > new Date(startsAt)), + () => Boolean(startsAt && endsAt && brusselsLocalToIso(endsAt) > brusselsLocalToIso(startsAt)), [endsAt, startsAt], ); @@ -39,15 +34,21 @@ export function BookingCreate() { setCustomers([]); return; } + const controller = new AbortController(); const timeout = window.setTimeout(() => { - api.get(`/api/v1/customers?query=${encodeURIComponent(customerQuery.trim())}`) + api.get(`/api/v1/customers?query=${encodeURIComponent(customerQuery.trim())}`, { signal: controller.signal }) .then((result) => { setCustomers(result); if (!result.some((customer) => customer.public_ref === customerRef)) setCustomerRef(""); }) - .catch((err) => setError(describeApiError(t, err))); + .catch((err) => { + if (!controller.signal.aborted) setError(describeApiError(t, err)); + }); }, 250); - return () => window.clearTimeout(timeout); + return () => { + window.clearTimeout(timeout); + controller.abort(); + }; }, [customerQuery, customerRef, t]); useEffect(() => { @@ -56,19 +57,32 @@ export function BookingCreate() { setVehicleRef(""); return; } - setLoadingVehicles(true); - const params = new URLSearchParams({ - starts_at: new Date(startsAt).toISOString(), - ends_at: new Date(endsAt).toISOString(), - }); - api.get(`/api/v1/bookings/availability?${params.toString()}`) - .then((result) => { - setVehicles(result); - setVehicleRef((current) => result.some((vehicle) => vehicle.public_ref === current) ? current : ""); - }) - .catch((err) => setError(describeApiError(t, err))) - .finally(() => setLoadingVehicles(false)); - }, [endsAt, startsAt, t, windowValid]); + const controller = new AbortController(); + const timeout = window.setTimeout(() => { + setLoadingVehicles(true); + const params = new URLSearchParams({ + starts_at: brusselsLocalToIso(startsAt), + ends_at: brusselsLocalToIso(endsAt), + }); + if (vehicleQuery.trim()) params.set("query", vehicleQuery.trim()); + params.set("limit", "50"); + api.get(`/api/v1/bookings/availability?${params.toString()}`, { signal: controller.signal }) + .then((result) => { + setVehicles(result); + setVehicleRef((current) => result.some((vehicle) => vehicle.public_ref === current) ? current : ""); + }) + .catch((err) => { + if (!controller.signal.aborted) setError(describeApiError(t, err)); + }) + .finally(() => { + if (!controller.signal.aborted) setLoadingVehicles(false); + }); + }, 250); + return () => { + window.clearTimeout(timeout); + controller.abort(); + }; + }, [endsAt, startsAt, t, vehicleQuery, windowValid]); async function submit(event: FormEvent) { event.preventDefault(); @@ -78,8 +92,8 @@ export function BookingCreate() { const booking = await api.post("/api/v1/bookings", { customer_ref: customerRef, vehicle_ref: vehicleRef, - starts_at: new Date(startsAt).toISOString(), - ends_at: new Date(endsAt).toISOString(), + starts_at: brusselsLocalToIso(startsAt), + ends_at: brusselsLocalToIso(endsAt), requirements_complete: requirementsComplete, }); navigate(`/bookings/${booking.public_ref}`); @@ -101,7 +115,8 @@ export function BookingCreate() { - + +
{t("create.cancel")}
diff --git a/frontend/src/pages/BookingDetail.tsx b/frontend/src/pages/BookingDetail.tsx index af2cbae..4d851bf 100644 --- a/frontend/src/pages/BookingDetail.tsx +++ b/frontend/src/pages/BookingDetail.tsx @@ -27,6 +27,8 @@ export function BookingDetail() { const [cancelling, setCancelling] = useState(false); const [actionError, setActionError] = useState(null); const [checkoutResult, setCheckoutResult] = useState(null); + const [requirementsConfirmation, setRequirementsConfirmation] = useState(""); + const [confirmingRequirements, setConfirmingRequirements] = useState(false); const load = useCallback(() => { if (!publicRef) return; @@ -84,6 +86,24 @@ export function BookingDetail() { load(); } + async function confirmRequirements(event: FormEvent) { + event.preventDefault(); + if (!publicRef) return; + setConfirmingRequirements(true); + setActionError(null); + try { + const updated = await api.post(`/api/v1/bookings/${publicRef}/complete-requirements`, { + confirmation: requirementsConfirmation, + }); + setBooking(updated); + setRequirementsConfirmation(""); + } catch (err) { + setActionError(describeApiError(t, err, "bookings:detail.requirementsFailed")); + } finally { + setConfirmingRequirements(false); + } + } + if (error) return ; if (!booking) return ; @@ -101,6 +121,18 @@ export function BookingDetail() {
{t("detail.requirementsComplete")}
{booking.requirements_complete ? t("detail.yes") : t("detail.no")}
+ {booking.status === "reserved" && !booking.requirements_complete ? ( +
+
+

{t("detail.requirementsAction")}

+

{t("detail.requirementsActionDetail")}

+
+ +