diff --git a/src/app.module.ts b/src/app.module.ts index 41f8cb9..680b687 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -21,6 +21,7 @@ import { RulesEngineModule } from './rules-engine/rules-engine.module'; import { ConfigThemeModule } from './config/config-theme.module'; import { WidgetModule } from './widget/widget.module'; import { TeamModule } from './team/team.module'; +import { MasterdataModule } from './masterdata/masterdata.module'; import { TelephonyRegistrationService } from './telephony-registration.service'; @Module({ @@ -49,6 +50,7 @@ import { TelephonyRegistrationService } from './telephony-registration.service'; ConfigThemeModule, WidgetModule, TeamModule, + MasterdataModule, ], providers: [TelephonyRegistrationService], }) diff --git a/src/masterdata/masterdata.controller.ts b/src/masterdata/masterdata.controller.ts new file mode 100644 index 0000000..9bfaf2a --- /dev/null +++ b/src/masterdata/masterdata.controller.ts @@ -0,0 +1,45 @@ +import { Controller, Get, Query, Logger } from '@nestjs/common'; +import { MasterdataService } from './masterdata.service'; + +@Controller('api/masterdata') +export class MasterdataController { + private readonly logger = new Logger(MasterdataController.name); + + constructor(private masterdata: MasterdataService) {} + + @Get('departments') + async departments() { + return this.masterdata.getDepartments(); + } + + @Get('doctors') + async doctors() { + return this.masterdata.getDoctors(); + } + + @Get('clinics') + async clinics() { + return this.masterdata.getClinics(); + } + + // Available time slots for a doctor on a given date. + // Computed from DoctorVisitSlot entities (doctor × clinic × dayOfWeek). + // Returns 30-min slots within the doctor's visiting window for that day. + // + // GET /api/masterdata/slots?doctorId=xxx&date=2026-04-15 + @Get('slots') + async slots( + @Query('doctorId') doctorId: string, + @Query('date') date: string, + ) { + if (!doctorId || !date) return []; + return this.masterdata.getAvailableSlots(doctorId, date); + } + + // Force cache refresh (admin use) + @Get('refresh') + async refresh() { + await this.masterdata.invalidateAll(); + return { refreshed: true }; + } +} diff --git a/src/masterdata/masterdata.module.ts b/src/masterdata/masterdata.module.ts new file mode 100644 index 0000000..8e76bf5 --- /dev/null +++ b/src/masterdata/masterdata.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { PlatformModule } from '../platform/platform.module'; +import { AuthModule } from '../auth/auth.module'; +import { MasterdataController } from './masterdata.controller'; +import { MasterdataService } from './masterdata.service'; + +@Module({ + imports: [PlatformModule, AuthModule], + controllers: [MasterdataController], + providers: [MasterdataService], + exports: [MasterdataService], +}) +export class MasterdataModule {} diff --git a/src/masterdata/masterdata.service.ts b/src/masterdata/masterdata.service.ts new file mode 100644 index 0000000..d64f63c --- /dev/null +++ b/src/masterdata/masterdata.service.ts @@ -0,0 +1,183 @@ +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}`; + + const cached = await this.cache.getCache(cacheKey); + if (cached) return JSON.parse(cached); + + 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)); + + await this.cache.setCache(cacheKey, JSON.stringify(slots), CACHE_TTL); + this.logger.log(`Generated ${slots.length} slots for doctor ${doctorId} on ${dayOfWeek}`); + return slots; + } + + 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'); + } +}