diff --git a/.env b/.env
new file mode 100644
index 0000000..e69de29
diff --git a/.env.development b/.env.development
index edcde4e..14c25d8 100644
--- a/.env.development
+++ b/.env.development
@@ -5,31 +5,42 @@ COMPOSE_PROJECT_NAME=suitecoffee_dev
NODE_ENV=development
# app - app
-APP_LOCAL_PORT=3030
-APP_DOCKER_PORT=3030
+APP_PORT=3030
# auth - app
-AUTH_LOCAL_PORT=4040
-AUTH_DOCKER_PORT=4040
+AUTH_PORT=4040
# tenants - postgres
TENANTS_DB_NAME=dev-postgres
TENANTS_DB_USER=dev-user-postgres
TENANTS_DB_PASS=dev-pass-postgres
-TENANTS_DB_LOCAL_PORT=54321
-TENANTS_DB_DOCKER_PORT=5432
-
# db primaria - postgres
DB_NAME=dev-suitecoffee
DB_USER=dev-user-suitecoffee
DB_PASS=dev-pass-suitecoffee
-DB_LOCAL_PORT=54322
-DB_DOCKER_PORT=5432
# --- secretos para Authentik
-AUTHENTIK_SECRET_KEY=poné_un_valor_largo_y_unico
-AUTHENTIK_DB_PASS=cambia_esto
-AUTHENTIK_BOOTSTRAP_PASSWORD=cambia_esto
-AUTHENTIK_BOOTSTRAP_EMAIL=info.suitecoffee@gmail.com
+AK_SECRET_KEY=Timothy-Yearning-Unzip-Playmate3-Snowiness-Desecrate
+AK_DB_PASS=Doable8
+AK_BOOTSTRAP_PASSWORD=Succulent-Sanded7
+AK_BOOTSTRAP_EMAIL=info.suitecoffee@gmail.com
+
+PG_PASS=gOWjL8V564vyh1aXUcqh4o/xo7eObraaCVZezPi3iw2LzPlU
+AUTHENTIK_SECRET_KEY=11zMsUL57beO+okjeGh7OB3lQdGUWII+VaATHs/zsw1+6KMSTyGfAY0yHpq3C442+3CwrZ/KtjgHBfbv
+AUTHENTIK_ERROR_REPORTING__ENABLED=true
+
+# SMTP Host Emails are sent to
+AUTHENTIK_EMAIL__HOST=localhost
+AUTHENTIK_EMAIL__PORT=25
+# Optionally authenticate (don't add quotation marks to your password)
+AUTHENTIK_EMAIL__USERNAME=info.suitecoffee@gmail.com
+AUTHENTIK_EMAIL__PASSWORD=Succulent-Sanded7
+# Use StartTLS
+AUTHENTIK_EMAIL__USE_TLS=false
+# Use SSL
+AUTHENTIK_EMAIL__USE_SSL=false
+AUTHENTIK_EMAIL__TIMEOUT=10
+# Email address authentik will send from, should have a correct @domain
+AUTHENTIK_EMAIL__FROM=info.suitecoffee@gmail.com
\ No newline at end of file
diff --git a/.env.production b/.env.production
index 5e344a2..054af27 100644
--- a/.env.production
+++ b/.env.production
@@ -5,12 +5,10 @@ COMPOSE_PROJECT_NAME=suitecoffee_prod
NODE_ENV=production
# app - app
-APP_LOCAL_PORT=3000
-APP_DOCKER_PORT=3000
+APP_PORT=3000
# auth - app
-AUTH_LOCAL_PORT=4000
-AUTH_DOCKER_PORT=4000
+AUTH_PORT=4000
# tenants - postgres
TENANTS_DB_NAME=postgres
diff --git a/app.index.js b/app.index.js
new file mode 100644
index 0000000..1d95b89
--- /dev/null
+++ b/app.index.js
@@ -0,0 +1,344 @@
+// services/app/src/index.js
+// ------------------------------------------------------------
+// SuiteCoffee — Servicio APP (UI + APIs negocio)
+// - ESM (Node >=18)
+// - Vistas EJS en ./views (dashboard.ejs, comandas.ejs, etc.)
+// - Sesión compartida con AUTH (cookie: sc.sid, Redis)
+// - Monta routes.legacy.js con requireAuth + withTenant
+// ------------------------------------------------------------
+
+import 'dotenv/config';
+import express from 'express';
+import cors from 'cors';
+import morgan from 'morgan';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+import session from 'express-session';
+import expressLayouts from 'express-ejs-layouts';
+// import RedisStore from "connect-redis";
+import { createClient } from 'redis';
+import { Pool } from 'pg';
+
+// -----------------------------------------------------------------------------
+// Utilidades base
+// -----------------------------------------------------------------------------
+const __filename = fileURLToPath(import.meta.url);
+const __dirname = path.dirname(__filename);
+
+const qi = (ident) => `"${String(ident).replace(/"/g, '""')}"`;
+const CLEAN_HEX = (s) => (String(s || '').toLowerCase().replace(/[^0-9a-f]/g, '') || null);
+const REQUIRED = (...keys) => {
+ const miss = keys.filter((k) => !process.env[k]);
+ if (miss.length) {
+ console.warn(`⚠ Faltan variables de entorno: ${miss.join(', ')}`);
+ }
+};
+
+// -----------------------------------------------------------------------------
+// Validación de entorno mínimo (ajusta nombres si difieren)
+// -----------------------------------------------------------------------------
+REQUIRED(
+ // Sesión
+ 'SESSION_SECRET', 'REDIS_URL',
+ // DB principal
+ 'DB_HOST', 'DB_NAME', 'DB_USER', 'DB_PASS',
+ // DB de tenants
+ 'TENANTS_HOST', 'TENANTS_DB', 'TENANTS_USER', 'TENANTS_PASS'
+);
+
+// -----------------------------------------------------------------------------
+// Pools de PostgreSQL
+// -----------------------------------------------------------------------------
+const mainPool = new Pool({
+ host: process.env.DB_HOST,
+ port: Number(process.env.DB_PORT || 5432),
+ database: process.env.DB_NAME,
+ user: process.env.DB_USER,
+ password: process.env.DB_PASS,
+ max: 10,
+ idleTimeoutMillis: 30_000,
+});
+
+const tenantsPool = new Pool({
+ host: process.env.TENANTS_HOST,
+ port: Number(process.env.TENANTS_PORT || 5432),
+ database: process.env.TENANTS_DB,
+ user: process.env.TENANTS_USER,
+ password: process.env.TENANTS_PASS,
+ max: 10,
+ idleTimeoutMillis: 30_000,
+});
+
+// Autotest (no rompe si falla; sólo loguea)
+(async () => {
+ try {
+ const c = await mainPool.connect();
+ const r = await c.query('SELECT NOW() now');
+ console.log('[APP] DB principal OK. Hora:', r.rows[0].now);
+ c.release();
+ } catch (e) {
+ console.error('[APP] Error al conectar DB principal:', e.message);
+ }
+ try {
+ const c = await tenantsPool.connect();
+ const r = await c.query('SELECT NOW() now');
+ console.log('[APP] DB tenants OK. Hora:', r.rows[0].now);
+ c.release();
+ } catch (e) {
+ console.error('[APP] Error al conectar DB tenants:', e.message);
+ }
+})();
+
+// -----------------------------------------------------------------------------
+// Express + EJS
+// -----------------------------------------------------------------------------
+const app = express();
+app.set('trust proxy', Number(process.env.TRUST_PROXY_HOPS || 2));
+app.use(cors({ origin: true, credentials: true }));
+app.use(express.json());
+app.use(express.urlencoded({ extended: true }));
+app.use(express.static(path.join(__dirname, 'public')));
+
+
+// ----------------------------------------------------------
+// Motor de vistas EJS
+// ----------------------------------------------------------
+
+// Views EJS en ./views
+app.set('views', path.join(__dirname, 'views'));
+app.set('view engine', 'ejs');
+app.use(expressLayouts);
+app.set("layout", "layouts/main");
+
+// Estáticos (si tenés carpeta public/, assets, etc.)
+app.use('/public', express.static(path.join(__dirname, 'public')));
+
+// Middlewares básicos
+app.use(morgan('dev'));
+
+
+// ----------------------------------------------------------
+// Middleware para datos globales
+// ----------------------------------------------------------
+app.use((req, res, next) => {
+ res.locals.currentPath = req.path;
+ res.locals.pageTitle = "SuiteCoffee";
+ res.locals.pageId = "";
+ next();
+});
+
+// ----------------------------------------------------------
+// Rutas de UI
+// ----------------------------------------------------------
+app.get("/", (req, res) => {
+ res.locals.pageTitle = "Inicio";
+ res.locals.pageId = "inicio";
+ res.render("inicio");
+});
+
+// -----------------------------------------------------------------------------
+// Sesión (Redis) — misma cookie que AUTH
+// -----------------------------------------------------------------------------
+
+const SESSION_COOKIE_NAME = process.env.SESSION_COOKIE_NAME || "sc.sid";
+const SESSION_SECRET = process.env.SESSION_SECRET || "pon-una-clave-larga-y-unica";
+const REDIS_URL = process.env.REDIS_URL || "redis://authentik-redis:6379";
+
+// 1) Redis client
+const redis = createClient({ url: REDIS_URL /*, legacyMode: true */ });
+redis.on("error", (err) => console.error("[Redis] Client Error:", err));
+await redis.connect();
+console.log("[Redis] connected");
+
+// 2) Resolver RedisStore (soporta:
+// - v5: factory CJS -> connectRedis(session)
+// - v6/v7: export { RedisStore } ó export default class RedisStore)
+async function resolveRedisStore(session) {
+ const mod = await import("connect-redis"); // ESM/CJS agnóstico
+ // named export (v6/v7)
+ if (typeof mod.RedisStore === "function") return mod.RedisStore;
+ // default export (class ó factory)
+ if (typeof mod.default === "function") {
+ // ¿es clase neweable?
+ if (mod.default.prototype && (mod.default.prototype.get || mod.default.prototype.set)) {
+ return mod.default; // class RedisStore
+ }
+ // si no, asumimos factory antigua
+ const Store = mod.default(session); // connectRedis(session)
+ if (typeof Store === "function") return Store; // class devuelta por factory
+ }
+ // algunos builds CJS exponen la factory bajo mod (poco común)
+ if (typeof mod === "function") {
+ const Store = mod(session);
+ if (typeof Store === "function") return Store;
+ }
+ throw new Error("connect-redis: no se pudo resolver RedisStore (API desconocida).");
+}
+
+const RedisStore = await resolveRedisStore(session);
+
+// 3) Session middleware
+app.use(session({
+ name: SESSION_COOKIE_NAME,
+ secret: SESSION_SECRET,
+ resave: false,
+ saveUninitialized: false,
+ store: new RedisStore({
+ client: redis,
+ prefix: "sc:", // opcional
+ }),
+ proxy: true,
+ cookie: {
+ secure: "auto",
+ httpOnly: true,
+ sameSite: "lax",
+ path: "/", // ¡crítico! visible en / y /auth/*
+ },
+}));
+
+// -----------------------------------------------------------------------------
+// Middlewares de Auth/Tenant para routes.legacy.js
+// -----------------------------------------------------------------------------
+function requireAuth(req, res, next) {
+ if (!req.session?.user) return res.redirect(303, "/auth/login");
+ next();
+}
+
+// Abre un client al DB de tenants y fija search_path al esquema del usuario
+async function withTenant(req, res, next) {
+ try {
+ const hex = CLEAN_HEX(req.session?.user?.tenant_uuid);
+ if (!hex) return res.status(400).json({ error: 'tenant-missing' });
+
+ const schema = `schema_tenant_${hex}`;
+ const client = await tenantsPool.connect();
+
+ // Fijar search_path para que las consultas apunten al esquema del tenant
+ await client.query(`SET SESSION search_path TO ${qi(schema)}, public`);
+
+ // Hacemos el client accesible para los handlers de routes.legacy.js
+ req.pg = client;
+
+ // Liberar el client al finalizar la respuesta
+ const release = () => {
+ try { client.release(); } catch {}
+ };
+ res.on('finish', release);
+ res.on('close', release);
+
+ next();
+ } catch (e) {
+ next(e);
+ }
+}
+
+// No-op (compatibilidad con el archivo legacy si lo pasa al final)
+function done(_req, _res, next) { return next && next(); }
+
+// -----------------------------------------------------------------------------
+// Home / Landing
+// -----------------------------------------------------------------------------
+// app.get('/', (req, res) => {
+// if (req.session?.user) return res.redirect(303, "/inicio");
+// return res.redirect(303, "/auth/login");
+// });
+
+// Página de login
+app.get("/auth/login", (_req, res) => {
+ return res.render("login", { pageTitle: "Iniciar sesión" });
+});
+
+app.get('/', (_req, res) => {
+ return res.render("inicio", { pageTitle: "Bienvenido" });
+});
+
+app.get("/", (_req, res) => res.redirect(303, "/auth/login"));
+
+app.use([
+ "/dashboard",
+ "/comandas",
+ "/estadoComandas",
+ "/productos",
+ "/usuarios",
+ "/reportes",
+ "/compras",
+], requireAuth);
+
+
+// Página para definir contraseña (el form envía al servicio AUTH)
+app.get('/set-password', (req, res) => {
+ const pp = req.session?.pendingPassword;
+ if (!pp) return req.session?.user ? res.redirect('/comandas') : res.redirect('/auth/login');
+
+ res.type('html').send(`
+
+
SuiteCoffee · Definir contraseña
+
+
+
Definir contraseña
+
+
+ `);
+});
+
+// -----------------------------------------------------------------------------
+// Montar rutas legacy (render de EJS y APIs de negocio)
+// -----------------------------------------------------------------------------
+const legacy = await import('./routes.legacy.js');
+legacy.default(app, {
+ requireAuth,
+ withTenant,
+ done,
+ mainPool,
+ tenantsPool,
+ express,
+});
+
+// ----------------------------------------------------------
+// Verificación de conexión
+// ----------------------------------------------------------
+async function verificarConexion() {
+ try {
+ const client = await pool.connect();
+ const res = await client.query('SELECT NOW() AS hora');
+ console.log(`Conexión con la base de datos ${process.env.DB_NAME} fue exitosa.`);
+ console.log('Fecha y hora actual de la base de datos:', res.rows[0].hora);
+ client.release();
+ } catch (error) {
+ console.error('Error al conectar con la base de datos al iniciar:', error.message);
+ console.error('Revisar credenciales y accesos de red.');
+ }
+}
+
+// -----------------------------------------------------------------------------
+// Health + 404 + errores
+// -----------------------------------------------------------------------------
+app.get('/health', (_req, res) => res.status(200).json({ status: 'ok', service: 'app' }));
+
+app.use((req, res) => res.status(404).json({ error: 'not-found', path: req.originalUrl }));
+
+app.use((err, _req, res, _next) => {
+ console.error('[APP] Error:', err);
+ if (res.headersSent) return;
+ res.status(500).json({ error: 'internal-error', detail: err?.message || String(err) });
+});
+
+// -----------------------------------------------------------------------------
+// Arranque
+// -----------------------------------------------------------------------------
+const PORT = Number(process.env.PORT || process.env.APP_LOCAL_PORT || 3030);
+// app.listen(PORT, () => {
+// console.log(`[APP] SuiteCoffee corriendo en http://localhost:${PORT}`);
+// });
+
+(async () => {
+ await verificarConexion();
+ app.listen(PORT, () => {
+ console.log(`[APP] SuiteCoffee corriendo en http://localhost:${PORT}`);
+ });
+})();
\ No newline at end of file
diff --git a/compose.dev.yaml b/compose.dev.yaml
index 2d66784..8f401b7 100644
--- a/compose.dev.yaml
+++ b/compose.dev.yaml
@@ -15,7 +15,7 @@ services:
environment:
NODE_ENV: development # <- fuerza el entorno para que el loader tome .env.development
expose:
- - ${APP_LOCAL_PORT}
+ - ${APP_PORT}
networks:
net:
aliases: [dev-app]
@@ -33,7 +33,7 @@ services:
environment:
NODE_ENV: development # <- fuerza el entorno para que el loader tome .env.development
expose:
- - ${AUTH_LOCAL_PORT}
+ - ${AUTH_PORT}
networks:
net:
aliases: [dev-auth]
@@ -67,71 +67,84 @@ services:
### Authentik ###
#################
- authentik-db:
- image: postgres:16
+ ak-db:
+ image: docker.io/library/postgres:16-alpine
+ env_file:
+ - .env.development
environment:
- POSTGRES_DB: authentik
- POSTGRES_USER: authentik
- POSTGRES_PASSWORD: ${AUTHENTIK_DB_PASS}
+ POSTGRES_DB: ${PG_DB:-authentik}
+ POSTGRES_PASSWORD: ${PG_PASS:?database password required}
+ POSTGRES_USER: ${PG_USER:-authentik}
volumes:
- authentik-db:/var/lib/postgresql/data
networks:
net:
- aliases: [ak-db]
+ aliases: [dev-ak-db]
- authentik-redis:
- image: redis:7-alpine
- command: ["redis-server", "--save", "", "--appendonly", "no"]
+ ak-redis:
+ image: docker.io/library/redis:alpine
+ command: --save 60 1 --loglevel warning
networks:
net:
- aliases: [ak-redis]
+ aliases: [dev-ak-redis]
+ volumes:
+ - ak-redis:/data
- authentik:
+ ak:
image: ghcr.io/goauthentik/server:latest
+ env_file:
+ - .env.development
command: server
environment:
- AUTHENTIK_SECRET_KEY: ${AUTHENTIK_SECRET_KEY}
- AUTHENTIK_DEBUG: "false"
- AUTHENTIK_POSTGRESQL__HOST: authentik-db
- AUTHENTIK_POSTGRESQL__USER: authentik
- AUTHENTIK_POSTGRESQL__NAME: authentik
- AUTHENTIK_POSTGRESQL__PASSWORD: ${AUTHENTIK_DB_PASS}
- AUTHENTIK_REDIS__HOST: authentik-redis
- AUTHENTIK_BOOTSTRAP_PASSWORD: ${AUTHENTIK_BOOTSTRAP_PASSWORD}
- AUTHENTIK_BOOTSTRAP_EMAIL: ${AUTHENTIK_BOOTSTRAP_EMAIL}
+ AUTHENTIK_SECRET_KEY: "Timothy-Yearning-Unzip-Playmate3-Snowiness-Desecrate"
+ AUTHENTIK_DEBUG: false
+ AUTHENTIK_POSTGRESQL__HOST: ak-db
+ AUTHENTIK_POSTGRESQL__NAME: ${PG_DB:-authentik}
+ AUTHENTIK_POSTGRESQL__PASSWORD: ${PG_PASS}
+ AUTHENTIK_POSTGRESQL__USER: ${PG_USER:-authentik}
+ AUTHENTIK_REDIS__HOST: ak-redis
+
+ AUTHENTIK_BOOTSTRAP_PASSWORD: Succulent-Sanded7
+ AUTHENTIK_BOOTSTRAP_EMAIL: info.suitecoffee@gmail.com
AUTHENTIK_HTTP__TRUSTED_PROXY__CIDRS: "0.0.0.0/0,::/0"
- AUTHENTIK_SECURITY__CSRF_TRUSTED_ORIGINS: "https://authentik.suitecoffee.mateosaldain.uy,https://suitecoffee.mateosaldain.uy"
- AUTHENTIK_COOKIE__DOMAIN: "authentik.suitecoffee.mateosaldain.uy"
+ AUTHENTIK_SECURITY__CSRF_TRUSTED_ORIGINS: "https://authentik.suitecoffee.mateosaldain.uy,https://suitecoffee.mateosaldain.uy,https://sso.suitecoffee.uy,https://suitecoffee.uy"
+ AUTHENTIK_COOKIE__DOMAIN: sso.suitecoffee.uy
networks:
net:
- aliases: [authentik]
+ aliases: [dev-authentik]
+ volumes:
+ - ./media:/media
+ - ./custom-templates:/templates
- authentik-worker:
+ ak-worker:
image: ghcr.io/goauthentik/server:latest
command: worker
- depends_on:
- authentik-db:
- condition: service_started
- authentik-redis:
- condition: service_started
environment:
- AUTHENTIK_SECRET_KEY: ${AUTHENTIK_SECRET_KEY}
- AUTHENTIK_POSTGRESQL__HOST: authentik-db
- AUTHENTIK_POSTGRESQL__USER: authentik
- AUTHENTIK_POSTGRESQL__NAME: authentik
- AUTHENTIK_POSTGRESQL__PASSWORD: ${AUTHENTIK_DB_PASS}
- AUTHENTIK_REDIS__HOST: authentik-redis
+ AUTHENTIK_POSTGRESQL__HOST: ak-db
+ AUTHENTIK_POSTGRESQL__NAME: ${PG_DB:-authentik}
+ AUTHENTIK_POSTGRESQL__PASSWORD: ${PG_PASS}
+ AUTHENTIK_POSTGRESQL__USER: ${PG_USER:-authentik}
+ AUTHENTIK_REDIS__HOST: ak-redis
+ AUTHENTIK_SECRET_KEY: ${AUTHENTIK_SECRET_KEY:?secret key required}
+
AUTHENTIK_HTTP__TRUSTED_PROXY__CIDRS: "0.0.0.0/0,::/0"
- AUTHENTIK_SECURITY__CSRF_TRUSTED_ORIGINS: "https://authentik.suitecoffee.mateosaldain.uy,https://suitecoffee.mateosaldain.uy"
- AUTHENTIK_COOKIE__DOMAIN: "authentik.suitecoffee.mateosaldain.uy"
+ AUTHENTIK_SECURITY__CSRF_TRUSTED_ORIGINS: "https://authentik.suitecoffee.mateosaldain.uy,https://suitecoffee.mateosaldain.uy,https://sso.suitecoffee.uy,https://suitecoffee.uy"
+ AUTHENTIK_COOKIE__DOMAIN: "sso.suitecoffee.uy"
networks:
net:
- aliases: [ak-work]
+ aliases: [dev-ak-work]
+ user: root
+ volumes:
+ - /var/run/docker.sock:/var/run/docker.sock
+ - ./media:/media
+ - ./certs:/certs
+ - ./custom-templates:/templates
volumes:
tenants-db:
suitecoffee-db:
authentik-db:
+ ak-redis:
networks:
net:
diff --git a/compose.prod.yaml b/compose.prod.yaml
index 3489b9a..4c990a2 100644
--- a/compose.prod.yaml
+++ b/compose.prod.yaml
@@ -9,7 +9,7 @@ services:
context: ./services/app
dockerfile: Dockerfile.production
expose:
- - ${APP_LOCAL_PORT}
+ - ${APP_PORT}
volumes:
- ./services/app:/app
env_file:
@@ -26,7 +26,7 @@ services:
context: ./services/auth
dockerfile: Dockerfile.production
expose:
- - ${AUTH_LOCAL_PORT}
+ - ${AUTH_PORT}
volumes:
- ./services/auth:/app
env_file:
diff --git a/compose.yaml b/compose.yaml
index 1e0ca8b..c8d979b 100644
--- a/compose.yaml
+++ b/compose.yaml
@@ -3,7 +3,6 @@
name: ${COMPOSE_PROJECT_NAME:-suitecoffee}
services:
-
app:
depends_on:
db:
@@ -11,7 +10,7 @@ services:
tenants:
condition: service_healthy
healthcheck:
- test: ["CMD-SHELL", "curl -fsS http://localhost:${APP_DOCKER_PORT}/health || exit 1"]
+ test: ["CMD-SHELL", "curl -fsS http://localhost:${APP_PORT}/health || exit 1"]
interval: 10s
timeout: 3s
retries: 10
@@ -22,10 +21,10 @@ services:
depends_on:
db:
condition: service_healthy
- authentik:
+ ak:
condition: service_started
healthcheck:
- test: ["CMD-SHELL", "curl -fsS http://localhost:${AUTH_DOCKER_PORT}/health || exit 1"]
+ test: ["CMD-SHELL", "curl -fsS http://localhost:${AUTH_PORT}/health || exit 1"]
interval: 10s
timeout: 3s
retries: 10
@@ -56,32 +55,45 @@ services:
start_period: 10s
restart: unless-stopped
- authentik-db:
+ ak-db:
image: postgres:16-alpine
healthcheck:
- test: ["CMD-SHELL", "pg_isready -U authentik -d authentik"]
- interval: 5s
- timeout: 3s
- retries: 20
+ interval: 30s
+ retries: 5
+ start_period: 20s
+ test:
+ - CMD-SHELL
+ - pg_isready -d $${POSTGRES_DB} -U $${POSTGRES_USER}
+ timeout: 5s
restart: unless-stopped
- authentik-redis:
+ ak-redis:
image: redis:7-alpine
healthcheck:
- test: ["CMD", "redis-cli", "ping"]
- interval: 5s
+ interval: 30s
+ retries: 5
+ start_period: 20s
+ test:
+ - CMD-SHELL
+ - redis-cli ping | grep PONG
timeout: 3s
- retries: 20
restart: unless-stopped
- authentik:
+ ak:
image: ghcr.io/goauthentik/server:latest
depends_on:
- authentik-db: { condition: service_healthy }
- authentik-redis: { condition: service_healthy }
+ ak-db:
+ condition: service_healthy
+ ak-redis:
+ condition: service_healthy
restart: unless-stopped
- authentik-worker:
+ ak-worker:
image: ghcr.io/goauthentik/server:latest
+ depends_on:
+ ak-db:
+ condition: service_started
+ ak-redis:
+ condition: service_started
restart: unless-stopped
\ No newline at end of file
diff --git a/docs/ak.md b/docs/ak.md
new file mode 100644
index 0000000..84c3b8e
--- /dev/null
+++ b/docs/ak.md
@@ -0,0 +1,316 @@
+# Documentación detallada de funciones: `ak.js`
+
+Este documento fue generado automáticamente a partir del archivo `ak.js` proporcionado. Incluye una sección por función detectada, con firma, ubicación, descripción, parámetros, valores de retorno, posibles errores y un ejemplo de uso.
+
+> **Nota:** Las descripciones y tipos se infieren heurísticamente a partir de los nombres, comentarios y cuerpo de cada función. Revise y ajuste donde corresponda.
+
+---
+
+### `getConfig`
+
+**Firma:** `function getConfig()`
+**Ubicación:** línea 28
+
+**Comentario previo en el código:**
+```js
+// ------------------------------------------------------------
+// Cliente para la API Admin de Authentik (v3)
+// - Sin dependencias externas (usa fetch nativo de Node >=18)
+// - ESM compatible
+// - Timeouts, reintentos opcionales y mensajes de error claros
+// - Compatible con services/auth/src/index.js actual
+// ------------------------------------------------------------
+
+// Utiliza AUTHENTIK_BASE_URL y AUTHENTIK_TOKEN para validar y devuelve la configuración (base URL y token) desde variables de entorno.
+// Devuelve la URL base y el Token que se leyó desde .env
+
+/**
+ * @typedef {Object} AkCfg
+ * @property {string} BASE // p.ej. "https://idp.example.com"
+ * @property {string} TOKEN // bearer
+ */
+
+/**
+ * @typedef {Object} AkOpts
+ * @property {Record>} [qs]
+ * @property {any} [body]
+ * @property {number} [timeoutMs=10000]
+ * @property {number} [retries=0]
+ * @property {Record} [headers]
+ */
+```
+**Descripción:** Obtiene Config.
+
+**Parámetros:** *(sin parámetros)*
+
+**Retorna (aprox.):** `{ BASE, TOKEN }`
+
+**Errores/excepciones:**
+- Puede lanzar `Error('[AK_CONFIG] Falta variable AUTHENTIK_BASE_URL')`.
+- Puede lanzar `Error('[AK_CONFIG] Falta variable AUTHENTIK_TOKEN')`.
+
+**Ejemplo de uso:**
+```js
+const result = getConfig();
+console.log(result);
+```
+
+---
+### `akListGroups`
+
+**Firma:** `export async function akListGroups(search = "")`
+**Ubicación:** línea 60
+
+**Comentario previo en el código:**
+```js
+// Listar grupos con búsqueda por nombre/slug
+```
+**Descripción:** Función `akListGroups`. Interactúa con una API HTTP.
+
+**Parámetros:**
+- `search` (opcional, por defecto = `""`): descripción.
+
+**Retorna (aprox.):** `[]`
+
+**Ejemplo de uso:**
+```js
+const result = await akListGroups(search);
+console.log(result);
+```
+
+---
+### `akPatchUserAttributes`
+
+**Firma:** `export async function akPatchUserAttributes(userPk, partialAttrs = {})`
+**Ubicación:** línea 73
+
+**Descripción:** Función `akPatchUserAttributes`.
+
+**Parámetros:**
+- `userPk`: descripción.
+- `partialAttrs` (opcional, por defecto = `{}`): descripción.
+
+**Retorna:** Puede no retornar valor explícito (`void`) o retorna según la rama de ejecución.
+
+**Ejemplo de uso:**
+```js
+const result = await akPatchUserAttributes(userPk, partialAttrs);
+console.log(result);
+```
+
+---
+### `akEnsureGroupForTenant`
+
+**Firma:** `export async function akEnsureGroupForTenant(tenantHex)`
+**Ubicación:** línea 97
+
+**Descripción:** Función `akEnsureGroupForTenant`. Interactúa con una API HTTP. Maneja errores con bloques try/catch.
+
+**Parámetros:**
+- `tenantHex`: descripción.
+
+**Retorna (aprox.):** `found.pk ?? found.id`
+
+**Errores/excepciones:**
+- Puede lanzar `TypeError("akEnsureGroupForTenant: `tenantHex` is required")`.
+
+**Ejemplo de uso:**
+```js
+const result = await akEnsureGroupForTenant(tenantHex);
+console.log(result);
+```
+
+---
+### `akAddUserToGroup`
+
+**Firma:** `export async function akAddUserToGroup(userPk, groupPk)`
+**Ubicación:** línea 130
+
+**Descripción:** Función `akAddUserToGroup`. Interactúa con una API HTTP. Maneja errores con bloques try/catch.
+
+**Parámetros:**
+- `userPk`: descripción.
+- `groupPk`: descripción.
+
+**Retorna (aprox.):** `await akPOST("/core/group_memberships/", { body: { user, group } })`
+
+**Errores/excepciones:**
+- Puede lanzar `TypeError("akAddUserToGroup: `userPk` is required")`.
+- Puede lanzar `TypeError("akAddUserToGroup: `groupPk` is required")`.
+
+**Ejemplo de uso:**
+```js
+const result = await akAddUserToGroup(userPk, groupPk);
+console.log(result);
+```
+
+---
+### `request`
+
+**Firma:** `export async function request(method, path, opts = {}, cfg)`
+**Ubicación:** línea 167
+
+**Comentario previo en el código:**
+```js
+/**
+ * Llamada HTTP genérica con fetch + timeout + manejo de errores.
+ * @param {'GET'|'POST'|'PUT'|'PATCH'|'DELETE'} method
+ * @param {string} path Ruta relativa (ej. "/core/users/") o absoluta; si es relativa se antepone "/api/v3".
+ * @param {AkOpts} [opts]
+ * @param {AkCfg} [cfg] Config inyectada; si se omite se usa getConfig()
+ * @returns {Promise}
+ */
+```
+**Descripción:** Función `request`.
+
+**Parámetros:**
+- `method`: descripción.
+- `path`: descripción.
+- `opts` (opcional, por defecto = `{}`): descripción.
+- `cfg`: descripción.
+
+**Retorna:** Puede no retornar valor explícito (`void`) o retorna según la rama de ejecución.
+
+**Ejemplo de uso:**
+```js
+const result = await request(method, path, opts, cfg);
+console.log(result);
+```
+
+---
+### `akFindUserByEmail`
+
+**Firma:** `export async function akFindUserByEmail(email)`
+**Ubicación:** línea 262
+
+**Comentario previo en el código:**
+```js
+// ------------------------------------------------------------
+// Funciones públicas
+// ------------------------------------------------------------
+```
+**Descripción:** Función `akFindUserByEmail`. Interactúa con una API HTTP.
+
+**Parámetros:**
+- `email`: descripción.
+
+**Retorna (aprox.):** `null`
+
+**Errores/excepciones:**
+- Puede lanzar `TypeError("akFindUserByEmail: `email` is required")`.
+
+**Ejemplo de uso:**
+```js
+const result = await akFindUserByEmail(email);
+console.log(result);
+```
+
+---
+### `akCreateUser`
+
+**Firma:** `export async function akCreateUser(p = {})`
+**Ubicación:** línea 298
+
+**Descripción:** Función `akCreateUser`.
+
+**Parámetros:**
+- `p` (opcional, por defecto = `{}`): descripción.
+
+**Retorna:** Puede no retornar valor explícito (`void`) o retorna según la rama de ejecución.
+
+**Ejemplo de uso:**
+```js
+const result = await akCreateUser(p);
+console.log(result);
+```
+
+---
+### `akSetPassword`
+
+**Firma:** `export async function akSetPassword(userPk, password, requireChange = true)`
+**Ubicación:** línea 349
+
+**Descripción:** Función `akSetPassword`. Interactúa con una API HTTP. Maneja errores con bloques try/catch.
+
+**Parámetros:**
+- `userPk`: descripción.
+- `password`: descripción.
+- `requireChange` (opcional, por defecto = `true`): descripción.
+
+**Retorna (aprox.):** `true`
+
+**Errores/excepciones:**
+- Puede lanzar `TypeError("akSetPassword: `userPk` is required")`.
+- Puede lanzar `TypeError("akSetPassword: `password` is required")`.
+
+**Ejemplo de uso:**
+```js
+const result = await akSetPassword(userPk, password, requireChange);
+console.log(result);
+```
+
+---
+### `akResolveGroupIdByName`
+
+**Firma:** `export async function akResolveGroupIdByName(name)`
+**Ubicación:** línea 373
+
+**Descripción:** Función `akResolveGroupIdByName`.
+
+**Parámetros:**
+- `name`: descripción.
+
+**Retorna (aprox.):** `byName?.pk ?? byName?.id ?? null`
+
+**Errores/excepciones:**
+- Puede lanzar `TypeError("akResolveGroupIdByName: `name` is required")`.
+
+**Ejemplo de uso:**
+```js
+const result = await akResolveGroupIdByName(name);
+console.log(result);
+```
+
+---
+### `akResolveGroupId`
+
+**Firma:** `export async function akResolveGroupId({ id, pk, uuid, name, slug } = {})`
+**Ubicación:** línea 389
+
+**Descripción:** Función `akResolveGroupId`.
+
+**Parámetros:**
+- `{ id`: descripción.
+- `pk`: descripción.
+- `uuid`: descripción.
+- `name`: descripción.
+- `slug }` (opcional, por defecto = `{}`): descripción.
+
+**Retorna:** Puede no retornar valor explícito (`void`) o retorna según la rama de ejecución.
+
+**Ejemplo de uso:**
+```js
+const result = await akResolveGroupId({ id, pk, uuid, name, slug });
+console.log(result);
+```
+
+---
+### `toPk`
+
+**Firma:** `const => toPk(v)`
+**Ubicación:** línea 390
+
+**Descripción:** Función `toPk`.
+
+**Parámetros:**
+- `v`: descripción.
+
+**Retorna (aprox.):** `Number.isFinite(n) ? n : String(v)`
+
+**Ejemplo de uso:**
+```js
+const result = toPk(v);
+console.log(result);
+```
+
+---
\ No newline at end of file
diff --git a/services/app/.env.development b/services/app/.env.development
index 98e361f..d9d6986 100644
--- a/services/app/.env.development
+++ b/services/app/.env.development
@@ -1,29 +1,26 @@
# ===== Runtime =====
NODE_ENV=development
PORT=3030
-APP_LOCAL_PORT=3030
# ===== Session (usa el Redis del stack) =====
# Para DEV podemos reutilizar el Redis de Authentik. En prod conviene uno separado.
-SESSION_SECRET=pon-una-clave-larga-y-unica
-REDIS_URL=redis://authentik-redis:6379
+SESSION_SECRET=Neon*Mammal*Boaster*Ludicrous*Fender8*Crablike
+REDIS_URL=redis://ak-redis:6379
-# ===== DB principal (metadatos de SuiteCoffee) =====
-DB_HOST=dev-tenants
+# # ===== DB principal (metadatos de SuiteCoffee) =====
+DB_HOST=dev-db
+DB_NAME=dev-suitecoffee
DB_PORT=5432
-DB_NAME=dev-postgres
-DB_USER=dev-user-postgres
-DB_PASS=dev-pass-postgres
+DB_USER=dev-user-suitecoffee
+DB_PASS=dev-pass-suitecoffee
-# ===== DB tenants (Tenants de SuiteCoffee) =====
+# # ===== DB tenants (Tenants de SuiteCoffee) =====
TENANTS_HOST=dev-tenants
TENANTS_DB=dev-postgres
+TENANTS_PORT=5432
TENANTS_USER=dev-user-postgres
TENANTS_PASS=dev-pass-postgres
-TENANTS_PORT=5432
-
-# ===== (Opcional) Colores UI, si alguna vista los lee =====
-COL_PRI=452D19 # Marrón oscuro
-COL_SEC=D7A666 # Crema / Café
-COL_BG=FFA500 # Naranja
+SESSION_COOKIE_NAME=sc.sid
+SESSION_SECRET=pon-una-clave-larga-y-unica
+REDIS_URL=redis://authentik-redis:6379
\ No newline at end of file
diff --git a/services/app/src/pages/comandas.html.bak b/services/app/src/bak/comandas.html.bak
similarity index 100%
rename from services/app/src/pages/comandas.html.bak
rename to services/app/src/bak/comandas.html.bak
diff --git a/services/app/src/pages/dashboard.html.bak b/services/app/src/bak/dashboard.html.bak
similarity index 100%
rename from services/app/src/pages/dashboard.html.bak
rename to services/app/src/bak/dashboard.html.bak
diff --git a/services/app/src/pages/estadoComandas.html.bak b/services/app/src/bak/estadoComandas.html.bak
similarity index 100%
rename from services/app/src/pages/estadoComandas.html.bak
rename to services/app/src/bak/estadoComandas.html.bak
diff --git a/services/app/src/bak/reportes.ejs.bak b/services/app/src/bak/reportes.ejs.bak
new file mode 100644
index 0000000..7f5ce28
--- /dev/null
+++ b/services/app/src/bak/reportes.ejs.bak
@@ -0,0 +1,402 @@
+<% /* Reportes - Asistencias y Tickets (Comandas) */ %>
+
+
+
+
Reportes
+ —
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Los archivos Excel se generan como CSV (compatibles). Los PDF se generan con “Imprimir área” del navegador.
+
+
+
+
+
+
+
+
+
+
+
+
+ | Documento |
+ Nombre |
+ Apellido |
+ Fecha |
+ Desde |
+ Hasta |
+ Duración |
+
+
+
+ | Sin datos |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Tickets por mes
+
Cantidad
+
+
+
+
+
+
+
+
+
+
+ | Mes |
+ Tickets |
+ Importe |
+ Ticket promedio |
+
+
+
+ | Sin datos |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/services/app/src/index.js b/services/app/src/index.js
index 66124f5..55f8170 100644
--- a/services/app/src/index.js
+++ b/services/app/src/index.js
@@ -1,44 +1,62 @@
// services/app/src/index.js
// ------------------------------------------------------------
// SuiteCoffee — Servicio APP (UI + APIs negocio)
-// - ESM (Node >=18)
// - Vistas EJS en ./views (dashboard.ejs, comandas.ejs, etc.)
// - Sesión compartida con AUTH (cookie: sc.sid, Redis)
-// - Monta routes.legacy.js con requireAuth + withTenant
// ------------------------------------------------------------
-
-import 'dotenv/config';
-import express from 'express';
-import cors from 'cors';
-import morgan from 'morgan';
-import path from 'node:path';
-import { fileURLToPath } from 'node:url';
-import session from 'express-session';
+import 'dotenv/config'; // Variables de entorno directamente
+// import dotenv from 'dotenv';
+import favicon from 'serve-favicon'; // Favicon
+import express from 'express'; // Framework para enderizado de apps Web
import expressLayouts from 'express-ejs-layouts';
-import { createClient as createRedisClient } from 'redis';
-import * as connectRedis from 'connect-redis';
-import { Pool } from 'pg';
+import session from 'express-session'; // Sessiones de Usuarios
+import cors from 'cors'; // Seguridad en solicitudes de orige
+import { Pool } from 'pg'; // Controlador node-postgres
+import path from 'node:path'; // Rutas del servidor
+import { fileURLToPath } from 'url'; // Converts a file:// URL string or URL object into a platform-specific file
+import { createClient } from 'redis'; // Cnexión con servidor redis
+import morgan from 'morgan'; // Middleware de Express
-// -----------------------------------------------------------------------------
-// Utilidades base
-// -----------------------------------------------------------------------------
+
+// ----------------------------------------------------------
+// Utilidades
+// ----------------------------------------------------------
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
-const RedisStore = connectRedis.default || connectRedis.RedisStore;
-
+const url = v => !v ? "" : (v.startsWith("http") ? v : `/img/productos/${v}`);
+const VALID_IDENT = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
+// Identificadores SQL -> comillas dobles y escape correcto
+const q = (s) => `"${String(s).replace(/"/g, '""')}"`;
const qi = (ident) => `"${String(ident).replace(/"/g, '""')}"`;
const CLEAN_HEX = (s) => (String(s || '').toLowerCase().replace(/[^0-9a-f]/g, '') || null);
-const REQUIRED = (...keys) => {
- const miss = keys.filter((k) => !process.env[k]);
- if (miss.length) {
- console.warn(`⚠ Faltan variables de entorno: ${miss.join(', ')}`);
+
+// Función para verificar que ciertas variables de entorno estén definida
+function checkRequiredEnvVars(...requiredKeys) {
+ const missingKeys = requiredKeys.filter((key) => !process.env[key]); // Filtramos las que NO existen en process.env
+ if (missingKeys.length > 0) { // Si falta alguna, mostramos una advertencia
+ console.warn(
+ `[APP] No se encontraron las siguientes variables de entorno: ${missingKeys.join(', ')}`
+ );
}
-};
+}
+
+// ¿Está permitida la tabla?
+function ensureTable(name) {
+ const t = String(name || '').toLowerCase();
+ if (!ALLOWED_TABLES.includes(t)) throw new Error(`Tabla ${t} no permitida`);
+ return t;
+}
+
+//
+async function getClient() {
+ const client = await mainPool.connect();
+ return client;
+}
// -----------------------------------------------------------------------------
// Validación de entorno mínimo (ajusta nombres si difieren)
// -----------------------------------------------------------------------------
-REQUIRED(
+checkRequiredEnvVars(
// Sesión
'SESSION_SECRET', 'REDIS_URL',
// DB principal
@@ -47,117 +65,338 @@ REQUIRED(
'TENANTS_HOST', 'TENANTS_DB', 'TENANTS_USER', 'TENANTS_PASS'
);
-// -----------------------------------------------------------------------------
-// Pools de PostgreSQL
-// -----------------------------------------------------------------------------
-const mainPool = new Pool({
- host: process.env.DB_HOST,
- port: Number(process.env.DB_PORT || 5432),
- database: process.env.DB_NAME,
- user: process.env.DB_USER,
- password: process.env.DB_PASS,
- max: 10,
- idleTimeoutMillis: 30_000,
-});
-
-const tenantsPool = new Pool({
- host: process.env.TENANTS_HOST,
- port: Number(process.env.TENANTS_PORT || 5432),
- database: process.env.TENANTS_DB,
- user: process.env.TENANTS_USER,
- password: process.env.TENANTS_PASS,
- max: 10,
- idleTimeoutMillis: 30_000,
-});
-
-// Autotest (no rompe si falla; sólo loguea)
-(async () => {
- try {
- const c = await mainPool.connect();
- const r = await c.query('SELECT NOW() now');
- console.log('[APP] DB principal OK. Hora:', r.rows[0].now);
- c.release();
- } catch (e) {
- console.error('[APP] Error al conectar DB principal:', e.message);
- }
- try {
- const c = await tenantsPool.connect();
- const r = await c.query('SELECT NOW() now');
- console.log('[APP] DB tenants OK. Hora:', r.rows[0].now);
- c.release();
- } catch (e) {
- console.error('[APP] Error al conectar DB tenants:', e.message);
- }
-})();
-
-// -----------------------------------------------------------------------------
-// Express + EJS
-// -----------------------------------------------------------------------------
+// ----------------------------------------------------------
+// App
+// ----------------------------------------------------------
const app = express();
-app.set('trust proxy', 1);
-
-// Views EJS en ./views
-app.set('views', path.join(__dirname, 'views'));
-app.set('view engine', 'ejs');
-app.use(expressLayouts);
-app.set("layout", "layouts/main");
-
-// Estáticos (si tenés carpeta public/, assets, etc.)
-app.use('/public', express.static(path.join(__dirname, 'public')));
-
-// Middlewares básicos
-app.use(morgan('dev'));
+app.set('trust proxy', Number(process.env.TRUST_PROXY_HOPS || 2));
app.use(cors({ origin: true, credentials: true }));
app.use(express.json());
+app.use(express.json({ limit: '1mb' }));
app.use(express.urlencoded({ extended: true }));
+app.use(express.static(path.join(__dirname, 'pages')));
+
+
+// ----------------------------------------------------------
+// Motor de vistas EJS
+// ----------------------------------------------------------
+app.set("views", path.join(__dirname, "views"));
+app.set("view engine", "ejs");
+app.set("layout", "layouts/main");
+app.use(expressLayouts);
+app.use(morgan('dev')); // Middlewares básicos
+
+
+// Archivos estáticos que fuerzan la re-descarga de arhivos
+app.use(express.static(path.join(__dirname, "public"), {
+ etag: false, maxAge: 0,
+ setHeaders: (res, path) => {
+ res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
+ }
+}));
+
+app.use('/favicon', express.static(path.join(__dirname, 'public', 'favicon'), { maxAge: '1y' }));
+app.use(favicon(path.join(__dirname, 'public', 'favicon', 'favicon.ico'), { maxAge: '1y' }));
// ----------------------------------------------------------
// Middleware para datos globales
// ----------------------------------------------------------
app.use((req, res, next) => {
res.locals.currentPath = req.path;
- res.locals.pageTitle = "SuiteCoffee";
- res.locals.pageId = "";
+ res.locals.pageTitle = "SuiteCoffee";
+ res.locals.pageId = "";
next();
});
+
+
+
+
+
+
+
+
+
+
+// ----------------------------------------------------------
+// Configuración de Pool principal a PostgreSQL
+// ----------------------------------------------------------
+const mainPool = new Pool({
+ host: process.env.DB_HOST || '',
+ database: process.env.DB_NAME || '',
+ port: Number(process.env.DB_PORT || 5432),
+ user: process.env.DB_USER || '',
+ password: process.env.DB_PASS || '',
+ // ssl: process.env.PGSSL === 'true' ? { rejectUnauthorized: false } : undefined,
+ max: -1,
+ idleTimeoutMillis: 30_000,
+});
+
+// ----------------------------------------------------------
+// Configuración Pool de Tenants a PostgreSQL
+// ----------------------------------------------------------
+const tenantsPool = new Pool({
+ host: process.env.TENANTS_HOST,
+ database: process.env.TENANTS_DB,
+ port: Number(process.env.TENANTS_PORT || 5432),
+ user: process.env.TENANTS_USER,
+ password: process.env.TENANTS_PASS,
+ max: -1,
+ idleTimeoutMillis: 30_000,
+});
+
+// ----------------------------------------------------------
+// Seguridad: Tablas permitidas
+// ----------------------------------------------------------
+
+const ALLOWED_TABLES = [
+ 'roles', 'usuarios', 'usua_roles',
+ 'categorias', 'productos',
+ 'clientes', 'mesas',
+ 'comandas', 'deta_comandas',
+ 'proveedores', 'compras', 'deta_comp_producto',
+ 'mate_primas', 'deta_comp_materias',
+ 'prov_producto', 'prov_mate_prima',
+ 'receta_producto', 'asistencia_resumen_diario',
+ 'asistencia_intervalo', 'vw_compras'
+];
+
+
+
+
+
+
+
+
+
+
+
+// ----------------------------------------------------------
+// Rutas de UI
+// ----------------------------------------------------------
+
+app.get("/", (req, res) => {
+ if (req.session?.user) {
+ res.locals.pageTitle = "Bienvenida";
+ res.locals.pageId = "inicio"; // para el sidebar contextual
+ res.render("inicio");
+ }else{
+ res.redirect(303, "/auth/login");
+ }
+});
+
+app.get("/inicio", (req, res) => {
+ res.locals.pageTitle = "Inicio";
+ res.locals.pageId = "inicio"; // <- importante
+ res.render("inicio");
+});
+
+app.get("/dashboard", requireAuth,(req, res) => {
+ res.locals.pageTitle = "Dashboard";
+ res.locals.pageId = "dashboard"; // <- importante
+ res.render("dashboard");
+});
+
+app.get("/comandas", requireAuth,(req, res) => {
+ res.locals.pageTitle = "Comandas";
+ res.locals.pageId = "comandas"; // <- importante para el sidebar contextual
+ res.render("comandas");
+});
+
+app.get("/estadoComandas", requireAuth,(req, res) => {
+ res.locals.pageTitle = "Estado de Comandas";
+ res.locals.pageId = "estadoComandas";
+ res.render("estadoComandas");
+});
+
+app.get("/productos", requireAuth,(req, res) => {
+ res.locals.pageTitle = "Productos";
+ res.locals.pageId = "productos";
+ res.render("productos");
+});
+
+app.get('/usuarios', requireAuth,(req, res) => {
+ res.locals.pageTitle = 'Usuarios';
+ res.locals.pageId = 'usuarios';
+ res.render('usuarios');
+});
+
+app.get('/reportes', requireAuth,(req, res) => {
+ res.locals.pageTitle = 'Reportes';
+ res.locals.pageId = 'reportes';
+ res.render('reportes');
+});
+
+app.get('/compras', requireAuth,(req, res) => {
+ res.locals.pageTitle = 'Compras';
+ res.locals.pageId = 'compras';
+ res.render('compras');
+});
+
+
+// ----------------------------------------------------------
+// Introspección de esquema
+// ----------------------------------------------------------
+async function loadColumns(client, table) {
+ const sql = `
+ SELECT
+ c.column_name,
+ c.data_type,
+ c.is_nullable = 'YES' AS is_nullable,
+ c.column_default,
+ (SELECT EXISTS (
+ SELECT 1 FROM pg_attribute a
+ JOIN pg_class t ON t.oid = a.attrelid
+ JOIN pg_index i ON i.indrelid = t.oid AND a.attnum = ANY(i.indkey)
+ WHERE t.relname = $1 AND i.indisprimary AND a.attname = c.column_name
+ )) AS is_primary,
+ (SELECT a.attgenerated = 's' OR a.attidentity IN ('a','d')
+ FROM pg_attribute a
+ JOIN pg_class t ON t.oid = a.attrelid
+ WHERE t.relname = $1 AND a.attname = c.column_name
+ ) AS is_identity
+ FROM information_schema.columns c
+ WHERE c.table_schema='public' AND c.table_name=$1
+ ORDER BY c.ordinal_position
+ `;
+ const { rows } = await client.query(sql, [table]);
+ return rows;
+}
+
+async function loadForeignKeys(client, table) {
+ const sql = `
+ SELECT
+ kcu.column_name,
+ ccu.table_name AS foreign_table,
+ ccu.column_name AS foreign_column
+ FROM information_schema.table_constraints tc
+ JOIN information_schema.key_column_usage kcu
+ ON tc.constraint_name = kcu.constraint_name AND tc.table_schema = kcu.table_schema
+ JOIN information_schema.constraint_column_usage ccu
+ ON ccu.constraint_name = tc.constraint_name AND ccu.table_schema = tc.table_schema
+ WHERE tc.table_schema='public' AND tc.table_name=$1 AND tc.constraint_type='FOREIGN KEY'
+ `;
+ const { rows } = await client.query(sql, [table]);
+ const map = {};
+ for (const r of rows) map[r.column_name] = { foreign_table: r.foreign_table, foreign_column: r.foreign_column };
+ return map;
+}
+
+async function loadPrimaryKey(client, table) {
+ const sql = `
+ SELECT a.attname AS column_name
+ FROM pg_index i
+ JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = ANY(i.indkey)
+ JOIN pg_class t ON t.oid = i.indrelid
+ WHERE t.relname = $1 AND i.indisprimary
+ `;
+ const { rows } = await client.query(sql, [table]);
+ return rows.map(r => r.column_name);
+}
+
+// label column for FK options
+async function pickLabelColumn(client, refTable) {
+ const preferred = ['nombre', 'raz_social', 'apodo', 'documento', 'correo', 'telefono'];
+ const { rows } = await client.query(
+ `SELECT column_name, data_type
+ FROM information_schema.columns
+ WHERE table_schema='public' AND table_name=$1
+ ORDER BY ordinal_position`, [refTable]
+ );
+ for (const cand of preferred) {
+ if (rows.find(r => r.column_name === cand)) return cand;
+ }
+ const textish = rows.find(r => /text|character varying|varchar/i.test(r.data_type));
+ if (textish) return textish.column_name;
+ return rows[0]?.column_name || 'id';
+}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
// -----------------------------------------------------------------------------
// Sesión (Redis) — misma cookie que AUTH
// -----------------------------------------------------------------------------
-const SESSION_COOKIE_NAME = 'sc.sid';
-const redis = createRedisClient({ url: process.env.REDIS_URL });
-await redis.connect().catch((e) => console.error('[APP] Redis session error:', e.message));
+const SESSION_COOKIE_NAME = process.env.SESSION_COOKIE_NAME || "sc.sid";
+const SESSION_SECRET = process.env.SESSION_SECRET || "pon-una-clave-larga-y-unica";
+const REDIS_URL = process.env.REDIS_URL || "redis://ak-redis:6379";
-app.use(
- session({
- name: SESSION_COOKIE_NAME,
- store: new RedisStore({ client: redis, prefix: 'sess:' }),
- secret: process.env.SESSION_SECRET || 'change-me',
- resave: false,
- saveUninitialized: false,
- cookie: {
- httpOnly: true,
- sameSite: 'lax',
- secure: process.env.NODE_ENV === 'production',
- // domain: 'suitecoffee.mateosaldain.uy', // (opcional) si lo necesitás
- },
- })
-);
+// 1) Redis client
+const redis = createClient({ url: REDIS_URL /*, legacyMode: true */ });
+redis.on("error", (err) => console.error("[Redis] Client Error:", err));
+await redis.connect();
+console.log("[Redis] connected");
-// Exponer usuario a las vistas
-app.use((req, res, next) => {
- res.locals.user = req.session?.user || null;
- next();
-});
+// 2) Resolver RedisStore (soporta:
+// - v5: factory CJS -> connectRedis(session)
+// - v6/v7: export { RedisStore } ó export default class RedisStore)
+async function resolveRedisStore(session) {
+ const mod = await import("connect-redis"); // ESM/CJS agnóstico
+ // named export (v6/v7)
+ if (typeof mod.RedisStore === "function") return mod.RedisStore;
+ // default export (class ó factory)
+ if (typeof mod.default === "function") {
+ // ¿es clase neweable?
+ if (mod.default.prototype && (mod.default.prototype.get || mod.default.prototype.set)) {
+ return mod.default; // class RedisStore
+ }
+ // si no, asumimos factory antigua
+ const Store = mod.default(session); // connectRedis(session)
+ if (typeof Store === "function") return Store; // class devuelta por factory
+ }
+ // algunos builds CJS exponen la factory bajo mod (poco común)
+ if (typeof mod === "function") {
+ const Store = mod(session);
+ if (typeof Store === "function") return Store;
+ }
+ throw new Error("connect-redis: no se pudo resolver RedisStore (API desconocida).");
+}
+
+const RedisStore = await resolveRedisStore(session);
+
+// 3) Session middleware
+app.use(session({
+ name: SESSION_COOKIE_NAME,
+ secret: SESSION_SECRET,
+ resave: false,
+ saveUninitialized: false,
+ store: new RedisStore({
+ client: redis,
+ prefix: "sc:", // opcional
+ }),
+ proxy: true,
+ cookie: {
+ secure: "auto",
+ httpOnly: true,
+ sameSite: "lax",
+ path: "/", // ¡crítico! visible en / y /auth/*
+ },
+}));
// -----------------------------------------------------------------------------
// Middlewares de Auth/Tenant para routes.legacy.js
// -----------------------------------------------------------------------------
function requireAuth(req, res, next) {
- if (!req.session?.user) {
- // Si querés devolver 401 en lugar de redirigir, cambia esta línea
- return res.redirect('/auth/login');
+ if (!req.session || !req.session.user) {
+ // Podés usar 302 (found) o 303 (see other) para redirección
+ return res.redirect(303, "/auth/login");
}
next();
}
@@ -190,53 +429,436 @@ async function withTenant(req, res, next) {
}
}
-// No-op (compatibilidad con el archivo legacy si lo pasa al final)
-function done(_req, _res, next) { return next && next(); }
+// ----------------------------------------------------------
+// API
+// ----------------------------------------------------------
+app.get('/api/tables', async (_req, res) => {
+ res.json(ALLOWED_TABLES);
+});
-// -----------------------------------------------------------------------------
-// Home / Landing
-// -----------------------------------------------------------------------------
-app.get('/', (req, res) => {
- if (req.session?.user) {
- return res.redirect('/comandas'); // ya logueado → dashboard
+app.get('/api/schema/:table', async (req, res) => {
+ try {
+ const table = ensureTable(req.params.table);
+ const client = await getClient();
+ try {
+ const columns = await loadColumns(client, table);
+ const fks = await loadForeignKeys(client, table);
+ const enriched = columns.map(c => ({ ...c, foreign: fks[c.column_name] || null }));
+ res.json({ table, columns: enriched });
+ } finally { client.release(); }
+ } catch (e) {
+ res.status(400).json({ error: e.message });
}
- return res.render('login', { pageTitle: 'Iniciar sesión' });
});
-// Página para definir contraseña (el form envía al servicio AUTH)
-app.get('/set-password', (req, res) => {
- const pp = req.session?.pendingPassword;
- if (!pp) return req.session?.user ? res.redirect('/comandas') : res.redirect('/auth/login');
+app.get('/api/options/:table/:column', async (req, res) => {
+ try {
+ const table = ensureTable(req.params.table);
+ const column = req.params.column;
+ if (!VALID_IDENT.test(column)) throw new Error('Columna inválida');
- res.type('html').send(`
-
- SuiteCoffee · Definir contraseña
-
-
-
Definir contraseña
-
-
- `);
+ const client = await getClient();
+ try {
+ const fks = await loadForeignKeys(client, table);
+ const fk = fks[column];
+ if (!fk) return res.json([]);
+
+ const refTable = fk.foreign_table;
+ const refId = fk.foreign_column;
+ const labelCol = await pickLabelColumn(client, refTable);
+
+ const sql = `SELECT ${q(refId)} AS id, ${q(labelCol)} AS label FROM ${q(refTable)} ORDER BY ${q(labelCol)} LIMIT 1000`;
+ const result = await client.query(sql);
+ res.json(result.rows);
+ } finally { client.release(); }
+ } catch (e) {
+ res.status(400).json({ error: e.message });
+ }
});
-// -----------------------------------------------------------------------------
-// Montar rutas legacy (render de EJS y APIs de negocio)
-// -----------------------------------------------------------------------------
-const legacy = await import('./routes.legacy.js');
-legacy.default(app, {
- requireAuth,
- withTenant,
- done,
- mainPool,
- tenantsPool,
- express,
+app.get('/api/table/:table', async (req, res) => {
+ try {
+ const table = ensureTable(req.params.table);
+ const limit = Math.min(parseInt(req.query.limit || '100', 10), 1000);
+ const client = await getClient();
+ try {
+ const pks = await loadPrimaryKey(client, table);
+ const orderBy = pks.length ? `ORDER BY ${pks.map(q).join(', ')} DESC` : '';
+ const sql = `SELECT * FROM ${q(table)} ${orderBy} LIMIT ${limit}`;
+ const result = await client.query(sql);
+
+ // Normalizar: siempre devolver objetos {col: valor}
+ const colNames = result.fields.map(f => f.name);
+ let rows = result.rows;
+ if (rows.length && Array.isArray(rows[0])) {
+ rows = rows.map(r => Object.fromEntries(r.map((v, i) => [colNames[i], v])));
+ }
+ res.json(rows);
+ } finally { client.release(); }
+ } catch (e) {
+ res.status(400).json({ error: e.message, code: e.code, detail: e.detail });
+ }
});
+app.post('/api/table/:table', async (req, res) => {
+ const table = ensureTable(req.params.table);
+ const payload = req.body || {};
+ try {
+ const client = await getClient();
+ try {
+ const columns = await loadColumns(client, table);
+ const insertable = columns.filter(c =>
+ !c.is_primary && !c.is_identity && !(c.column_default || '').startsWith('nextval(')
+ );
+ const allowedCols = new Set(insertable.map(c => c.column_name));
+
+ const cols = [];
+ const vals = [];
+ const params = [];
+ let idx = 1;
+ for (const [k, v] of Object.entries(payload)) {
+ if (!allowedCols.has(k)) continue;
+ if (!VALID_IDENT.test(k)) continue;
+ cols.push(q(k));
+ vals.push(`$${idx++}`);
+ params.push(v);
+ }
+
+ if (!cols.length) {
+ const { rows } = await client.query(`INSERT INTO ${q(table)} DEFAULT VALUES RETURNING *`);
+ res.status(201).json({ inserted: rows[0] });
+ } else {
+ const { rows } = await client.query(
+ `INSERT INTO ${q(table)} (${cols.join(', ')}) VALUES (${vals.join(', ')}) RETURNING *`,
+ params
+ );
+ res.status(201).json({ inserted: rows[0] });
+ }
+ } catch (e) {
+ if (e.code === '23503') return res.status(400).json({ error: 'Violación de clave foránea', detail: e.detail });
+ if (e.code === '23505') return res.status(400).json({ error: 'Violación de unicidad', detail: e.detail });
+ if (e.code === '23514') return res.status(400).json({ error: 'Violación de CHECK', detail: e.detail });
+ if (e.code === '23502') return res.status(400).json({ error: 'Campo NOT NULL faltante', detail: e.detail });
+ throw e;
+ }
+ } catch (e) {
+ res.status(400).json({ error: e.message });
+ }
+});
+
+app.get('/api/comandas', async (req, res, next) => {
+ try {
+ const estado = (req.query.estado || '').trim() || null;
+ const limit = Math.min(parseInt(req.query.limit || '200', 10), 1000);
+
+ const { rows } = await mainPool.query(
+ `SELECT * FROM public.f_comandas_resumen($1, $2)`,
+ [estado, limit]
+ );
+ res.json(rows);
+ } catch (e) { next(e); }
+});
+
+
+// Detalle de una comanda (con nombres de productos)
+// GET /api/comandas/:id/detalle
+app.get('/api/comandas/:id/detalle', (req, res, next) =>
+ mainPool.query(
+ `SELECT id_det_comanda, id_producto, producto_nombre,
+ cantidad, pre_unitario, subtotal, observaciones
+ FROM public.v_comandas_detalle_items
+ WHERE id_comanda = $1::int
+ ORDER BY id_det_comanda`,
+ [req.params.id]
+ )
+ .then(r => res.json(r.rows))
+ .catch(next)
+);
+
+// Cerrar comanda (setea estado y fec_cierre en DB)
+app.post('/api/comandas/:id/cerrar', async (req, res, next) => {
+ try {
+ const id = Number(req.params.id);
+ if (!Number.isInteger(id) || id <= 0) {
+ return res.status(400).json({ error: 'id inválido' });
+ }
+ const { rows } = await mainPool.query(
+ `SELECT public.f_cerrar_comanda($1) AS data`,
+ [id]
+ );
+ if (!rows.length || rows[0].data === null) {
+ return res.status(404).json({ error: 'Comanda no encontrada' });
+ }
+ res.json(rows[0].data);
+ } catch (err) { next(err); }
+});
+
+// Abrir (reabrir) comanda
+app.post('/api/comandas/:id/abrir', async (req, res, next) => {
+ try {
+ const id = Number(req.params.id);
+ if (!Number.isInteger(id) || id <= 0) {
+ return res.status(400).json({ error: 'id inválido' });
+ }
+ const { rows } = await mainPool.query(
+ `SELECT public.f_abrir_comanda($1) AS data`,
+ [id]
+ );
+ if (!rows.length || rows[0].data === null) {
+ return res.status(404).json({ error: 'Comanda no encontrada' });
+ }
+ res.json(rows[0].data);
+ } catch (err) { next(err); }
+});
+
+// GET producto + receta
+app.get('/api/rpc/get_producto/:id', async (req, res) => {
+ const id = Number(req.params.id);
+ const { rows } = await mainPool.query('SELECT public.get_producto($1) AS data', [id]);
+ res.json(rows[0]?.data || {});
+});
+
+// POST guardar producto + receta
+
+app.post('/api/rpc/save_producto', async (req, res) => {
+ try {
+ // console.debug('receta payload:', req.body?.receta); // habilitalo si lo necesitás
+ const q = 'SELECT public.save_producto($1,$2,$3,$4,$5,$6,$7::jsonb) AS id_producto';
+ const { id_producto = null, nombre, img_producto = null, precio = 0, activo = true, id_categoria = null, receta = [] } = req.body || {};
+ const params = [id_producto, nombre, img_producto, precio, activo, id_categoria, JSON.stringify(receta || [])];
+ const { rows } = await mainPool.query(q, params);
+ res.json(rows[0] || {});
+ } catch (e) {
+ console.error(e);
+ res.status(500).json({ error: 'save_producto failed' });
+ }
+});
+
+// GET MP + proveedores
+app.get('/api/rpc/get_materia/:id', async (req, res) => {
+ const id = Number(req.params.id);
+ try {
+ const { rows } = await mainPool.query('SELECT public.get_materia_prima($1) AS data', [id]);
+ res.json(rows[0]?.data || {});
+ } catch (e) {
+ console.error(e);
+ res.status(500).json({ error: 'get_materia failed' });
+ }
+});
+
+// SAVE MP + proveedores (array)
+app.post('/api/rpc/save_materia', async (req, res) => {
+ const { id_mat_prima = null, nombre, unidad, activo = true, proveedores = [] } = req.body || {};
+ try {
+ const q = 'SELECT public.save_materia_prima($1,$2,$3,$4,$5::jsonb) AS id_mat_prima';
+ const params = [id_mat_prima, nombre, unidad, activo, JSON.stringify(proveedores || [])];
+ const { rows } = await mainPool.query(q, params);
+ res.json(rows[0] || {});
+ } catch (e) {
+ console.error(e);
+ res.status(500).json({ error: 'save_materia failed' });
+ }
+});
+
+// POST /api/rpc/find_usuarios_por_documentos { docs: ["12345678","09123456", ...] }
+app.post('/api/rpc/find_usuarios_por_documentos', async (req, res) => {
+ try {
+ const docs = Array.isArray(req.body?.docs) ? req.body.docs : [];
+ const sql = 'SELECT public.find_usuarios_por_documentos($1::jsonb) AS data';
+ const { rows } = await mainPool.query(sql, [JSON.stringify(docs)]);
+ res.json(rows[0]?.data || {});
+ } catch (e) {
+ console.error(e);
+ res.status(500).json({ error: 'find_usuarios_por_documentos failed' });
+ }
+});
+
+// POST /api/rpc/import_asistencia { registros: [...], origen?: "AGL_001.txt" }
+app.post('/api/rpc/import_asistencia', async (req, res) => {
+ try {
+ const registros = Array.isArray(req.body?.registros) ? req.body.registros : [];
+ const origen = req.body?.origen || null;
+ const sql = 'SELECT public.import_asistencia($1::jsonb,$2) AS data';
+ const { rows } = await mainPool.query(sql, [JSON.stringify(registros), origen]);
+ res.json(rows[0]?.data || {});
+ } catch (e) {
+ console.error(e);
+ res.status(500).json({ error: 'import_asistencia failed' });
+ }
+});
+
+// Consultar datos de asistencia (raw + pares) para un usuario y rango
+app.post('/api/rpc/asistencia_get', async (req, res) => {
+ try {
+ const { doc, desde, hasta } = req.body || {};
+ const sql = 'SELECT public.asistencia_get($1::text,$2::date,$3::date) AS data';
+ const { rows } = await mainPool.query(sql, [doc, desde, hasta]);
+ res.json(rows[0]?.data || {});
+ } catch (e) {
+ console.error(e); res.status(500).json({ error: 'asistencia_get failed' });
+ }
+});
+
+// Editar un registro crudo y recalcular pares
+app.post('/api/rpc/asistencia_update_raw', async (req, res) => {
+ try {
+ const { id_raw, fecha, hora, modo } = req.body || {};
+ const sql = 'SELECT public.asistencia_update_raw($1::bigint,$2::date,$3::text,$4::text) AS data';
+ const { rows } = await mainPool.query(sql, [id_raw, fecha, hora, modo ?? null]);
+ res.json(rows[0]?.data || {});
+ } catch (e) {
+ console.error(e); res.status(500).json({ error: 'asistencia_update_raw failed' });
+ }
+});
+
+// Eliminar un registro crudo y recalcular pares
+app.post('/api/rpc/asistencia_delete_raw', async (req, res) => {
+ try {
+ const { id_raw } = req.body || {};
+ const sql = 'SELECT public.asistencia_delete_raw($1::bigint) AS data';
+ const { rows } = await mainPool.query(sql, [id_raw]);
+ res.json(rows[0]?.data || {});
+ } catch (e) {
+ console.error(e); res.status(500).json({ error: 'asistencia_delete_raw failed' });
+ }
+});
+
+// POST /api/rpc/report_tickets { year }
+app.post('/api/rpc/report_tickets', async (req, res) => {
+ try {
+ const y = parseInt(req.body?.year ?? req.query?.year, 10);
+ const year = (Number.isFinite(y) && y >= 2000 && y <= 2100)
+ ? y
+ : (new Date()).getFullYear();
+
+ const { rows } = await mainPool.query(
+ 'SELECT public.report_tickets_year($1::int) AS j', [year]
+ );
+ res.json(rows[0].j);
+ } catch (e) {
+ console.error('report_tickets error:', e);
+ res.status(500).json({
+ error: 'report_tickets failed',
+ message: e.message, detail: e.detail, where: e.where, code: e.code
+ });
+ }
+});
+
+// POST /api/rpc/report_asistencia { desde: 'YYYY-MM-DD', hasta: 'YYYY-MM-DD' }
+app.post('/api/rpc/report_asistencia', async (req, res) => {
+ try {
+ let { desde, hasta } = req.body || {};
+ // defaults si vienen vacíos/invalidos
+ const re = /^\d{4}-\d{2}-\d{2}$/;
+ if (!re.test(desde) || !re.test(hasta)) {
+ const end = new Date();
+ const start = new Date(end); start.setDate(end.getDate() - 30);
+ desde = start.toISOString().slice(0, 10);
+ hasta = end.toISOString().slice(0, 10);
+ }
+
+ const { rows } = await mainPool.query(
+ 'SELECT public.report_asistencia($1::date,$2::date) AS j', [desde, hasta]
+ );
+ res.json(rows[0].j);
+ } catch (e) {
+ console.error('report_asistencia error:', e);
+ res.status(500).json({
+ error: 'report_asistencia failed',
+ message: e.message, detail: e.detail, where: e.where, code: e.code
+ });
+ }
+});
+
+// Guardar (insert/update)
+app.post('/api/rpc/save_compra', async (req, res) => {
+ try {
+ const { id_compra, id_proveedor, fec_compra, detalles } = req.body || {};
+ const sql = 'SELECT * FROM public.save_compra($1::int,$2::int,$3::timestamptz,$4::jsonb)';
+ const args = [id_compra ?? null, id_proveedor, fec_compra ? new Date(fec_compra) : null, JSON.stringify(detalles)];
+ const { rows } = await mainPool.query(sql, args);
+ res.json(rows[0]); // { id_compra, total }
+ } catch (e) {
+ console.error('save_compra error:', e);
+ res.status(500).json({ error: 'save_compra failed', message: e.message, detail: e.detail, where: e.where, code: e.code });
+ }
+});
+
+// Obtener para editar
+app.post('/api/rpc/get_compra', async (req, res) => {
+ try {
+ const { id_compra } = req.body || {};
+ const sql = `SELECT public.get_compra($1::int) AS data`;
+ const { rows } = await mainPool.query(sql, [id_compra]);
+ res.json(rows[0]?.data || {});
+ } catch (e) {
+ console.error(e); res.status(500).json({ error: 'get_compra failed' });
+ }
+});
+
+// Eliminar
+app.post('/api/rpc/delete_compra', async (req, res) => {
+ try {
+ const { id_compra } = req.body || {};
+ await mainPool.query(`SELECT public.delete_compra($1::int)`, [id_compra]);
+ res.json({ ok: true });
+ } catch (e) {
+ console.error(e); res.status(500).json({ error: 'delete_compra failed' });
+ }
+});
+
+// POST /api/rpc/report_gastos { year: 2025 }
+app.post('/api/rpc/report_gastos', async (req, res) => {
+ try {
+ const year = parseInt(req.body?.year ?? new Date().getFullYear(), 10);
+ const { rows } = await mainPool.query(
+ 'SELECT public.report_gastos($1::int) AS j', [year]
+ );
+ res.json(rows[0].j);
+ } catch (e) {
+ console.error('report_gastos error:', e);
+ res.status(500).json({
+ error: 'report_gastos failed',
+ message: e.message, detail: e.detail, code: e.code
+ });
+ }
+});
+
+// (Opcional) GET para probar rápido desde el navegador:
+// /api/rpc/report_gastos?year=2025
+app.get('/api/rpc/report_gastos', async (req, res) => {
+ try {
+ const year = parseInt(req.query.year ?? new Date().getFullYear(), 10);
+ const { rows } = await mainPool.query(
+ 'SELECT public.report_gastos($1::int) AS j', [year]
+ );
+ res.json(rows[0].j);
+ } catch (e) {
+ console.error('report_gastos error:', e);
+ res.status(500).json({
+ error: 'report_gastos failed',
+ message: e.message, detail: e.detail, code: e.code
+ });
+ }
+});
+
+
+// ----------------------------------------------------------
+// Verificación de conexión
+// ----------------------------------------------------------
+async function verificarConexion() {
+ try {
+ const client = await mainPool.connect();
+ const res = await client.query('SELECT NOW() AS hora');
+ console.log(`\nConexión con la base de datos ${process.env.DB_NAME} fue exitosa.`);
+ console.log('Fecha y hora actual de la base de datos:', res.rows[0].hora);
+ client.release();
+ } catch (error) {
+ console.error('Error al conectar con la base de datos al iniciar:', error.message);
+ console.error('Revisar credenciales y accesos de red.');
+ }
+}
+
// -----------------------------------------------------------------------------
// Health + 404 + errores
// -----------------------------------------------------------------------------
@@ -250,10 +872,15 @@ app.use((err, _req, res, _next) => {
res.status(500).json({ error: 'internal-error', detail: err?.message || String(err) });
});
-// -----------------------------------------------------------------------------
-// Arranque
-// -----------------------------------------------------------------------------
-const PORT = Number(process.env.PORT || process.env.APP_LOCAL_PORT || 3030);
+
+// ----------------------------------------------------------
+// Inicio del servidor
+// ----------------------------------------------------------
+
+const PORT = process.env.PORT ? Number(process.env.PORT) : 3000;
app.listen(PORT, () => {
- console.log(`[APP] SuiteCoffee corriendo en http://localhost:${PORT}`);
+ console.log(`[APP] Servidor de aplicación escuchando en ${`http://localhost:${PORT}`}`);
+ console.log(`[APP] Comprobando accesibilidad a la db ${process.env.DB_NAME} del host ${process.env.DB_HOST} ...`);
+ verificarConexion();
});
+
diff --git a/services/app/src/routes.legacy.js b/services/app/src/routes.legacy.js
deleted file mode 100644
index fee4e5d..0000000
--- a/services/app/src/routes.legacy.js
+++ /dev/null
@@ -1,337 +0,0 @@
-// services/app/src/routes.legacy.js
-// -----------------------------------------------------------------------------
-// Endpoints legacy de SuiteCoffee extraídos del index original y montados
-// como módulo. No elimina nada; sólo organiza y robustece.
-//
-// Cómo se usa: el nuevo services/app/src/index.js hace
-// const legacy = await import('./routes.legacy.js')
-// legacy.default(app, { requireAuth, withTenant, done, mainPool, tenantsPool, express })
-// -----------------------------------------------------------------------------
-
-export default function mount(app, ctx) {
- const { requireAuth, withTenant, done, mainPool, tenantsPool, express } = ctx;
-
- // Aliases de compatibilidad con el archivo original
- const pool = mainPool; // el original usaba `pool` (DB principal)
-
- // ---------------------------------------------------------------------------
- // Helpers y seguridad (copiados/adaptados del archivo original)
- // ---------------------------------------------------------------------------
- const ALLOWED_TABLES = [
- 'roles','usuarios','usua_roles',
- 'categorias','productos',
- 'clientes','mesas',
- 'comandas','deta_comandas',
- 'proveedores','compras','deta_comp_producto',
- 'mate_primas','deta_comp_materias',
- 'prov_producto','prov_mate_prima',
- 'receta_producto', 'asistencia_resumen_diario',
- 'asistencia_intervalo', 'vw_compras'
- ];
- const VALID_IDENT = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
- const q = (s) => `"${String(s).replace(/"/g, '""')}"`; // quote ident simple
- function ensureTable(name) {
- const t = String(name || '').toLowerCase();
- if (!ALLOWED_TABLES.includes(t)) throw new Error('Tabla no permitida');
- return t;
- }
-
- async function getClient() { // el original devolvía pool.connect()
- const client = await pool.connect();
- return client;
- }
-
- // Columnas de una tabla
- async function loadColumns(client, table) {
- const sql = `
- SELECT
- c.column_name,
- c.data_type,
- c.is_nullable = 'YES' AS is_nullable,
- c.column_default,
- EXISTS (
- SELECT 1 FROM pg_attribute a
- JOIN pg_class t ON t.oid = a.attrelid
- JOIN pg_index i ON i.indrelid = t.oid AND a.attnum = ANY(i.indkey)
- WHERE t.relname = $1 AND i.indisprimary AND a.attname = c.column_name
- ) AS is_primary,
- (
- SELECT a.attgenerated = 's' OR a.attidentity IN ('a','d')
- FROM pg_attribute a
- JOIN pg_class t2 ON t2.oid = a.attrelid
- WHERE t2.relname = $1 AND a.attname = c.column_name
- ) AS is_generated
- FROM information_schema.columns c
- WHERE c.table_schema = 'public' AND c.table_name = $1
- ORDER BY c.ordinal_position`;
- const { rows } = await client.query(sql, [table]);
- return rows;
- }
-
- // PKs de una tabla
- async function loadPrimaryKey(client, table) {
- const sql = `
- SELECT a.attname AS column_name
- FROM pg_index i
- JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = ANY(i.indkey)
- JOIN pg_class t ON t.oid = i.indrelid
- WHERE t.relname = $1 AND i.indisprimary`;
- const { rows } = await client.query(sql, [table]);
- return rows.map(r => r.column_name);
- }
-
- // FKs salientes de una tabla → { [column]: { foreign_table, foreign_column } }
- async function loadForeignKeys(client, table) {
- const sql = `
- SELECT
- kcu.column_name AS column_name,
- ccu.table_name AS foreign_table,
- ccu.column_name AS foreign_column
- FROM information_schema.table_constraints tc
- JOIN information_schema.key_column_usage kcu
- ON tc.constraint_name = kcu.constraint_name
- AND tc.table_schema = kcu.table_schema
- JOIN information_schema.constraint_column_usage ccu
- ON ccu.constraint_name = tc.constraint_name
- AND ccu.table_schema = tc.table_schema
- WHERE tc.constraint_type = 'FOREIGN KEY'
- AND tc.table_schema = 'public'
- AND tc.table_name = $1`;
- const { rows } = await client.query(sql, [table]);
- const map = {};
- for (const r of rows) map[r.column_name] = { foreign_table: r.foreign_table, foreign_column: r.foreign_column };
- return map;
- }
-
- // Heurística para elegir una columna "label" en tablas referenciadas
- async function pickLabelColumn(client, refTable) {
- const preferred = ['nombre','raz_social','apodo','documento','correo','telefono','descripcion','detalle'];
- const { rows } = await client.query(
- `SELECT column_name, data_type
- FROM information_schema.columns
- WHERE table_schema='public' AND table_name=$1
- ORDER BY ordinal_position`, [refTable]
- );
- for (const cand of preferred) if (rows.find(r => r.column_name === cand)) return cand;
- const textish = rows.find(r => /text|character varying|varchar/i.test(r.data_type));
- if (textish) return textish.column_name;
- return rows[0]?.column_name || 'id';
- }
-
- // ---------------------------------------------------------------------------
- // RUTAS DE UI (vistas)
- // ---------------------------------------------------------------------------
- app.get('/', (req, res) => {
- res.locals.pageTitle = 'Dashboard';
- res.locals.pageId = 'home';
- res.render('dashboard');
- });
-
- app.get('/dashboard', (req, res) => {
- res.locals.pageTitle = 'Dashboard';
- res.locals.pageId = 'dashboard';
- res.render('dashboard');
- });
-
- app.get('/comandas', (req, res) => {
- res.locals.pageTitle = 'Comandas';
- res.locals.pageId = 'comandas';
- res.render('comandas');
- });
-
- app.get('/estadoComandas', (req, res) => {
- res.locals.pageTitle = 'Estado de Comandas';
- res.locals.pageId = 'estadoComandas';
- res.render('estadoComandas');
- });
-
- app.get('/productos', (req, res) => {
- res.locals.pageTitle = 'Productos';
- res.locals.pageId = 'productos';
- res.render('productos');
- });
-
- app.get('/usuarios', (req, res) => {
- res.locals.pageTitle = 'Usuarios';
- res.locals.pageId = 'usuarios';
- res.render('usuarios');
- });
-
- app.get('/reportes', (req, res) => {
- res.locals.pageTitle = 'Reportes';
- res.locals.pageId = 'reportes';
- res.render('reportes');
- });
-
- app.get('/compras', (req, res) => {
- res.locals.pageTitle = 'Compras';
- res.locals.pageId = 'compras';
- res.render('compras');
- });
-
- // ---------------------------------------------------------------------------
- // API: ejemplos por-tenant y utilitarios (introspección)
- // ---------------------------------------------------------------------------
- // Ejemplo conservado del original (usar search_path via withTenant)
- app.get('/api/productos', requireAuth, withTenant, async (req, res, next) => {
- const { rows } = await req.pg.query('SELECT * FROM productos ORDER BY id');
- res.json(rows);
- }, done);
-
- // Listado de tablas permitidas
- app.get('/api/tables', async (_req, res) => {
- res.json(ALLOWED_TABLES);
- });
-
- // Esquema de una tabla (columnas + FKs)
- app.get('/api/schema/:table', async (req, res) => {
- try {
- const table = ensureTable(req.params.table);
- const client = await getClient();
- try {
- const columns = await loadColumns(client, table);
- const fks = await loadForeignKeys(client, table);
- const enriched = columns.map(c => ({ ...c, foreign: fks[c.column_name] || null }));
- res.json({ table, columns: enriched });
- } finally { client.release(); }
- } catch (e) {
- res.status(400).json({ error: e.message });
- }
- });
-
- // Opciones para una columna con FK (id/label)
- app.get('/api/options/:table/:column', async (req, res) => {
- try {
- const table = ensureTable(req.params.table);
- const column = req.params.column;
- if (!VALID_IDENT.test(column)) throw new Error('Columna inválida');
-
- const client = await getClient();
- try {
- const fks = await loadForeignKeys(client, table);
- const fk = fks[column];
- if (!fk) return res.json([]);
- const refTable = fk.foreign_table;
- const refId = fk.foreign_column;
- const labelCol = await pickLabelColumn(client, refTable);
- const sql = `SELECT ${q(refId)} AS id, ${q(labelCol)} AS label FROM ${q(refTable)} ORDER BY ${q(labelCol)} LIMIT 1000`;
- const result = await client.query(sql);
- res.json(result.rows);
- } finally { client.release(); }
- } catch (e) {
- res.status(400).json({ error: e.message });
- }
- });
-
- // Datos de una tabla (limitados) — vista rápida
- app.get('/api/table/:table', async (req, res) => {
- try {
- const table = ensureTable(req.params.table);
- const limit = Math.min(parseInt(req.query.limit || '100', 10), 1000);
- const client = await getClient();
- try {
- const pks = await loadPrimaryKey(client, table);
- const order = pks[0] ? q(pks[0]) : '1';
- const sql = `SELECT * FROM ${q(table)} ORDER BY ${order} LIMIT $1`;
- const { rows } = await client.query(sql, [limit]);
- res.json(rows);
- } finally { client.release(); }
- } catch (e) {
- res.status(400).json({ error: e.message, code: e.code, detail: e.detail });
- }
- });
-
- // Crear/actualizar registros genéricos (placeholder: pega aquí tu lógica original)
- app.post('/api/table/:table', async (req, res) => {
- // TODO: Pegar implementación original (insert/update genérico) aquí.
- // Sugerencia: validar payload contra loadColumns(client, table),
- // construir INSERT/UPDATE dinámico ignorando columnas generadas y PKs cuando corresponda.
- res.status(501).json({ error: 'not-implemented', detail: 'Pegar lógica original de POST /api/table/:table' });
- });
-
- // ---------------------------------------------------------------------------
- // Endpoints de negocio (conservados tal cual cuando fue posible)
- // ---------------------------------------------------------------------------
-
- // Detalle de una comanda
- app.get('/api/comandas/:id/detalle', (req, res, next) =>
- pool.query(
- `SELECT id_det_comanda, id_producto, producto_nombre,
- cantidad, pre_unitario, subtotal, observaciones
- FROM public.v_comandas_detalle_items
- WHERE id_comanda = $1::int
- ORDER BY id_det_comanda`,
- [req.params.id]
- )
- .then(r => res.json(r.rows))
- .catch(next)
- );
-
- // ---------------------------------------------------------------------------
- // RPC / Reportes / Procedimientos (stubs con TODO si no se extrajo el SQL)
- // ---------------------------------------------------------------------------
- app.post('/api/rpc/find_usuarios_por_documentos', async (req, res) => {
- // TODO: Pegar el SQL original. Ejemplo:
- // const { documentos } = req.body || {};
- // const { rows } = await pool.query('SELECT * FROM public.find_usuarios_por_documentos($1::jsonb)', [JSON.stringify(documentos||[])])
- // res.json(rows);
- res.status(501).json({ error: 'not-implemented' });
- });
-
- app.post('/api/rpc/import_asistencia', async (req, res) => {
- // TODO: pegar lógica original
- res.status(501).json({ error: 'not-implemented' });
- });
-
- app.post('/api/rpc/asistencia_get', async (req, res) => {
- // TODO
- res.status(501).json({ error: 'not-implemented' });
- });
-
- app.post('/api/rpc/asistencia_update_raw', async (req, res) => {
- // TODO
- res.status(501).json({ error: 'not-implemented' });
- });
-
- app.post('/api/rpc/asistencia_delete_raw', async (req, res) => {
- // TODO
- res.status(501).json({ error: 'not-implemented' });
- });
-
- app.post('/api/rpc/report_tickets', async (req, res) => {
- // TODO: posiblemente public.report_tickets_year(year int)
- res.status(501).json({ error: 'not-implemented' });
- });
-
- app.post('/api/rpc/report_asistencia', async (req, res) => {
- // TODO: posiblemente public.report_asistencia(desde date, hasta date)
- res.status(501).json({ error: 'not-implemented' });
- });
-
- app.get('/api/rpc/report_gastos', async (req, res) => {
- // TODO: pegar la SELECT/función original
- res.status(501).json({ error: 'not-implemented' });
- });
-
- app.post('/api/rpc/report_gastos', async (req, res) => {
- try {
- // Ejemplo de carcasa robusta en base a nombres vistos
- const { desde, hasta } = req.body || {};
- if (!desde || !hasta) return res.status(400).json({ error: 'desde y hasta son requeridos' });
- // TODO: reemplazar por tu SQL real; esto es un placeholder ilutrativo
- const sql = 'SELECT * FROM public.report_gastos($1::date, $2::date)';
- try {
- const { rows } = await pool.query(sql, [desde, hasta]);
- res.json(rows);
- } catch (e) {
- res.status(500).json({ error: 'report_gastos failed', message: e.message, detail: e.detail, code: e.code });
- }
- } catch (e) {
- res.status(500).json({ error: 'report_gastos failed', message: e.message });
- }
- });
-
- app.post('/api/rpc/save_compra', async (req, res) => { res.status(501).json({ error: 'not-implemented' }); });
- app.post('/api/rpc/get_compra', async (req, res) => { res.status(501).json({ error: 'not-implemented' }); });
- app.post('/api/rpc/delete_compra', async (req, res) => { res.status(501).json({ error: 'not-implemented' }); });
-}
diff --git a/services/app/src/views/inicio.ejs b/services/app/src/views/inicio.ejs
new file mode 100644
index 0000000..392e712
--- /dev/null
+++ b/services/app/src/views/inicio.ejs
@@ -0,0 +1,130 @@
+
+
+
+
+
+
+
+ Inicio • SuiteCoffee
+
+
+
+
+
+ <%
+ const hasUser = typeof user !== 'undefined' && user;
+ const hasCookies = typeof cookies !== 'undefined' && cookies && Object.keys(cookies).length;
+ const displayName =
+ (hasUser && (user.name || user.displayName || user.email)) ||
+ (hasCookies && (cookies.user_name || cookies.displayName || cookies.email)) ||
+ 'usuario';
+ %>
+
Hola, <%= displayName %> 👋
+
Bienvenido a SuiteCoffee. Este es tu inicio.
+
+ <% if (hasUser) { %>
+
Sesión
+
+
+ <% for (const [k,v] of Object.entries(user)) { %>
+
+ <%= k %> |
+ <%= typeof v === 'object' ? JSON.stringify(v) : String(v) %> |
+
+ <% } %>
+
+
+ <% } %>
+
+
+
+ Cookies (servidor)
+ <% if (hasCookies) { %>
+
+
+ | Nombre | Valor |
+
+
+ <% for (const [name, value] of Object.entries(cookies)) { %>
+
+ <%= name %> |
+ <%= typeof value === 'object' ? JSON.stringify(value) : String(value) %> |
+
+ <% } %>
+
+
+ <% } else { %>
+ No se recibieron cookies desde el servidor (req.cookies). ¿Estás usando cookie-parser o pasando cookies al render?
+ <% } %>
+
+
+
+ Cookies (navegador)
+
+
+ | Nombre | Valor |
+
+
+ | Cargando… |
+
+
+ Raw document.cookie:
+
+
+
+
+
+
+
+
+
diff --git a/services/auth/.env.development b/services/auth/.env.development
index f22c778..0130084 100644
--- a/services/auth/.env.development
+++ b/services/auth/.env.development
@@ -1,12 +1,11 @@
# ===== Runtime =====
NODE_ENV=development
PORT=4040
-AUTH_LOCAL_PORT=4040 # coincide con 'expose' del servicio auth
# ===== Session (usa el Redis del stack) =====
# Para DEV podemos reutilizar el Redis de Authentik. En prod conviene uno separado.
-SESSION_SECRET=pon-una-clave-larga-y-unica
-REDIS_URL=redis://authentik-redis:6379
+SESSION_SECRET=Neon*Mammal*Boaster*Ludicrous*Fender8*Crablike
+REDIS_URL=redis://ak-redis:6379
# ===== DB principal (metadatos de SuiteCoffee) =====
# Usa el alias de red del servicio 'db' (compose: aliases [dev-db])
@@ -23,8 +22,7 @@ TENANTS_USER=dev-user-postgres
TENANTS_PASS=dev-pass-postgres
TENANTS_PORT=5432
-TENANT_INIT_SQL=/home/mateo/SuiteCoffee/services/auth/src/db/initTenant.sql
-# TENANT_INIT_SQL=~/SuiteCoffee/services/app/src/db/01_init.sql
+TENANT_INIT_SQL=/app/src/db/initTenant.sql
# ===== (Opcional) Colores UI, si alguna vista los lee =====
COL_PRI=452D19 # Marrón oscuro
@@ -33,7 +31,7 @@ COL_BG=FFA500 # Naranja
# ===== Authentik — Admin API (server-to-server dentro de la red) =====
# Usa el alias de red del servicio 'authentik' y su puerto interno 9000
-AUTHENTIK_BASE_URL=http://authentik:9000
+AUTHENTIK_BASE_URL=http://dev-authentik:9000
AUTHENTIK_TOKEN=eE3bFTLd4Rpt3ZkcidTC1EppDYMIr023ev3SXt4ImHynOfAGRVtAZVBXSNxj
AUTHENTIK_DEFAULT_GROUP_NAME=suitecoffee-users
@@ -41,13 +39,15 @@ AUTHENTIK_DEFAULT_GROUP_NAME=suitecoffee-users
# DEV (todo dentro de la red de Docker):
# - El auth service redirige al navegador a este issuer. Si NO tenés reverse proxy hacia Authentik,
# esta URL interna NO será accesible desde el navegador del host. En ese caso, ver nota más abajo.
-OIDC_ISSUER=https://authentik.suitecoffee.mateosaldain.uy/application/o/suitecoffee/
-OIDC_CLIENT_ID=ydnp9s9I7G4p9Pwt5OsNlcpk1VKB9auN7AxqqNjC
-OIDC_CLIENT_SECRET=yqdI00kYMeQF8VdmhwN5QWUzPLUzRBYeeAH193FynuVD19mo1nBRf5c5IRojzPrxDS0Hk33guUwHFzaj8vjTbTRetwK528uNJ6BfrYGUN2vzxgdMHFLQOHSTR0gR1LtG
+# OIDC_ISSUER=https://authentik.suitecoffee.mateosaldain.uy/application/o/suitecoffee/
+OIDC_ISSUER=https://sso.suitecoffee.uy/application/o/suitecoffee/
+OIDC_CLIENT_ID=1orMM8vOvf3WkN2FejXYvUFpPtONG0Lx1eMlwIpW
+OIDC_CLIENT_SECRET=t5wx13qBcM0EFQ3cGnUIAmLzvbdsQrUVPv1OGWjszWkEp35pJQ55t7vZeeShqG49kuRAaiXv6PSGJLhRfGaponGaJl8gH1uCL7KIxdmm7UihgYoAXB2dFhZV4zRxfze2
# Redirect URI que definiste en el Provider. Usa el alias de red del servicio 'auth' (dev-auth)
# Si accedés desde el host sin proxy, usa mejor http://localhost:4040/auth/callback y añadilo al Provider.
-OIDC_REDIRECT_URI=https://suitecoffee.mateosaldain.uy/auth/callback
+# OIDC_REDIRECT_URI=https://suitecoffee.mateosaldain.uy/auth/callback
+OIDC_REDIRECT_URI=https://suitecoffee.uy/auth/callback
# Cómo querés que maneje la contraseña Authentik para usuarios NUEVOS creados por tu backend:
# - TEMP_FORCE_CHANGE: crea un password temporal y obliga a cambiar en el primer login (recomendado si usás login con usuario/clave)
diff --git a/services/auth/src/ak.js b/services/auth/src/ak.js
index 739c7c0..e3b113e 100644
--- a/services/auth/src/ak.js
+++ b/services/auth/src/ak.js
@@ -1,126 +1,244 @@
// services/auth/src/ak.js
// ------------------------------------------------------------
-// Cliente mínimo y robusto para la API Admin de Authentik (v3)
+// Cliente para la API Admin de Authentik (v3)
// - Sin dependencias externas (usa fetch nativo de Node >=18)
// - ESM compatible
// - Timeouts, reintentos opcionales y mensajes de error claros
// - Compatible con services/auth/src/index.js actual
// ------------------------------------------------------------
+// Utiliza AUTHENTIK_BASE_URL y AUTHENTIK_TOKEN para validar y devuelve la configuración (base URL y token) desde variables de entorno.
+// Devuelve la URL base y el Token que se leyó desde .env
+
/**
- * Lee configuración desde process.env en cada llamada (para evitar problemas
- * de orden de imports con dotenv). No falla en import-time.
+ * @typedef {Object} AkCfg
+ * @property {string} BASE // p.ej. "https://idp.example.com"
+ * @property {string} TOKEN // bearer
*/
+
+/**
+ * @typedef {Object} AkOpts
+ * @property {Record>} [qs]
+ * @property {any} [body]
+ * @property {number} [timeoutMs=10000]
+ * @property {number} [retries=0]
+ * @property {Record} [headers]
+ */
+
function getConfig() {
- const BASE = (process.env.AUTHENTIK_BASE_URL || '').replace(/\/+$/, '');
+ const BASE = (process.env.AUTHENTIK_BASE_URL || "").trim().replace(/\/+$/, "");
const TOKEN = process.env.AUTHENTIK_TOKEN || '';
- if (!BASE) throw new Error('AK_CONFIG: Falta AUTHENTIK_BASE_URL');
- if (!TOKEN) throw new Error('AK_CONFIG: Falta AUTHENTIK_TOKEN');
+ if (!BASE) throw new Error('[AK_CONFIG] Falta variable AUTHENTIK_BASE_URL');
+ if (!TOKEN) throw new Error('[AK_CONFIG] Falta variable AUTHENTIK_TOKEN');
return { BASE, TOKEN };
}
+// ------------------------------------------------------------
+// Utilidades
+// ------------------------------------------------------------
+
+// Espera
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+
// ------------------------------------------------------------
// Helpers de sincronización
// ------------------------------------------------------------
-// -- util GET contra la API admin (ajusta si ya tenés un helper igual)
-async function akGET(path) {
- const base = (process.env.AUTHENTIK_BASE_URL || '').replace(/\/+$/, '');
- const url = `${base}${path}`;
- const res = await fetch(url, {
- headers: { Authorization: `Bearer ${process.env.AUTHENTIK_TOKEN}` },
- });
- if (!res.ok) {
- const body = await res.text().catch(() => '');
- throw new Error(`AK GET ${path} -> ${res.status}: ${body}`);
- }
- return res.json();
+export function createAkClient(cfg = getConfig()) {
+ return {
+ request: (method, path, opts = {}) => request(method, path, opts, cfg),
+ akGET: (path, opts) => request("GET", path, opts, cfg),
+ akPOST: (path, opts) => request("POST", path, opts, cfg),
+ akPUT: (path, opts) => request("PUT", path, opts, cfg),
+ akPATCH: (path, opts) => request("PATCH", path, opts, cfg),
+ akDELETE:(path, opts) => request("DELETE", path, opts, cfg),
+ };
}
-// -- listar grupos con búsqueda por nombre/slug
-export async function akListGroups(search = '') {
- const q = search ? `?search=${encodeURIComponent(search)}` : '';
- const data = await akGET(`/api/v3/core/groups/${q}`);
- // algunas versiones devuelven {results:[]}, otras un array directo
- return Array.isArray(data) ? data : (data.results || []);
+// Listar grupos con búsqueda por nombre/slug
+export async function akListGroups(search = "") {
+ const { akGET } = createAkClient();
+ const term = String(search ?? "").trim();
+
+ const data = await akGET("/core/groups/", {
+ qs: term ? { search: term } : undefined,
+ });
+
+ if (Array.isArray(data)) return data;
+ if (data && Array.isArray(data.results)) return data.results;
+ return [];
}
export async function akPatchUserAttributes(userPk, partialAttrs = {}) {
- // PATCH del usuario para asegurar attributes.tenant_uuid
- return akRequest('patch', `/api/v3/core/users/${userPk}/`, {
- data: { attributes: partialAttrs },
+
+ const id = String(userPk ?? "").trim();
+ if (!id) throw new TypeError("[AK_PATH_USER_ATRIBUTES] atribute `userPk` is required");
+
+ if (partialAttrs == null || typeof partialAttrs !== "object" || Array.isArray(partialAttrs)) {
+ throw new TypeError("[AK_PATH_USER_ATRIBUTES] atribute `partialAttrs` must be a plain object");
+ }
+
+ // Remove undefineds to avoid unintentionally nulling keys server-side
+ const cleaned = Object.fromEntries(
+ Object.entries(partialAttrs).filter(([, v]) => v !== undefined)
+ );
+
+ if (Object.keys(cleaned).length === 0) {
+ throw new TypeError("[AK_PATH_USER_ATRIBUTES] atribute `partialAttrs` is required");
+ }
+
+ // NOTE: pass path WITHOUT /api/v3; the client prefixes it
+ return akPATCH(`/core/users/${encodeURIComponent(id)}/`, {
+ body: { attributes: cleaned },
});
}
export async function akEnsureGroupForTenant(tenantHex) {
- const groupName = `tenant_${tenantHex}`;
+ const { akGET, akPOST } = createAkClient();
- // buscar por nombre
- const data = await akRequest('get', '/api/v3/core/groups/', { params: { name: groupName }});
- const g = (data?.results || [])[0];
- if (g) return g.pk;
+ const hex = String(tenantHex ?? "").trim();
+ if (!hex) throw new TypeError("akEnsureGroupForTenant: `tenantHex` is required");
- // crear si no existe
- const created = await akRequest('post', '/api/v3/core/groups/', {
- data: { name: groupName, attributes: { tenant_uuid: tenantHex } },
- });
- return created.pk;
+ const groupName = `tenant_${hex}`;
+
+ // 1) Buscar existente (normaliza {results:[]}/[])
+ const data = await akGET("/core/groups/", { qs: { search: groupName } });
+ const list = Array.isArray(data) ? data : (Array.isArray(data?.results) ? data.results : []);
+ const existing = list.find(g => g?.name === groupName);
+ if (existing?.pk ?? existing?.id) return existing.pk ?? existing.id;
+
+ // 2) Crear si no existe
+ try {
+ const created = await akPOST("/core/groups/", {
+ body: { name: groupName, attributes: { tenant_uuid: hex } },
+ });
+ return created?.pk ?? created?.id;
+ } catch (e) {
+ // 3) Condición de carrera (otro proceso lo creó): reconsulta y devuelve
+ const msg = String(e?.message || "");
+ if (/already exists|unique|duplicate|409/i.test(msg)) {
+ const data2 = await akGET("/core/groups/", { qs: { search: groupName } });
+ const list2 = Array.isArray(data2) ? data2 : (Array.isArray(data2?.results) ? data2.results : []);
+ const found = list2.find(g => g?.name === groupName);
+ if (found?.pk ?? found?.id) return found.pk ?? found.id;
+ }
+ throw e;
+ }
}
export async function akAddUserToGroup(userPk, groupPk) {
- // Endpoint de membership (en versiones recientes, POST users//groups/)
- return akRequest('post', `/api/v3/core/users/${userPk}/groups/`, { data: { group: groupPk } });
+ const { akPOST } = createAkClient();
+
+ const user = String(userPk ?? "").trim();
+ const group = String(groupPk ?? "").trim();
+ if (!user) throw new TypeError("akAddUserToGroup: `userPk` is required");
+ if (!group) throw new TypeError("akAddUserToGroup: `groupPk` is required");
+
+ // API reciente: POST /core/users//groups/ { group: }
+ const path = `/core/users/${encodeURIComponent(user)}/groups/`;
+
+ try {
+ return await akPOST(path, { body: { group } });
+ } catch (e) {
+ const msg = String(e?.message || "");
+ // Si ya es miembro, tratamos como éxito idempotente
+ if (/already.*member|exists|duplicate|409/i.test(msg)) {
+ return { ok: true, alreadyMember: true, userPk: user, groupPk: group };
+ }
+ // Fallback para instancias viejas: /core/group_memberships/ { user, group }
+ if (/404|not\s*found/i.test(msg)) {
+ return await akPOST("/core/group_memberships/", { body: { user, group } });
+ }
+ throw e;
+ }
}
-// Utilidad de espera
-const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
/**
* Llamada HTTP genérica con fetch + timeout + manejo de errores.
- * @param {('GET'|'POST'|'PUT'|'PATCH'|'DELETE')} method
- * @param {string} path - Ruta a partir de /api/v3 (por ej. "/core/users/")
- * @param {{qs?:Record, body?:any, timeoutMs?:number, retries?:number}} [opts]
+ * @param {'GET'|'POST'|'PUT'|'PATCH'|'DELETE'} method
+ * @param {string} path Ruta relativa (ej. "/core/users/") o absoluta; si es relativa se antepone "/api/v3".
+ * @param {AkOpts} [opts]
+ * @param {AkCfg} [cfg] Config inyectada; si se omite se usa getConfig()
+ * @returns {Promise}
*/
-async function request(method, path, opts = {}) {
- const { BASE, TOKEN } = getConfig();
+
+export async function request(method, path, opts = {}, cfg) {
+ const { BASE, TOKEN } = cfg ?? getConfig();
const {
- qs = undefined,
- body = undefined,
- timeoutMs = 10000,
+ qs,
+ body,
+ timeoutMs = 10_000,
retries = 0,
+ headers = {},
} = opts;
- const url = new URL(`${BASE}/api/v3${path}`);
- if (qs) Object.entries(qs).forEach(([k, v]) => url.searchParams.set(k, String(v)));
+ // Construcción segura de URL + QS
+ const base = BASE.endsWith("/") ? BASE : `${BASE}/`;
+ let p = /^https?:\/\//i.test(path) ? path : (path.startsWith("/") ? path : `/${path}`);
+ if (!/^https?:\/\//i.test(p) && !p.startsWith("/api/")) p = `/api/v3${p}`;
+ const url = new URL(p, base);
+ if (qs && typeof qs === "object") {
+ for (const [k, v] of Object.entries(qs)) {
+ if (v == null) continue;
+ if (Array.isArray(v)) v.forEach((x) => url.searchParams.append(k, String(x)));
+ else url.searchParams.set(k, String(v));
+ }
+ }
+ // Reintentos + timeout
+ const maxAttempts = Math.max(1, retries + 1);
let lastErr;
- for (let attempt = 1; attempt <= Math.max(1, retries + 1); attempt++) {
- const controller = new AbortController();
- const t = setTimeout(() => controller.abort(new Error('AK_TIMEOUT')), timeoutMs);
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
+ const ctrl = new AbortController();
+ const t = setTimeout(() => ctrl.abort(new Error("AK_TIMEOUT")), timeoutMs);
try {
- const res = await fetch(url, {
+ const init = {
method,
- signal: controller.signal,
+ signal: ctrl.signal,
headers: {
- 'Authorization': `Bearer ${TOKEN}`,
- 'Content-Type': 'application/json',
- 'Accept': 'application/json',
+ Authorization: `Bearer ${TOKEN}`,
+ Accept: "application/json",
+ ...headers,
},
- body: body !== undefined ? JSON.stringify(body) : undefined,
- });
+ };
+ if (body !== undefined) {
+ // Sólo forzar JSON si es objeto plano
+ const isPlainObj = body && typeof body === "object" &&
+ !(body instanceof FormData) &&
+ !(body instanceof URLSearchParams) &&
+ !(typeof Blob !== "undefined" && body instanceof Blob);
+ if (isPlainObj) {
+ init.headers["Content-Type"] = init.headers["Content-Type"] || "application/json";
+ init.body = JSON.stringify(body);
+ } else {
+ init.body = body; // deja que fetch maneje el Content-Type
+ }
+ }
+
+ const res = await fetch(url, init);
clearTimeout(t);
- if (res.status === 204) return null; // sin contenido
-
- // intenta parsear JSON; si no es JSON, devuelve texto
- const ctype = res.headers.get('content-type') || '';
- const payload = ctype.includes('application/json') ? await res.json().catch(() => ({})) : await res.text();
+ if (res.status === 204 || res.status === 205) return null;
+ const ctype = res.headers.get("content-type") || "";
+ const payload = /\bapplication\/json\b/i.test(ctype) ? await res.json().catch(() => ({})) : await res.text();
if (!res.ok) {
- const detail = typeof payload === 'string' ? payload : payload?.detail || payload?.error || JSON.stringify(payload);
- const err = new Error(`AK ${method} ${url.pathname} → HTTP ${res.status}: ${detail}`);
+ const detail = typeof payload === "string" ? payload : payload?.detail || payload?.error || JSON.stringify(payload);
+ const err = new Error(`AK ${method} ${url.pathname}${url.search} → ${res.status}: ${detail}`);
err.status = res.status; // @ts-ignore
+ // Reintenta 5xx y 429
+ if ((res.status >= 500 && res.status <= 599) || res.status === 429) {
+ lastErr = err;
+ if (attempt < maxAttempts) {
+ let delay = 500 * 2 ** (attempt - 1);
+ const ra = parseInt(res.headers.get("retry-after") || "", 10);
+ if (!Number.isNaN(ra)) delay = Math.max(delay, ra * 1000);
+ await new Promise(r => setTimeout(r, delay));
+ continue;
+ }
+ }
throw err;
}
@@ -128,11 +246,10 @@ async function request(method, path, opts = {}) {
} catch (e) {
clearTimeout(t);
lastErr = e;
- // Reintentos sólo en ECONNREFUSED/timeout/5xx
- const msg = String(e?.message || e);
- const retriable = msg.includes('ECONNREFUSED') || msg.includes('AK_TIMEOUT') || /\b5\d\d\b/.test(e?.status?.toString?.() || '');
- if (!retriable || attempt > retries) throw e;
- await sleep(500 * attempt); // backoff lineal suave
+ const msg = String(e?.message || "");
+ const retriable = msg.includes("AK_TIMEOUT") || msg.includes("ECONNREFUSED") || msg.includes("fetch failed");
+ if (!retriable || attempt >= maxAttempts) throw e;
+ await new Promise(r => setTimeout(r, 500 * 2 ** (attempt - 1)));
}
}
throw lastErr;
@@ -142,124 +259,178 @@ async function request(method, path, opts = {}) {
// Funciones públicas
// ------------------------------------------------------------
-/**
- * Busca un usuario por email en Authentik (case-insensitive) usando ?search=
- * Devuelve el usuario exacto o null si no existe.
- */
export async function akFindUserByEmail(email) {
- if (!email) throw new Error('akFindUserByEmail: email requerido');
- const data = await request('GET', '/core/users/', { qs: { search: email, page_size: 50 }, retries: 3 });
- const list = Array.isArray(data?.results) ? data.results : [];
- const lower = String(email).toLowerCase();
- return list.find((u) => (u.email || '').toLowerCase() === lower) || null;
+ const { akGET } = createAkClient();
+
+ const needle = String(email ?? "").trim().toLowerCase();
+ if (!needle) throw new TypeError("akFindUserByEmail: `email` is required");
+
+ const PAGE_SIZE = 100;
+ let page = 1;
+ const MAX_PAGES = 10;
+
+ while (page <= MAX_PAGES) {
+ const data = await akGET("/core/users/", {
+ qs: { search: needle, page_size: PAGE_SIZE, page },
+ retries: 2,
+ });
+
+ const list = Array.isArray(data)
+ ? data
+ : (Array.isArray(data?.results) ? data.results : []);
+
+ const found = list.find(u => String(u?.email || "").toLowerCase() === needle);
+ if (found) return found || null;
+
+ // Continuar paginando sólo si hay más resultados
+ const hasNext =
+ Array.isArray(data)
+ ? list.length === PAGE_SIZE // array plano: inferimos por tamaño
+ : Boolean(data?.next); // DRF: link "next"
+ if (!hasNext) break;
+
+ page += 1;
+ }
+
+ return null;
}
-/**
- * Crea un usuario en Authentik con atributos de tenant y opcionalmente lo
- * agrega a un grupo existente.
- * @param {{email:string, displayName?:string, tenantUuid?:string, addToGroupId?: number|string, isActive?: boolean}} p
- * @returns {Promise} el objeto usuario creado
- */
-export async function akCreateUser(p) {
- const email = p?.email;
- if (!email) throw new Error('akCreateUser: email requerido');
- const name = p?.displayName || email;
- const tenantUuid = (p?.tenantUuid || '').replace(/-/g, '');
- const isActive = p?.isActive ?? true;
+export async function akCreateUser(p = {}) {
+ const { akPOST } = createAkClient();
- // 1) crear usuario
- const user = await request('POST', '/core/users/', {
- body: {
- username: email,
- name,
- email,
- is_active: isActive,
- attributes: tenantUuid ? { tenant_uuid: tenantUuid } : {},
- },
- retries: 3,
- });
+ const email = String(p.email ?? "").trim().toLowerCase();
+ if (!email) throw new TypeError("akCreateUser: `email` is required");
- // 2) agregar a grupo (opcional)
- if (p?.addToGroupId) {
+ const name = String(p.displayName ?? email).trim() || email;
+ const tenantUuid = String(p.tenantUuid ?? "").replace(/-/g, "").trim();
+ const isActive = p.isActive ?? true;
+
+ const body = {
+ username: email,
+ name,
+ email,
+ is_active: !!isActive,
+ attributes: tenantUuid ? { tenant_uuid: tenantUuid } : {},
+ };
+
+ let user;
+ try {
+ user = await akPOST("/core/users/", { body, retries: 2 });
+ } catch (e) {
+ const msg = String(e?.message || "");
+ if (/409|already\s*exists|unique|duplicate/i.test(msg)) {
+ // Idempotencia: si ya existe, lo buscamos por email y lo devolvemos
+ const existing = await akFindUserByEmail(email);
+ if (existing) return existing;
+ }
+ throw e;
+ }
+
+ // Agregar a grupo (opcional, no rompe el flujo si falla)
+ const groupId = p.addToGroupId != null ? String(p.addToGroupId).trim() : "";
+ if (groupId) {
try {
- await request('POST', `/core/users/${user.pk}/groups/`, { body: { group: p.addToGroupId }, retries: 2 });
- } catch (e) {
- // No rompas todo por el grupo; deja registro del error para que el caller decida.
- console.warn(`akCreateUser: no se pudo agregar al grupo ${p.addToGroupId}:`, e?.message || e);
+ const userPk = encodeURIComponent(user.pk ?? user.id);
+ await akPOST(`/core/users/${userPk}/groups/`, {
+ body: { group: groupId },
+ retries: 2,
+ });
+ } catch (err) {
+ console.warn(
+ `akCreateUser: could not add user ${user.pk ?? user.id} to group ${groupId}:`,
+ err?.message || err
+ );
}
}
return user;
}
-/**
- * Establece/forza una contraseña a un usuario (si tu política lo permite).
- * @param {number|string} userPk
- * @param {string} password
- * @param {boolean} requireChange - si el usuario debe cambiarla al siguiente login
- */
export async function akSetPassword(userPk, password, requireChange = true) {
- if (!userPk) throw new Error('akSetPassword: userPk requerido');
- if (!password) throw new Error('akSetPassword: password requerida');
+ const { akPOST } = createAkClient();
+
+ const id = String(userPk ?? "").trim();
+ if (!id) throw new TypeError("akSetPassword: `userPk` is required");
+
+ const pwd = String(password ?? "");
+ if (!pwd) throw new TypeError("akSetPassword: `password` is required");
+
try {
- await request('POST', `/core/users/${userPk}/set_password/`, {
- body: { password, require_change: !!requireChange },
+ await akPOST(`/core/users/${encodeURIComponent(id)}/set_password/`, {
+ body: { password: pwd, require_change: !!requireChange },
retries: 1,
+ timeoutMs: 15_000,
});
return true;
} catch (e) {
- // Algunas instalaciones no permiten setear password por API (políticas). Propaga un error legible.
- const err = new Error(`akSetPassword: no se pudo establecer la contraseña: ${e?.message || e}`);
+ const status = e?.status ? `HTTP ${e.status}: ` : "";
+ const err = new Error(`akSetPassword: failed to set password (${status}${e?.message || e})`);
err.cause = e;
throw err;
}
}
-/**
- * Helper opcional para obtener grupos por nombre/slug si en el futuro lo necesitas
- * (no usado por index.js; se deja por conveniencia).
- */
-
export async function akResolveGroupIdByName(name) {
- const data = await akListGroups(name);
- const lower = String(name || '').toLowerCase();
- const found = data.find(g =>
- String(g.name || '').toLowerCase() === lower ||
- String(g.slug || '').toLowerCase() === lower
- );
- return found?.pk ?? null;
+ const term = String(name ?? "").trim();
+ if (!term) throw new TypeError("akResolveGroupIdByName: `name` is required");
+
+ const needle = term.toLowerCase();
+ const groups = await akListGroups(term);
+ if (!Array.isArray(groups) || groups.length === 0) return null;
+
+ // Prefer exact slug match, then exact name match
+ const bySlug = groups.find(g => String(g?.slug ?? "").toLowerCase() === needle);
+ if (bySlug) return bySlug.pk ?? bySlug.id ?? null;
+
+ const byName = groups.find(g => String(g?.name ?? "").toLowerCase() === needle);
+ return byName?.pk ?? byName?.id ?? null;
}
export async function akResolveGroupId({ id, pk, uuid, name, slug } = {}) {
- // si te pasan pk/id directo, devolvelo
- if (pk != null) return Number(pk);
- if (id != null) return Number(id);
+ const toPk = (v) => {
+ if (v == null || v === "") return null;
+ const n = Number(v);
+ return Number.isFinite(n) ? n : String(v);
+ };
- // por UUID (devuelve objeto con pk)
- if (uuid) {
+ // 1) Direct pk/id
+ const direct = pk ?? id;
+ const directPk = toPk(direct);
+ if (directPk != null) return directPk;
+
+ const { akGET } = createAkClient();
+
+ // 2) By UUID (detail endpoint)
+ const uuidStr = String(uuid ?? "").trim();
+ if (uuidStr) {
try {
- const g = await akGET(`/api/v3/core/groups/${encodeURIComponent(uuid)}/`);
- if (g?.pk != null) return Number(g.pk);
- } catch (e) {
- // sigue intentando por nombre/slug
- }
+ const g = await akGET(`/core/groups/${encodeURIComponent(uuidStr)}/`, { retries: 1 });
+ const fromDetail = toPk(g?.pk ?? g?.id);
+ if (fromDetail != null) return fromDetail;
+ } catch { /* continue with name/slug */ }
}
- // por nombre/slug
- if (name || slug) {
- const needle = (name || slug);
- const list = await akListGroups(needle);
- const lower = String(needle || '').toLowerCase();
- const found = list.find(g =>
- String(g.name || '').toLowerCase() === lower ||
- String(g.slug || '').toLowerCase() === lower
- );
- if (found?.pk != null) return Number(found.pk);
+ // 3) By exact name/slug
+ const needle = String(name ?? slug ?? "").trim();
+ if (needle) {
+ const lower = needle.toLowerCase();
+ const list = await akListGroups(needle); // expects [] or {results:[]}, handled in akListGroups
+ const found =
+ list.find(g => String(g?.slug ?? "").toLowerCase() === lower) ||
+ list.find(g => String(g?.name ?? "").toLowerCase() === lower);
+ const fromList = toPk(found?.pk ?? found?.id);
+ if (fromList != null) return fromList;
}
return null;
}
// ------------------------------------------------------------
-// Fin
+// Exportación de constantes
// ------------------------------------------------------------
+
+export const akGET = (path, opts) => request("GET", path, opts);
+export const akPOST = (path, opts) => request("POST", path, opts);
+export const akPUT = (path, opts) => request("PUT", path, opts);
+export const akPATCH = (path, opts) => request("PATCH", path, opts);
+export const akDELETE = (path, opts) => request("DELETE", path, opts);
\ No newline at end of file
diff --git a/services/auth/src/index.js b/services/auth/src/index.js
index 603fe8a..777e0f8 100644
--- a/services/auth/src/index.js
+++ b/services/auth/src/index.js
@@ -5,7 +5,7 @@
// - Sesiones con Redis (compartibles con otros servicios)
// - Vistas EJS (login)
// - Rutas OIDC: /auth/login, /auth/callback, /auth/logout
-// - Registro de usuario: /api/users/register (DB + Authentik)
+// - Registro de usuario: /auth/api/users/register (DB + Authentik)
// ------------------------------------------------------------
import 'dotenv/config';
@@ -13,23 +13,28 @@ import chalk from 'chalk';
import express from 'express';
import cors from 'cors';
import path from 'node:path';
-import fs from 'node:fs/promises';
+import { access, readFile } from 'node:fs/promises';
+import { constants as fsConstants } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { Pool } from 'pg';
import session from 'express-session';
-import { createClient as createRedisClient } from 'redis';
-import * as connectRedis from 'connect-redis';
+import { createClient } from 'redis';
import expressLayouts from 'express-ejs-layouts';
import { Issuer, generators } from 'openid-client';
import crypto from 'node:crypto';
-import { readFile } from 'node:fs/promises';
+
+// -----------------------------------------------------------------------------
+// Importaciones desde archivos
+// -----------------------------------------------------------------------------
+// Helpers de Authentik (admin API)
+import { akFindUserByEmail, akCreateUser,
+ akSetPassword, akResolveGroupId } from './ak.js';
+
// -----------------------------------------------------------------------------
// Helpers
// -----------------------------------------------------------------------------
-const SESSION_COOKIE_NAME = 'sc.sid';
-
// Normaliza UUID (acepta con/sin guiones) → "hex" sin guiones
const cleanUuid = (u) => (u ? String(u).toLowerCase().replace(/[^0-9a-f]/g, '') : '');
@@ -37,14 +42,6 @@ const cleanUuid = (u) => (u ? String(u).toLowerCase().replace(/[^0-9a-f]/g, '')
const schemaNameFor = (uuidHex) => `schema_tenant_${uuidHex}`;
const roleNameFor = (uuidHex) => `tenant_${uuidHex}`;
-// Helpers de Authentik (admin API)
-const {
- akFindUserByEmail,
- akCreateUser,
- akSetPassword,
- akResolveGroupId
-} = await import('./ak.js');
-
// Quoter seguro de identificadores SQL (roles, schemas, tablas)
// Identificador SQL (schema, role, table, …)
const qi = (ident) => `"${String(ident).replace(/"/g, '""')}"`;
@@ -282,13 +279,13 @@ let _cachedInitSql = null;
async function loadInitSql() {
if (_cachedInitSql !== null) return _cachedInitSql;
const candidates = [
- process.env.TENANT_INIT_SQL, // recomendado via .env
+ process.env.TENANT_INIT_SQL, // opcional
path.resolve(__dirname, 'db', 'initTenant.sql'),
path.resolve(__dirname, '..', 'src', 'db', 'initTenant.sql'),
].filter(Boolean);
for (const p of candidates) {
try {
- await fs.promises.access(p, fs.constants.R_OK);
+ await access(p, fsConstants.R_OK);
const txt = await readFile(p, 'utf8');
_cachedInitSql = String(txt || '');
console.log(`[TENANT INIT] initTenant.sql: ${p} (${_cachedInitSql.length} bytes)`);
@@ -349,7 +346,6 @@ async function initializeTenantSchemaIfEmpty(schema) {
// -----------------------------------------------------------------------------
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
-const RedisStore = connectRedis.default || connectRedis.RedisStore;
function requiredEnv(keys) {
const missing = keys.filter((k) => !process.env[k]);
@@ -375,7 +371,7 @@ function genTempPassword(len = 12) {
// Configuración Express
// -----------------------------------------------------------------------------
const app = express();
-app.set('trust proxy', 1);
+app.set('trust proxy', Number(process.env.TRUST_PROXY_HOPS || 2));
app.use(cors({ origin: true, credentials: true }));
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
@@ -384,31 +380,70 @@ app.use(express.urlencoded({ extended: true }));
app.set('views', path.join(__dirname, 'views'));
app.set('view engine', 'ejs');
+
// Archivos estáticos opcionales (ajusta si tu estructura difiere)
app.use(express.static(path.join(__dirname, 'public')));
app.use('/pages', express.static(path.join(__dirname, 'pages')));
// -----------------------------------------------------------------------------
-// Sesión (Redis)
+// Sesión (Redis) — misma cookie que APP
// -----------------------------------------------------------------------------
requiredEnv(['SESSION_SECRET', 'REDIS_URL']);
-const redis = createRedisClient({ url: process.env.REDIS_URL || 'redis://sessions-redis:6379' });
-await redis.connect().catch((e) => onFatal(e, 'No se pudo conectar a Redis (sesiones)'));
+const SESSION_COOKIE_NAME = process.env.SESSION_NAME || "sc.sid";
+const SESSION_SECRET = process.env.SESSION_SECRET || "pon-una-clave-larga-y-unica";
+const REDIS_URL = process.env.REDIS_URL || "redis://authentik-redis:6379";
-app.use(
- session({
- name: SESSION_COOKIE_NAME,
- store: new RedisStore({ client: redis, prefix: 'sess:' }),
- secret: process.env.SESSION_SECRET || 'change-me',
- resave: false,
- saveUninitialized: false,
- cookie: {
- httpOnly: true,
- sameSite: 'lax',
- secure: process.env.NODE_ENV === 'production',
- },
- })
-);
+// 1) Redis client
+const redis = createClient({ url: REDIS_URL /*, legacyMode: true */ });
+redis.on("error", (err) => console.error("[Redis] Client Error:", err));
+await redis.connect();
+console.log("[Redis] connected");
+
+// 2) Resolver RedisStore (soporta:
+// - v5: factory CJS -> connectRedis(session)
+// - v6/v7: export { RedisStore } ó export default class RedisStore)
+async function resolveRedisStore(session) {
+ const mod = await import("connect-redis"); // ESM/CJS agnóstico
+ // named export (v6/v7)
+ if (typeof mod.RedisStore === "function") return mod.RedisStore;
+ // default export (class ó factory)
+ if (typeof mod.default === "function") {
+ // ¿es clase neweable?
+ if (mod.default.prototype && (mod.default.prototype.get || mod.default.prototype.set)) {
+ return mod.default; // class RedisStore
+ }
+ // si no, asumimos factory antigua
+ const Store = mod.default(session); // connectRedis(session)
+ if (typeof Store === "function") return Store; // class devuelta por factory
+ }
+ // algunos builds CJS exponen la factory bajo mod (poco común)
+ if (typeof mod === "function") {
+ const Store = mod(session);
+ if (typeof Store === "function") return Store;
+ }
+ throw new Error("connect-redis: no se pudo resolver RedisStore (API desconocida).");
+}
+
+const RedisStore = await resolveRedisStore(session);
+
+// 3) Session middleware
+app.use(session({
+ name: SESSION_COOKIE_NAME,
+ secret: SESSION_SECRET,
+ resave: false,
+ saveUninitialized: false,
+ store: new RedisStore({
+ client: redis,
+ prefix: "sc:", // opcional
+ }),
+ proxy: true,
+ cookie: {
+ secure: "auto",
+ httpOnly: true,
+ sameSite: "lax",
+ path: "/", // ¡crítico! visible en / y /auth/*
+ },
+}));
// Exponer usuario a las vistas (no tocar req.session)
app.use((req, res, next) => {
@@ -507,6 +542,89 @@ let oidcClient;
}
})();
+// -----------------------------------------------------------------------------
+// Vistas
+// -----------------------------------------------------------------------------
+
+// Página de login
+app.get("/auth/login", (_req, res) => {
+ return res.render("login", { pageTitle: "Iniciar sesión" });
+});
+
+app.post("/auth/login", async (req, res, next) => {
+ try {
+ const email = String(req.body.email || "").trim().toLowerCase();
+ const password = String(req.body.password || "");
+ const remember = req.body.remember === "on" || req.body.remember === true;
+
+ if (!email || !password) {
+ return res.status(400).render("login", { pageTitle: "Iniciar sesión", error: "Completa email y contraseña." });
+ }
+
+ // Tabla/columnas por defecto; ajustables por env si tu esquema difiere
+ const USERS_TABLE = process.env.TENANTS_USERS_TABLE || "users";
+ const COL_ID = process.env.TENANTS_COL_ID || "id";
+ const COL_EMAIL = process.env.TENANTS_COL_EMAIL || "email";
+ const COL_HASH = process.env.TENANTS_COL_HASH || "password_hash";
+ const COL_ROLE = process.env.TENANTS_COL_ROLE || "role";
+ const COL_TENANT = process.env.TENANTS_COL_TENANT || "tenant_id";
+
+ const { rows } = await tenantsPool.query(
+ `SELECT ${COL_ID} AS id, ${COL_EMAIL} AS email, ${COL_HASH} AS password_hash,
+ ${COL_ROLE} AS role, ${COL_TENANT} AS tenant_id
+ FROM ${USERS_TABLE}
+ WHERE ${COL_EMAIL} = $1
+ LIMIT 1`,
+ [email]
+ );
+
+ if (rows.length === 0) {
+ return res.status(401).render("login", { pageTitle: "Iniciar sesión", error: "Credenciales inválidas." });
+ }
+
+ const user = rows[0];
+ const ok = await bcrypt.compare(password, user.password_hash || "");
+ if (!ok) {
+ return res.status(401).render("login", { pageTitle: "Iniciar sesión", error: "Credenciales inválidas." });
+ }
+
+ // (Opcional) registro de acceso en DB principal
+ try {
+ await pool.query(
+ "INSERT INTO auth_audit_log(email, tenant_id, action, at) VALUES ($1, $2, $3, NOW())",
+ [user.email, user.tenant_id, "login_success"]
+ );
+ } catch { /* noop si no existe la tabla */ }
+
+ // Sesión compartida
+ req.session.regenerate((err) => {
+ if (err) return next(err);
+
+ req.session.user = {
+ id: user.id,
+ email: user.email,
+ role: user.role,
+ tenant_id: user.tenant_id,
+ loggedAt: Date.now(),
+ };
+
+ if (remember) {
+ req.session.cookie.maxAge = 30 * 24 * 60 * 60 * 1000;
+ } else {
+ req.session.cookie.expires = false;
+ }
+
+ req.session.save((err2) => {
+ if (err2) return next(err2);
+ return res.redirect(303, "/"); // "/" → app decide /dashboard o /auth/login
+ });
+ });
+ } catch (e) {
+ next(e);
+ }
+});
+
+
// -----------------------------------------------------------------------------
// Rutas OIDC
// -----------------------------------------------------------------------------
@@ -617,7 +735,7 @@ app.get('/auth/callback', async (req, res, next) => {
if (!res.headersSent) res.status(500).send('No se pudo guardar la sesión.');
return;
}
- if (!res.headersSent) return res.redirect('/'); // te llevará a /comandas si ya implementaste ese redirect
+ if (!res.headersSent) return res.redirect('/');
});
});
@@ -668,9 +786,9 @@ async function akDeleteUser(pkOrUuid) {
}
// ==============================
-// POST /api/users/register
+// POST /auth/api/users/register
// ==============================
-app.post('/api/users/register', async (req, res, next) => {
+app.post('/auth/api/users/register', async (req, res, next) => {
// 0) input
const {
email,
@@ -830,7 +948,7 @@ app.post('/auth/password/set', async (req, res, next) => {
// Espera: { email, display_name?, tenant_uuid }
-// app.post('/api/users/register', async (req, res, next) => {
+// app.post('/auth/auth/api/users/register', async (req, res, next) => {
// const { email, display_name, tenant_uuid: rawTenant, role } = req.body || {};
// if (!email) return res.status(400).json({ error: 'email es obligatorio' });
@@ -893,7 +1011,7 @@ app.post('/auth/password/set', async (req, res, next) => {
// -----------------------------------------------------------------------------
// Healthcheck
// -----------------------------------------------------------------------------
-app.get('/health', (_req, res) => res.status(200).json({ status: 'ok' }));
+app.get('/health', (_req, res) => res.status(200).json({ status: 'ok', service: 'auth' }));
// -----------------------------------------------------------------------------
// 404 + Manejo de errores
@@ -916,6 +1034,8 @@ const PORT = Number(process.env.PORT || 4040);
console.log(`Activando entorno de -> ${env === 'PRODUCTION' ? chalk.red('PRODUCTION') : chalk.green('DEVELOPMENT')}`);
await verificarConexion();
app.listen(PORT, () => {
- console.log(`Servidor de autenticación de SuiteCoffee corriendo en ${chalk.yellow(`http://localhost:${PORT}`)}`);
+ console.log(`[AUTH] SuiteCoffee corriendo en http://localhost:${PORT}`);
});
})();
+
+export default app;
\ No newline at end of file
diff --git a/services/auth/src/views/login.ejs b/services/auth/src/views/login.ejs
new file mode 100644
index 0000000..65dc771
--- /dev/null
+++ b/services/auth/src/views/login.ejs
@@ -0,0 +1,164 @@
+
+
+
+
+
+ <%= typeof pageTitle !== 'undefined' ? pageTitle : 'Iniciar sesión · SuiteCoffee' %>
+
+
+
+
+
+
+
+
+
+
+
+
SuiteCoffee
+
Accedé a tu cuenta
+
+
+
+
+
+
+
+
+
+
+
+
o
+
+
+
+
+
+ Al continuar aceptás nuestros términos y políticas.
+
+
+
+
+
+
+ ¿Ya tenés cuenta? Iniciá sesión con SSO
+
+
+
+
+
+
+
+
+
+