[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 <noreply@anthropic.com>
This commit is contained in:
Adrian Flores Cortes 2026-01-30 19:55:56 -06:00
parent 50a409bf17
commit 56ded676ae
13 changed files with 2409 additions and 0 deletions

View File

@ -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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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);
}
}
}

View File

@ -0,0 +1 @@
export { ImagingController } from './imaging.controller';

View File

@ -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<string, unknown>;
}
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;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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';

View File

@ -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;
}
}

View File

@ -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';

View File

@ -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<DicomInstance>;
private orderRepository: Repository<ImagingOrder>;
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<DicomInstance | null> {
return this.repository.findOne({
where: { id, tenantId },
});
}
async findByOrder(tenantId: string, imagingOrderId: string): Promise<DicomInstance[]> {
return this.repository.find({
where: { tenantId, imagingOrderId },
order: { seriesNumber: 'ASC', instanceNumber: 'ASC' },
});
}
async findByStudyInstanceUID(tenantId: string, studyInstanceUID: string): Promise<DicomInstance[]> {
return this.repository.find({
where: { tenantId, studyInstanceUID },
order: { seriesNumber: 'ASC', instanceNumber: 'ASC' },
});
}
async findBySeriesInstanceUID(tenantId: string, seriesInstanceUID: string): Promise<DicomInstance[]> {
return this.repository.find({
where: { tenantId, seriesInstanceUID },
order: { instanceNumber: 'ASC' },
});
}
async findBySopInstanceUID(tenantId: string, sopInstanceUID: string): Promise<DicomInstance | null> {
return this.repository.findOne({
where: { tenantId, sopInstanceUID },
});
}
async create(tenantId: string, dto: CreateDicomInstanceDto): Promise<DicomInstance> {
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<DicomInstance[]> {
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<DicomInstance | null> {
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<DicomInstance | null> {
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<DicomInstance | null> {
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<DicomInstance | null> {
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<DicomInstance | null> {
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<ViewerUrlResult | null> {
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<number> {
return this.repository.count({
where: { tenantId, imagingOrderId },
});
}
async getAvailableInstanceCount(tenantId: string, imagingOrderId: string): Promise<number> {
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<number> {
const result = await this.repository.delete({
tenantId,
imagingOrderId,
});
return result.affected || 0;
}
}

View File

@ -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<ImagingOrder>;
private studyRepository: Repository<ImagingStudy>;
private dicomRepository: Repository<DicomInstance>;
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<ImagingOrder | null> {
return this.repository.findOne({
where: { id, tenantId },
relations: ['dicomInstances'],
});
}
async findByOrderNumber(tenantId: string, orderNumber: string): Promise<ImagingOrder | null> {
return this.repository.findOne({
where: { orderNumber, tenantId },
relations: ['dicomInstances'],
});
}
async findByPatient(tenantId: string, patientId: string, limit: number = 10): Promise<ImagingOrder[]> {
return this.repository.find({
where: { tenantId, patientId },
relations: ['dicomInstances'],
order: { orderDate: 'DESC' },
take: limit,
});
}
async findByConsultation(tenantId: string, consultationId: string): Promise<ImagingOrder[]> {
return this.repository.find({
where: { tenantId, consultationId },
relations: ['dicomInstances'],
order: { orderDate: 'DESC' },
});
}
async findByRadiologist(tenantId: string, radiologistId: string, status?: string): Promise<ImagingOrder[]> {
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<ImagingOrder> {
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<ImagingOrder>;
}
async update(tenantId: string, id: string, dto: UpdateImagingOrderDto): Promise<ImagingOrder | null> {
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<ImagingOrder | null> {
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<ImagingOrder | null> {
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<ImagingOrder | null> {
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<ImagingOrder | null> {
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<ImagingOrder | null> {
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<ImagingOrder | null> {
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<ImagingOrder | null> {
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<ImagingOrder | null> {
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<number> {
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<ImagingOrder[]> {
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<ImagingOrder[]> {
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<ImagingOrder[]> {
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<string> {
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}`;
}
}

View File

@ -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<ImagingStudy>;
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<ImagingStudy | null> {
return this.repository.findOne({
where: { id, tenantId },
});
}
async findByCode(tenantId: string, code: string): Promise<ImagingStudy | null> {
return this.repository.findOne({
where: { code, tenantId },
});
}
async findByIds(tenantId: string, ids: string[]): Promise<ImagingStudy[]> {
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<ImagingStudy[]> {
return this.repository.find({
where: { tenantId, studyType: studyType as any, status: 'active' },
order: { displayOrder: 'ASC', name: 'ASC' },
});
}
async findActiveByBodyRegion(tenantId: string, bodyRegion: string): Promise<ImagingStudy[]> {
return this.repository.find({
where: { tenantId, bodyRegion: bodyRegion as any, status: 'active' },
order: { displayOrder: 'ASC', name: 'ASC' },
});
}
async create(tenantId: string, dto: CreateImagingStudyDto): Promise<ImagingStudy> {
const study = this.repository.create({
...dto,
tenantId,
});
return this.repository.save(study);
}
async update(tenantId: string, id: string, dto: UpdateImagingStudyDto): Promise<ImagingStudy | null> {
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<boolean> {
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) }));
}
}

View File

@ -0,0 +1,3 @@
export { ImagingStudyService } from './imaging-study.service';
export { ImagingOrderService } from './imaging-order.service';
export { DicomService, ViewerUrlResult } from './dicom.service';