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]>
141 lines
4.8 KiB
TypeScript
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 });
|
|
})
|
|
);
|