import { Router } from "express"; import { pool, withTx } 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 appointmentsRouter = Router(); // `start_at` y `end_at` se serializan a ISO-Z sin milisegundos, que es el // formato que el frontend ya parsea. `to_char` sobre el valor convertido a UTC // evita depender de la zona del proceso de Node. const 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, cancel_reason, price, notes, source_channel, created_at`; /** Traduce la violación de exclusión de Postgres a un 409 en español. */ function isOverlap(e: any) { return e?.code === "23P01" && String(e?.constraint) === "appointments_no_overlap"; } const OCUPADO = "Ese horario ya está ocupado para esta especialista"; appointmentsRouter.get( "/", h(async (req: AuthedRequest, res) => { const { from, to, employee_id, client_id, status, limit } = req.query as Record< string, string | undefined >; const params: unknown[] = [req.user!.business_id]; let sql = `SELECT ${COLS} FROM appointments WHERE business_id = $1`; if (from) { params.push(from); sql += ` AND start_at >= $${params.length}::timestamptz`; } if (to) { params.push(to); sql += ` AND start_at < $${params.length}::timestamptz`; } if (employee_id) { params.push(Number(employee_id)); sql += ` AND employee_id = $${params.length}`; } // Sin este filtro, la ficha de una clienta enseñaba las citas de todas: el // cliente lo mandaba y el servidor lo ignoraba en silencio. if (client_id) { params.push(Number(client_id)); sql += ` AND client_id = $${params.length}`; } if (status) { params.push(status); sql += ` AND status = $${params.length}`; } params.push(Math.min(Number(limit) || 500, 1000)); sql += ` ORDER BY start_at DESC LIMIT $${params.length}`; const { rows } = await pool.query(sql, params); res.json({ appointments: rows }); }) ); appointmentsRouter.post( "/", h(async (req: AuthedRequest, res) => { const { client_id, employee_id, service_id, start_at, notes, source_channel } = req.body ?? {}; if (!client_id || !employee_id || !service_id || !start_at) { err(res, 400, "Faltan datos de la cita"); return; } const bid = req.user!.business_id; const svc = await pool.query( `SELECT duration_min, price FROM services WHERE id = $1 AND business_id = $2 AND active`, [service_id, bid] ); if (!svc.rows[0]) { err(res, 404, "Servicio no encontrado"); return; } const emp = await pool.query( `SELECT 1 FROM employees WHERE id = $1 AND business_id = $2 AND active`, [employee_id, bid] ); if (!emp.rows[0]) { err(res, 404, "Especialista no encontrada"); return; } const cli = await pool.query( `SELECT 1 FROM clients WHERE id = $1 AND business_id = $2 AND deleted_at IS NULL`, [client_id, bid] ); if (!cli.rows[0]) { err(res, 404, "Clienta no encontrada"); return; } try { const appointment = await withTx(async (c) => { const { rows } = await c.query( `INSERT INTO appointments (business_id, client_id, employee_id, service_id, start_at, end_at, price, notes, source_channel, created_by_user_id) VALUES ($1,$2,$3,$4,$5::timestamptz, $5::timestamptz + make_interval(mins => $6::int), $7,$8,$9,$10) RETURNING ${COLS}`, [ bid, client_id, employee_id, service_id, start_at, svc.rows[0].duration_min, svc.rows[0].price, notes || null, source_channel || null, req.user!.id, ] ); await c.query( `INSERT INTO appointment_events (appointment_id, actor_user_id, action, to_status) VALUES ($1,$2,'created','scheduled')`, [rows[0].id, req.user!.id] ); await writeAudit(c, { businessId: bid, actorUserId: req.user!.id, entity: "appointments", entityId: rows[0].id, action: "create", after: rows[0], ip: req.ip ?? null, }); // Se encola en la MISMA transacción: si el proceso muere aquí, la cita // y su intención de sincronizar caen juntas o sobreviven juntas. await encolar(c, { businessId: bid!, entidad: "appointment", entidadId: rows[0].id, operacion: "create", payload: { status: "scheduled" }, secuencia: "create", }); return rows[0]; }); res.status(201).json({ appointment }); } catch (e) { if (isOverlap(e)) { err(res, 409, OCUPADO); return; } throw e; } }) ); appointmentsRouter.patch( "/:id", h(async (req: AuthedRequest, res) => { const id = Number(req.params.id); const bid = req.user!.business_id; const { start_at, employee_id, notes } = req.body ?? {}; const cur = await pool.query( `SELECT ${COLS} FROM appointments WHERE id = $1 AND business_id = $2`, [id, bid] ); if (!cur.rows[0]) { err(res, 404, "Cita no encontrada"); return; } if (cur.rows[0].status === "cancelled") { err(res, 409, "Una cita cancelada no se puede modificar"); return; } try { const appointment = await withTx(async (c) => { const { rows } = await c.query( `UPDATE appointments SET start_at = COALESCE($3::timestamptz, start_at), end_at = CASE WHEN $3::timestamptz IS NULL THEN end_at ELSE $3::timestamptz + (end_at - start_at) END, employee_id = COALESCE($4::bigint, employee_id), notes = COALESCE($5::text, notes), updated_at = now() WHERE id = $1 AND business_id = $2 RETURNING ${COLS}`, [id, bid, start_at ?? null, employee_id ?? null, notes ?? null] ); if (start_at || employee_id) { await c.query( `INSERT INTO appointment_events (appointment_id, actor_user_id, action, detail) VALUES ($1,$2,'rescheduled',$3::jsonb)`, [ id, req.user!.id, JSON.stringify({ from: { start_at: cur.rows[0].start_at, employee_id: cur.rows[0].employee_id, }, to: { start_at: rows[0].start_at, employee_id: rows[0].employee_id }, }), ] ); } await writeAudit(c, { businessId: bid, actorUserId: req.user!.id, entity: "appointments", entityId: id, action: "update", before: cur.rows[0], after: rows[0], ip: req.ip ?? null, }); return rows[0]; }); res.json({ appointment }); } catch (e) { if (isOverlap(e)) { err(res, 409, OCUPADO); return; } throw e; } }) ); appointmentsRouter.post( "/:id/cancel", h(async (req: AuthedRequest, res) => { const id = Number(req.params.id); const bid = req.user!.business_id; const { cancelled_by, reason } = req.body ?? {}; if (cancelled_by !== "client" && cancelled_by !== "business") { err(res, 400, "Indica quién canceló: la clienta o el spa"); return; } const cur = await pool.query( `SELECT ${COLS} FROM appointments WHERE id = $1 AND business_id = $2`, [id, bid] ); if (!cur.rows[0]) { err(res, 404, "Cita no encontrada"); return; } const appointment = await withTx(async (c) => { const { rows } = await c.query( `UPDATE appointments SET status = 'cancelled', cancelled_by = $3, cancel_reason = $4, updated_at = now() WHERE id = $1 AND business_id = $2 RETURNING ${COLS}`, [id, bid, cancelled_by, reason || null] ); await c.query( `INSERT INTO appointment_events (appointment_id, actor_user_id, action, from_status, to_status, detail) VALUES ($1,$2,'cancelled',$3,'cancelled',$4::jsonb)`, [ id, req.user!.id, cur.rows[0].status, JSON.stringify({ cancelled_by, reason: reason || null }), ] ); await writeAudit(c, { businessId: bid, actorUserId: req.user!.id, entity: "appointments", entityId: id, action: "cancel", before: cur.rows[0], after: rows[0], ip: req.ip ?? null, }); await encolar(c, { businessId: bid!, entidad: "appointment", entidadId: id, operacion: "status", payload: { status: "cancelled", cancelled_by }, secuencia: "cancelled", }); return rows[0]; }); res.json({ appointment }); }) );