import { useCallback, useContext } from 'react'; import { environment } from '../environments/environment.ts'; import { useHttpService } from './HttpService.tsx'; import { useAuthServiceContext } from './AuthService.tsx'; import { useOptionalBillingContext } from './BillingContext.tsx'; import type { ApiServiceSuggestion } from '../utils/booking/matchServiceSuggestions'; import type { ApiPractitionerRanking } from '../utils/booking/matchPractitionerSuggestions'; import type { BookingIntentConflict, BookingIntentSelection } from '../utils/booking/bookingIntentConflict'; import { DemoVisitRoleContext } from '../pages/Demo/DemoRoleSwitch'; /** One question whose answer shortens a service list too long to choose from. Never blocking. */ export interface ServiceClarifyingQuestion { question: string; options: string[]; } interface ServiceMatchResponse { matches?: ApiServiceSuggestion[]; question?: ServiceClarifyingQuestion; /** How many services matched above the floor - bigger than `matches` when the list was long. */ matchedCount?: number; } // Thin transport for reason-for-visit -> service suggestions. The server (fhirBaseApi, // POST /R4/booking/match-service) scopes to the org from the auth header and fails open to [] // on any internal error; this client fails open too on ANY thrown error (network, auth, // non-2xx, malformed body) so the caller can always fall back to the manual service picker // without special-casing failure. fhirBaseApi already ends in /R4, so the path is appended bare. const API_URL = environment.fhirBaseApi; export const useBookingMatchService = () => { const matchingAvailable = useContext(DemoVisitRoleContext) === undefined; const { post } = useHttpService(); // The intent check is a model call, so only a practice with AI Notes makes it; unknown is not entitled. const aiNotesEnabled = useOptionalBillingContext()?.entitlements?.aiNotesEnabled === true; const { wpOrganization } = useAuthServiceContext(); // The server answers with { matches, question }. `question` is present only when more than five // services matched and one plain fact would shorten the list; it is advisory and never blocks. // // BOTH SHAPES ARE ACCEPTED, and that is not superstition. This endpoint used to answer with a bare // array, and the old client narrowed with `Array.isArray(result)` - so a client one deploy behind the // server would have read the new object as "no suggestions" and shown an empty picker with no error // anywhere. Reading either shape means the two can be deployed in any order. const matchService = useCallback(async ( reasonText: string, clarifyAnswer?: string, organizationId?: string, ): Promise<{ suggestions: ApiServiceSuggestion[]; question?: ServiceClarifyingQuestion; matchedCount: number; }> => { if (!matchingAvailable) return { suggestions: [], matchedCount: 0 }; try { const result = await post( `${API_URL}/booking/match-service`, { organizationId: organizationId ?? wpOrganization.current?.id ?? '', reasonText, ...(clarifyAnswer ? { clarifyAnswer } : {}), }, { organizationId }, ); if (Array.isArray(result)) return { suggestions: result, matchedCount: result.length }; const suggestions = Array.isArray(result?.matches) ? result.matches : []; return { suggestions, question: result?.question, // An older server does not send it; the suggestions themselves are then the whole truth. matchedCount: typeof result?.matchedCount === 'number' ? result.matchedCount : suggestions.length, }; } catch { return { suggestions: [], matchedCount: 0 }; // client fail-open - falls back to the manual picker } }, [post, wpOrganization, matchingAvailable]); const matchPractitioner = useCallback(async (reasonText: string, serviceId: string, organizationId?: string): Promise => { if (!matchingAvailable) return []; try { const result = await post(`${API_URL}/booking/match-practitioner`, { organizationId: organizationId ?? wpOrganization.current?.id ?? '', reasonText, serviceId, }, { organizationId }); return Array.isArray(result) ? result : []; } catch { return []; // client fail-open - UI falls back to the manual practitioner picker } }, [post, wpOrganization, matchingAvailable]); /** * Does the appointment about to be booked contradict what the person asked for in their own words? * * The reason text is consumed once, to pick a service, and then discarded. Everything chosen after it * is narrowed against constraints that know nothing about the sentence that started it, so a booking * can be COMPLETELY VALID AND COMPLETELY WRONG - every field legal on its own, and nothing anywhere * disagreeing when a stated video visit quietly becomes an in-person one. * * `undefined` MEANS SILENCE, AND IT IS THE COMMONEST CORRECT ANSWER. It is also what every failure * returns, on both sides. This is advisory over a picker that must already be correct by itself: it * never gates a booking, and the server's Appointment POST remains the only thing that refuses one. * * NO organizationId IS SENT, unlike its two siblings above. This route reads nothing - it compares a * sentence the caller typed with a selection the caller made - so there is no catalog to scope. */ const checkIntent = useCallback(async ( reasonText: string, selection: BookingIntentSelection, ): Promise => { if (!reasonText.trim() || !aiNotesEnabled || !matchingAvailable) return undefined; try { const result = await post>(`${API_URL}/booking/check-intent`, { reasonText, selection, }); // The server answers {} for silence. Requiring all four fields rather than testing for an empty // object keeps a partial response - which is what a half-grounded conflict would look like - from // rendering a sentence with a hole in it. if (!result?.axis || !result.quote || !result.selected || !result.message) return undefined; return result as BookingIntentConflict; } catch { return undefined; // client fail-open - say nothing rather than block the booking } }, [post, aiNotesEnabled, matchingAvailable]); return { matchService, matchPractitioner, checkIntent, matchingAvailable }; };