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:
parent
3f4b0c21b6
commit
f8aacbd316
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
coverage/
|
||||||
|
.env
|
||||||
195
src/app.integration.ts
Normal file
195
src/app.integration.ts
Normal 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,
|
||||||
|
};
|
||||||
19
src/modules/appointments/appointments.module.ts
Normal file
19
src/modules/appointments/appointments.module.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
288
src/modules/appointments/controllers/index.ts
Normal file
288
src/modules/appointments/controllers/index.ts
Normal 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); }
|
||||||
|
}
|
||||||
|
}
|
||||||
286
src/modules/appointments/dto/index.ts
Normal file
286
src/modules/appointments/dto/index.ts
Normal 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;
|
||||||
|
}
|
||||||
59
src/modules/appointments/entities/appointment-slot.entity.ts
Normal file
59
src/modules/appointments/entities/appointment-slot.entity.ts
Normal 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;
|
||||||
|
}
|
||||||
93
src/modules/appointments/entities/appointment.entity.ts
Normal file
93
src/modules/appointments/entities/appointment.entity.ts
Normal 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;
|
||||||
|
}
|
||||||
80
src/modules/appointments/entities/doctor.entity.ts
Normal file
80
src/modules/appointments/entities/doctor.entity.ts
Normal 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}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
4
src/modules/appointments/entities/index.ts
Normal file
4
src/modules/appointments/entities/index.ts
Normal 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';
|
||||||
43
src/modules/appointments/entities/specialty.entity.ts
Normal file
43
src/modules/appointments/entities/specialty.entity.ts
Normal 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;
|
||||||
|
}
|
||||||
5
src/modules/appointments/index.ts
Normal file
5
src/modules/appointments/index.ts
Normal 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';
|
||||||
291
src/modules/appointments/services/index.ts
Normal file
291
src/modules/appointments/services/index.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
19
src/modules/consultations/consultations.module.ts
Normal file
19
src/modules/consultations/consultations.module.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
216
src/modules/consultations/controllers/index.ts
Normal file
216
src/modules/consultations/controllers/index.ts
Normal 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); }
|
||||||
|
}
|
||||||
|
}
|
||||||
283
src/modules/consultations/dto/index.ts
Normal file
283
src/modules/consultations/dto/index.ts
Normal 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;
|
||||||
|
}
|
||||||
80
src/modules/consultations/entities/consultation.entity.ts
Normal file
80
src/modules/consultations/entities/consultation.entity.ts
Normal 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;
|
||||||
|
}
|
||||||
47
src/modules/consultations/entities/diagnosis.entity.ts
Normal file
47
src/modules/consultations/entities/diagnosis.entity.ts
Normal 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;
|
||||||
|
}
|
||||||
5
src/modules/consultations/entities/index.ts
Normal file
5
src/modules/consultations/entities/index.ts
Normal 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';
|
||||||
@ -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;
|
||||||
|
}
|
||||||
60
src/modules/consultations/entities/prescription.entity.ts
Normal file
60
src/modules/consultations/entities/prescription.entity.ts
Normal 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;
|
||||||
|
}
|
||||||
61
src/modules/consultations/entities/vital-signs.entity.ts
Normal file
61
src/modules/consultations/entities/vital-signs.entity.ts
Normal 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;
|
||||||
|
}
|
||||||
11
src/modules/consultations/index.ts
Normal file
11
src/modules/consultations/index.ts
Normal 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';
|
||||||
204
src/modules/consultations/services/index.ts
Normal file
204
src/modules/consultations/services/index.ts
Normal 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' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
152
src/modules/patients/controllers/index.ts
Normal file
152
src/modules/patients/controllers/index.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
228
src/modules/patients/dto/index.ts
Normal file
228
src/modules/patients/dto/index.ts
Normal 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;
|
||||||
|
}
|
||||||
1
src/modules/patients/entities/index.ts
Normal file
1
src/modules/patients/entities/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { Patient, PatientStatus, Gender, BloodType, PatientAddress } from './patient.entity';
|
||||||
112
src/modules/patients/entities/patient.entity.ts
Normal file
112
src/modules/patients/entities/patient.entity.ts
Normal 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}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
5
src/modules/patients/index.ts
Normal file
5
src/modules/patients/index.ts
Normal 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';
|
||||||
19
src/modules/patients/patients.module.ts
Normal file
19
src/modules/patients/patients.module.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
121
src/modules/patients/services/index.ts
Normal file
121
src/modules/patients/services/index.ts
Normal 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
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user