mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage-server
synced 2026-05-18 20:08:19 +00:00
Previous flow cached the unfiltered slot list AND applied the "hide past slots" filter — but only on the fresh-fetch path. A cache hit returned the stored list untouched, so by lunchtime agents saw morning slots that had already passed. Refactored into a post-cache filterPastSlotsForToday() helper applied on both cache-hit and fresh paths. Cache stores the full day's slots (keyed by doctorId + dayOfWeek), so same-weekday reuse across weeks stays correct. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
214 lines
9.3 KiB
TypeScript
214 lines
9.3 KiB
TypeScript
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
|
import { ConfigService } from '@nestjs/config';
|
|
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
|
import { SessionService } from '../auth/session.service';
|
|
|
|
// Master data: cached lookups for departments, doctors, clinics.
|
|
// Fetched from the platform on first request, cached in Redis with TTL.
|
|
// Frontend dropdowns use these instead of direct GraphQL queries.
|
|
|
|
const CACHE_TTL = 300; // 5 minutes
|
|
const KEY_DEPARTMENTS = 'masterdata:departments';
|
|
const KEY_DOCTORS = 'masterdata:doctors';
|
|
const KEY_CLINICS = 'masterdata:clinics';
|
|
|
|
@Injectable()
|
|
export class MasterdataService implements OnModuleInit {
|
|
private readonly logger = new Logger(MasterdataService.name);
|
|
private readonly apiKey: string;
|
|
|
|
constructor(
|
|
private config: ConfigService,
|
|
private platform: PlatformGraphqlService,
|
|
private cache: SessionService,
|
|
) {
|
|
this.apiKey = this.config.get<string>('platform.apiKey') ?? process.env.PLATFORM_API_KEY ?? '';
|
|
}
|
|
|
|
async onModuleInit() {
|
|
// Warm cache on startup
|
|
try {
|
|
await this.getDepartments();
|
|
await this.getDoctors();
|
|
await this.getClinics();
|
|
this.logger.log('Master data cache warmed');
|
|
} catch (err: any) {
|
|
this.logger.warn(`Cache warm failed: ${err.message}`);
|
|
}
|
|
}
|
|
|
|
async getDepartments(): Promise<string[]> {
|
|
const cached = await this.cache.getCache(KEY_DEPARTMENTS);
|
|
if (cached) return JSON.parse(cached);
|
|
|
|
const auth = `Bearer ${this.apiKey}`;
|
|
const data = await this.platform.queryWithAuth<any>(
|
|
`{ doctors(first: 500) { edges { node { department } } } }`,
|
|
undefined, auth,
|
|
);
|
|
|
|
const departments = Array.from(new Set(
|
|
data.doctors.edges
|
|
.map((e: any) => e.node.department)
|
|
.filter((d: string) => d && d.trim()),
|
|
)).sort() as string[];
|
|
|
|
await this.cache.setCache(KEY_DEPARTMENTS, JSON.stringify(departments), CACHE_TTL);
|
|
this.logger.log(`Cached ${departments.length} departments`);
|
|
return departments;
|
|
}
|
|
|
|
async getDoctors(): Promise<Array<{ id: string; name: string; department: string; qualifications: string }>> {
|
|
const cached = await this.cache.getCache(KEY_DOCTORS);
|
|
if (cached) return JSON.parse(cached);
|
|
|
|
const auth = `Bearer ${this.apiKey}`;
|
|
const data = await this.platform.queryWithAuth<any>(
|
|
`{ doctors(first: 500) { edges { node {
|
|
id name department qualifications specialty active
|
|
fullName { firstName lastName }
|
|
} } } }`,
|
|
undefined, auth,
|
|
);
|
|
|
|
const doctors = data.doctors.edges
|
|
.map((e: any) => ({
|
|
id: e.node.id,
|
|
name: e.node.name ?? `${e.node.fullName?.firstName ?? ''} ${e.node.fullName?.lastName ?? ''}`.trim(),
|
|
department: e.node.department ?? '',
|
|
qualifications: e.node.qualifications ?? '',
|
|
specialty: e.node.specialty ?? '',
|
|
active: e.node.active ?? true,
|
|
}))
|
|
.filter((d: any) => d.active !== false);
|
|
|
|
await this.cache.setCache(KEY_DOCTORS, JSON.stringify(doctors), CACHE_TTL);
|
|
this.logger.log(`Cached ${doctors.length} doctors`);
|
|
return doctors;
|
|
}
|
|
|
|
async getClinics(): Promise<Array<{ id: string; name: string; phone: string; address: string; opensAt: string; closesAt: string }>> {
|
|
const cached = await this.cache.getCache(KEY_CLINICS);
|
|
if (cached) return JSON.parse(cached);
|
|
|
|
const auth = `Bearer ${this.apiKey}`;
|
|
const data = await this.platform.queryWithAuth<any>(
|
|
`{ clinics(first: 50) { edges { node {
|
|
id clinicName status opensAt closesAt
|
|
phone { primaryPhoneNumber }
|
|
addressCustom { addressCity addressState }
|
|
} } } }`,
|
|
undefined, auth,
|
|
);
|
|
|
|
const clinics = data.clinics.edges
|
|
.filter((e: any) => e.node.status !== 'INACTIVE')
|
|
.map((e: any) => ({
|
|
id: e.node.id,
|
|
name: e.node.clinicName ?? '',
|
|
phone: e.node.phone?.primaryPhoneNumber ?? '',
|
|
opensAt: e.node.opensAt ?? '08:00',
|
|
closesAt: e.node.closesAt ?? '20:00',
|
|
address: [e.node.addressCustom?.addressCity, e.node.addressCustom?.addressState].filter(Boolean).join(', '),
|
|
}));
|
|
|
|
await this.cache.setCache(KEY_CLINICS, JSON.stringify(clinics), CACHE_TTL);
|
|
this.logger.log(`Cached ${clinics.length} clinics`);
|
|
return clinics;
|
|
}
|
|
|
|
// Available time slots for a doctor on a given date.
|
|
// Reads DoctorVisitSlot entities for the matching dayOfWeek,
|
|
// then generates 30-min slots within each visiting window.
|
|
async getAvailableSlots(doctorId: string, date: string): Promise<Array<{ time: string; label: string; clinicId: string; clinicName: string }>> {
|
|
const dayOfWeek = new Date(date).toLocaleDateString('en-US', { weekday: 'long' }).toUpperCase();
|
|
const cacheKey = `masterdata:slots:${doctorId}:${dayOfWeek}`;
|
|
|
|
// Cache stores the UNFILTERED full-day slot list (keyed by dayOfWeek,
|
|
// so it's reusable across dates that fall on the same weekday). The
|
|
// "hide past slots on today" filter is applied AFTER cache read so it
|
|
// stays correct as real-time advances without cache churn.
|
|
const cached = await this.cache.getCache(cacheKey);
|
|
if (cached) return this.filterPastSlotsForToday(JSON.parse(cached), date);
|
|
|
|
const auth = `Bearer ${this.apiKey}`;
|
|
const data = await this.platform.queryWithAuth<any>(
|
|
`{ doctorVisitSlots(first: 100, filter: { doctorId: { eq: "${doctorId}" }, dayOfWeek: { eq: ${dayOfWeek} } }) {
|
|
edges { node { id startTime endTime clinic { id clinicName } } }
|
|
} }`,
|
|
undefined, auth,
|
|
);
|
|
|
|
const slots: Array<{ time: string; label: string; clinicId: string; clinicName: string }> = [];
|
|
|
|
for (const edge of data.doctorVisitSlots?.edges ?? []) {
|
|
const node = edge.node;
|
|
const clinicId = node.clinic?.id ?? '';
|
|
const clinicName = node.clinic?.clinicName ?? '';
|
|
const startTime = node.startTime ?? '09:00';
|
|
const endTime = node.endTime ?? '17:00';
|
|
|
|
// Generate 30-min slots within visiting window
|
|
const [startH, startM] = startTime.split(':').map(Number);
|
|
const [endH, endM] = endTime.split(':').map(Number);
|
|
let h = startH, m = startM ?? 0;
|
|
const endMin = endH * 60 + (endM ?? 0);
|
|
|
|
while (h * 60 + m < endMin) {
|
|
const hh = h.toString().padStart(2, '0');
|
|
const mm = m.toString().padStart(2, '0');
|
|
const ampm = h < 12 ? 'AM' : 'PM';
|
|
const displayH = h === 0 ? 12 : h > 12 ? h - 12 : h;
|
|
slots.push({
|
|
time: `${hh}:${mm}`,
|
|
label: `${displayH}:${mm.toString().padStart(2, '0')} ${ampm} — ${clinicName}`,
|
|
clinicId,
|
|
clinicName,
|
|
});
|
|
m += 30;
|
|
if (m >= 60) { h++; m = 0; }
|
|
}
|
|
}
|
|
|
|
// Sort by time
|
|
slots.sort((a, b) => a.time.localeCompare(b.time));
|
|
|
|
// Cache the full UNFILTERED list so reuse across dates (same dayOfWeek)
|
|
// doesn't mis-serve filtered data from an earlier date.
|
|
await this.cache.setCache(cacheKey, JSON.stringify(slots), CACHE_TTL);
|
|
this.logger.log(`Generated ${slots.length} slots for doctor ${doctorId} on ${dayOfWeek}`);
|
|
|
|
return this.filterPastSlotsForToday(slots, date);
|
|
}
|
|
|
|
// When the requested date is today (IST), hide slots whose time has
|
|
// already passed (30-min buffer so we don't offer the impossible-to-keep
|
|
// "in 5 minutes" slot). Applies to both cache-hit and fresh fetch paths.
|
|
private filterPastSlotsForToday(
|
|
slots: Array<{ time: string; label: string; clinicId: string; clinicName: string }>,
|
|
date: string,
|
|
): Array<{ time: string; label: string; clinicId: string; clinicName: string }> {
|
|
const todayIst = new Date().toLocaleDateString('en-CA', { timeZone: 'Asia/Kolkata' });
|
|
if (date !== todayIst) return slots;
|
|
|
|
const nowHHMM = new Date().toLocaleTimeString('en-GB', {
|
|
timeZone: 'Asia/Kolkata', hour: '2-digit', minute: '2-digit',
|
|
});
|
|
const [nowH, nowM] = nowHHMM.split(':').map(Number);
|
|
const cutoff = nowH * 60 + nowM + 30; // 30-min buffer
|
|
const filtered = slots.filter((s) => {
|
|
const [h, m] = s.time.split(':').map(Number);
|
|
return h * 60 + m >= cutoff;
|
|
});
|
|
this.logger.log(`[SLOTS] Today filter: ${slots.length} → ${filtered.length} (now=${nowHHMM} IST, cutoff=${Math.floor(cutoff / 60)}:${String(cutoff % 60).padStart(2, '0')})`);
|
|
return filtered;
|
|
}
|
|
|
|
async invalidateAll(): Promise<void> {
|
|
await this.cache.setCache(KEY_DEPARTMENTS, '', 1);
|
|
await this.cache.setCache(KEY_DOCTORS, '', 1);
|
|
await this.cache.setCache(KEY_CLINICS, '', 1);
|
|
this.logger.log('Master data cache invalidated');
|
|
}
|
|
}
|