diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1900a45 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +dist/ +coverage/ +.env diff --git a/src/app.integration.ts b/src/app.integration.ts new file mode 100644 index 0000000..d1ef7b4 --- /dev/null +++ b/src/app.integration.ts @@ -0,0 +1,195 @@ +/** + * Application Integration - ERP Clinicas + * + * Integrates all clinical modules and configures the application + */ + +import express, { Express } from 'express'; +import { DataSource } from 'typeorm'; + +// Import modules +import { PatientsModule } from './modules/patients'; +import { AppointmentsModule } from './modules/appointments'; +import { ConsultationsModule } from './modules/consultations'; + +// Import entities from all modules for TypeORM +import { Patient } from './modules/patients/entities'; +import { Specialty, Doctor, Appointment, AppointmentSlot } from './modules/appointments/entities'; +import { + Consultation, + Diagnosis, + Prescription, + PrescriptionItem, + VitalSignsRecord, +} from './modules/consultations/entities'; + +/** + * Get all entities for TypeORM configuration + */ +export function getAllEntities() { + return [ + // Patients (CL-002) + Patient, + // Appointments (CL-003) + Specialty, + Doctor, + Appointment, + AppointmentSlot, + // Consultations (CL-004) + Consultation, + Diagnosis, + Prescription, + PrescriptionItem, + VitalSignsRecord, + ]; +} + +/** + * Module configuration options + */ +export interface ModuleOptions { + patients?: { enabled: boolean; basePath?: string }; + appointments?: { enabled: boolean; basePath?: string }; + consultations?: { enabled: boolean; basePath?: string }; +} + +/** + * Default module options + */ +const defaultModuleOptions: ModuleOptions = { + patients: { enabled: true, basePath: '/api' }, + appointments: { enabled: true, basePath: '/api' }, + consultations: { enabled: true, basePath: '/api' }, +}; + +/** + * Initialize and integrate all modules + */ +export function initializeModules( + app: Express, + dataSource: DataSource, + options: ModuleOptions = {} +): void { + const config = { ...defaultModuleOptions, ...options }; + + // Initialize Patients Module + if (config.patients?.enabled) { + const patientsModule = new PatientsModule({ + dataSource, + basePath: config.patients.basePath, + }); + app.use(patientsModule.router); + console.log('✅ Patients module initialized (CL-002)'); + } + + // Initialize Appointments Module + if (config.appointments?.enabled) { + const appointmentsModule = new AppointmentsModule({ + dataSource, + basePath: config.appointments.basePath, + }); + app.use(appointmentsModule.router); + console.log('✅ Appointments module initialized (CL-003)'); + } + + // Initialize Consultations Module + if (config.consultations?.enabled) { + const consultationsModule = new ConsultationsModule({ + dataSource, + basePath: config.consultations.basePath, + }); + app.use(consultationsModule.router); + console.log('✅ Consultations module initialized (CL-004)'); + } +} + +/** + * Create TypeORM DataSource configuration + */ +export function createDataSourceConfig(options: { + host: string; + port: number; + username: string; + password: string; + database: string; + ssl?: boolean; + logging?: boolean; +}) { + return { + type: 'postgres' as const, + host: options.host, + port: options.port, + username: options.username, + password: options.password, + database: options.database, + ssl: options.ssl ? { rejectUnauthorized: false } : false, + logging: options.logging ?? false, + entities: getAllEntities(), + synchronize: false, // Use migrations instead + migrations: ['src/migrations/*.ts'], + }; +} + +/** + * Example application setup + */ +export async function createApplication(dataSourceConfig: any): Promise { + // Create Express app + const app = express(); + + // Middleware + app.use(express.json()); + app.use(express.urlencoded({ extended: true })); + + // CORS middleware + app.use((req, res, next) => { + res.header('Access-Control-Allow-Origin', '*'); + res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, PATCH, OPTIONS'); + res.header('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Tenant-ID, X-User-ID'); + if (req.method === 'OPTIONS') { + res.sendStatus(200); + return; + } + next(); + }); + + // Initialize database + const dataSource = new DataSource(dataSourceConfig); + await dataSource.initialize(); + console.log('✅ Database connected'); + + // Initialize all modules + initializeModules(app, dataSource); + + // Health check endpoint + app.get('/health', (req, res) => { + res.json({ + status: 'healthy', + timestamp: new Date().toISOString(), + project: 'erp-clinicas', + modules: { + patients: true, + appointments: true, + consultations: true, + }, + }); + }); + + // Error handling middleware + app.use((err: any, req: express.Request, res: express.Response, next: express.NextFunction) => { + console.error('Error:', err); + res.status(err.status || 500).json({ + error: err.message || 'Internal Server Error', + code: err.code || 'INTERNAL_ERROR', + }); + }); + + return app; +} + +export default { + getAllEntities, + initializeModules, + createDataSourceConfig, + createApplication, +}; diff --git a/src/modules/appointments/appointments.module.ts b/src/modules/appointments/appointments.module.ts new file mode 100644 index 0000000..47060b1 --- /dev/null +++ b/src/modules/appointments/appointments.module.ts @@ -0,0 +1,19 @@ +import { Router } from 'express'; +import { DataSource } from 'typeorm'; +import { AppointmentController } from './controllers'; + +export interface AppointmentsModuleOptions { + dataSource: DataSource; + basePath?: string; +} + +export class AppointmentsModule { + public router: Router; + private controller: AppointmentController; + + constructor(options: AppointmentsModuleOptions) { + const { dataSource, basePath = '/api' } = options; + this.controller = new AppointmentController(dataSource, basePath); + this.router = this.controller.router; + } +} diff --git a/src/modules/appointments/controllers/index.ts b/src/modules/appointments/controllers/index.ts new file mode 100644 index 0000000..5d79c84 --- /dev/null +++ b/src/modules/appointments/controllers/index.ts @@ -0,0 +1,288 @@ +import { Router, Request, Response, NextFunction } from 'express'; +import { DataSource } from 'typeorm'; +import { SpecialtyService, DoctorService, AppointmentService, AppointmentSlotService } from '../services'; +import { + CreateSpecialtyDto, UpdateSpecialtyDto, + CreateDoctorDto, UpdateDoctorDto, + CreateAppointmentDto, UpdateAppointmentDto, AppointmentQueryDto, + ConfirmAppointmentDto, CancelAppointmentDto, + CreateAppointmentSlotDto, AvailableSlotsQueryDto, +} from '../dto'; + +export class AppointmentController { + public router: Router; + private specialtyService: SpecialtyService; + private doctorService: DoctorService; + private appointmentService: AppointmentService; + private slotService: AppointmentSlotService; + + constructor(dataSource: DataSource, basePath: string = '/api') { + this.router = Router(); + this.specialtyService = new SpecialtyService(dataSource); + this.doctorService = new DoctorService(dataSource); + this.appointmentService = new AppointmentService(dataSource); + this.slotService = new AppointmentSlotService(dataSource); + this.setupRoutes(basePath); + } + + private setupRoutes(basePath: string): void { + // Specialties + this.router.get(`${basePath}/specialties`, this.getSpecialties.bind(this)); + this.router.get(`${basePath}/specialties/:id`, this.getSpecialtyById.bind(this)); + this.router.post(`${basePath}/specialties`, this.createSpecialty.bind(this)); + this.router.patch(`${basePath}/specialties/:id`, this.updateSpecialty.bind(this)); + + // Doctors + this.router.get(`${basePath}/doctors`, this.getDoctors.bind(this)); + this.router.get(`${basePath}/doctors/:id`, this.getDoctorById.bind(this)); + this.router.get(`${basePath}/doctors/:id/schedule`, this.getDoctorSchedule.bind(this)); + this.router.post(`${basePath}/doctors`, this.createDoctor.bind(this)); + this.router.patch(`${basePath}/doctors/:id`, this.updateDoctor.bind(this)); + this.router.delete(`${basePath}/doctors/:id`, this.deleteDoctor.bind(this)); + + // Appointments + this.router.get(`${basePath}/appointments`, this.getAppointments.bind(this)); + this.router.get(`${basePath}/appointments/available-slots`, this.getAvailableSlots.bind(this)); + this.router.get(`${basePath}/appointments/:id`, this.getAppointmentById.bind(this)); + this.router.post(`${basePath}/appointments`, this.createAppointment.bind(this)); + this.router.patch(`${basePath}/appointments/:id`, this.updateAppointment.bind(this)); + this.router.post(`${basePath}/appointments/:id/confirm`, this.confirmAppointment.bind(this)); + this.router.post(`${basePath}/appointments/:id/cancel`, this.cancelAppointment.bind(this)); + this.router.post(`${basePath}/appointments/:id/check-in`, this.checkIn.bind(this)); + this.router.post(`${basePath}/appointments/:id/check-out`, this.checkOut.bind(this)); + + // Appointment Slots + this.router.post(`${basePath}/appointment-slots`, this.createSlot.bind(this)); + this.router.delete(`${basePath}/appointment-slots/:id`, this.deleteSlot.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; + } + + // Specialties + private async getSpecialties(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const data = await this.specialtyService.findAll(tenantId); + res.json({ data }); + } catch (error) { next(error); } + } + + private async getSpecialtyById(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const { id } = req.params; + const data = await this.specialtyService.findById(tenantId, id); + if (!data) { res.status(404).json({ error: 'Specialty not found' }); return; } + res.json({ data }); + } catch (error) { next(error); } + } + + private async createSpecialty(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const dto: CreateSpecialtyDto = req.body; + const data = await this.specialtyService.create(tenantId, dto); + res.status(201).json({ data }); + } catch (error) { next(error); } + } + + private async updateSpecialty(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const { id } = req.params; + const dto: UpdateSpecialtyDto = req.body; + const data = await this.specialtyService.update(tenantId, id, dto); + if (!data) { res.status(404).json({ error: 'Specialty not found' }); return; } + res.json({ data }); + } catch (error) { next(error); } + } + + // Doctors + private async getDoctors(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const specialtyId = req.query.specialtyId as string; + const data = await this.doctorService.findAll(tenantId, specialtyId); + res.json({ data }); + } catch (error) { next(error); } + } + + private async getDoctorById(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const { id } = req.params; + const data = await this.doctorService.findById(tenantId, id); + if (!data) { res.status(404).json({ error: 'Doctor not found' }); return; } + res.json({ data }); + } catch (error) { next(error); } + } + + private async getDoctorSchedule(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const { id } = req.params; + const slots = await this.slotService.findByDoctor(tenantId, id); + res.json({ data: slots }); + } catch (error) { next(error); } + } + + private async createDoctor(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const dto: CreateDoctorDto = req.body; + const data = await this.doctorService.create(tenantId, dto); + res.status(201).json({ data }); + } catch (error) { next(error); } + } + + private async updateDoctor(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const { id } = req.params; + const dto: UpdateDoctorDto = req.body; + const data = await this.doctorService.update(tenantId, id, dto); + if (!data) { res.status(404).json({ error: 'Doctor not found' }); return; } + res.json({ data }); + } catch (error) { next(error); } + } + + private async deleteDoctor(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const { id } = req.params; + const deleted = await this.doctorService.softDelete(tenantId, id); + if (!deleted) { res.status(404).json({ error: 'Doctor not found' }); return; } + res.status(204).send(); + } catch (error) { next(error); } + } + + // Appointments + private async getAppointments(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const query: AppointmentQueryDto = { + patientId: req.query.patientId as string, + doctorId: req.query.doctorId as string, + dateFrom: req.query.dateFrom as string, + dateTo: req.query.dateTo as string, + status: req.query.status as any, + page: req.query.page ? parseInt(req.query.page as string) : 1, + limit: req.query.limit ? parseInt(req.query.limit as string) : 20, + }; + const result = await this.appointmentService.findAll(tenantId, query); + res.json({ data: result.data, meta: { total: result.total, page: query.page, limit: query.limit } }); + } catch (error) { next(error); } + } + + private async getAvailableSlots(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const doctorId = req.query.doctorId as string; + const date = req.query.date as string; + if (!doctorId || !date) { res.status(400).json({ error: 'doctorId and date are required' }); return; } + const slots = await this.appointmentService.getAvailableSlots(tenantId, doctorId, date); + res.json({ data: slots }); + } catch (error) { next(error); } + } + + private async getAppointmentById(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const { id } = req.params; + const data = await this.appointmentService.findById(tenantId, id); + if (!data) { res.status(404).json({ error: 'Appointment not found' }); return; } + res.json({ data }); + } catch (error) { next(error); } + } + + private async createAppointment(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const userId = this.getUserId(req); + const dto: CreateAppointmentDto = req.body; + const data = await this.appointmentService.create(tenantId, dto, userId); + res.status(201).json({ data }); + } catch (error) { next(error); } + } + + private async updateAppointment(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const { id } = req.params; + const dto: UpdateAppointmentDto = req.body; + const data = await this.appointmentService.update(tenantId, id, dto); + if (!data) { res.status(404).json({ error: 'Appointment not found' }); return; } + res.json({ data }); + } catch (error) { next(error); } + } + + private async confirmAppointment(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const { id } = req.params; + const { confirmedBy }: ConfirmAppointmentDto = req.body; + const data = await this.appointmentService.confirm(tenantId, id, confirmedBy); + if (!data) { res.status(404).json({ error: 'Appointment not found' }); return; } + res.json({ data }); + } catch (error) { next(error); } + } + + private async cancelAppointment(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const { id } = req.params; + const { cancelledBy, cancellationReason }: CancelAppointmentDto = req.body; + const data = await this.appointmentService.cancel(tenantId, id, cancelledBy, cancellationReason); + if (!data) { res.status(404).json({ error: 'Appointment not found' }); return; } + res.json({ data }); + } catch (error) { next(error); } + } + + private async checkIn(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const { id } = req.params; + const data = await this.appointmentService.checkIn(tenantId, id); + if (!data) { res.status(404).json({ error: 'Appointment not found' }); return; } + res.json({ data }); + } catch (error) { next(error); } + } + + private async checkOut(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const { id } = req.params; + const data = await this.appointmentService.checkOut(tenantId, id); + if (!data) { res.status(404).json({ error: 'Appointment not found' }); return; } + res.json({ data }); + } catch (error) { next(error); } + } + + // Appointment Slots + private async createSlot(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const dto: CreateAppointmentSlotDto = req.body; + const data = await this.slotService.create(tenantId, dto); + res.status(201).json({ data }); + } catch (error) { next(error); } + } + + private async deleteSlot(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const { id } = req.params; + const deleted = await this.slotService.delete(tenantId, id); + if (!deleted) { res.status(404).json({ error: 'Slot not found' }); return; } + res.status(204).send(); + } catch (error) { next(error); } + } +} diff --git a/src/modules/appointments/dto/index.ts b/src/modules/appointments/dto/index.ts new file mode 100644 index 0000000..a3bb3d1 --- /dev/null +++ b/src/modules/appointments/dto/index.ts @@ -0,0 +1,286 @@ +import { IsString, IsOptional, IsUUID, IsEnum, IsBoolean, IsDateString, IsInt, Min, Max, MaxLength } from 'class-validator'; +import { AppointmentStatus } from '../entities'; + +// Specialty DTOs +export class CreateSpecialtyDto { + @IsString() + @MaxLength(20) + code: string; + + @IsString() + @MaxLength(100) + name: string; + + @IsOptional() + @IsString() + description?: string; + + @IsOptional() + @IsBoolean() + requiresReferral?: boolean; + + @IsOptional() + @IsInt() + @Min(15) + @Max(120) + consultationDurationMinutes?: number; +} + +export class UpdateSpecialtyDto { + @IsOptional() + @IsString() + @MaxLength(100) + name?: string; + + @IsOptional() + @IsString() + description?: string; + + @IsOptional() + @IsBoolean() + requiresReferral?: boolean; + + @IsOptional() + @IsInt() + @Min(15) + @Max(120) + consultationDurationMinutes?: number; + + @IsOptional() + @IsBoolean() + active?: boolean; +} + +// Doctor DTOs +export class CreateDoctorDto { + @IsString() + @MaxLength(100) + firstName: string; + + @IsString() + @MaxLength(100) + lastName: string; + + @IsOptional() + @IsString() + @MaxLength(255) + email?: string; + + @IsOptional() + @IsString() + @MaxLength(20) + phone?: string; + + @IsString() + @MaxLength(50) + professionalLicense: string; + + @IsOptional() + @IsString() + @MaxLength(50) + specialtyLicense?: string; + + @IsUUID() + specialtyId: string; + + @IsOptional() + @IsInt() + @Min(15) + @Max(120) + consultationDurationMinutes?: number; + + @IsOptional() + @IsInt() + @Min(1) + @Max(50) + maxAppointmentsPerDay?: number; + + @IsOptional() + @IsBoolean() + acceptsInsurance?: boolean; +} + +export class UpdateDoctorDto { + @IsOptional() + @IsString() + @MaxLength(100) + firstName?: string; + + @IsOptional() + @IsString() + @MaxLength(100) + lastName?: string; + + @IsOptional() + @IsString() + @MaxLength(255) + email?: string; + + @IsOptional() + @IsString() + @MaxLength(20) + phone?: string; + + @IsOptional() + @IsString() + @MaxLength(50) + specialtyLicense?: string; + + @IsOptional() + @IsUUID() + specialtyId?: string; + + @IsOptional() + @IsInt() + @Min(15) + @Max(120) + consultationDurationMinutes?: number; + + @IsOptional() + @IsInt() + @Min(1) + @Max(50) + maxAppointmentsPerDay?: number; + + @IsOptional() + @IsBoolean() + acceptsInsurance?: boolean; + + @IsOptional() + @IsBoolean() + active?: boolean; +} + +// Appointment DTOs +export class CreateAppointmentDto { + @IsUUID() + patientId: string; + + @IsUUID() + doctorId: string; + + @IsDateString() + scheduledDate: string; + + @IsString() + scheduledTime: string; // "HH:mm" format + + @IsOptional() + @IsInt() + @Min(15) + @Max(180) + durationMinutes?: number; + + @IsOptional() + @IsString() + reason?: string; + + @IsOptional() + @IsString() + notes?: string; +} + +export class UpdateAppointmentDto { + @IsOptional() + @IsDateString() + scheduledDate?: string; + + @IsOptional() + @IsString() + scheduledTime?: string; + + @IsOptional() + @IsInt() + @Min(15) + @Max(180) + durationMinutes?: number; + + @IsOptional() + @IsString() + reason?: string; + + @IsOptional() + @IsString() + notes?: string; +} + +export class ConfirmAppointmentDto { + @IsUUID() + confirmedBy: string; +} + +export class CancelAppointmentDto { + @IsUUID() + cancelledBy: string; + + @IsOptional() + @IsString() + cancellationReason?: string; +} + +export class AppointmentQueryDto { + @IsOptional() + @IsUUID() + patientId?: string; + + @IsOptional() + @IsUUID() + doctorId?: string; + + @IsOptional() + @IsDateString() + dateFrom?: string; + + @IsOptional() + @IsDateString() + dateTo?: string; + + @IsOptional() + @IsEnum(['scheduled', 'confirmed', 'in_progress', 'completed', 'cancelled', 'no_show']) + status?: AppointmentStatus; + + @IsOptional() + page?: number; + + @IsOptional() + limit?: number; +} + +// Appointment Slot DTOs +export class CreateAppointmentSlotDto { + @IsUUID() + doctorId: string; + + @IsInt() + @Min(0) + @Max(6) + dayOfWeek: number; + + @IsString() + startTime: string; // "HH:mm" + + @IsString() + endTime: string; // "HH:mm" + + @IsOptional() + @IsInt() + @Min(15) + @Max(120) + slotDurationMinutes?: number; + + @IsOptional() + @IsDateString() + validFrom?: string; + + @IsOptional() + @IsDateString() + validUntil?: string; +} + +export class AvailableSlotsQueryDto { + @IsUUID() + doctorId: string; + + @IsDateString() + date: string; +} diff --git a/src/modules/appointments/entities/appointment-slot.entity.ts b/src/modules/appointments/entities/appointment-slot.entity.ts new file mode 100644 index 0000000..664cbd5 --- /dev/null +++ b/src/modules/appointments/entities/appointment-slot.entity.ts @@ -0,0 +1,59 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { Doctor } from './doctor.entity'; + +@Entity({ name: 'appointment_slots', schema: 'clinica' }) +export class AppointmentSlot { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Index() + @Column({ name: 'doctor_id', type: 'uuid' }) + doctorId: string; + + @ManyToOne(() => Doctor) + @JoinColumn({ name: 'doctor_id' }) + doctor: Doctor; + + // Horario (0=domingo, 1=lunes, ..., 6=sabado) + @Index() + @Column({ name: 'day_of_week', type: 'integer' }) + dayOfWeek: number; + + @Column({ name: 'start_time', type: 'time' }) + startTime: string; + + @Column({ name: 'end_time', type: 'time' }) + endTime: string; + + @Column({ name: 'slot_duration_minutes', type: 'integer', default: 30 }) + slotDurationMinutes: number; + + // Control + @Column({ type: 'boolean', default: true }) + active: boolean; + + @Column({ name: 'valid_from', type: 'date', default: () => 'CURRENT_DATE' }) + validFrom: Date; + + @Column({ name: 'valid_until', type: 'date', nullable: true }) + validUntil?: Date; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; +} diff --git a/src/modules/appointments/entities/appointment.entity.ts b/src/modules/appointments/entities/appointment.entity.ts new file mode 100644 index 0000000..a2a5f57 --- /dev/null +++ b/src/modules/appointments/entities/appointment.entity.ts @@ -0,0 +1,93 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { Doctor } from './doctor.entity'; + +export type AppointmentStatus = 'scheduled' | 'confirmed' | 'in_progress' | 'completed' | 'cancelled' | 'no_show'; + +@Entity({ name: 'appointments', schema: 'clinica' }) +export class Appointment { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + // Relaciones + @Index() + @Column({ name: 'patient_id', type: 'uuid' }) + patientId: string; + + @Index() + @Column({ name: 'doctor_id', type: 'uuid' }) + doctorId: string; + + @ManyToOne(() => Doctor) + @JoinColumn({ name: 'doctor_id' }) + doctor: Doctor; + + // Programacion + @Index() + @Column({ name: 'scheduled_date', type: 'date' }) + scheduledDate: Date; + + @Column({ name: 'scheduled_time', type: 'time' }) + scheduledTime: string; + + @Column({ name: 'duration_minutes', type: 'integer', default: 30 }) + durationMinutes: number; + + // Estado + @Index() + @Column({ type: 'enum', enum: ['scheduled', 'confirmed', 'in_progress', 'completed', 'cancelled', 'no_show'], default: 'scheduled' }) + status: AppointmentStatus; + + // Detalles + @Column({ type: 'text', nullable: true }) + reason?: string; + + @Column({ type: 'text', nullable: true }) + notes?: string; + + // Confirmacion + @Column({ name: 'confirmed_at', type: 'timestamptz', nullable: true }) + confirmedAt?: Date; + + @Column({ name: 'confirmed_by', type: 'uuid', nullable: true }) + confirmedBy?: string; + + // Cancelacion + @Column({ name: 'cancelled_at', type: 'timestamptz', nullable: true }) + cancelledAt?: Date; + + @Column({ name: 'cancelled_by', type: 'uuid', nullable: true }) + cancelledBy?: string; + + @Column({ name: 'cancellation_reason', type: 'text', nullable: true }) + cancellationReason?: string; + + // Check-in/Check-out + @Column({ name: 'check_in_at', type: 'timestamptz', nullable: true }) + checkInAt?: Date; + + @Column({ name: 'check_out_at', type: 'timestamptz', nullable: true }) + checkOutAt?: Date; + + // Control + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdBy?: string; +} diff --git a/src/modules/appointments/entities/doctor.entity.ts b/src/modules/appointments/entities/doctor.entity.ts new file mode 100644 index 0000000..a4a69b8 --- /dev/null +++ b/src/modules/appointments/entities/doctor.entity.ts @@ -0,0 +1,80 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + DeleteDateColumn, + Index, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { Specialty } from './specialty.entity'; + +@Entity({ name: 'doctors', schema: 'clinica' }) +export class Doctor { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ name: 'employee_id', type: 'uuid', nullable: true }) + employeeId?: string; + + // Datos personales + @Column({ name: 'first_name', type: 'varchar', length: 100 }) + firstName: string; + + @Column({ name: 'last_name', type: 'varchar', length: 100 }) + lastName: string; + + @Column({ type: 'varchar', length: 255, nullable: true }) + email?: string; + + @Column({ type: 'varchar', length: 20, nullable: true }) + phone?: string; + + // Datos profesionales + @Index() + @Column({ name: 'professional_license', type: 'varchar', length: 50 }) + professionalLicense: string; + + @Column({ name: 'specialty_license', type: 'varchar', length: 50, nullable: true }) + specialtyLicense?: string; + + @Column({ name: 'specialty_id', type: 'uuid' }) + specialtyId: string; + + @ManyToOne(() => Specialty) + @JoinColumn({ name: 'specialty_id' }) + specialty: Specialty; + + // Configuracion + @Column({ name: 'consultation_duration_minutes', type: 'integer', default: 30 }) + consultationDurationMinutes: number; + + @Column({ name: 'max_appointments_per_day', type: 'integer', default: 20 }) + maxAppointmentsPerDay: number; + + @Column({ name: 'accepts_insurance', type: 'boolean', default: true }) + acceptsInsurance: boolean; + + // Control + @Column({ type: 'boolean', default: true }) + active: boolean; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + @DeleteDateColumn({ name: 'deleted_at', type: 'timestamptz', nullable: true }) + deletedAt?: Date; + + get fullName(): string { + return `Dr. ${this.firstName} ${this.lastName}`; + } +} diff --git a/src/modules/appointments/entities/index.ts b/src/modules/appointments/entities/index.ts new file mode 100644 index 0000000..92bc6ed --- /dev/null +++ b/src/modules/appointments/entities/index.ts @@ -0,0 +1,4 @@ +export { Specialty } from './specialty.entity'; +export { Doctor } from './doctor.entity'; +export { Appointment, AppointmentStatus } from './appointment.entity'; +export { AppointmentSlot } from './appointment-slot.entity'; diff --git a/src/modules/appointments/entities/specialty.entity.ts b/src/modules/appointments/entities/specialty.entity.ts new file mode 100644 index 0000000..b1e78b5 --- /dev/null +++ b/src/modules/appointments/entities/specialty.entity.ts @@ -0,0 +1,43 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + OneToMany, +} from 'typeorm'; + +@Entity({ name: 'specialties', schema: 'clinica' }) +export class Specialty { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ type: 'varchar', length: 20 }) + code: string; + + @Column({ type: 'varchar', length: 100 }) + name: string; + + @Column({ type: 'text', nullable: true }) + description?: string; + + @Column({ name: 'requires_referral', type: 'boolean', default: false }) + requiresReferral: boolean; + + @Column({ name: 'consultation_duration_minutes', type: 'integer', default: 30 }) + consultationDurationMinutes: number; + + @Column({ type: 'boolean', default: true }) + active: boolean; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; +} diff --git a/src/modules/appointments/index.ts b/src/modules/appointments/index.ts new file mode 100644 index 0000000..1a77385 --- /dev/null +++ b/src/modules/appointments/index.ts @@ -0,0 +1,5 @@ +export { AppointmentsModule, AppointmentsModuleOptions } from './appointments.module'; +export { Specialty, Doctor, Appointment, AppointmentStatus, AppointmentSlot } from './entities'; +export { SpecialtyService, DoctorService, AppointmentService, AppointmentSlotService } from './services'; +export { AppointmentController } from './controllers'; +export * from './dto'; diff --git a/src/modules/appointments/services/index.ts b/src/modules/appointments/services/index.ts new file mode 100644 index 0000000..1ed76b7 --- /dev/null +++ b/src/modules/appointments/services/index.ts @@ -0,0 +1,291 @@ +import { DataSource, Repository, Between, LessThanOrEqual, MoreThanOrEqual } from 'typeorm'; +import { Specialty, Doctor, Appointment, AppointmentSlot } from '../entities'; +import { + CreateSpecialtyDto, UpdateSpecialtyDto, + CreateDoctorDto, UpdateDoctorDto, + CreateAppointmentDto, UpdateAppointmentDto, AppointmentQueryDto, + CreateAppointmentSlotDto, AvailableSlotsQueryDto, +} from '../dto'; + +export class SpecialtyService { + private repository: Repository; + + constructor(dataSource: DataSource) { + this.repository = dataSource.getRepository(Specialty); + } + + async findAll(tenantId: string, activeOnly: boolean = true): Promise { + const where: any = { tenantId }; + if (activeOnly) where.active = true; + return this.repository.find({ where, order: { name: 'ASC' } }); + } + + async findById(tenantId: string, id: string): Promise { + return this.repository.findOne({ where: { id, tenantId } }); + } + + async create(tenantId: string, dto: CreateSpecialtyDto): Promise { + const specialty = this.repository.create({ ...dto, tenantId }); + return this.repository.save(specialty); + } + + async update(tenantId: string, id: string, dto: UpdateSpecialtyDto): Promise { + const specialty = await this.findById(tenantId, id); + if (!specialty) return null; + Object.assign(specialty, dto); + return this.repository.save(specialty); + } +} + +export class DoctorService { + private repository: Repository; + + constructor(dataSource: DataSource) { + this.repository = dataSource.getRepository(Doctor); + } + + async findAll(tenantId: string, specialtyId?: string): Promise { + const where: any = { tenantId, active: true }; + if (specialtyId) where.specialtyId = specialtyId; + + return this.repository.find({ + where, + relations: ['specialty'], + order: { lastName: 'ASC', firstName: 'ASC' }, + }); + } + + async findById(tenantId: string, id: string): Promise { + return this.repository.findOne({ + where: { id, tenantId }, + relations: ['specialty'], + }); + } + + async create(tenantId: string, dto: CreateDoctorDto): Promise { + const doctor = this.repository.create({ ...dto, tenantId }); + return this.repository.save(doctor); + } + + async update(tenantId: string, id: string, dto: UpdateDoctorDto): Promise { + const doctor = await this.findById(tenantId, id); + if (!doctor) return null; + Object.assign(doctor, dto); + return this.repository.save(doctor); + } + + async softDelete(tenantId: string, id: string): Promise { + const doctor = await this.findById(tenantId, id); + if (!doctor) return false; + await this.repository.softDelete(id); + return true; + } +} + +export class AppointmentService { + private repository: Repository; + private slotRepository: Repository; + + constructor(dataSource: DataSource) { + this.repository = dataSource.getRepository(Appointment); + this.slotRepository = dataSource.getRepository(AppointmentSlot); + } + + async findAll(tenantId: string, query: AppointmentQueryDto): Promise<{ data: Appointment[]; total: number }> { + const { patientId, doctorId, dateFrom, dateTo, status, page = 1, limit = 20 } = query; + + const queryBuilder = this.repository.createQueryBuilder('appointment') + .leftJoinAndSelect('appointment.doctor', 'doctor') + .where('appointment.tenant_id = :tenantId', { tenantId }); + + if (patientId) { + queryBuilder.andWhere('appointment.patient_id = :patientId', { patientId }); + } + + if (doctorId) { + queryBuilder.andWhere('appointment.doctor_id = :doctorId', { doctorId }); + } + + if (dateFrom) { + queryBuilder.andWhere('appointment.scheduled_date >= :dateFrom', { dateFrom }); + } + + if (dateTo) { + queryBuilder.andWhere('appointment.scheduled_date <= :dateTo', { dateTo }); + } + + if (status) { + queryBuilder.andWhere('appointment.status = :status', { status }); + } + + queryBuilder + .orderBy('appointment.scheduled_date', 'ASC') + .addOrderBy('appointment.scheduled_time', 'ASC') + .skip((page - 1) * limit) + .take(limit); + + const [data, total] = await queryBuilder.getManyAndCount(); + return { data, total }; + } + + async findById(tenantId: string, id: string): Promise { + return this.repository.findOne({ + where: { id, tenantId }, + relations: ['doctor'], + }); + } + + async create(tenantId: string, dto: CreateAppointmentDto, createdBy?: string): Promise { + const appointment = this.repository.create({ + ...dto, + tenantId, + scheduledDate: new Date(dto.scheduledDate), + createdBy, + }); + return this.repository.save(appointment); + } + + async update(tenantId: string, id: string, dto: UpdateAppointmentDto): Promise { + const appointment = await this.findById(tenantId, id); + if (!appointment) return null; + + const updateData: any = { ...dto }; + if (dto.scheduledDate) { + updateData.scheduledDate = new Date(dto.scheduledDate); + } + + Object.assign(appointment, updateData); + return this.repository.save(appointment); + } + + async confirm(tenantId: string, id: string, confirmedBy: string): Promise { + const appointment = await this.findById(tenantId, id); + if (!appointment) return null; + + appointment.status = 'confirmed'; + appointment.confirmedAt = new Date(); + appointment.confirmedBy = confirmedBy; + return this.repository.save(appointment); + } + + async cancel(tenantId: string, id: string, cancelledBy: string, reason?: string): Promise { + const appointment = await this.findById(tenantId, id); + if (!appointment) return null; + + appointment.status = 'cancelled'; + appointment.cancelledAt = new Date(); + appointment.cancelledBy = cancelledBy; + appointment.cancellationReason = reason; + return this.repository.save(appointment); + } + + async checkIn(tenantId: string, id: string): Promise { + const appointment = await this.findById(tenantId, id); + if (!appointment) return null; + + appointment.status = 'in_progress'; + appointment.checkInAt = new Date(); + return this.repository.save(appointment); + } + + async checkOut(tenantId: string, id: string): Promise { + const appointment = await this.findById(tenantId, id); + if (!appointment) return null; + + appointment.status = 'completed'; + appointment.checkOutAt = new Date(); + return this.repository.save(appointment); + } + + async getAvailableSlots(tenantId: string, doctorId: string, date: string): Promise { + const targetDate = new Date(date); + const dayOfWeek = targetDate.getDay(); + + // Obtener slots configurados para ese dia + const slots = await this.slotRepository.find({ + where: { + tenantId, + doctorId, + dayOfWeek, + active: true, + }, + }); + + if (slots.length === 0) return []; + + // Obtener citas existentes para ese dia + const existingAppointments = await this.repository.find({ + where: { + tenantId, + doctorId, + scheduledDate: targetDate, + status: 'cancelled', + }, + }); + + const bookedTimes = new Set( + existingAppointments + .filter(a => a.status !== 'cancelled') + .map(a => a.scheduledTime) + ); + + // Generar slots disponibles + const availableSlots: string[] = []; + + for (const slot of slots) { + const [startHour, startMin] = slot.startTime.split(':').map(Number); + const [endHour, endMin] = slot.endTime.split(':').map(Number); + + let currentHour = startHour; + let currentMin = startMin; + + while (currentHour * 60 + currentMin < endHour * 60 + endMin) { + const timeStr = `${currentHour.toString().padStart(2, '0')}:${currentMin.toString().padStart(2, '0')}`; + + if (!bookedTimes.has(timeStr)) { + availableSlots.push(timeStr); + } + + currentMin += slot.slotDurationMinutes; + if (currentMin >= 60) { + currentHour += Math.floor(currentMin / 60); + currentMin = currentMin % 60; + } + } + } + + return availableSlots.sort(); + } +} + +export class AppointmentSlotService { + private repository: Repository; + + constructor(dataSource: DataSource) { + this.repository = dataSource.getRepository(AppointmentSlot); + } + + async findByDoctor(tenantId: string, doctorId: string): Promise { + return this.repository.find({ + where: { tenantId, doctorId, active: true }, + order: { dayOfWeek: 'ASC', startTime: 'ASC' }, + }); + } + + async create(tenantId: string, dto: CreateAppointmentSlotDto): Promise { + const slot = this.repository.create({ + ...dto, + tenantId, + validFrom: dto.validFrom ? new Date(dto.validFrom) : new Date(), + validUntil: dto.validUntil ? new Date(dto.validUntil) : undefined, + }); + return this.repository.save(slot); + } + + async delete(tenantId: string, id: string): Promise { + const slot = await this.repository.findOne({ where: { id, tenantId } }); + if (!slot) return false; + await this.repository.remove(slot); + return true; + } +} diff --git a/src/modules/consultations/consultations.module.ts b/src/modules/consultations/consultations.module.ts new file mode 100644 index 0000000..67c9cb2 --- /dev/null +++ b/src/modules/consultations/consultations.module.ts @@ -0,0 +1,19 @@ +import { Router } from 'express'; +import { DataSource } from 'typeorm'; +import { ConsultationController } from './controllers'; + +export interface ConsultationsModuleOptions { + dataSource: DataSource; + basePath?: string; +} + +export class ConsultationsModule { + public router: Router; + private controller: ConsultationController; + + constructor(options: ConsultationsModuleOptions) { + const { dataSource, basePath = '/api' } = options; + this.controller = new ConsultationController(dataSource, basePath); + this.router = this.controller.router; + } +} diff --git a/src/modules/consultations/controllers/index.ts b/src/modules/consultations/controllers/index.ts new file mode 100644 index 0000000..66c8792 --- /dev/null +++ b/src/modules/consultations/controllers/index.ts @@ -0,0 +1,216 @@ +import { Router, Request, Response, NextFunction } from 'express'; +import { DataSource } from 'typeorm'; +import { ConsultationService, DiagnosisService, PrescriptionService, VitalSignsService } from '../services'; +import { + CreateConsultationDto, UpdateConsultationDto, CompleteConsultationDto, ConsultationQueryDto, + CreateDiagnosisDto, CreatePrescriptionDto, CreateVitalSignsRecordDto, +} from '../dto'; + +export class ConsultationController { + public router: Router; + private consultationService: ConsultationService; + private diagnosisService: DiagnosisService; + private prescriptionService: PrescriptionService; + private vitalSignsService: VitalSignsService; + + constructor(dataSource: DataSource, basePath: string = '/api') { + this.router = Router(); + this.consultationService = new ConsultationService(dataSource); + this.diagnosisService = new DiagnosisService(dataSource); + this.prescriptionService = new PrescriptionService(dataSource); + this.vitalSignsService = new VitalSignsService(dataSource); + this.setupRoutes(basePath); + } + + private setupRoutes(basePath: string): void { + // Consultations + this.router.get(`${basePath}/consultations`, this.getConsultations.bind(this)); + this.router.get(`${basePath}/consultations/:id`, this.getConsultationById.bind(this)); + this.router.get(`${basePath}/consultations/:id/diagnoses`, this.getConsultationDiagnoses.bind(this)); + this.router.get(`${basePath}/consultations/:id/prescriptions`, this.getConsultationPrescriptions.bind(this)); + this.router.post(`${basePath}/consultations`, this.createConsultation.bind(this)); + this.router.patch(`${basePath}/consultations/:id`, this.updateConsultation.bind(this)); + this.router.post(`${basePath}/consultations/:id/complete`, this.completeConsultation.bind(this)); + this.router.post(`${basePath}/consultations/:id/cancel`, this.cancelConsultation.bind(this)); + + // Diagnoses + this.router.post(`${basePath}/diagnoses`, this.createDiagnosis.bind(this)); + this.router.delete(`${basePath}/diagnoses/:id`, this.deleteDiagnosis.bind(this)); + + // Prescriptions + this.router.get(`${basePath}/prescriptions/:id`, this.getPrescriptionById.bind(this)); + this.router.get(`${basePath}/patients/:patientId/prescriptions`, this.getPatientPrescriptions.bind(this)); + this.router.post(`${basePath}/prescriptions`, this.createPrescription.bind(this)); + + // Vital Signs + this.router.get(`${basePath}/patients/:patientId/vital-signs`, this.getPatientVitalSigns.bind(this)); + this.router.post(`${basePath}/vital-signs`, this.createVitalSigns.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; + } + + // Consultations + private async getConsultations(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const query: ConsultationQueryDto = { + patientId: req.query.patientId as string, + doctorId: req.query.doctorId as string, + dateFrom: req.query.dateFrom as string, + dateTo: req.query.dateTo as string, + status: req.query.status as any, + page: req.query.page ? parseInt(req.query.page as string) : 1, + limit: req.query.limit ? parseInt(req.query.limit as string) : 20, + }; + const result = await this.consultationService.findAll(tenantId, query); + res.json({ data: result.data, meta: { total: result.total, page: query.page, limit: query.limit } }); + } catch (error) { next(error); } + } + + private async getConsultationById(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const { id } = req.params; + const data = await this.consultationService.findById(tenantId, id); + if (!data) { res.status(404).json({ error: 'Consultation not found' }); return; } + res.json({ data }); + } catch (error) { next(error); } + } + + private async getConsultationDiagnoses(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const { id } = req.params; + const data = await this.diagnosisService.findByConsultation(tenantId, id); + res.json({ data }); + } catch (error) { next(error); } + } + + private async getConsultationPrescriptions(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const { id } = req.params; + const data = await this.prescriptionService.findByConsultation(tenantId, id); + res.json({ data }); + } catch (error) { next(error); } + } + + private async createConsultation(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const dto: CreateConsultationDto = req.body; + const data = await this.consultationService.create(tenantId, dto); + res.status(201).json({ data }); + } catch (error) { next(error); } + } + + private async updateConsultation(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const { id } = req.params; + const dto: UpdateConsultationDto = req.body; + const data = await this.consultationService.update(tenantId, id, dto); + if (!data) { res.status(404).json({ error: 'Consultation not found' }); return; } + res.json({ data }); + } catch (error) { next(error); } + } + + private async completeConsultation(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const { id } = req.params; + const dto: CompleteConsultationDto = req.body; + const data = await this.consultationService.complete(tenantId, id, dto); + if (!data) { res.status(404).json({ error: 'Consultation not found' }); return; } + res.json({ data }); + } catch (error) { next(error); } + } + + private async cancelConsultation(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const { id } = req.params; + const data = await this.consultationService.cancel(tenantId, id); + if (!data) { res.status(404).json({ error: 'Consultation not found' }); return; } + res.json({ data }); + } catch (error) { next(error); } + } + + // Diagnoses + private async createDiagnosis(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const dto: CreateDiagnosisDto = req.body; + const data = await this.diagnosisService.create(tenantId, dto); + res.status(201).json({ data }); + } catch (error) { next(error); } + } + + private async deleteDiagnosis(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const { id } = req.params; + const deleted = await this.diagnosisService.delete(tenantId, id); + if (!deleted) { res.status(404).json({ error: 'Diagnosis not found' }); return; } + res.status(204).send(); + } catch (error) { next(error); } + } + + // Prescriptions + private async getPrescriptionById(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const { id } = req.params; + const data = await this.prescriptionService.findById(tenantId, id); + if (!data) { res.status(404).json({ error: 'Prescription not found' }); return; } + res.json({ data }); + } catch (error) { next(error); } + } + + private async getPatientPrescriptions(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const { patientId } = req.params; + const data = await this.prescriptionService.findByPatient(tenantId, patientId); + res.json({ data }); + } catch (error) { next(error); } + } + + private async createPrescription(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const dto: CreatePrescriptionDto = req.body; + const data = await this.prescriptionService.create(tenantId, dto); + res.status(201).json({ data }); + } catch (error) { next(error); } + } + + // Vital Signs + private async getPatientVitalSigns(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const { patientId } = req.params; + const limit = req.query.limit ? parseInt(req.query.limit as string) : 10; + const data = await this.vitalSignsService.findByPatient(tenantId, patientId, limit); + res.json({ data }); + } catch (error) { next(error); } + } + + private async createVitalSigns(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const userId = this.getUserId(req); + const dto: CreateVitalSignsRecordDto = req.body; + const data = await this.vitalSignsService.create(tenantId, dto, userId); + res.status(201).json({ data }); + } catch (error) { next(error); } + } +} diff --git a/src/modules/consultations/dto/index.ts b/src/modules/consultations/dto/index.ts new file mode 100644 index 0000000..1fc9b8d --- /dev/null +++ b/src/modules/consultations/dto/index.ts @@ -0,0 +1,283 @@ +import { IsString, IsOptional, IsUUID, IsEnum, IsBoolean, IsDateString, IsInt, IsNumber, IsArray, ValidateNested, MaxLength, Min, Max } from 'class-validator'; +import { Type } from 'class-transformer'; +import { ConsultationStatus, DiagnosisType, PrescriptionStatus, VitalSigns } from '../entities'; + +// Vital Signs DTO +export class VitalSignsDto implements VitalSigns { + @IsOptional() + @IsNumber() + @Min(0) + @Max(500) + weight_kg?: number; + + @IsOptional() + @IsNumber() + @Min(0) + @Max(300) + height_cm?: number; + + @IsOptional() + @IsNumber() + @Min(30) + @Max(45) + temperature?: number; + + @IsOptional() + @IsString() + blood_pressure?: string; + + @IsOptional() + @IsInt() + @Min(0) + @Max(300) + heart_rate?: number; + + @IsOptional() + @IsInt() + @Min(0) + @Max(100) + respiratory_rate?: number; + + @IsOptional() + @IsInt() + @Min(0) + @Max(100) + oxygen_saturation?: number; +} + +// Consultation DTOs +export class CreateConsultationDto { + @IsUUID() + patientId: string; + + @IsUUID() + doctorId: string; + + @IsOptional() + @IsUUID() + appointmentId?: string; + + @IsOptional() + @IsString() + chiefComplaint?: string; + + @IsOptional() + @ValidateNested() + @Type(() => VitalSignsDto) + vitalSigns?: VitalSignsDto; +} + +export class UpdateConsultationDto { + @IsOptional() + @IsString() + chiefComplaint?: string; + + @IsOptional() + @IsString() + presentIllness?: string; + + @IsOptional() + @IsString() + physicalExamination?: string; + + @IsOptional() + @IsString() + assessment?: string; + + @IsOptional() + @IsString() + plan?: string; + + @IsOptional() + @ValidateNested() + @Type(() => VitalSignsDto) + vitalSigns?: VitalSignsDto; +} + +export class CompleteConsultationDto { + @IsOptional() + @IsString() + physicalExamination?: string; + + @IsOptional() + @IsString() + assessment?: string; + + @IsOptional() + @IsString() + plan?: string; +} + +export class ConsultationQueryDto { + @IsOptional() + @IsUUID() + patientId?: string; + + @IsOptional() + @IsUUID() + doctorId?: string; + + @IsOptional() + @IsDateString() + dateFrom?: string; + + @IsOptional() + @IsDateString() + dateTo?: string; + + @IsOptional() + @IsEnum(['in_progress', 'completed', 'cancelled']) + status?: ConsultationStatus; + + @IsOptional() + page?: number; + + @IsOptional() + limit?: number; +} + +// Diagnosis DTOs +export class CreateDiagnosisDto { + @IsUUID() + consultationId: string; + + @IsOptional() + @IsString() + @MaxLength(10) + icd10Code?: string; + + @IsString() + description: string; + + @IsOptional() + @IsBoolean() + isPrimary?: boolean; + + @IsOptional() + @IsEnum(['presumptive', 'definitive', 'differential']) + diagnosisType?: DiagnosisType; +} + +// Prescription DTOs +export class PrescriptionItemDto { + @IsString() + @MaxLength(200) + medicationName: string; + + @IsOptional() + @IsString() + @MaxLength(50) + medicationCode?: string; + + @IsString() + @MaxLength(100) + dosage: string; + + @IsString() + @MaxLength(100) + frequency: string; + + @IsOptional() + @IsString() + @MaxLength(100) + duration?: string; + + @IsOptional() + @IsInt() + @Min(1) + quantity?: number; + + @IsOptional() + @IsString() + instructions?: string; +} + +export class CreatePrescriptionDto { + @IsUUID() + consultationId: string; + + @IsUUID() + patientId: string; + + @IsUUID() + doctorId: string; + + @IsOptional() + @IsString() + instructions?: string; + + @IsOptional() + @IsString() + notes?: string; + + @IsArray() + @ValidateNested({ each: true }) + @Type(() => PrescriptionItemDto) + items: PrescriptionItemDto[]; +} + +// Vital Signs Record DTOs +export class CreateVitalSignsRecordDto { + @IsUUID() + patientId: string; + + @IsOptional() + @IsUUID() + consultationId?: string; + + @IsOptional() + @IsNumber() + @Min(0) + @Max(500) + weightKg?: number; + + @IsOptional() + @IsNumber() + @Min(0) + @Max(300) + heightCm?: number; + + @IsOptional() + @IsNumber() + @Min(30) + @Max(45) + temperatureCelsius?: number; + + @IsOptional() + @IsInt() + @Min(50) + @Max(250) + bloodPressureSystolic?: number; + + @IsOptional() + @IsInt() + @Min(30) + @Max(150) + bloodPressureDiastolic?: number; + + @IsOptional() + @IsInt() + @Min(30) + @Max(250) + heartRate?: number; + + @IsOptional() + @IsInt() + @Min(5) + @Max(60) + respiratoryRate?: number; + + @IsOptional() + @IsInt() + @Min(50) + @Max(100) + oxygenSaturation?: number; + + @IsOptional() + @IsNumber() + glucoseMgDl?: number; + + @IsOptional() + @IsString() + notes?: string; +} diff --git a/src/modules/consultations/entities/consultation.entity.ts b/src/modules/consultations/entities/consultation.entity.ts new file mode 100644 index 0000000..f3dcee5 --- /dev/null +++ b/src/modules/consultations/entities/consultation.entity.ts @@ -0,0 +1,80 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + OneToMany, +} from 'typeorm'; + +export type ConsultationStatus = 'in_progress' | 'completed' | 'cancelled'; + +export interface VitalSigns { + weight_kg?: number; + height_cm?: number; + temperature?: number; + blood_pressure?: string; // "120/80" + heart_rate?: number; + respiratory_rate?: number; + oxygen_saturation?: number; +} + +@Entity({ name: 'consultations', schema: 'clinica' }) +export class Consultation { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + // Relaciones + @Index() + @Column({ name: 'patient_id', type: 'uuid' }) + patientId: string; + + @Index() + @Column({ name: 'doctor_id', type: 'uuid' }) + doctorId: string; + + @Column({ name: 'appointment_id', type: 'uuid', nullable: true }) + appointmentId?: string; + + // Tiempo + @Column({ name: 'started_at', type: 'timestamptz', default: () => 'NOW()' }) + startedAt: Date; + + @Column({ name: 'ended_at', type: 'timestamptz', nullable: true }) + endedAt?: Date; + + // Contenido clinico + @Column({ name: 'chief_complaint', type: 'text', nullable: true }) + chiefComplaint?: string; + + @Column({ name: 'present_illness', type: 'text', nullable: true }) + presentIllness?: string; + + @Column({ name: 'physical_examination', type: 'text', nullable: true }) + physicalExamination?: string; + + @Column({ type: 'text', nullable: true }) + assessment?: string; + + @Column({ type: 'text', nullable: true }) + plan?: string; + + // Signos vitales (snapshot) + @Column({ name: 'vital_signs', type: 'jsonb', nullable: true }) + vitalSigns?: VitalSigns; + + // Estado + @Column({ type: 'enum', enum: ['in_progress', 'completed', 'cancelled'], default: 'in_progress' }) + status: ConsultationStatus; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; +} diff --git a/src/modules/consultations/entities/diagnosis.entity.ts b/src/modules/consultations/entities/diagnosis.entity.ts new file mode 100644 index 0000000..b952d70 --- /dev/null +++ b/src/modules/consultations/entities/diagnosis.entity.ts @@ -0,0 +1,47 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + Index, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { Consultation } from './consultation.entity'; + +export type DiagnosisType = 'presumptive' | 'definitive' | 'differential'; + +@Entity({ name: 'diagnoses', schema: 'clinica' }) +export class Diagnosis { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Index() + @Column({ name: 'consultation_id', type: 'uuid' }) + consultationId: string; + + @ManyToOne(() => Consultation, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'consultation_id' }) + consultation: Consultation; + + // Diagnostico CIE-10 + @Index() + @Column({ name: 'icd10_code', type: 'varchar', length: 10, nullable: true }) + icd10Code?: string; + + @Column({ type: 'text' }) + description: string; + + @Column({ name: 'is_primary', type: 'boolean', default: false }) + isPrimary: boolean; + + @Column({ name: 'diagnosis_type', type: 'varchar', length: 20, default: 'definitive' }) + diagnosisType: DiagnosisType; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; +} diff --git a/src/modules/consultations/entities/index.ts b/src/modules/consultations/entities/index.ts new file mode 100644 index 0000000..2f59d29 --- /dev/null +++ b/src/modules/consultations/entities/index.ts @@ -0,0 +1,5 @@ +export { Consultation, ConsultationStatus, VitalSigns } from './consultation.entity'; +export { Diagnosis, DiagnosisType } from './diagnosis.entity'; +export { Prescription, PrescriptionStatus } from './prescription.entity'; +export { PrescriptionItem } from './prescription-item.entity'; +export { VitalSignsRecord } from './vital-signs.entity'; diff --git a/src/modules/consultations/entities/prescription-item.entity.ts b/src/modules/consultations/entities/prescription-item.entity.ts new file mode 100644 index 0000000..5938d3a --- /dev/null +++ b/src/modules/consultations/entities/prescription-item.entity.ts @@ -0,0 +1,58 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + Index, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { Prescription } from './prescription.entity'; + +@Entity({ name: 'prescription_items', schema: 'clinica' }) +export class PrescriptionItem { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Index() + @Column({ name: 'prescription_id', type: 'uuid' }) + prescriptionId: string; + + @ManyToOne(() => Prescription, prescription => prescription.items, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'prescription_id' }) + prescription: Prescription; + + // Medicamento + @Column({ name: 'medication_name', type: 'varchar', length: 200 }) + medicationName: string; + + @Column({ name: 'medication_code', type: 'varchar', length: 50, nullable: true }) + medicationCode?: string; + + // Dosificacion + @Column({ type: 'varchar', length: 100 }) + dosage: string; + + @Column({ type: 'varchar', length: 100 }) + frequency: string; + + @Column({ type: 'varchar', length: 100, nullable: true }) + duration?: string; + + @Column({ type: 'integer', nullable: true }) + quantity?: number; + + // Instrucciones + @Column({ type: 'text', nullable: true }) + instructions?: string; + + @Column({ type: 'integer', default: 1 }) + sequence: number; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; +} diff --git a/src/modules/consultations/entities/prescription.entity.ts b/src/modules/consultations/entities/prescription.entity.ts new file mode 100644 index 0000000..8d13d72 --- /dev/null +++ b/src/modules/consultations/entities/prescription.entity.ts @@ -0,0 +1,60 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + OneToMany, +} from 'typeorm'; +import { PrescriptionItem } from './prescription-item.entity'; + +export type PrescriptionStatus = 'active' | 'completed' | 'cancelled'; + +@Entity({ name: 'prescriptions', schema: 'clinica' }) +export class Prescription { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Index() + @Column({ name: 'consultation_id', type: 'uuid' }) + consultationId: string; + + @Index() + @Column({ name: 'patient_id', type: 'uuid' }) + patientId: string; + + @Column({ name: 'doctor_id', type: 'uuid' }) + doctorId: string; + + // Datos de la receta + @Column({ name: 'prescription_number', type: 'varchar', length: 50, nullable: true }) + prescriptionNumber?: string; + + @Column({ name: 'prescription_date', type: 'date', default: () => 'CURRENT_DATE' }) + prescriptionDate: Date; + + // Estado + @Column({ type: 'enum', enum: ['active', 'completed', 'cancelled'], default: 'active' }) + status: PrescriptionStatus; + + // Notas + @Column({ type: 'text', nullable: true }) + instructions?: string; + + @Column({ type: 'text', nullable: true }) + notes?: string; + + @OneToMany(() => PrescriptionItem, item => item.prescription) + items: PrescriptionItem[]; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; +} diff --git a/src/modules/consultations/entities/vital-signs.entity.ts b/src/modules/consultations/entities/vital-signs.entity.ts new file mode 100644 index 0000000..b0180c4 --- /dev/null +++ b/src/modules/consultations/entities/vital-signs.entity.ts @@ -0,0 +1,61 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + Index, +} from 'typeorm'; + +@Entity({ name: 'vital_signs', schema: 'clinica' }) +export class VitalSignsRecord { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Index() + @Column({ name: 'patient_id', type: 'uuid' }) + patientId: string; + + @Column({ name: 'consultation_id', type: 'uuid', nullable: true }) + consultationId?: string; + + @Column({ name: 'recorded_by', type: 'uuid', nullable: true }) + recordedBy?: string; + + // Mediciones + @Column({ name: 'weight_kg', type: 'numeric', precision: 5, scale: 2, nullable: true }) + weightKg?: number; + + @Column({ name: 'height_cm', type: 'numeric', precision: 5, scale: 2, nullable: true }) + heightCm?: number; + + @Column({ name: 'temperature_celsius', type: 'numeric', precision: 4, scale: 1, nullable: true }) + temperatureCelsius?: number; + + @Column({ name: 'blood_pressure_systolic', type: 'integer', nullable: true }) + bloodPressureSystolic?: number; + + @Column({ name: 'blood_pressure_diastolic', type: 'integer', nullable: true }) + bloodPressureDiastolic?: number; + + @Column({ name: 'heart_rate', type: 'integer', nullable: true }) + heartRate?: number; + + @Column({ name: 'respiratory_rate', type: 'integer', nullable: true }) + respiratoryRate?: number; + + @Column({ name: 'oxygen_saturation', type: 'integer', nullable: true }) + oxygenSaturation?: number; + + @Column({ name: 'glucose_mg_dl', type: 'numeric', precision: 5, scale: 1, nullable: true }) + glucoseMgDl?: number; + + @Column({ type: 'text', nullable: true }) + notes?: string; + + @Index() + @Column({ name: 'recorded_at', type: 'timestamptz', default: () => 'NOW()' }) + recordedAt: Date; +} diff --git a/src/modules/consultations/index.ts b/src/modules/consultations/index.ts new file mode 100644 index 0000000..63c3c08 --- /dev/null +++ b/src/modules/consultations/index.ts @@ -0,0 +1,11 @@ +export { ConsultationsModule, ConsultationsModuleOptions } from './consultations.module'; +export { + Consultation, ConsultationStatus, VitalSigns, + Diagnosis, DiagnosisType, + Prescription, PrescriptionStatus, + PrescriptionItem, + VitalSignsRecord, +} from './entities'; +export { ConsultationService, DiagnosisService, PrescriptionService, VitalSignsService } from './services'; +export { ConsultationController } from './controllers'; +export * from './dto'; diff --git a/src/modules/consultations/services/index.ts b/src/modules/consultations/services/index.ts new file mode 100644 index 0000000..61e5950 --- /dev/null +++ b/src/modules/consultations/services/index.ts @@ -0,0 +1,204 @@ +import { DataSource, Repository } from 'typeorm'; +import { Consultation, Diagnosis, Prescription, PrescriptionItem, VitalSignsRecord } from '../entities'; +import { + CreateConsultationDto, UpdateConsultationDto, CompleteConsultationDto, ConsultationQueryDto, + CreateDiagnosisDto, CreatePrescriptionDto, CreateVitalSignsRecordDto, +} from '../dto'; + +export class ConsultationService { + private repository: Repository; + + constructor(dataSource: DataSource) { + this.repository = dataSource.getRepository(Consultation); + } + + async findAll(tenantId: string, query: ConsultationQueryDto): Promise<{ data: Consultation[]; total: number }> { + const { patientId, doctorId, dateFrom, dateTo, status, page = 1, limit = 20 } = query; + + const queryBuilder = this.repository.createQueryBuilder('consultation') + .where('consultation.tenant_id = :tenantId', { tenantId }); + + if (patientId) queryBuilder.andWhere('consultation.patient_id = :patientId', { patientId }); + if (doctorId) queryBuilder.andWhere('consultation.doctor_id = :doctorId', { doctorId }); + if (dateFrom) queryBuilder.andWhere('consultation.started_at >= :dateFrom', { dateFrom }); + if (dateTo) queryBuilder.andWhere('consultation.started_at <= :dateTo', { dateTo }); + if (status) queryBuilder.andWhere('consultation.status = :status', { status }); + + queryBuilder + .orderBy('consultation.started_at', 'DESC') + .skip((page - 1) * limit) + .take(limit); + + const [data, total] = await queryBuilder.getManyAndCount(); + return { data, total }; + } + + async findById(tenantId: string, id: string): Promise { + return this.repository.findOne({ where: { id, tenantId } }); + } + + async findByPatient(tenantId: string, patientId: string): Promise { + return this.repository.find({ + where: { tenantId, patientId }, + order: { startedAt: 'DESC' }, + }); + } + + async create(tenantId: string, dto: CreateConsultationDto): Promise { + const consultation = this.repository.create({ ...dto, tenantId }); + return this.repository.save(consultation); + } + + async update(tenantId: string, id: string, dto: UpdateConsultationDto): Promise { + const consultation = await this.findById(tenantId, id); + if (!consultation) return null; + Object.assign(consultation, dto); + return this.repository.save(consultation); + } + + async complete(tenantId: string, id: string, dto: CompleteConsultationDto): Promise { + const consultation = await this.findById(tenantId, id); + if (!consultation) return null; + + consultation.status = 'completed'; + consultation.endedAt = new Date(); + if (dto.physicalExamination) consultation.physicalExamination = dto.physicalExamination; + if (dto.assessment) consultation.assessment = dto.assessment; + if (dto.plan) consultation.plan = dto.plan; + + return this.repository.save(consultation); + } + + async cancel(tenantId: string, id: string): Promise { + const consultation = await this.findById(tenantId, id); + if (!consultation) return null; + + consultation.status = 'cancelled'; + consultation.endedAt = new Date(); + return this.repository.save(consultation); + } +} + +export class DiagnosisService { + private repository: Repository; + + constructor(dataSource: DataSource) { + this.repository = dataSource.getRepository(Diagnosis); + } + + async findByConsultation(tenantId: string, consultationId: string): Promise { + return this.repository.find({ + where: { tenantId, consultationId }, + order: { isPrimary: 'DESC', createdAt: 'ASC' }, + }); + } + + async create(tenantId: string, dto: CreateDiagnosisDto): Promise { + const diagnosis = this.repository.create({ ...dto, tenantId }); + return this.repository.save(diagnosis); + } + + async delete(tenantId: string, id: string): Promise { + const diagnosis = await this.repository.findOne({ where: { id, tenantId } }); + if (!diagnosis) return false; + await this.repository.remove(diagnosis); + return true; + } +} + +export class PrescriptionService { + private repository: Repository; + private itemRepository: Repository; + + constructor(dataSource: DataSource) { + this.repository = dataSource.getRepository(Prescription); + this.itemRepository = dataSource.getRepository(PrescriptionItem); + } + + async findByConsultation(tenantId: string, consultationId: string): Promise { + return this.repository.find({ + where: { tenantId, consultationId }, + relations: ['items'], + order: { createdAt: 'DESC' }, + }); + } + + async findByPatient(tenantId: string, patientId: string): Promise { + return this.repository.find({ + where: { tenantId, patientId }, + relations: ['items'], + order: { prescriptionDate: 'DESC' }, + }); + } + + async findById(tenantId: string, id: string): Promise { + return this.repository.findOne({ + where: { id, tenantId }, + relations: ['items'], + }); + } + + async create(tenantId: string, dto: CreatePrescriptionDto): Promise { + // Generar numero de receta + const count = await this.repository.count({ where: { tenantId } }); + const prescriptionNumber = `RX-${Date.now().toString(36).toUpperCase()}-${(count + 1).toString().padStart(4, '0')}`; + + const prescription = this.repository.create({ + tenantId, + consultationId: dto.consultationId, + patientId: dto.patientId, + doctorId: dto.doctorId, + prescriptionNumber, + instructions: dto.instructions, + notes: dto.notes, + }); + + const savedPrescription = await this.repository.save(prescription); + + // Crear items + const items = dto.items.map((item, index) => + this.itemRepository.create({ + ...item, + tenantId, + prescriptionId: savedPrescription.id, + sequence: index + 1, + }) + ); + + await this.itemRepository.save(items); + + return this.findById(tenantId, savedPrescription.id) as Promise; + } +} + +export class VitalSignsService { + private repository: Repository; + + constructor(dataSource: DataSource) { + this.repository = dataSource.getRepository(VitalSignsRecord); + } + + async findByPatient(tenantId: string, patientId: string, limit: number = 10): Promise { + return this.repository.find({ + where: { tenantId, patientId }, + order: { recordedAt: 'DESC' }, + take: limit, + }); + } + + async create(tenantId: string, dto: CreateVitalSignsRecordDto, recordedBy?: string): Promise { + const record = this.repository.create({ + ...dto, + tenantId, + recordedBy, + }); + return this.repository.save(record); + } + + async getLatest(tenantId: string, patientId: string): Promise { + return this.repository.findOne({ + where: { tenantId, patientId }, + order: { recordedAt: 'DESC' }, + }); + } +} diff --git a/src/modules/patients/controllers/index.ts b/src/modules/patients/controllers/index.ts new file mode 100644 index 0000000..23bb444 --- /dev/null +++ b/src/modules/patients/controllers/index.ts @@ -0,0 +1,152 @@ +import { Router, Request, Response, NextFunction } from 'express'; +import { DataSource } from 'typeorm'; +import { PatientService } from '../services'; +import { CreatePatientDto, UpdatePatientDto, PatientQueryDto } from '../dto'; + +export class PatientController { + public router: Router; + private service: PatientService; + + constructor(dataSource: DataSource, basePath: string = '/api') { + this.router = Router(); + this.service = new PatientService(dataSource); + this.setupRoutes(basePath); + } + + private setupRoutes(basePath: string): void { + const path = `${basePath}/patients`; + + // GET /patients - Listar pacientes + this.router.get(path, this.findAll.bind(this)); + + // GET /patients/:id - Obtener paciente por ID + this.router.get(`${path}/:id`, this.findById.bind(this)); + + // GET /patients/:id/history - Obtener historial del paciente + this.router.get(`${path}/:id/history`, this.getHistory.bind(this)); + + // POST /patients - Crear paciente + this.router.post(path, this.create.bind(this)); + + // PATCH /patients/:id - Actualizar paciente + this.router.patch(`${path}/:id`, this.update.bind(this)); + + // DELETE /patients/:id - Eliminar paciente (soft delete) + this.router.delete(`${path}/:id`, this.delete.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 async findAll(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const query: PatientQueryDto = { + search: req.query.search as string, + status: req.query.status as any, + hasInsurance: req.query.hasInsurance === 'true' ? true : req.query.hasInsurance === 'false' ? false : undefined, + page: req.query.page ? parseInt(req.query.page as string) : 1, + limit: req.query.limit ? parseInt(req.query.limit as string) : 20, + }; + + const result = await this.service.findAll(tenantId, query); + res.json({ + data: result.data, + meta: { + total: result.total, + page: query.page, + limit: query.limit, + totalPages: Math.ceil(result.total / (query.limit || 20)), + }, + }); + } catch (error) { + next(error); + } + } + + private async findById(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const { id } = req.params; + + const patient = await this.service.findById(tenantId, id); + if (!patient) { + res.status(404).json({ error: 'Patient not found', code: 'PATIENT_NOT_FOUND' }); + return; + } + + res.json({ data: patient }); + } catch (error) { + next(error); + } + } + + private async getHistory(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const { id } = req.params; + + const history = await this.service.getPatientHistory(tenantId, id); + if (!history) { + res.status(404).json({ error: 'Patient not found', code: 'PATIENT_NOT_FOUND' }); + return; + } + + res.json({ data: history }); + } catch (error) { + next(error); + } + } + + private async create(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const dto: CreatePatientDto = req.body; + + const patient = await this.service.create(tenantId, dto); + res.status(201).json({ data: patient }); + } catch (error) { + next(error); + } + } + + private async update(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const { id } = req.params; + const dto: UpdatePatientDto = req.body; + + const patient = await this.service.update(tenantId, id, dto); + if (!patient) { + res.status(404).json({ error: 'Patient not found', code: 'PATIENT_NOT_FOUND' }); + return; + } + + res.json({ data: patient }); + } catch (error) { + next(error); + } + } + + private async delete(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const { id } = req.params; + + const deleted = await this.service.softDelete(tenantId, id); + if (!deleted) { + res.status(404).json({ error: 'Patient not found', code: 'PATIENT_NOT_FOUND' }); + return; + } + + res.status(204).send(); + } catch (error) { + next(error); + } + } +} diff --git a/src/modules/patients/dto/index.ts b/src/modules/patients/dto/index.ts new file mode 100644 index 0000000..88fd9b3 --- /dev/null +++ b/src/modules/patients/dto/index.ts @@ -0,0 +1,228 @@ +import { IsString, IsOptional, IsEmail, IsEnum, IsBoolean, IsArray, IsDateString, IsUUID, ValidateNested, MaxLength } from 'class-validator'; +import { Type } from 'class-transformer'; +import { PatientStatus, Gender, BloodType, PatientAddress } from '../entities'; + +export class AddressDto implements PatientAddress { + @IsOptional() + @IsString() + @MaxLength(255) + street?: string; + + @IsOptional() + @IsString() + @MaxLength(100) + city?: string; + + @IsOptional() + @IsString() + @MaxLength(100) + state?: string; + + @IsOptional() + @IsString() + @MaxLength(20) + zip?: string; + + @IsOptional() + @IsString() + @MaxLength(100) + country?: string; +} + +export class CreatePatientDto { + @IsString() + @MaxLength(100) + firstName: string; + + @IsString() + @MaxLength(100) + lastName: string; + + @IsOptional() + @IsDateString() + dateOfBirth?: string; + + @IsOptional() + @IsEnum(['male', 'female', 'other', 'unknown']) + gender?: Gender; + + @IsOptional() + @IsString() + @MaxLength(18) + curp?: string; + + @IsOptional() + @IsString() + @MaxLength(13) + rfc?: string; + + @IsOptional() + @IsEmail() + @MaxLength(255) + email?: string; + + @IsOptional() + @IsString() + @MaxLength(20) + phone?: string; + + @IsOptional() + @IsString() + @MaxLength(20) + mobile?: string; + + @IsOptional() + @ValidateNested() + @Type(() => AddressDto) + address?: AddressDto; + + @IsOptional() + @IsEnum(['A+', 'A-', 'B+', 'B-', 'AB+', 'AB-', 'O+', 'O-', 'unknown']) + bloodType?: BloodType; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + allergies?: string[]; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + chronicConditions?: string[]; + + @IsOptional() + @IsString() + @MaxLength(200) + emergencyContactName?: string; + + @IsOptional() + @IsString() + @MaxLength(20) + emergencyContactPhone?: string; + + @IsOptional() + @IsBoolean() + hasInsurance?: boolean; + + @IsOptional() + @IsString() + @MaxLength(100) + insuranceProvider?: string; + + @IsOptional() + @IsString() + @MaxLength(50) + insurancePolicyNumber?: string; +} + +export class UpdatePatientDto { + @IsOptional() + @IsString() + @MaxLength(100) + firstName?: string; + + @IsOptional() + @IsString() + @MaxLength(100) + lastName?: string; + + @IsOptional() + @IsDateString() + dateOfBirth?: string; + + @IsOptional() + @IsEnum(['male', 'female', 'other', 'unknown']) + gender?: Gender; + + @IsOptional() + @IsString() + @MaxLength(18) + curp?: string; + + @IsOptional() + @IsString() + @MaxLength(13) + rfc?: string; + + @IsOptional() + @IsEmail() + @MaxLength(255) + email?: string; + + @IsOptional() + @IsString() + @MaxLength(20) + phone?: string; + + @IsOptional() + @IsString() + @MaxLength(20) + mobile?: string; + + @IsOptional() + @ValidateNested() + @Type(() => AddressDto) + address?: AddressDto; + + @IsOptional() + @IsEnum(['A+', 'A-', 'B+', 'B-', 'AB+', 'AB-', 'O+', 'O-', 'unknown']) + bloodType?: BloodType; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + allergies?: string[]; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + chronicConditions?: string[]; + + @IsOptional() + @IsString() + @MaxLength(200) + emergencyContactName?: string; + + @IsOptional() + @IsString() + @MaxLength(20) + emergencyContactPhone?: string; + + @IsOptional() + @IsBoolean() + hasInsurance?: boolean; + + @IsOptional() + @IsString() + @MaxLength(100) + insuranceProvider?: string; + + @IsOptional() + @IsString() + @MaxLength(50) + insurancePolicyNumber?: string; + + @IsOptional() + @IsEnum(['active', 'inactive', 'deceased']) + status?: PatientStatus; +} + +export class PatientQueryDto { + @IsOptional() + @IsString() + search?: string; + + @IsOptional() + @IsEnum(['active', 'inactive', 'deceased']) + status?: PatientStatus; + + @IsOptional() + @IsBoolean() + hasInsurance?: boolean; + + @IsOptional() + page?: number; + + @IsOptional() + limit?: number; +} diff --git a/src/modules/patients/entities/index.ts b/src/modules/patients/entities/index.ts new file mode 100644 index 0000000..3bd8226 --- /dev/null +++ b/src/modules/patients/entities/index.ts @@ -0,0 +1 @@ +export { Patient, PatientStatus, Gender, BloodType, PatientAddress } from './patient.entity'; diff --git a/src/modules/patients/entities/patient.entity.ts b/src/modules/patients/entities/patient.entity.ts new file mode 100644 index 0000000..a33c35c --- /dev/null +++ b/src/modules/patients/entities/patient.entity.ts @@ -0,0 +1,112 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + DeleteDateColumn, + Index, +} from 'typeorm'; + +export type PatientStatus = 'active' | 'inactive' | 'deceased'; +export type Gender = 'male' | 'female' | 'other' | 'unknown'; +export type BloodType = 'A+' | 'A-' | 'B+' | 'B-' | 'AB+' | 'AB-' | 'O+' | 'O-' | 'unknown'; + +export interface PatientAddress { + street?: string; + city?: string; + state?: string; + zip?: string; + country?: string; +} + +@Entity({ name: 'patients', schema: 'clinica' }) +export class Patient { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ name: 'partner_id', type: 'uuid', nullable: true }) + partnerId?: string; + + // Datos personales + @Column({ name: 'first_name', type: 'varchar', length: 100 }) + firstName: string; + + @Column({ name: 'last_name', type: 'varchar', length: 100 }) + lastName: string; + + @Column({ name: 'date_of_birth', type: 'date', nullable: true }) + dateOfBirth?: Date; + + @Column({ type: 'enum', enum: ['male', 'female', 'other', 'unknown'], default: 'unknown' }) + gender: Gender; + + // Identificacion + @Column({ type: 'varchar', length: 18, nullable: true }) + curp?: string; + + @Column({ type: 'varchar', length: 13, nullable: true }) + rfc?: string; + + // Contacto + @Column({ type: 'varchar', length: 255, nullable: true }) + email?: string; + + @Column({ type: 'varchar', length: 20, nullable: true }) + phone?: string; + + @Index() + @Column({ type: 'varchar', length: 20, nullable: true }) + mobile?: string; + + @Column({ type: 'jsonb', nullable: true }) + address?: PatientAddress; + + // Datos medicos basicos + @Column({ name: 'blood_type', type: 'enum', enum: ['A+', 'A-', 'B+', 'B-', 'AB+', 'AB-', 'O+', 'O-', 'unknown'], default: 'unknown' }) + bloodType: BloodType; + + @Column({ type: 'text', array: true, nullable: true }) + allergies?: string[]; + + @Column({ name: 'chronic_conditions', type: 'text', array: true, nullable: true }) + chronicConditions?: string[]; + + @Column({ name: 'emergency_contact_name', type: 'varchar', length: 200, nullable: true }) + emergencyContactName?: string; + + @Column({ name: 'emergency_contact_phone', type: 'varchar', length: 20, nullable: true }) + emergencyContactPhone?: string; + + // Seguro medico + @Column({ name: 'has_insurance', type: 'boolean', default: false }) + hasInsurance: boolean; + + @Column({ name: 'insurance_provider', type: 'varchar', length: 100, nullable: true }) + insuranceProvider?: string; + + @Column({ name: 'insurance_policy_number', type: 'varchar', length: 50, nullable: true }) + insurancePolicyNumber?: string; + + // Control + @Column({ type: 'enum', enum: ['active', 'inactive', 'deceased'], default: 'active' }) + status: PatientStatus; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + @DeleteDateColumn({ name: 'deleted_at', type: 'timestamptz', nullable: true }) + deletedAt?: Date; + + // Computed property + get fullName(): string { + return `${this.firstName} ${this.lastName}`; + } +} diff --git a/src/modules/patients/index.ts b/src/modules/patients/index.ts new file mode 100644 index 0000000..bcd9ccc --- /dev/null +++ b/src/modules/patients/index.ts @@ -0,0 +1,5 @@ +export { PatientsModule, PatientsModuleOptions } from './patients.module'; +export { Patient, PatientStatus, Gender, BloodType, PatientAddress } from './entities'; +export { PatientService } from './services'; +export { PatientController } from './controllers'; +export { CreatePatientDto, UpdatePatientDto, PatientQueryDto } from './dto'; diff --git a/src/modules/patients/patients.module.ts b/src/modules/patients/patients.module.ts new file mode 100644 index 0000000..34319ab --- /dev/null +++ b/src/modules/patients/patients.module.ts @@ -0,0 +1,19 @@ +import { Router } from 'express'; +import { DataSource } from 'typeorm'; +import { PatientController } from './controllers'; + +export interface PatientsModuleOptions { + dataSource: DataSource; + basePath?: string; +} + +export class PatientsModule { + public router: Router; + private controller: PatientController; + + constructor(options: PatientsModuleOptions) { + const { dataSource, basePath = '/api' } = options; + this.controller = new PatientController(dataSource, basePath); + this.router = this.controller.router; + } +} diff --git a/src/modules/patients/services/index.ts b/src/modules/patients/services/index.ts new file mode 100644 index 0000000..cdb8a07 --- /dev/null +++ b/src/modules/patients/services/index.ts @@ -0,0 +1,121 @@ +import { Router, Request, Response, NextFunction } from 'express'; +import { DataSource, Repository, ILike } from 'typeorm'; +import { Patient } from '../entities'; +import { CreatePatientDto, UpdatePatientDto, PatientQueryDto } from '../dto'; + +export class PatientService { + private repository: Repository; + + constructor(dataSource: DataSource) { + this.repository = dataSource.getRepository(Patient); + } + + async findAll(tenantId: string, query: PatientQueryDto): Promise<{ data: Patient[]; total: number }> { + const { search, status, hasInsurance, page = 1, limit = 20 } = query; + + const whereConditions: any = { + tenantId, + }; + + if (status) { + whereConditions.status = status; + } + + if (hasInsurance !== undefined) { + whereConditions.hasInsurance = hasInsurance; + } + + const queryBuilder = this.repository.createQueryBuilder('patient') + .where('patient.tenant_id = :tenantId', { tenantId }) + .andWhere('patient.deleted_at IS NULL'); + + if (status) { + queryBuilder.andWhere('patient.status = :status', { status }); + } + + if (hasInsurance !== undefined) { + queryBuilder.andWhere('patient.has_insurance = :hasInsurance', { hasInsurance }); + } + + if (search) { + queryBuilder.andWhere( + '(patient.first_name ILIKE :search OR patient.last_name ILIKE :search OR patient.mobile ILIKE :search OR patient.email ILIKE :search)', + { search: `%${search}%` } + ); + } + + queryBuilder + .orderBy('patient.last_name', 'ASC') + .addOrderBy('patient.first_name', 'ASC') + .skip((page - 1) * limit) + .take(limit); + + const [data, total] = await queryBuilder.getManyAndCount(); + + return { data, total }; + } + + async findById(tenantId: string, id: string): Promise { + return this.repository.findOne({ + where: { id, tenantId }, + }); + } + + async findByMobile(tenantId: string, mobile: string): Promise { + return this.repository.findOne({ + where: { mobile, tenantId }, + }); + } + + async create(tenantId: string, dto: CreatePatientDto): Promise { + const patient = this.repository.create({ + ...dto, + tenantId, + dateOfBirth: dto.dateOfBirth ? new Date(dto.dateOfBirth) : undefined, + }); + + return this.repository.save(patient); + } + + async update(tenantId: string, id: string, dto: UpdatePatientDto): Promise { + const patient = await this.findById(tenantId, id); + if (!patient) { + return null; + } + + const updateData: any = { ...dto }; + if (dto.dateOfBirth) { + updateData.dateOfBirth = new Date(dto.dateOfBirth); + } + + Object.assign(patient, updateData); + return this.repository.save(patient); + } + + async softDelete(tenantId: string, id: string): Promise { + const patient = await this.findById(tenantId, id); + if (!patient) { + return false; + } + + await this.repository.softDelete(id); + return true; + } + + async getPatientHistory(tenantId: string, patientId: string): Promise { + // Este metodo devolvera el historial completo del paciente + // incluyendo citas, consultas, prescripciones, etc. + // Por ahora retorna datos basicos + const patient = await this.findById(tenantId, patientId); + if (!patient) { + return null; + } + + return { + patient, + appointments: [], // TODO: Integrar con AppointmentService + consultations: [], // TODO: Integrar con ConsultationService + prescriptions: [], // TODO: Integrar con PrescriptionService + }; + } +}