From 96977e84a171bcb2d40c5b97711b9a699ee13d2f Mon Sep 17 00:00:00 2001 From: saridsa2 Date: Wed, 15 Apr 2026 12:01:08 +0530 Subject: [PATCH] feat(maint): backfill-appointment-clinics endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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: Co-Authored-By: Claude Opus 4.6 (1M context) --- src/maint/maint.controller.ts | 125 ++++++++++++++++++++++++++++++++++ 1 file changed, 125 insertions(+) diff --git a/src/maint/maint.controller.ts b/src/maint/maint.controller.ts index 07f3dda..abfd3fb 100644 --- a/src/maint/maint.controller.ts +++ b/src/maint/maint.controller.ts @@ -781,4 +781,129 @@ export class MaintController { } 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: + @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( + `{ 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>(); + for (const docId of uniqueDoctorIds) { + try { + const data: any = await this.platform.query( + `{ 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 = { 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( + `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 }; + } }