[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