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 { bizDayBoundsIsoFor, bizTodayISO, DEFAULT_TZ } from "../../server/lib/time.ts"; export const dayCloseRouter = Router(); const APPT_COLS = `a.id, a.client_id, a.employee_id, a.service_id, to_char(a.start_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"') AS start_at, to_char(a.end_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"') AS end_at, a.status, a.price, c.name AS client_name, e.name AS employee_name, s.name AS service_name`; // `bizDayBoundsIsoFor` devuelve un fin INCLUSIVO (23:59:59 hora local), así que // la comparación es `<=`. Con `<` se perdería la última cita del día. const IN_DAY = `a.start_at >= $2::timestamptz AND a.start_at <= $3::timestamptz`; const UNRESOLVED_SQL = ` SELECT ${APPT_COLS} FROM appointments a JOIN clients c ON c.id = a.client_id JOIN employees e ON e.id = a.employee_id JOIN services s ON s.id = a.service_id WHERE a.business_id = $1 AND ${IN_DAY} AND a.status = 'scheduled' ORDER BY a.start_at`; const COUNTS_SQL = ` SELECT count(*) FILTER (WHERE status = 'completed')::int AS attended, count(*) FILTER (WHERE status = 'no_show')::int AS no_show, count(*) FILTER (WHERE status = 'cancelled')::int AS cancelled FROM appointments WHERE business_id = $1 AND start_at >= $2::timestamptz AND start_at <= $3::timestamptz`; async function bizTz(businessId: number): Promise { const { rows } = await pool.query(`SELECT timezone FROM businesses WHERE id = $1`, [ businessId, ]); return rows[0]?.timezone || DEFAULT_TZ; } dayCloseRouter.get( "/", h(async (req: AuthedRequest, res) => { const bid = req.user!.business_id!; const tz = await bizTz(bid); const date = (req.query.date as string | undefined) || bizTodayISO(tz); if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) { err(res, 400, "Fecha no válida"); return; } const { start, end } = bizDayBoundsIsoFor(tz, date); const unresolved = await pool.query(UNRESOLVED_SQL, [bid, start, end]); const counts = await pool.query(COUNTS_SQL, [bid, start, end]); const closure = await pool.query( `SELECT closed_at FROM day_closures WHERE business_id = $1 AND business_date = $2::date`, [bid, date] ); res.json({ date, closed_at: closure.rows[0]?.closed_at ?? null, unresolved: unresolved.rows, attended: counts.rows[0].attended, no_show: counts.rows[0].no_show, cancelled: counts.rows[0].cancelled, }); }) ); dayCloseRouter.post( "/", h(async (req: AuthedRequest, res) => { const bid = req.user!.business_id!; const tz = await bizTz(bid); const date = (req.body?.date as string | undefined) || bizTodayISO(tz); if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) { err(res, 400, "Fecha no válida"); return; } const { start, end } = bizDayBoundsIsoFor(tz, date); const ya = await pool.query( `SELECT 1 FROM day_closures WHERE business_id = $1 AND business_date = $2::date`, [bid, date] ); if (ya.rows[0]) { err(res, 409, "Ese día ya está cerrado"); return; } // La regla que sostiene todo el proyecto: no se puede cerrar el día dejando // citas sin desenlace. Es lo que convierte el registro en el camino más // corto para trabajar, en vez de una tarea añadida al final. const pend = await pool.query(UNRESOLVED_SQL, [bid, start, end]); if (pend.rows.length) { res.status(409).json({ error: `Quedan ${pend.rows.length} cita(s) sin resolver: marca si vinieron o no antes de cerrar`, unresolved: pend.rows, }); return; } const closure = await withTx(async (c) => { const counts = await c.query(COUNTS_SQL, [bid, start, end]); const { rows } = await c.query( `INSERT INTO day_closures (business_id, business_date, closed_by_user_id, attended_count, no_show_count, cancelled_count) VALUES ($1,$2::date,$3,$4,$5,$6) RETURNING id, business_id, to_char(business_date, 'YYYY-MM-DD') AS business_date, closed_by_user_id, closed_at, attended_count, no_show_count, cancelled_count`, [ bid, date, req.user!.id, counts.rows[0].attended, counts.rows[0].no_show, counts.rows[0].cancelled, ] ); await writeAudit(c, { businessId: bid, actorUserId: req.user!.id, entity: "day_closures", entityId: rows[0].id, action: "close", after: rows[0], ip: req.ip ?? null, }); return rows[0]; }); res.json({ closure }); }) );