From 56ded676aedcbf5c51c308f7a7f84be9b92b297e Mon Sep 17 00:00:00 2001 From: Adrian Flores Cortes Date: Fri, 30 Jan 2026 19:55:56 -0600 Subject: [PATCH] [CL-009] feat: Implement imaging module for radiology studies Add complete imaging module with: - Entities: ImagingStudy (catalog), ImagingOrder (workflow), DicomInstance (DICOM tracking) - Services: Study management, Order workflow (ordered->scheduled->performed->reported), DICOM file management - Controller: REST endpoints for studies, orders, and DICOM instances - Features: Multi-tenant support, study types (xray, ct, mri, ultrasound, mammography, pet, nuclear), order scheduling, radiologist assignment, critical findings flagging, DICOM viewer URLs Co-Authored-By: Claude Opus 4.5 --- .../imaging/controllers/imaging.controller.ts | 685 ++++++++++++++++++ src/modules/imaging/controllers/index.ts | 1 + src/modules/imaging/dto/index.ts | 511 +++++++++++++ .../imaging/entities/dicom-instance.entity.ts | 121 ++++ .../imaging/entities/imaging-order.entity.ts | 147 ++++ .../imaging/entities/imaging-study.entity.ts | 84 +++ src/modules/imaging/entities/index.ts | 3 + src/modules/imaging/imaging.module.ts | 19 + src/modules/imaging/index.ts | 37 + src/modules/imaging/services/dicom.service.ts | 271 +++++++ .../imaging/services/imaging-order.service.ts | 390 ++++++++++ .../imaging/services/imaging-study.service.ts | 137 ++++ src/modules/imaging/services/index.ts | 3 + 13 files changed, 2409 insertions(+) create mode 100644 src/modules/imaging/controllers/imaging.controller.ts create mode 100644 src/modules/imaging/controllers/index.ts create mode 100644 src/modules/imaging/dto/index.ts create mode 100644 src/modules/imaging/entities/dicom-instance.entity.ts create mode 100644 src/modules/imaging/entities/imaging-order.entity.ts create mode 100644 src/modules/imaging/entities/imaging-study.entity.ts create mode 100644 src/modules/imaging/entities/index.ts create mode 100644 src/modules/imaging/imaging.module.ts create mode 100644 src/modules/imaging/index.ts create mode 100644 src/modules/imaging/services/dicom.service.ts create mode 100644 src/modules/imaging/services/imaging-order.service.ts create mode 100644 src/modules/imaging/services/imaging-study.service.ts create mode 100644 src/modules/imaging/services/index.ts diff --git a/src/modules/imaging/controllers/imaging.controller.ts b/src/modules/imaging/controllers/imaging.controller.ts new file mode 100644 index 0000000..777068b --- /dev/null +++ b/src/modules/imaging/controllers/imaging.controller.ts @@ -0,0 +1,685 @@ +import { Router, Request, Response, NextFunction } from 'express'; +import { DataSource } from 'typeorm'; +import { ImagingStudyService, ImagingOrderService, DicomService } from '../services'; +import { + CreateImagingStudyDto, + UpdateImagingStudyDto, + ImagingStudyQueryDto, + CreateImagingOrderDto, + UpdateImagingOrderDto, + ScheduleImagingOrderDto, + PerformImagingOrderDto, + AssignRadiologistDto, + ReportImagingOrderDto, + SignReportDto, + NotifyCriticalFindingDto, + CancelImagingOrderDto, + ImagingOrderQueryDto, + CreateDicomInstanceDto, + UpdateDicomInstanceDto, + DicomInstanceQueryDto, +} from '../dto'; + +export class ImagingController { + public router: Router; + private studyService: ImagingStudyService; + private orderService: ImagingOrderService; + private dicomService: DicomService; + + constructor(dataSource: DataSource, basePath: string = '/api') { + this.router = Router(); + this.studyService = new ImagingStudyService(dataSource); + this.orderService = new ImagingOrderService(dataSource); + this.dicomService = new DicomService(dataSource); + this.setupRoutes(basePath); + } + + private setupRoutes(basePath: string): void { + const imagingPath = `${basePath}/imaging`; + + // Imaging Studies Routes + this.router.get(`${imagingPath}/studies`, this.findAllStudies.bind(this)); + this.router.get(`${imagingPath}/studies/types`, this.getStudyTypes.bind(this)); + this.router.get(`${imagingPath}/studies/body-regions`, this.getBodyRegions.bind(this)); + this.router.get(`${imagingPath}/studies/:id`, this.findStudyById.bind(this)); + this.router.post(`${imagingPath}/studies`, this.createStudy.bind(this)); + this.router.patch(`${imagingPath}/studies/:id`, this.updateStudy.bind(this)); + this.router.delete(`${imagingPath}/studies/:id`, this.deleteStudy.bind(this)); + + // Imaging Orders Routes + this.router.get(`${imagingPath}/orders`, this.findAllOrders.bind(this)); + this.router.get(`${imagingPath}/orders/stats`, this.getOrderStats.bind(this)); + this.router.get(`${imagingPath}/orders/critical-findings`, this.getCriticalFindings.bind(this)); + this.router.get(`${imagingPath}/orders/scheduled/:date`, this.getScheduledOrders.bind(this)); + this.router.get(`${imagingPath}/orders/radiologist/:radiologistId/worklist`, this.getRadiologistWorklist.bind(this)); + this.router.get(`${imagingPath}/orders/:id`, this.findOrderById.bind(this)); + this.router.get(`${imagingPath}/orders/patient/:patientId`, this.findOrdersByPatient.bind(this)); + this.router.get(`${imagingPath}/orders/consultation/:consultationId`, this.findOrdersByConsultation.bind(this)); + this.router.post(`${imagingPath}/orders`, this.createOrder.bind(this)); + this.router.patch(`${imagingPath}/orders/:id`, this.updateOrder.bind(this)); + this.router.post(`${imagingPath}/orders/:id/schedule`, this.scheduleOrder.bind(this)); + this.router.post(`${imagingPath}/orders/:id/start`, this.startPerformance.bind(this)); + this.router.post(`${imagingPath}/orders/:id/perform`, this.performOrder.bind(this)); + this.router.post(`${imagingPath}/orders/:id/assign-radiologist`, this.assignRadiologist.bind(this)); + this.router.post(`${imagingPath}/orders/:id/report`, this.reportOrder.bind(this)); + this.router.post(`${imagingPath}/orders/:id/sign`, this.signReport.bind(this)); + this.router.post(`${imagingPath}/orders/:id/notify-critical`, this.notifyCriticalFinding.bind(this)); + this.router.post(`${imagingPath}/orders/:id/cancel`, this.cancelOrder.bind(this)); + + // DICOM Instance Routes + this.router.get(`${imagingPath}/dicom`, this.findAllDicomInstances.bind(this)); + this.router.get(`${imagingPath}/dicom/order/:orderId`, this.findDicomByOrder.bind(this)); + this.router.get(`${imagingPath}/dicom/order/:orderId/viewer-url`, this.getViewerUrl.bind(this)); + this.router.get(`${imagingPath}/dicom/order/:orderId/series-summary`, this.getSeriesSummary.bind(this)); + this.router.get(`${imagingPath}/dicom/:id`, this.findDicomById.bind(this)); + this.router.post(`${imagingPath}/dicom`, this.createDicomInstance.bind(this)); + this.router.post(`${imagingPath}/dicom/bulk`, this.createDicomInstancesBulk.bind(this)); + this.router.patch(`${imagingPath}/dicom/:id`, this.updateDicomInstance.bind(this)); + this.router.post(`${imagingPath}/dicom/:id/mark-available`, this.markDicomAvailable.bind(this)); + this.router.post(`${imagingPath}/dicom/:id/mark-error`, this.markDicomError.bind(this)); + this.router.post(`${imagingPath}/dicom/:id/archive`, this.archiveDicom.bind(this)); + } + + private getTenantId(req: Request): string { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + throw new Error('x-tenant-id header is required'); + } + return tenantId; + } + + // Imaging Studies Handlers + private async findAllStudies(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const query: ImagingStudyQueryDto = { + search: req.query.search as string, + studyType: req.query.studyType as any, + bodyRegion: req.query.bodyRegion as any, + status: req.query.status as any, + page: req.query.page ? parseInt(req.query.page as string) : 1, + limit: req.query.limit ? parseInt(req.query.limit as string) : 50, + }; + + const result = await this.studyService.findAll(tenantId, query); + res.json({ + data: result.data, + meta: { + total: result.total, + page: query.page, + limit: query.limit, + totalPages: Math.ceil(result.total / (query.limit || 50)), + }, + }); + } catch (error) { + next(error); + } + } + + private async getStudyTypes(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const types = await this.studyService.getStudyTypes(tenantId); + res.json({ data: types }); + } catch (error) { + next(error); + } + } + + private async getBodyRegions(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const regions = await this.studyService.getBodyRegions(tenantId); + res.json({ data: regions }); + } catch (error) { + next(error); + } + } + + private async findStudyById(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const { id } = req.params; + + const study = await this.studyService.findById(tenantId, id); + if (!study) { + res.status(404).json({ error: 'Imaging study not found', code: 'IMAGING_STUDY_NOT_FOUND' }); + return; + } + + res.json({ data: study }); + } catch (error) { + next(error); + } + } + + private async createStudy(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const dto: CreateImagingStudyDto = req.body; + + const study = await this.studyService.create(tenantId, dto); + res.status(201).json({ data: study }); + } catch (error) { + next(error); + } + } + + private async updateStudy(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const { id } = req.params; + const dto: UpdateImagingStudyDto = req.body; + + const study = await this.studyService.update(tenantId, id, dto); + if (!study) { + res.status(404).json({ error: 'Imaging study not found', code: 'IMAGING_STUDY_NOT_FOUND' }); + return; + } + + res.json({ data: study }); + } catch (error) { + next(error); + } + } + + private async deleteStudy(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const { id } = req.params; + + const deleted = await this.studyService.softDelete(tenantId, id); + if (!deleted) { + res.status(404).json({ error: 'Imaging study not found', code: 'IMAGING_STUDY_NOT_FOUND' }); + return; + } + + res.status(204).send(); + } catch (error) { + next(error); + } + } + + // Imaging Orders Handlers + private async findAllOrders(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const query: ImagingOrderQueryDto = { + patientId: req.query.patientId as string, + doctorId: req.query.doctorId as string, + radiologistId: req.query.radiologistId as string, + consultationId: req.query.consultationId as string, + dateFrom: req.query.dateFrom as string, + dateTo: req.query.dateTo as string, + status: req.query.status as any, + priority: req.query.priority as any, + isCriticalFinding: req.query.isCriticalFinding === 'true' ? true : req.query.isCriticalFinding === 'false' ? false : undefined, + page: req.query.page ? parseInt(req.query.page as string) : 1, + limit: req.query.limit ? parseInt(req.query.limit as string) : 20, + }; + + const result = await this.orderService.findAll(tenantId, query); + res.json({ + data: result.data, + meta: { + total: result.total, + page: query.page, + limit: query.limit, + totalPages: Math.ceil(result.total / (query.limit || 20)), + }, + }); + } catch (error) { + next(error); + } + } + + private async getOrderStats(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const [pendingCount, statusBreakdown] = await Promise.all([ + this.orderService.getPendingOrdersCount(tenantId), + this.orderService.getOrdersByStatus(tenantId), + ]); + + res.json({ + data: { + pendingCount, + statusBreakdown, + }, + }); + } catch (error) { + next(error); + } + } + + private async getCriticalFindings(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const unnotifiedOnly = req.query.unnotifiedOnly === 'true'; + const findings = await this.orderService.getCriticalFindings(tenantId, unnotifiedOnly); + res.json({ data: findings }); + } catch (error) { + next(error); + } + } + + private async getScheduledOrders(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const { date } = req.params; + const orders = await this.orderService.getScheduledOrders(tenantId, date); + res.json({ data: orders }); + } catch (error) { + next(error); + } + } + + private async getRadiologistWorklist(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const { radiologistId } = req.params; + const worklist = await this.orderService.getWorklistForRadiologist(tenantId, radiologistId); + res.json({ data: worklist }); + } catch (error) { + next(error); + } + } + + private async findOrderById(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const { id } = req.params; + + const order = await this.orderService.findById(tenantId, id); + if (!order) { + res.status(404).json({ error: 'Imaging order not found', code: 'IMAGING_ORDER_NOT_FOUND' }); + return; + } + + res.json({ data: order }); + } catch (error) { + next(error); + } + } + + private async findOrdersByPatient(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const { patientId } = req.params; + const limit = req.query.limit ? parseInt(req.query.limit as string) : 10; + + const orders = await this.orderService.findByPatient(tenantId, patientId, limit); + res.json({ data: orders }); + } catch (error) { + next(error); + } + } + + private async findOrdersByConsultation(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const { consultationId } = req.params; + + const orders = await this.orderService.findByConsultation(tenantId, consultationId); + res.json({ data: orders }); + } catch (error) { + next(error); + } + } + + private async createOrder(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const dto: CreateImagingOrderDto = req.body; + + const order = await this.orderService.create(tenantId, dto); + res.status(201).json({ data: order }); + } catch (error) { + next(error); + } + } + + private async updateOrder(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const { id } = req.params; + const dto: UpdateImagingOrderDto = req.body; + + const order = await this.orderService.update(tenantId, id, dto); + if (!order) { + res.status(404).json({ error: 'Imaging order not found', code: 'IMAGING_ORDER_NOT_FOUND' }); + return; + } + + res.json({ data: order }); + } catch (error) { + next(error); + } + } + + private async scheduleOrder(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const { id } = req.params; + const dto: ScheduleImagingOrderDto = req.body; + + const order = await this.orderService.schedule(tenantId, id, dto); + if (!order) { + res.status(404).json({ error: 'Imaging order not found', code: 'IMAGING_ORDER_NOT_FOUND' }); + return; + } + + res.json({ data: order }); + } catch (error) { + next(error); + } + } + + private async startPerformance(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const { id } = req.params; + + const order = await this.orderService.startPerformance(tenantId, id); + if (!order) { + res.status(404).json({ error: 'Imaging order not found', code: 'IMAGING_ORDER_NOT_FOUND' }); + return; + } + + res.json({ data: order }); + } catch (error) { + next(error); + } + } + + private async performOrder(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const { id } = req.params; + const dto: PerformImagingOrderDto = req.body; + + const order = await this.orderService.perform(tenantId, id, dto); + if (!order) { + res.status(404).json({ error: 'Imaging order not found', code: 'IMAGING_ORDER_NOT_FOUND' }); + return; + } + + res.json({ data: order }); + } catch (error) { + next(error); + } + } + + private async assignRadiologist(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const { id } = req.params; + const dto: AssignRadiologistDto = req.body; + + const order = await this.orderService.assignRadiologist(tenantId, id, dto); + if (!order) { + res.status(404).json({ error: 'Imaging order not found', code: 'IMAGING_ORDER_NOT_FOUND' }); + return; + } + + res.json({ data: order }); + } catch (error) { + next(error); + } + } + + private async reportOrder(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const { id } = req.params; + const dto: ReportImagingOrderDto = req.body; + + const order = await this.orderService.report(tenantId, id, dto); + if (!order) { + res.status(404).json({ error: 'Imaging order not found', code: 'IMAGING_ORDER_NOT_FOUND' }); + return; + } + + res.json({ data: order }); + } catch (error) { + next(error); + } + } + + private async signReport(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const { id } = req.params; + const dto: SignReportDto = req.body; + + const order = await this.orderService.signReport(tenantId, id, dto); + if (!order) { + res.status(404).json({ error: 'Imaging order not found', code: 'IMAGING_ORDER_NOT_FOUND' }); + return; + } + + res.json({ data: order }); + } catch (error) { + next(error); + } + } + + private async notifyCriticalFinding(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const { id } = req.params; + const dto: NotifyCriticalFindingDto = req.body; + + const order = await this.orderService.notifyCriticalFinding(tenantId, id, dto); + if (!order) { + res.status(404).json({ error: 'Imaging order not found', code: 'IMAGING_ORDER_NOT_FOUND' }); + return; + } + + res.json({ data: order }); + } catch (error) { + next(error); + } + } + + private async cancelOrder(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const { id } = req.params; + const dto: CancelImagingOrderDto = req.body; + + const order = await this.orderService.cancel(tenantId, id, dto); + if (!order) { + res.status(404).json({ error: 'Imaging order not found', code: 'IMAGING_ORDER_NOT_FOUND' }); + return; + } + + res.json({ data: order }); + } catch (error) { + next(error); + } + } + + // DICOM Instance Handlers + private async findAllDicomInstances(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const query: DicomInstanceQueryDto = { + imagingOrderId: req.query.imagingOrderId as string, + studyInstanceUID: req.query.studyInstanceUID as string, + seriesInstanceUID: req.query.seriesInstanceUID as string, + status: req.query.status as any, + modality: req.query.modality as string, + page: req.query.page ? parseInt(req.query.page as string) : 1, + limit: req.query.limit ? parseInt(req.query.limit as string) : 50, + }; + + const result = await this.dicomService.findAll(tenantId, query); + res.json({ + data: result.data, + meta: { + total: result.total, + page: query.page, + limit: query.limit, + totalPages: Math.ceil(result.total / (query.limit || 50)), + }, + }); + } catch (error) { + next(error); + } + } + + private async findDicomByOrder(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const { orderId } = req.params; + + const instances = await this.dicomService.findByOrder(tenantId, orderId); + res.json({ data: instances }); + } catch (error) { + next(error); + } + } + + private async getViewerUrl(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const { orderId } = req.params; + + const urlResult = await this.dicomService.getViewerUrl(tenantId, orderId); + if (!urlResult) { + res.status(404).json({ error: 'No viewer URL available', code: 'VIEWER_URL_NOT_FOUND' }); + return; + } + + res.json({ data: urlResult }); + } catch (error) { + next(error); + } + } + + private async getSeriesSummary(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const { orderId } = req.params; + + const summary = await this.dicomService.getSeriesSummary(tenantId, orderId); + res.json({ data: summary }); + } catch (error) { + next(error); + } + } + + private async findDicomById(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const { id } = req.params; + + const instance = await this.dicomService.findById(tenantId, id); + if (!instance) { + res.status(404).json({ error: 'DICOM instance not found', code: 'DICOM_INSTANCE_NOT_FOUND' }); + return; + } + + res.json({ data: instance }); + } catch (error) { + next(error); + } + } + + private async createDicomInstance(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const dto: CreateDicomInstanceDto = req.body; + + const instance = await this.dicomService.create(tenantId, dto); + res.status(201).json({ data: instance }); + } catch (error) { + next(error); + } + } + + private async createDicomInstancesBulk(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const dtos: CreateDicomInstanceDto[] = req.body; + + const instances = await this.dicomService.createBulk(tenantId, dtos); + res.status(201).json({ data: instances }); + } catch (error) { + next(error); + } + } + + private async updateDicomInstance(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const { id } = req.params; + const dto: UpdateDicomInstanceDto = req.body; + + const instance = await this.dicomService.update(tenantId, id, dto); + if (!instance) { + res.status(404).json({ error: 'DICOM instance not found', code: 'DICOM_INSTANCE_NOT_FOUND' }); + return; + } + + res.json({ data: instance }); + } catch (error) { + next(error); + } + } + + private async markDicomAvailable(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const { id } = req.params; + const urls = req.body; + + const instance = await this.dicomService.markAsAvailable(tenantId, id, urls); + if (!instance) { + res.status(404).json({ error: 'DICOM instance not found', code: 'DICOM_INSTANCE_NOT_FOUND' }); + return; + } + + res.json({ data: instance }); + } catch (error) { + next(error); + } + } + + private async markDicomError(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const { id } = req.params; + const { errorMessage } = req.body; + + const instance = await this.dicomService.markAsError(tenantId, id, errorMessage); + if (!instance) { + res.status(404).json({ error: 'DICOM instance not found', code: 'DICOM_INSTANCE_NOT_FOUND' }); + return; + } + + res.json({ data: instance }); + } catch (error) { + next(error); + } + } + + private async archiveDicom(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const { id } = req.params; + const { archivePath } = req.body; + + const instance = await this.dicomService.archive(tenantId, id, archivePath); + if (!instance) { + res.status(404).json({ error: 'DICOM instance not found', code: 'DICOM_INSTANCE_NOT_FOUND' }); + return; + } + + res.json({ data: instance }); + } catch (error) { + next(error); + } + } +} diff --git a/src/modules/imaging/controllers/index.ts b/src/modules/imaging/controllers/index.ts new file mode 100644 index 0000000..ff9742f --- /dev/null +++ b/src/modules/imaging/controllers/index.ts @@ -0,0 +1 @@ +export { ImagingController } from './imaging.controller'; diff --git a/src/modules/imaging/dto/index.ts b/src/modules/imaging/dto/index.ts new file mode 100644 index 0000000..bd67090 --- /dev/null +++ b/src/modules/imaging/dto/index.ts @@ -0,0 +1,511 @@ +import { + IsString, + IsOptional, + IsUUID, + IsEnum, + IsBoolean, + IsDateString, + IsInt, + IsNumber, + IsArray, + ValidateNested, + MaxLength, + Min, +} from 'class-validator'; +import { Type } from 'class-transformer'; +import { + ImagingStudyType, + ImagingStudyStatus, + BodyRegion, + ImagingOrderStatus, + ImagingOrderPriority, + DicomInstanceStatus, +} from '../entities'; + +// Study Protocol DTO +export class StudyProtocolDto { + @IsString() + @MaxLength(100) + name: string; + + @IsOptional() + @IsString() + description?: string; + + @IsOptional() + @IsBoolean() + contrastRequired?: boolean; + + @IsOptional() + @IsString() + @MaxLength(100) + contrastType?: string; + + @IsOptional() + @IsInt() + @Min(1) + estimatedDuration?: number; + + @IsOptional() + @IsString() + patientPreparation?: string; +} + +// Imaging Study DTOs +export class CreateImagingStudyDto { + @IsString() + @MaxLength(50) + code: string; + + @IsString() + @MaxLength(200) + name: string; + + @IsOptional() + @IsString() + description?: string; + + @IsOptional() + @IsEnum(['xray', 'ct', 'mri', 'ultrasound', 'mammography', 'pet', 'nuclear']) + studyType?: ImagingStudyType; + + @IsOptional() + @IsEnum(['head', 'neck', 'chest', 'abdomen', 'pelvis', 'spine', 'upper_extremity', 'lower_extremity', 'whole_body', 'other']) + bodyRegion?: BodyRegion; + + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => StudyProtocolDto) + protocols?: StudyProtocolDto[]; + + @IsOptional() + @IsBoolean() + contrastRequired?: boolean; + + @IsOptional() + @IsString() + preparationInstructions?: string; + + @IsOptional() + @IsInt() + @Min(1) + estimatedDurationMinutes?: number; + + @IsOptional() + @IsNumber() + @Min(0) + price?: number; + + @IsOptional() + @IsString() + @MaxLength(20) + cptCode?: string; + + @IsOptional() + @IsBoolean() + requiresRadiologist?: boolean; + + @IsOptional() + @IsInt() + displayOrder?: number; +} + +export class UpdateImagingStudyDto { + @IsOptional() + @IsString() + @MaxLength(50) + code?: string; + + @IsOptional() + @IsString() + @MaxLength(200) + name?: string; + + @IsOptional() + @IsString() + description?: string; + + @IsOptional() + @IsEnum(['xray', 'ct', 'mri', 'ultrasound', 'mammography', 'pet', 'nuclear']) + studyType?: ImagingStudyType; + + @IsOptional() + @IsEnum(['head', 'neck', 'chest', 'abdomen', 'pelvis', 'spine', 'upper_extremity', 'lower_extremity', 'whole_body', 'other']) + bodyRegion?: BodyRegion; + + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => StudyProtocolDto) + protocols?: StudyProtocolDto[]; + + @IsOptional() + @IsBoolean() + contrastRequired?: boolean; + + @IsOptional() + @IsString() + preparationInstructions?: string; + + @IsOptional() + @IsInt() + @Min(1) + estimatedDurationMinutes?: number; + + @IsOptional() + @IsNumber() + @Min(0) + price?: number; + + @IsOptional() + @IsString() + @MaxLength(20) + cptCode?: string; + + @IsOptional() + @IsEnum(['active', 'inactive', 'discontinued']) + status?: ImagingStudyStatus; + + @IsOptional() + @IsBoolean() + requiresRadiologist?: boolean; + + @IsOptional() + @IsInt() + displayOrder?: number; +} + +export class ImagingStudyQueryDto { + @IsOptional() + @IsString() + search?: string; + + @IsOptional() + @IsEnum(['xray', 'ct', 'mri', 'ultrasound', 'mammography', 'pet', 'nuclear']) + studyType?: ImagingStudyType; + + @IsOptional() + @IsEnum(['head', 'neck', 'chest', 'abdomen', 'pelvis', 'spine', 'upper_extremity', 'lower_extremity', 'whole_body', 'other']) + bodyRegion?: BodyRegion; + + @IsOptional() + @IsEnum(['active', 'inactive', 'discontinued']) + status?: ImagingStudyStatus; + + @IsOptional() + page?: number; + + @IsOptional() + limit?: number; +} + +// Imaging Order DTOs +export class CreateImagingOrderDto { + @IsUUID() + patientId: string; + + @IsOptional() + @IsUUID() + consultationId?: string; + + @IsUUID() + orderingDoctorId: string; + + @IsUUID() + imagingStudyId: string; + + @IsOptional() + @IsEnum(['routine', 'urgent', 'stat']) + priority?: ImagingOrderPriority; + + @IsOptional() + @IsString() + clinicalIndication?: string; + + @IsOptional() + @IsString() + clinicalHistory?: string; + + @IsOptional() + @IsString() + notes?: string; +} + +export class UpdateImagingOrderDto { + @IsOptional() + @IsEnum(['routine', 'urgent', 'stat']) + priority?: ImagingOrderPriority; + + @IsOptional() + @IsString() + clinicalIndication?: string; + + @IsOptional() + @IsString() + clinicalHistory?: string; + + @IsOptional() + @IsString() + notes?: string; +} + +export class ScheduleImagingOrderDto { + @IsDateString() + scheduledDate: string; + + @IsOptional() + @IsString() + @MaxLength(10) + scheduledTime?: string; + + @IsOptional() + @IsString() + @MaxLength(50) + room?: string; + + @IsOptional() + @IsString() + @MaxLength(100) + equipment?: string; + + @IsOptional() + @IsUUID() + technologistId?: string; +} + +export class PerformImagingOrderDto { + @IsOptional() + @IsUUID() + performedBy?: string; + + @IsOptional() + @IsString() + @MaxLength(50) + accessionNumber?: string; + + @IsOptional() + @IsBoolean() + contrastUsed?: boolean; + + @IsOptional() + @IsString() + @MaxLength(100) + contrastType?: string; + + @IsOptional() + @IsString() + @MaxLength(50) + contrastAmount?: string; + + @IsOptional() + @IsString() + @MaxLength(50) + radiationDose?: string; +} + +export class AssignRadiologistDto { + @IsUUID() + radiologistId: string; +} + +export class ReportImagingOrderDto { + @IsString() + findings: string; + + @IsOptional() + @IsString() + impression?: string; + + @IsOptional() + @IsString() + recommendations?: string; + + @IsOptional() + @IsBoolean() + isCriticalFinding?: boolean; + + @IsOptional() + @IsUUID() + reportedBy?: string; +} + +export class SignReportDto { + @IsUUID() + signedBy: string; +} + +export class NotifyCriticalFindingDto { + @IsString() + @MaxLength(200) + notifiedTo: string; + + @IsOptional() + @IsString() + method?: string; +} + +export class CancelImagingOrderDto { + @IsString() + @MaxLength(500) + reason: string; +} + +export class ImagingOrderQueryDto { + @IsOptional() + @IsUUID() + patientId?: string; + + @IsOptional() + @IsUUID() + doctorId?: string; + + @IsOptional() + @IsUUID() + radiologistId?: string; + + @IsOptional() + @IsUUID() + consultationId?: string; + + @IsOptional() + @IsDateString() + dateFrom?: string; + + @IsOptional() + @IsDateString() + dateTo?: string; + + @IsOptional() + @IsEnum(['ordered', 'scheduled', 'in_progress', 'performed', 'reported', 'cancelled']) + status?: ImagingOrderStatus; + + @IsOptional() + @IsEnum(['routine', 'urgent', 'stat']) + priority?: ImagingOrderPriority; + + @IsOptional() + @IsBoolean() + isCriticalFinding?: boolean; + + @IsOptional() + page?: number; + + @IsOptional() + limit?: number; +} + +// DICOM Instance DTOs +export class CreateDicomInstanceDto { + @IsUUID() + imagingOrderId: string; + + @IsString() + @MaxLength(128) + studyInstanceUID: string; + + @IsString() + @MaxLength(128) + seriesInstanceUID: string; + + @IsString() + @MaxLength(128) + sopInstanceUID: string; + + @IsString() + @MaxLength(128) + sopClassUID: string; + + @IsOptional() + @IsInt() + seriesNumber?: number; + + @IsOptional() + @IsInt() + instanceNumber?: number; + + @IsOptional() + @IsString() + @MaxLength(200) + seriesDescription?: string; + + @IsOptional() + @IsString() + @MaxLength(16) + modality?: string; + + @IsOptional() + @IsString() + @MaxLength(128) + transferSyntaxUID?: string; + + @IsOptional() + @IsString() + @MaxLength(500) + filePath?: string; + + @IsOptional() + @IsNumber() + fileSize?: number; + + @IsOptional() + metadata?: Record; +} + +export class UpdateDicomInstanceDto { + @IsOptional() + @IsEnum(['received', 'processing', 'available', 'archived', 'error']) + status?: DicomInstanceStatus; + + @IsOptional() + @IsString() + @MaxLength(500) + storagePath?: string; + + @IsOptional() + @IsString() + @MaxLength(500) + viewerUrl?: string; + + @IsOptional() + @IsString() + @MaxLength(500) + thumbnailUrl?: string; + + @IsOptional() + @IsString() + @MaxLength(500) + wadoUrl?: string; + + @IsOptional() + @IsString() + errorMessage?: string; +} + +export class DicomInstanceQueryDto { + @IsOptional() + @IsUUID() + imagingOrderId?: string; + + @IsOptional() + @IsString() + studyInstanceUID?: string; + + @IsOptional() + @IsString() + seriesInstanceUID?: string; + + @IsOptional() + @IsEnum(['received', 'processing', 'available', 'archived', 'error']) + status?: DicomInstanceStatus; + + @IsOptional() + @IsString() + modality?: string; + + @IsOptional() + page?: number; + + @IsOptional() + limit?: number; +} diff --git a/src/modules/imaging/entities/dicom-instance.entity.ts b/src/modules/imaging/entities/dicom-instance.entity.ts new file mode 100644 index 0000000..f331a0b --- /dev/null +++ b/src/modules/imaging/entities/dicom-instance.entity.ts @@ -0,0 +1,121 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { ImagingOrder } from './imaging-order.entity'; + +export type DicomInstanceStatus = 'received' | 'processing' | 'available' | 'archived' | 'error'; + +export interface DicomMetadata { + manufacturer?: string; + stationName?: string; + institutionName?: string; + acquisitionDate?: string; + acquisitionTime?: string; + bodyPartExamined?: string; + patientPosition?: string; + sliceThickness?: number; + pixelSpacing?: number[]; + windowCenter?: number; + windowWidth?: number; + rows?: number; + columns?: number; + bitsAllocated?: number; + photometricInterpretation?: string; +} + +@Entity({ name: 'dicom_instances', schema: 'clinica' }) +export class DicomInstance { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Index() + @Column({ name: 'imaging_order_id', type: 'uuid' }) + imagingOrderId: string; + + @ManyToOne(() => ImagingOrder, (order) => order.dicomInstances) + @JoinColumn({ name: 'imaging_order_id' }) + imagingOrder: ImagingOrder; + + @Index() + @Column({ name: 'study_instance_uid', type: 'varchar', length: 128 }) + studyInstanceUID: string; + + @Index() + @Column({ name: 'series_instance_uid', type: 'varchar', length: 128 }) + seriesInstanceUID: string; + + @Index() + @Column({ name: 'sop_instance_uid', type: 'varchar', length: 128 }) + sopInstanceUID: string; + + @Column({ name: 'sop_class_uid', type: 'varchar', length: 128 }) + sopClassUID: string; + + @Column({ name: 'series_number', type: 'int', nullable: true }) + seriesNumber?: number; + + @Column({ name: 'instance_number', type: 'int', nullable: true }) + instanceNumber?: number; + + @Column({ name: 'series_description', type: 'varchar', length: 200, nullable: true }) + seriesDescription?: string; + + @Column({ type: 'enum', enum: ['received', 'processing', 'available', 'archived', 'error'], default: 'received' }) + status: DicomInstanceStatus; + + @Column({ name: 'modality', type: 'varchar', length: 16, nullable: true }) + modality?: string; + + @Column({ name: 'transfer_syntax_uid', type: 'varchar', length: 128, nullable: true }) + transferSyntaxUID?: string; + + @Column({ name: 'file_path', type: 'varchar', length: 500, nullable: true }) + filePath?: string; + + @Column({ name: 'file_size', type: 'bigint', nullable: true }) + fileSize?: number; + + @Column({ name: 'storage_path', type: 'varchar', length: 500, nullable: true }) + storagePath?: string; + + @Column({ type: 'jsonb', nullable: true }) + metadata?: DicomMetadata; + + @Column({ name: 'viewer_url', type: 'varchar', length: 500, nullable: true }) + viewerUrl?: string; + + @Column({ name: 'thumbnail_url', type: 'varchar', length: 500, nullable: true }) + thumbnailUrl?: string; + + @Column({ name: 'wado_url', type: 'varchar', length: 500, nullable: true }) + wadoUrl?: string; + + @Column({ name: 'received_at', type: 'timestamptz', default: () => 'NOW()' }) + receivedAt: Date; + + @Column({ name: 'processed_at', type: 'timestamptz', nullable: true }) + processedAt?: Date; + + @Column({ name: 'archived_at', type: 'timestamptz', nullable: true }) + archivedAt?: Date; + + @Column({ name: 'error_message', type: 'text', nullable: true }) + errorMessage?: string; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; +} diff --git a/src/modules/imaging/entities/imaging-order.entity.ts b/src/modules/imaging/entities/imaging-order.entity.ts new file mode 100644 index 0000000..5186380 --- /dev/null +++ b/src/modules/imaging/entities/imaging-order.entity.ts @@ -0,0 +1,147 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + OneToMany, +} from 'typeorm'; +import { DicomInstance } from './dicom-instance.entity'; + +export type ImagingOrderStatus = 'ordered' | 'scheduled' | 'in_progress' | 'performed' | 'reported' | 'cancelled'; +export type ImagingOrderPriority = 'routine' | 'urgent' | 'stat'; + +export interface ScheduleInfo { + scheduledDate: Date; + scheduledTime?: string; + room?: string; + equipment?: string; + technologist?: string; +} + +export interface ReportInfo { + findings?: string; + impression?: string; + recommendations?: string; + reportedAt?: Date; + reportedBy?: string; + signedAt?: Date; + signedBy?: string; +} + +@Entity({ name: 'imaging_orders', schema: 'clinica' }) +export class ImagingOrder { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Index() + @Column({ name: 'order_number', type: 'varchar', length: 50 }) + orderNumber: string; + + @Index() + @Column({ name: 'patient_id', type: 'uuid' }) + patientId: string; + + @Column({ name: 'consultation_id', type: 'uuid', nullable: true }) + consultationId?: string; + + @Index() + @Column({ name: 'ordering_doctor_id', type: 'uuid' }) + orderingDoctorId: string; + + @Index() + @Column({ name: 'imaging_study_id', type: 'uuid' }) + imagingStudyId: string; + + @Column({ name: 'study_code', type: 'varchar', length: 50 }) + studyCode: string; + + @Column({ name: 'study_name', type: 'varchar', length: 200 }) + studyName: string; + + @Column({ name: 'order_date', type: 'timestamptz', default: () => 'NOW()' }) + orderDate: Date; + + @Column({ type: 'enum', enum: ['ordered', 'scheduled', 'in_progress', 'performed', 'reported', 'cancelled'], default: 'ordered' }) + status: ImagingOrderStatus; + + @Column({ type: 'enum', enum: ['routine', 'urgent', 'stat'], default: 'routine' }) + priority: ImagingOrderPriority; + + @Column({ name: 'clinical_indication', type: 'text', nullable: true }) + clinicalIndication?: string; + + @Column({ name: 'clinical_history', type: 'text', nullable: true }) + clinicalHistory?: string; + + @Column({ name: 'schedule_info', type: 'jsonb', nullable: true }) + scheduleInfo?: ScheduleInfo; + + @Column({ name: 'scheduled_at', type: 'timestamptz', nullable: true }) + scheduledAt?: Date; + + @Column({ name: 'scheduled_room', type: 'varchar', length: 50, nullable: true }) + scheduledRoom?: string; + + @Column({ name: 'performed_at', type: 'timestamptz', nullable: true }) + performedAt?: Date; + + @Column({ name: 'performed_by', type: 'uuid', nullable: true }) + performedBy?: string; + + @Column({ name: 'radiologist_id', type: 'uuid', nullable: true }) + radiologistId?: string; + + @Column({ name: 'report_info', type: 'jsonb', nullable: true }) + reportInfo?: ReportInfo; + + @Column({ name: 'is_critical_finding', type: 'boolean', default: false }) + isCriticalFinding: boolean; + + @Column({ name: 'critical_finding_notified', type: 'boolean', default: false }) + criticalFindingNotified: boolean; + + @Column({ name: 'critical_finding_notified_at', type: 'timestamptz', nullable: true }) + criticalFindingNotifiedAt?: Date; + + @Column({ name: 'critical_finding_notified_to', type: 'varchar', length: 200, nullable: true }) + criticalFindingNotifiedTo?: string; + + @Column({ name: 'accession_number', type: 'varchar', length: 50, nullable: true }) + accessionNumber?: string; + + @Column({ name: 'contrast_used', type: 'boolean', default: false }) + contrastUsed: boolean; + + @Column({ name: 'contrast_type', type: 'varchar', length: 100, nullable: true }) + contrastType?: string; + + @Column({ name: 'contrast_amount', type: 'varchar', length: 50, nullable: true }) + contrastAmount?: string; + + @Column({ name: 'radiation_dose', type: 'varchar', length: 50, nullable: true }) + radiationDose?: string; + + @Column({ name: 'cancelled_at', type: 'timestamptz', nullable: true }) + cancelledAt?: Date; + + @Column({ name: 'cancellation_reason', type: 'text', nullable: true }) + cancellationReason?: string; + + @Column({ type: 'text', nullable: true }) + notes?: string; + + @OneToMany(() => DicomInstance, (instance) => instance.imagingOrder) + dicomInstances?: DicomInstance[]; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; +} diff --git a/src/modules/imaging/entities/imaging-study.entity.ts b/src/modules/imaging/entities/imaging-study.entity.ts new file mode 100644 index 0000000..649b930 --- /dev/null +++ b/src/modules/imaging/entities/imaging-study.entity.ts @@ -0,0 +1,84 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + DeleteDateColumn, + Index, +} from 'typeorm'; + +export type ImagingStudyType = 'xray' | 'ct' | 'mri' | 'ultrasound' | 'mammography' | 'pet' | 'nuclear'; +export type ImagingStudyStatus = 'active' | 'inactive' | 'discontinued'; +export type BodyRegion = 'head' | 'neck' | 'chest' | 'abdomen' | 'pelvis' | 'spine' | 'upper_extremity' | 'lower_extremity' | 'whole_body' | 'other'; + +export interface StudyProtocol { + name: string; + description?: string; + contrastRequired?: boolean; + contrastType?: string; + estimatedDuration?: number; + patientPreparation?: string; +} + +@Entity({ name: 'imaging_studies', schema: 'clinica' }) +export class ImagingStudy { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Index() + @Column({ type: 'varchar', length: 50, unique: false }) + code: string; + + @Column({ type: 'varchar', length: 200 }) + name: string; + + @Column({ type: 'text', nullable: true }) + description?: string; + + @Column({ name: 'study_type', type: 'enum', enum: ['xray', 'ct', 'mri', 'ultrasound', 'mammography', 'pet', 'nuclear'], default: 'xray' }) + studyType: ImagingStudyType; + + @Column({ name: 'body_region', type: 'enum', enum: ['head', 'neck', 'chest', 'abdomen', 'pelvis', 'spine', 'upper_extremity', 'lower_extremity', 'whole_body', 'other'], default: 'other' }) + bodyRegion: BodyRegion; + + @Column({ type: 'jsonb', nullable: true }) + protocols?: StudyProtocol[]; + + @Column({ name: 'contrast_required', type: 'boolean', default: false }) + contrastRequired: boolean; + + @Column({ name: 'preparation_instructions', type: 'text', nullable: true }) + preparationInstructions?: string; + + @Column({ name: 'estimated_duration_minutes', type: 'int', nullable: true }) + estimatedDurationMinutes?: number; + + @Column({ type: 'decimal', precision: 10, scale: 2, nullable: true }) + price?: number; + + @Column({ name: 'cpt_code', type: 'varchar', length: 20, nullable: true }) + cptCode?: string; + + @Column({ type: 'enum', enum: ['active', 'inactive', 'discontinued'], default: 'active' }) + status: ImagingStudyStatus; + + @Column({ name: 'display_order', type: 'int', default: 0 }) + displayOrder: number; + + @Column({ name: 'requires_radiologist', type: 'boolean', default: true }) + requiresRadiologist: boolean; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + @DeleteDateColumn({ name: 'deleted_at', type: 'timestamptz', nullable: true }) + deletedAt?: Date; +} diff --git a/src/modules/imaging/entities/index.ts b/src/modules/imaging/entities/index.ts new file mode 100644 index 0000000..27b2b6e --- /dev/null +++ b/src/modules/imaging/entities/index.ts @@ -0,0 +1,3 @@ +export { ImagingStudy, ImagingStudyType, ImagingStudyStatus, BodyRegion, StudyProtocol } from './imaging-study.entity'; +export { ImagingOrder, ImagingOrderStatus, ImagingOrderPriority, ScheduleInfo, ReportInfo } from './imaging-order.entity'; +export { DicomInstance, DicomInstanceStatus, DicomMetadata } from './dicom-instance.entity'; diff --git a/src/modules/imaging/imaging.module.ts b/src/modules/imaging/imaging.module.ts new file mode 100644 index 0000000..2d4fee0 --- /dev/null +++ b/src/modules/imaging/imaging.module.ts @@ -0,0 +1,19 @@ +import { Router } from 'express'; +import { DataSource } from 'typeorm'; +import { ImagingController } from './controllers'; + +export interface ImagingModuleOptions { + dataSource: DataSource; + basePath?: string; +} + +export class ImagingModule { + public router: Router; + private controller: ImagingController; + + constructor(options: ImagingModuleOptions) { + const { dataSource, basePath = '/api' } = options; + this.controller = new ImagingController(dataSource, basePath); + this.router = this.controller.router; + } +} diff --git a/src/modules/imaging/index.ts b/src/modules/imaging/index.ts new file mode 100644 index 0000000..3891b32 --- /dev/null +++ b/src/modules/imaging/index.ts @@ -0,0 +1,37 @@ +export { ImagingModule, ImagingModuleOptions } from './imaging.module'; +export { + ImagingStudy, + ImagingStudyType, + ImagingStudyStatus, + BodyRegion, + StudyProtocol, + ImagingOrder, + ImagingOrderStatus, + ImagingOrderPriority, + ScheduleInfo, + ReportInfo, + DicomInstance, + DicomInstanceStatus, + DicomMetadata, +} from './entities'; +export { ImagingStudyService, ImagingOrderService, DicomService, ViewerUrlResult } from './services'; +export { ImagingController } from './controllers'; +export { + StudyProtocolDto, + CreateImagingStudyDto, + UpdateImagingStudyDto, + ImagingStudyQueryDto, + CreateImagingOrderDto, + UpdateImagingOrderDto, + ScheduleImagingOrderDto, + PerformImagingOrderDto, + AssignRadiologistDto, + ReportImagingOrderDto, + SignReportDto, + NotifyCriticalFindingDto, + CancelImagingOrderDto, + ImagingOrderQueryDto, + CreateDicomInstanceDto, + UpdateDicomInstanceDto, + DicomInstanceQueryDto, +} from './dto'; diff --git a/src/modules/imaging/services/dicom.service.ts b/src/modules/imaging/services/dicom.service.ts new file mode 100644 index 0000000..8ecd4a4 --- /dev/null +++ b/src/modules/imaging/services/dicom.service.ts @@ -0,0 +1,271 @@ +import { DataSource, Repository } from 'typeorm'; +import { DicomInstance, ImagingOrder } from '../entities'; +import { + CreateDicomInstanceDto, + UpdateDicomInstanceDto, + DicomInstanceQueryDto, +} from '../dto'; + +export interface ViewerUrlResult { + viewerUrl: string; + wadoUrl?: string; + thumbnailUrl?: string; +} + +export class DicomService { + private repository: Repository; + private orderRepository: Repository; + + constructor(dataSource: DataSource) { + this.repository = dataSource.getRepository(DicomInstance); + this.orderRepository = dataSource.getRepository(ImagingOrder); + } + + async findAll(tenantId: string, query: DicomInstanceQueryDto): Promise<{ data: DicomInstance[]; total: number }> { + const { imagingOrderId, studyInstanceUID, seriesInstanceUID, status, modality, page = 1, limit = 50 } = query; + + const queryBuilder = this.repository.createQueryBuilder('instance') + .where('instance.tenant_id = :tenantId', { tenantId }); + + if (imagingOrderId) { + queryBuilder.andWhere('instance.imaging_order_id = :imagingOrderId', { imagingOrderId }); + } + + if (studyInstanceUID) { + queryBuilder.andWhere('instance.study_instance_uid = :studyInstanceUID', { studyInstanceUID }); + } + + if (seriesInstanceUID) { + queryBuilder.andWhere('instance.series_instance_uid = :seriesInstanceUID', { seriesInstanceUID }); + } + + if (status) { + queryBuilder.andWhere('instance.status = :status', { status }); + } + + if (modality) { + queryBuilder.andWhere('instance.modality = :modality', { modality }); + } + + queryBuilder + .orderBy('instance.series_number', 'ASC') + .addOrderBy('instance.instance_number', 'ASC') + .skip((page - 1) * limit) + .take(limit); + + const [data, total] = await queryBuilder.getManyAndCount(); + return { data, total }; + } + + async findById(tenantId: string, id: string): Promise { + return this.repository.findOne({ + where: { id, tenantId }, + }); + } + + async findByOrder(tenantId: string, imagingOrderId: string): Promise { + return this.repository.find({ + where: { tenantId, imagingOrderId }, + order: { seriesNumber: 'ASC', instanceNumber: 'ASC' }, + }); + } + + async findByStudyInstanceUID(tenantId: string, studyInstanceUID: string): Promise { + return this.repository.find({ + where: { tenantId, studyInstanceUID }, + order: { seriesNumber: 'ASC', instanceNumber: 'ASC' }, + }); + } + + async findBySeriesInstanceUID(tenantId: string, seriesInstanceUID: string): Promise { + return this.repository.find({ + where: { tenantId, seriesInstanceUID }, + order: { instanceNumber: 'ASC' }, + }); + } + + async findBySopInstanceUID(tenantId: string, sopInstanceUID: string): Promise { + return this.repository.findOne({ + where: { tenantId, sopInstanceUID }, + }); + } + + async create(tenantId: string, dto: CreateDicomInstanceDto): Promise { + const order = await this.orderRepository.findOne({ + where: { id: dto.imagingOrderId, tenantId }, + }); + + if (!order) { + throw new Error('Imaging order not found'); + } + + const instance = this.repository.create({ + ...dto, + tenantId, + status: 'received', + receivedAt: new Date(), + }); + + return this.repository.save(instance); + } + + async createBulk(tenantId: string, dtos: CreateDicomInstanceDto[]): Promise { + if (dtos.length === 0) return []; + + const orderIds = [...new Set(dtos.map(d => d.imagingOrderId))]; + const orders = await this.orderRepository.createQueryBuilder('order') + .where('order.tenant_id = :tenantId', { tenantId }) + .andWhere('order.id IN (:...orderIds)', { orderIds }) + .getMany(); + + if (orders.length !== orderIds.length) { + throw new Error('One or more imaging orders not found'); + } + + const instances = dtos.map(dto => this.repository.create({ + ...dto, + tenantId, + status: 'received', + receivedAt: new Date(), + })); + + return this.repository.save(instances); + } + + async update(tenantId: string, id: string, dto: UpdateDicomInstanceDto): Promise { + const instance = await this.findById(tenantId, id); + if (!instance) return null; + + Object.assign(instance, dto); + + if (dto.status === 'available' && !instance.processedAt) { + instance.processedAt = new Date(); + } + + if (dto.status === 'archived' && !instance.archivedAt) { + instance.archivedAt = new Date(); + } + + return this.repository.save(instance); + } + + async markAsProcessing(tenantId: string, id: string): Promise { + const instance = await this.findById(tenantId, id); + if (!instance) return null; + + instance.status = 'processing'; + return this.repository.save(instance); + } + + async markAsAvailable(tenantId: string, id: string, urls?: { viewerUrl?: string; thumbnailUrl?: string; wadoUrl?: string }): Promise { + const instance = await this.findById(tenantId, id); + if (!instance) return null; + + instance.status = 'available'; + instance.processedAt = new Date(); + + if (urls) { + if (urls.viewerUrl) instance.viewerUrl = urls.viewerUrl; + if (urls.thumbnailUrl) instance.thumbnailUrl = urls.thumbnailUrl; + if (urls.wadoUrl) instance.wadoUrl = urls.wadoUrl; + } + + return this.repository.save(instance); + } + + async markAsError(tenantId: string, id: string, errorMessage: string): Promise { + const instance = await this.findById(tenantId, id); + if (!instance) return null; + + instance.status = 'error'; + instance.errorMessage = errorMessage; + + return this.repository.save(instance); + } + + async archive(tenantId: string, id: string, archivePath?: string): Promise { + const instance = await this.findById(tenantId, id); + if (!instance) return null; + + instance.status = 'archived'; + instance.archivedAt = new Date(); + if (archivePath) { + instance.storagePath = archivePath; + } + + return this.repository.save(instance); + } + + async getViewerUrl(tenantId: string, imagingOrderId: string): Promise { + const instances = await this.findByOrder(tenantId, imagingOrderId); + if (instances.length === 0) return null; + + const availableInstance = instances.find(i => i.status === 'available' && i.viewerUrl); + if (!availableInstance) return null; + + return { + viewerUrl: availableInstance.viewerUrl!, + wadoUrl: availableInstance.wadoUrl, + thumbnailUrl: availableInstance.thumbnailUrl, + }; + } + + async getSeriesSummary(tenantId: string, imagingOrderId: string): Promise<{ seriesInstanceUID: string; seriesNumber?: number; seriesDescription?: string; instanceCount: number; modality?: string }[]> { + const result = await this.repository.createQueryBuilder('instance') + .select('instance.series_instance_uid', 'seriesInstanceUID') + .addSelect('instance.series_number', 'seriesNumber') + .addSelect('instance.series_description', 'seriesDescription') + .addSelect('instance.modality', 'modality') + .addSelect('COUNT(*)', 'instanceCount') + .where('instance.tenant_id = :tenantId', { tenantId }) + .andWhere('instance.imaging_order_id = :imagingOrderId', { imagingOrderId }) + .groupBy('instance.series_instance_uid') + .addGroupBy('instance.series_number') + .addGroupBy('instance.series_description') + .addGroupBy('instance.modality') + .orderBy('instance.series_number', 'ASC') + .getRawMany(); + + return result.map(r => ({ + seriesInstanceUID: r.seriesInstanceUID, + seriesNumber: r.seriesNumber, + seriesDescription: r.seriesDescription, + modality: r.modality, + instanceCount: parseInt(r.instanceCount, 10), + })); + } + + async getInstanceCount(tenantId: string, imagingOrderId: string): Promise { + return this.repository.count({ + where: { tenantId, imagingOrderId }, + }); + } + + async getAvailableInstanceCount(tenantId: string, imagingOrderId: string): Promise { + return this.repository.count({ + where: { tenantId, imagingOrderId, status: 'available' }, + }); + } + + async getModalityCounts(tenantId: string): Promise<{ modality: string; count: number }[]> { + const result = await this.repository.createQueryBuilder('instance') + .select('instance.modality', 'modality') + .addSelect('COUNT(*)', 'count') + .where('instance.tenant_id = :tenantId', { tenantId }) + .andWhere('instance.modality IS NOT NULL') + .groupBy('instance.modality') + .orderBy('count', 'DESC') + .getRawMany(); + + return result.map(r => ({ modality: r.modality, count: parseInt(r.count, 10) })); + } + + async deleteByOrder(tenantId: string, imagingOrderId: string): Promise { + const result = await this.repository.delete({ + tenantId, + imagingOrderId, + }); + + return result.affected || 0; + } +} diff --git a/src/modules/imaging/services/imaging-order.service.ts b/src/modules/imaging/services/imaging-order.service.ts new file mode 100644 index 0000000..7f398f3 --- /dev/null +++ b/src/modules/imaging/services/imaging-order.service.ts @@ -0,0 +1,390 @@ +import { DataSource, Repository } from 'typeorm'; +import { ImagingOrder, ImagingStudy, DicomInstance } from '../entities'; +import { + CreateImagingOrderDto, + UpdateImagingOrderDto, + ScheduleImagingOrderDto, + PerformImagingOrderDto, + AssignRadiologistDto, + ReportImagingOrderDto, + SignReportDto, + NotifyCriticalFindingDto, + CancelImagingOrderDto, + ImagingOrderQueryDto, +} from '../dto'; + +export class ImagingOrderService { + private repository: Repository; + private studyRepository: Repository; + private dicomRepository: Repository; + + constructor(dataSource: DataSource) { + this.repository = dataSource.getRepository(ImagingOrder); + this.studyRepository = dataSource.getRepository(ImagingStudy); + this.dicomRepository = dataSource.getRepository(DicomInstance); + } + + async findAll(tenantId: string, query: ImagingOrderQueryDto): Promise<{ data: ImagingOrder[]; total: number }> { + const { + patientId, + doctorId, + radiologistId, + consultationId, + dateFrom, + dateTo, + status, + priority, + isCriticalFinding, + page = 1, + limit = 20, + } = query; + + const queryBuilder = this.repository.createQueryBuilder('order') + .leftJoinAndSelect('order.dicomInstances', 'dicom') + .where('order.tenant_id = :tenantId', { tenantId }); + + if (patientId) { + queryBuilder.andWhere('order.patient_id = :patientId', { patientId }); + } + + if (doctorId) { + queryBuilder.andWhere('order.ordering_doctor_id = :doctorId', { doctorId }); + } + + if (radiologistId) { + queryBuilder.andWhere('order.radiologist_id = :radiologistId', { radiologistId }); + } + + if (consultationId) { + queryBuilder.andWhere('order.consultation_id = :consultationId', { consultationId }); + } + + if (dateFrom) { + queryBuilder.andWhere('order.order_date >= :dateFrom', { dateFrom }); + } + + if (dateTo) { + queryBuilder.andWhere('order.order_date <= :dateTo', { dateTo }); + } + + if (status) { + queryBuilder.andWhere('order.status = :status', { status }); + } + + if (priority) { + queryBuilder.andWhere('order.priority = :priority', { priority }); + } + + if (isCriticalFinding !== undefined) { + queryBuilder.andWhere('order.is_critical_finding = :isCriticalFinding', { isCriticalFinding }); + } + + queryBuilder + .orderBy('order.order_date', 'DESC') + .skip((page - 1) * limit) + .take(limit); + + const [data, total] = await queryBuilder.getManyAndCount(); + return { data, total }; + } + + async findById(tenantId: string, id: string): Promise { + return this.repository.findOne({ + where: { id, tenantId }, + relations: ['dicomInstances'], + }); + } + + async findByOrderNumber(tenantId: string, orderNumber: string): Promise { + return this.repository.findOne({ + where: { orderNumber, tenantId }, + relations: ['dicomInstances'], + }); + } + + async findByPatient(tenantId: string, patientId: string, limit: number = 10): Promise { + return this.repository.find({ + where: { tenantId, patientId }, + relations: ['dicomInstances'], + order: { orderDate: 'DESC' }, + take: limit, + }); + } + + async findByConsultation(tenantId: string, consultationId: string): Promise { + return this.repository.find({ + where: { tenantId, consultationId }, + relations: ['dicomInstances'], + order: { orderDate: 'DESC' }, + }); + } + + async findByRadiologist(tenantId: string, radiologistId: string, status?: string): Promise { + const query: any = { tenantId, radiologistId }; + if (status) { + query.status = status; + } + return this.repository.find({ + where: query, + relations: ['dicomInstances'], + order: { orderDate: 'DESC' }, + }); + } + + async create(tenantId: string, dto: CreateImagingOrderDto): Promise { + const study = await this.studyRepository.findOne({ + where: { id: dto.imagingStudyId, tenantId, status: 'active' }, + }); + + if (!study) { + throw new Error('Imaging study not found or not active'); + } + + const orderNumber = await this.generateOrderNumber(tenantId); + + const order = this.repository.create({ + tenantId, + orderNumber, + patientId: dto.patientId, + consultationId: dto.consultationId, + orderingDoctorId: dto.orderingDoctorId, + imagingStudyId: dto.imagingStudyId, + studyCode: study.code, + studyName: study.name, + priority: dto.priority || 'routine', + clinicalIndication: dto.clinicalIndication, + clinicalHistory: dto.clinicalHistory, + notes: dto.notes, + status: 'ordered', + }); + + const savedOrder = await this.repository.save(order); + return this.findById(tenantId, savedOrder.id) as Promise; + } + + async update(tenantId: string, id: string, dto: UpdateImagingOrderDto): Promise { + const order = await this.findById(tenantId, id); + if (!order) return null; + + if (order.status === 'reported' || order.status === 'cancelled') { + throw new Error('Cannot update reported or cancelled order'); + } + + Object.assign(order, dto); + await this.repository.save(order); + + return this.findById(tenantId, id); + } + + async schedule(tenantId: string, id: string, dto: ScheduleImagingOrderDto): Promise { + const order = await this.findById(tenantId, id); + if (!order) return null; + + if (order.status !== 'ordered') { + throw new Error('Can only schedule orders with status "ordered"'); + } + + order.status = 'scheduled'; + order.scheduledAt = new Date(dto.scheduledDate); + order.scheduledRoom = dto.room; + order.scheduleInfo = { + scheduledDate: new Date(dto.scheduledDate), + scheduledTime: dto.scheduledTime, + room: dto.room, + equipment: dto.equipment, + technologist: dto.technologistId, + }; + + await this.repository.save(order); + return this.findById(tenantId, id); + } + + async startPerformance(tenantId: string, id: string): Promise { + const order = await this.findById(tenantId, id); + if (!order) return null; + + if (order.status !== 'scheduled') { + throw new Error('Can only start performance for scheduled orders'); + } + + order.status = 'in_progress'; + await this.repository.save(order); + + return this.findById(tenantId, id); + } + + async perform(tenantId: string, id: string, dto: PerformImagingOrderDto): Promise { + const order = await this.findById(tenantId, id); + if (!order) return null; + + if (order.status !== 'scheduled' && order.status !== 'in_progress') { + throw new Error('Can only perform scheduled or in-progress orders'); + } + + order.status = 'performed'; + order.performedAt = new Date(); + order.performedBy = dto.performedBy; + order.accessionNumber = dto.accessionNumber; + order.contrastUsed = dto.contrastUsed || false; + order.contrastType = dto.contrastType; + order.contrastAmount = dto.contrastAmount; + order.radiationDose = dto.radiationDose; + + await this.repository.save(order); + return this.findById(tenantId, id); + } + + async assignRadiologist(tenantId: string, id: string, dto: AssignRadiologistDto): Promise { + const order = await this.findById(tenantId, id); + if (!order) return null; + + if (order.status === 'cancelled') { + throw new Error('Cannot assign radiologist to cancelled order'); + } + + order.radiologistId = dto.radiologistId; + await this.repository.save(order); + + return this.findById(tenantId, id); + } + + async report(tenantId: string, id: string, dto: ReportImagingOrderDto): Promise { + const order = await this.findById(tenantId, id); + if (!order) return null; + + if (order.status !== 'performed') { + throw new Error('Can only report performed orders'); + } + + order.status = 'reported'; + order.isCriticalFinding = dto.isCriticalFinding || false; + order.reportInfo = { + findings: dto.findings, + impression: dto.impression, + recommendations: dto.recommendations, + reportedAt: new Date(), + reportedBy: dto.reportedBy, + }; + + await this.repository.save(order); + return this.findById(tenantId, id); + } + + async signReport(tenantId: string, id: string, dto: SignReportDto): Promise { + const order = await this.findById(tenantId, id); + if (!order) return null; + + if (order.status !== 'reported') { + throw new Error('Can only sign reported orders'); + } + + if (!order.reportInfo) { + throw new Error('Order has no report to sign'); + } + + order.reportInfo = { + ...order.reportInfo, + signedAt: new Date(), + signedBy: dto.signedBy, + }; + + await this.repository.save(order); + return this.findById(tenantId, id); + } + + async notifyCriticalFinding(tenantId: string, id: string, dto: NotifyCriticalFindingDto): Promise { + const order = await this.findById(tenantId, id); + if (!order) return null; + + if (!order.isCriticalFinding) { + throw new Error('Order does not have a critical finding'); + } + + order.criticalFindingNotified = true; + order.criticalFindingNotifiedAt = new Date(); + order.criticalFindingNotifiedTo = dto.notifiedTo; + + await this.repository.save(order); + return this.findById(tenantId, id); + } + + async cancel(tenantId: string, id: string, dto: CancelImagingOrderDto): Promise { + const order = await this.findById(tenantId, id); + if (!order) return null; + + if (order.status === 'reported') { + throw new Error('Cannot cancel reported order'); + } + + order.status = 'cancelled'; + order.cancelledAt = new Date(); + order.cancellationReason = dto.reason; + + await this.repository.save(order); + return this.findById(tenantId, id); + } + + async getPendingOrdersCount(tenantId: string): Promise { + return this.repository.count({ + where: { tenantId, status: 'ordered' }, + }); + } + + async getOrdersByStatus(tenantId: string): Promise<{ status: string; count: number }[]> { + const result = await this.repository.createQueryBuilder('order') + .select('order.status', 'status') + .addSelect('COUNT(*)', 'count') + .where('order.tenant_id = :tenantId', { tenantId }) + .groupBy('order.status') + .getRawMany(); + + return result.map(r => ({ status: r.status, count: parseInt(r.count, 10) })); + } + + async getCriticalFindings(tenantId: string, unnotifiedOnly: boolean = false): Promise { + const query: any = { tenantId, isCriticalFinding: true }; + if (unnotifiedOnly) { + query.criticalFindingNotified = false; + } + + return this.repository.find({ + where: query, + relations: ['dicomInstances'], + order: { orderDate: 'DESC' }, + }); + } + + async getScheduledOrders(tenantId: string, date: string): Promise { + return this.repository.createQueryBuilder('order') + .leftJoinAndSelect('order.dicomInstances', 'dicom') + .where('order.tenant_id = :tenantId', { tenantId }) + .andWhere('order.status = :status', { status: 'scheduled' }) + .andWhere('DATE(order.scheduled_at) = :date', { date }) + .orderBy('order.scheduled_at', 'ASC') + .getMany(); + } + + async getWorklistForRadiologist(tenantId: string, radiologistId: string): Promise { + return this.repository.find({ + where: [ + { tenantId, radiologistId, status: 'performed' }, + { tenantId, radiologistId, status: 'in_progress' }, + ], + relations: ['dicomInstances'], + order: { priority: 'DESC', orderDate: 'ASC' }, + }); + } + + private async generateOrderNumber(tenantId: string): Promise { + const today = new Date(); + const dateStr = today.toISOString().slice(0, 10).replace(/-/g, ''); + + const count = await this.repository.createQueryBuilder('order') + .where('order.tenant_id = :tenantId', { tenantId }) + .andWhere('DATE(order.created_at) = CURRENT_DATE') + .getCount(); + + const sequence = (count + 1).toString().padStart(4, '0'); + return `IMG-${dateStr}-${sequence}`; + } +} diff --git a/src/modules/imaging/services/imaging-study.service.ts b/src/modules/imaging/services/imaging-study.service.ts new file mode 100644 index 0000000..866e55c --- /dev/null +++ b/src/modules/imaging/services/imaging-study.service.ts @@ -0,0 +1,137 @@ +import { DataSource, Repository } from 'typeorm'; +import { ImagingStudy } from '../entities'; +import { CreateImagingStudyDto, UpdateImagingStudyDto, ImagingStudyQueryDto } from '../dto'; + +export class ImagingStudyService { + private repository: Repository; + + constructor(dataSource: DataSource) { + this.repository = dataSource.getRepository(ImagingStudy); + } + + async findAll(tenantId: string, query: ImagingStudyQueryDto): Promise<{ data: ImagingStudy[]; total: number }> { + const { search, studyType, bodyRegion, status, page = 1, limit = 50 } = query; + + const queryBuilder = this.repository.createQueryBuilder('study') + .where('study.tenant_id = :tenantId', { tenantId }) + .andWhere('study.deleted_at IS NULL'); + + if (studyType) { + queryBuilder.andWhere('study.study_type = :studyType', { studyType }); + } + + if (bodyRegion) { + queryBuilder.andWhere('study.body_region = :bodyRegion', { bodyRegion }); + } + + if (status) { + queryBuilder.andWhere('study.status = :status', { status }); + } + + if (search) { + queryBuilder.andWhere( + '(study.code ILIKE :search OR study.name ILIKE :search)', + { search: `%${search}%` } + ); + } + + queryBuilder + .orderBy('study.study_type', 'ASC') + .addOrderBy('study.body_region', 'ASC') + .addOrderBy('study.display_order', 'ASC') + .addOrderBy('study.name', 'ASC') + .skip((page - 1) * limit) + .take(limit); + + const [data, total] = await queryBuilder.getManyAndCount(); + return { data, total }; + } + + async findById(tenantId: string, id: string): Promise { + return this.repository.findOne({ + where: { id, tenantId }, + }); + } + + async findByCode(tenantId: string, code: string): Promise { + return this.repository.findOne({ + where: { code, tenantId }, + }); + } + + async findByIds(tenantId: string, ids: string[]): Promise { + if (ids.length === 0) return []; + + return this.repository.createQueryBuilder('study') + .where('study.tenant_id = :tenantId', { tenantId }) + .andWhere('study.id IN (:...ids)', { ids }) + .andWhere('study.deleted_at IS NULL') + .getMany(); + } + + async findActiveByType(tenantId: string, studyType: string): Promise { + return this.repository.find({ + where: { tenantId, studyType: studyType as any, status: 'active' }, + order: { displayOrder: 'ASC', name: 'ASC' }, + }); + } + + async findActiveByBodyRegion(tenantId: string, bodyRegion: string): Promise { + return this.repository.find({ + where: { tenantId, bodyRegion: bodyRegion as any, status: 'active' }, + order: { displayOrder: 'ASC', name: 'ASC' }, + }); + } + + async create(tenantId: string, dto: CreateImagingStudyDto): Promise { + const study = this.repository.create({ + ...dto, + tenantId, + }); + return this.repository.save(study); + } + + async update(tenantId: string, id: string, dto: UpdateImagingStudyDto): Promise { + const study = await this.findById(tenantId, id); + if (!study) return null; + + Object.assign(study, dto); + return this.repository.save(study); + } + + async softDelete(tenantId: string, id: string): Promise { + const study = await this.findById(tenantId, id); + if (!study) return false; + + await this.repository.softDelete(id); + return true; + } + + async getStudyTypes(tenantId: string): Promise<{ studyType: string; count: number }[]> { + const result = await this.repository.createQueryBuilder('study') + .select('study.study_type', 'studyType') + .addSelect('COUNT(*)', 'count') + .where('study.tenant_id = :tenantId', { tenantId }) + .andWhere('study.deleted_at IS NULL') + .andWhere('study.status = :status', { status: 'active' }) + .groupBy('study.study_type') + .orderBy('study.study_type', 'ASC') + .getRawMany(); + + return result.map(r => ({ studyType: r.studyType, count: parseInt(r.count, 10) })); + } + + async getBodyRegions(tenantId: string): Promise<{ bodyRegion: string; count: number }[]> { + const result = await this.repository.createQueryBuilder('study') + .select('study.body_region', 'bodyRegion') + .addSelect('COUNT(*)', 'count') + .where('study.tenant_id = :tenantId', { tenantId }) + .andWhere('study.deleted_at IS NULL') + .andWhere('study.status = :status', { status: 'active' }) + .groupBy('study.body_region') + .orderBy('study.body_region', 'ASC') + .getRawMany(); + + return result.map(r => ({ bodyRegion: r.bodyRegion, count: parseInt(r.count, 10) })); + } +} diff --git a/src/modules/imaging/services/index.ts b/src/modules/imaging/services/index.ts new file mode 100644 index 0000000..639a529 --- /dev/null +++ b/src/modules/imaging/services/index.ts @@ -0,0 +1,3 @@ +export { ImagingStudyService } from './imaging-study.service'; +export { ImagingOrderService } from './imaging-order.service'; +export { DicomService, ViewerUrlResult } from './dicom.service';