366447dffc
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
412 lines
26 KiB
JavaScript
412 lines
26 KiB
JavaScript
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<? 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<? AND deleted_at IS NULL', [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, 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];
|
|
}
|