// Shared utilities for working with the helix-engage Doctor entity // after the multi-clinic visit-slot rework. The doctor data model // changed from { clinic: RELATION, visitingHours: TEXT } to many // DoctorVisitSlot records (one per day-of-week × clinic), so every // service that fetches doctors needs to: // // 1. Query `visitSlots { dayOfWeek startTime endTime clinic { id clinicName } }` // instead of the legacy flat fields. // 2. Fold the slots back into a "where do they visit" summary string // and a list of unique clinics for branch-matching. // // This file provides: // // - DOCTOR_VISIT_SLOTS_FRAGMENT: a string fragment that callers can // splice into their `doctors { edges { node { ... } } }` query so // the field selection stays consistent across services. // // - normalizeDoctor(d): takes the raw GraphQL node and returns the // same object plus three derived fields: // * `clinics: { id, clinicName }[]` — unique list of clinics // the doctor visits, deduped by id. // * `clinic: { clinicName } | null` — first clinic for legacy // consumers that only show one (the AI prompt KB, etc.). // * `visitingHours: string` — pre-formatted summary like // "Mon 09:00-13:00 (Koramangala) · Wed 14:00-18:00 (Indiranagar)" // suitable for inlining into AI prompts. // // Keeping the legacy field names (`clinic`, `visitingHours`) on the // normalized object means call sites that previously read those // fields keep working — only the GraphQL query and the call to // normalizeDoctor need to be added. export type RawDoctorVisitSlot = { dayOfWeek?: string | null; startTime?: string | null; endTime?: string | null; clinic?: { id?: string | null; clinicName?: string | null } | null; }; export type RawDoctor = { id?: string; name?: string | null; fullName?: { firstName?: string | null; lastName?: string | null } | null; department?: string | null; specialty?: string | null; visitSlots?: { edges?: Array<{ node: RawDoctorVisitSlot }> } | null; [key: string]: any; }; // Tightened shape — `id` and `name` are always strings (with sensible // fallbacks) so consumers can assign them to typed maps without // "string | undefined" errors. The remaining fields keep their // nullable nature from RawDoctor. export type NormalizedDoctor = Omit & { id: string; name: string; clinics: Array<{ id: string; clinicName: string }>; clinic: { clinicName: string } | null; visitingHours: string; }; // GraphQL fragment for the visit-slots reverse relation. Spliced into // each doctors query so all services fetch the same shape. Capped at // 20 slots per doctor — generous for any realistic schedule (7 days // × 2-3 clinics). export const DOCTOR_VISIT_SLOTS_FRAGMENT = `visitSlots(first: 20) { edges { node { dayOfWeek startTime endTime clinic { id clinicName } } } }`; const DAY_ABBREV: Record = { MONDAY: 'Mon', TUESDAY: 'Tue', WEDNESDAY: 'Wed', THURSDAY: 'Thu', FRIDAY: 'Fri', SATURDAY: 'Sat', SUNDAY: 'Sun', }; const formatTime = (t: string | null | undefined): string => { if (!t) return ''; // Times come in as "HH:MM" or "HH:MM:SS" — strip seconds for // display compactness. return t.length > 5 ? t.slice(0, 5) : t; }; // Best-effort doctor name derivation — prefer the platform's `name` // field, then fall back to the composite fullName, then to a generic // label so consumers never see undefined. const deriveName = (raw: RawDoctor): string => { if (raw.name && raw.name.trim()) return raw.name.trim(); const first = raw.fullName?.firstName?.trim() ?? ''; const last = raw.fullName?.lastName?.trim() ?? ''; const full = `${first} ${last}`.trim(); if (full) return full; return 'Unknown doctor'; }; export const normalizeDoctor = (raw: RawDoctor): NormalizedDoctor => { const slots = raw.visitSlots?.edges?.map((e) => e.node) ?? []; // Unique clinics, preserving the order they were encountered. const seen = new Set(); const clinics: Array<{ id: string; clinicName: string }> = []; for (const slot of slots) { const id = slot.clinic?.id; const name = slot.clinic?.clinicName; if (!id || !name || seen.has(id)) continue; seen.add(id); clinics.push({ id, clinicName: name }); } // Visiting hours summary — `Day HH:MM-HH:MM (Clinic)` joined by // " · ". Slots without a clinic or without a day get dropped. const segments: string[] = []; for (const slot of slots) { const day = slot.dayOfWeek ? (DAY_ABBREV[slot.dayOfWeek] ?? slot.dayOfWeek) : null; const start = formatTime(slot.startTime); const end = formatTime(slot.endTime); const clinic = slot.clinic?.clinicName; if (!day || !start || !clinic) continue; segments.push(`${day} ${start}${end ? `-${end}` : ''} (${clinic})`); } return { ...raw, id: raw.id ?? '', name: deriveName(raw), clinics, // Bridge field — first clinic, so legacy consumers that read // `d.clinic.clinicName` keep working. clinic: clinics.length > 0 ? { clinicName: clinics[0].clinicName } : null, visitingHours: segments.join(' · '), }; }; // Convenience: normalize an array of raw GraphQL nodes in one call. export const normalizeDoctors = (raws: RawDoctor[]): NormalizedDoctor[] => raws.map(normalizeDoctor); // Branch-matching helper: a doctor "matches" a branch if any of their // visit slots is at a clinic whose name contains the branch substring // (case-insensitive). Used by widget chat tools to filter doctors by // the visitor's selected branch. export const doctorMatchesBranch = (d: NormalizedDoctor, branch: string | undefined | null): boolean => { if (!branch) return true; const needle = branch.toLowerCase(); return d.clinics.some((c) => c.clinicName.toLowerCase().includes(needle)); };