Files
2026-06-28 21:59:59 +03:30

38 KiB

Backend Phase 8 — Booking requests & lifecycle (pre-payment intent)

Mission: build the pre-payment intent layer of the engagement lifecycle — the money-free booking_requests table and its full state machine (request → accept → pay-window → expire/reject). A customer requests a specific nurse, patient, service variant, address, date, and required caregiver gender; the nurse sees only the limited unencrypted customer_notes (never the full clinical instructions) and accepts or rejects before a config-driven response deadline; on accept a config-driven 30-minute payment window opens. No bookings row and no money exist yet — that conversion happens in b9/b10. This phase owns the two-stage clinical-disclosure boundary's first stage, the tenancy invariant, the same-gender filter, and the deadlines that the whole booking flow hinges on.

Track: backend · Depends on: backend-phase-3 (customer_profiles, nurse_profiles, patients, tenancy via ICurrentUser), backend-phase-4 (customer_addresses), backend-phase-5 (nurse_service_variants + the IVariantSnapshotSerializer), backend-phase-1 (typed cached config accessor IPlatformConfig, the IJobScheduler/BackgroundService pattern, notifications), backend-phase-7 (search & matching — the gender/match data customers discover nurses through) · Unlocks: bookings · sessions · care · EVV (backend-phase-9), and the booking-request UI (frontend-phase-7-b8) Before you start, read ../_shared/agent-operating-rules.md. It is not optional.


1. Context — where this sits

This is the booking-request phase — the first half of the request→accept→pay→confirm engagement model. The product deliberately splits the lifecycle into two tables so each keeps clean invariants: a money-free request phase (booking_requests, this phase) and a payment-backed booking phase (bookings, backend-phase-9). A booking_requests row can be rejected, time out, or have its payment window lapse without a booking ever existing — merging the two would mean a swamp of nullable money fields and tangled status logic. This phase ends in a complete, testable request state machine; b9 picks it up at "payment captured" and converts an accepted_awaiting_payment request into a confirmed bookings row.

Everything this phase needs already exists. A customer (with a customer_profiles row) has patients and customer_addresses; a nurse (with a nurse_profiles row and a gender) has priced, bookable nurse_service_variants; config exposes the deadline durations; search lets the customer find the nurse in the first place. This phase wires those into a request a nurse can accept or reject.

What already exists (do not rebuild) — confirmed from the prior phases:

  • Customers, nurses, patients & tenancybackend-phase-3 built customer_profiles, nurse_profiles (carrying is_verified, is_accepting_bookings, and the average_rating/total_reviews aggregates), and patients (the care recipient, separate from the payer, carrying a load-bearing gender). Tenancy is resolved off ICurrentUser.UserIdcustomer_profiles/nurse_profiles — a patient belongs to exactly one customer_id; b3 started the ownership scoping and b8 enforces the full booking-request tenancy invariant (§5). Read those entities; do not re-model them.
  • Addressesbackend-phase-4 built customer_addresses (encrypted address line + decimal(9,6) coordinates, customer_id FK → customer_profiles, filtered UNIQUE(customer_id) WHERE is_primary=1). The request points at one via customer_address_id. Do not geocode here — b4 already did; b9/EVV consumes the coordinates.
  • Service variantsbackend-phase-5 built nurse_service_variants (the atomic bookable unit: a nurse + category + chosen option-set at a price (IRR BIGINT) + price_unit), and shipped IVariantSnapshotSerializer (a pure serializer for the canonical variant_snapshot_json). The request FKs a variant_id; a variant belongs to exactly one nurse, which the tenancy invariant verifies. Do not compute any price/total here — no money lives on a request; the serializer/snapshot/total are b9's concern.
  • Config, the job runner & notificationsbackend-phase-1 built platform_configs + the typed cached accessor IPlatformConfig (GetConfig<T>(key)), seeded the deadline keys nurse_response_deadline_hours and booking_payment_deadline_minutes (= 30), introduced the in-process IJobScheduler/BackgroundService interval-runner pattern (used for PurgeOldReadNotifications), and shipped the real in-app INotificationDispatcher that writes a notifications row. Reuse all three — read deadlines through IPlatformConfig (never hardcode), schedule the expiry sweep through the same job pattern, and emit request/accept/expiry notifications through INotificationDispatcher.
  • Search & matchingbackend-phase-7 built the nurse_search_index and the INurseSearch query (filterable by category/city/district/gender/price). The customer arrives at this phase from a search result, having already filtered on required_caregiver_gender against nurse gender; this phase re-validates that same-gender match server-side at request time (search is a discovery aid, not the authority).
  • Cross-cutting plumbingbackend-phase-0: the REST surface (BaseController, snake_case routing, rate limiting), the CQRS pipeline (ISender/ICommand/IQuery, ValidateCommandBehavior, OperationResult<T>), IDateTimeProvider (use it — never DateTime.Now — deadlines and expiry are time-sensitive and must be testable), ICurrentUser, the audit-field interceptor, and ICacheService. Reuse these; introduce no new seam.

bookings, booking_sessions, booking_care_instructions, the three-amount money split, the conversion command, and dispute_window_ends_at are owned by backend-phase-9 (with payment capture from backend-phase-10). This phase ends at accepted_awaiting_payment; it must not create a booking, post a ledger entry, compute a price, or persist variant_snapshot_json/address_snapshot_json. (DEFERRED → b9/b10.) The request's converted terminal status is set by b9 when it consumes the accepted request — this phase models the status value and the 1:1 link but does not perform the conversion.

cancellation_policies, CancelBooking, refunds, and recurring_booking_schedules are also b9+ / DEFERRED — do not build them here. A customer cancelling a request (before any booking) is in scope as the cancelled_by_customer transition (§3.3); a booking cancellation is not.

2. Required reading (do this first)

  • ../_shared/agent-operating-rules.md and ../_shared/backend-conventions-checklist.md.
  • Product — business rules (source of truth): product/business/05-booking-and-scheduling.md — the request → accept → pay → confirm lifecycle, the two-table phase split (no money on a request), the response deadline computed-and-frozen-from-config, the 30-minute payment window, the request statuses, and (b) the Iran-specific note that the platform deliberately keeps the nurse's per-request accept/reject autonomy (availability is soft guidance, never a hard auto-accept). Read (c) MVP vs DEFERRED so you don't pull booking/session/cancellation scope forward.
  • Product — data model (source of truth): product/data-model/05-booking-and-scheduling.md — the canonical booking_requests schema: the FK set + the tenancy invariant (patient & address belong to customer_id; variant belongs to nurse_id), required_caregiver_gender (male/female/any), requested_date/requested_time_start/requested_time_end, unencrypted request-stage customer_notes, the exact status set, nurse_response_deadline_at (frozen from config), payment_deadline_at (set on accept), and nurse_rejection_reason. Note the 1:1 → bookings on conversion relation (which b9 owns).
  • Type, money & gender rules on the wire: ../../contracts/conventions/money-and-types.mdgender is load-bearing (required_caregiver_gender = male/female/any, matched against nurse gender; never default or drop it silently); timestamps are UTC ISO-8601; enums cross the wire as stable string codes; ids are BIGINT. (There is no money on a request — but read this so the request's wire shape is consistent with the rest of the contract.)
  • Contract conventions: ../../contracts/conventions/api-conventions.md (envelope, snake_case routes, status codes — 400 validation/business, 403/404 tenancy, 409 state-machine conflict — mandatory list pagination, locale handling).
  • Code to mirror (existing patterns): a b3/b4/b5 feature folder under Baya.Application/Features/<Area>/{Commands|Queries}/<Name>/ (request record + internal sealed handler + OperationResult, validator picked up by ValidateCommandBehavior); an IEntityTypeConfiguration<T> under Persistence/Configuration/<Area>Config/; a sealed controller under Baya.Web.Api/Controllers/V1/; b1's IPlatformConfig.GetConfig<T> usage and its IJobScheduler/BackgroundService retention job (mirror it for the expiry sweep); how b3 resolves tenancy off ICurrentUser; how reads use AsNoTracking() + .Select() projection + pagination.
  • Prior handoffs: dev/shared-working-context/backend/handoff/after-backend-phase-5.md (the variant shape + IVariantSnapshotSerializer), .../after-backend-phase-4.md (the customer_addresses shape), .../after-backend-phase-3.md (profiles/patients + the tenancy + role policies), and .../after-backend-phase-1.md (the config accessor keys + the job-runner pattern + notifications).

3. Scope — build this

A vertical slice per capability: entity + EF config + one additive migration → command/query handler(s) → controller endpoint → contract. Everything is async with CancellationToken threaded through; reads are AsNoTracking() + .Select() projection + pagination; writes go through IUnitOfWork with a single CommitAsync. There is no money and no bookings row anywhere in this phase.

3.1 Entity, config & migration

Add booking_requests as one additive EF Core migration on top of the existing baseline, with one IEntityTypeConfiguration<BookingRequest> in Persistence/Configuration/BookingConfig/. The entity lives in the Booking domain area (Baya.Domain/Entities/Booking/); ids are BIGINT.

  • booking_requests — the pre-payment intent. No money columns, ever.
    • FKs: id (BIGINT PK), customer_id (BIGINT FK → customer_profiles), nurse_id (BIGINT FK → nurse_profiles), patient_id (BIGINT FK → patients), variant_id (BIGINT FK → nurse_service_variants), customer_address_id (BIGINT FK → customer_addresses).
    • required_caregiver_gender (NVARCHAR(10), nullable) — closed code set male | female | any. Same-gender care is decisive for bodily care; this is a first-class filter, matched against the nurse's gender at request time (§5), not a soft preference.
    • requested_date (DATE), requested_time_start (TIME), requested_time_end (TIME) — for multi-day engagements these are the engagement start; per-visit scheduling is b9's booking_sessions.
    • customer_notes (NVARCHAR(1000), nullable, UNENCRYPTED) — the only clinical context the nurse sees pre-accept (Principle 6 / two-stage disclosure, stage 1). Do not route this through IFieldEncryptor; it is deliberately limited and plaintext. The full encrypted care instructions are b9's booking_care_instructions.
    • status (NVARCHAR(50)) — closed code set: pending_nurse_responseaccepted_awaiting_paymentconverted / rejected_by_nurse / expired_no_response / payment_deadline_expired / cancelled_by_customer. Guarded by a forward-only transition check (§3.4).
    • nurse_response_deadline_at (DATETIME2(7), not null) — computed once from config at creation and frozen (now + nurse_response_deadline_hours); immune to later config changes.
    • payment_deadline_at (DATETIME2(7), nullable) — null until accept; set to now + booking_payment_deadline_minutes (= 30) on accept, then frozen.
    • nurse_rejection_reason (NVARCHAR(500), nullable) — set on reject.
    • Audit fields (CreatedAt/ModifiedAt/CreatedById/ModifiedById, stamped by the b0 interceptor) + soft-delete (deleted_at) with the !IsDeleted global query filter.
    • Indexes: (nurse_id, status) for the nurse inbox ordered list; (customer_id, status) for the customer inbox; (status, nurse_response_deadline_at) and (status, payment_deadline_at) so the expiry sweep (§3.4) selects stale rows by a covering index instead of scanning. Do not add a variant_snapshot_json / address_snapshot_json column here — those land on bookings in b9.

Do not add bookings, booking_sessions, booking_care_instructions, visit_verifications, or cancellation_policies in this migration. They are b9's. This migration adds exactly one table.

3.2 Customer-side — create & cancel a request

Feature folder Baya.Application/Features/Booking/ (Commands/Queries sub-folders per the surrounding convention). Customer-owner-only for these writes; tenancy resolved via ICurrentUser.UserIdcustomer_profiles.

  • CreateBookingRequestCommand (Commands/CreateBookingRequest/) — the customer requests a nurse. Input: nurse_id, variant_id, patient_id, customer_address_id, requested_date, requested_time_start, requested_time_end, required_caregiver_gender, optional customer_notes. The handler, in one transaction:
    1. Resolve the caller's customer_id from ICurrentUsercustomer_profiles (never trust a customer_id from the body).
    2. Tenancy invariant (§5): load the patients row and assert patient.customer_id == customer_id; load the customer_addresses row and assert address.customer_id == customer_id; load the nurse_service_variants row and assert variant.nurse_id == nurse_id. Any mismatch ⇒ a clean NotFoundResult/FailureResult (never a raw 500, never leaking another customer's data).
    3. Bookability checks: the variant is active (is_active), the nurse is_verified and is_accepting_bookings. A request against an inactive variant or an unbookable nurse fails cleanly.
    4. Same-gender match (§5): if required_caregiver_gender is male/female, assert the nurse's gender equals it; any matches either. A mismatch is a validation failure naming the conflict.
    5. Compute & freeze nurse_response_deadline_at = IDateTimeProvider.UtcNow + IPlatformConfig.GetConfig<int>("nurse_response_deadline_hours"). Read from config — never hardcode. Store the absolute timestamp on the row so a later config change cannot move it.
    6. Insert the booking_requests row with status = pending_nurse_response, payment_deadline_at = null; CommitAsync once.
    7. Notify the nurse of the new request via INotificationDispatcher (a booking_request_received notification type, data_json carrying the request id + patient display name + requested date — no PII beyond what stage-1 disclosure allows).
    • FluentValidation: all FKs present/positive; requested_time_end > requested_time_start; requested_date not in the past; required_caregiver_gender in the closed set when supplied; customer_notes ≤ 1000 chars.
  • CancelBookingRequestCommand (Commands/CancelBookingRequest/) — the customer withdraws a request that is still pending_nurse_response or accepted_awaiting_payment (before they pay). Owner-tenancy; transition to cancelled_by_customer through the guard (§3.4). A request already converted/rejected_by_nurse/expired_* cannot be cancelled ⇒ 409 conflict. (This is a request cancellation only — a booking cancellation with refund tiers is b9+/DEFERRED.)

3.3 Nurse-side — accept & reject

Nurse-owner-only; tenancy resolved via ICurrentUser.UserIdnurse_profiles; the request's nurse_id must equal the caller's nurse id (else 403/NotFound).

  • AcceptBookingRequestCommand (Commands/AcceptBookingRequest/) — the assigned nurse accepts a pending_nurse_response request. The handler:
    1. Load the request scoped to the caller's nurse_id; assert status == pending_nurse_response (else 409). Assert nurse_response_deadline_at has not already passed (IDateTimeProvider.UtcNow); an accept after the deadline is rejected with a clear message (the sweep may not have run yet — the command must self-guard, not rely on the job).
    2. Set & freeze payment_deadline_at = IDateTimeProvider.UtcNow + IPlatformConfig.GetConfig<int>("booking_payment_deadline_minutes") (= 30). From config, never hardcode 30.
    3. Transition status → accepted_awaiting_payment through the guard; CommitAsync.
    4. Notify the customer (booking_request_accepted, data_json with the request id + the payment_deadline_at so the client can show the 30-minute countdown).
    • Two-stage disclosure (§5): the accept handler — and the nurse inbox/detail queries — expose only customer_notes, never any encrypted care instructions (those don't exist until b9 and are gated to post-confirmation). There is nothing encrypted to leak here because there is nothing encrypted on a request — but the contract and queries must make the stage-1-only boundary explicit so b9 inherits it correctly.
  • RejectBookingRequestCommand (Commands/RejectBookingRequest/) — the assigned nurse declines a pending_nurse_response request with a required nurse_rejection_reason. Transition → rejected_by_nurse; notify the customer (booking_request_rejected). Reject is only valid from pending_nurse_response (else 409). FluentValidation: reason non-empty, ≤ 500 chars.

3.4 Status guard & the expiry sweep (this phase owns the scheduled job)

  • Forward-only transition guard. Add an allowed-transition table/helper for booking_requests.status and route every write through it; an illegal transition returns a 409 OperationResult conflict, never a silent overwrite. Allowed edges:
    • pending_nurse_responseaccepted_awaiting_payment | rejected_by_nurse | expired_no_response | cancelled_by_customer
    • accepted_awaiting_paymentconverted (b9 only) | payment_deadline_expired | cancelled_by_customer
    • all of converted / rejected_by_nurse / expired_no_response / payment_deadline_expired / cancelled_by_customer are terminal (no outgoing edges).
  • ExpireBookingRequestsCommand (Commands/ExpireBookingRequests/) + a hosted BackgroundService registered through the b1 IJobScheduler pattern, running on a short interval (e.g. every minute — config-driven if b1 exposes an interval key, else a sensible constant documented in the report). On each tick, in a single bounded, paginated pass (do not load the whole table):
    • select pending_nurse_response rows where nurse_response_deadline_at <= UtcNow → transition → expired_no_response; notify the customer (booking_request_expired_no_response).
    • select accepted_awaiting_payment rows where payment_deadline_at <= UtcNow → transition → payment_deadline_expired; notify the customer (booking_request_payment_window_expired).
    • The sweep uses the covering indexes from §3.1, batches its updates, threads CancellationToken, and is idempotent/re-entrant (a row already moved by a concurrent accept/reject is simply skipped — the status predicate in the WHERE is the guard; never assume a row is still pending just because the previous tick saw it). Expiry is computed against IDateTimeProvider.UtcNow so tests can drive it deterministically.
    • The command is also exposed as an admin/manual trigger endpoint (§3.6) so a human/test can fire the sweep on demand without waiting for the interval.

3.5 Queries — customer inbox, nurse inbox, single request

  • ListBookingRequestsQuery (Queries/ListBookingRequests/) — paginated, role-aware: a customer sees their own requests (scoped to customer_id); a nurse sees requests addressed to them (scoped to nurse_id). Optional status filter. AsNoTracking() + .Select() projection returning: request id, status, the counterparty (nurse display name + rating for the customer view; patient display name + requested date for the nurse view), requested_date/times, required_caregiver_gender, nurse_response_deadline_at, payment_deadline_at, and — for the nurse viewcustomer_notes (stage-1 only). Order: actionable first (e.g. pending_nurse_response / accepted_awaiting_payment before terminal states), then by deadline. Never project encrypted/care fields (there are none, and there must never be any added to this query).
  • GetBookingRequestQuery (Queries/GetBookingRequest/) — a single request, visible to its customer or its nurse (admin too). Returns the full request projection: the resolved variant label (via the b5 variant projection — read the canonical offering, do not duplicate price math), patient + address summary (the customer sees their own full address; the nurse sees only what stage-1 disclosure allows — a coarse location/the request fields, not the encrypted full address line), both deadlines, status, and customer_notes. A caller who is neither the request's customer nor its nurse ⇒ 403/NotFound.

3.6 REST endpoints

Controllers under Baya.Web.Api/Controllers/V1/ (sealed, BaseController, inject ISender, [controller]/[action] snake_case tokens, base.OperationResult(...), narrowest authorize policy, lists paginated). Routes shown logically; the snake_case transformer produces the real segments.

Verb & route Maps to Auth
POST /v1/booking_requests CreateBookingRequestCommand customer (owner)
POST /v1/booking_requests/{id}/cancel CancelBookingRequestCommand customer (owner)
POST /v1/booking_requests/{id}/accept AcceptBookingRequestCommand nurse (assigned)
POST /v1/booking_requests/{id}/reject RejectBookingRequestCommand nurse (assigned)
GET /v1/booking_requests ListBookingRequestsQuery customer or nurse (role-scoped)
GET /v1/booking_requests/{id} GetBookingRequestQuery customer / nurse (party) / admin
POST /v1/admin/booking_requests/expire ExpireBookingRequestsCommand (manual trigger) admin

3.7 Out of scope (DEFERRED — do not build)

  • bookings, the three-amount money split (gross_price_irr/balinyaar_commission_irr/ nurse_payout_amount), psp_fee_amount, the conversion command, booking_sessions, booking_care_instructions, visit_verifications, dispute_window_ends_at, variant_snapshot_json/address_snapshot_json — **(DEFERRED → backend-phase-9
    • backend-phase-10).** This phase stops at accepted_awaiting_payment; b9 consumes the accepted request, captures payment (b10), and sets the request to converted.
  • cancellation_policies, CancelBooking/CancelSession, refund tiers, recurring_booking_schedules, nurse_availability_slots/_exceptions hard-blocking(DEFERRED); availability stays soft guidance (search-only). The nurse always individually accepts/rejects — never auto-accept from availability.
  • SMS/push notification channelsINotificationDispatcher writes in-app only (the channel abstraction exists from b0/b1; SMS/push are DEFERRED). Emit the in-app notification; do not add a real SMS path.

4. Mocks & seams in this phase

None introduced. This phase owns no third-party integration — booking-request data is fully Balinyaar's. It reuses existing seams; it must not redefine them:

Reused seam From Used for
IPlatformConfig b1 reading nurse_response_deadline_hours & booking_payment_deadline_minutes (cached)
INotificationDispatcher b1 (real in-app) request-received / accepted / rejected / expired in-app notifications
IJobScheduler / BackgroundService b1 the recurring expiry sweep
IDateTimeProvider b0 deadline computation + expiry, testably
ICurrentUser b0 tenancy resolution (customer/nurse)

Because this phase mocks nothing, there is no mocks-registry.md row to add — state that explicitly in your report (§8) so the next agent doesn't go looking. The expiry job is an internal hosted service, not an external seam; it does not go in the mock registry.

5. Critical rules you must not get wrong

  • NO money exists on a booking_requests row — and no bookings row exists yet. The request phase is deliberately money-free; a bookings row exists only when the nurse accepted AND payment was captured (b9/b10). Never add a money column to booking_requests, never compute a price/total here, never create a booking on accept alone. Accept only opens the payment window.
  • Two-stage clinical disclosure (Principle 6), stage 1. Pre-accept (and pre-payment), the nurse sees only the unencrypted, limited customer_notes — never any full clinical/care instructions. Those are b9's encrypted booking_care_instructions, readable only post-confirmation by the assigned nurse + admin. Do not add an encrypted clinical field to this table; do not surface anything beyond customer_notes in the nurse inbox/detail queries. This boundary is the entire reason the request and booking phases are split — preserve it exactly so b9 inherits a clean seam.
  • Tenancy invariant — enforced before the request is created. The patient and the customer_address must belong to the caller's customer_id; the variant must belong to the requested nurse_id. Resolve customer_id/nurse_id from ICurrentUser, never from the request body. A cross-customer patient/address or a variant that isn't the nurse's is a clean failure, never a success and never a leak of the other party's data.
  • required_caregiver_gender is a first-class same-gender filter, not a soft preference. When it is male/female, it must be matched against the nurse's gender at request time and rejected on mismatch; any matches either. Same-gender bodily care is decisive in the Iranian context. Never default it silently or treat it as advisory.
  • Deadlines come from config and are frozen on the row — never hardcoded, never recomputed. Compute nurse_response_deadline_at from nurse_response_deadline_hours at create time and payment_deadline_at from booking_payment_deadline_minutes (= 30) at accept time, both via IPlatformConfig + IDateTimeProvider, and store the absolute timestamp. A later config change must not move an existing request's deadlines. Do not read "30 minutes" as a literal anywhere.
  • The nurse always individually accepts or rejects. Availability slots are soft guidance only (b5-area, DEFERRED) — never auto-accept a request from availability. This deliberate per-request autonomy also underpins the worker-misclassification posture; do not erode it.
  • Status transitions go through the forward-only guard. Every accept/reject/cancel/expire/convert edge is validated; illegal transitions return 409, never a silent overwrite. Terminal states have no outgoing edges. The expiry sweep's WHERE status = … predicate is the concurrency guard — a row moved by a racing accept is simply skipped.
  • Time is injected, expiry is idempotent. Use IDateTimeProvider.UtcNow everywhere (no DateTime.Now); accept/reject self-guard against an already-passed deadline rather than trusting that the sweep ran; the sweep is bounded, paginated, re-entrant, and safe to run concurrently with user actions.
  • The request → booking link is 1:1 and owned by b9. Model the converted status and let b9 create the bookings row that points back at the request; this phase never writes that link itself.

6. Definition of Done

The shared definition-of-done.md, plus:

  • booking_requests exists via one additive migration with the §3.1 columns, the closed status set, the unencrypted customer_notes, nurse_response_deadline_at (not null) + nullable payment_deadline_at, the FK set, the inbox/expiry indexes, soft-delete filter, and no money column.
  • CreateBookingRequestCommand enforces the tenancy invariant (patient + address ∈ customer_id, variant ∈ nurse_id), the same-gender match, bookability (active variant, verified/accepting nurse), and freezes nurse_response_deadline_at from nurse_response_deadline_hours; it notifies the nurse.
  • AcceptBookingRequestCommand sets payment_deadline_at = now + booking_payment_deadline_minutes (30, from config), transitions to accepted_awaiting_payment, notifies the customer, and self-guards against an expired response deadline. RejectBookingRequestCommand requires a reason and transitions to rejected_by_nurse. CancelBookingRequestCommand handles cancelled_by_customer.
  • The forward-only status guard rejects illegal transitions with 409; ExpireBookingRequestsCommand + its BackgroundService transition pending_nurse_response → expired_no_response and accepted_awaiting_payment → payment_deadline_expired, paginated, idempotent, time-injected, and is also reachable via the admin manual-trigger endpoint.
  • ListBookingRequestsQuery (role-scoped customer/nurse inbox, paginated, projected) and GetBookingRequestQuery (party/admin only) are implemented; the nurse inbox exposes only customer_notes and never any care/clinical field.
  • Tests prove the §7 scenarios (incl. cross-customer patient rejected, same-gender mismatch rejected, accept sets the 30-min window, the expiry job transitions stale rows); dotnet build Baya.sln zero new warnings; dotnet test Baya.sln green including this phase's tests.
  • The contract dev/contracts/domains/booking-requests.md is written, the swagger.json snapshot is refreshed, and the server/CLAUDE.md Project map notes the new Features/Booking area, the BookingConfig configuration folder, and the request-expiry BackgroundService.

7. How to test (what a human can verify after this phase)

Run the API (dotnet run --project src/API/Baya.Web.Api/...) against a reachable SQL Server; use Swagger or curl, signing in as the relevant role. These expected results become the "what can be tested" section of your report.

  1. Create a request (happy path). As a customer, POST /v1/booking_requests with your own patient_id + customer_address_id, a nurse's variant_id (that nurse is_verified & is_accepting_bookings), a future requested_date, required_caregiver_gender: any, and a short customer_notes200, status pending_nurse_response, nurse_response_deadline_at set to now + nurse_response_deadline_hours, payment_deadline_at null. The nurse receives an in-app notification.
  2. Cross-customer patient is rejected. Repeat the create but with a patient_id belonging to a different customer → a clean tenancy failure (403/404 OperationResult), not a 500 and no request row created. Same for an customer_address_id you don't own and a variant_id that isn't the requested nurse's.
  3. Same-gender mismatch is rejected. Request required_caregiver_gender: female against a male nurse → a validation failure naming the gender conflict; any against any nurse succeeds.
  4. Nurse accepts → 30-minute window. As the assigned nurse, POST /v1/booking_requests/{id}/accept200, status accepted_awaiting_payment, payment_deadline_at = now + booking_payment_deadline_minutes (30 min). The customer gets a booking_request_accepted notification carrying the deadline. No bookings row exists (there is no bookings table yet).
  5. Nurse rejects. POST /v1/booking_requests/{id}/reject with a reason on a fresh pending request → 200, status rejected_by_nurse, nurse_rejection_reason stored, customer notified. Rejecting a non-pending request → 409.
  6. Nurse inbox shows only customer_notes. As the nurse, GET /v1/booking_requests?status=pending_nurse_response → the request appears with customer_notes and the per-request countdown to nurse_response_deadline_at, and no encrypted/clinical field of any kind.
  7. Customer cancels before paying. On an accepted_awaiting_payment request, the customer POST /v1/booking_requests/{id}/cancel200, status cancelled_by_customer. Cancelling a converted/rejected/expired request → 409.
  8. The expiry job transitions stale requests. Create a request, then (using the injected clock in a test, or by waiting / a short test config) advance past the response deadline and POST /v1/admin/booking_requests/expire (admin) → the pending request becomes expired_no_response; an accepted request past payment_deadline_at becomes payment_deadline_expired. Both notify the customer. Running the trigger again is a no-op (idempotent).
  9. Tenancy on read. As a third party (neither the request's customer nor nurse), GET /v1/booking_requests/{id}403/NotFound, never the request's data.

8. Hand off & document (close the phase)

  • Docs to update (same change): server/CLAUDE.md Project map — add the Features/Booking (booking-requests) feature area, the new booking_requests table + the Persistence/Configuration/BookingConfig/ folder, and the request-expiry BackgroundService (note it reuses the b1 IJobScheduler pattern). If you established the forward-only status-guard helper as a reusable pattern (b9 will reuse it for the bookings machine), note it in server/CONVENTIONS.md. If you discovered/decided any business rule not already in the product docs, reflect it in product/business/05-booking-and-scheduling.md or product/data-model/05-booking-and-scheduling.md (no invented rules — record decisions, and regenerate the HTML view per product/CLAUDE.md if you touched Markdown).
  • Contract to write: publish dev/contracts/domains/booking-requests.md — the §3.6 routes; request/response shapes for create/accept/reject/cancel and the two queries; the status enum (pending_nurse_response/accepted_awaiting_payment/converted/rejected_by_nurse/ expired_no_response/payment_deadline_expired/cancelled_by_customer) and required_caregiver_gender enum (male/female/any); the deadline timestamps (UTC ISO-8601); the tenancy and stage-1-disclosure notes (nurse view exposes only customer_notes); the failure cases and status codes (400 validation/same-gender, 403/404 tenancy, 409 illegal transition / already-expired) — per ../../contracts/conventions/api-conventions.md and money-and-types.md (gender section), starting from the domain contract template. Refresh the swagger.json snapshot per ../../contracts/openapi/README.md so frontend-phase-7-b8 can derive its types (it does not guess shapes).
  • Handoff & report: write shared-working-context/backend/handoff/after-backend-phase-8.md — the request lifecycle is live end-to-end (create/accept/reject/cancel/expire); the deadlines are config- driven and frozen; what b9 must consume (an accepted_awaiting_payment request → create the bookings row, set the request to converted through the guard, and that the full encrypted care instructions are b9's stage-2 — this phase intentionally exposes only customer_notes); what f7-b8 can now build (the request form C4, the awaiting-acceptance/status tracker C5 with the response
    • payment countdowns, and the nurse incoming-requests inbox with accept/reject). Append your phase summary to shared-working-context/backend/STATUS.md, and write reports/backend-phase-8-report.md (what was built, what is now testable and exactly how — the §7 steps, that nothing is mocked here, the contract produced, and follow-ups for b9: conversion, sessions, two-stage stage-2 care instructions). State explicitly in the report that this phase adds no mocks-registry.md row.
  • Memory: save a project-type memory note for the non-obvious decisions here — the two-table phase split / no-money-on-a-request rule, the stage-1 (customer_notes-only) vs stage-2 (encrypted care-instructions) disclosure boundary, the deadlines-frozen-from-config rule (which config keys), the tenancy invariant (patient+address ∈ customer, variant ∈ nurse), the same-gender match at request time, and the forward-only status guard + idempotent expiry sweep — with a one-line MEMORY.md pointer.