Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2437012c46 |
@@ -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
|
||||
|
||||
@@ -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-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"
|
||||
},
|
||||
|
||||
@@ -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 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);
|
||||
|
||||
@@ -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 { 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();
|
||||
|
||||
|
||||
@@ -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