From 5d078f3932fcfbdb18fc543ff5f478dc226eb056 Mon Sep 17 00:00:00 2001 From: msaldain Date: Sat, 6 Sep 2025 11:19:42 +0000 Subject: [PATCH] Carga completa --- .env.development | 35 + .env.production | 23 + .gitignore | 2 +- docs/db's.md | 0 services/app/.env.development | 29 + services/app/.env.production | 22 + services/app/package-lock.json | 62 + services/app/package.json | 1 + services/app/src/index.js | 351 +-- services/{auth => app}/src/views/login.ejs | 0 services/auth/.env.development | 103 + services/auth/.env.production | 22 + services/auth/src/ak.js | 65 +- services/auth/src/db/initTenant.sql | 2239 ++++++++++++++++++++ services/auth/src/index.js | 583 +++-- services/manso/.env.development | 19 + services/manso/.env.production | 20 + 17 files changed, 3278 insertions(+), 298 deletions(-) create mode 100644 .env.development create mode 100644 .env.production create mode 100644 docs/db's.md create mode 100644 services/app/.env.development create mode 100644 services/app/.env.production rename services/{auth => app}/src/views/login.ejs (100%) create mode 100644 services/auth/.env.development create mode 100644 services/auth/.env.production create mode 100644 services/auth/src/db/initTenant.sql create mode 100644 services/manso/.env.development create mode 100644 services/manso/.env.production diff --git a/.env.development b/.env.development new file mode 100644 index 0000000..edcde4e --- /dev/null +++ b/.env.development @@ -0,0 +1,35 @@ +# Archivo de variables de entorno para docker-compose.yml +COMPOSE_PROJECT_NAME=suitecoffee_dev + +# Entorno de desarrollo +NODE_ENV=development + +# app - app +APP_LOCAL_PORT=3030 +APP_DOCKER_PORT=3030 + +# auth - app +AUTH_LOCAL_PORT=4040 +AUTH_DOCKER_PORT=4040 + +# tenants - postgres +TENANTS_DB_NAME=dev-postgres +TENANTS_DB_USER=dev-user-postgres +TENANTS_DB_PASS=dev-pass-postgres + +TENANTS_DB_LOCAL_PORT=54321 +TENANTS_DB_DOCKER_PORT=5432 + +# db primaria - postgres +DB_NAME=dev-suitecoffee +DB_USER=dev-user-suitecoffee +DB_PASS=dev-pass-suitecoffee + +DB_LOCAL_PORT=54322 +DB_DOCKER_PORT=5432 + +# --- secretos para Authentik +AUTHENTIK_SECRET_KEY=poné_un_valor_largo_y_unico +AUTHENTIK_DB_PASS=cambia_esto +AUTHENTIK_BOOTSTRAP_PASSWORD=cambia_esto +AUTHENTIK_BOOTSTRAP_EMAIL=info.suitecoffee@gmail.com diff --git a/.env.production b/.env.production new file mode 100644 index 0000000..5e344a2 --- /dev/null +++ b/.env.production @@ -0,0 +1,23 @@ +# Archivo de variables de entorno para docker-compose.yml +COMPOSE_PROJECT_NAME=suitecoffee_prod + +# Entorno de desarrollo +NODE_ENV=production + +# app - app +APP_LOCAL_PORT=3000 +APP_DOCKER_PORT=3000 + +# auth - app +AUTH_LOCAL_PORT=4000 +AUTH_DOCKER_PORT=4000 + +# tenants - postgres +TENANTS_DB_NAME=postgres +TENANTS_DB_USER=postgres +TENANTS_DB_PASS=postgres + +# db primaria - postgres +DB_NAME=suitecoffee +DB_USER=suitecoffee +DB_PASS=suitecoffee \ No newline at end of file diff --git a/.gitignore b/.gitignore index ca1399e..a87e292 100644 --- a/.gitignore +++ b/.gitignore @@ -33,6 +33,6 @@ tests/ .gitmodules # Ignorar archivos personales o privados (si existen) -.env.* +# .env.* *.pem *.key \ No newline at end of file diff --git a/docs/db's.md b/docs/db's.md new file mode 100644 index 0000000..e69de29 diff --git a/services/app/.env.development b/services/app/.env.development new file mode 100644 index 0000000..98e361f --- /dev/null +++ b/services/app/.env.development @@ -0,0 +1,29 @@ +# ===== Runtime ===== +NODE_ENV=development +PORT=3030 +APP_LOCAL_PORT=3030 + +# ===== Session (usa el Redis del stack) ===== +# Para DEV podemos reutilizar el Redis de Authentik. En prod conviene uno separado. +SESSION_SECRET=pon-una-clave-larga-y-unica +REDIS_URL=redis://authentik-redis:6379 + +# ===== DB principal (metadatos de SuiteCoffee) ===== +DB_HOST=dev-tenants +DB_PORT=5432 +DB_NAME=dev-postgres +DB_USER=dev-user-postgres +DB_PASS=dev-pass-postgres + +# ===== DB tenants (Tenants de SuiteCoffee) ===== +TENANTS_HOST=dev-tenants +TENANTS_DB=dev-postgres +TENANTS_USER=dev-user-postgres +TENANTS_PASS=dev-pass-postgres +TENANTS_PORT=5432 + +# ===== (Opcional) Colores UI, si alguna vista los lee ===== +COL_PRI=452D19 # Marrón oscuro +COL_SEC=D7A666 # Crema / Café +COL_BG=FFA500 # Naranja + diff --git a/services/app/.env.production b/services/app/.env.production new file mode 100644 index 0000000..0b341ba --- /dev/null +++ b/services/app/.env.production @@ -0,0 +1,22 @@ +NODE_ENV=production # Entorno de desarrollo + +PORT=3000 # Variables del servicio -> suitecoffee-app + +# Variables del servicio -> suitecoffee-db de suitecoffee-app + +DB_HOST=prod-tenants +# Nombre de la base de datos +DB_NAME=postgres + +# Usuario y contraseña +DB_USER=postgres +DB_PASS=postgres + +# Puertos del servicio de db +DB_LOCAL_PORT=5432 +DB_DOCKER_PORT=5432 + +# Colores personalizados +COL_PRI=452D19 # Marrón oscuro +COL_SEC=D7A666 # Crema / Café +COL_BG=FFA500 # Naranja \ No newline at end of file diff --git a/services/app/package-lock.json b/services/app/package-lock.json index ecf02e6..be8bb82 100644 --- a/services/app/package-lock.json +++ b/services/app/package-lock.json @@ -19,6 +19,7 @@ "express-ejs-layouts": "^2.5.1", "express-session": "^1.18.2", "ioredis": "^5.7.0", + "morgan": "^1.10.1", "pg": "^8.16.3", "pg-format": "^1.0.4", "redis": "^5.8.2", @@ -131,6 +132,24 @@ "version": "1.0.2", "license": "MIT" }, + "node_modules/basic-auth": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/basic-auth/-/basic-auth-2.0.1.tgz", + "integrity": "sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.1.2" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/basic-auth/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, "node_modules/bcrypt": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-6.0.0.tgz", @@ -902,6 +921,49 @@ "node": "*" } }, + "node_modules/morgan": { + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/morgan/-/morgan-1.10.1.tgz", + "integrity": "sha512-223dMRJtI/l25dJKWpgij2cMtywuG/WiUKXdvwfbhGKBhy1puASqXwFzmWZ7+K73vUPoR7SS2Qz2cI/g9MKw0A==", + "license": "MIT", + "dependencies": { + "basic-auth": "~2.0.1", + "debug": "2.6.9", + "depd": "~2.0.0", + "on-finished": "~2.3.0", + "on-headers": "~1.1.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/morgan/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/morgan/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/morgan/node_modules/on-finished": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz", + "integrity": "sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/ms": { "version": "2.1.3", "license": "MIT" diff --git a/services/app/package.json b/services/app/package.json index 0039ace..04a8927 100644 --- a/services/app/package.json +++ b/services/app/package.json @@ -25,6 +25,7 @@ "express-ejs-layouts": "^2.5.1", "express-session": "^1.18.2", "ioredis": "^5.7.0", + "morgan": "^1.10.1", "pg": "^8.16.3", "pg-format": "^1.0.4", "redis": "^5.8.2", diff --git a/services/app/src/index.js b/services/app/src/index.js index 6d797b6..66124f5 100644 --- a/services/app/src/index.js +++ b/services/app/src/index.js @@ -1,216 +1,259 @@ // services/app/src/index.js -// ----------------------------------------------------------------------------- -// SuiteCoffee — Servicio APP (Express) -// - Carga de entorno robusta (compatible con Docker Compose env_file) -// - Sesiones compartidas via Redis (mismo cookie que AUTH) -// - Middlewares: CORS, JSON, estáticos, EJS opcional -// - Multitenant por esquema: requireAuth + withTenant + done -// - Montaje automático de ENDPOINTS LEGACY sin perder nada -// - Healthcheck, 404 y manejador de errores -// - Conserva y expone pools para que tus endpoints los usen -// ----------------------------------------------------------------------------- +// ------------------------------------------------------------ +// 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 +// ------------------------------------------------------------ -// === 0) CARGA DE ENTORNO ROBUSTA (no pisa variables ya definidas por Compose) -import fs from 'node:fs'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; -import dotenv from 'dotenv'; -import chalk from 'chalk'; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); -const ENV = (process.env.NODE_ENV || 'development').toLowerCase(); -const envMap = { development: '.env.development', stage: '.env.test', production: '.env.production' }; -const envFile = envMap[ENV] || '.env.development'; -const candidates = [ - path.resolve(process.cwd(), envFile), // /app/.env.development (dentro del contenedor) - path.resolve(__dirname, '..', envFile), // por si queda un nivel arriba - path.resolve(__dirname, envFile), // por si lo ponen junto al src -]; -const found = candidates.find((p) => fs.existsSync(p)); -if (found) { - dotenv.config({ path: found, override: false }); - console.log(`Activando entorno de -> ${ENV.toUpperCase()} ${chalk.gray(`(${found})`)}`); -} else { - console.log(`Activando entorno de -> ${ENV.toUpperCase()} (sin archivo .env; usando variables del proceso)`); -} - -// === 1) IMPORTS PRINCIPALES +import 'dotenv/config'; import express from 'express'; import cors from 'cors'; -import expressLayouts from 'express-ejs-layouts'; +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 { createClient as createRedisClient } from 'redis'; import * as connectRedis from 'connect-redis'; import { Pool } from 'pg'; -import bcrypt from 'bcrypt'; // <- lo conservamos si ya lo usabas -import crypto from 'node:crypto'; // <- idem -// Tolerante a cambios de export en connect-redis +// ----------------------------------------------------------------------------- +// Utilidades base +// ----------------------------------------------------------------------------- +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); const RedisStore = connectRedis.default || connectRedis.RedisStore; -// === 2) APP y CONFIG BASICA +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', 1); -app.use(cors({ origin: true, credentials: true })); -app.use(express.json({ limit: '1mb' })); -app.use(express.urlencoded({ extended: true })); -// Vistas EJS (si no usás vistas, puedes dejarlo; no rompe) +// Views EJS en ./views app.set('views', path.join(__dirname, 'views')); app.set('view engine', 'ejs'); app.use(expressLayouts); -app.set('layout', 'layout'); +app.set("layout", "layouts/main"); -// Estáticos opcionales (ajusta si tu estructura difiere) -app.use(express.static(path.join(__dirname, 'public'))); +// Estáticos (si tenés carpeta public/, assets, etc.) +app.use('/public', express.static(path.join(__dirname, 'public'))); -// === 3) SESIONES COMPARTIDAS (mismo cookie que AUTH) -const SESSION_SECRET = process.env.SESSION_SECRET || 'change-me-in-dev'; -const REDIS_URL = process.env.REDIS_URL || 'redis://authentik-redis:6379'; +// Middlewares básicos +app.use(morgan('dev')); +app.use(cors({ origin: true, credentials: true })); +app.use(express.json()); +app.use(express.urlencoded({ extended: true })); -const redis = createRedisClient({ url: REDIS_URL }); -await redis.connect().catch((e) => { - console.warn('⚠ No se pudo conectar a Redis de sesiones:', e?.message || e); +// ---------------------------------------------------------- +// Middleware para datos globales +// ---------------------------------------------------------- +app.use((req, res, next) => { + res.locals.currentPath = req.path; + res.locals.pageTitle = "SuiteCoffee"; + res.locals.pageId = ""; + next(); }); + +// ----------------------------------------------------------------------------- +// Sesión (Redis) — misma cookie que AUTH +// ----------------------------------------------------------------------------- +const SESSION_COOKIE_NAME = 'sc.sid'; +const redis = createRedisClient({ url: process.env.REDIS_URL }); +await redis.connect().catch((e) => console.error('[APP] Redis session error:', e.message)); + app.use( session({ - name: 'sc.sid', // <- igual que en AUTH + name: SESSION_COOKIE_NAME, store: new RedisStore({ client: redis, prefix: 'sess:' }), - secret: SESSION_SECRET, + secret: process.env.SESSION_SECRET || 'change-me', resave: false, saveUninitialized: false, cookie: { httpOnly: true, sameSite: 'lax', secure: process.env.NODE_ENV === 'production', + // domain: 'suitecoffee.mateosaldain.uy', // (opcional) si lo necesitás }, }) ); -// Exponer el usuario a las vistas (no tocar req.session) +// Exponer usuario a las vistas app.use((req, res, next) => { res.locals.user = req.session?.user || null; next(); }); -// === 4) POOLS A BASES DE DATOS === -// 4.1) Base principal (si la usás en APP). Conservamos variables usadas en el repo. -const DB = { - 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', -}; -export const mainPool = new Pool({ ...DB, max: 10, idleTimeoutMillis: 30_000 }); - -async function verificarConexion() { - try { - const c = await mainPool.connect(); - const { rows } = await c.query('SELECT NOW() AS now'); - console.log(`DB principal OK @ ${rows[0].now}`); - c.release(); - } catch (e) { - console.warn('⚠ No se pudo verificar DB principal:', e?.message || e); +// ----------------------------------------------------------------------------- +// Middlewares de Auth/Tenant para routes.legacy.js +// ----------------------------------------------------------------------------- +function requireAuth(req, res, next) { + if (!req.session?.user) { + // Si querés devolver 401 en lugar de redirigir, cambia esta línea + return res.redirect('/auth/login'); } + next(); } -// 4.2) Base multi-tenant (un solo DB con esquemas por tenant) -const TENANTS = { - host: process.env.TENANTS_HOST || 'dev-tenants', - port: Number(process.env.TENANTS_PORT || 5432), - user: process.env.TENANTS_USER || 'postgres', - password: process.env.TENANTS_PASS || 'postgres', - database: process.env.TENANTS_DB || 'dev-postgres', -}; -export const tenantsPool = new Pool({ ...TENANTS, max: 20, idleTimeoutMillis: 30_000 }); - -// === 5) MIDDLEWARES DE AUTENTICACIÓN Y TENANT === -export function requireAuth(req, res, next) { - if (req.session?.user) return next(); - // Fallback DEV: permitir si el front envía explícitamente el tenant (para pruebas) - if (req.get('x-tenant-uuid')) return next(); - return res.status(401).json({ error: 'no-auth' }); -} - -function getTenantUuid(req) { - const h = req.get('x-tenant-uuid'); - if (h) return String(h).replace(/-/g, ''); - const s = req.session?.user?.tenant_uuid; - if (s) return String(s).replace(/-/g, ''); - throw new Error('Tenant no especificado'); -} - -export async function withTenant(req, res, next) { - const client = await tenantsPool.connect(); +// Abre un client al DB de tenants y fija search_path al esquema del usuario +async function withTenant(req, res, next) { try { - await client.query('BEGIN'); - const uuid = getTenantUuid(req); - const schema = `schema_tenant_${uuid}`; + const hex = CLEAN_HEX(req.session?.user?.tenant_uuid); + if (!hex) return res.status(400).json({ error: 'tenant-missing' }); - // Si creaste la función en DB: SELECT public.f_set_search_path($1) - // await client.query('SELECT public.f_set_search_path($1)', [schema]); - await client.query(`SET LOCAL search_path TO ${schema.replace(/"/g, '')}`); + 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; - req.pgSchema = schema; + + // Liberar el client al finalizar la respuesta + const release = () => { + try { client.release(); } catch {} + }; + res.on('finish', release); + res.on('close', release); + next(); } catch (e) { - try { await client.query('ROLLBACK'); } catch {} - client.release(); - return res.status(400).json({ error: e.message }); + next(e); } } -export async function done(req, res, next) { - try { - if (req.pg) await req.pg.query('COMMIT'); - } catch (e) { - try { if (req.pg) await req.pg.query('ROLLBACK'); } catch {} - } finally { - if (req.pg) req.pg.release(); +// 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('/comandas'); // ya logueado → dashboard } - next?.(); -} + return res.render('login', { pageTitle: 'Iniciar sesión' }); +}); -// === 6) RUTAS BÁSICAS / HEALTH === -app.get('/health', (_req, res) => res.status(200).json({ status: 'ok' })); -app.get('/api/health', (_req, res) => res.status(200).json({ status: 'ok' })); +// 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'); -// === 7) MONTAJE AUTOMÁTICO DE ENDPOINTS LEGACY === -// Para NO PERDER NADA de tu archivo original: -// 1) Crea services/app/src/routes.legacy.js y exporta por defecto una función: -// export default function mount(app, ctx) { /* pega aquí TODOS tus app.get/post/... */ } -// // ctx trae: { requireAuth, withTenant, done, mainPool, tenantsPool, express } -// 2) O exporta un Router en routes.legacy.js como: export const router = Router(); -// 3) Este bloque intentará montarlo si existe. -try { - const legacy = await import('./routes.legacy.js'); - if (legacy?.default) { - legacy.default(app, { requireAuth, withTenant, done, mainPool, tenantsPool, express }); - console.log('✔ Endpoints legacy montados (función default)'); - } else if (legacy?.router) { - app.use(legacy.router); - console.log('✔ Endpoints legacy montados (router)'); - } -} catch { - console.log('ℹ No se encontró routes.legacy.js; continúa sólo con las rutas nuevas'); -} + 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, +}); + +// ----------------------------------------------------------------------------- +// Health + 404 + errores +// ----------------------------------------------------------------------------- +app.get('/health', (_req, res) => res.status(200).json({ status: 'ok', service: 'app' })); -// === 8) 404 + MANEJO DE ERRORES === app.use((req, res) => res.status(404).json({ error: 'not-found', path: req.originalUrl })); + app.use((err, _req, res, _next) => { - console.error('❌ Error APP:', err); + console.error('[APP] Error:', err); if (res.headersSent) return; res.status(500).json({ error: 'internal-error', detail: err?.message || String(err) }); }); -// === 9) ARRANQUE === -const PORT = Number(process.env.APP_LOCAL_PORT || process.env.PORT || 4000); -(async () => { - console.log(`Entorno -> ${ENV.toUpperCase()} | Puerto -> ${PORT}`); - await verificarConexion(); - app.listen(PORT, () => console.log(`SuiteCoffee APP escuchando en ${chalk.yellow(`http://localhost:${PORT}`)}`)); -})(); +// ----------------------------------------------------------------------------- +// 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}`); +}); diff --git a/services/auth/src/views/login.ejs b/services/app/src/views/login.ejs similarity index 100% rename from services/auth/src/views/login.ejs rename to services/app/src/views/login.ejs diff --git a/services/auth/.env.development b/services/auth/.env.development new file mode 100644 index 0000000..f22c778 --- /dev/null +++ b/services/auth/.env.development @@ -0,0 +1,103 @@ +# ===== Runtime ===== +NODE_ENV=development +PORT=4040 +AUTH_LOCAL_PORT=4040 # coincide con 'expose' del servicio auth + +# ===== Session (usa el Redis del stack) ===== +# Para DEV podemos reutilizar el Redis de Authentik. En prod conviene uno separado. +SESSION_SECRET=pon-una-clave-larga-y-unica +REDIS_URL=redis://authentik-redis:6379 + +# ===== DB principal (metadatos de SuiteCoffee) ===== +# Usa el alias de red del servicio 'db' (compose: aliases [dev-db]) +DB_HOST=dev-db +DB_PORT=5432 +DB_NAME=dev-suitecoffee +DB_USER=dev-user-suitecoffee +DB_PASS=dev-pass-suitecoffee + +# ===== DB tenants (Tenants de SuiteCoffee) ===== +TENANTS_HOST=dev-tenants +TENANTS_DB=dev-postgres +TENANTS_USER=dev-user-postgres +TENANTS_PASS=dev-pass-postgres +TENANTS_PORT=5432 + +TENANT_INIT_SQL=/home/mateo/SuiteCoffee/services/auth/src/db/initTenant.sql +# TENANT_INIT_SQL=~/SuiteCoffee/services/app/src/db/01_init.sql + +# ===== (Opcional) Colores UI, si alguna vista los lee ===== +COL_PRI=452D19 # Marrón oscuro +COL_SEC=D7A666 # Crema / Café +COL_BG=FFA500 # Naranja + +# ===== Authentik — Admin API (server-to-server dentro de la red) ===== +# Usa el alias de red del servicio 'authentik' y su puerto interno 9000 +AUTHENTIK_BASE_URL=http://authentik:9000 +AUTHENTIK_TOKEN=eE3bFTLd4Rpt3ZkcidTC1EppDYMIr023ev3SXt4ImHynOfAGRVtAZVBXSNxj +AUTHENTIK_DEFAULT_GROUP_NAME=suitecoffee-users + +# ===== 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_CLIENT_ID=ydnp9s9I7G4p9Pwt5OsNlcpk1VKB9auN7AxqqNjC +OIDC_CLIENT_SECRET=yqdI00kYMeQF8VdmhwN5QWUzPLUzRBYeeAH193FynuVD19mo1nBRf5c5IRojzPrxDS0Hk33guUwHFzaj8vjTbTRetwK528uNJ6BfrYGUN2vzxgdMHFLQOHSTR0gR1LtG + +# 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 + +# 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 diff --git a/services/auth/.env.production b/services/auth/.env.production new file mode 100644 index 0000000..709cbf7 --- /dev/null +++ b/services/auth/.env.production @@ -0,0 +1,22 @@ +NODE_ENV=production # Entorno de desarrollo + +PORT=4000 # Variables del servicio -> suitecoffee-app + +# AUTH_HOST=prod-auth + +DB_HOST=prod-db +# Nombre de la base de datos +DB_NAME=suitecoffee + +# Usuario y contraseña +DB_USER=suitecoffee +DB_PASS=suitecoffee + +# Puertos del servicio de db +DB_LOCAL_PORT=5432 +DB_DOCKER_PORT=5432 + +# Colores personalizados +COL_PRI=452D19 # Marrón oscuro +COL_SEC=D7A666 # Crema / Café +COL_BG=FFA500 # Naranja \ No newline at end of file diff --git a/services/auth/src/ak.js b/services/auth/src/ak.js index 22545e1..739c7c0 100644 --- a/services/auth/src/ak.js +++ b/services/auth/src/ak.js @@ -23,6 +23,29 @@ function getConfig() { // ------------------------------------------------------------ // Helpers de sincronización // ------------------------------------------------------------ + +// -- util GET contra la API admin (ajusta si ya tenés un helper igual) +async function akGET(path) { + const base = (process.env.AUTHENTIK_BASE_URL || '').replace(/\/+$/, ''); + const url = `${base}${path}`; + const res = await fetch(url, { + headers: { Authorization: `Bearer ${process.env.AUTHENTIK_TOKEN}` }, + }); + if (!res.ok) { + const body = await res.text().catch(() => ''); + throw new Error(`AK GET ${path} -> ${res.status}: ${body}`); + } + return res.json(); +} + +// -- listar grupos con búsqueda por nombre/slug +export async function akListGroups(search = '') { + const q = search ? `?search=${encodeURIComponent(search)}` : ''; + const data = await akGET(`/api/v3/core/groups/${q}`); + // algunas versiones devuelven {results:[]}, otras un array directo + return Array.isArray(data) ? data : (data.results || []); +} + export async function akPatchUserAttributes(userPk, partialAttrs = {}) { // PATCH del usuario para asegurar attributes.tenant_uuid return akRequest('patch', `/api/v3/core/users/${userPk}/`, { @@ -196,18 +219,46 @@ export async function akSetPassword(userPk, password, requireChange = true) { * Helper opcional para obtener grupos por nombre/slug si en el futuro lo necesitas * (no usado por index.js; se deja por conveniencia). */ -export async function akListGroups(search) { - const data = await request('GET', '/core/groups/', { qs: { search, page_size: 50 }, retries: 2 }); - return Array.isArray(data?.results) ? data.results : []; -} export async function akResolveGroupIdByName(name) { const data = await akListGroups(name); - const lower = name.toLowerCase(); - const found = data.find(g => (g.name || '').toLowerCase() === lower || (g.slug || '').toLowerCase() === lower); - return found?.pk || null; + const lower = String(name || '').toLowerCase(); + const found = data.find(g => + String(g.name || '').toLowerCase() === lower || + String(g.slug || '').toLowerCase() === lower + ); + return found?.pk ?? null; } +export async function akResolveGroupId({ id, pk, uuid, name, slug } = {}) { + // si te pasan pk/id directo, devolvelo + if (pk != null) return Number(pk); + if (id != null) return Number(id); + + // por UUID (devuelve objeto con pk) + if (uuid) { + try { + const g = await akGET(`/api/v3/core/groups/${encodeURIComponent(uuid)}/`); + if (g?.pk != null) return Number(g.pk); + } catch (e) { + // sigue intentando por nombre/slug + } + } + + // por nombre/slug + if (name || slug) { + const needle = (name || slug); + const list = await akListGroups(needle); + const lower = String(needle || '').toLowerCase(); + const found = list.find(g => + String(g.name || '').toLowerCase() === lower || + String(g.slug || '').toLowerCase() === lower + ); + if (found?.pk != null) return Number(found.pk); + } + + return null; +} // ------------------------------------------------------------ // Fin diff --git a/services/auth/src/db/initTenant.sql b/services/auth/src/db/initTenant.sql new file mode 100644 index 0000000..a43ab8c --- /dev/null +++ b/services/auth/src/db/initTenant.sql @@ -0,0 +1,2239 @@ +-- =============================================================== +-- SuiteCoffee — Template de inicialización por tenant +-- Archivo: 01_init.sql +-- Uso (psql): +-- \set SCHEMA_NAME schema_tenant_12345678abcd +-- CREATE SCHEMA IF NOT EXISTS :"SCHEMA_NAME"; +-- -- Opcional: SET ROLE ; +-- \i 01_init.sql +-- =============================================================== + +BEGIN; +SET client_encoding = 'UTF8'; +SET standard_conforming_strings = on; +SET check_function_bodies = false; +SET client_min_messages = warning; +SET row_security = off; + +-- establece el schema de destino +SET search_path = :"SCHEMA_NAME", public; + +-- +-- PostgreSQL database dump +-- + +\restrict londHmqT4llS8Wof4ZnceO2dyFhn4jiR5xbaszMgZpMczgr6aVW6xQJxeUdqJwa + +-- 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) + +SET statement_timeout = 0; +SET lock_timeout = 0; +SET idle_in_transaction_session_timeout = 0; +SET client_encoding = 'UTF8'; +SET standard_conforming_strings = on; +SET check_function_bodies = false; +SET xmloption = content; +SET client_min_messages = warning; +SET row_security = off; + +-- +-- Name: asistencia_delete_raw(bigint, text); Type: FUNCTION; Schema: public; Owner: manso +-- + +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: public; Owner: manso +-- + +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: public; Owner: manso +-- + +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: public; Owner: manso +-- + +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: public; Owner: manso +-- + +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: public; Owner: manso +-- + +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: public; Owner: manso +-- + +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: public; Owner: manso +-- + +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: public; Owner: manso +-- + +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: public; Owner: manso +-- + +COMMENT ON COLUMN comandas.fec_cierre IS 'Fecha/hora de cierre de la comanda (NULL si está abierta)'; + +-- +-- Name: deta_comandas; Type: TABLE; Schema: public; Owner: manso +-- + +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: public; Owner: manso +-- + +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: public; Owner: manso +-- + +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: public; Owner: manso +-- + +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: public; Owner: manso +-- + +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: public; Owner: manso +-- + +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: public; Owner: manso +-- + +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: public; Owner: manso +-- + +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: public; Owner: manso +-- + +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: public; Owner: manso +-- + +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: public; Owner: manso +-- + +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: public; Owner: manso +-- + +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: public; Owner: manso +-- + +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: public; Owner: manso +-- + +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: public; Owner: manso +-- + +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: public; Owner: manso +-- + +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: public; Owner: manso +-- + +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: public; Owner: manso +-- + +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: public; Owner: manso +-- + +ALTER SEQUENCE asistencia_intervalo_id_intervalo_seq OWNED BY asistencia_intervalo.id_intervalo; + +-- +-- Name: asistencia_raw; Type: TABLE; Schema: public; Owner: manso +-- + +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: public; Owner: manso +-- + +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: public; Owner: manso +-- + +ALTER SEQUENCE asistencia_raw_id_raw_seq OWNED BY asistencia_raw.id_raw; + +-- +-- Name: asistencia_resumen_diario; Type: VIEW; Schema: public; Owner: manso +-- + +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: public; Owner: manso +-- + +CREATE TABLE categorias ( + id_categoria integer NOT NULL, + nombre text NOT NULL, + visible boolean DEFAULT true +); + +-- +-- Name: categorias_id_categoria_seq; Type: SEQUENCE; Schema: public; Owner: manso +-- + +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: public; Owner: manso +-- + +ALTER SEQUENCE categorias_id_categoria_seq OWNED BY categorias.id_categoria; + +-- +-- Name: clientes; Type: TABLE; Schema: public; Owner: manso +-- + +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: public; Owner: manso +-- + +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: public; Owner: manso +-- + +ALTER SEQUENCE clientes_id_cliente_seq OWNED BY clientes.id_cliente; + +-- +-- Name: comandas_id_comanda_seq; Type: SEQUENCE; Schema: public; Owner: manso +-- + +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: public; Owner: manso +-- + +ALTER SEQUENCE comandas_id_comanda_seq OWNED BY comandas.id_comanda; + +-- +-- Name: compras; Type: TABLE; Schema: public; Owner: manso +-- + +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: public; Owner: manso +-- + +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: public; Owner: manso +-- + +ALTER SEQUENCE compras_id_compra_seq OWNED BY compras.id_compra; + +-- +-- Name: deta_comandas_id_det_comanda_seq; Type: SEQUENCE; Schema: public; Owner: manso +-- + +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: public; Owner: manso +-- + +ALTER SEQUENCE deta_comandas_id_det_comanda_seq OWNED BY deta_comandas.id_det_comanda; + +-- +-- Name: deta_comp_materias; Type: TABLE; Schema: public; Owner: manso +-- + +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: public; Owner: manso +-- + +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: public; Owner: manso +-- + +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: public; Owner: manso +-- + +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: public; Owner: manso +-- + +ALTER SEQUENCE mate_primas_id_mat_prima_seq OWNED BY mate_primas.id_mat_prima; + +-- +-- Name: mesas_id_mesa_seq; Type: SEQUENCE; Schema: public; Owner: manso +-- + +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: public; Owner: manso +-- + +ALTER SEQUENCE mesas_id_mesa_seq OWNED BY mesas.id_mesa; + +-- +-- Name: productos; Type: TABLE; Schema: public; Owner: manso +-- + +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: public; Owner: manso +-- + +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: public; Owner: manso +-- + +ALTER SEQUENCE productos_id_producto_seq OWNED BY productos.id_producto; + +-- +-- Name: prov_mate_prima; Type: TABLE; Schema: public; Owner: manso +-- + +CREATE TABLE prov_mate_prima ( + id_proveedor integer NOT NULL, + id_mat_prima integer NOT NULL +); + +-- +-- Name: prov_producto; Type: TABLE; Schema: public; Owner: manso +-- + +CREATE TABLE prov_producto ( + id_proveedor integer NOT NULL, + id_producto integer NOT NULL +); + +-- +-- Name: proveedores; Type: TABLE; Schema: public; Owner: manso +-- + +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: public; Owner: manso +-- + +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: public; Owner: manso +-- + +ALTER SEQUENCE proveedores_id_proveedor_seq OWNED BY proveedores.id_proveedor; + +-- +-- Name: receta_producto; Type: TABLE; Schema: public; Owner: manso +-- + +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: public; Owner: manso +-- + +CREATE TABLE roles ( + id_rol integer NOT NULL, + nombre text NOT NULL +); + +-- +-- Name: roles_id_rol_seq; Type: SEQUENCE; Schema: public; Owner: manso +-- + +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: public; Owner: manso +-- + +ALTER SEQUENCE roles_id_rol_seq OWNED BY roles.id_rol; + +-- +-- Name: usua_roles; Type: TABLE; Schema: public; Owner: manso +-- + +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: public; Owner: manso +-- + +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: public; Owner: manso +-- + +ALTER SEQUENCE usuarios_id_usuario_seq OWNED BY usuarios.id_usuario; + +-- +-- Name: v_comandas_detalle_base; Type: VIEW; Schema: public; Owner: manso +-- + +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: public; Owner: manso +-- + +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: public; Owner: manso +-- + +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: public; Owner: manso +-- + +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: public; Owner: manso +-- + +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: public; Owner: manso +-- + +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: public; Owner: manso +-- + +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: public; Owner: manso +-- + +ALTER TABLE ONLY categorias ALTER COLUMN id_categoria SET DEFAULT nextval('categorias_id_categoria_seq'::regclass); + +-- +-- Name: clientes id_cliente; Type: DEFAULT; Schema: public; Owner: manso +-- + +ALTER TABLE ONLY clientes ALTER COLUMN id_cliente SET DEFAULT nextval('clientes_id_cliente_seq'::regclass); + +-- +-- Name: comandas id_comanda; Type: DEFAULT; Schema: public; Owner: manso +-- + +ALTER TABLE ONLY comandas ALTER COLUMN id_comanda SET DEFAULT nextval('comandas_id_comanda_seq'::regclass); + +-- +-- Name: compras id_compra; Type: DEFAULT; Schema: public; Owner: manso +-- + +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: public; Owner: manso +-- + +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: public; Owner: manso +-- + +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: public; Owner: manso +-- + +ALTER TABLE ONLY mesas ALTER COLUMN id_mesa SET DEFAULT nextval('mesas_id_mesa_seq'::regclass); + +-- +-- Name: productos id_producto; Type: DEFAULT; Schema: public; Owner: manso +-- + +ALTER TABLE ONLY productos ALTER COLUMN id_producto SET DEFAULT nextval('productos_id_producto_seq'::regclass); + +-- +-- Name: proveedores id_proveedor; Type: DEFAULT; Schema: public; Owner: manso +-- + +ALTER TABLE ONLY proveedores ALTER COLUMN id_proveedor SET DEFAULT nextval('proveedores_id_proveedor_seq'::regclass); + +-- +-- Name: roles id_rol; Type: DEFAULT; Schema: public; Owner: manso +-- + +ALTER TABLE ONLY roles ALTER COLUMN id_rol SET DEFAULT nextval('roles_id_rol_seq'::regclass); + +-- +-- Name: usuarios id_usuario; Type: DEFAULT; Schema: public; Owner: manso +-- + +ALTER TABLE ONLY usuarios ALTER COLUMN id_usuario SET DEFAULT nextval('usuarios_id_usuario_seq'::regclass); + +-- +-- +-- +-- +-- +-- +-- +-- +-- +-- +-- +-- +-- +-- +-- +-- +-- +-- +-- +-- +-- Name: asistencia_intervalo_id_intervalo_seq; Type: SEQUENCE SET; Schema: public; Owner: manso +-- + +SELECT pg_catalog.setval('asistencia_intervalo_id_intervalo_seq', 108, true); + +-- +-- Name: asistencia_raw_id_raw_seq; Type: SEQUENCE SET; Schema: public; Owner: manso +-- + +SELECT pg_catalog.setval('asistencia_raw_id_raw_seq', 88, true); + +-- +-- Name: categorias_id_categoria_seq; Type: SEQUENCE SET; Schema: public; Owner: manso +-- + +SELECT pg_catalog.setval('categorias_id_categoria_seq', 4, true); + +-- +-- Name: clientes_id_cliente_seq; Type: SEQUENCE SET; Schema: public; Owner: manso +-- + +SELECT pg_catalog.setval('clientes_id_cliente_seq', 1, true); + +-- +-- Name: comandas_id_comanda_seq; Type: SEQUENCE SET; Schema: public; Owner: manso +-- + +SELECT pg_catalog.setval('comandas_id_comanda_seq', 55, true); + +-- +-- Name: compras_id_compra_seq; Type: SEQUENCE SET; Schema: public; Owner: manso +-- + +SELECT pg_catalog.setval('compras_id_compra_seq', 66, true); + +-- +-- Name: deta_comandas_id_det_comanda_seq; Type: SEQUENCE SET; Schema: public; Owner: manso +-- + +SELECT pg_catalog.setval('deta_comandas_id_det_comanda_seq', 117, true); + +-- +-- Name: mate_primas_id_mat_prima_seq; Type: SEQUENCE SET; Schema: public; Owner: manso +-- + +SELECT pg_catalog.setval('mate_primas_id_mat_prima_seq', 10, true); + +-- +-- Name: mesas_id_mesa_seq; Type: SEQUENCE SET; Schema: public; Owner: manso +-- + +SELECT pg_catalog.setval('mesas_id_mesa_seq', 14, true); + +-- +-- Name: productos_id_producto_seq; Type: SEQUENCE SET; Schema: public; Owner: manso +-- + +SELECT pg_catalog.setval('productos_id_producto_seq', 52, true); + +-- +-- Name: proveedores_id_proveedor_seq; Type: SEQUENCE SET; Schema: public; Owner: manso +-- + +SELECT pg_catalog.setval('proveedores_id_proveedor_seq', 4, true); + +-- +-- Name: roles_id_rol_seq; Type: SEQUENCE SET; Schema: public; Owner: manso +-- + +SELECT pg_catalog.setval('roles_id_rol_seq', 6, true); + +-- +-- Name: usuarios_id_usuario_seq; Type: SEQUENCE SET; Schema: public; Owner: manso +-- + +SELECT pg_catalog.setval('usuarios_id_usuario_seq', 3, true); + +-- +-- Name: asistencia_intervalo asistencia_intervalo_id_usuario_desde_hasta_key; Type: CONSTRAINT; Schema: public; Owner: manso +-- + +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: public; Owner: manso +-- + +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: public; Owner: manso +-- + +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: public; Owner: manso +-- + +ALTER TABLE ONLY asistencia_raw + ADD CONSTRAINT asistencia_raw_pkey PRIMARY KEY (id_raw); + +-- +-- Name: categorias categorias_nombre_key; Type: CONSTRAINT; Schema: public; Owner: manso +-- + +ALTER TABLE ONLY categorias + ADD CONSTRAINT categorias_nombre_key UNIQUE (nombre); + +-- +-- Name: categorias categorias_pkey; Type: CONSTRAINT; Schema: public; Owner: manso +-- + +ALTER TABLE ONLY categorias + ADD CONSTRAINT categorias_pkey PRIMARY KEY (id_categoria); + +-- +-- Name: clientes clientes_correo_key; Type: CONSTRAINT; Schema: public; Owner: manso +-- + +ALTER TABLE ONLY clientes + ADD CONSTRAINT clientes_correo_key UNIQUE (correo); + +-- +-- Name: clientes clientes_pkey; Type: CONSTRAINT; Schema: public; Owner: manso +-- + +ALTER TABLE ONLY clientes + ADD CONSTRAINT clientes_pkey PRIMARY KEY (id_cliente); + +-- +-- Name: clientes clientes_telefono_key; Type: CONSTRAINT; Schema: public; Owner: manso +-- + +ALTER TABLE ONLY clientes + ADD CONSTRAINT clientes_telefono_key UNIQUE (telefono); + +-- +-- Name: comandas comandas_pkey; Type: CONSTRAINT; Schema: public; Owner: manso +-- + +ALTER TABLE ONLY comandas + ADD CONSTRAINT comandas_pkey PRIMARY KEY (id_comanda); + +-- +-- Name: compras compras_pkey; Type: CONSTRAINT; Schema: public; Owner: manso +-- + +ALTER TABLE ONLY compras + ADD CONSTRAINT compras_pkey PRIMARY KEY (id_compra); + +-- +-- Name: deta_comandas deta_comandas_pkey; Type: CONSTRAINT; Schema: public; Owner: manso +-- + +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: public; Owner: manso +-- + +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: public; Owner: manso +-- + +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: public; Owner: manso +-- + +ALTER TABLE ONLY mate_primas + ADD CONSTRAINT mate_primas_nombre_key UNIQUE (nombre); + +-- +-- Name: mate_primas mate_primas_pkey; Type: CONSTRAINT; Schema: public; Owner: manso +-- + +ALTER TABLE ONLY mate_primas + ADD CONSTRAINT mate_primas_pkey PRIMARY KEY (id_mat_prima); + +-- +-- Name: mesas mesas_apodo_key; Type: CONSTRAINT; Schema: public; Owner: manso +-- + +ALTER TABLE ONLY mesas + ADD CONSTRAINT mesas_apodo_key UNIQUE (apodo); + +-- +-- Name: mesas mesas_numero_key; Type: CONSTRAINT; Schema: public; Owner: manso +-- + +ALTER TABLE ONLY mesas + ADD CONSTRAINT mesas_numero_key UNIQUE (numero); + +-- +-- Name: mesas mesas_pkey; Type: CONSTRAINT; Schema: public; Owner: manso +-- + +ALTER TABLE ONLY mesas + ADD CONSTRAINT mesas_pkey PRIMARY KEY (id_mesa); + +-- +-- Name: productos productos_pkey; Type: CONSTRAINT; Schema: public; Owner: manso +-- + +ALTER TABLE ONLY productos + ADD CONSTRAINT productos_pkey PRIMARY KEY (id_producto); + +-- +-- Name: prov_mate_prima prov_mate_prima_pkey; Type: CONSTRAINT; Schema: public; Owner: manso +-- + +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: public; Owner: manso +-- + +ALTER TABLE ONLY prov_producto + ADD CONSTRAINT prov_producto_pkey PRIMARY KEY (id_proveedor, id_producto); + +-- +-- Name: proveedores proveedores_pkey; Type: CONSTRAINT; Schema: public; Owner: manso +-- + +ALTER TABLE ONLY proveedores + ADD CONSTRAINT proveedores_pkey PRIMARY KEY (id_proveedor); + +-- +-- Name: proveedores proveedores_rut_key; Type: CONSTRAINT; Schema: public; Owner: manso +-- + +ALTER TABLE ONLY proveedores + ADD CONSTRAINT proveedores_rut_key UNIQUE (rut); + +-- +-- Name: receta_producto receta_producto_pkey; Type: CONSTRAINT; Schema: public; Owner: manso +-- + +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: public; Owner: manso +-- + +ALTER TABLE ONLY roles + ADD CONSTRAINT roles_nombre_key UNIQUE (nombre); + +-- +-- Name: roles roles_pkey; Type: CONSTRAINT; Schema: public; Owner: manso +-- + +ALTER TABLE ONLY roles + ADD CONSTRAINT roles_pkey PRIMARY KEY (id_rol); + +-- +-- Name: usua_roles usua_roles_pkey; Type: CONSTRAINT; Schema: public; Owner: manso +-- + +ALTER TABLE ONLY usua_roles + ADD CONSTRAINT usua_roles_pkey PRIMARY KEY (id_usuario, id_rol); + +-- +-- Name: usuarios usuarios_documento_key; Type: CONSTRAINT; Schema: public; Owner: manso +-- + +ALTER TABLE ONLY usuarios + ADD CONSTRAINT usuarios_documento_key UNIQUE (documento); + +-- +-- Name: usuarios usuarios_pkey; Type: CONSTRAINT; Schema: public; Owner: manso +-- + +ALTER TABLE ONLY usuarios + ADD CONSTRAINT usuarios_pkey PRIMARY KEY (id_usuario); + +-- +-- Name: compras_fec_compra_idx; Type: INDEX; Schema: public; Owner: manso +-- + +CREATE INDEX compras_fec_compra_idx ON compras USING btree (fec_compra); + +-- +-- Name: idx_asist_int_usuario_fecha; Type: INDEX; Schema: public; Owner: manso +-- + +CREATE INDEX idx_asist_int_usuario_fecha ON asistencia_intervalo USING btree (id_usuario, fecha); + +-- +-- Name: idx_asist_raw_usuario_ts; Type: INDEX; Schema: public; Owner: manso +-- + +CREATE INDEX idx_asist_raw_usuario_ts ON asistencia_raw USING btree (id_usuario, ts); + +-- +-- Name: idx_detalle_comanda_comanda; Type: INDEX; Schema: public; Owner: manso +-- + +CREATE INDEX idx_detalle_comanda_comanda ON deta_comandas USING btree (id_comanda); + +-- +-- Name: idx_detalle_comanda_producto; Type: INDEX; Schema: public; Owner: manso +-- + +CREATE INDEX idx_detalle_comanda_producto ON deta_comandas USING btree (id_producto); + +-- +-- Name: ix_comandas_fec_cierre; Type: INDEX; Schema: public; Owner: manso +-- + +CREATE INDEX ix_comandas_fec_cierre ON comandas USING btree (fec_cierre); + +-- +-- Name: ix_comandas_id; Type: INDEX; Schema: public; Owner: manso +-- + +CREATE INDEX ix_comandas_id ON comandas USING btree (id_comanda); + +-- +-- Name: ix_deta_comandas_id_comanda; Type: INDEX; Schema: public; Owner: manso +-- + +CREATE INDEX ix_deta_comandas_id_comanda ON deta_comandas USING btree (id_comanda); + +-- +-- Name: ix_deta_comandas_id_producto; Type: INDEX; Schema: public; Owner: manso +-- + +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: public; Owner: manso +-- + +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: public; Owner: manso +-- + +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: public; Owner: manso +-- + +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: public; Owner: manso +-- + +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: public; Owner: manso +-- + +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: public; Owner: manso +-- + +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: public; Owner: manso +-- + +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: public; Owner: manso +-- + +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: public; Owner: manso +-- + +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: public; Owner: manso +-- + +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: public; Owner: manso +-- + +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: public; Owner: manso +-- + +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: public; Owner: manso +-- + +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: public; Owner: manso +-- + +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: public; Owner: manso +-- + +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: public; Owner: manso +-- + +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: public; Owner: manso +-- + +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: public; Owner: manso +-- + +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: public; Owner: manso +-- + +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: public; Owner: manso +-- + +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: public; Owner: manso +-- + +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 +-- + +\unrestrict londHmqT4llS8Wof4ZnceO2dyFhn4jiR5xbaszMgZpMczgr6aVW6xQJxeUdqJwa + + +COMMIT; diff --git a/services/auth/src/index.js b/services/auth/src/index.js index 0db6504..603fe8a 100644 --- a/services/auth/src/index.js +++ b/services/auth/src/index.js @@ -13,6 +13,7 @@ import chalk from 'chalk'; import express from 'express'; import cors from 'cors'; import path from 'node:path'; +import fs from 'node:fs/promises'; import { fileURLToPath } from 'node:url'; import { Pool } from 'pg'; import session from 'express-session'; @@ -21,13 +22,14 @@ import * as connectRedis from 'connect-redis'; import expressLayouts from 'express-ejs-layouts'; import { Issuer, generators } from 'openid-client'; import crypto from 'node:crypto'; - - +import { readFile } from 'node:fs/promises'; // ----------------------------------------------------------------------------- // Helpers // ----------------------------------------------------------------------------- +const SESSION_COOKIE_NAME = 'sc.sid'; + // Normaliza UUID (acepta con/sin guiones) → "hex" sin guiones const cleanUuid = (u) => (u ? String(u).toLowerCase().replace(/[^0-9a-f]/g, '') : ''); @@ -36,12 +38,22 @@ const schemaNameFor = (uuidHex) => `schema_tenant_${uuidHex}`; const roleNameFor = (uuidHex) => `tenant_${uuidHex}`; // Helpers de Authentik (admin API) -const { akFindUserByEmail, akCreateUser, akSetPassword } = await import('./ak.js'); +const { + akFindUserByEmail, + akCreateUser, + akSetPassword, + akResolveGroupId +} = await import('./ak.js'); // Quoter seguro de identificadores SQL (roles, schemas, tablas) +// Identificador SQL (schema, role, table, …) const qi = (ident) => `"${String(ident).replace(/"/g, '""')}"`; -const qs = (str) => `'${String(str).replace(/'/g, "''")}'`; // quote string literal seguro -const VALID_IDENT = /^[a-z_][a-z0-9_]*$/i; + +// 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 @@ -66,6 +78,26 @@ if (!DEFAULT_GROUP_ID) { })(); } +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; @@ -87,12 +119,12 @@ async function tenantExists(uuidHex) { // 2) Authentik (attributes.tenant_uuid del usuario) // 3) valor provisto en el request (si viene) async function resolveExistingTenantUuid({ email, requestedTenantUuid }) { - const emailLower = String(email).toLowerCase(); + 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', - [emailLower] + [normEmail] ); if (dbRes.rowCount) { const fromDb = cleanUuid(dbRes.rows[0].tenant_uuid); @@ -100,7 +132,7 @@ async function resolveExistingTenantUuid({ email, requestedTenantUuid }) { } // 2) Authentik - const akUser = await akFindUserByEmail(emailLower).catch(() => null); + const akUser = await akFindUserByEmail(normEmail).catch(() => null); const fromAk = cleanUuid(akUser?.attributes?.tenant_uuid); if (fromAk) return fromAk; @@ -113,57 +145,198 @@ async function resolveExistingTenantUuid({ email, requestedTenantUuid }) { // Helper para crear tenant si falta async function ensureTenant({ tenant_uuid }) { - const client = await tenantsPool.connect(); + const admin = await tenantsPool.connect(); try { - await client.query('BEGIN'); + await admin.query('BEGIN'); - // Si no vino UUID, generamos uno - let uuid = (tenant_uuid || crypto.randomUUID()).toLowerCase(); - const uuidNoHyphen = uuid.replace(/-/g, ''); + // 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_${uuidNoHyphen}`; - const role = `tenant_${uuidNoHyphen}`; - const pwd = crypto.randomBytes(18).toString('base64url'); // password del rol + 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 - 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)}`); + // 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 al rol del tenant - const { rowCount: hasSchema } = await client.query( + // 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 (!hasSchema) { - await client.query(`CREATE SCHEMA ${qi(schema)} AUTHORIZATION ${qi(role)}`); + 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 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( + // 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 client.query( + await admin.query( `ALTER DEFAULT PRIVILEGES IN SCHEMA ${qi(schema)} GRANT USAGE, SELECT, UPDATE ON SEQUENCES TO ${qi(role)}` ); - await client.query('COMMIT'); + // 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, // recomendado via .env + path.resolve(__dirname, 'db', 'initTenant.sql'), + path.resolve(__dirname, '..', 'src', 'db', 'initTenant.sql'), + ].filter(Boolean); + for (const p of candidates) { + try { + await fs.promises.access(p, fs.constants.R_OK); + 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(); @@ -191,6 +364,13 @@ function onFatal(err, msg = 'Error fatal') { 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 // ----------------------------------------------------------------------------- @@ -217,7 +397,7 @@ await redis.connect().catch((e) => onFatal(e, 'No se pudo conectar a Redis (sesi app.use( session({ - name: 'sc.sid', + name: SESSION_COOKIE_NAME, store: new RedisStore({ client: redis, prefix: 'sess:' }), secret: process.env.SESSION_SECRET || 'change-me', resave: false, @@ -248,7 +428,6 @@ const tenantsPool = new Pool({ max: 10, }); - // ----------------------------------------------------------------------------- // PostgreSQL — DB principal (metadatos de negocio) // ----------------------------------------------------------------------------- @@ -328,85 +507,132 @@ let oidcClient; } })(); -// ----------------------------------------------------------------------------- -// Vistas -// ----------------------------------------------------------------------------- -app.get('/', (req, res) => res.render('login', { pageTitle: 'Iniciar sesión' })); - // ----------------------------------------------------------------------------- // Rutas OIDC // ----------------------------------------------------------------------------- -app.get('/auth/login', (req, res) => { - const code_verifier = generators.codeVerifier(); - const code_challenge = generators.codeChallenge(code_verifier); - const state = generators.state(); - // req.session.code_verifier = code_verifier; +app.get('/auth/login', (req, res, next) => { + try { - // guarda todo lo necesario para el callback - req.session.code_verifier = code_verifier; - req.session.state = state; + if (req.session?.oidc) { + return nukeSession(req, res, '/auth/login', 'stale_oidc'); + } - // log de depuración - console.log('[OIDC] start login sid=%s state=%s', req.sessionID, state) + const code_verifier = generators.codeVerifier(); + const code_challenge = generators.codeChallenge(code_verifier); - const url = oidcClient.authorizationUrl({ - scope: 'openid email profile', - code_challenge, - code_challenge_method: 'S256', - state, - }); - console.log('[OIDC] auth URL has state? %s', url.includes(`state=${state}`)); - return res.redirect(url); + // 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); - const params = oidcClient.callbackParams(req); - const tokenSet = await oidcClient.callback( - process.env.OIDC_REDIRECT_URI, - params, - { code_verifier: req.session.code_verifier, state: req.session.state } - ); - delete req.session.code_verifier; - delete req.session.state; + // 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(); - const tenantUuid = (claims.tenant_uuid || '').replace(/-/g, ''); - + + // tenant desde claim, Authentik o fallback a tu DB let tenantHex = cleanUuid(claims.tenant_uuid); if (!tenantHex) { - // intenta Authentik - const akUser = await akFindUserByEmail(email).catch(()=>null); + const akUser = await akFindUserByEmail(email).catch(() => null); tenantHex = cleanUuid(akUser?.attributes?.tenant_uuid); - // último recurso: tu DB if (!tenantHex) { - const q = await pool.query('SELECT tenant_uuid FROM app_user WHERE LOWER(email)=LOWER($1) LIMIT 1', [email]); + 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 + // Regenerar sesión para evitar fijación y guardar el usuario req.session.regenerate((err) => { - if (err) return next(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: tenantUuid || null, + tenant_uuid: tenantHex || null, }; - req.session.save((e2) => (e2 ? next(e2) : res.redirect('/'))); + 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('/'); // te llevará a /comandas si ya implementaste ese redirect + }); }); + + return res.redirect('/'); + } catch (e) { - next(e); + console.error('[OIDC] callback error:', e); + if (!res.headersSent) return next(e); } }); + app.post('/auth/logout', (req, res) => { req.session.destroy(() => { - res.clearCookie('sc.sid'); + res.clearCookie(SESSION_COOKIE_NAME); res.status(204).end(); }); }); @@ -420,104 +646,189 @@ app.get('/auth/me', (req, res) => { // 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); + } +} + +// ============================== +// POST /api/users/register +// ============================== app.post('/api/users/register', async (req, res, next) => { - const { email, display_name, tenant_uuid: requestedTenant, role } = req.body || {}; - if (!email) return res.status(400).json({ error: 'email es obligatorio' }); + // 0) input + const { + email, + display_name, + role, + tenant_uuid: requestedTenantUuid, // opcional + } = req.body || {}; - const emailLower = String(email).toLowerCase(); + const normEmail = String(email || '').trim().toLowerCase(); + if (!normEmail) return res.status(400).json({ error: 'email requerido' }); - // 1) ¿Ya hay tenant conocido (DB o Authentik o request)? - let tenantHex = await resolveExistingTenantUuid({ - email: emailLower, - requestedTenantUuid: requestedTenant, - }); - - // 2) Si viene por request, asegurate que exista (o créalo a demanda) - if (tenantHex) { - const exists = await tenantExists(tenantHex); - if (!exists) { - // Si tu política es NO crear si lo traen y no existe, devolvé 400. - // return res.status(400).json({ error: 'tenant-invalido', detail: 'El tenant indicado no existe' }); - - // Si preferís crearlo para "reparar", lo creamos sin generar un UUID nuevo: - await ensureTenant({ tenant_uuid: tenantHex }); + // 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); } + + // 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}`)); } - // 3) Si todavía no hay tenant → primer alta de org → crear uno nuevo - if (!tenantHex) { - const created = await ensureTenant({ tenant_uuid: null }); // genera uuid nuevo - tenantHex = cleanUuid(created.tenant_uuid); - } - - // 4) Alta transaccional del usuario en TU DB + // 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'); - // Evitar duplicar usuario por email + tenant (ajusta según tu constraint) + // 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) AND tenant_uuid=$2', - [emailLower, tenantHex] + '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 en este tenant.', + message: 'Ya existe un usuario con este email.', next: '/auth/login', }); } - // Authentik: crear si no existe; si existe, reusar y (opcional) asegurar attributes.tenant_uuid - let akUser = await akFindUserByEmail(emailLower); + // Authentik: buscar o crear + let akUser = await akFindUserByEmail(normEmail).catch(() => null); if (!akUser) { akUser = await akCreateUser({ - email: emailLower, - displayName: display_name, - tenantUuid: tenantHex, // se guarda en attributes + email: normEmail, + displayName: display_name || null, + tenantUuid: tenantHex, // attributes.tenant_uuid addToGroupId: DEFAULT_GROUP_ID || null, isActive: true, }); - } else { - // si existe y no tiene attribute tenant_uuid, lo “reparamos” (opcional): - const akAttrHex = cleanUuid(akUser?.attributes?.tenant_uuid); - if (!akAttrHex) { - try { - // parchea attributes del user en AK (si tu versión permite PATCH) - await akPatchUserAttributes(akUser.pk, { tenant_uuid: tenantHex }); - } catch { /* opcional, no crítico */ } - } + createdAkUser = akUser; // marcar que lo creamos nosotros } - const _role = role || 'owner'; + // 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)`, - [emailLower, display_name || null, tenantHex, akUser.uuid, _role] + [normEmail, display_name || null, tenantHex, akUserUuid, finalRole] ); await client.query('COMMIT'); - return res.status(201).json({ - message: 'Usuario registrado', - email: emailLower, - tenant_uuid: tenantHex, // devolvés el mismo - role: _role, - authentik_user_uuid: akUser.uuid, - next: '/auth/login', + // 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 {} - if (err?.code === '23505') { // unique_violation - return res.status(409).json({ error: 'user-exists' }); - } - next(err); + 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('/api/users/register', async (req, res, next) => { @@ -587,12 +898,12 @@ app.get('/health', (_req, res) => res.status(200).json({ status: 'ok' })); // ----------------------------------------------------------------------------- // 404 + Manejo de errores // ----------------------------------------------------------------------------- -app.use((req, res) => res.status(404).json({ error: 'not-found', path: req.originalUrl })); +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); 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 auth.', detail: err.stack || String(err) }); }); // ----------------------------------------------------------------------------- diff --git a/services/manso/.env.development b/services/manso/.env.development new file mode 100644 index 0000000..653f03e --- /dev/null +++ b/services/manso/.env.development @@ -0,0 +1,19 @@ +NODE_ENV=development + +PORT=3030 + +DB_HOST=dev-tenants +DB_NAME=manso + +# Usuario y contraseña +DB_USER=manso +DB_PASS=manso + +# Puertos del servicio de db +DB_LOCAL_PORT=5432 +DB_DOCKER_PORT=5432 + +# Colores personalizados +COL_PRI=452D19 # Marrón oscuro +COL_SEC=D7A666 # Crema / Café +COL_BG=FFA500 # Naranja \ No newline at end of file diff --git a/services/manso/.env.production b/services/manso/.env.production new file mode 100644 index 0000000..a79fe8b --- /dev/null +++ b/services/manso/.env.production @@ -0,0 +1,20 @@ +NODE_ENV=production # Entorno de desarrollo + +PORT=3000 # Variables del servicio -> suitecoffee-app + +# Variables del servicio -> suitecoffee-db de suitecoffee-app + +DB_HOST=dev-tenants +DB_NAME=manso + +# Usuario y contraseña +DB_USER=manso +DB_PASS=manso +# Puertos del servicio de db +DB_LOCAL_PORT=5432 +DB_DOCKER_PORT=5432 + +# Colores personalizados +COL_PRI=452D19 # Marrón oscuro +COL_SEC=D7A666 # Crema / Café +COL_BG=FFA500 # Naranja \ No newline at end of file