feat: version 4.9.0 -- Agenda medica: clic en dia abre cita, autor fijo y borrado logico
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -39,6 +39,7 @@ 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/delete', (body, user, staff, n) => clinic.deleteAppointment(n, body, user, staff));
|
||||
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));
|
||||
|
||||
@@ -57,11 +57,13 @@ export async function schedules(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] : [])]);
|
||||
// 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<?', [doctorId, 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) {
|
||||
@@ -70,7 +72,7 @@ export async function appointments(q, staff) {
|
||||
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);
|
||||
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);
|
||||
@@ -113,7 +115,7 @@ export async function patientHistory(patientId, staff) {
|
||||
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]);
|
||||
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');
|
||||
@@ -179,6 +181,7 @@ export async function saveAppointment(raw, user, staff, appointmentId = 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.
|
||||
@@ -190,7 +193,7 @@ export async function saveAppointment(raw, user, staff, appointmentId = null) {
|
||||
}
|
||||
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 = 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);
|
||||
@@ -211,13 +214,28 @@ export async function changeStatus(appointmentId, raw, user, staff, cancel = fal
|
||||
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_reason=?,edit_version=edit_version+1 WHERE id=? AND edit_version=?', [status, cancel ? guayaquilNow() : null, cancel ? user.id : null, reason, n, editVersion]);
|
||||
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 audit(user, cancel ? 'cancel' : 'status', 'clinic_appointment', n);
|
||||
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) {
|
||||
@@ -270,7 +288,7 @@ export async function saveBlock(raw, user, staff, blockId = null) {
|
||||
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)]);
|
||||
[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');
|
||||
}
|
||||
@@ -289,7 +307,7 @@ export async function saveBlock(raw, user, staff, blockId = null) {
|
||||
};
|
||||
}
|
||||
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 = 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);
|
||||
@@ -307,10 +325,11 @@ export async function saveBlock(raw, user, staff, blockId = null) {
|
||||
}
|
||||
export async function deleteBlock(blockId, raw, user, staff) {
|
||||
const n = id(blockId);
|
||||
const [row] = await exec(pool, 'SELECT * FROM clinic_blocks WHERE id=?', [n]);
|
||||
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);
|
||||
const result = await exec(pool, 'DELETE FROM clinic_blocks WHERE id=? AND edit_version=?', [n, version(raw.editVersion)]);
|
||||
// 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 {
|
||||
|
||||
Reference in New Issue
Block a user