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('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 { 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( `{ 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> { 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( `{ 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> { 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( `{ 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> { 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( `{ 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 { 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'); } }