Files
helix-engage-server/src/shared/doctor-utils.ts
saridsa2 695f119c2b feat: team module, multi-stage Dockerfile, doctor utils, AI config overhaul
- Team module: POST /api/team/members (in-place employee creation with
  temp password + Redis cache), PUT /api/team/members/:id, GET temp
  password endpoint. Uses signUpInWorkspace — no email invites.
- Dockerfile: rewritten as multi-stage build (builder + runtime) so
  native modules compile for target arch. Fixes darwin→linux crash.
- .dockerignore: exclude dist, node_modules, .env, .git, data/
- package-lock.json: regenerated against public npmjs.org (was
  pointing at localhost:4873 Verdaccio — broke docker builds)
- Doctor utils: shared DOCTOR_VISIT_SLOTS_FRAGMENT + normalizeDoctors
  helper for visit-slot-aware queries across 6 consumers
- AI config: full admin CRUD (GET/PUT/POST reset), workspace-scoped
  setup-state with workspace ID isolation, AI prompt defaults overhaul
- Agent config: camelCase field fix for SDK-synced workspaces
- Session service: workspace-scoped Redis key prefixing for setup state
- Recordings/supervisor/widget services: updated to use doctor-utils
  shared fragments instead of inline visitingHours queries

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 08:37:58 +05:30

152 lines
5.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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<RawDoctor, 'id' | 'name'> & {
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<string, string> = {
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<string>();
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));
};