[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:
parent
56ded676ae
commit
e4d915889a
1
src/modules/telemedicine/controllers/index.ts
Normal file
1
src/modules/telemedicine/controllers/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { TelemedicineController } from './telemedicine.controller';
|
||||
747
src/modules/telemedicine/controllers/telemedicine.controller.ts
Normal file
747
src/modules/telemedicine/controllers/telemedicine.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
428
src/modules/telemedicine/dto/index.ts
Normal file
428
src/modules/telemedicine/dto/index.ts
Normal 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[];
|
||||
}
|
||||
3
src/modules/telemedicine/entities/index.ts
Normal file
3
src/modules/telemedicine/entities/index.ts
Normal 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';
|
||||
128
src/modules/telemedicine/entities/session-participant.entity.ts
Normal file
128
src/modules/telemedicine/entities/session-participant.entity.ts
Normal 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;
|
||||
}
|
||||
114
src/modules/telemedicine/entities/telemedicine-session.entity.ts
Normal file
114
src/modules/telemedicine/entities/telemedicine-session.entity.ts
Normal 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;
|
||||
}
|
||||
97
src/modules/telemedicine/entities/virtual-room.entity.ts
Normal file
97
src/modules/telemedicine/entities/virtual-room.entity.ts
Normal 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;
|
||||
}
|
||||
20
src/modules/telemedicine/index.ts
Normal file
20
src/modules/telemedicine/index.ts
Normal 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';
|
||||
3
src/modules/telemedicine/services/index.ts
Normal file
3
src/modules/telemedicine/services/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export { VirtualRoomService } from './virtual-room.service';
|
||||
export { TelemedicineSessionService } from './telemedicine-session.service';
|
||||
export { ParticipantService } from './participant.service';
|
||||
347
src/modules/telemedicine/services/participant.service.ts
Normal file
347
src/modules/telemedicine/services/participant.service.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
217
src/modules/telemedicine/services/virtual-room.service.ts
Normal file
217
src/modules/telemedicine/services/virtual-room.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
19
src/modules/telemedicine/telemedicine.module.ts
Normal file
19
src/modules/telemedicine/telemedicine.module.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user