diff --git a/.env.example b/.env.example index 05029ce..9c1fb66 100644 --- a/.env.example +++ b/.env.example @@ -10,6 +10,10 @@ LOG_LEVEL=info # SQLite Configuration SQLITE_DB_PATH=./data/fhirtogether.db +# MongoDB Configuration (if using mongo) +# MONGO_URI=mongodb://localhost:27017 +# MONGO_DB_NAME=fhirtogether + # PostgreSQL Configuration (if using postgres) # POSTGRES_HOST=localhost # POSTGRES_PORT=5432 diff --git a/IMPLEMENTATION.md b/IMPLEMENTATION.md index 79d3198..72acc71 100644 --- a/IMPLEMENTATION.md +++ b/IMPLEMENTATION.md @@ -157,7 +157,7 @@ When you run `npm run generate-data`: Environment variables in `.env`: ```env -STORE_BACKEND=sqlite # Database backend (only sqlite for now) +STORE_BACKEND=sqlite # Database backend (sqlite or mongo) PORT=4010 # Server port HOST=0.0.0.0 # Server host LOG_LEVEL=info # Logging level @@ -225,16 +225,16 @@ curl "http://localhost:4010/Appointment?date=2025-12-09" ## 🎉 Success Criteria Met -✅ **Directory Framework**: Complete src/ structure with types, store, routes, examples -✅ **Database Backend**: Full CRUD operations for all FHIR resources -✅ **API Endpoints**: All Schedule, Slot, Appointment operations implemented -✅ **Busy Office Example**: 3 providers, 50-60 patients/day, 30 days ahead -✅ **Data Generation**: Automated script with statistics reporting -✅ **Data Regeneration**: Can refresh stale data anytime +✅ **Directory Framework**: Complete src/ structure with types, store, routes, examples +✅ **Database Backend**: Full CRUD operations for all FHIR resources +✅ **API Endpoints**: All Schedule, Slot, Appointment operations implemented +✅ **Busy Office Example**: 3 providers, 50-60 patients/day, 30 days ahead +✅ **Data Generation**: Automated script with statistics reporting +✅ **Data Regeneration**: Can refresh stale data anytime ## 🔮 Next Steps (Future Enhancements) -- [ ] Add PostgreSQL, MySQL, MongoDB store implementations +- [ ] Add PostgreSQL, MySQL store implementations - [ ] Implement HL7v2 SIU message ingestion - [ ] Add `$find-appointment` FHIR operation - [ ] Implement SMART-on-FHIR authentication diff --git a/QUICKSTART.md b/QUICKSTART.md index 13a3af3..4cfdc95 100644 --- a/QUICKSTART.md +++ b/QUICKSTART.md @@ -174,6 +174,10 @@ LOG_LEVEL=info # Database Configuration SQLITE_DB_PATH=./data/fhirtogether.db +# MongoDB (used when STORE_BACKEND=mongo) +# MONGO_URI=mongodb://localhost:27017 +# MONGO_DB_NAME=fhirtogether + # Enable test/admin endpoints (DELETE operations) ENABLE_TEST_ENDPOINTS=true ``` @@ -184,7 +188,7 @@ The busy office example includes: - **3 Providers**: - Dr. Sarah Smith (Family Medicine) - 20 min appointments - - Dr. Michael Johnson (Internal Medicine) - 25 min appointments + - Dr. Michael Johnson (Internal Medicine) - 25 min appointments - Dr. Emily Williams (Pediatrics) - 15 min appointments - **Appointment Volume**: ~50-60 patients per provider per day @@ -228,7 +232,7 @@ All endpoints follow FHIR R4 specifications for: ## 🧩 Next Steps -- Implement additional backend stores (PostgreSQL, MySQL, MongoDB) +- Implement additional backend stores (PostgreSQL, MySQL) - Add HL7v2 message ingestion (`POST /$hl7v2-ingest`) - Implement `$find-appointment` operation - Add SMART-on-FHIR authentication diff --git a/docker-compose.yml b/docker-compose.yml index 0e02942..2575c8a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,6 +2,8 @@ services: app: build: . init: true + depends_on: + - mongo ports: - "4010:4010" volumes: @@ -12,4 +14,17 @@ services: environment: - NODE_ENV=production - SQLITE_DB_PATH=/app/data/fhirtogether.db - command: npm run dev \ No newline at end of file + - MONGO_URI=mongodb://mongo:27017 + - MONGO_DB_NAME=fhirtogether + command: npm run dev + + mongo: + image: mongo:7 + restart: unless-stopped + ports: + - "27017:27017" + volumes: + - mongo_data:/data/db + +volumes: + mongo_data: diff --git a/package-lock.json b/package-lock.json index 1a15e8b..d2af643 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,7 @@ "better-sqlite3": "^9.2.2", "dotenv": "^16.3.1", "fastify": "^5.8.5", + "mongodb": "^6.18.0", "pino": "^8.17.2", "pino-pretty": "^10.3.1", "rrule": "^2.8.1", @@ -2324,6 +2325,14 @@ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "license": "MIT" }, + "node_modules/@mongodb-js/saslprep": { + "version": "1.4.11", + "resolved": "https://registry.npmjs.org/@mongodb-js/saslprep/-/saslprep-1.4.11.tgz", + "integrity": "sha512-o9rAHc0IpIjuPSxRutWpE1F62x7n+4mVS4rCNHkzhIUMQcc18bb6xEq5wd2NdN0WjepIyXIppRshYI2kQDOZVA==", + "dependencies": { + "sparse-bitfield": "^3.0.3" + } + }, "node_modules/@napi-rs/wasm-runtime": { "version": "0.2.12", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", @@ -2573,6 +2582,19 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/webidl-conversions": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/@types/webidl-conversions/-/webidl-conversions-7.0.3.tgz", + "integrity": "sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA==" + }, + "node_modules/@types/whatwg-url": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/@types/whatwg-url/-/whatwg-url-11.0.5.tgz", + "integrity": "sha512-coYR071JRaHa+xoEvvYqvnIHaVqaYrLPbsufM9BF63HkwI5Lgmy2QR8Q5K/lYDYo5AK82wOvSOS0UsLTpTG7uQ==", + "dependencies": { + "@types/webidl-conversions": "*" + } + }, "node_modules/@types/yargs": { "version": "17.0.35", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", @@ -3757,6 +3779,14 @@ "node-int64": "^0.4.0" } }, + "node_modules/bson": { + "version": "6.10.4", + "resolved": "https://registry.npmjs.org/bson/-/bson-6.10.4.tgz", + "integrity": "sha512-WIsKqkSC0ABoBJuT1LEX+2HEvNmNKKgnTAyd0fL8qzK4SH2i9NXg+t08YtdZp/V9IZ33cxe3iV4yM0qg8lMQng==", + "engines": { + "node": ">=16.20.1" + } + }, "node_modules/buffer": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", @@ -6892,6 +6922,11 @@ "node": ">= 0.8" } }, + "node_modules/memory-pager": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/memory-pager/-/memory-pager-1.5.0.tgz", + "integrity": "sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==" + }, "node_modules/merge-descriptors": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", @@ -6994,6 +7029,60 @@ "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", "license": "MIT" }, + "node_modules/mongodb": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-6.21.0.tgz", + "integrity": "sha512-URyb/VXMjJ4da46OeSXg+puO39XH9DeQpWCslifrRn9JWugy0D+DvvBvkm2WxmHe61O/H19JM66p1z7RHVkZ6A==", + "dependencies": { + "@mongodb-js/saslprep": "^1.3.0", + "bson": "^6.10.4", + "mongodb-connection-string-url": "^3.0.2" + }, + "engines": { + "node": ">=16.20.1" + }, + "peerDependencies": { + "@aws-sdk/credential-providers": "^3.188.0", + "@mongodb-js/zstd": "^1.1.0 || ^2.0.0", + "gcp-metadata": "^5.2.0", + "kerberos": "^2.0.1", + "mongodb-client-encryption": ">=6.0.0 <7", + "snappy": "^7.3.2", + "socks": "^2.7.1" + }, + "peerDependenciesMeta": { + "@aws-sdk/credential-providers": { + "optional": true + }, + "@mongodb-js/zstd": { + "optional": true + }, + "gcp-metadata": { + "optional": true + }, + "kerberos": { + "optional": true + }, + "mongodb-client-encryption": { + "optional": true + }, + "snappy": { + "optional": true + }, + "socks": { + "optional": true + } + } + }, + "node_modules/mongodb-connection-string-url": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mongodb-connection-string-url/-/mongodb-connection-string-url-3.0.2.tgz", + "integrity": "sha512-rMO7CGo/9BFwyZABcKAWL8UJwH/Kc2x0g72uhDWzG48URRax5TCIcJ7Rc3RZqffZzO/Gwff/jyKwCU9TN8gehA==", + "dependencies": { + "@types/whatwg-url": "^11.0.2", + "whatwg-url": "^14.1.0 || ^13.0.0" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -7663,7 +7752,6 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -8274,6 +8362,14 @@ "source-map": "^0.6.0" } }, + "node_modules/sparse-bitfield": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/sparse-bitfield/-/sparse-bitfield-3.0.3.tgz", + "integrity": "sha512-kvzhi7vqKTfkh0PZU+2D2PIllw2ymqJKujUcyPMd9Y75Nv4nPbGJZXNhxsgdQab2BmlDct1YnfQCguEvHr7VsQ==", + "dependencies": { + "memory-pager": "^1.0.2" + } + }, "node_modules/split2": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", @@ -8649,6 +8745,17 @@ "node": ">=0.6" } }, + "node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/ts-jest": { "version": "29.4.9", "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.4.9.tgz", @@ -9245,6 +9352,26 @@ "makeerror": "1.0.12" } }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/package.json b/package.json index 2328273..cd95eb6 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "better-sqlite3": "^9.2.2", "dotenv": "^16.3.1", "fastify": "^5.8.5", + "mongodb": "^6.18.0", "pino": "^8.17.2", "pino-pretty": "^10.3.1", "rrule": "^2.8.1", diff --git a/src/server.ts b/src/server.ts index 563dba1..5739c8e 100644 --- a/src/server.ts +++ b/src/server.ts @@ -7,6 +7,7 @@ import { config } from 'dotenv'; import path from 'path'; import fs from 'fs'; import { SqliteStore, SCHEMA_VERSION } from './store/sqliteStore'; +import { MongoStore, MONGO_SCHEMA_VERSION } from './store/mongoStore'; import { slotRoutes } from './routes/slotRoutes'; import { scheduleRoutes } from './routes/scheduleRoutes'; import { appointmentRoutes } from './routes/appointmentRoutes'; @@ -171,6 +172,20 @@ async function buildServer() { } fastify.log.info('SQLite store initialized (schema v' + SCHEMA_VERSION + ')'); + } else if (STORE_BACKEND === 'mongo') { + store = new MongoStore(); + const schemaStatus = await store.initialize(); + + if (!schemaStatus.match) { + startupWarnings.push( + `⚠️ Mongo schema mismatch: database is v${schemaStatus.current}, code expects v${MONGO_SCHEMA_VERSION}`, + ` Auto-migrated to v${MONGO_SCHEMA_VERSION}`, + ); + } else if (schemaStatus.migrated && schemaStatus.current === 0) { + fastify.log.info('Fresh Mongo database - schema initialized at v' + MONGO_SCHEMA_VERSION); + } + + fastify.log.info('Mongo store initialized (schema v' + MONGO_SCHEMA_VERSION + ')'); } else { throw new Error(`Unsupported store backend: ${STORE_BACKEND}`); } diff --git a/src/store/mongoStore.ts b/src/store/mongoStore.ts new file mode 100644 index 0000000..9dc8ea2 --- /dev/null +++ b/src/store/mongoStore.ts @@ -0,0 +1,1119 @@ +import crypto from 'crypto'; +import { + MongoClient, + Db, + Collection, + Filter, + OptionalUnlessRequiredId, +} from 'mongodb'; +import { + FhirStore, + Schedule, + Slot, + Appointment, + SlotHold, + SynapseSystem, + SynapseLocation, + SynapseSystemQuery, + SynapseLocationQuery, + MSHLookupResult, + SystemStatus, + HL7MessageLogEntry, + HL7MessageLogQuery, + FhirSlotQuery, + FhirScheduleQuery, + FhirAppointmentQuery, +} from '../types/fhir'; + +export const MONGO_SCHEMA_VERSION = 1; + +interface SchemaStatus { + current: number; + expected: number; + match: boolean; + migrated: boolean; +} + +interface MetaDoc { + key: string; + value: string; +} + +interface SystemDoc { + id: string; + name: string; + url?: string; + apiKeyHash?: string; + mshApplication?: string; + mshFacility?: string; + mshSecretHash?: string; + challengeToken?: string; + status: SystemStatus; + lastActivityAt: string; + createdAt: string; + ttlDays: number; +} + +interface LocationDoc { + id: string; + systemId: string; + name: string; + address?: string; + city?: string; + state?: string; + zip?: string; + phone?: string; + hl7LocationId?: string; + createdAt: string; +} + +interface ScheduleDoc { + id: string; + active?: boolean; + serviceCategory?: Schedule['serviceCategory']; + serviceType?: Schedule['serviceType']; + specialty?: Schedule['specialty']; + actor: Schedule['actor']; + planningHorizonStart?: string; + planningHorizonEnd?: string; + comment?: string; + metaLastUpdated: string; + systemId?: string; + locationId?: string; + availabilityTemplate?: string; +} + +interface SlotDoc { + id: string; + scheduleId: string; + status: Slot['status']; + start: string; + end: string; + serviceCategory?: Slot['serviceCategory']; + serviceType?: Slot['serviceType']; + specialty?: Slot['specialty']; + appointmentType?: Slot['appointmentType']; + overbooked?: boolean; + comment?: string; + metaLastUpdated: string; +} + +interface AppointmentDoc { + id: string; + status: Appointment['status']; + identifier?: Appointment['identifier']; + cancelationReason?: Appointment['cancelationReason']; + serviceCategory?: Appointment['serviceCategory']; + serviceType?: Appointment['serviceType']; + specialty?: Appointment['specialty']; + appointmentType?: Appointment['appointmentType']; + reasonCode?: Appointment['reasonCode']; + priority?: number; + description?: string; + slotRefs?: Appointment['slot']; + start?: string; + end?: string; + created?: string; + comment?: string; + patientInstruction?: string; + participant: Appointment['participant']; + metaLastUpdated: string; +} + +interface SlotHoldDoc { + id: string; + slotId: string; + holdToken: string; + sessionId: string; + expiresAt: string; + createdAt: string; +} + +interface HL7LogDoc { + id: string; + receivedAt: string; + source: 'http' | 'mllp'; + remoteAddress?: string; + messageType?: string; + triggerEvent?: string; + controlId?: string; + rawMessage: string; + ackResponse?: string; + ackCode?: string; + processingMs?: number; +} + +function nowIso(): string { + return new Date().toISOString(); +} + +export class MongoStore implements FhirStore { + private client: MongoClient; + private dbName: string; + private db?: Db; + + private meta!: Collection; + private systems!: Collection; + private locations!: Collection; + private schedules!: Collection; + private slots!: Collection; + private appointments!: Collection; + private slotHolds!: Collection; + private hl7Logs!: Collection; + + constructor(mongoUri?: string, dbName?: string) { + const uri = mongoUri || process.env.MONGO_URI || 'mongodb://127.0.0.1:27017'; + this.dbName = dbName || process.env.MONGO_DB_NAME || 'fhirtogether'; + this.client = new MongoClient(uri); + } + + async initialize(): Promise { + await this.client.connect(); + this.db = this.client.db(this.dbName); + + this.meta = this.db.collection('meta'); + this.systems = this.db.collection('systems'); + this.locations = this.db.collection('locations'); + this.schedules = this.db.collection('schedules'); + this.slots = this.db.collection('slots'); + this.appointments = this.db.collection('appointments'); + this.slotHolds = this.db.collection('slot_holds'); + this.hl7Logs = this.db.collection('hl7_message_log'); + + await Promise.all([ + this.meta.createIndex({ key: 1 }, { unique: true }), + this.systems.createIndex({ id: 1 }, { unique: true }), + this.systems.createIndex({ url: 1 }), + this.systems.createIndex({ mshApplication: 1, mshFacility: 1 }, { unique: true, sparse: true }), + this.systems.createIndex({ apiKeyHash: 1 }, { sparse: true }), + this.systems.createIndex({ status: 1 }), + this.locations.createIndex({ id: 1 }, { unique: true }), + this.locations.createIndex({ systemId: 1 }), + this.locations.createIndex({ systemId: 1, hl7LocationId: 1 }, { sparse: true }), + this.schedules.createIndex({ id: 1 }, { unique: true }), + this.schedules.createIndex({ systemId: 1 }), + this.schedules.createIndex({ locationId: 1 }), + this.slots.createIndex({ id: 1 }, { unique: true }), + this.slots.createIndex({ scheduleId: 1 }), + this.slots.createIndex({ status: 1 }), + this.slots.createIndex({ start: 1 }), + this.appointments.createIndex({ id: 1 }, { unique: true }), + this.appointments.createIndex({ status: 1 }), + this.appointments.createIndex({ start: 1 }), + this.slotHolds.createIndex({ id: 1 }, { unique: true }), + this.slotHolds.createIndex({ holdToken: 1 }, { unique: true }), + this.slotHolds.createIndex({ slotId: 1 }), + this.slotHolds.createIndex({ expiresAt: 1 }), + this.hl7Logs.createIndex({ id: 1 }, { unique: true }), + this.hl7Logs.createIndex({ receivedAt: -1 }), + this.hl7Logs.createIndex({ source: 1 }), + this.hl7Logs.createIndex({ messageType: 1 }), + ]); + + const versionDoc = await this.meta.findOne({ key: 'schema_version' }); + const current = versionDoc ? parseInt(versionDoc.value, 10) : 0; + + await this.meta.updateOne( + { key: 'schema_version' }, + { $set: { value: String(MONGO_SCHEMA_VERSION) } }, + { upsert: true } + ); + + return { + current, + expected: MONGO_SCHEMA_VERSION, + match: current === MONGO_SCHEMA_VERSION || current === 0, + migrated: current !== MONGO_SCHEMA_VERSION, + }; + } + + async close(): Promise { + await this.client.close(); + } + + private generateId(): string { + return `${Date.now()}-${Math.random().toString(36).slice(2, 11)}`; + } + + private extractId(reference: string): string { + return reference.split('/').pop() || reference; + } + + private toSystem(doc: SystemDoc): SynapseSystem { + return { + id: doc.id, + name: doc.name, + url: doc.url, + mshApplication: doc.mshApplication, + mshFacility: doc.mshFacility, + status: doc.status, + lastActivityAt: doc.lastActivityAt, + createdAt: doc.createdAt, + ttlDays: doc.ttlDays, + }; + } + + private toLocation(doc: LocationDoc): SynapseLocation { + return { + id: doc.id, + systemId: doc.systemId, + name: doc.name, + address: doc.address, + city: doc.city, + state: doc.state, + zip: doc.zip, + phone: doc.phone, + hl7LocationId: doc.hl7LocationId, + createdAt: doc.createdAt, + }; + } + + private async toSchedule(doc: ScheduleDoc): Promise { + const schedule: Schedule & { extension?: Array<{ url: string; valueString?: string }>; system_id?: string; location_id?: string } = { + resourceType: 'Schedule', + id: doc.id, + active: doc.active, + serviceCategory: doc.serviceCategory, + serviceType: doc.serviceType, + specialty: doc.specialty, + actor: doc.actor, + planningHorizon: doc.planningHorizonStart + ? { start: doc.planningHorizonStart, end: doc.planningHorizonEnd } + : undefined, + comment: doc.comment, + meta: { lastUpdated: doc.metaLastUpdated }, + system_id: doc.systemId, + location_id: doc.locationId, + }; + + const extension: Array<{ url: string; valueString?: string }> = []; + if (doc.systemId) { + const system = await this.systems.findOne({ id: doc.systemId }); + if (system?.name) { + extension.push({ + url: 'https://fhirtogether.org/fhir/StructureDefinition/system-name', + valueString: system.name, + }); + } + } + + if (doc.availabilityTemplate) { + extension.push({ + url: 'https://fhirtogether.org/StructureDefinition/availability-template', + valueString: doc.availabilityTemplate, + }); + } + + if (extension.length > 0) { + schedule.extension = extension; + } + + return schedule; + } + + private toSlot(doc: SlotDoc): Slot { + return { + resourceType: 'Slot', + id: doc.id, + schedule: { reference: `Schedule/${doc.scheduleId}` }, + status: doc.status, + start: doc.start, + end: doc.end, + serviceCategory: doc.serviceCategory, + serviceType: doc.serviceType, + specialty: doc.specialty, + appointmentType: doc.appointmentType, + overbooked: doc.overbooked, + comment: doc.comment, + meta: { lastUpdated: doc.metaLastUpdated }, + }; + } + + private toAppointment(doc: AppointmentDoc): Appointment { + return { + resourceType: 'Appointment', + id: doc.id, + status: doc.status, + identifier: doc.identifier, + cancelationReason: doc.cancelationReason, + serviceCategory: doc.serviceCategory, + serviceType: doc.serviceType, + specialty: doc.specialty, + appointmentType: doc.appointmentType, + reasonCode: doc.reasonCode, + priority: doc.priority, + description: doc.description, + slot: doc.slotRefs, + start: doc.start, + end: doc.end, + created: doc.created, + comment: doc.comment, + patientInstruction: doc.patientInstruction, + participant: doc.participant, + meta: { lastUpdated: doc.metaLastUpdated }, + }; + } + + private toSlotHold(doc: SlotHoldDoc): SlotHold { + return { + id: doc.id, + slotId: doc.slotId, + holdToken: doc.holdToken, + sessionId: doc.sessionId, + expiresAt: doc.expiresAt, + createdAt: doc.createdAt, + }; + } + + private toHL7Log(doc: HL7LogDoc): HL7MessageLogEntry { + return { + id: doc.id, + receivedAt: doc.receivedAt, + source: doc.source, + remoteAddress: doc.remoteAddress, + messageType: doc.messageType, + triggerEvent: doc.triggerEvent, + controlId: doc.controlId, + rawMessage: doc.rawMessage, + ackResponse: doc.ackResponse, + ackCode: doc.ackCode, + processingMs: doc.processingMs, + }; + } + + private async deleteSystemCascade(systemId: string): Promise { + const scheduleDocs = await this.schedules.find({ systemId }, { projection: { id: 1 } }).toArray(); + const scheduleIds = scheduleDocs.map((s) => s.id); + + const slotDocs = scheduleIds.length > 0 + ? await this.slots.find({ scheduleId: { $in: scheduleIds } }, { projection: { id: 1 } }).toArray() + : []; + const slotIds = slotDocs.map((s) => s.id); + + if (slotIds.length > 0) { + const slotRefs = slotIds.map((id) => `Slot/${id}`); + await this.appointments.deleteMany({ + slotRefs: { $elemMatch: { reference: { $in: slotRefs } } }, + }); + await this.slotHolds.deleteMany({ slotId: { $in: slotIds } }); + } + + if (scheduleIds.length > 0) { + await this.slots.deleteMany({ scheduleId: { $in: scheduleIds } }); + await this.schedules.deleteMany({ id: { $in: scheduleIds } }); + } + + await this.locations.deleteMany({ systemId }); + await this.systems.deleteOne({ id: systemId }); + } + + async createSystem(system: Omit & { apiKeyHash?: string; mshSecretHash?: string; challengeToken?: string }): Promise { + const id = this.generateId(); + const now = nowIso(); + + const doc: OptionalUnlessRequiredId = { + id, + name: system.name, + url: system.url, + apiKeyHash: system.apiKeyHash, + mshApplication: system.mshApplication, + mshFacility: system.mshFacility, + mshSecretHash: system.mshSecretHash, + challengeToken: system.challengeToken, + status: system.status, + lastActivityAt: now, + createdAt: now, + ttlDays: system.ttlDays, + }; + + await this.systems.insertOne(doc); + return this.toSystem(doc as SystemDoc); + } + + async findOrCreateSystemByMSH(application: string, facility: string, secret: string): Promise { + const existing = await this.systems.findOne({ mshApplication: application, mshFacility: facility }); + + if (existing) { + const secretHash = crypto.createHash('sha256').update(secret).digest('hex'); + + if (!existing.mshSecretHash) { + await this.systems.updateOne({ id: existing.id }, { $set: { mshSecretHash: secretHash } }); + await this.updateSystemActivity(existing.id); + const apiKey = await this.issueApiKey(existing.id); + const refreshed = await this.systems.findOne({ id: existing.id }); + return { system: this.toSystem(refreshed || existing), isNew: false, secretMatch: true, apiKey }; + } + + const match = crypto.timingSafeEqual(Buffer.from(secretHash), Buffer.from(existing.mshSecretHash)); + if (match) { + await this.updateSystemActivity(existing.id); + const apiKey = await this.issueApiKey(existing.id); + const refreshed = await this.systems.findOne({ id: existing.id }); + return { system: this.toSystem(refreshed || existing), isNew: false, secretMatch: true, apiKey }; + } + + return { system: this.toSystem(existing), isNew: false, secretMatch: false }; + } + + const apiKey = crypto.randomBytes(32).toString('hex'); + const apiKeyHash = crypto.createHash('sha256').update(apiKey).digest('hex'); + const secretHash = secret ? crypto.createHash('sha256').update(secret).digest('hex') : undefined; + const defaultTtl = parseInt(process.env.SYSTEM_TTL_DAYS || '7', 10); + + const system = await this.createSystem({ + name: `${application}@${facility}`, + status: 'unverified', + ttlDays: defaultTtl, + mshApplication: application, + mshFacility: facility, + mshSecretHash: secretHash, + apiKeyHash, + }); + + return { system, isNew: true, secretMatch: true, apiKey }; + } + + private async issueApiKey(systemId: string): Promise { + const apiKey = crypto.randomBytes(32).toString('hex'); + const hash = crypto.createHash('sha256').update(apiKey).digest('hex'); + await this.systems.updateOne({ id: systemId }, { $set: { apiKeyHash: hash } }); + return apiKey; + } + + async getSystemById(id: string): Promise { + const row = await this.systems.findOne({ id }); + return row ? this.toSystem(row) : undefined; + } + + async getSystemByUrl(url: string): Promise { + const row = await this.systems.findOne({ url }); + return row ? this.toSystem(row) : undefined; + } + + async getSystemByMsh(application: string, facility: string): Promise { + const row = await this.systems.findOne({ mshApplication: application, mshFacility: facility }); + return row ? this.toSystem(row) : undefined; + } + + async getSystemByApiKeyHash(hash: string): Promise { + const row = await this.systems.findOne({ apiKeyHash: hash }); + return row ? this.toSystem(row) : undefined; + } + + async getSystems(query?: SynapseSystemQuery): Promise { + const filter: Filter = {}; + if (query?.status) filter.status = query.status; + + let cursor = this.systems.find(filter).sort({ name: 1 }); + if (query?._count) cursor = cursor.limit(query._count); + + const rows = await cursor.toArray(); + return rows.map((r) => this.toSystem(r)); + } + + async updateSystem(id: string, updates: Partial> & { apiKeyHash?: string; challengeToken?: string }): Promise { + const existing = await this.getSystemById(id); + if (!existing) throw new Error(`System ${id} not found`); + + const set: Partial = {}; + if (updates.name !== undefined) set.name = updates.name; + if (updates.url !== undefined) set.url = updates.url; + if (updates.status !== undefined) set.status = updates.status; + if (updates.ttlDays !== undefined) set.ttlDays = updates.ttlDays; + if (updates.apiKeyHash !== undefined) set.apiKeyHash = updates.apiKeyHash; + if (updates.challengeToken !== undefined) set.challengeToken = updates.challengeToken; + + if (Object.keys(set).length > 0) { + await this.systems.updateOne({ id }, { $set: set }); + } + + const updated = await this.getSystemById(id); + return updated || existing; + } + + async updateSystemActivity(id: string): Promise { + await this.systems.updateOne({ id }, { $set: { lastActivityAt: nowIso() } }); + } + + async deleteSystem(id: string): Promise { + await this.deleteSystemCascade(id); + } + + async getSystemChallengeToken(id: string): Promise { + const row = await this.systems.findOne({ id }, { projection: { challengeToken: 1 } }); + return row?.challengeToken; + } + + async evaporateExpiredSystems(): Promise<{ count: number; systems: Array<{ id: string; name: string; mshApplication?: string; mshFacility?: string }> }> { + const now = Date.now(); + const candidates = await this.systems.find({ status: { $ne: 'expired' } }).toArray(); + const expired = candidates.filter((s) => { + const last = new Date(s.lastActivityAt); + if (Number.isNaN(last.getTime())) return false; + last.setDate(last.getDate() + s.ttlDays); + return last.getTime() < now; + }); + + for (const system of expired) { + await this.deleteSystemCascade(system.id); + } + + return { + count: expired.length, + systems: expired.map((s) => ({ + id: s.id, + name: s.name, + mshApplication: s.mshApplication, + mshFacility: s.mshFacility, + })), + }; + } + + async createLocation(location: Omit): Promise { + const id = this.generateId(); + const now = nowIso(); + + const doc: OptionalUnlessRequiredId = { + id, + systemId: location.systemId, + name: location.name, + address: location.address, + city: location.city, + state: location.state, + zip: location.zip, + phone: location.phone, + hl7LocationId: location.hl7LocationId, + createdAt: now, + }; + + await this.locations.insertOne(doc); + return this.toLocation(doc as LocationDoc); + } + + async findOrCreateLocationByHL7(systemId: string, hl7LocationId: string, name: string, address?: string): Promise { + const existing = await this.locations.findOne({ systemId, hl7LocationId }); + if (existing) return this.toLocation(existing); + + return this.createLocation({ + systemId, + hl7LocationId, + name, + address, + }); + } + + async getLocations(query?: SynapseLocationQuery): Promise { + const filter: Filter = {}; + if (query?.systemId) filter.systemId = query.systemId; + if (query?.zip) filter.zip = query.zip; + + let cursor = this.locations.find(filter).sort({ name: 1 }); + if (query?._count) cursor = cursor.limit(query._count); + + const rows = await cursor.toArray(); + return rows.map((r) => this.toLocation(r)); + } + + async getLocationById(id: string): Promise { + const row = await this.locations.findOne({ id }); + return row ? this.toLocation(row) : undefined; + } + + async updateLocation(id: string, updates: Partial>): Promise { + const existing = await this.getLocationById(id); + if (!existing) throw new Error(`Location ${id} not found`); + + const set: Partial = {}; + if (updates.name !== undefined) set.name = updates.name; + if (updates.address !== undefined) set.address = updates.address; + if (updates.city !== undefined) set.city = updates.city; + if (updates.state !== undefined) set.state = updates.state; + if (updates.zip !== undefined) set.zip = updates.zip; + if (updates.phone !== undefined) set.phone = updates.phone; + if (updates.hl7LocationId !== undefined) set.hl7LocationId = updates.hl7LocationId; + + if (Object.keys(set).length > 0) { + await this.locations.updateOne({ id }, { $set: set }); + } + + const updated = await this.getLocationById(id); + return updated || existing; + } + + async deleteLocation(id: string): Promise { + await this.schedules.updateMany({ locationId: id }, { $unset: { locationId: '' } }); + await this.locations.deleteOne({ id }); + } + + async createSchedule(schedule: Schedule & { system_id?: string; location_id?: string; availability_template?: string }): Promise { + const id = schedule.id || this.generateId(); + const now = nowIso(); + + const doc: OptionalUnlessRequiredId = { + id, + active: schedule.active, + serviceCategory: schedule.serviceCategory, + serviceType: schedule.serviceType, + specialty: schedule.specialty, + actor: schedule.actor, + planningHorizonStart: schedule.planningHorizon?.start, + planningHorizonEnd: schedule.planningHorizon?.end, + comment: schedule.comment, + metaLastUpdated: now, + systemId: schedule.system_id, + locationId: schedule.location_id, + availabilityTemplate: schedule.availability_template, + }; + + await this.schedules.insertOne(doc); + const created = await this.schedules.findOne({ id }); + return this.toSchedule(created || (doc as ScheduleDoc)); + } + + async getSchedules(query: FhirScheduleQuery & { system_id?: string }): Promise { + const filter: Filter = {}; + + if (query.active !== undefined) filter.active = query.active; + if (query.system_id) filter.systemId = query.system_id; + if (query.date) { + filter.planningHorizonStart = { $lte: query.date }; + filter.planningHorizonEnd = { $gte: query.date }; + } + + let rows = await this.schedules.find(filter).toArray(); + + if (query.actor) { + const actorNeedle = query.actor.toLowerCase(); + rows = rows.filter((r) => JSON.stringify(r.actor || []).toLowerCase().includes(actorNeedle)); + } + + if (query._count) { + rows = rows.slice(0, query._count); + } + + const schedules: Schedule[] = []; + for (const row of rows) { + schedules.push(await this.toSchedule(row)); + } + return schedules; + } + + async getScheduleById(id: string): Promise { + const row = await this.schedules.findOne({ id }); + return row ? this.toSchedule(row) : null; + } + + async updateSchedule(id: string, schedule: Partial): Promise { + const existing = await this.schedules.findOne({ id }); + if (!existing) throw new Error(`Schedule ${id} not found`); + + const merged: ScheduleDoc = { + ...existing, + active: schedule.active !== undefined ? schedule.active : existing.active, + serviceCategory: schedule.serviceCategory !== undefined ? schedule.serviceCategory : existing.serviceCategory, + serviceType: schedule.serviceType !== undefined ? schedule.serviceType : existing.serviceType, + specialty: schedule.specialty !== undefined ? schedule.specialty : existing.specialty, + actor: schedule.actor !== undefined ? schedule.actor : existing.actor, + planningHorizonStart: schedule.planningHorizon?.start !== undefined ? schedule.planningHorizon.start : existing.planningHorizonStart, + planningHorizonEnd: schedule.planningHorizon?.end !== undefined ? schedule.planningHorizon.end : existing.planningHorizonEnd, + comment: schedule.comment !== undefined ? schedule.comment : existing.comment, + metaLastUpdated: nowIso(), + }; + + await this.schedules.replaceOne({ id }, merged); + return this.toSchedule(merged); + } + + async deleteSchedule(id: string): Promise { + const slotDocs = await this.slots.find({ scheduleId: id }, { projection: { id: 1 } }).toArray(); + const slotIds = slotDocs.map((s) => s.id); + + if (slotIds.length > 0) { + const slotRefs = slotIds.map((slotId) => `Slot/${slotId}`); + await this.slotHolds.deleteMany({ slotId: { $in: slotIds } }); + await this.appointments.deleteMany({ slotRefs: { $elemMatch: { reference: { $in: slotRefs } } } }); + await this.slots.deleteMany({ scheduleId: id }); + } + + await this.schedules.deleteOne({ id }); + } + + async deleteAllSchedules(): Promise { + const scheduleDocs = await this.schedules.find({}, { projection: { id: 1 } }).toArray(); + const scheduleIds = scheduleDocs.map((s) => s.id); + + if (scheduleIds.length > 0) { + const slotDocs = await this.slots.find({ scheduleId: { $in: scheduleIds } }, { projection: { id: 1 } }).toArray(); + const slotIds = slotDocs.map((s) => s.id); + const slotRefs = slotIds.map((slotId) => `Slot/${slotId}`); + + if (slotIds.length > 0) { + await this.slotHolds.deleteMany({ slotId: { $in: slotIds } }); + await this.appointments.deleteMany({ slotRefs: { $elemMatch: { reference: { $in: slotRefs } } } }); + } + + await this.slots.deleteMany({ scheduleId: { $in: scheduleIds } }); + } + + await this.schedules.deleteMany({}); + } + + async setAvailabilityTemplate(scheduleId: string, template: string | null): Promise { + if (template === null) { + await this.schedules.updateOne({ id: scheduleId }, { $unset: { availabilityTemplate: '' }, $set: { metaLastUpdated: nowIso() } }); + return; + } + await this.schedules.updateOne({ id: scheduleId }, { $set: { availabilityTemplate: template, metaLastUpdated: nowIso() } }); + } + + async getAvailabilityTemplate(scheduleId: string): Promise { + const row = await this.schedules.findOne({ id: scheduleId }, { projection: { availabilityTemplate: 1 } }); + return row?.availabilityTemplate || null; + } + + async getSlots(query: FhirSlotQuery): Promise { + const filter: Filter = {}; + + if (query.schedule) { + filter.scheduleId = this.extractId(query.schedule); + } + if (query.status) { + filter.status = query.status as Slot['status']; + } + if (query.start) { + filter.start = { $gte: query.start }; + } + if (query.end) { + filter.end = { $lte: query.end }; + } + + let cursor = this.slots.find(filter).sort({ start: 1 }); + if (query._count) cursor = cursor.limit(query._count); + + const rows = await cursor.toArray(); + return rows.map((r) => this.toSlot(r)); + } + + async getSlotById(id: string): Promise { + const row = await this.slots.findOne({ id }); + return row ? this.toSlot(row) : null; + } + + async createSlot(slot: Slot): Promise { + const id = slot.id || this.generateId(); + const now = nowIso(); + + const doc: OptionalUnlessRequiredId = { + id, + scheduleId: this.extractId(slot.schedule.reference), + status: slot.status, + start: slot.start, + end: slot.end, + serviceCategory: slot.serviceCategory, + serviceType: slot.serviceType, + specialty: slot.specialty, + appointmentType: slot.appointmentType, + overbooked: slot.overbooked, + comment: slot.comment, + metaLastUpdated: now, + }; + + await this.slots.insertOne(doc); + return this.toSlot(doc as SlotDoc); + } + + async createSlots(slots: Omit[]): Promise<{ count: number }> { + const now = nowIso(); + const docs: SlotDoc[] = slots.map((slot) => ({ + id: this.generateId(), + scheduleId: this.extractId(slot.schedule.reference), + status: slot.status, + start: slot.start, + end: slot.end, + serviceCategory: slot.serviceCategory, + serviceType: slot.serviceType, + specialty: slot.specialty, + appointmentType: slot.appointmentType, + overbooked: slot.overbooked, + comment: slot.comment, + metaLastUpdated: now, + })); + + if (docs.length > 0) { + await this.slots.insertMany(docs); + } + + return { count: docs.length }; + } + + async updateSlot(id: string, slot: Partial): Promise { + const existing = await this.slots.findOne({ id }); + if (!existing) throw new Error(`Slot ${id} not found`); + + const merged: SlotDoc = { + ...existing, + status: slot.status !== undefined ? slot.status : existing.status, + start: slot.start !== undefined ? slot.start : existing.start, + end: slot.end !== undefined ? slot.end : existing.end, + serviceCategory: slot.serviceCategory !== undefined ? slot.serviceCategory : existing.serviceCategory, + serviceType: slot.serviceType !== undefined ? slot.serviceType : existing.serviceType, + specialty: slot.specialty !== undefined ? slot.specialty : existing.specialty, + appointmentType: slot.appointmentType !== undefined ? slot.appointmentType : existing.appointmentType, + overbooked: slot.overbooked !== undefined ? slot.overbooked : existing.overbooked, + comment: slot.comment !== undefined ? slot.comment : existing.comment, + metaLastUpdated: nowIso(), + }; + + await this.slots.replaceOne({ id }, merged); + return this.toSlot(merged); + } + + async deleteSlot(id: string): Promise { + await this.slotHolds.deleteMany({ slotId: id }); + await this.slots.deleteOne({ id }); + } + + async deleteAllSlots(): Promise { + await this.slotHolds.deleteMany({}); + await this.slots.deleteMany({}); + } + + async deleteSlotsBySchedule(scheduleId: string, statusFilter?: string): Promise { + const slotFilter: Filter = { scheduleId }; + if (statusFilter) { + slotFilter.status = statusFilter as Slot['status']; + } + + const slotDocs = await this.slots.find(slotFilter, { projection: { id: 1 } }).toArray(); + const slotIds = slotDocs.map((s) => s.id); + + if (slotIds.length > 0) { + await this.slotHolds.deleteMany({ slotId: { $in: slotIds } }); + } + + const result = await this.slots.deleteMany(slotFilter); + return result.deletedCount; + } + + async getAppointments(query: FhirAppointmentQuery): Promise { + const filter: Filter = {}; + + if (query.status) { + filter.status = query.status as Appointment['status']; + } + + if (query.date) { + const startDate = query.date; + const endDateObj = new Date(`${query.date}T00:00:00.000Z`); + endDateObj.setUTCDate(endDateObj.getUTCDate() + 1); + const endDate = endDateObj.toISOString().split('T')[0]; + filter.start = { $gte: startDate, $lt: endDate }; + } + + let cursor = this.appointments.find(filter).sort({ start: 1 }); + if (query._count) cursor = cursor.limit(query._count); + + let rows = await cursor.toArray(); + + if (query.patient) { + const needle = query.patient.toLowerCase(); + rows = rows.filter((r) => JSON.stringify(r.participant || []).toLowerCase().includes(needle)); + } + + if (query.actor) { + const needle = query.actor.toLowerCase(); + rows = rows.filter((r) => JSON.stringify(r.participant || []).toLowerCase().includes(needle)); + } + + if (query.identifier) { + const needle = query.identifier.toLowerCase(); + rows = rows.filter((r) => JSON.stringify(r.identifier || []).toLowerCase().includes(needle)); + } + + return rows.map((r) => this.toAppointment(r)); + } + + async getAppointmentById(id: string): Promise { + const row = await this.appointments.findOne({ id }); + return row ? this.toAppointment(row) : null; + } + + async getAppointmentByIdentifier(system: string, value: string): Promise { + const row = await this.appointments.findOne({ identifier: { $elemMatch: { system, value } } }); + return row ? this.toAppointment(row) : null; + } + + async createAppointment(appointment: Appointment): Promise { + const id = appointment.id || this.generateId(); + const now = nowIso(); + + const doc: OptionalUnlessRequiredId = { + id, + status: appointment.status, + identifier: appointment.identifier, + cancelationReason: appointment.cancelationReason, + serviceCategory: appointment.serviceCategory, + serviceType: appointment.serviceType, + specialty: appointment.specialty, + appointmentType: appointment.appointmentType, + reasonCode: appointment.reasonCode, + priority: appointment.priority, + description: appointment.description, + slotRefs: appointment.slot, + start: appointment.start, + end: appointment.end, + created: appointment.created || now, + comment: appointment.comment, + patientInstruction: appointment.patientInstruction, + participant: appointment.participant, + metaLastUpdated: now, + }; + + await this.appointments.insertOne(doc); + + if (appointment.slot && appointment.slot.length > 0) { + for (const slotRef of appointment.slot) { + const slotId = this.extractId(slotRef.reference); + await this.updateSlot(slotId, { status: 'busy' }); + } + } + + return this.toAppointment(doc as AppointmentDoc); + } + + async updateAppointment(id: string, appointment: Partial): Promise { + const existing = await this.appointments.findOne({ id }); + if (!existing) throw new Error(`Appointment ${id} not found`); + + const merged: AppointmentDoc = { + ...existing, + status: appointment.status !== undefined ? appointment.status : existing.status, + cancelationReason: appointment.cancelationReason !== undefined ? appointment.cancelationReason : existing.cancelationReason, + description: appointment.description !== undefined ? appointment.description : existing.description, + start: appointment.start !== undefined ? appointment.start : existing.start, + end: appointment.end !== undefined ? appointment.end : existing.end, + comment: appointment.comment !== undefined ? appointment.comment : existing.comment, + participant: appointment.participant !== undefined ? appointment.participant : existing.participant, + identifier: appointment.identifier !== undefined ? appointment.identifier : existing.identifier, + metaLastUpdated: nowIso(), + }; + + await this.appointments.replaceOne({ id }, merged); + return this.toAppointment(merged); + } + + async deleteAppointment(id: string): Promise { + const appointment = await this.getAppointmentById(id); + + if (appointment?.slot) { + for (const slotRef of appointment.slot) { + const slotId = this.extractId(slotRef.reference); + await this.updateSlot(slotId, { status: 'free' }); + } + } + + await this.appointments.deleteOne({ id }); + } + + async deleteAllAppointments(): Promise { + await this.appointments.deleteMany({}); + } + + async holdSlot(slotId: string, sessionId: string, durationMinutes: number): Promise { + await this.cleanupExpiredHolds(); + + const slot = await this.getSlotById(slotId); + if (!slot) throw new Error(`Slot ${slotId} not found`); + if (slot.status !== 'free') throw new Error(`Slot ${slotId} is not available`); + + const existingHold = await this.getActiveHold(slotId); + if (existingHold) { + if (existingHold.sessionId === sessionId) { + const newExpiry = new Date(Date.now() + durationMinutes * 60 * 1000).toISOString(); + await this.slotHolds.updateOne({ id: existingHold.id }, { $set: { expiresAt: newExpiry } }); + return { ...existingHold, expiresAt: newExpiry }; + } + throw new Error(`Slot ${slotId} is already held by another user`); + } + + const id = this.generateId(); + const holdToken = `hold-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`; + const createdAt = nowIso(); + const expiresAt = new Date(Date.now() + durationMinutes * 60 * 1000).toISOString(); + + const hold: SlotHoldDoc = { + id, + slotId, + holdToken, + sessionId, + createdAt, + expiresAt, + }; + + await this.slotHolds.insertOne(hold); + return this.toSlotHold(hold); + } + + async releaseHold(holdToken: string): Promise { + await this.slotHolds.deleteOne({ holdToken }); + } + + async getActiveHold(slotId: string): Promise { + const hold = await this.slotHolds.findOne({ slotId, expiresAt: { $gt: nowIso() } }); + return hold ? this.toSlotHold(hold) : null; + } + + async getHoldByToken(holdToken: string): Promise { + const hold = await this.slotHolds.findOne({ holdToken }); + return hold ? this.toSlotHold(hold) : null; + } + + async cleanupExpiredHolds(): Promise { + const result = await this.slotHolds.deleteMany({ expiresAt: { $lte: nowIso() } }); + return result.deletedCount; + } + + async clearAllHolds(): Promise { + const result = await this.slotHolds.deleteMany({}); + return result.deletedCount; + } + + async logHL7Message(entry: Omit): Promise { + const id = this.generateId(); + const doc: HL7LogDoc = { + id, + receivedAt: entry.receivedAt, + source: entry.source, + remoteAddress: entry.remoteAddress, + messageType: entry.messageType, + triggerEvent: entry.triggerEvent, + controlId: entry.controlId, + rawMessage: entry.rawMessage, + ackResponse: entry.ackResponse, + ackCode: entry.ackCode, + processingMs: entry.processingMs, + }; + + await this.hl7Logs.insertOne(doc); + return this.toHL7Log(doc); + } + + async getHL7MessageLog(query?: HL7MessageLogQuery): Promise { + const filter: Filter = {}; + if (query?.source) filter.source = query.source; + if (query?.messageType) filter.messageType = query.messageType; + if (query?.ackCode) filter.ackCode = query.ackCode; + if (query?.since) filter.receivedAt = { $gte: query.since }; + + const limit = query?._count ?? 100; + const rows = await this.hl7Logs.find(filter).sort({ receivedAt: -1 }).limit(limit).toArray(); + return rows.map((r) => this.toHL7Log(r)); + } + + async cleanupHL7MessageLog(retentionDays: number): Promise { + const cutoff = new Date(); + cutoff.setDate(cutoff.getDate() - retentionDays); + const result = await this.hl7Logs.deleteMany({ receivedAt: { $lt: cutoff.toISOString() } }); + return result.deletedCount; + } +}