mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage-server
synced 2026-04-11 18:08:16 +00:00
- 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>
152 lines
5.9 KiB
TypeScript
152 lines
5.9 KiB
TypeScript
// 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));
|
||
};
|