From e4d915889a3f9c906bae43fa9b9b869fab6f064d Mon Sep 17 00:00:00 2001 From: Adrian Flores Cortes Date: Fri, 30 Jan 2026 19:56:19 -0600 Subject: [PATCH] [CL-012] feat: Implement telemedicine module for virtual consultations Add complete telemedicine module with: - VirtualRoom entity for video consultation room management - TelemedicineSession entity for session lifecycle tracking - SessionParticipant entity for participant join/leave tracking - Full REST API endpoints for rooms, sessions, and participants - Multi-tenant support throughout - Session states: scheduled, waiting, in_progress, completed, cancelled - Participant roles: patient, doctor, guest, interpreter, family_member - Recording consent tracking and session duration logging - Connection quality logging infrastructure - Integration points for WebRTC providers (Twilio, Daily.co, Vonage) Co-Authored-By: Claude Opus 4.5 --- src/modules/telemedicine/controllers/index.ts | 1 + .../controllers/telemedicine.controller.ts | 747 ++++++++++++++++++ src/modules/telemedicine/dto/index.ts | 428 ++++++++++ src/modules/telemedicine/entities/index.ts | 3 + .../entities/session-participant.entity.ts | 128 +++ .../entities/telemedicine-session.entity.ts | 114 +++ .../entities/virtual-room.entity.ts | 97 +++ src/modules/telemedicine/index.ts | 20 + src/modules/telemedicine/services/index.ts | 3 + .../services/participant.service.ts | 347 ++++++++ .../services/telemedicine-session.service.ts | 374 +++++++++ .../services/virtual-room.service.ts | 217 +++++ .../telemedicine/telemedicine.module.ts | 19 + 13 files changed, 2498 insertions(+) create mode 100644 src/modules/telemedicine/controllers/index.ts create mode 100644 src/modules/telemedicine/controllers/telemedicine.controller.ts create mode 100644 src/modules/telemedicine/dto/index.ts create mode 100644 src/modules/telemedicine/entities/index.ts create mode 100644 src/modules/telemedicine/entities/session-participant.entity.ts create mode 100644 src/modules/telemedicine/entities/telemedicine-session.entity.ts create mode 100644 src/modules/telemedicine/entities/virtual-room.entity.ts create mode 100644 src/modules/telemedicine/index.ts create mode 100644 src/modules/telemedicine/services/index.ts create mode 100644 src/modules/telemedicine/services/participant.service.ts create mode 100644 src/modules/telemedicine/services/telemedicine-session.service.ts create mode 100644 src/modules/telemedicine/services/virtual-room.service.ts create mode 100644 src/modules/telemedicine/telemedicine.module.ts diff --git a/src/modules/telemedicine/controllers/index.ts b/src/modules/telemedicine/controllers/index.ts new file mode 100644 index 0000000..cce0826 --- /dev/null +++ b/src/modules/telemedicine/controllers/index.ts @@ -0,0 +1 @@ +export { TelemedicineController } from './telemedicine.controller'; diff --git a/src/modules/telemedicine/controllers/telemedicine.controller.ts b/src/modules/telemedicine/controllers/telemedicine.controller.ts new file mode 100644 index 0000000..707b683 --- /dev/null +++ b/src/modules/telemedicine/controllers/telemedicine.controller.ts @@ -0,0 +1,747 @@ +import { Router, Request, Response, NextFunction } from 'express'; +import { DataSource } from 'typeorm'; +import { VirtualRoomService, TelemedicineSessionService, ParticipantService } from '../services'; +import { + CreateVirtualRoomDto, + UpdateVirtualRoomDto, + VirtualRoomQueryDto, + CreateTelemedicineSessionDto, + UpdateTelemedicineSessionDto, + StartSessionDto, + EndSessionDto, + CancelSessionDto, + RecordingConsentDto, + TelemedicineSessionQueryDto, + AddParticipantDto, + UpdateParticipantDto, + JoinSessionDto, + LeaveSessionDto, + ParticipantConsentDto, + ConnectionQualityDto, + GenerateTokenDto, + BulkInviteDto, +} from '../dto'; + +export class TelemedicineController { + public router: Router; + private roomService: VirtualRoomService; + private sessionService: TelemedicineSessionService; + private participantService: ParticipantService; + + constructor(dataSource: DataSource, basePath: string = '/api') { + this.router = Router(); + this.roomService = new VirtualRoomService(dataSource); + this.sessionService = new TelemedicineSessionService(dataSource); + this.participantService = new ParticipantService(dataSource); + this.setupRoutes(basePath); + } + + private setupRoutes(basePath: string): void { + // Virtual Rooms + this.router.get(`${basePath}/telemedicine/rooms`, this.getRooms.bind(this)); + this.router.get(`${basePath}/telemedicine/rooms/available`, this.getAvailableRooms.bind(this)); + this.router.get(`${basePath}/telemedicine/rooms/:id`, this.getRoomById.bind(this)); + this.router.get( + `${basePath}/telemedicine/rooms/appointment/:appointmentId`, + this.getRoomByAppointment.bind(this) + ); + this.router.post(`${basePath}/telemedicine/rooms`, this.createRoom.bind(this)); + this.router.patch(`${basePath}/telemedicine/rooms/:id`, this.updateRoom.bind(this)); + this.router.post(`${basePath}/telemedicine/rooms/:id/open`, this.openRoom.bind(this)); + this.router.post(`${basePath}/telemedicine/rooms/:id/close`, this.closeRoom.bind(this)); + this.router.post( + `${basePath}/telemedicine/rooms/:id/credentials`, + this.generateCredentials.bind(this) + ); + this.router.delete(`${basePath}/telemedicine/rooms/:id`, this.deleteRoom.bind(this)); + + // Telemedicine Sessions + this.router.get(`${basePath}/telemedicine/sessions`, this.getSessions.bind(this)); + this.router.get(`${basePath}/telemedicine/sessions/upcoming`, this.getUpcomingSessions.bind(this)); + this.router.get(`${basePath}/telemedicine/sessions/stats`, this.getSessionStats.bind(this)); + this.router.get(`${basePath}/telemedicine/sessions/:id`, this.getSessionById.bind(this)); + this.router.get( + `${basePath}/telemedicine/sessions/appointment/:appointmentId`, + this.getSessionByAppointment.bind(this) + ); + this.router.post(`${basePath}/telemedicine/sessions`, this.createSession.bind(this)); + this.router.patch(`${basePath}/telemedicine/sessions/:id`, this.updateSession.bind(this)); + this.router.post(`${basePath}/telemedicine/sessions/:id/start`, this.startSession.bind(this)); + this.router.post(`${basePath}/telemedicine/sessions/:id/end`, this.endSession.bind(this)); + this.router.post(`${basePath}/telemedicine/sessions/:id/cancel`, this.cancelSession.bind(this)); + this.router.post(`${basePath}/telemedicine/sessions/:id/waiting`, this.setSessionWaiting.bind(this)); + this.router.post( + `${basePath}/telemedicine/sessions/:id/recording-consent`, + this.updateSessionRecordingConsent.bind(this) + ); + + // Session Participants + this.router.get( + `${basePath}/telemedicine/sessions/:sessionId/participants`, + this.getParticipants.bind(this) + ); + this.router.get( + `${basePath}/telemedicine/sessions/:sessionId/participants/stats`, + this.getParticipantStats.bind(this) + ); + this.router.get( + `${basePath}/telemedicine/sessions/:sessionId/participants/waiting`, + this.getWaitingParticipants.bind(this) + ); + this.router.get( + `${basePath}/telemedicine/sessions/:sessionId/participants/connected`, + this.getConnectedParticipants.bind(this) + ); + this.router.post( + `${basePath}/telemedicine/sessions/:sessionId/participants`, + this.addParticipant.bind(this) + ); + this.router.post( + `${basePath}/telemedicine/sessions/:sessionId/participants/bulk`, + this.bulkInvite.bind(this) + ); + this.router.get( + `${basePath}/telemedicine/participants/:id`, + this.getParticipantById.bind(this) + ); + this.router.patch( + `${basePath}/telemedicine/participants/:id`, + this.updateParticipant.bind(this) + ); + this.router.post( + `${basePath}/telemedicine/participants/:id/join`, + this.joinSession.bind(this) + ); + this.router.post( + `${basePath}/telemedicine/participants/:id/leave`, + this.leaveSession.bind(this) + ); + this.router.post( + `${basePath}/telemedicine/participants/:id/waiting`, + this.setParticipantWaiting.bind(this) + ); + this.router.post( + `${basePath}/telemedicine/participants/:id/disconnect`, + this.disconnectParticipant.bind(this) + ); + this.router.post( + `${basePath}/telemedicine/participants/:id/consent`, + this.updateParticipantConsent.bind(this) + ); + this.router.post( + `${basePath}/telemedicine/participants/:id/quality`, + this.logConnectionQuality.bind(this) + ); + this.router.post( + `${basePath}/telemedicine/participants/:id/token`, + this.generateToken.bind(this) + ); + this.router.post( + `${basePath}/telemedicine/participants/:id/media`, + this.toggleMedia.bind(this) + ); + this.router.delete( + `${basePath}/telemedicine/participants/:id`, + this.removeParticipant.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; + } + + private getUserId(req: Request): string | undefined { + return req.headers['x-user-id'] as string; + } + + // Virtual Rooms + private async getRooms(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const query: VirtualRoomQueryDto = { + appointmentId: req.query.appointmentId as string, + status: req.query.status as any, + dateFrom: req.query.dateFrom as string, + dateTo: req.query.dateTo as string, + 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.roomService.findAll(tenantId, query); + res.json({ data: result.data, meta: { total: result.total, page: query.page, limit: query.limit } }); + } catch (error) { + next(error); + } + } + + private async getAvailableRooms(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const data = await this.roomService.findAvailableRooms(tenantId); + res.json({ data }); + } catch (error) { + next(error); + } + } + + private async getRoomById(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const { id } = req.params; + const data = await this.roomService.findById(tenantId, id); + if (!data) { + res.status(404).json({ error: 'Virtual room not found' }); + return; + } + res.json({ data }); + } catch (error) { + next(error); + } + } + + private async getRoomByAppointment(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const { appointmentId } = req.params; + const data = await this.roomService.findByAppointment(tenantId, appointmentId); + if (!data) { + res.status(404).json({ error: 'Virtual room not found for this appointment' }); + return; + } + res.json({ data }); + } catch (error) { + next(error); + } + } + + private async createRoom(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const userId = this.getUserId(req); + const dto: CreateVirtualRoomDto = req.body; + const data = await this.roomService.create(tenantId, dto, userId); + res.status(201).json({ data }); + } catch (error) { + next(error); + } + } + + private async updateRoom(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const { id } = req.params; + const dto: UpdateVirtualRoomDto = req.body; + const data = await this.roomService.update(tenantId, id, dto); + if (!data) { + res.status(404).json({ error: 'Virtual room not found' }); + return; + } + res.json({ data }); + } catch (error) { + next(error); + } + } + + private async openRoom(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const { id } = req.params; + const data = await this.roomService.openRoom(tenantId, id); + if (!data) { + res.status(404).json({ error: 'Virtual room not found' }); + return; + } + res.json({ data }); + } catch (error) { + next(error); + } + } + + private async closeRoom(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const { id } = req.params; + const data = await this.roomService.closeRoom(tenantId, id); + if (!data) { + res.status(404).json({ error: 'Virtual room not found' }); + return; + } + res.json({ data }); + } catch (error) { + next(error); + } + } + + private async generateCredentials(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const { id } = req.params; + const data = await this.roomService.generateProviderCredentials(tenantId, id); + if (!data) { + res.status(404).json({ error: 'Virtual room not found' }); + return; + } + res.json({ data }); + } catch (error) { + next(error); + } + } + + private async deleteRoom(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const { id } = req.params; + const deleted = await this.roomService.delete(tenantId, id); + if (!deleted) { + res.status(404).json({ error: 'Virtual room not found' }); + return; + } + res.status(204).send(); + } catch (error) { + next(error); + } + } + + // Telemedicine Sessions + private async getSessions(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const query: TelemedicineSessionQueryDto = { + virtualRoomId: req.query.virtualRoomId as string, + appointmentId: req.query.appointmentId as string, + patientId: req.query.patientId as string, + doctorId: req.query.doctorId as string, + status: req.query.status as any, + dateFrom: req.query.dateFrom as string, + dateTo: req.query.dateTo as string, + 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.sessionService.findAll(tenantId, query); + res.json({ data: result.data, meta: { total: result.total, page: query.page, limit: query.limit } }); + } catch (error) { + next(error); + } + } + + private async getUpcomingSessions(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const userId = req.query.userId as string; + const role = req.query.role as 'patient' | 'doctor'; + if (!userId || !role) { + res.status(400).json({ error: 'userId and role are required' }); + return; + } + const data = await this.sessionService.findUpcomingSessions(tenantId, userId, role); + res.json({ data }); + } catch (error) { + next(error); + } + } + + private async getSessionStats(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const dateFrom = req.query.dateFrom as string; + const dateTo = req.query.dateTo as string; + if (!dateFrom || !dateTo) { + res.status(400).json({ error: 'dateFrom and dateTo are required' }); + return; + } + const data = await this.sessionService.getSessionStats( + tenantId, + new Date(dateFrom), + new Date(dateTo) + ); + res.json({ data }); + } catch (error) { + next(error); + } + } + + private async getSessionById(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const { id } = req.params; + const data = await this.sessionService.findById(tenantId, id); + if (!data) { + res.status(404).json({ error: 'Telemedicine session not found' }); + return; + } + res.json({ data }); + } catch (error) { + next(error); + } + } + + private async getSessionByAppointment(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const { appointmentId } = req.params; + const data = await this.sessionService.findByAppointment(tenantId, appointmentId); + if (!data) { + res.status(404).json({ error: 'Telemedicine session not found for this appointment' }); + return; + } + res.json({ data }); + } catch (error) { + next(error); + } + } + + private async createSession(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const userId = this.getUserId(req); + const dto: CreateTelemedicineSessionDto = req.body; + const data = await this.sessionService.create(tenantId, dto, userId); + res.status(201).json({ data }); + } catch (error) { + next(error); + } + } + + private async updateSession(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const { id } = req.params; + const dto: UpdateTelemedicineSessionDto = req.body; + const data = await this.sessionService.update(tenantId, id, dto); + if (!data) { + res.status(404).json({ error: 'Telemedicine session not found' }); + return; + } + res.json({ data }); + } catch (error) { + next(error); + } + } + + private async startSession(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const { id } = req.params; + const dto: StartSessionDto = req.body || {}; + const data = await this.sessionService.startSession(tenantId, id, dto); + if (!data) { + res.status(404).json({ error: 'Telemedicine session not found' }); + return; + } + res.json({ data }); + } catch (error) { + next(error); + } + } + + private async endSession(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const { id } = req.params; + const dto: EndSessionDto = req.body || {}; + const data = await this.sessionService.endSession(tenantId, id, dto); + if (!data) { + res.status(404).json({ error: 'Telemedicine session not found' }); + return; + } + res.json({ data }); + } catch (error) { + next(error); + } + } + + private async cancelSession(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const { id } = req.params; + const dto: CancelSessionDto = req.body; + const data = await this.sessionService.cancelSession(tenantId, id, dto); + if (!data) { + res.status(404).json({ error: 'Telemedicine session not found' }); + return; + } + res.json({ data }); + } catch (error) { + next(error); + } + } + + private async setSessionWaiting(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const { id } = req.params; + const data = await this.sessionService.setWaiting(tenantId, id); + if (!data) { + res.status(404).json({ error: 'Telemedicine session not found' }); + return; + } + res.json({ data }); + } catch (error) { + next(error); + } + } + + private async updateSessionRecordingConsent(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const { id } = req.params; + const dto: RecordingConsentDto = req.body; + const data = await this.sessionService.updateRecordingConsent(tenantId, id, dto); + if (!data) { + res.status(404).json({ error: 'Telemedicine session not found' }); + return; + } + res.json({ data }); + } catch (error) { + next(error); + } + } + + // Session Participants + private async getParticipants(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const { sessionId } = req.params; + const data = await this.participantService.findBySession(tenantId, sessionId); + res.json({ data }); + } catch (error) { + next(error); + } + } + + private async getParticipantStats(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const { sessionId } = req.params; + const data = await this.participantService.getParticipantStats(tenantId, sessionId); + res.json({ data }); + } catch (error) { + next(error); + } + } + + private async getWaitingParticipants(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const { sessionId } = req.params; + const data = await this.participantService.getWaitingParticipants(tenantId, sessionId); + res.json({ data }); + } catch (error) { + next(error); + } + } + + private async getConnectedParticipants(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const { sessionId } = req.params; + const data = await this.participantService.getConnectedParticipants(tenantId, sessionId); + res.json({ data }); + } catch (error) { + next(error); + } + } + + private async addParticipant(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const { sessionId } = req.params; + const dto: AddParticipantDto = req.body; + const data = await this.participantService.addParticipant(tenantId, sessionId, dto); + res.status(201).json({ data }); + } catch (error) { + next(error); + } + } + + private async bulkInvite(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const { sessionId } = req.params; + const dto: BulkInviteDto = req.body; + const data = await this.participantService.bulkInvite(tenantId, sessionId, dto); + res.status(201).json({ data }); + } catch (error) { + next(error); + } + } + + private async getParticipantById(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const { id } = req.params; + const data = await this.participantService.findById(tenantId, id); + if (!data) { + res.status(404).json({ error: 'Participant not found' }); + return; + } + res.json({ data }); + } catch (error) { + next(error); + } + } + + private async updateParticipant(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const { id } = req.params; + const dto: UpdateParticipantDto = req.body; + const data = await this.participantService.updateParticipant(tenantId, id, dto); + if (!data) { + res.status(404).json({ error: 'Participant not found' }); + return; + } + res.json({ data }); + } catch (error) { + next(error); + } + } + + private async joinSession(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const { id } = req.params; + const dto: JoinSessionDto = req.body || {}; + const data = await this.participantService.joinSession(tenantId, id, dto); + if (!data) { + res.status(404).json({ error: 'Participant not found' }); + return; + } + res.json({ data }); + } catch (error) { + next(error); + } + } + + private async leaveSession(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const { id } = req.params; + const dto: LeaveSessionDto = req.body || {}; + const data = await this.participantService.leaveSession(tenantId, id, dto); + if (!data) { + res.status(404).json({ error: 'Participant not found' }); + return; + } + res.json({ data }); + } catch (error) { + next(error); + } + } + + private async setParticipantWaiting(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const { id } = req.params; + const data = await this.participantService.setWaiting(tenantId, id); + if (!data) { + res.status(404).json({ error: 'Participant not found' }); + return; + } + res.json({ data }); + } catch (error) { + next(error); + } + } + + private async disconnectParticipant(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const { id } = req.params; + const data = await this.participantService.disconnect(tenantId, id); + if (!data) { + res.status(404).json({ error: 'Participant not found' }); + return; + } + res.json({ data }); + } catch (error) { + next(error); + } + } + + private async updateParticipantConsent(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const { id } = req.params; + const dto: ParticipantConsentDto = req.body; + const data = await this.participantService.updateRecordingConsent(tenantId, id, dto); + if (!data) { + res.status(404).json({ error: 'Participant not found' }); + return; + } + res.json({ data }); + } catch (error) { + next(error); + } + } + + private async logConnectionQuality(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const { id } = req.params; + const dto: ConnectionQualityDto = req.body; + const data = await this.participantService.logConnectionQuality(tenantId, id, dto); + if (!data) { + res.status(404).json({ error: 'Participant not found' }); + return; + } + res.json({ data }); + } catch (error) { + next(error); + } + } + + private async generateToken(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const { id } = req.params; + const { expirationMinutes } = req.body as { expirationMinutes?: number }; + const data = await this.participantService.generateAccessToken(tenantId, id, expirationMinutes); + if (!data) { + res.status(404).json({ error: 'Participant not found' }); + return; + } + res.json({ data }); + } catch (error) { + next(error); + } + } + + private async toggleMedia(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const { id } = req.params; + const { audioEnabled, videoEnabled, screenSharing } = req.body; + const data = await this.participantService.toggleMedia( + tenantId, + id, + audioEnabled, + videoEnabled, + screenSharing + ); + if (!data) { + res.status(404).json({ error: 'Participant not found' }); + return; + } + res.json({ data }); + } catch (error) { + next(error); + } + } + + private async removeParticipant(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const { id } = req.params; + const deleted = await this.participantService.removeParticipant(tenantId, id); + if (!deleted) { + res.status(404).json({ error: 'Participant not found' }); + return; + } + res.status(204).send(); + } catch (error) { + next(error); + } + } +} diff --git a/src/modules/telemedicine/dto/index.ts b/src/modules/telemedicine/dto/index.ts new file mode 100644 index 0000000..0e5332c --- /dev/null +++ b/src/modules/telemedicine/dto/index.ts @@ -0,0 +1,428 @@ +import { + IsString, + IsOptional, + IsUUID, + IsEnum, + IsBoolean, + IsDateString, + IsInt, + Min, + Max, + MaxLength, + IsEmail, + IsObject, + ValidateNested, + IsArray, +} from 'class-validator'; +import { Type } from 'class-transformer'; +import { + VirtualRoomStatus, + VideoProvider, + SessionStatus, + ParticipantRole, + ParticipantStatus, +} from '../entities'; + +// Room Configuration DTO +export class RoomConfigurationDto { + @IsOptional() + @IsInt() + @Min(2) + @Max(50) + maxParticipants?: number; + + @IsOptional() + @IsBoolean() + enableRecording?: boolean; + + @IsOptional() + @IsBoolean() + enableChat?: boolean; + + @IsOptional() + @IsBoolean() + enableScreenShare?: boolean; + + @IsOptional() + @IsBoolean() + waitingRoomEnabled?: boolean; + + @IsOptional() + @IsBoolean() + autoCloseOnEmpty?: boolean; + + @IsOptional() + @IsInt() + @Min(15) + @Max(480) + maxDurationMinutes?: number; +} + +// Virtual Room DTOs +export class CreateVirtualRoomDto { + @IsString() + @MaxLength(100) + name: string; + + @IsOptional() + @IsString() + description?: string; + + @IsOptional() + @IsUUID() + appointmentId?: string; + + @IsOptional() + @IsEnum(['twilio', 'daily', 'vonage', 'custom']) + videoProvider?: VideoProvider; + + @IsOptional() + @ValidateNested() + @Type(() => RoomConfigurationDto) + configuration?: RoomConfigurationDto; + + @IsOptional() + @IsDateString() + scheduledStart?: string; + + @IsOptional() + @IsDateString() + scheduledEnd?: string; +} + +export class UpdateVirtualRoomDto { + @IsOptional() + @IsString() + @MaxLength(100) + name?: string; + + @IsOptional() + @IsString() + description?: string; + + @IsOptional() + @IsEnum(['available', 'in_use', 'maintenance', 'closed']) + status?: VirtualRoomStatus; + + @IsOptional() + @ValidateNested() + @Type(() => RoomConfigurationDto) + configuration?: RoomConfigurationDto; + + @IsOptional() + @IsDateString() + scheduledStart?: string; + + @IsOptional() + @IsDateString() + scheduledEnd?: string; +} + +export class VirtualRoomQueryDto { + @IsOptional() + @IsUUID() + appointmentId?: string; + + @IsOptional() + @IsEnum(['available', 'in_use', 'maintenance', 'closed']) + status?: VirtualRoomStatus; + + @IsOptional() + @IsDateString() + dateFrom?: string; + + @IsOptional() + @IsDateString() + dateTo?: string; + + @IsOptional() + page?: number; + + @IsOptional() + limit?: number; +} + +// Session Metadata DTO +export class SessionMetadataDto { + @IsOptional() + @IsString() + appointmentType?: string; + + @IsOptional() + @IsUUID() + specialtyId?: string; + + @IsOptional() + @IsUUID() + referralId?: string; + + @IsOptional() + @IsBoolean() + followUpRequired?: boolean; + + @IsOptional() + @IsString() + notes?: string; +} + +// Telemedicine Session DTOs +export class CreateTelemedicineSessionDto { + @IsUUID() + virtualRoomId: string; + + @IsOptional() + @IsUUID() + appointmentId?: string; + + @IsUUID() + patientId: string; + + @IsUUID() + doctorId: string; + + @IsDateString() + scheduledStart: string; + + @IsOptional() + @IsInt() + @Min(15) + @Max(180) + scheduledDurationMinutes?: number; + + @IsOptional() + @ValidateNested() + @Type(() => SessionMetadataDto) + metadata?: SessionMetadataDto; +} + +export class UpdateTelemedicineSessionDto { + @IsOptional() + @IsEnum(['scheduled', 'waiting', 'in_progress', 'completed', 'cancelled']) + status?: SessionStatus; + + @IsOptional() + @IsDateString() + scheduledStart?: string; + + @IsOptional() + @IsInt() + @Min(15) + @Max(180) + scheduledDurationMinutes?: number; + + @IsOptional() + @ValidateNested() + @Type(() => SessionMetadataDto) + metadata?: SessionMetadataDto; +} + +export class StartSessionDto { + @IsOptional() + @IsBoolean() + enableRecording?: boolean; +} + +export class EndSessionDto { + @IsOptional() + @IsString() + notes?: string; + + @IsOptional() + @IsBoolean() + followUpRequired?: boolean; +} + +export class CancelSessionDto { + @IsUUID() + cancelledBy: string; + + @IsOptional() + @IsString() + cancellationReason?: string; +} + +export class RecordingConsentDto { + @IsBoolean() + consent: boolean; + + @IsUUID() + consentGivenBy: string; +} + +export class TelemedicineSessionQueryDto { + @IsOptional() + @IsUUID() + virtualRoomId?: string; + + @IsOptional() + @IsUUID() + appointmentId?: string; + + @IsOptional() + @IsUUID() + patientId?: string; + + @IsOptional() + @IsUUID() + doctorId?: string; + + @IsOptional() + @IsEnum(['scheduled', 'waiting', 'in_progress', 'completed', 'cancelled']) + status?: SessionStatus; + + @IsOptional() + @IsDateString() + dateFrom?: string; + + @IsOptional() + @IsDateString() + dateTo?: string; + + @IsOptional() + page?: number; + + @IsOptional() + limit?: number; +} + +// Device Info DTO +export class DeviceInfoDto { + @IsOptional() + @IsString() + browser?: string; + + @IsOptional() + @IsString() + browserVersion?: string; + + @IsOptional() + @IsString() + os?: string; + + @IsOptional() + @IsString() + osVersion?: string; + + @IsOptional() + @IsEnum(['desktop', 'mobile', 'tablet']) + deviceType?: 'desktop' | 'mobile' | 'tablet'; + + @IsOptional() + @IsString() + audioDevice?: string; + + @IsOptional() + @IsString() + videoDevice?: string; + + @IsOptional() + @IsString() + screenResolution?: string; +} + +// Session Participant DTOs +export class AddParticipantDto { + @IsOptional() + @IsUUID() + userId?: string; + + @IsString() + @MaxLength(200) + displayName: string; + + @IsOptional() + @IsEmail() + email?: string; + + @IsEnum(['patient', 'doctor', 'guest', 'interpreter', 'family_member']) + role: ParticipantRole; +} + +export class UpdateParticipantDto { + @IsOptional() + @IsString() + @MaxLength(200) + displayName?: string; + + @IsOptional() + @IsBoolean() + audioEnabled?: boolean; + + @IsOptional() + @IsBoolean() + videoEnabled?: boolean; + + @IsOptional() + @IsBoolean() + screenSharing?: boolean; +} + +export class JoinSessionDto { + @IsOptional() + @ValidateNested() + @Type(() => DeviceInfoDto) + deviceInfo?: DeviceInfoDto; + + @IsOptional() + @IsBoolean() + audioEnabled?: boolean; + + @IsOptional() + @IsBoolean() + videoEnabled?: boolean; +} + +export class LeaveSessionDto { + @IsOptional() + @IsString() + reason?: string; +} + +export class ParticipantConsentDto { + @IsBoolean() + recordingConsent: boolean; +} + +export class ConnectionQualityDto { + @IsEnum(['excellent', 'good', 'fair', 'poor']) + audioQuality: 'excellent' | 'good' | 'fair' | 'poor'; + + @IsEnum(['excellent', 'good', 'fair', 'poor']) + videoQuality: 'excellent' | 'good' | 'fair' | 'poor'; + + @IsOptional() + @IsInt() + @Min(0) + latencyMs?: number; + + @IsOptional() + @IsInt() + @Min(0) + @Max(100) + packetLossPercent?: number; + + @IsOptional() + @IsInt() + @Min(0) + bandwidthKbps?: number; +} + +// Token generation DTO +export class GenerateTokenDto { + @IsUUID() + participantId: string; + + @IsOptional() + @IsInt() + @Min(5) + @Max(1440) + expirationMinutes?: number; +} + +// Bulk invite DTO +export class BulkInviteDto { + @IsArray() + @ValidateNested({ each: true }) + @Type(() => AddParticipantDto) + participants: AddParticipantDto[]; +} diff --git a/src/modules/telemedicine/entities/index.ts b/src/modules/telemedicine/entities/index.ts new file mode 100644 index 0000000..99d6edd --- /dev/null +++ b/src/modules/telemedicine/entities/index.ts @@ -0,0 +1,3 @@ +export { VirtualRoom, VirtualRoomStatus, VideoProvider, RoomConfiguration, ProviderCredentials } from './virtual-room.entity'; +export { TelemedicineSession, SessionStatus, SessionMetadata, RecordingInfo } from './telemedicine-session.entity'; +export { SessionParticipant, ParticipantRole, ParticipantStatus, ConnectionQuality, DeviceInfo } from './session-participant.entity'; diff --git a/src/modules/telemedicine/entities/session-participant.entity.ts b/src/modules/telemedicine/entities/session-participant.entity.ts new file mode 100644 index 0000000..f57c2fc --- /dev/null +++ b/src/modules/telemedicine/entities/session-participant.entity.ts @@ -0,0 +1,128 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { TelemedicineSession } from './telemedicine-session.entity'; + +export type ParticipantRole = 'patient' | 'doctor' | 'guest' | 'interpreter' | 'family_member'; + +export type ParticipantStatus = 'invited' | 'waiting' | 'connected' | 'disconnected' | 'left'; + +export interface ConnectionQuality { + timestamp: Date; + audioQuality: 'excellent' | 'good' | 'fair' | 'poor'; + videoQuality: 'excellent' | 'good' | 'fair' | 'poor'; + latencyMs?: number; + packetLossPercent?: number; + bandwidthKbps?: number; +} + +export interface DeviceInfo { + browser?: string; + browserVersion?: string; + os?: string; + osVersion?: string; + deviceType?: 'desktop' | 'mobile' | 'tablet'; + audioDevice?: string; + videoDevice?: string; + screenResolution?: string; +} + +@Entity({ name: 'session_participants', schema: 'clinica' }) +export class SessionParticipant { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Index() + @Column({ name: 'session_id', type: 'uuid' }) + sessionId: string; + + @ManyToOne(() => TelemedicineSession, (session) => session.participants) + @JoinColumn({ name: 'session_id' }) + session: TelemedicineSession; + + @Index() + @Column({ name: 'user_id', type: 'uuid', nullable: true }) + userId?: string; + + @Column({ name: 'display_name', type: 'varchar', length: 200 }) + displayName: string; + + @Column({ type: 'varchar', length: 255, nullable: true }) + email?: string; + + @Column({ + type: 'enum', + enum: ['patient', 'doctor', 'guest', 'interpreter', 'family_member'], + default: 'guest', + }) + role: ParticipantRole; + + @Column({ + type: 'enum', + enum: ['invited', 'waiting', 'connected', 'disconnected', 'left'], + default: 'invited', + }) + status: ParticipantStatus; + + @Column({ name: 'access_token', type: 'varchar', length: 500, nullable: true }) + accessToken?: string; + + @Column({ name: 'token_expires_at', type: 'timestamptz', nullable: true }) + tokenExpiresAt?: Date; + + @Column({ name: 'invited_at', type: 'timestamptz', default: () => 'NOW()' }) + invitedAt: Date; + + @Column({ name: 'joined_at', type: 'timestamptz', nullable: true }) + joinedAt?: Date; + + @Column({ name: 'left_at', type: 'timestamptz', nullable: true }) + leftAt?: Date; + + @Column({ name: 'total_duration_seconds', type: 'integer', nullable: true }) + totalDurationSeconds?: number; + + @Column({ name: 'connection_attempts', type: 'integer', default: 0 }) + connectionAttempts: number; + + @Column({ name: 'last_connection_attempt', type: 'timestamptz', nullable: true }) + lastConnectionAttempt?: Date; + + @Column({ name: 'connection_quality_logs', type: 'jsonb', nullable: true }) + connectionQualityLogs?: ConnectionQuality[]; + + @Column({ name: 'device_info', type: 'jsonb', nullable: true }) + deviceInfo?: DeviceInfo; + + @Column({ name: 'audio_enabled', type: 'boolean', default: true }) + audioEnabled: boolean; + + @Column({ name: 'video_enabled', type: 'boolean', default: true }) + videoEnabled: boolean; + + @Column({ name: 'screen_sharing', type: 'boolean', default: false }) + screenSharing: boolean; + + @Column({ name: 'recording_consent', type: 'boolean', default: false }) + recordingConsent: boolean; + + @Column({ name: 'recording_consent_at', type: 'timestamptz', nullable: true }) + recordingConsentAt?: Date; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; +} diff --git a/src/modules/telemedicine/entities/telemedicine-session.entity.ts b/src/modules/telemedicine/entities/telemedicine-session.entity.ts new file mode 100644 index 0000000..30542fe --- /dev/null +++ b/src/modules/telemedicine/entities/telemedicine-session.entity.ts @@ -0,0 +1,114 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + ManyToOne, + OneToMany, + JoinColumn, +} from 'typeorm'; +import { VirtualRoom } from './virtual-room.entity'; +import { SessionParticipant } from './session-participant.entity'; + +export type SessionStatus = 'scheduled' | 'waiting' | 'in_progress' | 'completed' | 'cancelled'; + +export interface SessionMetadata { + appointmentType?: string; + specialtyId?: string; + referralId?: string; + followUpRequired?: boolean; + notes?: string; +} + +export interface RecordingInfo { + enabled: boolean; + consentGiven: boolean; + consentGivenAt?: Date; + consentGivenBy?: string; + recordingUrl?: string; + recordingDurationSeconds?: number; + recordingStartedAt?: Date; + recordingEndedAt?: Date; +} + +@Entity({ name: 'telemedicine_sessions', schema: 'clinica' }) +export class TelemedicineSession { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Index() + @Column({ name: 'virtual_room_id', type: 'uuid' }) + virtualRoomId: string; + + @ManyToOne(() => VirtualRoom, (room) => room.sessions) + @JoinColumn({ name: 'virtual_room_id' }) + virtualRoom: VirtualRoom; + + @Index() + @Column({ name: 'appointment_id', type: 'uuid', nullable: true }) + appointmentId?: string; + + @Index() + @Column({ name: 'patient_id', type: 'uuid' }) + patientId: string; + + @Index() + @Column({ name: 'doctor_id', type: 'uuid' }) + doctorId: string; + + @Index() + @Column({ + type: 'enum', + enum: ['scheduled', 'waiting', 'in_progress', 'completed', 'cancelled'], + default: 'scheduled', + }) + status: SessionStatus; + + @Column({ name: 'scheduled_start', type: 'timestamptz' }) + scheduledStart: Date; + + @Column({ name: 'scheduled_duration_minutes', type: 'integer', default: 30 }) + scheduledDurationMinutes: number; + + @Column({ name: 'actual_start', type: 'timestamptz', nullable: true }) + actualStart?: Date; + + @Column({ name: 'actual_end', type: 'timestamptz', nullable: true }) + actualEnd?: Date; + + @Column({ name: 'actual_duration_seconds', type: 'integer', nullable: true }) + actualDurationSeconds?: number; + + @Column({ type: 'jsonb', nullable: true }) + metadata?: SessionMetadata; + + @Column({ name: 'recording_info', type: 'jsonb', nullable: true }) + recordingInfo?: RecordingInfo; + + @Column({ name: 'cancellation_reason', type: 'text', nullable: true }) + cancellationReason?: string; + + @Column({ name: 'cancelled_at', type: 'timestamptz', nullable: true }) + cancelledAt?: Date; + + @Column({ name: 'cancelled_by', type: 'uuid', nullable: true }) + cancelledBy?: string; + + @OneToMany(() => SessionParticipant, (participant) => participant.session) + participants: SessionParticipant[]; + + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdBy?: string; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; +} diff --git a/src/modules/telemedicine/entities/virtual-room.entity.ts b/src/modules/telemedicine/entities/virtual-room.entity.ts new file mode 100644 index 0000000..2f34809 --- /dev/null +++ b/src/modules/telemedicine/entities/virtual-room.entity.ts @@ -0,0 +1,97 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + OneToMany, +} from 'typeorm'; +import { TelemedicineSession } from './telemedicine-session.entity'; + +export type VirtualRoomStatus = 'available' | 'in_use' | 'maintenance' | 'closed'; + +export type VideoProvider = 'twilio' | 'daily' | 'vonage' | 'custom'; + +export interface RoomConfiguration { + maxParticipants: number; + enableRecording: boolean; + enableChat: boolean; + enableScreenShare: boolean; + waitingRoomEnabled: boolean; + autoCloseOnEmpty: boolean; + maxDurationMinutes: number; +} + +export interface ProviderCredentials { + roomId?: string; + roomUrl?: string; + hostToken?: string; + participantToken?: string; + expiresAt?: Date; +} + +@Entity({ name: 'virtual_rooms', schema: 'clinica' }) +export class VirtualRoom { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ type: 'varchar', length: 100 }) + name: string; + + @Column({ type: 'text', nullable: true }) + description?: string; + + @Index() + @Column({ name: 'appointment_id', type: 'uuid', nullable: true }) + appointmentId?: string; + + @Column({ + type: 'enum', + enum: ['available', 'in_use', 'maintenance', 'closed'], + default: 'available', + }) + status: VirtualRoomStatus; + + @Column({ + name: 'video_provider', + type: 'enum', + enum: ['twilio', 'daily', 'vonage', 'custom'], + default: 'daily', + }) + videoProvider: VideoProvider; + + @Column({ type: 'jsonb', nullable: true }) + configuration?: RoomConfiguration; + + @Column({ name: 'provider_credentials', type: 'jsonb', nullable: true }) + providerCredentials?: ProviderCredentials; + + @Column({ name: 'scheduled_start', type: 'timestamptz', nullable: true }) + scheduledStart?: Date; + + @Column({ name: 'scheduled_end', type: 'timestamptz', nullable: true }) + scheduledEnd?: Date; + + @Column({ name: 'actual_start', type: 'timestamptz', nullable: true }) + actualStart?: Date; + + @Column({ name: 'actual_end', type: 'timestamptz', nullable: true }) + actualEnd?: Date; + + @OneToMany(() => TelemedicineSession, (session) => session.virtualRoom) + sessions: TelemedicineSession[]; + + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdBy?: string; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; +} diff --git a/src/modules/telemedicine/index.ts b/src/modules/telemedicine/index.ts new file mode 100644 index 0000000..bfa908f --- /dev/null +++ b/src/modules/telemedicine/index.ts @@ -0,0 +1,20 @@ +export { TelemedicineModule, TelemedicineModuleOptions } from './telemedicine.module'; +export { + VirtualRoom, + VirtualRoomStatus, + VideoProvider, + RoomConfiguration, + ProviderCredentials, + TelemedicineSession, + SessionStatus, + SessionMetadata, + RecordingInfo, + SessionParticipant, + ParticipantRole, + ParticipantStatus, + ConnectionQuality, + DeviceInfo, +} from './entities'; +export { VirtualRoomService, TelemedicineSessionService, ParticipantService } from './services'; +export { TelemedicineController } from './controllers'; +export * from './dto'; diff --git a/src/modules/telemedicine/services/index.ts b/src/modules/telemedicine/services/index.ts new file mode 100644 index 0000000..b42f056 --- /dev/null +++ b/src/modules/telemedicine/services/index.ts @@ -0,0 +1,3 @@ +export { VirtualRoomService } from './virtual-room.service'; +export { TelemedicineSessionService } from './telemedicine-session.service'; +export { ParticipantService } from './participant.service'; diff --git a/src/modules/telemedicine/services/participant.service.ts b/src/modules/telemedicine/services/participant.service.ts new file mode 100644 index 0000000..9201713 --- /dev/null +++ b/src/modules/telemedicine/services/participant.service.ts @@ -0,0 +1,347 @@ +import { DataSource, Repository } from 'typeorm'; +import { + SessionParticipant, + ParticipantStatus, + ConnectionQuality, + DeviceInfo, + TelemedicineSession, +} from '../entities'; +import { + AddParticipantDto, + UpdateParticipantDto, + JoinSessionDto, + LeaveSessionDto, + ParticipantConsentDto, + ConnectionQualityDto, + GenerateTokenDto, + BulkInviteDto, +} from '../dto'; +import { v4 as uuidv4 } from 'uuid'; + +export class ParticipantService { + private repository: Repository; + private sessionRepository: Repository; + + constructor(dataSource: DataSource) { + this.repository = dataSource.getRepository(SessionParticipant); + this.sessionRepository = dataSource.getRepository(TelemedicineSession); + } + + async findBySession(tenantId: string, sessionId: string): Promise { + return this.repository.find({ + where: { tenantId, sessionId }, + order: { role: 'ASC', displayName: 'ASC' }, + }); + } + + async findById(tenantId: string, id: string): Promise { + return this.repository.findOne({ + where: { id, tenantId }, + relations: ['session'], + }); + } + + async findByUserId( + tenantId: string, + sessionId: string, + userId: string + ): Promise { + return this.repository.findOne({ + where: { tenantId, sessionId, userId }, + }); + } + + async addParticipant( + tenantId: string, + sessionId: string, + dto: AddParticipantDto + ): Promise { + // Verify session exists + const session = await this.sessionRepository.findOne({ + where: { id: sessionId, tenantId }, + }); + + if (!session) { + throw new Error('Session not found'); + } + + if (session.status === 'completed' || session.status === 'cancelled') { + throw new Error('Cannot add participants to a completed or cancelled session'); + } + + // Check if user is already a participant + if (dto.userId) { + const existing = await this.findByUserId(tenantId, sessionId, dto.userId); + if (existing) { + throw new Error('User is already a participant in this session'); + } + } + + const participant = this.repository.create({ + ...dto, + tenantId, + sessionId, + status: 'invited', + invitedAt: new Date(), + }); + + return this.repository.save(participant); + } + + async bulkInvite( + tenantId: string, + sessionId: string, + dto: BulkInviteDto + ): Promise { + const participants: SessionParticipant[] = []; + + for (const participantDto of dto.participants) { + const participant = await this.addParticipant(tenantId, sessionId, participantDto); + participants.push(participant); + } + + return participants; + } + + async updateParticipant( + tenantId: string, + id: string, + dto: UpdateParticipantDto + ): Promise { + const participant = await this.findById(tenantId, id); + if (!participant) return null; + + Object.assign(participant, dto); + return this.repository.save(participant); + } + + async joinSession( + tenantId: string, + id: string, + dto: JoinSessionDto + ): Promise { + const participant = await this.findById(tenantId, id); + if (!participant) return null; + + const session = await this.sessionRepository.findOne({ + where: { id: participant.sessionId }, + }); + + if (!session) { + throw new Error('Session not found'); + } + + if (session.status === 'completed' || session.status === 'cancelled') { + throw new Error('Cannot join a completed or cancelled session'); + } + + participant.status = 'connected'; + participant.joinedAt = new Date(); + participant.connectionAttempts += 1; + participant.lastConnectionAttempt = new Date(); + participant.deviceInfo = dto.deviceInfo as DeviceInfo; + participant.audioEnabled = dto.audioEnabled ?? true; + participant.videoEnabled = dto.videoEnabled ?? true; + + return this.repository.save(participant); + } + + async leaveSession( + tenantId: string, + id: string, + dto: LeaveSessionDto + ): Promise { + const participant = await this.findById(tenantId, id); + if (!participant) return null; + + const now = new Date(); + participant.status = 'left'; + participant.leftAt = now; + + if (participant.joinedAt) { + participant.totalDurationSeconds = Math.floor( + (now.getTime() - participant.joinedAt.getTime()) / 1000 + ); + } + + return this.repository.save(participant); + } + + async updateStatus( + tenantId: string, + id: string, + status: ParticipantStatus + ): Promise { + const participant = await this.findById(tenantId, id); + if (!participant) return null; + + participant.status = status; + + if (status === 'connected' && !participant.joinedAt) { + participant.joinedAt = new Date(); + } + + if ((status === 'left' || status === 'disconnected') && !participant.leftAt) { + participant.leftAt = new Date(); + } + + return this.repository.save(participant); + } + + async setWaiting(tenantId: string, id: string): Promise { + return this.updateStatus(tenantId, id, 'waiting'); + } + + async disconnect(tenantId: string, id: string): Promise { + return this.updateStatus(tenantId, id, 'disconnected'); + } + + async updateRecordingConsent( + tenantId: string, + id: string, + dto: ParticipantConsentDto + ): Promise { + const participant = await this.findById(tenantId, id); + if (!participant) return null; + + participant.recordingConsent = dto.recordingConsent; + participant.recordingConsentAt = dto.recordingConsent ? new Date() : undefined; + + return this.repository.save(participant); + } + + async logConnectionQuality( + tenantId: string, + id: string, + dto: ConnectionQualityDto + ): Promise { + const participant = await this.findById(tenantId, id); + if (!participant) return null; + + const qualityLog: ConnectionQuality = { + timestamp: new Date(), + ...dto, + }; + + const logs = participant.connectionQualityLogs || []; + logs.push(qualityLog); + + // Keep only last 100 quality logs + if (logs.length > 100) { + logs.shift(); + } + + participant.connectionQualityLogs = logs; + return this.repository.save(participant); + } + + async generateAccessToken( + tenantId: string, + id: string, + expirationMinutes: number = 60 + ): Promise<{ token: string; expiresAt: Date } | null> { + const participant = await this.findById(tenantId, id); + if (!participant) return null; + + // This would integrate with actual video provider APIs + // For now, we generate a placeholder token + const token = `tk-${uuidv4()}`; + const expiresAt = new Date(Date.now() + expirationMinutes * 60 * 1000); + + participant.accessToken = token; + participant.tokenExpiresAt = expiresAt; + + await this.repository.save(participant); + + return { token, expiresAt }; + } + + async removeParticipant(tenantId: string, id: string): Promise { + const participant = await this.findById(tenantId, id); + if (!participant) return false; + + if (participant.status === 'connected') { + throw new Error('Cannot remove a connected participant. Disconnect them first.'); + } + + await this.repository.remove(participant); + return true; + } + + async getConnectedParticipants( + tenantId: string, + sessionId: string + ): Promise { + return this.repository.find({ + where: { tenantId, sessionId, status: 'connected' }, + }); + } + + async getWaitingParticipants( + tenantId: string, + sessionId: string + ): Promise { + return this.repository.find({ + where: { tenantId, sessionId, status: 'waiting' }, + order: { invitedAt: 'ASC' }, + }); + } + + async toggleMedia( + tenantId: string, + id: string, + audioEnabled?: boolean, + videoEnabled?: boolean, + screenSharing?: boolean + ): Promise { + const participant = await this.findById(tenantId, id); + if (!participant) return null; + + if (audioEnabled !== undefined) { + participant.audioEnabled = audioEnabled; + } + if (videoEnabled !== undefined) { + participant.videoEnabled = videoEnabled; + } + if (screenSharing !== undefined) { + participant.screenSharing = screenSharing; + } + + return this.repository.save(participant); + } + + async getParticipantStats( + tenantId: string, + sessionId: string + ): Promise<{ + total: number; + connected: number; + waiting: number; + left: number; + averageDurationMinutes: number; + }> { + const participants = await this.findBySession(tenantId, sessionId); + + const connected = participants.filter((p) => p.status === 'connected').length; + const waiting = participants.filter((p) => p.status === 'waiting').length; + const left = participants.filter((p) => p.status === 'left').length; + + const totalDurationSeconds = participants.reduce( + (sum, p) => sum + (p.totalDurationSeconds || 0), + 0 + ); + + const participantsWithDuration = participants.filter((p) => p.totalDurationSeconds); + + return { + total: participants.length, + connected, + waiting, + left, + averageDurationMinutes: + participantsWithDuration.length > 0 + ? Math.round(totalDurationSeconds / participantsWithDuration.length / 60) + : 0, + }; + } +} diff --git a/src/modules/telemedicine/services/telemedicine-session.service.ts b/src/modules/telemedicine/services/telemedicine-session.service.ts new file mode 100644 index 0000000..b039f93 --- /dev/null +++ b/src/modules/telemedicine/services/telemedicine-session.service.ts @@ -0,0 +1,374 @@ +import { DataSource, Repository, Between, LessThanOrEqual, MoreThanOrEqual, In } from 'typeorm'; +import { + TelemedicineSession, + SessionStatus, + RecordingInfo, + VirtualRoom, + SessionParticipant, +} from '../entities'; +import { + CreateTelemedicineSessionDto, + UpdateTelemedicineSessionDto, + StartSessionDto, + EndSessionDto, + CancelSessionDto, + RecordingConsentDto, + TelemedicineSessionQueryDto, +} from '../dto'; + +export class TelemedicineSessionService { + private repository: Repository; + private roomRepository: Repository; + private participantRepository: Repository; + + constructor(dataSource: DataSource) { + this.repository = dataSource.getRepository(TelemedicineSession); + this.roomRepository = dataSource.getRepository(VirtualRoom); + this.participantRepository = dataSource.getRepository(SessionParticipant); + } + + async findAll( + tenantId: string, + query: TelemedicineSessionQueryDto + ): Promise<{ data: TelemedicineSession[]; total: number }> { + const { + virtualRoomId, + appointmentId, + patientId, + doctorId, + status, + dateFrom, + dateTo, + page = 1, + limit = 20, + } = query; + + const queryBuilder = this.repository + .createQueryBuilder('session') + .leftJoinAndSelect('session.virtualRoom', 'room') + .leftJoinAndSelect('session.participants', 'participants') + .where('session.tenant_id = :tenantId', { tenantId }); + + if (virtualRoomId) { + queryBuilder.andWhere('session.virtual_room_id = :virtualRoomId', { virtualRoomId }); + } + + if (appointmentId) { + queryBuilder.andWhere('session.appointment_id = :appointmentId', { appointmentId }); + } + + if (patientId) { + queryBuilder.andWhere('session.patient_id = :patientId', { patientId }); + } + + if (doctorId) { + queryBuilder.andWhere('session.doctor_id = :doctorId', { doctorId }); + } + + if (status) { + queryBuilder.andWhere('session.status = :status', { status }); + } + + if (dateFrom) { + queryBuilder.andWhere('session.scheduled_start >= :dateFrom', { dateFrom }); + } + + if (dateTo) { + queryBuilder.andWhere('session.scheduled_start <= :dateTo', { dateTo }); + } + + queryBuilder + .orderBy('session.scheduled_start', '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: ['virtualRoom', 'participants'], + }); + } + + async findByAppointment( + tenantId: string, + appointmentId: string + ): Promise { + return this.repository.findOne({ + where: { tenantId, appointmentId }, + relations: ['virtualRoom', 'participants'], + }); + } + + async findUpcomingSessions( + tenantId: string, + userId: string, + role: 'patient' | 'doctor' + ): Promise { + const where: any = { + tenantId, + status: In(['scheduled', 'waiting']), + }; + + if (role === 'patient') { + where.patientId = userId; + } else { + where.doctorId = userId; + } + + return this.repository.find({ + where, + relations: ['virtualRoom'], + order: { scheduledStart: 'ASC' }, + take: 10, + }); + } + + async create( + tenantId: string, + dto: CreateTelemedicineSessionDto, + createdBy?: string + ): Promise { + // Verify room exists + const room = await this.roomRepository.findOne({ + where: { id: dto.virtualRoomId, tenantId }, + }); + + if (!room) { + throw new Error('Virtual room not found'); + } + + const session = this.repository.create({ + ...dto, + tenantId, + scheduledStart: new Date(dto.scheduledStart), + scheduledDurationMinutes: dto.scheduledDurationMinutes || 30, + createdBy, + }); + + return this.repository.save(session); + } + + async update( + tenantId: string, + id: string, + dto: UpdateTelemedicineSessionDto + ): Promise { + const session = await this.findById(tenantId, id); + if (!session) return null; + + if (session.status === 'completed' || session.status === 'cancelled') { + throw new Error('Cannot update a completed or cancelled session'); + } + + if (dto.status) { + session.status = dto.status; + } + + if (dto.scheduledStart) { + session.scheduledStart = new Date(dto.scheduledStart); + } + + if (dto.scheduledDurationMinutes) { + session.scheduledDurationMinutes = dto.scheduledDurationMinutes; + } + + if (dto.metadata) { + session.metadata = { + ...session.metadata, + ...dto.metadata, + }; + } + + return this.repository.save(session); + } + + async startSession( + tenantId: string, + id: string, + dto: StartSessionDto + ): Promise { + const session = await this.findById(tenantId, id); + if (!session) return null; + + if (session.status !== 'scheduled' && session.status !== 'waiting') { + throw new Error('Session is not in a startable state'); + } + + session.status = 'in_progress'; + session.actualStart = new Date(); + + if (dto.enableRecording) { + session.recordingInfo = { + enabled: true, + consentGiven: false, + recordingStartedAt: new Date(), + }; + } + + // Update room status + await this.roomRepository.update( + { id: session.virtualRoomId }, + { status: 'in_use', actualStart: new Date() } + ); + + return this.repository.save(session); + } + + async endSession( + tenantId: string, + id: string, + dto: EndSessionDto + ): Promise { + const session = await this.findById(tenantId, id); + if (!session) return null; + + if (session.status !== 'in_progress') { + throw new Error('Session is not in progress'); + } + + const now = new Date(); + session.status = 'completed'; + session.actualEnd = now; + + if (session.actualStart) { + session.actualDurationSeconds = Math.floor( + (now.getTime() - session.actualStart.getTime()) / 1000 + ); + } + + if (session.recordingInfo?.enabled) { + session.recordingInfo = { + ...session.recordingInfo, + recordingEndedAt: now, + recordingDurationSeconds: session.actualDurationSeconds, + }; + } + + if (dto.notes) { + session.metadata = { + ...session.metadata, + notes: dto.notes, + }; + } + + if (dto.followUpRequired !== undefined) { + session.metadata = { + ...session.metadata, + followUpRequired: dto.followUpRequired, + }; + } + + // Update room status + await this.roomRepository.update( + { id: session.virtualRoomId }, + { status: 'available', actualEnd: new Date() } + ); + + // Update all connected participants + await this.participantRepository.update( + { sessionId: id, status: 'connected' }, + { status: 'left', leftAt: now } + ); + + return this.repository.save(session); + } + + async cancelSession( + tenantId: string, + id: string, + dto: CancelSessionDto + ): Promise { + const session = await this.findById(tenantId, id); + if (!session) return null; + + if (session.status === 'completed' || session.status === 'cancelled') { + throw new Error('Session is already completed or cancelled'); + } + + session.status = 'cancelled'; + session.cancelledAt = new Date(); + session.cancelledBy = dto.cancelledBy; + session.cancellationReason = dto.cancellationReason; + + // Update room status if it was in use + if (session.virtualRoom?.status === 'in_use') { + await this.roomRepository.update( + { id: session.virtualRoomId }, + { status: 'available' } + ); + } + + return this.repository.save(session); + } + + async setWaiting(tenantId: string, id: string): Promise { + const session = await this.findById(tenantId, id); + if (!session) return null; + + if (session.status !== 'scheduled') { + throw new Error('Session must be in scheduled state'); + } + + session.status = 'waiting'; + return this.repository.save(session); + } + + async updateRecordingConsent( + tenantId: string, + id: string, + dto: RecordingConsentDto + ): Promise { + const session = await this.findById(tenantId, id); + if (!session) return null; + + session.recordingInfo = { + ...session.recordingInfo, + enabled: session.recordingInfo?.enabled || false, + consentGiven: dto.consent, + consentGivenAt: dto.consent ? new Date() : undefined, + consentGivenBy: dto.consent ? dto.consentGivenBy : undefined, + }; + + return this.repository.save(session); + } + + async getSessionStats( + tenantId: string, + dateFrom: Date, + dateTo: Date + ): Promise<{ + totalSessions: number; + completedSessions: number; + cancelledSessions: number; + averageDurationMinutes: number; + }> { + const sessions = await this.repository.find({ + where: { + tenantId, + scheduledStart: Between(dateFrom, dateTo), + }, + }); + + const completedSessions = sessions.filter((s) => s.status === 'completed'); + const cancelledSessions = sessions.filter((s) => s.status === 'cancelled'); + + const totalDurationSeconds = completedSessions.reduce( + (sum, s) => sum + (s.actualDurationSeconds || 0), + 0 + ); + + return { + totalSessions: sessions.length, + completedSessions: completedSessions.length, + cancelledSessions: cancelledSessions.length, + averageDurationMinutes: + completedSessions.length > 0 + ? Math.round(totalDurationSeconds / completedSessions.length / 60) + : 0, + }; + } +} diff --git a/src/modules/telemedicine/services/virtual-room.service.ts b/src/modules/telemedicine/services/virtual-room.service.ts new file mode 100644 index 0000000..180147e --- /dev/null +++ b/src/modules/telemedicine/services/virtual-room.service.ts @@ -0,0 +1,217 @@ +import { DataSource, Repository, Between, LessThanOrEqual, MoreThanOrEqual } from 'typeorm'; +import { VirtualRoom, VirtualRoomStatus, RoomConfiguration, ProviderCredentials } from '../entities'; +import { CreateVirtualRoomDto, UpdateVirtualRoomDto, VirtualRoomQueryDto } from '../dto'; +import { v4 as uuidv4 } from 'uuid'; + +export class VirtualRoomService { + private repository: Repository; + + constructor(dataSource: DataSource) { + this.repository = dataSource.getRepository(VirtualRoom); + } + + private getDefaultConfiguration(): RoomConfiguration { + return { + maxParticipants: 10, + enableRecording: false, + enableChat: true, + enableScreenShare: true, + waitingRoomEnabled: true, + autoCloseOnEmpty: true, + maxDurationMinutes: 60, + }; + } + + async findAll( + tenantId: string, + query: VirtualRoomQueryDto + ): Promise<{ data: VirtualRoom[]; total: number }> { + const { appointmentId, status, dateFrom, dateTo, page = 1, limit = 20 } = query; + + const queryBuilder = this.repository + .createQueryBuilder('room') + .where('room.tenant_id = :tenantId', { tenantId }); + + if (appointmentId) { + queryBuilder.andWhere('room.appointment_id = :appointmentId', { appointmentId }); + } + + if (status) { + queryBuilder.andWhere('room.status = :status', { status }); + } + + if (dateFrom) { + queryBuilder.andWhere('room.scheduled_start >= :dateFrom', { dateFrom }); + } + + if (dateTo) { + queryBuilder.andWhere('room.scheduled_start <= :dateTo', { dateTo }); + } + + queryBuilder + .orderBy('room.scheduled_start', '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: ['sessions'], + }); + } + + async findByAppointment(tenantId: string, appointmentId: string): Promise { + return this.repository.findOne({ + where: { tenantId, appointmentId }, + }); + } + + async findAvailableRooms(tenantId: string): Promise { + return this.repository.find({ + where: { tenantId, status: 'available' }, + order: { name: 'ASC' }, + }); + } + + async create( + tenantId: string, + dto: CreateVirtualRoomDto, + createdBy?: string + ): Promise { + const configuration = { + ...this.getDefaultConfiguration(), + ...dto.configuration, + }; + + const room = this.repository.create({ + ...dto, + tenantId, + configuration, + scheduledStart: dto.scheduledStart ? new Date(dto.scheduledStart) : undefined, + scheduledEnd: dto.scheduledEnd ? new Date(dto.scheduledEnd) : undefined, + createdBy, + }); + + return this.repository.save(room); + } + + async update( + tenantId: string, + id: string, + dto: UpdateVirtualRoomDto + ): Promise { + const room = await this.findById(tenantId, id); + if (!room) return null; + + if (dto.name) { + room.name = dto.name; + } + + if (dto.description !== undefined) { + room.description = dto.description; + } + + if (dto.status) { + room.status = dto.status; + } + + if (dto.configuration && room.configuration) { + room.configuration = { + ...room.configuration, + ...dto.configuration, + } as RoomConfiguration; + } + + if (dto.scheduledStart) { + room.scheduledStart = new Date(dto.scheduledStart); + } + + if (dto.scheduledEnd) { + room.scheduledEnd = new Date(dto.scheduledEnd); + } + + return this.repository.save(room); + } + + async updateStatus( + tenantId: string, + id: string, + status: VirtualRoomStatus + ): Promise { + const room = await this.findById(tenantId, id); + if (!room) return null; + + room.status = status; + + if (status === 'in_use' && !room.actualStart) { + room.actualStart = new Date(); + } + + if (status === 'closed' && !room.actualEnd) { + room.actualEnd = new Date(); + } + + return this.repository.save(room); + } + + async generateProviderCredentials( + tenantId: string, + id: string + ): Promise { + const room = await this.findById(tenantId, id); + if (!room) return null; + + // This would integrate with actual video provider APIs + // For now, we generate placeholder credentials + const credentials: ProviderCredentials = { + roomId: `${room.videoProvider}-${room.id}`, + roomUrl: `https://${room.videoProvider}.example.com/rooms/${room.id}`, + hostToken: `host-${uuidv4()}`, + participantToken: `participant-${uuidv4()}`, + expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000), // 24 hours + }; + + room.providerCredentials = credentials; + await this.repository.save(room); + + return credentials; + } + + async openRoom(tenantId: string, id: string): Promise { + const room = await this.findById(tenantId, id); + if (!room) return null; + + room.status = 'available'; + room.actualStart = undefined; + room.actualEnd = undefined; + + return this.repository.save(room); + } + + async closeRoom(tenantId: string, id: string): Promise { + const room = await this.findById(tenantId, id); + if (!room) return null; + + room.status = 'closed'; + room.actualEnd = new Date(); + room.providerCredentials = undefined; + + return this.repository.save(room); + } + + async delete(tenantId: string, id: string): Promise { + const room = await this.findById(tenantId, id); + if (!room) return false; + + if (room.status === 'in_use') { + throw new Error('Cannot delete a room that is in use'); + } + + await this.repository.remove(room); + return true; + } +} diff --git a/src/modules/telemedicine/telemedicine.module.ts b/src/modules/telemedicine/telemedicine.module.ts new file mode 100644 index 0000000..9072dcc --- /dev/null +++ b/src/modules/telemedicine/telemedicine.module.ts @@ -0,0 +1,19 @@ +import { Router } from 'express'; +import { DataSource } from 'typeorm'; +import { TelemedicineController } from './controllers'; + +export interface TelemedicineModuleOptions { + dataSource: DataSource; + basePath?: string; +} + +export class TelemedicineModule { + public router: Router; + private controller: TelemedicineController; + + constructor(options: TelemedicineModuleOptions) { + const { dataSource, basePath = '/api' } = options; + this.controller = new TelemedicineController(dataSource, basePath); + this.router = this.controller.router; + } +}