diff --git a/.env.development b/.env.development
new file mode 100644
index 0000000..edcde4e
--- /dev/null
+++ b/.env.development
@@ -0,0 +1,35 @@
+# Archivo de variables de entorno para docker-compose.yml
+COMPOSE_PROJECT_NAME=suitecoffee_dev
+
+# Entorno de desarrollo
+NODE_ENV=development
+
+# app - app
+APP_LOCAL_PORT=3030
+APP_DOCKER_PORT=3030
+
+# auth - app
+AUTH_LOCAL_PORT=4040
+AUTH_DOCKER_PORT=4040
+
+# tenants - postgres
+TENANTS_DB_NAME=dev-postgres
+TENANTS_DB_USER=dev-user-postgres
+TENANTS_DB_PASS=dev-pass-postgres
+
+TENANTS_DB_LOCAL_PORT=54321
+TENANTS_DB_DOCKER_PORT=5432
+
+# db primaria - postgres
+DB_NAME=dev-suitecoffee
+DB_USER=dev-user-suitecoffee
+DB_PASS=dev-pass-suitecoffee
+
+DB_LOCAL_PORT=54322
+DB_DOCKER_PORT=5432
+
+# --- secretos para Authentik
+AUTHENTIK_SECRET_KEY=poné_un_valor_largo_y_unico
+AUTHENTIK_DB_PASS=cambia_esto
+AUTHENTIK_BOOTSTRAP_PASSWORD=cambia_esto
+AUTHENTIK_BOOTSTRAP_EMAIL=info.suitecoffee@gmail.com
diff --git a/.env.production b/.env.production
new file mode 100644
index 0000000..5e344a2
--- /dev/null
+++ b/.env.production
@@ -0,0 +1,23 @@
+# Archivo de variables de entorno para docker-compose.yml
+COMPOSE_PROJECT_NAME=suitecoffee_prod
+
+# Entorno de desarrollo
+NODE_ENV=production
+
+# app - app
+APP_LOCAL_PORT=3000
+APP_DOCKER_PORT=3000
+
+# auth - app
+AUTH_LOCAL_PORT=4000
+AUTH_DOCKER_PORT=4000
+
+# tenants - postgres
+TENANTS_DB_NAME=postgres
+TENANTS_DB_USER=postgres
+TENANTS_DB_PASS=postgres
+
+# db primaria - postgres
+DB_NAME=suitecoffee
+DB_USER=suitecoffee
+DB_PASS=suitecoffee
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
index ca1399e..a87e292 100644
--- a/.gitignore
+++ b/.gitignore
@@ -33,6 +33,6 @@ tests/
.gitmodules
# Ignorar archivos personales o privados (si existen)
-.env.*
+# .env.*
*.pem
*.key
\ No newline at end of file
diff --git a/docs/db's.md b/docs/db's.md
new file mode 100644
index 0000000..e69de29
diff --git a/services/app/.env.development b/services/app/.env.development
new file mode 100644
index 0000000..98e361f
--- /dev/null
+++ b/services/app/.env.development
@@ -0,0 +1,29 @@
+# ===== Runtime =====
+NODE_ENV=development
+PORT=3030
+APP_LOCAL_PORT=3030
+
+# ===== Session (usa el Redis del stack) =====
+# Para DEV podemos reutilizar el Redis de Authentik. En prod conviene uno separado.
+SESSION_SECRET=pon-una-clave-larga-y-unica
+REDIS_URL=redis://authentik-redis:6379
+
+# ===== DB principal (metadatos de SuiteCoffee) =====
+DB_HOST=dev-tenants
+DB_PORT=5432
+DB_NAME=dev-postgres
+DB_USER=dev-user-postgres
+DB_PASS=dev-pass-postgres
+
+# ===== DB tenants (Tenants de SuiteCoffee) =====
+TENANTS_HOST=dev-tenants
+TENANTS_DB=dev-postgres
+TENANTS_USER=dev-user-postgres
+TENANTS_PASS=dev-pass-postgres
+TENANTS_PORT=5432
+
+# ===== (Opcional) Colores UI, si alguna vista los lee =====
+COL_PRI=452D19 # Marrón oscuro
+COL_SEC=D7A666 # Crema / Café
+COL_BG=FFA500 # Naranja
+
diff --git a/services/app/.env.production b/services/app/.env.production
new file mode 100644
index 0000000..0b341ba
--- /dev/null
+++ b/services/app/.env.production
@@ -0,0 +1,22 @@
+NODE_ENV=production # Entorno de desarrollo
+
+PORT=3000 # Variables del servicio -> suitecoffee-app
+
+# Variables del servicio -> suitecoffee-db de suitecoffee-app
+
+DB_HOST=prod-tenants
+# Nombre de la base de datos
+DB_NAME=postgres
+
+# Usuario y contraseña
+DB_USER=postgres
+DB_PASS=postgres
+
+# Puertos del servicio de db
+DB_LOCAL_PORT=5432
+DB_DOCKER_PORT=5432
+
+# Colores personalizados
+COL_PRI=452D19 # Marrón oscuro
+COL_SEC=D7A666 # Crema / Café
+COL_BG=FFA500 # Naranja
\ No newline at end of file
diff --git a/services/app/package-lock.json b/services/app/package-lock.json
index ecf02e6..be8bb82 100644
--- a/services/app/package-lock.json
+++ b/services/app/package-lock.json
@@ -19,6 +19,7 @@
"express-ejs-layouts": "^2.5.1",
"express-session": "^1.18.2",
"ioredis": "^5.7.0",
+ "morgan": "^1.10.1",
"pg": "^8.16.3",
"pg-format": "^1.0.4",
"redis": "^5.8.2",
@@ -131,6 +132,24 @@
"version": "1.0.2",
"license": "MIT"
},
+ "node_modules/basic-auth": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/basic-auth/-/basic-auth-2.0.1.tgz",
+ "integrity": "sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==",
+ "license": "MIT",
+ "dependencies": {
+ "safe-buffer": "5.1.2"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/basic-auth/node_modules/safe-buffer": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
+ "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
+ "license": "MIT"
+ },
"node_modules/bcrypt": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-6.0.0.tgz",
@@ -902,6 +921,49 @@
"node": "*"
}
},
+ "node_modules/morgan": {
+ "version": "1.10.1",
+ "resolved": "https://registry.npmjs.org/morgan/-/morgan-1.10.1.tgz",
+ "integrity": "sha512-223dMRJtI/l25dJKWpgij2cMtywuG/WiUKXdvwfbhGKBhy1puASqXwFzmWZ7+K73vUPoR7SS2Qz2cI/g9MKw0A==",
+ "license": "MIT",
+ "dependencies": {
+ "basic-auth": "~2.0.1",
+ "debug": "2.6.9",
+ "depd": "~2.0.0",
+ "on-finished": "~2.3.0",
+ "on-headers": "~1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/morgan/node_modules/debug": {
+ "version": "2.6.9",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
+ "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
+ "license": "MIT",
+ "dependencies": {
+ "ms": "2.0.0"
+ }
+ },
+ "node_modules/morgan/node_modules/ms": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
+ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
+ "license": "MIT"
+ },
+ "node_modules/morgan/node_modules/on-finished": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz",
+ "integrity": "sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==",
+ "license": "MIT",
+ "dependencies": {
+ "ee-first": "1.1.1"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
"node_modules/ms": {
"version": "2.1.3",
"license": "MIT"
diff --git a/services/app/package.json b/services/app/package.json
index 0039ace..04a8927 100644
--- a/services/app/package.json
+++ b/services/app/package.json
@@ -25,6 +25,7 @@
"express-ejs-layouts": "^2.5.1",
"express-session": "^1.18.2",
"ioredis": "^5.7.0",
+ "morgan": "^1.10.1",
"pg": "^8.16.3",
"pg-format": "^1.0.4",
"redis": "^5.8.2",
diff --git a/services/app/src/index.js b/services/app/src/index.js
index 6d797b6..66124f5 100644
--- a/services/app/src/index.js
+++ b/services/app/src/index.js
@@ -1,216 +1,259 @@
// services/app/src/index.js
-// -----------------------------------------------------------------------------
-// SuiteCoffee — Servicio APP (Express)
-// - Carga de entorno robusta (compatible con Docker Compose env_file)
-// - Sesiones compartidas via Redis (mismo cookie que AUTH)
-// - Middlewares: CORS, JSON, estáticos, EJS opcional
-// - Multitenant por esquema: requireAuth + withTenant + done
-// - Montaje automático de ENDPOINTS LEGACY sin perder nada
-// - Healthcheck, 404 y manejador de errores
-// - Conserva y expone pools para que tus endpoints los usen
-// -----------------------------------------------------------------------------
+// ------------------------------------------------------------
+// SuiteCoffee — Servicio APP (UI + APIs negocio)
+// - ESM (Node >=18)
+// - Vistas EJS en ./views (dashboard.ejs, comandas.ejs, etc.)
+// - Sesión compartida con AUTH (cookie: sc.sid, Redis)
+// - Monta routes.legacy.js con requireAuth + withTenant
+// ------------------------------------------------------------
-// === 0) CARGA DE ENTORNO ROBUSTA (no pisa variables ya definidas por Compose)
-import fs from 'node:fs';
-import path from 'node:path';
-import { fileURLToPath } from 'node:url';
-import dotenv from 'dotenv';
-import chalk from 'chalk';
-
-const __filename = fileURLToPath(import.meta.url);
-const __dirname = path.dirname(__filename);
-const ENV = (process.env.NODE_ENV || 'development').toLowerCase();
-const envMap = { development: '.env.development', stage: '.env.test', production: '.env.production' };
-const envFile = envMap[ENV] || '.env.development';
-const candidates = [
- path.resolve(process.cwd(), envFile), // /app/.env.development (dentro del contenedor)
- path.resolve(__dirname, '..', envFile), // por si queda un nivel arriba
- path.resolve(__dirname, envFile), // por si lo ponen junto al src
-];
-const found = candidates.find((p) => fs.existsSync(p));
-if (found) {
- dotenv.config({ path: found, override: false });
- console.log(`Activando entorno de -> ${ENV.toUpperCase()} ${chalk.gray(`(${found})`)}`);
-} else {
- console.log(`Activando entorno de -> ${ENV.toUpperCase()} (sin archivo .env; usando variables del proceso)`);
-}
-
-// === 1) IMPORTS PRINCIPALES
+import 'dotenv/config';
import express from 'express';
import cors from 'cors';
-import expressLayouts from 'express-ejs-layouts';
+import morgan from 'morgan';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
import session from 'express-session';
+import expressLayouts from 'express-ejs-layouts';
import { createClient as createRedisClient } from 'redis';
import * as connectRedis from 'connect-redis';
import { Pool } from 'pg';
-import bcrypt from 'bcrypt'; // <- lo conservamos si ya lo usabas
-import crypto from 'node:crypto'; // <- idem
-// Tolerante a cambios de export en connect-redis
+// -----------------------------------------------------------------------------
+// Utilidades base
+// -----------------------------------------------------------------------------
+const __filename = fileURLToPath(import.meta.url);
+const __dirname = path.dirname(__filename);
const RedisStore = connectRedis.default || connectRedis.RedisStore;
-// === 2) APP y CONFIG BASICA
+const qi = (ident) => `"${String(ident).replace(/"/g, '""')}"`;
+const CLEAN_HEX = (s) => (String(s || '').toLowerCase().replace(/[^0-9a-f]/g, '') || null);
+const REQUIRED = (...keys) => {
+ const miss = keys.filter((k) => !process.env[k]);
+ if (miss.length) {
+ console.warn(`⚠ Faltan variables de entorno: ${miss.join(', ')}`);
+ }
+};
+
+// -----------------------------------------------------------------------------
+// Validación de entorno mínimo (ajusta nombres si difieren)
+// -----------------------------------------------------------------------------
+REQUIRED(
+ // Sesión
+ 'SESSION_SECRET', 'REDIS_URL',
+ // DB principal
+ 'DB_HOST', 'DB_NAME', 'DB_USER', 'DB_PASS',
+ // DB de tenants
+ 'TENANTS_HOST', 'TENANTS_DB', 'TENANTS_USER', 'TENANTS_PASS'
+);
+
+// -----------------------------------------------------------------------------
+// Pools de PostgreSQL
+// -----------------------------------------------------------------------------
+const mainPool = new Pool({
+ host: process.env.DB_HOST,
+ port: Number(process.env.DB_PORT || 5432),
+ database: process.env.DB_NAME,
+ user: process.env.DB_USER,
+ password: process.env.DB_PASS,
+ max: 10,
+ idleTimeoutMillis: 30_000,
+});
+
+const tenantsPool = new Pool({
+ host: process.env.TENANTS_HOST,
+ port: Number(process.env.TENANTS_PORT || 5432),
+ database: process.env.TENANTS_DB,
+ user: process.env.TENANTS_USER,
+ password: process.env.TENANTS_PASS,
+ max: 10,
+ idleTimeoutMillis: 30_000,
+});
+
+// Autotest (no rompe si falla; sólo loguea)
+(async () => {
+ try {
+ const c = await mainPool.connect();
+ const r = await c.query('SELECT NOW() now');
+ console.log('[APP] DB principal OK. Hora:', r.rows[0].now);
+ c.release();
+ } catch (e) {
+ console.error('[APP] Error al conectar DB principal:', e.message);
+ }
+ try {
+ const c = await tenantsPool.connect();
+ const r = await c.query('SELECT NOW() now');
+ console.log('[APP] DB tenants OK. Hora:', r.rows[0].now);
+ c.release();
+ } catch (e) {
+ console.error('[APP] Error al conectar DB tenants:', e.message);
+ }
+})();
+
+// -----------------------------------------------------------------------------
+// Express + EJS
+// -----------------------------------------------------------------------------
const app = express();
app.set('trust proxy', 1);
-app.use(cors({ origin: true, credentials: true }));
-app.use(express.json({ limit: '1mb' }));
-app.use(express.urlencoded({ extended: true }));
-// Vistas EJS (si no usás vistas, puedes dejarlo; no rompe)
+// Views EJS en ./views
app.set('views', path.join(__dirname, 'views'));
app.set('view engine', 'ejs');
app.use(expressLayouts);
-app.set('layout', 'layout');
+app.set("layout", "layouts/main");
-// Estáticos opcionales (ajusta si tu estructura difiere)
-app.use(express.static(path.join(__dirname, 'public')));
+// Estáticos (si tenés carpeta public/, assets, etc.)
+app.use('/public', express.static(path.join(__dirname, 'public')));
-// === 3) SESIONES COMPARTIDAS (mismo cookie que AUTH)
-const SESSION_SECRET = process.env.SESSION_SECRET || 'change-me-in-dev';
-const REDIS_URL = process.env.REDIS_URL || 'redis://authentik-redis:6379';
+// Middlewares básicos
+app.use(morgan('dev'));
+app.use(cors({ origin: true, credentials: true }));
+app.use(express.json());
+app.use(express.urlencoded({ extended: true }));
-const redis = createRedisClient({ url: REDIS_URL });
-await redis.connect().catch((e) => {
- console.warn('⚠ No se pudo conectar a Redis de sesiones:', e?.message || e);
+// ----------------------------------------------------------
+// Middleware para datos globales
+// ----------------------------------------------------------
+app.use((req, res, next) => {
+ res.locals.currentPath = req.path;
+ res.locals.pageTitle = "SuiteCoffee";
+ res.locals.pageId = "";
+ next();
});
+
+// -----------------------------------------------------------------------------
+// Sesión (Redis) — misma cookie que AUTH
+// -----------------------------------------------------------------------------
+const SESSION_COOKIE_NAME = 'sc.sid';
+const redis = createRedisClient({ url: process.env.REDIS_URL });
+await redis.connect().catch((e) => console.error('[APP] Redis session error:', e.message));
+
app.use(
session({
- name: 'sc.sid', // <- igual que en AUTH
+ name: SESSION_COOKIE_NAME,
store: new RedisStore({ client: redis, prefix: 'sess:' }),
- secret: SESSION_SECRET,
+ secret: process.env.SESSION_SECRET || 'change-me',
resave: false,
saveUninitialized: false,
cookie: {
httpOnly: true,
sameSite: 'lax',
secure: process.env.NODE_ENV === 'production',
+ // domain: 'suitecoffee.mateosaldain.uy', // (opcional) si lo necesitás
},
})
);
-// Exponer el usuario a las vistas (no tocar req.session)
+// Exponer usuario a las vistas
app.use((req, res, next) => {
res.locals.user = req.session?.user || null;
next();
});
-// === 4) POOLS A BASES DE DATOS ===
-// 4.1) Base principal (si la usás en APP). Conservamos variables usadas en el repo.
-const DB = {
- host: process.env.DB_HOST || 'dev-db',
- port: Number(process.env.DB_PORT || 5432),
- user: process.env.DB_USER || 'dev-user-suitecoffee',
- password: process.env.DB_PASS || 'dev-pass-suitecoffee',
- database: process.env.DB_NAME || 'dev-suitecoffee',
-};
-export const mainPool = new Pool({ ...DB, max: 10, idleTimeoutMillis: 30_000 });
-
-async function verificarConexion() {
- try {
- const c = await mainPool.connect();
- const { rows } = await c.query('SELECT NOW() AS now');
- console.log(`DB principal OK @ ${rows[0].now}`);
- c.release();
- } catch (e) {
- console.warn('⚠ No se pudo verificar DB principal:', e?.message || e);
+// -----------------------------------------------------------------------------
+// Middlewares de Auth/Tenant para routes.legacy.js
+// -----------------------------------------------------------------------------
+function requireAuth(req, res, next) {
+ if (!req.session?.user) {
+ // Si querés devolver 401 en lugar de redirigir, cambia esta línea
+ return res.redirect('/auth/login');
}
+ next();
}
-// 4.2) Base multi-tenant (un solo DB con esquemas por tenant)
-const TENANTS = {
- host: process.env.TENANTS_HOST || 'dev-tenants',
- port: Number(process.env.TENANTS_PORT || 5432),
- user: process.env.TENANTS_USER || 'postgres',
- password: process.env.TENANTS_PASS || 'postgres',
- database: process.env.TENANTS_DB || 'dev-postgres',
-};
-export const tenantsPool = new Pool({ ...TENANTS, max: 20, idleTimeoutMillis: 30_000 });
-
-// === 5) MIDDLEWARES DE AUTENTICACIÓN Y TENANT ===
-export function requireAuth(req, res, next) {
- if (req.session?.user) return next();
- // Fallback DEV: permitir si el front envía explícitamente el tenant (para pruebas)
- if (req.get('x-tenant-uuid')) return next();
- return res.status(401).json({ error: 'no-auth' });
-}
-
-function getTenantUuid(req) {
- const h = req.get('x-tenant-uuid');
- if (h) return String(h).replace(/-/g, '');
- const s = req.session?.user?.tenant_uuid;
- if (s) return String(s).replace(/-/g, '');
- throw new Error('Tenant no especificado');
-}
-
-export async function withTenant(req, res, next) {
- const client = await tenantsPool.connect();
+// Abre un client al DB de tenants y fija search_path al esquema del usuario
+async function withTenant(req, res, next) {
try {
- await client.query('BEGIN');
- const uuid = getTenantUuid(req);
- const schema = `schema_tenant_${uuid}`;
+ const hex = CLEAN_HEX(req.session?.user?.tenant_uuid);
+ if (!hex) return res.status(400).json({ error: 'tenant-missing' });
- // Si creaste la función en DB: SELECT public.f_set_search_path($1)
- // await client.query('SELECT public.f_set_search_path($1)', [schema]);
- await client.query(`SET LOCAL search_path TO ${schema.replace(/"/g, '')}`);
+ const schema = `schema_tenant_${hex}`;
+ const client = await tenantsPool.connect();
+ // Fijar search_path para que las consultas apunten al esquema del tenant
+ await client.query(`SET SESSION search_path TO ${qi(schema)}, public`);
+
+ // Hacemos el client accesible para los handlers de routes.legacy.js
req.pg = client;
- req.pgSchema = schema;
+
+ // Liberar el client al finalizar la respuesta
+ const release = () => {
+ try { client.release(); } catch {}
+ };
+ res.on('finish', release);
+ res.on('close', release);
+
next();
} catch (e) {
- try { await client.query('ROLLBACK'); } catch {}
- client.release();
- return res.status(400).json({ error: e.message });
+ next(e);
}
}
-export async function done(req, res, next) {
- try {
- if (req.pg) await req.pg.query('COMMIT');
- } catch (e) {
- try { if (req.pg) await req.pg.query('ROLLBACK'); } catch {}
- } finally {
- if (req.pg) req.pg.release();
+// No-op (compatibilidad con el archivo legacy si lo pasa al final)
+function done(_req, _res, next) { return next && next(); }
+
+// -----------------------------------------------------------------------------
+// Home / Landing
+// -----------------------------------------------------------------------------
+app.get('/', (req, res) => {
+ if (req.session?.user) {
+ return res.redirect('/comandas'); // ya logueado → dashboard
}
- next?.();
-}
+ return res.render('login', { pageTitle: 'Iniciar sesión' });
+});
-// === 6) RUTAS BÁSICAS / HEALTH ===
-app.get('/health', (_req, res) => res.status(200).json({ status: 'ok' }));
-app.get('/api/health', (_req, res) => res.status(200).json({ status: 'ok' }));
+// Página para definir contraseña (el form envía al servicio AUTH)
+app.get('/set-password', (req, res) => {
+ const pp = req.session?.pendingPassword;
+ if (!pp) return req.session?.user ? res.redirect('/comandas') : res.redirect('/auth/login');
-// === 7) MONTAJE AUTOMÁTICO DE ENDPOINTS LEGACY ===
-// Para NO PERDER NADA de tu archivo original:
-// 1) Crea services/app/src/routes.legacy.js y exporta por defecto una función:
-// export default function mount(app, ctx) { /* pega aquí TODOS tus app.get/post/... */ }
-// // ctx trae: { requireAuth, withTenant, done, mainPool, tenantsPool, express }
-// 2) O exporta un Router en routes.legacy.js como: export const router = Router();
-// 3) Este bloque intentará montarlo si existe.
-try {
- const legacy = await import('./routes.legacy.js');
- if (legacy?.default) {
- legacy.default(app, { requireAuth, withTenant, done, mainPool, tenantsPool, express });
- console.log('✔ Endpoints legacy montados (función default)');
- } else if (legacy?.router) {
- app.use(legacy.router);
- console.log('✔ Endpoints legacy montados (router)');
- }
-} catch {
- console.log('ℹ No se encontró routes.legacy.js; continúa sólo con las rutas nuevas');
-}
+ res.type('html').send(`
+
+
SuiteCoffee · Definir contraseña
+
+
+
Definir contraseña
+
+
+ `);
+});
+
+// -----------------------------------------------------------------------------
+// Montar rutas legacy (render de EJS y APIs de negocio)
+// -----------------------------------------------------------------------------
+const legacy = await import('./routes.legacy.js');
+legacy.default(app, {
+ requireAuth,
+ withTenant,
+ done,
+ mainPool,
+ tenantsPool,
+ express,
+});
+
+// -----------------------------------------------------------------------------
+// Health + 404 + errores
+// -----------------------------------------------------------------------------
+app.get('/health', (_req, res) => res.status(200).json({ status: 'ok', service: 'app' }));
-// === 8) 404 + MANEJO DE ERRORES ===
app.use((req, res) => res.status(404).json({ error: 'not-found', path: req.originalUrl }));
+
app.use((err, _req, res, _next) => {
- console.error('❌ Error APP:', err);
+ console.error('[APP] Error:', err);
if (res.headersSent) return;
res.status(500).json({ error: 'internal-error', detail: err?.message || String(err) });
});
-// === 9) ARRANQUE ===
-const PORT = Number(process.env.APP_LOCAL_PORT || process.env.PORT || 4000);
-(async () => {
- console.log(`Entorno -> ${ENV.toUpperCase()} | Puerto -> ${PORT}`);
- await verificarConexion();
- app.listen(PORT, () => console.log(`SuiteCoffee APP escuchando en ${chalk.yellow(`http://localhost:${PORT}`)}`));
-})();
+// -----------------------------------------------------------------------------
+// Arranque
+// -----------------------------------------------------------------------------
+const PORT = Number(process.env.PORT || process.env.APP_LOCAL_PORT || 3030);
+app.listen(PORT, () => {
+ console.log(`[APP] SuiteCoffee corriendo en http://localhost:${PORT}`);
+});
diff --git a/services/auth/src/views/login.ejs b/services/app/src/views/login.ejs
similarity index 100%
rename from services/auth/src/views/login.ejs
rename to services/app/src/views/login.ejs
diff --git a/services/auth/.env.development b/services/auth/.env.development
new file mode 100644
index 0000000..f22c778
--- /dev/null
+++ b/services/auth/.env.development
@@ -0,0 +1,103 @@
+# ===== Runtime =====
+NODE_ENV=development
+PORT=4040
+AUTH_LOCAL_PORT=4040 # coincide con 'expose' del servicio auth
+
+# ===== Session (usa el Redis del stack) =====
+# Para DEV podemos reutilizar el Redis de Authentik. En prod conviene uno separado.
+SESSION_SECRET=pon-una-clave-larga-y-unica
+REDIS_URL=redis://authentik-redis:6379
+
+# ===== DB principal (metadatos de SuiteCoffee) =====
+# Usa el alias de red del servicio 'db' (compose: aliases [dev-db])
+DB_HOST=dev-db
+DB_PORT=5432
+DB_NAME=dev-suitecoffee
+DB_USER=dev-user-suitecoffee
+DB_PASS=dev-pass-suitecoffee
+
+# ===== DB tenants (Tenants de SuiteCoffee) =====
+TENANTS_HOST=dev-tenants
+TENANTS_DB=dev-postgres
+TENANTS_USER=dev-user-postgres
+TENANTS_PASS=dev-pass-postgres
+TENANTS_PORT=5432
+
+TENANT_INIT_SQL=/home/mateo/SuiteCoffee/services/auth/src/db/initTenant.sql
+# TENANT_INIT_SQL=~/SuiteCoffee/services/app/src/db/01_init.sql
+
+# ===== (Opcional) Colores UI, si alguna vista los lee =====
+COL_PRI=452D19 # Marrón oscuro
+COL_SEC=D7A666 # Crema / Café
+COL_BG=FFA500 # Naranja
+
+# ===== Authentik — Admin API (server-to-server dentro de la red) =====
+# Usa el alias de red del servicio 'authentik' y su puerto interno 9000
+AUTHENTIK_BASE_URL=http://authentik:9000
+AUTHENTIK_TOKEN=eE3bFTLd4Rpt3ZkcidTC1EppDYMIr023ev3SXt4ImHynOfAGRVtAZVBXSNxj
+AUTHENTIK_DEFAULT_GROUP_NAME=suitecoffee-users
+
+# ===== OIDC (DEBE coincidir con el Provider) =====
+# DEV (todo dentro de la red de Docker):
+# - El auth service redirige al navegador a este issuer. Si NO tenés reverse proxy hacia Authentik,
+# esta URL interna NO será accesible desde el navegador del host. En ese caso, ver nota más abajo.
+OIDC_ISSUER=https://authentik.suitecoffee.mateosaldain.uy/application/o/suitecoffee/
+OIDC_CLIENT_ID=ydnp9s9I7G4p9Pwt5OsNlcpk1VKB9auN7AxqqNjC
+OIDC_CLIENT_SECRET=yqdI00kYMeQF8VdmhwN5QWUzPLUzRBYeeAH193FynuVD19mo1nBRf5c5IRojzPrxDS0Hk33guUwHFzaj8vjTbTRetwK528uNJ6BfrYGUN2vzxgdMHFLQOHSTR0gR1LtG
+
+# Redirect URI que definiste en el Provider. Usa el alias de red del servicio 'auth' (dev-auth)
+# Si accedés desde el host sin proxy, usa mejor http://localhost:4040/auth/callback y añadilo al Provider.
+OIDC_REDIRECT_URI=https://suitecoffee.mateosaldain.uy/auth/callback
+
+# Cómo querés que maneje la contraseña Authentik para usuarios NUEVOS creados por tu backend:
+# - TEMP_FORCE_CHANGE: crea un password temporal y obliga a cambiar en el primer login (recomendado si usás login con usuario/clave)
+# - INVITE_LINK: envías/entregás un link de “establecer contraseña” (necesita flow de Enrollment/Recovery y SMTP configurado)
+# - SSO_ONLY: no setea password local; login solo por Google/Microsoft/WebAuthn
+AK_PASSWORD_MODE=TEMP_FORCE_CHANGE
+
+# (Opcional) longitud del password temporal
+AK_TEMP_PW_LENGTH=12
+
+
+# 3) Configuración en Authentik (por modo)
+ # A) TEMP_FORCE_CHANGE (password temporal + cambio obligado)
+ # Flow de Autenticación
+ # Entra al Admin de Authentik → Flows → tu Authentication Flow (el que usa tu Provider OIDC).
+ # Asegurate de que tenga:
+ # Identification Stage (identifica por email/username),
+ # Password Stage (para escribir contraseña).
+ # Con eso, cuando el usuario entre con la clave temporal, Authentik le pedirá cambiarla.
+ # Provider OIDC (suitecoffee)
+ # Admin → Applications → Providers → tu provider de SuiteCoffee → Flow settings
+ # Authentication flow: seleccioná el de arriba.
+ # (Opcional) Email SMTP
+ # Si querés notificar o enviar contraseñas temporales/enlaces desde Authentik, configura SMTP en Admin → System → Email.
+ # Resultado: el usuario se registra en tu app → lo redirigís a /auth/login → Authentik pide email+clave → entra con la temporal → obliga a cambiarla → vuelve a tu app.
+
+ # B) INVITE_LINK (enlace de “establecer contraseña”)
+ # SMTP
+ # Admin → System → Email: configura SMTP (host, puerto, credenciales, remitente).
+ # Flow de Enrollment/Recovery
+ # Admin → Flows → clona/crea un flow de Enrollment/Recovery con:
+ # Identification Stage (email/username),
+ # Email Stage (envía el link con token),
+ # Password Stage (para que defina su clave),
+ # (opcional) Prompt/ User Write para confirmar.
+ # Guardalo con un Slug fácil (ej. enroll-set-password).
+ # Cómo usarlo
+ # Caminos:
+ # Manual desde UI: Admin → Directory → Invitations → crear invitación, elegir Flow enroll-set-password, seleccionar usuario, copiar link y enviar.
+ # Automático (más adelante): podemos automatizar por API la creación de una Invitation y envío de mail. (Si querés, te armo el helper akCreateInvitation(userUUID, flowSlug).)
+ # Resultado: el registro en tu app no pone password; el usuario recibe un link para establecer la clave y desde ahí inicia normalmente.
+
+ # C) SSO_ONLY (sin contraseñas locales)
+ # Configura un Source (Google Workspace / Microsoft Entra / WebAuthn):
+ # Admin → Directory → Sources: crea el Source (por ejemplo, Google OAuth o Entra ID).
+ # Activa Create users (para que se creen en Authentik si no existen).
+ # Mapea email y name.
+ # Authentication Flow
+ # Agrega una Source Stage del proveedor (Google/Microsoft/WebAuthn) en tu Authentication Flow.
+ # (Podés dejar Password Stage deshabilitado si querés solo SSO.)
+ # Provider OIDC
+ # En tu Provider suitecoffee, seleccioná ese Authentication Flow.
+ # Resultado: el usuario se registra en tu app → al entrar a /auth/login ve botón Iniciar con Google/Microsoft → hace click, vuelve con sesión, tu backend setea sc.sid.
\ No newline at end of file
diff --git a/services/auth/.env.production b/services/auth/.env.production
new file mode 100644
index 0000000..709cbf7
--- /dev/null
+++ b/services/auth/.env.production
@@ -0,0 +1,22 @@
+NODE_ENV=production # Entorno de desarrollo
+
+PORT=4000 # Variables del servicio -> suitecoffee-app
+
+# AUTH_HOST=prod-auth
+
+DB_HOST=prod-db
+# Nombre de la base de datos
+DB_NAME=suitecoffee
+
+# Usuario y contraseña
+DB_USER=suitecoffee
+DB_PASS=suitecoffee
+
+# Puertos del servicio de db
+DB_LOCAL_PORT=5432
+DB_DOCKER_PORT=5432
+
+# Colores personalizados
+COL_PRI=452D19 # Marrón oscuro
+COL_SEC=D7A666 # Crema / Café
+COL_BG=FFA500 # Naranja
\ No newline at end of file
diff --git a/services/auth/src/ak.js b/services/auth/src/ak.js
index 22545e1..739c7c0 100644
--- a/services/auth/src/ak.js
+++ b/services/auth/src/ak.js
@@ -23,6 +23,29 @@ function getConfig() {
// ------------------------------------------------------------
// Helpers de sincronización
// ------------------------------------------------------------
+
+// -- util GET contra la API admin (ajusta si ya tenés un helper igual)
+async function akGET(path) {
+ const base = (process.env.AUTHENTIK_BASE_URL || '').replace(/\/+$/, '');
+ const url = `${base}${path}`;
+ const res = await fetch(url, {
+ headers: { Authorization: `Bearer ${process.env.AUTHENTIK_TOKEN}` },
+ });
+ if (!res.ok) {
+ const body = await res.text().catch(() => '');
+ throw new Error(`AK GET ${path} -> ${res.status}: ${body}`);
+ }
+ return res.json();
+}
+
+// -- listar grupos con búsqueda por nombre/slug
+export async function akListGroups(search = '') {
+ const q = search ? `?search=${encodeURIComponent(search)}` : '';
+ const data = await akGET(`/api/v3/core/groups/${q}`);
+ // algunas versiones devuelven {results:[]}, otras un array directo
+ return Array.isArray(data) ? data : (data.results || []);
+}
+
export async function akPatchUserAttributes(userPk, partialAttrs = {}) {
// PATCH del usuario para asegurar attributes.tenant_uuid
return akRequest('patch', `/api/v3/core/users/${userPk}/`, {
@@ -196,18 +219,46 @@ export async function akSetPassword(userPk, password, requireChange = true) {
* Helper opcional para obtener grupos por nombre/slug si en el futuro lo necesitas
* (no usado por index.js; se deja por conveniencia).
*/
-export async function akListGroups(search) {
- const data = await request('GET', '/core/groups/', { qs: { search, page_size: 50 }, retries: 2 });
- return Array.isArray(data?.results) ? data.results : [];
-}
export async function akResolveGroupIdByName(name) {
const data = await akListGroups(name);
- const lower = name.toLowerCase();
- const found = data.find(g => (g.name || '').toLowerCase() === lower || (g.slug || '').toLowerCase() === lower);
- return found?.pk || null;
+ const lower = String(name || '').toLowerCase();
+ const found = data.find(g =>
+ String(g.name || '').toLowerCase() === lower ||
+ String(g.slug || '').toLowerCase() === lower
+ );
+ return found?.pk ?? null;
}
+export async function akResolveGroupId({ id, pk, uuid, name, slug } = {}) {
+ // si te pasan pk/id directo, devolvelo
+ if (pk != null) return Number(pk);
+ if (id != null) return Number(id);
+
+ // por UUID (devuelve objeto con pk)
+ if (uuid) {
+ try {
+ const g = await akGET(`/api/v3/core/groups/${encodeURIComponent(uuid)}/`);
+ if (g?.pk != null) return Number(g.pk);
+ } catch (e) {
+ // sigue intentando por nombre/slug
+ }
+ }
+
+ // por nombre/slug
+ if (name || slug) {
+ const needle = (name || slug);
+ const list = await akListGroups(needle);
+ const lower = String(needle || '').toLowerCase();
+ const found = list.find(g =>
+ String(g.name || '').toLowerCase() === lower ||
+ String(g.slug || '').toLowerCase() === lower
+ );
+ if (found?.pk != null) return Number(found.pk);
+ }
+
+ return null;
+}
// ------------------------------------------------------------
// Fin
diff --git a/services/auth/src/db/initTenant.sql b/services/auth/src/db/initTenant.sql
new file mode 100644
index 0000000..a43ab8c
--- /dev/null
+++ b/services/auth/src/db/initTenant.sql
@@ -0,0 +1,2239 @@
+-- ===============================================================
+-- SuiteCoffee — Template de inicialización por tenant
+-- Archivo: 01_init.sql
+-- Uso (psql):
+-- \set SCHEMA_NAME schema_tenant_12345678abcd
+-- CREATE SCHEMA IF NOT EXISTS :"SCHEMA_NAME";
+-- -- Opcional: SET ROLE ;
+-- \i 01_init.sql
+-- ===============================================================
+
+BEGIN;
+SET client_encoding = 'UTF8';
+SET standard_conforming_strings = on;
+SET check_function_bodies = false;
+SET client_min_messages = warning;
+SET row_security = off;
+
+-- establece el schema de destino
+SET search_path = :"SCHEMA_NAME", public;
+
+--
+-- PostgreSQL database dump
+--
+
+\restrict londHmqT4llS8Wof4ZnceO2dyFhn4jiR5xbaszMgZpMczgr6aVW6xQJxeUdqJwa
+
+-- Dumped from database version 16.10 (Debian 16.10-1.pgdg13+1)
+-- Dumped by pg_dump version 16.10 (Debian 16.10-1.pgdg13+1)
+
+SET statement_timeout = 0;
+SET lock_timeout = 0;
+SET idle_in_transaction_session_timeout = 0;
+SET client_encoding = 'UTF8';
+SET standard_conforming_strings = on;
+SET check_function_bodies = false;
+SET xmloption = content;
+SET client_min_messages = warning;
+SET row_security = off;
+
+--
+-- Name: asistencia_delete_raw(bigint, text); Type: FUNCTION; Schema: public; Owner: manso
+--
+
+CREATE FUNCTION asistencia_delete_raw(p_id_raw bigint, p_tz text DEFAULT 'America/Montevideo'::text) RETURNS jsonb
+ LANGUAGE plpgsql
+ AS $$
+DECLARE
+ v_id_usuario INT;
+ v_ts TIMESTAMPTZ;
+ v_t0 TIMESTAMPTZ;
+ v_t1 TIMESTAMPTZ;
+ v_del_raw INT;
+ v_del INT;
+ v_ins INT;
+BEGIN
+ SELECT id_usuario, ts INTO v_id_usuario, v_ts
+ FROM asistencia_raw WHERE id_raw = p_id_raw;
+ IF v_id_usuario IS NULL THEN
+ RETURN jsonb_build_object('deleted',0,'msg','id_raw inexistente');
+ END IF;
+
+ v_t0 := v_ts - INTERVAL '1 day';
+ v_t1 := v_ts + INTERVAL '1 day';
+
+ -- borrar raw
+ DELETE FROM asistencia_raw WHERE id_raw = p_id_raw;
+ GET DIAGNOSTICS v_del_raw = ROW_COUNT;
+
+ -- recomputar pares en ventana
+ WITH tl AS (
+ SELECT ar.ts,
+ ROW_NUMBER() OVER (ORDER BY ar.ts) AS rn
+ FROM asistencia_raw ar
+ WHERE ar.id_usuario = v_id_usuario
+ AND ar.ts BETWEEN v_t0 AND v_t1
+ ),
+ ready AS (
+ SELECT (t1.ts AT TIME ZONE p_tz)::date AS fecha,
+ t1.ts AS desde,
+ t2.ts AS hasta,
+ EXTRACT(EPOCH FROM (t2.ts - t1.ts))/60.0 AS dur_min
+ FROM tl t1
+ JOIN tl t2 ON t2.rn = t1.rn + 1
+ WHERE (t1.rn % 2) = 1 AND t2.ts > t1.ts
+ ),
+ del AS (
+ DELETE FROM asistencia_intervalo ai
+ WHERE ai.id_usuario = v_id_usuario
+ AND (ai.desde BETWEEN v_t0 AND v_t1 OR ai.hasta BETWEEN v_t0 AND v_t1)
+ RETURNING 1
+ ),
+ ins AS (
+ INSERT INTO asistencia_intervalo (id_usuario, fecha, desde, hasta, dur_min, origen)
+ SELECT v_id_usuario, r.fecha, r.desde, r.hasta, r.dur_min, 'delete_adjust'
+ FROM ready r
+ ON CONFLICT (id_usuario, desde, hasta) DO NOTHING
+ RETURNING 1
+ )
+ SELECT (SELECT COUNT(*) FROM del), (SELECT COUNT(*) FROM ins) INTO v_del, v_ins;
+
+ RETURN jsonb_build_object('deleted',v_del_raw,'deleted_pairs',v_del,'inserted_pairs',v_ins);
+END;
+$$;
+
+--
+-- Name: asistencia_get(text, date, date, text); Type: FUNCTION; Schema: public; Owner: manso
+--
+
+CREATE FUNCTION asistencia_get(p_doc text, p_desde date, p_hasta date, p_tz text DEFAULT 'America/Montevideo'::text) RETURNS jsonb
+ LANGUAGE sql
+ AS $$
+WITH u AS (
+ SELECT id_usuario, documento, nombre, apellido
+ FROM usuarios
+ WHERE regexp_replace(documento,'^\s*0+','','g') = regexp_replace(p_doc,'^\s*0+','','g')
+ LIMIT 1
+),
+r AS (
+ SELECT ar.id_raw,
+ (ar.ts AT TIME ZONE p_tz)::date AS fecha,
+ to_char(ar.ts AT TIME ZONE p_tz,'HH24:MI:SS') AS hora,
+ COALESCE(ar.modo,'') AS modo,
+ COALESCE(ar.origen,'') AS origen,
+ ar.ts
+ FROM asistencia_raw ar
+ JOIN u USING (id_usuario)
+ WHERE (ar.ts AT TIME ZONE p_tz)::date BETWEEN p_desde AND p_hasta
+),
+i AS (
+ SELECT ai.id_intervalo,
+ ai.fecha,
+ to_char(ai.desde AT TIME ZONE p_tz,'HH24:MI:SS') AS desde_hora,
+ to_char(ai.hasta AT TIME ZONE p_tz,'HH24:MI:SS') AS hasta_hora,
+ ai.dur_min
+ FROM asistencia_intervalo ai
+ JOIN u USING (id_usuario)
+ WHERE ai.fecha BETWEEN p_desde AND p_hasta
+)
+SELECT jsonb_build_object(
+ 'usuario', (SELECT to_jsonb(u.*) FROM u),
+ 'raw', COALESCE((SELECT jsonb_agg(to_jsonb(r.*) ORDER BY r.ts) FROM r),'[]'::jsonb),
+ 'intervalos', COALESCE((SELECT jsonb_agg(to_jsonb(i.*) ORDER BY i.fecha, i.id_intervalo) FROM i),'[]'::jsonb)
+);
+$$;
+
+--
+-- Name: asistencia_update_raw(bigint, date, text, text, text); Type: FUNCTION; Schema: public; Owner: manso
+--
+
+CREATE FUNCTION asistencia_update_raw(p_id_raw bigint, p_fecha date, p_hora text, p_modo text, p_tz text DEFAULT 'America/Montevideo'::text) RETURNS jsonb
+ LANGUAGE plpgsql
+ AS $$
+DECLARE
+ v_id_usuario INT;
+ v_ts_old TIMESTAMPTZ;
+ v_ts_new TIMESTAMPTZ;
+ v_t0 TIMESTAMPTZ;
+ v_t1 TIMESTAMPTZ;
+ v_del INT;
+ v_ins INT;
+BEGIN
+ -- leer estado previo
+ SELECT id_usuario, ts INTO v_id_usuario, v_ts_old
+ FROM asistencia_raw WHERE id_raw = p_id_raw;
+ IF v_id_usuario IS NULL THEN
+ RETURN jsonb_build_object('updated',0,'msg','id_raw inexistente');
+ END IF;
+
+ -- construir ts nuevo
+ v_ts_new := make_timestamptz(
+ EXTRACT(YEAR FROM p_fecha)::INT,
+ EXTRACT(MONTH FROM p_fecha)::INT,
+ EXTRACT(DAY FROM p_fecha)::INT,
+ split_part(p_hora,':',1)::INT,
+ split_part(p_hora,':',2)::INT,
+ COALESCE(NULLIF(split_part(p_hora,':',3),''), '0')::INT,
+ p_tz);
+
+ -- aplicar update
+ UPDATE asistencia_raw
+ SET ts = v_ts_new,
+ modo = COALESCE(p_modo, modo)
+ WHERE id_raw = p_id_raw;
+
+ -- ventana de recálculo
+ v_t0 := LEAST(v_ts_old, v_ts_new) - INTERVAL '1 day';
+ v_t1 := GREATEST(v_ts_old, v_ts_new) + INTERVAL '1 day';
+
+ -- recomputar pares en la ventana: borrar los del rango y reinsertar
+ WITH tl AS (
+ SELECT ar.ts,
+ ROW_NUMBER() OVER (ORDER BY ar.ts) AS rn
+ FROM asistencia_raw ar
+ WHERE ar.id_usuario = v_id_usuario
+ AND ar.ts BETWEEN v_t0 AND v_t1
+ ),
+ ready AS (
+ SELECT (t1.ts AT TIME ZONE p_tz)::date AS fecha,
+ t1.ts AS desde,
+ t2.ts AS hasta,
+ EXTRACT(EPOCH FROM (t2.ts - t1.ts))/60.0 AS dur_min
+ FROM tl t1
+ JOIN tl t2 ON t2.rn = t1.rn + 1
+ WHERE (t1.rn % 2) = 1 AND t2.ts > t1.ts
+ ),
+ del AS (
+ DELETE FROM asistencia_intervalo ai
+ WHERE ai.id_usuario = v_id_usuario
+ AND (ai.desde BETWEEN v_t0 AND v_t1 OR ai.hasta BETWEEN v_t0 AND v_t1)
+ RETURNING 1
+ ),
+ ins AS (
+ INSERT INTO asistencia_intervalo (id_usuario, fecha, desde, hasta, dur_min, origen)
+ SELECT v_id_usuario, r.fecha, r.desde, r.hasta, r.dur_min, 'edit_manual'
+ FROM ready r
+ ON CONFLICT (id_usuario, desde, hasta) DO NOTHING
+ RETURNING 1
+ )
+ SELECT (SELECT COUNT(*) FROM del), (SELECT COUNT(*) FROM ins) INTO v_del, v_ins;
+
+ RETURN jsonb_build_object('updated',1,'deleted_pairs',v_del,'inserted_pairs',v_ins);
+END;
+$$;
+
+--
+-- Name: delete_compra(integer); Type: FUNCTION; Schema: public; Owner: manso
+--
+
+CREATE FUNCTION delete_compra(p_id_compra integer) RETURNS void
+ LANGUAGE plpgsql
+ AS $$
+BEGIN
+ DELETE FROM deta_comp_materias WHERE id_compra = p_id_compra;
+ DELETE FROM deta_comp_producto WHERE id_compra = p_id_compra;
+ DELETE FROM compras WHERE id_compra = p_id_compra;
+END;
+$$;
+
+--
+-- Name: f_abrir_comanda(integer); Type: FUNCTION; Schema: public; Owner: manso
+--
+
+CREATE FUNCTION f_abrir_comanda(p_id integer) RETURNS jsonb
+ LANGUAGE plpgsql
+ AS $$
+DECLARE r jsonb;
+BEGIN
+ UPDATE comandas
+ SET estado = 'abierta',
+ fec_cierre = NULL
+ WHERE id_comanda = p_id;
+
+ IF NOT FOUND THEN
+ RETURN NULL;
+ END IF;
+
+ SELECT to_jsonb(v) INTO r
+ FROM v_comandas_resumen v
+ WHERE v.id_comanda = p_id;
+
+ RETURN r;
+END;
+$$;
+
+--
+-- Name: f_cerrar_comanda(integer); Type: FUNCTION; Schema: public; Owner: manso
+--
+
+CREATE FUNCTION f_cerrar_comanda(p_id integer) RETURNS jsonb
+ LANGUAGE plpgsql
+ AS $$
+DECLARE r jsonb;
+BEGIN
+ UPDATE comandas
+ SET estado = 'cerrada',
+ fec_cierre = COALESCE(fec_cierre, NOW())
+ WHERE id_comanda = p_id;
+
+ IF NOT FOUND THEN
+ RETURN NULL;
+ END IF;
+
+ SELECT to_jsonb(v) INTO r
+ FROM v_comandas_resumen v
+ WHERE v.id_comanda = p_id;
+
+ RETURN r;
+END;
+$$;
+
+--
+-- Name: f_comanda_detalle_json(integer); Type: FUNCTION; Schema: public; Owner: manso
+--
+
+CREATE FUNCTION f_comanda_detalle_json(p_id_comanda integer) RETURNS jsonb
+ LANGUAGE sql
+ AS $$
+WITH base AS (
+ SELECT
+ c.id_comanda,
+ c.fec_creacion,
+ c.estado,
+ c.observaciones,
+ u.id_usuario,
+ u.nombre AS usuario_nombre,
+ u.apellido AS usuario_apellido,
+ m.id_mesa,
+ m.numero AS mesa_numero,
+ m.apodo AS mesa_apodo,
+ d.id_producto,
+ p.nombre AS producto_nombre,
+ d.cantidad,
+ d.pre_unitario,
+ (d.cantidad * d.pre_unitario) AS subtotal
+ FROM comandas c
+ JOIN usuarios u ON u.id_usuario = c.id_usuario
+ JOIN mesas m ON m.id_mesa = c.id_mesa
+ LEFT JOIN deta_comandas d ON d.id_comanda = c.id_comanda
+ LEFT JOIN productos p ON p.id_producto = d.id_producto
+ WHERE c.id_comanda = p_id_comanda
+),
+hdr AS (
+ -- 1 sola fila con los datos de cabecera
+ SELECT DISTINCT
+ id_comanda, fec_creacion, estado, observaciones,
+ id_usuario, usuario_nombre, usuario_apellido,
+ id_mesa, mesa_numero, mesa_apodo
+ FROM base
+),
+agg_items AS (
+ SELECT
+ COALESCE(
+ jsonb_agg(
+ jsonb_build_object(
+ 'producto_id', b.id_producto,
+ 'producto', b.producto_nombre,
+ 'cantidad', b.cantidad,
+ 'pre_unitario', b.pre_unitario,
+ 'subtotal', b.subtotal
+ )
+ ORDER BY b.producto_nombre NULLS LAST
+ ) FILTER (WHERE b.id_producto IS NOT NULL),
+ '[]'::jsonb
+ ) AS items
+ FROM base b
+),
+tot AS (
+ SELECT
+ COUNT(*) FILTER (WHERE id_producto IS NOT NULL) AS items,
+ COALESCE(SUM(subtotal), 0)::numeric AS total
+ FROM base
+)
+SELECT
+ CASE
+ WHEN EXISTS (SELECT 1 FROM hdr) THEN
+ jsonb_build_object(
+ 'id_comanda', h.id_comanda,
+ 'fec_creacion', h.fec_creacion,
+ 'estado', h.estado,
+ 'observaciones',h.observaciones,
+ 'usuario', jsonb_build_object(
+ 'id_usuario', h.id_usuario,
+ 'nombre', h.usuario_nombre,
+ 'apellido', h.usuario_apellido
+ ),
+ 'mesa', jsonb_build_object(
+ 'id_mesa', h.id_mesa,
+ 'numero', h.mesa_numero,
+ 'apodo', h.mesa_apodo
+ ),
+ 'items', i.items,
+ 'totales', jsonb_build_object(
+ 'items', t.items,
+ 'total', t.total
+ )
+ )
+ ELSE NULL
+ END
+FROM hdr h, agg_items i, tot t;
+$$;
+
+--
+-- Name: f_comanda_detalle_rows(integer); Type: FUNCTION; Schema: public; Owner: manso
+--
+
+CREATE FUNCTION f_comanda_detalle_rows(p_id_comanda integer) RETURNS TABLE(id_comanda integer, fec_creacion timestamp without time zone, estado text, observaciones text, id_usuario integer, usuario_nombre text, usuario_apellido text, id_mesa integer, mesa_numero integer, mesa_apodo text, producto_id integer, producto_nombre text, cantidad numeric, pre_unitario numeric, subtotal numeric, items integer, total numeric)
+ LANGUAGE sql
+ AS $$
+WITH base AS (
+ SELECT
+ c.id_comanda, c.fec_creacion, c.estado, c.observaciones,
+ u.id_usuario, u.nombre AS usuario_nombre, u.apellido AS usuario_apellido,
+ m.id_mesa, m.numero AS mesa_numero, m.apodo AS mesa_apodo,
+ d.id_producto, p.nombre AS producto_nombre,
+ d.cantidad, d.pre_unitario,
+ (d.cantidad * d.pre_unitario) AS subtotal
+ FROM comandas c
+ JOIN usuarios u ON u.id_usuario = c.id_usuario
+ JOIN mesas m ON m.id_mesa = c.id_mesa
+ LEFT JOIN deta_comandas d ON d.id_comanda = c.id_comanda
+ LEFT JOIN productos p ON p.id_producto = d.id_producto
+ WHERE c.id_comanda = p_id_comanda
+),
+tot AS (
+ SELECT
+ COUNT(*) FILTER (WHERE id_producto IS NOT NULL) AS items,
+ COALESCE(SUM(subtotal), 0) AS total
+ FROM base
+)
+SELECT
+ b.id_comanda, b.fec_creacion, b.estado, b.observaciones,
+ b.id_usuario, b.usuario_nombre, b.usuario_apellido,
+ b.id_mesa, b.mesa_numero, b.mesa_apodo,
+ b.id_producto, b.producto_nombre,
+ b.cantidad, b.pre_unitario, b.subtotal,
+ t.items, t.total
+FROM base b CROSS JOIN tot t
+ORDER BY b.producto_nombre NULLS LAST;
+$$;
+
+SET default_tablespace = '';
+
+SET default_table_access_method = heap;
+
+--
+-- Name: comandas; Type: TABLE; Schema: public; Owner: manso
+--
+
+CREATE TABLE comandas (
+ id_comanda integer NOT NULL,
+ id_usuario integer NOT NULL,
+ id_mesa integer NOT NULL,
+ fec_creacion timestamp without time zone DEFAULT now() NOT NULL,
+ estado text NOT NULL,
+ observaciones text,
+ fec_cierre timestamp with time zone,
+ CONSTRAINT comandas_estado_check CHECK ((estado = ANY (ARRAY['abierta'::text, 'cerrada'::text, 'pagada'::text, 'anulada'::text])))
+);
+
+--
+-- Name: COLUMN comandas.fec_cierre; Type: COMMENT; Schema: public; Owner: manso
+--
+
+COMMENT ON COLUMN comandas.fec_cierre IS 'Fecha/hora de cierre de la comanda (NULL si está abierta)';
+
+--
+-- Name: deta_comandas; Type: TABLE; Schema: public; Owner: manso
+--
+
+CREATE TABLE deta_comandas (
+ id_det_comanda integer NOT NULL,
+ id_comanda integer NOT NULL,
+ id_producto integer NOT NULL,
+ cantidad numeric(12,3) NOT NULL,
+ pre_unitario numeric(12,2) NOT NULL,
+ observaciones text,
+ CONSTRAINT deta_comandas_cantidad_check CHECK ((cantidad > (0)::numeric)),
+ CONSTRAINT deta_comandas_pre_unitario_check CHECK ((pre_unitario >= (0)::numeric))
+);
+
+--
+-- Name: mesas; Type: TABLE; Schema: public; Owner: manso
+--
+
+CREATE TABLE mesas (
+ id_mesa integer NOT NULL,
+ numero integer NOT NULL,
+ apodo text NOT NULL,
+ estado text DEFAULT 'libre'::text NOT NULL,
+ CONSTRAINT mesas_estado_check CHECK ((estado = ANY (ARRAY['libre'::text, 'ocupada'::text, 'reservada'::text])))
+);
+
+--
+-- Name: usuarios; Type: TABLE; Schema: public; Owner: manso
+--
+
+CREATE TABLE usuarios (
+ id_usuario integer NOT NULL,
+ documento text,
+ img_perfil character varying(255) DEFAULT 'img_perfil.png'::character varying NOT NULL,
+ nombre text NOT NULL,
+ apellido text NOT NULL,
+ correo text,
+ telefono text,
+ fec_nacimiento date,
+ activo boolean DEFAULT true
+);
+
+--
+-- Name: v_comandas_resumen; Type: VIEW; Schema: public; Owner: manso
+--
+
+CREATE VIEW v_comandas_resumen AS
+ WITH items AS (
+ SELECT d.id_comanda,
+ count(*) AS items,
+ sum((d.cantidad * d.pre_unitario)) AS total
+ FROM deta_comandas d
+ GROUP BY d.id_comanda
+ )
+ SELECT c.id_comanda,
+ c.fec_creacion,
+ c.estado,
+ c.observaciones,
+ u.id_usuario,
+ u.nombre AS usuario_nombre,
+ u.apellido AS usuario_apellido,
+ m.id_mesa,
+ m.numero AS mesa_numero,
+ m.apodo AS mesa_apodo,
+ COALESCE(i.items, (0)::bigint) AS items,
+ COALESCE(i.total, (0)::numeric) AS total,
+ c.fec_cierre,
+ CASE
+ WHEN (c.fec_cierre IS NOT NULL) THEN round((EXTRACT(epoch FROM (c.fec_cierre - (c.fec_creacion)::timestamp with time zone)) / 60.0), 1)
+ ELSE NULL::numeric
+ END AS duracion_min
+ FROM (((comandas c
+ JOIN usuarios u ON ((u.id_usuario = c.id_usuario)))
+ JOIN mesas m ON ((m.id_mesa = c.id_mesa)))
+ LEFT JOIN items i ON ((i.id_comanda = c.id_comanda)));
+
+--
+-- Name: f_comandas_resumen(text, integer); Type: FUNCTION; Schema: public; Owner: manso
+--
+
+CREATE FUNCTION f_comandas_resumen(p_estado text DEFAULT NULL::text, p_limit integer DEFAULT 200) RETURNS SETOF v_comandas_resumen
+ LANGUAGE sql
+ AS $$
+ SELECT *
+ FROM v_comandas_resumen
+ WHERE (p_estado IS NULL OR estado = p_estado)
+ ORDER BY id_comanda DESC
+ LIMIT p_limit;
+$$;
+
+--
+-- Name: find_usuarios_por_documentos(jsonb); Type: FUNCTION; Schema: public; Owner: manso
+--
+
+CREATE FUNCTION find_usuarios_por_documentos(p_docs jsonb) RETURNS jsonb
+ LANGUAGE sql
+ AS $$
+WITH docs AS (
+ SELECT DISTINCT
+ regexp_replace(value::text, '^\s*0+', '', 'g') AS doc_clean,
+ value::text AS original
+ FROM jsonb_array_elements_text(COALESCE(p_docs,'[]'))
+),
+rows AS (
+ SELECT d.original AS documento,
+ u.nombre,
+ u.apellido,
+ (u.id_usuario IS NOT NULL) AS found
+ FROM docs d
+ LEFT JOIN usuarios u
+ ON regexp_replace(u.documento, '^\s*0+', '', 'g') = d.doc_clean
+)
+SELECT COALESCE(
+ jsonb_object_agg(
+ documento,
+ jsonb_build_object(
+ 'nombre', COALESCE(nombre, ''),
+ 'apellido', COALESCE(apellido, ''),
+ 'found', found
+ )
+ ),
+ '{}'::jsonb
+)
+FROM rows;
+$$;
+
+--
+-- Name: get_compra(integer); Type: FUNCTION; Schema: public; Owner: manso
+--
+
+CREATE FUNCTION get_compra(p_id_compra integer) RETURNS jsonb
+ LANGUAGE sql
+ AS $$
+WITH cab AS (
+ SELECT c.id_compra, c.id_proveedor, c.fec_compra, c.total
+ FROM compras c
+ WHERE c.id_compra = p_id_compra
+),
+dm AS (
+ SELECT 'MAT'::text AS tipo, d.id_mat_prima AS id,
+ d.cantidad, d.pre_unitario AS precio
+ FROM deta_comp_materias d WHERE d.id_compra = p_id_compra
+),
+dp AS (
+ SELECT 'PROD'::text AS tipo, d.id_producto AS id,
+ d.cantidad, d.pre_unitario AS precio
+ FROM deta_comp_producto d WHERE d.id_compra = p_id_compra
+),
+det AS (
+ SELECT jsonb_agg(to_jsonb(x.*)) AS detalles
+ FROM (
+ SELECT * FROM dm
+ UNION ALL
+ SELECT * FROM dp
+ ) x
+)
+SELECT jsonb_build_object(
+ 'id_compra', (SELECT id_compra FROM cab),
+ 'id_proveedor',(SELECT id_proveedor FROM cab),
+ 'fec_compra', to_char((SELECT fec_compra FROM cab),'YYYY-MM-DD HH24:MI:SS'),
+ 'total', (SELECT total FROM cab),
+ 'detalles', COALESCE((SELECT detalles FROM det),'[]'::jsonb)
+);
+$$;
+
+--
+-- Name: get_materia_prima(integer); Type: FUNCTION; Schema: public; Owner: manso
+--
+
+CREATE FUNCTION get_materia_prima(p_id integer) RETURNS jsonb
+ LANGUAGE sql
+ AS $$
+SELECT jsonb_build_object(
+ 'materia', to_jsonb(mp),
+ 'proveedores', COALESCE(
+ (
+ SELECT jsonb_agg(
+ jsonb_build_object(
+ 'id_proveedor', pr.id_proveedor,
+ 'raz_social', pr.raz_social,
+ 'rut', pr.rut,
+ 'contacto', pr.contacto,
+ 'direccion', pr.direccion
+ )
+ )
+ FROM prov_mate_prima pmp
+ JOIN proveedores pr ON pr.id_proveedor = pmp.id_proveedor
+ WHERE pmp.id_mat_prima = mp.id_mat_prima
+ ),
+ '[]'::jsonb
+ )
+)
+FROM mate_primas mp
+WHERE mp.id_mat_prima = p_id;
+$$;
+
+--
+-- Name: get_producto(integer); Type: FUNCTION; Schema: public; Owner: manso
+--
+
+CREATE FUNCTION get_producto(p_id integer) RETURNS jsonb
+ LANGUAGE sql
+ AS $$
+SELECT jsonb_build_object(
+ 'producto', to_jsonb(p), -- el registro completo del producto en JSONB
+ 'receta', COALESCE(
+ (
+ SELECT jsonb_agg(
+ jsonb_build_object(
+ 'id_mat_prima', rp.id_mat_prima,
+ 'qty_por_unidad', rp.qty_por_unidad,
+ 'nombre', mp.nombre,
+ 'unidad', mp.unidad
+ )
+ )
+ FROM receta_producto rp
+ LEFT JOIN mate_primas mp USING (id_mat_prima)
+ WHERE rp.id_producto = p.id_producto
+ ),
+ '[]'::jsonb
+ )
+)
+FROM productos p
+WHERE p.id_producto = p_id;
+$$;
+
+--
+-- Name: import_asistencia(jsonb, text, text); Type: FUNCTION; Schema: public; Owner: manso
+--
+
+CREATE FUNCTION import_asistencia(p_registros jsonb, p_origen text, p_tz text DEFAULT 'America/Montevideo'::text) RETURNS jsonb
+ LANGUAGE plpgsql
+ AS $_$
+DECLARE
+ v_ins_raw INT;
+ v_ins_pairs INT;
+ v_miss JSONB;
+BEGIN
+ WITH
+ -- 1) JSON -> filas
+ j AS (
+ SELECT
+ regexp_replace(elem->>'doc','^\s*0+','','g')::TEXT AS doc_clean,
+ (elem->>'isoDate')::DATE AS d,
+ elem->>'time' AS time_str,
+ NULLIF(elem->>'mode','') AS modo
+ FROM jsonb_array_elements(COALESCE(p_registros,'[]')) elem
+ ),
+ -- 2) Vincular a usuarios
+ u AS (
+ SELECT j.*, u.id_usuario
+ FROM j
+ LEFT JOIN usuarios u
+ ON regexp_replace(u.documento,'^\s*0+','','g') = j.doc_clean
+ ),
+ -- 3) Documentos faltantes
+ miss AS (
+ SELECT jsonb_agg(doc_clean) AS missing
+ FROM u WHERE id_usuario IS NULL
+ ),
+ -- 4) TS determinista en TZ del negocio
+ parsed AS (
+ SELECT
+ u.id_usuario,
+ u.modo,
+ make_timestamptz(
+ EXTRACT(YEAR FROM u.d)::INT,
+ EXTRACT(MONTH FROM u.d)::INT,
+ EXTRACT(DAY FROM u.d)::INT,
+ split_part(u.time_str,':',1)::INT,
+ split_part(u.time_str,':',2)::INT,
+ COALESCE(NULLIF(split_part(u.time_str,':',3),''),'0')::INT,
+ p_tz
+ ) AS ts_calc
+ FROM u
+ WHERE u.id_usuario IS NOT NULL
+ ),
+ -- 5) Ventana por usuario (±1 día de lo importado)
+ win AS (
+ SELECT id_usuario,
+ (MIN(ts_calc) - INTERVAL '1 day') AS t0,
+ (MAX(ts_calc) + INTERVAL '1 day') AS t1
+ FROM parsed
+ GROUP BY id_usuario
+ ),
+ -- 6) Lo existente en BD dentro de la ventana
+ existing AS (
+ SELECT ar.id_usuario, ar.ts
+ FROM asistencia_raw ar
+ JOIN win w ON w.id_usuario = ar.id_usuario
+ AND ar.ts BETWEEN w.t0 AND w.t1
+ ),
+ -- 7) CANDIDATE = existente ∪ archivo (sin duplicados)
+ candidate AS (
+ SELECT id_usuario, ts FROM existing
+ UNION -- ¡clave para evitar doble click!
+ SELECT id_usuario, ts_calc AS ts FROM parsed
+ ),
+ -- 8) Paridad previa (cuántas marcas había ANTES de la ventana)
+ before_cnt AS (
+ SELECT w.id_usuario, COUNT(*)::int AS cnt
+ FROM win w
+ JOIN asistencia_raw ar
+ ON ar.id_usuario = w.id_usuario
+ AND ar.ts < w.t0
+ GROUP BY w.id_usuario
+ ),
+ -- 9) Línea de tiempo candidata y pares (1→2, 3→4…), jornada = día local del inicio
+ timeline AS (
+ SELECT
+ c.id_usuario,
+ c.ts,
+ ROW_NUMBER() OVER (PARTITION BY c.id_usuario ORDER BY c.ts) AS rn
+ FROM candidate c
+ ),
+ ready AS (
+ SELECT
+ t1.id_usuario,
+ (t1.ts AT TIME ZONE p_tz)::date AS fecha,
+ t1.ts AS desde,
+ t2.ts AS hasta,
+ EXTRACT(EPOCH FROM (t2.ts - t1.ts))/60.0 AS dur_min
+ FROM timeline t1
+ JOIN timeline t2
+ ON t2.id_usuario = t1.id_usuario
+ AND t2.rn = t1.rn + 1
+ LEFT JOIN before_cnt b ON b.id_usuario = t1.id_usuario
+ WHERE ((COALESCE(b.cnt,0) + t1.rn) % 2) = 1 -- t1 es IN global
+ AND t2.ts > t1.ts
+ ),
+ -- 10) INSERT crudo (dedupe)
+ ins_raw AS (
+ INSERT INTO asistencia_raw (id_usuario, ts, modo, origen)
+ SELECT id_usuario, ts_calc,
+ NULLIF(modo,'')::text, -- puede quedar NULL para auto-etiquetado
+ p_origen
+ FROM parsed
+ ON CONFLICT (id_usuario, ts) DO NOTHING
+ RETURNING 1
+ ),
+ -- 11) Auto-etiquetar IN/OUT en BD para filas con modo vacío/'1' (tras insertar)
+ before_cnt2 AS (
+ SELECT w.id_usuario, COUNT(*)::int AS cnt
+ FROM win w
+ JOIN asistencia_raw ar
+ ON ar.id_usuario = w.id_usuario
+ AND ar.ts < w.t0
+ GROUP BY w.id_usuario
+ ),
+ tl2 AS (
+ SELECT
+ ar.id_usuario, ar.ts,
+ ROW_NUMBER() OVER (PARTITION BY ar.id_usuario ORDER BY ar.ts) AS rn
+ FROM asistencia_raw ar
+ JOIN win w ON w.id_usuario = ar.id_usuario
+ AND ar.ts BETWEEN w.t0 AND w.t1
+ ),
+ label2 AS (
+ SELECT
+ t.id_usuario,
+ t.ts,
+ CASE WHEN ((COALESCE(b.cnt,0) + t.rn) % 2) = 1 THEN 'IN' ELSE 'OUT' END AS new_mode
+ FROM tl2 t
+ LEFT JOIN before_cnt2 b ON b.id_usuario = t.id_usuario
+ ),
+ set_mode AS (
+ UPDATE asistencia_raw ar
+ SET modo = l.new_mode
+ FROM label2 l
+ WHERE ar.id_usuario = l.id_usuario
+ AND ar.ts = l.ts
+ AND (ar.modo IS NULL OR btrim(ar.modo) = '' OR ar.modo ~ '^\s*1\s*$')
+ RETURNING 1
+ ),
+ -- 12) INSERT pares (dedupe) calculados desde CANDIDATE (ya tiene todo el contexto)
+ ins_pairs AS (
+ INSERT INTO asistencia_intervalo (id_usuario, fecha, desde, hasta, dur_min, origen)
+ SELECT id_usuario, fecha, desde, hasta, dur_min, p_origen
+ FROM ready
+ ON CONFLICT (id_usuario, desde, hasta) DO NOTHING
+ RETURNING 1
+ )
+ SELECT
+ (SELECT COUNT(*) FROM ins_raw),
+ (SELECT COUNT(*) FROM ins_pairs),
+ (SELECT COALESCE(missing,'[]'::jsonb) FROM miss)
+ INTO v_ins_raw, v_ins_pairs, v_miss;
+
+ RETURN jsonb_build_object(
+ 'inserted_raw', v_ins_raw,
+ 'inserted_pairs', v_ins_pairs,
+ 'missing_docs', v_miss
+ );
+END;
+$_$;
+
+--
+-- Name: report_asistencia(date, date); Type: FUNCTION; Schema: public; Owner: manso
+--
+
+CREATE FUNCTION report_asistencia(p_desde date, p_hasta date) RETURNS TABLE(documento text, nombre text, apellido text, fecha date, desde_hora text, hasta_hora text, dur_min numeric)
+ LANGUAGE sql
+ AS $$
+ SELECT
+ u.documento, u.nombre, u.apellido,
+ ai.fecha,
+ to_char(ai.desde AT TIME ZONE 'America/Montevideo','HH24:MI:SS') AS desde_hora,
+ to_char(ai.hasta AT TIME ZONE 'America/Montevideo','HH24:MI:SS') AS hasta_hora,
+ ai.dur_min
+ FROM asistencia_intervalo ai
+ JOIN usuarios u USING (id_usuario)
+ WHERE ai.fecha BETWEEN p_desde AND p_hasta
+ ORDER BY u.documento, ai.fecha, ai.desde;
+$$;
+
+--
+-- Name: report_gastos(integer); Type: FUNCTION; Schema: public; Owner: manso
+--
+
+CREATE FUNCTION report_gastos(p_year integer) RETURNS jsonb
+ LANGUAGE sql STABLE
+ AS $$
+WITH mdata AS (
+ SELECT date_trunc('month', c.fec_compra)::date AS m,
+ SUM(c.total)::numeric AS importe
+ FROM compras c
+ WHERE EXTRACT(YEAR FROM c.fec_compra) = p_year
+ GROUP BY 1
+),
+mm AS (
+ SELECT EXTRACT(MONTH FROM m)::int AS mes, importe
+ FROM mdata
+)
+SELECT jsonb_build_object(
+ 'year', p_year,
+ 'total', COALESCE((SELECT SUM(importe) FROM mdata), 0),
+ 'avg', COALESCE((SELECT AVG(importe) FROM mdata), 0),
+ 'months',
+ (SELECT jsonb_agg(
+ jsonb_build_object(
+ 'mes', gs,
+ 'nombre', to_char(to_date(gs::text,'MM'),'Mon'),
+ 'importe', COALESCE(mm.importe,0)
+ )
+ ORDER BY gs
+ )
+ FROM generate_series(1,12) gs
+ LEFT JOIN mm ON mm.mes = gs)
+);
+$$;
+
+--
+-- Name: report_tickets_year(integer, text); Type: FUNCTION; Schema: public; Owner: manso
+--
+
+CREATE FUNCTION report_tickets_year(p_year integer, p_tz text DEFAULT 'America/Montevideo'::text) RETURNS jsonb
+ LANGUAGE sql STABLE
+ AS $$
+WITH bounds AS (
+ SELECT
+ make_timestamp(p_year, 1, 1, 0,0,0) AS d0,
+ make_timestamp(p_year+1, 1, 1, 0,0,0) AS d1,
+ make_timestamptz(p_year, 1, 1, 0,0,0, p_tz) AS t0,
+ make_timestamptz(p_year+1, 1, 1, 0,0,0, p_tz) AS t1
+),
+base AS (
+ SELECT
+ c.id_comanda,
+ CASE WHEN c.fec_cierre IS NOT NULL
+ THEN (c.fec_cierre AT TIME ZONE p_tz)
+ ELSE c.fec_creacion
+ END AS fec_local,
+ v.total
+ FROM comandas c
+ JOIN vw_ticket_total v ON v.id_comanda = c.id_comanda
+ JOIN bounds b ON TRUE
+ WHERE
+ (c.fec_cierre IS NOT NULL AND c.fec_cierre >= b.t0 AND c.fec_cierre < b.t1)
+ OR
+ (c.fec_cierre IS NULL AND c.fec_creacion >= b.d0 AND c.fec_creacion < b.d1)
+),
+m AS (
+ SELECT
+ EXTRACT(MONTH FROM fec_local)::int AS mes,
+ COUNT(*)::int AS cant,
+ SUM(total)::numeric AS importe,
+ AVG(total)::numeric AS avg
+ FROM base
+ GROUP BY 1
+),
+ytd AS (
+ SELECT COUNT(*)::int AS total_ytd,
+ AVG(total)::numeric AS avg_ticket,
+ SUM(total)::numeric AS to_date
+ FROM base
+)
+SELECT jsonb_build_object(
+ 'year', p_year,
+ 'total_ytd', (SELECT total_ytd FROM ytd),
+ 'avg_ticket', (SELECT avg_ticket FROM ytd),
+ 'to_date', (SELECT to_date FROM ytd),
+ 'months',
+ (SELECT jsonb_agg(
+ jsonb_build_object(
+ 'mes', mes,
+ 'nombre', to_char(to_date(mes::text,'MM'),'Mon'),
+ 'cant', cant,
+ 'importe', importe,
+ 'avg', avg
+ )
+ ORDER BY mes
+ )
+ FROM m)
+);
+$$;
+
+--
+-- Name: save_compra(integer, integer, timestamp with time zone, jsonb); Type: FUNCTION; Schema: public; Owner: manso
+--
+
+CREATE FUNCTION save_compra(p_id_compra integer, p_id_proveedor integer, p_fec_compra timestamp with time zone, p_detalles jsonb) RETURNS TABLE(id_compra integer, total numeric)
+ LANGUAGE plpgsql
+ AS $$
+DECLARE
+ v_id INT;
+ v_total numeric := 0;
+BEGIN
+ IF COALESCE(jsonb_array_length(p_detalles),0) = 0 THEN
+ RAISE EXCEPTION 'No hay renglones en la compra';
+ END IF;
+
+ -- Cabecera (insert/update)
+ IF p_id_compra IS NULL THEN
+ INSERT INTO compras (id_proveedor, fec_compra, total)
+ VALUES (p_id_proveedor, COALESCE(p_fec_compra, now()), 0)
+ RETURNING compras.id_compra INTO v_id;
+ ELSE
+ UPDATE compras c
+ SET id_proveedor = p_id_proveedor,
+ fec_compra = COALESCE(p_fec_compra, c.fec_compra)
+ WHERE c.id_compra = p_id_compra
+ RETURNING c.id_compra INTO v_id;
+
+ -- Reemplazamos los renglones
+ DELETE FROM deta_comp_materias d WHERE d.id_compra = v_id;
+ DELETE FROM deta_comp_producto p WHERE p.id_compra = v_id;
+ END IF;
+
+ -- Materias primas (sin CTE: parseo JSON inline)
+ INSERT INTO deta_comp_materias (id_compra, id_mat_prima, cantidad, pre_unitario)
+ SELECT
+ v_id,
+ x.id,
+ x.cantidad,
+ x.precio
+ FROM jsonb_to_recordset(COALESCE(p_detalles, '[]'::jsonb))
+ AS x(tipo text, id int, cantidad numeric, precio numeric)
+ WHERE UPPER(TRIM(x.tipo)) = 'MAT';
+
+ -- Productos (sin CTE)
+ INSERT INTO deta_comp_producto (id_compra, id_producto, cantidad, pre_unitario)
+ SELECT
+ v_id,
+ x.id,
+ x.cantidad,
+ x.precio
+ FROM jsonb_to_recordset(COALESCE(p_detalles, '[]'::jsonb))
+ AS x(tipo text, id int, cantidad numeric, precio numeric)
+ WHERE UPPER(TRIM(x.tipo)) = 'PROD';
+
+ -- Recalcular total (calificado) y redondear a ENTERO
+ SELECT
+ COALESCE( (SELECT SUM(dcm.cantidad*dcm.pre_unitario)
+ FROM deta_comp_materias dcm
+ WHERE dcm.id_compra = v_id), 0)
+ + COALESCE( (SELECT SUM(dcp.cantidad*dcp.pre_unitario)
+ FROM deta_comp_producto dcp
+ WHERE dcp.id_compra = v_id), 0)
+ INTO v_total;
+
+ UPDATE compras c
+ SET total = round(v_total, 0)
+ WHERE c.id_compra = v_id;
+
+ RETURN QUERY SELECT v_id, round(v_total, 0);
+END;
+$$;
+
+--
+-- Name: save_materia_prima(integer, text, text, boolean, jsonb); Type: FUNCTION; Schema: public; Owner: manso
+--
+
+CREATE FUNCTION save_materia_prima(p_id_mat_prima integer, p_nombre text, p_unidad text, p_activo boolean, p_proveedores jsonb DEFAULT '[]'::jsonb) RETURNS integer
+ LANGUAGE plpgsql
+ AS $_$
+DECLARE
+ v_id INT;
+BEGIN
+ IF p_id_mat_prima IS NULL THEN
+ INSERT INTO mate_primas (nombre, unidad, activo)
+ VALUES (p_nombre, p_unidad, COALESCE(p_activo, TRUE))
+ RETURNING mate_primas.id_mat_prima INTO v_id;
+ ELSE
+ UPDATE mate_primas mp
+ SET nombre = p_nombre,
+ unidad = p_unidad,
+ activo = COALESCE(p_activo, TRUE)
+ WHERE mp.id_mat_prima = p_id_mat_prima;
+ v_id := p_id_mat_prima;
+ END IF;
+
+ -- Sincronizar proveedores: borrar todos y re-crear a partir de JSONB
+ DELETE FROM prov_mate_prima pmp WHERE pmp.id_mat_prima = v_id;
+
+ INSERT INTO prov_mate_prima (id_proveedor, id_mat_prima)
+ SELECT (e->>0)::INT AS id_proveedor, -- elementos JSON como enteros (array simple)
+ v_id AS id_mat_prima
+ FROM jsonb_array_elements(COALESCE(p_proveedores, '[]'::jsonb)) AS e
+ WHERE (e->>0) ~ '^\d+$'; -- solo enteros
+
+ RETURN v_id;
+END;
+$_$;
+
+--
+-- Name: save_producto(integer, text, text, numeric, boolean, integer, jsonb); Type: FUNCTION; Schema: public; Owner: manso
+--
+
+CREATE FUNCTION save_producto(p_id_producto integer, p_nombre text, p_img_producto text, p_precio numeric, p_activo boolean, p_id_categoria integer, p_receta jsonb DEFAULT '[]'::jsonb) RETURNS integer
+ LANGUAGE plpgsql
+ AS $_$
+DECLARE
+ v_id INT;
+BEGIN
+ IF p_id_producto IS NULL THEN
+ INSERT INTO productos (nombre, img_producto, precio, activo, id_categoria)
+ VALUES (p_nombre, p_img_producto, p_precio, COALESCE(p_activo, TRUE), p_id_categoria)
+ RETURNING productos.id_producto INTO v_id;
+ ELSE
+ UPDATE productos p
+ SET nombre = p_nombre,
+ img_producto = p_img_producto,
+ precio = p_precio,
+ activo = COALESCE(p_activo, TRUE),
+ id_categoria = p_id_categoria
+ WHERE p.id_producto = p_id_producto;
+ v_id := p_id_producto;
+ END IF;
+
+ -- Limpia receta actual
+ DELETE FROM receta_producto rp WHERE rp.id_producto = v_id;
+
+ -- Inserta SOLO ítems válidos (id entero positivo y cantidad > 0), redondeo a 3 decimales
+ INSERT INTO receta_producto (id_producto, id_mat_prima, qty_por_unidad)
+ SELECT
+ v_id,
+ (rec->>'id_mat_prima')::INT,
+ ROUND((rec->>'qty_por_unidad')::NUMERIC, 3)
+ FROM jsonb_array_elements(COALESCE(p_receta, '[]'::jsonb)) AS rec
+ WHERE
+ (rec->>'id_mat_prima') ~ '^\d+$'
+ AND (rec->>'id_mat_prima')::INT > 0
+ AND (rec->>'qty_por_unidad') ~ '^\d+(\.\d+)?$'
+ AND (rec->>'qty_por_unidad')::NUMERIC > 0;
+
+ RETURN v_id;
+END;
+$_$;
+
+--
+-- Name: asistencia_intervalo; Type: TABLE; Schema: public; Owner: manso
+--
+
+CREATE TABLE asistencia_intervalo (
+ id_intervalo bigint NOT NULL,
+ id_usuario integer NOT NULL,
+ fecha date NOT NULL,
+ desde timestamp with time zone NOT NULL,
+ hasta timestamp with time zone NOT NULL,
+ dur_min numeric(10,2) NOT NULL,
+ origen text,
+ created_at timestamp with time zone DEFAULT now() NOT NULL,
+ CONSTRAINT chk_ai_orden CHECK ((hasta > desde))
+);
+
+--
+-- Name: asistencia_intervalo_id_intervalo_seq; Type: SEQUENCE; Schema: public; Owner: manso
+--
+
+CREATE SEQUENCE asistencia_intervalo_id_intervalo_seq
+ START WITH 1
+ INCREMENT BY 1
+ NO MINVALUE
+ NO MAXVALUE
+ CACHE 1;
+
+--
+-- Name: asistencia_intervalo_id_intervalo_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: manso
+--
+
+ALTER SEQUENCE asistencia_intervalo_id_intervalo_seq OWNED BY asistencia_intervalo.id_intervalo;
+
+--
+-- Name: asistencia_raw; Type: TABLE; Schema: public; Owner: manso
+--
+
+CREATE TABLE asistencia_raw (
+ id_raw bigint NOT NULL,
+ id_usuario integer NOT NULL,
+ ts timestamp with time zone NOT NULL,
+ modo text,
+ origen text,
+ created_at timestamp with time zone DEFAULT now() NOT NULL
+);
+
+--
+-- Name: asistencia_raw_id_raw_seq; Type: SEQUENCE; Schema: public; Owner: manso
+--
+
+CREATE SEQUENCE asistencia_raw_id_raw_seq
+ START WITH 1
+ INCREMENT BY 1
+ NO MINVALUE
+ NO MAXVALUE
+ CACHE 1;
+
+--
+-- Name: asistencia_raw_id_raw_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: manso
+--
+
+ALTER SEQUENCE asistencia_raw_id_raw_seq OWNED BY asistencia_raw.id_raw;
+
+--
+-- Name: asistencia_resumen_diario; Type: VIEW; Schema: public; Owner: manso
+--
+
+CREATE VIEW asistencia_resumen_diario AS
+ SELECT ai.id_usuario,
+ u.documento,
+ u.nombre,
+ u.apellido,
+ ai.fecha,
+ sum(ai.dur_min) AS minutos_dia,
+ round((sum(ai.dur_min) / 60.0), 2) AS horas_dia,
+ count(*) AS pares_dia
+ FROM (asistencia_intervalo ai
+ JOIN usuarios u USING (id_usuario))
+ GROUP BY ai.id_usuario, u.documento, u.nombre, u.apellido, ai.fecha
+ ORDER BY ai.id_usuario, ai.fecha;
+
+--
+-- Name: categorias; Type: TABLE; Schema: public; Owner: manso
+--
+
+CREATE TABLE categorias (
+ id_categoria integer NOT NULL,
+ nombre text NOT NULL,
+ visible boolean DEFAULT true
+);
+
+--
+-- Name: categorias_id_categoria_seq; Type: SEQUENCE; Schema: public; Owner: manso
+--
+
+CREATE SEQUENCE categorias_id_categoria_seq
+ AS integer
+ START WITH 1
+ INCREMENT BY 1
+ NO MINVALUE
+ NO MAXVALUE
+ CACHE 1;
+
+--
+-- Name: categorias_id_categoria_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: manso
+--
+
+ALTER SEQUENCE categorias_id_categoria_seq OWNED BY categorias.id_categoria;
+
+--
+-- Name: clientes; Type: TABLE; Schema: public; Owner: manso
+--
+
+CREATE TABLE clientes (
+ id_cliente integer NOT NULL,
+ nombre text NOT NULL,
+ correo text,
+ telefono text,
+ fec_nacimiento date,
+ activo boolean DEFAULT true
+);
+
+--
+-- Name: clientes_id_cliente_seq; Type: SEQUENCE; Schema: public; Owner: manso
+--
+
+CREATE SEQUENCE clientes_id_cliente_seq
+ AS integer
+ START WITH 1
+ INCREMENT BY 1
+ NO MINVALUE
+ NO MAXVALUE
+ CACHE 1;
+
+--
+-- Name: clientes_id_cliente_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: manso
+--
+
+ALTER SEQUENCE clientes_id_cliente_seq OWNED BY clientes.id_cliente;
+
+--
+-- Name: comandas_id_comanda_seq; Type: SEQUENCE; Schema: public; Owner: manso
+--
+
+CREATE SEQUENCE comandas_id_comanda_seq
+ AS integer
+ START WITH 1
+ INCREMENT BY 1
+ NO MINVALUE
+ NO MAXVALUE
+ CACHE 1;
+
+--
+-- Name: comandas_id_comanda_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: manso
+--
+
+ALTER SEQUENCE comandas_id_comanda_seq OWNED BY comandas.id_comanda;
+
+--
+-- Name: compras; Type: TABLE; Schema: public; Owner: manso
+--
+
+CREATE TABLE compras (
+ id_compra integer NOT NULL,
+ id_proveedor integer NOT NULL,
+ fec_compra timestamp without time zone NOT NULL,
+ total numeric(14,2)
+);
+
+--
+-- Name: compras_id_compra_seq; Type: SEQUENCE; Schema: public; Owner: manso
+--
+
+CREATE SEQUENCE compras_id_compra_seq
+ AS integer
+ START WITH 1
+ INCREMENT BY 1
+ NO MINVALUE
+ NO MAXVALUE
+ CACHE 1;
+
+--
+-- Name: compras_id_compra_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: manso
+--
+
+ALTER SEQUENCE compras_id_compra_seq OWNED BY compras.id_compra;
+
+--
+-- Name: deta_comandas_id_det_comanda_seq; Type: SEQUENCE; Schema: public; Owner: manso
+--
+
+CREATE SEQUENCE deta_comandas_id_det_comanda_seq
+ AS integer
+ START WITH 1
+ INCREMENT BY 1
+ NO MINVALUE
+ NO MAXVALUE
+ CACHE 1;
+
+--
+-- Name: deta_comandas_id_det_comanda_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: manso
+--
+
+ALTER SEQUENCE deta_comandas_id_det_comanda_seq OWNED BY deta_comandas.id_det_comanda;
+
+--
+-- Name: deta_comp_materias; Type: TABLE; Schema: public; Owner: manso
+--
+
+CREATE TABLE deta_comp_materias (
+ id_compra integer NOT NULL,
+ id_mat_prima integer NOT NULL,
+ cantidad numeric(12,3) NOT NULL,
+ pre_unitario numeric(12,2) NOT NULL,
+ CONSTRAINT deta_comp_materias_cantidad_check CHECK ((cantidad > (0)::numeric)),
+ CONSTRAINT deta_comp_materias_pre_unitario_check CHECK ((pre_unitario >= (0)::numeric))
+);
+
+--
+-- Name: deta_comp_producto; Type: TABLE; Schema: public; Owner: manso
+--
+
+CREATE TABLE deta_comp_producto (
+ id_compra integer NOT NULL,
+ id_producto integer NOT NULL,
+ cantidad numeric(12,3) NOT NULL,
+ pre_unitario numeric(12,2) NOT NULL,
+ CONSTRAINT deta_comp_producto_cantidad_check CHECK ((cantidad > (0)::numeric)),
+ CONSTRAINT deta_comp_producto_pre_unitario_check CHECK ((pre_unitario >= (0)::numeric))
+);
+
+--
+-- Name: mate_primas; Type: TABLE; Schema: public; Owner: manso
+--
+
+CREATE TABLE mate_primas (
+ id_mat_prima integer NOT NULL,
+ nombre text NOT NULL,
+ unidad text NOT NULL,
+ activo boolean DEFAULT true
+);
+
+--
+-- Name: mate_primas_id_mat_prima_seq; Type: SEQUENCE; Schema: public; Owner: manso
+--
+
+CREATE SEQUENCE mate_primas_id_mat_prima_seq
+ AS integer
+ START WITH 1
+ INCREMENT BY 1
+ NO MINVALUE
+ NO MAXVALUE
+ CACHE 1;
+
+--
+-- Name: mate_primas_id_mat_prima_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: manso
+--
+
+ALTER SEQUENCE mate_primas_id_mat_prima_seq OWNED BY mate_primas.id_mat_prima;
+
+--
+-- Name: mesas_id_mesa_seq; Type: SEQUENCE; Schema: public; Owner: manso
+--
+
+CREATE SEQUENCE mesas_id_mesa_seq
+ AS integer
+ START WITH 1
+ INCREMENT BY 1
+ NO MINVALUE
+ NO MAXVALUE
+ CACHE 1;
+
+--
+-- Name: mesas_id_mesa_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: manso
+--
+
+ALTER SEQUENCE mesas_id_mesa_seq OWNED BY mesas.id_mesa;
+
+--
+-- Name: productos; Type: TABLE; Schema: public; Owner: manso
+--
+
+CREATE TABLE productos (
+ id_producto integer NOT NULL,
+ nombre text NOT NULL,
+ img_producto character varying(255) DEFAULT 'img/productos/img_producto.png'::character varying NOT NULL,
+ precio integer NOT NULL,
+ activo boolean DEFAULT true,
+ id_categoria integer NOT NULL,
+ CONSTRAINT productos_precio_check CHECK (((precio)::numeric >= (0)::numeric)),
+ CONSTRAINT productos_precio_nn CHECK ((precio >= 0))
+);
+
+--
+-- Name: productos_id_producto_seq; Type: SEQUENCE; Schema: public; Owner: manso
+--
+
+CREATE SEQUENCE productos_id_producto_seq
+ AS integer
+ START WITH 1
+ INCREMENT BY 1
+ NO MINVALUE
+ NO MAXVALUE
+ CACHE 1;
+
+--
+-- Name: productos_id_producto_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: manso
+--
+
+ALTER SEQUENCE productos_id_producto_seq OWNED BY productos.id_producto;
+
+--
+-- Name: prov_mate_prima; Type: TABLE; Schema: public; Owner: manso
+--
+
+CREATE TABLE prov_mate_prima (
+ id_proveedor integer NOT NULL,
+ id_mat_prima integer NOT NULL
+);
+
+--
+-- Name: prov_producto; Type: TABLE; Schema: public; Owner: manso
+--
+
+CREATE TABLE prov_producto (
+ id_proveedor integer NOT NULL,
+ id_producto integer NOT NULL
+);
+
+--
+-- Name: proveedores; Type: TABLE; Schema: public; Owner: manso
+--
+
+CREATE TABLE proveedores (
+ id_proveedor integer NOT NULL,
+ rut text,
+ raz_social text NOT NULL,
+ direccion text,
+ contacto text
+);
+
+--
+-- Name: proveedores_id_proveedor_seq; Type: SEQUENCE; Schema: public; Owner: manso
+--
+
+CREATE SEQUENCE proveedores_id_proveedor_seq
+ AS integer
+ START WITH 1
+ INCREMENT BY 1
+ NO MINVALUE
+ NO MAXVALUE
+ CACHE 1;
+
+--
+-- Name: proveedores_id_proveedor_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: manso
+--
+
+ALTER SEQUENCE proveedores_id_proveedor_seq OWNED BY proveedores.id_proveedor;
+
+--
+-- Name: receta_producto; Type: TABLE; Schema: public; Owner: manso
+--
+
+CREATE TABLE receta_producto (
+ id_producto integer NOT NULL,
+ id_mat_prima integer NOT NULL,
+ qty_por_unidad numeric(12,3) NOT NULL,
+ CONSTRAINT receta_producto_qty_por_unidad_check CHECK ((qty_por_unidad > (0)::numeric))
+);
+
+--
+-- Name: roles; Type: TABLE; Schema: public; Owner: manso
+--
+
+CREATE TABLE roles (
+ id_rol integer NOT NULL,
+ nombre text NOT NULL
+);
+
+--
+-- Name: roles_id_rol_seq; Type: SEQUENCE; Schema: public; Owner: manso
+--
+
+CREATE SEQUENCE roles_id_rol_seq
+ AS integer
+ START WITH 1
+ INCREMENT BY 1
+ NO MINVALUE
+ NO MAXVALUE
+ CACHE 1;
+
+--
+-- Name: roles_id_rol_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: manso
+--
+
+ALTER SEQUENCE roles_id_rol_seq OWNED BY roles.id_rol;
+
+--
+-- Name: usua_roles; Type: TABLE; Schema: public; Owner: manso
+--
+
+CREATE TABLE usua_roles (
+ id_usuario integer NOT NULL,
+ id_rol integer NOT NULL,
+ fec_asignacion timestamp without time zone DEFAULT now(),
+ autor integer,
+ activo boolean DEFAULT true
+);
+
+--
+-- Name: usuarios_id_usuario_seq; Type: SEQUENCE; Schema: public; Owner: manso
+--
+
+CREATE SEQUENCE usuarios_id_usuario_seq
+ AS integer
+ START WITH 1
+ INCREMENT BY 1
+ NO MINVALUE
+ NO MAXVALUE
+ CACHE 1;
+
+--
+-- Name: usuarios_id_usuario_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: manso
+--
+
+ALTER SEQUENCE usuarios_id_usuario_seq OWNED BY usuarios.id_usuario;
+
+--
+-- Name: v_comandas_detalle_base; Type: VIEW; Schema: public; Owner: manso
+--
+
+CREATE VIEW v_comandas_detalle_base AS
+ SELECT c.id_comanda,
+ c.fec_creacion,
+ c.fec_cierre,
+ c.estado,
+ c.observaciones,
+ u.id_usuario,
+ u.nombre AS usuario_nombre,
+ u.apellido AS usuario_apellido,
+ m.id_mesa,
+ m.numero AS mesa_numero,
+ m.apodo AS mesa_apodo,
+ d.id_producto,
+ p.nombre AS producto_nombre,
+ d.cantidad,
+ d.pre_unitario,
+ (d.cantidad * d.pre_unitario) AS subtotal
+ FROM ((((comandas c
+ JOIN usuarios u ON ((u.id_usuario = c.id_usuario)))
+ JOIN mesas m ON ((m.id_mesa = c.id_mesa)))
+ LEFT JOIN deta_comandas d ON ((d.id_comanda = c.id_comanda)))
+ LEFT JOIN productos p ON ((p.id_producto = d.id_producto)));
+
+--
+-- Name: v_comandas_detalle_items; Type: VIEW; Schema: public; Owner: manso
+--
+
+CREATE VIEW v_comandas_detalle_items AS
+ SELECT d.id_comanda,
+ d.id_det_comanda,
+ d.id_producto,
+ p.nombre AS producto_nombre,
+ d.cantidad,
+ d.pre_unitario,
+ (d.cantidad * d.pre_unitario) AS subtotal,
+ d.observaciones
+ FROM (deta_comandas d
+ JOIN productos p ON ((p.id_producto = d.id_producto)));
+
+--
+-- Name: v_comandas_detalle_json; Type: VIEW; Schema: public; Owner: manso
+--
+
+CREATE VIEW v_comandas_detalle_json AS
+ SELECT id_comanda,
+ jsonb_build_object('id_comanda', id_comanda, 'fec_creacion', fec_creacion, 'fec_cierre', fec_cierre, 'estado', estado, 'observaciones', observaciones, 'usuario', jsonb_build_object('id_usuario', id_usuario, 'nombre', usuario_nombre, 'apellido', usuario_apellido), 'mesa', jsonb_build_object('id_mesa', id_mesa, 'numero', mesa_numero, 'apodo', mesa_apodo), 'items', COALESCE(( SELECT jsonb_agg(jsonb_build_object('producto_id', b.id_producto, 'producto', b.producto_nombre, 'cantidad', b.cantidad, 'pre_unitario', b.pre_unitario, 'subtotal', b.subtotal) ORDER BY b.producto_nombre) AS jsonb_agg
+ FROM v_comandas_detalle_base b
+ WHERE ((b.id_comanda = h.id_comanda) AND (b.id_producto IS NOT NULL))), '[]'::jsonb), 'totales', jsonb_build_object('items', COALESCE(( SELECT count(*) AS count
+ FROM v_comandas_detalle_base b
+ WHERE ((b.id_comanda = h.id_comanda) AND (b.id_producto IS NOT NULL))), (0)::bigint), 'total', COALESCE(( SELECT sum(b.subtotal) AS sum
+ FROM v_comandas_detalle_base b
+ WHERE (b.id_comanda = h.id_comanda)), (0)::numeric))) AS data
+ FROM ( SELECT DISTINCT v_comandas_detalle_base.id_comanda,
+ v_comandas_detalle_base.fec_creacion,
+ v_comandas_detalle_base.fec_cierre,
+ v_comandas_detalle_base.estado,
+ v_comandas_detalle_base.observaciones,
+ v_comandas_detalle_base.id_usuario,
+ v_comandas_detalle_base.usuario_nombre,
+ v_comandas_detalle_base.usuario_apellido,
+ v_comandas_detalle_base.id_mesa,
+ v_comandas_detalle_base.mesa_numero,
+ v_comandas_detalle_base.mesa_apodo
+ FROM v_comandas_detalle_base) h;
+
+--
+-- Name: vw_compras; Type: VIEW; Schema: public; Owner: manso
+--
+
+CREATE VIEW vw_compras AS
+ SELECT c.id_compra,
+ c.id_proveedor,
+ p.raz_social AS proveedor,
+ c.fec_compra,
+ c.total
+ FROM (compras c
+ JOIN proveedores p USING (id_proveedor))
+ ORDER BY c.fec_compra DESC, c.id_compra DESC;
+
+--
+-- Name: vw_ticket_total; Type: VIEW; Schema: public; Owner: manso
+--
+
+CREATE VIEW vw_ticket_total AS
+ WITH lineas AS (
+ SELECT c.id_comanda,
+ COALESCE(c.fec_cierre, (c.fec_creacion)::timestamp with time zone) AS fec_ticket,
+ (COALESCE(dc.pre_unitario, (p.precio)::numeric, (0)::numeric))::numeric(14,2) AS pu,
+ (COALESCE(dc.cantidad, (1)::numeric))::numeric(14,3) AS qty
+ FROM ((comandas c
+ JOIN deta_comandas dc ON ((dc.id_comanda = c.id_comanda)))
+ LEFT JOIN productos p ON ((p.id_producto = dc.id_producto)))
+ )
+ SELECT id_comanda,
+ fec_ticket,
+ (sum((qty * pu)))::numeric(14,2) AS total
+ FROM lineas
+ GROUP BY id_comanda, fec_ticket;
+
+--
+-- Name: asistencia_intervalo id_intervalo; Type: DEFAULT; Schema: public; Owner: manso
+--
+
+ALTER TABLE ONLY asistencia_intervalo ALTER COLUMN id_intervalo SET DEFAULT nextval('asistencia_intervalo_id_intervalo_seq'::regclass);
+
+--
+-- Name: asistencia_raw id_raw; Type: DEFAULT; Schema: public; Owner: manso
+--
+
+ALTER TABLE ONLY asistencia_raw ALTER COLUMN id_raw SET DEFAULT nextval('asistencia_raw_id_raw_seq'::regclass);
+
+--
+-- Name: categorias id_categoria; Type: DEFAULT; Schema: public; Owner: manso
+--
+
+ALTER TABLE ONLY categorias ALTER COLUMN id_categoria SET DEFAULT nextval('categorias_id_categoria_seq'::regclass);
+
+--
+-- Name: clientes id_cliente; Type: DEFAULT; Schema: public; Owner: manso
+--
+
+ALTER TABLE ONLY clientes ALTER COLUMN id_cliente SET DEFAULT nextval('clientes_id_cliente_seq'::regclass);
+
+--
+-- Name: comandas id_comanda; Type: DEFAULT; Schema: public; Owner: manso
+--
+
+ALTER TABLE ONLY comandas ALTER COLUMN id_comanda SET DEFAULT nextval('comandas_id_comanda_seq'::regclass);
+
+--
+-- Name: compras id_compra; Type: DEFAULT; Schema: public; Owner: manso
+--
+
+ALTER TABLE ONLY compras ALTER COLUMN id_compra SET DEFAULT nextval('compras_id_compra_seq'::regclass);
+
+--
+-- Name: deta_comandas id_det_comanda; Type: DEFAULT; Schema: public; Owner: manso
+--
+
+ALTER TABLE ONLY deta_comandas ALTER COLUMN id_det_comanda SET DEFAULT nextval('deta_comandas_id_det_comanda_seq'::regclass);
+
+--
+-- Name: mate_primas id_mat_prima; Type: DEFAULT; Schema: public; Owner: manso
+--
+
+ALTER TABLE ONLY mate_primas ALTER COLUMN id_mat_prima SET DEFAULT nextval('mate_primas_id_mat_prima_seq'::regclass);
+
+--
+-- Name: mesas id_mesa; Type: DEFAULT; Schema: public; Owner: manso
+--
+
+ALTER TABLE ONLY mesas ALTER COLUMN id_mesa SET DEFAULT nextval('mesas_id_mesa_seq'::regclass);
+
+--
+-- Name: productos id_producto; Type: DEFAULT; Schema: public; Owner: manso
+--
+
+ALTER TABLE ONLY productos ALTER COLUMN id_producto SET DEFAULT nextval('productos_id_producto_seq'::regclass);
+
+--
+-- Name: proveedores id_proveedor; Type: DEFAULT; Schema: public; Owner: manso
+--
+
+ALTER TABLE ONLY proveedores ALTER COLUMN id_proveedor SET DEFAULT nextval('proveedores_id_proveedor_seq'::regclass);
+
+--
+-- Name: roles id_rol; Type: DEFAULT; Schema: public; Owner: manso
+--
+
+ALTER TABLE ONLY roles ALTER COLUMN id_rol SET DEFAULT nextval('roles_id_rol_seq'::regclass);
+
+--
+-- Name: usuarios id_usuario; Type: DEFAULT; Schema: public; Owner: manso
+--
+
+ALTER TABLE ONLY usuarios ALTER COLUMN id_usuario SET DEFAULT nextval('usuarios_id_usuario_seq'::regclass);
+
+--
+--
+--
+--
+--
+--
+--
+--
+--
+--
+--
+--
+--
+--
+--
+--
+--
+--
+--
+--
+-- Name: asistencia_intervalo_id_intervalo_seq; Type: SEQUENCE SET; Schema: public; Owner: manso
+--
+
+SELECT pg_catalog.setval('asistencia_intervalo_id_intervalo_seq', 108, true);
+
+--
+-- Name: asistencia_raw_id_raw_seq; Type: SEQUENCE SET; Schema: public; Owner: manso
+--
+
+SELECT pg_catalog.setval('asistencia_raw_id_raw_seq', 88, true);
+
+--
+-- Name: categorias_id_categoria_seq; Type: SEQUENCE SET; Schema: public; Owner: manso
+--
+
+SELECT pg_catalog.setval('categorias_id_categoria_seq', 4, true);
+
+--
+-- Name: clientes_id_cliente_seq; Type: SEQUENCE SET; Schema: public; Owner: manso
+--
+
+SELECT pg_catalog.setval('clientes_id_cliente_seq', 1, true);
+
+--
+-- Name: comandas_id_comanda_seq; Type: SEQUENCE SET; Schema: public; Owner: manso
+--
+
+SELECT pg_catalog.setval('comandas_id_comanda_seq', 55, true);
+
+--
+-- Name: compras_id_compra_seq; Type: SEQUENCE SET; Schema: public; Owner: manso
+--
+
+SELECT pg_catalog.setval('compras_id_compra_seq', 66, true);
+
+--
+-- Name: deta_comandas_id_det_comanda_seq; Type: SEQUENCE SET; Schema: public; Owner: manso
+--
+
+SELECT pg_catalog.setval('deta_comandas_id_det_comanda_seq', 117, true);
+
+--
+-- Name: mate_primas_id_mat_prima_seq; Type: SEQUENCE SET; Schema: public; Owner: manso
+--
+
+SELECT pg_catalog.setval('mate_primas_id_mat_prima_seq', 10, true);
+
+--
+-- Name: mesas_id_mesa_seq; Type: SEQUENCE SET; Schema: public; Owner: manso
+--
+
+SELECT pg_catalog.setval('mesas_id_mesa_seq', 14, true);
+
+--
+-- Name: productos_id_producto_seq; Type: SEQUENCE SET; Schema: public; Owner: manso
+--
+
+SELECT pg_catalog.setval('productos_id_producto_seq', 52, true);
+
+--
+-- Name: proveedores_id_proveedor_seq; Type: SEQUENCE SET; Schema: public; Owner: manso
+--
+
+SELECT pg_catalog.setval('proveedores_id_proveedor_seq', 4, true);
+
+--
+-- Name: roles_id_rol_seq; Type: SEQUENCE SET; Schema: public; Owner: manso
+--
+
+SELECT pg_catalog.setval('roles_id_rol_seq', 6, true);
+
+--
+-- Name: usuarios_id_usuario_seq; Type: SEQUENCE SET; Schema: public; Owner: manso
+--
+
+SELECT pg_catalog.setval('usuarios_id_usuario_seq', 3, true);
+
+--
+-- Name: asistencia_intervalo asistencia_intervalo_id_usuario_desde_hasta_key; Type: CONSTRAINT; Schema: public; Owner: manso
+--
+
+ALTER TABLE ONLY asistencia_intervalo
+ ADD CONSTRAINT asistencia_intervalo_id_usuario_desde_hasta_key UNIQUE (id_usuario, desde, hasta);
+
+--
+-- Name: asistencia_intervalo asistencia_intervalo_pkey; Type: CONSTRAINT; Schema: public; Owner: manso
+--
+
+ALTER TABLE ONLY asistencia_intervalo
+ ADD CONSTRAINT asistencia_intervalo_pkey PRIMARY KEY (id_intervalo);
+
+--
+-- Name: asistencia_raw asistencia_raw_id_usuario_ts_key; Type: CONSTRAINT; Schema: public; Owner: manso
+--
+
+ALTER TABLE ONLY asistencia_raw
+ ADD CONSTRAINT asistencia_raw_id_usuario_ts_key UNIQUE (id_usuario, ts);
+
+--
+-- Name: asistencia_raw asistencia_raw_pkey; Type: CONSTRAINT; Schema: public; Owner: manso
+--
+
+ALTER TABLE ONLY asistencia_raw
+ ADD CONSTRAINT asistencia_raw_pkey PRIMARY KEY (id_raw);
+
+--
+-- Name: categorias categorias_nombre_key; Type: CONSTRAINT; Schema: public; Owner: manso
+--
+
+ALTER TABLE ONLY categorias
+ ADD CONSTRAINT categorias_nombre_key UNIQUE (nombre);
+
+--
+-- Name: categorias categorias_pkey; Type: CONSTRAINT; Schema: public; Owner: manso
+--
+
+ALTER TABLE ONLY categorias
+ ADD CONSTRAINT categorias_pkey PRIMARY KEY (id_categoria);
+
+--
+-- Name: clientes clientes_correo_key; Type: CONSTRAINT; Schema: public; Owner: manso
+--
+
+ALTER TABLE ONLY clientes
+ ADD CONSTRAINT clientes_correo_key UNIQUE (correo);
+
+--
+-- Name: clientes clientes_pkey; Type: CONSTRAINT; Schema: public; Owner: manso
+--
+
+ALTER TABLE ONLY clientes
+ ADD CONSTRAINT clientes_pkey PRIMARY KEY (id_cliente);
+
+--
+-- Name: clientes clientes_telefono_key; Type: CONSTRAINT; Schema: public; Owner: manso
+--
+
+ALTER TABLE ONLY clientes
+ ADD CONSTRAINT clientes_telefono_key UNIQUE (telefono);
+
+--
+-- Name: comandas comandas_pkey; Type: CONSTRAINT; Schema: public; Owner: manso
+--
+
+ALTER TABLE ONLY comandas
+ ADD CONSTRAINT comandas_pkey PRIMARY KEY (id_comanda);
+
+--
+-- Name: compras compras_pkey; Type: CONSTRAINT; Schema: public; Owner: manso
+--
+
+ALTER TABLE ONLY compras
+ ADD CONSTRAINT compras_pkey PRIMARY KEY (id_compra);
+
+--
+-- Name: deta_comandas deta_comandas_pkey; Type: CONSTRAINT; Schema: public; Owner: manso
+--
+
+ALTER TABLE ONLY deta_comandas
+ ADD CONSTRAINT deta_comandas_pkey PRIMARY KEY (id_det_comanda);
+
+--
+-- Name: deta_comp_materias deta_comp_materias_pkey; Type: CONSTRAINT; Schema: public; Owner: manso
+--
+
+ALTER TABLE ONLY deta_comp_materias
+ ADD CONSTRAINT deta_comp_materias_pkey PRIMARY KEY (id_compra, id_mat_prima);
+
+--
+-- Name: deta_comp_producto deta_comp_producto_pkey; Type: CONSTRAINT; Schema: public; Owner: manso
+--
+
+ALTER TABLE ONLY deta_comp_producto
+ ADD CONSTRAINT deta_comp_producto_pkey PRIMARY KEY (id_compra, id_producto);
+
+--
+-- Name: mate_primas mate_primas_nombre_key; Type: CONSTRAINT; Schema: public; Owner: manso
+--
+
+ALTER TABLE ONLY mate_primas
+ ADD CONSTRAINT mate_primas_nombre_key UNIQUE (nombre);
+
+--
+-- Name: mate_primas mate_primas_pkey; Type: CONSTRAINT; Schema: public; Owner: manso
+--
+
+ALTER TABLE ONLY mate_primas
+ ADD CONSTRAINT mate_primas_pkey PRIMARY KEY (id_mat_prima);
+
+--
+-- Name: mesas mesas_apodo_key; Type: CONSTRAINT; Schema: public; Owner: manso
+--
+
+ALTER TABLE ONLY mesas
+ ADD CONSTRAINT mesas_apodo_key UNIQUE (apodo);
+
+--
+-- Name: mesas mesas_numero_key; Type: CONSTRAINT; Schema: public; Owner: manso
+--
+
+ALTER TABLE ONLY mesas
+ ADD CONSTRAINT mesas_numero_key UNIQUE (numero);
+
+--
+-- Name: mesas mesas_pkey; Type: CONSTRAINT; Schema: public; Owner: manso
+--
+
+ALTER TABLE ONLY mesas
+ ADD CONSTRAINT mesas_pkey PRIMARY KEY (id_mesa);
+
+--
+-- Name: productos productos_pkey; Type: CONSTRAINT; Schema: public; Owner: manso
+--
+
+ALTER TABLE ONLY productos
+ ADD CONSTRAINT productos_pkey PRIMARY KEY (id_producto);
+
+--
+-- Name: prov_mate_prima prov_mate_prima_pkey; Type: CONSTRAINT; Schema: public; Owner: manso
+--
+
+ALTER TABLE ONLY prov_mate_prima
+ ADD CONSTRAINT prov_mate_prima_pkey PRIMARY KEY (id_proveedor, id_mat_prima);
+
+--
+-- Name: prov_producto prov_producto_pkey; Type: CONSTRAINT; Schema: public; Owner: manso
+--
+
+ALTER TABLE ONLY prov_producto
+ ADD CONSTRAINT prov_producto_pkey PRIMARY KEY (id_proveedor, id_producto);
+
+--
+-- Name: proveedores proveedores_pkey; Type: CONSTRAINT; Schema: public; Owner: manso
+--
+
+ALTER TABLE ONLY proveedores
+ ADD CONSTRAINT proveedores_pkey PRIMARY KEY (id_proveedor);
+
+--
+-- Name: proveedores proveedores_rut_key; Type: CONSTRAINT; Schema: public; Owner: manso
+--
+
+ALTER TABLE ONLY proveedores
+ ADD CONSTRAINT proveedores_rut_key UNIQUE (rut);
+
+--
+-- Name: receta_producto receta_producto_pkey; Type: CONSTRAINT; Schema: public; Owner: manso
+--
+
+ALTER TABLE ONLY receta_producto
+ ADD CONSTRAINT receta_producto_pkey PRIMARY KEY (id_producto, id_mat_prima);
+
+--
+-- Name: roles roles_nombre_key; Type: CONSTRAINT; Schema: public; Owner: manso
+--
+
+ALTER TABLE ONLY roles
+ ADD CONSTRAINT roles_nombre_key UNIQUE (nombre);
+
+--
+-- Name: roles roles_pkey; Type: CONSTRAINT; Schema: public; Owner: manso
+--
+
+ALTER TABLE ONLY roles
+ ADD CONSTRAINT roles_pkey PRIMARY KEY (id_rol);
+
+--
+-- Name: usua_roles usua_roles_pkey; Type: CONSTRAINT; Schema: public; Owner: manso
+--
+
+ALTER TABLE ONLY usua_roles
+ ADD CONSTRAINT usua_roles_pkey PRIMARY KEY (id_usuario, id_rol);
+
+--
+-- Name: usuarios usuarios_documento_key; Type: CONSTRAINT; Schema: public; Owner: manso
+--
+
+ALTER TABLE ONLY usuarios
+ ADD CONSTRAINT usuarios_documento_key UNIQUE (documento);
+
+--
+-- Name: usuarios usuarios_pkey; Type: CONSTRAINT; Schema: public; Owner: manso
+--
+
+ALTER TABLE ONLY usuarios
+ ADD CONSTRAINT usuarios_pkey PRIMARY KEY (id_usuario);
+
+--
+-- Name: compras_fec_compra_idx; Type: INDEX; Schema: public; Owner: manso
+--
+
+CREATE INDEX compras_fec_compra_idx ON compras USING btree (fec_compra);
+
+--
+-- Name: idx_asist_int_usuario_fecha; Type: INDEX; Schema: public; Owner: manso
+--
+
+CREATE INDEX idx_asist_int_usuario_fecha ON asistencia_intervalo USING btree (id_usuario, fecha);
+
+--
+-- Name: idx_asist_raw_usuario_ts; Type: INDEX; Schema: public; Owner: manso
+--
+
+CREATE INDEX idx_asist_raw_usuario_ts ON asistencia_raw USING btree (id_usuario, ts);
+
+--
+-- Name: idx_detalle_comanda_comanda; Type: INDEX; Schema: public; Owner: manso
+--
+
+CREATE INDEX idx_detalle_comanda_comanda ON deta_comandas USING btree (id_comanda);
+
+--
+-- Name: idx_detalle_comanda_producto; Type: INDEX; Schema: public; Owner: manso
+--
+
+CREATE INDEX idx_detalle_comanda_producto ON deta_comandas USING btree (id_producto);
+
+--
+-- Name: ix_comandas_fec_cierre; Type: INDEX; Schema: public; Owner: manso
+--
+
+CREATE INDEX ix_comandas_fec_cierre ON comandas USING btree (fec_cierre);
+
+--
+-- Name: ix_comandas_id; Type: INDEX; Schema: public; Owner: manso
+--
+
+CREATE INDEX ix_comandas_id ON comandas USING btree (id_comanda);
+
+--
+-- Name: ix_deta_comandas_id_comanda; Type: INDEX; Schema: public; Owner: manso
+--
+
+CREATE INDEX ix_deta_comandas_id_comanda ON deta_comandas USING btree (id_comanda);
+
+--
+-- Name: ix_deta_comandas_id_producto; Type: INDEX; Schema: public; Owner: manso
+--
+
+CREATE INDEX ix_deta_comandas_id_producto ON deta_comandas USING btree (id_producto);
+
+--
+-- Name: asistencia_intervalo asistencia_intervalo_id_usuario_fkey; Type: FK CONSTRAINT; Schema: public; Owner: manso
+--
+
+ALTER TABLE ONLY asistencia_intervalo
+ ADD CONSTRAINT asistencia_intervalo_id_usuario_fkey FOREIGN KEY (id_usuario) REFERENCES usuarios(id_usuario) ON DELETE CASCADE;
+
+--
+-- Name: asistencia_raw asistencia_raw_id_usuario_fkey; Type: FK CONSTRAINT; Schema: public; Owner: manso
+--
+
+ALTER TABLE ONLY asistencia_raw
+ ADD CONSTRAINT asistencia_raw_id_usuario_fkey FOREIGN KEY (id_usuario) REFERENCES usuarios(id_usuario) ON DELETE CASCADE;
+
+--
+-- Name: comandas comandas_id_mesa_fkey; Type: FK CONSTRAINT; Schema: public; Owner: manso
+--
+
+ALTER TABLE ONLY comandas
+ ADD CONSTRAINT comandas_id_mesa_fkey FOREIGN KEY (id_mesa) REFERENCES mesas(id_mesa);
+
+--
+-- Name: comandas comandas_id_usuario_fkey; Type: FK CONSTRAINT; Schema: public; Owner: manso
+--
+
+ALTER TABLE ONLY comandas
+ ADD CONSTRAINT comandas_id_usuario_fkey FOREIGN KEY (id_usuario) REFERENCES usuarios(id_usuario);
+
+--
+-- Name: compras compras_id_proveedor_fkey; Type: FK CONSTRAINT; Schema: public; Owner: manso
+--
+
+ALTER TABLE ONLY compras
+ ADD CONSTRAINT compras_id_proveedor_fkey FOREIGN KEY (id_proveedor) REFERENCES proveedores(id_proveedor);
+
+--
+-- Name: deta_comandas deta_comandas_id_comanda_fkey; Type: FK CONSTRAINT; Schema: public; Owner: manso
+--
+
+ALTER TABLE ONLY deta_comandas
+ ADD CONSTRAINT deta_comandas_id_comanda_fkey FOREIGN KEY (id_comanda) REFERENCES comandas(id_comanda) ON DELETE CASCADE;
+
+--
+-- Name: deta_comandas deta_comandas_id_producto_fkey; Type: FK CONSTRAINT; Schema: public; Owner: manso
+--
+
+ALTER TABLE ONLY deta_comandas
+ ADD CONSTRAINT deta_comandas_id_producto_fkey FOREIGN KEY (id_producto) REFERENCES productos(id_producto);
+
+--
+-- Name: deta_comp_materias deta_comp_materias_id_compra_fkey; Type: FK CONSTRAINT; Schema: public; Owner: manso
+--
+
+ALTER TABLE ONLY deta_comp_materias
+ ADD CONSTRAINT deta_comp_materias_id_compra_fkey FOREIGN KEY (id_compra) REFERENCES compras(id_compra) ON DELETE CASCADE;
+
+--
+-- Name: deta_comp_materias deta_comp_materias_id_mat_prima_fkey; Type: FK CONSTRAINT; Schema: public; Owner: manso
+--
+
+ALTER TABLE ONLY deta_comp_materias
+ ADD CONSTRAINT deta_comp_materias_id_mat_prima_fkey FOREIGN KEY (id_mat_prima) REFERENCES mate_primas(id_mat_prima);
+
+--
+-- Name: deta_comp_producto deta_comp_producto_id_compra_fkey; Type: FK CONSTRAINT; Schema: public; Owner: manso
+--
+
+ALTER TABLE ONLY deta_comp_producto
+ ADD CONSTRAINT deta_comp_producto_id_compra_fkey FOREIGN KEY (id_compra) REFERENCES compras(id_compra) ON DELETE CASCADE;
+
+--
+-- Name: deta_comp_producto deta_comp_producto_id_producto_fkey; Type: FK CONSTRAINT; Schema: public; Owner: manso
+--
+
+ALTER TABLE ONLY deta_comp_producto
+ ADD CONSTRAINT deta_comp_producto_id_producto_fkey FOREIGN KEY (id_producto) REFERENCES productos(id_producto);
+
+--
+-- Name: productos productos_id_categoria_fkey; Type: FK CONSTRAINT; Schema: public; Owner: manso
+--
+
+ALTER TABLE ONLY productos
+ ADD CONSTRAINT productos_id_categoria_fkey FOREIGN KEY (id_categoria) REFERENCES categorias(id_categoria);
+
+--
+-- Name: prov_mate_prima prov_mate_prima_id_mat_prima_fkey; Type: FK CONSTRAINT; Schema: public; Owner: manso
+--
+
+ALTER TABLE ONLY prov_mate_prima
+ ADD CONSTRAINT prov_mate_prima_id_mat_prima_fkey FOREIGN KEY (id_mat_prima) REFERENCES mate_primas(id_mat_prima);
+
+--
+-- Name: prov_mate_prima prov_mate_prima_id_proveedor_fkey; Type: FK CONSTRAINT; Schema: public; Owner: manso
+--
+
+ALTER TABLE ONLY prov_mate_prima
+ ADD CONSTRAINT prov_mate_prima_id_proveedor_fkey FOREIGN KEY (id_proveedor) REFERENCES proveedores(id_proveedor) ON DELETE CASCADE;
+
+--
+-- Name: prov_producto prov_producto_id_producto_fkey; Type: FK CONSTRAINT; Schema: public; Owner: manso
+--
+
+ALTER TABLE ONLY prov_producto
+ ADD CONSTRAINT prov_producto_id_producto_fkey FOREIGN KEY (id_producto) REFERENCES productos(id_producto);
+
+--
+-- Name: prov_producto prov_producto_id_proveedor_fkey; Type: FK CONSTRAINT; Schema: public; Owner: manso
+--
+
+ALTER TABLE ONLY prov_producto
+ ADD CONSTRAINT prov_producto_id_proveedor_fkey FOREIGN KEY (id_proveedor) REFERENCES proveedores(id_proveedor) ON DELETE CASCADE;
+
+--
+-- Name: receta_producto receta_producto_id_mat_prima_fkey; Type: FK CONSTRAINT; Schema: public; Owner: manso
+--
+
+ALTER TABLE ONLY receta_producto
+ ADD CONSTRAINT receta_producto_id_mat_prima_fkey FOREIGN KEY (id_mat_prima) REFERENCES mate_primas(id_mat_prima);
+
+--
+-- Name: receta_producto receta_producto_id_producto_fkey; Type: FK CONSTRAINT; Schema: public; Owner: manso
+--
+
+ALTER TABLE ONLY receta_producto
+ ADD CONSTRAINT receta_producto_id_producto_fkey FOREIGN KEY (id_producto) REFERENCES productos(id_producto) ON DELETE CASCADE;
+
+--
+-- Name: usua_roles usua_roles_autor_fkey; Type: FK CONSTRAINT; Schema: public; Owner: manso
+--
+
+ALTER TABLE ONLY usua_roles
+ ADD CONSTRAINT usua_roles_autor_fkey FOREIGN KEY (autor) REFERENCES usuarios(id_usuario);
+
+--
+-- Name: usua_roles usua_roles_id_rol_fkey; Type: FK CONSTRAINT; Schema: public; Owner: manso
+--
+
+ALTER TABLE ONLY usua_roles
+ ADD CONSTRAINT usua_roles_id_rol_fkey FOREIGN KEY (id_rol) REFERENCES roles(id_rol);
+
+--
+-- Name: usua_roles usua_roles_id_usuario_fkey; Type: FK CONSTRAINT; Schema: public; Owner: manso
+--
+
+ALTER TABLE ONLY usua_roles
+ ADD CONSTRAINT usua_roles_id_usuario_fkey FOREIGN KEY (id_usuario) REFERENCES usuarios(id_usuario) ON DELETE CASCADE;
+
+--
+-- PostgreSQL database dump complete
+--
+
+\unrestrict londHmqT4llS8Wof4ZnceO2dyFhn4jiR5xbaszMgZpMczgr6aVW6xQJxeUdqJwa
+
+
+COMMIT;
diff --git a/services/auth/src/index.js b/services/auth/src/index.js
index 0db6504..603fe8a 100644
--- a/services/auth/src/index.js
+++ b/services/auth/src/index.js
@@ -13,6 +13,7 @@ import chalk from 'chalk';
import express from 'express';
import cors from 'cors';
import path from 'node:path';
+import fs from 'node:fs/promises';
import { fileURLToPath } from 'node:url';
import { Pool } from 'pg';
import session from 'express-session';
@@ -21,13 +22,14 @@ import * as connectRedis from 'connect-redis';
import expressLayouts from 'express-ejs-layouts';
import { Issuer, generators } from 'openid-client';
import crypto from 'node:crypto';
-
-
+import { readFile } from 'node:fs/promises';
// -----------------------------------------------------------------------------
// Helpers
// -----------------------------------------------------------------------------
+const SESSION_COOKIE_NAME = 'sc.sid';
+
// Normaliza UUID (acepta con/sin guiones) → "hex" sin guiones
const cleanUuid = (u) => (u ? String(u).toLowerCase().replace(/[^0-9a-f]/g, '') : '');
@@ -36,12 +38,22 @@ const schemaNameFor = (uuidHex) => `schema_tenant_${uuidHex}`;
const roleNameFor = (uuidHex) => `tenant_${uuidHex}`;
// Helpers de Authentik (admin API)
-const { akFindUserByEmail, akCreateUser, akSetPassword } = await import('./ak.js');
+const {
+ akFindUserByEmail,
+ akCreateUser,
+ akSetPassword,
+ akResolveGroupId
+} = await import('./ak.js');
// Quoter seguro de identificadores SQL (roles, schemas, tablas)
+// Identificador SQL (schema, role, table, …)
const qi = (ident) => `"${String(ident).replace(/"/g, '""')}"`;
-const qs = (str) => `'${String(str).replace(/'/g, "''")}'`; // quote string literal seguro
-const VALID_IDENT = /^[a-z_][a-z0-9_]*$/i;
+
+// Literal de texto SQL (valores: contraseñas, strings, …)
+const qs = (val) => `'${String(val).replace(/'/g, "''")}'`;
+
+
+const VALID_IDENT = /^[a-zA-Z_][a-zA-Z0-9_:$-]*$/;
// --- Resolver y cachear el grupo por ID/UUID/NOMBRE una sola vez ---
let DEFAULT_GROUP_ID = process.env.AUTHENTIK_DEFAULT_GROUP_ID
@@ -66,6 +78,26 @@ if (!DEFAULT_GROUP_ID) {
})();
}
+function nukeSession(req, res, redirectTo = '/auth/login', reason = 'reset') {
+ try {
+ // Destruye la sesión en el store (Redis)
+ req.session?.destroy(() => {
+ // Limpia la cookie en el navegador
+ res.clearCookie(SESSION_COOKIE_NAME, {
+ path: '/',
+ httpOnly: true,
+ sameSite: 'lax',
+ secure: process.env.NODE_ENV === 'production',
+ });
+ // Reinicia el flujo
+ return res.redirect(303, `${redirectTo}?reason=${encodeURIComponent(reason)}`);
+ });
+ } catch {
+ // Si algo falla, al menos intentamos redirigir
+ return res.redirect(303, `${redirectTo}?reason=${encodeURIComponent(reason)}`);
+ }
+}
+
// Verificar existencia del tenant sin crear (en la DB de tenants)
async function tenantExists(uuidHex) {
if (!uuidHex) return false;
@@ -87,12 +119,12 @@ async function tenantExists(uuidHex) {
// 2) Authentik (attributes.tenant_uuid del usuario)
// 3) valor provisto en el request (si viene)
async function resolveExistingTenantUuid({ email, requestedTenantUuid }) {
- const emailLower = String(email).toLowerCase();
+ const normEmail = String(email).trim().toLowerCase();
// 1) DB principal
const dbRes = await pool.query(
'SELECT tenant_uuid FROM app_user WHERE LOWER(email)=LOWER($1) LIMIT 1',
- [emailLower]
+ [normEmail]
);
if (dbRes.rowCount) {
const fromDb = cleanUuid(dbRes.rows[0].tenant_uuid);
@@ -100,7 +132,7 @@ async function resolveExistingTenantUuid({ email, requestedTenantUuid }) {
}
// 2) Authentik
- const akUser = await akFindUserByEmail(emailLower).catch(() => null);
+ const akUser = await akFindUserByEmail(normEmail).catch(() => null);
const fromAk = cleanUuid(akUser?.attributes?.tenant_uuid);
if (fromAk) return fromAk;
@@ -113,57 +145,198 @@ async function resolveExistingTenantUuid({ email, requestedTenantUuid }) {
// Helper para crear tenant si falta
async function ensureTenant({ tenant_uuid }) {
- const client = await tenantsPool.connect();
+ const admin = await tenantsPool.connect();
try {
- await client.query('BEGIN');
+ await admin.query('BEGIN');
- // Si no vino UUID, generamos uno
- let uuid = (tenant_uuid || crypto.randomUUID()).toLowerCase();
- const uuidNoHyphen = uuid.replace(/-/g, '');
+ // uuid y nombres
+ const uuid = (tenant_uuid || crypto.randomUUID()).toLowerCase();
+ const hex = uuid.replace(/-/g, '');
+ if (!/^[a-f0-9]{32}$/.test(hex)) throw new Error('tenant_uuid inválido');
- const schema = `schema_tenant_${uuidNoHyphen}`;
- const role = `tenant_${uuidNoHyphen}`;
- const pwd = crypto.randomBytes(18).toString('base64url'); // password del rol
+ const schema = `schema_tenant_${hex}`;
+ const role = `tenant_${hex}`;
+ const pwd = crypto.randomBytes(18).toString('base64url');
if (!VALID_IDENT.test(schema) || !VALID_IDENT.test(role)) {
throw new Error('Identificador de schema/rol inválido');
}
- // 1) Crear ROL si no existe
- const { rowCount: hasRole } = await client.query(
- 'SELECT 1 FROM pg_roles WHERE rolname=$1',
- [role]
- );
- if (!hasRole) {
- // Para el identificador usamos qi(); el password sí va parametrizado
- await client.query(`CREATE ROLE ${qi(role)} LOGIN PASSWORD ${qs(pwd)}`);
+ // 1) Crear ROL si no existe (PASSWORD debe ser LITERAL, no parámetro)
+ const r = await admin.query('SELECT 1 FROM pg_roles WHERE rolname=$1', [role]);
+ if (!r.rowCount) {
+ await admin.query(`CREATE ROLE ${qi(role)} LOGIN PASSWORD ${qs(pwd)}`);
+ // Si quisieras rotarla luego:
+ // await admin.query(`ALTER ROLE ${qi(role)} PASSWORD ${qs(pwd)}`);
}
- // 2) Crear SCHEMA si no existe y asignar owner al rol del tenant
- const { rowCount: hasSchema } = await client.query(
+ // 2) Crear SCHEMA si no existe y asignar owner
+ const s = await admin.query(
'SELECT 1 FROM information_schema.schemata WHERE schema_name=$1',
[schema]
);
- if (!hasSchema) {
- await client.query(`CREATE SCHEMA ${qi(schema)} AUTHORIZATION ${qi(role)}`);
+ if (!s.rowCount) {
+ await admin.query(`CREATE SCHEMA ${qi(schema)} AUTHORIZATION ${qi(role)}`);
+ } else {
+ await admin.query(`ALTER SCHEMA ${qi(schema)} OWNER TO ${qi(role)}`);
}
- // 3) Permisos mínimos para el rol del tenant en su schema
- await client.query(`GRANT USAGE ON SCHEMA ${qi(schema)} TO ${qi(role)}`);
- await client.query(
+ // 3) Permisos por defecto
+ await admin.query(`GRANT USAGE ON SCHEMA ${qi(schema)} TO ${qi(role)}`);
+ await admin.query(
`ALTER DEFAULT PRIVILEGES IN SCHEMA ${qi(schema)}
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO ${qi(role)}`
);
- await client.query(
+ await admin.query(
`ALTER DEFAULT PRIVILEGES IN SCHEMA ${qi(schema)}
GRANT USAGE, SELECT, UPDATE ON SEQUENCES TO ${qi(role)}`
);
- await client.query('COMMIT');
+ // 4) Aplicar 01_init.sql en la misma transacción
+ const initSql = await loadInitSql(); // tu caché/loader actual
+ if (initSql && initSql.trim()) {
+ await admin.query(`SET LOCAL search_path TO ${qi(schema)}, public`);
+ await admin.query(initSql);
+ }
+ await admin.query('COMMIT');
return { tenant_uuid: uuid, schema, role, role_password: pwd };
+ } catch (e) {
+ try { await admin.query('ROLLBACK'); } catch {}
+ throw e;
+ } finally {
+ admin.release();
+ }
+}
+
+// async function ensureTenant({ tenant_uuid }) {
+// const client = await tenantsPool.connect();
+// try {
+// await client.query('BEGIN');
+
+// // Si no vino UUID, generamos uno
+// let uuid = (tenant_uuid || crypto.randomUUID()).toLowerCase();
+// const uuidNoHyphen = uuid.replace(/-/g, '');
+
+// const schema = `schema_tenant_${uuidNoHyphen}`;
+// const role = `tenant_${uuidNoHyphen}`;
+// const pwd = crypto.randomBytes(18).toString('base64url'); // password del rol
+
+// if (!VALID_IDENT.test(schema) || !VALID_IDENT.test(role)) {
+// throw new Error('Identificador de schema/rol inválido');
+// }
+
+// // 1) Crear ROL si no existe
+// const { rowCount: hasRole } = await client.query(
+// 'SELECT 1 FROM pg_roles WHERE rolname=$1',
+// [role]
+// );
+// if (!hasRole) {
+// // Para el identificador usamos qi(); el password sí va parametrizado
+// await client.query(`CREATE ROLE ${qi(role)} LOGIN PASSWORD ${qs(pwd)}`);
+// }
+
+// // 2) Crear SCHEMA si no existe y asignar owner al rol del tenant
+// const { rowCount: hasSchema } = await client.query(
+// 'SELECT 1 FROM information_schema.schemata WHERE schema_name=$1',
+// [schema]
+// );
+// if (!hasSchema) {
+// await client.query(`CREATE SCHEMA ${qi(schema)} AUTHORIZATION ${qi(role)}`);
+// }
+
+// // 3) Permisos mínimos para el rol del tenant en su schema
+// await client.query(`GRANT USAGE ON SCHEMA ${qi(schema)} TO ${qi(role)}`);
+// await client.query(
+// `ALTER DEFAULT PRIVILEGES IN SCHEMA ${qi(schema)}
+// GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO ${qi(role)}`
+// );
+// await client.query(
+// `ALTER DEFAULT PRIVILEGES IN SCHEMA ${qi(schema)}
+// GRANT USAGE, SELECT, UPDATE ON SEQUENCES TO ${qi(role)}`
+// );
+
+// await client.query('COMMIT');
+
+// // 4) Inicialización del esquema con 01_init.sql (solo si está vacío)
+// try {
+// await initializeTenantSchemaIfEmpty(client, schema);
+// } catch (e) {
+// // Podés decidir si esto es fatal o "best-effort".
+// // Si querés cortar el alta cuando falla la init, usa: throw e;
+// console.warn(`[TENANT INIT] Falló inicialización de ${schema}:`, e?.message || e);
+// }
+
+// return { tenant_uuid: uuid, schema, role, role_password: pwd };
+// } catch (e) {
+// try { await client.query('ROLLBACK'); } catch {}
+// throw e;
+// } finally {
+// client.release();
+// }
+// }
+
+// Carga el 01_init.sql del disco, elimina BEGIN/COMMIT y sustituye el schema.
+
+let _cachedInitSql = null;
+async function loadInitSql() {
+ if (_cachedInitSql !== null) return _cachedInitSql;
+ const candidates = [
+ process.env.TENANT_INIT_SQL, // recomendado via .env
+ path.resolve(__dirname, 'db', 'initTenant.sql'),
+ path.resolve(__dirname, '..', 'src', 'db', 'initTenant.sql'),
+ ].filter(Boolean);
+ for (const p of candidates) {
+ try {
+ await fs.promises.access(p, fs.constants.R_OK);
+ const txt = await readFile(p, 'utf8');
+ _cachedInitSql = String(txt || '');
+ console.log(`[TENANT INIT] initTenant.sql: ${p} (${_cachedInitSql.length} bytes)`);
+ return _cachedInitSql;
+ } catch {}
+ }
+ console.warn('[TENANT INIT] initTenant.sql no encontrado (se omitirá).');
+ _cachedInitSql = '';
+ return _cachedInitSql;
+}
+
+async function isSchemaEmpty(client, schema) {
+ const { rows } = await client.query(
+ `SELECT COUNT(*)::int AS c
+ FROM information_schema.tables
+ WHERE table_schema = $1`,
+ [schema]
+ );
+ return rows[0].c === 0;
+}
+
+/** Ejecuta 01_init.sql para un tenant (solo si el esquema está vacío). */
+async function initializeTenantSchemaIfEmpty(schema) {
+ const sql = await loadInitSql();
+ if (!sql || !sql.trim()) {
+ console.warn(`[TENANT INIT] Esquema ${schema}: 01_init.sql vacío/no disponible. Salteando.`);
+ return;
+ }
+
+ const client = await tenantsPool.connect();
+ try {
+ // No usamos LOCAL: queremos que el search_path persista en esta conexión mientras dura el script
+ await client.query('BEGIN');
+ await client.query(`SET search_path TO ${qi(schema)}, public`);
+
+ const empty = await isSchemaEmpty(client, schema);
+ if (!empty) {
+ await client.query('ROLLBACK');
+ console.log(`[TENANT INIT] Esquema ${schema}: ya tiene tablas. No se aplica 01_init.sql.`);
+ return;
+ }
+
+ await client.query(sql); // acepta múltiples sentencias separadas por ';'
+ await client.query('COMMIT');
+ console.log(`[TENANT INIT] Esquema ${schema}: 01_init.sql aplicado.`);
} catch (e) {
try { await client.query('ROLLBACK'); } catch {}
+ console.error(`[TENANT INIT] Error aplicando 01_init.sql sobre ${schema}:`, e.message);
throw e;
} finally {
client.release();
@@ -191,6 +364,13 @@ function onFatal(err, msg = 'Error fatal') {
process.exit(1);
}
+function genTempPassword(len = 12) {
+ const base = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789!@$%';
+ let out = '';
+ for (let i = 0; i < len; i++) out += base[Math.floor(Math.random() * base.length)];
+ return out;
+}
+
// -----------------------------------------------------------------------------
// Configuración Express
// -----------------------------------------------------------------------------
@@ -217,7 +397,7 @@ await redis.connect().catch((e) => onFatal(e, 'No se pudo conectar a Redis (sesi
app.use(
session({
- name: 'sc.sid',
+ name: SESSION_COOKIE_NAME,
store: new RedisStore({ client: redis, prefix: 'sess:' }),
secret: process.env.SESSION_SECRET || 'change-me',
resave: false,
@@ -248,7 +428,6 @@ const tenantsPool = new Pool({
max: 10,
});
-
// -----------------------------------------------------------------------------
// PostgreSQL — DB principal (metadatos de negocio)
// -----------------------------------------------------------------------------
@@ -328,85 +507,132 @@ let oidcClient;
}
})();
-// -----------------------------------------------------------------------------
-// Vistas
-// -----------------------------------------------------------------------------
-app.get('/', (req, res) => res.render('login', { pageTitle: 'Iniciar sesión' }));
-
// -----------------------------------------------------------------------------
// Rutas OIDC
// -----------------------------------------------------------------------------
-app.get('/auth/login', (req, res) => {
- const code_verifier = generators.codeVerifier();
- const code_challenge = generators.codeChallenge(code_verifier);
- const state = generators.state();
- // req.session.code_verifier = code_verifier;
+app.get('/auth/login', (req, res, next) => {
+ try {
- // guarda todo lo necesario para el callback
- req.session.code_verifier = code_verifier;
- req.session.state = state;
+ if (req.session?.oidc) {
+ return nukeSession(req, res, '/auth/login', 'stale_oidc');
+ }
- // log de depuración
- console.log('[OIDC] start login sid=%s state=%s', req.sessionID, state)
+ const code_verifier = generators.codeVerifier();
+ const code_challenge = generators.codeChallenge(code_verifier);
- const url = oidcClient.authorizationUrl({
- scope: 'openid email profile',
- code_challenge,
- code_challenge_method: 'S256',
- state,
- });
- console.log('[OIDC] auth URL has state? %s', url.includes(`state=${state}`));
- return res.redirect(url);
+ // Podés usar generators.state() y generators.nonce(); ambas son válidas
+ const state = generators.state(); // crypto.randomBytes(24).toString('base64url') también sirve
+ const nonce = generators.nonce();
+
+
+
+ // Guardamos TODO dentro de un objeto para evitar claves sueltas
+ req.session.oidc = { code_verifier, state, nonce };
+
+ // Guardar sesión ANTES de redirigir
+ req.session.save((err) => {
+ if (err) return next(err);
+
+ const url = oidcClient.authorizationUrl({
+ scope: 'openid profile email offline_access',
+ code_challenge,
+ code_challenge_method: 'S256',
+ state,
+ nonce,
+ });
+
+ return res.redirect(url); // importantísimo: return
+ });
+ } catch (e) {
+ return next(e);
+ }
});
app.get('/auth/callback', async (req, res, next) => {
try {
+ // Log útil para debug
console.log('[OIDC] cb sid=%s query=%j', req.sessionID, req.query);
- const params = oidcClient.callbackParams(req);
- const tokenSet = await oidcClient.callback(
- process.env.OIDC_REDIRECT_URI,
- params,
- { code_verifier: req.session.code_verifier, state: req.session.state }
- );
- delete req.session.code_verifier;
- delete req.session.state;
+ // Recuperar lo que guardamos en /auth/login
+ const { oidc } = req.session || {};
+ const code_verifier = oidc?.code_verifier;
+ const stateStored = oidc?.state;
+ const nonceStored = oidc?.nonce;
+
+ // Si por algún motivo no está la info (sesión perdida/expirada), reiniciamos el flujo
+ if (!code_verifier || !stateStored) {
+ console.warn('[OIDC] Falta code_verifier/state en sesión; reiniciando login');
+ return res.redirect(303, '/auth/login');
+ }
+
+ const params = oidcClient.callbackParams(req);
+
+ // openid-client validará que el "state" recibido coincida con el que pasamos aquí
+ let tokenSet;
+ try {
+ tokenSet = await oidcClient.callback(
+ process.env.OIDC_REDIRECT_URI,
+ params,
+ { code_verifier, state: stateStored, nonce: nonceStored }
+ );
+ } catch (err) {
+ console.warn('[OIDC] callback error, resetting session:', err?.message || err);
+ return nukeSession(req, res, '/auth/login', 'callback_error');
+ }
+
+ // Limpiar datos OIDC de la sesión
+ delete req.session.oidc;
const claims = tokenSet.claims();
const email = (claims.email || '').toLowerCase();
- const tenantUuid = (claims.tenant_uuid || '').replace(/-/g, '');
-
+
+ // tenant desde claim, Authentik o fallback a tu DB
let tenantHex = cleanUuid(claims.tenant_uuid);
if (!tenantHex) {
- // intenta Authentik
- const akUser = await akFindUserByEmail(email).catch(()=>null);
+ const akUser = await akFindUserByEmail(email).catch(() => null);
tenantHex = cleanUuid(akUser?.attributes?.tenant_uuid);
- // último recurso: tu DB
if (!tenantHex) {
- const q = await pool.query('SELECT tenant_uuid FROM app_user WHERE LOWER(email)=LOWER($1) LIMIT 1', [email]);
+ const q = await pool.query(
+ 'SELECT tenant_uuid FROM app_user WHERE LOWER(email)=LOWER($1) LIMIT 1',
+ [email]
+ );
tenantHex = cleanUuid(q.rows?.[0]?.tenant_uuid);
}
}
- // Regenerar sesión para evitar fijación
+ // Regenerar sesión para evitar fijación y guardar el usuario
req.session.regenerate((err) => {
- if (err) return next(err);
+ if (err) {
+ if (!res.headersSent) res.status(500).send('No se pudo crear la sesión.');
+ return;
+ }
req.session.user = {
sub: claims.sub,
email,
- tenant_uuid: tenantUuid || null,
+ tenant_uuid: tenantHex || null,
};
- req.session.save((e2) => (e2 ? next(e2) : res.redirect('/')));
+ req.session.save((e2) => {
+ if (e2) {
+ if (!res.headersSent) res.status(500).send('No se pudo guardar la sesión.');
+ return;
+ }
+ if (!res.headersSent) return res.redirect('/'); // te llevará a /comandas si ya implementaste ese redirect
+ });
});
+
+ return res.redirect('/');
+
} catch (e) {
- next(e);
+ console.error('[OIDC] callback error:', e);
+ if (!res.headersSent) return next(e);
}
});
+
app.post('/auth/logout', (req, res) => {
req.session.destroy(() => {
- res.clearCookie('sc.sid');
+ res.clearCookie(SESSION_COOKIE_NAME);
res.status(204).end();
});
});
@@ -420,104 +646,189 @@ app.get('/auth/me', (req, res) => {
// Registro de usuario (DB principal + Authentik)
// -----------------------------------------------------------------------------
+// Helpers defensivos (si ya los tenés, podés omitir estas definiciones)
+const extractAkUserUuid = (u) =>
+ (u && (u.uuid || u?.user?.uuid || (Array.isArray(u.results) && u.results[0]?.uuid))) || null;
+const extractAkUserPk = (u) =>
+ (u && (u.pk ?? u?.user?.pk ?? null));
+
+async function akDeleteUser(pkOrUuid) {
+ try {
+ if (!pkOrUuid || !globalThis.fetch) return;
+ const base = process.env.AUTHENTIK_BASE_URL?.replace(/\/+$/, '') || '';
+ const id = String(pkOrUuid);
+ const url = `${base}/api/v3/core/users/${encodeURIComponent(id)}/`;
+ await fetch(url, {
+ method: 'DELETE',
+ headers: { 'Authorization': `Bearer ${process.env.AUTHENTIK_TOKEN}` }
+ });
+ } catch (e) {
+ console.warn('[AK] No se pudo borrar usuario (compensación):', e?.message || e);
+ }
+}
+
+// ==============================
+// POST /api/users/register
+// ==============================
app.post('/api/users/register', async (req, res, next) => {
- const { email, display_name, tenant_uuid: requestedTenant, role } = req.body || {};
- if (!email) return res.status(400).json({ error: 'email es obligatorio' });
+ // 0) input
+ const {
+ email,
+ display_name,
+ role,
+ tenant_uuid: requestedTenantUuid, // opcional
+ } = req.body || {};
- const emailLower = String(email).toLowerCase();
+ const normEmail = String(email || '').trim().toLowerCase();
+ if (!normEmail) return res.status(400).json({ error: 'email requerido' });
- // 1) ¿Ya hay tenant conocido (DB o Authentik o request)?
- let tenantHex = await resolveExistingTenantUuid({
- email: emailLower,
- requestedTenantUuid: requestedTenant,
- });
-
- // 2) Si viene por request, asegurate que exista (o créalo a demanda)
- if (tenantHex) {
- const exists = await tenantExists(tenantHex);
- if (!exists) {
- // Si tu política es NO crear si lo traen y no existe, devolvé 400.
- // return res.status(400).json({ error: 'tenant-invalido', detail: 'El tenant indicado no existe' });
-
- // Si preferís crearlo para "reparar", lo creamos sin generar un UUID nuevo:
- await ensureTenant({ tenant_uuid: tenantHex });
+ // 1) Resolver tenant uuid (existente o nuevo)
+ let tenantHex = null;
+ try {
+ if (typeof resolveExistingTenantUuid === 'function') {
+ tenantHex = await resolveExistingTenantUuid({
+ email: normEmail,
+ requestedTenantUuid,
+ });
+ } else {
+ tenantHex = cleanUuid(requestedTenantUuid);
}
+
+ // Crear/asegurar tenant en una transacción (ahí adentro corre 01_init.sql)
+ if (tenantHex) {
+ // si no existe, ensureTenant lo crea
+ await ensureTenant({ tenant_uuid: tenantHex });
+ } else {
+ const created = await ensureTenant({ tenant_uuid: null }); // genera uuid
+ tenantHex = cleanUuid(created?.tenant_uuid);
+ }
+ } catch (e) {
+ return next(new Error(`No se pudo preparar el tenant: ${e.message}`));
}
- // 3) Si todavía no hay tenant → primer alta de org → crear uno nuevo
- if (!tenantHex) {
- const created = await ensureTenant({ tenant_uuid: null }); // genera uuid nuevo
- tenantHex = cleanUuid(created.tenant_uuid);
- }
-
- // 4) Alta transaccional del usuario en TU DB
+ // 2) Transacción DB principal + Authentik con compensación
const client = await pool.connect();
+ let createdAkUser = null; // para compensación
try {
await client.query('BEGIN');
- // Evitar duplicar usuario por email + tenant (ajusta según tu constraint)
+ // Duplicados (ajusta a tu constraint real: por email o por (email,tenant))
const dup = await client.query(
- 'SELECT id FROM app_user WHERE LOWER(email)=LOWER($1) AND tenant_uuid=$2',
- [emailLower, tenantHex]
+ 'SELECT id FROM app_user WHERE LOWER(email)=LOWER($1)',
+ [normEmail]
);
if (dup.rowCount) {
await client.query('ROLLBACK');
return res.status(409).json({
error: 'user-exists',
- message: 'Ya existe un usuario con este email en este tenant.',
+ message: 'Ya existe un usuario con este email.',
next: '/auth/login',
});
}
- // Authentik: crear si no existe; si existe, reusar y (opcional) asegurar attributes.tenant_uuid
- let akUser = await akFindUserByEmail(emailLower);
+ // Authentik: buscar o crear
+ let akUser = await akFindUserByEmail(normEmail).catch(() => null);
if (!akUser) {
akUser = await akCreateUser({
- email: emailLower,
- displayName: display_name,
- tenantUuid: tenantHex, // se guarda en attributes
+ email: normEmail,
+ displayName: display_name || null,
+ tenantUuid: tenantHex, // attributes.tenant_uuid
addToGroupId: DEFAULT_GROUP_ID || null,
isActive: true,
});
- } else {
- // si existe y no tiene attribute tenant_uuid, lo “reparamos” (opcional):
- const akAttrHex = cleanUuid(akUser?.attributes?.tenant_uuid);
- if (!akAttrHex) {
- try {
- // parchea attributes del user en AK (si tu versión permite PATCH)
- await akPatchUserAttributes(akUser.pk, { tenant_uuid: tenantHex });
- } catch { /* opcional, no crítico */ }
- }
+ createdAkUser = akUser; // marcar que lo creamos nosotros
}
- const _role = role || 'owner';
+ // Asegurar uuid/pk
+ let akUserUuid = extractAkUserUuid(akUser);
+ let akUserPk = extractAkUserPk(akUser);
+ if (!akUserUuid || akUserPk == null) {
+ const ref = await akFindUserByEmail(normEmail).catch(() => null);
+ akUserUuid = akUserUuid || extractAkUserUuid(ref);
+ akUserPk = akUserPk ?? extractAkUserPk(ref);
+ }
+ if (!akUserUuid) throw new Error('No se pudo obtener uuid del usuario en Authentik');
+
+ // Insert en tu DB principal
+ const finalRole = role || 'owner';
await client.query(
`INSERT INTO app_user (email, display_name, tenant_uuid, ak_user_uuid, role)
VALUES ($1,$2,$3,$4,$5)`,
- [emailLower, display_name || null, tenantHex, akUser.uuid, _role]
+ [normEmail, display_name || null, tenantHex, akUserUuid, finalRole]
);
await client.query('COMMIT');
- return res.status(201).json({
- message: 'Usuario registrado',
- email: emailLower,
- tenant_uuid: tenantHex, // devolvés el mismo
- role: _role,
- authentik_user_uuid: akUser.uuid,
- next: '/auth/login',
+ // 3) Marcar sesión para set-password (si usás este flujo)
+ req.session.pendingPassword = {
+ email: normEmail,
+ ak_user_uuid: akUserUuid,
+ ak_user_pk: akUserPk,
+ exp: Date.now() + 10 * 60 * 1000,
+ };
+
+ return req.session.save(() => {
+ const accept = String(req.headers['accept'] || '');
+ if (accept.includes('text/html')) {
+ return res.redirect(303, '/set-password');
+ }
+ return res.status(201).json({
+ message: 'Usuario registrado',
+ email: normEmail,
+ tenant_uuid: tenantHex,
+ role: finalRole,
+ authentik_user_uuid: akUserUuid,
+ next: '/set-password',
+ });
});
} catch (err) {
+ // Rollbacks + Compensaciones
try { await client.query('ROLLBACK'); } catch {}
- if (err?.code === '23505') { // unique_violation
- return res.status(409).json({ error: 'user-exists' });
- }
- next(err);
+ try {
+ // Si creamos el usuario en Authentik y luego falló algo → borrar
+ if (createdAkUser) {
+ const id = extractAkUserPk(createdAkUser) ?? extractAkUserUuid(createdAkUser);
+ if (id) await akDeleteUser(id);
+ }
+ } catch {}
+ return next(err);
} finally {
client.release();
}
});
+
+
+// Definir contraseña
+app.post('/auth/password/set', async (req, res, next) => {
+ try {
+ const pp = req.session?.pendingPassword;
+ if (!pp || (pp.exp && Date.now() > pp.exp)) {
+ // token de sesión vencido o ausente
+ if (!res.headersSent) return res.redirect(303, '/set-password');
+ return;
+ }
+
+ const { password, password2 } = req.body || {};
+ if (!password || password.length < 8 || password !== password2) {
+ return res.status(400).send('Contraseña inválida o no coincide.');
+ }
+
+ // Buscar el usuario en Authentik y setear la clave
+ const u = await akFindUserByEmail(pp.email);
+ if (!u) return res.status(404).send('No se encontró el usuario en Authentik.');
+
+ await akSetPassword(u.pk, password, true); // true = force change handled; ajusta a tu helper
+
+ // Limpiar marcador y continuar al SSO
+ delete req.session.pendingPassword;
+ return req.session.save(() => res.redirect(303, '/auth/login'));
+ } catch (e) {
+ next(e);
+ }
+});
+
+
// Espera: { email, display_name?, tenant_uuid }
// app.post('/api/users/register', async (req, res, next) => {
@@ -587,12 +898,12 @@ app.get('/health', (_req, res) => res.status(200).json({ status: 'ok' }));
// -----------------------------------------------------------------------------
// 404 + Manejo de errores
// -----------------------------------------------------------------------------
-app.use((req, res) => res.status(404).json({ error: 'not-found', path: req.originalUrl }));
+app.use((req, res) => res.status(404).json({ error: 'Error 404, No se encontró la página', path: req.originalUrl }));
app.use((err, _req, res, _next) => {
console.error('❌ Error:', err);
if (res.headersSent) return;
- res.status(500).json({ error: 'internal-error', detail: err?.message || String(err) });
+ res.status(500).json({ error: '¡Oh! A ocurrido un error en el servidor auth.', detail: err.stack || String(err) });
});
// -----------------------------------------------------------------------------
diff --git a/services/manso/.env.development b/services/manso/.env.development
new file mode 100644
index 0000000..653f03e
--- /dev/null
+++ b/services/manso/.env.development
@@ -0,0 +1,19 @@
+NODE_ENV=development
+
+PORT=3030
+
+DB_HOST=dev-tenants
+DB_NAME=manso
+
+# Usuario y contraseña
+DB_USER=manso
+DB_PASS=manso
+
+# Puertos del servicio de db
+DB_LOCAL_PORT=5432
+DB_DOCKER_PORT=5432
+
+# Colores personalizados
+COL_PRI=452D19 # Marrón oscuro
+COL_SEC=D7A666 # Crema / Café
+COL_BG=FFA500 # Naranja
\ No newline at end of file
diff --git a/services/manso/.env.production b/services/manso/.env.production
new file mode 100644
index 0000000..a79fe8b
--- /dev/null
+++ b/services/manso/.env.production
@@ -0,0 +1,20 @@
+NODE_ENV=production # Entorno de desarrollo
+
+PORT=3000 # Variables del servicio -> suitecoffee-app
+
+# Variables del servicio -> suitecoffee-db de suitecoffee-app
+
+DB_HOST=dev-tenants
+DB_NAME=manso
+
+# Usuario y contraseña
+DB_USER=manso
+DB_PASS=manso
+# Puertos del servicio de db
+DB_LOCAL_PORT=5432
+DB_DOCKER_PORT=5432
+
+# Colores personalizados
+COL_PRI=452D19 # Marrón oscuro
+COL_SEC=D7A666 # Crema / Café
+COL_BG=FFA500 # Naranja
\ No newline at end of file