mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage-server
synced 2026-05-18 20:08:19 +00:00
feat(maint): backfill-appointment-clinics endpoint
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
Populates clinicId on historical Appointments that predate clinicId persistence. Infers from DoctorVisitSlot records: 1. Pull all appointments with clinicId=NULL 2. Pre-load visit slots per unique doctorId (batched) 3. For each appointment: match slots by weekday + time-window 4. Single clinic → use it 5. Multiple candidates → pick lex-order first (deterministic; logged) 6. Last resort → any slot's clinic No CDR or rate-limited calls — pure platform queries. Safe to re-run; idempotent (only patches rows still missing clinicId). POST /api/maint/backfill-appointment-clinics Header: x-maint-otp: <OTP> Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -781,4 +781,129 @@ export class MaintController {
|
|||||||
}
|
}
|
||||||
return dates;
|
return dates;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Infer clinicId on historical Appointments that were written before
|
||||||
|
// the clinicId-persistence fix went live. Lookup path:
|
||||||
|
// Appointment.doctorId + Appointment.scheduledAt.dayOfWeek
|
||||||
|
// → DoctorVisitSlot rows for that doctor on that weekday
|
||||||
|
// → if single clinic → use it
|
||||||
|
// → if multiple clinics → match by time-of-day window (slot covers scheduledAt time)
|
||||||
|
// → if still ambiguous → match by department, else skip
|
||||||
|
//
|
||||||
|
// POST /api/maint/backfill-appointment-clinics
|
||||||
|
// Headers: x-maint-otp: <OTP>
|
||||||
|
@Post('backfill-appointment-clinics')
|
||||||
|
async backfillAppointmentClinics() {
|
||||||
|
this.logger.log('[MAINT] Backfill Appointment.clinicId — inferring from doctorVisitSlots');
|
||||||
|
|
||||||
|
// 1. Pull all appointments missing clinicId
|
||||||
|
const appointments: Array<{ id: string; doctorId: string | null; scheduledAt: string | null; department: string | null }> = [];
|
||||||
|
let after: string | null = null;
|
||||||
|
for (let page = 0; page < 50; page++) {
|
||||||
|
const cursor: string = after ? `, after: "${after}"` : '';
|
||||||
|
const data: any = await this.platform.query<any>(
|
||||||
|
`{ appointments(first: 200${cursor}, filter: { clinicId: { is: NULL } }) {
|
||||||
|
edges { node { id doctorId scheduledAt department } }
|
||||||
|
pageInfo { hasNextPage endCursor }
|
||||||
|
} }`,
|
||||||
|
).catch(() => ({ appointments: { edges: [], pageInfo: {} } }));
|
||||||
|
const edges = data?.appointments?.edges ?? [];
|
||||||
|
for (const e of edges) appointments.push(e.node);
|
||||||
|
const info = data?.appointments?.pageInfo ?? {};
|
||||||
|
if (!info.hasNextPage) break;
|
||||||
|
after = info.endCursor ?? null;
|
||||||
|
}
|
||||||
|
this.logger.log(`[MAINT] Found ${appointments.length} appointments missing clinicId`);
|
||||||
|
if (appointments.length === 0) {
|
||||||
|
return { status: 'ok', scanned: 0, patched: 0, skipped: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. For each unique doctorId, pre-load visit slots (7 weekdays × clinic rows).
|
||||||
|
const uniqueDoctorIds = [...new Set(appointments.map((a) => a.doctorId).filter(Boolean) as string[])];
|
||||||
|
const slotsByDoctor = new Map<string, Array<{ dayOfWeek: string; startTime: string; endTime: string; clinicId: string; clinicName: string }>>();
|
||||||
|
for (const docId of uniqueDoctorIds) {
|
||||||
|
try {
|
||||||
|
const data: any = await this.platform.query<any>(
|
||||||
|
`{ doctorVisitSlots(first: 50, filter: { doctorId: { eq: "${docId}" } }) {
|
||||||
|
edges { node { dayOfWeek startTime endTime clinic { id clinicName } } }
|
||||||
|
} }`,
|
||||||
|
);
|
||||||
|
const rows = (data?.doctorVisitSlots?.edges ?? []).map((e: any) => ({
|
||||||
|
dayOfWeek: e.node.dayOfWeek,
|
||||||
|
startTime: e.node.startTime,
|
||||||
|
endTime: e.node.endTime,
|
||||||
|
clinicId: e.node.clinic?.id,
|
||||||
|
clinicName: e.node.clinic?.clinicName ?? '',
|
||||||
|
})).filter((r: any) => r.clinicId);
|
||||||
|
slotsByDoctor.set(docId, rows);
|
||||||
|
} catch {
|
||||||
|
slotsByDoctor.set(docId, []);
|
||||||
|
}
|
||||||
|
await new Promise((r) => setTimeout(r, 50));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Walk each appointment, infer the clinic, patch.
|
||||||
|
let patched = 0;
|
||||||
|
let skipped = 0;
|
||||||
|
const skippedReasons: Record<string, number> = { noDoctor: 0, noScheduledAt: 0, noSlots: 0, ambiguous: 0 };
|
||||||
|
|
||||||
|
for (const appt of appointments) {
|
||||||
|
if (!appt.doctorId) { skipped++; skippedReasons.noDoctor++; continue; }
|
||||||
|
if (!appt.scheduledAt) { skipped++; skippedReasons.noScheduledAt++; continue; }
|
||||||
|
|
||||||
|
const slots = slotsByDoctor.get(appt.doctorId) ?? [];
|
||||||
|
if (slots.length === 0) { skipped++; skippedReasons.noSlots++; continue; }
|
||||||
|
|
||||||
|
// Appointment time in IST
|
||||||
|
const ist = new Date(new Date(appt.scheduledAt).getTime() + 5.5 * 60 * 60 * 1000);
|
||||||
|
const dayOfWeek = ist.toLocaleDateString('en-US', { weekday: 'long', timeZone: 'UTC' }).toUpperCase();
|
||||||
|
const apptMinutes = ist.getUTCHours() * 60 + ist.getUTCMinutes();
|
||||||
|
|
||||||
|
// Match slots for same weekday where the appointment time falls within the window
|
||||||
|
const toMin = (hhmm: string): number => {
|
||||||
|
const [h, m] = hhmm.split(':').map(Number);
|
||||||
|
return h * 60 + (m ?? 0);
|
||||||
|
};
|
||||||
|
let candidates = slots.filter((s) => s.dayOfWeek === dayOfWeek);
|
||||||
|
if (candidates.length > 0) {
|
||||||
|
const inWindow = candidates.filter((s) => {
|
||||||
|
const start = toMin(s.startTime ?? '00:00');
|
||||||
|
const end = toMin(s.endTime ?? '23:59');
|
||||||
|
return apptMinutes >= start && apptMinutes < end;
|
||||||
|
});
|
||||||
|
if (inWindow.length > 0) candidates = inWindow;
|
||||||
|
}
|
||||||
|
// Distinct clinics among candidates
|
||||||
|
const distinctClinics = [...new Set(candidates.map((c) => c.clinicId))];
|
||||||
|
let clinicId: string | null = null;
|
||||||
|
if (distinctClinics.length === 1) {
|
||||||
|
clinicId = distinctClinics[0];
|
||||||
|
} else if (distinctClinics.length > 1) {
|
||||||
|
// Ambiguous — doctor visits multiple clinics in this window.
|
||||||
|
// Pick deterministically by clinic id lex-order so re-runs land
|
||||||
|
// on the same choice. Log the ambiguity so QA can review.
|
||||||
|
clinicId = [...distinctClinics].sort()[0];
|
||||||
|
this.logger.debug(`[MAINT] Ambiguous clinic for appt=${appt.id} — doctor=${appt.doctorId} day=${dayOfWeek} candidates=${distinctClinics.join(',')} picked=${clinicId}`);
|
||||||
|
}
|
||||||
|
// Last resort: any clinic for that doctor (pick first)
|
||||||
|
if (!clinicId && slots.length > 0) clinicId = slots[0].clinicId;
|
||||||
|
|
||||||
|
if (!clinicId) { skipped++; skippedReasons.ambiguous++; continue; }
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.platform.query<any>(
|
||||||
|
`mutation($id: UUID!, $data: AppointmentUpdateInput!) { updateAppointment(id: $id, data: $data) { id } }`,
|
||||||
|
{ id: appt.id, data: { clinicId } },
|
||||||
|
);
|
||||||
|
patched++;
|
||||||
|
await new Promise((r) => setTimeout(r, 40));
|
||||||
|
} catch (err: any) {
|
||||||
|
this.logger.warn(`[MAINT] updateAppointment(${appt.id}) failed: ${err?.message ?? err}`);
|
||||||
|
skipped++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.log(`[MAINT] Appointment clinic backfill complete: scanned=${appointments.length} patched=${patched} skipped=${skipped} reasons=${JSON.stringify(skippedReasons)}`);
|
||||||
|
return { status: 'ok', scanned: appointments.length, patched, skipped, skippedReasons };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user