Puesta a punto

This commit is contained in:
Mateo Saldain 2025-09-22 16:59:29 +00:00
parent 69f5860b7f
commit b4c5d2af4f
45 changed files with 7709 additions and 3631 deletions

0
.env
View File

View File

@ -6,7 +6,6 @@ NODE_ENV=development
# app - app
APP_PORT=3030
# auth - app
AUTH_PORT=4040
@ -20,27 +19,19 @@ DB_NAME=dev-suitecoffee
DB_USER=dev-user-suitecoffee
DB_PASS=dev-pass-suitecoffee
# Authentik PostgreSQL Setup
AK_HOST_DB=ak-db
AK_PG_DB=authentik
AK_PG_USER=authentik
AK_PG_PASS=gOWjL8V564vyh1aXUcqh4o/xo7eObraaCVZezPi3iw2LzPlU
# --- secretos para Authentik
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
# Authentik Cookies
AUTHENTIK_COOKIE__DOMAIN=sso.suitecoffee.uy
AUTHENTIK_SECURITY__CSRF_TRUSTED_ORIGINS=https://sso.suitecoffee.uy,https://suitecoffee.uy
PG_PASS=gOWjL8V564vyh1aXUcqh4o/xo7eObraaCVZezPi3iw2LzPlU
# Authentik Security
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
# Authentik Bootstrap
AUTHENTIK_BOOTSTRAP_PASSWORD=info.suitecoffee@gmail.com
AUTHENTIK_BOOTSTRAP_EMAIL=info.suitecoffee@gmail.com

View File

@ -18,4 +18,36 @@ TENANTS_DB_PASS=postgres
# db primaria - postgres
DB_NAME=suitecoffee
DB_USER=suitecoffee
DB_PASS=suitecoffee
DB_PASS=suitecoffee
# Authentik PostgreSQL Setup
AK_HOST_DB=ak-db
AK_PG_DB=authentik
AK_PG_USER=authentik
AK_PG_PASS=gOWjL8V564vyh1aXUcqh4o/xo7eObraaCVZezPi3iw2LzPlU
# Authentik Cookies
AUTHENTIK_COOKIE__DOMAIN=sso.suitecoffee.uy
AUTHENTIK_SECURITY__CSRF_TRUSTED_ORIGINS=https://sso.suitecoffee.uy,https://suitecoffee.uy
# Authentik Security
AUTHENTIK_SECRET_KEY=11zMsUL57beO+okjeGh7OB3lQdGUWII+VaATHs/zsw1+6KMSTyGfAY0yHpq3C442+3CwrZ/KtjgHBfbv
AUTHENTIK_ERROR_REPORTING__ENABLED=true
# Authentik Email
AUTHENTIK_EMAIL__HOST=smtp.gmail.com # SMTP Host Emails are sent to
AUTHENTIK_EMAIL__PORT=465
AUTHENTIK_EMAIL__PASSWORD=Succulent-Sanded7
AUTHENTIK_EMAIL__USE_TLS=false # Use StartTLS
AUTHENTIK_EMAIL__USE_SSL=true # Use SSL
AUTHENTIK_EMAIL__TIMEOUT=10
# Email address authentik will send from, should have a correct @domain
AUTHENTIK_EMAIL__FROM=info.suitecoffee@gmail.com
# Authentik Bootstrap
AUTHENTIK_BOOTSTRAP_PASSWORD=info.suitecoffee@gmail.com
AUTHENTIK_BOOTSTRAP_EMAIL=info.suitecoffee@gmail.com

View File

@ -1,344 +0,0 @@
// 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}`);
});
})();

1015
auth.index.js Normal file

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 717 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 717 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1005 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 173 KiB

View File

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Capa_1" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 1024 1024">
<!-- Generator: Adobe Illustrator 29.6.1, SVG Export Plug-In . SVG Version: 2.1.1 Build 9) -->
<defs>
<style>
.st0 {
fill: #fff;
}
</style>
</defs>
<path class="st0" d="M1024,0v1024H0V0h1024ZM555.65,53.34c-9.62.5-42.47,33.75-50.29,42.27-52.83,57.58-92.54,133.71-99.27,212.63-9.61,112.64,65.25,175.4,107.41,269.2,52.92,117.75,31.19,241.15-37.67,346.37-5.24,8.01-19.02,22.41-21.61,30.02-2.38,7.01,4.2,10.95,10.05,6.97,98.88-80.26,173.94-198.57,145.59-331.12-19.98-93.4-85.71-170.1-121.65-256.47-40.46-97.24-10.37-194.22,47.61-276.58,5.77-8.2,22.16-24.87,25.06-32.31.97-2.5,1.81-4.69.97-7.43-.72-2.16-3.99-3.67-6.19-3.56Z"/>
<path d="M555.65,53.34c2.2-.12,5.46,1.4,6.19,3.56.85,2.74,0,4.92-.97,7.43-2.89,7.44-19.28,24.11-25.06,32.31-57.98,82.36-88.07,179.34-47.61,276.58,35.94,86.37,101.67,163.07,121.65,256.47,28.35,132.55-46.71,250.87-145.59,331.12-5.85,3.99-12.43.04-10.05-6.97,2.59-7.61,16.36-22.01,21.61-30.02,68.86-105.22,90.59-228.62,37.67-346.37-42.16-93.81-117.02-156.57-107.41-269.2,6.74-78.93,46.45-155.06,99.27-212.63,7.81-8.51,40.66-41.77,50.29-42.27Z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 186 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 199 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 169 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 528 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 488 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@ -0,0 +1 @@
{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"}

View File

@ -10,6 +10,7 @@ services:
volumes:
- ./services/app:/app:rw
- ./services/app/node_modules:/app/node_modules
# - ./services/shared:/app/shared
env_file:
- ./services/app/.env.development
environment:
@ -21,23 +22,24 @@ services:
aliases: [dev-app]
command: npm run dev
auth:
image: node:20-bookworm
working_dir: /app
user: "${UID:-1000}:${GID:-1000}"
volumes:
- ./services/auth:/app:rw
- ./services/auth/node_modules:/app/node_modules
env_file:
- ./services/auth/.env.development
environment:
NODE_ENV: development # <- fuerza el entorno para que el loader tome .env.development
expose:
- ${AUTH_PORT}
networks:
net:
aliases: [dev-auth]
command: npm run dev
# auth:
# image: node:20-bookworm
# working_dir: /app
# user: "${UID:-1000}:${GID:-1000}"
# volumes:
# - ./services/auth:/app:rw
# - ./services/auth/node_modules:/app/node_modules
# - ./services/shared:/app/shared
# env_file:
# - ./services/auth/.env.development
# environment:
# NODE_ENV: development # <- fuerza el entorno para que el loader tome .env.development
# expose:
# - ${AUTH_PORT}
# networks:
# net:
# aliases: [dev-auth]
# command: npm run dev
db:
image: postgres:16
@ -72,9 +74,9 @@ services:
env_file:
- .env.development
environment:
POSTGRES_DB: ${PG_DB:-authentik}
POSTGRES_PASSWORD: ${PG_PASS:?database password required}
POSTGRES_USER: ${PG_USER:-authentik}
POSTGRES_DB: ${AK_PG_DB:-authentik}
POSTGRES_PASSWORD: ${AK_PG_PASS:?database password required}
POSTGRES_USER: ${AK_PG_USER:-authentik}
volumes:
- authentik-db:/var/lib/postgresql/data
networks:
@ -96,49 +98,50 @@ services:
- .env.development
command: server
environment:
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_POSTGRESQL__NAME: ${AK_PG_DB:-authentik}
AUTHENTIK_POSTGRESQL__PASSWORD: ${AK_PG_PASS}
AUTHENTIK_POSTGRESQL__USER: ${AK_PG_USER:-authentik}
AUTHENTIK_REDIS__HOST: ak-redis
AUTHENTIK_SECRET_KEY: ${AUTHENTIK_SECRET_KEY:?secret key required}
AUTHENTIK_BOOTSTRAP_PASSWORD: Succulent-Sanded7
AUTHENTIK_BOOTSTRAP_EMAIL: info.suitecoffee@gmail.com
AUTHENTIK_BOOTSTRAP_PASSWORD: ${AUTHENTIK_BOOTSTRAP_PASSWORD}
AUTHENTIK_BOOTSTRAP_EMAIL: ${AUTHENTIK_BOOTSTRAP_EMAIL}
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,https://sso.suitecoffee.uy,https://suitecoffee.uy"
AUTHENTIK_COOKIE__DOMAIN: sso.suitecoffee.uy
AUTHENTIK_SECURITY__CSRF_TRUSTED_ORIGINS: ${AUTHENTIK_SECURITY__CSRF_TRUSTED_ORIGINS}
AUTHENTIK_COOKIE__DOMAIN: ${AUTHENTIK_COOKIE__DOMAIN}
networks:
net:
aliases: [dev-authentik]
volumes:
- ./media:/media
- ./custom-templates:/templates
- ./authentik-media:/media
- ./authentik-custom-templates:/templates
ak-worker:
image: ghcr.io/goauthentik/server:latest
command: worker
environment:
AUTHENTIK_POSTGRESQL__HOST: ak-db
AUTHENTIK_POSTGRESQL__NAME: ${PG_DB:-authentik}
AUTHENTIK_POSTGRESQL__PASSWORD: ${PG_PASS}
AUTHENTIK_POSTGRESQL__USER: ${PG_USER:-authentik}
AUTHENTIK_POSTGRESQL__NAME: ${AK_PG_DB:-authentik}
AUTHENTIK_POSTGRESQL__PASSWORD: ${AK_PG_PASS}
AUTHENTIK_POSTGRESQL__USER: ${AK_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,https://sso.suitecoffee.uy,https://suitecoffee.uy"
AUTHENTIK_COOKIE__DOMAIN: "sso.suitecoffee.uy"
AUTHENTIK_SECURITY__CSRF_TRUSTED_ORIGINS: ${AUTHENTIK_SECURITY__CSRF_TRUSTED_ORIGINS}
AUTHENTIK_COOKIE__DOMAIN: ${AUTHENTIK_COOKIE__DOMAIN}
networks:
net:
aliases: [dev-ak-work]
user: root
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- ./media:/media
- ./certs:/certs
- ./custom-templates:/templates
- ./authentik-media:/media
- ./authentik-certs:/certs
- ./authentik-custom-templates:/templates
volumes:
tenants-db:

View File

@ -17,19 +17,19 @@ services:
start_period: 20s
restart: unless-stopped
auth:
depends_on:
db:
condition: service_healthy
ak:
condition: service_started
healthcheck:
test: ["CMD-SHELL", "curl -fsS http://localhost:${AUTH_PORT}/health || exit 1"]
interval: 10s
timeout: 3s
retries: 10
start_period: 15s
restart: unless-stopped
# auth:
# depends_on:
# db:
# condition: service_healthy
# ak:
# condition: service_started
# healthcheck:
# test: ["CMD-SHELL", "curl -fsS http://localhost:${AUTH_PORT}/health || exit 1"]
# interval: 10s
# timeout: 3s
# retries: 10
# start_period: 15s
# restart: unless-stopped
db:
image: postgres:16
@ -58,25 +58,21 @@ services:
ak-db:
image: postgres:16-alpine
healthcheck:
test: ["CMD-SHELL", "pg_isready -d ${AK_PG_DB} -U ${AK_PG_USER} || exit 1"]
interval: 30s
retries: 5
start_period: 20s
test:
- CMD-SHELL
- pg_isready -d $${POSTGRES_DB} -U $${POSTGRES_USER}
timeout: 5s
restart: unless-stopped
ak-redis:
image: redis:7-alpine
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 30s
timeout: 5s
retries: 5
start_period: 20s
test:
- CMD-SHELL
- redis-cli ping | grep PONG
timeout: 3s
start_period: 10s
restart: unless-stopped
ak:
@ -92,8 +88,8 @@ services:
image: ghcr.io/goauthentik/server:latest
depends_on:
ak-db:
condition: service_started
condition: service_healthy
ak-redis:
condition: service_started
condition: service_healthy
restart: unless-stopped

View File

@ -5,22 +5,34 @@ PORT=3030
# ===== Session (usa el Redis del stack) =====
# Para DEV podemos reutilizar el Redis de Authentik. En prod conviene uno separado.
SESSION_SECRET=Neon*Mammal*Boaster*Ludicrous*Fender8*Crablike
SESSION_COOKIE_NAME=sc.sid
REDIS_URL=redis://ak-redis:6379
# # ===== DB principal (metadatos de SuiteCoffee) =====
# ===== DB principal (metadatos de SuiteCoffee) =====
# Usa el alias de red del servicio 'db' (compose: aliases [dev-db])
DB_HOST=dev-db
DB_NAME=dev-suitecoffee
DB_PORT=5432
DB_NAME=dev-suitecoffee
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
SESSION_COOKIE_NAME=sc.sid
SESSION_SECRET=pon-una-clave-larga-y-unica
REDIS_URL=redis://authentik-redis:6379
TENANT_INIT_SQL=/app/src/db/initTenant_v2.sql
OIDC_CLIEN_ID=1orMM8vOvf3WkN2FejXYvUFpPtONG0Lx1eMlwIpW
OIDC_CONFIG_URL=https://sso.suitecoffee.uy/application/o/suitecoffee/.well-known/openid-configuration
OIDC_ISSUER=https://sso.suitecoffee.uy/application/o/suitecoffee/
OIDC_AUTHORIZE_URL=https://sso.suitecoffee.uy/application/o/authorize/
OIDC_TOKEN_URL=https://sso.suitecoffee.uy/application/o/token/
OIDC_USERINFO_URL=https://sso.suitecoffee.uy/application/o/userinfo/
OIDC_LOGOUT_URL=https://sso.suitecoffee.uy/application/o/suitecoffee/end-session/
OIDC_JWKS_URL=https://sso.suitecoffee.uy/application/o/suitecoffee/jwks/
OIDC_LOGIN_URL=https://sso.suitecoffee.uy
APP_BASE_URL=https://suitecoffee.uy

View File

@ -12,6 +12,7 @@
"bcrypt": "^6.0.0",
"chalk": "^5.6.0",
"connect-redis": "^9.0.0",
"cookie-parser": "^1.4.7",
"cors": "^2.8.5",
"dotenv": "^17.2.1",
"ejs": "^3.1.10",
@ -19,7 +20,11 @@
"express-ejs-layouts": "^2.5.1",
"express-session": "^1.18.2",
"ioredis": "^5.7.0",
"jose": "^6.1.0",
"jsonwebtoken": "^9.0.2",
"jwks-rsa": "^3.2.0",
"morgan": "^1.10.1",
"node-fetch": "^3.3.2",
"pg": "^8.16.3",
"pg-format": "^1.0.4",
"redis": "^5.8.2",
@ -101,6 +106,119 @@
"@redis/client": "^5.8.2"
}
},
"node_modules/@types/body-parser": {
"version": "1.19.6",
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
"integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==",
"license": "MIT",
"dependencies": {
"@types/connect": "*",
"@types/node": "*"
}
},
"node_modules/@types/connect": {
"version": "3.4.38",
"resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz",
"integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==",
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/express": {
"version": "4.17.23",
"resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.23.tgz",
"integrity": "sha512-Crp6WY9aTYP3qPi2wGDo9iUe/rceX01UMhnF1jmwDcKCFM6cx7YhGP/Mpr3y9AASpfHixIG0E6azCcL5OcDHsQ==",
"license": "MIT",
"dependencies": {
"@types/body-parser": "*",
"@types/express-serve-static-core": "^4.17.33",
"@types/qs": "*",
"@types/serve-static": "*"
}
},
"node_modules/@types/express-serve-static-core": {
"version": "4.19.6",
"resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.6.tgz",
"integrity": "sha512-N4LZ2xG7DatVqhCZzOGb1Yi5lMbXSZcmdLDe9EzSndPV2HpWYWzRbaerl2n27irrm94EPpprqa8KpskPT085+A==",
"license": "MIT",
"dependencies": {
"@types/node": "*",
"@types/qs": "*",
"@types/range-parser": "*",
"@types/send": "*"
}
},
"node_modules/@types/http-errors": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz",
"integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==",
"license": "MIT"
},
"node_modules/@types/jsonwebtoken": {
"version": "9.0.10",
"resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz",
"integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==",
"license": "MIT",
"dependencies": {
"@types/ms": "*",
"@types/node": "*"
}
},
"node_modules/@types/mime": {
"version": "1.3.5",
"resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz",
"integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==",
"license": "MIT"
},
"node_modules/@types/ms": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
"integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
"license": "MIT"
},
"node_modules/@types/node": {
"version": "24.3.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.3.1.tgz",
"integrity": "sha512-3vXmQDXy+woz+gnrTvuvNrPzekOi+Ds0ReMxw0LzBiK3a+1k0kQn9f2NWk+lgD4rJehFUmYy2gMhJ2ZI+7YP9g==",
"license": "MIT",
"dependencies": {
"undici-types": "~7.10.0"
}
},
"node_modules/@types/qs": {
"version": "6.14.0",
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz",
"integrity": "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==",
"license": "MIT"
},
"node_modules/@types/range-parser": {
"version": "1.2.7",
"resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz",
"integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==",
"license": "MIT"
},
"node_modules/@types/send": {
"version": "0.17.5",
"resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.5.tgz",
"integrity": "sha512-z6F2D3cOStZvuk2SaP6YrwkNO65iTZcwA2ZkSABegdkAh/lf+Aa/YQndZVfmEXT5vgAp6zv06VQ3ejSVjAny4w==",
"license": "MIT",
"dependencies": {
"@types/mime": "^1",
"@types/node": "*"
}
},
"node_modules/@types/serve-static": {
"version": "1.15.8",
"resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.8.tgz",
"integrity": "sha512-roei0UY3LhpOJvjbIP6ZZFngyLKl5dskOtDhxY5THRSpO+ZI+nzJ+m5yUMzGrp89YRa7lvknKkMYjqQFGwA7Sg==",
"license": "MIT",
"dependencies": {
"@types/http-errors": "*",
"@types/node": "*",
"@types/send": "*"
}
},
"node_modules/accepts": {
"version": "2.0.0",
"license": "MIT",
@ -213,6 +331,12 @@
"node": ">=8"
}
},
"node_modules/buffer-equal-constant-time": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
"license": "BSD-3-Clause"
},
"node_modules/bytes": {
"version": "3.1.2",
"license": "MIT",
@ -329,6 +453,25 @@
"node": ">= 0.6"
}
},
"node_modules/cookie-parser": {
"version": "1.4.7",
"resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz",
"integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==",
"license": "MIT",
"dependencies": {
"cookie": "0.7.2",
"cookie-signature": "1.0.6"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/cookie-parser/node_modules/cookie-signature": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
"integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
"license": "MIT"
},
"node_modules/cookie-signature": {
"version": "1.2.2",
"license": "MIT",
@ -376,6 +519,15 @@
"node": ">= 8"
}
},
"node_modules/data-uri-to-buffer": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz",
"integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==",
"license": "MIT",
"engines": {
"node": ">= 12"
}
},
"node_modules/debug": {
"version": "4.4.1",
"license": "MIT",
@ -429,6 +581,15 @@
"node": ">= 0.4"
}
},
"node_modules/ecdsa-sig-formatter": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
"license": "Apache-2.0",
"dependencies": {
"safe-buffer": "^5.0.1"
}
},
"node_modules/ee-first": {
"version": "1.1.1",
"license": "MIT"
@ -571,6 +732,29 @@
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT"
},
"node_modules/fetch-blob": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz",
"integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/jimmywarting"
},
{
"type": "paypal",
"url": "https://paypal.me/jimmywarting"
}
],
"license": "MIT",
"dependencies": {
"node-domexception": "^1.0.0",
"web-streams-polyfill": "^3.0.3"
},
"engines": {
"node": "^12.20 || >= 14.13"
}
},
"node_modules/filelist": {
"version": "1.0.4",
"license": "Apache-2.0",
@ -621,6 +805,18 @@
"node": ">= 0.8"
}
},
"node_modules/formdata-polyfill": {
"version": "4.0.10",
"resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz",
"integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==",
"license": "MIT",
"dependencies": {
"fetch-blob": "^3.1.2"
},
"engines": {
"node": ">=12.20.0"
}
},
"node_modules/forwarded": {
"version": "0.2.0",
"license": "MIT",
@ -857,18 +1053,171 @@
"node": ">=10"
}
},
"node_modules/jose": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/jose/-/jose-6.1.0.tgz",
"integrity": "sha512-TTQJyoEoKcC1lscpVDCSsVgYzUDg/0Bt3WE//WiTPK6uOCQC2KZS4MpugbMWt/zyjkopgZoXhZuCi00gLudfUA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/jsonwebtoken": {
"version": "9.0.2",
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz",
"integrity": "sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==",
"license": "MIT",
"dependencies": {
"jws": "^3.2.2",
"lodash.includes": "^4.3.0",
"lodash.isboolean": "^3.0.3",
"lodash.isinteger": "^4.0.4",
"lodash.isnumber": "^3.0.3",
"lodash.isplainobject": "^4.0.6",
"lodash.isstring": "^4.0.1",
"lodash.once": "^4.0.0",
"ms": "^2.1.1",
"semver": "^7.5.4"
},
"engines": {
"node": ">=12",
"npm": ">=6"
}
},
"node_modules/jwa": {
"version": "1.4.2",
"resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.2.tgz",
"integrity": "sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw==",
"license": "MIT",
"dependencies": {
"buffer-equal-constant-time": "^1.0.1",
"ecdsa-sig-formatter": "1.0.11",
"safe-buffer": "^5.0.1"
}
},
"node_modules/jwks-rsa": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/jwks-rsa/-/jwks-rsa-3.2.0.tgz",
"integrity": "sha512-PwchfHcQK/5PSydeKCs1ylNym0w/SSv8a62DgHJ//7x2ZclCoinlsjAfDxAAbpoTPybOum/Jgy+vkvMmKz89Ww==",
"license": "MIT",
"dependencies": {
"@types/express": "^4.17.20",
"@types/jsonwebtoken": "^9.0.4",
"debug": "^4.3.4",
"jose": "^4.15.4",
"limiter": "^1.1.5",
"lru-memoizer": "^2.2.0"
},
"engines": {
"node": ">=14"
}
},
"node_modules/jwks-rsa/node_modules/jose": {
"version": "4.15.9",
"resolved": "https://registry.npmjs.org/jose/-/jose-4.15.9.tgz",
"integrity": "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/jws": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz",
"integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==",
"license": "MIT",
"dependencies": {
"jwa": "^1.4.1",
"safe-buffer": "^5.0.1"
}
},
"node_modules/limiter": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/limiter/-/limiter-1.1.5.tgz",
"integrity": "sha512-FWWMIEOxz3GwUI4Ts/IvgVy6LPvoMPgjMdQ185nN6psJyBJ4yOpzqm695/h5umdLJg2vW3GR5iG11MAkR2AzJA=="
},
"node_modules/lodash.clonedeep": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz",
"integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==",
"license": "MIT"
},
"node_modules/lodash.defaults": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz",
"integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==",
"license": "MIT"
},
"node_modules/lodash.includes": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
"integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
"license": "MIT"
},
"node_modules/lodash.isarguments": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz",
"integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==",
"license": "MIT"
},
"node_modules/lodash.isboolean": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
"integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",
"license": "MIT"
},
"node_modules/lodash.isinteger": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
"integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==",
"license": "MIT"
},
"node_modules/lodash.isnumber": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz",
"integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==",
"license": "MIT"
},
"node_modules/lodash.isplainobject": {
"version": "4.0.6",
"resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
"integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
"license": "MIT"
},
"node_modules/lodash.isstring": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz",
"integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==",
"license": "MIT"
},
"node_modules/lodash.once": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
"integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
"license": "MIT"
},
"node_modules/lru-cache": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
"integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
"license": "ISC",
"dependencies": {
"yallist": "^4.0.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/lru-memoizer": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/lru-memoizer/-/lru-memoizer-2.3.0.tgz",
"integrity": "sha512-GXn7gyHAMhO13WSKrIiNfztwxodVsP8IoZ3XfrJV4yH2x0/OeTO/FIaAHTY5YekdGgW94njfuKmyyt1E0mR6Ug==",
"license": "MIT",
"dependencies": {
"lodash.clonedeep": "^4.5.0",
"lru-cache": "6.0.0"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"license": "MIT",
@ -984,6 +1333,44 @@
"node": "^18 || ^20 || >= 21"
}
},
"node_modules/node-domexception": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz",
"integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==",
"deprecated": "Use your platform's native DOMException instead",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/jimmywarting"
},
{
"type": "github",
"url": "https://paypal.me/jimmywarting"
}
],
"license": "MIT",
"engines": {
"node": ">=10.5.0"
}
},
"node_modules/node-fetch": {
"version": "3.3.2",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz",
"integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==",
"license": "MIT",
"dependencies": {
"data-uri-to-buffer": "^4.0.0",
"fetch-blob": "^3.1.4",
"formdata-polyfill": "^4.0.10"
},
"engines": {
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/node-fetch"
}
},
"node_modules/node-gyp-build": {
"version": "4.8.4",
"resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz",
@ -1365,7 +1752,6 @@
},
"node_modules/semver": {
"version": "7.7.2",
"dev": true,
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
@ -1612,6 +1998,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/undici-types": {
"version": "7.10.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.10.0.tgz",
"integrity": "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag==",
"license": "MIT"
},
"node_modules/unpipe": {
"version": "1.0.0",
"license": "MIT",
@ -1626,6 +2018,15 @@
"node": ">= 0.8"
}
},
"node_modules/web-streams-polyfill": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz",
"integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==",
"license": "MIT",
"engines": {
"node": ">= 8"
}
},
"node_modules/which": {
"version": "2.0.2",
"dev": true,
@ -1650,6 +2051,12 @@
"engines": {
"node": ">=0.4"
}
},
"node_modules/yallist": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
"license": "ISC"
}
}
}

View File

@ -18,6 +18,7 @@
"bcrypt": "^6.0.0",
"chalk": "^5.6.0",
"connect-redis": "^9.0.0",
"cookie-parser": "^1.4.7",
"cors": "^2.8.5",
"dotenv": "^17.2.1",
"ejs": "^3.1.10",
@ -25,7 +26,11 @@
"express-ejs-layouts": "^2.5.1",
"express-session": "^1.18.2",
"ioredis": "^5.7.0",
"jose": "^6.1.0",
"jsonwebtoken": "^9.0.2",
"jwks-rsa": "^3.2.0",
"morgan": "^1.10.1",
"node-fetch": "^3.3.2",
"pg": "^8.16.3",
"pg-format": "^1.0.4",
"redis": "^5.8.2",

181
services/app/src/api/api.js Normal file
View File

@ -0,0 +1,181 @@
// // ----------------------------------------------------------
// // API
// // ----------------------------------------------------------
// app.get('/api/tables', async (_req, res) => {
// res.json(ALLOWED_TABLES);
// });
// 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 });
// }
// });
// 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 });
// }
// });
// 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); }
});

230
services/app/src/api/rpc.js Normal file
View File

@ -0,0 +1,230 @@
// // 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
// });
// }
// });

View File

@ -1,355 +0,0 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>Comandas</title>
<style>
:root { --gap: 12px; --radius: 10px; }
* { box-sizing: border-box; }
body { margin:0; font-family: system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, 'Helvetica Neue', Arial; background:#f6f7fb; color:#111; }
header { position: sticky; top: 0; background:#fff; border-bottom:1px solid #e7e7ef; padding:14px 18px; display:flex; gap:10px; align-items:center; z-index:2; }
header h1 { margin:0; font-size:16px; font-weight:600; }
main { padding: 18px; max-width: 1200px; margin: 0 auto; display:grid; grid-template-columns: 1.15fr 0.85fr; gap: var(--gap); }
.card { background:#fff; border:1px solid #e7e7ef; border-radius: var(--radius); }
.card .hd { padding:12px 14px; border-bottom:1px solid #eee; display:flex; align-items:center; gap:10px; }
.card .bd { padding:14px; }
.row { display:flex; gap:10px; align-items:center; flex-wrap: wrap; }
.grid { display:grid; gap:10px; }
.grid.cols-2 { grid-template-columns: 1fr 1fr; }
.muted { color:#666; }
select, input, textarea, button { font: inherit; padding:10px; border:1px solid #dadbe4; border-radius:8px; background:#fff; }
select:focus, input:focus, textarea:focus { outline:none; border-color:#999; }
input[type="number"] { width: 100%; }
textarea { width:100%; min-height: 68px; resize: vertical; }
button { cursor: pointer; }
.btn { padding:10px 12px; border-radius:8px; border:1px solid #dadbe4; background:#fafafa; }
.btn.primary { background:#111; border-color:#111; color:#fff; }
.btn.ghost { background:#fff; }
.btn.small { padding:6px 8px; font-size: 13px; }
.pill { font-size:12px; padding:2px 8px; border-radius:99px; border:1px solid #e0e0ea; background:#fafafa; display:inline-block; }
.toolbar { display:flex; gap:10px; align-items:center; }
.spacer { flex:1 }
.search { display:flex; gap:8px; }
.search input { flex:1; }
table { width:100%; border-collapse: collapse; }
thead th { text-align:left; font-size:12px; text-transform: uppercase; letter-spacing:.04em; color:#555; background:#fafafa; }
th, td { padding:8px 10px; border-bottom:1px solid #eee; vertical-align: middle; }
.qty { display:flex; align-items:center; gap:6px; }
.qty input { width: 90px; }
.right { text-align:right; }
.total { font-size: 22px; font-weight: 700; }
.notice { padding:10px; border-radius:8px; border:1px solid #e7e7ef; background:#fafafa; }
.ok { color:#0a7d28; }
.err { color:#b00020; }
.sticky-footer { position: sticky; bottom: 0; background:#fff; padding:12px 14px; border-top:1px solid #eee; display:flex; gap:10px; align-items:center; }
.kpi { display:flex; gap:6px; align-items: baseline; }
</style>
</head>
<body>
<header>
<h1>📋 Nueva Comanda</h1>
<div class="spacer"></div>
<span class="pill muted">/api/*</span>
</header>
<main>
<!-- Panel izquierdo: productos -->
<section class="card" id="panelProductos">
<div class="hd">
<strong>Productos</strong>
<div class="spacer"></div>
<div class="toolbar">
<span class="muted" id="prodCount">0 ítems</span>
</div>
</div>
<div class="bd">
<div class="row search" style="margin-bottom:10px;">
<input id="busqueda" type="search" placeholder="Buscar por nombre o categoría…"/>
<button class="btn" id="limpiarBusqueda">Limpiar</button>
</div>
<div id="listadoProductos" style="max-height: 58vh; overflow:auto;">
<!-- tabla productos -->
</div>
</div>
</section>
<!-- Panel derecho: datos + carrito + crear -->
<section class="card" id="panelComanda">
<div class="hd"><strong>Detalles</strong></div>
<div class="bd grid" style="gap:14px;">
<div class="grid cols-2">
<div>
<label class="muted">Mesa</label>
<select id="selMesa"></select>
</div>
<div>
<label class="muted">Usuario</label>
<select id="selUsuario"></select>
</div>
</div>
<div>
<label class="muted">Observaciones</label>
<textarea id="obs"></textarea>
</div>
<div class="notice muted">La fecha se completa automáticamente y los estados/activos usan sus valores por defecto.</div>
<div class="card">
<div class="hd"><strong>Carrito</strong></div>
<div class="bd" id="carritoWrap">
<div class="muted">Aún no agregaste productos.</div>
</div>
<div class="sticky-footer">
<div class="kpi"><span class="muted">Ítems:</span><strong id="kpiItems">0</strong></div>
<div class="kpi" style="margin-left:8px;"><span class="muted">Total:</span><strong id="kpiTotal">$ 0.00</strong></div>
<div class="spacer"></div>
<button class="btn ghost" id="vaciar">Vaciar</button>
<button class="btn primary" id="crear">Crear Comanda</button>
</div>
</div>
<div id="msg" class="muted"></div>
</div>
</section>
</main>
<script>
const $ = (s, r=document) => r.querySelector(s);
const $$ = (s, r=document) => Array.from(r.querySelectorAll(s));
const state = {
productos: [],
mesas: [],
usuarios: [],
carrito: [], // [{id_producto, nombre, pre_unitario, cantidad}]
filtro: ''
};
// ---------- Utils ----------
const money = (n) => (isNaN(n) ? '—' : new Intl.NumberFormat('es-UY', { style:'currency', currency:'UYU' }).format(Number(n)));
const toast = (msg, ok=false) => { const el = $('#msg'); el.className = ok ? 'ok' : 'err'; el.textContent = msg; setTimeout(()=>{ el.textContent=''; el.className='muted'; }, 3500); };
async function jget(url) {
const res = await fetch(url);
let data; try { data = await res.json(); } catch { data = null; }
if (!res.ok) throw new Error(data?.error || `${res.status} ${res.statusText}`);
return data;
}
async function jpost(url, body) {
const res = await fetch(url, { method:'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify(body) });
const data = await res.json().catch(()=>null);
if (!res.ok) throw new Error(data?.error || `${res.status} ${res.statusText}`);
return data;
}
// ---------- Load data ----------
async function init() {
// productos, mesas, usuarios
const [prods, mesas, usuarios] = await Promise.all([
jget('/api/table/productos?limit=1000'),
jget('/api/table/mesas?limit=1000'),
jget('/api/table/usuarios?limit=1000')
]);
state.productos = prods.filter(p => p.activo !== false); // si existe activo=false, filtrarlo
state.mesas = mesas;
state.usuarios = usuarios.filter(u => u.activo !== false);
hydrateMesas();
hydrateUsuarios();
renderProductos();
renderCarrito();
$('#busqueda').addEventListener('input', () => { state.filtro = $('#busqueda').value.trim().toLowerCase(); renderProductos(); });
$('#limpiarBusqueda').addEventListener('click', () => { $('#busqueda').value=''; state.filtro=''; renderProductos(); });
$('#vaciar').addEventListener('click', () => { state.carrito=[]; renderCarrito(); });
$('#crear').addEventListener('click', crearComanda);
}
function hydrateMesas() {
const sel = $('#selMesa'); sel.innerHTML = '';
for (const m of state.mesas) {
const o = document.createElement('option');
o.value = m.id_mesa;
o.textContent = `#${m.numero} · ${m.apodo} (${m.estado})`;
sel.appendChild(o);
}
}
function hydrateUsuarios() {
const sel = $('#selUsuario'); sel.innerHTML = '';
for (const u of state.usuarios) {
const o = document.createElement('option');
o.value = u.id_usuario;
o.textContent = `${u.nombre} ${u.apellido}`.trim();
sel.appendChild(o);
}
}
// ---------- Productos ----------
function renderProductos() {
let rows = state.productos.slice();
if (state.filtro) {
rows = rows.filter(p =>
(p.nombre || '').toLowerCase().includes(state.filtro) ||
String(p.id_categoria ?? '').includes(state.filtro)
);
}
$('#prodCount').textContent = `${rows.length} ítems`;
if (!rows.length) {
$('#listadoProductos').innerHTML = '<div class="muted">Sin resultados.</div>';
return;
}
const tbl = document.createElement('table');
tbl.innerHTML = `
<thead>
<tr>
<th>Producto</th>
<th class="right">Precio</th>
<th style="width:180px;">Cantidad</th>
<th style="width:90px;"></th>
</tr>
</thead>
<tbody></tbody>
`;
const tb = tbl.querySelector('tbody');
for (const p of rows) {
const tr = document.createElement('tr');
tr.innerHTML = `
<td>${p.nombre}</td>
<td class="right">${money(p.precio)}</td>
<td>
<div class="qty">
<input type="number" min="0.001" step="0.001" value="1.000" data-qty />
<button class="btn small" data-dec>-</button>
<button class="btn small" data-inc>+</button>
</div>
</td>
<td><button class="btn primary small" data-add>Agregar</button></td>
`;
const qty = tr.querySelector('[data-qty]');
tr.querySelector('[data-dec]').addEventListener('click', () => { qty.value = Math.max(0.001, (parseFloat(qty.value||'0') - 1)).toFixed(3); });
tr.querySelector('[data-inc]').addEventListener('click', () => { qty.value = (parseFloat(qty.value||'0') + 1).toFixed(3); });
tr.querySelector('[data-add]').addEventListener('click', () => addToCart(p, parseFloat(qty.value||'1')) );
tb.appendChild(tr);
}
$('#listadoProductos').innerHTML = '';
$('#listadoProductos').appendChild(tbl);
}
function addToCart(prod, cantidad) {
if (!(cantidad > 0)) { toast('Cantidad inválida'); return; }
const precio = parseFloat(prod.precio);
const it = state.carrito.find(i => i.id_producto === prod.id_producto && i.pre_unitario === precio);
if (it) it.cantidad = Number((it.cantidad + cantidad).toFixed(3));
else state.carrito.push({ id_producto: prod.id_producto, nombre: prod.nombre, pre_unitario: precio, cantidad: Number(cantidad.toFixed(3)) });
renderCarrito();
}
// ---------- Carrito ----------
function renderCarrito() {
const wrap = $('#carritoWrap');
if (!state.carrito.length) { wrap.innerHTML = '<div class="muted">Aún no agregaste productos.</div>'; $('#kpiItems').textContent='0'; $('#kpiTotal').textContent=money(0); return; }
const tbl = document.createElement('table');
tbl.innerHTML = `
<thead>
<tr>
<th>Producto</th>
<th class="right">Unitario</th>
<th class="right">Cantidad</th>
<th class="right">Subtotal</th>
<th></th>
</tr>
</thead>
<tbody></tbody>
`;
const tb = tbl.querySelector('tbody');
let items = 0, total = 0;
state.carrito.forEach((it, idx) => {
items += 1;
const sub = Number(it.pre_unitario) * Number(it.cantidad);
total += sub;
const tr = document.createElement('tr');
tr.innerHTML = `
<td>${it.nombre}</td>
<td class="right">${money(it.pre_unitario)}</td>
<td class="right">
<input type="number" min="0.001" step="0.001" value="${it.cantidad.toFixed(3)}" style="width:120px"/>
</td>
<td class="right">${money(sub)}</td>
<td class="right">
<button class="btn small" data-del>Quitar</button>
</td>
`;
const qty = tr.querySelector('input[type="number"]');
qty.addEventListener('change', () => {
const v = parseFloat(qty.value||'0');
if (!(v>0)) { toast('Cantidad inválida'); qty.value = it.cantidad.toFixed(3); return; }
it.cantidad = Number(v.toFixed(3));
renderCarrito();
});
tr.querySelector('[data-del]').addEventListener('click', () => {
state.carrito.splice(idx,1);
renderCarrito();
});
tb.appendChild(tr);
});
wrap.innerHTML = '';
wrap.appendChild(tbl);
$('#kpiItems').textContent = String(items);
$('#kpiTotal').textContent = money(total);
}
// ---------- Crear comanda ----------
async function crearComanda() {
if (!state.carrito.length) { toast('Agrega al menos un producto'); return; }
const id_mesa = parseInt($('#selMesa').value, 10);
const id_usuario = parseInt($('#selUsuario').value, 10);
if (!id_mesa || !id_usuario) { toast('Selecciona mesa y usuario'); return; }
const observaciones = $('#obs').value.trim() || null;
try {
// 1) encabezado comanda (estado por defecto: 'abierta'; fecha la pone la DB)
const { inserted: com } = await jpost('/api/table/comandas', {
id_usuario,
id_mesa,
estado: 'abierta',
observaciones
});
// 2) detalle (una inserción por renglón)
const id_comanda = com.id_comanda;
const payloads = state.carrito.map(it => ({
id_comanda,
id_producto: it.id_producto,
cantidad: it.cantidad,
pre_unitario: it.pre_unitario
}));
await Promise.all(payloads.map(p => jpost('/api/table/deta_comandas', p)));
state.carrito = [];
renderCarrito();
$('#obs').value = '';
toast(`Comanda #${id_comanda} creada`, true);
} catch (e) {
toast(e.message || 'No se pudo crear la comanda');
}
}
// GO
init().catch(err => toast(err.message || 'Error cargando datos'));
</script>
</body>
</html>

View File

@ -1,293 +0,0 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>Dashboard</title>
<style>
:root { --radius: 10px; }
* { box-sizing: border-box; }
body { margin:0; font-family: system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, 'Helvetica Neue', Arial, 'Noto Sans', 'Apple Color Emoji', 'Segoe UI Emoji'; background:#f6f7fb; color:#111; }
header { position: sticky; top: 0; background:#fff; border-bottom:1px solid #e8e8ef; padding:16px 20px; display:flex; gap:12px; align-items:center; z-index:1;}
header h1 { margin:0; font-size:18px; font-weight:600;}
main { padding: 20px; max-width: 1200px; margin: 0 auto; }
.card { background:#fff; border:1px solid #e8e8ef; border-radius: var(--radius); padding:16px; }
.row { display:flex; gap:16px; align-items: center; flex-wrap:wrap; }
select, input, button, textarea { font: inherit; padding:10px; border-radius:8px; border:1px solid #d7d7e0; background:#fff; }
select:focus, input:focus, textarea:focus { outline: none; border-color:#888; }
button { cursor:pointer; }
button.primary { background:#111; color:#fff; border-color:#111; }
table { width:100%; border-collapse: collapse; }
thead th { text-align:left; font-size:12px; text-transform: uppercase; letter-spacing:.04em; color:#555; background:#fafafa; }
th, td { padding:10px 12px; border-bottom: 1px solid #eee; vertical-align: top; }
.muted { color:#666; }
.tabs { display:flex; gap:6px; margin-top:12px; }
.tab { padding:8px 10px; border:1px solid #e0e0ea; border-bottom:none; background:#fafafa; border-top-left-radius:8px; border-top-right-radius:8px; cursor:pointer; font-size:14px; }
.tab.active { background:#fff; border-color:#e0e0ea; }
.panel { border:1px solid #e0e0ea; border-radius: 0 8px 8px 8px; padding:16px; background:#fff; }
.grid { display:grid; grid-template-columns: repeat(auto-fill,minmax(220px,1fr)); gap:12px; }
.help { font-size:12px; color:#777; margin-top:6px; }
.pill { font-size:12px; padding:2px 8px; border-radius:99px; border:1px solid #e0e0ea; background:#fafafa; display:inline-block; }
.toolbar { display:flex; gap:10px; align-items:center; }
.spacer { flex:1 }
.error { color:#b00020; }
.success { color:#0a7d28; }
.sr-only{ position:absolute; width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0;}
details summary { cursor:pointer; }
</style>
</head>
<body>
<header>
<h1>Dashboard</h1>
<div class="spacer"></div>
<span class="pill muted">/api/*</span>
</header>
<main class="card">
<div class="row" style="margin-bottom:12px;">
<label for="tableSelect" class="sr-only">Tabla</label>
<select id="tableSelect"></select>
<div class="spacer"></div>
<div class="toolbar">
<button id="refreshBtn">Recargar</button>
<span id="status" class="muted"></span>
</div>
</div>
<div class="tabs">
<button class="tab active" data-tab="datos">Datos</button>
<button class="tab" data-tab="nuevo">Nuevo</button>
<button class="tab" data-tab="esquema">Esquema</button>
</div>
<section class="panel" id="panel-datos">
<div class="help">Mostrando hasta <span id="limitInfo">100</span> filas.</div>
<div id="tableContainer" style="overflow:auto;"></div>
</section>
<section class="panel" id="panel-nuevo" hidden>
<form id="insertForm" class="grid"></form>
<div class="row" style="margin-top:10px;">
<div class="spacer"></div>
<button id="insertBtn" class="primary">Insertar</button>
</div>
<div id="insertMsg" class="help"></div>
</section>
<section class="panel" id="panel-esquema" hidden>
<pre id="schemaPre" style="white-space:pre-wrap;"></pre>
</section>
<details style="margin-top:16px;">
<summary>Endpoints</summary>
<div class="help">GET /api/tables • GET /api/schema/:tabla • GET /api/table/:tabla?limit=100 • POST /api/table/:tabla</div>
</details>
</main>
<script>
const $ = (s, r=document) => r.querySelector(s);
const $$ = (s, r=document) => Array.from(r.querySelectorAll(s));
const state = { tables: [], table: null, schema: null, limit: 100 };
// Tabs
$$('.tab').forEach(t => t.addEventListener('click', () => {
$$('.tab').forEach(x => x.classList.remove('active'));
t.classList.add('active');
const tab = t.dataset.tab;
$('#panel-datos').hidden = tab !== 'datos';
$('#panel-nuevo').hidden = tab !== 'nuevo';
$('#panel-esquema').hidden = tab !== 'esquema';
}));
$('#refreshBtn').addEventListener('click', () => {
if (state.table) {
loadSchema(state.table);
loadData(state.table);
}
});
async function init() {
setStatus('Cargando tablas…');
const res = await fetch('/api/tables');
const tables = await res.json();
state.tables = tables;
const sel = $('#tableSelect');
sel.innerHTML = '';
tables.forEach(name => {
const o = document.createElement('option');
o.value = name; o.textContent = name;
sel.appendChild(o);
});
sel.addEventListener('change', () => selectTable(sel.value));
if (tables.length) {
selectTable(tables[0]);
} else {
setStatus('No hay tablas disponibles.');
}
}
async function selectTable(tbl) {
state.table = tbl;
await loadSchema(tbl);
await loadData(tbl);
buildForm();
}
async function loadSchema(tbl) {
const res = await fetch(`/api/schema/${tbl}`);
state.schema = await res.json();
$('#schemaPre').textContent = JSON.stringify(state.schema, null, 2);
}
async function loadData(tbl) {
setStatus('Cargando datos…');
const res = await fetch(`/api/table/${tbl}?limit=${state.limit}`);
const data = await res.json();
$('#limitInfo').textContent = String(state.limit);
renderTable(data);
clearStatus();
}
function renderTable(rows) {
const c = $('#tableContainer');
c.innerHTML = '';
if (!rows.length) { c.innerHTML = '<div class="muted">Sin datos.</div>'; return; }
const headers = Object.keys(rows[0]);
const table = document.createElement('table');
table.innerHTML = `
<thead><tr>${headers.map(h => '<th>'+h+'</th>').join('')}</tr></thead>
<tbody></tbody>
`;
const tbody = table.querySelector('tbody');
for (const row of rows) {
const tr = document.createElement('tr');
tr.innerHTML = headers.map(h => '<td>'+formatCell(row[h])+'</td>').join('');
tbody.appendChild(tr);
}
c.appendChild(table);
}
function formatCell(v) {
if (v === null || v === undefined) return '<span class="muted">NULL</span>';
if (typeof v === 'boolean') return v ? '✓' : '—';
if (typeof v === 'string' && v.match(/^\\d{4}-\\d{2}-\\d{2}/)) return new Date(v).toLocaleString();
return String(v);
}
function buildForm() {
const form = $('#insertForm');
form.innerHTML = '';
if (!state.schema) return;
for (const col of state.schema.columns) {
// Omitir PK auto y columnas generadas
if (col.is_primary || col.is_identity || (col.column_default || '').startsWith('nextval(')) continue;
const wrap = document.createElement('div');
const id = 'f_'+col.column_name;
wrap.innerHTML = `
<label for="${id}" class="muted">${col.column_name} <span class="muted">${col.data_type}</span> ${col.is_nullable ? '' : '<span class="pill">requerido</span>'}</label>
<div data-input></div>
${col.column_default ? '<div class="help">DEFAULT: '+col.column_default+'</div>' : ''}
`;
const holder = wrap.querySelector('[data-input]');
if (col.foreign) {
const sel = document.createElement('select');
sel.id = id;
holder.appendChild(sel);
hydrateOptions(sel, state.schema.table, col.column_name);
} else if (col.data_type.includes('boolean')) {
const inp = document.createElement('input');
inp.id = id; inp.type = 'checkbox';
holder.appendChild(inp);
} else if (col.data_type.includes('timestamp')) {
const inp = document.createElement('input');
inp.id = id; inp.type = 'datetime-local'; inp.required = !col.is_nullable && !col.column_default;
holder.appendChild(inp);
} else if (col.data_type.includes('date')) {
const inp = document.createElement('input');
inp.id = id; inp.type = 'date'; inp.required = !col.is_nullable && !col.column_default;
holder.appendChild(inp);
} else if (col.data_type.match(/numeric|integer|real|double/)) {
const inp = document.createElement('input');
inp.id = id; inp.type = 'number'; inp.step = 'any'; inp.required = !col.is_nullable && !col.column_default;
holder.appendChild(inp);
} else if (col.data_type.includes('text') || col.data_type.includes('character')) {
const inp = document.createElement('input');
inp.id = id; inp.type = 'text'; inp.required = !col.is_nullable && !col.column_default;
holder.appendChild(inp);
} else {
const inp = document.createElement('input');
inp.id = id; inp.type = 'text'; inp.required = !col.is_nullable && !col.column_default;
holder.appendChild(inp);
}
form.appendChild(wrap);
}
}
async function hydrateOptions(selectEl, table, column) {
selectEl.innerHTML = '<option value="">Cargando…</option>';
const res = await fetch(`/api/options/${table}/${column}`);
const opts = await res.json();
selectEl.innerHTML = '<option value="">Seleccione…</option>' + opts.map(o => `<option value="${o.id}">${o.label}</option>`).join('');
}
$('#insertBtn').addEventListener('click', async (e) => {
e.preventDefault();
if (!state.table) return;
const payload = {};
for (const col of state.schema.columns) {
if (col.is_primary || col.is_identity || (col.column_default || '').startsWith('nextval(')) continue;
const id = 'f_'+col.column_name;
const el = document.getElementById(id);
if (!el) continue;
let val = null;
if (el.type === 'checkbox') {
val = el.checked;
} else if (el.type === 'datetime-local' && el.value) {
// Convertir a ISO
val = new Date(el.value).toISOString().slice(0,19).replace('T',' ');
} else if (el.tagName === 'SELECT') {
val = el.value ? (isNaN(el.value) ? el.value : Number(el.value)) : null;
} else if (el.type === 'number') {
val = el.value === '' ? null : Number(el.value);
} else {
val = el.value === '' ? null : el.value;
}
if (val === null && !col.is_nullable && !col.column_default) {
showInsertMsg('Completa: '+col.column_name, true);
return;
}
if (val !== null) payload[col.column_name] = val;
}
try {
const res = await fetch(`/api/table/${state.table}`, {
method: 'POST',
headers: { 'Content-Type':'application/json' },
body: JSON.stringify(payload)
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Error');
showInsertMsg('Insertado correctamente (id: '+(data.inserted?.id || '?')+')', false);
// Reset form
$('#insertForm').reset?.();
await loadData(state.table);
} catch (e) {
showInsertMsg(e.message, true);
}
});
function showInsertMsg(msg, isError=false) {
const m = $('#insertMsg');
m.className = 'help ' + (isError ? 'error' : 'success');
m.textContent = msg;
}
function setStatus(text) { $('#status').textContent = text; }
function clearStatus() { setStatus(''); }
// Start
init();
</script>
</body>
</html>

View File

@ -1,280 +0,0 @@
<!-- pages/estadoComandas.html -->
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>Estado de Comandas</title>
<style>
:root { --gap: 12px; --radius: 10px; }
* { box-sizing: border-box; }
body { margin:0; font-family: system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, 'Helvetica Neue', Arial; background:#f6f7fb; color:#111; }
header { position: sticky; top: 0; background:#fff; border-bottom:1px solid #e7e7ef; padding:14px 18px; display:flex; gap:10px; align-items:center; z-index:2; }
header h1 { margin:0; font-size:16px; font-weight:600; }
main { padding: 18px; max-width: 1200px; margin: 0 auto; display:grid; grid-template-columns: 0.9fr 1.1fr; gap: var(--gap); }
.card { background:#fff; border:1px solid #e7e7ef; border-radius: var(--radius); }
.card .hd { padding:12px 14px; border-bottom:1px solid #eee; display:flex; align-items:center; gap:10px; }
.card .bd { padding:14px; }
.row { display:flex; gap:10px; align-items:center; flex-wrap: wrap; }
.grid { display:grid; gap:10px; }
.muted { color:#666; }
.pill { font-size:12px; padding:2px 8px; border-radius:99px; border:1px solid #e0e0ea; background:#fafafa; display:inline-block; }
.list { max-height: 70vh; overflow:auto; }
.list table { width:100%; border-collapse: collapse; }
.list th, .list td { padding:8px 10px; border-bottom:1px solid #eee; }
.list thead th { text-align:left; font-size:12px; text-transform: uppercase; letter-spacing:.04em; color:#555; background:#fafafa; }
.right { text-align:right; }
.btn { padding:10px 12px; border-radius:8px; border:1px solid #dadbe4; background:#fafafa; cursor:pointer; }
.btn.primary { background:#111; color:#fff; border-color:#111; }
.btn.danger { background:#b00020; color:#fff; border-color:#b00020; }
.btn.small { font-size: 13px; padding:6px 8px; }
select, input, textarea { font: inherit; padding:10px; border:1px solid #dadbe4; border-radius:8px; background:#fff; }
.kpi { display:flex; gap:6px; align-items: baseline; }
.sticky-footer { position: sticky; bottom: 0; background:#fff; padding:12px 14px; border-top:1px solid #eee; display:flex; gap:10px; align-items:center; }
.ok { color:#0a7d28; }
.err { color:#b00020; }
.tag { font-size:12px; padding:2px 8px; border-radius:6px; border:1px solid #e7e7ef; background:#fafafa; }
.tag.abierta { border-color:#0a7d28; color:#0a7d28; }
.tag.cerrada { border-color:#555; color:#555; }
.tag.pagada { border-color:#1b5e20; color:#1b5e20; }
.tag.anulada { border-color:#b00020; color:#b00020; }
table { width:100%; border-collapse: collapse; }
th, td { padding:8px 10px; border-bottom:1px solid #eee; }
thead th { text-align:left; font-size:12px; text-transform: uppercase; letter-spacing:.04em; color:#555; background:#fafafa; }
</style>
</head>
<body>
<header>
<h1>🧾 Estado de Comandas</h1>
<div style="flex:1"></div>
<a class="pill" href="/comandas"> Nueva comanda</a>
</header>
<main>
<!-- Izquierda: listado -->
<section class="card">
<div class="hd">
<strong>Listado</strong>
<div style="flex:1"></div>
<label class="muted" style="display:flex; gap:6px; align-items:center;">
<input id="soloAbiertas" type="checkbox" checked />
Solo abiertas
</label>
</div>
<div class="bd">
<div class="row" style="margin-bottom:10px;">
<input id="buscar" type="search" placeholder="Buscar por #, mesa o usuario…" style="flex:1"/>
<button class="btn" id="limpiar">Limpiar</button>
</div>
<div class="list" id="lista"></div>
</div>
</section>
<!-- Derecha: detalle -->
<section class="card">
<div class="hd">
<strong>Detalle</strong>
<div style="flex:1"></div>
<span id="detalleEstado" class="tag"></span>
</div>
<div class="bd" id="detalle">
<div class="muted">Selecciona una comanda para ver el detalle.</div>
</div>
<div class="sticky-footer">
<div class="kpi"><span class="muted">ID:</span><strong id="kpiId"></strong></div>
<div class="kpi" style="margin-left:8px;"><span class="muted">Mesa:</span><strong id="kpiMesa"></strong></div>
<div class="kpi" style="margin-left:8px;"><span class="muted">Total:</span><strong id="kpiTotal">$ 0.00</strong></div>
<div style="flex:1"></div>
<button class="btn" id="reabrir">Reabrir</button>
<button class="btn primary" id="cerrar">Cerrar</button>
</div>
<div class="bd">
<div id="msg" class="muted"></div>
</div>
</section>
</main>
<script>
const $ = (s, r=document) => r.querySelector(s);
const $$ = (s, r=document) => Array.from(r.querySelectorAll(s));
const state = {
filtro: '',
soloAbiertas: true,
lista: [], // [{ id_comanda, mesa_numero, mesa_apodo, usuario_nombre, usuario_apellido, fec_creacion, estado, observaciones }]
sel: null, // id seleccionado
detalle: [] // [{ id_det_comanda, producto_nombre, cantidad, pre_unitario, subtotal, observaciones }]
};
const money = (n) => (isNaN(n) ? '—' : new Intl.NumberFormat('es-UY', { style:'currency', currency:'UYU' }).format(Number(n)));
const toast = (msg, ok=false) => { const el = $('#msg'); el.className = ok ? 'ok' : 'err'; el.textContent = msg; setTimeout(()=>{ el.textContent=''; el.className='muted'; }, 3500); };
async function jget(url) {
const res = await fetch(url);
const data = await res.json().catch(()=>null);
if (!res.ok) throw new Error(data?.error || `${res.status} ${res.statusText}`);
return data;
}
async function jpost(url, body) {
const res = await fetch(url, { method:'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify(body) });
const data = await res.json().catch(()=>null);
if (!res.ok) throw new Error(data?.error || `${res.status} ${res.statusText}`);
return data;
}
// ----------- Data -----------
async function loadLista() {
const estado = state.soloAbiertas ? 'abierta' : '';
const url = estado ? `/api/comandas?estado=${encodeURIComponent(estado)}&limit=300` : '/api/comandas?limit=300';
const rows = await jget(url);
state.lista = rows;
renderLista();
}
async function loadDetalle(id) {
const det = await jget(`/api/comandas/${id}/detalle`);
state.detalle = det;
renderDetalle();
}
// ----------- UI: Lista -----------
function renderLista() {
let rows = state.lista.slice();
const f = state.filtro;
if (f) {
const k = f.toLowerCase();
rows = rows.filter(r =>
String(r.id_comanda).includes(k) ||
(String(r.mesa_numero ?? '').includes(k)) ||
((`${r.usuario_nombre||''} ${r.usuario_apellido||''}`).toLowerCase().includes(k))
);
}
const box = $('#lista');
if (!rows.length) { box.innerHTML = '<div class="muted">Sin resultados.</div>'; return; }
const tbl = document.createElement('table');
tbl.innerHTML = `
<thead>
<tr>
<th>#</th>
<th>Mesa</th>
<th>Usuario</th>
<th>Fecha</th>
<th>Estado</th>
<th class="right">Items</th>
<th class="right">Total</th>
</tr>
</thead>
<tbody></tbody>
`;
const tb = tbl.querySelector('tbody');
rows.forEach(r => {
const tr = document.createElement('tr');
tr.style.cursor = 'pointer';
tr.innerHTML = `
<td>${r.id_comanda}</td>
<td>#${r.mesa_numero} · ${r.mesa_apodo || ''}</td>
<td>${(r.usuario_nombre||'') + ' ' + (r.usuario_apellido||'')}</td>
<td>${new Date(r.fec_creacion).toLocaleString()}</td>
<td><span class="tag ${r.estado}">${r.estado}</span></td>
<td class="right">${r.items ?? '—'}</td>
<td class="right">${money(r.total ?? 0)}</td>
`;
tr.addEventListener('click', () => { state.sel = r.id_comanda; loadDetalle(r.id_comanda); applyHeader(r); });
tb.appendChild(tr);
});
box.innerHTML = '';
box.appendChild(tbl);
}
// ----------- UI: Detalle -----------
function applyHeader(r) {
$('#kpiId').textContent = r.id_comanda ?? '—';
$('#kpiMesa').textContent = r.mesa_numero ? `#${r.mesa_numero}` : '—';
$('#detalleEstado').className = `tag ${r.estado}`;
$('#detalleEstado').textContent = r.estado;
$('#kpiTotal').textContent = money(r.total ?? 0);
// Botones según estado
const cerr = $('#cerrar'), reab = $('#reabrir');
if (r.estado === 'abierta') {
cerr.disabled = false; cerr.title = '';
reab.disabled = true; reab.title = 'Ya está abierta';
} else {
cerr.disabled = false; // permitir cerrar (idempotente/override)
reab.disabled = false;
}
}
function renderDetalle() {
const box = $('#detalle');
if (!state.detalle.length) { box.innerHTML = '<div class="muted">Sin detalle.</div>'; return; }
const tbl = document.createElement('table');
tbl.innerHTML = `
<thead>
<tr>
<th>Producto</th>
<th class="right">Unitario</th>
<th class="right">Cantidad</th>
<th class="right">Subtotal</th>
<th>Observaciones</th>
</tr>
</thead>
<tbody></tbody>
`;
const tb = tbl.querySelector('tbody');
let total = 0;
state.detalle.forEach(r => {
total += Number(r.subtotal||0);
const tr = document.createElement('tr');
tr.innerHTML = `
<td>${r.producto_nombre}</td>
<td class="right">${money(r.pre_unitario)}</td>
<td class="right">${Number(r.cantidad).toFixed(3)}</td>
<td class="right">${money(r.subtotal)}</td>
<td>${r.observaciones||''}</td>
`;
tb.appendChild(tr);
});
box.innerHTML = '';
box.appendChild(tbl);
$('#kpiTotal').textContent = money(total);
}
// ----------- Actions -----------
async function setEstado(estado) {
if (!state.sel) return;
try {
const { updated } = await jpost(`/api/comandas/${state.sel}/estado`, { estado });
toast(`Comanda #${updated.id_comanda} → ${updated.estado}`, true);
await loadLista();
// mantener seleccionada si sigue existiendo en filtro
const found = state.lista.find(x => x.id_comanda === updated.id_comanda);
if (found) { applyHeader(found); await loadDetalle(found.id_comanda); }
else {
state.sel = null;
$('#detalle').innerHTML = '<div class="muted">Selecciona una comanda para ver el detalle.</div>';
$('#detalleEstado').textContent = '—'; $('#detalleEstado').className = 'tag';
$('#kpiId').textContent = '—'; $('#kpiMesa').textContent='—'; $('#kpiTotal').textContent = money(0);
}
} catch (e) {
toast(e.message || 'No se pudo cambiar el estado');
}
}
// ----------- Init -----------
$('#soloAbiertas').addEventListener('change', async (e) => { state.soloAbiertas = e.target.checked; await loadLista(); });
$('#buscar').addEventListener('input', () => { state.filtro = $('#buscar').value.trim(); renderLista(); });
$('#limpiar').addEventListener('click', () => { $('#buscar').value=''; state.filtro=''; renderLista(); });
$('#cerrar').addEventListener('click', () => setEstado('cerrada'));
$('#reabrir').addEventListener('click', () => setEstado('abierta'));
(async function main(){ try { await loadLista(); } catch(e){ toast(e.message||'Error cargando comandas'); }})();
</script>
</body>
</html>

View File

@ -1,402 +0,0 @@
<% /* 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,34 +1,40 @@
// services/app/src/index.js
// ------------------------------------------------------------
// SuiteCoffee — Servicio APP (UI + APIs negocio)
// - Vistas EJS en ./views (dashboard.ejs, comandas.ejs, etc.)
// - Sesión compartida con AUTH (cookie: sc.sid, Redis)
// ------------------------------------------------------------
// // services/app/src/index.js
// // ------------------------------------------------------------
// // SuiteCoffee — Servicio APP (UI + APIs negocio)
// // - Vistas EJS en ./views (dashboard.ejs, comandas.ejs, etc.)
// // - Sesión compartida con AUTH (cookie: sc.sid, Redis)
// // ------------------------------------------------------------
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 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
import expressLayouts from 'express-ejs-layouts';
import express from 'express'; // Framework para enderizado de apps Web
import { jwtVerify, createRemoteJWKSet } from "jose";
import cookieParser from 'cookie-parser';
import { loadColumns, loadForeignKeys, loadPrimaryKey, pickLabelColumn } from "./utilities/cargaEnVista.js";
import { createRedisSession } from "../shared/middlewares/redisConnect.js";
// // ----------------------------------------------------------
// // Utilidades
// // ----------------------------------------------------------
// ----------------------------------------------------------
// Utilidades
// ----------------------------------------------------------
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
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 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);
// Función para verificar que ciertas variables de entorno estén definida
function checkRequiredEnvVars(...requiredKeys) {
@ -70,6 +76,7 @@ checkRequiredEnvVars(
// ----------------------------------------------------------
const app = express();
app.set('trust proxy', Number(process.env.TRUST_PROXY_HOPS || 2));
app.disable("x-powered-by");
app.use(cors({ origin: true, credentials: true }));
app.use(express.json());
app.use(express.json({ limit: '1mb' }));
@ -84,8 +91,7 @@ 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
app.use(cookieParser(process.env.SESSION_SECRET));
// Archivos estáticos que fuerzan la re-descarga de arhivos
app.use(express.static(path.join(__dirname, "public"), {
@ -108,17 +114,6 @@ app.use((req, res, next) => {
next();
});
// ----------------------------------------------------------
// Configuración de Pool principal a PostgreSQL
// ----------------------------------------------------------
@ -150,697 +145,194 @@ const tenantsPool = new Pool({
// 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';
}
// 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'
// ];
// -----------------------------------------------------------------------------
// 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://ak-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");
const PORT = process.env.PORT || 3030;
const ISSUER = process.env.AUTHENTIK_ISSUER?.replace(/\/?$/, "/");
const CLIENT_ID = process.env.OIDC_CLIENT_ID;
const SSO_ENTRY_URL = process.env.SSO_ENTRY_URL || "https://sso.suitecoffee.uy";
// 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
// 1) SIEMPRE montar sesión ANTES de las rutas
const { sessionMw, trustProxy } = await createRedisSession();
app.use(sessionMw);
const JWKS = createRemoteJWKSet(new URL(`${ISSUER}jwks/`));
async function verifyIdToken(idToken) {
const { payload } = await jwtVerify(idToken, JWKS, {
issuer: ISSUER.replace(/\/$/, ""),
audience: CLIENT_ID,
});
return payload;
}
// 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 || !req.session.user) {
// Podés usar 302 (found) o 303 (see other) para redirección
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);
function requireToken(req, res, next) {
const id = req.session?.tokens?.id_token; // <- defensivo
if (!id) return res.redirect(302, SSO_ENTRY_URL);
next();
} catch (e) {
next(e);
}
}
// ----------------------------------------------------------
// API
// ----------------------------------------------------------
app.get('/api/tables', async (_req, res) => {
res.json(ALLOWED_TABLES);
});
app.get('/api/schema/:table', async (req, res) => {
try {
const table = ensureTable(req.params.table);
const client = await getClient();
app.get("/", requireToken, async (req, res) => {
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 });
}
});
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 });
}
});
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] });
}
const idToken = req.session?.tokens?.id_token; // <- defensivo
if (!idToken) return res.redirect(302, SSO_ENTRY_URL);
const claims = await verifyIdToken(idToken);
const email = claims.email || claims.preferred_username || "sin-email";
res.json({ usuario: { email, sub: claims.sub } });
} 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;
console.error("/ verificación ID token", e);
res.redirect(302, SSO_ENTRY_URL);
}
} 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)
);
// // -----------------------------------------------------------------------------
// // Comprobaciones de tenants en DB principal
// // -----------------------------------------------------------------------------
// 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' });
}
});
// // 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' });
// 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' });
}
});
// const schema = `schema_tenant_${hex}`;
// const client = await tenantsPool.connect();
// 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' });
}
});
// // Fijar search_path para que las consultas apunten al esquema del tenant
// await client.query(`SET SESSION search_path TO ${qi(schema)}, public`);
// 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' });
}
});
// // Hacemos el client accesible para los handlers de routes.legacy.js
// req.pg = client;
// 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' });
}
});
// // Liberar el client al finalizar la respuesta
// const release = () => {
// try { client.release(); } catch {}
// };
// res.on('finish', release);
// res.on('close', release);
// 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' });
}
});
// next();
// } catch (e) {
// next(e);
// }
// }
// 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
});
}
});
// // ----------------------------------------------------------
// // Rutas de UI
// // ----------------------------------------------------------
// app.get("/inicio", (req, res) => {
// try {
// const safeUser = req.session?.user || null;
// const safeCookies = req.cookies || {};
// const safeSession = req.session ? JSON.parse(JSON.stringify(req.session)) : {};
// res.locals.pageTitle = "Inicio";
// res.locals.pageId = "inicio"; // <- importante
// return res.render('inicio', {
// user: safeUser,
// cookies: safeCookies,
// session: safeSession,
// });
// } catch (e) {
// next(e);
// }
// });
// app.get("/dashboard", requireToken,(req, res) => {
// res.locals.pageTitle = "Dashboard";
// res.locals.pageId = "dashboard"; // <- importante
// res.render("dashboard");
// });
// app.get("/comandas", requireToken,(req, res) => {
// res.locals.pageTitle = "Comandas";
// res.locals.pageId = "comandas"; // <- importante para el sidebar contextual
// res.render("comandas");
// });
// app.get("/estadoComandas", requireToken,(req, res) => {
// res.locals.pageTitle = "Estado de Comandas";
// res.locals.pageId = "estadoComandas";
// res.render("estadoComandas");
// });
// app.get("/productos", requireToken,(req, res) => {
// res.locals.pageTitle = "Productos";
// res.locals.pageId = "productos";
// res.render("productos");
// });
// app.get('/usuarios', requireToken,(req, res) => {
// res.locals.pageTitle = 'Usuarios';
// res.locals.pageId = 'usuarios';
// res.render('usuarios');
// });
// app.get('/reportes', requireToken,(req, res) => {
// res.locals.pageTitle = 'Reportes';
// res.locals.pageId = 'reportes';
// res.render('reportes');
// });
// app.get('/compras', requireToken,(req, res) => {
// res.locals.pageTitle = 'Compras';
// res.locals.pageId = 'compras';
// res.render('compras');
// });
// // 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('/') : res.redirect('https://sso.suitecoffee.uy/if/flow/default-authentication-flow/');
// 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>
// `);
// });
// 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
});
}
});
// ----------------------------------------------------------
@ -848,39 +340,38 @@ app.get('/api/rpc/report_gastos', async (req, res) => {
// ----------------------------------------------------------
async function verificarConexion() {
try {
console.log(`[APP] Comprobando accesibilidad a la db ${process.env.DB_NAME} del host ${process.env.DB_HOST} ...`);
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);
const { rows } = await client.query('SELECT NOW() AS ahora');
console.log(`\n[APP] Conexión con ${process.env.DB_NAME} OK. Hora DB:`, rows[0].ahora);
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.');
console.error('[APP] Error al conectar con la base de datos al iniciar:', error.message);
console.error('[APP] Revisar DB_HOST/USER/PASS/NAME, accesos de red y firewall.');
}
}
// // -----------------------------------------------------------------------------
// // Healthcheck
// -----------------------------------------------------------------------------
// Health + 404 + errores
// -----------------------------------------------------------------------------
app.get('/health', (_req, res) => res.status(200).json({ status: 'ok', service: 'app' }));
app.get('/health', (_req, res) => res.status(200).json({ status: 'ok'}));
app.use((req, res) => res.status(404).json({ error: 'not-found', path: req.originalUrl }));
// -----------------------------------------------------------------------------
// 404 + Manejo de errores
// -----------------------------------------------------------------------------
app.use((req, res) => res.status(404).json({ error: 'Error 404, No se encontró la página', path: req.originalUrl }));
app.use((err, _req, res, _next) => {
console.error('[APP] Error:', err);
console.error('[APP] ', err);
if (res.headersSent) return;
res.status(500).json({ error: 'internal-error', detail: err?.message || String(err) });
res.status(500).json({ error: '¡Oh! A ocurrido un error en el servidor app.', detail: err.stack || String(err) });
});
// ----------------------------------------------------------
// Inicio del servidor
// ----------------------------------------------------------
const PORT = process.env.PORT ? Number(process.env.PORT) : 3000;
app.listen(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} ...`);
app.listen(3030, () => {
console.log(`[APP] SuiteCoffee corriendo en http://localhost:${3030}`);
verificarConexion();
});

View File

@ -0,0 +1,37 @@
// -----------------------------------------------------------------------------
// Middlewares de Auth/Tenant
// -----------------------------------------------------------------------------
export function requireAuth(req, res, next) {
const authHeader = req.headers["authorization"];
if (!authHeader) return res.status(401).send("Falta token");
const token = authHeader.split(" ")[1];
jwt.verify(token, getKey, { algorithms: ["RS256"] }, (err, decoded) => {
if (err) return res.status(403).send("Token inválido o vencido");
// Guardamos los claims del token en req.user
req.user = {
id: decoded.sub,
email: decoded.email,
username: decoded.preferred_username,
name: decoded.name,
roles: decoded.groups || []
};
next();
});
}
// export function exposeViewState(req, res, next) {
// res.locals.pageTitle = res.locals.pageTitle || '';
// res.locals.pageId = res.locals.pageId || '';
// res.locals.tenant_uuid = req.session?.tenant?.uuid || null;
// res.locals.ak_user_uuid = req.session?.tenant?.ak_user_uuid || null;
// // también pásalos como props al render
// res.locals.viewUser = req.session?.user || null;
// res.locals.viewCookies = req.cookies || {};
// res.locals.viewSession = req.session ? JSON.parse(JSON.stringify(req.session)) : {};
// next();
// }

View File

@ -0,0 +1,76 @@
// ----------------------------------------------------------
// Introspección de esquema
// ----------------------------------------------------------
export 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;
}
export 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;
}
export 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
export 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';
}

View File

@ -1,4 +1,4 @@
<!-- views/inicio.ejs -->
<!-- views/inicio_v2.ejs -->
<!doctype html>
<html lang="es">
<head>
@ -31,19 +31,24 @@
<body>
<div class="wrap">
<div class="card">
<%
<%
// Espera que el backend pase: { user, cookies, session }
const hasUser = typeof user !== 'undefined' && user;
const hasCookies = typeof cookies !== 'undefined' && cookies && Object.keys(cookies).length;
const hasSession = typeof session !== 'undefined' && session && Object.keys(session).length;
const displayName =
(hasUser && (user.name || user.displayName || user.email)) ||
(hasCookies && (cookies.user_name || cookies.displayName || cookies.email)) ||
(hasCookies && (cookies.user_name || cookies.displayName || cookies.email)) ||
(hasSession && (session.user?.email || session.user?.name)) ||
'usuario';
%>
<h1>Hola, <span><%= displayName %></span> 👋</h1>
<p class="lead">Bienvenido a SuiteCoffee. Este es tu inicio.</p>
<p class="lead">Bienvenido a SuiteCoffee. Este es tu inicio y panel de diagnóstico de cookies/sesión.</p>
<% if (hasUser) { %>
<h2>Sesión</h2>
<h2>Sesión de Aplicación (user)</h2>
<table>
<tbody>
<% for (const [k,v] of Object.entries(user)) { %>
@ -56,9 +61,23 @@
</table>
<% } %>
<% if (hasSession) { %>
<h2>Sesión Express (req.session)</h2>
<table>
<tbody>
<% for (const [k,v] of Object.entries(session)) { %>
<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>
<h2>Cookies (servidor: <code>req.cookies</code>)</h2>
<% if (hasCookies) { %>
<table>
<thead>
@ -74,12 +93,16 @@
</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>
<p class="muted">
No se recibieron cookies del lado servidor (<code>req.cookies</code>).
Asegurate de usar <code>cookie-parser</code> y de pasar <code>cookies</code> al render:
<br /><code>res.render('inicio_v2', { user: req.session.user, cookies: req.cookies, session: req.session })</code>
</p>
<% } %>
</section>
<section class="card">
<h2>Cookies (navegador)</h2>
<h2>Cookies (navegador: <code>document.cookie</code>)</h2>
<table id="client-cookies">
<thead>
<tr><th>Nombre</th><th>Valor</th></tr>
@ -88,6 +111,9 @@
<tr><td colspan="2" class="muted">Cargando…</td></tr>
</tbody>
</table>
<p class="muted" style="margin-top:10px;">
Total cookies en navegador: <span id="cookie-count">0</span>
</p>
<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>
@ -102,6 +128,8 @@
const raw = document.cookie || '';
document.getElementById('cookie-raw').textContent = raw || '(sin cookies)';
const pairs = raw ? raw.split(/;\s*/) : [];
document.getElementById('cookie-count').textContent = pairs.length;
if (!pairs.length) {
tbody.innerHTML = '<tr><td colspan="2"><em class="muted">sin cookies</em></td></tr>';
return;

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

@ -5,6 +5,7 @@ PORT=4040
# ===== Session (usa el Redis del stack) =====
# Para DEV podemos reutilizar el Redis de Authentik. En prod conviene uno separado.
SESSION_SECRET=Neon*Mammal*Boaster*Ludicrous*Fender8*Crablike
SESSION_COOKIE_NAME=sc.sid
REDIS_URL=redis://ak-redis:6379
# ===== DB principal (metadatos de SuiteCoffee) =====
@ -22,82 +23,22 @@ TENANTS_USER=dev-user-postgres
TENANTS_PASS=dev-pass-postgres
TENANTS_PORT=5432
TENANT_INIT_SQL=/app/src/db/initTenant.sql
# ===== (Opcional) Colores UI, si alguna vista los lee =====
COL_PRI=452D19 # Marrón oscuro
COL_SEC=D7A666 # Crema / Café
COL_BG=FFA500 # Naranja
TENANT_INIT_SQL=/app/src/db/initTenant_v2.sql
# ===== 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://dev-authentik:9000
AUTHENTIK_TOKEN=eE3bFTLd4Rpt3ZkcidTC1EppDYMIr023ev3SXt4ImHynOfAGRVtAZVBXSNxj
AUTHENTIK_DEFAULT_GROUP_NAME=suitecoffee-users
AUTHENTIK_TOKEN=h2apVHbd3ApMcnnSwfQPXbvximkvP8HnUE25ot3zXWuEEtJFaNCcOzDHB6Xw
AUTH_CALLBACK_URL=https://suitecoffee.uy/auth/callback
# ===== OIDC (DEBE coincidir con el Provider) =====
# 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_ISSUER=https://sso.suitecoffee.uy/application/o/suitecoffee/
# AUTHENTIK_ISSUER=https://sso.suitecoffee.mateosaldain.uy/application/o/suitecoffee/
AUTHENTIK_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.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)
# - INVITE_LINK: envías/entregás un link de “establecer contraseña” (necesita flow de Enrollment/Recovery y SMTP configurado)
# - SSO_ONLY: no setea password local; login solo por Google/Microsoft/WebAuthn
AK_PASSWORD_MODE=TEMP_FORCE_CHANGE
# (Opcional) longitud del password temporal
AK_TEMP_PW_LENGTH=12
# 3) Configuración en Authentik (por modo)
# A) TEMP_FORCE_CHANGE (password temporal + cambio obligado)
# Flow de Autenticación
# Entra al Admin de Authentik → Flows → tu Authentication Flow (el que usa tu Provider OIDC).
# Asegurate de que tenga:
# Identification Stage (identifica por email/username),
# Password Stage (para escribir contraseña).
# Con eso, cuando el usuario entre con la clave temporal, Authentik le pedirá cambiarla.
# Provider OIDC (suitecoffee)
# Admin → Applications → Providers → tu provider de SuiteCoffee → Flow settings
# Authentication flow: seleccioná el de arriba.
# (Opcional) Email SMTP
# Si querés notificar o enviar contraseñas temporales/enlaces desde Authentik, configura SMTP en Admin → System → Email.
# Resultado: el usuario se registra en tu app → lo redirigís a /auth/login → Authentik pide email+clave → entra con la temporal → obliga a cambiarla → vuelve a tu app.
# B) INVITE_LINK (enlace de “establecer contraseña”)
# SMTP
# Admin → System → Email: configura SMTP (host, puerto, credenciales, remitente).
# Flow de Enrollment/Recovery
# Admin → Flows → clona/crea un flow de Enrollment/Recovery con:
# Identification Stage (email/username),
# Email Stage (envía el link con token),
# Password Stage (para que defina su clave),
# (opcional) Prompt/ User Write para confirmar.
# Guardalo con un Slug fácil (ej. enroll-set-password).
# Cómo usarlo
# Caminos:
# Manual desde UI: Admin → Directory → Invitations → crear invitación, elegir Flow enroll-set-password, seleccionar usuario, copiar link y enviar.
# Automático (más adelante): podemos automatizar por API la creación de una Invitation y envío de mail. (Si querés, te armo el helper akCreateInvitation(userUUID, flowSlug).)
# Resultado: el registro en tu app no pone password; el usuario recibe un link para establecer la clave y desde ahí inicia normalmente.
# C) SSO_ONLY (sin contraseñas locales)
# Configura un Source (Google Workspace / Microsoft Entra / WebAuthn):
# Admin → Directory → Sources: crea el Source (por ejemplo, Google OAuth o Entra ID).
# Activa Create users (para que se creen en Authentik si no existen).
# Mapea email y name.
# Authentication Flow
# Agrega una Source Stage del proveedor (Google/Microsoft/WebAuthn) en tu Authentication Flow.
# (Podés dejar Password Stage deshabilitado si querés solo SSO.)
# Provider OIDC
# En tu Provider suitecoffee, seleccioná ese Authentication Flow.
# Resultado: el usuario se registra en tu app → al entrar a /auth/login ve botón Iniciar con Google/Microsoft → hace click, vuelve con sesión, tu backend setea sc.sid.
OIDC_ENROLLMENT_URL=https://sso.suitecoffee.uy/if/flow/registro-suitecoffee/

View File

@ -21,6 +21,10 @@
"express-ejs-layouts": "^2.5.1",
"express-session": "^1.18.2",
"ioredis": "^5.7.0",
"jose": "^6.1.0",
"jsonwebtoken": "^9.0.2",
"jwks-rsa": "^3.2.0",
"node-fetch": "^3.3.2",
"openid-client": "^5.7.1",
"pg": "^8.16.3",
"pg-format": "^1.0.4",
@ -64,6 +68,26 @@
"node-pre-gyp": "bin/node-pre-gyp"
}
},
"node_modules/@mapbox/node-pre-gyp/node_modules/node-fetch": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
"license": "MIT",
"dependencies": {
"whatwg-url": "^5.0.0"
},
"engines": {
"node": "4.x || >=6.0.0"
},
"peerDependencies": {
"encoding": "^0.1.0"
},
"peerDependenciesMeta": {
"encoding": {
"optional": true
}
}
},
"node_modules/@redis/bloom": {
"version": "5.8.2",
"resolved": "https://registry.npmjs.org/@redis/bloom/-/bloom-5.8.2.tgz",
@ -124,6 +148,119 @@
"@redis/client": "^5.8.2"
}
},
"node_modules/@types/body-parser": {
"version": "1.19.6",
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
"integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==",
"license": "MIT",
"dependencies": {
"@types/connect": "*",
"@types/node": "*"
}
},
"node_modules/@types/connect": {
"version": "3.4.38",
"resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz",
"integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==",
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/express": {
"version": "4.17.23",
"resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.23.tgz",
"integrity": "sha512-Crp6WY9aTYP3qPi2wGDo9iUe/rceX01UMhnF1jmwDcKCFM6cx7YhGP/Mpr3y9AASpfHixIG0E6azCcL5OcDHsQ==",
"license": "MIT",
"dependencies": {
"@types/body-parser": "*",
"@types/express-serve-static-core": "^4.17.33",
"@types/qs": "*",
"@types/serve-static": "*"
}
},
"node_modules/@types/express-serve-static-core": {
"version": "4.19.6",
"resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.6.tgz",
"integrity": "sha512-N4LZ2xG7DatVqhCZzOGb1Yi5lMbXSZcmdLDe9EzSndPV2HpWYWzRbaerl2n27irrm94EPpprqa8KpskPT085+A==",
"license": "MIT",
"dependencies": {
"@types/node": "*",
"@types/qs": "*",
"@types/range-parser": "*",
"@types/send": "*"
}
},
"node_modules/@types/http-errors": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz",
"integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==",
"license": "MIT"
},
"node_modules/@types/jsonwebtoken": {
"version": "9.0.10",
"resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz",
"integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==",
"license": "MIT",
"dependencies": {
"@types/ms": "*",
"@types/node": "*"
}
},
"node_modules/@types/mime": {
"version": "1.3.5",
"resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz",
"integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==",
"license": "MIT"
},
"node_modules/@types/ms": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
"integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
"license": "MIT"
},
"node_modules/@types/node": {
"version": "24.3.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.3.1.tgz",
"integrity": "sha512-3vXmQDXy+woz+gnrTvuvNrPzekOi+Ds0ReMxw0LzBiK3a+1k0kQn9f2NWk+lgD4rJehFUmYy2gMhJ2ZI+7YP9g==",
"license": "MIT",
"dependencies": {
"undici-types": "~7.10.0"
}
},
"node_modules/@types/qs": {
"version": "6.14.0",
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz",
"integrity": "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==",
"license": "MIT"
},
"node_modules/@types/range-parser": {
"version": "1.2.7",
"resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz",
"integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==",
"license": "MIT"
},
"node_modules/@types/send": {
"version": "0.17.5",
"resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.5.tgz",
"integrity": "sha512-z6F2D3cOStZvuk2SaP6YrwkNO65iTZcwA2ZkSABegdkAh/lf+Aa/YQndZVfmEXT5vgAp6zv06VQ3ejSVjAny4w==",
"license": "MIT",
"dependencies": {
"@types/mime": "^1",
"@types/node": "*"
}
},
"node_modules/@types/serve-static": {
"version": "1.15.8",
"resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.8.tgz",
"integrity": "sha512-roei0UY3LhpOJvjbIP6ZZFngyLKl5dskOtDhxY5THRSpO+ZI+nzJ+m5yUMzGrp89YRa7lvknKkMYjqQFGwA7Sg==",
"license": "MIT",
"dependencies": {
"@types/http-errors": "*",
"@types/node": "*",
"@types/send": "*"
}
},
"node_modules/abbrev": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz",
@ -297,6 +434,12 @@
"node": ">=8"
}
},
"node_modules/buffer-equal-constant-time": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
"license": "BSD-3-Clause"
},
"node_modules/bytes": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
@ -558,6 +701,15 @@
"node": ">= 8"
}
},
"node_modules/data-uri-to-buffer": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz",
"integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==",
"license": "MIT",
"engines": {
"node": ">= 12"
}
},
"node_modules/debug": {
"version": "4.4.1",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz",
@ -643,6 +795,15 @@
"node": ">= 0.4"
}
},
"node_modules/ecdsa-sig-formatter": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
"license": "Apache-2.0",
"dependencies": {
"safe-buffer": "^5.0.1"
}
},
"node_modules/ee-first": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
@ -826,6 +987,29 @@
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT"
},
"node_modules/fetch-blob": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz",
"integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/jimmywarting"
},
{
"type": "paypal",
"url": "https://paypal.me/jimmywarting"
}
],
"license": "MIT",
"dependencies": {
"node-domexception": "^1.0.0",
"web-streams-polyfill": "^3.0.3"
},
"engines": {
"node": "^12.20 || >= 14.13"
}
},
"node_modules/filelist": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.4.tgz",
@ -943,6 +1127,18 @@
"node": ">= 0.6"
}
},
"node_modules/formdata-polyfill": {
"version": "4.0.10",
"resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz",
"integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==",
"license": "MIT",
"dependencies": {
"fetch-blob": "^3.1.2"
},
"engines": {
"node": ">=12.20.0"
}
},
"node_modules/forwarded": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
@ -1367,6 +1563,65 @@
}
},
"node_modules/jose": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/jose/-/jose-6.1.0.tgz",
"integrity": "sha512-TTQJyoEoKcC1lscpVDCSsVgYzUDg/0Bt3WE//WiTPK6uOCQC2KZS4MpugbMWt/zyjkopgZoXhZuCi00gLudfUA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/jsonwebtoken": {
"version": "9.0.2",
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz",
"integrity": "sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==",
"license": "MIT",
"dependencies": {
"jws": "^3.2.2",
"lodash.includes": "^4.3.0",
"lodash.isboolean": "^3.0.3",
"lodash.isinteger": "^4.0.4",
"lodash.isnumber": "^3.0.3",
"lodash.isplainobject": "^4.0.6",
"lodash.isstring": "^4.0.1",
"lodash.once": "^4.0.0",
"ms": "^2.1.1",
"semver": "^7.5.4"
},
"engines": {
"node": ">=12",
"npm": ">=6"
}
},
"node_modules/jwa": {
"version": "1.4.2",
"resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.2.tgz",
"integrity": "sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw==",
"license": "MIT",
"dependencies": {
"buffer-equal-constant-time": "^1.0.1",
"ecdsa-sig-formatter": "1.0.11",
"safe-buffer": "^5.0.1"
}
},
"node_modules/jwks-rsa": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/jwks-rsa/-/jwks-rsa-3.2.0.tgz",
"integrity": "sha512-PwchfHcQK/5PSydeKCs1ylNym0w/SSv8a62DgHJ//7x2ZclCoinlsjAfDxAAbpoTPybOum/Jgy+vkvMmKz89Ww==",
"license": "MIT",
"dependencies": {
"@types/express": "^4.17.20",
"@types/jsonwebtoken": "^9.0.4",
"debug": "^4.3.4",
"jose": "^4.15.4",
"limiter": "^1.1.5",
"lru-memoizer": "^2.2.0"
},
"engines": {
"node": ">=14"
}
},
"node_modules/jwks-rsa/node_modules/jose": {
"version": "4.15.9",
"resolved": "https://registry.npmjs.org/jose/-/jose-4.15.9.tgz",
"integrity": "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==",
@ -1375,6 +1630,16 @@
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/jws": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz",
"integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==",
"license": "MIT",
"dependencies": {
"jwa": "^1.4.1",
"safe-buffer": "^5.0.1"
}
},
"node_modules/keygrip": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/keygrip/-/keygrip-1.1.0.tgz",
@ -1387,18 +1652,71 @@
"node": ">= 0.6"
}
},
"node_modules/limiter": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/limiter/-/limiter-1.1.5.tgz",
"integrity": "sha512-FWWMIEOxz3GwUI4Ts/IvgVy6LPvoMPgjMdQ185nN6psJyBJ4yOpzqm695/h5umdLJg2vW3GR5iG11MAkR2AzJA=="
},
"node_modules/lodash.clonedeep": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz",
"integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==",
"license": "MIT"
},
"node_modules/lodash.defaults": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz",
"integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==",
"license": "MIT"
},
"node_modules/lodash.includes": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
"integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
"license": "MIT"
},
"node_modules/lodash.isarguments": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz",
"integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==",
"license": "MIT"
},
"node_modules/lodash.isboolean": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
"integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",
"license": "MIT"
},
"node_modules/lodash.isinteger": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
"integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==",
"license": "MIT"
},
"node_modules/lodash.isnumber": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz",
"integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==",
"license": "MIT"
},
"node_modules/lodash.isplainobject": {
"version": "4.0.6",
"resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
"integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
"license": "MIT"
},
"node_modules/lodash.isstring": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz",
"integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==",
"license": "MIT"
},
"node_modules/lodash.once": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
"integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
"license": "MIT"
},
"node_modules/lru-cache": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
@ -1411,6 +1729,16 @@
"node": ">=10"
}
},
"node_modules/lru-memoizer": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/lru-memoizer/-/lru-memoizer-2.3.0.tgz",
"integrity": "sha512-GXn7gyHAMhO13WSKrIiNfztwxodVsP8IoZ3XfrJV4yH2x0/OeTO/FIaAHTY5YekdGgW94njfuKmyyt1E0mR6Ug==",
"license": "MIT",
"dependencies": {
"lodash.clonedeep": "^4.5.0",
"lru-cache": "6.0.0"
}
},
"node_modules/make-dir": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz",
@ -1565,24 +1893,42 @@
"integrity": "sha512-eh0GgfEkpnoWDq+VY8OyvYhFEzBk6jIYbRKdIlyTiAXIVJ8PyBaKb0rp7oDtoddbdoHWhq8wwr+XZ81F1rpNdA==",
"license": "MIT"
},
"node_modules/node-domexception": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz",
"integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==",
"deprecated": "Use your platform's native DOMException instead",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/jimmywarting"
},
{
"type": "github",
"url": "https://paypal.me/jimmywarting"
}
],
"license": "MIT",
"engines": {
"node": ">=10.5.0"
}
},
"node_modules/node-fetch": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
"version": "3.3.2",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz",
"integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==",
"license": "MIT",
"dependencies": {
"whatwg-url": "^5.0.0"
"data-uri-to-buffer": "^4.0.0",
"fetch-blob": "^3.1.4",
"formdata-polyfill": "^4.0.10"
},
"engines": {
"node": "4.x || >=6.0.0"
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
},
"peerDependencies": {
"encoding": "^0.1.0"
},
"peerDependenciesMeta": {
"encoding": {
"optional": true
}
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/node-fetch"
}
},
"node_modules/nodemon": {
@ -1736,6 +2082,15 @@
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/openid-client/node_modules/jose": {
"version": "4.15.9",
"resolved": "https://registry.npmjs.org/jose/-/jose-4.15.9.tgz",
"integrity": "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/parseurl": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
@ -2469,6 +2824,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/undici-types": {
"version": "7.10.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.10.0.tgz",
"integrity": "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag==",
"license": "MIT"
},
"node_modules/unpipe": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
@ -2493,6 +2854,15 @@
"node": ">= 0.8"
}
},
"node_modules/web-streams-polyfill": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz",
"integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==",
"license": "MIT",
"engines": {
"node": ">= 8"
}
},
"node_modules/webidl-conversions": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",

View File

@ -27,6 +27,10 @@
"express-ejs-layouts": "^2.5.1",
"express-session": "^1.18.2",
"ioredis": "^5.7.0",
"jose": "^6.1.0",
"jsonwebtoken": "^9.0.2",
"jwks-rsa": "^3.2.0",
"node-fetch": "^3.3.2",
"openid-client": "^5.7.1",
"pg": "^8.16.3",
"pg-format": "^1.0.4",

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -1,164 +0,0 @@
<!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>

View File

@ -291,8 +291,14 @@
}
function hydrateMesas() {
const sel = $('#selMesa'); sel.innerHTML = '';
for (const m of state.mesas) {
const sel = $('#selMesa');
sel.innerHTML = '';
// Ordena por número de mesa (o por id si no hay número)
const rows = state.mesas
.slice()
.sort((a, b) => Number(a?.numero ?? a?.id_mesa ?? 0) - Number(b?.numero ?? b?.id_mesa ?? 0));
for (const m of rows) {
const o = document.createElement('option');
o.value = m.id_mesa;
o.textContent = `#${m.numero} · ${m.apodo} (${m.estado})`;
@ -300,15 +306,20 @@
}
}
function hydrateUsuarios() {
const sel = $('#selUsuario'); sel.innerHTML = '';
for (const u of state.usuarios) {
const sel = $('#selUsuario');
sel.innerHTML = '';
// 🔽 Orden ascendente por id_usuario
const rows = state.usuarios
.slice()
.sort((a, b) => Number(a?.id_usuario ?? 0) - Number(b?.id_usuario ?? 0));
for (const u of rows) {
const o = document.createElement('option');
o.value = u.id_usuario;
o.textContent = `${u.nombre} ${u.apellido}`.trim();
sel.appendChild(o);
}
}
// Render productos
function renderProductos() {
let rows = state.productos.slice();

View File

@ -0,0 +1,58 @@
import session from "express-session";
import { createClient } from "redis";
export async function createRedisSession({
redisUrl = process.env.REDIS_URL,
cookieName = process.env.SESSION_COOKIE_NAME || "sc.sid",
secret = process.env.SESSION_SECRET,
trustProxy = process.env.TRUST_PROXY === "1",
ttlSeconds = 60 * 60 * 12, // 12h
} = {}) {
if (!redisUrl) throw new Error("REDIS_URL no definido");
if (!secret) throw new Error("SESSION_SECRET no definido");
const redis = createClient({ url: redisUrl });
redis.on("error", (err) => console.error("[Redis]", err));
await redis.connect();
console.log("[Redis] conectado");
// Resolver RedisStore (v5 / v6 / v7)
async function resolveRedisStore() {
const mod = await import("connect-redis");
// v6/v7: named export class
if (typeof mod.RedisStore === "function") return mod.RedisStore;
// v5: default factory connectRedis(session)
if (typeof mod.default === "function") {
const maybe = mod.default;
if (maybe.prototype && (maybe.prototype.get || maybe.prototype.set)) return maybe; // clase
const factory = mod.default(session);
return factory;
}
throw new Error("No se pudo resolver RedisStore de connect-redis");
}
const RedisStore = await resolveRedisStore();
const store = new RedisStore({ client: redis, prefix: "sc:sess:", ttl: ttlSeconds });
const sessionMw = session({
name: cookieName,
secret,
resave: false,
saveUninitialized: false,
store,
cookie: {
httpOnly: true,
sameSite: "lax",
secure: process.env.NODE_ENV === "production", // requiere https
maxAge: ttlSeconds * 1000,
},
});
return { sessionMw, redis, store, trustProxy };
}