Multi-tenancy con credenciales por negocio y sincronizacion por id #1
@@ -8,3 +8,15 @@ data
|
|||||||
graphify-out
|
graphify-out
|
||||||
screenshots
|
screenshots
|
||||||
docs
|
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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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 <GH_USER>` 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 @{ '<APP_DB_PASSWORD>' = '...' }
|
||||||
|
```
|
||||||
|
|
||||||
|
## 6. Verificar (lectura)
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
& "$S\Test-E3Service.ps1" -AppName agendapro -Subdomain agendapro
|
||||||
|
```
|
||||||
|
|
||||||
|
## Rollback
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
& "$S\Invoke-E3Rollback.ps1" -AppName agendapro
|
||||||
|
```
|
||||||
@@ -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=<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=<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
|
||||||
+1
-1
@@ -53,6 +53,7 @@
|
|||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-router-dom": "^6.26.2",
|
"react-router-dom": "^6.26.2",
|
||||||
"recharts": "^2.12.7",
|
"recharts": "^2.12.7",
|
||||||
|
"tsx": "^4.19.1",
|
||||||
"zod": "^3.23.8"
|
"zod": "^3.23.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -71,7 +72,6 @@
|
|||||||
"playwright": "^1.62.0",
|
"playwright": "^1.62.0",
|
||||||
"postcss": "^8.4.47",
|
"postcss": "^8.4.47",
|
||||||
"tailwindcss": "^3.4.13",
|
"tailwindcss": "^3.4.13",
|
||||||
"tsx": "^4.19.1",
|
|
||||||
"typescript": "^5.6.2",
|
"typescript": "^5.6.2",
|
||||||
"vite": "^5.4.8"
|
"vite": "^5.4.8"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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"]
|
||||||
+49
-1
@@ -1,4 +1,8 @@
|
|||||||
import express from "express";
|
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 cors from "cors";
|
||||||
import { authRequired } from "./lib/auth.ts";
|
import { authRequired } from "./lib/auth.ts";
|
||||||
import { authRouter } from "./routes/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 { messagesRouter } from "./routes/messages.ts";
|
||||||
import { arrancarWorker } from "./crm/worker.ts";
|
import { arrancarWorker } from "./crm/worker.ts";
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
export function createApp() {
|
export function createApp() {
|
||||||
const app = express();
|
const app = express();
|
||||||
app.use(cors());
|
app.use(cors());
|
||||||
app.use(express.json({ limit: "1mb" }));
|
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/auth", authRouter);
|
||||||
app.use("/api/business", authRequired, businessRouter);
|
app.use("/api/business", authRequired, businessRouter);
|
||||||
app.use("/api/clients", authRequired, clientsRouter);
|
app.use("/api/clients", authRequired, clientsRouter);
|
||||||
@@ -29,6 +51,15 @@ export function createApp() {
|
|||||||
app.use("/api/crm", authRequired, crmRouter);
|
app.use("/api/crm", authRequired, crmRouter);
|
||||||
app.use("/api/messages", authRequired, messagesRouter);
|
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
|
// 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.
|
// devuelve el HTML de stack de Express y el cliente no puede leer el mensaje.
|
||||||
app.use(
|
app.use(
|
||||||
@@ -49,7 +80,24 @@ export function createApp() {
|
|||||||
const invoked = process.argv[1]?.replace(/\\/g, "/") ?? "";
|
const invoked = process.argv[1]?.replace(/\\/g, "/") ?? "";
|
||||||
if (invoked.endsWith("platform/index.ts")) {
|
if (invoked.endsWith("platform/index.ts")) {
|
||||||
const port = Number(process.env.PLATFORM_PORT) || 3100;
|
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
|
// Despacha la bandeja hacia el CRM. No se arranca en `createApp()` para que
|
||||||
// las pruebas no salgan a la red por su cuenta.
|
// las pruebas no salgan a la red por su cuenta.
|
||||||
arrancarWorker(60_000);
|
arrancarWorker(60_000);
|
||||||
|
|||||||
@@ -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)),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@ import { Router } from "express";
|
|||||||
import { pool, withTx } from "../db/pool.ts";
|
import { pool, withTx } from "../db/pool.ts";
|
||||||
import { writeAudit } from "../lib/audit.ts";
|
import { writeAudit } from "../lib/audit.ts";
|
||||||
import { err, h, type AuthedRequest } from "../lib/auth.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();
|
export const dayCloseRouter = Router();
|
||||||
|
|
||||||
|
|||||||
@@ -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<string>();
|
||||||
|
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/");
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user