From 2437012c46a2a3dcd6aa31e678c3516b59a1a42f Mon Sep 17 00:00:00 2001 From: AgendaPro Dev Date: Sun, 30 Aug 2026 15:33:28 -0600 Subject: [PATCH] feat(deploy): empaquetar platform/ y desplegarlo en el server E3 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .dockerignore | 12 +++ .github/workflows/deploy.yml | 33 ++++++ deploy/README-deploy.md | 67 ++++++++++++ deploy/agendapro.yml | 89 ++++++++++++++++ package.json | 2 +- platform/Dockerfile | 53 ++++++++++ platform/index.ts | 50 ++++++++- platform/lib/time.ts | 156 ++++++++++++++++++++++++++++ platform/routes/dayClose.ts | 2 +- platform/test/autocontenido.test.ts | 53 ++++++++++ 10 files changed, 514 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/deploy.yml create mode 100644 deploy/README-deploy.md create mode 100644 deploy/agendapro.yml create mode 100644 platform/Dockerfile create mode 100644 platform/lib/time.ts create mode 100644 platform/test/autocontenido.test.ts diff --git a/.dockerignore b/.dockerignore index f9de826..b2c97d4 100644 --- a/.dockerignore +++ b/.dockerignore @@ -8,3 +8,15 @@ data graphify-out screenshots docs + +# Credenciales: nunca dentro de la imagen. Llegan como variables de entorno al +# desplegar. `.env` ya está gitignorado, pero el contexto de Docker no mira el +# .gitignore — hay que decirlo aquí también. +.env +*.env +!*.env.example +platform/.env + +# Respaldos y estado local +.cache +platform/data diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..e91cad1 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,33 @@ +name: build-and-push + +on: + push: + branches: [ main ] + workflow_dispatch: + +permissions: + contents: read + packages: write + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: docker/setup-buildx-action@v3 + - uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - uses: docker/build-push-action@v6 + with: + context: . + push: true + tags: | + ghcr.io/${{ github.repository }}:latest + ghcr.io/${{ github.repository }}:${{ github.sha }} + cache-from: type=gha + cache-to: type=gha,mode=max + - name: Tag desplegable + run: echo "Despliega ghcr.io/${{ github.repository }}:${{ github.sha }}" >> $GITHUB_STEP_SUMMARY diff --git a/deploy/README-deploy.md b/deploy/README-deploy.md new file mode 100644 index 0000000..2cc0aee --- /dev/null +++ b/deploy/README-deploy.md @@ -0,0 +1,67 @@ +# Desplegar agendapro en el server E3 + +Destino: **https://agendapro.consultoriae3.com** * Imagen: `agendapro:latest` * Puerto interno: **3100** + +```powershell +$S = "$env:USERPROFILE\.claude\skills\contabo-e3\scripts" +``` + +## 1. Publicar la imagen + +Haz push a `main`: el workflow `.github/workflows/deploy.yml` construye y publica en +`ghcr.io/urieljarethbusiness-cpu/agendamax`. Toma el tag por SHA del resumen del run y usalo en el stack +(`:latest` no fuerza el re-pull en Swarm). + +El paquete de GHCR nace **privado**. Elige: +- **Publico** (recomendado si el codigo no es sensible): GitHub > Packages > el paquete > + Package settings > Change visibility > Public. El server hace pull sin credenciales. +- **Privado**: en el server, `docker login ghcr.io -u ` con un PAT `read:packages`, + y desplegar con `--with-registry-auth`. + +## 2. A-record en SiteGround + +El DNS de consultoriae3.com lo sirve SiteGround. Site Tools > Domain > DNS Zone Editor > A: + + Type: A * Name: agendapro * Value: 157.173.205.217 + +Verifica: `Resolve-DnsName agendapro.consultoriae3.com -Type A` + +## 3. Base de datos + +Necesita Postgres. Provisiona DB + rol dedicados (ESCRITURA, pide confirmacion): + + & "$S\New-E3Database.ps1" -AppName agendapro + +Crea la DB `agendapro` con owner `agendapro_app` y guarda la password en +/root/dados_vps/dados_agendapro (root-only, nunca en git). + +## 4. Preflight (lectura, no toca nada) + +```powershell +& "$S\Test-E3Preflight.ps1" -ProjectPath "H:\MegaSync\Proyectos\AgendaPro" -AppName agendapro -Subdomain agendapro +``` + +## 5. Desplegar (ESCRITURA) + +```powershell +& "$S\Deploy-E3Stack.ps1" -StackFile "H:\MegaSync\Proyectos\AgendaPro\deploy\agendapro.yml" -AppName agendapro +``` + +Si el YAML lleva placeholders de secretos, pasalos al desplegar: + +```powershell +& "$S\Deploy-E3Stack.ps1" -StackFile "H:\MegaSync\Proyectos\AgendaPro\deploy\agendapro.yml" -AppName agendapro ` + -Replace @{ '' = '...' } +``` + +## 6. Verificar (lectura) + +```powershell +& "$S\Test-E3Service.ps1" -AppName agendapro -Subdomain agendapro +``` + +## Rollback + +```powershell +& "$S\Invoke-E3Rollback.ps1" -AppName agendapro +``` diff --git a/deploy/agendapro.yml b/deploy/agendapro.yml new file mode 100644 index 0000000..d1e9065 --- /dev/null +++ b/deploy/agendapro.yml @@ -0,0 +1,89 @@ +# Stack de despliegue de 'agendapro' en el server E3 (Docker Swarm single-node + Traefik). +# Generado por New-E3Scaffold.ps1 - https://agendapro.consultoriae3.com +# +# NO EDITES A LA LIGERA: +# - Las labels de Traefik van bajo deploy.labels. Fuera de ahi se IGNORAN (servicio 1/1 + HTTP 404). +# - Sin clave 'build:' - Swarm no construye; la imagen tiene que existir ya. +# - Sin clave 'ports:' - el unico que publica al host es Traefik (80/443). +# - Los marcadores de secreto se sustituyen AL DESPLEGAR (Deploy-E3Stack.ps1 -SecretsFile). +# No los rellenes con valores reales aqui si este archivo se versiona en git. + +version: "3.7" + +services: + agendapro: + # Taguear por SHA y no ':latest': con un tag rodante Swarm no vuelve a + # hacer pull y un 'service update' no jala la imagen nueva. + image: agendapro:6d67b23 + + networks: + - network_public + + # El healthcheck consulta la base, no solo el puerto: un proceso vivo con + # Postgres caido no esta sano, y sin esto Swarm lo daria por bueno. + healthcheck: + test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:3100/api/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 40s + + environment: + - NODE_ENV=production + # La variable se llama PLATFORM_PORT, no PORT: `PORT` es la del backend de + # demo de server/, que es otro proceso. Con la equivocada, el servidor + # escucharia en 3100 por defecto igualmente, pero por casualidad. + - PLATFORM_PORT=3100 + - HOST=0.0.0.0 + # UTC, NO America/Mexico_City. + # + # La agenda saca la zona de `businesses.timezone`, no del proceso, y las + # pruebas corren en UTC a proposito para que la del proceso y la del + # negocio nunca coincidan. Poner aqui la de Mexico haria que una recaida a + # la zona del proceso pasara desapercibida en produccion, que es + # exactamente el fallo que ya costo un incidente en este repo. + - TZ=UTC + + # Postgres 14 compartido - host = nombre de servicio en network_public + # Se usa la URL completa del archivo de secretos, que New-E3Database.ps1 ya + # deja armada. El andamiaje generaba un marcador de solo-la-contrasena con + # otro nombre del que usa ese archivo, se quedaba sin resolver, y el + # servicio arrancaba sin poder conectar a Postgres. + - DATABASE_URL= + + # Clave maestra con la que se cifran los tokens de subcuenta de cada + # negocio. Sin ella el servidor arranca pero NINGUNA credencial del CRM se + # puede descifrar. Se inyecta al desplegar; jamas va en este archivo. + - CRM_MASTER_KEY= + + deploy: + mode: replicated + replicas: 1 + placement: + constraints: + - node.role == manager + resources: + limits: + cpus: "1" + memory: 512M + restart_policy: + condition: on-failure + delay: 10s + update_config: + order: start-first + failure_action: rollback + labels: + - traefik.enable=true + - traefik.docker.network=network_public + - traefik.http.routers.agendapro.rule=Host(`agendapro.consultoriae3.com`) + - traefik.http.routers.agendapro.entrypoints=websecure + - traefik.http.routers.agendapro.tls=true + - traefik.http.routers.agendapro.tls.certresolver=letsencryptresolver + - traefik.http.routers.agendapro.service=agendapro + - traefik.http.services.agendapro.loadbalancer.server.port=3100 + - traefik.http.services.agendapro.loadbalancer.passHostHeader=1 + +networks: + network_public: + external: true + name: network_public diff --git a/package.json b/package.json index d4d843a..0213943 100644 --- a/package.json +++ b/package.json @@ -53,6 +53,7 @@ "react-dom": "^18.3.1", "react-router-dom": "^6.26.2", "recharts": "^2.12.7", + "tsx": "^4.19.1", "zod": "^3.23.8" }, "devDependencies": { @@ -71,7 +72,6 @@ "playwright": "^1.62.0", "postcss": "^8.4.47", "tailwindcss": "^3.4.13", - "tsx": "^4.19.1", "typescript": "^5.6.2", "vite": "^5.4.8" }, diff --git a/platform/Dockerfile b/platform/Dockerfile new file mode 100644 index 0000000..6a4599e --- /dev/null +++ b/platform/Dockerfile @@ -0,0 +1,53 @@ +# ---- Frontend compilado ---- +FROM node:22-slim AS web-build +WORKDIR /app +COPY package*.json ./ +RUN npm ci +COPY . . +RUN npm run build + +# ---- Runtime ---- +FROM node:22-slim +WORKDIR /app + +ENV NODE_ENV=production +ENV HOST=0.0.0.0 +ENV PLATFORM_PORT=3100 +# La zona del contenedor se fija a UTC, que es como Postgres almacena los +# instantes. La lógica de agenda no depende de ella —usa `businesses.timezone`— +# y dejarla en UTC hace que una recaída a la zona del proceso se note aquí en +# vez de esconderse en el equipo de desarrollo, que está en México. +ENV TZ=UTC + +COPY package*.json ./ +# `--omit=dev` a secas dejaría fuera a `tsx`, que este backend NECESITA en +# tiempo de ejecución porque corre TypeScript directo. Por eso `tsx` está en +# `dependencies` y no en `devDependencies`: arrancar con `npx tsx` lo bajaría +# de npm en cada arranque, sin versión fijada y sobre todo el código del +# servidor. Es un riesgo de cadena de suministro que no compensa. +RUN npm ci --omit=dev && npm cache clean --force + +COPY --from=web-build /app/dist ./dist +COPY platform ./platform +COPY shared ./shared +COPY scripts ./scripts +COPY tsconfig.json ./ + +# `platform/.env` está gitignorado y NO se copia: las credenciales llegan como +# variables de entorno al desplegar. Si alguna vez aparece dentro de la imagen, +# es un fallo del `.dockerignore`. + +EXPOSE 3100 + +# Consulta la base, no solo el puerto: un proceso vivo con Postgres caido no +# esta sano, y sin esto el orquestador lo daria por bueno y no reiniciaria. +HEALTHCHECK --interval=30s --timeout=5s --start-period=40s --retries=3 CMD node -e "fetch('http://127.0.0.1:3100/api/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))" + + +# El contenedor no corre como root. +USER node + +# Las migraciones NO se ejecutan aquí: se lanzan como paso explícito del +# despliegue. Correrlas al arrancar hace que dos réplicas migren a la vez sobre +# la misma base, y que un arranque fallido deje el esquema a medias. +CMD ["node", "scripts/run-tsx.mjs", "platform/index.ts"] diff --git a/platform/index.ts b/platform/index.ts index 09b7515..b186d62 100644 --- a/platform/index.ts +++ b/platform/index.ts @@ -1,4 +1,8 @@ import express from "express"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { pool } from "./db/pool.ts"; import cors from "cors"; import { authRequired } from "./lib/auth.ts"; import { authRouter } from "./routes/auth.ts"; @@ -12,11 +16,29 @@ import { crmRouter } from "./routes/crm.ts"; import { messagesRouter } from "./routes/messages.ts"; import { arrancarWorker } from "./crm/worker.ts"; +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + export function createApp() { const app = express(); app.use(cors()); app.use(express.json({ limit: "1mb" })); + /** + * Salud del servicio. + * + * Consulta la base a propósito. Un health check que solo responde `{ok:true}` + * sigue en verde con Postgres caído o el disco lleno — justo el escenario en + * que el orquestador debería reiniciar y no lo haría. + */ + app.get("/api/health", async (_req, res) => { + try { + await pool.query("SELECT 1"); + res.json({ ok: true, db: "ok", ts: Date.now() }); + } catch (e: any) { + res.status(503).json({ ok: false, db: "error", error: String(e?.message ?? e) }); + } + }); + app.use("/api/auth", authRouter); app.use("/api/business", authRequired, businessRouter); app.use("/api/clients", authRequired, clientsRouter); @@ -29,6 +51,15 @@ export function createApp() { app.use("/api/crm", authRequired, crmRouter); app.use("/api/messages", authRequired, messagesRouter); + // En producción este proceso también sirve el frontend compilado. El fallback + // de SPA excluye `/api/` para que una ruta de API inexistente devuelva 404 y + // no el index.html, que el cliente no sabría interpretar. + const dist = path.resolve(__dirname, "..", "dist"); + if (fs.existsSync(dist)) { + app.use(express.static(dist)); + app.get(/^(?!\/api\/).*/, (_req, res) => res.sendFile(path.join(dist, "index.html"))); + } + // Traductor final de errores: sin esto, un rechazo dentro de un handler async // devuelve el HTML de stack de Express y el cliente no puede leer el mensaje. app.use( @@ -49,7 +80,24 @@ export function createApp() { const invoked = process.argv[1]?.replace(/\\/g, "/") ?? ""; if (invoked.endsWith("platform/index.ts")) { const port = Number(process.env.PLATFORM_PORT) || 3100; - createApp().listen(port, () => console.log(`[platform] escuchando en :${port}`)); + // 0.0.0.0 explícito: dentro de un contenedor, escuchar solo en localhost deja + // el servicio inalcanzable desde la red del orquestador. + const host = process.env.HOST || "0.0.0.0"; + const server = createApp().listen(port, host, () => + console.log(`[platform] escuchando en ${host}:${port}`) + ); + + // Apagado ordenado: sin esto, cada redespliegue corta las peticiones en vuelo. + for (const senal of ["SIGTERM", "SIGINT"] as const) { + process.on(senal, () => { + console.log(`[platform] ${senal} recibida, cerrando…`); + server.close(() => { + pool.end().finally(() => process.exit(0)); + }); + // Si algo se atasca, no se cuelga para siempre. + setTimeout(() => process.exit(1), 10_000).unref(); + }); + } // Despacha la bandeja hacia el CRM. No se arranca en `createApp()` para que // las pruebas no salgan a la red por su cuenta. arrancarWorker(60_000); diff --git a/platform/lib/time.ts b/platform/lib/time.ts new file mode 100644 index 0000000..fc8775a --- /dev/null +++ b/platform/lib/time.ts @@ -0,0 +1,156 @@ +// platform/lib/time.ts +// +// Helpers puros de zona horaria del negocio. +// +// Son una COPIA de `server/lib/time.ts`, y es deliberado: los dos backends +// conviven sin compartir código —misma decisión que `businessDefaults.ts`— y +// cruzarlos ataría la evolución de uno a la del otro. El coste es duplicar unas +// líneas de funciones puras; el beneficio es que `platform/` se pueda empaquetar +// solo, sin arrastrar el backend de demo dentro de su imagen. +// +// Se descubrió al contenerizar: `dayClose.ts` importaba de `../../server/`, la +// imagen no copiaba `server/`, y el proceso moría al arrancar con +// ERR_MODULE_NOT_FOUND. Un typecheck limpio no lo detecta. +/** Returns the calendar date (YYYY-MM-DD) of the given instant in the given IANA tz. */ +export function bizDateISO(instant: Date, tz: string): string { + return new Intl.DateTimeFormat("en-CA", { + timeZone: tz || "UTC", + year: "numeric", + month: "2-digit", + day: "2-digit", + }).format(instant); // en-CA yields "YYYY-MM-DD" +} + +/** Business "today" (YYYY-MM-DD) for a tz, at the given instant (default: server now). */ +export function bizTodayISO(tz: string, now: Date = new Date()): string { + return bizDateISO(now, tz); +} + +/** tz offset (in minutes) of the given instant, east of UTC positive. */ +function tzOffsetMinutes(instant: Date, tz: string): number { + const parts = new Intl.DateTimeFormat("en-US", { + timeZone: tz || "UTC", + timeZoneName: "longOffset", + }).formatToParts(instant); + const off = parts.find((p) => p.type === "timeZoneName")?.value ?? "GMT+00:00"; + // e.g. "GMT-06:00", "GMT+05:30", "GMT+00:00", or bare "GMT" for UTC + const m = off.match(/GMT([+-])(\d{1,2})(?::(\d{2}))?/); + if (!m) return 0; // bare "GMT" → UTC + const sign = m[1] === "-" ? -1 : 1; + const h = parseInt(m[2], 10); + const min = m[3] ? parseInt(m[3], 10) : 0; + return sign * (h * 60 + min); +} + +/** + * Convert a business-local wall-clock (year, month, day, hh, mm, ss) in `tz` to a UTC Date. + * We first treat the wall-clock as if it were UTC, read the tz offset at that instant, + * then subtract the offset: a west tz (negative offset) is "behind" UTC, so the same + * wall-clock happens later in UTC → UTC = wall − offset. + */ +export function wallToUtcDate( + tz: string, + y: number, + mo: number, + d: number, + hh: number, + mm: number, + ss: number +): Date { + const guess = new Date(Date.UTC(y, mo - 1, d, hh, mm, ss)); + const offsetMin = tzOffsetMinutes(guess, tz); + return new Date(guess.getTime() - offsetMin * 60000); +} + +/** Business-day UTC bounds (start = local 00:00:00, end = local 23:59:59) for today +/- offset. */ +export function bizDayBounds( + tz: string, + dayOffset = 0, + now: Date = new Date() +): { start: Date; end: Date } { + const today = bizDateISO(now, tz); + const [y, m, d] = today.split("-").map(Number); + const base = new Date(Date.UTC(y, m - 1, d)); + base.setUTCDate(base.getUTCDate() + dayOffset); + const ty = base.getUTCFullYear(); + const tm = base.getUTCMonth() + 1; + const td = base.getUTCDate(); + return { + start: wallToUtcDate(tz, ty, tm, td, 0, 0, 0), + end: wallToUtcDate(tz, ty, tm, td, 23, 59, 59), + }; +} + +/** Format a UTC instant as SQLite canonical "YYYY-MM-DD HH:MM:SS" (matches datetime() output). */ +export function toSqliteUtc(d: Date): string { + return d.toISOString().slice(0, 19).replace("T", " "); +} + +/** Format a UTC instant as ISO "YYYY-MM-DDTHH:MM:SSZ" (matches the stored start_at format). */ +export function toIsoUtc(d: Date): string { + return d.toISOString().slice(0, 19) + "Z"; +} + +/** Business-day bounds in SQLite canonical format — pair with `datetime(column) >= ?`. */ +export function bizDayBoundsSqlite( + tz: string, + dayOffset = 0, + now: Date = new Date() +): { start: string; end: string } { + const b = bizDayBounds(tz, dayOffset, now); + return { start: toSqliteUtc(b.start), end: toSqliteUtc(b.end) }; +} + +/** Business-day bounds in ISO-Z format — pair with raw `start_at >= ?`. */ +export function bizDayBoundsIso( + tz: string, + dayOffset = 0, + now: Date = new Date() +): { start: string; end: string } { + const b = bizDayBounds(tz, dayOffset, now); + return { start: toIsoUtc(b.start), end: toIsoUtc(b.end) }; +} + +/** Convenience: convert a business-local wall-clock to a UTC ISO string. */ +export function wallToUtcISO( + tz: string, + y: number, + mo: number, + d: number, + hh: number, + mm: number, + ss: number +): string { + return wallToUtcDate(tz, y, mo, d, hh, mm, ss).toISOString(); +} + +/** Zona horaria por defecto de los negocios (México). Única definición. */ +export const DEFAULT_TZ = "America/Mexico_City"; + +/** + * Día ISO de la semana (1=Lun … 7=Dom) de una fecha natural "YYYY-MM-DD". + * Deriva del string, nunca de un `Date` local, así que es independiente de + * la tz del proceso (en un contenedor UTC `new Date("…").getDay()` puede + * caer en el día anterior). + */ +export function isoDowFromDateStr(dateIso: string): number { + const [y, m, d] = dateIso.split("-").map(Number); + const j = new Date(Date.UTC(y, m - 1, d)).getUTCDay(); // 0=Dom..6=Sáb + return j === 0 ? 7 : j; +} + +/** + * Límites UTC del día natural `dateIso` **en la tz del negocio**, en formato + * ISO-Z. Emparéjalo con `start_at >= ? AND start_at <= ?` (comparación + * lexicográfica sobre el formato canónico almacenado). + * + * A diferencia de `bizDayBoundsIso`, que trabaja con desplazamientos respecto + * de "hoy", este acepta la fecha explícita que pide el cliente. + */ +export function bizDayBoundsIsoFor(tz: string, dateIso: string): { start: string; end: string } { + const [y, m, d] = dateIso.split("-").map(Number); + return { + start: toIsoUtc(wallToUtcDate(tz || DEFAULT_TZ, y, m, d, 0, 0, 0)), + end: toIsoUtc(wallToUtcDate(tz || DEFAULT_TZ, y, m, d, 23, 59, 59)), + }; +} diff --git a/platform/routes/dayClose.ts b/platform/routes/dayClose.ts index d12e627..ee9931b 100644 --- a/platform/routes/dayClose.ts +++ b/platform/routes/dayClose.ts @@ -2,7 +2,7 @@ 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"; +import { bizDayBoundsIsoFor, bizTodayISO, DEFAULT_TZ } from "../lib/time.ts"; export const dayCloseRouter = Router(); diff --git a/platform/test/autocontenido.test.ts b/platform/test/autocontenido.test.ts new file mode 100644 index 0000000..012bbda --- /dev/null +++ b/platform/test/autocontenido.test.ts @@ -0,0 +1,53 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const raiz = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); + +function archivosTs(dir: string): string[] { + const out: string[] = []; + for (const e of fs.readdirSync(dir, { withFileTypes: true })) { + const p = path.join(dir, e.name); + if (e.isDirectory()) out.push(...archivosTs(p)); + else if (/\.(ts|mjs)$/.test(e.name)) out.push(p); + } + return out; +} + +/** + * `platform/` tiene que poder empaquetarse solo. + * + * Esta prueba existe porque `routes/dayClose.ts` importaba + * `../../server/lib/time.ts`, la imagen de Docker no copia `server/`, y el + * proceso moría al arrancar con ERR_MODULE_NOT_FOUND. El typecheck pasaba + * limpio: en el equipo de desarrollo el archivo existe. Solo se vio al + * contenerizar, que es demasiado tarde. + */ +test("platform/ no importa nada de server/: debe poder empaquetarse solo", () => { + const culpables: string[] = []; + for (const f of archivosTs(raiz)) { + const src = fs.readFileSync(f, "utf8"); + // Se buscan importaciones, no menciones en comentarios. + const re = /(?:from|import)\s+["'][^"']*\.\.\/server\/[^"']*["']/g; + if (re.test(src)) culpables.push(path.relative(raiz, f)); + } + assert.deepEqual( + culpables, + [], + `estos archivos de platform/ importan de server/: ${culpables.join(", ")}` + ); +}); + +test("platform/ solo depende de shared/ hacia fuera de su carpeta", () => { + const fuera = new Set(); + for (const f of archivosTs(raiz)) { + const src = fs.readFileSync(f, "utf8"); + for (const m of src.matchAll(/(?:from|import)\s+["'](\.\.\/\.\.\/[^"']+)["']/g)) { + const destino = m[1].replace(/^\.\.\/\.\.\//, "").split("/")[0]; + if (destino !== "shared") fuera.add(`${path.relative(raiz, f)} → ${m[1]}`); + } + } + assert.deepEqual([...fuera], [], "solo se admite salir hacia shared/"); +});