Migración desde erp-clinicas/backend - Estándar multi-repo v2

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
rckrdmrd 2026-01-16 08:11:57 -06:00
parent 3f4b0c21b6
commit f8aacbd316
30 changed files with 3049 additions and 0 deletions

4
.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
node_modules/
dist/
coverage/
.env

195
src/app.integration.ts Normal file
View File

@ -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<Express> {
// 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,
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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<Specialty>;
constructor(dataSource: DataSource) {
this.repository = dataSource.getRepository(Specialty);
}
async findAll(tenantId: string, activeOnly: boolean = true): Promise<Specialty[]> {
const where: any = { tenantId };
if (activeOnly) where.active = true;
return this.repository.find({ where, order: { name: 'ASC' } });
}
async findById(tenantId: string, id: string): Promise<Specialty | null> {
return this.repository.findOne({ where: { id, tenantId } });
}
async create(tenantId: string, dto: CreateSpecialtyDto): Promise<Specialty> {
const specialty = this.repository.create({ ...dto, tenantId });
return this.repository.save(specialty);
}
async update(tenantId: string, id: string, dto: UpdateSpecialtyDto): Promise<Specialty | null> {
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<Doctor>;
constructor(dataSource: DataSource) {
this.repository = dataSource.getRepository(Doctor);
}
async findAll(tenantId: string, specialtyId?: string): Promise<Doctor[]> {
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<Doctor | null> {
return this.repository.findOne({
where: { id, tenantId },
relations: ['specialty'],
});
}
async create(tenantId: string, dto: CreateDoctorDto): Promise<Doctor> {
const doctor = this.repository.create({ ...dto, tenantId });
return this.repository.save(doctor);
}
async update(tenantId: string, id: string, dto: UpdateDoctorDto): Promise<Doctor | null> {
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<boolean> {
const doctor = await this.findById(tenantId, id);
if (!doctor) return false;
await this.repository.softDelete(id);
return true;
}
}
export class AppointmentService {
private repository: Repository<Appointment>;
private slotRepository: Repository<AppointmentSlot>;
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<Appointment | null> {
return this.repository.findOne({
where: { id, tenantId },
relations: ['doctor'],
});
}
async create(tenantId: string, dto: CreateAppointmentDto, createdBy?: string): Promise<Appointment> {
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<Appointment | null> {
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<Appointment | null> {
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<Appointment | null> {
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<Appointment | null> {
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<Appointment | null> {
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<string[]> {
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<AppointmentSlot>;
constructor(dataSource: DataSource) {
this.repository = dataSource.getRepository(AppointmentSlot);
}
async findByDoctor(tenantId: string, doctorId: string): Promise<AppointmentSlot[]> {
return this.repository.find({
where: { tenantId, doctorId, active: true },
order: { dayOfWeek: 'ASC', startTime: 'ASC' },
});
}
async create(tenantId: string, dto: CreateAppointmentSlotDto): Promise<AppointmentSlot> {
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<boolean> {
const slot = await this.repository.findOne({ where: { id, tenantId } });
if (!slot) return false;
await this.repository.remove(slot);
return true;
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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<Consultation>;
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<Consultation | null> {
return this.repository.findOne({ where: { id, tenantId } });
}
async findByPatient(tenantId: string, patientId: string): Promise<Consultation[]> {
return this.repository.find({
where: { tenantId, patientId },
order: { startedAt: 'DESC' },
});
}
async create(tenantId: string, dto: CreateConsultationDto): Promise<Consultation> {
const consultation = this.repository.create({ ...dto, tenantId });
return this.repository.save(consultation);
}
async update(tenantId: string, id: string, dto: UpdateConsultationDto): Promise<Consultation | null> {
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<Consultation | null> {
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<Consultation | null> {
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<Diagnosis>;
constructor(dataSource: DataSource) {
this.repository = dataSource.getRepository(Diagnosis);
}
async findByConsultation(tenantId: string, consultationId: string): Promise<Diagnosis[]> {
return this.repository.find({
where: { tenantId, consultationId },
order: { isPrimary: 'DESC', createdAt: 'ASC' },
});
}
async create(tenantId: string, dto: CreateDiagnosisDto): Promise<Diagnosis> {
const diagnosis = this.repository.create({ ...dto, tenantId });
return this.repository.save(diagnosis);
}
async delete(tenantId: string, id: string): Promise<boolean> {
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<Prescription>;
private itemRepository: Repository<PrescriptionItem>;
constructor(dataSource: DataSource) {
this.repository = dataSource.getRepository(Prescription);
this.itemRepository = dataSource.getRepository(PrescriptionItem);
}
async findByConsultation(tenantId: string, consultationId: string): Promise<Prescription[]> {
return this.repository.find({
where: { tenantId, consultationId },
relations: ['items'],
order: { createdAt: 'DESC' },
});
}
async findByPatient(tenantId: string, patientId: string): Promise<Prescription[]> {
return this.repository.find({
where: { tenantId, patientId },
relations: ['items'],
order: { prescriptionDate: 'DESC' },
});
}
async findById(tenantId: string, id: string): Promise<Prescription | null> {
return this.repository.findOne({
where: { id, tenantId },
relations: ['items'],
});
}
async create(tenantId: string, dto: CreatePrescriptionDto): Promise<Prescription> {
// 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<Prescription>;
}
}
export class VitalSignsService {
private repository: Repository<VitalSignsRecord>;
constructor(dataSource: DataSource) {
this.repository = dataSource.getRepository(VitalSignsRecord);
}
async findByPatient(tenantId: string, patientId: string, limit: number = 10): Promise<VitalSignsRecord[]> {
return this.repository.find({
where: { tenantId, patientId },
order: { recordedAt: 'DESC' },
take: limit,
});
}
async create(tenantId: string, dto: CreateVitalSignsRecordDto, recordedBy?: string): Promise<VitalSignsRecord> {
const record = this.repository.create({
...dto,
tenantId,
recordedBy,
});
return this.repository.save(record);
}
async getLatest(tenantId: string, patientId: string): Promise<VitalSignsRecord | null> {
return this.repository.findOne({
where: { tenantId, patientId },
order: { recordedAt: 'DESC' },
});
}
}

View File

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

View File

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

View File

@ -0,0 +1 @@
export { Patient, PatientStatus, Gender, BloodType, PatientAddress } from './patient.entity';

View File

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

View File

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

View File

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

View File

@ -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<Patient>;
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<Patient | null> {
return this.repository.findOne({
where: { id, tenantId },
});
}
async findByMobile(tenantId: string, mobile: string): Promise<Patient | null> {
return this.repository.findOne({
where: { mobile, tenantId },
});
}
async create(tenantId: string, dto: CreatePatientDto): Promise<Patient> {
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<Patient | null> {
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<boolean> {
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<any> {
// 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
};
}
}