Puesta a punto
@ -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
|
||||
@ -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
|
||||
344
app.index.js
@ -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
|
After Width: | Height: | Size: 717 KiB |
|
After Width: | Height: | Size: 717 KiB |
BIN
authentik-media/suitecoffee-logo/SuiteCoffee.png
Normal file
|
After Width: | Height: | Size: 1005 KiB |
BIN
authentik-media/suitecoffee-logo/SuiteCoffee_BM_1.png
Normal file
|
After Width: | Height: | Size: 173 KiB |
13
authentik-media/suitecoffee-logo/SuiteCoffee_BN_1.svg
Normal 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 |
BIN
authentik-media/suitecoffee-logo/SuiteCoffee_TB_1.png
Normal file
|
After Width: | Height: | Size: 186 KiB |
BIN
authentik-media/suitecoffee-logo/SuiteCoffee_TM_1.png
Normal file
|
After Width: | Height: | Size: 199 KiB |
BIN
authentik-media/suitecoffee-logo/SuiteCoffee_TN_1.png
Normal file
|
After Width: | Height: | Size: 169 KiB |
|
After Width: | Height: | Size: 57 KiB |
|
After Width: | Height: | Size: 528 KiB |
BIN
authentik-media/suitecoffee-logo/favicon_io/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 51 KiB |
BIN
authentik-media/suitecoffee-logo/favicon_io/favicon-16x16.png
Normal file
|
After Width: | Height: | Size: 488 B |
BIN
authentik-media/suitecoffee-logo/favicon_io/favicon-32x32.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
authentik-media/suitecoffee-logo/favicon_io/favicon.ico
Normal file
|
After Width: | Height: | Size: 15 KiB |
@ -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"}
|
||||
@ -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:
|
||||
|
||||
42
compose.yaml
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
409
services/app/package-lock.json
generated
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
@ -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
@ -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
|
||||
// });
|
||||
// }
|
||||
// });
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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();
|
||||
});
|
||||
|
||||
|
||||
37
services/app/src/middlewares/tenant.js
Normal 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();
|
||||
// }
|
||||
76
services/app/src/utilities/cargaEnVista.js
Normal 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';
|
||||
}
|
||||
@ -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;
|
||||
|
||||
130
services/app/src/views/inicio.ejs.bak
Normal 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>
|
||||
@ -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/
|
||||
394
services/auth/package-lock.json
generated
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
2267
services/auth/src/db/initTenant_v2.sql
Normal file
2284
services/auth/src/db/initTenant_v3.sql
Normal 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>
|
||||
@ -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();
|
||||
|
||||
58
services/shared/middlewares/redisConnect.js
Normal 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 };
|
||||
}
|
||||