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'); } // Por defecto solo citas vigentes (las que ocupan horario); con all=true tambien canceladas y // eliminadas, para mostrarlas con su trazabilidad. function selectAppointments(db, from, to, doctorId, patientId, excludeId, all = false) { 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 ${all ? '1=1' : "a.status<>'cancelled' AND a.deleted_at IS NULL"} AND a.starts_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 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, true); } 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,a.created_by_kind,a.created_by_name,a.deleted_at,a.deleted_by_name,a.deleted_reason,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.deleted_at) throw new HttpError(409, 'Cita eliminada'); 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,created_by_kind,created_by_name) VALUES (?,?,?,?,?,?,?,?,'user',?)", [d.doctorId, d.patientId, d.typeId, d.start, d.end, d.channel, d.notes, user.id, user.name]); 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.deleted_at) throw new HttpError(409, 'Cita eliminada'); 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_by_name=?,cancelled_reason=?,edit_version=edit_version+1 WHERE id=? AND edit_version=?', [status, cancel ? guayaquilNow() : null, cancel ? user.id : null, cancel ? user.name : null, reason, n, editVersion]); if (!result.affectedRows) throw new HttpError(409, 'Cita modificada por otra persona'); await logAudit({ userId: user.id, action: cancel ? 'cancel' : 'status', entityType: 'clinic_appointment', entityId: n, detail: cancel ? { reason } : { from: row.status, to: status } }); return (await exec(pool, 'SELECT * FROM clinic_appointments WHERE id=?', [n]))[0]; } // Borrado logico: la cita deja de ocupar horario y de verse por defecto, pero queda con quien la // elimino, cuando y por que (y su autor original intacto). export async function deleteAppointment(appointmentId, raw, user, staff) { if (staff.scope !== 'all') throw new HttpError(403, 'Solo recepción elimina citas'); 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'); if (row.deleted_at) throw new HttpError(409, 'Cita ya eliminada'); const reason = text(raw.reason, 500, true); const result = await exec(pool, 'UPDATE clinic_appointments SET deleted_at=?,deleted_by=?,deleted_by_name=?,deleted_reason=?,edit_version=edit_version+1 WHERE id=? AND edit_version=? AND deleted_at IS NULL', [guayaquilNow(), user.id, user.name, reason, n, version(raw.editVersion)]); if (!result.affectedRows) throw new HttpError(409, 'Cita modificada por otra persona'); await logAudit({ userId: user.id, action: 'delete', entityType: 'clinic_appointment', entityId: n, detail: { reason } }); 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=? AND deleted_at IS NULL', [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,created_by_name) VALUES (?,?,?,?,?,?,?,?)', [doctorId, kind, title, start, end, recurrence ? JSON.stringify(recurrence) : null, user.id, user.name]); 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=? AND deleted_at IS NULL', [n]); if (!row) throw new HttpError(404, 'Bloqueo no encontrado'); doctorAllowed(staff, row.doctor_user_id); // Borrado logico: deja de bloquear horario pero la fila queda para trazabilidad. const result = await exec(pool, 'UPDATE clinic_blocks SET deleted_at=?,deleted_by=?,deleted_by_name=?,edit_version=edit_version+1 WHERE id=? AND edit_version=? AND deleted_at IS NULL', [guayaquilNow(), user.id, user.name, 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]; }