mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage-server
synced 2026-04-12 02:18:18 +00:00
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>
This commit is contained in:
151
src/shared/doctor-utils.ts
Normal file
151
src/shared/doctor-utils.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
// 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));
|
||||
};
|
||||
Reference in New Issue
Block a user