import { Router } from "express"; import { withTx, pool } from "../db/pool.ts"; import { writeAudit } from "../lib/audit.ts"; import { err, h, type AuthedRequest } from "../lib/auth.ts"; import { encolar } from "../crm/outbox.ts"; export const attendanceRouter = Router({ mergeParams: true }); const APPT_COLS = `id, business_id, client_id, employee_id, service_id, to_char(start_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"') AS start_at, to_char(end_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"') AS end_at, status, cancelled_by, price, notes, created_at`; const VALID_PAYMENT = new Set(["cash", "card", "transfer", "other"]); /** * El toque de asistencia: un solo POST resuelve la cita. * * La visita se crea **solo** si la clienta vino. Una visita es un hecho con * dinero; el "no vino" es un estado de la cita. Fusionar los dos conceptos es * lo que produce registros que sirven para planear y para cerrar, y que * terminan sin cerrarse nunca. */ attendanceRouter.post( "/", h(async (req: AuthedRequest, res) => { const id = Number(req.params.id); const bid = req.user!.business_id; const { attended, total_charged, payment_method } = req.body ?? {}; if (typeof attended !== "boolean") { err(res, 400, "Indica si la clienta vino o no vino"); return; } if (payment_method != null && !VALID_PAYMENT.has(payment_method)) { err(res, 400, "Método de pago no válido"); return; } const cur = await pool.query( `SELECT ${APPT_COLS} FROM appointments WHERE id = $1 AND business_id = $2`, [id, bid] ); const appt = cur.rows[0]; if (!appt) { err(res, 404, "Cita no encontrada"); return; } if (appt.status === "cancelled") { err(res, 409, "Esta cita está cancelada: no se le puede marcar asistencia"); return; } if (appt.status === "completed" || appt.status === "no_show") { err(res, 409, "Esta cita ya se resolvió"); return; } // Una empleada solo resuelve sus propias citas; la administradora, cualquiera. if (req.user!.role === "employee" && req.user!.employee_id !== appt.employee_id) { err(res, 403, "Solo puedes marcar asistencia en tus propias citas"); return; } const out = await withTx(async (c) => { const nextStatus = attended ? "completed" : "no_show"; const { rows } = await c.query( `UPDATE appointments SET status = $3, updated_at = now() WHERE id = $1 AND business_id = $2 RETURNING ${APPT_COLS}`, [id, bid, nextStatus] ); let visit = null; if (attended) { const v = await c.query( `INSERT INTO visits (business_id, appointment_id, client_id, employee_id, occurred_at, total_charged, payment_method, recorded_by_user_id) SELECT business_id, id, client_id, employee_id, start_at, $2, $3, $4 FROM appointments WHERE id = $1 RETURNING id, business_id, appointment_id, client_id, employee_id, to_char(occurred_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"') AS occurred_at, total_charged, payment_method, recorded_by_user_id, recorded_at`, [id, total_charged ?? null, payment_method ?? null, req.user!.id] ); visit = v.rows[0]; } await c.query( `INSERT INTO appointment_events (appointment_id, actor_user_id, action, from_status, to_status) VALUES ($1,$2,$3,$4,$5)`, [id, req.user!.id, attended ? "attended" : "no_show", appt.status, nextStatus] ); await writeAudit(c, { businessId: bid, actorUserId: req.user!.id, entity: "appointments", entityId: id, action: "attendance", before: { status: appt.status }, after: { status: nextStatus, visit_id: visit?.id ?? null }, ip: req.ip ?? null, }); // La proyección al CRM se ENCOLA en esta misma transacción. Si se hiciera // aquí una llamada de red, un CRM caído impediría marcar la asistencia — // justo el registro que el negocio no puede permitirse perder. await encolar(c, { businessId: bid!, entidad: "appointment", entidadId: id, operacion: "status", payload: { status: nextStatus }, secuencia: nextStatus, }); return { appointment: rows[0], visit }; }); res.json(out); }) );