Inclusión del dominio suitecoffee.uy al NPM.

Se ajustaron los problemas de renderizado y redirección mayores de https://suitecoffee.uy/
Se re-creó el archivo ~/SuiteCoffee/services/app/src/index.js para mantener un orden adecuado
Las rutas exigen una cookie de seción para cargarse, de o contrario redireccionan a  https://suitecoffee.uy/auth/login para iniciar o crear sesión de usuario, excepto https://suitecoffee.uy/inicio que se mantene de esta manera con motivos de desarrollo
This commit is contained in:
Mateo Saldain 2025-09-09 14:20:05 +00:00
parent 5d078f3932
commit 69f5860b7f
20 changed files with 2758 additions and 790 deletions

0
.env Normal file
View File

View File

@ -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

View File

@ -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

344
app.index.js Normal file
View File

@ -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(`
<!doctype html><meta charset="utf-8">
<title>SuiteCoffee · Definir contraseña</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css">
<div class="container py-5" style="max-width:520px;">
<h2 class="mb-4">Definir contraseña</h2>
<form method="post" action="/auth/password/set" class="vstack gap-3">
<input class="form-control" type="password" name="password" placeholder="Nueva contraseña" minlength="8" required>
<input class="form-control" type="password" name="password2" placeholder="Repetí la contraseña" minlength="8" required>
<button class="btn btn-primary" type="submit">Guardar y continuar</button>
<small class="text-muted">Luego te redirigiremos a iniciar sesión por SSO.</small>
</form>
</div>
`);
});
// -----------------------------------------------------------------------------
// 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}`);
});
})();

View File

@ -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:

View File

@ -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:

View File

@ -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

316
docs/ak.md Normal file
View File

@ -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<string, string|number|boolean|Array<string|number|boolean>>} [qs]
* @property {any} [body]
* @property {number} [timeoutMs=10000]
* @property {number} [retries=0]
* @property {Record<string,string>} [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<any|null>}
*/
```
**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);
```
---

View File

@ -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

View File

@ -0,0 +1,402 @@
<% /* Reportes - Asistencias y Tickets (Comandas) */ %>
<div class="container-fluid py-3">
<div class="d-flex align-items-center mb-2">
<h3 class="mb-0">Reportes</h3>
<span class="ms-auto small text-muted" id="repStatus">—</span>
</div>
<!-- Filtros -->
<div class="card shadow-sm mb-3">
<div class="card-body">
<div class="row g-3 align-items-end">
<div class="col-12 col-lg-6">
<label class="form-label mb-1">Asistencias · Rango</label>
<div class="row g-2">
<div class="col-6 col-md-4">
<input id="asistDesde" type="date" class="form-control">
</div>
<div class="col-6 col-md-4">
<input id="asistHasta" type="date" class="form-control">
</div>
<div class="col-12 col-md-4 d-grid d-md-block">
<button id="btnAsistCargar" class="btn btn-primary me-2">Cargar</button>
<button id="btnAsistExcel" class="btn btn-outline-success me-2">Excel</button>
<button id="btnAsistPDF" class="btn btn-outline-secondary">PDF</button>
</div>
</div>
</div>
<div class="col-12 col-lg-6">
<label class="form-label mb-1">Tickets (Comandas) · Año</label>
<div class="row g-2">
<div class="col-6 col-md-4">
<input id="ticketsYear" type="number" min="2000" step="1" class="form-control">
</div>
<div class="col-6 col-md-8 d-grid d-md-block">
<button id="btnTicketsCargar" class="btn btn-primary me-2">Cargar</button>
<button id="btnTicketsExcel" class="btn btn-outline-success me-2">Excel</button>
<button id="btnTicketsPDF" class="btn btn-outline-secondary">PDF</button>
</div>
</div>
</div>
</div>
<div class="small text-muted mt-2">
Los archivos Excel se generan como CSV (compatibles). Los PDF se generan con “Imprimir área” del navegador.
</div>
</div>
</div>
<!-- Reporte Asistencias -->
<div class="card shadow-sm mb-3" id="PRINT_ASIST">
<div class="card-header d-flex align-items-center">
<strong>Asistencias</strong>
<span class="ms-auto small text-muted" id="asistInfo">—</span>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-sm align-middle">
<thead class="table-light">
<tr>
<th>Documento</th>
<th>Nombre</th>
<th>Apellido</th>
<th>Fecha</th>
<th class="text-end">Desde</th>
<th class="text-end">Hasta</th>
<th class="text-end">Duración</th>
</tr>
</thead>
<tbody id="tbAsist">
<tr><td colspan="7" class="p-3 text-muted">Sin datos</td></tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- Reporte Tickets -->
<div class="card shadow-sm" id="PRINT_TICKETS">
<div class="card-header d-flex align-items-center">
<strong>Tickets</strong>
<span class="ms-auto small text-muted" id="ticketsInfo">—</span>
</div>
<div class="card-body">
<div class="row g-3">
<div class="col-12 col-lg-6">
<div class="emp-card p-3 border rounded">
<div class="d-flex justify-content-between align-items-center mb-2">
<div class="fw-semibold">Resumen del año</div>
<div class="small text-muted" id="ticketsYearTitle">—</div>
</div>
<div class="row text-center">
<div class="col-4">
<div class="small text-muted">Tickets YTD</div>
<div class="fs-5 fw-semibold" id="tYtd">—</div>
</div>
<div class="col-4">
<div class="small text-muted">Promedio</div>
<div class="fs-5 fw-semibold" id="tAvg">—</div>
</div>
<div class="col-4">
<div class="small text-muted">Hasta la fecha</div>
<div class="fs-5 fw-semibold" id="tToDate">—</div>
</div>
</div>
</div>
</div>
<div class="col-12 col-lg-6">
<div class="emp-card p-3 border rounded">
<div class="d-flex justify-content-between align-items-center mb-2">
<div class="fw-semibold">Tickets por mes</div>
<div class="small text-muted">Cantidad</div>
</div>
<div class="spark-wrap" id="ticketsChart" style="height:140px;"></div>
</div>
</div>
<div class="col-12">
<div class="table-responsive">
<table class="table table-sm align-middle">
<thead class="table-light">
<tr>
<th>Mes</th>
<th class="text-end">Tickets</th>
<th class="text-end">Importe</th>
<th class="text-end">Ticket promedio</th>
</tr>
</thead>
<tbody id="tbTickets">
<tr><td colspan="4" class="p-3 text-muted">Sin datos</td></tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
<style>
.spark rect:hover { filter: brightness(0.9); }
@media print {
body * { visibility: hidden !important; }
#PRINT_ASIST, #PRINT_ASIST *,
#PRINT_TICKETS, #PRINT_TICKETS * { visibility: visible !important; }
#PRINT_ASIST, #PRINT_TICKETS { position: absolute; left:0; top:0; width:100%; }
}
</style>
<script>
/* =========================
Helpers reutilizables
========================= */
const $ = s => document.querySelector(s);
const z2 = n => String(n).padStart(2,'0');
const fmtMoney = v => (v==null? '—' : new Intl.NumberFormat('es-UY',{style:'currency',currency:'UYU'}).format(Number(v)));
const fmtHM = mins => { const h = Math.floor(mins/60); const m = Math.round(mins%60); return `${z2(h)}:${z2(m)}`; };
// GET JSON simple
async function jget(url){
const r = await fetch(url);
const j = await r.json().catch(()=>null);
if (!r.ok) throw new Error(j?.error || `${r.status} ${r.statusText}`);
return j;
}
// POST JSON simple
async function jpost(url, body){
const r = await fetch(url,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body||{})});
const j = await r.json().catch(()=>null);
if (!r.ok) throw new Error(j?.error || `${r.status} ${r.statusText}`);
return j;
}
// CSV (Excel-friendly)
function toCSV(rows, headers){
const esc = v => {
if (v == null) return '';
if (typeof v === 'number') return String(v); // números sin comillas
const s = String(v);
return /[",\n]/.test(s) ? `"${s.replace(/"/g,'""')}"` : s;
};
const cols = headers && headers.length ? headers : Object.keys(rows?.[0] || {});
const lines = [];
if (headers) lines.push(cols.join(','));
for (const r of (rows || [])) lines.push(cols.map(c => esc(r[c])).join(','));
return lines.join('\r\n');
}
function downloadText(filename, text){
const blob = new Blob([text], {type:'text/csv;charset=utf-8;'});
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = filename;
a.click();
URL.revokeObjectURL(a.href);
}
// Print solo área
function printArea(id){
// cambiamos el hash para que @media print muestre el área; luego invocamos print
const el = document.getElementById(id);
if (!el) return;
window.print();
}
// SVG barras simple (sin librerías)
function barsSVG(series /* [{label:'Ene', value:Number}] */){
const W=560, H=120, PAD=10, GAP=6;
const n = series.length||1;
const max = Math.max(1, ...series.map(d=>d.value||0));
const bw = Math.max(6, Math.floor((W-PAD*2-GAP*(n-1))/n));
let x = PAD;
let bars = '';
series.forEach((d,i)=>{
const vh = Math.round((d.value/max)*(H-PAD-26)); // 26px para etiquetas
const y = H-20 - vh;
const title = `${d.label} · ${d.value}`;
bars += `<g>
<rect x="${x}" y="${y}" width="${bw}" height="${vh}" rx="3" ry="3" class="bar">
<title>${title}</title>
</rect>
<text x="${x + bw/2}" y="${H-6}" text-anchor="middle">${d.label}</text>
</g>`;
x += bw + GAP;
});
const css = `.bar{fill:#0d6efd}`;
const axis = `<line x1="${PAD}" y1="${H-20}" x2="${W-PAD}" y2="${H-20}" stroke="#adb5bd" stroke-width="1"/>`;
return `<svg viewBox="0 0 ${W} ${H}" class="spark" preserveAspectRatio="none">
<style>${css}</style>
${axis}
${bars}
</svg>`;
}
/* =========================
Data access (enchufable)
=========================
Estas funciones llaman RPCs del server, que a su vez deben
delegar en funciones SQL. Si aún no existen, ver más abajo
el bloque "Sugerencia de funciones SQL".
*/
async function fetchAsistencias(desde, hasta){
// endpoint recomendado (RPC):
// POST /api/rpc/report_asistencia { desde, hasta }
// Retorna [{documento,nombre,apellido,fecha,desde_hora,hasta_hora,dur_min}]
try {
return await jpost('/api/rpc/report_asistencia', { desde, hasta });
} catch {
// fallback (si aún no tienes RPC): lee la vista "asistencia_detalle" hipotética
const url = `/api/table/asistencia_detalle?desde=${encodeURIComponent(desde)}&hasta=${encodeURIComponent(hasta)}&limit=10000`;
return await jget(url);
}
}
async function fetchTickets(year){
// endpoint recomendado (RPC):
// POST /api/rpc/report_tickets { year }
// Retorna: { year, total_ytd, avg_ticket, to_date, months:[{mes:1..12, nombre:'Ene', cant, importe, avg}] }
return await jpost('/api/rpc/report_tickets', { year });
}
/* =========================
Render Asistencias
========================= */
let cacheAsist = [];
function renderAsistTabla(rows){
const tb = $('#tbAsist');
if (!rows?.length){ tb.innerHTML = '<tr><td colspan="7" class="p-3 text-muted">Sin datos</td></tr>'; return; }
tb.innerHTML = '';
rows.forEach(r=>{
const tr = document.createElement('tr');
tr.innerHTML = `
<td>${r.documento||'—'}</td>
<td>${r.nombre||'—'}</td>
<td>${r.apellido||'—'}</td>
<td>${r.fecha||'—'}</td>
<td class="text-end">${r.desde_hora||'—'}</td>
<td class="text-end">${r.hasta_hora||'—'}</td>
<td class="text-end">${fmtHM(Number(r.dur_min||0))}</td>
`;
tb.appendChild(tr);
});
}
async function loadAsist(){
const d = $('#asistDesde').value;
const h = $('#asistHasta').value;
$('#repStatus').textContent = 'Cargando asistencias…';
const rows = await fetchAsistencias(d,h);
cacheAsist = rows||[];
renderAsistTabla(cacheAsist);
const minsTot = cacheAsist.reduce((s,r)=> s + Number(r.dur_min||0), 0);
$('#asistInfo').textContent = `${cacheAsist.length} registros · ${fmtHM(minsTot)}`;
$('#repStatus').textContent = 'Listo';
}
/* =========================
Render Tickets
========================= */
let cacheTickets = null;
function renderTickets(data){
const months = data?.months||[];
$('#ticketsYearTitle').textContent = data?.year || '—';
$('#tYtd').textContent = months.reduce((s,m)=> s + Number(m.cant||0), 0);
$('#tAvg').textContent = fmtMoney(data?.avg_ticket ?? 0);
$('#tToDate').textContent = data?.to_date != null ? fmtMoney(data.to_date) : '—';
const series = months.map(m=>({ label:m.nombre||m.mes, value:Number(m.cant||0) }));
$('#ticketsChart').innerHTML = barsSVG(series);
const tb = $('#tbTickets');
if (!months.length){ tb.innerHTML = '<tr><td colspan="4" class="p-3 text-muted">Sin datos</td></tr>'; return; }
tb.innerHTML = '';
months.forEach(m=>{
const tr = document.createElement('tr');
tr.innerHTML = `
<td>${m.nombre||m.mes}</td>
<td class="text-end">${m.cant||0}</td>
<td class="text-end">${fmtMoney(m.importe||0)}</td>
<td class="text-end">${fmtMoney(m.avg||0)}</td>
`;
tb.appendChild(tr);
});
$('#ticketsInfo').textContent = `${months.length} meses`;
}
async function loadTickets(){
const y = Number($('#ticketsYear').value);
$('#repStatus').textContent = 'Cargando tickets…';
const data = await fetchTickets(y);
cacheTickets = data;
renderTickets(cacheTickets);
$('#repStatus').textContent = 'Listo';
}
/* =========================
Excel (CSV) & PDF
========================= */
function exportAsistCSV(){
if (!cacheAsist?.length) return;
const headers = ['Documento','Nombre','Apellido','Fecha','Desde','Hasta','Duración(min)'];
const rows = cacheAsist.map(r=>({
Documento:r.documento||'',
Nombre:r.nombre||'',
Apellido:r.apellido||'',
Fecha:r.fecha||'',
Desde:r.desde_hora||'',
Hasta:r.hasta_hora||'',
'Duración(min)':Number(r.dur_min||0)
}));
const csv = toCSV(rows, headers);
downloadText(`asistencias_${$('#asistDesde').value}_${$('#asistHasta').value}.csv`, csv);
}
function exportTicketsCSV(){
if (!cacheTickets?.months?.length) return;
const toInt = v => Math.round(Number(v || 0)); // sin decimales
const headers = ['Año','Mes','Tickets','Importe','Ticket promedio'];
const rows = cacheTickets.months.map(m => ({
'Año': cacheTickets.year,
'Mes': m.nombre || m.mes,
'Tickets': Number(m.cant || 0),
'Importe': toInt(m.importe), // ← entero
'Ticket promedio': toInt(m.avg) // ← entero
}));
const csv = toCSV(rows, headers);
downloadText(`tickets_${cacheTickets.year}.csv`, csv);
}
// PDF vía print-area del navegador
const onPDFAsist = () => printArea('PRINT_ASIST');
const onPDFTicket = () => printArea('PRINT_TICKETS');
/* =========================
Eventos + defaults
========================= */
document.getElementById('btnAsistCargar').addEventListener('click', loadAsist);
document.getElementById('btnTicketsCargar').addEventListener('click', loadTickets);
document.getElementById('btnAsistExcel').addEventListener('click', exportAsistCSV);
document.getElementById('btnTicketsExcel').addEventListener('click', exportTicketsCSV);
document.getElementById('btnAsistPDF').addEventListener('click', onPDFAsist);
document.getElementById('btnTicketsPDF').addEventListener('click', onPDFTicket);
// Defaults: último mes y año actual
(function initDefaults(){
const today = new Date();
const y = today.getFullYear();
const hasta = today.toISOString().slice(0,10);
const d = new Date(today); d.setMonth(d.getMonth()-1);
const desde = d.toISOString().slice(0,10);
$('#asistDesde').value = desde;
$('#asistHasta').value = hasta;
$('#ticketsYear').value = y;
// carga inicial
loadAsist().catch(()=>{});
loadTickets().catch(()=>{});
})();
</script>

View File

@ -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,69 +65,38 @@ 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
@ -122,42 +109,294 @@ app.use((req, res, 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({
// 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,
store: new RedisStore({ client: redis, prefix: 'sess:' }),
secret: process.env.SESSION_SECRET || 'change-me',
secret: SESSION_SECRET,
resave: false,
saveUninitialized: false,
store: new RedisStore({
client: redis,
prefix: "sc:", // opcional
}),
proxy: true,
cookie: {
secure: "auto",
httpOnly: true,
sameSite: 'lax',
secure: process.env.NODE_ENV === 'production',
// domain: 'suitecoffee.mateosaldain.uy', // (opcional) si lo necesitás
sameSite: "lax",
path: "/", // ¡crítico! visible en / y /auth/*
},
})
);
// Exponer usuario a las vistas
app.use((req, res, next) => {
res.locals.user = req.session?.user || null;
next();
});
}));
// -----------------------------------------------------------------------------
// 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(`
<!doctype html><meta charset="utf-8">
<title>SuiteCoffee · Definir contraseña</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css">
<div class="container py-5" style="max-width:520px;">
<h2 class="mb-4">Definir contraseña</h2>
<form method="post" action="/auth/password/set" class="vstack gap-3">
<input class="form-control" type="password" name="password" placeholder="Nueva contraseña" minlength="8" required>
<input class="form-control" type="password" name="password2" placeholder="Repetí la contraseña" minlength="8" required>
<button class="btn btn-primary" type="submit">Guardar y continuar</button>
<small class="text-muted">Luego te redirigiremos a iniciar sesión por SSO.</small>
</form>
</div>
`);
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();
});

View File

@ -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' }); });
}

View File

@ -0,0 +1,130 @@
<!-- views/inicio.ejs -->
<!doctype html>
<html lang="es">
<head>
<meta charset="utf-8" />
<meta http-equiv="x-ua-compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Inicio • SuiteCoffee</title>
<style>
:root { --bg:#0b0b0c; --card:#141519; --text:#e7e9ee; --muted:#a5acb8; --accent:#6ee7b7; --border:#232733; }
* { box-sizing: border-box; }
body { margin:0; font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, Noto Sans, Helvetica Neue, Arial, "Apple Color Emoji","Segoe UI Emoji"; background: var(--bg); color: var(--text); }
.wrap { max-width: 960px; margin: 48px auto; padding: 0 20px; }
.card { background: var(--card); border: 1px solid var(--border); border-radius: 16px; padding: 20px; box-shadow: 0 10px 30px rgba(0,0,0,.25); }
h1 { font-size: 26px; margin: 0 0 8px; }
p.lead { color: var(--muted); margin: 0 0 20px; }
h2 { margin: 24px 0 10px; font-size: 18px; color: var(--accent); }
table { width: 100%; border-collapse: collapse; overflow: hidden; border-radius: 12px; border: 1px solid var(--border); }
th, td { padding: 10px 12px; text-align: left; border-bottom: 1px solid var(--border); vertical-align: top; }
th { color: var(--muted); font-weight: 600; letter-spacing: .02em; }
tbody tr:last-child td { border-bottom: 0; }
code, pre { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; }
.grid { display: grid; gap: 18px; grid-template-columns: 1fr; }
@media (min-width: 900px) { .grid { grid-template-columns: 1fr 1fr; } }
.muted { color: var(--muted); }
.pill { display:inline-block; padding:2px 8px; border:1px solid var(--border); border-radius:999px; color:var(--muted); font-size:12px; }
.k { color:#93c5fd; }
.v { color:#fca5a5; word-break: break-all; }
</style>
</head>
<body>
<div class="wrap">
<div class="card">
<%
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';
%>
<h1>Hola, <span><%= displayName %></span> 👋</h1>
<p class="lead">Bienvenido a SuiteCoffee. Este es tu inicio.</p>
<% if (hasUser) { %>
<h2>Sesión</h2>
<table>
<tbody>
<% for (const [k,v] of Object.entries(user)) { %>
<tr>
<th><code class="k"><%= k %></code></th>
<td><code class="v"><%= typeof v === 'object' ? JSON.stringify(v) : String(v) %></code></td>
</tr>
<% } %>
</tbody>
</table>
<% } %>
<div class="grid" style="margin-top:18px;">
<section class="card">
<h2>Cookies (servidor)</h2>
<% if (hasCookies) { %>
<table>
<thead>
<tr><th>Nombre</th><th>Valor</th></tr>
</thead>
<tbody>
<% for (const [name, value] of Object.entries(cookies)) { %>
<tr>
<td><code class="k"><%= name %></code></td>
<td><code class="v"><%= typeof value === 'object' ? JSON.stringify(value) : String(value) %></code></td>
</tr>
<% } %>
</tbody>
</table>
<% } else { %>
<p class="muted">No se recibieron cookies desde el servidor (req.cookies). ¿Estás usando <code>cookie-parser</code> o pasando <code>cookies</code> al render?</p>
<% } %>
</section>
<section class="card">
<h2>Cookies (navegador)</h2>
<table id="client-cookies">
<thead>
<tr><th>Nombre</th><th>Valor</th></tr>
</thead>
<tbody>
<tr><td colspan="2" class="muted">Cargando…</td></tr>
</tbody>
</table>
<p class="muted" style="margin-top:10px;">Raw <code>document.cookie</code>:</p>
<pre id="cookie-raw" class="muted" style="white-space: pre-wrap; word-break: break-all;"></pre>
</section>
</div>
</div>
</div>
<script>
(function () {
try {
const tbody = document.querySelector('#client-cookies tbody');
const raw = document.cookie || '';
document.getElementById('cookie-raw').textContent = raw || '(sin cookies)';
const pairs = raw ? raw.split(/;\s*/) : [];
if (!pairs.length) {
tbody.innerHTML = '<tr><td colspan="2"><em class="muted">sin cookies</em></td></tr>';
return;
}
tbody.innerHTML = '';
for (const kv of pairs) {
const i = kv.indexOf('=');
const name = i >= 0 ? kv.slice(0, i) : kv;
const value = i >= 0 ? kv.slice(i + 1) : '';
const tr = document.createElement('tr');
const td1 = document.createElement('td');
const td2 = document.createElement('td');
td1.innerHTML = '<code class="k"></code>';
td2.innerHTML = '<code class="v"></code>';
td1.querySelector('code').textContent = decodeURIComponent(name);
td2.querySelector('code').textContent = decodeURIComponent(value);
tr.append(td1, td2);
tbody.appendChild(tr);
}
} catch (err) {
console.error('cookie render error:', err);
}
})();
</script>
</body>
</html>

View File

@ -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)

View File

@ -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<string, string|number|boolean|Array<string|number|boolean>>} [qs]
* @property {any} [body]
* @property {number} [timeoutMs=10000]
* @property {number} [retries=0]
* @property {Record<string,string>} [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 } },
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;
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/<pk>/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/<pk>/groups/ { group: <pk> }
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<string,string|number|boolean>, 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<any|null>}
*/
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<any>} 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: {
const email = String(p.email ?? "").trim().toLowerCase();
if (!email) throw new TypeError("akCreateUser: `email` is required");
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,
is_active: !!isActive,
attributes: tenantUuid ? { tenant_uuid: tenantUuid } : {},
},
retries: 3,
});
};
// 2) agregar a grupo (opcional)
if (p?.addToGroupId) {
let user;
try {
await request('POST', `/core/users/${user.pk}/groups/`, { body: { group: p.addToGroupId }, retries: 2 });
user = await akPOST("/core/users/", { body, 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 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 {
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);

View File

@ -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({
// 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,
store: new RedisStore({ client: redis, prefix: 'sess:' }),
secret: process.env.SESSION_SECRET || 'change-me',
secret: SESSION_SECRET,
resave: false,
saveUninitialized: false,
store: new RedisStore({
client: redis,
prefix: "sc:", // opcional
}),
proxy: true,
cookie: {
secure: "auto",
httpOnly: true,
sameSite: 'lax',
secure: process.env.NODE_ENV === 'production',
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;

View File

@ -0,0 +1,164 @@
<!doctype html>
<html lang="es">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title><%= typeof pageTitle !== 'undefined' ? pageTitle : 'Iniciar sesión · SuiteCoffee' %></title>
<!-- Bootstrap 5 (minimal) -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" crossorigin="anonymous">
<style>
:root {
--col-pri: #<%= (process.env.COL_PRI || '452D19') %>;
--col-sec: #<%= (process.env.COL_SEC || 'D7A666') %>;
--col-bg: #<%= (process.env.COL_BG || 'FFA500') %>33; /* con alpha */
}
body { background: radial-gradient(1200px 600px at 10% -10%, var(--col-bg), transparent), #f8f9fa; }
.brand { color: var(--col-pri); }
.btn-sso { background: var(--col-pri); color: #fff; border-color: var(--col-pri); }
.btn-sso:hover { filter: brightness(1.05); color: #fff; }
.card { border-radius: 14px; }
.form-hint { font-size: .875rem; color: #6c757d; }
.divider { display:flex; align-items:center; text-transform:uppercase; font-size:.8rem; color:#6c757d; }
.divider::before, .divider::after { content:""; height:1px; background:#dee2e6; flex:1; }
.divider:not(:empty)::before { margin-right:.75rem; }
.divider:not(:empty)::after { margin-left:.75rem; }
</style>
</head>
<body>
<div class="container py-5">
<div class="row justify-content-center">
<div class="col-12 col-sm-10 col-md-8 col-lg-6 col-xl-5">
<div class="text-center mb-4">
<h1 class="brand fw-bold">SuiteCoffee</h1>
<p class="text-secondary mb-0">Accedé a tu cuenta</p>
</div>
<!-- Mensajes (query ?msg= / ?error=) -->
<div id="flash" class="mb-3" style="display:none"></div>
<div class="card shadow-sm">
<div class="card-body p-4 p-md-5">
<!-- SSO con Authentik -->
<div class="d-grid gap-2 mb-3">
<a href="/auth/login" class="btn btn-sso btn-lg" id="btn-sso">
Ingresar con SSO (Authentik)
</a>
</div>
<div class="divider my-3">o</div>
<!-- Registro mínimo (usa POST /auth/api/users/register) -->
<form id="form-register" class="needs-validation" novalidate>
<div class="mb-3">
<label for="email" class="form-label">Email</label>
<input type="email" class="form-control" id="email" name="email" placeholder="tu@correo.com" required>
<div class="invalid-feedback">Ingresá un email válido.</div>
</div>
<div class="mb-3">
<label for="display_name" class="form-label">Nombre a mostrar</label>
<input type="text" class="form-control" id="display_name" name="display_name" placeholder="Ej.: Juan Pérez" required>
<div class="invalid-feedback">Ingresá tu nombre.</div>
</div>
<div class="mb-3">
<label for="tenant_uuid" class="form-label">Código de organización (tenant UUID)</label>
<input type="text" class="form-control" id="tenant_uuid" name="tenant_uuid" placeholder="Ej.: 4b8d0f6a-...">
<div class="form-hint">Si te invitaron a una organización existente, pegá aquí su UUID. Si sos el primero de tu empresa, dejalo vacío y el equipo te asignará uno.</div>
</div>
<div class="mb-3">
<label for="role" class="form-label">Rol</label>
<select id="role" name="role" class="form-select">
<option value="owner">Owner</option>
<option value="admin">Admin</option>
<option value="staff">Staff</option>
</select>
</div>
<div class="d-grid gap-2">
<button type="submit" class="btn btn-outline-dark">Crear cuenta</button>
</div>
</form>
<p class="text-center text-muted mt-3 mb-0" style="font-size:.9rem;">
Al continuar aceptás nuestros términos y políticas.
</p>
</div>
</div>
<p class="text-center text-secondary mt-3" style="font-size:.9rem;">
¿Ya tenés cuenta? <a href="/auth/login" class="link-dark">Iniciá sesión con SSO</a>
</p>
</div>
</div>
</div>
<script>
// Mostrar mensajes por querystring (?msg=... / ?error=...)
(function() {
const params = new URLSearchParams(location.search);
const el = document.getElementById('flash');
const msg = params.get('msg');
const err = params.get('error');
if (msg) {
el.innerHTML = `<div class="alert alert-success mb-0" role="alert">${decodeURIComponent(msg)}</div>`;
el.style.display = '';
} else if (err) {
el.innerHTML = `<div class="alert alert-danger mb-0" role="alert">${decodeURIComponent(err)}</div>`;
el.style.display = '';
}
})();
// Validación Bootstrap + envío del registro contra /auth/api/users/register
(function() {
const form = document.getElementById('form-register');
form.addEventListener('submit', async function(e) {
e.preventDefault();
e.stopPropagation();
form.classList.add('was-validated');
if (!form.checkValidity()) return;
const btn = form.querySelector('button[type="submit"]');
btn.disabled = true; btn.innerText = 'Creando...';
try {
const payload = {
email: document.getElementById('email').value.trim(),
display_name: document.getElementById('display_name').value.trim(),
tenant_uuid: document.getElementById('tenant_uuid').value.trim() || undefined,
role: document.getElementById('role').value
};
const res = await fetch('/auth/api/users/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
credentials: 'include'
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
throw new Error(data?.error || data?.message || 'No se pudo registrar');
}
// Registro OK → redirigimos a login SSO
const redir = '/auth/login';
location.href = redir + '?msg=' + encodeURIComponent('Registro exitoso. Iniciá sesión con SSO.');
} catch (err) {
alert(err.message || String(err));
} finally {
btn.disabled = false; btn.innerText = 'Crear cuenta';
}
});
})();
</script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js" integrity="sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz" crossorigin="anonymous"></script>
</body>
</html>