feat: version 4.7.0 -- Agenda medica

Modulo clinic (habilitado por usuario): citas de pacientes para medicos con choques
bloqueantes (medico, paciente con cualquier medico, quirofano/bloqueos recurrentes y
horario de atencion), validados en transaccion con bloqueo de filas para que dos
recepcionistas no tomen el mismo hueco. Recepcion (alcance todos) y medico (alcance
propio). Vista Dia por columnas de medico, Semana y Lista; pacientes con historial;
bloqueos; configuracion de personal, horarios y tipos solo admin/superadmin. Capa de
citas en el calendario personal del medico. Migracion 036.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Milton Gonzalez
2026-10-28 12:57:43 -05:00
parent bdc62403f4
commit 1e25022630
29 changed files with 1649 additions and 34 deletions
+2
View File
@@ -29,6 +29,7 @@ import devicesRoutes from './modules/devices/routes.js';
import inventoryIngestRoutes from './modules/devices/ingest.js';
import aiRoutes from './modules/ai/routes.js';
import meetingsRoutes from './modules/meetings/routes.js';
import clinicRoutes from './modules/clinic/routes.js';
export const app = express();
@@ -74,6 +75,7 @@ app.use('/api/vault', vaultRoutes);
app.use('/api/devices', devicesRoutes);
app.use('/api/ai', aiRoutes);
app.use('/api/meetings', meetingsRoutes);
app.use('/api/clinic', clinicRoutes);
// Mas generico (mount en /api sin sub-path propio) — SIEMPRE al final de los /api/*, para no
// interceptar con su requireAuth interno una ruta que en realidad pertenece a un router mas
// especifico registrado despues (ver ESTADO.md, deuda tecnica).
+5 -4
View File
@@ -3,14 +3,15 @@ import { fail } from '../utils/http.js';
// Modulos que el superadmin puede habilitar/deshabilitar por usuario. Panel y Configuracion no
// estan aqui: el Panel lo ve todo usuario y Configuracion sigue siendo solo de superadmin.
export const MODULES = ['projects', 'calendar', 'recurring_tasks', 'notes', 'vault', 'devices', 'organization'];
export const MODULES = ['projects', 'calendar', 'clinic', 'recurring_tasks', 'notes', 'vault', 'devices', 'organization'];
// Sin fila en user_modules = habilitado. El superadmin siempre tiene todo.
export async function loadModules(userId, roleCode) {
if (roleCode === 'superadmin') return [...MODULES];
const [rows] = await pool.query('SELECT module FROM user_modules WHERE user_id = ? AND enabled = 0', [userId]);
const disabled = new Set(rows.map((r) => r.module));
return MODULES.filter((m) => !disabled.has(m));
const [rows] = await pool.query('SELECT module, enabled FROM user_modules WHERE user_id = ?', [userId]);
const enabled = new Map(rows.map((r) => [r.module, !!r.enabled]));
// Clinic exige activación explícita; los módulos previos conservan su valor por defecto.
return MODULES.filter((m) => m === 'clinic' ? enabled.get(m) === true : enabled.get(m) !== false);
}
export function hasModule(user, module) {
+51
View File
@@ -0,0 +1,51 @@
import { Router } from 'express';
import { requireAuth } from '../../middleware/auth.js';
import { requireModule } from '../../middleware/modules.js';
import { asyncHandler, ok } from '../../utils/http.js';
import * as clinic from './service.js';
const router = Router();
router.use(requireAuth, requireModule('clinic'));
// Configuracion (solo admin/superadmin): no exige ser personal de la agenda, por eso va antes
// del middleware que resuelve clinicStaff.
router.get('/config/staff', asyncHandler(async (req, res) => ok(res, await clinic.configStaff(req.currentUser))));
router.put('/config/staff/:id', asyncHandler(async (req, res) => ok(res, await clinic.saveStaff(req.body, req.currentUser, req.params.id))));
router.get('/config/schedules/:id', asyncHandler(async (req, res) => {
clinic.requireConfig(req.currentUser);
ok(res, await clinic.schedules(req.params.id));
}));
router.put('/config/schedules/:id', asyncHandler(async (req, res) => ok(res, await clinic.saveSchedules(req.params.id, req.body, req.currentUser))));
router.get('/config/types', asyncHandler(async (req, res) => {
clinic.requireConfig(req.currentUser);
ok(res, await clinic.types());
}));
router.post('/config/types', asyncHandler(async (req, res) => ok(res, await clinic.saveType(req.body, req.currentUser), 201)));
router.put('/config/types/:id', asyncHandler(async (req, res) => ok(res, await clinic.saveType(req.body, req.currentUser, req.params.id))));
router.use(asyncHandler(async (req, res, next) => {
req.clinicStaff = await clinic.staffFor(req.currentUser);
next();
}));
const get = (path, fn) => router.get(path, asyncHandler(async (req, res) => ok(res, await fn(req.query, req.clinicStaff, req.currentUser))));
const write = (method, path, fn) => router[method](path, asyncHandler(async (req, res) => ok(res, await fn(req.body, req.currentUser, req.clinicStaff, req.params.id), method === 'post' && !req.params.id ? 201 : 200)));
get('/staff/me', async (q, s) => s);
get('/doctors', async (q, s) => Promise.all([clinic.doctors(s), clinic.types()]).then(async ([doctors, types]) => ({
doctors: await Promise.all(doctors.map(async d => ({
...d,
schedules: await clinic.schedules(d.id)
}))),
types: types.filter(t => t.is_active)
})));
get('/availability', (q, s) => clinic.availability(q, s));
get('/appointments', (q, s) => clinic.appointments(q, s));
write('post', '/appointments', (body, user, staff) => clinic.saveAppointment(body, user, staff));
write('put', '/appointments/:id', (body, user, staff, n) => clinic.saveAppointment(body, user, staff, n));
write('post', '/appointments/:id/cancel', (body, user, staff, n) => clinic.changeStatus(n, body, user, staff, true));
write('post', '/appointments/:id/status', (body, user, staff, n) => clinic.changeStatus(n, body, user, staff));
get('/blocks', (q, s) => clinic.blocks(q, s));
write('post', '/blocks', (body, user, staff) => clinic.saveBlock(body, user, staff));
write('put', '/blocks/:id', (body, user, staff, n) => clinic.saveBlock(body, user, staff, n));
router.delete('/blocks/:id', asyncHandler(async (req, res) => ok(res, await clinic.deleteBlock(req.params.id, req.query, req.currentUser, req.clinicStaff))));
get('/patients', (q, s) => clinic.patients(q, s));
router.get('/patients/:id/appointments', asyncHandler(async (req, res) => ok(res, await clinic.patientHistory(req.params.id, req.clinicStaff))));
write('post', '/patients', (body, user, staff) => clinic.savePatient(body, user, staff));
write('put', '/patients/:id', (body, user, staff, n) => clinic.savePatient(body, user, staff, n));
export default router;
+392
View File
@@ -0,0 +1,392 @@
import { pool } from '../../config/db.js';
import { HttpError } from '../../utils/http.js';
import { requireEditVersion } from '../../utils/editVersion.js';
import { logAudit } from '../../utils/audit.js';
import { addMinutes, durationMinutes, expand, validateRecurrence, guayaquilNow } from '../meetings/recurrence.js';
const dateRe = /^\d{4}-\d\d-\d\d \d\d:\d\d(?::\d\d)?$/;
const id = value => {
const n = Number(value);
if (!Number.isSafeInteger(n) || n < 1) throw new HttpError(400, 'ID inválido');
return n;
};
const date = value => {
if (typeof value !== 'string' || !dateRe.test(value)) throw new HttpError(400, 'Fecha inválida');
const s = `${value.slice(0, 16)}:00`;
const d = new Date(`${s.replace(' ', 'T')}Z`);
if (Number.isNaN(d.getTime()) || d.toISOString().slice(0, 19).replace('T', ' ') !== s) throw new HttpError(400, 'Fecha inválida');
return s;
};
const text = (v, max, required = false) => {
const s = String(v ?? '').trim();
if (required && !s || s.length > max || /[\u0000-\u001f\u007f]/.test(s)) throw new HttpError(400, 'Texto inválido');
return s;
};
const version = v => requireEditVersion(Number(v));
const range = q => {
const from = date(q.from),
to = date(q.to);
if (from >= to || durationMinutes(from, to) > 93 * 1440) throw new HttpError(400, 'Rango inválido (máximo 93 días)');
return [from, to];
};
const exec = async (db, sql, params = []) => (await db.query(sql, params))[0];
const audit = async (user, action, entityType, entityId, connection = pool) => logAudit({
userId: user.id,
action,
entityType,
entityId,
connection
});
const roleAdmin = user => ['admin', 'superadmin'].includes(user.roleCode);
export function requireConfig(user) {
if (!roleAdmin(user)) throw new HttpError(403, 'Solo administración configura la agenda');
}
export async function staffFor(user) {
const rows = await exec(pool, 'SELECT s.*, u.name FROM clinic_staff s JOIN users u ON u.id=s.user_id WHERE s.user_id=? AND s.is_active=1 AND u.is_active=1', [user.id]);
if (!rows.length) throw new HttpError(403, 'No estás habilitado como personal de Agenda médica');
return rows[0];
}
function doctorAllowed(staff, doctorId) {
if (staff.scope === 'own' && (!staff.is_doctor || doctorId !== staff.user_id)) throw new HttpError(403, 'Solo puedes acceder a tu propia agenda');
}
export async function doctors(staff) {
return exec(pool, `SELECT s.user_id AS id, u.name, s.specialty, s.color, s.scope, s.is_doctor FROM clinic_staff s JOIN users u ON u.id=s.user_id WHERE s.is_doctor=1 AND s.is_active=1 AND u.is_active=1 ${staff.scope === 'own' ? 'AND s.user_id=?' : ''} ORDER BY u.name`, staff.scope === 'own' ? [staff.user_id] : []);
}
export async function schedules(doctorId) {
return exec(pool, 'SELECT id,doctor_user_id,weekday,starts_time,ends_time FROM clinic_schedules WHERE doctor_user_id=? ORDER BY weekday,starts_time', [id(doctorId)]);
}
export async function types() {
return exec(pool, 'SELECT * FROM clinic_appointment_types ORDER BY name');
}
function selectAppointments(db, from, to, doctorId, patientId, excludeId) {
return exec(db, `SELECT a.*, p.first_names,p.last_names,t.name AS type_name,t.color AS type_color,u.name AS doctor_name FROM clinic_appointments a JOIN clinic_patients p ON p.id=a.patient_id JOIN clinic_appointment_types t ON t.id=a.type_id JOIN users u ON u.id=a.doctor_user_id WHERE a.status<>'cancelled' AND a.starts_at<? AND a.ends_at>? ${doctorId ? 'AND a.doctor_user_id=?' : ''} ${patientId ? 'AND a.patient_id=?' : ''} ${excludeId ? 'AND a.id<>?' : ''} ORDER BY a.starts_at`, [to, from, ...(doctorId ? [doctorId] : []), ...(patientId ? [patientId] : []), ...(excludeId ? [excludeId] : [])]);
}
async function blocksIn(db, doctorId, from, to) {
const rows = await exec(db, 'SELECT * FROM clinic_blocks WHERE doctor_user_id=? AND starts_at<?', [doctorId, to]);
return rows.flatMap(r => expand(r, from, to));
}
export async function appointments(q, staff) {
const [from, to] = range(q);
const doctorId = q.doctorId ? id(q.doctorId) : null;
if (doctorId) doctorAllowed(staff, doctorId);
const patientId = q.patientId ? id(q.patientId) : null;
if (patientId && staff.scope !== 'all') throw new HttpError(403, 'Solo recepción consulta el historial');
return selectAppointments(pool, from, to, staff.scope === 'own' ? staff.user_id : doctorId, patientId, null);
}
export async function blocks(q, staff) {
const [from, to] = range(q);
const doctorId = q.doctorId ? id(q.doctorId) : null;
if (doctorId) doctorAllowed(staff, doctorId);
const ds = doctorId ? [doctorId] : (await doctors(staff)).map(d => d.id);
return (await Promise.all(ds.map(d => blocksIn(pool, d, from, to)))).flat();
}
export async function patients(q, staff) {
if (staff.scope !== 'all') throw new HttpError(403, 'Solo recepción gestiona pacientes');
const search = text(q.search, 120);
const page = q.page === undefined ? 1 : Number(q.page),
pageSize = q.pageSize === undefined ? 20 : Number(q.pageSize);
if (!Number.isSafeInteger(page) || page < 1 || !Number.isSafeInteger(pageSize) || pageSize < 1 || pageSize > 50 || !Number.isSafeInteger((page - 1) * pageSize)) throw new HttpError(400, 'Paginación inválida');
const like = `%${search}%`;
const where = 'is_active=1 AND (first_names LIKE ? OR last_names LIKE ? OR identification LIKE ? OR phone LIKE ?)';
const [count] = await exec(pool, `SELECT COUNT(*) AS n FROM clinic_patients WHERE ${where}`, [like, like, like, like]);
const items = await exec(pool, `SELECT * FROM clinic_patients WHERE ${where} ORDER BY last_names,first_names LIMIT ? OFFSET ?`, [like, like, like, like, pageSize, (page - 1) * pageSize]);
return {
items,
total: count.n,
page,
pageSize
};
}
function patientData(raw) {
const first = text(raw.firstNames, 120, true),
last = text(raw.lastNames, 120, true),
identification = text(raw.identification, 40) || null,
phone = text(raw.phone, 40, true),
email = text(raw.email, 160) || null,
notes = text(raw.notes, 500) || null,
birth = raw.birthDate ? String(raw.birthDate) : null;
if (email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) throw new HttpError(400, 'Correo inválido');
if (birth && (!/^\d{4}-\d\d-\d\d$/.test(birth) || date(`${birth} 00:00:00`).slice(0, 10) !== birth)) throw new HttpError(400, 'Nacimiento inválido');
return [first, last, identification, phone, email, birth, notes];
}
export async function patientHistory(patientId, staff) {
if (staff.scope !== 'all') throw new HttpError(403, 'Solo recepción consulta el historial');
const n = id(patientId);
const [patient] = await exec(pool, 'SELECT id FROM clinic_patients WHERE id=?', [n]);
if (!patient) throw new HttpError(404, 'Paciente no encontrado');
return exec(pool, 'SELECT a.id,a.starts_at,a.ends_at,a.status,a.channel,u.name AS doctor_name,t.name AS type_name FROM clinic_appointments a JOIN users u ON u.id=a.doctor_user_id JOIN clinic_appointment_types t ON t.id=a.type_id WHERE a.patient_id=? ORDER BY a.starts_at DESC LIMIT 100', [n]);
}
export async function savePatient(raw, user, staff, patientId = null) {
if (staff.scope !== 'all') throw new HttpError(403, 'Solo recepción gestiona pacientes');
const data = patientData(raw);
let result;
try {
if (patientId) result = await exec(pool, 'UPDATE clinic_patients SET first_names=?,last_names=?,identification=?,phone=?,email=?,birth_date=?,notes=?,edit_version=edit_version+1 WHERE id=? AND edit_version=?', [...data, id(patientId), version(raw.editVersion)]);else result = await exec(pool, 'INSERT INTO clinic_patients (first_names,last_names,identification,phone,email,birth_date,notes,created_by) VALUES (?,?,?,?,?,?,?,?)', [...data, user.id]);
} catch (e) {
if (e.code === 'ER_DUP_ENTRY') throw new HttpError(409, 'Identificación ya registrada');
throw e;
}
if (patientId && !result.affectedRows) throw new HttpError(409, 'Paciente modificado por otra persona');
const newId = patientId || result.insertId;
await audit(user, patientId ? 'update' : 'create', 'clinic_patient', newId);
return (await exec(pool, 'SELECT * FROM clinic_patients WHERE id=?', [newId]))[0];
}
function appointmentData(raw, staff) {
const doctorId = id(raw.doctorId),
patientId = id(raw.patientId),
typeId = id(raw.typeId),
start = date(raw.startsAt),
end = date(raw.endsAt);
doctorAllowed(staff, doctorId);
if (end <= start || durationMinutes(start, end) > 24 * 60) throw new HttpError(400, 'Horario inválido');
const channel = raw.channel || 'phone';
if (!['phone', 'whatsapp', 'in_person', 'other'].includes(channel)) throw new HttpError(400, 'Canal inválido');
return {
doctorId,
patientId,
typeId,
start,
end,
channel,
notes: text(raw.notes, 500) || null
};
}
async function validateSlot(db, d, excludeId) {
const [doctor] = await exec(db, 'SELECT s.user_id FROM clinic_staff s JOIN users u ON u.id=s.user_id WHERE s.user_id=? AND s.is_doctor=1 AND s.is_active=1 AND u.is_active=1', [d.doctorId]);
if (!doctor) throw new HttpError(400, 'Médico no disponible');
const [patient] = await exec(db, 'SELECT id FROM clinic_patients WHERE id=? AND is_active=1', [d.patientId]);
if (!patient) throw new HttpError(400, 'Paciente no disponible');
const [type] = await exec(db, 'SELECT id FROM clinic_appointment_types WHERE id=? AND is_active=1', [d.typeId]);
if (!type) throw new HttpError(400, 'Tipo de cita no disponible');
const weekday = (new Date(`${d.start.slice(0, 10)}T00:00:00Z`).getUTCDay() + 6) % 7;
const day = d.start.slice(0, 10);
const available = await exec(db, `SELECT id FROM clinic_schedules WHERE doctor_user_id=? AND weekday=? AND CONCAT(?, ' ', starts_time)<=? AND CONCAT(?, ' ', ends_time)>=?`, [d.doctorId, weekday, day, d.start, day, d.end]);
if (!available.length || d.end.slice(0, 10) !== day) throw new HttpError(409, 'Fuera del horario de atención del médico');
const doctorClash = await selectAppointments(db, d.start, d.end, d.doctorId, null, excludeId);
const hhmm = (s) => String(s).slice(11, 16);
if (doctorClash.length) { const c = doctorClash[0]; throw new HttpError(409, `El médico ya tiene una cita de ${hhmm(c.starts_at)} a ${hhmm(c.ends_at)} (${c.first_names} ${c.last_names})`); }
const block = (await blocksIn(db, d.doctorId, d.start, d.end))[0];
if (block) throw new HttpError(409, `El médico no está disponible de ${hhmm(block.occurrence_start)} a ${hhmm(block.occurrence_end)}: ${block.title}`);
const patientClash = await selectAppointments(db, d.start, d.end, null, d.patientId, excludeId);
if (patientClash.length) { const c = patientClash[0]; throw new HttpError(409, `El paciente ya tiene una cita de ${hhmm(c.starts_at)} a ${hhmm(c.ends_at)} con ${c.doctor_name}`); }
}
export async function saveAppointment(raw, user, staff, appointmentId = null) {
if (staff.scope !== 'all') throw new HttpError(403, 'Solo recepción agenda citas');
const d = appointmentData(raw, staff);
const db = await pool.getConnection();
try {
await db.beginTransaction();
let before = null;
if (appointmentId) {
[before] = await exec(db, 'SELECT * FROM clinic_appointments WHERE id=?', [id(appointmentId)]);
if (!before) throw new HttpError(404, 'Cita no encontrada');
if (before.status === 'cancelled') throw new HttpError(409, 'Cita cancelada');
if (Number(before.edit_version) !== version(raw.editVersion)) throw new HttpError(409, 'Cita modificada por otra persona');
} // Se bloquean filas de médicos en orden para mover entre dos agendas.
for (const doctorId of [...new Set([d.doctorId, ...(before ? [before.doctor_user_id] : [])])].sort((a, b) => a - b)) await exec(db, 'SELECT user_id FROM clinic_staff WHERE user_id=? FOR UPDATE', [doctorId]);
for (const patientId of [...new Set([d.patientId, ...(before ? [before.patient_id] : [])])].sort((a, b) => a - b)) await exec(db, 'SELECT id FROM clinic_patients WHERE id=? FOR UPDATE', [patientId]);
if (before) {
const [fresh] = await exec(db, 'SELECT edit_version FROM clinic_appointments WHERE id=?', [before.id]);
if (Number(fresh.edit_version) !== Number(before.edit_version)) throw new HttpError(409, 'Cita modificada por otra persona');
}
await validateSlot(db, d, before?.id);
let result;
if (before) result = await exec(db, 'UPDATE clinic_appointments SET doctor_user_id=?,patient_id=?,type_id=?,starts_at=?,ends_at=?,channel=?,notes=?,edit_version=edit_version+1 WHERE id=? AND edit_version=?', [d.doctorId, d.patientId, d.typeId, d.start, d.end, d.channel, d.notes, before.id, version(raw.editVersion)]);else result = await exec(db, 'INSERT INTO clinic_appointments (doctor_user_id,patient_id,type_id,starts_at,ends_at,channel,notes,created_by) VALUES (?,?,?,?,?,?,?,?)', [d.doctorId, d.patientId, d.typeId, d.start, d.end, d.channel, d.notes, user.id]);
if (before && !result.affectedRows) throw new HttpError(409, 'Cita modificada por otra persona');
const newId = before?.id || result.insertId;
await audit(user, before ? 'update' : 'create', 'clinic_appointment', newId, db);
await db.commit();
return (await exec(pool, 'SELECT * FROM clinic_appointments WHERE id=?', [newId]))[0];
} catch (e) {
await db.rollback();
throw e;
} finally {
db.release();
}
}
export async function changeStatus(appointmentId, raw, user, staff, cancel = false) {
const n = id(appointmentId);
const [row] = await exec(pool, 'SELECT * FROM clinic_appointments WHERE id=?', [n]);
if (!row) throw new HttpError(404, 'Cita no encontrada');
doctorAllowed(staff, row.doctor_user_id);
const status = cancel ? 'cancelled' : raw.status;
if (cancel && staff.scope !== 'all') throw new HttpError(403, 'Solo recepción cancela citas');
if (!cancel && !(staff.scope === 'all' ? ['scheduled', 'confirmed', 'attended', 'no_show'].includes(status) : ['attended', 'no_show'].includes(status))) throw new HttpError(403, 'Estado no permitido');
if (row.status === 'cancelled') throw new HttpError(409, 'Cita cancelada');
const editVersion = version(raw.editVersion);
if (!cancel && row.status === status && Number(row.edit_version) === editVersion) return row;
const reason = cancel ? text(raw.reason, 500, true) : null;
const result = await exec(pool, 'UPDATE clinic_appointments SET status=?,cancelled_at=?,cancelled_by=?,cancelled_reason=?,edit_version=edit_version+1 WHERE id=? AND edit_version=?', [status, cancel ? guayaquilNow() : null, cancel ? user.id : null, reason, n, editVersion]);
if (!result.affectedRows) throw new HttpError(409, 'Cita modificada por otra persona');
await audit(user, cancel ? 'cancel' : 'status', 'clinic_appointment', n);
return (await exec(pool, 'SELECT * FROM clinic_appointments WHERE id=?', [n]))[0];
}
export async function availability(q, staff) {
const doctorId = id(q.doctorId),
day = String(q.date || '');
doctorAllowed(staff, doctorId);
const [doctor] = await exec(pool, 'SELECT user_id FROM clinic_staff WHERE user_id=? AND is_doctor=1 AND is_active=1', [doctorId]);
if (!doctor) throw new HttpError(404, 'Médico no encontrado');
if (!/^\d{4}-\d\d-\d\d$/.test(day)) throw new HttpError(400, 'Fecha inválida');
date(`${day} 00:00:00`);
const [type] = await exec(pool, 'SELECT duration_minutes FROM clinic_appointment_types WHERE id=? AND is_active=1', [id(q.typeId)]);
if (!type) throw new HttpError(400, 'Tipo inválido');
const weekday = (new Date(`${day}T00:00:00Z`).getUTCDay() + 6) % 7;
const segments = await exec(pool, 'SELECT starts_time,ends_time FROM clinic_schedules WHERE doctor_user_id=? AND weekday=?', [doctorId, weekday]);
const from = `${day} 00:00:00`,
to = addMinutes(from, 1440);
const busy = await selectAppointments(pool, from, to, doctorId, null, null),
blocked = await blocksIn(pool, doctorId, from, to);
const slots = [];
for (const s of segments) {
for (let start = `${day} ${s.starts_time}`; addMinutes(start, type.duration_minutes) <= `${day} ${s.ends_time}`; start = addMinutes(start, 15)) {
const end = addMinutes(start, type.duration_minutes);
if (!busy.some(x => x.starts_at < end && x.ends_at > start) && !blocked.some(x => x.occurrence_start < end && x.occurrence_end > start)) slots.push({
startsAt: start,
endsAt: end
});
}
}
return slots;
}
export async function saveBlock(raw, user, staff, blockId = null) {
const doctorId = id(raw.doctorId);
doctorAllowed(staff, doctorId);
const kind = raw.kind;
if (!['surgery', 'vacation', 'other'].includes(kind)) throw new HttpError(400, 'Tipo de bloqueo inválido');
const title = text(raw.title, 160, true),
start = date(raw.startsAt),
end = date(raw.endsAt);
if (end <= start || durationMinutes(start, end) > 31 * 1440) throw new HttpError(400, 'Horario inválido');
const recurrence = validateRecurrence(raw.recurrence, start);
const proposed = expand({
starts_at: start,
ends_at: end,
recurrence_json: recurrence ? JSON.stringify(recurrence) : null
}, start, '9999-12-31 23:59:59');
const db = await pool.getConnection();
try {
await db.beginTransaction();
const [doctor] = await exec(db, 'SELECT user_id FROM clinic_staff WHERE user_id=? AND is_doctor=1 AND is_active=1 FOR UPDATE', [doctorId]);
if (!doctor) throw new HttpError(400, 'Médico no disponible');
let before = null;
if (blockId) {
[before] = await exec(db, 'SELECT * FROM clinic_blocks WHERE id=?', [id(blockId)]);
if (!before || before.doctor_user_id !== doctorId) throw new HttpError(404, 'Bloqueo no encontrado');
if (Number(before.edit_version) !== version(raw.editVersion)) throw new HttpError(409, 'Bloqueo modificado por otra persona');
}
const affected = [];
const candidates = await selectAppointments(db, proposed[0].occurrence_start, proposed.at(-1).occurrence_end, doctorId, null, null);
for (const c of candidates) if (proposed.some(p => p.occurrence_start < c.ends_at && p.occurrence_end > c.starts_at)) affected.push({
id: c.id,
starts_at: c.starts_at,
patient_name: `${c.first_names} ${c.last_names}`
});
if (affected.length && raw.confirmAffected !== true) {
await db.rollback();
return {
requiresConfirmation: true,
affected
};
}
let result;
if (before) result = await exec(db, 'UPDATE clinic_blocks SET kind=?,title=?,starts_at=?,ends_at=?,recurrence_json=?,edit_version=edit_version+1 WHERE id=? AND edit_version=?', [kind, title, start, end, recurrence ? JSON.stringify(recurrence) : null, before.id, before.edit_version]);else result = await exec(db, 'INSERT INTO clinic_blocks (doctor_user_id,kind,title,starts_at,ends_at,recurrence_json,created_by) VALUES (?,?,?,?,?,?,?)', [doctorId, kind, title, start, end, recurrence ? JSON.stringify(recurrence) : null, user.id]);
if (before && !result.affectedRows) throw new HttpError(409, 'Bloqueo modificado por otra persona');
const newId = before?.id || result.insertId;
await audit(user, before ? 'update' : 'create', 'clinic_block', newId, db);
await db.commit();
return {
block: (await exec(pool, 'SELECT * FROM clinic_blocks WHERE id=?', [newId]))[0],
affected
};
} catch (e) {
await db.rollback();
throw e;
} finally {
db.release();
}
}
export async function deleteBlock(blockId, raw, user, staff) {
const n = id(blockId);
const [row] = await exec(pool, 'SELECT * FROM clinic_blocks WHERE id=?', [n]);
if (!row) throw new HttpError(404, 'Bloqueo no encontrado');
doctorAllowed(staff, row.doctor_user_id);
const result = await exec(pool, 'DELETE FROM clinic_blocks WHERE id=? AND edit_version=?', [n, version(raw.editVersion)]);
if (!result.affectedRows) throw new HttpError(409, 'Bloqueo modificado por otra persona');
await audit(user, 'delete', 'clinic_block', n);
return {
id: n
};
}
export async function configStaff(user) {
requireConfig(user);
return exec(pool, 'SELECT s.*,u.name,u.email FROM clinic_staff s JOIN users u ON u.id=s.user_id ORDER BY u.name');
}
export async function saveStaff(raw, user, staffId) {
requireConfig(user);
const n = id(staffId),
scope = raw.scope;
if (!['all', 'own'].includes(scope)) throw new HttpError(400, 'Alcance inválido');
const doctor = !!raw.isDoctor;
if (scope === 'own' && !doctor) throw new HttpError(400, 'El alcance propio exige médico');
const specialty = text(raw.specialty, 120) || null,
color = raw.color || null;
if (color && !/^#[0-9a-fA-F]{6}$/.test(color)) throw new HttpError(400, 'Color inválido');
const [target] = await exec(pool, 'SELECT id FROM users WHERE id=? AND is_active=1', [n]);
if (!target) throw new HttpError(404, 'Usuario no encontrado');
if (raw.editVersion) {
const result = await exec(pool, 'UPDATE clinic_staff SET scope=?,is_doctor=?,specialty=?,color=?,is_active=?,edit_version=edit_version+1 WHERE user_id=? AND edit_version=?', [scope, doctor, specialty, color, raw.isActive === false ? 0 : 1, n, version(raw.editVersion)]);
if (!result.affectedRows) throw new HttpError(409, 'Personal modificado por otra persona');
} else {
try {
await exec(pool, 'INSERT INTO clinic_staff (user_id,scope,is_doctor,specialty,color,is_active) VALUES (?,?,?,?,?,?)', [n, scope, doctor, specialty, color, raw.isActive === false ? 0 : 1]);
} catch (e) {
if (e.code === 'ER_DUP_ENTRY') throw new HttpError(409, 'Personal creado por otra persona');
throw e;
}
}
await audit(user, 'update', 'clinic_staff', n);
return (await exec(pool, 'SELECT * FROM clinic_staff WHERE user_id=?', [n]))[0];
}
export async function saveSchedules(doctorId, raw, user) {
requireConfig(user);
const n = id(doctorId),
rows = raw.segments;
if (!Array.isArray(rows) || rows.length > 30) throw new HttpError(400, 'Horario inválido');
const parsed = rows.map(s => {
const weekday = Number(s.weekday),
start = String(s.startsTime || ''),
end = String(s.endsTime || '');
if (!Number.isInteger(weekday) || weekday < 0 || weekday > 6 || !/^\d\d:\d\d(?::\d\d)?$/.test(start) || !/^\d\d:\d\d(?::\d\d)?$/.test(end) || start >= end || start.slice(0, 5) > '23:59' || end.slice(0, 5) > '23:59') throw new HttpError(400, 'Tramo inválido');
return [weekday, start, end];
});
for (let i = 0; i < parsed.length; i++) for (let j = i + 1; j < parsed.length; j++) if (parsed[i][0] === parsed[j][0] && parsed[i][1] < parsed[j][2] && parsed[i][2] > parsed[j][1]) throw new HttpError(400, 'Tramos superpuestos');
const db = await pool.getConnection();
try {
await db.beginTransaction();
const [doctor] = await exec(db, 'SELECT user_id FROM clinic_staff WHERE user_id=? AND is_doctor=1 FOR UPDATE', [n]);
if (!doctor) throw new HttpError(404, 'Médico no encontrado');
await exec(db, 'DELETE FROM clinic_schedules WHERE doctor_user_id=?', [n]);
for (const r of parsed) await exec(db, 'INSERT INTO clinic_schedules (doctor_user_id,weekday,starts_time,ends_time) VALUES (?,?,?,?)', [n, ...r]);
await audit(user, 'update', 'clinic_schedule', n, db);
await db.commit();
return schedules(n);
} catch (e) {
await db.rollback();
throw e;
} finally {
db.release();
}
}
export async function saveType(raw, user, typeId = null) {
requireConfig(user);
const name = text(raw.name, 120, true),
duration = Number(raw.durationMinutes),
color = raw.color;
if (!Number.isInteger(duration) || duration < 5 || duration > 480 || !/^#[0-9a-fA-F]{6}$/.test(color || '')) throw new HttpError(400, 'Tipo inválido');
let result;
if (typeId) result = await exec(pool, 'UPDATE clinic_appointment_types SET name=?,duration_minutes=?,color=?,is_active=?,edit_version=edit_version+1 WHERE id=? AND edit_version=?', [name, duration, color, raw.isActive === false ? 0 : 1, id(typeId), version(raw.editVersion)]);else result = await exec(pool, 'INSERT INTO clinic_appointment_types (name,duration_minutes,color,is_active) VALUES (?,?,?,?)', [name, duration, color, raw.isActive === false ? 0 : 1]);
if (typeId && !result.affectedRows) throw new HttpError(409, 'Tipo modificado por otra persona');
const n = typeId || result.insertId;
await audit(user, typeId ? 'update' : 'create', 'clinic_appointment_type', n);
return (await exec(pool, 'SELECT * FROM clinic_appointment_types WHERE id=?', [n]))[0];
}
+2 -2
View File
@@ -1,7 +1,7 @@
import { pool } from '../config/db.js';
export async function logAudit({ userId, action, entityType, entityId, detail }) {
await pool.query(
export async function logAudit({ userId, action, entityType, entityId, detail, connection = pool }) {
await connection.query(
`INSERT INTO audit_log (user_id, action, entity_type, entity_id, detail_json) VALUES (?, ?, ?, ?, ?)`,
[userId, action, entityType, entityId ?? null, detail ? JSON.stringify(detail) : null]
);