Files
AgendaPro/platform/routes/dayClose.ts
T
AgendaPro DevandClaude Opus 5 2437012c46 feat(deploy): empaquetar platform/ y desplegarlo en el server E3
Prepara el backend Postgres para producción y lo despliega como stack de Swarm
en el servidor de Consultoría E3, detrás de Traefik.

## Lo que le faltaba al proyecto para poder empaquetarse

- **`platform/` importaba de `server/`.** `routes/dayClose.ts` traía los helpers
  de zona horaria de `../../server/lib/time.ts`, contradiciendo lo que el propio
  README declara. El typecheck pasaba limpio porque en el equipo de desarrollo
  el archivo existe; solo se vio al contenerizar, cuando el proceso murió al
  arrancar con ERR_MODULE_NOT_FOUND. Ahora hay `platform/lib/time.ts`, copia
  deliberada —misma decisión que `businessDefaults.ts`— y una prueba que impide
  que vuelva a colarse una importación cruzada.
- **No había endpoint de salud.** `/api/health` consulta la base: uno que solo
  responde `{ok:true}` sigue en verde con Postgres caído, justo cuando el
  orquestador debería reiniciar.
- **No servía el frontend.** Ahora sirve `dist/` con fallback de SPA que excluye
  `/api/`, para que una ruta de API inexistente devuelva 404 y no el index.
- **No había apagado ordenado.** Sin él, cada redespliegue corta las peticiones
  en vuelo. Verificado: la tarea vieja registra el SIGTERM y cierra.

## Imagen

`platform/Dockerfile`, multi-etapa. Corre como `USER node`, fija `TZ=UTC` —la
agenda saca la zona de `businesses.timezone`, y dejar la del proceso en México
haría que una recaída pasara desapercibida— y **no** ejecuta las migraciones al
arrancar: se lanzan como paso explícito del despliegue, porque hacerlo al inicio
deja el esquema a medias si el arranque falla.

`tsx` pasa de devDependencies a dependencies: este backend corre TypeScript
directo y arrancar con `npx tsx` lo bajaría de npm en cada arranque, sin versión
fijada y sobre todo el código del servidor.

## Estado en el servidor

- Base `agendapro` con rol `agendapro_app` de mínimo privilegio, `PUBLIC`
  revocado. Verificado: el rol no ve ninguna tabla de las otras bases.
- Clave maestra de cifrado **generada en el servidor**, nunca copiada de
  desarrollo.
- Stack `agendapro` desplegado, 1/1 réplicas, 5 migraciones aplicadas, 17 tablas.
- Salud, SPA y API de administración verificadas desde dentro de la red overlay.

## Pendiente y bloqueante para el acceso externo

El A-record `agendapro.consultoriae3.com` -> 157.173.205.217 hay que crearlo a
mano en SiteGround. Sin él no hay certificado, porque Let's Encrypt valida por
HTTP-01.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-30 15:33:28 -06:00

141 lines
4.8 KiB
TypeScript

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 "../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<string> {
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 });
})
);