diff --git a/.env b/.env deleted file mode 100644 index e69de29..0000000 diff --git a/.env.development b/.env.development index 14c25d8..34b9183 100644 --- a/.env.development +++ b/.env.development @@ -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 \ No newline at end of file +# Authentik Bootstrap +AUTHENTIK_BOOTSTRAP_PASSWORD=info.suitecoffee@gmail.com +AUTHENTIK_BOOTSTRAP_EMAIL=info.suitecoffee@gmail.com \ No newline at end of file diff --git a/.env.production b/.env.production index 054af27..4834ca8 100644 --- a/.env.production +++ b/.env.production @@ -18,4 +18,36 @@ TENANTS_DB_PASS=postgres # db primaria - postgres DB_NAME=suitecoffee DB_USER=suitecoffee -DB_PASS=suitecoffee \ No newline at end of file +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 \ No newline at end of file diff --git a/app.index.js b/app.index.js deleted file mode 100644 index 1d95b89..0000000 --- a/app.index.js +++ /dev/null @@ -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(` - - SuiteCoffee · Definir contraseña - -
-

Definir contraseña

-
- - - - Luego te redirigiremos a iniciar sesión por SSO. -
-
- `); -}); - -// ----------------------------------------------------------------------------- -// Montar rutas legacy (render de EJS y APIs de negocio) -// ----------------------------------------------------------------------------- -const legacy = await import('./routes.legacy.js'); -legacy.default(app, { - requireAuth, - withTenant, - done, - mainPool, - tenantsPool, - express, -}); - -// ---------------------------------------------------------- -// Verificación de conexión -// ---------------------------------------------------------- -async function verificarConexion() { - try { - const client = await pool.connect(); - const res = await client.query('SELECT NOW() AS hora'); - console.log(`Conexión con la base de datos ${process.env.DB_NAME} fue exitosa.`); - console.log('Fecha y hora actual de la base de datos:', res.rows[0].hora); - client.release(); - } catch (error) { - console.error('Error al conectar con la base de datos al iniciar:', error.message); - console.error('Revisar credenciales y accesos de red.'); - } -} - -// ----------------------------------------------------------------------------- -// Health + 404 + errores -// ----------------------------------------------------------------------------- -app.get('/health', (_req, res) => res.status(200).json({ status: 'ok', service: 'app' })); - -app.use((req, res) => res.status(404).json({ error: 'not-found', path: req.originalUrl })); - -app.use((err, _req, res, _next) => { - console.error('[APP] Error:', err); - if (res.headersSent) return; - res.status(500).json({ error: 'internal-error', detail: err?.message || String(err) }); -}); - -// ----------------------------------------------------------------------------- -// Arranque -// ----------------------------------------------------------------------------- -const PORT = Number(process.env.PORT || process.env.APP_LOCAL_PORT || 3030); -// app.listen(PORT, () => { -// console.log(`[APP] SuiteCoffee corriendo en http://localhost:${PORT}`); -// }); - -(async () => { - await verificarConexion(); - app.listen(PORT, () => { - console.log(`[APP] SuiteCoffee corriendo en http://localhost:${PORT}`); - }); -})(); \ No newline at end of file diff --git a/auth.index.js b/auth.index.js new file mode 100644 index 0000000..2f8233a --- /dev/null +++ b/auth.index.js @@ -0,0 +1,1015 @@ +// services/auth/src/index.js +// ------------------------------------------------------------ +// SuiteCoffee — Servicio de Autenticación (Express + OIDC) +// - ESM compatible (Node >=18) +// - Sesiones con Redis (compartibles con otros servicios) +// - Vistas EJS (login) +// - Rutas OIDC: /auth/login, /auth/callback, /auth/logout +// - Registro de usuario: /auth/api/users/register (DB + Authentik) +// ------------------------------------------------------------ + +import 'dotenv/config'; +import chalk from 'chalk'; +import express from 'express'; +import cors from 'cors'; +import path from 'node:path'; +import { access, readFile } from 'node:fs/promises'; +import { constants as fsConstants } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { Pool } from 'pg'; +import session from 'express-session'; +import { createClient } from 'redis'; +import expressLayouts from 'express-ejs-layouts'; +import { Issuer, generators } from 'openid-client'; +import crypto from 'node:crypto'; + +// ----------------------------------------------------------------------------- +// Variables globales +// ----------------------------------------------------------------------------- +let oidcClient; +let _cachedInitSql = null; + +// ----------------------------------------------------------------------------- +// Importaciones desde archivos +// ----------------------------------------------------------------------------- +// Helpers de Authentik (admin API) +import { akFindUserByEmail, akCreateUser, + akSetPassword, akResolveGroupId } from './ak.js'; + + +// ----------------------------------------------------------------------------- +// Utilidades / Helpers +// ----------------------------------------------------------------------------- +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +// Normaliza UUID (acepta con/sin guiones) → "hex" sin guiones +const cleanUuid = (u) => (u ? String(u).toLowerCase().replace(/[^0-9a-f]/g, '') : ''); + +// Nombre de schema/rol a partir de uuid limpio +const schemaNameFor = (uuidHex) => `schema_tenant_${uuidHex}`; +const roleNameFor = (uuidHex) => `tenant_${uuidHex}`; + +// Quoter seguro de identificadores SQL (roles, schemas, tablas) +// Identificador SQL (schema, role, table, …) +const qi = (ident) => `"${String(ident).replace(/"/g, '""')}"`; + +// Literal de texto SQL (valores: contraseñas, strings, …) +const qs = (val) => `'${String(val).replace(/'/g, "''")}'`; + +const VALID_IDENT = /^[a-zA-Z_][a-zA-Z0-9_:$-]*$/; + +// Helpers defensivos +const extractAkUserUuid = (u) => + (u && (u.uuid || u?.user?.uuid || (Array.isArray(u.results) && u.results[0]?.uuid))) || null; +const extractAkUserPk = (u) => + (u && (u.pk ?? u?.user?.pk ?? null)); + +// --- Resolver y cachear el grupo por ID/UUID/NOMBRE una sola vez --- +let DEFAULT_GROUP_ID = process.env.AUTHENTIK_DEFAULT_GROUP_ID + ? Number(process.env.AUTHENTIK_DEFAULT_GROUP_ID) : null; + +if (!DEFAULT_GROUP_ID) { + (async () => { + try { + // Si tenés akResolveGroupIdByName, usalo: + // DEFAULT_GROUP_ID = await akResolveGroupIdByName(process.env.AUTHENTIK_DEFAULT_GROUP_NAME); + + // Con el helper genérico que te dejé en ak.js: + DEFAULT_GROUP_ID = await akResolveGroupId({ + uuid: process.env.AUTHENTIK_DEFAULT_GROUP_UUID, + name: process.env.AUTHENTIK_DEFAULT_GROUP_NAME, + }); + console.log('[AK] DEFAULT_GROUP_ID resuelto:', DEFAULT_GROUP_ID); + } catch (e) { + console.warn('[AK] No se pudo resolver DEFAULT_GROUP_ID:', e?.message || e); + } + })(); +} + +function nukeSession(req, res, redirectTo = '/auth/login', reason = 'reset') { + try { + // Destruye la sesión en el store (Redis) + req.session?.destroy(() => { + // Limpia la cookie en el navegador + res.clearCookie(SESSION_COOKIE_NAME, { + path: '/', + httpOnly: true, + sameSite: 'lax', + secure: process.env.NODE_ENV === 'production', + }); + // Reinicia el flujo + return res.redirect(303, `${redirectTo}?reason=${encodeURIComponent(reason)}`); + }); + } catch { + // Si algo falla, al menos intentamos redirigir + return res.redirect(303, `${redirectTo}?reason=${encodeURIComponent(reason)}`); + } +} + +// Verificar existencia del tenant sin crear (en la DB de tenants) +async function tenantExists(uuidHex) { + if (!uuidHex) return false; + const schema = schemaNameFor(uuidHex); + const client = await tenantsPool.connect(); + try { + const q = await client.query( + 'SELECT 1 FROM information_schema.schemata WHERE schema_name=$1', + [schema] + ); + return q.rowCount > 0; + } finally { + client.release(); + } +} + +// Intenta obtener el tenant por orden: +// 1) DB principal (app_user por email) +// 2) Authentik (attributes.tenant_uuid del usuario) +// 3) valor provisto en el request (si viene) +async function resolveExistingTenantUuid({ email, requestedTenantUuid }) { + const normEmail = String(email).trim().toLowerCase(); + + // 1) DB principal + const dbRes = await mainPool.query( + 'SELECT tenant_uuid FROM app_user WHERE LOWER(email)=LOWER($1) LIMIT 1', + [normEmail] + ); + if (dbRes.rowCount) { + const fromDb = cleanUuid(dbRes.rows[0].tenant_uuid); + if (fromDb) return fromDb; + } + + // 2) Authentik + const akUser = await akFindUserByEmail(normEmail).catch(() => null); + const fromAk = cleanUuid(akUser?.attributes?.tenant_uuid); + if (fromAk) return fromAk; + + // 3) Pedido del request + const fromReq = cleanUuid(requestedTenantUuid); + if (fromReq) return fromReq; + + return null; // no hay tenant conocido +} + +// Helper para crear tenant si falta +async function ensureTenant({ tenant_uuid }) { + const admin = await tenantsPool.connect(); + try { + await admin.query('BEGIN'); + + // uuid y nombres + const uuid = (tenant_uuid || crypto.randomUUID()).toLowerCase(); + const hex = uuid.replace(/-/g, ''); + if (!/^[a-f0-9]{32}$/.test(hex)) throw new Error('tenant_uuid inválido'); + + const schema = `schema_tenant_${hex}`; + const role = `tenant_${hex}`; + const pwd = crypto.randomBytes(18).toString('base64url'); + + if (!VALID_IDENT.test(schema) || !VALID_IDENT.test(role)) { + throw new Error('Identificador de schema/rol inválido'); + } + + // 1) Crear ROL si no existe (PASSWORD debe ser LITERAL, no parámetro) + const r = await admin.query('SELECT 1 FROM pg_roles WHERE rolname=$1', [role]); + if (!r.rowCount) { + await admin.query(`CREATE ROLE ${qi(role)} LOGIN PASSWORD ${qs(pwd)}`); + // Si quisieras rotarla luego: + // await admin.query(`ALTER ROLE ${qi(role)} PASSWORD ${qs(pwd)}`); + } + + // 2) Crear SCHEMA si no existe y asignar owner + const s = await admin.query( + 'SELECT 1 FROM information_schema.schemata WHERE schema_name=$1', + [schema] + ); + if (!s.rowCount) { + await admin.query(`CREATE SCHEMA ${qi(schema)} AUTHORIZATION ${qi(role)}`); + } else { + await admin.query(`ALTER SCHEMA ${qi(schema)} OWNER TO ${qi(role)}`); + } + + // 3) Permisos por defecto + await admin.query(`GRANT USAGE ON SCHEMA ${qi(schema)} TO ${qi(role)}`); + await admin.query( + `ALTER DEFAULT PRIVILEGES IN SCHEMA ${qi(schema)} + GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO ${qi(role)}` + ); + await admin.query( + `ALTER DEFAULT PRIVILEGES IN SCHEMA ${qi(schema)} + GRANT USAGE, SELECT, UPDATE ON SEQUENCES TO ${qi(role)}` + ); + + // 4) Aplicar 01_init.sql en la misma transacción + const initSql = await loadInitSql(); // tu caché/loader actual + if (initSql && initSql.trim()) { + await admin.query(`SET LOCAL search_path TO ${qi(schema)}, public`); + await admin.query(initSql); + } + + await admin.query('COMMIT'); + return { tenant_uuid: uuid, schema, role, role_password: pwd }; + } catch (e) { + try { await admin.query('ROLLBACK'); } catch {} + throw e; + } finally { + admin.release(); + } +} + +// Carga el 01_init.sql del disco, elimina BEGIN/COMMIT y sustituye el schema. +requiredEnv(['TENANT_INIT_SQL']); +async function loadInitSql() { + if (_cachedInitSql !== null) return _cachedInitSql; + + const raw = String(process.env.TENANT_INIT_SQL || '').trim(); + if (!raw) return (_cachedInitSql = ''); + + if (raw.startsWith('base64:')) { + _cachedInitSql = Buffer.from(raw.slice(7), 'base64').toString('utf8'); + return _cachedInitSql; + } + + const v = raw.startsWith('@') ? raw.slice(1) : raw; + + // absoluta o relativa al CWD (/app) + const candidates = path.isAbsolute(v) + ? [v] + : [path.resolve(process.cwd(), v), path.resolve('.', v)]; + + for (const c of candidates) { + try { + await access(c, fsConstants.R_OK); + _cachedInitSql = await readFile(c, 'utf8'); + return _cachedInitSql; + } catch {} + } + + // Si no parecía ruta, trátalo como SQL inline + _cachedInitSql = v; + return _cachedInitSql; +} + +async function isSchemaEmpty(client, schema) { + const { rows } = await client.query( + `SELECT COUNT(*)::int AS c + FROM information_schema.tables + WHERE table_schema = $1`, + [schema] + ); + return rows[0].c === 0; +} + +/** Ejecuta 01_init.sql para un tenant (solo si el esquema está vacío). */ +async function initializeTenantSchemaIfEmpty(schema) { + const sql = await loadInitSql(); + if (!sql || !sql.trim()) { + console.warn(`[TENANT INIT] Esquema ${schema}: 01_init.sql vacío/no disponible. Salteando.`); + return; + } + + const client = await tenantsPool.connect(); + try { + // No usamos LOCAL: queremos que el search_path persista en esta conexión mientras dura el script + await client.query('BEGIN'); + await client.query(`SET search_path TO ${qi(schema)}, public`); + + const empty = await isSchemaEmpty(client, schema); + if (!empty) { + await client.query('ROLLBACK'); + console.log(`[TENANT INIT] Esquema ${schema}: ya tiene tablas. No se aplica 01_init.sql.`); + return; + } + + await client.query(sql); // acepta múltiples sentencias separadas por ';' + await client.query('COMMIT'); + console.log(`[TENANT INIT] Esquema ${schema}: 01_init.sql aplicado.`); + } catch (e) { + try { await client.query('ROLLBACK'); } catch {} + console.error(`[TENANT INIT] Error aplicando 01_init.sql sobre ${schema}:`, e.message); + throw e; + } finally { + client.release(); + } +} + + +// ----------------------------------------------------------------------------- +// Utilidades +// ----------------------------------------------------------------------------- +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +function requiredEnv(keys) { + const missing = keys.filter((k) => !process.env[k]); + if (missing.length) { + console.warn(chalk.yellow(`⚠ Falta configurar variables de entorno: ${missing.join(', ')}`)); + } +} + +function onFatal(err, msg = 'Error fatal') { + console.error(chalk.red(`\n${msg}:`)); + console.error(err); + process.exit(1); +} + +function genTempPassword(len = 12) { + const base = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789!@$%'; + let out = ''; + for (let i = 0; i < len; i++) out += base[Math.floor(Math.random() * base.length)]; + return out; +} + +// ----------------------------------------------------------------------------- +// Configuración Express +// ----------------------------------------------------------------------------- +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 })); + +// Vistas EJS +app.set('views', path.join(__dirname, 'views')); +app.set('view engine', 'ejs'); + + +// Archivos estáticos opcionales (ajusta si tu estructura difiere) +app.use(express.static(path.join(__dirname, 'public'))); +app.use('/pages', express.static(path.join(__dirname, 'pages'))); + +// ----------------------------------------------------------------------------- +// Sesión (Redis) — misma cookie que APP +// ----------------------------------------------------------------------------- +requiredEnv(['SESSION_SECRET', 'REDIS_URL']); +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/* + }, +})); + +// Exponer usuario a las vistas (no tocar req.session) +app.use((req, res, next) => { + res.locals.user = req.session?.user || null; + next(); +}); + +// ----------------------------------------------------------------------------- +// PostgreSQL — DB tenants (usuarios de suitecoffee) +// ----------------------------------------------------------------------------- +const tenantsPool = new Pool({ + host: process.env.TENANTS_HOST || 'dev-tenants', + port: Number(process.env.TENANTS_PORT || 5432), + user: process.env.TENANTS_USER || 'dev-user-postgres', + password: process.env.TENANTS_PASS || 'dev-pass-postgres', + database: process.env.TENANTS_DB || 'dev-postgres', + max: 10, +}); + +// ----------------------------------------------------------------------------- +// PostgreSQL — DB principal (metadatos de negocio) +// ----------------------------------------------------------------------------- +requiredEnv(['DB_HOST', 'DB_USER', 'DB_PASS', 'DB_NAME']); +const mainPool = new Pool({ + host: process.env.DB_HOST || 'dev-db', + port: Number(process.env.DB_PORT || 5432), + user: process.env.DB_USER || 'dev-user-suitecoffee', + password: process.env.DB_PASS || 'dev-pass-suitecoffee', + database: process.env.DB_NAME || 'dev-suitecoffee', + max: 10, + idleTimeoutMillis: 30_000, +}); + +// ---------------------------------------------------------- +// Verificación de conexión +// ---------------------------------------------------------- + +async function verificarConexion() { + try { + console.log(`[AUTH] Comprobando accesibilidad a la db ${process.env.DB_NAME} del host ${process.env.DB_HOST} ...`); + const client = await mainPool.connect(); + const { rows } = await client.query('SELECT NOW() AS ahora'); + console.log(`\n[AUTH] Conexión con ${process.env.DB_NAME} OK. Hora DB:`, rows[0].ahora); + client.release(); + } catch (error) { + console.error('[AUTH] Error al conectar con la base de datos al iniciar:', error.message); + console.error('[AUTH] Revisar DB_HOST/USER/PASS/NAME, accesos de red y firewall.'); + } +} + +// ----------------------------------------------------------------------------- +// OIDC (Authentik) — discovery + cliente +// ----------------------------------------------------------------------------- +requiredEnv(['OIDC_ISSUER', 'OIDC_CLIENT_ID', 'OIDC_CLIENT_SECRET', 'OIDC_REDIRECT_URI']); + +async function OIDCdiscover(){ + let RETRY_MS = 10_000; + let _retryTimer = null; + try { + const issuer = await discoverOIDCWithRetry(process.env.OIDC_ISSUER, { retries: 60, delayMs: 2000 }); + oidcClient = new issuer.Client({ + client_id: process.env.OIDC_CLIENT_ID, + client_secret: process.env.OIDC_CLIENT_SECRET, + redirect_uris: [process.env.OIDC_REDIRECT_URI], + response_types: ['code'], + }); + } catch (e) { + console.error('[OIDC] No se pudo inicializar OIDC aún. Seguirá reintentando cada 10s en background.'); + + if (_retryTimer) return; // ya hay un reintento programado + + // Mensaje diferido (exactamente como pediste): + setTimeout(() => { + console.log('[OIDC] Inicializando en 10 segundos '); + }, RETRY_MS); + + // Bucle de reintentos (primer intento dentro de 10s) + const loop = async () => { + try { + const issuer = await Issuer.discover(process.env.OIDC_ISSUER); + oidcClient = new issuer.Client({ + client_id: process.env.OIDC_CLIENT_ID, + client_secret: process.env.OIDC_CLIENT_SECRET, + redirect_uris: [process.env.OIDC_REDIRECT_URI], + response_types: ['code'], + }); + console.log('[OIDC] Inicializado correctamente en reintento tardío'); + _retryTimer = null; // limpiar timer + } catch { + console.log(`[OIDC] Aún no disponible. Próximo intento en ${RETRY_MS / 1000}s.`); + _retryTimer = setTimeout(loop, RETRY_MS); + } + }; + + _retryTimer = setTimeout(loop, RETRY_MS); + } +} + +async function discoverOIDCWithRetry(issuerUrl, { retries = 30, delayMs = 2000 } = {}) { + let lastErr; + for (let i = 1; i <= retries; i++) { + try { + const issuer = await Issuer.discover(issuerUrl); + console.log(`[OIDC] Issuer OK en intento ${i}:`, issuer.metadata.issuer); + return issuer; + } catch (err) { + lastErr = err; + console.warn(`[OIDC] Intento ${i}/${retries} falló: ${err.code || err.message}`); + await sleep(delayMs); + } + } + // No abortamos el proceso; dejamos el servidor vivo y seguimos reintentando en background + throw lastErr; +} + + +// ----------------------------------------------------------------------------- +// Vistas +// ----------------------------------------------------------------------------- + +// Página de login +app.get("/auth/login", (_req, res) => { + return res.render("login", { pageTitle: "Iniciar sesión" }); +}); + +app.post("/auth/login", async (req, res, next) => { + try { + const email = String(req.body.email || "").trim().toLowerCase(); + const password = String(req.body.password || ""); + const remember = req.body.remember === "on" || req.body.remember === true; + + if (!email || !password) { + return res.status(400).render("login", { pageTitle: "Iniciar sesión", error: "Completa email y contraseña." }); + } + + // Tabla/columnas por defecto; ajustables por env si tu esquema difiere + const USERS_TABLE = process.env.TENANTS_USERS_TABLE || "users"; + const COL_ID = process.env.TENANTS_COL_ID || "id"; + const COL_EMAIL = process.env.TENANTS_COL_EMAIL || "email"; + const COL_HASH = process.env.TENANTS_COL_HASH || "password_hash"; + const COL_ROLE = process.env.TENANTS_COL_ROLE || "role"; + const COL_TENANT = process.env.TENANTS_COL_TENANT || "tenant_id"; + + const { rows } = await tenantsPool.query( + `SELECT ${COL_ID} AS id, ${COL_EMAIL} AS email, ${COL_HASH} AS password_hash, + ${COL_ROLE} AS role, ${COL_TENANT} AS tenant_id + FROM ${USERS_TABLE} + WHERE ${COL_EMAIL} = $1 + LIMIT 1`, + [email] + ); + + if (rows.length === 0) { + return res.status(401).render("login", { pageTitle: "Iniciar sesión", error: "Credenciales inválidas." }); + } + + const user = rows[0]; + const ok = await bcrypt.compare(password, user.password_hash || ""); + if (!ok) { + return res.status(401).render("login", { pageTitle: "Iniciar sesión", error: "Credenciales inválidas." }); + } + + // (Opcional) registro de acceso en DB principal + try { + await mainPool.query( + "INSERT INTO auth_audit_log(email, tenant_id, action, at) VALUES ($1, $2, $3, NOW())", + [user.email, user.tenant_id, "login_success"] + ); + } catch { /* noop si no existe la tabla */ } + + // Sesión compartida + req.session.regenerate((err) => { + if (err) return next(err); + + req.session.user = { + id: user.id, + email: user.email, + role: user.role, + tenant_id: user.tenant_id, + loggedAt: Date.now(), + }; + + if (remember) { + req.session.cookie.maxAge = 30 * 24 * 60 * 60 * 1000; + } else { + req.session.cookie.expires = false; + } + + req.session.save((err2) => { + if (err2) return next(err2); + return res.redirect(303, "/"); // "/" → app decide /dashboard o /auth/login + }); + }); + } catch (e) { + next(e); + } +}); + + +// ----------------------------------------------------------------------------- +// Rutas OIDC +// ----------------------------------------------------------------------------- +app.get('/auth/login', (req, res, next) => { + try { + + if (req.session?.oidc) { + return nukeSession(req, res, '/auth/login', 'stale_oidc'); + } + + const code_verifier = generators.codeVerifier(); + const code_challenge = generators.codeChallenge(code_verifier); + + // Podés usar generators.state() y generators.nonce(); ambas son válidas + const state = generators.state(); // crypto.randomBytes(24).toString('base64url') también sirve + const nonce = generators.nonce(); + + + + // Guardamos TODO dentro de un objeto para evitar claves sueltas + req.session.oidc = { code_verifier, state, nonce }; + + // Guardar sesión ANTES de redirigir + req.session.save((err) => { + if (err) return next(err); + + const url = oidcClient.authorizationUrl({ + scope: 'openid profile email offline_access', + code_challenge, + code_challenge_method: 'S256', + state, + nonce, + }); + + return res.redirect(url); // importantísimo: return + }); + } catch (e) { + return next(e); + } +}); + +app.get('/auth/callback', async (req, res, next) => { + try { + // Log útil para debug + console.log('[OIDC] cb sid=%s query=%j', req.sessionID, req.query); + + // Recuperar lo que guardamos en /auth/login + const { oidc } = req.session || {}; + const code_verifier = oidc?.code_verifier; + const stateStored = oidc?.state; + const nonceStored = oidc?.nonce; + + // Si por algún motivo no está la info (sesión perdida/expirada), reiniciamos el flujo + if (!code_verifier || !stateStored) { + console.warn('[OIDC] Falta code_verifier/state en sesión; reiniciando login'); + return res.redirect(303, '/auth/login'); + } + + const params = oidcClient.callbackParams(req); + + // openid-client validará que el "state" recibido coincida con el que pasamos aquí + let tokenSet; + try { + tokenSet = await oidcClient.callback( + process.env.OIDC_REDIRECT_URI, + params, + { code_verifier, state: stateStored, nonce: nonceStored } + ); + } catch (err) { + console.warn('[OIDC] callback error, resetting session:', err?.message || err); + return nukeSession(req, res, '/auth/login', 'callback_error'); + } + + // Limpiar datos OIDC de la sesión + delete req.session.oidc; + + const claims = tokenSet.claims(); + const email = (claims.email || '').toLowerCase(); + + // tenant desde claim, Authentik o fallback a tu DB + let tenantHex = cleanUuid(claims.tenant_uuid); + if (!tenantHex) { + const akUser = await akFindUserByEmail(email).catch(() => null); + tenantHex = cleanUuid(akUser?.attributes?.tenant_uuid); + + if (!tenantHex) { + const q = await mainPool.query( + 'SELECT tenant_uuid FROM app_user WHERE LOWER(email)=LOWER($1) LIMIT 1', + [email] + ); + tenantHex = cleanUuid(q.rows?.[0]?.tenant_uuid); + } + } + + // Regenerar sesión para evitar fijación y guardar el usuario + req.session.regenerate((err) => { + if (err) { + if (!res.headersSent) res.status(500).send('No se pudo crear la sesión.'); + return; + } + req.session.user = { + sub: claims.sub, + email, + tenant_uuid: tenantHex || null, + }; + req.session.save((e2) => { + if (e2) { + if (!res.headersSent) res.status(500).send('No se pudo guardar la sesión.'); + return; + } + if (!res.headersSent) return res.redirect('/'); + }); + }); + + return res.redirect('/'); + + } catch (e) { + console.error('[OIDC] callback error:', e); + if (!res.headersSent) return next(e); + } +}); + + +app.post('/auth/logout', (req, res) => { + req.session.destroy(() => { + res.clearCookie(SESSION_COOKIE_NAME); + res.status(204).end(); + }); +}); + +app.get('/auth/me', (req, res) => { + if (!req.session?.user) return res.status(401).json({ error: 'no-auth' }); + res.json({ user: req.session.user }); +}); + +// ----------------------------------------------------------------------------- +// Registro de usuario (DB principal + Authentik) +// ----------------------------------------------------------------------------- + +async function akDeleteUser(pkOrUuid) { + try { + if (!pkOrUuid || !globalThis.fetch) return; + const base = process.env.AUTHENTIK_BASE_URL?.replace(/\/+$/, '') || ''; + const id = String(pkOrUuid); + const url = `${base}/api/v3/core/users/${encodeURIComponent(id)}/`; + await fetch(url, { + method: 'DELETE', + headers: { 'Authorization': `Bearer ${process.env.AUTHENTIK_TOKEN}` } + }); + } catch (e) { + console.warn('[AK] No se pudo borrar usuario (compensación):', e?.message || e); + } +} + +// ============================== +// POST /auth/api/users/register +// ============================== +app.post('/auth/api/users/register', async (req, res, next) => { + + const { + email, display_name, role, + tenant_uuid: requestedTenantUuid, // opcional + } = req.body || {}; + + const normEmail = String(email || '').trim().toLowerCase(); + if (!normEmail) return res.status(400).json({ error: 'Email requerido' }); + + // 1) Resolver tenant uuid (existente o nuevo) + let tenantHex = null; + try { + if (typeof resolveExistingTenantUuid === 'function') { + tenantHex = await resolveExistingTenantUuid({ + email: normEmail, + requestedTenantUuid, + }); + } else { + tenantHex = cleanUuid(requestedTenantUuid); + console.log("------ Bandera ------"); + } + + // Crear/asegurar tenant en una transacción (ahí adentro corre 01_init.sql) + if (tenantHex) { + // si no existe, ensureTenant lo crea + await ensureTenant({ tenant_uuid: tenantHex }); + } else { + const created = await ensureTenant({ tenant_uuid: null }); // genera uuid + tenantHex = cleanUuid(created?.tenant_uuid); + } + } catch (e) { + return next(new Error(`No se pudo preparar el tenant: ${e.message}`)); + } + + // 2) Transacción DB principal + Authentik con compensación + const client = await mainPool.connect(); + let createdAkUser = null; // para compensación + try { + await client.query('BEGIN'); + + // Duplicados (ajusta a tu constraint real: por email o por (email,tenant)) + const dup = await client.query( + 'SELECT id FROM app_user WHERE LOWER(email)=LOWER($1)', + [normEmail] + ); + if (dup.rowCount) { + await client.query('ROLLBACK'); + return res.status(409).json({ + error: 'user-exists', + message: 'Ya existe un usuario con este email.', + next: '/auth/login', + }); + } + + // Authentik: buscar o crear + let akUser = await akFindUserByEmail(normEmail).catch(() => null); + if (!akUser) { + akUser = await akCreateUser({ + email: normEmail, + displayName: display_name || null, + tenantUuid: tenantHex, // attributes.tenant_uuid + addToGroupId: DEFAULT_GROUP_ID || null, + isActive: true, + }); + createdAkUser = akUser; // marcar que lo creamos nosotros + } + + // Asegurar uuid/pk + let akUserUuid = extractAkUserUuid(akUser); + let akUserPk = extractAkUserPk(akUser); + if (!akUserUuid || akUserPk == null) { + const ref = await akFindUserByEmail(normEmail).catch(() => null); + akUserUuid = akUserUuid || extractAkUserUuid(ref); + akUserPk = akUserPk ?? extractAkUserPk(ref); + } + if (!akUserUuid) throw new Error('No se pudo obtener uuid del usuario en Authentik'); + + // Insert en tu DB principal + const finalRole = role || 'owner'; + await client.query( + `INSERT INTO app_user (email, display_name, tenant_uuid, ak_user_uuid, role) + VALUES ($1,$2,$3,$4,$5)`, + [normEmail, display_name || null, tenantHex, akUserUuid, finalRole] + ); + + await client.query('COMMIT'); + + // 3) Marcar sesión para set-password (si usás este flujo) + req.session.pendingPassword = { + email: normEmail, + ak_user_uuid: akUserUuid, + ak_user_pk: akUserPk, + exp: Date.now() + 10 * 60 * 1000, + }; + + return req.session.save(() => { + const accept = String(req.headers['accept'] || ''); + if (accept.includes('text/html')) { + return res.redirect(303, '/set-password'); + } + return res.status(201).json({ + message: 'Usuario registrado', + email: normEmail, + tenant_uuid: tenantHex, + role: finalRole, + authentik_user_uuid: akUserUuid, + next: '/set-password', + }); + }); + } catch (err) { + // Rollbacks + Compensaciones + try { await client.query('ROLLBACK'); } catch {} + try { + // Si creamos el usuario en Authentik y luego falló algo → borrar + if (createdAkUser) { + const id = extractAkUserPk(createdAkUser) ?? extractAkUserUuid(createdAkUser); + if (id) await akDeleteUser(id); + } + } catch {} + return next(err); + } finally { + client.release(); + } +}); + + + +// Definir contraseña +app.post('/auth/password/set', async (req, res, next) => { + try { + const pp = req.session?.pendingPassword; + if (!pp || (pp.exp && Date.now() > pp.exp)) { + // token de sesión vencido o ausente + if (!res.headersSent) return res.redirect(303, '/set-password'); + return; + } + + const { password, password2 } = req.body || {}; + if (!password || password.length < 8 || password !== password2) { + return res.status(400).send('Contraseña inválida o no coincide.'); + } + + // Buscar el usuario en Authentik y setear la clave + const u = await akFindUserByEmail(pp.email); + if (!u) return res.status(404).send('No se encontró el usuario en Authentik.'); + + await akSetPassword(u.pk, password, true); // true = force change handled; ajusta a tu helper + + // Limpiar marcador y continuar al SSO + delete req.session.pendingPassword; + return req.session.save(() => res.redirect(303, '/auth/login')); + } catch (e) { + next(e); + } +}); + + +// Espera: { email, display_name?, tenant_uuid } +// app.post('/auth/auth/api/users/register', async (req, res, next) => { + +// const { email, display_name, tenant_uuid: rawTenant, role } = req.body || {}; +// if (!email) return res.status(400).json({ error: 'email es obligatorio' }); +// // Si no vino tenant: lo creamos +// const { tenant_uuid, schema, role: dbRole } = await ensureTenant({ tenant_uuid: rawTenant }); + +// const client = await mainPool.connect(); +// try { +// await client.query('BEGIN'); + +// // ¿ya existe en tu DB? +// const { rows: dup } = await client.query( +// 'SELECT id FROM app_user WHERE email=$1 AND tenant_uuid=$2', +// [email.toLowerCase(), tenant_uuid.replace(/-/g, '')] +// ); +// if (dup.length) { +// await client.query('ROLLBACK'); +// return res.status(409).json({ error: 'El usuario ya existe en SuiteCoffee' }); +// } + +// // Authentik: crear si no existe +// let akUser = await akFindUserByEmail(email); +// if (!akUser) { +// akUser = await akCreateUser({ +// email, +// displayName: display_name, +// tenantUuid: tenant_uuid, // se normaliza dentro de ak.js +// addToGroupId: DEFAULT_GROUP_ID || null, +// isActive: true, +// }); +// // Si querés forzar clave inicial (opcional; depende de tus políticas): +// // await akSetPassword(akUser.pk, 'ClaveTemporal123!', true); +// } + +// const _role = role || 'owner'; +// await client.query( +// `INSERT INTO app_user (email, display_name, tenant_uuid, ak_user_uuid, role) +// VALUES ($1,$2,$3,$4,$5)`, +// [email.toLowerCase(), display_name || null, tenant_uuid.replace(/-/g, ''), akUser.uuid, _role] +// ); + +// await client.query('COMMIT'); +// return res.status(201).json({ +// message: 'Usuario registrado', +// email, +// tenant_uuid, +// role: _role, +// authentik_user_uuid: akUser.uuid, +// next: '/auth/login' +// }); +// } catch (err) { +// try { await client.query('ROLLBACK'); } catch {} +// next(err); +// } finally { +// client.release(); +// } +// }); + + +// ----------------------------------------------------------------------------- +// Healthcheck +// ----------------------------------------------------------------------------- +app.get('/health', (_req, res) => res.status(200).json({ status: 'ok', service: 'auth' })); + +// ----------------------------------------------------------------------------- +// 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('[AUTH] ', err); + if (res.headersSent) return; + res.status(500).json({ error: '¡Oh! A ocurrido un error en el servidor auth.', detail: err.stack || String(err) }); +}); + +/* +----------------------------------------------------------------------------- +Exportación principal del módulo. +Es típico exportar la instancia (app) y arrancarla en otro archivo. +- Facilita tests (p.ej. con supertest: import app from './app.js') +- Evita que el servidor se inicie al importar el módulo. + +# Default + export default app; // importar: import app from './app.js' + +# Con nombre + export const app = express(); // importar: import { app } from './app.js' +----------------------------------------------------------------------------- +*/ +export default app; + +// ----------------------------------------------------------------------------- +// Arranque +// ----------------------------------------------------------------------------- +const PORT = Number(process.env.PORT || 4040); +app.listen(PORT, () => { + console.log(`[AUTH] Servicio de autenticación escuchando en http://localhost:${PORT}`); + verificarConexion(); + OIDCdiscover(); +}); \ No newline at end of file diff --git a/authentik-media/public/flow-backgrounds/369222221_9a9865d1-1bd6-4827-98b0-7d6111517095.jpg b/authentik-media/public/flow-backgrounds/369222221_9a9865d1-1bd6-4827-98b0-7d6111517095.jpg new file mode 100644 index 0000000..e799070 Binary files /dev/null and b/authentik-media/public/flow-backgrounds/369222221_9a9865d1-1bd6-4827-98b0-7d6111517095.jpg differ diff --git a/authentik-media/public/flow-backgrounds/369222221_9a9865d1-1bd6-4827-98b0-7d6111517095_qyJPXWw.jpg b/authentik-media/public/flow-backgrounds/369222221_9a9865d1-1bd6-4827-98b0-7d6111517095_qyJPXWw.jpg new file mode 100644 index 0000000..e799070 Binary files /dev/null and b/authentik-media/public/flow-backgrounds/369222221_9a9865d1-1bd6-4827-98b0-7d6111517095_qyJPXWw.jpg differ diff --git a/authentik-media/suitecoffee-logo/SuiteCoffee.png b/authentik-media/suitecoffee-logo/SuiteCoffee.png new file mode 100644 index 0000000..a0529b4 Binary files /dev/null and b/authentik-media/suitecoffee-logo/SuiteCoffee.png differ diff --git a/authentik-media/suitecoffee-logo/SuiteCoffee_BM_1.png b/authentik-media/suitecoffee-logo/SuiteCoffee_BM_1.png new file mode 100644 index 0000000..0d6ead3 Binary files /dev/null and b/authentik-media/suitecoffee-logo/SuiteCoffee_BM_1.png differ diff --git a/authentik-media/suitecoffee-logo/SuiteCoffee_BN_1.svg b/authentik-media/suitecoffee-logo/SuiteCoffee_BN_1.svg new file mode 100644 index 0000000..6f57d6b --- /dev/null +++ b/authentik-media/suitecoffee-logo/SuiteCoffee_BN_1.svg @@ -0,0 +1,13 @@ + + + + + + + + + \ No newline at end of file diff --git a/authentik-media/suitecoffee-logo/SuiteCoffee_TB_1.png b/authentik-media/suitecoffee-logo/SuiteCoffee_TB_1.png new file mode 100644 index 0000000..56965b8 Binary files /dev/null and b/authentik-media/suitecoffee-logo/SuiteCoffee_TB_1.png differ diff --git a/authentik-media/suitecoffee-logo/SuiteCoffee_TM_1.png b/authentik-media/suitecoffee-logo/SuiteCoffee_TM_1.png new file mode 100644 index 0000000..8cb2bcd Binary files /dev/null and b/authentik-media/suitecoffee-logo/SuiteCoffee_TM_1.png differ diff --git a/authentik-media/suitecoffee-logo/SuiteCoffee_TN_1.png b/authentik-media/suitecoffee-logo/SuiteCoffee_TN_1.png new file mode 100644 index 0000000..2273e3d Binary files /dev/null and b/authentik-media/suitecoffee-logo/SuiteCoffee_TN_1.png differ diff --git a/authentik-media/suitecoffee-logo/favicon_io/android-chrome-192x192.png b/authentik-media/suitecoffee-logo/favicon_io/android-chrome-192x192.png new file mode 100644 index 0000000..1210323 Binary files /dev/null and b/authentik-media/suitecoffee-logo/favicon_io/android-chrome-192x192.png differ diff --git a/authentik-media/suitecoffee-logo/favicon_io/android-chrome-512x512.png b/authentik-media/suitecoffee-logo/favicon_io/android-chrome-512x512.png new file mode 100644 index 0000000..1b029ab Binary files /dev/null and b/authentik-media/suitecoffee-logo/favicon_io/android-chrome-512x512.png differ diff --git a/authentik-media/suitecoffee-logo/favicon_io/apple-touch-icon.png b/authentik-media/suitecoffee-logo/favicon_io/apple-touch-icon.png new file mode 100644 index 0000000..4164fdb Binary files /dev/null and b/authentik-media/suitecoffee-logo/favicon_io/apple-touch-icon.png differ diff --git a/authentik-media/suitecoffee-logo/favicon_io/favicon-16x16.png b/authentik-media/suitecoffee-logo/favicon_io/favicon-16x16.png new file mode 100644 index 0000000..eabf7e6 Binary files /dev/null and b/authentik-media/suitecoffee-logo/favicon_io/favicon-16x16.png differ diff --git a/authentik-media/suitecoffee-logo/favicon_io/favicon-32x32.png b/authentik-media/suitecoffee-logo/favicon_io/favicon-32x32.png new file mode 100644 index 0000000..e98f802 Binary files /dev/null and b/authentik-media/suitecoffee-logo/favicon_io/favicon-32x32.png differ diff --git a/authentik-media/suitecoffee-logo/favicon_io/favicon.ico b/authentik-media/suitecoffee-logo/favicon_io/favicon.ico new file mode 100644 index 0000000..c8e2059 Binary files /dev/null and b/authentik-media/suitecoffee-logo/favicon_io/favicon.ico differ diff --git a/authentik-media/suitecoffee-logo/favicon_io/site.webmanifest b/authentik-media/suitecoffee-logo/favicon_io/site.webmanifest new file mode 100644 index 0000000..45dc8a2 --- /dev/null +++ b/authentik-media/suitecoffee-logo/favicon_io/site.webmanifest @@ -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"} \ No newline at end of file diff --git a/compose.dev.yaml b/compose.dev.yaml index 8f401b7..f19944b 100644 --- a/compose.dev.yaml +++ b/compose.dev.yaml @@ -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: diff --git a/compose.yaml b/compose.yaml index c8d979b..20429b8 100644 --- a/compose.yaml +++ b/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 \ No newline at end of file diff --git a/services/app/.env.development b/services/app/.env.development index d9d6986..4e75a73 100644 --- a/services/app/.env.development +++ b/services/app/.env.development @@ -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 \ No newline at end of file +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 diff --git a/services/app/package-lock.json b/services/app/package-lock.json index be8bb82..6df8869 100644 --- a/services/app/package-lock.json +++ b/services/app/package-lock.json @@ -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" } } } diff --git a/services/app/package.json b/services/app/package.json index 04a8927..9ae6e52 100644 --- a/services/app/package.json +++ b/services/app/package.json @@ -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", diff --git a/services/app/src/api/api.js b/services/app/src/api/api.js new file mode 100644 index 0000000..d65d8b8 --- /dev/null +++ b/services/app/src/api/api.js @@ -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); } +}); \ No newline at end of file diff --git a/services/app/src/api/rpc.js b/services/app/src/api/rpc.js new file mode 100644 index 0000000..56766d1 --- /dev/null +++ b/services/app/src/api/rpc.js @@ -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 +// }); +// } +// }); \ No newline at end of file diff --git a/services/app/src/bak/comandas.html.bak b/services/app/src/bak/comandas.html.bak deleted file mode 100644 index 93c6e5d..0000000 --- a/services/app/src/bak/comandas.html.bak +++ /dev/null @@ -1,355 +0,0 @@ - - - - - - Comandas - - - -
-

📋 Nueva Comanda

-
- /api/* -
- -
- -
-
- Productos -
-
- 0 ítems -
-
-
- -
- -
-
-
- - -
-
Detalles
-
-
-
- - -
-
- - -
-
- -
- - -
- -
La fecha se completa automáticamente y los estados/activos usan sus valores por defecto.
- -
-
Carrito
-
-
Aún no agregaste productos.
-
- -
- -
-
-
-
- - - - diff --git a/services/app/src/bak/dashboard.html.bak b/services/app/src/bak/dashboard.html.bak deleted file mode 100644 index 78fbbb6..0000000 --- a/services/app/src/bak/dashboard.html.bak +++ /dev/null @@ -1,293 +0,0 @@ - - - - - - Dashboard - - - -
-

Dashboard

-
- /api/* -
- -
-
- - -
-
- - -
-
- -
- - - -
-
-
Mostrando hasta 100 filas.
-
-
- - - -
- Endpoints -
GET /api/tables • GET /api/schema/:tabla • GET /api/table/:tabla?limit=100 • POST /api/table/:tabla
-
-
- - - - diff --git a/services/app/src/bak/estadoComandas.html.bak b/services/app/src/bak/estadoComandas.html.bak deleted file mode 100644 index dd69c9a..0000000 --- a/services/app/src/bak/estadoComandas.html.bak +++ /dev/null @@ -1,280 +0,0 @@ - - - - - - - Estado de Comandas - - - -
-

🧾 Estado de Comandas

-
- ➕ Nueva comanda -
- -
- -
-
- Listado -
- -
-
-
- - -
-
-
-
- - -
-
- Detalle -
- -
-
-
Selecciona una comanda para ver el detalle.
-
- -
-
-
-
-
- - - - diff --git a/services/app/src/bak/reportes.ejs.bak b/services/app/src/bak/reportes.ejs.bak deleted file mode 100644 index 7f5ce28..0000000 --- a/services/app/src/bak/reportes.ejs.bak +++ /dev/null @@ -1,402 +0,0 @@ -<% /* Reportes - Asistencias y Tickets (Comandas) */ %> -
- -
-

Reportes

- -
- - -
-
-
-
- -
-
- -
-
- -
-
- - - -
-
-
- -
- -
-
- -
-
- - - -
-
-
-
- -
- Los archivos Excel se generan como CSV (compatibles). Los PDF se generan con “Imprimir área” del navegador. -
-
-
- - - - - - - -
- - - - diff --git a/services/app/src/index.js b/services/app/src/index.js index 55f8170..174a4af 100644 --- a/services/app/src/index.js +++ b/services/app/src/index.js @@ -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(` +// +// SuiteCoffee · Definir contraseña +// +//
+//

Definir contraseña

+//
+// +// +// +// Luego te redirigiremos a iniciar sesión por SSO. +//
+//
+// `); +// }); + -// 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(); }); diff --git a/services/app/src/middlewares/tenant.js b/services/app/src/middlewares/tenant.js new file mode 100644 index 0000000..78937bb --- /dev/null +++ b/services/app/src/middlewares/tenant.js @@ -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(); +// } \ No newline at end of file diff --git a/services/app/src/utilities/cargaEnVista.js b/services/app/src/utilities/cargaEnVista.js new file mode 100644 index 0000000..9acc5e8 --- /dev/null +++ b/services/app/src/utilities/cargaEnVista.js @@ -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'; +} \ No newline at end of file diff --git a/services/app/src/views/inicio.ejs b/services/app/src/views/inicio.ejs index 392e712..6901b5f 100644 --- a/services/app/src/views/inicio.ejs +++ b/services/app/src/views/inicio.ejs @@ -1,4 +1,4 @@ - + @@ -31,19 +31,24 @@
- <% + <% + // 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'; %> +

Hola, <%= displayName %> 👋

-

Bienvenido a SuiteCoffee. Este es tu inicio.

+

Bienvenido a SuiteCoffee. Este es tu inicio y panel de diagnóstico de cookies/sesión.

<% if (hasUser) { %> -

Sesión

+

Sesión de Aplicación (user)

<% for (const [k,v] of Object.entries(user)) { %> @@ -56,9 +61,23 @@
<% } %> + <% if (hasSession) { %> +

Sesión Express (req.session)

+ + + <% for (const [k,v] of Object.entries(session)) { %> + + + + + <% } %> + +
<%= k %><%= typeof v === 'object' ? JSON.stringify(v) : String(v) %>
+ <% } %> +
-

Cookies (servidor)

+

Cookies (servidor: req.cookies)

<% if (hasCookies) { %> @@ -74,12 +93,16 @@
<% } else { %> -

No se recibieron cookies desde el servidor (req.cookies). ¿Estás usando cookie-parser o pasando cookies al render?

+

+ No se recibieron cookies del lado servidor (req.cookies). + Asegurate de usar cookie-parser y de pasar cookies al render: +
res.render('inicio_v2', { user: req.session.user, cookies: req.cookies, session: req.session }) +

<% } %>
-

Cookies (navegador)

+

Cookies (navegador: document.cookie)

@@ -88,6 +111,9 @@
NombreValor
Cargando…
+

+ Total cookies en navegador: 0 +

Raw document.cookie:

@@ -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 = 'sin cookies'; return; diff --git a/services/app/src/views/inicio.ejs.bak b/services/app/src/views/inicio.ejs.bak new file mode 100644 index 0000000..392e712 --- /dev/null +++ b/services/app/src/views/inicio.ejs.bak @@ -0,0 +1,130 @@ + + + + + + + + Inicio • SuiteCoffee + + + +
+
+ <% + const hasUser = typeof user !== 'undefined' && user; + const hasCookies = typeof cookies !== 'undefined' && cookies && Object.keys(cookies).length; + const displayName = + (hasUser && (user.name || user.displayName || user.email)) || + (hasCookies && (cookies.user_name || cookies.displayName || cookies.email)) || + 'usuario'; + %> +

Hola, <%= displayName %> 👋

+

Bienvenido a SuiteCoffee. Este es tu inicio.

+ + <% if (hasUser) { %> +

Sesión

+ + + <% for (const [k,v] of Object.entries(user)) { %> + + + + + <% } %> + +
<%= k %><%= typeof v === 'object' ? JSON.stringify(v) : String(v) %>
+ <% } %> + +
+
+

Cookies (servidor)

+ <% if (hasCookies) { %> + + + + + + <% for (const [name, value] of Object.entries(cookies)) { %> + + + + + <% } %> + +
NombreValor
<%= name %><%= typeof value === 'object' ? JSON.stringify(value) : String(value) %>
+ <% } else { %> +

No se recibieron cookies desde el servidor (req.cookies). ¿Estás usando cookie-parser o pasando cookies al render?

+ <% } %> +
+ +
+

Cookies (navegador)

+ + + + + + + +
NombreValor
Cargando…
+

Raw document.cookie:

+ +
+
+
+
+ + + + diff --git a/services/auth/.env.development b/services/auth/.env.development index 0130084..8775b30 100644 --- a/services/auth/.env.development +++ b/services/auth/.env.development @@ -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. \ No newline at end of file +OIDC_ENROLLMENT_URL=https://sso.suitecoffee.uy/if/flow/registro-suitecoffee/ \ No newline at end of file diff --git a/services/auth/package-lock.json b/services/auth/package-lock.json index bc0735f..0955b96 100644 --- a/services/auth/package-lock.json +++ b/services/auth/package-lock.json @@ -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", diff --git a/services/auth/package.json b/services/auth/package.json index a541c61..41dcffa 100644 --- a/services/auth/package.json +++ b/services/auth/package.json @@ -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", diff --git a/services/auth/src/db/initTenant_v2.sql b/services/auth/src/db/initTenant_v2.sql new file mode 100644 index 0000000..2081ece --- /dev/null +++ b/services/auth/src/db/initTenant_v2.sql @@ -0,0 +1,2267 @@ +-- +-- PostgreSQL database dump +-- + + +-- Dumped from database version 16.10 (Debian 16.10-1.pgdg13+1) +-- Dumped by pg_dump version 16.10 (Debian 16.10-1.pgdg13+1) + +BEGIN; +SET client_encoding = 'UTF8'; +SET standard_conforming_strings = on; +SET row_security = off; +SET statement_timeout = 0; +SET lock_timeout = 0; +SET idle_in_transaction_session_timeout = 0; +SET client_encoding = 'UTF8'; +SET standard_conforming_strings = on; +SELECT pg_catalog.set_config('search_path', '', false); +SET check_function_bodies = false; +SET xmloption = content; +SET client_min_messages = warning; +SET row_security = off; + + +CREATE FUNCTION asistencia_delete_raw(p_id_raw bigint, p_tz text DEFAULT 'America/Montevideo'::text) RETURNS jsonb + LANGUAGE plpgsql + AS $$ +DECLARE + v_id_usuario INT; + v_ts TIMESTAMPTZ; + v_t0 TIMESTAMPTZ; + v_t1 TIMESTAMPTZ; + v_del_raw INT; + v_del INT; + v_ins INT; +BEGIN + SELECT id_usuario, ts INTO v_id_usuario, v_ts + FROM asistencia_raw WHERE id_raw = p_id_raw; + IF v_id_usuario IS NULL THEN + RETURN jsonb_build_object('deleted',0,'msg','id_raw inexistente'); + END IF; + + v_t0 := v_ts - INTERVAL '1 day'; + v_t1 := v_ts + INTERVAL '1 day'; + + -- borrar raw + DELETE FROM asistencia_raw WHERE id_raw = p_id_raw; + GET DIAGNOSTICS v_del_raw = ROW_COUNT; + + -- recomputar pares en ventana + WITH tl AS ( + SELECT ar.ts, + ROW_NUMBER() OVER (ORDER BY ar.ts) AS rn + FROM asistencia_raw ar + WHERE ar.id_usuario = v_id_usuario + AND ar.ts BETWEEN v_t0 AND v_t1 + ), + ready AS ( + SELECT (t1.ts AT TIME ZONE p_tz)::date AS fecha, + t1.ts AS desde, + t2.ts AS hasta, + EXTRACT(EPOCH FROM (t2.ts - t1.ts))/60.0 AS dur_min + FROM tl t1 + JOIN tl t2 ON t2.rn = t1.rn + 1 + WHERE (t1.rn % 2) = 1 AND t2.ts > t1.ts + ), + del AS ( + DELETE FROM asistencia_intervalo ai + WHERE ai.id_usuario = v_id_usuario + AND (ai.desde BETWEEN v_t0 AND v_t1 OR ai.hasta BETWEEN v_t0 AND v_t1) + RETURNING 1 + ), + ins AS ( + INSERT INTO asistencia_intervalo (id_usuario, fecha, desde, hasta, dur_min, origen) + SELECT v_id_usuario, r.fecha, r.desde, r.hasta, r.dur_min, 'delete_adjust' + FROM ready r + ON CONFLICT (id_usuario, desde, hasta) DO NOTHING + RETURNING 1 + ) + SELECT (SELECT COUNT(*) FROM del), (SELECT COUNT(*) FROM ins) INTO v_del, v_ins; + + RETURN jsonb_build_object('deleted',v_del_raw,'deleted_pairs',v_del,'inserted_pairs',v_ins); +END; +$$; + + +-- +-- Name: asistencia_get(text, date, date, text); Type: FUNCTION; Schema: ; Owner: - +-- + +CREATE FUNCTION asistencia_get(p_doc text, p_desde date, p_hasta date, p_tz text DEFAULT 'America/Montevideo'::text) RETURNS jsonb + LANGUAGE sql + AS $$ +WITH u AS ( + SELECT id_usuario, documento, nombre, apellido + FROM usuarios + WHERE regexp_replace(documento,'^\s*0+','','g') = regexp_replace(p_doc,'^\s*0+','','g') + LIMIT 1 +), +r AS ( + SELECT ar.id_raw, + (ar.ts AT TIME ZONE p_tz)::date AS fecha, + to_char(ar.ts AT TIME ZONE p_tz,'HH24:MI:SS') AS hora, + COALESCE(ar.modo,'') AS modo, + COALESCE(ar.origen,'') AS origen, + ar.ts + FROM asistencia_raw ar + JOIN u USING (id_usuario) + WHERE (ar.ts AT TIME ZONE p_tz)::date BETWEEN p_desde AND p_hasta +), +i AS ( + SELECT ai.id_intervalo, + ai.fecha, + to_char(ai.desde AT TIME ZONE p_tz,'HH24:MI:SS') AS desde_hora, + to_char(ai.hasta AT TIME ZONE p_tz,'HH24:MI:SS') AS hasta_hora, + ai.dur_min + FROM asistencia_intervalo ai + JOIN u USING (id_usuario) + WHERE ai.fecha BETWEEN p_desde AND p_hasta +) +SELECT jsonb_build_object( + 'usuario', (SELECT to_jsonb(u.*) FROM u), + 'raw', COALESCE((SELECT jsonb_agg(to_jsonb(r.*) ORDER BY r.ts) FROM r),'[]'::jsonb), + 'intervalos', COALESCE((SELECT jsonb_agg(to_jsonb(i.*) ORDER BY i.fecha, i.id_intervalo) FROM i),'[]'::jsonb) +); +$$; + + +-- +-- Name: asistencia_update_raw(bigint, date, text, text, text); Type: FUNCTION; Schema: ; Owner: - +-- + +CREATE FUNCTION asistencia_update_raw(p_id_raw bigint, p_fecha date, p_hora text, p_modo text, p_tz text DEFAULT 'America/Montevideo'::text) RETURNS jsonb + LANGUAGE plpgsql + AS $$ +DECLARE + v_id_usuario INT; + v_ts_old TIMESTAMPTZ; + v_ts_new TIMESTAMPTZ; + v_t0 TIMESTAMPTZ; + v_t1 TIMESTAMPTZ; + v_del INT; + v_ins INT; +BEGIN + -- leer estado previo + SELECT id_usuario, ts INTO v_id_usuario, v_ts_old + FROM asistencia_raw WHERE id_raw = p_id_raw; + IF v_id_usuario IS NULL THEN + RETURN jsonb_build_object('updated',0,'msg','id_raw inexistente'); + END IF; + + -- construir ts nuevo + v_ts_new := make_timestamptz( + EXTRACT(YEAR FROM p_fecha)::INT, + EXTRACT(MONTH FROM p_fecha)::INT, + EXTRACT(DAY FROM p_fecha)::INT, + split_part(p_hora,':',1)::INT, + split_part(p_hora,':',2)::INT, + COALESCE(NULLIF(split_part(p_hora,':',3),''), '0')::INT, + p_tz); + + -- aplicar update + UPDATE asistencia_raw + SET ts = v_ts_new, + modo = COALESCE(p_modo, modo) + WHERE id_raw = p_id_raw; + + -- ventana de recálculo + v_t0 := LEAST(v_ts_old, v_ts_new) - INTERVAL '1 day'; + v_t1 := GREATEST(v_ts_old, v_ts_new) + INTERVAL '1 day'; + + -- recomputar pares en la ventana: borrar los del rango y reinsertar + WITH tl AS ( + SELECT ar.ts, + ROW_NUMBER() OVER (ORDER BY ar.ts) AS rn + FROM asistencia_raw ar + WHERE ar.id_usuario = v_id_usuario + AND ar.ts BETWEEN v_t0 AND v_t1 + ), + ready AS ( + SELECT (t1.ts AT TIME ZONE p_tz)::date AS fecha, + t1.ts AS desde, + t2.ts AS hasta, + EXTRACT(EPOCH FROM (t2.ts - t1.ts))/60.0 AS dur_min + FROM tl t1 + JOIN tl t2 ON t2.rn = t1.rn + 1 + WHERE (t1.rn % 2) = 1 AND t2.ts > t1.ts + ), + del AS ( + DELETE FROM asistencia_intervalo ai + WHERE ai.id_usuario = v_id_usuario + AND (ai.desde BETWEEN v_t0 AND v_t1 OR ai.hasta BETWEEN v_t0 AND v_t1) + RETURNING 1 + ), + ins AS ( + INSERT INTO asistencia_intervalo (id_usuario, fecha, desde, hasta, dur_min, origen) + SELECT v_id_usuario, r.fecha, r.desde, r.hasta, r.dur_min, 'edit_manual' + FROM ready r + ON CONFLICT (id_usuario, desde, hasta) DO NOTHING + RETURNING 1 + ) + SELECT (SELECT COUNT(*) FROM del), (SELECT COUNT(*) FROM ins) INTO v_del, v_ins; + + RETURN jsonb_build_object('updated',1,'deleted_pairs',v_del,'inserted_pairs',v_ins); +END; +$$; + + +-- +-- Name: delete_compra(integer); Type: FUNCTION; Schema: ; Owner: - +-- + +CREATE FUNCTION delete_compra(p_id_compra integer) RETURNS void + LANGUAGE plpgsql + AS $$ +BEGIN + DELETE FROM deta_comp_materias WHERE id_compra = p_id_compra; + DELETE FROM deta_comp_producto WHERE id_compra = p_id_compra; + DELETE FROM compras WHERE id_compra = p_id_compra; +END; +$$; + + +-- +-- Name: f_abrir_comanda(integer); Type: FUNCTION; Schema: ; Owner: - +-- + +CREATE FUNCTION f_abrir_comanda(p_id integer) RETURNS jsonb + LANGUAGE plpgsql + AS $$ +DECLARE r jsonb; +BEGIN + UPDATE comandas + SET estado = 'abierta', + fec_cierre = NULL + WHERE id_comanda = p_id; + + IF NOT FOUND THEN + RETURN NULL; + END IF; + + SELECT to_jsonb(v) INTO r + FROM v_comandas_resumen v + WHERE v.id_comanda = p_id; + + RETURN r; +END; +$$; + + +-- +-- Name: f_cerrar_comanda(integer); Type: FUNCTION; Schema: ; Owner: - +-- + +CREATE FUNCTION f_cerrar_comanda(p_id integer) RETURNS jsonb + LANGUAGE plpgsql + AS $$ +DECLARE r jsonb; +BEGIN + UPDATE comandas + SET estado = 'cerrada', + fec_cierre = COALESCE(fec_cierre, NOW()) + WHERE id_comanda = p_id; + + IF NOT FOUND THEN + RETURN NULL; + END IF; + + SELECT to_jsonb(v) INTO r + FROM v_comandas_resumen v + WHERE v.id_comanda = p_id; + + RETURN r; +END; +$$; + + +-- +-- Name: f_comanda_detalle_json(integer); Type: FUNCTION; Schema: ; Owner: - +-- + +CREATE FUNCTION f_comanda_detalle_json(p_id_comanda integer) RETURNS jsonb + LANGUAGE sql + AS $$ +WITH base AS ( + SELECT + c.id_comanda, + c.fec_creacion, + c.estado, + c.observaciones, + u.id_usuario, + u.nombre AS usuario_nombre, + u.apellido AS usuario_apellido, + m.id_mesa, + m.numero AS mesa_numero, + m.apodo AS mesa_apodo, + d.id_producto, + p.nombre AS producto_nombre, + d.cantidad, + d.pre_unitario, + (d.cantidad * d.pre_unitario) AS subtotal + FROM comandas c + JOIN usuarios u ON u.id_usuario = c.id_usuario + JOIN mesas m ON m.id_mesa = c.id_mesa + LEFT JOIN deta_comandas d ON d.id_comanda = c.id_comanda + LEFT JOIN productos p ON p.id_producto = d.id_producto + WHERE c.id_comanda = p_id_comanda +), +hdr AS ( + -- 1 sola fila con los datos de cabecera + SELECT DISTINCT + id_comanda, fec_creacion, estado, observaciones, + id_usuario, usuario_nombre, usuario_apellido, + id_mesa, mesa_numero, mesa_apodo + FROM base +), +agg_items AS ( + SELECT + COALESCE( + jsonb_agg( + jsonb_build_object( + 'producto_id', b.id_producto, + 'producto', b.producto_nombre, + 'cantidad', b.cantidad, + 'pre_unitario', b.pre_unitario, + 'subtotal', b.subtotal + ) + ORDER BY b.producto_nombre NULLS LAST + ) FILTER (WHERE b.id_producto IS NOT NULL), + '[]'::jsonb + ) AS items + FROM base b +), +tot AS ( + SELECT + COUNT(*) FILTER (WHERE id_producto IS NOT NULL) AS items, + COALESCE(SUM(subtotal), 0)::numeric AS total + FROM base +) +SELECT + CASE + WHEN EXISTS (SELECT 1 FROM hdr) THEN + jsonb_build_object( + 'id_comanda', h.id_comanda, + 'fec_creacion', h.fec_creacion, + 'estado', h.estado, + 'observaciones',h.observaciones, + 'usuario', jsonb_build_object( + 'id_usuario', h.id_usuario, + 'nombre', h.usuario_nombre, + 'apellido', h.usuario_apellido + ), + 'mesa', jsonb_build_object( + 'id_mesa', h.id_mesa, + 'numero', h.mesa_numero, + 'apodo', h.mesa_apodo + ), + 'items', i.items, + 'totales', jsonb_build_object( + 'items', t.items, + 'total', t.total + ) + ) + ELSE NULL + END +FROM hdr h, agg_items i, tot t; +$$; + + +-- +-- Name: f_comanda_detalle_rows(integer); Type: FUNCTION; Schema: ; Owner: - +-- + +CREATE FUNCTION f_comanda_detalle_rows(p_id_comanda integer) RETURNS TABLE(id_comanda integer, fec_creacion timestamp without time zone, estado text, observaciones text, id_usuario integer, usuario_nombre text, usuario_apellido text, id_mesa integer, mesa_numero integer, mesa_apodo text, producto_id integer, producto_nombre text, cantidad numeric, pre_unitario numeric, subtotal numeric, items integer, total numeric) + LANGUAGE sql + AS $$ +WITH base AS ( + SELECT + c.id_comanda, c.fec_creacion, c.estado, c.observaciones, + u.id_usuario, u.nombre AS usuario_nombre, u.apellido AS usuario_apellido, + m.id_mesa, m.numero AS mesa_numero, m.apodo AS mesa_apodo, + d.id_producto, p.nombre AS producto_nombre, + d.cantidad, d.pre_unitario, + (d.cantidad * d.pre_unitario) AS subtotal + FROM comandas c + JOIN usuarios u ON u.id_usuario = c.id_usuario + JOIN mesas m ON m.id_mesa = c.id_mesa + LEFT JOIN deta_comandas d ON d.id_comanda = c.id_comanda + LEFT JOIN productos p ON p.id_producto = d.id_producto + WHERE c.id_comanda = p_id_comanda +), +tot AS ( + SELECT + COUNT(*) FILTER (WHERE id_producto IS NOT NULL) AS items, + COALESCE(SUM(subtotal), 0) AS total + FROM base +) +SELECT + b.id_comanda, b.fec_creacion, b.estado, b.observaciones, + b.id_usuario, b.usuario_nombre, b.usuario_apellido, + b.id_mesa, b.mesa_numero, b.mesa_apodo, + b.id_producto, b.producto_nombre, + b.cantidad, b.pre_unitario, b.subtotal, + t.items, t.total +FROM base b CROSS JOIN tot t +ORDER BY b.producto_nombre NULLS LAST; +$$; + + +SET default_tablespace = ''; + +SET default_table_access_method = heap; + +-- +-- Name: comandas; Type: TABLE; Schema: ; Owner: - +-- + +CREATE TABLE comandas ( + id_comanda integer NOT NULL, + id_usuario integer NOT NULL, + id_mesa integer NOT NULL, + fec_creacion timestamp without time zone DEFAULT now() NOT NULL, + estado text NOT NULL, + observaciones text, + fec_cierre timestamp with time zone, + CONSTRAINT comandas_estado_check CHECK ((estado = ANY (ARRAY['abierta'::text, 'cerrada'::text, 'pagada'::text, 'anulada'::text]))) +); + + +-- +-- Name: COLUMN comandas.fec_cierre; Type: COMMENT; Schema: ; Owner: - +-- + +COMMENT ON COLUMN comandas.fec_cierre IS 'Fecha/hora de cierre de la comanda (NULL si está abierta)'; + + +-- +-- Name: deta_comandas; Type: TABLE; Schema: ; Owner: - +-- + +CREATE TABLE deta_comandas ( + id_det_comanda integer NOT NULL, + id_comanda integer NOT NULL, + id_producto integer NOT NULL, + cantidad numeric(12,3) NOT NULL, + pre_unitario numeric(12,2) NOT NULL, + observaciones text, + CONSTRAINT deta_comandas_cantidad_check CHECK ((cantidad > (0)::numeric)), + CONSTRAINT deta_comandas_pre_unitario_check CHECK ((pre_unitario >= (0)::numeric)) +); + + +-- +-- Name: mesas; Type: TABLE; Schema: ; Owner: - +-- + +CREATE TABLE mesas ( + id_mesa integer NOT NULL, + numero integer NOT NULL, + apodo text NOT NULL, + estado text DEFAULT 'libre'::text NOT NULL, + CONSTRAINT mesas_estado_check CHECK ((estado = ANY (ARRAY['libre'::text, 'ocupada'::text, 'reservada'::text]))) +); + + +-- +-- Name: usuarios; Type: TABLE; Schema: ; Owner: - +-- + +CREATE TABLE usuarios ( + id_usuario integer NOT NULL, + documento text, + img_perfil character varying(255) DEFAULT 'img_perfil.png'::character varying NOT NULL, + nombre text NOT NULL, + apellido text NOT NULL, + correo text, + telefono text, + fec_nacimiento date, + activo boolean DEFAULT true +); + + +-- +-- Name: v_comandas_resumen; Type: VIEW; Schema: ; Owner: - +-- + +CREATE VIEW v_comandas_resumen AS + WITH items AS ( + SELECT d.id_comanda, + count(*) AS items, + sum((d.cantidad * d.pre_unitario)) AS total + FROM deta_comandas d + GROUP BY d.id_comanda + ) + SELECT c.id_comanda, + c.fec_creacion, + c.estado, + c.observaciones, + u.id_usuario, + u.nombre AS usuario_nombre, + u.apellido AS usuario_apellido, + m.id_mesa, + m.numero AS mesa_numero, + m.apodo AS mesa_apodo, + COALESCE(i.items, (0)::bigint) AS items, + COALESCE(i.total, (0)::numeric) AS total, + c.fec_cierre, + CASE + WHEN (c.fec_cierre IS NOT NULL) THEN round((EXTRACT(epoch FROM (c.fec_cierre - (c.fec_creacion)::timestamp with time zone)) / 60.0), 1) + ELSE NULL::numeric + END AS duracion_min + FROM (((comandas c + JOIN usuarios u ON ((u.id_usuario = c.id_usuario))) + JOIN mesas m ON ((m.id_mesa = c.id_mesa))) + LEFT JOIN items i ON ((i.id_comanda = c.id_comanda))); + + +-- +-- Name: f_comandas_resumen(text, integer); Type: FUNCTION; Schema: ; Owner: - +-- + +CREATE FUNCTION f_comandas_resumen(p_estado text DEFAULT NULL::text, p_limit integer DEFAULT 200) RETURNS SETOF v_comandas_resumen + LANGUAGE sql + AS $$ + SELECT * + FROM v_comandas_resumen + WHERE (p_estado IS NULL OR estado = p_estado) + ORDER BY id_comanda DESC + LIMIT p_limit; +$$; + + +-- +-- Name: find_usuarios_por_documentos(jsonb); Type: FUNCTION; Schema: ; Owner: - +-- + +CREATE FUNCTION find_usuarios_por_documentos(p_docs jsonb) RETURNS jsonb + LANGUAGE sql + AS $$ +WITH docs AS ( + SELECT DISTINCT + regexp_replace(value::text, '^\s*0+', '', 'g') AS doc_clean, + value::text AS original + FROM jsonb_array_elements_text(COALESCE(p_docs,'[]')) +), +rows AS ( + SELECT d.original AS documento, + u.nombre, + u.apellido, + (u.id_usuario IS NOT NULL) AS found + FROM docs d + LEFT JOIN usuarios u + ON regexp_replace(u.documento, '^\s*0+', '', 'g') = d.doc_clean +) +SELECT COALESCE( + jsonb_object_agg( + documento, + jsonb_build_object( + 'nombre', COALESCE(nombre, ''), + 'apellido', COALESCE(apellido, ''), + 'found', found + ) + ), + '{}'::jsonb +) +FROM rows; +$$; + + +-- +-- Name: get_compra(integer); Type: FUNCTION; Schema: ; Owner: - +-- + +CREATE FUNCTION get_compra(p_id_compra integer) RETURNS jsonb + LANGUAGE sql + AS $$ +WITH cab AS ( + SELECT c.id_compra, c.id_proveedor, c.fec_compra, c.total + FROM compras c + WHERE c.id_compra = p_id_compra +), +dm AS ( + SELECT 'MAT'::text AS tipo, d.id_mat_prima AS id, + d.cantidad, d.pre_unitario AS precio + FROM deta_comp_materias d WHERE d.id_compra = p_id_compra +), +dp AS ( + SELECT 'PROD'::text AS tipo, d.id_producto AS id, + d.cantidad, d.pre_unitario AS precio + FROM deta_comp_producto d WHERE d.id_compra = p_id_compra +), +det AS ( + SELECT jsonb_agg(to_jsonb(x.*)) AS detalles + FROM ( + SELECT * FROM dm + UNION ALL + SELECT * FROM dp + ) x +) +SELECT jsonb_build_object( + 'id_compra', (SELECT id_compra FROM cab), + 'id_proveedor',(SELECT id_proveedor FROM cab), + 'fec_compra', to_char((SELECT fec_compra FROM cab),'YYYY-MM-DD HH24:MI:SS'), + 'total', (SELECT total FROM cab), + 'detalles', COALESCE((SELECT detalles FROM det),'[]'::jsonb) +); +$$; + + +-- +-- Name: get_materia_prima(integer); Type: FUNCTION; Schema: ; Owner: - +-- + +CREATE FUNCTION get_materia_prima(p_id integer) RETURNS jsonb + LANGUAGE sql + AS $$ +SELECT jsonb_build_object( + 'materia', to_jsonb(mp), + 'proveedores', COALESCE( + ( + SELECT jsonb_agg( + jsonb_build_object( + 'id_proveedor', pr.id_proveedor, + 'raz_social', pr.raz_social, + 'rut', pr.rut, + 'contacto', pr.contacto, + 'direccion', pr.direccion + ) + ) + FROM prov_mate_prima pmp + JOIN proveedores pr ON pr.id_proveedor = pmp.id_proveedor + WHERE pmp.id_mat_prima = mp.id_mat_prima + ), + '[]'::jsonb + ) +) +FROM mate_primas mp +WHERE mp.id_mat_prima = p_id; +$$; + + +-- +-- Name: get_producto(integer); Type: FUNCTION; Schema: ; Owner: - +-- + +CREATE FUNCTION get_producto(p_id integer) RETURNS jsonb + LANGUAGE sql + AS $$ +SELECT jsonb_build_object( + 'producto', to_jsonb(p), -- el registro completo del producto en JSONB + 'receta', COALESCE( + ( + SELECT jsonb_agg( + jsonb_build_object( + 'id_mat_prima', rp.id_mat_prima, + 'qty_por_unidad', rp.qty_por_unidad, + 'nombre', mp.nombre, + 'unidad', mp.unidad + ) + ) + FROM receta_producto rp + LEFT JOIN mate_primas mp USING (id_mat_prima) + WHERE rp.id_producto = p.id_producto + ), + '[]'::jsonb + ) +) +FROM productos p +WHERE p.id_producto = p_id; +$$; + + +-- +-- Name: import_asistencia(jsonb, text, text); Type: FUNCTION; Schema: ; Owner: - +-- + +CREATE FUNCTION import_asistencia(p_registros jsonb, p_origen text, p_tz text DEFAULT 'America/Montevideo'::text) RETURNS jsonb + LANGUAGE plpgsql + AS $_$ +DECLARE + v_ins_raw INT; + v_ins_pairs INT; + v_miss JSONB; +BEGIN + WITH + -- 1) JSON -> filas + j AS ( + SELECT + regexp_replace(elem->>'doc','^\s*0+','','g')::TEXT AS doc_clean, + (elem->>'isoDate')::DATE AS d, + elem->>'time' AS time_str, + NULLIF(elem->>'mode','') AS modo + FROM jsonb_array_elements(COALESCE(p_registros,'[]')) elem + ), + -- 2) Vincular a usuarios + u AS ( + SELECT j.*, u.id_usuario + FROM j + LEFT JOIN usuarios u + ON regexp_replace(u.documento,'^\s*0+','','g') = j.doc_clean + ), + -- 3) Documentos faltantes + miss AS ( + SELECT jsonb_agg(doc_clean) AS missing + FROM u WHERE id_usuario IS NULL + ), + -- 4) TS determinista en TZ del negocio + parsed AS ( + SELECT + u.id_usuario, + u.modo, + make_timestamptz( + EXTRACT(YEAR FROM u.d)::INT, + EXTRACT(MONTH FROM u.d)::INT, + EXTRACT(DAY FROM u.d)::INT, + split_part(u.time_str,':',1)::INT, + split_part(u.time_str,':',2)::INT, + COALESCE(NULLIF(split_part(u.time_str,':',3),''),'0')::INT, + p_tz + ) AS ts_calc + FROM u + WHERE u.id_usuario IS NOT NULL + ), + -- 5) Ventana por usuario (±1 día de lo importado) + win AS ( + SELECT id_usuario, + (MIN(ts_calc) - INTERVAL '1 day') AS t0, + (MAX(ts_calc) + INTERVAL '1 day') AS t1 + FROM parsed + GROUP BY id_usuario + ), + -- 6) Lo existente en BD dentro de la ventana + existing AS ( + SELECT ar.id_usuario, ar.ts + FROM asistencia_raw ar + JOIN win w ON w.id_usuario = ar.id_usuario + AND ar.ts BETWEEN w.t0 AND w.t1 + ), + -- 7) CANDIDATE = existente ∪ archivo (sin duplicados) + candidate AS ( + SELECT id_usuario, ts FROM existing + UNION -- ¡clave para evitar doble click! + SELECT id_usuario, ts_calc AS ts FROM parsed + ), + -- 8) Paridad previa (cuántas marcas había ANTES de la ventana) + before_cnt AS ( + SELECT w.id_usuario, COUNT(*)::int AS cnt + FROM win w + JOIN asistencia_raw ar + ON ar.id_usuario = w.id_usuario + AND ar.ts < w.t0 + GROUP BY w.id_usuario + ), + -- 9) Línea de tiempo candidata y pares (1→2, 3→4…), jornada = día local del inicio + timeline AS ( + SELECT + c.id_usuario, + c.ts, + ROW_NUMBER() OVER (PARTITION BY c.id_usuario ORDER BY c.ts) AS rn + FROM candidate c + ), + ready AS ( + SELECT + t1.id_usuario, + (t1.ts AT TIME ZONE p_tz)::date AS fecha, + t1.ts AS desde, + t2.ts AS hasta, + EXTRACT(EPOCH FROM (t2.ts - t1.ts))/60.0 AS dur_min + FROM timeline t1 + JOIN timeline t2 + ON t2.id_usuario = t1.id_usuario + AND t2.rn = t1.rn + 1 + LEFT JOIN before_cnt b ON b.id_usuario = t1.id_usuario + WHERE ((COALESCE(b.cnt,0) + t1.rn) % 2) = 1 -- t1 es IN global + AND t2.ts > t1.ts + ), + -- 10) INSERT crudo (dedupe) + ins_raw AS ( + INSERT INTO asistencia_raw (id_usuario, ts, modo, origen) + SELECT id_usuario, ts_calc, + NULLIF(modo,'')::text, -- puede quedar NULL para auto-etiquetado + p_origen + FROM parsed + ON CONFLICT (id_usuario, ts) DO NOTHING + RETURNING 1 + ), + -- 11) Auto-etiquetar IN/OUT en BD para filas con modo vacío/'1' (tras insertar) + before_cnt2 AS ( + SELECT w.id_usuario, COUNT(*)::int AS cnt + FROM win w + JOIN asistencia_raw ar + ON ar.id_usuario = w.id_usuario + AND ar.ts < w.t0 + GROUP BY w.id_usuario + ), + tl2 AS ( + SELECT + ar.id_usuario, ar.ts, + ROW_NUMBER() OVER (PARTITION BY ar.id_usuario ORDER BY ar.ts) AS rn + FROM asistencia_raw ar + JOIN win w ON w.id_usuario = ar.id_usuario + AND ar.ts BETWEEN w.t0 AND w.t1 + ), + label2 AS ( + SELECT + t.id_usuario, + t.ts, + CASE WHEN ((COALESCE(b.cnt,0) + t.rn) % 2) = 1 THEN 'IN' ELSE 'OUT' END AS new_mode + FROM tl2 t + LEFT JOIN before_cnt2 b ON b.id_usuario = t.id_usuario + ), + set_mode AS ( + UPDATE asistencia_raw ar + SET modo = l.new_mode + FROM label2 l + WHERE ar.id_usuario = l.id_usuario + AND ar.ts = l.ts + AND (ar.modo IS NULL OR btrim(ar.modo) = '' OR ar.modo ~ '^\s*1\s*$') + RETURNING 1 + ), + -- 12) INSERT pares (dedupe) calculados desde CANDIDATE (ya tiene todo el contexto) + ins_pairs AS ( + INSERT INTO asistencia_intervalo (id_usuario, fecha, desde, hasta, dur_min, origen) + SELECT id_usuario, fecha, desde, hasta, dur_min, p_origen + FROM ready + ON CONFLICT (id_usuario, desde, hasta) DO NOTHING + RETURNING 1 + ) + SELECT + (SELECT COUNT(*) FROM ins_raw), + (SELECT COUNT(*) FROM ins_pairs), + (SELECT COALESCE(missing,'[]'::jsonb) FROM miss) + INTO v_ins_raw, v_ins_pairs, v_miss; + + RETURN jsonb_build_object( + 'inserted_raw', v_ins_raw, + 'inserted_pairs', v_ins_pairs, + 'missing_docs', v_miss + ); +END; +$_$; + + +-- +-- Name: report_asistencia(date, date); Type: FUNCTION; Schema: ; Owner: - +-- + +CREATE FUNCTION report_asistencia(p_desde date, p_hasta date) RETURNS TABLE(documento text, nombre text, apellido text, fecha date, desde_hora text, hasta_hora text, dur_min numeric) + LANGUAGE sql + AS $$ + SELECT + u.documento, u.nombre, u.apellido, + ai.fecha, + to_char(ai.desde AT TIME ZONE 'America/Montevideo','HH24:MI:SS') AS desde_hora, + to_char(ai.hasta AT TIME ZONE 'America/Montevideo','HH24:MI:SS') AS hasta_hora, + ai.dur_min + FROM asistencia_intervalo ai + JOIN usuarios u USING (id_usuario) + WHERE ai.fecha BETWEEN p_desde AND p_hasta + ORDER BY u.documento, ai.fecha, ai.desde; +$$; + + +-- +-- Name: report_gastos(integer); Type: FUNCTION; Schema: ; Owner: - +-- + +CREATE FUNCTION report_gastos(p_year integer) RETURNS jsonb + LANGUAGE sql STABLE + AS $$ +WITH mdata AS ( + SELECT date_trunc('month', c.fec_compra)::date AS m, + SUM(c.total)::numeric AS importe + FROM compras c + WHERE EXTRACT(YEAR FROM c.fec_compra) = p_year + GROUP BY 1 +), +mm AS ( + SELECT EXTRACT(MONTH FROM m)::int AS mes, importe + FROM mdata +) +SELECT jsonb_build_object( + 'year', p_year, + 'total', COALESCE((SELECT SUM(importe) FROM mdata), 0), + 'avg', COALESCE((SELECT AVG(importe) FROM mdata), 0), + 'months', + (SELECT jsonb_agg( + jsonb_build_object( + 'mes', gs, + 'nombre', to_char(to_date(gs::text,'MM'),'Mon'), + 'importe', COALESCE(mm.importe,0) + ) + ORDER BY gs + ) + FROM generate_series(1,12) gs + LEFT JOIN mm ON mm.mes = gs) +); +$$; + + +-- +-- Name: report_tickets_year(integer, text); Type: FUNCTION; Schema: ; Owner: - +-- + +CREATE FUNCTION report_tickets_year(p_year integer, p_tz text DEFAULT 'America/Montevideo'::text) RETURNS jsonb + LANGUAGE sql STABLE + AS $$ +WITH bounds AS ( + SELECT + make_timestamp(p_year, 1, 1, 0,0,0) AS d0, + make_timestamp(p_year+1, 1, 1, 0,0,0) AS d1, + make_timestamptz(p_year, 1, 1, 0,0,0, p_tz) AS t0, + make_timestamptz(p_year+1, 1, 1, 0,0,0, p_tz) AS t1 +), +base AS ( + SELECT + c.id_comanda, + CASE WHEN c.fec_cierre IS NOT NULL + THEN (c.fec_cierre AT TIME ZONE p_tz) + ELSE c.fec_creacion + END AS fec_local, + v.total + FROM comandas c + JOIN vw_ticket_total v ON v.id_comanda = c.id_comanda + JOIN bounds b ON TRUE + WHERE + (c.fec_cierre IS NOT NULL AND c.fec_cierre >= b.t0 AND c.fec_cierre < b.t1) + OR + (c.fec_cierre IS NULL AND c.fec_creacion >= b.d0 AND c.fec_creacion < b.d1) +), +m AS ( + SELECT + EXTRACT(MONTH FROM fec_local)::int AS mes, + COUNT(*)::int AS cant, + SUM(total)::numeric AS importe, + AVG(total)::numeric AS avg + FROM base + GROUP BY 1 +), +ytd AS ( + SELECT COUNT(*)::int AS total_ytd, + AVG(total)::numeric AS avg_ticket, + SUM(total)::numeric AS to_date + FROM base +) +SELECT jsonb_build_object( + 'year', p_year, + 'total_ytd', (SELECT total_ytd FROM ytd), + 'avg_ticket', (SELECT avg_ticket FROM ytd), + 'to_date', (SELECT to_date FROM ytd), + 'months', + (SELECT jsonb_agg( + jsonb_build_object( + 'mes', mes, + 'nombre', to_char(to_date(mes::text,'MM'),'Mon'), + 'cant', cant, + 'importe', importe, + 'avg', avg + ) + ORDER BY mes + ) + FROM m) +); +$$; + + +-- +-- Name: save_compra(integer, integer, timestamp with time zone, jsonb); Type: FUNCTION; Schema: ; Owner: - +-- + +CREATE FUNCTION save_compra(p_id_compra integer, p_id_proveedor integer, p_fec_compra timestamp with time zone, p_detalles jsonb) RETURNS TABLE(id_compra integer, total numeric) + LANGUAGE plpgsql + AS $$ +DECLARE + v_id INT; + v_total numeric := 0; +BEGIN + IF COALESCE(jsonb_array_length(p_detalles),0) = 0 THEN + RAISE EXCEPTION 'No hay renglones en la compra'; + END IF; + + -- Cabecera (insert/update) + IF p_id_compra IS NULL THEN + INSERT INTO compras (id_proveedor, fec_compra, total) + VALUES (p_id_proveedor, COALESCE(p_fec_compra, now()), 0) + RETURNING compras.id_compra INTO v_id; + ELSE + UPDATE compras c + SET id_proveedor = p_id_proveedor, + fec_compra = COALESCE(p_fec_compra, c.fec_compra) + WHERE c.id_compra = p_id_compra + RETURNING c.id_compra INTO v_id; + + -- Reemplazamos los renglones + DELETE FROM deta_comp_materias d WHERE d.id_compra = v_id; + DELETE FROM deta_comp_producto p WHERE p.id_compra = v_id; + END IF; + + -- Materias primas (sin CTE: parseo JSON inline) + INSERT INTO deta_comp_materias (id_compra, id_mat_prima, cantidad, pre_unitario) + SELECT + v_id, + x.id, + x.cantidad, + x.precio + FROM jsonb_to_recordset(COALESCE(p_detalles, '[]'::jsonb)) + AS x(tipo text, id int, cantidad numeric, precio numeric) + WHERE UPPER(TRIM(x.tipo)) = 'MAT'; + + -- Productos (sin CTE) + INSERT INTO deta_comp_producto (id_compra, id_producto, cantidad, pre_unitario) + SELECT + v_id, + x.id, + x.cantidad, + x.precio + FROM jsonb_to_recordset(COALESCE(p_detalles, '[]'::jsonb)) + AS x(tipo text, id int, cantidad numeric, precio numeric) + WHERE UPPER(TRIM(x.tipo)) = 'PROD'; + + -- Recalcular total (calificado) y redondear a ENTERO + SELECT + COALESCE( (SELECT SUM(dcm.cantidad*dcm.pre_unitario) + FROM deta_comp_materias dcm + WHERE dcm.id_compra = v_id), 0) + + COALESCE( (SELECT SUM(dcp.cantidad*dcp.pre_unitario) + FROM deta_comp_producto dcp + WHERE dcp.id_compra = v_id), 0) + INTO v_total; + + UPDATE compras c + SET total = round(v_total, 0) + WHERE c.id_compra = v_id; + + RETURN QUERY SELECT v_id, round(v_total, 0); +END; +$$; + + +-- +-- Name: save_materia_prima(integer, text, text, boolean, jsonb); Type: FUNCTION; Schema: ; Owner: - +-- + +CREATE FUNCTION save_materia_prima(p_id_mat_prima integer, p_nombre text, p_unidad text, p_activo boolean, p_proveedores jsonb DEFAULT '[]'::jsonb) RETURNS integer + LANGUAGE plpgsql + AS $_$ +DECLARE + v_id INT; +BEGIN + IF p_id_mat_prima IS NULL THEN + INSERT INTO mate_primas (nombre, unidad, activo) + VALUES (p_nombre, p_unidad, COALESCE(p_activo, TRUE)) + RETURNING mate_primas.id_mat_prima INTO v_id; + ELSE + UPDATE mate_primas mp + SET nombre = p_nombre, + unidad = p_unidad, + activo = COALESCE(p_activo, TRUE) + WHERE mp.id_mat_prima = p_id_mat_prima; + v_id := p_id_mat_prima; + END IF; + + -- Sincronizar proveedores: borrar todos y re-crear a partir de JSONB + DELETE FROM prov_mate_prima pmp WHERE pmp.id_mat_prima = v_id; + + INSERT INTO prov_mate_prima (id_proveedor, id_mat_prima) + SELECT (e->>0)::INT AS id_proveedor, -- elementos JSON como enteros (array simple) + v_id AS id_mat_prima + FROM jsonb_array_elements(COALESCE(p_proveedores, '[]'::jsonb)) AS e + WHERE (e->>0) ~ '^\d+$'; -- solo enteros + + RETURN v_id; +END; +$_$; + + +-- +-- Name: save_producto(integer, text, text, numeric, boolean, integer, jsonb); Type: FUNCTION; Schema: ; Owner: - +-- + +CREATE FUNCTION save_producto(p_id_producto integer, p_nombre text, p_img_producto text, p_precio numeric, p_activo boolean, p_id_categoria integer, p_receta jsonb DEFAULT '[]'::jsonb) RETURNS integer + LANGUAGE plpgsql + AS $_$ +DECLARE + v_id INT; +BEGIN + IF p_id_producto IS NULL THEN + INSERT INTO productos (nombre, img_producto, precio, activo, id_categoria) + VALUES (p_nombre, p_img_producto, p_precio, COALESCE(p_activo, TRUE), p_id_categoria) + RETURNING productos.id_producto INTO v_id; + ELSE + UPDATE productos p + SET nombre = p_nombre, + img_producto = p_img_producto, + precio = p_precio, + activo = COALESCE(p_activo, TRUE), + id_categoria = p_id_categoria + WHERE p.id_producto = p_id_producto; + v_id := p_id_producto; + END IF; + + -- Limpia receta actual + DELETE FROM receta_producto rp WHERE rp.id_producto = v_id; + + -- Inserta SOLO ítems válidos (id entero positivo y cantidad > 0), redondeo a 3 decimales + INSERT INTO receta_producto (id_producto, id_mat_prima, qty_por_unidad) + SELECT + v_id, + (rec->>'id_mat_prima')::INT, + ROUND((rec->>'qty_por_unidad')::NUMERIC, 3) + FROM jsonb_array_elements(COALESCE(p_receta, '[]'::jsonb)) AS rec + WHERE + (rec->>'id_mat_prima') ~ '^\d+$' + AND (rec->>'id_mat_prima')::INT > 0 + AND (rec->>'qty_por_unidad') ~ '^\d+(\.\d+)?$' + AND (rec->>'qty_por_unidad')::NUMERIC > 0; + + RETURN v_id; +END; +$_$; + + +-- +-- Name: asistencia_intervalo; Type: TABLE; Schema: ; Owner: - +-- + +CREATE TABLE asistencia_intervalo ( + id_intervalo bigint NOT NULL, + id_usuario integer NOT NULL, + fecha date NOT NULL, + desde timestamp with time zone NOT NULL, + hasta timestamp with time zone NOT NULL, + dur_min numeric(10,2) NOT NULL, + origen text, + created_at timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT chk_ai_orden CHECK ((hasta > desde)) +); + + +-- +-- Name: asistencia_intervalo_id_intervalo_seq; Type: SEQUENCE; Schema: ; Owner: - +-- + +CREATE SEQUENCE asistencia_intervalo_id_intervalo_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: asistencia_intervalo_id_intervalo_seq; Type: SEQUENCE OWNED BY; Schema: ; Owner: - +-- + +ALTER SEQUENCE asistencia_intervalo_id_intervalo_seq OWNED BY asistencia_intervalo.id_intervalo; + + +-- +-- Name: asistencia_raw; Type: TABLE; Schema: ; Owner: - +-- + +CREATE TABLE asistencia_raw ( + id_raw bigint NOT NULL, + id_usuario integer NOT NULL, + ts timestamp with time zone NOT NULL, + modo text, + origen text, + created_at timestamp with time zone DEFAULT now() NOT NULL +); + + +-- +-- Name: asistencia_raw_id_raw_seq; Type: SEQUENCE; Schema: ; Owner: - +-- + +CREATE SEQUENCE asistencia_raw_id_raw_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: asistencia_raw_id_raw_seq; Type: SEQUENCE OWNED BY; Schema: ; Owner: - +-- + +ALTER SEQUENCE asistencia_raw_id_raw_seq OWNED BY asistencia_raw.id_raw; + + +-- +-- Name: asistencia_resumen_diario; Type: VIEW; Schema: ; Owner: - +-- + +CREATE VIEW asistencia_resumen_diario AS + SELECT ai.id_usuario, + u.documento, + u.nombre, + u.apellido, + ai.fecha, + sum(ai.dur_min) AS minutos_dia, + round((sum(ai.dur_min) / 60.0), 2) AS horas_dia, + count(*) AS pares_dia + FROM (asistencia_intervalo ai + JOIN usuarios u USING (id_usuario)) + GROUP BY ai.id_usuario, u.documento, u.nombre, u.apellido, ai.fecha + ORDER BY ai.id_usuario, ai.fecha; + + +-- +-- Name: categorias; Type: TABLE; Schema: ; Owner: - +-- + +CREATE TABLE categorias ( + id_categoria integer NOT NULL, + nombre text NOT NULL, + visible boolean DEFAULT true +); + + +-- +-- Name: categorias_id_categoria_seq; Type: SEQUENCE; Schema: ; Owner: - +-- + +CREATE SEQUENCE categorias_id_categoria_seq + AS integer + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: categorias_id_categoria_seq; Type: SEQUENCE OWNED BY; Schema: ; Owner: - +-- + +ALTER SEQUENCE categorias_id_categoria_seq OWNED BY categorias.id_categoria; + + +-- +-- Name: clientes; Type: TABLE; Schema: ; Owner: - +-- + +CREATE TABLE clientes ( + id_cliente integer NOT NULL, + nombre text NOT NULL, + correo text, + telefono text, + fec_nacimiento date, + activo boolean DEFAULT true +); + + +-- +-- Name: clientes_id_cliente_seq; Type: SEQUENCE; Schema: ; Owner: - +-- + +CREATE SEQUENCE clientes_id_cliente_seq + AS integer + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: clientes_id_cliente_seq; Type: SEQUENCE OWNED BY; Schema: ; Owner: - +-- + +ALTER SEQUENCE clientes_id_cliente_seq OWNED BY clientes.id_cliente; + + +-- +-- Name: comandas_id_comanda_seq; Type: SEQUENCE; Schema: ; Owner: - +-- + +CREATE SEQUENCE comandas_id_comanda_seq + AS integer + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: comandas_id_comanda_seq; Type: SEQUENCE OWNED BY; Schema: ; Owner: - +-- + +ALTER SEQUENCE comandas_id_comanda_seq OWNED BY comandas.id_comanda; + + +-- +-- Name: compras; Type: TABLE; Schema: ; Owner: - +-- + +CREATE TABLE compras ( + id_compra integer NOT NULL, + id_proveedor integer NOT NULL, + fec_compra timestamp without time zone NOT NULL, + total numeric(14,2) +); + + +-- +-- Name: compras_id_compra_seq; Type: SEQUENCE; Schema: ; Owner: - +-- + +CREATE SEQUENCE compras_id_compra_seq + AS integer + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: compras_id_compra_seq; Type: SEQUENCE OWNED BY; Schema: ; Owner: - +-- + +ALTER SEQUENCE compras_id_compra_seq OWNED BY compras.id_compra; + + +-- +-- Name: deta_comandas_id_det_comanda_seq; Type: SEQUENCE; Schema: ; Owner: - +-- + +CREATE SEQUENCE deta_comandas_id_det_comanda_seq + AS integer + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: deta_comandas_id_det_comanda_seq; Type: SEQUENCE OWNED BY; Schema: ; Owner: - +-- + +ALTER SEQUENCE deta_comandas_id_det_comanda_seq OWNED BY deta_comandas.id_det_comanda; + + +-- +-- Name: deta_comp_materias; Type: TABLE; Schema: ; Owner: - +-- + +CREATE TABLE deta_comp_materias ( + id_compra integer NOT NULL, + id_mat_prima integer NOT NULL, + cantidad numeric(12,3) NOT NULL, + pre_unitario numeric(12,2) NOT NULL, + CONSTRAINT deta_comp_materias_cantidad_check CHECK ((cantidad > (0)::numeric)), + CONSTRAINT deta_comp_materias_pre_unitario_check CHECK ((pre_unitario >= (0)::numeric)) +); + + +-- +-- Name: deta_comp_producto; Type: TABLE; Schema: ; Owner: - +-- + +CREATE TABLE deta_comp_producto ( + id_compra integer NOT NULL, + id_producto integer NOT NULL, + cantidad numeric(12,3) NOT NULL, + pre_unitario numeric(12,2) NOT NULL, + CONSTRAINT deta_comp_producto_cantidad_check CHECK ((cantidad > (0)::numeric)), + CONSTRAINT deta_comp_producto_pre_unitario_check CHECK ((pre_unitario >= (0)::numeric)) +); + + +-- +-- Name: mate_primas; Type: TABLE; Schema: ; Owner: - +-- + +CREATE TABLE mate_primas ( + id_mat_prima integer NOT NULL, + nombre text NOT NULL, + unidad text NOT NULL, + activo boolean DEFAULT true +); + + +-- +-- Name: mate_primas_id_mat_prima_seq; Type: SEQUENCE; Schema: ; Owner: - +-- + +CREATE SEQUENCE mate_primas_id_mat_prima_seq + AS integer + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: mate_primas_id_mat_prima_seq; Type: SEQUENCE OWNED BY; Schema: ; Owner: - +-- + +ALTER SEQUENCE mate_primas_id_mat_prima_seq OWNED BY mate_primas.id_mat_prima; + + +-- +-- Name: mesas_id_mesa_seq; Type: SEQUENCE; Schema: ; Owner: - +-- + +CREATE SEQUENCE mesas_id_mesa_seq + AS integer + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: mesas_id_mesa_seq; Type: SEQUENCE OWNED BY; Schema: ; Owner: - +-- + +ALTER SEQUENCE mesas_id_mesa_seq OWNED BY mesas.id_mesa; + + +-- +-- Name: productos; Type: TABLE; Schema: ; Owner: - +-- + +CREATE TABLE productos ( + id_producto integer NOT NULL, + nombre text NOT NULL, + img_producto character varying(255) DEFAULT 'img/productos/img_producto.png'::character varying NOT NULL, + precio integer NOT NULL, + activo boolean DEFAULT true, + id_categoria integer NOT NULL, + CONSTRAINT productos_precio_check CHECK (((precio)::numeric >= (0)::numeric)), + CONSTRAINT productos_precio_nn CHECK ((precio >= 0)) +); + + +-- +-- Name: productos_id_producto_seq; Type: SEQUENCE; Schema: ; Owner: - +-- + +CREATE SEQUENCE productos_id_producto_seq + AS integer + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: productos_id_producto_seq; Type: SEQUENCE OWNED BY; Schema: ; Owner: - +-- + +ALTER SEQUENCE productos_id_producto_seq OWNED BY productos.id_producto; + + +-- +-- Name: prov_mate_prima; Type: TABLE; Schema: ; Owner: - +-- + +CREATE TABLE prov_mate_prima ( + id_proveedor integer NOT NULL, + id_mat_prima integer NOT NULL +); + + +-- +-- Name: prov_producto; Type: TABLE; Schema: ; Owner: - +-- + +CREATE TABLE prov_producto ( + id_proveedor integer NOT NULL, + id_producto integer NOT NULL +); + + +-- +-- Name: proveedores; Type: TABLE; Schema: ; Owner: - +-- + +CREATE TABLE proveedores ( + id_proveedor integer NOT NULL, + rut text, + raz_social text NOT NULL, + direccion text, + contacto text +); + + +-- +-- Name: proveedores_id_proveedor_seq; Type: SEQUENCE; Schema: ; Owner: - +-- + +CREATE SEQUENCE proveedores_id_proveedor_seq + AS integer + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: proveedores_id_proveedor_seq; Type: SEQUENCE OWNED BY; Schema: ; Owner: - +-- + +ALTER SEQUENCE proveedores_id_proveedor_seq OWNED BY proveedores.id_proveedor; + + +-- +-- Name: receta_producto; Type: TABLE; Schema: ; Owner: - +-- + +CREATE TABLE receta_producto ( + id_producto integer NOT NULL, + id_mat_prima integer NOT NULL, + qty_por_unidad numeric(12,3) NOT NULL, + CONSTRAINT receta_producto_qty_por_unidad_check CHECK ((qty_por_unidad > (0)::numeric)) +); + + +-- +-- Name: roles; Type: TABLE; Schema: ; Owner: - +-- + +CREATE TABLE roles ( + id_rol integer NOT NULL, + nombre text NOT NULL +); + + +-- +-- Name: roles_id_rol_seq; Type: SEQUENCE; Schema: ; Owner: - +-- + +CREATE SEQUENCE roles_id_rol_seq + AS integer + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: roles_id_rol_seq; Type: SEQUENCE OWNED BY; Schema: ; Owner: - +-- + +ALTER SEQUENCE roles_id_rol_seq OWNED BY roles.id_rol; + + +-- +-- Name: usua_roles; Type: TABLE; Schema: ; Owner: - +-- + +CREATE TABLE usua_roles ( + id_usuario integer NOT NULL, + id_rol integer NOT NULL, + fec_asignacion timestamp without time zone DEFAULT now(), + autor integer, + activo boolean DEFAULT true +); + + +-- +-- Name: usuarios_id_usuario_seq; Type: SEQUENCE; Schema: ; Owner: - +-- + +CREATE SEQUENCE usuarios_id_usuario_seq + AS integer + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: usuarios_id_usuario_seq; Type: SEQUENCE OWNED BY; Schema: ; Owner: - +-- + +ALTER SEQUENCE usuarios_id_usuario_seq OWNED BY usuarios.id_usuario; + + +-- +-- Name: v_comandas_detalle_base; Type: VIEW; Schema: ; Owner: - +-- + +CREATE VIEW v_comandas_detalle_base AS + SELECT c.id_comanda, + c.fec_creacion, + c.fec_cierre, + c.estado, + c.observaciones, + u.id_usuario, + u.nombre AS usuario_nombre, + u.apellido AS usuario_apellido, + m.id_mesa, + m.numero AS mesa_numero, + m.apodo AS mesa_apodo, + d.id_producto, + p.nombre AS producto_nombre, + d.cantidad, + d.pre_unitario, + (d.cantidad * d.pre_unitario) AS subtotal + FROM ((((comandas c + JOIN usuarios u ON ((u.id_usuario = c.id_usuario))) + JOIN mesas m ON ((m.id_mesa = c.id_mesa))) + LEFT JOIN deta_comandas d ON ((d.id_comanda = c.id_comanda))) + LEFT JOIN productos p ON ((p.id_producto = d.id_producto))); + + +-- +-- Name: v_comandas_detalle_items; Type: VIEW; Schema: ; Owner: - +-- + +CREATE VIEW v_comandas_detalle_items AS + SELECT d.id_comanda, + d.id_det_comanda, + d.id_producto, + p.nombre AS producto_nombre, + d.cantidad, + d.pre_unitario, + (d.cantidad * d.pre_unitario) AS subtotal, + d.observaciones + FROM (deta_comandas d + JOIN productos p ON ((p.id_producto = d.id_producto))); + + +-- +-- Name: v_comandas_detalle_json; Type: VIEW; Schema: ; Owner: - +-- + +CREATE VIEW v_comandas_detalle_json AS + SELECT id_comanda, + jsonb_build_object('id_comanda', id_comanda, 'fec_creacion', fec_creacion, 'fec_cierre', fec_cierre, 'estado', estado, 'observaciones', observaciones, 'usuario', jsonb_build_object('id_usuario', id_usuario, 'nombre', usuario_nombre, 'apellido', usuario_apellido), 'mesa', jsonb_build_object('id_mesa', id_mesa, 'numero', mesa_numero, 'apodo', mesa_apodo), 'items', COALESCE(( SELECT jsonb_agg(jsonb_build_object('producto_id', b.id_producto, 'producto', b.producto_nombre, 'cantidad', b.cantidad, 'pre_unitario', b.pre_unitario, 'subtotal', b.subtotal) ORDER BY b.producto_nombre) AS jsonb_agg + FROM v_comandas_detalle_base b + WHERE ((b.id_comanda = h.id_comanda) AND (b.id_producto IS NOT NULL))), '[]'::jsonb), 'totales', jsonb_build_object('items', COALESCE(( SELECT count(*) AS count + FROM v_comandas_detalle_base b + WHERE ((b.id_comanda = h.id_comanda) AND (b.id_producto IS NOT NULL))), (0)::bigint), 'total', COALESCE(( SELECT sum(b.subtotal) AS sum + FROM v_comandas_detalle_base b + WHERE (b.id_comanda = h.id_comanda)), (0)::numeric))) AS data + FROM ( SELECT DISTINCT v_comandas_detalle_base.id_comanda, + v_comandas_detalle_base.fec_creacion, + v_comandas_detalle_base.fec_cierre, + v_comandas_detalle_base.estado, + v_comandas_detalle_base.observaciones, + v_comandas_detalle_base.id_usuario, + v_comandas_detalle_base.usuario_nombre, + v_comandas_detalle_base.usuario_apellido, + v_comandas_detalle_base.id_mesa, + v_comandas_detalle_base.mesa_numero, + v_comandas_detalle_base.mesa_apodo + FROM v_comandas_detalle_base) h; + + +-- +-- Name: vw_compras; Type: VIEW; Schema: ; Owner: - +-- + +CREATE VIEW vw_compras AS + SELECT c.id_compra, + c.id_proveedor, + p.raz_social AS proveedor, + c.fec_compra, + c.total + FROM (compras c + JOIN proveedores p USING (id_proveedor)) + ORDER BY c.fec_compra DESC, c.id_compra DESC; + + +-- +-- Name: vw_ticket_total; Type: VIEW; Schema: ; Owner: - +-- + +CREATE VIEW vw_ticket_total AS + WITH lineas AS ( + SELECT c.id_comanda, + COALESCE(c.fec_cierre, (c.fec_creacion)::timestamp with time zone) AS fec_ticket, + (COALESCE(dc.pre_unitario, (p.precio)::numeric, (0)::numeric))::numeric(14,2) AS pu, + (COALESCE(dc.cantidad, (1)::numeric))::numeric(14,3) AS qty + FROM ((comandas c + JOIN deta_comandas dc ON ((dc.id_comanda = c.id_comanda))) + LEFT JOIN productos p ON ((p.id_producto = dc.id_producto))) + ) + SELECT id_comanda, + fec_ticket, + (sum((qty * pu)))::numeric(14,2) AS total + FROM lineas + GROUP BY id_comanda, fec_ticket; + + +-- +-- Name: asistencia_intervalo id_intervalo; Type: DEFAULT; Schema: ; Owner: - +-- + +ALTER TABLE ONLY asistencia_intervalo ALTER COLUMN id_intervalo SET DEFAULT nextval('asistencia_intervalo_id_intervalo_seq'::regclass); + + +-- +-- Name: asistencia_raw id_raw; Type: DEFAULT; Schema: ; Owner: - +-- + +ALTER TABLE ONLY asistencia_raw ALTER COLUMN id_raw SET DEFAULT nextval('asistencia_raw_id_raw_seq'::regclass); + + +-- +-- Name: categorias id_categoria; Type: DEFAULT; Schema: ; Owner: - +-- + +ALTER TABLE ONLY categorias ALTER COLUMN id_categoria SET DEFAULT nextval('categorias_id_categoria_seq'::regclass); + + +-- +-- Name: clientes id_cliente; Type: DEFAULT; Schema: ; Owner: - +-- + +ALTER TABLE ONLY clientes ALTER COLUMN id_cliente SET DEFAULT nextval('clientes_id_cliente_seq'::regclass); + + +-- +-- Name: comandas id_comanda; Type: DEFAULT; Schema: ; Owner: - +-- + +ALTER TABLE ONLY comandas ALTER COLUMN id_comanda SET DEFAULT nextval('comandas_id_comanda_seq'::regclass); + + +-- +-- Name: compras id_compra; Type: DEFAULT; Schema: ; Owner: - +-- + +ALTER TABLE ONLY compras ALTER COLUMN id_compra SET DEFAULT nextval('compras_id_compra_seq'::regclass); + + +-- +-- Name: deta_comandas id_det_comanda; Type: DEFAULT; Schema: ; Owner: - +-- + +ALTER TABLE ONLY deta_comandas ALTER COLUMN id_det_comanda SET DEFAULT nextval('deta_comandas_id_det_comanda_seq'::regclass); + + +-- +-- Name: mate_primas id_mat_prima; Type: DEFAULT; Schema: ; Owner: - +-- + +ALTER TABLE ONLY mate_primas ALTER COLUMN id_mat_prima SET DEFAULT nextval('mate_primas_id_mat_prima_seq'::regclass); + + +-- +-- Name: mesas id_mesa; Type: DEFAULT; Schema: ; Owner: - +-- + +ALTER TABLE ONLY mesas ALTER COLUMN id_mesa SET DEFAULT nextval('mesas_id_mesa_seq'::regclass); + + +-- +-- Name: productos id_producto; Type: DEFAULT; Schema: ; Owner: - +-- + +ALTER TABLE ONLY productos ALTER COLUMN id_producto SET DEFAULT nextval('productos_id_producto_seq'::regclass); + + +-- +-- Name: proveedores id_proveedor; Type: DEFAULT; Schema: ; Owner: - +-- + +ALTER TABLE ONLY proveedores ALTER COLUMN id_proveedor SET DEFAULT nextval('proveedores_id_proveedor_seq'::regclass); + + +-- +-- Name: roles id_rol; Type: DEFAULT; Schema: ; Owner: - +-- + +ALTER TABLE ONLY roles ALTER COLUMN id_rol SET DEFAULT nextval('roles_id_rol_seq'::regclass); + + +-- +-- Name: usuarios id_usuario; Type: DEFAULT; Schema: ; Owner: - +-- + +ALTER TABLE ONLY usuarios ALTER COLUMN id_usuario SET DEFAULT nextval('usuarios_id_usuario_seq'::regclass); + + +-- +-- Name: asistencia_intervalo asistencia_intervalo_id_usuario_desde_hasta_key; Type: CONSTRAINT; Schema: ; Owner: - +-- + +ALTER TABLE ONLY asistencia_intervalo + ADD CONSTRAINT asistencia_intervalo_id_usuario_desde_hasta_key UNIQUE (id_usuario, desde, hasta); + + +-- +-- Name: asistencia_intervalo asistencia_intervalo_pkey; Type: CONSTRAINT; Schema: ; Owner: - +-- + +ALTER TABLE ONLY asistencia_intervalo + ADD CONSTRAINT asistencia_intervalo_pkey PRIMARY KEY (id_intervalo); + + +-- +-- Name: asistencia_raw asistencia_raw_id_usuario_ts_key; Type: CONSTRAINT; Schema: ; Owner: - +-- + +ALTER TABLE ONLY asistencia_raw + ADD CONSTRAINT asistencia_raw_id_usuario_ts_key UNIQUE (id_usuario, ts); + + +-- +-- Name: asistencia_raw asistencia_raw_pkey; Type: CONSTRAINT; Schema: ; Owner: - +-- + +ALTER TABLE ONLY asistencia_raw + ADD CONSTRAINT asistencia_raw_pkey PRIMARY KEY (id_raw); + + +-- +-- Name: categorias categorias_nombre_key; Type: CONSTRAINT; Schema: ; Owner: - +-- + +ALTER TABLE ONLY categorias + ADD CONSTRAINT categorias_nombre_key UNIQUE (nombre); + + +-- +-- Name: categorias categorias_pkey; Type: CONSTRAINT; Schema: ; Owner: - +-- + +ALTER TABLE ONLY categorias + ADD CONSTRAINT categorias_pkey PRIMARY KEY (id_categoria); + + +-- +-- Name: clientes clientes_correo_key; Type: CONSTRAINT; Schema: ; Owner: - +-- + +ALTER TABLE ONLY clientes + ADD CONSTRAINT clientes_correo_key UNIQUE (correo); + + +-- +-- Name: clientes clientes_pkey; Type: CONSTRAINT; Schema: ; Owner: - +-- + +ALTER TABLE ONLY clientes + ADD CONSTRAINT clientes_pkey PRIMARY KEY (id_cliente); + + +-- +-- Name: clientes clientes_telefono_key; Type: CONSTRAINT; Schema: ; Owner: - +-- + +ALTER TABLE ONLY clientes + ADD CONSTRAINT clientes_telefono_key UNIQUE (telefono); + + +-- +-- Name: comandas comandas_pkey; Type: CONSTRAINT; Schema: ; Owner: - +-- + +ALTER TABLE ONLY comandas + ADD CONSTRAINT comandas_pkey PRIMARY KEY (id_comanda); + + +-- +-- Name: compras compras_pkey; Type: CONSTRAINT; Schema: ; Owner: - +-- + +ALTER TABLE ONLY compras + ADD CONSTRAINT compras_pkey PRIMARY KEY (id_compra); + + +-- +-- Name: deta_comandas deta_comandas_pkey; Type: CONSTRAINT; Schema: ; Owner: - +-- + +ALTER TABLE ONLY deta_comandas + ADD CONSTRAINT deta_comandas_pkey PRIMARY KEY (id_det_comanda); + + +-- +-- Name: deta_comp_materias deta_comp_materias_pkey; Type: CONSTRAINT; Schema: ; Owner: - +-- + +ALTER TABLE ONLY deta_comp_materias + ADD CONSTRAINT deta_comp_materias_pkey PRIMARY KEY (id_compra, id_mat_prima); + + +-- +-- Name: deta_comp_producto deta_comp_producto_pkey; Type: CONSTRAINT; Schema: ; Owner: - +-- + +ALTER TABLE ONLY deta_comp_producto + ADD CONSTRAINT deta_comp_producto_pkey PRIMARY KEY (id_compra, id_producto); + + +-- +-- Name: mate_primas mate_primas_nombre_key; Type: CONSTRAINT; Schema: ; Owner: - +-- + +ALTER TABLE ONLY mate_primas + ADD CONSTRAINT mate_primas_nombre_key UNIQUE (nombre); + + +-- +-- Name: mate_primas mate_primas_pkey; Type: CONSTRAINT; Schema: ; Owner: - +-- + +ALTER TABLE ONLY mate_primas + ADD CONSTRAINT mate_primas_pkey PRIMARY KEY (id_mat_prima); + + +-- +-- Name: mesas mesas_apodo_key; Type: CONSTRAINT; Schema: ; Owner: - +-- + +ALTER TABLE ONLY mesas + ADD CONSTRAINT mesas_apodo_key UNIQUE (apodo); + + +-- +-- Name: mesas mesas_numero_key; Type: CONSTRAINT; Schema: ; Owner: - +-- + +ALTER TABLE ONLY mesas + ADD CONSTRAINT mesas_numero_key UNIQUE (numero); + + +-- +-- Name: mesas mesas_pkey; Type: CONSTRAINT; Schema: ; Owner: - +-- + +ALTER TABLE ONLY mesas + ADD CONSTRAINT mesas_pkey PRIMARY KEY (id_mesa); + + +-- +-- Name: productos productos_pkey; Type: CONSTRAINT; Schema: ; Owner: - +-- + +ALTER TABLE ONLY productos + ADD CONSTRAINT productos_pkey PRIMARY KEY (id_producto); + + +-- +-- Name: prov_mate_prima prov_mate_prima_pkey; Type: CONSTRAINT; Schema: ; Owner: - +-- + +ALTER TABLE ONLY prov_mate_prima + ADD CONSTRAINT prov_mate_prima_pkey PRIMARY KEY (id_proveedor, id_mat_prima); + + +-- +-- Name: prov_producto prov_producto_pkey; Type: CONSTRAINT; Schema: ; Owner: - +-- + +ALTER TABLE ONLY prov_producto + ADD CONSTRAINT prov_producto_pkey PRIMARY KEY (id_proveedor, id_producto); + + +-- +-- Name: proveedores proveedores_pkey; Type: CONSTRAINT; Schema: ; Owner: - +-- + +ALTER TABLE ONLY proveedores + ADD CONSTRAINT proveedores_pkey PRIMARY KEY (id_proveedor); + + +-- +-- Name: proveedores proveedores_rut_key; Type: CONSTRAINT; Schema: ; Owner: - +-- + +ALTER TABLE ONLY proveedores + ADD CONSTRAINT proveedores_rut_key UNIQUE (rut); + + +-- +-- Name: receta_producto receta_producto_pkey; Type: CONSTRAINT; Schema: ; Owner: - +-- + +ALTER TABLE ONLY receta_producto + ADD CONSTRAINT receta_producto_pkey PRIMARY KEY (id_producto, id_mat_prima); + + +-- +-- Name: roles roles_nombre_key; Type: CONSTRAINT; Schema: ; Owner: - +-- + +ALTER TABLE ONLY roles + ADD CONSTRAINT roles_nombre_key UNIQUE (nombre); + + +-- +-- Name: roles roles_pkey; Type: CONSTRAINT; Schema: ; Owner: - +-- + +ALTER TABLE ONLY roles + ADD CONSTRAINT roles_pkey PRIMARY KEY (id_rol); + + +-- +-- Name: usua_roles usua_roles_pkey; Type: CONSTRAINT; Schema: ; Owner: - +-- + +ALTER TABLE ONLY usua_roles + ADD CONSTRAINT usua_roles_pkey PRIMARY KEY (id_usuario, id_rol); + + +-- +-- Name: usuarios usuarios_documento_key; Type: CONSTRAINT; Schema: ; Owner: - +-- + +ALTER TABLE ONLY usuarios + ADD CONSTRAINT usuarios_documento_key UNIQUE (documento); + + +-- +-- Name: usuarios usuarios_pkey; Type: CONSTRAINT; Schema: ; Owner: - +-- + +ALTER TABLE ONLY usuarios + ADD CONSTRAINT usuarios_pkey PRIMARY KEY (id_usuario); + + +-- +-- Name: compras_fec_compra_idx; Type: INDEX; Schema: ; Owner: - +-- + +CREATE INDEX compras_fec_compra_idx ON compras USING btree (fec_compra); + + +-- +-- Name: idx_asist_int_usuario_fecha; Type: INDEX; Schema: ; Owner: - +-- + +CREATE INDEX idx_asist_int_usuario_fecha ON asistencia_intervalo USING btree (id_usuario, fecha); + + +-- +-- Name: idx_asist_raw_usuario_ts; Type: INDEX; Schema: ; Owner: - +-- + +CREATE INDEX idx_asist_raw_usuario_ts ON asistencia_raw USING btree (id_usuario, ts); + + +-- +-- Name: idx_detalle_comanda_comanda; Type: INDEX; Schema: ; Owner: - +-- + +CREATE INDEX idx_detalle_comanda_comanda ON deta_comandas USING btree (id_comanda); + + +-- +-- Name: idx_detalle_comanda_producto; Type: INDEX; Schema: ; Owner: - +-- + +CREATE INDEX idx_detalle_comanda_producto ON deta_comandas USING btree (id_producto); + + +-- +-- Name: ix_comandas_fec_cierre; Type: INDEX; Schema: ; Owner: - +-- + +CREATE INDEX ix_comandas_fec_cierre ON comandas USING btree (fec_cierre); + + +-- +-- Name: ix_comandas_id; Type: INDEX; Schema: ; Owner: - +-- + +CREATE INDEX ix_comandas_id ON comandas USING btree (id_comanda); + + +-- +-- Name: ix_deta_comandas_id_comanda; Type: INDEX; Schema: ; Owner: - +-- + +CREATE INDEX ix_deta_comandas_id_comanda ON deta_comandas USING btree (id_comanda); + + +-- +-- Name: ix_deta_comandas_id_producto; Type: INDEX; Schema: ; Owner: - +-- + +CREATE INDEX ix_deta_comandas_id_producto ON deta_comandas USING btree (id_producto); + + +-- +-- Name: asistencia_intervalo asistencia_intervalo_id_usuario_fkey; Type: FK CONSTRAINT; Schema: ; Owner: - +-- + +ALTER TABLE ONLY asistencia_intervalo + ADD CONSTRAINT asistencia_intervalo_id_usuario_fkey FOREIGN KEY (id_usuario) REFERENCES usuarios(id_usuario) ON DELETE CASCADE; + + +-- +-- Name: asistencia_raw asistencia_raw_id_usuario_fkey; Type: FK CONSTRAINT; Schema: ; Owner: - +-- + +ALTER TABLE ONLY asistencia_raw + ADD CONSTRAINT asistencia_raw_id_usuario_fkey FOREIGN KEY (id_usuario) REFERENCES usuarios(id_usuario) ON DELETE CASCADE; + + +-- +-- Name: comandas comandas_id_mesa_fkey; Type: FK CONSTRAINT; Schema: ; Owner: - +-- + +ALTER TABLE ONLY comandas + ADD CONSTRAINT comandas_id_mesa_fkey FOREIGN KEY (id_mesa) REFERENCES mesas(id_mesa); + + +-- +-- Name: comandas comandas_id_usuario_fkey; Type: FK CONSTRAINT; Schema: ; Owner: - +-- + +ALTER TABLE ONLY comandas + ADD CONSTRAINT comandas_id_usuario_fkey FOREIGN KEY (id_usuario) REFERENCES usuarios(id_usuario); + + +-- +-- Name: compras compras_id_proveedor_fkey; Type: FK CONSTRAINT; Schema: ; Owner: - +-- + +ALTER TABLE ONLY compras + ADD CONSTRAINT compras_id_proveedor_fkey FOREIGN KEY (id_proveedor) REFERENCES proveedores(id_proveedor); + + +-- +-- Name: deta_comandas deta_comandas_id_comanda_fkey; Type: FK CONSTRAINT; Schema: ; Owner: - +-- + +ALTER TABLE ONLY deta_comandas + ADD CONSTRAINT deta_comandas_id_comanda_fkey FOREIGN KEY (id_comanda) REFERENCES comandas(id_comanda) ON DELETE CASCADE; + + +-- +-- Name: deta_comandas deta_comandas_id_producto_fkey; Type: FK CONSTRAINT; Schema: ; Owner: - +-- + +ALTER TABLE ONLY deta_comandas + ADD CONSTRAINT deta_comandas_id_producto_fkey FOREIGN KEY (id_producto) REFERENCES productos(id_producto); + + +-- +-- Name: deta_comp_materias deta_comp_materias_id_compra_fkey; Type: FK CONSTRAINT; Schema: ; Owner: - +-- + +ALTER TABLE ONLY deta_comp_materias + ADD CONSTRAINT deta_comp_materias_id_compra_fkey FOREIGN KEY (id_compra) REFERENCES compras(id_compra) ON DELETE CASCADE; + + +-- +-- Name: deta_comp_materias deta_comp_materias_id_mat_prima_fkey; Type: FK CONSTRAINT; Schema: ; Owner: - +-- + +ALTER TABLE ONLY deta_comp_materias + ADD CONSTRAINT deta_comp_materias_id_mat_prima_fkey FOREIGN KEY (id_mat_prima) REFERENCES mate_primas(id_mat_prima); + + +-- +-- Name: deta_comp_producto deta_comp_producto_id_compra_fkey; Type: FK CONSTRAINT; Schema: ; Owner: - +-- + +ALTER TABLE ONLY deta_comp_producto + ADD CONSTRAINT deta_comp_producto_id_compra_fkey FOREIGN KEY (id_compra) REFERENCES compras(id_compra) ON DELETE CASCADE; + + +-- +-- Name: deta_comp_producto deta_comp_producto_id_producto_fkey; Type: FK CONSTRAINT; Schema: ; Owner: - +-- + +ALTER TABLE ONLY deta_comp_producto + ADD CONSTRAINT deta_comp_producto_id_producto_fkey FOREIGN KEY (id_producto) REFERENCES productos(id_producto); + + +-- +-- Name: productos productos_id_categoria_fkey; Type: FK CONSTRAINT; Schema: ; Owner: - +-- + +ALTER TABLE ONLY productos + ADD CONSTRAINT productos_id_categoria_fkey FOREIGN KEY (id_categoria) REFERENCES categorias(id_categoria); + + +-- +-- Name: prov_mate_prima prov_mate_prima_id_mat_prima_fkey; Type: FK CONSTRAINT; Schema: ; Owner: - +-- + +ALTER TABLE ONLY prov_mate_prima + ADD CONSTRAINT prov_mate_prima_id_mat_prima_fkey FOREIGN KEY (id_mat_prima) REFERENCES mate_primas(id_mat_prima); + + +-- +-- Name: prov_mate_prima prov_mate_prima_id_proveedor_fkey; Type: FK CONSTRAINT; Schema: ; Owner: - +-- + +ALTER TABLE ONLY prov_mate_prima + ADD CONSTRAINT prov_mate_prima_id_proveedor_fkey FOREIGN KEY (id_proveedor) REFERENCES proveedores(id_proveedor) ON DELETE CASCADE; + + +-- +-- Name: prov_producto prov_producto_id_producto_fkey; Type: FK CONSTRAINT; Schema: ; Owner: - +-- + +ALTER TABLE ONLY prov_producto + ADD CONSTRAINT prov_producto_id_producto_fkey FOREIGN KEY (id_producto) REFERENCES productos(id_producto); + + +-- +-- Name: prov_producto prov_producto_id_proveedor_fkey; Type: FK CONSTRAINT; Schema: ; Owner: - +-- + +ALTER TABLE ONLY prov_producto + ADD CONSTRAINT prov_producto_id_proveedor_fkey FOREIGN KEY (id_proveedor) REFERENCES proveedores(id_proveedor) ON DELETE CASCADE; + + +-- +-- Name: receta_producto receta_producto_id_mat_prima_fkey; Type: FK CONSTRAINT; Schema: ; Owner: - +-- + +ALTER TABLE ONLY receta_producto + ADD CONSTRAINT receta_producto_id_mat_prima_fkey FOREIGN KEY (id_mat_prima) REFERENCES mate_primas(id_mat_prima); + + +-- +-- Name: receta_producto receta_producto_id_producto_fkey; Type: FK CONSTRAINT; Schema: ; Owner: - +-- + +ALTER TABLE ONLY receta_producto + ADD CONSTRAINT receta_producto_id_producto_fkey FOREIGN KEY (id_producto) REFERENCES productos(id_producto) ON DELETE CASCADE; + + +-- +-- Name: usua_roles usua_roles_autor_fkey; Type: FK CONSTRAINT; Schema: ; Owner: - +-- + +ALTER TABLE ONLY usua_roles + ADD CONSTRAINT usua_roles_autor_fkey FOREIGN KEY (autor) REFERENCES usuarios(id_usuario); + + +-- +-- Name: usua_roles usua_roles_id_rol_fkey; Type: FK CONSTRAINT; Schema: ; Owner: - +-- + +ALTER TABLE ONLY usua_roles + ADD CONSTRAINT usua_roles_id_rol_fkey FOREIGN KEY (id_rol) REFERENCES roles(id_rol); + + +-- +-- Name: usua_roles usua_roles_id_usuario_fkey; Type: FK CONSTRAINT; Schema: ; Owner: - +-- + +ALTER TABLE ONLY usua_roles + ADD CONSTRAINT usua_roles_id_usuario_fkey FOREIGN KEY (id_usuario) REFERENCES usuarios(id_usuario) ON DELETE CASCADE; + + +-- +-- PostgreSQL database dump complete +-- + + +COMMIT; diff --git a/services/auth/src/db/initTenant_v3.sql b/services/auth/src/db/initTenant_v3.sql new file mode 100644 index 0000000..36c2575 --- /dev/null +++ b/services/auth/src/db/initTenant_v3.sql @@ -0,0 +1,2284 @@ +-- +-- PostgreSQL database dump +-- + + +-- Dumped from database version 16.10 (Debian 16.10-1.pgdg13+1) +-- Dumped by pg_dump version 16.10 (Debian 16.10-1.pgdg13+1) + +BEGIN; +SET client_encoding = 'UTF8'; +SET standard_conforming_strings = on; +SET row_security = off; +SET statement_timeout = 0; +SET lock_timeout = 0; +SET idle_in_transaction_session_timeout = 0; +SET client_encoding = 'UTF8'; +SET standard_conforming_strings = on; +SELECT pg_catalog.set_config('search_path', '', false); +SET check_function_bodies = false; +SET xmloption = content; +SET client_min_messages = warning; +SET row_security = off; + +-- +-- Name: public; Type: SCHEMA; Schema: -; Owner: - +-- + +CREATE SCHEMA public; + + +-- +-- Name: SCHEMA public; Type: COMMENT; Schema: -; Owner: - +-- + +COMMENT ON SCHEMA public IS 'standard public schema'; + + +-- +-- Name: asistencia_delete_raw(bigint, text); Type: FUNCTION; Schema: public; Owner: - +-- + +CREATE FUNCTION public.asistencia_delete_raw(p_id_raw bigint, p_tz text DEFAULT 'America/Montevideo'::text) RETURNS jsonb + LANGUAGE plpgsql + AS $$ +DECLARE + v_id_usuario INT; + v_ts TIMESTAMPTZ; + v_t0 TIMESTAMPTZ; + v_t1 TIMESTAMPTZ; + v_del_raw INT; + v_del INT; + v_ins INT; +BEGIN + SELECT id_usuario, ts INTO v_id_usuario, v_ts + FROM public.asistencia_raw WHERE id_raw = p_id_raw; + IF v_id_usuario IS NULL THEN + RETURN jsonb_build_object('deleted',0,'msg','id_raw inexistente'); + END IF; + + v_t0 := v_ts - INTERVAL '1 day'; + v_t1 := v_ts + INTERVAL '1 day'; + + -- borrar raw + DELETE FROM public.asistencia_raw WHERE id_raw = p_id_raw; + GET DIAGNOSTICS v_del_raw = ROW_COUNT; + + -- recomputar pares en ventana + WITH tl AS ( + SELECT ar.ts, + ROW_NUMBER() OVER (ORDER BY ar.ts) AS rn + FROM public.asistencia_raw ar + WHERE ar.id_usuario = v_id_usuario + AND ar.ts BETWEEN v_t0 AND v_t1 + ), + ready AS ( + SELECT (t1.ts AT TIME ZONE p_tz)::date AS fecha, + t1.ts AS desde, + t2.ts AS hasta, + EXTRACT(EPOCH FROM (t2.ts - t1.ts))/60.0 AS dur_min + FROM tl t1 + JOIN tl t2 ON t2.rn = t1.rn + 1 + WHERE (t1.rn % 2) = 1 AND t2.ts > t1.ts + ), + del AS ( + DELETE FROM public.asistencia_intervalo ai + WHERE ai.id_usuario = v_id_usuario + AND (ai.desde BETWEEN v_t0 AND v_t1 OR ai.hasta BETWEEN v_t0 AND v_t1) + RETURNING 1 + ), + ins AS ( + INSERT INTO public.asistencia_intervalo (id_usuario, fecha, desde, hasta, dur_min, origen) + SELECT v_id_usuario, r.fecha, r.desde, r.hasta, r.dur_min, 'delete_adjust' + FROM ready r + ON CONFLICT (id_usuario, desde, hasta) DO NOTHING + RETURNING 1 + ) + SELECT (SELECT COUNT(*) FROM del), (SELECT COUNT(*) FROM ins) INTO v_del, v_ins; + + RETURN jsonb_build_object('deleted',v_del_raw,'deleted_pairs',v_del,'inserted_pairs',v_ins); +END; +$$; + + +-- +-- Name: asistencia_get(text, date, date, text); Type: FUNCTION; Schema: public; Owner: - +-- + +CREATE FUNCTION public.asistencia_get(p_doc text, p_desde date, p_hasta date, p_tz text DEFAULT 'America/Montevideo'::text) RETURNS jsonb + LANGUAGE sql + AS $$ +WITH u AS ( + SELECT id_usuario, documento, nombre, apellido + FROM public.usuarios + WHERE regexp_replace(documento,'^\s*0+','','g') = regexp_replace(p_doc,'^\s*0+','','g') + LIMIT 1 +), +r AS ( + SELECT ar.id_raw, + (ar.ts AT TIME ZONE p_tz)::date AS fecha, + to_char(ar.ts AT TIME ZONE p_tz,'HH24:MI:SS') AS hora, + COALESCE(ar.modo,'') AS modo, + COALESCE(ar.origen,'') AS origen, + ar.ts + FROM public.asistencia_raw ar + JOIN u USING (id_usuario) + WHERE (ar.ts AT TIME ZONE p_tz)::date BETWEEN p_desde AND p_hasta +), +i AS ( + SELECT ai.id_intervalo, + ai.fecha, + to_char(ai.desde AT TIME ZONE p_tz,'HH24:MI:SS') AS desde_hora, + to_char(ai.hasta AT TIME ZONE p_tz,'HH24:MI:SS') AS hasta_hora, + ai.dur_min + FROM public.asistencia_intervalo ai + JOIN u USING (id_usuario) + WHERE ai.fecha BETWEEN p_desde AND p_hasta +) +SELECT jsonb_build_object( + 'usuario', (SELECT to_jsonb(u.*) FROM u), + 'raw', COALESCE((SELECT jsonb_agg(to_jsonb(r.*) ORDER BY r.ts) FROM r),'[]'::jsonb), + 'intervalos', COALESCE((SELECT jsonb_agg(to_jsonb(i.*) ORDER BY i.fecha, i.id_intervalo) FROM i),'[]'::jsonb) +); +$$; + + +-- +-- Name: asistencia_update_raw(bigint, date, text, text, text); Type: FUNCTION; Schema: public; Owner: - +-- + +CREATE FUNCTION public.asistencia_update_raw(p_id_raw bigint, p_fecha date, p_hora text, p_modo text, p_tz text DEFAULT 'America/Montevideo'::text) RETURNS jsonb + LANGUAGE plpgsql + AS $$ +DECLARE + v_id_usuario INT; + v_ts_old TIMESTAMPTZ; + v_ts_new TIMESTAMPTZ; + v_t0 TIMESTAMPTZ; + v_t1 TIMESTAMPTZ; + v_del INT; + v_ins INT; +BEGIN + -- leer estado previo + SELECT id_usuario, ts INTO v_id_usuario, v_ts_old + FROM public.asistencia_raw WHERE id_raw = p_id_raw; + IF v_id_usuario IS NULL THEN + RETURN jsonb_build_object('updated',0,'msg','id_raw inexistente'); + END IF; + + -- construir ts nuevo + v_ts_new := make_timestamptz( + EXTRACT(YEAR FROM p_fecha)::INT, + EXTRACT(MONTH FROM p_fecha)::INT, + EXTRACT(DAY FROM p_fecha)::INT, + split_part(p_hora,':',1)::INT, + split_part(p_hora,':',2)::INT, + COALESCE(NULLIF(split_part(p_hora,':',3),''), '0')::INT, + p_tz); + + -- aplicar update + UPDATE public.asistencia_raw + SET ts = v_ts_new, + modo = COALESCE(p_modo, modo) + WHERE id_raw = p_id_raw; + + -- ventana de recálculo + v_t0 := LEAST(v_ts_old, v_ts_new) - INTERVAL '1 day'; + v_t1 := GREATEST(v_ts_old, v_ts_new) + INTERVAL '1 day'; + + -- recomputar pares en la ventana: borrar los del rango y reinsertar + WITH tl AS ( + SELECT ar.ts, + ROW_NUMBER() OVER (ORDER BY ar.ts) AS rn + FROM public.asistencia_raw ar + WHERE ar.id_usuario = v_id_usuario + AND ar.ts BETWEEN v_t0 AND v_t1 + ), + ready AS ( + SELECT (t1.ts AT TIME ZONE p_tz)::date AS fecha, + t1.ts AS desde, + t2.ts AS hasta, + EXTRACT(EPOCH FROM (t2.ts - t1.ts))/60.0 AS dur_min + FROM tl t1 + JOIN tl t2 ON t2.rn = t1.rn + 1 + WHERE (t1.rn % 2) = 1 AND t2.ts > t1.ts + ), + del AS ( + DELETE FROM public.asistencia_intervalo ai + WHERE ai.id_usuario = v_id_usuario + AND (ai.desde BETWEEN v_t0 AND v_t1 OR ai.hasta BETWEEN v_t0 AND v_t1) + RETURNING 1 + ), + ins AS ( + INSERT INTO public.asistencia_intervalo (id_usuario, fecha, desde, hasta, dur_min, origen) + SELECT v_id_usuario, r.fecha, r.desde, r.hasta, r.dur_min, 'edit_manual' + FROM ready r + ON CONFLICT (id_usuario, desde, hasta) DO NOTHING + RETURNING 1 + ) + SELECT (SELECT COUNT(*) FROM del), (SELECT COUNT(*) FROM ins) INTO v_del, v_ins; + + RETURN jsonb_build_object('updated',1,'deleted_pairs',v_del,'inserted_pairs',v_ins); +END; +$$; + + +-- +-- Name: delete_compra(integer); Type: FUNCTION; Schema: public; Owner: - +-- + +CREATE FUNCTION public.delete_compra(p_id_compra integer) RETURNS void + LANGUAGE plpgsql + AS $$ +BEGIN + DELETE FROM public.deta_comp_materias WHERE id_compra = p_id_compra; + DELETE FROM public.deta_comp_producto WHERE id_compra = p_id_compra; + DELETE FROM public.compras WHERE id_compra = p_id_compra; +END; +$$; + + +-- +-- Name: f_abrir_comanda(integer); Type: FUNCTION; Schema: public; Owner: - +-- + +CREATE FUNCTION public.f_abrir_comanda(p_id integer) RETURNS jsonb + LANGUAGE plpgsql + AS $$ +DECLARE r jsonb; +BEGIN + UPDATE public.comandas + SET estado = 'abierta', + fec_cierre = NULL + WHERE id_comanda = p_id; + + IF NOT FOUND THEN + RETURN NULL; + END IF; + + SELECT to_jsonb(v) INTO r + FROM public.v_comandas_resumen v + WHERE v.id_comanda = p_id; + + RETURN r; +END; +$$; + + +-- +-- Name: f_cerrar_comanda(integer); Type: FUNCTION; Schema: public; Owner: - +-- + +CREATE FUNCTION public.f_cerrar_comanda(p_id integer) RETURNS jsonb + LANGUAGE plpgsql + AS $$ +DECLARE r jsonb; +BEGIN + UPDATE public.comandas + SET estado = 'cerrada', + fec_cierre = COALESCE(fec_cierre, NOW()) + WHERE id_comanda = p_id; + + IF NOT FOUND THEN + RETURN NULL; + END IF; + + SELECT to_jsonb(v) INTO r + FROM public.v_comandas_resumen v + WHERE v.id_comanda = p_id; + + RETURN r; +END; +$$; + + +-- +-- Name: f_comanda_detalle_json(integer); Type: FUNCTION; Schema: public; Owner: - +-- + +CREATE FUNCTION public.f_comanda_detalle_json(p_id_comanda integer) RETURNS jsonb + LANGUAGE sql + AS $$ +WITH base AS ( + SELECT + c.id_comanda, + c.fec_creacion, + c.estado, + c.observaciones, + u.id_usuario, + u.nombre AS usuario_nombre, + u.apellido AS usuario_apellido, + m.id_mesa, + m.numero AS mesa_numero, + m.apodo AS mesa_apodo, + d.id_producto, + p.nombre AS producto_nombre, + d.cantidad, + d.pre_unitario, + (d.cantidad * d.pre_unitario) AS subtotal + FROM public.comandas c + JOIN public.usuarios u ON u.id_usuario = c.id_usuario + JOIN public.mesas m ON m.id_mesa = c.id_mesa + LEFT JOIN public.deta_comandas d ON d.id_comanda = c.id_comanda + LEFT JOIN public.productos p ON p.id_producto = d.id_producto + WHERE c.id_comanda = p_id_comanda +), +hdr AS ( + -- 1 sola fila con los datos de cabecera + SELECT DISTINCT + id_comanda, fec_creacion, estado, observaciones, + id_usuario, usuario_nombre, usuario_apellido, + id_mesa, mesa_numero, mesa_apodo + FROM base +), +agg_items AS ( + SELECT + COALESCE( + jsonb_agg( + jsonb_build_object( + 'producto_id', b.id_producto, + 'producto', b.producto_nombre, + 'cantidad', b.cantidad, + 'pre_unitario', b.pre_unitario, + 'subtotal', b.subtotal + ) + ORDER BY b.producto_nombre NULLS LAST + ) FILTER (WHERE b.id_producto IS NOT NULL), + '[]'::jsonb + ) AS items + FROM base b +), +tot AS ( + SELECT + COUNT(*) FILTER (WHERE id_producto IS NOT NULL) AS items, + COALESCE(SUM(subtotal), 0)::numeric AS total + FROM base +) +SELECT + CASE + WHEN EXISTS (SELECT 1 FROM hdr) THEN + jsonb_build_object( + 'id_comanda', h.id_comanda, + 'fec_creacion', h.fec_creacion, + 'estado', h.estado, + 'observaciones',h.observaciones, + 'usuario', jsonb_build_object( + 'id_usuario', h.id_usuario, + 'nombre', h.usuario_nombre, + 'apellido', h.usuario_apellido + ), + 'mesa', jsonb_build_object( + 'id_mesa', h.id_mesa, + 'numero', h.mesa_numero, + 'apodo', h.mesa_apodo + ), + 'items', i.items, + 'totales', jsonb_build_object( + 'items', t.items, + 'total', t.total + ) + ) + ELSE NULL + END +FROM hdr h, agg_items i, tot t; +$$; + + +-- +-- Name: f_comanda_detalle_rows(integer); Type: FUNCTION; Schema: public; Owner: - +-- + +CREATE FUNCTION public.f_comanda_detalle_rows(p_id_comanda integer) RETURNS TABLE(id_comanda integer, fec_creacion timestamp without time zone, estado text, observaciones text, id_usuario integer, usuario_nombre text, usuario_apellido text, id_mesa integer, mesa_numero integer, mesa_apodo text, producto_id integer, producto_nombre text, cantidad numeric, pre_unitario numeric, subtotal numeric, items integer, total numeric) + LANGUAGE sql + AS $$ +WITH base AS ( + SELECT + c.id_comanda, c.fec_creacion, c.estado, c.observaciones, + u.id_usuario, u.nombre AS usuario_nombre, u.apellido AS usuario_apellido, + m.id_mesa, m.numero AS mesa_numero, m.apodo AS mesa_apodo, + d.id_producto, p.nombre AS producto_nombre, + d.cantidad, d.pre_unitario, + (d.cantidad * d.pre_unitario) AS subtotal + FROM public.comandas c + JOIN public.usuarios u ON u.id_usuario = c.id_usuario + JOIN public.mesas m ON m.id_mesa = c.id_mesa + LEFT JOIN public.deta_comandas d ON d.id_comanda = c.id_comanda + LEFT JOIN public.productos p ON p.id_producto = d.id_producto + WHERE c.id_comanda = p_id_comanda +), +tot AS ( + SELECT + COUNT(*) FILTER (WHERE id_producto IS NOT NULL) AS items, + COALESCE(SUM(subtotal), 0) AS total + FROM base +) +SELECT + b.id_comanda, b.fec_creacion, b.estado, b.observaciones, + b.id_usuario, b.usuario_nombre, b.usuario_apellido, + b.id_mesa, b.mesa_numero, b.mesa_apodo, + b.id_producto, b.producto_nombre, + b.cantidad, b.pre_unitario, b.subtotal, + t.items, t.total +FROM base b CROSS JOIN tot t +ORDER BY b.producto_nombre NULLS LAST; +$$; + + +SET default_tablespace = ''; + +SET default_table_access_method = heap; + +-- +-- Name: comandas; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.comandas ( + id_comanda integer NOT NULL, + id_usuario integer NOT NULL, + id_mesa integer NOT NULL, + fec_creacion timestamp without time zone DEFAULT now() NOT NULL, + estado text NOT NULL, + observaciones text, + fec_cierre timestamp with time zone, + CONSTRAINT comandas_estado_check CHECK ((estado = ANY (ARRAY['abierta'::text, 'cerrada'::text, 'pagada'::text, 'anulada'::text]))) +); + + +-- +-- Name: COLUMN comandas.fec_cierre; Type: COMMENT; Schema: public; Owner: - +-- + +COMMENT ON COLUMN public.comandas.fec_cierre IS 'Fecha/hora de cierre de la comanda (NULL si está abierta)'; + + +-- +-- Name: deta_comandas; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.deta_comandas ( + id_det_comanda integer NOT NULL, + id_comanda integer NOT NULL, + id_producto integer NOT NULL, + cantidad numeric(12,3) NOT NULL, + pre_unitario numeric(12,2) NOT NULL, + observaciones text, + CONSTRAINT deta_comandas_cantidad_check CHECK ((cantidad > (0)::numeric)), + CONSTRAINT deta_comandas_pre_unitario_check CHECK ((pre_unitario >= (0)::numeric)) +); + + +-- +-- Name: mesas; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.mesas ( + id_mesa integer NOT NULL, + numero integer NOT NULL, + apodo text NOT NULL, + estado text DEFAULT 'libre'::text NOT NULL, + CONSTRAINT mesas_estado_check CHECK ((estado = ANY (ARRAY['libre'::text, 'ocupada'::text, 'reservada'::text]))) +); + + +-- +-- Name: usuarios; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.usuarios ( + id_usuario integer NOT NULL, + documento text, + img_perfil character varying(255) DEFAULT 'img_perfil.png'::character varying NOT NULL, + nombre text NOT NULL, + apellido text NOT NULL, + correo text, + telefono text, + fec_nacimiento date, + activo boolean DEFAULT true +); + + +-- +-- Name: v_comandas_resumen; Type: VIEW; Schema: public; Owner: - +-- + +CREATE VIEW public.v_comandas_resumen AS + WITH items AS ( + SELECT d.id_comanda, + count(*) AS items, + sum((d.cantidad * d.pre_unitario)) AS total + FROM public.deta_comandas d + GROUP BY d.id_comanda + ) + SELECT c.id_comanda, + c.fec_creacion, + c.estado, + c.observaciones, + u.id_usuario, + u.nombre AS usuario_nombre, + u.apellido AS usuario_apellido, + m.id_mesa, + m.numero AS mesa_numero, + m.apodo AS mesa_apodo, + COALESCE(i.items, (0)::bigint) AS items, + COALESCE(i.total, (0)::numeric) AS total, + c.fec_cierre, + CASE + WHEN (c.fec_cierre IS NOT NULL) THEN round((EXTRACT(epoch FROM (c.fec_cierre - (c.fec_creacion)::timestamp with time zone)) / 60.0), 1) + ELSE NULL::numeric + END AS duracion_min + FROM (((public.comandas c + JOIN public.usuarios u ON ((u.id_usuario = c.id_usuario))) + JOIN public.mesas m ON ((m.id_mesa = c.id_mesa))) + LEFT JOIN items i ON ((i.id_comanda = c.id_comanda))); + + +-- +-- Name: f_comandas_resumen(text, integer); Type: FUNCTION; Schema: public; Owner: - +-- + +CREATE FUNCTION public.f_comandas_resumen(p_estado text DEFAULT NULL::text, p_limit integer DEFAULT 200) RETURNS SETOF public.v_comandas_resumen + LANGUAGE sql + AS $$ + SELECT * + FROM public.v_comandas_resumen + WHERE (p_estado IS NULL OR estado = p_estado) + ORDER BY id_comanda DESC + LIMIT p_limit; +$$; + + +-- +-- Name: find_usuarios_por_documentos(jsonb); Type: FUNCTION; Schema: public; Owner: - +-- + +CREATE FUNCTION public.find_usuarios_por_documentos(p_docs jsonb) RETURNS jsonb + LANGUAGE sql + AS $$ +WITH docs AS ( + SELECT DISTINCT + regexp_replace(value::text, '^\s*0+', '', 'g') AS doc_clean, + value::text AS original + FROM jsonb_array_elements_text(COALESCE(p_docs,'[]')) +), +rows AS ( + SELECT d.original AS documento, + u.nombre, + u.apellido, + (u.id_usuario IS NOT NULL) AS found + FROM docs d + LEFT JOIN public.usuarios u + ON regexp_replace(u.documento, '^\s*0+', '', 'g') = d.doc_clean +) +SELECT COALESCE( + jsonb_object_agg( + documento, + jsonb_build_object( + 'nombre', COALESCE(nombre, ''), + 'apellido', COALESCE(apellido, ''), + 'found', found + ) + ), + '{}'::jsonb +) +FROM rows; +$$; + + +-- +-- Name: get_compra(integer); Type: FUNCTION; Schema: public; Owner: - +-- + +CREATE FUNCTION public.get_compra(p_id_compra integer) RETURNS jsonb + LANGUAGE sql + AS $$ +WITH cab AS ( + SELECT c.id_compra, c.id_proveedor, c.fec_compra, c.total + FROM public.compras c + WHERE c.id_compra = p_id_compra +), +dm AS ( + SELECT 'MAT'::text AS tipo, d.id_mat_prima AS id, + d.cantidad, d.pre_unitario AS precio + FROM public.deta_comp_materias d WHERE d.id_compra = p_id_compra +), +dp AS ( + SELECT 'PROD'::text AS tipo, d.id_producto AS id, + d.cantidad, d.pre_unitario AS precio + FROM public.deta_comp_producto d WHERE d.id_compra = p_id_compra +), +det AS ( + SELECT jsonb_agg(to_jsonb(x.*)) AS detalles + FROM ( + SELECT * FROM dm + UNION ALL + SELECT * FROM dp + ) x +) +SELECT jsonb_build_object( + 'id_compra', (SELECT id_compra FROM cab), + 'id_proveedor',(SELECT id_proveedor FROM cab), + 'fec_compra', to_char((SELECT fec_compra FROM cab),'YYYY-MM-DD HH24:MI:SS'), + 'total', (SELECT total FROM cab), + 'detalles', COALESCE((SELECT detalles FROM det),'[]'::jsonb) +); +$$; + + +-- +-- Name: get_materia_prima(integer); Type: FUNCTION; Schema: public; Owner: - +-- + +CREATE FUNCTION public.get_materia_prima(p_id integer) RETURNS jsonb + LANGUAGE sql + AS $$ +SELECT jsonb_build_object( + 'materia', to_jsonb(mp), + 'proveedores', COALESCE( + ( + SELECT jsonb_agg( + jsonb_build_object( + 'id_proveedor', pr.id_proveedor, + 'raz_social', pr.raz_social, + 'rut', pr.rut, + 'contacto', pr.contacto, + 'direccion', pr.direccion + ) + ) + FROM public.prov_mate_prima pmp + JOIN public.proveedores pr ON pr.id_proveedor = pmp.id_proveedor + WHERE pmp.id_mat_prima = mp.id_mat_prima + ), + '[]'::jsonb + ) +) +FROM public.mate_primas mp +WHERE mp.id_mat_prima = p_id; +$$; + + +-- +-- Name: get_producto(integer); Type: FUNCTION; Schema: public; Owner: - +-- + +CREATE FUNCTION public.get_producto(p_id integer) RETURNS jsonb + LANGUAGE sql + AS $$ +SELECT jsonb_build_object( + 'producto', to_jsonb(p), -- el registro completo del producto en JSONB + 'receta', COALESCE( + ( + SELECT jsonb_agg( + jsonb_build_object( + 'id_mat_prima', rp.id_mat_prima, + 'qty_por_unidad', rp.qty_por_unidad, + 'nombre', mp.nombre, + 'unidad', mp.unidad + ) + ) + FROM receta_producto rp + LEFT JOIN mate_primas mp USING (id_mat_prima) + WHERE rp.id_producto = p.id_producto + ), + '[]'::jsonb + ) +) +FROM productos p +WHERE p.id_producto = p_id; +$$; + + +-- +-- Name: import_asistencia(jsonb, text, text); Type: FUNCTION; Schema: public; Owner: - +-- + +CREATE FUNCTION public.import_asistencia(p_registros jsonb, p_origen text, p_tz text DEFAULT 'America/Montevideo'::text) RETURNS jsonb + LANGUAGE plpgsql + AS $_$ +DECLARE + v_ins_raw INT; + v_ins_pairs INT; + v_miss JSONB; +BEGIN + WITH + -- 1) JSON -> filas + j AS ( + SELECT + regexp_replace(elem->>'doc','^\s*0+','','g')::TEXT AS doc_clean, + (elem->>'isoDate')::DATE AS d, + elem->>'time' AS time_str, + NULLIF(elem->>'mode','') AS modo + FROM jsonb_array_elements(COALESCE(p_registros,'[]')) elem + ), + -- 2) Vincular a usuarios + u AS ( + SELECT j.*, u.id_usuario + FROM j + LEFT JOIN public.usuarios u + ON regexp_replace(u.documento,'^\s*0+','','g') = j.doc_clean + ), + -- 3) Documentos faltantes + miss AS ( + SELECT jsonb_agg(doc_clean) AS missing + FROM u WHERE id_usuario IS NULL + ), + -- 4) TS determinista en TZ del negocio + parsed AS ( + SELECT + u.id_usuario, + u.modo, + make_timestamptz( + EXTRACT(YEAR FROM u.d)::INT, + EXTRACT(MONTH FROM u.d)::INT, + EXTRACT(DAY FROM u.d)::INT, + split_part(u.time_str,':',1)::INT, + split_part(u.time_str,':',2)::INT, + COALESCE(NULLIF(split_part(u.time_str,':',3),''),'0')::INT, + p_tz + ) AS ts_calc + FROM u + WHERE u.id_usuario IS NOT NULL + ), + -- 5) Ventana por usuario (±1 día de lo importado) + win AS ( + SELECT id_usuario, + (MIN(ts_calc) - INTERVAL '1 day') AS t0, + (MAX(ts_calc) + INTERVAL '1 day') AS t1 + FROM parsed + GROUP BY id_usuario + ), + -- 6) Lo existente en BD dentro de la ventana + existing AS ( + SELECT ar.id_usuario, ar.ts + FROM public.asistencia_raw ar + JOIN win w ON w.id_usuario = ar.id_usuario + AND ar.ts BETWEEN w.t0 AND w.t1 + ), + -- 7) CANDIDATE = existente ∪ archivo (sin duplicados) + candidate AS ( + SELECT id_usuario, ts FROM existing + UNION -- ¡clave para evitar doble click! + SELECT id_usuario, ts_calc AS ts FROM parsed + ), + -- 8) Paridad previa (cuántas marcas había ANTES de la ventana) + before_cnt AS ( + SELECT w.id_usuario, COUNT(*)::int AS cnt + FROM win w + JOIN public.asistencia_raw ar + ON ar.id_usuario = w.id_usuario + AND ar.ts < w.t0 + GROUP BY w.id_usuario + ), + -- 9) Línea de tiempo candidata y pares (1→2, 3→4…), jornada = día local del inicio + timeline AS ( + SELECT + c.id_usuario, + c.ts, + ROW_NUMBER() OVER (PARTITION BY c.id_usuario ORDER BY c.ts) AS rn + FROM candidate c + ), + ready AS ( + SELECT + t1.id_usuario, + (t1.ts AT TIME ZONE p_tz)::date AS fecha, + t1.ts AS desde, + t2.ts AS hasta, + EXTRACT(EPOCH FROM (t2.ts - t1.ts))/60.0 AS dur_min + FROM timeline t1 + JOIN timeline t2 + ON t2.id_usuario = t1.id_usuario + AND t2.rn = t1.rn + 1 + LEFT JOIN before_cnt b ON b.id_usuario = t1.id_usuario + WHERE ((COALESCE(b.cnt,0) + t1.rn) % 2) = 1 -- t1 es IN global + AND t2.ts > t1.ts + ), + -- 10) INSERT crudo (dedupe) + ins_raw AS ( + INSERT INTO public.asistencia_raw (id_usuario, ts, modo, origen) + SELECT id_usuario, ts_calc, + NULLIF(modo,'')::text, -- puede quedar NULL para auto-etiquetado + p_origen + FROM parsed + ON CONFLICT (id_usuario, ts) DO NOTHING + RETURNING 1 + ), + -- 11) Auto-etiquetar IN/OUT en BD para filas con modo vacío/'1' (tras insertar) + before_cnt2 AS ( + SELECT w.id_usuario, COUNT(*)::int AS cnt + FROM win w + JOIN public.asistencia_raw ar + ON ar.id_usuario = w.id_usuario + AND ar.ts < w.t0 + GROUP BY w.id_usuario + ), + tl2 AS ( + SELECT + ar.id_usuario, ar.ts, + ROW_NUMBER() OVER (PARTITION BY ar.id_usuario ORDER BY ar.ts) AS rn + FROM public.asistencia_raw ar + JOIN win w ON w.id_usuario = ar.id_usuario + AND ar.ts BETWEEN w.t0 AND w.t1 + ), + label2 AS ( + SELECT + t.id_usuario, + t.ts, + CASE WHEN ((COALESCE(b.cnt,0) + t.rn) % 2) = 1 THEN 'IN' ELSE 'OUT' END AS new_mode + FROM tl2 t + LEFT JOIN before_cnt2 b ON b.id_usuario = t.id_usuario + ), + set_mode AS ( + UPDATE public.asistencia_raw ar + SET modo = l.new_mode + FROM label2 l + WHERE ar.id_usuario = l.id_usuario + AND ar.ts = l.ts + AND (ar.modo IS NULL OR btrim(ar.modo) = '' OR ar.modo ~ '^\s*1\s*$') + RETURNING 1 + ), + -- 12) INSERT pares (dedupe) calculados desde CANDIDATE (ya tiene todo el contexto) + ins_pairs AS ( + INSERT INTO public.asistencia_intervalo (id_usuario, fecha, desde, hasta, dur_min, origen) + SELECT id_usuario, fecha, desde, hasta, dur_min, p_origen + FROM ready + ON CONFLICT (id_usuario, desde, hasta) DO NOTHING + RETURNING 1 + ) + SELECT + (SELECT COUNT(*) FROM ins_raw), + (SELECT COUNT(*) FROM ins_pairs), + (SELECT COALESCE(missing,'[]'::jsonb) FROM miss) + INTO v_ins_raw, v_ins_pairs, v_miss; + + RETURN jsonb_build_object( + 'inserted_raw', v_ins_raw, + 'inserted_pairs', v_ins_pairs, + 'missing_docs', v_miss + ); +END; +$_$; + + +-- +-- Name: report_asistencia(date, date); Type: FUNCTION; Schema: public; Owner: - +-- + +CREATE FUNCTION public.report_asistencia(p_desde date, p_hasta date) RETURNS TABLE(documento text, nombre text, apellido text, fecha date, desde_hora text, hasta_hora text, dur_min numeric) + LANGUAGE sql + AS $$ + SELECT + u.documento, u.nombre, u.apellido, + ai.fecha, + to_char(ai.desde AT TIME ZONE 'America/Montevideo','HH24:MI:SS') AS desde_hora, + to_char(ai.hasta AT TIME ZONE 'America/Montevideo','HH24:MI:SS') AS hasta_hora, + ai.dur_min + FROM public.asistencia_intervalo ai + JOIN public.usuarios u USING (id_usuario) + WHERE ai.fecha BETWEEN p_desde AND p_hasta + ORDER BY u.documento, ai.fecha, ai.desde; +$$; + + +-- +-- Name: report_gastos(integer); Type: FUNCTION; Schema: public; Owner: - +-- + +CREATE FUNCTION public.report_gastos(p_year integer) RETURNS jsonb + LANGUAGE sql STABLE + AS $$ +WITH mdata AS ( + SELECT date_trunc('month', c.fec_compra)::date AS m, + SUM(c.total)::numeric AS importe + FROM public.compras c + WHERE EXTRACT(YEAR FROM c.fec_compra) = p_year + GROUP BY 1 +), +mm AS ( + SELECT EXTRACT(MONTH FROM m)::int AS mes, importe + FROM mdata +) +SELECT jsonb_build_object( + 'year', p_year, + 'total', COALESCE((SELECT SUM(importe) FROM mdata), 0), + 'avg', COALESCE((SELECT AVG(importe) FROM mdata), 0), + 'months', + (SELECT jsonb_agg( + jsonb_build_object( + 'mes', gs, + 'nombre', to_char(to_date(gs::text,'MM'),'Mon'), + 'importe', COALESCE(mm.importe,0) + ) + ORDER BY gs + ) + FROM generate_series(1,12) gs + LEFT JOIN mm ON mm.mes = gs) +); +$$; + + +-- +-- Name: report_tickets_year(integer, text); Type: FUNCTION; Schema: public; Owner: - +-- + +CREATE FUNCTION public.report_tickets_year(p_year integer, p_tz text DEFAULT 'America/Montevideo'::text) RETURNS jsonb + LANGUAGE sql STABLE + AS $$ +WITH bounds AS ( + SELECT + make_timestamp(p_year, 1, 1, 0,0,0) AS d0, + make_timestamp(p_year+1, 1, 1, 0,0,0) AS d1, + make_timestamptz(p_year, 1, 1, 0,0,0, p_tz) AS t0, + make_timestamptz(p_year+1, 1, 1, 0,0,0, p_tz) AS t1 +), +base AS ( + SELECT + c.id_comanda, + CASE WHEN c.fec_cierre IS NOT NULL + THEN (c.fec_cierre AT TIME ZONE p_tz) + ELSE c.fec_creacion + END AS fec_local, + v.total + FROM public.comandas c + JOIN public.vw_ticket_total v ON v.id_comanda = c.id_comanda + JOIN bounds b ON TRUE + WHERE + (c.fec_cierre IS NOT NULL AND c.fec_cierre >= b.t0 AND c.fec_cierre < b.t1) + OR + (c.fec_cierre IS NULL AND c.fec_creacion >= b.d0 AND c.fec_creacion < b.d1) +), +m AS ( + SELECT + EXTRACT(MONTH FROM fec_local)::int AS mes, + COUNT(*)::int AS cant, + SUM(total)::numeric AS importe, + AVG(total)::numeric AS avg + FROM base + GROUP BY 1 +), +ytd AS ( + SELECT COUNT(*)::int AS total_ytd, + AVG(total)::numeric AS avg_ticket, + SUM(total)::numeric AS to_date + FROM base +) +SELECT jsonb_build_object( + 'year', p_year, + 'total_ytd', (SELECT total_ytd FROM ytd), + 'avg_ticket', (SELECT avg_ticket FROM ytd), + 'to_date', (SELECT to_date FROM ytd), + 'months', + (SELECT jsonb_agg( + jsonb_build_object( + 'mes', mes, + 'nombre', to_char(to_date(mes::text,'MM'),'Mon'), + 'cant', cant, + 'importe', importe, + 'avg', avg + ) + ORDER BY mes + ) + FROM m) +); +$$; + + +-- +-- Name: save_compra(integer, integer, timestamp with time zone, jsonb); Type: FUNCTION; Schema: public; Owner: - +-- + +CREATE FUNCTION public.save_compra(p_id_compra integer, p_id_proveedor integer, p_fec_compra timestamp with time zone, p_detalles jsonb) RETURNS TABLE(id_compra integer, total numeric) + LANGUAGE plpgsql + AS $$ +DECLARE + v_id INT; + v_total numeric := 0; +BEGIN + IF COALESCE(jsonb_array_length(p_detalles),0) = 0 THEN + RAISE EXCEPTION 'No hay renglones en la compra'; + END IF; + + -- Cabecera (insert/update) + IF p_id_compra IS NULL THEN + INSERT INTO public.compras (id_proveedor, fec_compra, total) + VALUES (p_id_proveedor, COALESCE(p_fec_compra, now()), 0) + RETURNING public.compras.id_compra INTO v_id; + ELSE + UPDATE public.compras c + SET id_proveedor = p_id_proveedor, + fec_compra = COALESCE(p_fec_compra, c.fec_compra) + WHERE c.id_compra = p_id_compra + RETURNING c.id_compra INTO v_id; + + -- Reemplazamos los renglones + DELETE FROM public.deta_comp_materias d WHERE d.id_compra = v_id; + DELETE FROM public.deta_comp_producto p WHERE p.id_compra = v_id; + END IF; + + -- Materias primas (sin CTE: parseo JSON inline) + INSERT INTO public.deta_comp_materias (id_compra, id_mat_prima, cantidad, pre_unitario) + SELECT + v_id, + x.id, + x.cantidad, + x.precio + FROM jsonb_to_recordset(COALESCE(p_detalles, '[]'::jsonb)) + AS x(tipo text, id int, cantidad numeric, precio numeric) + WHERE UPPER(TRIM(x.tipo)) = 'MAT'; + + -- Productos (sin CTE) + INSERT INTO public.deta_comp_producto (id_compra, id_producto, cantidad, pre_unitario) + SELECT + v_id, + x.id, + x.cantidad, + x.precio + FROM jsonb_to_recordset(COALESCE(p_detalles, '[]'::jsonb)) + AS x(tipo text, id int, cantidad numeric, precio numeric) + WHERE UPPER(TRIM(x.tipo)) = 'PROD'; + + -- Recalcular total (calificado) y redondear a ENTERO + SELECT + COALESCE( (SELECT SUM(dcm.cantidad*dcm.pre_unitario) + FROM public.deta_comp_materias dcm + WHERE dcm.id_compra = v_id), 0) + + COALESCE( (SELECT SUM(dcp.cantidad*dcp.pre_unitario) + FROM public.deta_comp_producto dcp + WHERE dcp.id_compra = v_id), 0) + INTO v_total; + + UPDATE public.compras c + SET total = round(v_total, 0) + WHERE c.id_compra = v_id; + + RETURN QUERY SELECT v_id, round(v_total, 0); +END; +$$; + + +-- +-- Name: save_materia_prima(integer, text, text, boolean, jsonb); Type: FUNCTION; Schema: public; Owner: - +-- + +CREATE FUNCTION public.save_materia_prima(p_id_mat_prima integer, p_nombre text, p_unidad text, p_activo boolean, p_proveedores jsonb DEFAULT '[]'::jsonb) RETURNS integer + LANGUAGE plpgsql + AS $_$ +DECLARE + v_id INT; +BEGIN + IF p_id_mat_prima IS NULL THEN + INSERT INTO public.mate_primas (nombre, unidad, activo) + VALUES (p_nombre, p_unidad, COALESCE(p_activo, TRUE)) + RETURNING mate_primas.id_mat_prima INTO v_id; + ELSE + UPDATE public.mate_primas mp + SET nombre = p_nombre, + unidad = p_unidad, + activo = COALESCE(p_activo, TRUE) + WHERE mp.id_mat_prima = p_id_mat_prima; + v_id := p_id_mat_prima; + END IF; + + -- Sincronizar proveedores: borrar todos y re-crear a partir de JSONB + DELETE FROM public.prov_mate_prima pmp WHERE pmp.id_mat_prima = v_id; + + INSERT INTO public.prov_mate_prima (id_proveedor, id_mat_prima) + SELECT (e->>0)::INT AS id_proveedor, -- elementos JSON como enteros (array simple) + v_id AS id_mat_prima + FROM jsonb_array_elements(COALESCE(p_proveedores, '[]'::jsonb)) AS e + WHERE (e->>0) ~ '^\d+$'; -- solo enteros + + RETURN v_id; +END; +$_$; + + +-- +-- Name: save_producto(integer, text, text, numeric, boolean, integer, jsonb); Type: FUNCTION; Schema: public; Owner: - +-- + +CREATE FUNCTION public.save_producto(p_id_producto integer, p_nombre text, p_img_producto text, p_precio numeric, p_activo boolean, p_id_categoria integer, p_receta jsonb DEFAULT '[]'::jsonb) RETURNS integer + LANGUAGE plpgsql + AS $_$ +DECLARE + v_id INT; +BEGIN + IF p_id_producto IS NULL THEN + INSERT INTO public.productos (nombre, img_producto, precio, activo, id_categoria) + VALUES (p_nombre, p_img_producto, p_precio, COALESCE(p_activo, TRUE), p_id_categoria) + RETURNING productos.id_producto INTO v_id; + ELSE + UPDATE public.productos p + SET nombre = p_nombre, + img_producto = p_img_producto, + precio = p_precio, + activo = COALESCE(p_activo, TRUE), + id_categoria = p_id_categoria + WHERE p.id_producto = p_id_producto; + v_id := p_id_producto; + END IF; + + -- Limpia receta actual + DELETE FROM public.receta_producto rp WHERE rp.id_producto = v_id; + + -- Inserta SOLO ítems válidos (id entero positivo y cantidad > 0), redondeo a 3 decimales + INSERT INTO public.receta_producto (id_producto, id_mat_prima, qty_por_unidad) + SELECT + v_id, + (rec->>'id_mat_prima')::INT, + ROUND((rec->>'qty_por_unidad')::NUMERIC, 3) + FROM jsonb_array_elements(COALESCE(p_receta, '[]'::jsonb)) AS rec + WHERE + (rec->>'id_mat_prima') ~ '^\d+$' + AND (rec->>'id_mat_prima')::INT > 0 + AND (rec->>'qty_por_unidad') ~ '^\d+(\.\d+)?$' + AND (rec->>'qty_por_unidad')::NUMERIC > 0; + + RETURN v_id; +END; +$_$; + + +-- +-- Name: asistencia_intervalo; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.asistencia_intervalo ( + id_intervalo bigint NOT NULL, + id_usuario integer NOT NULL, + fecha date NOT NULL, + desde timestamp with time zone NOT NULL, + hasta timestamp with time zone NOT NULL, + dur_min numeric(10,2) NOT NULL, + origen text, + created_at timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT chk_ai_orden CHECK ((hasta > desde)) +); + + +-- +-- Name: asistencia_intervalo_id_intervalo_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.asistencia_intervalo_id_intervalo_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: asistencia_intervalo_id_intervalo_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.asistencia_intervalo_id_intervalo_seq OWNED BY public.asistencia_intervalo.id_intervalo; + + +-- +-- Name: asistencia_raw; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.asistencia_raw ( + id_raw bigint NOT NULL, + id_usuario integer NOT NULL, + ts timestamp with time zone NOT NULL, + modo text, + origen text, + created_at timestamp with time zone DEFAULT now() NOT NULL +); + + +-- +-- Name: asistencia_raw_id_raw_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.asistencia_raw_id_raw_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: asistencia_raw_id_raw_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.asistencia_raw_id_raw_seq OWNED BY public.asistencia_raw.id_raw; + + +-- +-- Name: asistencia_resumen_diario; Type: VIEW; Schema: public; Owner: - +-- + +CREATE VIEW public.asistencia_resumen_diario AS + SELECT ai.id_usuario, + u.documento, + u.nombre, + u.apellido, + ai.fecha, + sum(ai.dur_min) AS minutos_dia, + round((sum(ai.dur_min) / 60.0), 2) AS horas_dia, + count(*) AS pares_dia + FROM (public.asistencia_intervalo ai + JOIN public.usuarios u USING (id_usuario)) + GROUP BY ai.id_usuario, u.documento, u.nombre, u.apellido, ai.fecha + ORDER BY ai.id_usuario, ai.fecha; + + +-- +-- Name: categorias; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.categorias ( + id_categoria integer NOT NULL, + nombre text NOT NULL, + visible boolean DEFAULT true +); + + +-- +-- Name: categorias_id_categoria_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.categorias_id_categoria_seq + AS integer + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: categorias_id_categoria_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.categorias_id_categoria_seq OWNED BY public.categorias.id_categoria; + + +-- +-- Name: clientes; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.clientes ( + id_cliente integer NOT NULL, + nombre text NOT NULL, + correo text, + telefono text, + fec_nacimiento date, + activo boolean DEFAULT true +); + + +-- +-- Name: clientes_id_cliente_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.clientes_id_cliente_seq + AS integer + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: clientes_id_cliente_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.clientes_id_cliente_seq OWNED BY public.clientes.id_cliente; + + +-- +-- Name: comandas_id_comanda_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.comandas_id_comanda_seq + AS integer + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: comandas_id_comanda_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.comandas_id_comanda_seq OWNED BY public.comandas.id_comanda; + + +-- +-- Name: compras; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.compras ( + id_compra integer NOT NULL, + id_proveedor integer NOT NULL, + fec_compra timestamp without time zone NOT NULL, + total numeric(14,2) +); + + +-- +-- Name: compras_id_compra_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.compras_id_compra_seq + AS integer + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: compras_id_compra_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.compras_id_compra_seq OWNED BY public.compras.id_compra; + + +-- +-- Name: deta_comandas_id_det_comanda_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.deta_comandas_id_det_comanda_seq + AS integer + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: deta_comandas_id_det_comanda_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.deta_comandas_id_det_comanda_seq OWNED BY public.deta_comandas.id_det_comanda; + + +-- +-- Name: deta_comp_materias; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.deta_comp_materias ( + id_compra integer NOT NULL, + id_mat_prima integer NOT NULL, + cantidad numeric(12,3) NOT NULL, + pre_unitario numeric(12,2) NOT NULL, + CONSTRAINT deta_comp_materias_cantidad_check CHECK ((cantidad > (0)::numeric)), + CONSTRAINT deta_comp_materias_pre_unitario_check CHECK ((pre_unitario >= (0)::numeric)) +); + + +-- +-- Name: deta_comp_producto; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.deta_comp_producto ( + id_compra integer NOT NULL, + id_producto integer NOT NULL, + cantidad numeric(12,3) NOT NULL, + pre_unitario numeric(12,2) NOT NULL, + CONSTRAINT deta_comp_producto_cantidad_check CHECK ((cantidad > (0)::numeric)), + CONSTRAINT deta_comp_producto_pre_unitario_check CHECK ((pre_unitario >= (0)::numeric)) +); + + +-- +-- Name: mate_primas; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.mate_primas ( + id_mat_prima integer NOT NULL, + nombre text NOT NULL, + unidad text NOT NULL, + activo boolean DEFAULT true +); + + +-- +-- Name: mate_primas_id_mat_prima_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.mate_primas_id_mat_prima_seq + AS integer + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: mate_primas_id_mat_prima_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.mate_primas_id_mat_prima_seq OWNED BY public.mate_primas.id_mat_prima; + + +-- +-- Name: mesas_id_mesa_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.mesas_id_mesa_seq + AS integer + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: mesas_id_mesa_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.mesas_id_mesa_seq OWNED BY public.mesas.id_mesa; + + +-- +-- Name: productos; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.productos ( + id_producto integer NOT NULL, + nombre text NOT NULL, + img_producto character varying(255) DEFAULT 'img/productos/img_producto.png'::character varying NOT NULL, + precio integer NOT NULL, + activo boolean DEFAULT true, + id_categoria integer NOT NULL, + CONSTRAINT productos_precio_check CHECK (((precio)::numeric >= (0)::numeric)), + CONSTRAINT productos_precio_nn CHECK ((precio >= 0)) +); + + +-- +-- Name: productos_id_producto_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.productos_id_producto_seq + AS integer + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: productos_id_producto_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.productos_id_producto_seq OWNED BY public.productos.id_producto; + + +-- +-- Name: prov_mate_prima; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.prov_mate_prima ( + id_proveedor integer NOT NULL, + id_mat_prima integer NOT NULL +); + + +-- +-- Name: prov_producto; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.prov_producto ( + id_proveedor integer NOT NULL, + id_producto integer NOT NULL +); + + +-- +-- Name: proveedores; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.proveedores ( + id_proveedor integer NOT NULL, + rut text, + raz_social text NOT NULL, + direccion text, + contacto text +); + + +-- +-- Name: proveedores_id_proveedor_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.proveedores_id_proveedor_seq + AS integer + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: proveedores_id_proveedor_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.proveedores_id_proveedor_seq OWNED BY public.proveedores.id_proveedor; + + +-- +-- Name: receta_producto; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.receta_producto ( + id_producto integer NOT NULL, + id_mat_prima integer NOT NULL, + qty_por_unidad numeric(12,3) NOT NULL, + CONSTRAINT receta_producto_qty_por_unidad_check CHECK ((qty_por_unidad > (0)::numeric)) +); + + +-- +-- Name: roles; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.roles ( + id_rol integer NOT NULL, + nombre text NOT NULL +); + + +-- +-- Name: roles_id_rol_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.roles_id_rol_seq + AS integer + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: roles_id_rol_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.roles_id_rol_seq OWNED BY public.roles.id_rol; + + +-- +-- Name: usua_roles; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.usua_roles ( + id_usuario integer NOT NULL, + id_rol integer NOT NULL, + fec_asignacion timestamp without time zone DEFAULT now(), + autor integer, + activo boolean DEFAULT true +); + + +-- +-- Name: usuarios_id_usuario_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.usuarios_id_usuario_seq + AS integer + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: usuarios_id_usuario_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.usuarios_id_usuario_seq OWNED BY public.usuarios.id_usuario; + + +-- +-- Name: v_comandas_detalle_base; Type: VIEW; Schema: public; Owner: - +-- + +CREATE VIEW public.v_comandas_detalle_base AS + SELECT c.id_comanda, + c.fec_creacion, + c.fec_cierre, + c.estado, + c.observaciones, + u.id_usuario, + u.nombre AS usuario_nombre, + u.apellido AS usuario_apellido, + m.id_mesa, + m.numero AS mesa_numero, + m.apodo AS mesa_apodo, + d.id_producto, + p.nombre AS producto_nombre, + d.cantidad, + d.pre_unitario, + (d.cantidad * d.pre_unitario) AS subtotal + FROM ((((public.comandas c + JOIN public.usuarios u ON ((u.id_usuario = c.id_usuario))) + JOIN public.mesas m ON ((m.id_mesa = c.id_mesa))) + LEFT JOIN public.deta_comandas d ON ((d.id_comanda = c.id_comanda))) + LEFT JOIN public.productos p ON ((p.id_producto = d.id_producto))); + + +-- +-- Name: v_comandas_detalle_items; Type: VIEW; Schema: public; Owner: - +-- + +CREATE VIEW public.v_comandas_detalle_items AS + SELECT d.id_comanda, + d.id_det_comanda, + d.id_producto, + p.nombre AS producto_nombre, + d.cantidad, + d.pre_unitario, + (d.cantidad * d.pre_unitario) AS subtotal, + d.observaciones + FROM (public.deta_comandas d + JOIN public.productos p ON ((p.id_producto = d.id_producto))); + + +-- +-- Name: v_comandas_detalle_json; Type: VIEW; Schema: public; Owner: - +-- + +CREATE VIEW public.v_comandas_detalle_json AS + SELECT id_comanda, + jsonb_build_object('id_comanda', id_comanda, 'fec_creacion', fec_creacion, 'fec_cierre', fec_cierre, 'estado', estado, 'observaciones', observaciones, 'usuario', jsonb_build_object('id_usuario', id_usuario, 'nombre', usuario_nombre, 'apellido', usuario_apellido), 'mesa', jsonb_build_object('id_mesa', id_mesa, 'numero', mesa_numero, 'apodo', mesa_apodo), 'items', COALESCE(( SELECT jsonb_agg(jsonb_build_object('producto_id', b.id_producto, 'producto', b.producto_nombre, 'cantidad', b.cantidad, 'pre_unitario', b.pre_unitario, 'subtotal', b.subtotal) ORDER BY b.producto_nombre) AS jsonb_agg + FROM public.v_comandas_detalle_base b + WHERE ((b.id_comanda = h.id_comanda) AND (b.id_producto IS NOT NULL))), '[]'::jsonb), 'totales', jsonb_build_object('items', COALESCE(( SELECT count(*) AS count + FROM public.v_comandas_detalle_base b + WHERE ((b.id_comanda = h.id_comanda) AND (b.id_producto IS NOT NULL))), (0)::bigint), 'total', COALESCE(( SELECT sum(b.subtotal) AS sum + FROM public.v_comandas_detalle_base b + WHERE (b.id_comanda = h.id_comanda)), (0)::numeric))) AS data + FROM ( SELECT DISTINCT v_comandas_detalle_base.id_comanda, + v_comandas_detalle_base.fec_creacion, + v_comandas_detalle_base.fec_cierre, + v_comandas_detalle_base.estado, + v_comandas_detalle_base.observaciones, + v_comandas_detalle_base.id_usuario, + v_comandas_detalle_base.usuario_nombre, + v_comandas_detalle_base.usuario_apellido, + v_comandas_detalle_base.id_mesa, + v_comandas_detalle_base.mesa_numero, + v_comandas_detalle_base.mesa_apodo + FROM public.v_comandas_detalle_base) h; + + +-- +-- Name: vw_compras; Type: VIEW; Schema: public; Owner: - +-- + +CREATE VIEW public.vw_compras AS + SELECT c.id_compra, + c.id_proveedor, + p.raz_social AS proveedor, + c.fec_compra, + c.total + FROM (public.compras c + JOIN public.proveedores p USING (id_proveedor)) + ORDER BY c.fec_compra DESC, c.id_compra DESC; + + +-- +-- Name: vw_ticket_total; Type: VIEW; Schema: public; Owner: - +-- + +CREATE VIEW public.vw_ticket_total AS + WITH lineas AS ( + SELECT c.id_comanda, + COALESCE(c.fec_cierre, (c.fec_creacion)::timestamp with time zone) AS fec_ticket, + (COALESCE(dc.pre_unitario, (p.precio)::numeric, (0)::numeric))::numeric(14,2) AS pu, + (COALESCE(dc.cantidad, (1)::numeric))::numeric(14,3) AS qty + FROM ((public.comandas c + JOIN public.deta_comandas dc ON ((dc.id_comanda = c.id_comanda))) + LEFT JOIN public.productos p ON ((p.id_producto = dc.id_producto))) + ) + SELECT id_comanda, + fec_ticket, + (sum((qty * pu)))::numeric(14,2) AS total + FROM lineas + GROUP BY id_comanda, fec_ticket; + + +-- +-- Name: asistencia_intervalo id_intervalo; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.asistencia_intervalo ALTER COLUMN id_intervalo SET DEFAULT nextval('public.asistencia_intervalo_id_intervalo_seq'::regclass); + + +-- +-- Name: asistencia_raw id_raw; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.asistencia_raw ALTER COLUMN id_raw SET DEFAULT nextval('public.asistencia_raw_id_raw_seq'::regclass); + + +-- +-- Name: categorias id_categoria; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.categorias ALTER COLUMN id_categoria SET DEFAULT nextval('public.categorias_id_categoria_seq'::regclass); + + +-- +-- Name: clientes id_cliente; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.clientes ALTER COLUMN id_cliente SET DEFAULT nextval('public.clientes_id_cliente_seq'::regclass); + + +-- +-- Name: comandas id_comanda; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.comandas ALTER COLUMN id_comanda SET DEFAULT nextval('public.comandas_id_comanda_seq'::regclass); + + +-- +-- Name: compras id_compra; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.compras ALTER COLUMN id_compra SET DEFAULT nextval('public.compras_id_compra_seq'::regclass); + + +-- +-- Name: deta_comandas id_det_comanda; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.deta_comandas ALTER COLUMN id_det_comanda SET DEFAULT nextval('public.deta_comandas_id_det_comanda_seq'::regclass); + + +-- +-- Name: mate_primas id_mat_prima; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.mate_primas ALTER COLUMN id_mat_prima SET DEFAULT nextval('public.mate_primas_id_mat_prima_seq'::regclass); + + +-- +-- Name: mesas id_mesa; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.mesas ALTER COLUMN id_mesa SET DEFAULT nextval('public.mesas_id_mesa_seq'::regclass); + + +-- +-- Name: productos id_producto; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.productos ALTER COLUMN id_producto SET DEFAULT nextval('public.productos_id_producto_seq'::regclass); + + +-- +-- Name: proveedores id_proveedor; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.proveedores ALTER COLUMN id_proveedor SET DEFAULT nextval('public.proveedores_id_proveedor_seq'::regclass); + + +-- +-- Name: roles id_rol; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.roles ALTER COLUMN id_rol SET DEFAULT nextval('public.roles_id_rol_seq'::regclass); + + +-- +-- Name: usuarios id_usuario; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.usuarios ALTER COLUMN id_usuario SET DEFAULT nextval('public.usuarios_id_usuario_seq'::regclass); + + +-- +-- Name: asistencia_intervalo asistencia_intervalo_id_usuario_desde_hasta_key; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.asistencia_intervalo + ADD CONSTRAINT asistencia_intervalo_id_usuario_desde_hasta_key UNIQUE (id_usuario, desde, hasta); + + +-- +-- Name: asistencia_intervalo asistencia_intervalo_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.asistencia_intervalo + ADD CONSTRAINT asistencia_intervalo_pkey PRIMARY KEY (id_intervalo); + + +-- +-- Name: asistencia_raw asistencia_raw_id_usuario_ts_key; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.asistencia_raw + ADD CONSTRAINT asistencia_raw_id_usuario_ts_key UNIQUE (id_usuario, ts); + + +-- +-- Name: asistencia_raw asistencia_raw_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.asistencia_raw + ADD CONSTRAINT asistencia_raw_pkey PRIMARY KEY (id_raw); + + +-- +-- Name: categorias categorias_nombre_key; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.categorias + ADD CONSTRAINT categorias_nombre_key UNIQUE (nombre); + + +-- +-- Name: categorias categorias_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.categorias + ADD CONSTRAINT categorias_pkey PRIMARY KEY (id_categoria); + + +-- +-- Name: clientes clientes_correo_key; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.clientes + ADD CONSTRAINT clientes_correo_key UNIQUE (correo); + + +-- +-- Name: clientes clientes_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.clientes + ADD CONSTRAINT clientes_pkey PRIMARY KEY (id_cliente); + + +-- +-- Name: clientes clientes_telefono_key; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.clientes + ADD CONSTRAINT clientes_telefono_key UNIQUE (telefono); + + +-- +-- Name: comandas comandas_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.comandas + ADD CONSTRAINT comandas_pkey PRIMARY KEY (id_comanda); + + +-- +-- Name: compras compras_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.compras + ADD CONSTRAINT compras_pkey PRIMARY KEY (id_compra); + + +-- +-- Name: deta_comandas deta_comandas_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.deta_comandas + ADD CONSTRAINT deta_comandas_pkey PRIMARY KEY (id_det_comanda); + + +-- +-- Name: deta_comp_materias deta_comp_materias_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.deta_comp_materias + ADD CONSTRAINT deta_comp_materias_pkey PRIMARY KEY (id_compra, id_mat_prima); + + +-- +-- Name: deta_comp_producto deta_comp_producto_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.deta_comp_producto + ADD CONSTRAINT deta_comp_producto_pkey PRIMARY KEY (id_compra, id_producto); + + +-- +-- Name: mate_primas mate_primas_nombre_key; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.mate_primas + ADD CONSTRAINT mate_primas_nombre_key UNIQUE (nombre); + + +-- +-- Name: mate_primas mate_primas_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.mate_primas + ADD CONSTRAINT mate_primas_pkey PRIMARY KEY (id_mat_prima); + + +-- +-- Name: mesas mesas_apodo_key; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.mesas + ADD CONSTRAINT mesas_apodo_key UNIQUE (apodo); + + +-- +-- Name: mesas mesas_numero_key; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.mesas + ADD CONSTRAINT mesas_numero_key UNIQUE (numero); + + +-- +-- Name: mesas mesas_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.mesas + ADD CONSTRAINT mesas_pkey PRIMARY KEY (id_mesa); + + +-- +-- Name: productos productos_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.productos + ADD CONSTRAINT productos_pkey PRIMARY KEY (id_producto); + + +-- +-- Name: prov_mate_prima prov_mate_prima_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.prov_mate_prima + ADD CONSTRAINT prov_mate_prima_pkey PRIMARY KEY (id_proveedor, id_mat_prima); + + +-- +-- Name: prov_producto prov_producto_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.prov_producto + ADD CONSTRAINT prov_producto_pkey PRIMARY KEY (id_proveedor, id_producto); + + +-- +-- Name: proveedores proveedores_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.proveedores + ADD CONSTRAINT proveedores_pkey PRIMARY KEY (id_proveedor); + + +-- +-- Name: proveedores proveedores_rut_key; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.proveedores + ADD CONSTRAINT proveedores_rut_key UNIQUE (rut); + + +-- +-- Name: receta_producto receta_producto_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.receta_producto + ADD CONSTRAINT receta_producto_pkey PRIMARY KEY (id_producto, id_mat_prima); + + +-- +-- Name: roles roles_nombre_key; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.roles + ADD CONSTRAINT roles_nombre_key UNIQUE (nombre); + + +-- +-- Name: roles roles_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.roles + ADD CONSTRAINT roles_pkey PRIMARY KEY (id_rol); + + +-- +-- Name: usua_roles usua_roles_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.usua_roles + ADD CONSTRAINT usua_roles_pkey PRIMARY KEY (id_usuario, id_rol); + + +-- +-- Name: usuarios usuarios_documento_key; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.usuarios + ADD CONSTRAINT usuarios_documento_key UNIQUE (documento); + + +-- +-- Name: usuarios usuarios_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.usuarios + ADD CONSTRAINT usuarios_pkey PRIMARY KEY (id_usuario); + + +-- +-- Name: compras_fec_compra_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX compras_fec_compra_idx ON public.compras USING btree (fec_compra); + + +-- +-- Name: idx_asist_int_usuario_fecha; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_asist_int_usuario_fecha ON public.asistencia_intervalo USING btree (id_usuario, fecha); + + +-- +-- Name: idx_asist_raw_usuario_ts; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_asist_raw_usuario_ts ON public.asistencia_raw USING btree (id_usuario, ts); + + +-- +-- Name: idx_detalle_comanda_comanda; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_detalle_comanda_comanda ON public.deta_comandas USING btree (id_comanda); + + +-- +-- Name: idx_detalle_comanda_producto; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_detalle_comanda_producto ON public.deta_comandas USING btree (id_producto); + + +-- +-- Name: ix_comandas_fec_cierre; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX ix_comandas_fec_cierre ON public.comandas USING btree (fec_cierre); + + +-- +-- Name: ix_comandas_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX ix_comandas_id ON public.comandas USING btree (id_comanda); + + +-- +-- Name: ix_deta_comandas_id_comanda; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX ix_deta_comandas_id_comanda ON public.deta_comandas USING btree (id_comanda); + + +-- +-- Name: ix_deta_comandas_id_producto; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX ix_deta_comandas_id_producto ON public.deta_comandas USING btree (id_producto); + + +-- +-- Name: asistencia_intervalo asistencia_intervalo_id_usuario_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.asistencia_intervalo + ADD CONSTRAINT asistencia_intervalo_id_usuario_fkey FOREIGN KEY (id_usuario) REFERENCES public.usuarios(id_usuario) ON DELETE CASCADE; + + +-- +-- Name: asistencia_raw asistencia_raw_id_usuario_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.asistencia_raw + ADD CONSTRAINT asistencia_raw_id_usuario_fkey FOREIGN KEY (id_usuario) REFERENCES public.usuarios(id_usuario) ON DELETE CASCADE; + + +-- +-- Name: comandas comandas_id_mesa_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.comandas + ADD CONSTRAINT comandas_id_mesa_fkey FOREIGN KEY (id_mesa) REFERENCES public.mesas(id_mesa); + + +-- +-- Name: comandas comandas_id_usuario_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.comandas + ADD CONSTRAINT comandas_id_usuario_fkey FOREIGN KEY (id_usuario) REFERENCES public.usuarios(id_usuario); + + +-- +-- Name: compras compras_id_proveedor_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.compras + ADD CONSTRAINT compras_id_proveedor_fkey FOREIGN KEY (id_proveedor) REFERENCES public.proveedores(id_proveedor); + + +-- +-- Name: deta_comandas deta_comandas_id_comanda_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.deta_comandas + ADD CONSTRAINT deta_comandas_id_comanda_fkey FOREIGN KEY (id_comanda) REFERENCES public.comandas(id_comanda) ON DELETE CASCADE; + + +-- +-- Name: deta_comandas deta_comandas_id_producto_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.deta_comandas + ADD CONSTRAINT deta_comandas_id_producto_fkey FOREIGN KEY (id_producto) REFERENCES public.productos(id_producto); + + +-- +-- Name: deta_comp_materias deta_comp_materias_id_compra_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.deta_comp_materias + ADD CONSTRAINT deta_comp_materias_id_compra_fkey FOREIGN KEY (id_compra) REFERENCES public.compras(id_compra) ON DELETE CASCADE; + + +-- +-- Name: deta_comp_materias deta_comp_materias_id_mat_prima_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.deta_comp_materias + ADD CONSTRAINT deta_comp_materias_id_mat_prima_fkey FOREIGN KEY (id_mat_prima) REFERENCES public.mate_primas(id_mat_prima); + + +-- +-- Name: deta_comp_producto deta_comp_producto_id_compra_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.deta_comp_producto + ADD CONSTRAINT deta_comp_producto_id_compra_fkey FOREIGN KEY (id_compra) REFERENCES public.compras(id_compra) ON DELETE CASCADE; + + +-- +-- Name: deta_comp_producto deta_comp_producto_id_producto_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.deta_comp_producto + ADD CONSTRAINT deta_comp_producto_id_producto_fkey FOREIGN KEY (id_producto) REFERENCES public.productos(id_producto); + + +-- +-- Name: productos productos_id_categoria_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.productos + ADD CONSTRAINT productos_id_categoria_fkey FOREIGN KEY (id_categoria) REFERENCES public.categorias(id_categoria); + + +-- +-- Name: prov_mate_prima prov_mate_prima_id_mat_prima_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.prov_mate_prima + ADD CONSTRAINT prov_mate_prima_id_mat_prima_fkey FOREIGN KEY (id_mat_prima) REFERENCES public.mate_primas(id_mat_prima); + + +-- +-- Name: prov_mate_prima prov_mate_prima_id_proveedor_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.prov_mate_prima + ADD CONSTRAINT prov_mate_prima_id_proveedor_fkey FOREIGN KEY (id_proveedor) REFERENCES public.proveedores(id_proveedor) ON DELETE CASCADE; + + +-- +-- Name: prov_producto prov_producto_id_producto_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.prov_producto + ADD CONSTRAINT prov_producto_id_producto_fkey FOREIGN KEY (id_producto) REFERENCES public.productos(id_producto); + + +-- +-- Name: prov_producto prov_producto_id_proveedor_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.prov_producto + ADD CONSTRAINT prov_producto_id_proveedor_fkey FOREIGN KEY (id_proveedor) REFERENCES public.proveedores(id_proveedor) ON DELETE CASCADE; + + +-- +-- Name: receta_producto receta_producto_id_mat_prima_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.receta_producto + ADD CONSTRAINT receta_producto_id_mat_prima_fkey FOREIGN KEY (id_mat_prima) REFERENCES public.mate_primas(id_mat_prima); + + +-- +-- Name: receta_producto receta_producto_id_producto_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.receta_producto + ADD CONSTRAINT receta_producto_id_producto_fkey FOREIGN KEY (id_producto) REFERENCES public.productos(id_producto) ON DELETE CASCADE; + + +-- +-- Name: usua_roles usua_roles_autor_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.usua_roles + ADD CONSTRAINT usua_roles_autor_fkey FOREIGN KEY (autor) REFERENCES public.usuarios(id_usuario); + + +-- +-- Name: usua_roles usua_roles_id_rol_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.usua_roles + ADD CONSTRAINT usua_roles_id_rol_fkey FOREIGN KEY (id_rol) REFERENCES public.roles(id_rol); + + +-- +-- Name: usua_roles usua_roles_id_usuario_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.usua_roles + ADD CONSTRAINT usua_roles_id_usuario_fkey FOREIGN KEY (id_usuario) REFERENCES public.usuarios(id_usuario) ON DELETE CASCADE; + + +-- +-- PostgreSQL database dump complete +-- + + +COMMIT; diff --git a/services/auth/src/index.js b/services/auth/src/index.js index 777e0f8..d596bb8 100644 --- a/services/auth/src/index.js +++ b/services/auth/src/index.js @@ -4,342 +4,35 @@ // - ESM compatible (Node >=18) // - Sesiones con Redis (compartibles con otros servicios) // - Vistas EJS (login) -// - Rutas OIDC: /auth/login, /auth/callback, /auth/logout // - Registro de usuario: /auth/api/users/register (DB + Authentik) // ------------------------------------------------------------ import 'dotenv/config'; -import chalk from 'chalk'; -import express from 'express'; -import cors from 'cors'; import path from 'node:path'; -import { access, readFile } from 'node:fs/promises'; -import { constants as fsConstants } from 'node:fs'; +import fs from 'node:fs/promises'; import { fileURLToPath } from 'node:url'; import { Pool } from 'pg'; -import session from 'express-session'; -import { createClient } from 'redis'; -import expressLayouts from 'express-ejs-layouts'; -import { Issuer, generators } from 'openid-client'; +import express from 'express'; + import crypto from 'node:crypto'; +import fetch from "node-fetch"; + +import { createRedisSession } from "../shared/middlewares/redisConnect.js"; // ----------------------------------------------------------------------------- -// Importaciones desde archivos +// Variables globales // ----------------------------------------------------------------------------- -// Helpers de Authentik (admin API) -import { akFindUserByEmail, akCreateUser, - akSetPassword, akResolveGroupId } from './ak.js'; - +const PORT = process.env.PORT || 4040; +const ISSUER = process.env.AUTHENTIK_ISSUER?.replace(/\/?$/, "/"); // asegura barra final +const CLIENT_ID = process.env.OIDC_CLIENT_ID; +const CLIENT_SECRET = process.env.OIDC_CLIENT_SECRET; +const REDIRECT_URI = process.env.OIDC_REDIRECT_URI || process.env.AUTH_CALLBACK_URL; +const APP_BASE_URL = process.env.APP_BASE_URL || "http://localhost:3030"; // ----------------------------------------------------------------------------- -// Helpers +// Utilidades / Helpers // ----------------------------------------------------------------------------- -// Normaliza UUID (acepta con/sin guiones) → "hex" sin guiones -const cleanUuid = (u) => (u ? String(u).toLowerCase().replace(/[^0-9a-f]/g, '') : ''); - -// Nombre de schema/rol a partir de uuid limpio -const schemaNameFor = (uuidHex) => `schema_tenant_${uuidHex}`; -const roleNameFor = (uuidHex) => `tenant_${uuidHex}`; - -// Quoter seguro de identificadores SQL (roles, schemas, tablas) -// Identificador SQL (schema, role, table, …) -const qi = (ident) => `"${String(ident).replace(/"/g, '""')}"`; - -// Literal de texto SQL (valores: contraseñas, strings, …) -const qs = (val) => `'${String(val).replace(/'/g, "''")}'`; - - -const VALID_IDENT = /^[a-zA-Z_][a-zA-Z0-9_:$-]*$/; - -// --- Resolver y cachear el grupo por ID/UUID/NOMBRE una sola vez --- -let DEFAULT_GROUP_ID = process.env.AUTHENTIK_DEFAULT_GROUP_ID - ? Number(process.env.AUTHENTIK_DEFAULT_GROUP_ID) - : null; - -if (!DEFAULT_GROUP_ID) { - (async () => { - try { - // Si tenés akResolveGroupIdByName, usalo: - // DEFAULT_GROUP_ID = await akResolveGroupIdByName(process.env.AUTHENTIK_DEFAULT_GROUP_NAME); - - // Con el helper genérico que te dejé en ak.js: - DEFAULT_GROUP_ID = await akResolveGroupId({ - uuid: process.env.AUTHENTIK_DEFAULT_GROUP_UUID, - name: process.env.AUTHENTIK_DEFAULT_GROUP_NAME, - }); - console.log('[AK] DEFAULT_GROUP_ID resuelto:', DEFAULT_GROUP_ID); - } catch (e) { - console.warn('[AK] No se pudo resolver DEFAULT_GROUP_ID:', e?.message || e); - } - })(); -} - -function nukeSession(req, res, redirectTo = '/auth/login', reason = 'reset') { - try { - // Destruye la sesión en el store (Redis) - req.session?.destroy(() => { - // Limpia la cookie en el navegador - res.clearCookie(SESSION_COOKIE_NAME, { - path: '/', - httpOnly: true, - sameSite: 'lax', - secure: process.env.NODE_ENV === 'production', - }); - // Reinicia el flujo - return res.redirect(303, `${redirectTo}?reason=${encodeURIComponent(reason)}`); - }); - } catch { - // Si algo falla, al menos intentamos redirigir - return res.redirect(303, `${redirectTo}?reason=${encodeURIComponent(reason)}`); - } -} - -// Verificar existencia del tenant sin crear (en la DB de tenants) -async function tenantExists(uuidHex) { - if (!uuidHex) return false; - const schema = schemaNameFor(uuidHex); - const client = await tenantsPool.connect(); - try { - const q = await client.query( - 'SELECT 1 FROM information_schema.schemata WHERE schema_name=$1', - [schema] - ); - return q.rowCount > 0; - } finally { - client.release(); - } -} - -// Intenta obtener el tenant por orden: -// 1) DB principal (app_user por email) -// 2) Authentik (attributes.tenant_uuid del usuario) -// 3) valor provisto en el request (si viene) -async function resolveExistingTenantUuid({ email, requestedTenantUuid }) { - const normEmail = String(email).trim().toLowerCase(); - - // 1) DB principal - const dbRes = await pool.query( - 'SELECT tenant_uuid FROM app_user WHERE LOWER(email)=LOWER($1) LIMIT 1', - [normEmail] - ); - if (dbRes.rowCount) { - const fromDb = cleanUuid(dbRes.rows[0].tenant_uuid); - if (fromDb) return fromDb; - } - - // 2) Authentik - const akUser = await akFindUserByEmail(normEmail).catch(() => null); - const fromAk = cleanUuid(akUser?.attributes?.tenant_uuid); - if (fromAk) return fromAk; - - // 3) Pedido del request - const fromReq = cleanUuid(requestedTenantUuid); - if (fromReq) return fromReq; - - return null; // no hay tenant conocido -} - -// Helper para crear tenant si falta -async function ensureTenant({ tenant_uuid }) { - const admin = await tenantsPool.connect(); - try { - await admin.query('BEGIN'); - - // uuid y nombres - const uuid = (tenant_uuid || crypto.randomUUID()).toLowerCase(); - const hex = uuid.replace(/-/g, ''); - if (!/^[a-f0-9]{32}$/.test(hex)) throw new Error('tenant_uuid inválido'); - - const schema = `schema_tenant_${hex}`; - const role = `tenant_${hex}`; - const pwd = crypto.randomBytes(18).toString('base64url'); - - if (!VALID_IDENT.test(schema) || !VALID_IDENT.test(role)) { - throw new Error('Identificador de schema/rol inválido'); - } - - // 1) Crear ROL si no existe (PASSWORD debe ser LITERAL, no parámetro) - const r = await admin.query('SELECT 1 FROM pg_roles WHERE rolname=$1', [role]); - if (!r.rowCount) { - await admin.query(`CREATE ROLE ${qi(role)} LOGIN PASSWORD ${qs(pwd)}`); - // Si quisieras rotarla luego: - // await admin.query(`ALTER ROLE ${qi(role)} PASSWORD ${qs(pwd)}`); - } - - // 2) Crear SCHEMA si no existe y asignar owner - const s = await admin.query( - 'SELECT 1 FROM information_schema.schemata WHERE schema_name=$1', - [schema] - ); - if (!s.rowCount) { - await admin.query(`CREATE SCHEMA ${qi(schema)} AUTHORIZATION ${qi(role)}`); - } else { - await admin.query(`ALTER SCHEMA ${qi(schema)} OWNER TO ${qi(role)}`); - } - - // 3) Permisos por defecto - await admin.query(`GRANT USAGE ON SCHEMA ${qi(schema)} TO ${qi(role)}`); - await admin.query( - `ALTER DEFAULT PRIVILEGES IN SCHEMA ${qi(schema)} - GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO ${qi(role)}` - ); - await admin.query( - `ALTER DEFAULT PRIVILEGES IN SCHEMA ${qi(schema)} - GRANT USAGE, SELECT, UPDATE ON SEQUENCES TO ${qi(role)}` - ); - - // 4) Aplicar 01_init.sql en la misma transacción - const initSql = await loadInitSql(); // tu caché/loader actual - if (initSql && initSql.trim()) { - await admin.query(`SET LOCAL search_path TO ${qi(schema)}, public`); - await admin.query(initSql); - } - - await admin.query('COMMIT'); - return { tenant_uuid: uuid, schema, role, role_password: pwd }; - } catch (e) { - try { await admin.query('ROLLBACK'); } catch {} - throw e; - } finally { - admin.release(); - } -} - -// async function ensureTenant({ tenant_uuid }) { -// const client = await tenantsPool.connect(); -// try { -// await client.query('BEGIN'); - -// // Si no vino UUID, generamos uno -// let uuid = (tenant_uuid || crypto.randomUUID()).toLowerCase(); -// const uuidNoHyphen = uuid.replace(/-/g, ''); - -// const schema = `schema_tenant_${uuidNoHyphen}`; -// const role = `tenant_${uuidNoHyphen}`; -// const pwd = crypto.randomBytes(18).toString('base64url'); // password del rol - -// if (!VALID_IDENT.test(schema) || !VALID_IDENT.test(role)) { -// throw new Error('Identificador de schema/rol inválido'); -// } - -// // 1) Crear ROL si no existe -// const { rowCount: hasRole } = await client.query( -// 'SELECT 1 FROM pg_roles WHERE rolname=$1', -// [role] -// ); -// if (!hasRole) { -// // Para el identificador usamos qi(); el password sí va parametrizado -// await client.query(`CREATE ROLE ${qi(role)} LOGIN PASSWORD ${qs(pwd)}`); -// } - -// // 2) Crear SCHEMA si no existe y asignar owner al rol del tenant -// const { rowCount: hasSchema } = await client.query( -// 'SELECT 1 FROM information_schema.schemata WHERE schema_name=$1', -// [schema] -// ); -// if (!hasSchema) { -// await client.query(`CREATE SCHEMA ${qi(schema)} AUTHORIZATION ${qi(role)}`); -// } - -// // 3) Permisos mínimos para el rol del tenant en su schema -// await client.query(`GRANT USAGE ON SCHEMA ${qi(schema)} TO ${qi(role)}`); -// await client.query( -// `ALTER DEFAULT PRIVILEGES IN SCHEMA ${qi(schema)} -// GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO ${qi(role)}` -// ); -// await client.query( -// `ALTER DEFAULT PRIVILEGES IN SCHEMA ${qi(schema)} -// GRANT USAGE, SELECT, UPDATE ON SEQUENCES TO ${qi(role)}` -// ); - -// await client.query('COMMIT'); - -// // 4) Inicialización del esquema con 01_init.sql (solo si está vacío) -// try { -// await initializeTenantSchemaIfEmpty(client, schema); -// } catch (e) { -// // Podés decidir si esto es fatal o "best-effort". -// // Si querés cortar el alta cuando falla la init, usa: throw e; -// console.warn(`[TENANT INIT] Falló inicialización de ${schema}:`, e?.message || e); -// } - -// return { tenant_uuid: uuid, schema, role, role_password: pwd }; -// } catch (e) { -// try { await client.query('ROLLBACK'); } catch {} -// throw e; -// } finally { -// client.release(); -// } -// } - -// Carga el 01_init.sql del disco, elimina BEGIN/COMMIT y sustituye el schema. - -let _cachedInitSql = null; -async function loadInitSql() { - if (_cachedInitSql !== null) return _cachedInitSql; - const candidates = [ - process.env.TENANT_INIT_SQL, // opcional - path.resolve(__dirname, 'db', 'initTenant.sql'), - path.resolve(__dirname, '..', 'src', 'db', 'initTenant.sql'), - ].filter(Boolean); - for (const p of candidates) { - try { - await access(p, fsConstants.R_OK); - const txt = await readFile(p, 'utf8'); - _cachedInitSql = String(txt || ''); - console.log(`[TENANT INIT] initTenant.sql: ${p} (${_cachedInitSql.length} bytes)`); - return _cachedInitSql; - } catch {} - } - console.warn('[TENANT INIT] initTenant.sql no encontrado (se omitirá).'); - _cachedInitSql = ''; - return _cachedInitSql; -} - -async function isSchemaEmpty(client, schema) { - const { rows } = await client.query( - `SELECT COUNT(*)::int AS c - FROM information_schema.tables - WHERE table_schema = $1`, - [schema] - ); - return rows[0].c === 0; -} - -/** Ejecuta 01_init.sql para un tenant (solo si el esquema está vacío). */ -async function initializeTenantSchemaIfEmpty(schema) { - const sql = await loadInitSql(); - if (!sql || !sql.trim()) { - console.warn(`[TENANT INIT] Esquema ${schema}: 01_init.sql vacío/no disponible. Salteando.`); - return; - } - - const client = await tenantsPool.connect(); - try { - // No usamos LOCAL: queremos que el search_path persista en esta conexión mientras dura el script - await client.query('BEGIN'); - await client.query(`SET search_path TO ${qi(schema)}, public`); - - const empty = await isSchemaEmpty(client, schema); - if (!empty) { - await client.query('ROLLBACK'); - console.log(`[TENANT INIT] Esquema ${schema}: ya tiene tablas. No se aplica 01_init.sql.`); - return; - } - - await client.query(sql); // acepta múltiples sentencias separadas por ';' - await client.query('COMMIT'); - console.log(`[TENANT INIT] Esquema ${schema}: 01_init.sql aplicado.`); - } catch (e) { - try { await client.query('ROLLBACK'); } catch {} - console.error(`[TENANT INIT] Error aplicando 01_init.sql sobre ${schema}:`, e.message); - throw e; - } finally { - client.release(); - } -} - // ----------------------------------------------------------------------------- // Utilidades @@ -350,29 +43,18 @@ const __dirname = path.dirname(__filename); function requiredEnv(keys) { const missing = keys.filter((k) => !process.env[k]); if (missing.length) { - console.warn(chalk.yellow(`⚠ Falta configurar variables de entorno: ${missing.join(', ')}`)); + console.warn(`Falta configurar variables de entorno: ${missing.join(', ')}`); } } -function onFatal(err, msg = 'Error fatal') { - console.error(chalk.red(`\n${msg}:`)); - console.error(err); - process.exit(1); -} -function genTempPassword(len = 12) { - const base = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789!@$%'; - let out = ''; - for (let i = 0; i < len; i++) out += base[Math.floor(Math.random() * base.length)]; - return out; -} // ----------------------------------------------------------------------------- // Configuración Express // ----------------------------------------------------------------------------- const app = express(); app.set('trust proxy', Number(process.env.TRUST_PROXY_HOPS || 2)); -app.use(cors({ origin: true, credentials: true })); +app.disable("x-powered-by"); app.use(express.json()); app.use(express.urlencoded({ extended: true })); @@ -380,77 +62,81 @@ app.use(express.urlencoded({ extended: true })); app.set('views', path.join(__dirname, 'views')); app.set('view engine', 'ejs'); - // Archivos estáticos opcionales (ajusta si tu estructura difiere) app.use(express.static(path.join(__dirname, 'public'))); app.use('/pages', express.static(path.join(__dirname, 'pages'))); -// ----------------------------------------------------------------------------- -// Sesión (Redis) — misma cookie que APP -// ----------------------------------------------------------------------------- -requiredEnv(['SESSION_SECRET', 'REDIS_URL']); -const SESSION_COOKIE_NAME = process.env.SESSION_NAME || "sc.sid"; -const SESSION_SECRET = process.env.SESSION_SECRET || "pon-una-clave-larga-y-unica"; -const REDIS_URL = process.env.REDIS_URL || "redis://authentik-redis:6379"; -// 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"); +// ----------------------------------------------------------------------------- +// Sesión (Redis) +// ----------------------------------------------------------------------------- +// --- Sesión/Redis --- +const { sessionMw, trustProxy } = await createRedisSession(); +if (trustProxy) app.set("trust proxy", 1); +app.use(sessionMw); +app.use(express.json()); -// 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)."); + +// --- Utiles OIDC --- +function base64url(buf) { +return Buffer.from(buf).toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); } -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/* - }, -})); +function genPKCE() { +const verifier = base64url(crypto.randomBytes(32)); +const challenge = base64url(crypto.createHash("sha256").update(verifier).digest()); +return { verifier, challenge }; +} -// Exponer usuario a las vistas (no tocar req.session) + +function authorizeUrl({ state, challenge }) { +const u = new URL(`${ISSUER}authorize/`); +u.searchParams.set("client_id", CLIENT_ID); +u.searchParams.set("redirect_uri", REDIRECT_URI); +u.searchParams.set("response_type", "code"); +u.searchParams.set("scope", "openid email profile"); +u.searchParams.set("state", state); +u.searchParams.set("code_challenge", challenge); +u.searchParams.set("code_challenge_method", "S256"); +return u.toString(); +} + + +async function exchangeCodeForTokens({ code, verifier }) { +const tokenUrl = `${ISSUER}token/`; +const body = new URLSearchParams({ +grant_type: "authorization_code", +code, +redirect_uri: REDIRECT_URI, +client_id: CLIENT_ID, +code_verifier: verifier, +}); +// auth básica si el proveedor la requiere (Authentik soporta ambos modos) +const basic = Buffer.from(`${CLIENT_ID}:${CLIENT_SECRET}`).toString("base64"); +const res = await fetch(tokenUrl, { +method: "POST", +headers: { +"content-type": "application/x-www-form-urlencoded", +"authorization": `Basic ${basic}`, +}, +body, +}); +if (!res.ok) throw new Error(`Token endpoint ${res.status}`); +return res.json(); +} + +// ---------------------------------------------------------- +// Middleware para datos globales +// ---------------------------------------------------------- app.use((req, res, next) => { - res.locals.user = req.session?.user || null; + res.locals.currentPath = req.path; + res.locals.pageTitle = "SuiteCoffee"; + res.locals.pageId = ""; next(); }); + // ----------------------------------------------------------------------------- // PostgreSQL — DB tenants (usuarios de suitecoffee) // ----------------------------------------------------------------------------- @@ -467,7 +153,7 @@ const tenantsPool = new Pool({ // PostgreSQL — DB principal (metadatos de negocio) // ----------------------------------------------------------------------------- requiredEnv(['DB_HOST', 'DB_USER', 'DB_PASS', 'DB_NAME']); -const pool = new Pool({ +const mainPool = new Pool({ host: process.env.DB_HOST || 'dev-db', port: Number(process.env.DB_PORT || 5432), user: process.env.DB_USER || 'dev-user-suitecoffee', @@ -477,565 +163,212 @@ const pool = new Pool({ idleTimeoutMillis: 30_000, }); +// ---------------------------------------------------------- +// Verificación de conexión +// ---------------------------------------------------------- + async function verificarConexion() { try { - const client = await pool.connect(); - const { rows } = await client.query('SELECT NOW() AS ahora'); - console.log(`\nConexión con ${chalk.green(process.env.DB_NAME)} OK. Hora DB:`, rows[0].ahora); - client.release(); + console.log(`[AUTH] Comprobando accesibilidad a la db ${process.env.DB_NAME} del host ${process.env.DB_HOST} ...`); + var client = await mainPool.connect(); + var { rows } = await client.query('SELECT NOW() AS ahora'); + console.log(`\n[AUTH] Conexión con ${process.env.DB_NAME} OK. Hora DB:`, rows[0].ahora); } catch (error) { - console.error('Error al conectar con la base de datos al iniciar:', error.message); - console.error('Revisar DB_HOST/USER/PASS/NAME, accesos de red y firewall.'); + console.error('[AUTH] Error al conectar con la base de datos al iniciar:', error.message); + console.error('[AUTH] Revisar DB_HOST/USER/PASS/NAME, accesos de red y firewall.'); + } finally { + client.release(); } } -// ----------------------------------------------------------------------------- -// OIDC (Authentik) — discovery + cliente -// ----------------------------------------------------------------------------- -requiredEnv(['OIDC_ISSUER', 'OIDC_CLIENT_ID', 'OIDC_CLIENT_SECRET', 'OIDC_REDIRECT_URI']); - - -async function discoverOIDCWithRetry(issuerUrl, { retries = 30, delayMs = 2000 } = {}) { - let lastErr; - for (let i = 1; i <= retries; i++) { - try { - const issuer = await Issuer.discover(issuerUrl); - console.log(`[OIDC] issuer OK en intento ${i}:`, issuer.metadata.issuer); - return issuer; - } catch (err) { - lastErr = err; - console.warn(`[OIDC] intento ${i}/${retries} falló: ${err.code || err.message}`); - await sleep(delayMs); - } - } - // No abortamos el proceso; dejamos el servidor vivo y seguimos reintentando en background - throw lastErr; -} - -let oidcClient; -(async () => { - try { - const issuer = await discoverOIDCWithRetry(process.env.OIDC_ISSUER, { retries: 60, delayMs: 2000 }); - oidcClient = new issuer.Client({ - client_id: process.env.OIDC_CLIENT_ID, - client_secret: process.env.OIDC_CLIENT_SECRET, - redirect_uris: [process.env.OIDC_REDIRECT_URI], - response_types: ['code'], - }); - } catch (e) { - console.error('⚠ No se pudo inicializar OIDC aún. Seguirá reintentando cada 10s en background.'); - // reintento en background cada 10s sin tumbar el proceso - (async function loop() { - try { - const issuer = await Issuer.discover(process.env.OIDC_ISSUER); - oidcClient = new issuer.Client({ - client_id: process.env.OIDC_CLIENT_ID, - client_secret: process.env.OIDC_CLIENT_SECRET, - redirect_uris: [process.env.OIDC_REDIRECT_URI], - response_types: ['code'], - }); - console.log('[OIDC] inicializado correctamente en reintento tardío'); - } catch { - setTimeout(loop, 10000); - } - })(); - } -})(); // ----------------------------------------------------------------------------- // Vistas // ----------------------------------------------------------------------------- -// Página de login -app.get("/auth/login", (_req, res) => { - return res.render("login", { pageTitle: "Iniciar sesión" }); -}); -app.post("/auth/login", async (req, res, next) => { +// ============================================= +// Registro de usuario (DB principal) +// ============================================= + +requiredEnv(['TENANT_INIT_SQL']); +async function loadInitSqlFromEnv() { + const v = process.env.TENANT_INIT_SQL?.trim(); + if (!v) return ''; try { - const email = String(req.body.email || "").trim().toLowerCase(); - const password = String(req.body.password || ""); - const remember = req.body.remember === "on" || req.body.remember === true; - - if (!email || !password) { - return res.status(400).render("login", { pageTitle: "Iniciar sesión", error: "Completa email y contraseña." }); - } - - // Tabla/columnas por defecto; ajustables por env si tu esquema difiere - const USERS_TABLE = process.env.TENANTS_USERS_TABLE || "users"; - const COL_ID = process.env.TENANTS_COL_ID || "id"; - const COL_EMAIL = process.env.TENANTS_COL_EMAIL || "email"; - const COL_HASH = process.env.TENANTS_COL_HASH || "password_hash"; - const COL_ROLE = process.env.TENANTS_COL_ROLE || "role"; - const COL_TENANT = process.env.TENANTS_COL_TENANT || "tenant_id"; - - const { rows } = await tenantsPool.query( - `SELECT ${COL_ID} AS id, ${COL_EMAIL} AS email, ${COL_HASH} AS password_hash, - ${COL_ROLE} AS role, ${COL_TENANT} AS tenant_id - FROM ${USERS_TABLE} - WHERE ${COL_EMAIL} = $1 - LIMIT 1`, - [email] - ); - - if (rows.length === 0) { - return res.status(401).render("login", { pageTitle: "Iniciar sesión", error: "Credenciales inválidas." }); - } - - const user = rows[0]; - const ok = await bcrypt.compare(password, user.password_hash || ""); - if (!ok) { - return res.status(401).render("login", { pageTitle: "Iniciar sesión", error: "Credenciales inválidas." }); - } - - // (Opcional) registro de acceso en DB principal - try { - await pool.query( - "INSERT INTO auth_audit_log(email, tenant_id, action, at) VALUES ($1, $2, $3, NOW())", - [user.email, user.tenant_id, "login_success"] - ); - } catch { /* noop si no existe la tabla */ } - - // Sesión compartida - req.session.regenerate((err) => { - if (err) return next(err); - - req.session.user = { - id: user.id, - email: user.email, - role: user.role, - tenant_id: user.tenant_id, - loggedAt: Date.now(), - }; - - if (remember) { - req.session.cookie.maxAge = 30 * 24 * 60 * 60 * 1000; - } else { - req.session.cookie.expires = false; - } - - req.session.save((err2) => { - if (err2) return next(err2); - return res.redirect(303, "/"); // "/" → app decide /dashboard o /auth/login - }); - }); - } catch (e) { - next(e); - } -}); - - -// ----------------------------------------------------------------------------- -// Rutas OIDC -// ----------------------------------------------------------------------------- -app.get('/auth/login', (req, res, next) => { - try { - - if (req.session?.oidc) { - return nukeSession(req, res, '/auth/login', 'stale_oidc'); - } - - const code_verifier = generators.codeVerifier(); - const code_challenge = generators.codeChallenge(code_verifier); - - // Podés usar generators.state() y generators.nonce(); ambas son válidas - const state = generators.state(); // crypto.randomBytes(24).toString('base64url') también sirve - const nonce = generators.nonce(); - - - - // Guardamos TODO dentro de un objeto para evitar claves sueltas - req.session.oidc = { code_verifier, state, nonce }; - - // Guardar sesión ANTES de redirigir - req.session.save((err) => { - if (err) return next(err); - - const url = oidcClient.authorizationUrl({ - scope: 'openid profile email offline_access', - code_challenge, - code_challenge_method: 'S256', - state, - nonce, - }); - - return res.redirect(url); // importantísimo: return - }); - } catch (e) { - return next(e); - } -}); - -app.get('/auth/callback', async (req, res, next) => { - try { - // Log útil para debug - console.log('[OIDC] cb sid=%s query=%j', req.sessionID, req.query); - - // Recuperar lo que guardamos en /auth/login - const { oidc } = req.session || {}; - const code_verifier = oidc?.code_verifier; - const stateStored = oidc?.state; - const nonceStored = oidc?.nonce; - - // Si por algún motivo no está la info (sesión perdida/expirada), reiniciamos el flujo - if (!code_verifier || !stateStored) { - console.warn('[OIDC] Falta code_verifier/state en sesión; reiniciando login'); - return res.redirect(303, '/auth/login'); - } - - const params = oidcClient.callbackParams(req); - - // openid-client validará que el "state" recibido coincida con el que pasamos aquí - let tokenSet; - try { - tokenSet = await oidcClient.callback( - process.env.OIDC_REDIRECT_URI, - params, - { code_verifier, state: stateStored, nonce: nonceStored } - ); - } catch (err) { - console.warn('[OIDC] callback error, resetting session:', err?.message || err); - return nukeSession(req, res, '/auth/login', 'callback_error'); - } - - // Limpiar datos OIDC de la sesión - delete req.session.oidc; - - const claims = tokenSet.claims(); - const email = (claims.email || '').toLowerCase(); - - // tenant desde claim, Authentik o fallback a tu DB - let tenantHex = cleanUuid(claims.tenant_uuid); - if (!tenantHex) { - const akUser = await akFindUserByEmail(email).catch(() => null); - tenantHex = cleanUuid(akUser?.attributes?.tenant_uuid); - - if (!tenantHex) { - const q = await pool.query( - 'SELECT tenant_uuid FROM app_user WHERE LOWER(email)=LOWER($1) LIMIT 1', - [email] - ); - tenantHex = cleanUuid(q.rows?.[0]?.tenant_uuid); - } - } - - // Regenerar sesión para evitar fijación y guardar el usuario - req.session.regenerate((err) => { - if (err) { - if (!res.headersSent) res.status(500).send('No se pudo crear la sesión.'); - return; - } - req.session.user = { - sub: claims.sub, - email, - tenant_uuid: tenantHex || null, - }; - req.session.save((e2) => { - if (e2) { - if (!res.headersSent) res.status(500).send('No se pudo guardar la sesión.'); - return; - } - if (!res.headersSent) return res.redirect('/'); - }); - }); - - return res.redirect('/'); - - } catch (e) { - console.error('[OIDC] callback error:', e); - if (!res.headersSent) return next(e); - } -}); - - -app.post('/auth/logout', (req, res) => { - req.session.destroy(() => { - res.clearCookie(SESSION_COOKIE_NAME); - res.status(204).end(); - }); -}); - -app.get('/auth/me', (req, res) => { - if (!req.session?.user) return res.status(401).json({ error: 'no-auth' }); - res.json({ user: req.session.user }); -}); - -// ----------------------------------------------------------------------------- -// Registro de usuario (DB principal + Authentik) -// ----------------------------------------------------------------------------- - -// Helpers defensivos (si ya los tenés, podés omitir estas definiciones) -const extractAkUserUuid = (u) => - (u && (u.uuid || u?.user?.uuid || (Array.isArray(u.results) && u.results[0]?.uuid))) || null; -const extractAkUserPk = (u) => - (u && (u.pk ?? u?.user?.pk ?? null)); - -async function akDeleteUser(pkOrUuid) { - try { - if (!pkOrUuid || !globalThis.fetch) return; - const base = process.env.AUTHENTIK_BASE_URL?.replace(/\/+$/, '') || ''; - const id = String(pkOrUuid); - const url = `${base}/api/v3/core/users/${encodeURIComponent(id)}/`; - await fetch(url, { - method: 'DELETE', - headers: { 'Authorization': `Bearer ${process.env.AUTHENTIK_TOKEN}` } - }); - } catch (e) { - console.warn('[AK] No se pudo borrar usuario (compensación):', e?.message || e); + // ¿Es una ruta existente? + const p = path.isAbsolute(v) ? v : path.resolve(__dirname, v); + const txt = await fs.readFile(p, 'utf8'); + console.log(`[TENANT INIT] Cargado desde archivo: ${p} (${txt.length} bytes)`); + return String(txt || ''); + } catch { + // Tratar como literal + console.log(`[TENANT INIT] Usando SQL literal desde TENANT_INIT_SQL (${v.length} chars).`); + return v; } } -// ============================== -// POST /auth/api/users/register -// ============================== -app.post('/auth/api/users/register', async (req, res, next) => { - // 0) input - const { - email, - display_name, - role, - tenant_uuid: requestedTenantUuid, // opcional - } = req.body || {}; +// Reemplaza placeholders simples en la plantilla de SQL (opcional) +function renderInitSqlTemplate(sql, { schema, owner }) { + return sql + .replaceAll(':TENANT_SCHEMA', `"${schema}"`) + .replaceAll(':OWNER', `"${owner}"`); +} +// Genera ids sencillos +function newTenantIds() { + return { + tenant_uuid: crypto.randomUUID(), + tenant_role: null, // lo decidirás luego (owner, barman, staff) + }; +} - const normEmail = String(email || '').trim().toLowerCase(); - if (!normEmail) return res.status(400).json({ error: 'email requerido' }); +async function createTenantUserAndSchema(tenClient, { tenant_uuid, password }) { + const roleName = `tenant_${tenant_uuid.replace(/-/g, '')}`; + const schemaName = `t_${tenant_uuid.replace(/-/g, '')}`; + const escapedPassword = `'${String(password).replace(/'/g, "''")}'`; - // 1) Resolver tenant uuid (existente o nuevo) - let tenantHex = null; - try { - if (typeof resolveExistingTenantUuid === 'function') { - tenantHex = await resolveExistingTenantUuid({ - email: normEmail, - requestedTenantUuid, - }); - } else { - tenantHex = cleanUuid(requestedTenantUuid); - } + // 1) crear role y schema (misma conexión que ya viene en BEGIN desde la ruta) + await tenClient.query(`CREATE ROLE "${roleName}" LOGIN PASSWORD ${escapedPassword}`); + await tenClient.query(`CREATE SCHEMA "${schemaName}" AUTHORIZATION "${roleName}"`); + await tenClient.query(`GRANT USAGE ON SCHEMA "${schemaName}" TO "${roleName}"`); + await tenClient.query(`ALTER ROLE "${roleName}" INHERIT`); + // (idempotente) + await tenClient.query(`CREATE SCHEMA IF NOT EXISTS "${schemaName}"`); - // Crear/asegurar tenant en una transacción (ahí adentro corre 01_init.sql) - if (tenantHex) { - // si no existe, ensureTenant lo crea - await ensureTenant({ tenant_uuid: tenantHex }); - } else { - const created = await ensureTenant({ tenant_uuid: null }); // genera uuid - tenantHex = cleanUuid(created?.tenant_uuid); - } - } catch (e) { - return next(new Error(`No se pudo preparar el tenant: ${e.message}`)); + // 2) cargar y sanear la plantilla + let sql = await loadInitSqlFromEnv(); + if (!sql?.trim()) { + console.log('[TENANT INIT] No hay SQL de plantilla; se omite.'); + return { roleName, schemaName }; } - // 2) Transacción DB principal + Authentik con compensación - const client = await pool.connect(); - let createdAkUser = null; // para compensación - try { - await client.query('BEGIN'); + // 👉 quita metacomandos psql '\' (por si alguno quedó) y cualquier cambio de search_path dentro del dump + sql = sql + .split(/\r?\n/) + .filter(line => !line.trim().startsWith('\\')) // \restrict, \unrestrict, \i, etc. + .filter(line => !/^SET\s+search_path\b/i.test(line)) // SET search_path = ... + .filter(line => !/set_config\(\s*'search_path'/i.test(line)) // SELECT set_config('search_path',... + .join('\n'); - // Duplicados (ajusta a tu constraint real: por email o por (email,tenant)) - const dup = await client.query( - 'SELECT id FROM app_user WHERE LOWER(email)=LOWER($1)', - [normEmail] - ); - if (dup.rowCount) { - await client.query('ROLLBACK'); - return res.status(409).json({ - error: 'user-exists', - message: 'Ya existe un usuario con este email.', - next: '/auth/login', - }); - } + // si usás placeholders, renderealos acá (opcional) + // sql = renderInitSqlTemplate(sql, { schema: schemaName, owner: roleName }); - // Authentik: buscar o crear - let akUser = await akFindUserByEmail(normEmail).catch(() => null); - if (!akUser) { - akUser = await akCreateUser({ - email: normEmail, - displayName: display_name || null, - tenantUuid: tenantHex, // attributes.tenant_uuid - addToGroupId: DEFAULT_GROUP_ID || null, - isActive: true, - }); - createdAkUser = akUser; // marcar que lo creamos nosotros - } + // 3) forzá el search_path SOLO dentro de esta transacción + await tenClient.query(`SET LOCAL search_path TO "${schemaName}", public`); - // Asegurar uuid/pk - let akUserUuid = extractAkUserUuid(akUser); - let akUserPk = extractAkUserPk(akUser); - if (!akUserUuid || akUserPk == null) { - const ref = await akFindUserByEmail(normEmail).catch(() => null); - akUserUuid = akUserUuid || extractAkUserUuid(ref); - akUserPk = akUserPk ?? extractAkUserPk(ref); - } - if (!akUserUuid) throw new Error('No se pudo obtener uuid del usuario en Authentik'); + // 4) ejecutar el dump (una sola vez, no lo partas por ';' para no romper $$...$$) + await tenClient.query(sql); - // Insert en tu DB principal - const finalRole = role || 'owner'; - await client.query( - `INSERT INTO app_user (email, display_name, tenant_uuid, ak_user_uuid, role) - VALUES ($1,$2,$3,$4,$5)`, - [normEmail, display_name || null, tenantHex, akUserUuid, finalRole] - ); + console.log(`[TENANT INIT] OK usuario="${roleName}" schema="${schemaName}"`); + return { roleName, schemaName }; +} - await client.query('COMMIT'); +//============================================= +// ---------- Authentik (API & OIDC) ---------- +//============================================= - // 3) Marcar sesión para set-password (si usás este flujo) - req.session.pendingPassword = { - email: normEmail, - ak_user_uuid: akUserUuid, - ak_user_pk: akUserPk, - exp: Date.now() + 10 * 60 * 1000, - }; - return req.session.save(() => { - const accept = String(req.headers['accept'] || ''); - if (accept.includes('text/html')) { - return res.redirect(303, '/set-password'); - } - return res.status(201).json({ - message: 'Usuario registrado', - email: normEmail, - tenant_uuid: tenantHex, - role: finalRole, - authentik_user_uuid: akUserUuid, - next: '/set-password', - }); - }); - } catch (err) { - // Rollbacks + Compensaciones - try { await client.query('ROLLBACK'); } catch {} - try { - // Si creamos el usuario en Authentik y luego falló algo → borrar - if (createdAkUser) { - const id = extractAkUserPk(createdAkUser) ?? extractAkUserUuid(createdAkUser); - if (id) await akDeleteUser(id); - } - } catch {} - return next(err); - } finally { - client.release(); - } +// =========================== +// GET /auth/users/register +// =========================== + +// =========================== +// POST /auth/login +// =========================== +app.get("/auth/login", (req, res) => { +const { verifier, challenge } = genPKCE(); +const state = base64url(crypto.randomBytes(24)); +req.session.pkce_verifier = verifier; +req.session.oidc_state = state; +const url = authorizeUrl({ state, challenge }); +res.redirect(302, url); +}); +// =========================== +// GET /auth/callback +// =========================== +app.get("/auth/callback", async (req, res) => { +try { +const { code, state } = req.query; +if (!code || !state) return res.status(400).send("Faltan parámetros"); +if (state !== req.session.oidc_state) return res.status(400).send("State inválido"); + + +const verifier = req.session.pkce_verifier; +if (!verifier) return res.status(400).send("PKCE verifier faltante"); + + +const tokens = await exchangeCodeForTokens({ code, verifier }); +// Guarda en sesión (ID Token, Access Token, Refresh Token si viene) +req.session.tokens = { +id_token: tokens.id_token, +access_token: tokens.access_token, +refresh_token: tokens.refresh_token, +token_type: tokens.token_type, +expires_in: tokens.expires_in, +received_at: Date.now(), +}; +// Limpia PKCE/state +delete req.session.pkce_verifier; +delete req.session.oidc_state; + + +// Redirige al home de App +res.redirect(303, `${APP_BASE_URL}/`); +} catch (e) { +console.error("/auth/callback error", e); +res.status(500).send("Error en callback"); +} }); - -// Definir contraseña -app.post('/auth/password/set', async (req, res, next) => { - try { - const pp = req.session?.pendingPassword; - if (!pp || (pp.exp && Date.now() > pp.exp)) { - // token de sesión vencido o ausente - if (!res.headersSent) return res.redirect(303, '/set-password'); - return; - } - - const { password, password2 } = req.body || {}; - if (!password || password.length < 8 || password !== password2) { - return res.status(400).send('Contraseña inválida o no coincide.'); - } - - // Buscar el usuario en Authentik y setear la clave - const u = await akFindUserByEmail(pp.email); - if (!u) return res.status(404).send('No se encontró el usuario en Authentik.'); - - await akSetPassword(u.pk, password, true); // true = force change handled; ajusta a tu helper - - // Limpiar marcador y continuar al SSO - delete req.session.pendingPassword; - return req.session.save(() => res.redirect(303, '/auth/login')); - } catch (e) { - next(e); - } +// =========================== +// POST /auth/logout +// =========================== +app.get("/auth/logout", (req, res) => { +req.session.destroy(() => { +res.clearCookie(process.env.SESSION_COOKIE_NAME || "sc.sid"); +res.redirect(303, APP_BASE_URL || "/"); +}); }); -// Espera: { email, display_name?, tenant_uuid } -// app.post('/auth/auth/api/users/register', async (req, res, next) => { - -// const { email, display_name, tenant_uuid: rawTenant, role } = req.body || {}; -// if (!email) return res.status(400).json({ error: 'email es obligatorio' }); -// // Si no vino tenant: lo creamos -// const { tenant_uuid, schema, role: dbRole } = await ensureTenant({ tenant_uuid: rawTenant }); - -// const client = await pool.connect(); -// try { -// await client.query('BEGIN'); - -// // ¿ya existe en tu DB? -// const { rows: dup } = await client.query( -// 'SELECT id FROM app_user WHERE email=$1 AND tenant_uuid=$2', -// [email.toLowerCase(), tenant_uuid.replace(/-/g, '')] -// ); -// if (dup.length) { -// await client.query('ROLLBACK'); -// return res.status(409).json({ error: 'El usuario ya existe en SuiteCoffee' }); -// } - -// // Authentik: crear si no existe -// let akUser = await akFindUserByEmail(email); -// if (!akUser) { -// akUser = await akCreateUser({ -// email, -// displayName: display_name, -// tenantUuid: tenant_uuid, // se normaliza dentro de ak.js -// addToGroupId: DEFAULT_GROUP_ID || null, -// isActive: true, -// }); -// // Si querés forzar clave inicial (opcional; depende de tus políticas): -// // await akSetPassword(akUser.pk, 'ClaveTemporal123!', true); -// } - -// const _role = role || 'owner'; -// await client.query( -// `INSERT INTO app_user (email, display_name, tenant_uuid, ak_user_uuid, role) -// VALUES ($1,$2,$3,$4,$5)`, -// [email.toLowerCase(), display_name || null, tenant_uuid.replace(/-/g, ''), akUser.uuid, _role] -// ); - -// await client.query('COMMIT'); -// return res.status(201).json({ -// message: 'Usuario registrado', -// email, -// tenant_uuid, -// role: _role, -// authentik_user_uuid: akUser.uuid, -// next: '/auth/login' -// }); -// } catch (err) { -// try { await client.query('ROLLBACK'); } catch {} -// next(err); -// } finally { -// client.release(); -// } -// }); - - -// ----------------------------------------------------------------------------- +// ============================================= // Healthcheck -// ----------------------------------------------------------------------------- -app.get('/health', (_req, res) => res.status(200).json({ status: 'ok', service: 'auth' })); +// ============================================= +app.get('/health', (_req, res) => res.status(200).json({ status: 'ok'})); -// ----------------------------------------------------------------------------- +// ============================================= // 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('❌ Error:', err); + console.error('[AUTH] ', err); if (res.headersSent) return; res.status(500).json({ error: '¡Oh! A ocurrido un error en el servidor auth.', detail: err.stack || String(err) }); }); +/* +----------------------------------------------------------------------------- +Exportación principal del módulo. +Es típico exportar la instancia (app) y arrancarla en otro archivo. +- Facilita tests (p.ej. con supertest: import app from './app.js') +- Evita que el servidor se inicie al importar el módulo. + +# Default + export default app; // importar: import app from './app.js' + +# Con nombre + export const app = express(); // importar: import { app } from './app.js' +----------------------------------------------------------------------------- +*/ +export default app; + // ----------------------------------------------------------------------------- // Arranque // ----------------------------------------------------------------------------- -const PORT = Number(process.env.PORT || 4040); - -(async () => { - const env = (process.env.NODE_ENV || 'development').toUpperCase(); - console.log(`Activando entorno de -> ${env === 'PRODUCTION' ? chalk.red('PRODUCTION') : chalk.green('DEVELOPMENT')}`); - await verificarConexion(); - app.listen(PORT, () => { - console.log(`[AUTH] SuiteCoffee corriendo en http://localhost:${PORT}`); - }); -})(); - -export default app; \ No newline at end of file +app.listen(PORT, () => { + console.log(`[AUTH] Servicio de autenticación escuchando en http://localhost:${PORT}`); + verificarConexion(); + // OIDCdiscover(); +}); \ No newline at end of file diff --git a/services/auth/src/views/login.ejs b/services/auth/src/views/login.ejs deleted file mode 100644 index 65dc771..0000000 --- a/services/auth/src/views/login.ejs +++ /dev/null @@ -1,164 +0,0 @@ - - - - - - <%= typeof pageTitle !== 'undefined' ? pageTitle : 'Iniciar sesión · SuiteCoffee' %> - - - - - - - -
-
-
-
-

SuiteCoffee

-

Accedé a tu cuenta

-
- - - - -
-
- - - - -
o
- - -
-
- - -
Ingresá un email válido.
-
- -
- - -
Ingresá tu nombre.
-
- -
- - -
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.
-
- -
- - -
- -
- -
-
- -

- Al continuar aceptás nuestros términos y políticas. -

- -
-
- -

- ¿Ya tenés cuenta? Iniciá sesión con SSO -

-
-
-
- - - - - - diff --git a/services/manso/src/views/comandas.ejs b/services/manso/src/views/comandas.ejs index ae564e9..3426d6c 100644 --- a/services/manso/src/views/comandas.ejs +++ b/services/manso/src/views/comandas.ejs @@ -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(); diff --git a/services/shared/middlewares/redisConnect.js b/services/shared/middlewares/redisConnect.js new file mode 100644 index 0000000..378cf11 --- /dev/null +++ b/services/shared/middlewares/redisConnect.js @@ -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 }; +} \ No newline at end of file