Canonical source: docs/claude/client-reschedule.md. This page is generated by docs/scripts/sync-handbook.mjs. Edit the source file in the repo; changes appear here on the next build.
Client-App Rescheduling
Lets clients reschedule their own bookings from basishybrid (and basisweb later) instead of calling the clinic. Configurable per-clinic, with per-service overrides.
Where basishybrid reads the configuration
All reschedule configuration lives in Firestore, written by basisflow-web's settings UI. basishybrid is a read-only consumer. Do not duplicate the rules in the Flutter codebase.
Two reads on the clinic the user is currently scoped to:
clinicsv2/{clinicId}— root doc. Read fieldclientAppReschedule(object withenabled,cutoffMinutesBefore,sameDayOnly). Use a snapshot listener so changes from staff in basisflow-web reflect live.clinicsv2/{clinicId}/preferences/clientApp— subdoc. Read fieldserviceReschedulePolicies(map ofserviceId→'allow' | 'disallow').
If clientAppReschedule.enabled is missing/false and there is no per-service override of 'allow' for the event's service, show "Call the clinic to reschedule" instead of the Reschedule button (no in-app reschedule action).
If enabled === true (or the service has 'allow' override), show the Reschedule button and apply the cutoff + same-day rules from the same doc.
If the service has 'disallow' override, show "Call the clinic to reschedule" regardless of the org-wide toggle.
Effective-rule resolver (paste into Dart as the source of truth)
bool canClientReschedule({
required bool orgEnabled,
required Map<String, String> perServiceOverrides, // serviceId -> 'allow'|'disallow'
required String serviceId,
}) {
final override = perServiceOverrides[serviceId];
if (override == 'allow') return true;
if (override == 'disallow') return false;
return orgEnabled; // inherit
}
Reschedule UI gating
enabled | per-service override | What basishybrid shows |
|---|---|---|
| any value | 'disallow' | "Call the clinic to reschedule" |
| false / missing | none | "Call the clinic to reschedule" |
| false / missing | 'allow' | Reschedule button (with the org's cutoff/same-day rules — these always apply when allowed) |
| true | none | Reschedule button |
| true | 'allow' | Reschedule button (same as inherit-true) |
In all "Call the clinic" cases, basishybrid should show the clinic's phone number / contact info inline if available, not just a dead-end message.
Status as of writing
| Piece | Status |
|---|---|
| Settings UI in basisflow-web | Shipped |
| Per-service override in marketplace service editor | Shipped |
Backend reschedule_appointment handler | Not built |
| basishybrid UI | Not built |
| basisweb (client portal) UI | Not built |
The settings UI saves to Firestore and the data model is locked in. What's left is the backend handler and the client UIs that call it.
Data model
Org-wide settings — clinicsv2/{clinicId}.clientAppReschedule
{
enabled: boolean; // master switch
cutoffMinutesBefore: number; // how close to original start time clients
// can still act. 0 = up to start time.
sameDayOnly: boolean; // destination slot must be same calendar
// day as the original appointment
}
Stored as a nested field on the clinic doc using dot-notation updateDoc. Read with data.clientAppReschedule ?? {}. Defaults if missing: enabled=false, cutoffMinutesBefore=60, sameDayOnly=false.
The "cutoff" and "same-day" are independent: cutoff restricts when the client can act; same-day restricts where they can move to.
Per-service override — clinicsv2/{clinicId}/preferences/clientApp.serviceReschedulePolicies
{
[serviceId: string]: 'allow' | 'disallow';
}
Three logical states per service:
- Inherit (default) — no entry in the map; service follows the org-wide
enabledflag - Allow —
'allow'; service is reschedulable even when org-wide is off - Disallow —
'disallow'; service is locked even when org-wide is on
Stored in preferences/clientApp subcollection (same doc that holds visible metrics/labs). When the dropdown is set to "Inherit," the entry is deleted from the map (not stored as 'inherit').
Effective rule for a given event
See the resolver in the "Where basishybrid reads the configuration" section above. Apply this before showing a Reschedule button in basishybrid. Backend reschedule_appointment re-validates it server-side using the same logic against the same Firestore docs — basishybrid never sends rules to the backend, the backend reads them directly.
Backend contract (to be built)
Request type
request_type: 'reschedule_appointment'
Request shape
{
clinic_id: string;
event_id: string;
new_start: string; // ISO datetime
new_staff_id?: string; // optional, lets client pick a different coach
// from the available slot's offer set
}
Same service only — no new_service_id in v1.
Server-side validation (must run on the server, not just frontend)
- Load the event. Confirm
event.uid === caller.uid(client can only reschedule their own bookings). Staff useupdate_event_detailsdirectly. - Load org
clientAppReschedulesettings +serviceReschedulePoliciesfrom prefs. - Compute the effective policy for
event.service. If not allowed → returnpermission-deniedwith a clear message. - Cutoff check:
now > event.start - cutoffMinutesBefore * 60000→failed-precondition"Too close to appointment time." - Same-day check (if enabled):
new_start.date() !== event.start.date()(in the clinic's timezone) →failed-precondition"Reschedule must be on the same day." - Slot validity: re-run availability check for
event.serviceatnew_startfor the event's location. If the slot isn't currently bookable →failed-precondition"Slot not available." - Coach pick: if
new_staff_idgiven, verify they're one of the coaches available for that slot.
If all pass, delegate to update_event_details with new_start, optional new_staff_id, and a note flagging the change for the activity log.
Response
Return the updated event (same shape as update_event_details).
Implementation hint
Don't write a parallel reschedule engine. Call update_event_details under the hood after validation — it already handles attendee shuffling, time change, activity-log entry, and notifications.
Notifications
Should match the existing reschedule path's behavior — staff receives a calendar update; client receives confirmation. The notification toggle that staff have in their reschedule modal doesn't apply here (the client is the one doing it).
basishybrid UI requirements
Add a "Reschedule" button on the appointment detail screen. Visible only when canClientReschedule(org, perService, event.service) === true.
When tapped:
- Cutoff guard upfront: if
now > event.start - cutoffMinutesBefore * 60000, show "It's too close to your appointment to reschedule. Please contact the clinic." Don't open the picker. - Date picker — initial date = event's date. If
sameDayOnly, lock to that day. - Time/coach picker — call
getAvailableServiceTimes(clinic, event.service, event.location, dayStart, dayEnd). Show only slots whereavailable === true(use theavailableSlotsarray, notslots). For each slot, surface the coach if known. - Confirm screen — show "From {old time} → To {new time}" with the new coach name.
- Submit — call
reschedule_appointment. On success, navigate back and refresh. Onfailed-precondition(cutoff race / slot taken since rendered), show the error message verbatim — the backend's messages are user-facing.
Disabled / hidden states
- Org toggle off + service is "inherit" → hide button entirely.
- Service has override
disallow→ hide button entirely. - Past cutoff → show button but tapping shows the "too close" message.
What clients can change
- Time (new start)
- Coach (only among coaches available for that slot)
What they can't change in v1:
- Service (cross-service reschedule is a future feature, would need allowed-targets matrix)
- Location
- Duration
Edge cases
- Client books and immediately reschedules: cutoff and slot-validity checks still apply.
- Slot becomes unavailable between picker render and submit: backend re-validates and returns clear error; surface verbatim.
- Group services: same flow. The slot's capacity is checked server-side; if full when they submit, return
failed-precondition. IfhasWaitlist, the reschedule path should NOT silently waitlist — fail with "Slot was just filled." - Membership credit / payment: the original booking's payment carries to the new event.
update_event_detailsalready preserves the event's payment attachments since it's an in-place update. No refund/recharge. - Recurring memberships with bookable windows: the new slot must also be inside the member's allowed booking window. Reuse the existing booking eligibility check.
Where the existing settings UI lives
app/(main)/settings/page.tsx→ ClientAppTab → "Booking" section.app/(main)/marketplace/page.tsx→ ServiceModal → "Client app rescheduling" dropdown.
Open questions
- Should the cutoff respect the clinic's timezone or always client-local? Currently the field is just a duration; recommend interpreting
event.start - cutoffstrictly in UTC sinceevent.startis UTC. - Should rescheduled events get a distinct status / activity-log entry? Recommend yes — adds an
Activity Historyrow "Rescheduled by client" with old/new times.
Cross-references
- Existing reschedule mechanics:
basis-functions/functions/src/functions_clinic.pyupdate_event_detailsat line ~11448 - Existing client-app payment toggles for reference pattern:
data.clientAppPaymentson the clinic doc - Existing settings model in basisflow-web:
app/(main)/settings/page.tsxClientAppTab