diff --git a/compose.dev.yaml b/compose.dev.yaml index 88b2fab..2d66784 100644 --- a/compose.dev.yaml +++ b/compose.dev.yaml @@ -1,13 +1,10 @@ -# docker-compose.overrride.yml -# Docker Comose para entorno de desarrollo o development. - +# compose.dev.yaml +# Docker Compose para entorno de desarrollo. services: app: image: node:20-bookworm - expose: - - ${APP_LOCAL_PORT} working_dir: /app user: "${UID:-1000}:${GID:-1000}" volumes: @@ -16,7 +13,9 @@ services: env_file: - ./services/app/.env.development environment: - - NODE_ENV=${NODE_ENV} + NODE_ENV: development # <- fuerza el entorno para que el loader tome .env.development + expose: + - ${APP_LOCAL_PORT} networks: net: aliases: [dev-app] @@ -24,8 +23,6 @@ services: auth: image: node:20-bookworm - expose: - - ${AUTH_LOCAL_PORT} working_dir: /app user: "${UID:-1000}:${GID:-1000}" volumes: @@ -34,11 +31,13 @@ services: env_file: - ./services/auth/.env.development environment: - - NODE_ENV=${NODE_ENV} - command: npm run dev + NODE_ENV: development # <- fuerza el entorno para que el loader tome .env.development + expose: + - ${AUTH_LOCAL_PORT} networks: net: aliases: [dev-auth] + command: npm run dev db: image: postgres:16 @@ -63,12 +62,13 @@ services: networks: net: aliases: [dev-tenants] - + ################# ### Authentik ### ################# - # --- Authentik db (solo interno) + authentik-db: + image: postgres:16 environment: POSTGRES_DB: authentik POSTGRES_USER: authentik @@ -77,17 +77,17 @@ services: - authentik-db:/var/lib/postgresql/data networks: net: - aliases: [ak-db] + aliases: [ak-db] - # --- Authentik Redis (solo interno) authentik-redis: + image: redis:7-alpine command: ["redis-server", "--save", "", "--appendonly", "no"] networks: - net: + net: aliases: [ak-redis] - # --- Authentik Server (sin puertos públicos) authentik: + image: ghcr.io/goauthentik/server:latest command: server environment: AUTHENTIK_SECRET_KEY: ${AUTHENTIK_SECRET_KEY} @@ -97,19 +97,23 @@ services: AUTHENTIK_POSTGRESQL__NAME: authentik AUTHENTIK_POSTGRESQL__PASSWORD: ${AUTHENTIK_DB_PASS} AUTHENTIK_REDIS__HOST: authentik-redis - # Opcional: bootstrap automático del admin AUTHENTIK_BOOTSTRAP_PASSWORD: ${AUTHENTIK_BOOTSTRAP_PASSWORD} AUTHENTIK_BOOTSTRAP_EMAIL: ${AUTHENTIK_BOOTSTRAP_EMAIL} - networks: - net: + AUTHENTIK_HTTP__TRUSTED_PROXY__CIDRS: "0.0.0.0/0,::/0" + AUTHENTIK_SECURITY__CSRF_TRUSTED_ORIGINS: "https://authentik.suitecoffee.mateosaldain.uy,https://suitecoffee.mateosaldain.uy" + AUTHENTIK_COOKIE__DOMAIN: "authentik.suitecoffee.mateosaldain.uy" + networks: + net: aliases: [authentik] - # --- Authentik Worker authentik-worker: + image: ghcr.io/goauthentik/server:latest command: worker depends_on: - authentik-db: { condition: service_healthy } - authentik-redis: { condition: service_started } + authentik-db: + condition: service_started + authentik-redis: + condition: service_started environment: AUTHENTIK_SECRET_KEY: ${AUTHENTIK_SECRET_KEY} AUTHENTIK_POSTGRESQL__HOST: authentik-db @@ -117,8 +121,11 @@ services: AUTHENTIK_POSTGRESQL__NAME: authentik AUTHENTIK_POSTGRESQL__PASSWORD: ${AUTHENTIK_DB_PASS} AUTHENTIK_REDIS__HOST: authentik-redis - networks: - net: + AUTHENTIK_HTTP__TRUSTED_PROXY__CIDRS: "0.0.0.0/0,::/0" + AUTHENTIK_SECURITY__CSRF_TRUSTED_ORIGINS: "https://authentik.suitecoffee.mateosaldain.uy,https://suitecoffee.mateosaldain.uy" + AUTHENTIK_COOKIE__DOMAIN: "authentik.suitecoffee.mateosaldain.uy" + networks: + net: aliases: [ak-work] volumes: @@ -128,4 +135,4 @@ volumes: networks: net: - driver: bridge \ No newline at end of file + driver: bridge diff --git a/compose.yaml b/compose.yaml index 800a201..1e0ca8b 100644 --- a/compose.yaml +++ b/compose.yaml @@ -22,6 +22,8 @@ services: depends_on: db: condition: service_healthy + authentik: + condition: service_started healthcheck: test: ["CMD-SHELL", "curl -fsS http://localhost:${AUTH_DOCKER_PORT}/health || exit 1"] interval: 10s @@ -58,20 +60,25 @@ services: image: postgres:16-alpine healthcheck: test: ["CMD-SHELL", "pg_isready -U authentik -d authentik"] - interval: 10s + interval: 5s timeout: 3s - retries: 10 + retries: 20 restart: unless-stopped authentik-redis: image: redis:7-alpine + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 3s + retries: 20 restart: unless-stopped authentik: image: ghcr.io/goauthentik/server:latest depends_on: - authentik-db: { condition: service_healthy } - authentik-redis: { condition: service_started } + authentik-db: { condition: service_healthy } + authentik-redis: { condition: service_healthy } restart: unless-stopped authentik-worker: diff --git a/services/app/package-lock.json b/services/app/package-lock.json index abe7006..ecf02e6 100644 --- a/services/app/package-lock.json +++ b/services/app/package-lock.json @@ -9,6 +9,7 @@ "version": "1.0.0", "license": "ISC", "dependencies": { + "bcrypt": "^6.0.0", "chalk": "^5.6.0", "connect-redis": "^9.0.0", "cors": "^2.8.5", @@ -130,6 +131,20 @@ "version": "1.0.2", "license": "MIT" }, + "node_modules/bcrypt": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-6.0.0.tgz", + "integrity": "sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "node-addon-api": "^8.3.0", + "node-gyp-build": "^4.8.4" + }, + "engines": { + "node": ">= 18" + } + }, "node_modules/binary-extensions": { "version": "2.3.0", "dev": true, @@ -898,6 +913,26 @@ "node": ">= 0.6" } }, + "node_modules/node-addon-api": { + "version": "8.5.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.5.0.tgz", + "integrity": "sha512-/bRZty2mXUIFY/xU5HLvveNHlswNJej+RnxBjOMkidWfwZzgTbPG1E3K5TOxRLOR+5hX7bSofy8yf1hZevMS8A==", + "license": "MIT", + "engines": { + "node": "^18 || ^20 || >= 21" + } + }, + "node_modules/node-gyp-build": { + "version": "4.8.4", + "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", + "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", + "license": "MIT", + "bin": { + "node-gyp-build": "bin.js", + "node-gyp-build-optional": "optional.js", + "node-gyp-build-test": "build-test.js" + } + }, "node_modules/nodemon": { "version": "3.1.10", "dev": true, diff --git a/services/app/package.json b/services/app/package.json index e831785..0039ace 100644 --- a/services/app/package.json +++ b/services/app/package.json @@ -15,6 +15,7 @@ "nodemon": "^3.1.10" }, "dependencies": { + "bcrypt": "^6.0.0", "chalk": "^5.6.0", "connect-redis": "^9.0.0", "cors": "^2.8.5", diff --git a/services/app/src/index.js b/services/app/src/index.js index d321210..6d797b6 100644 --- a/services/app/src/index.js +++ b/services/app/src/index.js @@ -1,134 +1,146 @@ -// app/src/index.js -import chalk from 'chalk'; // Colores! -import favicon from 'serve-favicon'; // Favicon -import express from 'express'; -import expressLayouts from 'express-ejs-layouts'; -import cors from 'cors'; -import { Pool } from 'pg'; +// 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 +// ----------------------------------------------------------------------------- + +// === 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'; -// Rutas -import path from 'path'; -import { fileURLToPath } from 'url'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); - -//Redis -import session from 'express-session'; -import { createClient } from 'redis'; -import * as connectRedis from 'connect-redis'; -const RedisStore = connectRedis.default || connectRedis.RedisStore; - -const redis = createClient({ url: process.env.REDIS_URL || 'redis://authentik-redis:6379' }); -await redis.connect(); -// Variables de Entorno -import dotenv from 'dotenv'; - -// Cargar .env según entorno -if (process.env.NODE_ENV === 'development') { - dotenv.config({ path: path.resolve(__dirname, '../.env.development') }); -} else if (process.env.NODE_ENV === 'test') { - dotenv.config({ path: path.resolve(__dirname, '../.env.test') }); -} else if (process.env.NODE_ENV === 'production') { - dotenv.config({ path: path.resolve(__dirname, '../.env.production') }); +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 { - dotenv.config(); // .env por defecto + console.log(`Activando entorno de -> ${ENV.toUpperCase()} (sin archivo .env; usando variables del proceso)`); } -// ---------------------------------------------------------- -// App -// ---------------------------------------------------------- +// === 1) IMPORTS PRINCIPALES +import express from 'express'; +import cors from 'cors'; +import expressLayouts from 'express-ejs-layouts'; +import session from 'express-session'; +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 +const RedisStore = connectRedis.default || connectRedis.RedisStore; + +// === 2) APP y CONFIG BASICA const app = express(); -app.set('trust proxy', true); -app.use(cors()); -app.use(express.json()); +app.set('trust proxy', 1); +app.use(cors({ origin: true, credentials: true })); app.use(express.json({ limit: '1mb' })); -app.use(express.static(path.join(__dirname, 'pages'))); +app.use(express.urlencoded({ extended: true })); -app.use(session({ - name: 'sc.sid', - store: new RedisStore({ client: redis, prefix: 'sess:' }), - secret: process.env.SESSION_SECRET || 'change-me', - resave: false, - saveUninitialized: false, - cookie: { - httpOnly: true, - sameSite: 'lax', - secure: process.env.NODE_ENV === 'production', - }, -})); - -// ---------------------------------------------------------- -// Motor de vistas EJS -// ---------------------------------------------------------- -app.set("views", path.join(__dirname, "views")); -app.set("view engine", "ejs"); +// Vistas EJS (si no usás vistas, puedes dejarlo; no rompe) +app.set('views', path.join(__dirname, 'views')); +app.set('view engine', 'ejs'); app.use(expressLayouts); -app.set("layout", "layouts/main"); +app.set('layout', 'layout'); -// Archivos estáticos -app.use(express.static(path.join(__dirname, "public"))); +// Estáticos opcionales (ajusta si tu estructura difiere) +app.use(express.static(path.join(__dirname, 'public'))); -app.use('/favicon', express.static(path.join(__dirname, 'public', 'favicon'), { - maxAge: '1y' -})); +// === 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'; -app.use(favicon(path.join(__dirname, 'public', 'favicon', 'favicon.ico'), { - maxAge: '1y' -})); +const redis = createRedisClient({ url: REDIS_URL }); +await redis.connect().catch((e) => { + console.warn('⚠ No se pudo conectar a Redis de sesiones:', e?.message || e); +}); -const url = v => !v ? "" : (v.startsWith("http") ? v : `/img/productos/${v}`); +app.use( + session({ + name: 'sc.sid', // <- igual que en AUTH + store: new RedisStore({ client: redis, prefix: 'sess:' }), + secret: SESSION_SECRET, + resave: false, + saveUninitialized: false, + cookie: { + httpOnly: true, + sameSite: 'lax', + secure: process.env.NODE_ENV === 'production', + }, + }) +); -// ---------------------------------------------------------- -// Configuración de conexión PostgreSQL -// ---------------------------------------------------------- -const dbConfig = { - host: process.env.DB_HOST, - user: process.env.DB_USER, - password: process.env.DB_PASS, - database: process.env.DB_NAME, - port: process.env.DB_LOCAL_PORT ? Number(process.env.DB_LOCAL_PORT) : undefined, - ssl: process.env.PGSSL === 'true' ? { rejectUnauthorized: false } : undefined, +// Exponer el usuario a las vistas (no tocar req.session) +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 }); -const pool = new Pool(dbConfig); +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); + } +} -const tenantsPool = new Pool({ +// 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 }); -// ---------------------------------------------------------- -// Seguridad: Tablas permitidas -// ---------------------------------------------------------- -const ALLOWED_TABLES = [ - 'roles','usuarios','usua_roles', - 'categorias','productos', - 'clientes','mesas', - 'comandas','deta_comandas', - 'proveedores','compras','deta_comp_producto', - 'mate_primas','deta_comp_materias', - 'prov_producto','prov_mate_prima', - 'receta_producto', 'asistencia_resumen_diario', - 'asistencia_intervalo', 'vw_compras' -]; - -const VALID_IDENT = /^[a-zA-Z_][a-zA-Z0-9_]*$/; - -// Identificadores SQL -> comillas dobles y escape correcto -const q = (s) => `"${String(s).replace(/"/g, '""')}"`; - -function ensureTable(name) { - const t = String(name || '').toLowerCase(); - if (!ALLOWED_TABLES.includes(t)) throw new Error('Tabla no permitida'); - return t; +// === 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' }); } -async function getClient() { - const client = await pool.connect(); - return client; +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) { @@ -138,23 +150,20 @@ export async function withTenant(req, res, next) { const uuid = getTenantUuid(req); const schema = `schema_tenant_${uuid}`; - // Usa la función helper si la creaste en la DB (recomendado) + // 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]); - - // Alternativa directa si aún no tienes la función: await client.query(`SET LOCAL search_path TO ${schema.replace(/"/g, '')}`); req.pg = client; req.pgSchema = schema; next(); } catch (e) { - try { if (client) await client.query('ROLLBACK'); } catch {} - if (client) client.release(); + try { await client.query('ROLLBACK'); } catch {} + client.release(); return res.status(400).json({ error: e.message }); } } -// Cierra la transacción y libera la conexión export async function done(req, res, next) { try { if (req.pg) await req.pg.query('COMMIT'); @@ -166,762 +175,42 @@ export async function done(req, res, next) { next?.(); } -function requireAuth(req, res, next) { - if (!req.session?.user) return res.status(401).json({ error: 'no-auth' }); - next(); +// === 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' })); + +// === 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'); } -function getTenantUuid(req) { - // 1) header enviado por el front (fetchWithTenant) - const h = req.get('x-tenant-uuid'); - if (h) return String(h).replace(/-/g, ''); - // 2) sesión del login OIDC - const s = req.session?.user?.tenant_uuid; - if (s) return String(s).replace(/-/g, ''); - throw new Error('Tenant no especificado'); -} - -app.get('/api/productos', requireAuth, withTenant, async (req, res, next) => { - const { rows } = await req.pg.query('SELECT * FROM productos ORDER BY id'); - res.json(rows); -}, done); - -app.use((req,res,next)=>{ res.locals.user = req.session?.user || null; next(); }); - -// ---------------------------------------------------------- -// Introspección de esquema -// ---------------------------------------------------------- -async function loadColumns(client, table) { - const sql = ` - SELECT - c.column_name, - c.data_type, - c.is_nullable = 'YES' AS is_nullable, - c.column_default, - (SELECT EXISTS ( - SELECT 1 FROM pg_attribute a - JOIN pg_class t ON t.oid = a.attrelid - JOIN pg_index i ON i.indrelid = t.oid AND a.attnum = ANY(i.indkey) - WHERE t.relname = $1 AND i.indisprimary AND a.attname = c.column_name - )) AS is_primary, - (SELECT a.attgenerated = 's' OR a.attidentity IN ('a','d') - FROM pg_attribute a - JOIN pg_class t ON t.oid = a.attrelid - WHERE t.relname = $1 AND a.attname = c.column_name - ) AS is_identity - FROM information_schema.columns c - WHERE c.table_schema='public' AND c.table_name=$1 - ORDER BY c.ordinal_position - `; - const { rows } = await client.query(sql, [table]); - return rows; -} - -async function loadForeignKeys(client, table) { - const sql = ` - SELECT - kcu.column_name, - ccu.table_name AS foreign_table, - ccu.column_name AS foreign_column - FROM information_schema.table_constraints tc - JOIN information_schema.key_column_usage kcu - ON tc.constraint_name = kcu.constraint_name AND tc.table_schema = kcu.table_schema - JOIN information_schema.constraint_column_usage ccu - ON ccu.constraint_name = tc.constraint_name AND ccu.table_schema = tc.table_schema - WHERE tc.table_schema='public' AND tc.table_name=$1 AND tc.constraint_type='FOREIGN KEY' - `; - const { rows } = await client.query(sql, [table]); - const map = {}; - for (const r of rows) map[r.column_name] = { foreign_table: r.foreign_table, foreign_column: r.foreign_column }; - return map; -} - -async function loadPrimaryKey(client, table) { - const sql = ` - SELECT a.attname AS column_name - FROM pg_index i - JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = ANY(i.indkey) - JOIN pg_class t ON t.oid = i.indrelid - WHERE t.relname = $1 AND i.indisprimary - `; - const { rows } = await client.query(sql, [table]); - return rows.map(r => r.column_name); -} - -// label column for FK options -async function pickLabelColumn(client, refTable) { - const preferred = ['nombre','raz_social','apodo','documento','correo','telefono']; - const { rows } = await client.query( - `SELECT column_name, data_type - FROM information_schema.columns - WHERE table_schema='public' AND table_name=$1 - ORDER BY ordinal_position`, [refTable] - ); - for (const cand of preferred) { - if (rows.find(r => r.column_name === cand)) return cand; - } - const textish = rows.find(r => /text|character varying|varchar/i.test(r.data_type)); - if (textish) return textish.column_name; - return rows[0]?.column_name || 'id'; -} - -// ---------------------------------------------------------- -// Middleware para datos globales -// ---------------------------------------------------------- -app.use((req, res, next) => { - res.locals.currentPath = req.path; - res.locals.pageTitle = "SuiteCoffee"; - res.locals.pageId = ""; - next(); +// === 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); + if (res.headersSent) return; + res.status(500).json({ error: 'internal-error', detail: err?.message || String(err) }); }); - -// ---------------------------------------------------------- -// Rutas de UI -// ---------------------------------------------------------- - -app.get("/", (req, res) => { - res.locals.pageTitle = "Dashboard"; - res.locals.pageId = "home"; // para el sidebar contextual - res.render("dashboard"); -}); - -app.get("/dashboard", (req, res) => { - res.locals.pageTitle = "Dashboard"; - res.locals.pageId = "dashboard"; // <- importante - res.render("dashboard"); -}); - -// app.get('/', (req, res) => { -// res.sendFile(path.join(__dirname, 'pages', 'dashboard.html')); -// }); - -app.get("/comandas", (req, res) => { - res.locals.pageTitle = "Comandas"; - res.locals.pageId = "comandas"; // <- importante para el sidebar contextual - res.render("comandas"); -}); - -// app.get('/comandas', (req, res) => { -// res.sendFile(path.join(__dirname, 'pages', 'comandas.html')); -// }); - -app.get("/estadoComandas", (req, res) => { - res.locals.pageTitle = "Estado de Comandas"; - res.locals.pageId = "estadoComandas"; - res.render("estadoComandas"); -}); - -// app.get('/estadoComandas', (req, res) => { -// res.sendFile(path.join(__dirname, 'pages', 'estadoComandas.html')); -// }); - -app.get("/productos", (req, res) => { - res.locals.pageTitle = "Productos"; - res.locals.pageId = "productos"; - res.render("productos"); -}); - -app.get('/usuarios', (req, res) => { - res.locals.pageTitle = 'Usuarios'; - res.locals.pageId = 'usuarios'; - res.render('usuarios'); -}); - -app.get('/reportes', (req, res) => { - res.locals.pageTitle = 'Reportes'; - res.locals.pageId = 'reportes'; - res.render('reportes'); -}); - -app.get('/compras', (req, res) => { - res.locals.pageTitle = 'Compras'; - res.locals.pageId = 'compras'; - res.render('compras'); -}); - -// ---------------------------------------------------------- -// API -// ---------------------------------------------------------- -app.get('/api/tables', async (_req, res) => { - res.json(ALLOWED_TABLES); -}); - -app.get('/api/schema/:table', async (req, res) => { - try { - const table = ensureTable(req.params.table); - const client = await getClient(); - try { - const columns = await loadColumns(client, table); - const fks = await loadForeignKeys(client, table); - const enriched = columns.map(c => ({ ...c, foreign: fks[c.column_name] || null })); - res.json({ table, columns: enriched }); - } finally { client.release(); } - } catch (e) { - res.status(400).json({ error: e.message }); - } -}); - -app.get('/api/options/:table/:column', async (req, res) => { - try { - const table = ensureTable(req.params.table); - const column = req.params.column; - if (!VALID_IDENT.test(column)) throw new Error('Columna inválida'); - - const client = await getClient(); - try { - const fks = await loadForeignKeys(client, table); - const fk = fks[column]; - if (!fk) return res.json([]); - - const refTable = fk.foreign_table; - const refId = fk.foreign_column; - const labelCol = await pickLabelColumn(client, refTable); - - const sql = `SELECT ${q(refId)} AS id, ${q(labelCol)} AS label FROM ${q(refTable)} ORDER BY ${q(labelCol)} LIMIT 1000`; - const result = await client.query(sql); - res.json(result.rows); - } finally { client.release(); } - } catch (e) { - res.status(400).json({ error: e.message }); - } -}); - -app.get('/api/table/:table', async (req, res) => { - try { - const table = ensureTable(req.params.table); - const limit = Math.min(parseInt(req.query.limit || '100', 10), 1000); - const client = await getClient(); - try { - const pks = await loadPrimaryKey(client, table); - const orderBy = pks.length ? `ORDER BY ${pks.map(q).join(', ')} DESC` : ''; - const sql = `SELECT * FROM ${q(table)} ${orderBy} LIMIT ${limit}`; - const result = await client.query(sql); - - // Normalizar: siempre devolver objetos {col: valor} - const colNames = result.fields.map(f => f.name); - let rows = result.rows; - if (rows.length && Array.isArray(rows[0])) { - rows = rows.map(r => Object.fromEntries(r.map((v, i) => [colNames[i], v]))); - } - res.json(rows); - } finally { client.release(); } - } catch (e) { - res.status(400).json({ error: e.message, code: e.code, detail: e.detail }); - } -}); - -app.post('/api/table/:table', async (req, res) => { - const table = ensureTable(req.params.table); - const payload = req.body || {}; - try { - const client = await getClient(); - try { - const columns = await loadColumns(client, table); - const insertable = columns.filter(c => - !c.is_primary && !c.is_identity && !(c.column_default || '').startsWith('nextval(') - ); - const allowedCols = new Set(insertable.map(c => c.column_name)); - - const cols = []; - const vals = []; - const params = []; - let idx = 1; - for (const [k, v] of Object.entries(payload)) { - if (!allowedCols.has(k)) continue; - if (!VALID_IDENT.test(k)) continue; - cols.push(q(k)); - vals.push(`$${idx++}`); - params.push(v); - } - - if (!cols.length) { - const { rows } = await client.query(`INSERT INTO ${q(table)} DEFAULT VALUES RETURNING *`); - res.status(201).json({ inserted: rows[0] }); - } else { - const { rows } = await client.query( - `INSERT INTO ${q(table)} (${cols.join(', ')}) VALUES (${vals.join(', ')}) RETURNING *`, - params - ); - res.status(201).json({ inserted: rows[0] }); - } - } catch (e) { - if (e.code === '23503') return res.status(400).json({ error: 'Violación de clave foránea', detail: e.detail }); - if (e.code === '23505') return res.status(400).json({ error: 'Violación de unicidad', detail: e.detail }); - if (e.code === '23514') return res.status(400).json({ error: 'Violación de CHECK', detail: e.detail }); - if (e.code === '23502') return res.status(400).json({ error: 'Campo NOT NULL faltante', detail: e.detail }); - throw e; - } - } catch (e) { - res.status(400).json({ error: e.message }); - } -}); - -app.get('/api/comandas', async (req, res, next) => { - try { - const estado = (req.query.estado || '').trim() || null; - const limit = Math.min(parseInt(req.query.limit || '200', 10), 1000); - - const { rows } = await pool.query( - `SELECT * FROM public.f_comandas_resumen($1, $2)`, - [estado, limit] - ); - res.json(rows); - } catch (e) { next(e); } -}); - - -// app.get('/api/comandas', async (req, res, next) => { -// try { -// const estado = (req.query.estado || '').trim(); -// const limit = Math.min(parseInt(req.query.limit || '200', 10), 1000); -// const params = []; -// let where = ''; -// if (estado) { params.push(estado); where = `WHERE c.estado = $${params.length}`; } -// params.push(limit); - -// const sql = ` -// 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) AS items, -// COALESCE(i.total, 0) AS total -// 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 -// ${where} -// ORDER BY c.id_comanda DESC -// LIMIT $${params.length} -// `; -// const client = await pool.connect(); -// try { -// const { rows } = await client.query(sql, params); -// res.json(rows); -// } finally { client.release(); } -// } catch (e) { next(e); } -// }); - - -// Detalle de una comanda (con nombres de productos) - -// GET /api/comandas/:id/detalle -app.get('/api/comandas/:id/detalle', (req, res, next) => - pool.query( - `SELECT id_det_comanda, id_producto, producto_nombre, - cantidad, pre_unitario, subtotal, observaciones - FROM public.v_comandas_detalle_items - WHERE id_comanda = $1::int - ORDER BY id_det_comanda`, - [req.params.id] - ) - .then(r => res.json(r.rows)) - .catch(next) -); - - -// app.get('/api/comandas/:id/detalle', async (req, res, next) => { -// try { -// const id = parseInt(req.params.id, 10); -// if (!Number.isInteger(id) || id <= 0) { -// return res.status(400).json({ error: 'id inválido' }); -// } - -// const sql = ` -// SELECT -// id_det_comanda, id_producto, producto_nombre, -// cantidad, pre_unitario, subtotal, observaciones -// FROM public.v_comandas_detalle_items -// WHERE id_comanda = $1::int -// ORDER BY id_det_comanda -// `; -// const { rows } = await pool.query(sql, [id]); -// res.json(rows); -// } catch (e) { next(e); } -// }); - - -// app.get('/api/comandas/:id/detalle', async (req, res, next) => { -// try { -// const id = parseInt(req.params.id, 10); -// if (!id) return res.status(400).json({ error: 'id inválido' }); - -// const sql = ` -// SELECT 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 -// WHERE d.id_comanda = $1 -// ORDER BY d.id_det_comanda -// `; -// const { rows } = await pool.query(sql, [id]); -// res.json(rows); -// } catch (e) { next(e); } -// }); - - - -// Cerrar comanda (setea estado y fec_cierre en DB) -app.post('/api/comandas/:id/cerrar', async (req, res, next) => { - try { - const id = Number(req.params.id); - if (!Number.isInteger(id) || id <= 0) { - return res.status(400).json({ error: 'id inválido' }); - } - const { rows } = await pool.query( - `SELECT public.f_cerrar_comanda($1) AS data`, - [id] - ); - if (!rows.length || rows[0].data === null) { - return res.status(404).json({ error: 'Comanda no encontrada' }); - } - res.json(rows[0].data); - } catch (err) { next(err); } -}); - -// Abrir (reabrir) comanda -app.post('/api/comandas/:id/abrir', async (req, res, next) => { - try { - const id = Number(req.params.id); - if (!Number.isInteger(id) || id <= 0) { - return res.status(400).json({ error: 'id inválido' }); - } - const { rows } = await pool.query( - `SELECT public.f_abrir_comanda($1) AS data`, - [id] - ); - if (!rows.length || rows[0].data === null) { - return res.status(404).json({ error: 'Comanda no encontrada' }); - } - res.json(rows[0].data); - } catch (err) { next(err); } -}); - -// // Cambiar estado (abrir/cerrar) -// app.post('/api/comandas/:id/estado', async (req, res, next) => { -// try { -// const id = parseInt(req.params.id, 10); -// let { estado } = req.body || {}; -// if (!id) return res.status(400).json({ error: 'id inválido' }); - -// const allowed = new Set(['abierta','cerrada','pagada','anulada']); -// if (!allowed.has(estado)) return res.status(400).json({ error: 'estado inválido' }); - -// const { rows } = await pool.query( -// `UPDATE comandas SET estado = $2 WHERE id_comanda = $1 RETURNING *`, -// [id, estado] -// ); -// if (!rows.length) return res.status(404).json({ error: 'comanda no encontrada' }); -// res.json({ updated: rows[0] }); -// } catch (e) { next(e); } -// }); - -// GET producto + receta -app.get('/api/rpc/get_producto/:id', async (req, res) => { - const id = Number(req.params.id); - const { rows } = await pool.query('SELECT public.get_producto($1) AS data', [id]); - res.json(rows[0]?.data || {}); -}); - -// POST guardar producto + receta - -app.post('/api/rpc/save_producto', async (req, res) => { - try { - // console.debug('receta payload:', req.body?.receta); // habilitalo si lo necesitás - const q = 'SELECT public.save_producto($1,$2,$3,$4,$5,$6,$7::jsonb) AS id_producto'; - const { id_producto=null, nombre, img_producto=null, precio=0, activo=true, id_categoria=null, receta=[] } = req.body || {}; - const params = [id_producto, nombre, img_producto, precio, activo, id_categoria, JSON.stringify(receta||[])]; - const { rows } = await pool.query(q, params); - res.json(rows[0] || {}); - } catch(e) { - console.error(e); - res.status(500).json({ error: 'save_producto failed' }); - } -}); - - -// app.post('/api/rpc/save_producto', async (req, res) => { -// const { id_producto=null, nombre, img_producto=null, precio=0, activo=true, id_categoria=null, receta=[] } = req.body || {}; -// const q = 'SELECT * FROM public.save_producto($1,$2,$3,$4,$5,$6,$7::jsonb)'; -// const params = [id_producto, nombre, img_producto, precio, activo, id_categoria, JSON.stringify(receta||[])]; -// const { rows } = await pool.query(q, params); -// res.json(rows[0] || {}); -// }); - -// GET MP + proveedores -app.get('/api/rpc/get_materia/:id', async (req, res) => { - const id = Number(req.params.id); - try { - const { rows } = await pool.query('SELECT public.get_materia_prima($1) AS data', [id]); - res.json(rows[0]?.data || {}); - } catch (e) { - console.error(e); - res.status(500).json({ error: 'get_materia failed' }); - } -}); - -// SAVE MP + proveedores (array) -app.post('/api/rpc/save_materia', async (req, res) => { - const { id_mat_prima=null, nombre, unidad, activo=true, proveedores=[] } = req.body || {}; - try { - const q = 'SELECT public.save_materia_prima($1,$2,$3,$4,$5::jsonb) AS id_mat_prima'; - const params = [id_mat_prima, nombre, unidad, activo, JSON.stringify(proveedores||[])]; - const { rows } = await pool.query(q, params); - res.json(rows[0] || {}); - } catch (e) { - console.error(e); - res.status(500).json({ error: 'save_materia failed' }); - } -}); - -// POST /api/rpc/find_usuarios_por_documentos { docs: ["12345678","09123456", ...] } -app.post('/api/rpc/find_usuarios_por_documentos', async (req, res) => { - try { - const docs = Array.isArray(req.body?.docs) ? req.body.docs : []; - const sql = 'SELECT public.find_usuarios_por_documentos($1::jsonb) AS data'; - const { rows } = await pool.query(sql, [JSON.stringify(docs)]); - res.json(rows[0]?.data || {}); - } catch (e) { - console.error(e); - res.status(500).json({ error: 'find_usuarios_por_documentos failed' }); - } -}); - -// POST /api/rpc/import_asistencia { registros: [...], origen?: "AGL_001.txt" } -app.post('/api/rpc/import_asistencia', async (req, res) => { - try { - const registros = Array.isArray(req.body?.registros) ? req.body.registros : []; - const origen = req.body?.origen || null; - const sql = 'SELECT public.import_asistencia($1::jsonb,$2) AS data'; - const { rows } = await pool.query(sql, [JSON.stringify(registros), origen]); - res.json(rows[0]?.data || {}); - } catch (e) { - console.error(e); - res.status(500).json({ error: 'import_asistencia failed' }); - } -}); - -// Consultar datos de asistencia (raw + pares) para un usuario y rango -app.post('/api/rpc/asistencia_get', async (req, res) => { - try { - const { doc, desde, hasta } = req.body || {}; - const sql = 'SELECT public.asistencia_get($1::text,$2::date,$3::date) AS data'; - const { rows } = await pool.query(sql, [doc, desde, hasta]); - res.json(rows[0]?.data || {}); - } catch (e) { - console.error(e); res.status(500).json({ error: 'asistencia_get failed' }); - } -}); - -// Editar un registro crudo y recalcular pares -app.post('/api/rpc/asistencia_update_raw', async (req, res) => { - try { - const { id_raw, fecha, hora, modo } = req.body || {}; - const sql = 'SELECT public.asistencia_update_raw($1::bigint,$2::date,$3::text,$4::text) AS data'; - const { rows } = await pool.query(sql, [id_raw, fecha, hora, modo ?? null]); - res.json(rows[0]?.data || {}); - } catch (e) { - console.error(e); res.status(500).json({ error: 'asistencia_update_raw failed' }); - } -}); - -// Eliminar un registro crudo y recalcular pares -app.post('/api/rpc/asistencia_delete_raw', async (req, res) => { - try { - const { id_raw } = req.body || {}; - const sql = 'SELECT public.asistencia_delete_raw($1::bigint) AS data'; - const { rows } = await pool.query(sql, [id_raw]); - res.json(rows[0]?.data || {}); - } catch (e) { - console.error(e); res.status(500).json({ error: 'asistencia_delete_raw failed' }); - } -}); - -// POST /api/rpc/report_tickets { year } -app.post('/api/rpc/report_tickets', async (req, res) => { - try { - const y = parseInt(req.body?.year ?? req.query?.year, 10); - const year = (Number.isFinite(y) && y >= 2000 && y <= 2100) - ? y - : (new Date()).getFullYear(); - - const { rows } = await pool.query( - 'SELECT public.report_tickets_year($1::int) AS j', [year] - ); - res.json(rows[0].j); - } catch (e) { - console.error('report_tickets error:', e); - res.status(500).json({ - error: 'report_tickets failed', - message: e.message, detail: e.detail, where: e.where, code: e.code - }); - } -}); - -// POST /api/rpc/report_asistencia { desde: 'YYYY-MM-DD', hasta: 'YYYY-MM-DD' } -app.post('/api/rpc/report_asistencia', async (req, res) => { - try { - let { desde, hasta } = req.body || {}; - // defaults si vienen vacíos/invalidos - const re = /^\d{4}-\d{2}-\d{2}$/; - if (!re.test(desde) || !re.test(hasta)) { - const end = new Date(); - const start = new Date(end); start.setDate(end.getDate()-30); - desde = start.toISOString().slice(0,10); - hasta = end.toISOString().slice(0,10); - } - - const { rows } = await pool.query( - 'SELECT public.report_asistencia($1::date,$2::date) AS j', [desde, hasta] - ); - res.json(rows[0].j); - } catch (e) { - console.error('report_asistencia error:', e); - res.status(500).json({ - error: 'report_asistencia failed', - message: e.message, detail: e.detail, where: e.where, code: e.code - }); - } -}); - - -// app.post('/api/rpc/report_asistencia', async (req,res)=>{ -// try{ -// const {desde, hasta} = req.body||{}; -// const sql = 'SELECT * FROM public.report_asistencia($1::date,$2::date)'; -// const {rows} = await pool.query(sql,[desde, hasta]); -// res.json(rows); -// } catch (e) { -// console.error(e); -// res.status(500).json({ error: 'report_tickets failed' + e }); -// } -// }); - -// app.post('/api/rpc/report_tickets', async (req, res) => { -// try { -// const { year } = req.body || {}; -// const sql = 'SELECT public.report_tickets_year($1::int) AS data'; -// const { rows } = await pool.query(sql, [year]); -// res.json(rows[0]?.data || {}); -// } catch (e) { -// console.error(e); -// res.status(500).json({ error: 'report_tickets failed' + e }); -// } -// }); - - -// Guardar (insert/update) -app.post('/api/rpc/save_compra', async (req, res) => { - try { - const { id_compra, id_proveedor, fec_compra, detalles } = req.body || {}; - const sql = 'SELECT * FROM public.save_compra($1::int,$2::int,$3::timestamptz,$4::jsonb)'; - const args = [id_compra ?? null, id_proveedor, fec_compra ? new Date(fec_compra) : null, JSON.stringify(detalles)]; - const { rows } = await pool.query(sql, args); - res.json(rows[0]); // { id_compra, total } - } catch (e) { - console.error('save_compra error:', e); - res.status(500).json({ error: 'save_compra failed', message: e.message, detail: e.detail, where: e.where, code: e.code }); - } -}); - - -// Obtener para editar -app.post('/api/rpc/get_compra', async (req, res) => { - try { - const { id_compra } = req.body || {}; - const sql = `SELECT public.get_compra($1::int) AS data`; - const { rows } = await pool.query(sql, [id_compra]); - res.json(rows[0]?.data || {}); - } catch (e) { - console.error(e); res.status(500).json({ error: 'get_compra failed' }); - } -}); - -// Eliminar -app.post('/api/rpc/delete_compra', async (req, res) => { - try { - const { id_compra } = req.body || {}; - await pool.query(`SELECT public.delete_compra($1::int)`, [id_compra]); - res.json({ ok: true }); - } catch (e) { - console.error(e); res.status(500).json({ error: 'delete_compra failed' }); - } -}); - - -// POST /api/rpc/report_gastos { year: 2025 } -app.post('/api/rpc/report_gastos', async (req, res) => { - try { - const year = parseInt(req.body?.year ?? new Date().getFullYear(), 10); - const { rows } = await pool.query( - 'SELECT public.report_gastos($1::int) AS j', [year] - ); - res.json(rows[0].j); - } catch (e) { - console.error('report_gastos error:', e); - res.status(500).json({ - error: 'report_gastos failed', - message: e.message, detail: e.detail, code: e.code - }); - } -}); - -// (Opcional) GET para probar rápido desde el navegador: -// /api/rpc/report_gastos?year=2025 -app.get('/api/rpc/report_gastos', async (req, res) => { - try { - const year = parseInt(req.query.year ?? new Date().getFullYear(), 10); - const { rows } = await pool.query( - 'SELECT public.report_gastos($1::int) AS j', [year] - ); - res.json(rows[0].j); - } catch (e) { - console.error('report_gastos error:', e); - res.status(500).json({ - error: 'report_gastos failed', - message: e.message, detail: e.detail, code: e.code - }); - } -}); - - -// ---------------------------------------------------------- -// Verificación de conexión -// ---------------------------------------------------------- -async function verificarConexion() { - try { - const client = await pool.connect(); - const res = await client.query('SELECT NOW() AS hora'); - console.log(`\nConexión con la base de datos ${chalk.green(process.env.DB_NAME)} fue exitosa.`); - console.log('Fecha y hora actual de la base de datos:', res.rows[0].hora); - client.release(); - } catch (error) { - console.error('Error al conectar con la base de datos al iniciar:', error.message); - console.error('Revisar credenciales y accesos de red.'); - } -} - -// ---------------------------------------------------------- -// Inicio del servidor -// ---------------------------------------------------------- - -const PORT = process.env.PORT ? Number(process.env.PORT) : 3000; -app.listen(PORT, () => { - console.log(`Servidor de aplicación escuchando en ${chalk.yellow(`http://localhost:${PORT}`)}`); - console.log(chalk.grey(`Comprobando accesibilidad a la db ${chalk.white(process.env.DB_NAME)} del host ${chalk.white(process.env.DB_HOST)} ...`)); - verificarConexion(); -}); - -// Healthcheck -app.get('/health', async (_req, res) => { - res.status(200).json({ status: 'ok' }); -}); +// === 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}`)}`)); +})(); diff --git a/services/app/src/routes.legacy.js b/services/app/src/routes.legacy.js new file mode 100644 index 0000000..fee4e5d --- /dev/null +++ b/services/app/src/routes.legacy.js @@ -0,0 +1,337 @@ +// services/app/src/routes.legacy.js +// ----------------------------------------------------------------------------- +// Endpoints legacy de SuiteCoffee extraídos del index original y montados +// como módulo. No elimina nada; sólo organiza y robustece. +// +// Cómo se usa: el nuevo services/app/src/index.js hace +// const legacy = await import('./routes.legacy.js') +// legacy.default(app, { requireAuth, withTenant, done, mainPool, tenantsPool, express }) +// ----------------------------------------------------------------------------- + +export default function mount(app, ctx) { + const { requireAuth, withTenant, done, mainPool, tenantsPool, express } = ctx; + + // Aliases de compatibilidad con el archivo original + const pool = mainPool; // el original usaba `pool` (DB principal) + + // --------------------------------------------------------------------------- + // Helpers y seguridad (copiados/adaptados del archivo original) + // --------------------------------------------------------------------------- + const ALLOWED_TABLES = [ + 'roles','usuarios','usua_roles', + 'categorias','productos', + 'clientes','mesas', + 'comandas','deta_comandas', + 'proveedores','compras','deta_comp_producto', + 'mate_primas','deta_comp_materias', + 'prov_producto','prov_mate_prima', + 'receta_producto', 'asistencia_resumen_diario', + 'asistencia_intervalo', 'vw_compras' + ]; + const VALID_IDENT = /^[a-zA-Z_][a-zA-Z0-9_]*$/; + const q = (s) => `"${String(s).replace(/"/g, '""')}"`; // quote ident simple + function ensureTable(name) { + const t = String(name || '').toLowerCase(); + if (!ALLOWED_TABLES.includes(t)) throw new Error('Tabla no permitida'); + return t; + } + + async function getClient() { // el original devolvía pool.connect() + const client = await pool.connect(); + return client; + } + + // Columnas de una tabla + async function loadColumns(client, table) { + const sql = ` + SELECT + c.column_name, + c.data_type, + c.is_nullable = 'YES' AS is_nullable, + c.column_default, + EXISTS ( + SELECT 1 FROM pg_attribute a + JOIN pg_class t ON t.oid = a.attrelid + JOIN pg_index i ON i.indrelid = t.oid AND a.attnum = ANY(i.indkey) + WHERE t.relname = $1 AND i.indisprimary AND a.attname = c.column_name + ) AS is_primary, + ( + SELECT a.attgenerated = 's' OR a.attidentity IN ('a','d') + FROM pg_attribute a + JOIN pg_class t2 ON t2.oid = a.attrelid + WHERE t2.relname = $1 AND a.attname = c.column_name + ) AS is_generated + FROM information_schema.columns c + WHERE c.table_schema = 'public' AND c.table_name = $1 + ORDER BY c.ordinal_position`; + const { rows } = await client.query(sql, [table]); + return rows; + } + + // PKs de una tabla + async function loadPrimaryKey(client, table) { + const sql = ` + SELECT a.attname AS column_name + FROM pg_index i + JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = ANY(i.indkey) + JOIN pg_class t ON t.oid = i.indrelid + WHERE t.relname = $1 AND i.indisprimary`; + const { rows } = await client.query(sql, [table]); + return rows.map(r => r.column_name); + } + + // FKs salientes de una tabla → { [column]: { foreign_table, foreign_column } } + async function loadForeignKeys(client, table) { + const sql = ` + SELECT + kcu.column_name AS column_name, + ccu.table_name AS foreign_table, + ccu.column_name AS foreign_column + FROM information_schema.table_constraints tc + JOIN information_schema.key_column_usage kcu + ON tc.constraint_name = kcu.constraint_name + AND tc.table_schema = kcu.table_schema + JOIN information_schema.constraint_column_usage ccu + ON ccu.constraint_name = tc.constraint_name + AND ccu.table_schema = tc.table_schema + WHERE tc.constraint_type = 'FOREIGN KEY' + AND tc.table_schema = 'public' + AND tc.table_name = $1`; + const { rows } = await client.query(sql, [table]); + const map = {}; + for (const r of rows) map[r.column_name] = { foreign_table: r.foreign_table, foreign_column: r.foreign_column }; + return map; + } + + // Heurística para elegir una columna "label" en tablas referenciadas + async function pickLabelColumn(client, refTable) { + const preferred = ['nombre','raz_social','apodo','documento','correo','telefono','descripcion','detalle']; + const { rows } = await client.query( + `SELECT column_name, data_type + FROM information_schema.columns + WHERE table_schema='public' AND table_name=$1 + ORDER BY ordinal_position`, [refTable] + ); + for (const cand of preferred) if (rows.find(r => r.column_name === cand)) return cand; + const textish = rows.find(r => /text|character varying|varchar/i.test(r.data_type)); + if (textish) return textish.column_name; + return rows[0]?.column_name || 'id'; + } + + // --------------------------------------------------------------------------- + // RUTAS DE UI (vistas) + // --------------------------------------------------------------------------- + app.get('/', (req, res) => { + res.locals.pageTitle = 'Dashboard'; + res.locals.pageId = 'home'; + res.render('dashboard'); + }); + + app.get('/dashboard', (req, res) => { + res.locals.pageTitle = 'Dashboard'; + res.locals.pageId = 'dashboard'; + res.render('dashboard'); + }); + + app.get('/comandas', (req, res) => { + res.locals.pageTitle = 'Comandas'; + res.locals.pageId = 'comandas'; + res.render('comandas'); + }); + + app.get('/estadoComandas', (req, res) => { + res.locals.pageTitle = 'Estado de Comandas'; + res.locals.pageId = 'estadoComandas'; + res.render('estadoComandas'); + }); + + app.get('/productos', (req, res) => { + res.locals.pageTitle = 'Productos'; + res.locals.pageId = 'productos'; + res.render('productos'); + }); + + app.get('/usuarios', (req, res) => { + res.locals.pageTitle = 'Usuarios'; + res.locals.pageId = 'usuarios'; + res.render('usuarios'); + }); + + app.get('/reportes', (req, res) => { + res.locals.pageTitle = 'Reportes'; + res.locals.pageId = 'reportes'; + res.render('reportes'); + }); + + app.get('/compras', (req, res) => { + res.locals.pageTitle = 'Compras'; + res.locals.pageId = 'compras'; + res.render('compras'); + }); + + // --------------------------------------------------------------------------- + // API: ejemplos por-tenant y utilitarios (introspección) + // --------------------------------------------------------------------------- + // Ejemplo conservado del original (usar search_path via withTenant) + app.get('/api/productos', requireAuth, withTenant, async (req, res, next) => { + const { rows } = await req.pg.query('SELECT * FROM productos ORDER BY id'); + res.json(rows); + }, done); + + // Listado de tablas permitidas + app.get('/api/tables', async (_req, res) => { + res.json(ALLOWED_TABLES); + }); + + // Esquema de una tabla (columnas + FKs) + app.get('/api/schema/:table', async (req, res) => { + try { + const table = ensureTable(req.params.table); + const client = await getClient(); + try { + const columns = await loadColumns(client, table); + const fks = await loadForeignKeys(client, table); + const enriched = columns.map(c => ({ ...c, foreign: fks[c.column_name] || null })); + res.json({ table, columns: enriched }); + } finally { client.release(); } + } catch (e) { + res.status(400).json({ error: e.message }); + } + }); + + // Opciones para una columna con FK (id/label) + app.get('/api/options/:table/:column', async (req, res) => { + try { + const table = ensureTable(req.params.table); + const column = req.params.column; + if (!VALID_IDENT.test(column)) throw new Error('Columna inválida'); + + const client = await getClient(); + try { + const fks = await loadForeignKeys(client, table); + const fk = fks[column]; + if (!fk) return res.json([]); + const refTable = fk.foreign_table; + const refId = fk.foreign_column; + const labelCol = await pickLabelColumn(client, refTable); + const sql = `SELECT ${q(refId)} AS id, ${q(labelCol)} AS label FROM ${q(refTable)} ORDER BY ${q(labelCol)} LIMIT 1000`; + const result = await client.query(sql); + res.json(result.rows); + } finally { client.release(); } + } catch (e) { + res.status(400).json({ error: e.message }); + } + }); + + // Datos de una tabla (limitados) — vista rápida + app.get('/api/table/:table', async (req, res) => { + try { + const table = ensureTable(req.params.table); + const limit = Math.min(parseInt(req.query.limit || '100', 10), 1000); + const client = await getClient(); + try { + const pks = await loadPrimaryKey(client, table); + const order = pks[0] ? q(pks[0]) : '1'; + const sql = `SELECT * FROM ${q(table)} ORDER BY ${order} LIMIT $1`; + const { rows } = await client.query(sql, [limit]); + res.json(rows); + } finally { client.release(); } + } catch (e) { + res.status(400).json({ error: e.message, code: e.code, detail: e.detail }); + } + }); + + // Crear/actualizar registros genéricos (placeholder: pega aquí tu lógica original) + app.post('/api/table/:table', async (req, res) => { + // TODO: Pegar implementación original (insert/update genérico) aquí. + // Sugerencia: validar payload contra loadColumns(client, table), + // construir INSERT/UPDATE dinámico ignorando columnas generadas y PKs cuando corresponda. + res.status(501).json({ error: 'not-implemented', detail: 'Pegar lógica original de POST /api/table/:table' }); + }); + + // --------------------------------------------------------------------------- + // Endpoints de negocio (conservados tal cual cuando fue posible) + // --------------------------------------------------------------------------- + + // Detalle de una comanda + app.get('/api/comandas/:id/detalle', (req, res, next) => + pool.query( + `SELECT id_det_comanda, id_producto, producto_nombre, + cantidad, pre_unitario, subtotal, observaciones + FROM public.v_comandas_detalle_items + WHERE id_comanda = $1::int + ORDER BY id_det_comanda`, + [req.params.id] + ) + .then(r => res.json(r.rows)) + .catch(next) + ); + + // --------------------------------------------------------------------------- + // RPC / Reportes / Procedimientos (stubs con TODO si no se extrajo el SQL) + // --------------------------------------------------------------------------- + app.post('/api/rpc/find_usuarios_por_documentos', async (req, res) => { + // TODO: Pegar el SQL original. Ejemplo: + // const { documentos } = req.body || {}; + // const { rows } = await pool.query('SELECT * FROM public.find_usuarios_por_documentos($1::jsonb)', [JSON.stringify(documentos||[])]) + // res.json(rows); + res.status(501).json({ error: 'not-implemented' }); + }); + + app.post('/api/rpc/import_asistencia', async (req, res) => { + // TODO: pegar lógica original + res.status(501).json({ error: 'not-implemented' }); + }); + + app.post('/api/rpc/asistencia_get', async (req, res) => { + // TODO + res.status(501).json({ error: 'not-implemented' }); + }); + + app.post('/api/rpc/asistencia_update_raw', async (req, res) => { + // TODO + res.status(501).json({ error: 'not-implemented' }); + }); + + app.post('/api/rpc/asistencia_delete_raw', async (req, res) => { + // TODO + res.status(501).json({ error: 'not-implemented' }); + }); + + app.post('/api/rpc/report_tickets', async (req, res) => { + // TODO: posiblemente public.report_tickets_year(year int) + res.status(501).json({ error: 'not-implemented' }); + }); + + app.post('/api/rpc/report_asistencia', async (req, res) => { + // TODO: posiblemente public.report_asistencia(desde date, hasta date) + res.status(501).json({ error: 'not-implemented' }); + }); + + app.get('/api/rpc/report_gastos', async (req, res) => { + // TODO: pegar la SELECT/función original + res.status(501).json({ error: 'not-implemented' }); + }); + + app.post('/api/rpc/report_gastos', async (req, res) => { + try { + // Ejemplo de carcasa robusta en base a nombres vistos + const { desde, hasta } = req.body || {}; + if (!desde || !hasta) return res.status(400).json({ error: 'desde y hasta son requeridos' }); + // TODO: reemplazar por tu SQL real; esto es un placeholder ilutrativo + const sql = 'SELECT * FROM public.report_gastos($1::date, $2::date)'; + try { + const { rows } = await pool.query(sql, [desde, hasta]); + res.json(rows); + } catch (e) { + res.status(500).json({ error: 'report_gastos failed', message: e.message, detail: e.detail, code: e.code }); + } + } catch (e) { + res.status(500).json({ error: 'report_gastos failed', message: e.message }); + } + }); + + app.post('/api/rpc/save_compra', async (req, res) => { res.status(501).json({ error: 'not-implemented' }); }); + app.post('/api/rpc/get_compra', async (req, res) => { res.status(501).json({ error: 'not-implemented' }); }); + app.post('/api/rpc/delete_compra', async (req, res) => { res.status(501).json({ error: 'not-implemented' }); }); +} diff --git a/services/auth/package-lock.json b/services/auth/package-lock.json index b8a2f52..bc0735f 100644 --- a/services/auth/package-lock.json +++ b/services/auth/package-lock.json @@ -16,6 +16,7 @@ "cookie-session": "^2.0.0", "cors": "^2.8.5", "dotenv": "^17.2.1", + "ejs": "^3.1.10", "express": "^5.1.0", "express-ejs-layouts": "^2.5.1", "express-session": "^1.18.2", @@ -197,6 +198,12 @@ "node": ">=10" } }, + "node_modules/async": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "license": "MIT" + }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -642,6 +649,21 @@ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", "license": "MIT" }, + "node_modules/ejs": { + "version": "3.1.10", + "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz", + "integrity": "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==", + "license": "Apache-2.0", + "dependencies": { + "jake": "^10.8.5" + }, + "bin": { + "ejs": "bin/cli.js" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", @@ -804,6 +826,36 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "license": "MIT" }, + "node_modules/filelist": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.4.tgz", + "integrity": "sha512-w1cEuf3S+DrLCQL7ET6kz+gmlJdbq9J7yXCSjK/OZCPA+qEN1WyF4ZAf0YYJa4/shHJra2t/d/r8SV4Ji+x+8Q==", + "license": "Apache-2.0", + "dependencies": { + "minimatch": "^5.0.1" + } + }, + "node_modules/filelist/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/filelist/node_modules/minimatch": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", + "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", @@ -1297,6 +1349,23 @@ "dev": true, "license": "ISC" }, + "node_modules/jake": { + "version": "10.9.4", + "resolved": "https://registry.npmjs.org/jake/-/jake-10.9.4.tgz", + "integrity": "sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA==", + "license": "Apache-2.0", + "dependencies": { + "async": "^3.2.6", + "filelist": "^1.0.4", + "picocolors": "^1.1.1" + }, + "bin": { + "jake": "bin/cli.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/jose": { "version": "4.15.9", "resolved": "https://registry.npmjs.org/jose/-/jose-4.15.9.tgz", @@ -1802,6 +1871,12 @@ "split2": "^4.1.0" } }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, "node_modules/picomatch": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", diff --git a/services/auth/package.json b/services/auth/package.json index d88ff97..a541c61 100644 --- a/services/auth/package.json +++ b/services/auth/package.json @@ -22,6 +22,7 @@ "cookie-session": "^2.0.0", "cors": "^2.8.5", "dotenv": "^17.2.1", + "ejs": "^3.1.10", "express": "^5.1.0", "express-ejs-layouts": "^2.5.1", "express-session": "^1.18.2", diff --git a/services/auth/src/ak.js b/services/auth/src/ak.js index 0b8f654..22545e1 100644 --- a/services/auth/src/ak.js +++ b/services/auth/src/ak.js @@ -1,46 +1,214 @@ // services/auth/src/ak.js -import axios from 'axios'; +// ------------------------------------------------------------ +// Cliente mínimo y robusto para la API Admin de Authentik (v3) +// - Sin dependencias externas (usa fetch nativo de Node >=18) +// - ESM compatible +// - Timeouts, reintentos opcionales y mensajes de error claros +// - Compatible con services/auth/src/index.js actual +// ------------------------------------------------------------ -const AK = axios.create({ - baseURL: `${process.env.AUTHENTIK_BASE_URL}/api/v3`, - headers: { Authorization: `Bearer ${process.env.AUTHENTIK_TOKEN}` }, - timeout: 10000, -}); - -// Busca usuario por email (case-insensitive) -export async function akFindUserByEmail(email) { - const { data } = await AK.get('/core/users/', { params: { search: email }}); - // filtra exacto por email si querés evitar colisiones de 'search' - return data.results?.find(u => (u.email || '').toLowerCase() === email.toLowerCase()) || null; +/** + * Lee configuración desde process.env en cada llamada (para evitar problemas + * de orden de imports con dotenv). No falla en import-time. + */ +function getConfig() { + const BASE = (process.env.AUTHENTIK_BASE_URL || '').replace(/\/+$/, ''); + const TOKEN = process.env.AUTHENTIK_TOKEN || ''; + if (!BASE) throw new Error('AK_CONFIG: Falta AUTHENTIK_BASE_URL'); + if (!TOKEN) throw new Error('AK_CONFIG: Falta AUTHENTIK_TOKEN'); + return { BASE, TOKEN }; } -// Crea usuario en Authentik con atributo tenant_uuid y lo agrega a un grupo (opcional) -export async function akCreateUser({ email, displayName, tenantUuid, addToGroupId }) { + +// ------------------------------------------------------------ +// Helpers de sincronización +// ------------------------------------------------------------ +export async function akPatchUserAttributes(userPk, partialAttrs = {}) { + // PATCH del usuario para asegurar attributes.tenant_uuid + return akRequest('patch', `/api/v3/core/users/${userPk}/`, { + data: { attributes: partialAttrs }, + }); +} + +export async function akEnsureGroupForTenant(tenantHex) { + const groupName = `tenant_${tenantHex}`; + + // buscar por nombre + const data = await akRequest('get', '/api/v3/core/groups/', { params: { name: groupName }}); + const g = (data?.results || [])[0]; + if (g) return g.pk; + + // crear si no existe + const created = await akRequest('post', '/api/v3/core/groups/', { + data: { name: groupName, attributes: { tenant_uuid: tenantHex } }, + }); + return created.pk; +} + +export async function akAddUserToGroup(userPk, groupPk) { + // Endpoint de membership (en versiones recientes, POST users//groups/) + return akRequest('post', `/api/v3/core/users/${userPk}/groups/`, { data: { group: groupPk } }); +} + +// Utilidad de espera +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +/** + * Llamada HTTP genérica con fetch + timeout + manejo de errores. + * @param {('GET'|'POST'|'PUT'|'PATCH'|'DELETE')} method + * @param {string} path - Ruta a partir de /api/v3 (por ej. "/core/users/") + * @param {{qs?:Record, body?:any, timeoutMs?:number, retries?:number}} [opts] + */ +async function request(method, path, opts = {}) { + const { BASE, TOKEN } = getConfig(); + const { + qs = undefined, + body = undefined, + timeoutMs = 10000, + retries = 0, + } = opts; + + const url = new URL(`${BASE}/api/v3${path}`); + if (qs) Object.entries(qs).forEach(([k, v]) => url.searchParams.set(k, String(v))); + + let lastErr; + for (let attempt = 1; attempt <= Math.max(1, retries + 1); attempt++) { + const controller = new AbortController(); + const t = setTimeout(() => controller.abort(new Error('AK_TIMEOUT')), timeoutMs); + try { + const res = await fetch(url, { + method, + signal: controller.signal, + headers: { + 'Authorization': `Bearer ${TOKEN}`, + 'Content-Type': 'application/json', + 'Accept': 'application/json', + }, + body: body !== undefined ? JSON.stringify(body) : undefined, + }); + clearTimeout(t); + + if (res.status === 204) return null; // sin contenido + + // intenta parsear JSON; si no es JSON, devuelve texto + const ctype = res.headers.get('content-type') || ''; + const payload = ctype.includes('application/json') ? await res.json().catch(() => ({})) : await res.text(); + + if (!res.ok) { + const detail = typeof payload === 'string' ? payload : payload?.detail || payload?.error || JSON.stringify(payload); + const err = new Error(`AK ${method} ${url.pathname} → HTTP ${res.status}: ${detail}`); + err.status = res.status; // @ts-ignore + throw err; + } + + return payload; + } catch (e) { + clearTimeout(t); + lastErr = e; + // Reintentos sólo en ECONNREFUSED/timeout/5xx + const msg = String(e?.message || e); + const retriable = msg.includes('ECONNREFUSED') || msg.includes('AK_TIMEOUT') || /\b5\d\d\b/.test(e?.status?.toString?.() || ''); + if (!retriable || attempt > retries) throw e; + await sleep(500 * attempt); // backoff lineal suave + } + } + throw lastErr; +} + +// ------------------------------------------------------------ +// Funciones públicas +// ------------------------------------------------------------ + +/** + * Busca un usuario por email en Authentik (case-insensitive) usando ?search= + * Devuelve el usuario exacto o null si no existe. + */ +export async function akFindUserByEmail(email) { + if (!email) throw new Error('akFindUserByEmail: email requerido'); + const data = await request('GET', '/core/users/', { qs: { search: email, page_size: 50 }, retries: 3 }); + const list = Array.isArray(data?.results) ? data.results : []; + const lower = String(email).toLowerCase(); + return list.find((u) => (u.email || '').toLowerCase() === lower) || null; +} + +/** + * Crea un usuario en Authentik con atributos de tenant y opcionalmente lo + * agrega a un grupo existente. + * @param {{email:string, displayName?:string, tenantUuid?:string, addToGroupId?: number|string, isActive?: boolean}} p + * @returns {Promise} el objeto usuario creado + */ +export async function akCreateUser(p) { + const email = p?.email; + if (!email) throw new Error('akCreateUser: email requerido'); + const name = p?.displayName || email; + const tenantUuid = (p?.tenantUuid || '').replace(/-/g, ''); + const isActive = p?.isActive ?? true; + // 1) crear usuario - const { data: user } = await AK.post('/core/users/', { - username: email, // en Authentik el username puede ser el email - name: displayName || email, - email, - is_active: true, - attributes: { tenant_uuid: tenantUuid }, // <-- para tu claim custom + const user = await request('POST', '/core/users/', { + body: { + username: email, + name, + email, + is_active: isActive, + attributes: tenantUuid ? { tenant_uuid: tenantUuid } : {}, + }, + retries: 3, }); - // 2) agregar a grupo por defecto (opcional) - if (addToGroupId) { - await AK.post(`/core/users/${user.pk}/groups/`, { group: addToGroupId }); + // 2) agregar a grupo (opcional) + if (p?.addToGroupId) { + try { + await request('POST', `/core/users/${user.pk}/groups/`, { body: { group: p.addToGroupId }, retries: 2 }); + } catch (e) { + // No rompas todo por el grupo; deja registro del error para que el caller decida. + console.warn(`akCreateUser: no se pudo agregar al grupo ${p.addToGroupId}:`, e?.message || e); + } } - return user; // contiene pk y uuid + return user; } -// Opcional: setear/forzar password inicial (si querés flujo con password local en Authentik) +/** + * Establece/forza una contraseña a un usuario (si tu política lo permite). + * @param {number|string} userPk + * @param {string} password + * @param {boolean} requireChange - si el usuario debe cambiarla al siguiente login + */ export async function akSetPassword(userPk, password, requireChange = true) { + if (!userPk) throw new Error('akSetPassword: userPk requerido'); + if (!password) throw new Error('akSetPassword: password requerida'); try { - await AK.post(`/core/users/${userPk}/set_password/`, { - password, require_change: requireChange, + await request('POST', `/core/users/${userPk}/set_password/`, { + body: { password, require_change: !!requireChange }, + retries: 1, }); + return true; } catch (e) { - // Si tu instancia no permite setear password por API, capturá y usá un flow de "reset password" - throw new Error('No se pudo establecer la contraseña en Authentik por API'); + // Algunas instalaciones no permiten setear password por API (políticas). Propaga un error legible. + const err = new Error(`akSetPassword: no se pudo establecer la contraseña: ${e?.message || e}`); + err.cause = e; + throw err; } } + +/** + * Helper opcional para obtener grupos por nombre/slug si en el futuro lo necesitas + * (no usado por index.js; se deja por conveniencia). + */ +export async function 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; +} + + +// ------------------------------------------------------------ +// Fin +// ------------------------------------------------------------ diff --git a/services/auth/src/db/dumpl_manso_250905.sql b/services/auth/src/db/dumpl_manso_250905.sql new file mode 100644 index 0000000..e58a318 --- /dev/null +++ b/services/auth/src/db/dumpl_manso_250905.sql @@ -0,0 +1,3071 @@ +-- +-- 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; +SELECT pg_catalog.set_config('search_path', '', false); +SET check_function_bodies = false; +SET xmloption = content; +SET client_min_messages = warning; +SET row_security = off; + +-- +-- Name: asistencia_delete_raw(bigint, text); Type: FUNCTION; Schema: public; Owner: manso +-- + +CREATE FUNCTION public.asistencia_delete_raw(p_id_raw bigint, p_tz text DEFAULT 'America/Montevideo'::text) RETURNS jsonb + LANGUAGE plpgsql + AS $$ +DECLARE + v_id_usuario INT; + v_ts TIMESTAMPTZ; + v_t0 TIMESTAMPTZ; + v_t1 TIMESTAMPTZ; + v_del_raw INT; + v_del INT; + v_ins INT; +BEGIN + SELECT id_usuario, ts INTO v_id_usuario, v_ts + FROM public.asistencia_raw WHERE id_raw = p_id_raw; + IF v_id_usuario IS NULL THEN + RETURN jsonb_build_object('deleted',0,'msg','id_raw inexistente'); + END IF; + + v_t0 := v_ts - INTERVAL '1 day'; + v_t1 := v_ts + INTERVAL '1 day'; + + -- borrar raw + DELETE FROM public.asistencia_raw WHERE id_raw = p_id_raw; + GET DIAGNOSTICS v_del_raw = ROW_COUNT; + + -- recomputar pares en ventana + WITH tl AS ( + SELECT ar.ts, + ROW_NUMBER() OVER (ORDER BY ar.ts) AS rn + FROM public.asistencia_raw ar + WHERE ar.id_usuario = v_id_usuario + AND ar.ts BETWEEN v_t0 AND v_t1 + ), + ready AS ( + SELECT (t1.ts AT TIME ZONE p_tz)::date AS fecha, + t1.ts AS desde, + t2.ts AS hasta, + EXTRACT(EPOCH FROM (t2.ts - t1.ts))/60.0 AS dur_min + FROM tl t1 + JOIN tl t2 ON t2.rn = t1.rn + 1 + WHERE (t1.rn % 2) = 1 AND t2.ts > t1.ts + ), + del AS ( + DELETE FROM public.asistencia_intervalo ai + WHERE ai.id_usuario = v_id_usuario + AND (ai.desde BETWEEN v_t0 AND v_t1 OR ai.hasta BETWEEN v_t0 AND v_t1) + RETURNING 1 + ), + ins AS ( + INSERT INTO public.asistencia_intervalo (id_usuario, fecha, desde, hasta, dur_min, origen) + SELECT v_id_usuario, r.fecha, r.desde, r.hasta, r.dur_min, 'delete_adjust' + FROM ready r + ON CONFLICT (id_usuario, desde, hasta) DO NOTHING + RETURNING 1 + ) + SELECT (SELECT COUNT(*) FROM del), (SELECT COUNT(*) FROM ins) INTO v_del, v_ins; + + RETURN jsonb_build_object('deleted',v_del_raw,'deleted_pairs',v_del,'inserted_pairs',v_ins); +END; +$$; + + +ALTER FUNCTION public.asistencia_delete_raw(p_id_raw bigint, p_tz text) OWNER TO manso; + +-- +-- Name: asistencia_get(text, date, date, text); Type: FUNCTION; Schema: public; Owner: manso +-- + +CREATE FUNCTION public.asistencia_get(p_doc text, p_desde date, p_hasta date, p_tz text DEFAULT 'America/Montevideo'::text) RETURNS jsonb + LANGUAGE sql + AS $$ +WITH u AS ( + SELECT id_usuario, documento, nombre, apellido + FROM public.usuarios + WHERE regexp_replace(documento,'^\s*0+','','g') = regexp_replace(p_doc,'^\s*0+','','g') + LIMIT 1 +), +r AS ( + SELECT ar.id_raw, + (ar.ts AT TIME ZONE p_tz)::date AS fecha, + to_char(ar.ts AT TIME ZONE p_tz,'HH24:MI:SS') AS hora, + COALESCE(ar.modo,'') AS modo, + COALESCE(ar.origen,'') AS origen, + ar.ts + FROM public.asistencia_raw ar + JOIN u USING (id_usuario) + WHERE (ar.ts AT TIME ZONE p_tz)::date BETWEEN p_desde AND p_hasta +), +i AS ( + SELECT ai.id_intervalo, + ai.fecha, + to_char(ai.desde AT TIME ZONE p_tz,'HH24:MI:SS') AS desde_hora, + to_char(ai.hasta AT TIME ZONE p_tz,'HH24:MI:SS') AS hasta_hora, + ai.dur_min + FROM public.asistencia_intervalo ai + JOIN u USING (id_usuario) + WHERE ai.fecha BETWEEN p_desde AND p_hasta +) +SELECT jsonb_build_object( + 'usuario', (SELECT to_jsonb(u.*) FROM u), + 'raw', COALESCE((SELECT jsonb_agg(to_jsonb(r.*) ORDER BY r.ts) FROM r),'[]'::jsonb), + 'intervalos', COALESCE((SELECT jsonb_agg(to_jsonb(i.*) ORDER BY i.fecha, i.id_intervalo) FROM i),'[]'::jsonb) +); +$$; + + +ALTER FUNCTION public.asistencia_get(p_doc text, p_desde date, p_hasta date, p_tz text) OWNER TO manso; + +-- +-- Name: asistencia_update_raw(bigint, date, text, text, text); Type: FUNCTION; Schema: public; Owner: manso +-- + +CREATE FUNCTION public.asistencia_update_raw(p_id_raw bigint, p_fecha date, p_hora text, p_modo text, p_tz text DEFAULT 'America/Montevideo'::text) RETURNS jsonb + LANGUAGE plpgsql + AS $$ +DECLARE + v_id_usuario INT; + v_ts_old TIMESTAMPTZ; + v_ts_new TIMESTAMPTZ; + v_t0 TIMESTAMPTZ; + v_t1 TIMESTAMPTZ; + v_del INT; + v_ins INT; +BEGIN + -- leer estado previo + SELECT id_usuario, ts INTO v_id_usuario, v_ts_old + FROM public.asistencia_raw WHERE id_raw = p_id_raw; + IF v_id_usuario IS NULL THEN + RETURN jsonb_build_object('updated',0,'msg','id_raw inexistente'); + END IF; + + -- construir ts nuevo + v_ts_new := make_timestamptz( + EXTRACT(YEAR FROM p_fecha)::INT, + EXTRACT(MONTH FROM p_fecha)::INT, + EXTRACT(DAY FROM p_fecha)::INT, + split_part(p_hora,':',1)::INT, + split_part(p_hora,':',2)::INT, + COALESCE(NULLIF(split_part(p_hora,':',3),''), '0')::INT, + p_tz); + + -- aplicar update + UPDATE public.asistencia_raw + SET ts = v_ts_new, + modo = COALESCE(p_modo, modo) + WHERE id_raw = p_id_raw; + + -- ventana de recálculo + v_t0 := LEAST(v_ts_old, v_ts_new) - INTERVAL '1 day'; + v_t1 := GREATEST(v_ts_old, v_ts_new) + INTERVAL '1 day'; + + -- recomputar pares en la ventana: borrar los del rango y reinsertar + WITH tl AS ( + SELECT ar.ts, + ROW_NUMBER() OVER (ORDER BY ar.ts) AS rn + FROM public.asistencia_raw ar + WHERE ar.id_usuario = v_id_usuario + AND ar.ts BETWEEN v_t0 AND v_t1 + ), + ready AS ( + SELECT (t1.ts AT TIME ZONE p_tz)::date AS fecha, + t1.ts AS desde, + t2.ts AS hasta, + EXTRACT(EPOCH FROM (t2.ts - t1.ts))/60.0 AS dur_min + FROM tl t1 + JOIN tl t2 ON t2.rn = t1.rn + 1 + WHERE (t1.rn % 2) = 1 AND t2.ts > t1.ts + ), + del AS ( + DELETE FROM public.asistencia_intervalo ai + WHERE ai.id_usuario = v_id_usuario + AND (ai.desde BETWEEN v_t0 AND v_t1 OR ai.hasta BETWEEN v_t0 AND v_t1) + RETURNING 1 + ), + ins AS ( + INSERT INTO public.asistencia_intervalo (id_usuario, fecha, desde, hasta, dur_min, origen) + SELECT v_id_usuario, r.fecha, r.desde, r.hasta, r.dur_min, 'edit_manual' + FROM ready r + ON CONFLICT (id_usuario, desde, hasta) DO NOTHING + RETURNING 1 + ) + SELECT (SELECT COUNT(*) FROM del), (SELECT COUNT(*) FROM ins) INTO v_del, v_ins; + + RETURN jsonb_build_object('updated',1,'deleted_pairs',v_del,'inserted_pairs',v_ins); +END; +$$; + + +ALTER FUNCTION public.asistencia_update_raw(p_id_raw bigint, p_fecha date, p_hora text, p_modo text, p_tz text) OWNER TO manso; + +-- +-- Name: delete_compra(integer); Type: FUNCTION; Schema: public; Owner: manso +-- + +CREATE FUNCTION public.delete_compra(p_id_compra integer) RETURNS void + LANGUAGE plpgsql + AS $$ +BEGIN + DELETE FROM public.deta_comp_materias WHERE id_compra = p_id_compra; + DELETE FROM public.deta_comp_producto WHERE id_compra = p_id_compra; + DELETE FROM public.compras WHERE id_compra = p_id_compra; +END; +$$; + + +ALTER FUNCTION public.delete_compra(p_id_compra integer) OWNER TO manso; + +-- +-- Name: f_abrir_comanda(integer); Type: FUNCTION; Schema: public; Owner: manso +-- + +CREATE FUNCTION public.f_abrir_comanda(p_id integer) RETURNS jsonb + LANGUAGE plpgsql + AS $$ +DECLARE r jsonb; +BEGIN + UPDATE public.comandas + SET estado = 'abierta', + fec_cierre = NULL + WHERE id_comanda = p_id; + + IF NOT FOUND THEN + RETURN NULL; + END IF; + + SELECT to_jsonb(v) INTO r + FROM public.v_comandas_resumen v + WHERE v.id_comanda = p_id; + + RETURN r; +END; +$$; + + +ALTER FUNCTION public.f_abrir_comanda(p_id integer) OWNER TO manso; + +-- +-- Name: f_cerrar_comanda(integer); Type: FUNCTION; Schema: public; Owner: manso +-- + +CREATE FUNCTION public.f_cerrar_comanda(p_id integer) RETURNS jsonb + LANGUAGE plpgsql + AS $$ +DECLARE r jsonb; +BEGIN + UPDATE public.comandas + SET estado = 'cerrada', + fec_cierre = COALESCE(fec_cierre, NOW()) + WHERE id_comanda = p_id; + + IF NOT FOUND THEN + RETURN NULL; + END IF; + + SELECT to_jsonb(v) INTO r + FROM public.v_comandas_resumen v + WHERE v.id_comanda = p_id; + + RETURN r; +END; +$$; + + +ALTER FUNCTION public.f_cerrar_comanda(p_id integer) OWNER TO manso; + +-- +-- Name: f_comanda_detalle_json(integer); Type: FUNCTION; Schema: public; Owner: manso +-- + +CREATE FUNCTION public.f_comanda_detalle_json(p_id_comanda integer) RETURNS jsonb + LANGUAGE sql + AS $$ +WITH base AS ( + SELECT + c.id_comanda, + c.fec_creacion, + c.estado, + c.observaciones, + u.id_usuario, + u.nombre AS usuario_nombre, + u.apellido AS usuario_apellido, + m.id_mesa, + m.numero AS mesa_numero, + m.apodo AS mesa_apodo, + d.id_producto, + p.nombre AS producto_nombre, + d.cantidad, + d.pre_unitario, + (d.cantidad * d.pre_unitario) AS subtotal + FROM public.comandas c + JOIN public.usuarios u ON u.id_usuario = c.id_usuario + JOIN public.mesas m ON m.id_mesa = c.id_mesa + LEFT JOIN public.deta_comandas d ON d.id_comanda = c.id_comanda + LEFT JOIN public.productos p ON p.id_producto = d.id_producto + WHERE c.id_comanda = p_id_comanda +), +hdr AS ( + -- 1 sola fila con los datos de cabecera + SELECT DISTINCT + id_comanda, fec_creacion, estado, observaciones, + id_usuario, usuario_nombre, usuario_apellido, + id_mesa, mesa_numero, mesa_apodo + FROM base +), +agg_items AS ( + SELECT + COALESCE( + jsonb_agg( + jsonb_build_object( + 'producto_id', b.id_producto, + 'producto', b.producto_nombre, + 'cantidad', b.cantidad, + 'pre_unitario', b.pre_unitario, + 'subtotal', b.subtotal + ) + ORDER BY b.producto_nombre NULLS LAST + ) FILTER (WHERE b.id_producto IS NOT NULL), + '[]'::jsonb + ) AS items + FROM base b +), +tot AS ( + SELECT + COUNT(*) FILTER (WHERE id_producto IS NOT NULL) AS items, + COALESCE(SUM(subtotal), 0)::numeric AS total + FROM base +) +SELECT + CASE + WHEN EXISTS (SELECT 1 FROM hdr) THEN + jsonb_build_object( + 'id_comanda', h.id_comanda, + 'fec_creacion', h.fec_creacion, + 'estado', h.estado, + 'observaciones',h.observaciones, + 'usuario', jsonb_build_object( + 'id_usuario', h.id_usuario, + 'nombre', h.usuario_nombre, + 'apellido', h.usuario_apellido + ), + 'mesa', jsonb_build_object( + 'id_mesa', h.id_mesa, + 'numero', h.mesa_numero, + 'apodo', h.mesa_apodo + ), + 'items', i.items, + 'totales', jsonb_build_object( + 'items', t.items, + 'total', t.total + ) + ) + ELSE NULL + END +FROM hdr h, agg_items i, tot t; +$$; + + +ALTER FUNCTION public.f_comanda_detalle_json(p_id_comanda integer) OWNER TO manso; + +-- +-- Name: f_comanda_detalle_rows(integer); Type: FUNCTION; Schema: public; Owner: manso +-- + +CREATE FUNCTION public.f_comanda_detalle_rows(p_id_comanda integer) RETURNS TABLE(id_comanda integer, fec_creacion timestamp without time zone, estado text, observaciones text, id_usuario integer, usuario_nombre text, usuario_apellido text, id_mesa integer, mesa_numero integer, mesa_apodo text, producto_id integer, producto_nombre text, cantidad numeric, pre_unitario numeric, subtotal numeric, items integer, total numeric) + LANGUAGE sql + AS $$ +WITH base AS ( + SELECT + c.id_comanda, c.fec_creacion, c.estado, c.observaciones, + u.id_usuario, u.nombre AS usuario_nombre, u.apellido AS usuario_apellido, + m.id_mesa, m.numero AS mesa_numero, m.apodo AS mesa_apodo, + d.id_producto, p.nombre AS producto_nombre, + d.cantidad, d.pre_unitario, + (d.cantidad * d.pre_unitario) AS subtotal + FROM public.comandas c + JOIN public.usuarios u ON u.id_usuario = c.id_usuario + JOIN public.mesas m ON m.id_mesa = c.id_mesa + LEFT JOIN public.deta_comandas d ON d.id_comanda = c.id_comanda + LEFT JOIN public.productos p ON p.id_producto = d.id_producto + WHERE c.id_comanda = p_id_comanda +), +tot AS ( + SELECT + COUNT(*) FILTER (WHERE id_producto IS NOT NULL) AS items, + COALESCE(SUM(subtotal), 0) AS total + FROM base +) +SELECT + b.id_comanda, b.fec_creacion, b.estado, b.observaciones, + b.id_usuario, b.usuario_nombre, b.usuario_apellido, + b.id_mesa, b.mesa_numero, b.mesa_apodo, + b.id_producto, b.producto_nombre, + b.cantidad, b.pre_unitario, b.subtotal, + t.items, t.total +FROM base b CROSS JOIN tot t +ORDER BY b.producto_nombre NULLS LAST; +$$; + + +ALTER FUNCTION public.f_comanda_detalle_rows(p_id_comanda integer) OWNER TO manso; + +SET default_tablespace = ''; + +SET default_table_access_method = heap; + +-- +-- Name: comandas; Type: TABLE; Schema: public; Owner: manso +-- + +CREATE TABLE public.comandas ( + id_comanda integer NOT NULL, + id_usuario integer NOT NULL, + id_mesa integer NOT NULL, + fec_creacion timestamp without time zone DEFAULT now() NOT NULL, + estado text NOT NULL, + observaciones text, + fec_cierre timestamp with time zone, + CONSTRAINT comandas_estado_check CHECK ((estado = ANY (ARRAY['abierta'::text, 'cerrada'::text, 'pagada'::text, 'anulada'::text]))) +); + + +ALTER TABLE public.comandas OWNER TO manso; + +-- +-- Name: COLUMN comandas.fec_cierre; Type: COMMENT; Schema: public; Owner: manso +-- + +COMMENT ON COLUMN public.comandas.fec_cierre IS 'Fecha/hora de cierre de la comanda (NULL si está abierta)'; + + +-- +-- Name: deta_comandas; Type: TABLE; Schema: public; Owner: manso +-- + +CREATE TABLE public.deta_comandas ( + id_det_comanda integer NOT NULL, + id_comanda integer NOT NULL, + id_producto integer NOT NULL, + cantidad numeric(12,3) NOT NULL, + pre_unitario numeric(12,2) NOT NULL, + observaciones text, + CONSTRAINT deta_comandas_cantidad_check CHECK ((cantidad > (0)::numeric)), + CONSTRAINT deta_comandas_pre_unitario_check CHECK ((pre_unitario >= (0)::numeric)) +); + + +ALTER TABLE public.deta_comandas OWNER TO manso; + +-- +-- Name: mesas; Type: TABLE; Schema: public; Owner: manso +-- + +CREATE TABLE public.mesas ( + id_mesa integer NOT NULL, + numero integer NOT NULL, + apodo text NOT NULL, + estado text DEFAULT 'libre'::text NOT NULL, + CONSTRAINT mesas_estado_check CHECK ((estado = ANY (ARRAY['libre'::text, 'ocupada'::text, 'reservada'::text]))) +); + + +ALTER TABLE public.mesas OWNER TO manso; + +-- +-- Name: usuarios; Type: TABLE; Schema: public; Owner: manso +-- + +CREATE TABLE public.usuarios ( + id_usuario integer NOT NULL, + documento text, + img_perfil character varying(255) DEFAULT 'img_perfil.png'::character varying NOT NULL, + nombre text NOT NULL, + apellido text NOT NULL, + correo text, + telefono text, + fec_nacimiento date, + activo boolean DEFAULT true +); + + +ALTER TABLE public.usuarios OWNER TO manso; + +-- +-- Name: v_comandas_resumen; Type: VIEW; Schema: public; Owner: manso +-- + +CREATE VIEW public.v_comandas_resumen AS + WITH items AS ( + SELECT d.id_comanda, + count(*) AS items, + sum((d.cantidad * d.pre_unitario)) AS total + FROM public.deta_comandas d + GROUP BY d.id_comanda + ) + SELECT c.id_comanda, + c.fec_creacion, + c.estado, + c.observaciones, + u.id_usuario, + u.nombre AS usuario_nombre, + u.apellido AS usuario_apellido, + m.id_mesa, + m.numero AS mesa_numero, + m.apodo AS mesa_apodo, + COALESCE(i.items, (0)::bigint) AS items, + COALESCE(i.total, (0)::numeric) AS total, + c.fec_cierre, + CASE + WHEN (c.fec_cierre IS NOT NULL) THEN round((EXTRACT(epoch FROM (c.fec_cierre - (c.fec_creacion)::timestamp with time zone)) / 60.0), 1) + ELSE NULL::numeric + END AS duracion_min + FROM (((public.comandas c + JOIN public.usuarios u ON ((u.id_usuario = c.id_usuario))) + JOIN public.mesas m ON ((m.id_mesa = c.id_mesa))) + LEFT JOIN items i ON ((i.id_comanda = c.id_comanda))); + + +ALTER VIEW public.v_comandas_resumen OWNER TO manso; + +-- +-- Name: f_comandas_resumen(text, integer); Type: FUNCTION; Schema: public; Owner: manso +-- + +CREATE FUNCTION public.f_comandas_resumen(p_estado text DEFAULT NULL::text, p_limit integer DEFAULT 200) RETURNS SETOF public.v_comandas_resumen + LANGUAGE sql + AS $$ + SELECT * + FROM public.v_comandas_resumen + WHERE (p_estado IS NULL OR estado = p_estado) + ORDER BY id_comanda DESC + LIMIT p_limit; +$$; + + +ALTER FUNCTION public.f_comandas_resumen(p_estado text, p_limit integer) OWNER TO manso; + +-- +-- Name: find_usuarios_por_documentos(jsonb); Type: FUNCTION; Schema: public; Owner: manso +-- + +CREATE FUNCTION public.find_usuarios_por_documentos(p_docs jsonb) RETURNS jsonb + LANGUAGE sql + AS $$ +WITH docs AS ( + SELECT DISTINCT + regexp_replace(value::text, '^\s*0+', '', 'g') AS doc_clean, + value::text AS original + FROM jsonb_array_elements_text(COALESCE(p_docs,'[]')) +), +rows AS ( + SELECT d.original AS documento, + u.nombre, + u.apellido, + (u.id_usuario IS NOT NULL) AS found + FROM docs d + LEFT JOIN public.usuarios u + ON regexp_replace(u.documento, '^\s*0+', '', 'g') = d.doc_clean +) +SELECT COALESCE( + jsonb_object_agg( + documento, + jsonb_build_object( + 'nombre', COALESCE(nombre, ''), + 'apellido', COALESCE(apellido, ''), + 'found', found + ) + ), + '{}'::jsonb +) +FROM rows; +$$; + + +ALTER FUNCTION public.find_usuarios_por_documentos(p_docs jsonb) OWNER TO manso; + +-- +-- Name: get_compra(integer); Type: FUNCTION; Schema: public; Owner: manso +-- + +CREATE FUNCTION public.get_compra(p_id_compra integer) RETURNS jsonb + LANGUAGE sql + AS $$ +WITH cab AS ( + SELECT c.id_compra, c.id_proveedor, c.fec_compra, c.total + FROM public.compras c + WHERE c.id_compra = p_id_compra +), +dm AS ( + SELECT 'MAT'::text AS tipo, d.id_mat_prima AS id, + d.cantidad, d.pre_unitario AS precio + FROM public.deta_comp_materias d WHERE d.id_compra = p_id_compra +), +dp AS ( + SELECT 'PROD'::text AS tipo, d.id_producto AS id, + d.cantidad, d.pre_unitario AS precio + FROM public.deta_comp_producto d WHERE d.id_compra = p_id_compra +), +det AS ( + SELECT jsonb_agg(to_jsonb(x.*)) AS detalles + FROM ( + SELECT * FROM dm + UNION ALL + SELECT * FROM dp + ) x +) +SELECT jsonb_build_object( + 'id_compra', (SELECT id_compra FROM cab), + 'id_proveedor',(SELECT id_proveedor FROM cab), + 'fec_compra', to_char((SELECT fec_compra FROM cab),'YYYY-MM-DD HH24:MI:SS'), + 'total', (SELECT total FROM cab), + 'detalles', COALESCE((SELECT detalles FROM det),'[]'::jsonb) +); +$$; + + +ALTER FUNCTION public.get_compra(p_id_compra integer) OWNER TO manso; + +-- +-- Name: get_materia_prima(integer); Type: FUNCTION; Schema: public; Owner: manso +-- + +CREATE FUNCTION public.get_materia_prima(p_id integer) RETURNS jsonb + LANGUAGE sql + AS $$ +SELECT jsonb_build_object( + 'materia', to_jsonb(mp), + 'proveedores', COALESCE( + ( + SELECT jsonb_agg( + jsonb_build_object( + 'id_proveedor', pr.id_proveedor, + 'raz_social', pr.raz_social, + 'rut', pr.rut, + 'contacto', pr.contacto, + 'direccion', pr.direccion + ) + ) + FROM public.prov_mate_prima pmp + JOIN public.proveedores pr ON pr.id_proveedor = pmp.id_proveedor + WHERE pmp.id_mat_prima = mp.id_mat_prima + ), + '[]'::jsonb + ) +) +FROM public.mate_primas mp +WHERE mp.id_mat_prima = p_id; +$$; + + +ALTER FUNCTION public.get_materia_prima(p_id integer) OWNER TO manso; + +-- +-- Name: get_producto(integer); Type: FUNCTION; Schema: public; Owner: manso +-- + +CREATE FUNCTION public.get_producto(p_id integer) RETURNS jsonb + LANGUAGE sql + AS $$ +SELECT jsonb_build_object( + 'producto', to_jsonb(p), -- el registro completo del producto en JSONB + 'receta', COALESCE( + ( + SELECT jsonb_agg( + jsonb_build_object( + 'id_mat_prima', rp.id_mat_prima, + 'qty_por_unidad', rp.qty_por_unidad, + 'nombre', mp.nombre, + 'unidad', mp.unidad + ) + ) + FROM receta_producto rp + LEFT JOIN mate_primas mp USING (id_mat_prima) + WHERE rp.id_producto = p.id_producto + ), + '[]'::jsonb + ) +) +FROM productos p +WHERE p.id_producto = p_id; +$$; + + +ALTER FUNCTION public.get_producto(p_id integer) OWNER TO manso; + +-- +-- Name: import_asistencia(jsonb, text, text); Type: FUNCTION; Schema: public; Owner: manso +-- + +CREATE FUNCTION public.import_asistencia(p_registros jsonb, p_origen text, p_tz text DEFAULT 'America/Montevideo'::text) RETURNS jsonb + LANGUAGE plpgsql + AS $_$ +DECLARE + v_ins_raw INT; + v_ins_pairs INT; + v_miss JSONB; +BEGIN + WITH + -- 1) JSON -> filas + j AS ( + SELECT + regexp_replace(elem->>'doc','^\s*0+','','g')::TEXT AS doc_clean, + (elem->>'isoDate')::DATE AS d, + elem->>'time' AS time_str, + NULLIF(elem->>'mode','') AS modo + FROM jsonb_array_elements(COALESCE(p_registros,'[]')) elem + ), + -- 2) Vincular a usuarios + u AS ( + SELECT j.*, u.id_usuario + FROM j + LEFT JOIN public.usuarios u + ON regexp_replace(u.documento,'^\s*0+','','g') = j.doc_clean + ), + -- 3) Documentos faltantes + miss AS ( + SELECT jsonb_agg(doc_clean) AS missing + FROM u WHERE id_usuario IS NULL + ), + -- 4) TS determinista en TZ del negocio + parsed AS ( + SELECT + u.id_usuario, + u.modo, + make_timestamptz( + EXTRACT(YEAR FROM u.d)::INT, + EXTRACT(MONTH FROM u.d)::INT, + EXTRACT(DAY FROM u.d)::INT, + split_part(u.time_str,':',1)::INT, + split_part(u.time_str,':',2)::INT, + COALESCE(NULLIF(split_part(u.time_str,':',3),''),'0')::INT, + p_tz + ) AS ts_calc + FROM u + WHERE u.id_usuario IS NOT NULL + ), + -- 5) Ventana por usuario (±1 día de lo importado) + win AS ( + SELECT id_usuario, + (MIN(ts_calc) - INTERVAL '1 day') AS t0, + (MAX(ts_calc) + INTERVAL '1 day') AS t1 + FROM parsed + GROUP BY id_usuario + ), + -- 6) Lo existente en BD dentro de la ventana + existing AS ( + SELECT ar.id_usuario, ar.ts + FROM public.asistencia_raw ar + JOIN win w ON w.id_usuario = ar.id_usuario + AND ar.ts BETWEEN w.t0 AND w.t1 + ), + -- 7) CANDIDATE = existente ∪ archivo (sin duplicados) + candidate AS ( + SELECT id_usuario, ts FROM existing + UNION -- ¡clave para evitar doble click! + SELECT id_usuario, ts_calc AS ts FROM parsed + ), + -- 8) Paridad previa (cuántas marcas había ANTES de la ventana) + before_cnt AS ( + SELECT w.id_usuario, COUNT(*)::int AS cnt + FROM win w + JOIN public.asistencia_raw ar + ON ar.id_usuario = w.id_usuario + AND ar.ts < w.t0 + GROUP BY w.id_usuario + ), + -- 9) Línea de tiempo candidata y pares (1→2, 3→4…), jornada = día local del inicio + timeline AS ( + SELECT + c.id_usuario, + c.ts, + ROW_NUMBER() OVER (PARTITION BY c.id_usuario ORDER BY c.ts) AS rn + FROM candidate c + ), + ready AS ( + SELECT + t1.id_usuario, + (t1.ts AT TIME ZONE p_tz)::date AS fecha, + t1.ts AS desde, + t2.ts AS hasta, + EXTRACT(EPOCH FROM (t2.ts - t1.ts))/60.0 AS dur_min + FROM timeline t1 + JOIN timeline t2 + ON t2.id_usuario = t1.id_usuario + AND t2.rn = t1.rn + 1 + LEFT JOIN before_cnt b ON b.id_usuario = t1.id_usuario + WHERE ((COALESCE(b.cnt,0) + t1.rn) % 2) = 1 -- t1 es IN global + AND t2.ts > t1.ts + ), + -- 10) INSERT crudo (dedupe) + ins_raw AS ( + INSERT INTO public.asistencia_raw (id_usuario, ts, modo, origen) + SELECT id_usuario, ts_calc, + NULLIF(modo,'')::text, -- puede quedar NULL para auto-etiquetado + p_origen + FROM parsed + ON CONFLICT (id_usuario, ts) DO NOTHING + RETURNING 1 + ), + -- 11) Auto-etiquetar IN/OUT en BD para filas con modo vacío/'1' (tras insertar) + before_cnt2 AS ( + SELECT w.id_usuario, COUNT(*)::int AS cnt + FROM win w + JOIN public.asistencia_raw ar + ON ar.id_usuario = w.id_usuario + AND ar.ts < w.t0 + GROUP BY w.id_usuario + ), + tl2 AS ( + SELECT + ar.id_usuario, ar.ts, + ROW_NUMBER() OVER (PARTITION BY ar.id_usuario ORDER BY ar.ts) AS rn + FROM public.asistencia_raw ar + JOIN win w ON w.id_usuario = ar.id_usuario + AND ar.ts BETWEEN w.t0 AND w.t1 + ), + label2 AS ( + SELECT + t.id_usuario, + t.ts, + CASE WHEN ((COALESCE(b.cnt,0) + t.rn) % 2) = 1 THEN 'IN' ELSE 'OUT' END AS new_mode + FROM tl2 t + LEFT JOIN before_cnt2 b ON b.id_usuario = t.id_usuario + ), + set_mode AS ( + UPDATE public.asistencia_raw ar + SET modo = l.new_mode + FROM label2 l + WHERE ar.id_usuario = l.id_usuario + AND ar.ts = l.ts + AND (ar.modo IS NULL OR btrim(ar.modo) = '' OR ar.modo ~ '^\s*1\s*$') + RETURNING 1 + ), + -- 12) INSERT pares (dedupe) calculados desde CANDIDATE (ya tiene todo el contexto) + ins_pairs AS ( + INSERT INTO public.asistencia_intervalo (id_usuario, fecha, desde, hasta, dur_min, origen) + SELECT id_usuario, fecha, desde, hasta, dur_min, p_origen + FROM ready + ON CONFLICT (id_usuario, desde, hasta) DO NOTHING + RETURNING 1 + ) + SELECT + (SELECT COUNT(*) FROM ins_raw), + (SELECT COUNT(*) FROM ins_pairs), + (SELECT COALESCE(missing,'[]'::jsonb) FROM miss) + INTO v_ins_raw, v_ins_pairs, v_miss; + + RETURN jsonb_build_object( + 'inserted_raw', v_ins_raw, + 'inserted_pairs', v_ins_pairs, + 'missing_docs', v_miss + ); +END; +$_$; + + +ALTER FUNCTION public.import_asistencia(p_registros jsonb, p_origen text, p_tz text) OWNER TO manso; + +-- +-- Name: report_asistencia(date, date); Type: FUNCTION; Schema: public; Owner: manso +-- + +CREATE FUNCTION public.report_asistencia(p_desde date, p_hasta date) RETURNS TABLE(documento text, nombre text, apellido text, fecha date, desde_hora text, hasta_hora text, dur_min numeric) + LANGUAGE sql + AS $$ + SELECT + u.documento, u.nombre, u.apellido, + ai.fecha, + to_char(ai.desde AT TIME ZONE 'America/Montevideo','HH24:MI:SS') AS desde_hora, + to_char(ai.hasta AT TIME ZONE 'America/Montevideo','HH24:MI:SS') AS hasta_hora, + ai.dur_min + FROM public.asistencia_intervalo ai + JOIN public.usuarios u USING (id_usuario) + WHERE ai.fecha BETWEEN p_desde AND p_hasta + ORDER BY u.documento, ai.fecha, ai.desde; +$$; + + +ALTER FUNCTION public.report_asistencia(p_desde date, p_hasta date) OWNER TO manso; + +-- +-- Name: report_gastos(integer); Type: FUNCTION; Schema: public; Owner: manso +-- + +CREATE FUNCTION public.report_gastos(p_year integer) RETURNS jsonb + LANGUAGE sql STABLE + AS $$ +WITH mdata AS ( + SELECT date_trunc('month', c.fec_compra)::date AS m, + SUM(c.total)::numeric AS importe + FROM public.compras c + WHERE EXTRACT(YEAR FROM c.fec_compra) = p_year + GROUP BY 1 +), +mm AS ( + SELECT EXTRACT(MONTH FROM m)::int AS mes, importe + FROM mdata +) +SELECT jsonb_build_object( + 'year', p_year, + 'total', COALESCE((SELECT SUM(importe) FROM mdata), 0), + 'avg', COALESCE((SELECT AVG(importe) FROM mdata), 0), + 'months', + (SELECT jsonb_agg( + jsonb_build_object( + 'mes', gs, + 'nombre', to_char(to_date(gs::text,'MM'),'Mon'), + 'importe', COALESCE(mm.importe,0) + ) + ORDER BY gs + ) + FROM generate_series(1,12) gs + LEFT JOIN mm ON mm.mes = gs) +); +$$; + + +ALTER FUNCTION public.report_gastos(p_year integer) OWNER TO manso; + +-- +-- Name: report_tickets_year(integer, text); Type: FUNCTION; Schema: public; Owner: manso +-- + +CREATE FUNCTION public.report_tickets_year(p_year integer, p_tz text DEFAULT 'America/Montevideo'::text) RETURNS jsonb + LANGUAGE sql STABLE + AS $$ +WITH bounds AS ( + SELECT + make_timestamp(p_year, 1, 1, 0,0,0) AS d0, + make_timestamp(p_year+1, 1, 1, 0,0,0) AS d1, + make_timestamptz(p_year, 1, 1, 0,0,0, p_tz) AS t0, + make_timestamptz(p_year+1, 1, 1, 0,0,0, p_tz) AS t1 +), +base AS ( + SELECT + c.id_comanda, + CASE WHEN c.fec_cierre IS NOT NULL + THEN (c.fec_cierre AT TIME ZONE p_tz) + ELSE c.fec_creacion + END AS fec_local, + v.total + FROM public.comandas c + JOIN public.vw_ticket_total v ON v.id_comanda = c.id_comanda + JOIN bounds b ON TRUE + WHERE + (c.fec_cierre IS NOT NULL AND c.fec_cierre >= b.t0 AND c.fec_cierre < b.t1) + OR + (c.fec_cierre IS NULL AND c.fec_creacion >= b.d0 AND c.fec_creacion < b.d1) +), +m AS ( + SELECT + EXTRACT(MONTH FROM fec_local)::int AS mes, + COUNT(*)::int AS cant, + SUM(total)::numeric AS importe, + AVG(total)::numeric AS avg + FROM base + GROUP BY 1 +), +ytd AS ( + SELECT COUNT(*)::int AS total_ytd, + AVG(total)::numeric AS avg_ticket, + SUM(total)::numeric AS to_date + FROM base +) +SELECT jsonb_build_object( + 'year', p_year, + 'total_ytd', (SELECT total_ytd FROM ytd), + 'avg_ticket', (SELECT avg_ticket FROM ytd), + 'to_date', (SELECT to_date FROM ytd), + 'months', + (SELECT jsonb_agg( + jsonb_build_object( + 'mes', mes, + 'nombre', to_char(to_date(mes::text,'MM'),'Mon'), + 'cant', cant, + 'importe', importe, + 'avg', avg + ) + ORDER BY mes + ) + FROM m) +); +$$; + + +ALTER FUNCTION public.report_tickets_year(p_year integer, p_tz text) OWNER TO manso; + +-- +-- Name: save_compra(integer, integer, timestamp with time zone, jsonb); Type: FUNCTION; Schema: public; Owner: manso +-- + +CREATE FUNCTION public.save_compra(p_id_compra integer, p_id_proveedor integer, p_fec_compra timestamp with time zone, p_detalles jsonb) RETURNS TABLE(id_compra integer, total numeric) + LANGUAGE plpgsql + AS $$ +DECLARE + v_id INT; + v_total numeric := 0; +BEGIN + IF COALESCE(jsonb_array_length(p_detalles),0) = 0 THEN + RAISE EXCEPTION 'No hay renglones en la compra'; + END IF; + + -- Cabecera (insert/update) + IF p_id_compra IS NULL THEN + INSERT INTO public.compras (id_proveedor, fec_compra, total) + VALUES (p_id_proveedor, COALESCE(p_fec_compra, now()), 0) + RETURNING public.compras.id_compra INTO v_id; + ELSE + UPDATE public.compras c + SET id_proveedor = p_id_proveedor, + fec_compra = COALESCE(p_fec_compra, c.fec_compra) + WHERE c.id_compra = p_id_compra + RETURNING c.id_compra INTO v_id; + + -- Reemplazamos los renglones + DELETE FROM public.deta_comp_materias d WHERE d.id_compra = v_id; + DELETE FROM public.deta_comp_producto p WHERE p.id_compra = v_id; + END IF; + + -- Materias primas (sin CTE: parseo JSON inline) + INSERT INTO public.deta_comp_materias (id_compra, id_mat_prima, cantidad, pre_unitario) + SELECT + v_id, + x.id, + x.cantidad, + x.precio + FROM jsonb_to_recordset(COALESCE(p_detalles, '[]'::jsonb)) + AS x(tipo text, id int, cantidad numeric, precio numeric) + WHERE UPPER(TRIM(x.tipo)) = 'MAT'; + + -- Productos (sin CTE) + INSERT INTO public.deta_comp_producto (id_compra, id_producto, cantidad, pre_unitario) + SELECT + v_id, + x.id, + x.cantidad, + x.precio + FROM jsonb_to_recordset(COALESCE(p_detalles, '[]'::jsonb)) + AS x(tipo text, id int, cantidad numeric, precio numeric) + WHERE UPPER(TRIM(x.tipo)) = 'PROD'; + + -- Recalcular total (calificado) y redondear a ENTERO + SELECT + COALESCE( (SELECT SUM(dcm.cantidad*dcm.pre_unitario) + FROM public.deta_comp_materias dcm + WHERE dcm.id_compra = v_id), 0) + + COALESCE( (SELECT SUM(dcp.cantidad*dcp.pre_unitario) + FROM public.deta_comp_producto dcp + WHERE dcp.id_compra = v_id), 0) + INTO v_total; + + UPDATE public.compras c + SET total = round(v_total, 0) + WHERE c.id_compra = v_id; + + RETURN QUERY SELECT v_id, round(v_total, 0); +END; +$$; + + +ALTER FUNCTION public.save_compra(p_id_compra integer, p_id_proveedor integer, p_fec_compra timestamp with time zone, p_detalles jsonb) OWNER TO manso; + +-- +-- Name: save_materia_prima(integer, text, text, boolean, jsonb); Type: FUNCTION; Schema: public; Owner: manso +-- + +CREATE FUNCTION public.save_materia_prima(p_id_mat_prima integer, p_nombre text, p_unidad text, p_activo boolean, p_proveedores jsonb DEFAULT '[]'::jsonb) RETURNS integer + LANGUAGE plpgsql + AS $_$ +DECLARE + v_id INT; +BEGIN + IF p_id_mat_prima IS NULL THEN + INSERT INTO public.mate_primas (nombre, unidad, activo) + VALUES (p_nombre, p_unidad, COALESCE(p_activo, TRUE)) + RETURNING mate_primas.id_mat_prima INTO v_id; + ELSE + UPDATE public.mate_primas mp + SET nombre = p_nombre, + unidad = p_unidad, + activo = COALESCE(p_activo, TRUE) + WHERE mp.id_mat_prima = p_id_mat_prima; + v_id := p_id_mat_prima; + END IF; + + -- Sincronizar proveedores: borrar todos y re-crear a partir de JSONB + DELETE FROM public.prov_mate_prima pmp WHERE pmp.id_mat_prima = v_id; + + INSERT INTO public.prov_mate_prima (id_proveedor, id_mat_prima) + SELECT (e->>0)::INT AS id_proveedor, -- elementos JSON como enteros (array simple) + v_id AS id_mat_prima + FROM jsonb_array_elements(COALESCE(p_proveedores, '[]'::jsonb)) AS e + WHERE (e->>0) ~ '^\d+$'; -- solo enteros + + RETURN v_id; +END; +$_$; + + +ALTER FUNCTION public.save_materia_prima(p_id_mat_prima integer, p_nombre text, p_unidad text, p_activo boolean, p_proveedores jsonb) OWNER TO manso; + +-- +-- Name: save_producto(integer, text, text, numeric, boolean, integer, jsonb); Type: FUNCTION; Schema: public; Owner: manso +-- + +CREATE FUNCTION public.save_producto(p_id_producto integer, p_nombre text, p_img_producto text, p_precio numeric, p_activo boolean, p_id_categoria integer, p_receta jsonb DEFAULT '[]'::jsonb) RETURNS integer + LANGUAGE plpgsql + AS $_$ +DECLARE + v_id INT; +BEGIN + IF p_id_producto IS NULL THEN + INSERT INTO public.productos (nombre, img_producto, precio, activo, id_categoria) + VALUES (p_nombre, p_img_producto, p_precio, COALESCE(p_activo, TRUE), p_id_categoria) + RETURNING productos.id_producto INTO v_id; + ELSE + UPDATE public.productos p + SET nombre = p_nombre, + img_producto = p_img_producto, + precio = p_precio, + activo = COALESCE(p_activo, TRUE), + id_categoria = p_id_categoria + WHERE p.id_producto = p_id_producto; + v_id := p_id_producto; + END IF; + + -- Limpia receta actual + DELETE FROM public.receta_producto rp WHERE rp.id_producto = v_id; + + -- Inserta SOLO ítems válidos (id entero positivo y cantidad > 0), redondeo a 3 decimales + INSERT INTO public.receta_producto (id_producto, id_mat_prima, qty_por_unidad) + SELECT + v_id, + (rec->>'id_mat_prima')::INT, + ROUND((rec->>'qty_por_unidad')::NUMERIC, 3) + FROM jsonb_array_elements(COALESCE(p_receta, '[]'::jsonb)) AS rec + WHERE + (rec->>'id_mat_prima') ~ '^\d+$' + AND (rec->>'id_mat_prima')::INT > 0 + AND (rec->>'qty_por_unidad') ~ '^\d+(\.\d+)?$' + AND (rec->>'qty_por_unidad')::NUMERIC > 0; + + RETURN v_id; +END; +$_$; + + +ALTER FUNCTION public.save_producto(p_id_producto integer, p_nombre text, p_img_producto text, p_precio numeric, p_activo boolean, p_id_categoria integer, p_receta jsonb) OWNER TO manso; + +-- +-- Name: asistencia_intervalo; Type: TABLE; Schema: public; Owner: manso +-- + +CREATE TABLE public.asistencia_intervalo ( + id_intervalo bigint NOT NULL, + id_usuario integer NOT NULL, + fecha date NOT NULL, + desde timestamp with time zone NOT NULL, + hasta timestamp with time zone NOT NULL, + dur_min numeric(10,2) NOT NULL, + origen text, + created_at timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT chk_ai_orden CHECK ((hasta > desde)) +); + + +ALTER TABLE public.asistencia_intervalo OWNER TO manso; + +-- +-- Name: asistencia_intervalo_id_intervalo_seq; Type: SEQUENCE; Schema: public; Owner: manso +-- + +CREATE SEQUENCE public.asistencia_intervalo_id_intervalo_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +ALTER SEQUENCE public.asistencia_intervalo_id_intervalo_seq OWNER TO manso; + +-- +-- Name: asistencia_intervalo_id_intervalo_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: manso +-- + +ALTER SEQUENCE public.asistencia_intervalo_id_intervalo_seq OWNED BY public.asistencia_intervalo.id_intervalo; + + +-- +-- Name: asistencia_raw; Type: TABLE; Schema: public; Owner: manso +-- + +CREATE TABLE public.asistencia_raw ( + id_raw bigint NOT NULL, + id_usuario integer NOT NULL, + ts timestamp with time zone NOT NULL, + modo text, + origen text, + created_at timestamp with time zone DEFAULT now() NOT NULL +); + + +ALTER TABLE public.asistencia_raw OWNER TO manso; + +-- +-- Name: asistencia_raw_id_raw_seq; Type: SEQUENCE; Schema: public; Owner: manso +-- + +CREATE SEQUENCE public.asistencia_raw_id_raw_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +ALTER SEQUENCE public.asistencia_raw_id_raw_seq OWNER TO manso; + +-- +-- Name: asistencia_raw_id_raw_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: manso +-- + +ALTER SEQUENCE public.asistencia_raw_id_raw_seq OWNED BY public.asistencia_raw.id_raw; + + +-- +-- Name: asistencia_resumen_diario; Type: VIEW; Schema: public; Owner: manso +-- + +CREATE VIEW public.asistencia_resumen_diario AS + SELECT ai.id_usuario, + u.documento, + u.nombre, + u.apellido, + ai.fecha, + sum(ai.dur_min) AS minutos_dia, + round((sum(ai.dur_min) / 60.0), 2) AS horas_dia, + count(*) AS pares_dia + FROM (public.asistencia_intervalo ai + JOIN public.usuarios u USING (id_usuario)) + GROUP BY ai.id_usuario, u.documento, u.nombre, u.apellido, ai.fecha + ORDER BY ai.id_usuario, ai.fecha; + + +ALTER VIEW public.asistencia_resumen_diario OWNER TO manso; + +-- +-- Name: categorias; Type: TABLE; Schema: public; Owner: manso +-- + +CREATE TABLE public.categorias ( + id_categoria integer NOT NULL, + nombre text NOT NULL, + visible boolean DEFAULT true +); + + +ALTER TABLE public.categorias OWNER TO manso; + +-- +-- Name: categorias_id_categoria_seq; Type: SEQUENCE; Schema: public; Owner: manso +-- + +CREATE SEQUENCE public.categorias_id_categoria_seq + AS integer + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +ALTER SEQUENCE public.categorias_id_categoria_seq OWNER TO manso; + +-- +-- Name: categorias_id_categoria_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: manso +-- + +ALTER SEQUENCE public.categorias_id_categoria_seq OWNED BY public.categorias.id_categoria; + + +-- +-- Name: clientes; Type: TABLE; Schema: public; Owner: manso +-- + +CREATE TABLE public.clientes ( + id_cliente integer NOT NULL, + nombre text NOT NULL, + correo text, + telefono text, + fec_nacimiento date, + activo boolean DEFAULT true +); + + +ALTER TABLE public.clientes OWNER TO manso; + +-- +-- Name: clientes_id_cliente_seq; Type: SEQUENCE; Schema: public; Owner: manso +-- + +CREATE SEQUENCE public.clientes_id_cliente_seq + AS integer + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +ALTER SEQUENCE public.clientes_id_cliente_seq OWNER TO manso; + +-- +-- Name: clientes_id_cliente_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: manso +-- + +ALTER SEQUENCE public.clientes_id_cliente_seq OWNED BY public.clientes.id_cliente; + + +-- +-- Name: comandas_id_comanda_seq; Type: SEQUENCE; Schema: public; Owner: manso +-- + +CREATE SEQUENCE public.comandas_id_comanda_seq + AS integer + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +ALTER SEQUENCE public.comandas_id_comanda_seq OWNER TO manso; + +-- +-- Name: comandas_id_comanda_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: manso +-- + +ALTER SEQUENCE public.comandas_id_comanda_seq OWNED BY public.comandas.id_comanda; + + +-- +-- Name: compras; Type: TABLE; Schema: public; Owner: manso +-- + +CREATE TABLE public.compras ( + id_compra integer NOT NULL, + id_proveedor integer NOT NULL, + fec_compra timestamp without time zone NOT NULL, + total numeric(14,2) +); + + +ALTER TABLE public.compras OWNER TO manso; + +-- +-- Name: compras_id_compra_seq; Type: SEQUENCE; Schema: public; Owner: manso +-- + +CREATE SEQUENCE public.compras_id_compra_seq + AS integer + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +ALTER SEQUENCE public.compras_id_compra_seq OWNER TO manso; + +-- +-- Name: compras_id_compra_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: manso +-- + +ALTER SEQUENCE public.compras_id_compra_seq OWNED BY public.compras.id_compra; + + +-- +-- Name: deta_comandas_id_det_comanda_seq; Type: SEQUENCE; Schema: public; Owner: manso +-- + +CREATE SEQUENCE public.deta_comandas_id_det_comanda_seq + AS integer + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +ALTER SEQUENCE public.deta_comandas_id_det_comanda_seq OWNER TO manso; + +-- +-- Name: deta_comandas_id_det_comanda_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: manso +-- + +ALTER SEQUENCE public.deta_comandas_id_det_comanda_seq OWNED BY public.deta_comandas.id_det_comanda; + + +-- +-- Name: deta_comp_materias; Type: TABLE; Schema: public; Owner: manso +-- + +CREATE TABLE public.deta_comp_materias ( + id_compra integer NOT NULL, + id_mat_prima integer NOT NULL, + cantidad numeric(12,3) NOT NULL, + pre_unitario numeric(12,2) NOT NULL, + CONSTRAINT deta_comp_materias_cantidad_check CHECK ((cantidad > (0)::numeric)), + CONSTRAINT deta_comp_materias_pre_unitario_check CHECK ((pre_unitario >= (0)::numeric)) +); + + +ALTER TABLE public.deta_comp_materias OWNER TO manso; + +-- +-- Name: deta_comp_producto; Type: TABLE; Schema: public; Owner: manso +-- + +CREATE TABLE public.deta_comp_producto ( + id_compra integer NOT NULL, + id_producto integer NOT NULL, + cantidad numeric(12,3) NOT NULL, + pre_unitario numeric(12,2) NOT NULL, + CONSTRAINT deta_comp_producto_cantidad_check CHECK ((cantidad > (0)::numeric)), + CONSTRAINT deta_comp_producto_pre_unitario_check CHECK ((pre_unitario >= (0)::numeric)) +); + + +ALTER TABLE public.deta_comp_producto OWNER TO manso; + +-- +-- Name: mate_primas; Type: TABLE; Schema: public; Owner: manso +-- + +CREATE TABLE public.mate_primas ( + id_mat_prima integer NOT NULL, + nombre text NOT NULL, + unidad text NOT NULL, + activo boolean DEFAULT true +); + + +ALTER TABLE public.mate_primas OWNER TO manso; + +-- +-- Name: mate_primas_id_mat_prima_seq; Type: SEQUENCE; Schema: public; Owner: manso +-- + +CREATE SEQUENCE public.mate_primas_id_mat_prima_seq + AS integer + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +ALTER SEQUENCE public.mate_primas_id_mat_prima_seq OWNER TO manso; + +-- +-- Name: mate_primas_id_mat_prima_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: manso +-- + +ALTER SEQUENCE public.mate_primas_id_mat_prima_seq OWNED BY public.mate_primas.id_mat_prima; + + +-- +-- Name: mesas_id_mesa_seq; Type: SEQUENCE; Schema: public; Owner: manso +-- + +CREATE SEQUENCE public.mesas_id_mesa_seq + AS integer + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +ALTER SEQUENCE public.mesas_id_mesa_seq OWNER TO manso; + +-- +-- Name: mesas_id_mesa_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: manso +-- + +ALTER SEQUENCE public.mesas_id_mesa_seq OWNED BY public.mesas.id_mesa; + + +-- +-- Name: productos; Type: TABLE; Schema: public; Owner: manso +-- + +CREATE TABLE public.productos ( + id_producto integer NOT NULL, + nombre text NOT NULL, + img_producto character varying(255) DEFAULT 'img/productos/img_producto.png'::character varying NOT NULL, + precio integer NOT NULL, + activo boolean DEFAULT true, + id_categoria integer NOT NULL, + CONSTRAINT productos_precio_check CHECK (((precio)::numeric >= (0)::numeric)), + CONSTRAINT productos_precio_nn CHECK ((precio >= 0)) +); + + +ALTER TABLE public.productos OWNER TO manso; + +-- +-- Name: productos_id_producto_seq; Type: SEQUENCE; Schema: public; Owner: manso +-- + +CREATE SEQUENCE public.productos_id_producto_seq + AS integer + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +ALTER SEQUENCE public.productos_id_producto_seq OWNER TO manso; + +-- +-- Name: productos_id_producto_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: manso +-- + +ALTER SEQUENCE public.productos_id_producto_seq OWNED BY public.productos.id_producto; + + +-- +-- Name: prov_mate_prima; Type: TABLE; Schema: public; Owner: manso +-- + +CREATE TABLE public.prov_mate_prima ( + id_proveedor integer NOT NULL, + id_mat_prima integer NOT NULL +); + + +ALTER TABLE public.prov_mate_prima OWNER TO manso; + +-- +-- Name: prov_producto; Type: TABLE; Schema: public; Owner: manso +-- + +CREATE TABLE public.prov_producto ( + id_proveedor integer NOT NULL, + id_producto integer NOT NULL +); + + +ALTER TABLE public.prov_producto OWNER TO manso; + +-- +-- Name: proveedores; Type: TABLE; Schema: public; Owner: manso +-- + +CREATE TABLE public.proveedores ( + id_proveedor integer NOT NULL, + rut text, + raz_social text NOT NULL, + direccion text, + contacto text +); + + +ALTER TABLE public.proveedores OWNER TO manso; + +-- +-- Name: proveedores_id_proveedor_seq; Type: SEQUENCE; Schema: public; Owner: manso +-- + +CREATE SEQUENCE public.proveedores_id_proveedor_seq + AS integer + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +ALTER SEQUENCE public.proveedores_id_proveedor_seq OWNER TO manso; + +-- +-- Name: proveedores_id_proveedor_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: manso +-- + +ALTER SEQUENCE public.proveedores_id_proveedor_seq OWNED BY public.proveedores.id_proveedor; + + +-- +-- Name: receta_producto; Type: TABLE; Schema: public; Owner: manso +-- + +CREATE TABLE public.receta_producto ( + id_producto integer NOT NULL, + id_mat_prima integer NOT NULL, + qty_por_unidad numeric(12,3) NOT NULL, + CONSTRAINT receta_producto_qty_por_unidad_check CHECK ((qty_por_unidad > (0)::numeric)) +); + + +ALTER TABLE public.receta_producto OWNER TO manso; + +-- +-- Name: roles; Type: TABLE; Schema: public; Owner: manso +-- + +CREATE TABLE public.roles ( + id_rol integer NOT NULL, + nombre text NOT NULL +); + + +ALTER TABLE public.roles OWNER TO manso; + +-- +-- Name: roles_id_rol_seq; Type: SEQUENCE; Schema: public; Owner: manso +-- + +CREATE SEQUENCE public.roles_id_rol_seq + AS integer + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +ALTER SEQUENCE public.roles_id_rol_seq OWNER TO manso; + +-- +-- Name: roles_id_rol_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: manso +-- + +ALTER SEQUENCE public.roles_id_rol_seq OWNED BY public.roles.id_rol; + + +-- +-- Name: usua_roles; Type: TABLE; Schema: public; Owner: manso +-- + +CREATE TABLE public.usua_roles ( + id_usuario integer NOT NULL, + id_rol integer NOT NULL, + fec_asignacion timestamp without time zone DEFAULT now(), + autor integer, + activo boolean DEFAULT true +); + + +ALTER TABLE public.usua_roles OWNER TO manso; + +-- +-- Name: usuarios_id_usuario_seq; Type: SEQUENCE; Schema: public; Owner: manso +-- + +CREATE SEQUENCE public.usuarios_id_usuario_seq + AS integer + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +ALTER SEQUENCE public.usuarios_id_usuario_seq OWNER TO manso; + +-- +-- Name: usuarios_id_usuario_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: manso +-- + +ALTER SEQUENCE public.usuarios_id_usuario_seq OWNED BY public.usuarios.id_usuario; + + +-- +-- Name: v_comandas_detalle_base; Type: VIEW; Schema: public; Owner: manso +-- + +CREATE VIEW public.v_comandas_detalle_base AS + SELECT c.id_comanda, + c.fec_creacion, + c.fec_cierre, + c.estado, + c.observaciones, + u.id_usuario, + u.nombre AS usuario_nombre, + u.apellido AS usuario_apellido, + m.id_mesa, + m.numero AS mesa_numero, + m.apodo AS mesa_apodo, + d.id_producto, + p.nombre AS producto_nombre, + d.cantidad, + d.pre_unitario, + (d.cantidad * d.pre_unitario) AS subtotal + FROM ((((public.comandas c + JOIN public.usuarios u ON ((u.id_usuario = c.id_usuario))) + JOIN public.mesas m ON ((m.id_mesa = c.id_mesa))) + LEFT JOIN public.deta_comandas d ON ((d.id_comanda = c.id_comanda))) + LEFT JOIN public.productos p ON ((p.id_producto = d.id_producto))); + + +ALTER VIEW public.v_comandas_detalle_base OWNER TO manso; + +-- +-- Name: v_comandas_detalle_items; Type: VIEW; Schema: public; Owner: manso +-- + +CREATE VIEW public.v_comandas_detalle_items AS + SELECT d.id_comanda, + d.id_det_comanda, + d.id_producto, + p.nombre AS producto_nombre, + d.cantidad, + d.pre_unitario, + (d.cantidad * d.pre_unitario) AS subtotal, + d.observaciones + FROM (public.deta_comandas d + JOIN public.productos p ON ((p.id_producto = d.id_producto))); + + +ALTER VIEW public.v_comandas_detalle_items OWNER TO manso; + +-- +-- Name: v_comandas_detalle_json; Type: VIEW; Schema: public; Owner: manso +-- + +CREATE VIEW public.v_comandas_detalle_json AS + SELECT id_comanda, + jsonb_build_object('id_comanda', id_comanda, 'fec_creacion', fec_creacion, 'fec_cierre', fec_cierre, 'estado', estado, 'observaciones', observaciones, 'usuario', jsonb_build_object('id_usuario', id_usuario, 'nombre', usuario_nombre, 'apellido', usuario_apellido), 'mesa', jsonb_build_object('id_mesa', id_mesa, 'numero', mesa_numero, 'apodo', mesa_apodo), 'items', COALESCE(( SELECT jsonb_agg(jsonb_build_object('producto_id', b.id_producto, 'producto', b.producto_nombre, 'cantidad', b.cantidad, 'pre_unitario', b.pre_unitario, 'subtotal', b.subtotal) ORDER BY b.producto_nombre) AS jsonb_agg + FROM public.v_comandas_detalle_base b + WHERE ((b.id_comanda = h.id_comanda) AND (b.id_producto IS NOT NULL))), '[]'::jsonb), 'totales', jsonb_build_object('items', COALESCE(( SELECT count(*) AS count + FROM public.v_comandas_detalle_base b + WHERE ((b.id_comanda = h.id_comanda) AND (b.id_producto IS NOT NULL))), (0)::bigint), 'total', COALESCE(( SELECT sum(b.subtotal) AS sum + FROM public.v_comandas_detalle_base b + WHERE (b.id_comanda = h.id_comanda)), (0)::numeric))) AS data + FROM ( SELECT DISTINCT v_comandas_detalle_base.id_comanda, + v_comandas_detalle_base.fec_creacion, + v_comandas_detalle_base.fec_cierre, + v_comandas_detalle_base.estado, + v_comandas_detalle_base.observaciones, + v_comandas_detalle_base.id_usuario, + v_comandas_detalle_base.usuario_nombre, + v_comandas_detalle_base.usuario_apellido, + v_comandas_detalle_base.id_mesa, + v_comandas_detalle_base.mesa_numero, + v_comandas_detalle_base.mesa_apodo + FROM public.v_comandas_detalle_base) h; + + +ALTER VIEW public.v_comandas_detalle_json OWNER TO manso; + +-- +-- Name: vw_compras; Type: VIEW; Schema: public; Owner: manso +-- + +CREATE VIEW public.vw_compras AS + SELECT c.id_compra, + c.id_proveedor, + p.raz_social AS proveedor, + c.fec_compra, + c.total + FROM (public.compras c + JOIN public.proveedores p USING (id_proveedor)) + ORDER BY c.fec_compra DESC, c.id_compra DESC; + + +ALTER VIEW public.vw_compras OWNER TO manso; + +-- +-- Name: vw_ticket_total; Type: VIEW; Schema: public; Owner: manso +-- + +CREATE VIEW public.vw_ticket_total AS + WITH lineas AS ( + SELECT c.id_comanda, + COALESCE(c.fec_cierre, (c.fec_creacion)::timestamp with time zone) AS fec_ticket, + (COALESCE(dc.pre_unitario, (p.precio)::numeric, (0)::numeric))::numeric(14,2) AS pu, + (COALESCE(dc.cantidad, (1)::numeric))::numeric(14,3) AS qty + FROM ((public.comandas c + JOIN public.deta_comandas dc ON ((dc.id_comanda = c.id_comanda))) + LEFT JOIN public.productos p ON ((p.id_producto = dc.id_producto))) + ) + SELECT id_comanda, + fec_ticket, + (sum((qty * pu)))::numeric(14,2) AS total + FROM lineas + GROUP BY id_comanda, fec_ticket; + + +ALTER VIEW public.vw_ticket_total OWNER TO manso; + +-- +-- Name: asistencia_intervalo id_intervalo; Type: DEFAULT; Schema: public; Owner: manso +-- + +ALTER TABLE ONLY public.asistencia_intervalo ALTER COLUMN id_intervalo SET DEFAULT nextval('public.asistencia_intervalo_id_intervalo_seq'::regclass); + + +-- +-- Name: asistencia_raw id_raw; Type: DEFAULT; Schema: public; Owner: manso +-- + +ALTER TABLE ONLY public.asistencia_raw ALTER COLUMN id_raw SET DEFAULT nextval('public.asistencia_raw_id_raw_seq'::regclass); + + +-- +-- Name: categorias id_categoria; Type: DEFAULT; Schema: public; Owner: manso +-- + +ALTER TABLE ONLY public.categorias ALTER COLUMN id_categoria SET DEFAULT nextval('public.categorias_id_categoria_seq'::regclass); + + +-- +-- Name: clientes id_cliente; Type: DEFAULT; Schema: public; Owner: manso +-- + +ALTER TABLE ONLY public.clientes ALTER COLUMN id_cliente SET DEFAULT nextval('public.clientes_id_cliente_seq'::regclass); + + +-- +-- Name: comandas id_comanda; Type: DEFAULT; Schema: public; Owner: manso +-- + +ALTER TABLE ONLY public.comandas ALTER COLUMN id_comanda SET DEFAULT nextval('public.comandas_id_comanda_seq'::regclass); + + +-- +-- Name: compras id_compra; Type: DEFAULT; Schema: public; Owner: manso +-- + +ALTER TABLE ONLY public.compras ALTER COLUMN id_compra SET DEFAULT nextval('public.compras_id_compra_seq'::regclass); + + +-- +-- Name: deta_comandas id_det_comanda; Type: DEFAULT; Schema: public; Owner: manso +-- + +ALTER TABLE ONLY public.deta_comandas ALTER COLUMN id_det_comanda SET DEFAULT nextval('public.deta_comandas_id_det_comanda_seq'::regclass); + + +-- +-- Name: mate_primas id_mat_prima; Type: DEFAULT; Schema: public; Owner: manso +-- + +ALTER TABLE ONLY public.mate_primas ALTER COLUMN id_mat_prima SET DEFAULT nextval('public.mate_primas_id_mat_prima_seq'::regclass); + + +-- +-- Name: mesas id_mesa; Type: DEFAULT; Schema: public; Owner: manso +-- + +ALTER TABLE ONLY public.mesas ALTER COLUMN id_mesa SET DEFAULT nextval('public.mesas_id_mesa_seq'::regclass); + + +-- +-- Name: productos id_producto; Type: DEFAULT; Schema: public; Owner: manso +-- + +ALTER TABLE ONLY public.productos ALTER COLUMN id_producto SET DEFAULT nextval('public.productos_id_producto_seq'::regclass); + + +-- +-- Name: proveedores id_proveedor; Type: DEFAULT; Schema: public; Owner: manso +-- + +ALTER TABLE ONLY public.proveedores ALTER COLUMN id_proveedor SET DEFAULT nextval('public.proveedores_id_proveedor_seq'::regclass); + + +-- +-- Name: roles id_rol; Type: DEFAULT; Schema: public; Owner: manso +-- + +ALTER TABLE ONLY public.roles ALTER COLUMN id_rol SET DEFAULT nextval('public.roles_id_rol_seq'::regclass); + + +-- +-- Name: usuarios id_usuario; Type: DEFAULT; Schema: public; Owner: manso +-- + +ALTER TABLE ONLY public.usuarios ALTER COLUMN id_usuario SET DEFAULT nextval('public.usuarios_id_usuario_seq'::regclass); + + +-- +-- Data for Name: asistencia_intervalo; Type: TABLE DATA; Schema: public; Owner: manso +-- + +COPY public.asistencia_intervalo (id_intervalo, id_usuario, fecha, desde, hasta, dur_min, origen, created_at) FROM stdin; +83 1 2025-08-29 2025-08-30 01:19:38+00 2025-08-30 01:26:19+00 6.68 delete_adjust 2025-08-30 04:42:43.597798+00 +84 1 2025-08-29 2025-08-30 02:30:00+00 2025-08-30 02:46:40+00 16.67 delete_adjust 2025-08-30 04:42:43.597798+00 +85 1 2025-08-30 2025-08-30 03:13:31+00 2025-08-30 03:36:03+00 22.53 delete_adjust 2025-08-30 04:42:43.597798+00 +86 1 2025-08-30 2025-08-30 04:10:00+00 2025-08-30 04:12:00+00 2.00 delete_adjust 2025-08-30 04:42:43.597798+00 +87 1 2025-08-30 2025-08-30 04:24:08+00 2025-08-30 04:38:56+00 14.80 delete_adjust 2025-08-30 04:42:43.597798+00 +88 1 2025-08-30 2025-08-30 05:01:55+00 2025-08-30 05:10:00+00 8.08 delete_adjust 2025-08-30 04:42:43.597798+00 +89 1 2025-08-27 2025-08-27 04:34:55+00 2025-08-27 06:35:08+00 120.22 AGL_001.txt 2025-08-30 04:43:13.749738+00 +90 1 2025-08-29 2025-08-30 00:12:34+00 2025-08-30 00:47:24+00 34.83 AGL_001.txt 2025-08-30 04:43:13.749738+00 +97 2 2025-01-02 2025-01-02 12:12:00+00 2025-01-02 14:48:00+00 156.00 manual_form 2025-08-30 04:45:59.234439+00 +99 2 2025-01-02 2025-01-02 20:50:00+00 2025-01-03 02:48:00+00 358.00 manual_form 2025-08-30 04:46:45.672304+00 +108 1 2025-01-01 2025-01-01 10:00:00+00 2025-01-01 16:00:00+00 360.00 manual_form 2025-09-01 21:03:36.046072+00 +\. + + +-- +-- Data for Name: asistencia_raw; Type: TABLE DATA; Schema: public; Owner: manso +-- + +COPY public.asistencia_raw (id_raw, id_usuario, ts, modo, origen, created_at) FROM stdin; +1 1 2025-08-30 05:10:00+00 OUT manual_form 2025-08-30 04:11:08.227836+00 +2 1 2025-08-30 04:10:00+00 IN manual_form 2025-08-30 04:11:08.227836+00 +3 1 2025-08-30 04:12:00+00 OUT manual_form 2025-08-30 04:12:30.456958+00 +4 1 2025-08-30 02:30:00+00 IN manual_form 2025-08-30 04:12:30.456958+00 +22 1 2025-08-30 01:19:38+00 IN AGL_001.txt 2025-08-30 04:32:09.754679+00 +21 1 2025-08-30 01:26:19+00 OUT AGL_001.txt 2025-08-30 04:32:09.754679+00 +20 1 2025-08-30 02:46:40+00 OUT AGL_001.txt 2025-08-30 04:32:09.754679+00 +19 1 2025-08-30 03:13:31+00 IN AGL_001.txt 2025-08-30 04:32:09.754679+00 +18 1 2025-08-30 03:36:03+00 OUT AGL_001.txt 2025-08-30 04:32:09.754679+00 +17 1 2025-08-30 04:38:56+00 OUT AGL_001.txt 2025-08-30 04:32:09.754679+00 +16 1 2025-08-30 05:01:55+00 IN AGL_001.txt 2025-08-30 04:32:09.754679+00 +15 1 2025-08-30 04:24:08+00 IN AGL_001.txt 2025-08-30 04:32:09.754679+00 +71 2 2025-01-02 14:48:00+00 OUT manual_form 2025-08-30 04:45:59.234439+00 +72 2 2025-01-02 12:12:00+00 IN manual_form 2025-08-30 04:45:59.234439+00 +73 2 2025-01-03 02:48:00+00 OUT manual_form 2025-08-30 04:46:45.672304+00 +74 2 2025-01-02 20:50:00+00 IN manual_form 2025-08-30 04:46:45.672304+00 +60 1 2025-08-27 04:34:55+00 IN AGL_001.txt 2025-08-30 04:43:13.749738+00 +59 1 2025-08-27 06:35:08+00 OUT AGL_001.txt 2025-08-30 04:43:13.749738+00 +70 1 2025-08-30 00:12:34+00 IN AGL_001.txt 2025-08-30 04:43:13.749738+00 +69 1 2025-08-30 00:47:24+00 OUT AGL_001.txt 2025-08-30 04:43:13.749738+00 +87 1 2025-01-01 16:00:00+00 OUT manual_form 2025-09-01 21:03:36.046072+00 +88 1 2025-01-01 10:00:00+00 IN manual_form 2025-09-01 21:03:36.046072+00 +\. + + +-- +-- Data for Name: categorias; Type: TABLE DATA; Schema: public; Owner: manso +-- + +COPY public.categorias (id_categoria, nombre, visible) FROM stdin; +1 Cafetería t +2 Café t +3 Bar t +4 Tragos y Refrescos t +\. + + +-- +-- Data for Name: clientes; Type: TABLE DATA; Schema: public; Owner: manso +-- + +COPY public.clientes (id_cliente, nombre, correo, telefono, fec_nacimiento, activo) FROM stdin; +1 Familia \N \N \N t +\. + + +-- +-- Data for Name: comandas; Type: TABLE DATA; Schema: public; Owner: manso +-- + +COPY public.comandas (id_comanda, id_usuario, id_mesa, fec_creacion, estado, observaciones, fec_cierre) FROM stdin; +2 3 14 2025-08-25 18:47:57.398972 cerrada \N \N +49 1 14 2025-08-29 16:53:42.617246 cerrada Auto blanco 2025-08-29 16:56:44.184737+00 +4 1 1 2025-08-25 19:07:51.695426 cerrada Pedido para una familia grande. \N +3 1 1 2025-08-25 19:07:37.584356 cerrada Pedido para una familia grande. \N +1 3 14 2025-08-25 18:46:18.834688 cerrada \N \N +48 3 14 2025-08-29 16:53:06.77149 cerrada Grande huevo 2025-08-29 16:56:45.225257+00 +47 3 14 2025-08-29 16:52:22.556665 cerrada Grande huevo 2025-08-29 16:56:46.440835+00 +8 3 14 2025-08-29 02:57:22.46956 cerrada \N 2025-08-29 03:47:50.750949+00 +7 1 14 2025-08-29 02:56:29.755449 cerrada \N 2025-08-29 04:31:33.254769+00 +6 2 6 2025-08-25 19:09:25.280339 cerrada \N 2025-08-29 04:32:29.813119+00 +46 3 14 2025-08-29 16:51:23.011327 cerrada Grande huevo 2025-08-29 16:56:47.457248+00 +5 1 1 2025-08-25 19:08:08.596438 cerrada \N 2025-08-29 04:35:24.52745+00 +10 1 3 2025-08-29 04:41:51.354916 cerrada \N 2025-08-29 04:48:01.548441+00 +12 3 14 2025-08-29 04:48:53.292023 cerrada \N 2025-08-29 05:17:41.133298+00 +19 3 14 2025-08-29 05:49:56.258621 cerrada \N 2025-08-29 05:50:10.286428+00 +18 3 14 2025-08-29 05:49:13.120391 cerrada \N 2025-08-29 05:50:11.319667+00 +17 3 14 2025-08-29 05:46:54.548073 cerrada oBSERVACIOOONNN 2025-08-29 05:50:12.331972+00 +16 2 8 2025-08-29 05:46:23.763257 cerrada oBSERVACIOOONNN 2025-08-29 05:50:13.37213+00 +15 2 8 2025-08-29 05:46:18.69042 cerrada oBSERVACIOOONNN 2025-08-29 05:50:14.334797+00 +14 3 14 2025-08-29 05:19:28.908216 cerrada jdwkjklqwndv 2025-08-29 05:50:15.26329+00 +13 3 14 2025-08-29 04:58:36.159791 cerrada hola 2025-08-29 05:50:16.511989+00 +11 1 4 2025-08-29 04:48:36.541902 cerrada \N 2025-08-29 05:50:17.832327+00 +9 1 1 2025-08-29 04:37:56.310221 cerrada Olaaa 2025-08-29 05:50:19.084017+00 +20 3 14 2025-08-29 05:50:25.106438 cerrada \N 2025-08-29 05:50:30.321838+00 +29 2 6 2025-08-29 06:06:44.812528 cerrada \N 2025-08-29 06:08:16.181067+00 +28 3 14 2025-08-29 06:06:13.297627 cerrada \N 2025-08-29 06:08:17.69094+00 +27 1 1 2025-08-29 06:03:10.757812 cerrada \N 2025-08-29 06:08:18.986658+00 +26 1 8 2025-08-29 06:02:25.460776 cerrada \N 2025-08-29 06:08:20.296605+00 +25 1 8 2025-08-29 06:01:26.571144 cerrada Sin gluten 2025-08-29 06:08:22.898867+00 +24 1 1 2025-08-29 05:58:17.922202 cerrada \N 2025-08-29 06:08:24.187839+00 +23 1 1 2025-08-29 05:57:35.418369 cerrada Observacionesssqaishfoiadhfohsdf 2025-08-29 06:08:25.558933+00 +22 3 14 2025-08-29 05:54:44.675905 cerrada \N 2025-08-29 06:08:27.55245+00 +21 3 14 2025-08-29 05:51:54.451937 cerrada \N 2025-08-29 06:08:29.094665+00 +31 1 14 2025-08-29 16:10:31.619565 cerrada TestObs,.-.-. 2025-08-29 16:13:11.693159+00 +30 1 4 2025-08-29 14:46:00.062522 cerrada 123 2025-08-29 16:13:14.63311+00 +52 1 12 2025-08-29 16:55:21.360321 cerrada grande el huevo 2025-08-29 16:56:40.145463+00 +51 1 14 2025-08-29 16:54:44.410546 cerrada auto blanco 2025-08-29 16:56:41.279442+00 +50 1 14 2025-08-29 16:54:08.307324 cerrada auto blanco 2025-08-29 16:56:42.968122+00 +45 3 14 2025-08-29 16:51:08.371592 cerrada Grande huevo 2025-08-29 16:56:48.831883+00 +44 3 14 2025-08-29 16:50:54.483409 cerrada Grande huevo 2025-08-29 16:56:49.85665+00 +43 1 14 2025-08-29 16:50:11.083248 cerrada Grande huevo 2025-08-29 16:56:51.165809+00 +41 1 13 2025-08-29 16:27:13.691181 cerrada Mesa normal 2025-08-29 16:56:52.301133+00 +42 3 12 2025-08-29 16:27:47.766143 cerrada Mesa normal 2025-08-29 16:56:53.405482+00 +40 1 14 2025-08-29 16:26:43.787724 cerrada Observación huevo 2025-08-29 16:56:54.62857+00 +39 3 14 2025-08-29 16:26:09.754868 cerrada Observación del huevo 2025-08-29 16:56:55.865184+00 +38 3 14 2025-08-29 16:25:43.366429 cerrada Observación del huevo 2025-08-29 16:56:57.13223+00 +37 1 14 2025-08-29 16:25:01.413168 cerrada Observación del huevo 2025-08-29 16:56:58.590021+00 +36 1 14 2025-08-29 16:24:23.587871 cerrada Observación del huevo 2025-08-29 16:56:59.863697+00 +35 3 14 2025-08-29 16:23:33.26689 cerrada Observación del huevo 2025-08-29 16:57:01.082691+00 +34 3 14 2025-08-29 16:23:27.385911 cerrada Observación del huevo 2025-08-29 16:57:02.429882+00 +33 1 5 2025-08-29 16:16:28.527735 cerrada Esta es una observación 2025-08-29 16:57:03.761533+00 +32 1 5 2025-08-29 16:15:54.377397 cerrada Esta es una observación 2025-08-29 16:57:05.939418+00 +53 3 8 2025-09-01 20:21:27.553491 abierta \N \N +54 3 14 2025-09-01 21:16:30.760241 abierta Ana \N +55 3 11 2025-09-02 00:22:18.600045 abierta \N \N +\. + + +-- +-- Data for Name: compras; Type: TABLE DATA; Schema: public; Owner: manso +-- + +COPY public.compras (id_compra, id_proveedor, fec_compra, total) FROM stdin; +36 4 2025-07-29 08:02:33.489822 3806.00 +37 1 2025-06-28 06:27:33.489822 2944.00 +38 2 2025-08-22 22:23:33.489822 2866.00 +39 2 2025-07-27 10:50:33.489822 7774.00 +40 4 2025-08-03 21:28:33.489822 2373.00 +41 1 2025-06-13 08:23:33.489822 1556.00 +42 4 2025-07-29 02:30:33.489822 5941.00 +43 4 2025-07-27 04:39:33.489822 3570.00 +44 4 2025-07-10 04:43:33.489822 2648.00 +45 1 2025-07-05 00:59:33.489822 11349.00 +46 3 2025-07-04 18:17:33.489822 1671.00 +47 3 2025-07-11 02:42:33.489822 2423.00 +48 3 2025-07-16 14:47:33.489822 7851.00 +49 3 2025-07-21 01:24:33.489822 4888.00 +50 1 2025-06-05 13:46:33.489822 4985.00 +51 3 2025-08-02 20:32:33.489822 144.00 +52 1 2025-07-27 10:08:33.489822 1840.00 +53 2 2025-08-22 08:01:33.489822 3398.00 +55 2 2025-07-14 10:16:33.489822 632.00 +56 2 2025-07-14 20:17:33.489822 6882.00 +57 1 2025-06-17 15:01:33.489822 2974.00 +58 2 2025-06-19 15:29:33.489822 2644.00 +60 2 2025-06-09 15:01:33.489822 1436.00 +61 3 2025-08-09 00:54:33.489822 6453.00 +62 1 2025-08-26 16:17:33.489822 5450.00 +63 1 2025-08-05 08:39:33.489822 8873.00 +64 1 2025-07-19 12:38:33.489822 4093.00 +65 1 2025-06-23 09:31:33.489822 666.00 +59 1 2025-08-29 16:42:00 3248.00 +54 2 2025-08-26 04:37:00 2483.00 +\. + + +-- +-- Data for Name: deta_comandas; Type: TABLE DATA; Schema: public; Owner: manso +-- + +COPY public.deta_comandas (id_det_comanda, id_comanda, id_producto, cantidad, pre_unitario, observaciones) FROM stdin; +1 1 52 4.000 130.00 \N +2 2 40 1.000 250.00 \N +3 2 49 1.000 230.00 \N +4 2 24 3.000 100.00 \N +5 6 51 5.000 230.00 \N +6 6 33 2.000 550.00 \N +7 7 52 3.000 130.00 \N +8 8 33 3.000 550.00 \N +9 9 37 3.000 320.00 \N +10 10 52 1.000 130.00 \N +11 11 47 3.000 280.00 \N +12 11 43 1.000 230.00 \N +13 11 45 1.000 280.00 \N +14 11 44 1.000 150.00 \N +15 11 24 2.000 100.00 \N +16 12 28 1.000 100.00 \N +17 12 31 13.000 100.00 \N +18 13 51 2.000 230.00 \N +19 13 52 2.000 130.00 \N +20 14 52 1.000 130.00 \N +21 15 52 1.000 130.00 \N +22 15 51 1.000 230.00 \N +23 16 51 1.000 230.00 \N +24 16 52 1.000 130.00 \N +25 17 46 1.000 150.00 \N +26 18 47 1.000 280.00 \N +27 19 51 1.000 230.00 \N +28 19 48 1.000 230.00 \N +29 20 52 1.000 130.00 \N +30 20 49 1.000 230.00 \N +31 20 51 1.000 230.00 \N +32 22 49 1.000 230.00 \N +33 23 52 1.000 130.00 \N +34 23 48 1.000 230.00 \N +35 23 51 1.000 230.00 \N +36 23 50 1.000 230.00 \N +37 24 52 1.000 130.00 \N +38 24 51 1.000 230.00 \N +39 25 47 1.000 280.00 \N +40 25 49 1.000 230.00 \N +41 25 50 1.000 230.00 \N +42 26 50 1.000 230.00 \N +43 26 49 1.000 230.00 \N +44 27 50 3.000 230.00 \N +45 27 49 3.000 230.00 \N +46 28 51 1.000 230.00 \N +47 28 52 1.000 130.00 \N +48 28 45 1.000 280.00 \N +49 28 50 1.000 230.00 \N +50 29 48 2.000 230.00 \N +51 29 49 6.000 230.00 \N +52 29 47 2.000 280.00 \N +54 30 20 3.000 140.00 \N +53 30 16 1.000 100.00 \N +55 30 17 1.000 150.00 \N +56 31 51 1.000 230.00 \N +57 31 23 1.000 180.00 \N +58 31 52 2.000 140.00 \N +59 31 33 1.000 550.00 \N +60 33 9 2.000 120.00 \N +61 33 18 1.000 80.00 \N +62 33 2 1.000 60.00 \N +63 34 3 1.000 60.00 \N +64 34 21 1.000 180.00 \N +65 34 33 1.000 550.00 \N +66 34 23 2.000 180.00 \N +67 36 33 1.000 550.00 \N +68 36 49 1.000 230.00 \N +69 36 3 1.000 60.00 \N +70 36 6 1.000 50.00 \N +71 36 11 1.000 120.00 \N +72 37 3 1.000 60.00 \N +73 37 6 1.000 50.00 \N +74 37 49 1.000 230.00 \N +75 38 38 4.000 250.00 \N +76 38 33 1.000 550.00 \N +77 38 50 1.000 230.00 \N +78 38 10 1.000 120.00 \N +79 38 52 1.000 140.00 \N +80 38 9 1.000 120.00 \N +81 39 33 1.000 550.00 \N +82 39 38 4.000 250.00 \N +83 40 52 1.000 140.00 \N +84 40 29 1.000 80.00 \N +85 40 46 1.000 150.00 \N +86 40 49 1.000 230.00 \N +87 40 10 1.000 120.00 \N +88 40 11 6.000 120.00 \N +89 41 4 1.000 250.00 \N +90 41 8 1.000 120.00 \N +91 42 46 1.000 150.00 \N +92 42 49 1.000 230.00 \N +93 42 44 1.000 150.00 \N +94 43 33 1.000 550.00 \N +95 43 25 1.000 180.00 \N +96 43 16 1.000 100.00 \N +97 43 24 1.000 100.00 \N +98 43 47 2.000 280.00 \N +99 44 46 1.000 150.00 \N +100 45 46 1.000 150.00 \N +101 46 46 1.000 150.00 \N +102 47 51 1.000 230.00 \N +103 48 50 1.000 230.00 \N +104 49 47 1.000 280.00 \N +105 49 33 2.000 550.00 \N +106 51 33 1.000 550.00 \N +107 51 47 1.000 280.00 \N +108 52 24 1.000 100.00 \N +109 52 25 1.000 180.00 \N +110 52 16 1.000 100.00 \N +111 53 52 10.000 130.00 \N +112 53 23 11.000 180.00 \N +113 53 33 10.000 550.00 \N +114 54 50 2.000 230.00 \N +115 54 52 2.000 130.00 \N +116 55 33 2.000 550.00 \N +117 55 52 2.000 130.00 \N +\. + + +-- +-- Data for Name: deta_comp_materias; Type: TABLE DATA; Schema: public; Owner: manso +-- + +COPY public.deta_comp_materias (id_compra, id_mat_prima, cantidad, pre_unitario) FROM stdin; +36 5 1.019 322.00 +36 9 1.370 80.00 +36 10 5.755 232.00 +38 3 7.311 392.00 +39 1 6.840 447.00 +39 10 7.533 397.00 +40 9 2.636 283.00 +40 8 8.262 151.00 +41 7 3.794 410.00 +43 3 5.017 293.00 +44 4 8.652 306.00 +45 6 9.781 426.00 +46 7 3.017 422.00 +48 1 0.735 511.00 +48 3 5.249 373.00 +49 1 9.816 498.00 +50 1 2.517 459.00 +50 7 2.785 239.00 +50 2 3.788 392.00 +52 6 7.542 244.00 +53 3 4.332 522.00 +55 6 4.937 128.00 +56 5 6.062 45.00 +57 9 1.806 465.00 +57 5 4.283 275.00 +57 7 1.861 402.00 +57 3 4.002 52.00 +58 1 3.379 471.00 +60 2 0.588 216.00 +61 10 13.011 327.00 +62 10 4.719 405.00 +62 9 4.646 295.00 +62 1 3.242 338.00 +63 6 8.843 457.00 +63 7 6.086 270.00 +63 8 6.852 394.00 +63 1 1.678 147.00 +65 1 4.788 139.00 +59 9 1.000 590.00 +54 3 2.000 157.00 +54 6 2.000 376.00 +54 9 4.000 67.00 +\. + + +-- +-- Data for Name: deta_comp_producto; Type: TABLE DATA; Schema: public; Owner: manso +-- + +COPY public.deta_comp_producto (id_compra, id_producto, cantidad, pre_unitario) FROM stdin; +36 31 8.500 35.00 +36 33 8.224 211.00 +37 19 2.839 238.00 +37 51 5.961 162.00 +37 36 8.570 152.00 +39 12 2.329 515.00 +39 2 2.859 184.00 +40 13 1.521 63.00 +40 2 2.897 98.00 +42 50 8.152 516.00 +42 15 8.067 215.00 +43 26 2.209 314.00 +43 14 5.892 179.00 +43 33 2.023 174.00 +45 40 9.248 434.00 +45 27 5.979 530.00 +46 46 2.919 87.00 +46 32 0.658 219.00 +47 17 7.792 225.00 +47 34 2.923 229.00 +48 2 8.937 268.00 +48 14 8.415 371.00 +50 9 1.573 516.00 +50 14 1.940 447.00 +51 41 1.598 90.00 +53 40 1.525 416.00 +53 38 2.629 191.00 +56 1 5.752 412.00 +56 42 9.845 378.00 +56 7 2.073 250.00 +58 14 6.745 156.00 +60 34 9.698 135.00 +61 26 3.828 460.00 +61 22 1.325 330.00 +62 8 8.369 105.00 +62 27 0.812 238.00 +63 13 0.730 332.00 +64 46 9.524 257.00 +64 22 1.355 431.00 +64 47 6.969 99.00 +64 43 2.123 175.00 +59 39 6.000 443.00 +54 16 3.000 383.00 +\. + + +-- +-- Data for Name: mate_primas; Type: TABLE DATA; Schema: public; Owner: manso +-- + +COPY public.mate_primas (id_mat_prima, nombre, unidad, activo) FROM stdin; +2 Huevo u t +3 Harina gr t +4 Avena gr t +1 Capsulas u t +9 Queso Azul San Ignacio gr t +10 Panceta gr t +8 Sal gr t +7 Sobres de Azucar u t +6 Azucar gr t +5 Bondiola cocida gr t +\. + + +-- +-- Data for Name: mesas; Type: TABLE DATA; Schema: public; Owner: manso +-- + +COPY public.mesas (id_mesa, numero, apodo, estado) FROM stdin; +1 1 Living princial libre +2 2 Ventanal izquierdo libre +3 3 Primer mesa contra la baranda libre +4 4 Ventanal derecho libre +5 5 Segunda mesa contra la baranda libre +6 6 Junto a Juana libre +7 7 Mostrador/Barra libre +8 8 Booth derecho libre +9 9 Booth izquierdo libre +10 10 Living secundario libre +11 11 Zona del Pool libre +12 12 Cowork libre +13 13 Mesa del fuego libre +14 14 Takeaway libre +\. + + +-- +-- Data for Name: productos; Type: TABLE DATA; Schema: public; Owner: manso +-- + +COPY public.productos (id_producto, nombre, img_producto, precio, activo, id_categoria) FROM stdin; +17 Cheesecake img/productos/img_producto.png 250 t 1 +2 Alfajores de Maicena img/productos/img_producto.png 60 t 1 +3 Alfajores sin TACC img/productos/img_producto.png 60 t 1 +4 Sandwich tostado LyC img/productos/img_producto.png 250 t 1 +5 Sandwich tostado JyQ img/productos/img_producto.png 250 t 1 +6 Scones img/productos/img_producto.png 50 t 1 +7 Medialunas img/productos/img_producto.png 80 t 1 +8 Medialunas Rellenas img/productos/img_producto.png 120 t 1 +9 Cookies de Avena img/productos/img_producto.png 120 t 1 +10 Cookies de chocolate img/productos/img_producto.png 120 t 1 +11 Brownies con helado img/productos/img_producto.png 120 t 1 +12 Budín de banana img/productos/img_producto.png 150 t 1 +13 Budín de naranja img/productos/img_producto.png 150 t 1 +14 Tostadas img/productos/img_producto.png 100 t 1 +15 Tarteletas img/productos/img_producto.png 150 t 1 +16 Chocobomba img/productos/img_producto.png 100 t 1 +18 Carajillo Oriental img/productos/img_producto.png 80 t 2 +20 Latte img/productos/img_producto.png 140 t 2 +21 Latte de DDL img/productos/img_producto.png 180 t 2 +22 Latte de chocolate semi amargo img/productos/img_producto.png 180 t 2 +23 Latte de vainilla img/productos/img_producto.png 180 t 2 +24 Expresso img/productos/img_producto.png 100 t 2 +25 Expresso doble img/productos/img_producto.png 180 t 2 +26 Cortado img/productos/img_producto.png 100 t 2 +27 Lágrima img/productos/img_producto.png 100 t 2 +28 Americano img/productos/img_producto.png 100 t 2 +29 Té img/productos/img_producto.png 80 t 2 +30 Té con leche img/productos/img_producto.png 100 t 2 +31 Submarino img/productos/img_producto.png 100 t 2 +32 Muzzarela clásica img/productos/img_producto.png 450 t 3 +34 Margarita img/productos/img_producto.png 500 t 3 +35 Calzone img/productos/img_producto.png 450 t 3 +36 Fritas img/productos/img_producto.png 250 t 3 +37 Aros de cebolla img/productos/img_producto.png 320 t 3 +38 Papas con cheddar img/productos/img_producto.png 250 t 3 +41 Tostones img/productos/img_producto.png 250 t 3 +42 Corona chica img/productos/img_producto.png 150 t 4 +43 Corona grande img/productos/img_producto.png 230 t 4 +44 Patricia Dunkel (lata) img/productos/img_producto.png 150 t 4 +45 Patricia Dunkel (grande) img/productos/img_producto.png 280 t 4 +46 Zillertal (lata) img/productos/img_producto.png 150 t 4 +47 Zillertal (grande) img/productos/img_producto.png 280 t 4 +48 Patagonia Weisse img/productos/img_producto.png 230 t 4 +19 Cappuccino img/productos/img_producto.png 140 t 2 +50 Patagonia Bohemian img/productos/img_producto.png 230 t 4 +49 Patagonia 24.7 img/productos/img_producto.png 230 t 4 +1 Desayuno americano para dos img/productos/img_producto.png 800 t 1 +51 Patagonia Amber Lager img/productos/img_producto.png 240 t 4 +33 Una Vaina Bien img/productos/img_producto.png 550 t 3 +40 Tequeños img/productos/img_producto.png 250 f 3 +52 Monster img/productos/img_producto.png 140 t 4 +39 Pastelitos img/productos/img_producto.png 250 t 3 +\. + + +-- +-- Data for Name: prov_mate_prima; Type: TABLE DATA; Schema: public; Owner: manso +-- + +COPY public.prov_mate_prima (id_proveedor, id_mat_prima) FROM stdin; +3 2 +2 3 +2 4 +3 1 +2 9 +3 10 +2 8 +3 7 +2 6 +3 5 +\. + + +-- +-- Data for Name: prov_producto; Type: TABLE DATA; Schema: public; Owner: manso +-- + +COPY public.prov_producto (id_proveedor, id_producto) FROM stdin; +\. + + +-- +-- Data for Name: proveedores; Type: TABLE DATA; Schema: public; Owner: manso +-- + +COPY public.proveedores (id_proveedor, rut, raz_social, direccion, contacto) FROM stdin; +1 217795000011 Emilupe S.R.L. \N 091049216 +2 216450470015 Finesa Trading S.A. \N 094426877 +3 \N Otro \N \N +4 \N Gara Gardo S en C \N \N +\. + + +-- +-- Data for Name: receta_producto; Type: TABLE DATA; Schema: public; Owner: manso +-- + +COPY public.receta_producto (id_producto, id_mat_prima, qty_por_unidad) FROM stdin; +28 1 1.000 +19 1 1.000 +50 7 1.000 +49 8 1.000 +17 2 2.000 +17 3 21.500 +1 2 1.000 +1 10 10.000 +51 6 1.000 +33 3 60.000 +33 2 25.000 +52 5 1.000 +39 10 1.000 +39 3 1.000 +\. + + +-- +-- Data for Name: roles; Type: TABLE DATA; Schema: public; Owner: manso +-- + +COPY public.roles (id_rol, nombre) FROM stdin; +1 Dueño +2 Cocinero +3 Barista +4 Barman +5 Bachero +6 Mozo +\. + + +-- +-- Data for Name: usua_roles; Type: TABLE DATA; Schema: public; Owner: manso +-- + +COPY public.usua_roles (id_usuario, id_rol, fec_asignacion, autor, activo) FROM stdin; +1 1 2025-08-25 14:39:39.204513 1 t +1 3 2025-08-25 14:39:39.204513 1 t +2 3 2025-08-25 14:39:39.204513 1 t +2 4 2025-08-25 14:39:39.204513 1 t +3 2 2025-08-25 14:39:39.204513 1 t +\. + + +-- +-- Data for Name: usuarios; Type: TABLE DATA; Schema: public; Owner: manso +-- + +COPY public.usuarios (id_usuario, documento, img_perfil, nombre, apellido, correo, telefono, fec_nacimiento, activo) FROM stdin; +1 52809684 img_perfil.png Mateo Saldain mateosaldain02@gmail.com \N 2002-08-11 t +2 55683627 img_perfil.png Cristopher Moreno \N \N 2001-08-11 t +3 49953084 img_perfil.png Bruno Correa \N \N 1999-08-19 t +\. + + +-- +-- Name: asistencia_intervalo_id_intervalo_seq; Type: SEQUENCE SET; Schema: public; Owner: manso +-- + +SELECT pg_catalog.setval('public.asistencia_intervalo_id_intervalo_seq', 108, true); + + +-- +-- Name: asistencia_raw_id_raw_seq; Type: SEQUENCE SET; Schema: public; Owner: manso +-- + +SELECT pg_catalog.setval('public.asistencia_raw_id_raw_seq', 88, true); + + +-- +-- Name: categorias_id_categoria_seq; Type: SEQUENCE SET; Schema: public; Owner: manso +-- + +SELECT pg_catalog.setval('public.categorias_id_categoria_seq', 4, true); + + +-- +-- Name: clientes_id_cliente_seq; Type: SEQUENCE SET; Schema: public; Owner: manso +-- + +SELECT pg_catalog.setval('public.clientes_id_cliente_seq', 1, true); + + +-- +-- Name: comandas_id_comanda_seq; Type: SEQUENCE SET; Schema: public; Owner: manso +-- + +SELECT pg_catalog.setval('public.comandas_id_comanda_seq', 55, true); + + +-- +-- Name: compras_id_compra_seq; Type: SEQUENCE SET; Schema: public; Owner: manso +-- + +SELECT pg_catalog.setval('public.compras_id_compra_seq', 66, true); + + +-- +-- Name: deta_comandas_id_det_comanda_seq; Type: SEQUENCE SET; Schema: public; Owner: manso +-- + +SELECT pg_catalog.setval('public.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('public.mate_primas_id_mat_prima_seq', 10, true); + + +-- +-- Name: mesas_id_mesa_seq; Type: SEQUENCE SET; Schema: public; Owner: manso +-- + +SELECT pg_catalog.setval('public.mesas_id_mesa_seq', 14, true); + + +-- +-- Name: productos_id_producto_seq; Type: SEQUENCE SET; Schema: public; Owner: manso +-- + +SELECT pg_catalog.setval('public.productos_id_producto_seq', 52, true); + + +-- +-- Name: proveedores_id_proveedor_seq; Type: SEQUENCE SET; Schema: public; Owner: manso +-- + +SELECT pg_catalog.setval('public.proveedores_id_proveedor_seq', 4, true); + + +-- +-- Name: roles_id_rol_seq; Type: SEQUENCE SET; Schema: public; Owner: manso +-- + +SELECT pg_catalog.setval('public.roles_id_rol_seq', 6, true); + + +-- +-- Name: usuarios_id_usuario_seq; Type: SEQUENCE SET; Schema: public; Owner: manso +-- + +SELECT pg_catalog.setval('public.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 public.asistencia_intervalo + ADD CONSTRAINT asistencia_intervalo_id_usuario_desde_hasta_key UNIQUE (id_usuario, desde, hasta); + + +-- +-- Name: asistencia_intervalo asistencia_intervalo_pkey; Type: CONSTRAINT; Schema: public; Owner: manso +-- + +ALTER TABLE ONLY public.asistencia_intervalo + ADD CONSTRAINT asistencia_intervalo_pkey PRIMARY KEY (id_intervalo); + + +-- +-- Name: asistencia_raw asistencia_raw_id_usuario_ts_key; Type: CONSTRAINT; Schema: public; Owner: manso +-- + +ALTER TABLE ONLY public.asistencia_raw + ADD CONSTRAINT asistencia_raw_id_usuario_ts_key UNIQUE (id_usuario, ts); + + +-- +-- Name: asistencia_raw asistencia_raw_pkey; Type: CONSTRAINT; Schema: public; Owner: manso +-- + +ALTER TABLE ONLY public.asistencia_raw + ADD CONSTRAINT asistencia_raw_pkey PRIMARY KEY (id_raw); + + +-- +-- Name: categorias categorias_nombre_key; Type: CONSTRAINT; Schema: public; Owner: manso +-- + +ALTER TABLE ONLY public.categorias + ADD CONSTRAINT categorias_nombre_key UNIQUE (nombre); + + +-- +-- Name: categorias categorias_pkey; Type: CONSTRAINT; Schema: public; Owner: manso +-- + +ALTER TABLE ONLY public.categorias + ADD CONSTRAINT categorias_pkey PRIMARY KEY (id_categoria); + + +-- +-- Name: clientes clientes_correo_key; Type: CONSTRAINT; Schema: public; Owner: manso +-- + +ALTER TABLE ONLY public.clientes + ADD CONSTRAINT clientes_correo_key UNIQUE (correo); + + +-- +-- Name: clientes clientes_pkey; Type: CONSTRAINT; Schema: public; Owner: manso +-- + +ALTER TABLE ONLY public.clientes + ADD CONSTRAINT clientes_pkey PRIMARY KEY (id_cliente); + + +-- +-- Name: clientes clientes_telefono_key; Type: CONSTRAINT; Schema: public; Owner: manso +-- + +ALTER TABLE ONLY public.clientes + ADD CONSTRAINT clientes_telefono_key UNIQUE (telefono); + + +-- +-- Name: comandas comandas_pkey; Type: CONSTRAINT; Schema: public; Owner: manso +-- + +ALTER TABLE ONLY public.comandas + ADD CONSTRAINT comandas_pkey PRIMARY KEY (id_comanda); + + +-- +-- Name: compras compras_pkey; Type: CONSTRAINT; Schema: public; Owner: manso +-- + +ALTER TABLE ONLY public.compras + ADD CONSTRAINT compras_pkey PRIMARY KEY (id_compra); + + +-- +-- Name: deta_comandas deta_comandas_pkey; Type: CONSTRAINT; Schema: public; Owner: manso +-- + +ALTER TABLE ONLY public.deta_comandas + ADD CONSTRAINT deta_comandas_pkey PRIMARY KEY (id_det_comanda); + + +-- +-- Name: deta_comp_materias deta_comp_materias_pkey; Type: CONSTRAINT; Schema: public; Owner: manso +-- + +ALTER TABLE ONLY public.deta_comp_materias + ADD CONSTRAINT deta_comp_materias_pkey PRIMARY KEY (id_compra, id_mat_prima); + + +-- +-- Name: deta_comp_producto deta_comp_producto_pkey; Type: CONSTRAINT; Schema: public; Owner: manso +-- + +ALTER TABLE ONLY public.deta_comp_producto + ADD CONSTRAINT deta_comp_producto_pkey PRIMARY KEY (id_compra, id_producto); + + +-- +-- Name: mate_primas mate_primas_nombre_key; Type: CONSTRAINT; Schema: public; Owner: manso +-- + +ALTER TABLE ONLY public.mate_primas + ADD CONSTRAINT mate_primas_nombre_key UNIQUE (nombre); + + +-- +-- Name: mate_primas mate_primas_pkey; Type: CONSTRAINT; Schema: public; Owner: manso +-- + +ALTER TABLE ONLY public.mate_primas + ADD CONSTRAINT mate_primas_pkey PRIMARY KEY (id_mat_prima); + + +-- +-- Name: mesas mesas_apodo_key; Type: CONSTRAINT; Schema: public; Owner: manso +-- + +ALTER TABLE ONLY public.mesas + ADD CONSTRAINT mesas_apodo_key UNIQUE (apodo); + + +-- +-- Name: mesas mesas_numero_key; Type: CONSTRAINT; Schema: public; Owner: manso +-- + +ALTER TABLE ONLY public.mesas + ADD CONSTRAINT mesas_numero_key UNIQUE (numero); + + +-- +-- Name: mesas mesas_pkey; Type: CONSTRAINT; Schema: public; Owner: manso +-- + +ALTER TABLE ONLY public.mesas + ADD CONSTRAINT mesas_pkey PRIMARY KEY (id_mesa); + + +-- +-- Name: productos productos_pkey; Type: CONSTRAINT; Schema: public; Owner: manso +-- + +ALTER TABLE ONLY public.productos + ADD CONSTRAINT productos_pkey PRIMARY KEY (id_producto); + + +-- +-- Name: prov_mate_prima prov_mate_prima_pkey; Type: CONSTRAINT; Schema: public; Owner: manso +-- + +ALTER TABLE ONLY public.prov_mate_prima + ADD CONSTRAINT prov_mate_prima_pkey PRIMARY KEY (id_proveedor, id_mat_prima); + + +-- +-- Name: prov_producto prov_producto_pkey; Type: CONSTRAINT; Schema: public; Owner: manso +-- + +ALTER TABLE ONLY public.prov_producto + ADD CONSTRAINT prov_producto_pkey PRIMARY KEY (id_proveedor, id_producto); + + +-- +-- Name: proveedores proveedores_pkey; Type: CONSTRAINT; Schema: public; Owner: manso +-- + +ALTER TABLE ONLY public.proveedores + ADD CONSTRAINT proveedores_pkey PRIMARY KEY (id_proveedor); + + +-- +-- Name: proveedores proveedores_rut_key; Type: CONSTRAINT; Schema: public; Owner: manso +-- + +ALTER TABLE ONLY public.proveedores + ADD CONSTRAINT proveedores_rut_key UNIQUE (rut); + + +-- +-- Name: receta_producto receta_producto_pkey; Type: CONSTRAINT; Schema: public; Owner: manso +-- + +ALTER TABLE ONLY public.receta_producto + ADD CONSTRAINT receta_producto_pkey PRIMARY KEY (id_producto, id_mat_prima); + + +-- +-- Name: roles roles_nombre_key; Type: CONSTRAINT; Schema: public; Owner: manso +-- + +ALTER TABLE ONLY public.roles + ADD CONSTRAINT roles_nombre_key UNIQUE (nombre); + + +-- +-- Name: roles roles_pkey; Type: CONSTRAINT; Schema: public; Owner: manso +-- + +ALTER TABLE ONLY public.roles + ADD CONSTRAINT roles_pkey PRIMARY KEY (id_rol); + + +-- +-- Name: usua_roles usua_roles_pkey; Type: CONSTRAINT; Schema: public; Owner: manso +-- + +ALTER TABLE ONLY public.usua_roles + ADD CONSTRAINT usua_roles_pkey PRIMARY KEY (id_usuario, id_rol); + + +-- +-- Name: usuarios usuarios_documento_key; Type: CONSTRAINT; Schema: public; Owner: manso +-- + +ALTER TABLE ONLY public.usuarios + ADD CONSTRAINT usuarios_documento_key UNIQUE (documento); + + +-- +-- Name: usuarios usuarios_pkey; Type: CONSTRAINT; Schema: public; Owner: manso +-- + +ALTER TABLE ONLY public.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 public.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 public.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 public.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 public.deta_comandas USING btree (id_comanda); + + +-- +-- Name: idx_detalle_comanda_producto; Type: INDEX; Schema: public; Owner: manso +-- + +CREATE INDEX idx_detalle_comanda_producto ON public.deta_comandas USING btree (id_producto); + + +-- +-- Name: ix_comandas_fec_cierre; Type: INDEX; Schema: public; Owner: manso +-- + +CREATE INDEX ix_comandas_fec_cierre ON public.comandas USING btree (fec_cierre); + + +-- +-- Name: ix_comandas_id; Type: INDEX; Schema: public; Owner: manso +-- + +CREATE INDEX ix_comandas_id ON public.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 public.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 public.deta_comandas USING btree (id_producto); + + +-- +-- Name: asistencia_intervalo asistencia_intervalo_id_usuario_fkey; Type: FK CONSTRAINT; Schema: public; Owner: manso +-- + +ALTER TABLE ONLY public.asistencia_intervalo + ADD CONSTRAINT asistencia_intervalo_id_usuario_fkey FOREIGN KEY (id_usuario) REFERENCES public.usuarios(id_usuario) ON DELETE CASCADE; + + +-- +-- Name: asistencia_raw asistencia_raw_id_usuario_fkey; Type: FK CONSTRAINT; Schema: public; Owner: manso +-- + +ALTER TABLE ONLY public.asistencia_raw + ADD CONSTRAINT asistencia_raw_id_usuario_fkey FOREIGN KEY (id_usuario) REFERENCES public.usuarios(id_usuario) ON DELETE CASCADE; + + +-- +-- Name: comandas comandas_id_mesa_fkey; Type: FK CONSTRAINT; Schema: public; Owner: manso +-- + +ALTER TABLE ONLY public.comandas + ADD CONSTRAINT comandas_id_mesa_fkey FOREIGN KEY (id_mesa) REFERENCES public.mesas(id_mesa); + + +-- +-- Name: comandas comandas_id_usuario_fkey; Type: FK CONSTRAINT; Schema: public; Owner: manso +-- + +ALTER TABLE ONLY public.comandas + ADD CONSTRAINT comandas_id_usuario_fkey FOREIGN KEY (id_usuario) REFERENCES public.usuarios(id_usuario); + + +-- +-- Name: compras compras_id_proveedor_fkey; Type: FK CONSTRAINT; Schema: public; Owner: manso +-- + +ALTER TABLE ONLY public.compras + ADD CONSTRAINT compras_id_proveedor_fkey FOREIGN KEY (id_proveedor) REFERENCES public.proveedores(id_proveedor); + + +-- +-- Name: deta_comandas deta_comandas_id_comanda_fkey; Type: FK CONSTRAINT; Schema: public; Owner: manso +-- + +ALTER TABLE ONLY public.deta_comandas + ADD CONSTRAINT deta_comandas_id_comanda_fkey FOREIGN KEY (id_comanda) REFERENCES public.comandas(id_comanda) ON DELETE CASCADE; + + +-- +-- Name: deta_comandas deta_comandas_id_producto_fkey; Type: FK CONSTRAINT; Schema: public; Owner: manso +-- + +ALTER TABLE ONLY public.deta_comandas + ADD CONSTRAINT deta_comandas_id_producto_fkey FOREIGN KEY (id_producto) REFERENCES public.productos(id_producto); + + +-- +-- Name: deta_comp_materias deta_comp_materias_id_compra_fkey; Type: FK CONSTRAINT; Schema: public; Owner: manso +-- + +ALTER TABLE ONLY public.deta_comp_materias + ADD CONSTRAINT deta_comp_materias_id_compra_fkey FOREIGN KEY (id_compra) REFERENCES public.compras(id_compra) ON DELETE CASCADE; + + +-- +-- Name: deta_comp_materias deta_comp_materias_id_mat_prima_fkey; Type: FK CONSTRAINT; Schema: public; Owner: manso +-- + +ALTER TABLE ONLY public.deta_comp_materias + ADD CONSTRAINT deta_comp_materias_id_mat_prima_fkey FOREIGN KEY (id_mat_prima) REFERENCES public.mate_primas(id_mat_prima); + + +-- +-- Name: deta_comp_producto deta_comp_producto_id_compra_fkey; Type: FK CONSTRAINT; Schema: public; Owner: manso +-- + +ALTER TABLE ONLY public.deta_comp_producto + ADD CONSTRAINT deta_comp_producto_id_compra_fkey FOREIGN KEY (id_compra) REFERENCES public.compras(id_compra) ON DELETE CASCADE; + + +-- +-- Name: deta_comp_producto deta_comp_producto_id_producto_fkey; Type: FK CONSTRAINT; Schema: public; Owner: manso +-- + +ALTER TABLE ONLY public.deta_comp_producto + ADD CONSTRAINT deta_comp_producto_id_producto_fkey FOREIGN KEY (id_producto) REFERENCES public.productos(id_producto); + + +-- +-- Name: productos productos_id_categoria_fkey; Type: FK CONSTRAINT; Schema: public; Owner: manso +-- + +ALTER TABLE ONLY public.productos + ADD CONSTRAINT productos_id_categoria_fkey FOREIGN KEY (id_categoria) REFERENCES public.categorias(id_categoria); + + +-- +-- Name: prov_mate_prima prov_mate_prima_id_mat_prima_fkey; Type: FK CONSTRAINT; Schema: public; Owner: manso +-- + +ALTER TABLE ONLY public.prov_mate_prima + ADD CONSTRAINT prov_mate_prima_id_mat_prima_fkey FOREIGN KEY (id_mat_prima) REFERENCES public.mate_primas(id_mat_prima); + + +-- +-- Name: prov_mate_prima prov_mate_prima_id_proveedor_fkey; Type: FK CONSTRAINT; Schema: public; Owner: manso +-- + +ALTER TABLE ONLY public.prov_mate_prima + ADD CONSTRAINT prov_mate_prima_id_proveedor_fkey FOREIGN KEY (id_proveedor) REFERENCES public.proveedores(id_proveedor) ON DELETE CASCADE; + + +-- +-- Name: prov_producto prov_producto_id_producto_fkey; Type: FK CONSTRAINT; Schema: public; Owner: manso +-- + +ALTER TABLE ONLY public.prov_producto + ADD CONSTRAINT prov_producto_id_producto_fkey FOREIGN KEY (id_producto) REFERENCES public.productos(id_producto); + + +-- +-- Name: prov_producto prov_producto_id_proveedor_fkey; Type: FK CONSTRAINT; Schema: public; Owner: manso +-- + +ALTER TABLE ONLY public.prov_producto + ADD CONSTRAINT prov_producto_id_proveedor_fkey FOREIGN KEY (id_proveedor) REFERENCES public.proveedores(id_proveedor) ON DELETE CASCADE; + + +-- +-- Name: receta_producto receta_producto_id_mat_prima_fkey; Type: FK CONSTRAINT; Schema: public; Owner: manso +-- + +ALTER TABLE ONLY public.receta_producto + ADD CONSTRAINT receta_producto_id_mat_prima_fkey FOREIGN KEY (id_mat_prima) REFERENCES public.mate_primas(id_mat_prima); + + +-- +-- Name: receta_producto receta_producto_id_producto_fkey; Type: FK CONSTRAINT; Schema: public; Owner: manso +-- + +ALTER TABLE ONLY public.receta_producto + ADD CONSTRAINT receta_producto_id_producto_fkey FOREIGN KEY (id_producto) REFERENCES public.productos(id_producto) ON DELETE CASCADE; + + +-- +-- Name: usua_roles usua_roles_autor_fkey; Type: FK CONSTRAINT; Schema: public; Owner: manso +-- + +ALTER TABLE ONLY public.usua_roles + ADD CONSTRAINT usua_roles_autor_fkey FOREIGN KEY (autor) REFERENCES public.usuarios(id_usuario); + + +-- +-- Name: usua_roles usua_roles_id_rol_fkey; Type: FK CONSTRAINT; Schema: public; Owner: manso +-- + +ALTER TABLE ONLY public.usua_roles + ADD CONSTRAINT usua_roles_id_rol_fkey FOREIGN KEY (id_rol) REFERENCES public.roles(id_rol); + + +-- +-- Name: usua_roles usua_roles_id_usuario_fkey; Type: FK CONSTRAINT; Schema: public; Owner: manso +-- + +ALTER TABLE ONLY public.usua_roles + ADD CONSTRAINT usua_roles_id_usuario_fkey FOREIGN KEY (id_usuario) REFERENCES public.usuarios(id_usuario) ON DELETE CASCADE; + + +-- +-- PostgreSQL database dump complete +-- + +\unrestrict londHmqT4llS8Wof4ZnceO2dyFhn4jiR5xbaszMgZpMczgr6aVW6xQJxeUdqJwa + diff --git a/services/auth/src/index.js b/services/auth/src/index.js index 5f236e2..0db6504 100644 --- a/services/auth/src/index.js +++ b/services/auth/src/index.js @@ -1,504 +1,610 @@ -// auth/src/index.js +// services/auth/src/index.js +// ------------------------------------------------------------ +// SuiteCoffee — Servicio de Autenticación (Express + OIDC) +// - ESM compatible (Node >=18) +// - Sesiones con Redis (compartibles con otros servicios) +// - Vistas EJS (login) +// - Rutas OIDC: /auth/login, /auth/callback, /auth/logout +// - Registro de usuario: /api/users/register (DB + Authentik) +// ------------------------------------------------------------ + +import 'dotenv/config'; import chalk from 'chalk'; import express from 'express'; -import expressLayouts from 'express-ejs-layouts'; import cors from 'cors'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; import { Pool } from 'pg'; -import bcrypt from'bcrypt'; +import session from 'express-session'; +import { createClient as createRedisClient } from 'redis'; +import * as connectRedis from 'connect-redis'; +import expressLayouts from 'express-ejs-layouts'; +import { Issuer, generators } from 'openid-client'; import crypto from 'node:crypto'; -import session from 'express-session'; -import { createClient } from 'redis'; -import * as connectRedis from 'connect-redis'; -const RedisStore = connectRedis.default || connectRedis.RedisStore; - -const redis = createClient({ url: process.env.REDIS_URL || 'redis://authentik-redis:6379' }); -await redis.connect(); - -import { Issuer, generators } from 'openid-client'; -import cookieSession from 'cookie-session'; - -import { akFindUserByEmail, akCreateUser, akSetPassword } from './ak.js'; -// Rutas -import path from 'path'; -import { fileURLToPath } from 'url'; -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); +// ----------------------------------------------------------------------------- +// Helpers +// ----------------------------------------------------------------------------- -// Variables de Entorno -import dotenv, { config } from 'dotenv'; +// Normaliza UUID (acepta con/sin guiones) → "hex" sin guiones +const cleanUuid = (u) => (u ? String(u).toLowerCase().replace(/[^0-9a-f]/g, '') : ''); -// Obtención de la ruta de la variable de entorno correspondiente a NODE_ENV -try { - if (process.env.NODE_ENV === 'development') { - dotenv.config({ path: path.resolve(__dirname, '../.env.development' )}); - console.log(`Activando entorno de ->${chalk.green(` DEVELOPMENT `)}`); - } else if (process.env.NODE_ENV === 'stage') { - dotenv.config({ path: path.resolve(__dirname, '../.env.test' )}); - console.log(`Activando entorno de ->${chalk.yellow(` TESTING `)}`); - } else if (process.env.NODE_ENV === 'production') { - dotenv.config({ path: path.resolve(__dirname, '../.env.production' )}); - console.log(`Activando entorno de ->${chalk.red(` PRODUCTION `)}`); - } -} catch (error) { - console.log("A ocurrido un error al seleccionar el entorno. \nError: " + error); +// Nombre de schema/rol a partir de uuid limpio +const schemaNameFor = (uuidHex) => `schema_tenant_${uuidHex}`; +const roleNameFor = (uuidHex) => `tenant_${uuidHex}`; + +// Helpers de Authentik (admin API) +const { akFindUserByEmail, akCreateUser, akSetPassword } = await import('./ak.js'); + +// Quoter seguro de identificadores SQL (roles, schemas, tablas) +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; + +// --- Resolver y cachear el grupo por ID/UUID/NOMBRE una sola vez --- +let DEFAULT_GROUP_ID = process.env.AUTHENTIK_DEFAULT_GROUP_ID + ? Number(process.env.AUTHENTIK_DEFAULT_GROUP_ID) + : null; + +if (!DEFAULT_GROUP_ID) { + (async () => { + try { + // Si tenés akResolveGroupIdByName, usalo: + // DEFAULT_GROUP_ID = await akResolveGroupIdByName(process.env.AUTHENTIK_DEFAULT_GROUP_NAME); + + // Con el helper genérico que te dejé en ak.js: + DEFAULT_GROUP_ID = await akResolveGroupId({ + uuid: process.env.AUTHENTIK_DEFAULT_GROUP_UUID, + name: process.env.AUTHENTIK_DEFAULT_GROUP_NAME, + }); + console.log('[AK] DEFAULT_GROUP_ID resuelto:', DEFAULT_GROUP_ID); + } catch (e) { + console.warn('[AK] No se pudo resolver DEFAULT_GROUP_ID:', e?.message || e); + } + })(); } -// Configuración de renderizado -const app = express(); -app.use(cors()); -app.use(express.json()); -app.set('trust proxy', true); -app.use(express.static(path.join(__dirname, 'pages'))); +// Verificar existencia del tenant sin crear (en la DB de tenants) +async function tenantExists(uuidHex) { + if (!uuidHex) return false; + const schema = schemaNameFor(uuidHex); + const client = await tenantsPool.connect(); + try { + const q = await client.query( + 'SELECT 1 FROM information_schema.schemata WHERE schema_name=$1', + [schema] + ); + return q.rowCount > 0; + } finally { + client.release(); + } +} -/* 1) Motor de vistas apuntando a /auth/src/views */ +// Intenta obtener el tenant por orden: +// 1) DB principal (app_user por email) +// 2) Authentik (attributes.tenant_uuid del usuario) +// 3) valor provisto en el request (si viene) +async function resolveExistingTenantUuid({ email, requestedTenantUuid }) { + const emailLower = String(email).toLowerCase(); + + // 1) DB principal + const dbRes = await pool.query( + 'SELECT tenant_uuid FROM app_user WHERE LOWER(email)=LOWER($1) LIMIT 1', + [emailLower] + ); + if (dbRes.rowCount) { + const fromDb = cleanUuid(dbRes.rows[0].tenant_uuid); + if (fromDb) return fromDb; + } + + // 2) Authentik + const akUser = await akFindUserByEmail(emailLower).catch(() => null); + const fromAk = cleanUuid(akUser?.attributes?.tenant_uuid); + if (fromAk) return fromAk; + + // 3) Pedido del request + const fromReq = cleanUuid(requestedTenantUuid); + if (fromReq) return fromReq; + + return null; // no hay tenant conocido +} + +// Helper para crear tenant si falta +async function ensureTenant({ tenant_uuid }) { + const 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'); + + return { tenant_uuid: uuid, schema, role, role_password: pwd }; + } catch (e) { + try { await client.query('ROLLBACK'); } catch {} + throw e; + } finally { + client.release(); + } +} + + +// ----------------------------------------------------------------------------- +// Utilidades +// ----------------------------------------------------------------------------- +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const RedisStore = connectRedis.default || connectRedis.RedisStore; + +function requiredEnv(keys) { + const missing = keys.filter((k) => !process.env[k]); + if (missing.length) { + console.warn(chalk.yellow(`⚠ Falta configurar variables de entorno: ${missing.join(', ')}`)); + } +} + +function onFatal(err, msg = 'Error fatal') { + console.error(chalk.red(`\n${msg}:`)); + console.error(err); + process.exit(1); +} + +// ----------------------------------------------------------------------------- +// Configuración Express +// ----------------------------------------------------------------------------- +const app = express(); +app.set('trust proxy', 1); +app.use(cors({ origin: true, credentials: true })); +app.use(express.json()); +app.use(express.urlencoded({ extended: true })); + +// Vistas EJS app.set('views', path.join(__dirname, 'views')); app.set('view engine', 'ejs'); -/* 2) Estáticos si usás /css/main.css dentro de /auth/src/public */ +// Archivos estáticos opcionales (ajusta si tu estructura difiere) app.use(express.static(path.join(__dirname, 'public'))); +app.use('/pages', express.static(path.join(__dirname, 'pages'))); -/* 3) Exponer user a las vistas (opcional, cómodo) */ +// ----------------------------------------------------------------------------- +// Sesión (Redis) +// ----------------------------------------------------------------------------- +requiredEnv(['SESSION_SECRET', 'REDIS_URL']); +const redis = createRedisClient({ url: process.env.REDIS_URL || 'redis://sessions-redis:6379' }); +await redis.connect().catch((e) => onFatal(e, 'No se pudo conectar a Redis (sesiones)')); + +app.use( + session({ + name: 'sc.sid', + store: new RedisStore({ client: redis, prefix: 'sess:' }), + secret: process.env.SESSION_SECRET || 'change-me', + resave: false, + saveUninitialized: false, + cookie: { + httpOnly: true, + sameSite: 'lax', + secure: process.env.NODE_ENV === 'production', + }, + }) +); + +// Exponer usuario a las vistas (no tocar req.session) app.use((req, res, next) => { res.locals.user = req.session?.user || null; next(); }); - -/* 4) Página de login (renderiza el EJS de arriba) - - Mantén /auth/login para iniciar OIDC (redirección a Authentik) - - Usa /login para mostrar la página con el botón */ -app.get('/login', (req, res) => { - res.render('login'); // -> /auth/src/views/login.ejs -}); - -app.use(session({ - name: 'sc.sid', - store: new RedisStore({ client: redis, prefix: 'sess:' }), - secret: process.env.SESSION_SECRET || 'change-me', - resave: false, - saveUninitialized: false, - cookie: { - httpOnly: true, - sameSite: 'lax', - secure: process.env.NODE_ENV === 'production', - }, -})); - -app.use(cookieSession({ - name: 'sid', - secret: process.env.SESSION_SECRET, - httpOnly: true, - sameSite: 'lax', - secure: false // en prod detrás de https: true -})); - -// Configuración de conexión PostgreSQL - -const poolMeta = { - host: process.env.DB_HOST, - user: process.env.DB_USER, - password: process.env.DB_PASS, - database: process.env.DB_NAME, - port: process.env.DB_LOCAL_PORT -}; - -const pool = new Pool(poolMeta); - -const poolTenants = new Pool({ // apunta al servidor/base multi-tenant - host: process.env.TENANTS_HOST, // dev-tenants - user: process.env.TENANTS_USER, - password: process.env.TENANTS_PASS, - database: process.env.TENANTS_DB, // dev-postgres - port: process.env.TENANTS_PORT, -}); - +// ----------------------------------------------------------------------------- +// PostgreSQL — DB tenants (usuarios de suitecoffee) +// ----------------------------------------------------------------------------- const tenantsPool = new Pool({ - host: process.env.TENANTS_HOST, // dev-tenants - user: process.env.TENANTS_USER, - password: process.env.TENANTS_PASS, - database: process.env.TENANTS_DB, // dev-postgres - port: process.env.TENANTS_PORT + host: process.env.TENANTS_HOST || 'dev-tenants', + port: Number(process.env.TENANTS_PORT || 5432), + user: process.env.TENANTS_USER || 'dev-user-postgres', + password: process.env.TENANTS_PASS || 'dev-pass-postgres', + database: process.env.TENANTS_DB || 'dev-postgres', + max: 10, +}); + + +// ----------------------------------------------------------------------------- +// PostgreSQL — DB principal (metadatos de negocio) +// ----------------------------------------------------------------------------- +requiredEnv(['DB_HOST', 'DB_USER', 'DB_PASS', 'DB_NAME']); +const pool = new Pool({ + host: process.env.DB_HOST || 'dev-db', + port: Number(process.env.DB_PORT || 5432), + user: process.env.DB_USER || 'dev-user-suitecoffee', + password: process.env.DB_PASS || 'dev-pass-suitecoffee', + database: process.env.DB_NAME || 'dev-suitecoffee', + max: 10, + idleTimeoutMillis: 30_000, }); async function verificarConexion() { try { const client = await pool.connect(); - const res = await client.query('SELECT NOW() AS hora'); - console.log(`\nConexión con la base de datos ${chalk.green(process.env.DB_NAME)} fue exitosa.`); - console.log('Fecha y hora actual de la base de datos:', res.rows[0].hora); - client.release(); // liberar el cliente de nuevo al pool + const { rows } = await client.query('SELECT NOW() AS ahora'); + console.log(`\nConexión con ${chalk.green(process.env.DB_NAME)} OK. Hora DB:`, rows[0].ahora); + client.release(); } catch (error) { console.error('Error al conectar con la base de datos al iniciar:', error.message); - console.error(`Troubleshooting:\n1. Compruebe que las bases de datos se iniciaron correctamente.\n2. Verifique las credenciales y puertos de acceso a la base de datos.\n3. Si está conectandose a una base de datos externa a localhost, verifique las reglas del firewal de entrada y salida de ambos dispositivos.`); + console.error('Revisar DB_HOST/USER/PASS/NAME, accesos de red y firewall.'); } } -// Descubrimiento OIDC (una sola vez) +// ----------------------------------------------------------------------------- +// OIDC (Authentik) — discovery + cliente +// ----------------------------------------------------------------------------- +requiredEnv(['OIDC_ISSUER', 'OIDC_CLIENT_ID', 'OIDC_CLIENT_SECRET', 'OIDC_REDIRECT_URI']); + + +async function discoverOIDCWithRetry(issuerUrl, { retries = 30, delayMs = 2000 } = {}) { + let lastErr; + for (let i = 1; i <= retries; i++) { + try { + const issuer = await Issuer.discover(issuerUrl); + console.log(`[OIDC] issuer OK en intento ${i}:`, issuer.metadata.issuer); + return issuer; + } catch (err) { + lastErr = err; + console.warn(`[OIDC] intento ${i}/${retries} falló: ${err.code || err.message}`); + await sleep(delayMs); + } + } + // No abortamos el proceso; dejamos el servidor vivo y seguimos reintentando en background + throw lastErr; +} + let oidcClient; (async () => { - const issuer = await Issuer.discover(process.env.OIDC_ISSUER); // debe coincidir EXACTO - oidcClient = new issuer.Client({ - client_id: process.env.OIDC_CLIENT_ID, - client_secret: process.env.OIDC_CLIENT_SECRET, - redirect_uris: [process.env.OIDC_REDIRECT_URI], - response_types: ['code'], + try { + const issuer = await discoverOIDCWithRetry(process.env.OIDC_ISSUER, { retries: 60, delayMs: 2000 }); + oidcClient = new issuer.Client({ + client_id: process.env.OIDC_CLIENT_ID, + client_secret: process.env.OIDC_CLIENT_SECRET, + redirect_uris: [process.env.OIDC_REDIRECT_URI], + response_types: ['code'], + }); + } catch (e) { + console.error('⚠ No se pudo inicializar OIDC aún. Seguirá reintentando cada 10s en background.'); + // reintento en background cada 10s sin tumbar el proceso + (async function loop() { + try { + const issuer = await Issuer.discover(process.env.OIDC_ISSUER); + oidcClient = new issuer.Client({ + client_id: process.env.OIDC_CLIENT_ID, + client_secret: process.env.OIDC_CLIENT_SECRET, + redirect_uris: [process.env.OIDC_REDIRECT_URI], + response_types: ['code'], + }); + console.log('[OIDC] inicializado correctamente en reintento tardío'); + } catch { + setTimeout(loop, 10000); + } + })(); + } +})(); + +// ----------------------------------------------------------------------------- +// Vistas +// ----------------------------------------------------------------------------- +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; + + // guarda todo lo necesario para el callback + req.session.code_verifier = code_verifier; + req.session.state = state; + + // log de depuración + console.log('[OIDC] start login sid=%s state=%s', req.sessionID, state) + + const url = oidcClient.authorizationUrl({ + scope: 'openid email profile', + code_challenge, + code_challenge_method: 'S256', + state, }); -})().catch(err => { - console.error('Error inicializando OIDC:', err); - process.exit(1); + console.log('[OIDC] auth URL has state? %s', url.includes(`state=${state}`)); + return res.redirect(url); }); - -// util para resolver tenant si aún no usás claim tenant_uuid -async function lookupTenantByEmail(email) { - const { rows } = await poolMeta.query( - `SELECT tenant_uuid FROM app_user WHERE email = $1 ORDER BY id LIMIT 1`, - [email.toLowerCase()] - ); - return rows[0]?.tenant_uuid || null; -} - -// === Servir páginas estáticas === - -app.get('/',(req, res) => res.sendFile(path.join(__dirname, 'pages', 'index.html'))); - -app.get('/planes', async (req, res) => { +app.get('/auth/callback', async (req, res, next) => { try { - const { rows: [row] } = await pool.query( - 'SELECT api.get_planes_json($1) AS data;', - [true] + 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 } ); - res.type('application/json').send(row.data); - } catch (err) { - console.error(err); - res.status(500).json({ error: 'Error al cargar planes' }); + + delete req.session.code_verifier; + delete req.session.state; + + const claims = tokenSet.claims(); + const email = (claims.email || '').toLowerCase(); + const tenantUuid = (claims.tenant_uuid || '').replace(/-/g, ''); + + let tenantHex = cleanUuid(claims.tenant_uuid); + if (!tenantHex) { + // intenta Authentik + 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]); + tenantHex = cleanUuid(q.rows?.[0]?.tenant_uuid); + } + } + + // Regenerar sesión para evitar fijación + req.session.regenerate((err) => { + if (err) return next(err); + req.session.user = { + sub: claims.sub, + email, + tenant_uuid: tenantUuid || null, + }; + req.session.save((e2) => (e2 ? next(e2) : res.redirect('/'))); + }); + } catch (e) { + next(e); } }); +app.post('/auth/logout', (req, res) => { + req.session.destroy(() => { + res.clearCookie('sc.sid'); + res.status(204).end(); + }); +}); + +app.get('/auth/me', (req, res) => { + if (!req.session?.user) return res.status(401).json({ error: 'no-auth' }); + res.json({ user: req.session.user }); +}); + +// ----------------------------------------------------------------------------- +// Registro de usuario (DB principal + Authentik) +// ----------------------------------------------------------------------------- + app.post('/api/users/register', async (req, res, next) => { - const { email, display_name, tenant_uuid, role, password } = req.body; + const { email, display_name, tenant_uuid: requestedTenant, role } = req.body || {}; + if (!email) return res.status(400).json({ error: 'email es obligatorio' }); - if (!email || !tenant_uuid) { - return res.status(400).json({ error: 'email y tenant_uuid son obligatorios' }); + const emailLower = String(email).toLowerCase(); + + // 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 }); + } } - const client = await poolMeta.connect(); + // 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 + const client = await pool.connect(); try { await client.query('BEGIN'); - // 0) idempotencia: si ya existe en tu DB, devolvés 409 o retornás el existente - const { rows: existing } = await client.query( - `SELECT id, email, ak_sub FROM app_user WHERE email = $1`, [email] + // Evitar duplicar usuario por email + tenant (ajusta según tu constraint) + const dup = await client.query( + 'SELECT id FROM app_user WHERE LOWER(email)=LOWER($1) AND tenant_uuid=$2', + [emailLower, tenantHex] ); - if (existing.length) { + if (dup.rowCount) { await client.query('ROLLBACK'); - return res.status(409).json({ error: 'El usuario ya existe en SuiteCoffee' }); + return res.status(409).json({ + error: 'user-exists', + message: 'Ya existe un usuario con este email en este tenant.', + next: '/auth/login', + }); } - // 1) crear/obtener usuario en Authentik - let akUser = await akFindUserByEmail(email); + // Authentik: crear si no existe; si existe, reusar y (opcional) asegurar attributes.tenant_uuid + let akUser = await akFindUserByEmail(emailLower); if (!akUser) { akUser = await akCreateUser({ - email, + email: emailLower, displayName: display_name, - tenantUuid: tenant_uuid.replace(/-/g, ''), - addToGroupId: process.env.AUTHENTIK_DEFAULT_GROUP_ID || null, + tenantUuid: tenantHex, // se guarda en attributes + addToGroupId: DEFAULT_GROUP_ID || null, + isActive: true, }); - // Si querés asignar una clave inicial (no recomendado en prod), descomentá: - // if (password) await akSetPassword(akUser.pk, password, 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 */ } + } } - // el 'sub' lo tendrás recién tras login OIDC; guardamos el uuid interno si te sirve const _role = role || 'owner'; - - // 2) crear usuario local (sin password, dependemos del SSO) await client.query( `INSERT INTO app_user (email, display_name, tenant_uuid, ak_user_uuid, role) - VALUES ($1, $2, $3, $4, $5)`, - [email, display_name || null, tenant_uuid.replace(/-/g, ''), akUser.uuid, _role] + VALUES ($1,$2,$3,$4,$5)`, + [emailLower, display_name || null, tenantHex, akUser.uuid, _role] ); await client.query('COMMIT'); return res.status(201).json({ message: 'Usuario registrado', - email, tenant_uuid, role: _role, + email: emailLower, + tenant_uuid: tenantHex, // devolvés el mismo + role: _role, authentik_user_uuid: akUser.uuid, - next: '/auth/login' // redirigí a OIDC + next: '/auth/login', }); - } catch (err) { - await client.query('ROLLBACK'); + try { await client.query('ROLLBACK'); } catch {} + if (err?.code === '23505') { // unique_violation + return res.status(409).json({ error: 'user-exists' }); + } next(err); } finally { client.release(); } }); +// Espera: { email, display_name?, tenant_uuid } +// app.post('/api/users/register', async (req, res, next) => { -// app.post('/api/registro', async (req, res) => { -// const { -// nombre_empresa, rut, correo, telefono, direccion, logo, -// clave_acceso, plan_id -// } = req.body; - -// const clientMeta = await poolMeta.connect(); -// const clientTen = await poolTenants.connect(); +// const { email, display_name, tenant_uuid: rawTenant, role } = req.body || {}; +// if (!email) return res.status(400).json({ error: 'email es obligatorio' }); +// // Si no vino tenant: lo creamos +// const { tenant_uuid, schema, role: dbRole } = await ensureTenant({ tenant_uuid: rawTenant }); +// const client = await pool.connect(); // try { -// await clientMeta.query('BEGIN'); +// await client.query('BEGIN'); -// // 1) Generar UUID sin guiones -// const uuid = crypto.randomUUID().replace(/-/g, ''); -// const hash = await bcrypt.hash(clave_acceso, 10); +// // ¿ya existe en tu DB? +// const { rows: dup } = await client.query( +// 'SELECT id FROM app_user WHERE email=$1 AND tenant_uuid=$2', +// [email.toLowerCase(), tenant_uuid.replace(/-/g, '')] +// ); +// if (dup.length) { +// await client.query('ROLLBACK'); +// return res.status(409).json({ error: 'El usuario ya existe en SuiteCoffee' }); +// } -// // 2) Provisionar en PG (dev-tenants/dev-postgres) -// const { rows: [prov] } = await clientTen.query( -// `SELECT public.f_tenant_provision($1::text, $2::text) AS data`, -// [uuid, `tenant_${uuid}`] +// // Authentik: crear si no existe +// let akUser = await akFindUserByEmail(email); +// if (!akUser) { +// akUser = await akCreateUser({ +// email, +// displayName: display_name, +// tenantUuid: tenant_uuid, // se normaliza dentro de ak.js +// addToGroupId: DEFAULT_GROUP_ID || null, +// isActive: true, +// }); +// // Si querés forzar clave inicial (opcional; depende de tus políticas): +// // await akSetPassword(akUser.pk, 'ClaveTemporal123!', true); +// } + +// const _role = role || 'owner'; +// await client.query( +// `INSERT INTO app_user (email, display_name, tenant_uuid, ak_user_uuid, role) +// VALUES ($1,$2,$3,$4,$5)`, +// [email.toLowerCase(), display_name || null, tenant_uuid.replace(/-/g, ''), akUser.uuid, _role] // ); -// const info = prov.data; // { tenant_uuid, schema, role, user, password, ... } - -// // 3) Guardar metadatos en suitecoffee_db (tu tabla 'tenant') -// await clientMeta.query(` -// INSERT INTO tenant ( -// uuid, nombre_empresa, rut, correo, telefono, direccion, logo, -// clave_acceso, plan_id, -// schema_name, role_name, user_name -// ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12) -// `, [ -// uuid, nombre_empresa, rut, correo, telefono, direccion, logo, -// hash, plan_id, -// info.schema, info.role, info.user -// ]); - -// await clientMeta.query('COMMIT'); - -// // 4) Devolver credenciales del usuario de DB si las necesitás (mejor *no* persistir la password) +// await client.query('COMMIT'); // return res.status(201).json({ -// message: 'Tenant registrado correctamente', -// uuid, -// schema: info.schema, -// db_user: info.user, -// db_password: info.password // muéstrala *una vez* y recomendación: NO guardarla +// message: 'Usuario registrado', +// email, +// tenant_uuid, +// role: _role, +// authentik_user_uuid: akUser.uuid, +// next: '/auth/login' // }); - // } catch (err) { -// await clientMeta.query('ROLLBACK'); -// console.error(err); -// return res.status(500).json({ error: 'Error al registrar tenant' }); +// try { await client.query('ROLLBACK'); } catch {} +// next(err); // } finally { -// clientMeta.release(); -// clientTen.release(); +// client.release(); // } // }); -app.post('/api/login', async (req, res) => { - const { correo, clave_acceso } = req.body; - try { - const client = await pool.connect(); +// ----------------------------------------------------------------------------- +// Healthcheck +// ----------------------------------------------------------------------------- +app.get('/health', (_req, res) => res.status(200).json({ status: 'ok' })); - const result = await client.query(` - SELECT uuid, clave_acceso, nombre_empresa, nombre_base_datos - FROM tenant - WHERE correo = $1 AND estado = true - `, [correo]); +// ----------------------------------------------------------------------------- +// 404 + Manejo de errores +// ----------------------------------------------------------------------------- +app.use((req, res) => res.status(404).json({ error: 'not-found', path: req.originalUrl })); - client.release(); - - if (result.rows.length === 0) { - return res.status(401).json({ error: 'Correo no registrado o inactivo' }); - } - - const tenant = result.rows[0]; - const coincide = await bcrypt.compare(clave_acceso, tenant.clave_acceso); - - if (!coincide) { - return res.status(401).json({ error: 'Clave incorrecta' }); - } - - return res.status(200).json({ - message: 'Login correcto', - uuid: tenant.uuid, - nombre_empresa: tenant.nombre_empresa, - base_datos: tenant.nombre_base_datos - }); - - } catch (err) { - console.error(err); - return res.status(500).json({ error: 'Error al validar login' }); - } +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) }); }); -app.get('/auth/login', (req, res) => { - const code_verifier = generators.codeVerifier(); - const code_challenge = generators.codeChallenge(code_verifier); - req.session.code_verifier = code_verifier; +// ----------------------------------------------------------------------------- +// Arranque +// ----------------------------------------------------------------------------- +const PORT = Number(process.env.PORT || 4040); - const url = oidcClient.authorizationUrl({ - scope: 'openid profile email offline_access tenant', // incluye tu scope custom “tenant” - code_challenge: code_challenge, - code_challenge_method: 'S256', +(async () => { + const env = (process.env.NODE_ENV || 'development').toUpperCase(); + console.log(`Activando entorno de -> ${env === 'PRODUCTION' ? chalk.red('PRODUCTION') : chalk.green('DEVELOPMENT')}`); + await verificarConexion(); + app.listen(PORT, () => { + console.log(`Servidor de autenticación de SuiteCoffee corriendo en ${chalk.yellow(`http://localhost:${PORT}`)}`); }); - res.redirect(url); -}); - - -// ------------- Middleware ---------------- -function getTenantUuid(req) { - // Ejemplo 1: header - if (req.headers['x-tenant-uuid']) return String(req.headers['x-tenant-uuid']); - // Ejemplo 2: si más adelante usás JWT: - // return req.user?.tenantUuid; - throw new Error('Tenant no especificado'); -} - -async function withTenant(req, res, next) { - const client = await tenantsPool.connect(); - try { - await client.query('BEGIN'); - const uuid = getTenantUuid(req).replace(/-/g, ''); - const schema = `schema_tenant_${uuid}`; - await client.query(`SELECT public.f_set_search_path($1)`, [schema]); - - // guardamos el cliente en req para reutilizar en los handlers - req.pg = client; - req.pgSchema = schema; - next(); - } catch (e) { - if (req.pg) await req.pg.query('ROLLBACK'); - if (req.pg) req.pg.release(); - return res.status(400).json({ error: e.message }); - } -} - -// Al final de cada handler, hacé COMMIT y release -async function done(req, res, next) { - try { if (req.pg) await req.pg.query('COMMIT'); } - finally { if (req.pg) req.pg.release(); } -} - - - - -// --- login: redirige a Authentik con PKCE -app.get('/auth/login', async (req, res) => { - const client = await getClient(); - const state = generators.state(); - const code_verifier = generators.codeVerifier(); - const code_challenge = generators.codeChallenge(code_verifier); - - req.session.state = state; - req.session.code_verifier = code_verifier; - - const authUrl = client.authorizationUrl({ - scope: 'openid profile email', - state, - code_challenge, - code_challenge_method: 'S256' - }); - - res.redirect(authUrl); -}); - -app.use((req,res,next)=>{ res.locals.user = req.session?.user || null; next(); }); - - -// --- callback: intercambia code por tokens y guarda sesión mínima -// --- RUTA: callback (enlaza la sesión con tu usuario local) -app.get('/auth/callback', async (req, res, next) => { - try { - const params = oidcClient.callbackParams(req); - const tokenSet = await oidcClient.callback( - process.env.OIDC_REDIRECT_URI, - params, - { code_verifier: req.session.code_verifier } - ); - const claims = tokenSet.claims(); // { sub, email, tenant_uuid?, ... } - - const email = (claims.email || '').toLowerCase(); - const sub = claims.sub; - const tenantUuid = (claims.tenant_uuid || await lookupTenantByEmail(email))?.replace(/-/g, ''); - - if (!tenantUuid) { - return res.status(403).send('No se pudo determinar el tenant del usuario.'); - } - - // Asegurar presencia del usuario en tu DB y enlazar el sub de OIDC - const { rows } = await poolMeta.query( - `SELECT id, ak_sub FROM app_user WHERE email=$1 AND tenant_uuid=$2`, - [email, tenantUuid] - ); - let userId; - if (rows.length) { - userId = rows[0].id; - if (!rows[0].ak_sub) { - await poolMeta.query(`UPDATE app_user SET ak_sub=$1 WHERE id=$2`, [sub, userId]); - } - } else { - // “just in time” create (opcional): lo das de alta si no existe aún en tu app - const ins = await poolMeta.query( - `INSERT INTO app_user (email, tenant_uuid, ak_sub, role) - VALUES ($1,$2,$3,'staff') RETURNING id`, - [email, tenantUuid, sub] - ); - userId = ins.rows[0].id; - } - - // Sesión de aplicación (lo que el resto del backend necesita) - req.session.user = { id: userId, email, tenant_uuid: tenantUuid, sub }; - - req.session.regenerate(err => { - if (err) return next(err); - req.session.user = { id: userId, email, tenant_uuid: tenantUuid, sub }; - req.session.save(err2 => { - if (err2) return next(err2); - return res.redirect('/'); - }); - }); - - // redirige a la app (home o dashboard) - res.redirect('/'); - } catch (e) { next(e); } -}); - -// (Opcional) logout “local” -app.post('/auth/logout', (req, res) => { - req.session.destroy(() => res.clearCookie('sc.sid').status(204).end()); -}); - -// --- ver quién soy (para probar) -app.get('/auth/me', (req, res) => { - if (!req.session?.user) return res.status(401).json({ error: 'no autenticado' }); - res.json({ user: req.session.user }); -}); - -// --- logout simple (borra cookie) -app.post('/auth/logout', (req, res) => { - req.session = null; - res.status(204).end(); -}); - -// Colores personalizados -let primaryColor = chalk.hex('#'+`${process.env.COL_PRI}`); -let secondaryColor = chalk.hex('#'+`${process.env.COL_SEC}`); -// let backgroundColor = chalk.hex('#'+`${process.env.COL_BG}`); - - -app.use(expressLayouts); -// Iniciar servidor -app.listen( process.env.PORT, () => { - console.log(`Servidor de ${chalk.yellow('autenticación')} de ${secondaryColor('SuiteCoffee')} corriendo en ${chalk.yellow(`http://localhost:${process.env.PORT}\n`)}` ); - console.log(chalk.grey(`Comprobando accesibilidad a la db ${chalk.green(process.env.DB_NAME)} del host ${chalk.white(`${process.env.DB_HOST}`)} ...`)); - verificarConexion(); -}); - -app.get("/health", async (req, res) => { - // Podés chequear DB aquí. 200 = healthy; 503 = not ready. - res.status(200).json({ status: "ok" }); -}); \ No newline at end of file +})(); diff --git a/services/auth/src/views/login.ejs b/services/auth/src/views/login.ejs index 0cec78d..3ef9350 100644 --- a/services/auth/src/views/login.ejs +++ b/services/auth/src/views/login.ejs @@ -1,25 +1,164 @@ - - - Iniciar sesión | SuiteCoffee - - - - -
-

SuiteCoffee — Acceso

-
+ + + + <%= typeof pageTitle !== 'undefined' ? pageTitle : 'Iniciar sesión · SuiteCoffee' %> - <% if (user) { %> -

Ya iniciaste sesión como <%= user.email %>.

-

Continuar a la aplicación

- <% } else { %> -
-

Usamos inicio de sesión único (SSO) con nuestro Identity Provider.

- - Iniciar sesión con SuiteCoffee SSO + + + + + + +
+
+
+
+

SuiteCoffee

+

Accedé a tu cuenta

+
+ + + + +
+
+ + + + +
o
+ + +
+
+ + +
Ingresá un email válido.
+
+ +
+ + +
Ingresá tu nombre.
+
+ +
+ + +
Si te invitaron a una organización existente, pegá aquí su UUID. Si sos el primero de tu empresa, dejalo vacío y el equipo te asignará uno.
+
+ +
+ + +
+ +
+ +
+
+ +

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

+ +
+
+ +

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

+
+
- <% } %> - + + + + +