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

View File

@ -0,0 +1 @@
export { TelemedicineController } from './telemedicine.controller';

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,3 @@
export { VirtualRoomService } from './virtual-room.service';
export { TelemedicineSessionService } from './telemedicine-session.service';
export { ParticipantService } from './participant.service';

View File

@ -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<SessionParticipant>;
private sessionRepository: Repository<TelemedicineSession>;
constructor(dataSource: DataSource) {
this.repository = dataSource.getRepository(SessionParticipant);
this.sessionRepository = dataSource.getRepository(TelemedicineSession);
}
async findBySession(tenantId: string, sessionId: string): Promise<SessionParticipant[]> {
return this.repository.find({
where: { tenantId, sessionId },
order: { role: 'ASC', displayName: 'ASC' },
});
}
async findById(tenantId: string, id: string): Promise<SessionParticipant | null> {
return this.repository.findOne({
where: { id, tenantId },
relations: ['session'],
});
}
async findByUserId(
tenantId: string,
sessionId: string,
userId: string
): Promise<SessionParticipant | null> {
return this.repository.findOne({
where: { tenantId, sessionId, userId },
});
}
async addParticipant(
tenantId: string,
sessionId: string,
dto: AddParticipantDto
): Promise<SessionParticipant> {
// 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<SessionParticipant[]> {
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<SessionParticipant | null> {
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<SessionParticipant | null> {
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<SessionParticipant | null> {
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<SessionParticipant | null> {
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<SessionParticipant | null> {
return this.updateStatus(tenantId, id, 'waiting');
}
async disconnect(tenantId: string, id: string): Promise<SessionParticipant | null> {
return this.updateStatus(tenantId, id, 'disconnected');
}
async updateRecordingConsent(
tenantId: string,
id: string,
dto: ParticipantConsentDto
): Promise<SessionParticipant | null> {
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<SessionParticipant | null> {
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<boolean> {
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<SessionParticipant[]> {
return this.repository.find({
where: { tenantId, sessionId, status: 'connected' },
});
}
async getWaitingParticipants(
tenantId: string,
sessionId: string
): Promise<SessionParticipant[]> {
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<SessionParticipant | null> {
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,
};
}
}

View File

@ -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<TelemedicineSession>;
private roomRepository: Repository<VirtualRoom>;
private participantRepository: Repository<SessionParticipant>;
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<TelemedicineSession | null> {
return this.repository.findOne({
where: { id, tenantId },
relations: ['virtualRoom', 'participants'],
});
}
async findByAppointment(
tenantId: string,
appointmentId: string
): Promise<TelemedicineSession | null> {
return this.repository.findOne({
where: { tenantId, appointmentId },
relations: ['virtualRoom', 'participants'],
});
}
async findUpcomingSessions(
tenantId: string,
userId: string,
role: 'patient' | 'doctor'
): Promise<TelemedicineSession[]> {
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<TelemedicineSession> {
// 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<TelemedicineSession | null> {
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<TelemedicineSession | null> {
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<TelemedicineSession | null> {
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<TelemedicineSession | null> {
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<TelemedicineSession | null> {
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<TelemedicineSession | null> {
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,
};
}
}

View File

@ -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<VirtualRoom>;
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<VirtualRoom | null> {
return this.repository.findOne({
where: { id, tenantId },
relations: ['sessions'],
});
}
async findByAppointment(tenantId: string, appointmentId: string): Promise<VirtualRoom | null> {
return this.repository.findOne({
where: { tenantId, appointmentId },
});
}
async findAvailableRooms(tenantId: string): Promise<VirtualRoom[]> {
return this.repository.find({
where: { tenantId, status: 'available' },
order: { name: 'ASC' },
});
}
async create(
tenantId: string,
dto: CreateVirtualRoomDto,
createdBy?: string
): Promise<VirtualRoom> {
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<VirtualRoom | null> {
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<VirtualRoom | null> {
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<ProviderCredentials | null> {
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<VirtualRoom | null> {
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<VirtualRoom | null> {
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<boolean> {
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;
}
}

View File

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