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_requeststable 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 unencryptedcustomer_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. Nobookingsrow 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 viaICurrentUser), backend-phase-4 (customer_addresses), backend-phase-5 (nurse_service_variants+ theIVariantSnapshotSerializer), backend-phase-1 (typed cached config accessorIPlatformConfig, theIJobScheduler/BackgroundServicepattern,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 & tenancy — backend-phase-3 built
customer_profiles,nurse_profiles(carryingis_verified,is_accepting_bookings, and theaverage_rating/total_reviewsaggregates), andpatients(the care recipient, separate from the payer, carrying a load-bearinggender). Tenancy is resolved offICurrentUser.UserId→customer_profiles/nurse_profiles— a patient belongs to exactly onecustomer_id; b3 started the ownership scoping and b8 enforces the full booking-request tenancy invariant (§5). Read those entities; do not re-model them. - Addresses — backend-phase-4 built
customer_addresses(encrypted address line +decimal(9,6)coordinates,customer_idFK →customer_profiles, filteredUNIQUE(customer_id) WHERE is_primary=1). The request points at one viacustomer_address_id. Do not geocode here — b4 already did; b9/EVV consumes the coordinates. - Service variants — backend-phase-5 built
nurse_service_variants(the atomic bookable unit: a nurse + category + chosen option-set at aprice(IRRBIGINT) +price_unit), and shippedIVariantSnapshotSerializer(a pure serializer for the canonicalvariant_snapshot_json). The request FKs avariant_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 & notifications — backend-phase-1 built
platform_configs+ the typed cached accessorIPlatformConfig(GetConfig<T>(key)), seeded the deadline keysnurse_response_deadline_hoursandbooking_payment_deadline_minutes(= 30), introduced the in-processIJobScheduler/BackgroundServiceinterval-runner pattern (used forPurgeOldReadNotifications), and shipped the real in-appINotificationDispatcherthat writes anotificationsrow. Reuse all three — read deadlines throughIPlatformConfig(never hardcode), schedule the expiry sweep through the same job pattern, and emit request/accept/expiry notifications throughINotificationDispatcher. - Search & matching — backend-phase-7 built the
nurse_search_indexand theINurseSearchquery (filterable by category/city/district/gender/price). The customer arrives at this phase from a search result, having already filtered onrequired_caregiver_genderagainst 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 plumbing — backend-phase-0: the REST surface (
BaseController, snake_case routing, rate limiting), the CQRS pipeline (ISender/ICommand/IQuery,ValidateCommandBehavior,OperationResult<T>),IDateTimeProvider(use it — neverDateTime.Now— deadlines and expiry are time-sensitive and must be testable),ICurrentUser, the audit-field interceptor, andICacheService. Reuse these; introduce no new seam.
bookings,booking_sessions,booking_care_instructions, the three-amount money split, the conversion command, anddispute_window_ends_atare owned by backend-phase-9 (with payment capture from backend-phase-10). This phase ends ataccepted_awaiting_payment; it must not create a booking, post a ledger entry, compute a price, or persistvariant_snapshot_json/address_snapshot_json. (DEFERRED → b9/b10.) The request'sconvertedterminal 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, andrecurring_booking_schedulesare also b9+ / DEFERRED — do not build them here. A customer cancelling a request (before any booking) is in scope as thecancelled_by_customertransition (§3.3); a booking cancellation is not.
2. Required reading (do this first)
../_shared/agent-operating-rules.mdand../_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 canonicalbooking_requestsschema: the FK set + the tenancy invariant (patient & address belong tocustomer_id; variant belongs tonurse_id),required_caregiver_gender(male/female/any),requested_date/requested_time_start/requested_time_end, unencrypted request-stagecustomer_notes, the exactstatusset,nurse_response_deadline_at(frozen from config),payment_deadline_at(set on accept), andnurse_rejection_reason. Note the 1:1 →bookingson conversion relation (which b9 owns). - Type, money & gender rules on the wire:
../../contracts/conventions/money-and-types.md— gender 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 areBIGINT. (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 —400validation/business,403/404tenancy,409state-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>/(requestrecord+internal sealedhandler +OperationResult, validator picked up byValidateCommandBehavior); anIEntityTypeConfiguration<T>underPersistence/Configuration/<Area>Config/; asealedcontroller underBaya.Web.Api/Controllers/V1/; b1'sIPlatformConfig.GetConfig<T>usage and itsIJobScheduler/BackgroundServiceretention job (mirror it for the expiry sweep); how b3 resolves tenancy offICurrentUser; how reads useAsNoTracking()+.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(thecustomer_addressesshape),.../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 setmale|female|any. Same-gender care is decisive for bodily care; this is a first-class filter, matched against the nurse'sgenderat 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'sbooking_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 throughIFieldEncryptor; it is deliberately limited and plaintext. The full encrypted care instructions are b9'sbooking_care_instructions.status(NVARCHAR(50)) — closed code set:pending_nurse_response→accepted_awaiting_payment→converted/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 tonow + 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!IsDeletedglobal 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 avariant_snapshot_json/address_snapshot_jsoncolumn here — those land onbookingsin b9.
- FKs:
Do not add
bookings,booking_sessions,booking_care_instructions,visit_verifications, orcancellation_policiesin 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.UserId →
customer_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, optionalcustomer_notes. The handler, in one transaction:- Resolve the caller's
customer_idfromICurrentUser→customer_profiles(never trust acustomer_idfrom the body). - Tenancy invariant (§5): load the
patientsrow and assertpatient.customer_id == customer_id; load thecustomer_addressesrow and assertaddress.customer_id == customer_id; load thenurse_service_variantsrow and assertvariant.nurse_id == nurse_id. Any mismatch ⇒ a cleanNotFoundResult/FailureResult(never a raw 500, never leaking another customer's data). - Bookability checks: the variant is active (
is_active), the nurseis_verifiedandis_accepting_bookings. A request against an inactive variant or an unbookable nurse fails cleanly. - Same-gender match (§5): if
required_caregiver_genderismale/female, assert the nurse'sgenderequals it;anymatches either. A mismatch is a validation failure naming the conflict. - 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. - Insert the
booking_requestsrow withstatus = pending_nurse_response,payment_deadline_at = null;CommitAsynconce. - Notify the nurse of the new request via
INotificationDispatcher(abooking_request_receivednotification type,data_jsoncarrying 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_datenot in the past;required_caregiver_genderin the closed set when supplied;customer_notes≤ 1000 chars.
- Resolve the caller's
CancelBookingRequestCommand(Commands/CancelBookingRequest/) — the customer withdraws a request that is stillpending_nurse_responseoraccepted_awaiting_payment(before they pay). Owner-tenancy; transition tocancelled_by_customerthrough the guard (§3.4). A request alreadyconverted/rejected_by_nurse/expired_*cannot be cancelled ⇒409conflict. (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.UserId → nurse_profiles; the request's
nurse_id must equal the caller's nurse id (else 403/NotFound).
AcceptBookingRequestCommand(Commands/AcceptBookingRequest/) — the assigned nurse accepts apending_nurse_responserequest. The handler:- Load the request scoped to the caller's
nurse_id; assertstatus == pending_nurse_response(else409). Assertnurse_response_deadline_athas 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). - Set & freeze
payment_deadline_at=IDateTimeProvider.UtcNow + IPlatformConfig.GetConfig<int>("booking_payment_deadline_minutes")(= 30). From config, never hardcode 30. - Transition
status → accepted_awaiting_paymentthrough the guard;CommitAsync. - Notify the customer (
booking_request_accepted,data_jsonwith the request id + thepayment_deadline_atso 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.
- Load the request scoped to the caller's
RejectBookingRequestCommand(Commands/RejectBookingRequest/) — the assigned nurse declines apending_nurse_responserequest with a requirednurse_rejection_reason. Transition →rejected_by_nurse; notify the customer (booking_request_rejected). Reject is only valid frompending_nurse_response(else409). 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.statusand route every write through it; an illegal transition returns a409OperationResultconflict, never a silent overwrite. Allowed edges:pending_nurse_response→accepted_awaiting_payment|rejected_by_nurse|expired_no_response|cancelled_by_customeraccepted_awaiting_payment→converted(b9 only) |payment_deadline_expired|cancelled_by_customer- all of
converted/rejected_by_nurse/expired_no_response/payment_deadline_expired/cancelled_by_customerare terminal (no outgoing edges).
ExpireBookingRequestsCommand(Commands/ExpireBookingRequests/) + a hostedBackgroundServiceregistered through the b1IJobSchedulerpattern, 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_responserows wherenurse_response_deadline_at <= UtcNow→ transition →expired_no_response; notify the customer (booking_request_expired_no_response). - select
accepted_awaiting_paymentrows wherepayment_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 — thestatuspredicate in theWHEREis the guard; never assume a row is still pending just because the previous tick saw it). Expiry is computed againstIDateTimeProvider.UtcNowso 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.
- select
3.5 Queries — customer inbox, nurse inbox, single request
ListBookingRequestsQuery(Queries/ListBookingRequests/) — paginated, role-aware: a customer sees their own requests (scoped tocustomer_id); a nurse sees requests addressed to them (scoped tonurse_id). Optionalstatusfilter.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 view —customer_notes(stage-1 only). Order: actionable first (e.g.pending_nurse_response/accepted_awaiting_paymentbefore 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, andcustomer_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 toconverted.
- backend-phase-10).** This phase stops at
cancellation_policies,CancelBooking/CancelSession, refund tiers,recurring_booking_schedules,nurse_availability_slots/_exceptionshard-blocking — (DEFERRED); availability stays soft guidance (search-only). The nurse always individually accepts/rejects — never auto-accept from availability.- SMS/push notification channels —
INotificationDispatcherwrites 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_requestsrow — and nobookingsrow exists yet. The request phase is deliberately money-free; abookingsrow exists only when the nurse accepted AND payment was captured (b9/b10). Never add a money column tobooking_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 encryptedbooking_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 beyondcustomer_notesin 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
patientand thecustomer_addressmust belong to the caller'scustomer_id; thevariantmust belong to the requestednurse_id. Resolvecustomer_id/nurse_idfromICurrentUser, 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_genderis a first-class same-gender filter, not a soft preference. When it ismale/female, it must be matched against the nurse'sgenderat request time and rejected on mismatch;anymatches 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_atfromnurse_response_deadline_hoursat create time andpayment_deadline_atfrombooking_payment_deadline_minutes(= 30) at accept time, both viaIPlatformConfig+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'sWHERE status = …predicate is the concurrency guard — a row moved by a racing accept is simply skipped. - Time is injected, expiry is idempotent. Use
IDateTimeProvider.UtcNoweverywhere (noDateTime.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
convertedstatus and let b9 create thebookingsrow 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_requestsexists via one additive migration with the §3.1 columns, the closedstatusset, the unencryptedcustomer_notes,nurse_response_deadline_at(not null) + nullablepayment_deadline_at, the FK set, the inbox/expiry indexes, soft-delete filter, and no money column.CreateBookingRequestCommandenforces the tenancy invariant (patient + address ∈customer_id, variant ∈nurse_id), the same-gender match, bookability (active variant, verified/accepting nurse), and freezesnurse_response_deadline_atfromnurse_response_deadline_hours; it notifies the nurse.AcceptBookingRequestCommandsetspayment_deadline_at = now + booking_payment_deadline_minutes(30, from config), transitions toaccepted_awaiting_payment, notifies the customer, and self-guards against an expired response deadline.RejectBookingRequestCommandrequires a reason and transitions torejected_by_nurse.CancelBookingRequestCommandhandlescancelled_by_customer.- The forward-only status guard rejects illegal transitions with
409;ExpireBookingRequestsCommand+ itsBackgroundServicetransitionpending_nurse_response → expired_no_responseandaccepted_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) andGetBookingRequestQuery(party/admin only) are implemented; the nurse inbox exposes onlycustomer_notesand 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.slnzero new warnings;dotnet test Baya.slngreen including this phase's tests. - The contract
dev/contracts/domains/booking-requests.mdis written, theswagger.jsonsnapshot is refreshed, and theserver/CLAUDE.mdProject map notes the newFeatures/Bookingarea, theBookingConfigconfiguration folder, and the request-expiryBackgroundService.
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.
- Create a request (happy path). As a customer,
POST /v1/booking_requestswith your ownpatient_id+customer_address_id, a nurse'svariant_id(that nurseis_verified&is_accepting_bookings), a futurerequested_date,required_caregiver_gender: any, and a shortcustomer_notes→200, statuspending_nurse_response,nurse_response_deadline_atset tonow + nurse_response_deadline_hours,payment_deadline_atnull. The nurse receives an in-app notification. - Cross-customer patient is rejected. Repeat the create but with a
patient_idbelonging to a different customer → a clean tenancy failure (403/404OperationResult), not a 500 and no request row created. Same for ancustomer_address_idyou don't own and avariant_idthat isn't the requested nurse's. - Same-gender mismatch is rejected. Request
required_caregiver_gender: femaleagainst amalenurse → a validation failure naming the gender conflict;anyagainst any nurse succeeds. - Nurse accepts → 30-minute window. As the assigned nurse,
POST /v1/booking_requests/{id}/accept→200, statusaccepted_awaiting_payment,payment_deadline_at = now + booking_payment_deadline_minutes(30 min). The customer gets abooking_request_acceptednotification carrying the deadline. Nobookingsrow exists (there is no bookings table yet). - Nurse rejects.
POST /v1/booking_requests/{id}/rejectwith a reason on a fresh pending request →200, statusrejected_by_nurse,nurse_rejection_reasonstored, customer notified. Rejecting a non-pending request →409. - Nurse inbox shows only
customer_notes. As the nurse,GET /v1/booking_requests?status=pending_nurse_response→ the request appears withcustomer_notesand the per-request countdown tonurse_response_deadline_at, and no encrypted/clinical field of any kind. - Customer cancels before paying. On an
accepted_awaiting_paymentrequest, the customerPOST /v1/booking_requests/{id}/cancel→200, statuscancelled_by_customer. Cancelling aconverted/rejected/expiredrequest →409. - 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 becomesexpired_no_response; an accepted request pastpayment_deadline_atbecomespayment_deadline_expired. Both notify the customer. Running the trigger again is a no-op (idempotent). - 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.mdProject map — add theFeatures/Booking(booking-requests) feature area, the newbooking_requeststable + thePersistence/Configuration/BookingConfig/folder, and the request-expiryBackgroundService(note it reuses the b1IJobSchedulerpattern). If you established the forward-only status-guard helper as a reusable pattern (b9 will reuse it for thebookingsmachine), note it inserver/CONVENTIONS.md. If you discovered/decided any business rule not already in the product docs, reflect it inproduct/business/05-booking-and-scheduling.mdorproduct/data-model/05-booking-and-scheduling.md(no invented rules — record decisions, and regenerate the HTML view perproduct/CLAUDE.mdif 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; thestatusenum (pending_nurse_response/accepted_awaiting_payment/converted/rejected_by_nurse/expired_no_response/payment_deadline_expired/cancelled_by_customer) andrequired_caregiver_genderenum (male/female/any); the deadline timestamps (UTC ISO-8601); the tenancy and stage-1-disclosure notes (nurse view exposes onlycustomer_notes); the failure cases and status codes (400validation/same-gender,403/404tenancy,409illegal transition / already-expired) — per../../contracts/conventions/api-conventions.mdandmoney-and-types.md(gender section), starting from the domain contract template. Refresh theswagger.jsonsnapshot per../../contracts/openapi/README.mdso 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 (anaccepted_awaiting_paymentrequest → create thebookingsrow, set the request toconvertedthrough the guard, and that the full encrypted care instructions are b9's stage-2 — this phase intentionally exposes onlycustomer_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 writereports/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 nomocks-registry.mdrow.
- payment countdowns, and the nurse incoming-requests inbox with accept/reject). Append your phase
summary to
- 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-lineMEMORY.mdpointer.