mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage-server
synced 2026-04-11 18:08:16 +00:00
feat: master data endpoint — cached departments, doctors, clinics
Redis-cached (5min TTL) lookups via /api/masterdata/departments, /api/masterdata/doctors, /api/masterdata/clinics. Warms cache on startup. Frontend dropdowns use these instead of hardcoded lists. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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],
|
||||
})
|
||||
|
||||
45
src/masterdata/masterdata.controller.ts
Normal file
45
src/masterdata/masterdata.controller.ts
Normal file
@@ -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 };
|
||||
}
|
||||
}
|
||||
13
src/masterdata/masterdata.module.ts
Normal file
13
src/masterdata/masterdata.module.ts
Normal file
@@ -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 {}
|
||||
183
src/masterdata/masterdata.service.ts
Normal file
183
src/masterdata/masterdata.service.ts
Normal file
@@ -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<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}`;
|
||||
|
||||
const cached = await this.cache.getCache(cacheKey);
|
||||
if (cached) return JSON.parse(cached);
|
||||
|
||||
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));
|
||||
|
||||
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<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');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user