- <%
+ <%
+ // Espera que el backend pase: { user, cookies, session }
const hasUser = typeof user !== 'undefined' && user;
const hasCookies = typeof cookies !== 'undefined' && cookies && Object.keys(cookies).length;
+ const hasSession = typeof session !== 'undefined' && session && Object.keys(session).length;
+
const displayName =
(hasUser && (user.name || user.displayName || user.email)) ||
- (hasCookies && (cookies.user_name || cookies.displayName || cookies.email)) ||
+ (hasCookies && (cookies.user_name || cookies.displayName || cookies.email)) ||
+ (hasSession && (session.user?.email || session.user?.name)) ||
'usuario';
%>
+
Bienvenido a SuiteCoffee. Este es tu inicio.
Bienvenido a SuiteCoffee. Este es tu inicio y panel de diagnóstico de cookies/sesión.
- Cookies (servidor)
+ Cookies (servidor: req.cookies)
<% if (hasCookies) { %>
<% } else { %>
- No se recibieron cookies desde el servidor (req.cookies). ¿Estás usando cookie-parser o pasando cookies al render?
+
+ No se recibieron cookies del lado servidor (req.cookies).
+ Asegurate de usar cookie-parser y de pasar cookies al render:
+ res.render('inicio_v2', { user: req.session.user, cookies: req.cookies, session: req.session })
+
<% } %>
- Cookies (navegador)
+ Cookies (navegador: document.cookie)
Nombre Valor
@@ -88,6 +111,9 @@
Cargando…
+
+ Total cookies en navegador: 0
+
Raw document.cookie:
@@ -102,6 +128,8 @@
const raw = document.cookie || '';
document.getElementById('cookie-raw').textContent = raw || '(sin cookies)';
const pairs = raw ? raw.split(/;\s*/) : [];
+ document.getElementById('cookie-count').textContent = pairs.length;
+
if (!pairs.length) {
tbody.innerHTML = '
sin cookies ';
return;
diff --git a/services/app/src/views/inicio.ejs.bak b/services/app/src/views/inicio.ejs.bak
new file mode 100644
index 0000000..392e712
--- /dev/null
+++ b/services/app/src/views/inicio.ejs.bak
@@ -0,0 +1,130 @@
+
+
+
+
+
+
+
+
Inicio • SuiteCoffee
+
+
+
+
+
+ <%
+ const hasUser = typeof user !== 'undefined' && user;
+ const hasCookies = typeof cookies !== 'undefined' && cookies && Object.keys(cookies).length;
+ const displayName =
+ (hasUser && (user.name || user.displayName || user.email)) ||
+ (hasCookies && (cookies.user_name || cookies.displayName || cookies.email)) ||
+ 'usuario';
+ %>
+
Hola, <%= displayName %> 👋
+
Bienvenido a SuiteCoffee. Este es tu inicio.
+
+ <% if (hasUser) { %>
+
Sesión
+
+
+ <% for (const [k,v] of Object.entries(user)) { %>
+
+ <%= k %>
+ <%= typeof v === 'object' ? JSON.stringify(v) : String(v) %>
+
+ <% } %>
+
+
+ <% } %>
+
+
+
+ Cookies (servidor)
+ <% if (hasCookies) { %>
+
+
+ Nombre Valor
+
+
+ <% for (const [name, value] of Object.entries(cookies)) { %>
+
+ <%= name %>
+ <%= typeof value === 'object' ? JSON.stringify(value) : String(value) %>
+
+ <% } %>
+
+
+ <% } else { %>
+ No se recibieron cookies desde el servidor (req.cookies). ¿Estás usando cookie-parser o pasando cookies al render?
+ <% } %>
+
+
+
+ Cookies (navegador)
+
+
+ Nombre Valor
+
+
+ Cargando…
+
+
+ Raw document.cookie:
+
+
+
+
+
+
+
+
+
diff --git a/services/auth/.env.development b/services/auth/.env.development
index 0130084..8775b30 100644
--- a/services/auth/.env.development
+++ b/services/auth/.env.development
@@ -5,6 +5,7 @@ PORT=4040
# ===== Session (usa el Redis del stack) =====
# Para DEV podemos reutilizar el Redis de Authentik. En prod conviene uno separado.
SESSION_SECRET=Neon*Mammal*Boaster*Ludicrous*Fender8*Crablike
+SESSION_COOKIE_NAME=sc.sid
REDIS_URL=redis://ak-redis:6379
# ===== DB principal (metadatos de SuiteCoffee) =====
@@ -22,82 +23,22 @@ TENANTS_USER=dev-user-postgres
TENANTS_PASS=dev-pass-postgres
TENANTS_PORT=5432
-TENANT_INIT_SQL=/app/src/db/initTenant.sql
-
-# ===== (Opcional) Colores UI, si alguna vista los lee =====
-COL_PRI=452D19 # Marrón oscuro
-COL_SEC=D7A666 # Crema / Café
-COL_BG=FFA500 # Naranja
+TENANT_INIT_SQL=/app/src/db/initTenant_v2.sql
# ===== Authentik — Admin API (server-to-server dentro de la red) =====
# Usa el alias de red del servicio 'authentik' y su puerto interno 9000
-AUTHENTIK_BASE_URL=http://dev-authentik:9000
-AUTHENTIK_TOKEN=eE3bFTLd4Rpt3ZkcidTC1EppDYMIr023ev3SXt4ImHynOfAGRVtAZVBXSNxj
-AUTHENTIK_DEFAULT_GROUP_NAME=suitecoffee-users
+AUTHENTIK_TOKEN=h2apVHbd3ApMcnnSwfQPXbvximkvP8HnUE25ot3zXWuEEtJFaNCcOzDHB6Xw
+AUTH_CALLBACK_URL=https://suitecoffee.uy/auth/callback
# ===== OIDC (DEBE coincidir con el Provider) =====
# DEV (todo dentro de la red de Docker):
# - El auth service redirige al navegador a este issuer. Si NO tenés reverse proxy hacia Authentik,
# esta URL interna NO será accesible desde el navegador del host. En ese caso, ver nota más abajo.
-# OIDC_ISSUER=https://authentik.suitecoffee.mateosaldain.uy/application/o/suitecoffee/
-OIDC_ISSUER=https://sso.suitecoffee.uy/application/o/suitecoffee/
+# AUTHENTIK_ISSUER=https://sso.suitecoffee.mateosaldain.uy/application/o/suitecoffee/
+AUTHENTIK_ISSUER=https://sso.suitecoffee.uy/application/o/suitecoffee/
+
OIDC_CLIENT_ID=1orMM8vOvf3WkN2FejXYvUFpPtONG0Lx1eMlwIpW
OIDC_CLIENT_SECRET=t5wx13qBcM0EFQ3cGnUIAmLzvbdsQrUVPv1OGWjszWkEp35pJQ55t7vZeeShqG49kuRAaiXv6PSGJLhRfGaponGaJl8gH1uCL7KIxdmm7UihgYoAXB2dFhZV4zRxfze2
-
-# Redirect URI que definiste en el Provider. Usa el alias de red del servicio 'auth' (dev-auth)
-# Si accedés desde el host sin proxy, usa mejor http://localhost:4040/auth/callback y añadilo al Provider.
-# OIDC_REDIRECT_URI=https://suitecoffee.mateosaldain.uy/auth/callback
OIDC_REDIRECT_URI=https://suitecoffee.uy/auth/callback
-# Cómo querés que maneje la contraseña Authentik para usuarios NUEVOS creados por tu backend:
-# - TEMP_FORCE_CHANGE: crea un password temporal y obliga a cambiar en el primer login (recomendado si usás login con usuario/clave)
-# - INVITE_LINK: envías/entregás un link de “establecer contraseña” (necesita flow de Enrollment/Recovery y SMTP configurado)
-# - SSO_ONLY: no setea password local; login solo por Google/Microsoft/WebAuthn
-AK_PASSWORD_MODE=TEMP_FORCE_CHANGE
-
-# (Opcional) longitud del password temporal
-AK_TEMP_PW_LENGTH=12
-
-
-# 3) Configuración en Authentik (por modo)
- # A) TEMP_FORCE_CHANGE (password temporal + cambio obligado)
- # Flow de Autenticación
- # Entra al Admin de Authentik → Flows → tu Authentication Flow (el que usa tu Provider OIDC).
- # Asegurate de que tenga:
- # Identification Stage (identifica por email/username),
- # Password Stage (para escribir contraseña).
- # Con eso, cuando el usuario entre con la clave temporal, Authentik le pedirá cambiarla.
- # Provider OIDC (suitecoffee)
- # Admin → Applications → Providers → tu provider de SuiteCoffee → Flow settings
- # Authentication flow: seleccioná el de arriba.
- # (Opcional) Email SMTP
- # Si querés notificar o enviar contraseñas temporales/enlaces desde Authentik, configura SMTP en Admin → System → Email.
- # Resultado: el usuario se registra en tu app → lo redirigís a /auth/login → Authentik pide email+clave → entra con la temporal → obliga a cambiarla → vuelve a tu app.
-
- # B) INVITE_LINK (enlace de “establecer contraseña”)
- # SMTP
- # Admin → System → Email: configura SMTP (host, puerto, credenciales, remitente).
- # Flow de Enrollment/Recovery
- # Admin → Flows → clona/crea un flow de Enrollment/Recovery con:
- # Identification Stage (email/username),
- # Email Stage (envía el link con token),
- # Password Stage (para que defina su clave),
- # (opcional) Prompt/ User Write para confirmar.
- # Guardalo con un Slug fácil (ej. enroll-set-password).
- # Cómo usarlo
- # Caminos:
- # Manual desde UI: Admin → Directory → Invitations → crear invitación, elegir Flow enroll-set-password, seleccionar usuario, copiar link y enviar.
- # Automático (más adelante): podemos automatizar por API la creación de una Invitation y envío de mail. (Si querés, te armo el helper akCreateInvitation(userUUID, flowSlug).)
- # Resultado: el registro en tu app no pone password; el usuario recibe un link para establecer la clave y desde ahí inicia normalmente.
-
- # C) SSO_ONLY (sin contraseñas locales)
- # Configura un Source (Google Workspace / Microsoft Entra / WebAuthn):
- # Admin → Directory → Sources: crea el Source (por ejemplo, Google OAuth o Entra ID).
- # Activa Create users (para que se creen en Authentik si no existen).
- # Mapea email y name.
- # Authentication Flow
- # Agrega una Source Stage del proveedor (Google/Microsoft/WebAuthn) en tu Authentication Flow.
- # (Podés dejar Password Stage deshabilitado si querés solo SSO.)
- # Provider OIDC
- # En tu Provider suitecoffee, seleccioná ese Authentication Flow.
- # Resultado: el usuario se registra en tu app → al entrar a /auth/login ve botón Iniciar con Google/Microsoft → hace click, vuelve con sesión, tu backend setea sc.sid.
\ No newline at end of file
+OIDC_ENROLLMENT_URL=https://sso.suitecoffee.uy/if/flow/registro-suitecoffee/
\ No newline at end of file
diff --git a/services/auth/package-lock.json b/services/auth/package-lock.json
index bc0735f..0955b96 100644
--- a/services/auth/package-lock.json
+++ b/services/auth/package-lock.json
@@ -21,6 +21,10 @@
"express-ejs-layouts": "^2.5.1",
"express-session": "^1.18.2",
"ioredis": "^5.7.0",
+ "jose": "^6.1.0",
+ "jsonwebtoken": "^9.0.2",
+ "jwks-rsa": "^3.2.0",
+ "node-fetch": "^3.3.2",
"openid-client": "^5.7.1",
"pg": "^8.16.3",
"pg-format": "^1.0.4",
@@ -64,6 +68,26 @@
"node-pre-gyp": "bin/node-pre-gyp"
}
},
+ "node_modules/@mapbox/node-pre-gyp/node_modules/node-fetch": {
+ "version": "2.7.0",
+ "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
+ "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
+ "license": "MIT",
+ "dependencies": {
+ "whatwg-url": "^5.0.0"
+ },
+ "engines": {
+ "node": "4.x || >=6.0.0"
+ },
+ "peerDependencies": {
+ "encoding": "^0.1.0"
+ },
+ "peerDependenciesMeta": {
+ "encoding": {
+ "optional": true
+ }
+ }
+ },
"node_modules/@redis/bloom": {
"version": "5.8.2",
"resolved": "https://registry.npmjs.org/@redis/bloom/-/bloom-5.8.2.tgz",
@@ -124,6 +148,119 @@
"@redis/client": "^5.8.2"
}
},
+ "node_modules/@types/body-parser": {
+ "version": "1.19.6",
+ "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
+ "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/connect": "*",
+ "@types/node": "*"
+ }
+ },
+ "node_modules/@types/connect": {
+ "version": "3.4.38",
+ "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz",
+ "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "*"
+ }
+ },
+ "node_modules/@types/express": {
+ "version": "4.17.23",
+ "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.23.tgz",
+ "integrity": "sha512-Crp6WY9aTYP3qPi2wGDo9iUe/rceX01UMhnF1jmwDcKCFM6cx7YhGP/Mpr3y9AASpfHixIG0E6azCcL5OcDHsQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/body-parser": "*",
+ "@types/express-serve-static-core": "^4.17.33",
+ "@types/qs": "*",
+ "@types/serve-static": "*"
+ }
+ },
+ "node_modules/@types/express-serve-static-core": {
+ "version": "4.19.6",
+ "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.6.tgz",
+ "integrity": "sha512-N4LZ2xG7DatVqhCZzOGb1Yi5lMbXSZcmdLDe9EzSndPV2HpWYWzRbaerl2n27irrm94EPpprqa8KpskPT085+A==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "*",
+ "@types/qs": "*",
+ "@types/range-parser": "*",
+ "@types/send": "*"
+ }
+ },
+ "node_modules/@types/http-errors": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz",
+ "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==",
+ "license": "MIT"
+ },
+ "node_modules/@types/jsonwebtoken": {
+ "version": "9.0.10",
+ "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz",
+ "integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/ms": "*",
+ "@types/node": "*"
+ }
+ },
+ "node_modules/@types/mime": {
+ "version": "1.3.5",
+ "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz",
+ "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==",
+ "license": "MIT"
+ },
+ "node_modules/@types/ms": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
+ "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
+ "license": "MIT"
+ },
+ "node_modules/@types/node": {
+ "version": "24.3.1",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-24.3.1.tgz",
+ "integrity": "sha512-3vXmQDXy+woz+gnrTvuvNrPzekOi+Ds0ReMxw0LzBiK3a+1k0kQn9f2NWk+lgD4rJehFUmYy2gMhJ2ZI+7YP9g==",
+ "license": "MIT",
+ "dependencies": {
+ "undici-types": "~7.10.0"
+ }
+ },
+ "node_modules/@types/qs": {
+ "version": "6.14.0",
+ "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz",
+ "integrity": "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==",
+ "license": "MIT"
+ },
+ "node_modules/@types/range-parser": {
+ "version": "1.2.7",
+ "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz",
+ "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==",
+ "license": "MIT"
+ },
+ "node_modules/@types/send": {
+ "version": "0.17.5",
+ "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.5.tgz",
+ "integrity": "sha512-z6F2D3cOStZvuk2SaP6YrwkNO65iTZcwA2ZkSABegdkAh/lf+Aa/YQndZVfmEXT5vgAp6zv06VQ3ejSVjAny4w==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mime": "^1",
+ "@types/node": "*"
+ }
+ },
+ "node_modules/@types/serve-static": {
+ "version": "1.15.8",
+ "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.8.tgz",
+ "integrity": "sha512-roei0UY3LhpOJvjbIP6ZZFngyLKl5dskOtDhxY5THRSpO+ZI+nzJ+m5yUMzGrp89YRa7lvknKkMYjqQFGwA7Sg==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/http-errors": "*",
+ "@types/node": "*",
+ "@types/send": "*"
+ }
+ },
"node_modules/abbrev": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz",
@@ -297,6 +434,12 @@
"node": ">=8"
}
},
+ "node_modules/buffer-equal-constant-time": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
+ "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
+ "license": "BSD-3-Clause"
+ },
"node_modules/bytes": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
@@ -558,6 +701,15 @@
"node": ">= 8"
}
},
+ "node_modules/data-uri-to-buffer": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz",
+ "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 12"
+ }
+ },
"node_modules/debug": {
"version": "4.4.1",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz",
@@ -643,6 +795,15 @@
"node": ">= 0.4"
}
},
+ "node_modules/ecdsa-sig-formatter": {
+ "version": "1.0.11",
+ "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
+ "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "safe-buffer": "^5.0.1"
+ }
+ },
"node_modules/ee-first": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
@@ -826,6 +987,29 @@
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT"
},
+ "node_modules/fetch-blob": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz",
+ "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/jimmywarting"
+ },
+ {
+ "type": "paypal",
+ "url": "https://paypal.me/jimmywarting"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "node-domexception": "^1.0.0",
+ "web-streams-polyfill": "^3.0.3"
+ },
+ "engines": {
+ "node": "^12.20 || >= 14.13"
+ }
+ },
"node_modules/filelist": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.4.tgz",
@@ -943,6 +1127,18 @@
"node": ">= 0.6"
}
},
+ "node_modules/formdata-polyfill": {
+ "version": "4.0.10",
+ "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz",
+ "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==",
+ "license": "MIT",
+ "dependencies": {
+ "fetch-blob": "^3.1.2"
+ },
+ "engines": {
+ "node": ">=12.20.0"
+ }
+ },
"node_modules/forwarded": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
@@ -1367,6 +1563,65 @@
}
},
"node_modules/jose": {
+ "version": "6.1.0",
+ "resolved": "https://registry.npmjs.org/jose/-/jose-6.1.0.tgz",
+ "integrity": "sha512-TTQJyoEoKcC1lscpVDCSsVgYzUDg/0Bt3WE//WiTPK6uOCQC2KZS4MpugbMWt/zyjkopgZoXhZuCi00gLudfUA==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/panva"
+ }
+ },
+ "node_modules/jsonwebtoken": {
+ "version": "9.0.2",
+ "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz",
+ "integrity": "sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==",
+ "license": "MIT",
+ "dependencies": {
+ "jws": "^3.2.2",
+ "lodash.includes": "^4.3.0",
+ "lodash.isboolean": "^3.0.3",
+ "lodash.isinteger": "^4.0.4",
+ "lodash.isnumber": "^3.0.3",
+ "lodash.isplainobject": "^4.0.6",
+ "lodash.isstring": "^4.0.1",
+ "lodash.once": "^4.0.0",
+ "ms": "^2.1.1",
+ "semver": "^7.5.4"
+ },
+ "engines": {
+ "node": ">=12",
+ "npm": ">=6"
+ }
+ },
+ "node_modules/jwa": {
+ "version": "1.4.2",
+ "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.2.tgz",
+ "integrity": "sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw==",
+ "license": "MIT",
+ "dependencies": {
+ "buffer-equal-constant-time": "^1.0.1",
+ "ecdsa-sig-formatter": "1.0.11",
+ "safe-buffer": "^5.0.1"
+ }
+ },
+ "node_modules/jwks-rsa": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/jwks-rsa/-/jwks-rsa-3.2.0.tgz",
+ "integrity": "sha512-PwchfHcQK/5PSydeKCs1ylNym0w/SSv8a62DgHJ//7x2ZclCoinlsjAfDxAAbpoTPybOum/Jgy+vkvMmKz89Ww==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/express": "^4.17.20",
+ "@types/jsonwebtoken": "^9.0.4",
+ "debug": "^4.3.4",
+ "jose": "^4.15.4",
+ "limiter": "^1.1.5",
+ "lru-memoizer": "^2.2.0"
+ },
+ "engines": {
+ "node": ">=14"
+ }
+ },
+ "node_modules/jwks-rsa/node_modules/jose": {
"version": "4.15.9",
"resolved": "https://registry.npmjs.org/jose/-/jose-4.15.9.tgz",
"integrity": "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==",
@@ -1375,6 +1630,16 @@
"url": "https://github.com/sponsors/panva"
}
},
+ "node_modules/jws": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz",
+ "integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==",
+ "license": "MIT",
+ "dependencies": {
+ "jwa": "^1.4.1",
+ "safe-buffer": "^5.0.1"
+ }
+ },
"node_modules/keygrip": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/keygrip/-/keygrip-1.1.0.tgz",
@@ -1387,18 +1652,71 @@
"node": ">= 0.6"
}
},
+ "node_modules/limiter": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/limiter/-/limiter-1.1.5.tgz",
+ "integrity": "sha512-FWWMIEOxz3GwUI4Ts/IvgVy6LPvoMPgjMdQ185nN6psJyBJ4yOpzqm695/h5umdLJg2vW3GR5iG11MAkR2AzJA=="
+ },
+ "node_modules/lodash.clonedeep": {
+ "version": "4.5.0",
+ "resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz",
+ "integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==",
+ "license": "MIT"
+ },
"node_modules/lodash.defaults": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz",
"integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==",
"license": "MIT"
},
+ "node_modules/lodash.includes": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
+ "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
+ "license": "MIT"
+ },
"node_modules/lodash.isarguments": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz",
"integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==",
"license": "MIT"
},
+ "node_modules/lodash.isboolean": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
+ "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.isinteger": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
+ "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.isnumber": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz",
+ "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.isplainobject": {
+ "version": "4.0.6",
+ "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
+ "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.isstring": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz",
+ "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.once": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
+ "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
+ "license": "MIT"
+ },
"node_modules/lru-cache": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
@@ -1411,6 +1729,16 @@
"node": ">=10"
}
},
+ "node_modules/lru-memoizer": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/lru-memoizer/-/lru-memoizer-2.3.0.tgz",
+ "integrity": "sha512-GXn7gyHAMhO13WSKrIiNfztwxodVsP8IoZ3XfrJV4yH2x0/OeTO/FIaAHTY5YekdGgW94njfuKmyyt1E0mR6Ug==",
+ "license": "MIT",
+ "dependencies": {
+ "lodash.clonedeep": "^4.5.0",
+ "lru-cache": "6.0.0"
+ }
+ },
"node_modules/make-dir": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz",
@@ -1565,24 +1893,42 @@
"integrity": "sha512-eh0GgfEkpnoWDq+VY8OyvYhFEzBk6jIYbRKdIlyTiAXIVJ8PyBaKb0rp7oDtoddbdoHWhq8wwr+XZ81F1rpNdA==",
"license": "MIT"
},
+ "node_modules/node-domexception": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz",
+ "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==",
+ "deprecated": "Use your platform's native DOMException instead",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/jimmywarting"
+ },
+ {
+ "type": "github",
+ "url": "https://paypal.me/jimmywarting"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=10.5.0"
+ }
+ },
"node_modules/node-fetch": {
- "version": "2.7.0",
- "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
- "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
+ "version": "3.3.2",
+ "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz",
+ "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==",
"license": "MIT",
"dependencies": {
- "whatwg-url": "^5.0.0"
+ "data-uri-to-buffer": "^4.0.0",
+ "fetch-blob": "^3.1.4",
+ "formdata-polyfill": "^4.0.10"
},
"engines": {
- "node": "4.x || >=6.0.0"
+ "node": "^12.20.0 || ^14.13.1 || >=16.0.0"
},
- "peerDependencies": {
- "encoding": "^0.1.0"
- },
- "peerDependenciesMeta": {
- "encoding": {
- "optional": true
- }
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/node-fetch"
}
},
"node_modules/nodemon": {
@@ -1736,6 +2082,15 @@
"url": "https://github.com/sponsors/panva"
}
},
+ "node_modules/openid-client/node_modules/jose": {
+ "version": "4.15.9",
+ "resolved": "https://registry.npmjs.org/jose/-/jose-4.15.9.tgz",
+ "integrity": "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/panva"
+ }
+ },
"node_modules/parseurl": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
@@ -2469,6 +2824,12 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/undici-types": {
+ "version": "7.10.0",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.10.0.tgz",
+ "integrity": "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag==",
+ "license": "MIT"
+ },
"node_modules/unpipe": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
@@ -2493,6 +2854,15 @@
"node": ">= 0.8"
}
},
+ "node_modules/web-streams-polyfill": {
+ "version": "3.3.3",
+ "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz",
+ "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 8"
+ }
+ },
"node_modules/webidl-conversions": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
diff --git a/services/auth/package.json b/services/auth/package.json
index a541c61..41dcffa 100644
--- a/services/auth/package.json
+++ b/services/auth/package.json
@@ -27,6 +27,10 @@
"express-ejs-layouts": "^2.5.1",
"express-session": "^1.18.2",
"ioredis": "^5.7.0",
+ "jose": "^6.1.0",
+ "jsonwebtoken": "^9.0.2",
+ "jwks-rsa": "^3.2.0",
+ "node-fetch": "^3.3.2",
"openid-client": "^5.7.1",
"pg": "^8.16.3",
"pg-format": "^1.0.4",
diff --git a/services/auth/src/db/initTenant_v2.sql b/services/auth/src/db/initTenant_v2.sql
new file mode 100644
index 0000000..2081ece
--- /dev/null
+++ b/services/auth/src/db/initTenant_v2.sql
@@ -0,0 +1,2267 @@
+--
+-- PostgreSQL database dump
+--
+
+
+-- Dumped from database version 16.10 (Debian 16.10-1.pgdg13+1)
+-- Dumped by pg_dump version 16.10 (Debian 16.10-1.pgdg13+1)
+
+BEGIN;
+SET client_encoding = 'UTF8';
+SET standard_conforming_strings = on;
+SET row_security = off;
+SET statement_timeout = 0;
+SET lock_timeout = 0;
+SET idle_in_transaction_session_timeout = 0;
+SET client_encoding = 'UTF8';
+SET standard_conforming_strings = on;
+SELECT pg_catalog.set_config('search_path', '', false);
+SET check_function_bodies = false;
+SET xmloption = content;
+SET client_min_messages = warning;
+SET row_security = off;
+
+
+CREATE FUNCTION asistencia_delete_raw(p_id_raw bigint, p_tz text DEFAULT 'America/Montevideo'::text) RETURNS jsonb
+ LANGUAGE plpgsql
+ AS $$
+DECLARE
+ v_id_usuario INT;
+ v_ts TIMESTAMPTZ;
+ v_t0 TIMESTAMPTZ;
+ v_t1 TIMESTAMPTZ;
+ v_del_raw INT;
+ v_del INT;
+ v_ins INT;
+BEGIN
+ SELECT id_usuario, ts INTO v_id_usuario, v_ts
+ FROM asistencia_raw WHERE id_raw = p_id_raw;
+ IF v_id_usuario IS NULL THEN
+ RETURN jsonb_build_object('deleted',0,'msg','id_raw inexistente');
+ END IF;
+
+ v_t0 := v_ts - INTERVAL '1 day';
+ v_t1 := v_ts + INTERVAL '1 day';
+
+ -- borrar raw
+ DELETE FROM asistencia_raw WHERE id_raw = p_id_raw;
+ GET DIAGNOSTICS v_del_raw = ROW_COUNT;
+
+ -- recomputar pares en ventana
+ WITH tl AS (
+ SELECT ar.ts,
+ ROW_NUMBER() OVER (ORDER BY ar.ts) AS rn
+ FROM asistencia_raw ar
+ WHERE ar.id_usuario = v_id_usuario
+ AND ar.ts BETWEEN v_t0 AND v_t1
+ ),
+ ready AS (
+ SELECT (t1.ts AT TIME ZONE p_tz)::date AS fecha,
+ t1.ts AS desde,
+ t2.ts AS hasta,
+ EXTRACT(EPOCH FROM (t2.ts - t1.ts))/60.0 AS dur_min
+ FROM tl t1
+ JOIN tl t2 ON t2.rn = t1.rn + 1
+ WHERE (t1.rn % 2) = 1 AND t2.ts > t1.ts
+ ),
+ del AS (
+ DELETE FROM asistencia_intervalo ai
+ WHERE ai.id_usuario = v_id_usuario
+ AND (ai.desde BETWEEN v_t0 AND v_t1 OR ai.hasta BETWEEN v_t0 AND v_t1)
+ RETURNING 1
+ ),
+ ins AS (
+ INSERT INTO asistencia_intervalo (id_usuario, fecha, desde, hasta, dur_min, origen)
+ SELECT v_id_usuario, r.fecha, r.desde, r.hasta, r.dur_min, 'delete_adjust'
+ FROM ready r
+ ON CONFLICT (id_usuario, desde, hasta) DO NOTHING
+ RETURNING 1
+ )
+ SELECT (SELECT COUNT(*) FROM del), (SELECT COUNT(*) FROM ins) INTO v_del, v_ins;
+
+ RETURN jsonb_build_object('deleted',v_del_raw,'deleted_pairs',v_del,'inserted_pairs',v_ins);
+END;
+$$;
+
+
+--
+-- Name: asistencia_get(text, date, date, text); Type: FUNCTION; Schema: ; Owner: -
+--
+
+CREATE FUNCTION asistencia_get(p_doc text, p_desde date, p_hasta date, p_tz text DEFAULT 'America/Montevideo'::text) RETURNS jsonb
+ LANGUAGE sql
+ AS $$
+WITH u AS (
+ SELECT id_usuario, documento, nombre, apellido
+ FROM usuarios
+ WHERE regexp_replace(documento,'^\s*0+','','g') = regexp_replace(p_doc,'^\s*0+','','g')
+ LIMIT 1
+),
+r AS (
+ SELECT ar.id_raw,
+ (ar.ts AT TIME ZONE p_tz)::date AS fecha,
+ to_char(ar.ts AT TIME ZONE p_tz,'HH24:MI:SS') AS hora,
+ COALESCE(ar.modo,'') AS modo,
+ COALESCE(ar.origen,'') AS origen,
+ ar.ts
+ FROM asistencia_raw ar
+ JOIN u USING (id_usuario)
+ WHERE (ar.ts AT TIME ZONE p_tz)::date BETWEEN p_desde AND p_hasta
+),
+i AS (
+ SELECT ai.id_intervalo,
+ ai.fecha,
+ to_char(ai.desde AT TIME ZONE p_tz,'HH24:MI:SS') AS desde_hora,
+ to_char(ai.hasta AT TIME ZONE p_tz,'HH24:MI:SS') AS hasta_hora,
+ ai.dur_min
+ FROM asistencia_intervalo ai
+ JOIN u USING (id_usuario)
+ WHERE ai.fecha BETWEEN p_desde AND p_hasta
+)
+SELECT jsonb_build_object(
+ 'usuario', (SELECT to_jsonb(u.*) FROM u),
+ 'raw', COALESCE((SELECT jsonb_agg(to_jsonb(r.*) ORDER BY r.ts) FROM r),'[]'::jsonb),
+ 'intervalos', COALESCE((SELECT jsonb_agg(to_jsonb(i.*) ORDER BY i.fecha, i.id_intervalo) FROM i),'[]'::jsonb)
+);
+$$;
+
+
+--
+-- Name: asistencia_update_raw(bigint, date, text, text, text); Type: FUNCTION; Schema: ; Owner: -
+--
+
+CREATE FUNCTION asistencia_update_raw(p_id_raw bigint, p_fecha date, p_hora text, p_modo text, p_tz text DEFAULT 'America/Montevideo'::text) RETURNS jsonb
+ LANGUAGE plpgsql
+ AS $$
+DECLARE
+ v_id_usuario INT;
+ v_ts_old TIMESTAMPTZ;
+ v_ts_new TIMESTAMPTZ;
+ v_t0 TIMESTAMPTZ;
+ v_t1 TIMESTAMPTZ;
+ v_del INT;
+ v_ins INT;
+BEGIN
+ -- leer estado previo
+ SELECT id_usuario, ts INTO v_id_usuario, v_ts_old
+ FROM asistencia_raw WHERE id_raw = p_id_raw;
+ IF v_id_usuario IS NULL THEN
+ RETURN jsonb_build_object('updated',0,'msg','id_raw inexistente');
+ END IF;
+
+ -- construir ts nuevo
+ v_ts_new := make_timestamptz(
+ EXTRACT(YEAR FROM p_fecha)::INT,
+ EXTRACT(MONTH FROM p_fecha)::INT,
+ EXTRACT(DAY FROM p_fecha)::INT,
+ split_part(p_hora,':',1)::INT,
+ split_part(p_hora,':',2)::INT,
+ COALESCE(NULLIF(split_part(p_hora,':',3),''), '0')::INT,
+ p_tz);
+
+ -- aplicar update
+ UPDATE asistencia_raw
+ SET ts = v_ts_new,
+ modo = COALESCE(p_modo, modo)
+ WHERE id_raw = p_id_raw;
+
+ -- ventana de recálculo
+ v_t0 := LEAST(v_ts_old, v_ts_new) - INTERVAL '1 day';
+ v_t1 := GREATEST(v_ts_old, v_ts_new) + INTERVAL '1 day';
+
+ -- recomputar pares en la ventana: borrar los del rango y reinsertar
+ WITH tl AS (
+ SELECT ar.ts,
+ ROW_NUMBER() OVER (ORDER BY ar.ts) AS rn
+ FROM asistencia_raw ar
+ WHERE ar.id_usuario = v_id_usuario
+ AND ar.ts BETWEEN v_t0 AND v_t1
+ ),
+ ready AS (
+ SELECT (t1.ts AT TIME ZONE p_tz)::date AS fecha,
+ t1.ts AS desde,
+ t2.ts AS hasta,
+ EXTRACT(EPOCH FROM (t2.ts - t1.ts))/60.0 AS dur_min
+ FROM tl t1
+ JOIN tl t2 ON t2.rn = t1.rn + 1
+ WHERE (t1.rn % 2) = 1 AND t2.ts > t1.ts
+ ),
+ del AS (
+ DELETE FROM asistencia_intervalo ai
+ WHERE ai.id_usuario = v_id_usuario
+ AND (ai.desde BETWEEN v_t0 AND v_t1 OR ai.hasta BETWEEN v_t0 AND v_t1)
+ RETURNING 1
+ ),
+ ins AS (
+ INSERT INTO asistencia_intervalo (id_usuario, fecha, desde, hasta, dur_min, origen)
+ SELECT v_id_usuario, r.fecha, r.desde, r.hasta, r.dur_min, 'edit_manual'
+ FROM ready r
+ ON CONFLICT (id_usuario, desde, hasta) DO NOTHING
+ RETURNING 1
+ )
+ SELECT (SELECT COUNT(*) FROM del), (SELECT COUNT(*) FROM ins) INTO v_del, v_ins;
+
+ RETURN jsonb_build_object('updated',1,'deleted_pairs',v_del,'inserted_pairs',v_ins);
+END;
+$$;
+
+
+--
+-- Name: delete_compra(integer); Type: FUNCTION; Schema: ; Owner: -
+--
+
+CREATE FUNCTION delete_compra(p_id_compra integer) RETURNS void
+ LANGUAGE plpgsql
+ AS $$
+BEGIN
+ DELETE FROM deta_comp_materias WHERE id_compra = p_id_compra;
+ DELETE FROM deta_comp_producto WHERE id_compra = p_id_compra;
+ DELETE FROM compras WHERE id_compra = p_id_compra;
+END;
+$$;
+
+
+--
+-- Name: f_abrir_comanda(integer); Type: FUNCTION; Schema: ; Owner: -
+--
+
+CREATE FUNCTION f_abrir_comanda(p_id integer) RETURNS jsonb
+ LANGUAGE plpgsql
+ AS $$
+DECLARE r jsonb;
+BEGIN
+ UPDATE comandas
+ SET estado = 'abierta',
+ fec_cierre = NULL
+ WHERE id_comanda = p_id;
+
+ IF NOT FOUND THEN
+ RETURN NULL;
+ END IF;
+
+ SELECT to_jsonb(v) INTO r
+ FROM v_comandas_resumen v
+ WHERE v.id_comanda = p_id;
+
+ RETURN r;
+END;
+$$;
+
+
+--
+-- Name: f_cerrar_comanda(integer); Type: FUNCTION; Schema: ; Owner: -
+--
+
+CREATE FUNCTION f_cerrar_comanda(p_id integer) RETURNS jsonb
+ LANGUAGE plpgsql
+ AS $$
+DECLARE r jsonb;
+BEGIN
+ UPDATE comandas
+ SET estado = 'cerrada',
+ fec_cierre = COALESCE(fec_cierre, NOW())
+ WHERE id_comanda = p_id;
+
+ IF NOT FOUND THEN
+ RETURN NULL;
+ END IF;
+
+ SELECT to_jsonb(v) INTO r
+ FROM v_comandas_resumen v
+ WHERE v.id_comanda = p_id;
+
+ RETURN r;
+END;
+$$;
+
+
+--
+-- Name: f_comanda_detalle_json(integer); Type: FUNCTION; Schema: ; Owner: -
+--
+
+CREATE FUNCTION f_comanda_detalle_json(p_id_comanda integer) RETURNS jsonb
+ LANGUAGE sql
+ AS $$
+WITH base AS (
+ SELECT
+ c.id_comanda,
+ c.fec_creacion,
+ c.estado,
+ c.observaciones,
+ u.id_usuario,
+ u.nombre AS usuario_nombre,
+ u.apellido AS usuario_apellido,
+ m.id_mesa,
+ m.numero AS mesa_numero,
+ m.apodo AS mesa_apodo,
+ d.id_producto,
+ p.nombre AS producto_nombre,
+ d.cantidad,
+ d.pre_unitario,
+ (d.cantidad * d.pre_unitario) AS subtotal
+ FROM comandas c
+ JOIN usuarios u ON u.id_usuario = c.id_usuario
+ JOIN mesas m ON m.id_mesa = c.id_mesa
+ LEFT JOIN deta_comandas d ON d.id_comanda = c.id_comanda
+ LEFT JOIN productos p ON p.id_producto = d.id_producto
+ WHERE c.id_comanda = p_id_comanda
+),
+hdr AS (
+ -- 1 sola fila con los datos de cabecera
+ SELECT DISTINCT
+ id_comanda, fec_creacion, estado, observaciones,
+ id_usuario, usuario_nombre, usuario_apellido,
+ id_mesa, mesa_numero, mesa_apodo
+ FROM base
+),
+agg_items AS (
+ SELECT
+ COALESCE(
+ jsonb_agg(
+ jsonb_build_object(
+ 'producto_id', b.id_producto,
+ 'producto', b.producto_nombre,
+ 'cantidad', b.cantidad,
+ 'pre_unitario', b.pre_unitario,
+ 'subtotal', b.subtotal
+ )
+ ORDER BY b.producto_nombre NULLS LAST
+ ) FILTER (WHERE b.id_producto IS NOT NULL),
+ '[]'::jsonb
+ ) AS items
+ FROM base b
+),
+tot AS (
+ SELECT
+ COUNT(*) FILTER (WHERE id_producto IS NOT NULL) AS items,
+ COALESCE(SUM(subtotal), 0)::numeric AS total
+ FROM base
+)
+SELECT
+ CASE
+ WHEN EXISTS (SELECT 1 FROM hdr) THEN
+ jsonb_build_object(
+ 'id_comanda', h.id_comanda,
+ 'fec_creacion', h.fec_creacion,
+ 'estado', h.estado,
+ 'observaciones',h.observaciones,
+ 'usuario', jsonb_build_object(
+ 'id_usuario', h.id_usuario,
+ 'nombre', h.usuario_nombre,
+ 'apellido', h.usuario_apellido
+ ),
+ 'mesa', jsonb_build_object(
+ 'id_mesa', h.id_mesa,
+ 'numero', h.mesa_numero,
+ 'apodo', h.mesa_apodo
+ ),
+ 'items', i.items,
+ 'totales', jsonb_build_object(
+ 'items', t.items,
+ 'total', t.total
+ )
+ )
+ ELSE NULL
+ END
+FROM hdr h, agg_items i, tot t;
+$$;
+
+
+--
+-- Name: f_comanda_detalle_rows(integer); Type: FUNCTION; Schema: ; Owner: -
+--
+
+CREATE FUNCTION f_comanda_detalle_rows(p_id_comanda integer) RETURNS TABLE(id_comanda integer, fec_creacion timestamp without time zone, estado text, observaciones text, id_usuario integer, usuario_nombre text, usuario_apellido text, id_mesa integer, mesa_numero integer, mesa_apodo text, producto_id integer, producto_nombre text, cantidad numeric, pre_unitario numeric, subtotal numeric, items integer, total numeric)
+ LANGUAGE sql
+ AS $$
+WITH base AS (
+ SELECT
+ c.id_comanda, c.fec_creacion, c.estado, c.observaciones,
+ u.id_usuario, u.nombre AS usuario_nombre, u.apellido AS usuario_apellido,
+ m.id_mesa, m.numero AS mesa_numero, m.apodo AS mesa_apodo,
+ d.id_producto, p.nombre AS producto_nombre,
+ d.cantidad, d.pre_unitario,
+ (d.cantidad * d.pre_unitario) AS subtotal
+ FROM comandas c
+ JOIN usuarios u ON u.id_usuario = c.id_usuario
+ JOIN mesas m ON m.id_mesa = c.id_mesa
+ LEFT JOIN deta_comandas d ON d.id_comanda = c.id_comanda
+ LEFT JOIN productos p ON p.id_producto = d.id_producto
+ WHERE c.id_comanda = p_id_comanda
+),
+tot AS (
+ SELECT
+ COUNT(*) FILTER (WHERE id_producto IS NOT NULL) AS items,
+ COALESCE(SUM(subtotal), 0) AS total
+ FROM base
+)
+SELECT
+ b.id_comanda, b.fec_creacion, b.estado, b.observaciones,
+ b.id_usuario, b.usuario_nombre, b.usuario_apellido,
+ b.id_mesa, b.mesa_numero, b.mesa_apodo,
+ b.id_producto, b.producto_nombre,
+ b.cantidad, b.pre_unitario, b.subtotal,
+ t.items, t.total
+FROM base b CROSS JOIN tot t
+ORDER BY b.producto_nombre NULLS LAST;
+$$;
+
+
+SET default_tablespace = '';
+
+SET default_table_access_method = heap;
+
+--
+-- Name: comandas; Type: TABLE; Schema: ; Owner: -
+--
+
+CREATE TABLE comandas (
+ id_comanda integer NOT NULL,
+ id_usuario integer NOT NULL,
+ id_mesa integer NOT NULL,
+ fec_creacion timestamp without time zone DEFAULT now() NOT NULL,
+ estado text NOT NULL,
+ observaciones text,
+ fec_cierre timestamp with time zone,
+ CONSTRAINT comandas_estado_check CHECK ((estado = ANY (ARRAY['abierta'::text, 'cerrada'::text, 'pagada'::text, 'anulada'::text])))
+);
+
+
+--
+-- Name: COLUMN comandas.fec_cierre; Type: COMMENT; Schema: ; Owner: -
+--
+
+COMMENT ON COLUMN comandas.fec_cierre IS 'Fecha/hora de cierre de la comanda (NULL si está abierta)';
+
+
+--
+-- Name: deta_comandas; Type: TABLE; Schema: ; Owner: -
+--
+
+CREATE TABLE deta_comandas (
+ id_det_comanda integer NOT NULL,
+ id_comanda integer NOT NULL,
+ id_producto integer NOT NULL,
+ cantidad numeric(12,3) NOT NULL,
+ pre_unitario numeric(12,2) NOT NULL,
+ observaciones text,
+ CONSTRAINT deta_comandas_cantidad_check CHECK ((cantidad > (0)::numeric)),
+ CONSTRAINT deta_comandas_pre_unitario_check CHECK ((pre_unitario >= (0)::numeric))
+);
+
+
+--
+-- Name: mesas; Type: TABLE; Schema: ; Owner: -
+--
+
+CREATE TABLE mesas (
+ id_mesa integer NOT NULL,
+ numero integer NOT NULL,
+ apodo text NOT NULL,
+ estado text DEFAULT 'libre'::text NOT NULL,
+ CONSTRAINT mesas_estado_check CHECK ((estado = ANY (ARRAY['libre'::text, 'ocupada'::text, 'reservada'::text])))
+);
+
+
+--
+-- Name: usuarios; Type: TABLE; Schema: ; Owner: -
+--
+
+CREATE TABLE usuarios (
+ id_usuario integer NOT NULL,
+ documento text,
+ img_perfil character varying(255) DEFAULT 'img_perfil.png'::character varying NOT NULL,
+ nombre text NOT NULL,
+ apellido text NOT NULL,
+ correo text,
+ telefono text,
+ fec_nacimiento date,
+ activo boolean DEFAULT true
+);
+
+
+--
+-- Name: v_comandas_resumen; Type: VIEW; Schema: ; Owner: -
+--
+
+CREATE VIEW v_comandas_resumen AS
+ WITH items AS (
+ SELECT d.id_comanda,
+ count(*) AS items,
+ sum((d.cantidad * d.pre_unitario)) AS total
+ FROM deta_comandas d
+ GROUP BY d.id_comanda
+ )
+ SELECT c.id_comanda,
+ c.fec_creacion,
+ c.estado,
+ c.observaciones,
+ u.id_usuario,
+ u.nombre AS usuario_nombre,
+ u.apellido AS usuario_apellido,
+ m.id_mesa,
+ m.numero AS mesa_numero,
+ m.apodo AS mesa_apodo,
+ COALESCE(i.items, (0)::bigint) AS items,
+ COALESCE(i.total, (0)::numeric) AS total,
+ c.fec_cierre,
+ CASE
+ WHEN (c.fec_cierre IS NOT NULL) THEN round((EXTRACT(epoch FROM (c.fec_cierre - (c.fec_creacion)::timestamp with time zone)) / 60.0), 1)
+ ELSE NULL::numeric
+ END AS duracion_min
+ FROM (((comandas c
+ JOIN usuarios u ON ((u.id_usuario = c.id_usuario)))
+ JOIN mesas m ON ((m.id_mesa = c.id_mesa)))
+ LEFT JOIN items i ON ((i.id_comanda = c.id_comanda)));
+
+
+--
+-- Name: f_comandas_resumen(text, integer); Type: FUNCTION; Schema: ; Owner: -
+--
+
+CREATE FUNCTION f_comandas_resumen(p_estado text DEFAULT NULL::text, p_limit integer DEFAULT 200) RETURNS SETOF v_comandas_resumen
+ LANGUAGE sql
+ AS $$
+ SELECT *
+ FROM v_comandas_resumen
+ WHERE (p_estado IS NULL OR estado = p_estado)
+ ORDER BY id_comanda DESC
+ LIMIT p_limit;
+$$;
+
+
+--
+-- Name: find_usuarios_por_documentos(jsonb); Type: FUNCTION; Schema: ; Owner: -
+--
+
+CREATE FUNCTION find_usuarios_por_documentos(p_docs jsonb) RETURNS jsonb
+ LANGUAGE sql
+ AS $$
+WITH docs AS (
+ SELECT DISTINCT
+ regexp_replace(value::text, '^\s*0+', '', 'g') AS doc_clean,
+ value::text AS original
+ FROM jsonb_array_elements_text(COALESCE(p_docs,'[]'))
+),
+rows AS (
+ SELECT d.original AS documento,
+ u.nombre,
+ u.apellido,
+ (u.id_usuario IS NOT NULL) AS found
+ FROM docs d
+ LEFT JOIN usuarios u
+ ON regexp_replace(u.documento, '^\s*0+', '', 'g') = d.doc_clean
+)
+SELECT COALESCE(
+ jsonb_object_agg(
+ documento,
+ jsonb_build_object(
+ 'nombre', COALESCE(nombre, ''),
+ 'apellido', COALESCE(apellido, ''),
+ 'found', found
+ )
+ ),
+ '{}'::jsonb
+)
+FROM rows;
+$$;
+
+
+--
+-- Name: get_compra(integer); Type: FUNCTION; Schema: ; Owner: -
+--
+
+CREATE FUNCTION get_compra(p_id_compra integer) RETURNS jsonb
+ LANGUAGE sql
+ AS $$
+WITH cab AS (
+ SELECT c.id_compra, c.id_proveedor, c.fec_compra, c.total
+ FROM compras c
+ WHERE c.id_compra = p_id_compra
+),
+dm AS (
+ SELECT 'MAT'::text AS tipo, d.id_mat_prima AS id,
+ d.cantidad, d.pre_unitario AS precio
+ FROM deta_comp_materias d WHERE d.id_compra = p_id_compra
+),
+dp AS (
+ SELECT 'PROD'::text AS tipo, d.id_producto AS id,
+ d.cantidad, d.pre_unitario AS precio
+ FROM deta_comp_producto d WHERE d.id_compra = p_id_compra
+),
+det AS (
+ SELECT jsonb_agg(to_jsonb(x.*)) AS detalles
+ FROM (
+ SELECT * FROM dm
+ UNION ALL
+ SELECT * FROM dp
+ ) x
+)
+SELECT jsonb_build_object(
+ 'id_compra', (SELECT id_compra FROM cab),
+ 'id_proveedor',(SELECT id_proveedor FROM cab),
+ 'fec_compra', to_char((SELECT fec_compra FROM cab),'YYYY-MM-DD HH24:MI:SS'),
+ 'total', (SELECT total FROM cab),
+ 'detalles', COALESCE((SELECT detalles FROM det),'[]'::jsonb)
+);
+$$;
+
+
+--
+-- Name: get_materia_prima(integer); Type: FUNCTION; Schema: ; Owner: -
+--
+
+CREATE FUNCTION get_materia_prima(p_id integer) RETURNS jsonb
+ LANGUAGE sql
+ AS $$
+SELECT jsonb_build_object(
+ 'materia', to_jsonb(mp),
+ 'proveedores', COALESCE(
+ (
+ SELECT jsonb_agg(
+ jsonb_build_object(
+ 'id_proveedor', pr.id_proveedor,
+ 'raz_social', pr.raz_social,
+ 'rut', pr.rut,
+ 'contacto', pr.contacto,
+ 'direccion', pr.direccion
+ )
+ )
+ FROM prov_mate_prima pmp
+ JOIN proveedores pr ON pr.id_proveedor = pmp.id_proveedor
+ WHERE pmp.id_mat_prima = mp.id_mat_prima
+ ),
+ '[]'::jsonb
+ )
+)
+FROM mate_primas mp
+WHERE mp.id_mat_prima = p_id;
+$$;
+
+
+--
+-- Name: get_producto(integer); Type: FUNCTION; Schema: ; Owner: -
+--
+
+CREATE FUNCTION get_producto(p_id integer) RETURNS jsonb
+ LANGUAGE sql
+ AS $$
+SELECT jsonb_build_object(
+ 'producto', to_jsonb(p), -- el registro completo del producto en JSONB
+ 'receta', COALESCE(
+ (
+ SELECT jsonb_agg(
+ jsonb_build_object(
+ 'id_mat_prima', rp.id_mat_prima,
+ 'qty_por_unidad', rp.qty_por_unidad,
+ 'nombre', mp.nombre,
+ 'unidad', mp.unidad
+ )
+ )
+ FROM receta_producto rp
+ LEFT JOIN mate_primas mp USING (id_mat_prima)
+ WHERE rp.id_producto = p.id_producto
+ ),
+ '[]'::jsonb
+ )
+)
+FROM productos p
+WHERE p.id_producto = p_id;
+$$;
+
+
+--
+-- Name: import_asistencia(jsonb, text, text); Type: FUNCTION; Schema: ; Owner: -
+--
+
+CREATE FUNCTION import_asistencia(p_registros jsonb, p_origen text, p_tz text DEFAULT 'America/Montevideo'::text) RETURNS jsonb
+ LANGUAGE plpgsql
+ AS $_$
+DECLARE
+ v_ins_raw INT;
+ v_ins_pairs INT;
+ v_miss JSONB;
+BEGIN
+ WITH
+ -- 1) JSON -> filas
+ j AS (
+ SELECT
+ regexp_replace(elem->>'doc','^\s*0+','','g')::TEXT AS doc_clean,
+ (elem->>'isoDate')::DATE AS d,
+ elem->>'time' AS time_str,
+ NULLIF(elem->>'mode','') AS modo
+ FROM jsonb_array_elements(COALESCE(p_registros,'[]')) elem
+ ),
+ -- 2) Vincular a usuarios
+ u AS (
+ SELECT j.*, u.id_usuario
+ FROM j
+ LEFT JOIN usuarios u
+ ON regexp_replace(u.documento,'^\s*0+','','g') = j.doc_clean
+ ),
+ -- 3) Documentos faltantes
+ miss AS (
+ SELECT jsonb_agg(doc_clean) AS missing
+ FROM u WHERE id_usuario IS NULL
+ ),
+ -- 4) TS determinista en TZ del negocio
+ parsed AS (
+ SELECT
+ u.id_usuario,
+ u.modo,
+ make_timestamptz(
+ EXTRACT(YEAR FROM u.d)::INT,
+ EXTRACT(MONTH FROM u.d)::INT,
+ EXTRACT(DAY FROM u.d)::INT,
+ split_part(u.time_str,':',1)::INT,
+ split_part(u.time_str,':',2)::INT,
+ COALESCE(NULLIF(split_part(u.time_str,':',3),''),'0')::INT,
+ p_tz
+ ) AS ts_calc
+ FROM u
+ WHERE u.id_usuario IS NOT NULL
+ ),
+ -- 5) Ventana por usuario (±1 día de lo importado)
+ win AS (
+ SELECT id_usuario,
+ (MIN(ts_calc) - INTERVAL '1 day') AS t0,
+ (MAX(ts_calc) + INTERVAL '1 day') AS t1
+ FROM parsed
+ GROUP BY id_usuario
+ ),
+ -- 6) Lo existente en BD dentro de la ventana
+ existing AS (
+ SELECT ar.id_usuario, ar.ts
+ FROM asistencia_raw ar
+ JOIN win w ON w.id_usuario = ar.id_usuario
+ AND ar.ts BETWEEN w.t0 AND w.t1
+ ),
+ -- 7) CANDIDATE = existente ∪ archivo (sin duplicados)
+ candidate AS (
+ SELECT id_usuario, ts FROM existing
+ UNION -- ¡clave para evitar doble click!
+ SELECT id_usuario, ts_calc AS ts FROM parsed
+ ),
+ -- 8) Paridad previa (cuántas marcas había ANTES de la ventana)
+ before_cnt AS (
+ SELECT w.id_usuario, COUNT(*)::int AS cnt
+ FROM win w
+ JOIN asistencia_raw ar
+ ON ar.id_usuario = w.id_usuario
+ AND ar.ts < w.t0
+ GROUP BY w.id_usuario
+ ),
+ -- 9) Línea de tiempo candidata y pares (1→2, 3→4…), jornada = día local del inicio
+ timeline AS (
+ SELECT
+ c.id_usuario,
+ c.ts,
+ ROW_NUMBER() OVER (PARTITION BY c.id_usuario ORDER BY c.ts) AS rn
+ FROM candidate c
+ ),
+ ready AS (
+ SELECT
+ t1.id_usuario,
+ (t1.ts AT TIME ZONE p_tz)::date AS fecha,
+ t1.ts AS desde,
+ t2.ts AS hasta,
+ EXTRACT(EPOCH FROM (t2.ts - t1.ts))/60.0 AS dur_min
+ FROM timeline t1
+ JOIN timeline t2
+ ON t2.id_usuario = t1.id_usuario
+ AND t2.rn = t1.rn + 1
+ LEFT JOIN before_cnt b ON b.id_usuario = t1.id_usuario
+ WHERE ((COALESCE(b.cnt,0) + t1.rn) % 2) = 1 -- t1 es IN global
+ AND t2.ts > t1.ts
+ ),
+ -- 10) INSERT crudo (dedupe)
+ ins_raw AS (
+ INSERT INTO asistencia_raw (id_usuario, ts, modo, origen)
+ SELECT id_usuario, ts_calc,
+ NULLIF(modo,'')::text, -- puede quedar NULL para auto-etiquetado
+ p_origen
+ FROM parsed
+ ON CONFLICT (id_usuario, ts) DO NOTHING
+ RETURNING 1
+ ),
+ -- 11) Auto-etiquetar IN/OUT en BD para filas con modo vacío/'1' (tras insertar)
+ before_cnt2 AS (
+ SELECT w.id_usuario, COUNT(*)::int AS cnt
+ FROM win w
+ JOIN asistencia_raw ar
+ ON ar.id_usuario = w.id_usuario
+ AND ar.ts < w.t0
+ GROUP BY w.id_usuario
+ ),
+ tl2 AS (
+ SELECT
+ ar.id_usuario, ar.ts,
+ ROW_NUMBER() OVER (PARTITION BY ar.id_usuario ORDER BY ar.ts) AS rn
+ FROM asistencia_raw ar
+ JOIN win w ON w.id_usuario = ar.id_usuario
+ AND ar.ts BETWEEN w.t0 AND w.t1
+ ),
+ label2 AS (
+ SELECT
+ t.id_usuario,
+ t.ts,
+ CASE WHEN ((COALESCE(b.cnt,0) + t.rn) % 2) = 1 THEN 'IN' ELSE 'OUT' END AS new_mode
+ FROM tl2 t
+ LEFT JOIN before_cnt2 b ON b.id_usuario = t.id_usuario
+ ),
+ set_mode AS (
+ UPDATE asistencia_raw ar
+ SET modo = l.new_mode
+ FROM label2 l
+ WHERE ar.id_usuario = l.id_usuario
+ AND ar.ts = l.ts
+ AND (ar.modo IS NULL OR btrim(ar.modo) = '' OR ar.modo ~ '^\s*1\s*$')
+ RETURNING 1
+ ),
+ -- 12) INSERT pares (dedupe) calculados desde CANDIDATE (ya tiene todo el contexto)
+ ins_pairs AS (
+ INSERT INTO asistencia_intervalo (id_usuario, fecha, desde, hasta, dur_min, origen)
+ SELECT id_usuario, fecha, desde, hasta, dur_min, p_origen
+ FROM ready
+ ON CONFLICT (id_usuario, desde, hasta) DO NOTHING
+ RETURNING 1
+ )
+ SELECT
+ (SELECT COUNT(*) FROM ins_raw),
+ (SELECT COUNT(*) FROM ins_pairs),
+ (SELECT COALESCE(missing,'[]'::jsonb) FROM miss)
+ INTO v_ins_raw, v_ins_pairs, v_miss;
+
+ RETURN jsonb_build_object(
+ 'inserted_raw', v_ins_raw,
+ 'inserted_pairs', v_ins_pairs,
+ 'missing_docs', v_miss
+ );
+END;
+$_$;
+
+
+--
+-- Name: report_asistencia(date, date); Type: FUNCTION; Schema: ; Owner: -
+--
+
+CREATE FUNCTION report_asistencia(p_desde date, p_hasta date) RETURNS TABLE(documento text, nombre text, apellido text, fecha date, desde_hora text, hasta_hora text, dur_min numeric)
+ LANGUAGE sql
+ AS $$
+ SELECT
+ u.documento, u.nombre, u.apellido,
+ ai.fecha,
+ to_char(ai.desde AT TIME ZONE 'America/Montevideo','HH24:MI:SS') AS desde_hora,
+ to_char(ai.hasta AT TIME ZONE 'America/Montevideo','HH24:MI:SS') AS hasta_hora,
+ ai.dur_min
+ FROM asistencia_intervalo ai
+ JOIN usuarios u USING (id_usuario)
+ WHERE ai.fecha BETWEEN p_desde AND p_hasta
+ ORDER BY u.documento, ai.fecha, ai.desde;
+$$;
+
+
+--
+-- Name: report_gastos(integer); Type: FUNCTION; Schema: ; Owner: -
+--
+
+CREATE FUNCTION report_gastos(p_year integer) RETURNS jsonb
+ LANGUAGE sql STABLE
+ AS $$
+WITH mdata AS (
+ SELECT date_trunc('month', c.fec_compra)::date AS m,
+ SUM(c.total)::numeric AS importe
+ FROM compras c
+ WHERE EXTRACT(YEAR FROM c.fec_compra) = p_year
+ GROUP BY 1
+),
+mm AS (
+ SELECT EXTRACT(MONTH FROM m)::int AS mes, importe
+ FROM mdata
+)
+SELECT jsonb_build_object(
+ 'year', p_year,
+ 'total', COALESCE((SELECT SUM(importe) FROM mdata), 0),
+ 'avg', COALESCE((SELECT AVG(importe) FROM mdata), 0),
+ 'months',
+ (SELECT jsonb_agg(
+ jsonb_build_object(
+ 'mes', gs,
+ 'nombre', to_char(to_date(gs::text,'MM'),'Mon'),
+ 'importe', COALESCE(mm.importe,0)
+ )
+ ORDER BY gs
+ )
+ FROM generate_series(1,12) gs
+ LEFT JOIN mm ON mm.mes = gs)
+);
+$$;
+
+
+--
+-- Name: report_tickets_year(integer, text); Type: FUNCTION; Schema: ; Owner: -
+--
+
+CREATE FUNCTION report_tickets_year(p_year integer, p_tz text DEFAULT 'America/Montevideo'::text) RETURNS jsonb
+ LANGUAGE sql STABLE
+ AS $$
+WITH bounds AS (
+ SELECT
+ make_timestamp(p_year, 1, 1, 0,0,0) AS d0,
+ make_timestamp(p_year+1, 1, 1, 0,0,0) AS d1,
+ make_timestamptz(p_year, 1, 1, 0,0,0, p_tz) AS t0,
+ make_timestamptz(p_year+1, 1, 1, 0,0,0, p_tz) AS t1
+),
+base AS (
+ SELECT
+ c.id_comanda,
+ CASE WHEN c.fec_cierre IS NOT NULL
+ THEN (c.fec_cierre AT TIME ZONE p_tz)
+ ELSE c.fec_creacion
+ END AS fec_local,
+ v.total
+ FROM comandas c
+ JOIN vw_ticket_total v ON v.id_comanda = c.id_comanda
+ JOIN bounds b ON TRUE
+ WHERE
+ (c.fec_cierre IS NOT NULL AND c.fec_cierre >= b.t0 AND c.fec_cierre < b.t1)
+ OR
+ (c.fec_cierre IS NULL AND c.fec_creacion >= b.d0 AND c.fec_creacion < b.d1)
+),
+m AS (
+ SELECT
+ EXTRACT(MONTH FROM fec_local)::int AS mes,
+ COUNT(*)::int AS cant,
+ SUM(total)::numeric AS importe,
+ AVG(total)::numeric AS avg
+ FROM base
+ GROUP BY 1
+),
+ytd AS (
+ SELECT COUNT(*)::int AS total_ytd,
+ AVG(total)::numeric AS avg_ticket,
+ SUM(total)::numeric AS to_date
+ FROM base
+)
+SELECT jsonb_build_object(
+ 'year', p_year,
+ 'total_ytd', (SELECT total_ytd FROM ytd),
+ 'avg_ticket', (SELECT avg_ticket FROM ytd),
+ 'to_date', (SELECT to_date FROM ytd),
+ 'months',
+ (SELECT jsonb_agg(
+ jsonb_build_object(
+ 'mes', mes,
+ 'nombre', to_char(to_date(mes::text,'MM'),'Mon'),
+ 'cant', cant,
+ 'importe', importe,
+ 'avg', avg
+ )
+ ORDER BY mes
+ )
+ FROM m)
+);
+$$;
+
+
+--
+-- Name: save_compra(integer, integer, timestamp with time zone, jsonb); Type: FUNCTION; Schema: ; Owner: -
+--
+
+CREATE FUNCTION save_compra(p_id_compra integer, p_id_proveedor integer, p_fec_compra timestamp with time zone, p_detalles jsonb) RETURNS TABLE(id_compra integer, total numeric)
+ LANGUAGE plpgsql
+ AS $$
+DECLARE
+ v_id INT;
+ v_total numeric := 0;
+BEGIN
+ IF COALESCE(jsonb_array_length(p_detalles),0) = 0 THEN
+ RAISE EXCEPTION 'No hay renglones en la compra';
+ END IF;
+
+ -- Cabecera (insert/update)
+ IF p_id_compra IS NULL THEN
+ INSERT INTO compras (id_proveedor, fec_compra, total)
+ VALUES (p_id_proveedor, COALESCE(p_fec_compra, now()), 0)
+ RETURNING compras.id_compra INTO v_id;
+ ELSE
+ UPDATE compras c
+ SET id_proveedor = p_id_proveedor,
+ fec_compra = COALESCE(p_fec_compra, c.fec_compra)
+ WHERE c.id_compra = p_id_compra
+ RETURNING c.id_compra INTO v_id;
+
+ -- Reemplazamos los renglones
+ DELETE FROM deta_comp_materias d WHERE d.id_compra = v_id;
+ DELETE FROM deta_comp_producto p WHERE p.id_compra = v_id;
+ END IF;
+
+ -- Materias primas (sin CTE: parseo JSON inline)
+ INSERT INTO deta_comp_materias (id_compra, id_mat_prima, cantidad, pre_unitario)
+ SELECT
+ v_id,
+ x.id,
+ x.cantidad,
+ x.precio
+ FROM jsonb_to_recordset(COALESCE(p_detalles, '[]'::jsonb))
+ AS x(tipo text, id int, cantidad numeric, precio numeric)
+ WHERE UPPER(TRIM(x.tipo)) = 'MAT';
+
+ -- Productos (sin CTE)
+ INSERT INTO deta_comp_producto (id_compra, id_producto, cantidad, pre_unitario)
+ SELECT
+ v_id,
+ x.id,
+ x.cantidad,
+ x.precio
+ FROM jsonb_to_recordset(COALESCE(p_detalles, '[]'::jsonb))
+ AS x(tipo text, id int, cantidad numeric, precio numeric)
+ WHERE UPPER(TRIM(x.tipo)) = 'PROD';
+
+ -- Recalcular total (calificado) y redondear a ENTERO
+ SELECT
+ COALESCE( (SELECT SUM(dcm.cantidad*dcm.pre_unitario)
+ FROM deta_comp_materias dcm
+ WHERE dcm.id_compra = v_id), 0)
+ + COALESCE( (SELECT SUM(dcp.cantidad*dcp.pre_unitario)
+ FROM deta_comp_producto dcp
+ WHERE dcp.id_compra = v_id), 0)
+ INTO v_total;
+
+ UPDATE compras c
+ SET total = round(v_total, 0)
+ WHERE c.id_compra = v_id;
+
+ RETURN QUERY SELECT v_id, round(v_total, 0);
+END;
+$$;
+
+
+--
+-- Name: save_materia_prima(integer, text, text, boolean, jsonb); Type: FUNCTION; Schema: ; Owner: -
+--
+
+CREATE FUNCTION save_materia_prima(p_id_mat_prima integer, p_nombre text, p_unidad text, p_activo boolean, p_proveedores jsonb DEFAULT '[]'::jsonb) RETURNS integer
+ LANGUAGE plpgsql
+ AS $_$
+DECLARE
+ v_id INT;
+BEGIN
+ IF p_id_mat_prima IS NULL THEN
+ INSERT INTO mate_primas (nombre, unidad, activo)
+ VALUES (p_nombre, p_unidad, COALESCE(p_activo, TRUE))
+ RETURNING mate_primas.id_mat_prima INTO v_id;
+ ELSE
+ UPDATE mate_primas mp
+ SET nombre = p_nombre,
+ unidad = p_unidad,
+ activo = COALESCE(p_activo, TRUE)
+ WHERE mp.id_mat_prima = p_id_mat_prima;
+ v_id := p_id_mat_prima;
+ END IF;
+
+ -- Sincronizar proveedores: borrar todos y re-crear a partir de JSONB
+ DELETE FROM prov_mate_prima pmp WHERE pmp.id_mat_prima = v_id;
+
+ INSERT INTO prov_mate_prima (id_proveedor, id_mat_prima)
+ SELECT (e->>0)::INT AS id_proveedor, -- elementos JSON como enteros (array simple)
+ v_id AS id_mat_prima
+ FROM jsonb_array_elements(COALESCE(p_proveedores, '[]'::jsonb)) AS e
+ WHERE (e->>0) ~ '^\d+$'; -- solo enteros
+
+ RETURN v_id;
+END;
+$_$;
+
+
+--
+-- Name: save_producto(integer, text, text, numeric, boolean, integer, jsonb); Type: FUNCTION; Schema: ; Owner: -
+--
+
+CREATE FUNCTION save_producto(p_id_producto integer, p_nombre text, p_img_producto text, p_precio numeric, p_activo boolean, p_id_categoria integer, p_receta jsonb DEFAULT '[]'::jsonb) RETURNS integer
+ LANGUAGE plpgsql
+ AS $_$
+DECLARE
+ v_id INT;
+BEGIN
+ IF p_id_producto IS NULL THEN
+ INSERT INTO productos (nombre, img_producto, precio, activo, id_categoria)
+ VALUES (p_nombre, p_img_producto, p_precio, COALESCE(p_activo, TRUE), p_id_categoria)
+ RETURNING productos.id_producto INTO v_id;
+ ELSE
+ UPDATE productos p
+ SET nombre = p_nombre,
+ img_producto = p_img_producto,
+ precio = p_precio,
+ activo = COALESCE(p_activo, TRUE),
+ id_categoria = p_id_categoria
+ WHERE p.id_producto = p_id_producto;
+ v_id := p_id_producto;
+ END IF;
+
+ -- Limpia receta actual
+ DELETE FROM receta_producto rp WHERE rp.id_producto = v_id;
+
+ -- Inserta SOLO ítems válidos (id entero positivo y cantidad > 0), redondeo a 3 decimales
+ INSERT INTO receta_producto (id_producto, id_mat_prima, qty_por_unidad)
+ SELECT
+ v_id,
+ (rec->>'id_mat_prima')::INT,
+ ROUND((rec->>'qty_por_unidad')::NUMERIC, 3)
+ FROM jsonb_array_elements(COALESCE(p_receta, '[]'::jsonb)) AS rec
+ WHERE
+ (rec->>'id_mat_prima') ~ '^\d+$'
+ AND (rec->>'id_mat_prima')::INT > 0
+ AND (rec->>'qty_por_unidad') ~ '^\d+(\.\d+)?$'
+ AND (rec->>'qty_por_unidad')::NUMERIC > 0;
+
+ RETURN v_id;
+END;
+$_$;
+
+
+--
+-- Name: asistencia_intervalo; Type: TABLE; Schema: ; Owner: -
+--
+
+CREATE TABLE asistencia_intervalo (
+ id_intervalo bigint NOT NULL,
+ id_usuario integer NOT NULL,
+ fecha date NOT NULL,
+ desde timestamp with time zone NOT NULL,
+ hasta timestamp with time zone NOT NULL,
+ dur_min numeric(10,2) NOT NULL,
+ origen text,
+ created_at timestamp with time zone DEFAULT now() NOT NULL,
+ CONSTRAINT chk_ai_orden CHECK ((hasta > desde))
+);
+
+
+--
+-- Name: asistencia_intervalo_id_intervalo_seq; Type: SEQUENCE; Schema: ; Owner: -
+--
+
+CREATE SEQUENCE asistencia_intervalo_id_intervalo_seq
+ START WITH 1
+ INCREMENT BY 1
+ NO MINVALUE
+ NO MAXVALUE
+ CACHE 1;
+
+
+--
+-- Name: asistencia_intervalo_id_intervalo_seq; Type: SEQUENCE OWNED BY; Schema: ; Owner: -
+--
+
+ALTER SEQUENCE asistencia_intervalo_id_intervalo_seq OWNED BY asistencia_intervalo.id_intervalo;
+
+
+--
+-- Name: asistencia_raw; Type: TABLE; Schema: ; Owner: -
+--
+
+CREATE TABLE asistencia_raw (
+ id_raw bigint NOT NULL,
+ id_usuario integer NOT NULL,
+ ts timestamp with time zone NOT NULL,
+ modo text,
+ origen text,
+ created_at timestamp with time zone DEFAULT now() NOT NULL
+);
+
+
+--
+-- Name: asistencia_raw_id_raw_seq; Type: SEQUENCE; Schema: ; Owner: -
+--
+
+CREATE SEQUENCE asistencia_raw_id_raw_seq
+ START WITH 1
+ INCREMENT BY 1
+ NO MINVALUE
+ NO MAXVALUE
+ CACHE 1;
+
+
+--
+-- Name: asistencia_raw_id_raw_seq; Type: SEQUENCE OWNED BY; Schema: ; Owner: -
+--
+
+ALTER SEQUENCE asistencia_raw_id_raw_seq OWNED BY asistencia_raw.id_raw;
+
+
+--
+-- Name: asistencia_resumen_diario; Type: VIEW; Schema: ; Owner: -
+--
+
+CREATE VIEW asistencia_resumen_diario AS
+ SELECT ai.id_usuario,
+ u.documento,
+ u.nombre,
+ u.apellido,
+ ai.fecha,
+ sum(ai.dur_min) AS minutos_dia,
+ round((sum(ai.dur_min) / 60.0), 2) AS horas_dia,
+ count(*) AS pares_dia
+ FROM (asistencia_intervalo ai
+ JOIN usuarios u USING (id_usuario))
+ GROUP BY ai.id_usuario, u.documento, u.nombre, u.apellido, ai.fecha
+ ORDER BY ai.id_usuario, ai.fecha;
+
+
+--
+-- Name: categorias; Type: TABLE; Schema: ; Owner: -
+--
+
+CREATE TABLE categorias (
+ id_categoria integer NOT NULL,
+ nombre text NOT NULL,
+ visible boolean DEFAULT true
+);
+
+
+--
+-- Name: categorias_id_categoria_seq; Type: SEQUENCE; Schema: ; Owner: -
+--
+
+CREATE SEQUENCE categorias_id_categoria_seq
+ AS integer
+ START WITH 1
+ INCREMENT BY 1
+ NO MINVALUE
+ NO MAXVALUE
+ CACHE 1;
+
+
+--
+-- Name: categorias_id_categoria_seq; Type: SEQUENCE OWNED BY; Schema: ; Owner: -
+--
+
+ALTER SEQUENCE categorias_id_categoria_seq OWNED BY categorias.id_categoria;
+
+
+--
+-- Name: clientes; Type: TABLE; Schema: ; Owner: -
+--
+
+CREATE TABLE clientes (
+ id_cliente integer NOT NULL,
+ nombre text NOT NULL,
+ correo text,
+ telefono text,
+ fec_nacimiento date,
+ activo boolean DEFAULT true
+);
+
+
+--
+-- Name: clientes_id_cliente_seq; Type: SEQUENCE; Schema: ; Owner: -
+--
+
+CREATE SEQUENCE clientes_id_cliente_seq
+ AS integer
+ START WITH 1
+ INCREMENT BY 1
+ NO MINVALUE
+ NO MAXVALUE
+ CACHE 1;
+
+
+--
+-- Name: clientes_id_cliente_seq; Type: SEQUENCE OWNED BY; Schema: ; Owner: -
+--
+
+ALTER SEQUENCE clientes_id_cliente_seq OWNED BY clientes.id_cliente;
+
+
+--
+-- Name: comandas_id_comanda_seq; Type: SEQUENCE; Schema: ; Owner: -
+--
+
+CREATE SEQUENCE comandas_id_comanda_seq
+ AS integer
+ START WITH 1
+ INCREMENT BY 1
+ NO MINVALUE
+ NO MAXVALUE
+ CACHE 1;
+
+
+--
+-- Name: comandas_id_comanda_seq; Type: SEQUENCE OWNED BY; Schema: ; Owner: -
+--
+
+ALTER SEQUENCE comandas_id_comanda_seq OWNED BY comandas.id_comanda;
+
+
+--
+-- Name: compras; Type: TABLE; Schema: ; Owner: -
+--
+
+CREATE TABLE compras (
+ id_compra integer NOT NULL,
+ id_proveedor integer NOT NULL,
+ fec_compra timestamp without time zone NOT NULL,
+ total numeric(14,2)
+);
+
+
+--
+-- Name: compras_id_compra_seq; Type: SEQUENCE; Schema: ; Owner: -
+--
+
+CREATE SEQUENCE compras_id_compra_seq
+ AS integer
+ START WITH 1
+ INCREMENT BY 1
+ NO MINVALUE
+ NO MAXVALUE
+ CACHE 1;
+
+
+--
+-- Name: compras_id_compra_seq; Type: SEQUENCE OWNED BY; Schema: ; Owner: -
+--
+
+ALTER SEQUENCE compras_id_compra_seq OWNED BY compras.id_compra;
+
+
+--
+-- Name: deta_comandas_id_det_comanda_seq; Type: SEQUENCE; Schema: ; Owner: -
+--
+
+CREATE SEQUENCE deta_comandas_id_det_comanda_seq
+ AS integer
+ START WITH 1
+ INCREMENT BY 1
+ NO MINVALUE
+ NO MAXVALUE
+ CACHE 1;
+
+
+--
+-- Name: deta_comandas_id_det_comanda_seq; Type: SEQUENCE OWNED BY; Schema: ; Owner: -
+--
+
+ALTER SEQUENCE deta_comandas_id_det_comanda_seq OWNED BY deta_comandas.id_det_comanda;
+
+
+--
+-- Name: deta_comp_materias; Type: TABLE; Schema: ; Owner: -
+--
+
+CREATE TABLE deta_comp_materias (
+ id_compra integer NOT NULL,
+ id_mat_prima integer NOT NULL,
+ cantidad numeric(12,3) NOT NULL,
+ pre_unitario numeric(12,2) NOT NULL,
+ CONSTRAINT deta_comp_materias_cantidad_check CHECK ((cantidad > (0)::numeric)),
+ CONSTRAINT deta_comp_materias_pre_unitario_check CHECK ((pre_unitario >= (0)::numeric))
+);
+
+
+--
+-- Name: deta_comp_producto; Type: TABLE; Schema: ; Owner: -
+--
+
+CREATE TABLE deta_comp_producto (
+ id_compra integer NOT NULL,
+ id_producto integer NOT NULL,
+ cantidad numeric(12,3) NOT NULL,
+ pre_unitario numeric(12,2) NOT NULL,
+ CONSTRAINT deta_comp_producto_cantidad_check CHECK ((cantidad > (0)::numeric)),
+ CONSTRAINT deta_comp_producto_pre_unitario_check CHECK ((pre_unitario >= (0)::numeric))
+);
+
+
+--
+-- Name: mate_primas; Type: TABLE; Schema: ; Owner: -
+--
+
+CREATE TABLE mate_primas (
+ id_mat_prima integer NOT NULL,
+ nombre text NOT NULL,
+ unidad text NOT NULL,
+ activo boolean DEFAULT true
+);
+
+
+--
+-- Name: mate_primas_id_mat_prima_seq; Type: SEQUENCE; Schema: ; Owner: -
+--
+
+CREATE SEQUENCE mate_primas_id_mat_prima_seq
+ AS integer
+ START WITH 1
+ INCREMENT BY 1
+ NO MINVALUE
+ NO MAXVALUE
+ CACHE 1;
+
+
+--
+-- Name: mate_primas_id_mat_prima_seq; Type: SEQUENCE OWNED BY; Schema: ; Owner: -
+--
+
+ALTER SEQUENCE mate_primas_id_mat_prima_seq OWNED BY mate_primas.id_mat_prima;
+
+
+--
+-- Name: mesas_id_mesa_seq; Type: SEQUENCE; Schema: ; Owner: -
+--
+
+CREATE SEQUENCE mesas_id_mesa_seq
+ AS integer
+ START WITH 1
+ INCREMENT BY 1
+ NO MINVALUE
+ NO MAXVALUE
+ CACHE 1;
+
+
+--
+-- Name: mesas_id_mesa_seq; Type: SEQUENCE OWNED BY; Schema: ; Owner: -
+--
+
+ALTER SEQUENCE mesas_id_mesa_seq OWNED BY mesas.id_mesa;
+
+
+--
+-- Name: productos; Type: TABLE; Schema: ; Owner: -
+--
+
+CREATE TABLE productos (
+ id_producto integer NOT NULL,
+ nombre text NOT NULL,
+ img_producto character varying(255) DEFAULT 'img/productos/img_producto.png'::character varying NOT NULL,
+ precio integer NOT NULL,
+ activo boolean DEFAULT true,
+ id_categoria integer NOT NULL,
+ CONSTRAINT productos_precio_check CHECK (((precio)::numeric >= (0)::numeric)),
+ CONSTRAINT productos_precio_nn CHECK ((precio >= 0))
+);
+
+
+--
+-- Name: productos_id_producto_seq; Type: SEQUENCE; Schema: ; Owner: -
+--
+
+CREATE SEQUENCE productos_id_producto_seq
+ AS integer
+ START WITH 1
+ INCREMENT BY 1
+ NO MINVALUE
+ NO MAXVALUE
+ CACHE 1;
+
+
+--
+-- Name: productos_id_producto_seq; Type: SEQUENCE OWNED BY; Schema: ; Owner: -
+--
+
+ALTER SEQUENCE productos_id_producto_seq OWNED BY productos.id_producto;
+
+
+--
+-- Name: prov_mate_prima; Type: TABLE; Schema: ; Owner: -
+--
+
+CREATE TABLE prov_mate_prima (
+ id_proveedor integer NOT NULL,
+ id_mat_prima integer NOT NULL
+);
+
+
+--
+-- Name: prov_producto; Type: TABLE; Schema: ; Owner: -
+--
+
+CREATE TABLE prov_producto (
+ id_proveedor integer NOT NULL,
+ id_producto integer NOT NULL
+);
+
+
+--
+-- Name: proveedores; Type: TABLE; Schema: ; Owner: -
+--
+
+CREATE TABLE proveedores (
+ id_proveedor integer NOT NULL,
+ rut text,
+ raz_social text NOT NULL,
+ direccion text,
+ contacto text
+);
+
+
+--
+-- Name: proveedores_id_proveedor_seq; Type: SEQUENCE; Schema: ; Owner: -
+--
+
+CREATE SEQUENCE proveedores_id_proveedor_seq
+ AS integer
+ START WITH 1
+ INCREMENT BY 1
+ NO MINVALUE
+ NO MAXVALUE
+ CACHE 1;
+
+
+--
+-- Name: proveedores_id_proveedor_seq; Type: SEQUENCE OWNED BY; Schema: ; Owner: -
+--
+
+ALTER SEQUENCE proveedores_id_proveedor_seq OWNED BY proveedores.id_proveedor;
+
+
+--
+-- Name: receta_producto; Type: TABLE; Schema: ; Owner: -
+--
+
+CREATE TABLE receta_producto (
+ id_producto integer NOT NULL,
+ id_mat_prima integer NOT NULL,
+ qty_por_unidad numeric(12,3) NOT NULL,
+ CONSTRAINT receta_producto_qty_por_unidad_check CHECK ((qty_por_unidad > (0)::numeric))
+);
+
+
+--
+-- Name: roles; Type: TABLE; Schema: ; Owner: -
+--
+
+CREATE TABLE roles (
+ id_rol integer NOT NULL,
+ nombre text NOT NULL
+);
+
+
+--
+-- Name: roles_id_rol_seq; Type: SEQUENCE; Schema: ; Owner: -
+--
+
+CREATE SEQUENCE roles_id_rol_seq
+ AS integer
+ START WITH 1
+ INCREMENT BY 1
+ NO MINVALUE
+ NO MAXVALUE
+ CACHE 1;
+
+
+--
+-- Name: roles_id_rol_seq; Type: SEQUENCE OWNED BY; Schema: ; Owner: -
+--
+
+ALTER SEQUENCE roles_id_rol_seq OWNED BY roles.id_rol;
+
+
+--
+-- Name: usua_roles; Type: TABLE; Schema: ; Owner: -
+--
+
+CREATE TABLE usua_roles (
+ id_usuario integer NOT NULL,
+ id_rol integer NOT NULL,
+ fec_asignacion timestamp without time zone DEFAULT now(),
+ autor integer,
+ activo boolean DEFAULT true
+);
+
+
+--
+-- Name: usuarios_id_usuario_seq; Type: SEQUENCE; Schema: ; Owner: -
+--
+
+CREATE SEQUENCE usuarios_id_usuario_seq
+ AS integer
+ START WITH 1
+ INCREMENT BY 1
+ NO MINVALUE
+ NO MAXVALUE
+ CACHE 1;
+
+
+--
+-- Name: usuarios_id_usuario_seq; Type: SEQUENCE OWNED BY; Schema: ; Owner: -
+--
+
+ALTER SEQUENCE usuarios_id_usuario_seq OWNED BY usuarios.id_usuario;
+
+
+--
+-- Name: v_comandas_detalle_base; Type: VIEW; Schema: ; Owner: -
+--
+
+CREATE VIEW v_comandas_detalle_base AS
+ SELECT c.id_comanda,
+ c.fec_creacion,
+ c.fec_cierre,
+ c.estado,
+ c.observaciones,
+ u.id_usuario,
+ u.nombre AS usuario_nombre,
+ u.apellido AS usuario_apellido,
+ m.id_mesa,
+ m.numero AS mesa_numero,
+ m.apodo AS mesa_apodo,
+ d.id_producto,
+ p.nombre AS producto_nombre,
+ d.cantidad,
+ d.pre_unitario,
+ (d.cantidad * d.pre_unitario) AS subtotal
+ FROM ((((comandas c
+ JOIN usuarios u ON ((u.id_usuario = c.id_usuario)))
+ JOIN mesas m ON ((m.id_mesa = c.id_mesa)))
+ LEFT JOIN deta_comandas d ON ((d.id_comanda = c.id_comanda)))
+ LEFT JOIN productos p ON ((p.id_producto = d.id_producto)));
+
+
+--
+-- Name: v_comandas_detalle_items; Type: VIEW; Schema: ; Owner: -
+--
+
+CREATE VIEW v_comandas_detalle_items AS
+ SELECT d.id_comanda,
+ d.id_det_comanda,
+ d.id_producto,
+ p.nombre AS producto_nombre,
+ d.cantidad,
+ d.pre_unitario,
+ (d.cantidad * d.pre_unitario) AS subtotal,
+ d.observaciones
+ FROM (deta_comandas d
+ JOIN productos p ON ((p.id_producto = d.id_producto)));
+
+
+--
+-- Name: v_comandas_detalle_json; Type: VIEW; Schema: ; Owner: -
+--
+
+CREATE VIEW v_comandas_detalle_json AS
+ SELECT id_comanda,
+ jsonb_build_object('id_comanda', id_comanda, 'fec_creacion', fec_creacion, 'fec_cierre', fec_cierre, 'estado', estado, 'observaciones', observaciones, 'usuario', jsonb_build_object('id_usuario', id_usuario, 'nombre', usuario_nombre, 'apellido', usuario_apellido), 'mesa', jsonb_build_object('id_mesa', id_mesa, 'numero', mesa_numero, 'apodo', mesa_apodo), 'items', COALESCE(( SELECT jsonb_agg(jsonb_build_object('producto_id', b.id_producto, 'producto', b.producto_nombre, 'cantidad', b.cantidad, 'pre_unitario', b.pre_unitario, 'subtotal', b.subtotal) ORDER BY b.producto_nombre) AS jsonb_agg
+ FROM v_comandas_detalle_base b
+ WHERE ((b.id_comanda = h.id_comanda) AND (b.id_producto IS NOT NULL))), '[]'::jsonb), 'totales', jsonb_build_object('items', COALESCE(( SELECT count(*) AS count
+ FROM v_comandas_detalle_base b
+ WHERE ((b.id_comanda = h.id_comanda) AND (b.id_producto IS NOT NULL))), (0)::bigint), 'total', COALESCE(( SELECT sum(b.subtotal) AS sum
+ FROM v_comandas_detalle_base b
+ WHERE (b.id_comanda = h.id_comanda)), (0)::numeric))) AS data
+ FROM ( SELECT DISTINCT v_comandas_detalle_base.id_comanda,
+ v_comandas_detalle_base.fec_creacion,
+ v_comandas_detalle_base.fec_cierre,
+ v_comandas_detalle_base.estado,
+ v_comandas_detalle_base.observaciones,
+ v_comandas_detalle_base.id_usuario,
+ v_comandas_detalle_base.usuario_nombre,
+ v_comandas_detalle_base.usuario_apellido,
+ v_comandas_detalle_base.id_mesa,
+ v_comandas_detalle_base.mesa_numero,
+ v_comandas_detalle_base.mesa_apodo
+ FROM v_comandas_detalle_base) h;
+
+
+--
+-- Name: vw_compras; Type: VIEW; Schema: ; Owner: -
+--
+
+CREATE VIEW vw_compras AS
+ SELECT c.id_compra,
+ c.id_proveedor,
+ p.raz_social AS proveedor,
+ c.fec_compra,
+ c.total
+ FROM (compras c
+ JOIN proveedores p USING (id_proveedor))
+ ORDER BY c.fec_compra DESC, c.id_compra DESC;
+
+
+--
+-- Name: vw_ticket_total; Type: VIEW; Schema: ; Owner: -
+--
+
+CREATE VIEW vw_ticket_total AS
+ WITH lineas AS (
+ SELECT c.id_comanda,
+ COALESCE(c.fec_cierre, (c.fec_creacion)::timestamp with time zone) AS fec_ticket,
+ (COALESCE(dc.pre_unitario, (p.precio)::numeric, (0)::numeric))::numeric(14,2) AS pu,
+ (COALESCE(dc.cantidad, (1)::numeric))::numeric(14,3) AS qty
+ FROM ((comandas c
+ JOIN deta_comandas dc ON ((dc.id_comanda = c.id_comanda)))
+ LEFT JOIN productos p ON ((p.id_producto = dc.id_producto)))
+ )
+ SELECT id_comanda,
+ fec_ticket,
+ (sum((qty * pu)))::numeric(14,2) AS total
+ FROM lineas
+ GROUP BY id_comanda, fec_ticket;
+
+
+--
+-- Name: asistencia_intervalo id_intervalo; Type: DEFAULT; Schema: ; Owner: -
+--
+
+ALTER TABLE ONLY asistencia_intervalo ALTER COLUMN id_intervalo SET DEFAULT nextval('asistencia_intervalo_id_intervalo_seq'::regclass);
+
+
+--
+-- Name: asistencia_raw id_raw; Type: DEFAULT; Schema: ; Owner: -
+--
+
+ALTER TABLE ONLY asistencia_raw ALTER COLUMN id_raw SET DEFAULT nextval('asistencia_raw_id_raw_seq'::regclass);
+
+
+--
+-- Name: categorias id_categoria; Type: DEFAULT; Schema: ; Owner: -
+--
+
+ALTER TABLE ONLY categorias ALTER COLUMN id_categoria SET DEFAULT nextval('categorias_id_categoria_seq'::regclass);
+
+
+--
+-- Name: clientes id_cliente; Type: DEFAULT; Schema: ; Owner: -
+--
+
+ALTER TABLE ONLY clientes ALTER COLUMN id_cliente SET DEFAULT nextval('clientes_id_cliente_seq'::regclass);
+
+
+--
+-- Name: comandas id_comanda; Type: DEFAULT; Schema: ; Owner: -
+--
+
+ALTER TABLE ONLY comandas ALTER COLUMN id_comanda SET DEFAULT nextval('comandas_id_comanda_seq'::regclass);
+
+
+--
+-- Name: compras id_compra; Type: DEFAULT; Schema: ; Owner: -
+--
+
+ALTER TABLE ONLY compras ALTER COLUMN id_compra SET DEFAULT nextval('compras_id_compra_seq'::regclass);
+
+
+--
+-- Name: deta_comandas id_det_comanda; Type: DEFAULT; Schema: ; Owner: -
+--
+
+ALTER TABLE ONLY deta_comandas ALTER COLUMN id_det_comanda SET DEFAULT nextval('deta_comandas_id_det_comanda_seq'::regclass);
+
+
+--
+-- Name: mate_primas id_mat_prima; Type: DEFAULT; Schema: ; Owner: -
+--
+
+ALTER TABLE ONLY mate_primas ALTER COLUMN id_mat_prima SET DEFAULT nextval('mate_primas_id_mat_prima_seq'::regclass);
+
+
+--
+-- Name: mesas id_mesa; Type: DEFAULT; Schema: ; Owner: -
+--
+
+ALTER TABLE ONLY mesas ALTER COLUMN id_mesa SET DEFAULT nextval('mesas_id_mesa_seq'::regclass);
+
+
+--
+-- Name: productos id_producto; Type: DEFAULT; Schema: ; Owner: -
+--
+
+ALTER TABLE ONLY productos ALTER COLUMN id_producto SET DEFAULT nextval('productos_id_producto_seq'::regclass);
+
+
+--
+-- Name: proveedores id_proveedor; Type: DEFAULT; Schema: ; Owner: -
+--
+
+ALTER TABLE ONLY proveedores ALTER COLUMN id_proveedor SET DEFAULT nextval('proveedores_id_proveedor_seq'::regclass);
+
+
+--
+-- Name: roles id_rol; Type: DEFAULT; Schema: ; Owner: -
+--
+
+ALTER TABLE ONLY roles ALTER COLUMN id_rol SET DEFAULT nextval('roles_id_rol_seq'::regclass);
+
+
+--
+-- Name: usuarios id_usuario; Type: DEFAULT; Schema: ; Owner: -
+--
+
+ALTER TABLE ONLY usuarios ALTER COLUMN id_usuario SET DEFAULT nextval('usuarios_id_usuario_seq'::regclass);
+
+
+--
+-- Name: asistencia_intervalo asistencia_intervalo_id_usuario_desde_hasta_key; Type: CONSTRAINT; Schema: ; Owner: -
+--
+
+ALTER TABLE ONLY asistencia_intervalo
+ ADD CONSTRAINT asistencia_intervalo_id_usuario_desde_hasta_key UNIQUE (id_usuario, desde, hasta);
+
+
+--
+-- Name: asistencia_intervalo asistencia_intervalo_pkey; Type: CONSTRAINT; Schema: ; Owner: -
+--
+
+ALTER TABLE ONLY asistencia_intervalo
+ ADD CONSTRAINT asistencia_intervalo_pkey PRIMARY KEY (id_intervalo);
+
+
+--
+-- Name: asistencia_raw asistencia_raw_id_usuario_ts_key; Type: CONSTRAINT; Schema: ; Owner: -
+--
+
+ALTER TABLE ONLY asistencia_raw
+ ADD CONSTRAINT asistencia_raw_id_usuario_ts_key UNIQUE (id_usuario, ts);
+
+
+--
+-- Name: asistencia_raw asistencia_raw_pkey; Type: CONSTRAINT; Schema: ; Owner: -
+--
+
+ALTER TABLE ONLY asistencia_raw
+ ADD CONSTRAINT asistencia_raw_pkey PRIMARY KEY (id_raw);
+
+
+--
+-- Name: categorias categorias_nombre_key; Type: CONSTRAINT; Schema: ; Owner: -
+--
+
+ALTER TABLE ONLY categorias
+ ADD CONSTRAINT categorias_nombre_key UNIQUE (nombre);
+
+
+--
+-- Name: categorias categorias_pkey; Type: CONSTRAINT; Schema: ; Owner: -
+--
+
+ALTER TABLE ONLY categorias
+ ADD CONSTRAINT categorias_pkey PRIMARY KEY (id_categoria);
+
+
+--
+-- Name: clientes clientes_correo_key; Type: CONSTRAINT; Schema: ; Owner: -
+--
+
+ALTER TABLE ONLY clientes
+ ADD CONSTRAINT clientes_correo_key UNIQUE (correo);
+
+
+--
+-- Name: clientes clientes_pkey; Type: CONSTRAINT; Schema: ; Owner: -
+--
+
+ALTER TABLE ONLY clientes
+ ADD CONSTRAINT clientes_pkey PRIMARY KEY (id_cliente);
+
+
+--
+-- Name: clientes clientes_telefono_key; Type: CONSTRAINT; Schema: ; Owner: -
+--
+
+ALTER TABLE ONLY clientes
+ ADD CONSTRAINT clientes_telefono_key UNIQUE (telefono);
+
+
+--
+-- Name: comandas comandas_pkey; Type: CONSTRAINT; Schema: ; Owner: -
+--
+
+ALTER TABLE ONLY comandas
+ ADD CONSTRAINT comandas_pkey PRIMARY KEY (id_comanda);
+
+
+--
+-- Name: compras compras_pkey; Type: CONSTRAINT; Schema: ; Owner: -
+--
+
+ALTER TABLE ONLY compras
+ ADD CONSTRAINT compras_pkey PRIMARY KEY (id_compra);
+
+
+--
+-- Name: deta_comandas deta_comandas_pkey; Type: CONSTRAINT; Schema: ; Owner: -
+--
+
+ALTER TABLE ONLY deta_comandas
+ ADD CONSTRAINT deta_comandas_pkey PRIMARY KEY (id_det_comanda);
+
+
+--
+-- Name: deta_comp_materias deta_comp_materias_pkey; Type: CONSTRAINT; Schema: ; Owner: -
+--
+
+ALTER TABLE ONLY deta_comp_materias
+ ADD CONSTRAINT deta_comp_materias_pkey PRIMARY KEY (id_compra, id_mat_prima);
+
+
+--
+-- Name: deta_comp_producto deta_comp_producto_pkey; Type: CONSTRAINT; Schema: ; Owner: -
+--
+
+ALTER TABLE ONLY deta_comp_producto
+ ADD CONSTRAINT deta_comp_producto_pkey PRIMARY KEY (id_compra, id_producto);
+
+
+--
+-- Name: mate_primas mate_primas_nombre_key; Type: CONSTRAINT; Schema: ; Owner: -
+--
+
+ALTER TABLE ONLY mate_primas
+ ADD CONSTRAINT mate_primas_nombre_key UNIQUE (nombre);
+
+
+--
+-- Name: mate_primas mate_primas_pkey; Type: CONSTRAINT; Schema: ; Owner: -
+--
+
+ALTER TABLE ONLY mate_primas
+ ADD CONSTRAINT mate_primas_pkey PRIMARY KEY (id_mat_prima);
+
+
+--
+-- Name: mesas mesas_apodo_key; Type: CONSTRAINT; Schema: ; Owner: -
+--
+
+ALTER TABLE ONLY mesas
+ ADD CONSTRAINT mesas_apodo_key UNIQUE (apodo);
+
+
+--
+-- Name: mesas mesas_numero_key; Type: CONSTRAINT; Schema: ; Owner: -
+--
+
+ALTER TABLE ONLY mesas
+ ADD CONSTRAINT mesas_numero_key UNIQUE (numero);
+
+
+--
+-- Name: mesas mesas_pkey; Type: CONSTRAINT; Schema: ; Owner: -
+--
+
+ALTER TABLE ONLY mesas
+ ADD CONSTRAINT mesas_pkey PRIMARY KEY (id_mesa);
+
+
+--
+-- Name: productos productos_pkey; Type: CONSTRAINT; Schema: ; Owner: -
+--
+
+ALTER TABLE ONLY productos
+ ADD CONSTRAINT productos_pkey PRIMARY KEY (id_producto);
+
+
+--
+-- Name: prov_mate_prima prov_mate_prima_pkey; Type: CONSTRAINT; Schema: ; Owner: -
+--
+
+ALTER TABLE ONLY prov_mate_prima
+ ADD CONSTRAINT prov_mate_prima_pkey PRIMARY KEY (id_proveedor, id_mat_prima);
+
+
+--
+-- Name: prov_producto prov_producto_pkey; Type: CONSTRAINT; Schema: ; Owner: -
+--
+
+ALTER TABLE ONLY prov_producto
+ ADD CONSTRAINT prov_producto_pkey PRIMARY KEY (id_proveedor, id_producto);
+
+
+--
+-- Name: proveedores proveedores_pkey; Type: CONSTRAINT; Schema: ; Owner: -
+--
+
+ALTER TABLE ONLY proveedores
+ ADD CONSTRAINT proveedores_pkey PRIMARY KEY (id_proveedor);
+
+
+--
+-- Name: proveedores proveedores_rut_key; Type: CONSTRAINT; Schema: ; Owner: -
+--
+
+ALTER TABLE ONLY proveedores
+ ADD CONSTRAINT proveedores_rut_key UNIQUE (rut);
+
+
+--
+-- Name: receta_producto receta_producto_pkey; Type: CONSTRAINT; Schema: ; Owner: -
+--
+
+ALTER TABLE ONLY receta_producto
+ ADD CONSTRAINT receta_producto_pkey PRIMARY KEY (id_producto, id_mat_prima);
+
+
+--
+-- Name: roles roles_nombre_key; Type: CONSTRAINT; Schema: ; Owner: -
+--
+
+ALTER TABLE ONLY roles
+ ADD CONSTRAINT roles_nombre_key UNIQUE (nombre);
+
+
+--
+-- Name: roles roles_pkey; Type: CONSTRAINT; Schema: ; Owner: -
+--
+
+ALTER TABLE ONLY roles
+ ADD CONSTRAINT roles_pkey PRIMARY KEY (id_rol);
+
+
+--
+-- Name: usua_roles usua_roles_pkey; Type: CONSTRAINT; Schema: ; Owner: -
+--
+
+ALTER TABLE ONLY usua_roles
+ ADD CONSTRAINT usua_roles_pkey PRIMARY KEY (id_usuario, id_rol);
+
+
+--
+-- Name: usuarios usuarios_documento_key; Type: CONSTRAINT; Schema: ; Owner: -
+--
+
+ALTER TABLE ONLY usuarios
+ ADD CONSTRAINT usuarios_documento_key UNIQUE (documento);
+
+
+--
+-- Name: usuarios usuarios_pkey; Type: CONSTRAINT; Schema: ; Owner: -
+--
+
+ALTER TABLE ONLY usuarios
+ ADD CONSTRAINT usuarios_pkey PRIMARY KEY (id_usuario);
+
+
+--
+-- Name: compras_fec_compra_idx; Type: INDEX; Schema: ; Owner: -
+--
+
+CREATE INDEX compras_fec_compra_idx ON compras USING btree (fec_compra);
+
+
+--
+-- Name: idx_asist_int_usuario_fecha; Type: INDEX; Schema: ; Owner: -
+--
+
+CREATE INDEX idx_asist_int_usuario_fecha ON asistencia_intervalo USING btree (id_usuario, fecha);
+
+
+--
+-- Name: idx_asist_raw_usuario_ts; Type: INDEX; Schema: ; Owner: -
+--
+
+CREATE INDEX idx_asist_raw_usuario_ts ON asistencia_raw USING btree (id_usuario, ts);
+
+
+--
+-- Name: idx_detalle_comanda_comanda; Type: INDEX; Schema: ; Owner: -
+--
+
+CREATE INDEX idx_detalle_comanda_comanda ON deta_comandas USING btree (id_comanda);
+
+
+--
+-- Name: idx_detalle_comanda_producto; Type: INDEX; Schema: ; Owner: -
+--
+
+CREATE INDEX idx_detalle_comanda_producto ON deta_comandas USING btree (id_producto);
+
+
+--
+-- Name: ix_comandas_fec_cierre; Type: INDEX; Schema: ; Owner: -
+--
+
+CREATE INDEX ix_comandas_fec_cierre ON comandas USING btree (fec_cierre);
+
+
+--
+-- Name: ix_comandas_id; Type: INDEX; Schema: ; Owner: -
+--
+
+CREATE INDEX ix_comandas_id ON comandas USING btree (id_comanda);
+
+
+--
+-- Name: ix_deta_comandas_id_comanda; Type: INDEX; Schema: ; Owner: -
+--
+
+CREATE INDEX ix_deta_comandas_id_comanda ON deta_comandas USING btree (id_comanda);
+
+
+--
+-- Name: ix_deta_comandas_id_producto; Type: INDEX; Schema: ; Owner: -
+--
+
+CREATE INDEX ix_deta_comandas_id_producto ON deta_comandas USING btree (id_producto);
+
+
+--
+-- Name: asistencia_intervalo asistencia_intervalo_id_usuario_fkey; Type: FK CONSTRAINT; Schema: ; Owner: -
+--
+
+ALTER TABLE ONLY asistencia_intervalo
+ ADD CONSTRAINT asistencia_intervalo_id_usuario_fkey FOREIGN KEY (id_usuario) REFERENCES usuarios(id_usuario) ON DELETE CASCADE;
+
+
+--
+-- Name: asistencia_raw asistencia_raw_id_usuario_fkey; Type: FK CONSTRAINT; Schema: ; Owner: -
+--
+
+ALTER TABLE ONLY asistencia_raw
+ ADD CONSTRAINT asistencia_raw_id_usuario_fkey FOREIGN KEY (id_usuario) REFERENCES usuarios(id_usuario) ON DELETE CASCADE;
+
+
+--
+-- Name: comandas comandas_id_mesa_fkey; Type: FK CONSTRAINT; Schema: ; Owner: -
+--
+
+ALTER TABLE ONLY comandas
+ ADD CONSTRAINT comandas_id_mesa_fkey FOREIGN KEY (id_mesa) REFERENCES mesas(id_mesa);
+
+
+--
+-- Name: comandas comandas_id_usuario_fkey; Type: FK CONSTRAINT; Schema: ; Owner: -
+--
+
+ALTER TABLE ONLY comandas
+ ADD CONSTRAINT comandas_id_usuario_fkey FOREIGN KEY (id_usuario) REFERENCES usuarios(id_usuario);
+
+
+--
+-- Name: compras compras_id_proveedor_fkey; Type: FK CONSTRAINT; Schema: ; Owner: -
+--
+
+ALTER TABLE ONLY compras
+ ADD CONSTRAINT compras_id_proveedor_fkey FOREIGN KEY (id_proveedor) REFERENCES proveedores(id_proveedor);
+
+
+--
+-- Name: deta_comandas deta_comandas_id_comanda_fkey; Type: FK CONSTRAINT; Schema: ; Owner: -
+--
+
+ALTER TABLE ONLY deta_comandas
+ ADD CONSTRAINT deta_comandas_id_comanda_fkey FOREIGN KEY (id_comanda) REFERENCES comandas(id_comanda) ON DELETE CASCADE;
+
+
+--
+-- Name: deta_comandas deta_comandas_id_producto_fkey; Type: FK CONSTRAINT; Schema: ; Owner: -
+--
+
+ALTER TABLE ONLY deta_comandas
+ ADD CONSTRAINT deta_comandas_id_producto_fkey FOREIGN KEY (id_producto) REFERENCES productos(id_producto);
+
+
+--
+-- Name: deta_comp_materias deta_comp_materias_id_compra_fkey; Type: FK CONSTRAINT; Schema: ; Owner: -
+--
+
+ALTER TABLE ONLY deta_comp_materias
+ ADD CONSTRAINT deta_comp_materias_id_compra_fkey FOREIGN KEY (id_compra) REFERENCES compras(id_compra) ON DELETE CASCADE;
+
+
+--
+-- Name: deta_comp_materias deta_comp_materias_id_mat_prima_fkey; Type: FK CONSTRAINT; Schema: ; Owner: -
+--
+
+ALTER TABLE ONLY deta_comp_materias
+ ADD CONSTRAINT deta_comp_materias_id_mat_prima_fkey FOREIGN KEY (id_mat_prima) REFERENCES mate_primas(id_mat_prima);
+
+
+--
+-- Name: deta_comp_producto deta_comp_producto_id_compra_fkey; Type: FK CONSTRAINT; Schema: ; Owner: -
+--
+
+ALTER TABLE ONLY deta_comp_producto
+ ADD CONSTRAINT deta_comp_producto_id_compra_fkey FOREIGN KEY (id_compra) REFERENCES compras(id_compra) ON DELETE CASCADE;
+
+
+--
+-- Name: deta_comp_producto deta_comp_producto_id_producto_fkey; Type: FK CONSTRAINT; Schema: ; Owner: -
+--
+
+ALTER TABLE ONLY deta_comp_producto
+ ADD CONSTRAINT deta_comp_producto_id_producto_fkey FOREIGN KEY (id_producto) REFERENCES productos(id_producto);
+
+
+--
+-- Name: productos productos_id_categoria_fkey; Type: FK CONSTRAINT; Schema: ; Owner: -
+--
+
+ALTER TABLE ONLY productos
+ ADD CONSTRAINT productos_id_categoria_fkey FOREIGN KEY (id_categoria) REFERENCES categorias(id_categoria);
+
+
+--
+-- Name: prov_mate_prima prov_mate_prima_id_mat_prima_fkey; Type: FK CONSTRAINT; Schema: ; Owner: -
+--
+
+ALTER TABLE ONLY prov_mate_prima
+ ADD CONSTRAINT prov_mate_prima_id_mat_prima_fkey FOREIGN KEY (id_mat_prima) REFERENCES mate_primas(id_mat_prima);
+
+
+--
+-- Name: prov_mate_prima prov_mate_prima_id_proveedor_fkey; Type: FK CONSTRAINT; Schema: ; Owner: -
+--
+
+ALTER TABLE ONLY prov_mate_prima
+ ADD CONSTRAINT prov_mate_prima_id_proveedor_fkey FOREIGN KEY (id_proveedor) REFERENCES proveedores(id_proveedor) ON DELETE CASCADE;
+
+
+--
+-- Name: prov_producto prov_producto_id_producto_fkey; Type: FK CONSTRAINT; Schema: ; Owner: -
+--
+
+ALTER TABLE ONLY prov_producto
+ ADD CONSTRAINT prov_producto_id_producto_fkey FOREIGN KEY (id_producto) REFERENCES productos(id_producto);
+
+
+--
+-- Name: prov_producto prov_producto_id_proveedor_fkey; Type: FK CONSTRAINT; Schema: ; Owner: -
+--
+
+ALTER TABLE ONLY prov_producto
+ ADD CONSTRAINT prov_producto_id_proveedor_fkey FOREIGN KEY (id_proveedor) REFERENCES proveedores(id_proveedor) ON DELETE CASCADE;
+
+
+--
+-- Name: receta_producto receta_producto_id_mat_prima_fkey; Type: FK CONSTRAINT; Schema: ; Owner: -
+--
+
+ALTER TABLE ONLY receta_producto
+ ADD CONSTRAINT receta_producto_id_mat_prima_fkey FOREIGN KEY (id_mat_prima) REFERENCES mate_primas(id_mat_prima);
+
+
+--
+-- Name: receta_producto receta_producto_id_producto_fkey; Type: FK CONSTRAINT; Schema: ; Owner: -
+--
+
+ALTER TABLE ONLY receta_producto
+ ADD CONSTRAINT receta_producto_id_producto_fkey FOREIGN KEY (id_producto) REFERENCES productos(id_producto) ON DELETE CASCADE;
+
+
+--
+-- Name: usua_roles usua_roles_autor_fkey; Type: FK CONSTRAINT; Schema: ; Owner: -
+--
+
+ALTER TABLE ONLY usua_roles
+ ADD CONSTRAINT usua_roles_autor_fkey FOREIGN KEY (autor) REFERENCES usuarios(id_usuario);
+
+
+--
+-- Name: usua_roles usua_roles_id_rol_fkey; Type: FK CONSTRAINT; Schema: ; Owner: -
+--
+
+ALTER TABLE ONLY usua_roles
+ ADD CONSTRAINT usua_roles_id_rol_fkey FOREIGN KEY (id_rol) REFERENCES roles(id_rol);
+
+
+--
+-- Name: usua_roles usua_roles_id_usuario_fkey; Type: FK CONSTRAINT; Schema: ; Owner: -
+--
+
+ALTER TABLE ONLY usua_roles
+ ADD CONSTRAINT usua_roles_id_usuario_fkey FOREIGN KEY (id_usuario) REFERENCES usuarios(id_usuario) ON DELETE CASCADE;
+
+
+--
+-- PostgreSQL database dump complete
+--
+
+
+COMMIT;
diff --git a/services/auth/src/db/initTenant_v3.sql b/services/auth/src/db/initTenant_v3.sql
new file mode 100644
index 0000000..36c2575
--- /dev/null
+++ b/services/auth/src/db/initTenant_v3.sql
@@ -0,0 +1,2284 @@
+--
+-- PostgreSQL database dump
+--
+
+
+-- Dumped from database version 16.10 (Debian 16.10-1.pgdg13+1)
+-- Dumped by pg_dump version 16.10 (Debian 16.10-1.pgdg13+1)
+
+BEGIN;
+SET client_encoding = 'UTF8';
+SET standard_conforming_strings = on;
+SET row_security = off;
+SET statement_timeout = 0;
+SET lock_timeout = 0;
+SET idle_in_transaction_session_timeout = 0;
+SET client_encoding = 'UTF8';
+SET standard_conforming_strings = on;
+SELECT pg_catalog.set_config('search_path', '', false);
+SET check_function_bodies = false;
+SET xmloption = content;
+SET client_min_messages = warning;
+SET row_security = off;
+
+--
+-- Name: public; Type: SCHEMA; Schema: -; Owner: -
+--
+
+CREATE SCHEMA public;
+
+
+--
+-- Name: SCHEMA public; Type: COMMENT; Schema: -; Owner: -
+--
+
+COMMENT ON SCHEMA public IS 'standard public schema';
+
+
+--
+-- Name: asistencia_delete_raw(bigint, text); Type: FUNCTION; Schema: public; Owner: -
+--
+
+CREATE FUNCTION public.asistencia_delete_raw(p_id_raw bigint, p_tz text DEFAULT 'America/Montevideo'::text) RETURNS jsonb
+ LANGUAGE plpgsql
+ AS $$
+DECLARE
+ v_id_usuario INT;
+ v_ts TIMESTAMPTZ;
+ v_t0 TIMESTAMPTZ;
+ v_t1 TIMESTAMPTZ;
+ v_del_raw INT;
+ v_del INT;
+ v_ins INT;
+BEGIN
+ SELECT id_usuario, ts INTO v_id_usuario, v_ts
+ FROM public.asistencia_raw WHERE id_raw = p_id_raw;
+ IF v_id_usuario IS NULL THEN
+ RETURN jsonb_build_object('deleted',0,'msg','id_raw inexistente');
+ END IF;
+
+ v_t0 := v_ts - INTERVAL '1 day';
+ v_t1 := v_ts + INTERVAL '1 day';
+
+ -- borrar raw
+ DELETE FROM public.asistencia_raw WHERE id_raw = p_id_raw;
+ GET DIAGNOSTICS v_del_raw = ROW_COUNT;
+
+ -- recomputar pares en ventana
+ WITH tl AS (
+ SELECT ar.ts,
+ ROW_NUMBER() OVER (ORDER BY ar.ts) AS rn
+ FROM public.asistencia_raw ar
+ WHERE ar.id_usuario = v_id_usuario
+ AND ar.ts BETWEEN v_t0 AND v_t1
+ ),
+ ready AS (
+ SELECT (t1.ts AT TIME ZONE p_tz)::date AS fecha,
+ t1.ts AS desde,
+ t2.ts AS hasta,
+ EXTRACT(EPOCH FROM (t2.ts - t1.ts))/60.0 AS dur_min
+ FROM tl t1
+ JOIN tl t2 ON t2.rn = t1.rn + 1
+ WHERE (t1.rn % 2) = 1 AND t2.ts > t1.ts
+ ),
+ del AS (
+ DELETE FROM public.asistencia_intervalo ai
+ WHERE ai.id_usuario = v_id_usuario
+ AND (ai.desde BETWEEN v_t0 AND v_t1 OR ai.hasta BETWEEN v_t0 AND v_t1)
+ RETURNING 1
+ ),
+ ins AS (
+ INSERT INTO public.asistencia_intervalo (id_usuario, fecha, desde, hasta, dur_min, origen)
+ SELECT v_id_usuario, r.fecha, r.desde, r.hasta, r.dur_min, 'delete_adjust'
+ FROM ready r
+ ON CONFLICT (id_usuario, desde, hasta) DO NOTHING
+ RETURNING 1
+ )
+ SELECT (SELECT COUNT(*) FROM del), (SELECT COUNT(*) FROM ins) INTO v_del, v_ins;
+
+ RETURN jsonb_build_object('deleted',v_del_raw,'deleted_pairs',v_del,'inserted_pairs',v_ins);
+END;
+$$;
+
+
+--
+-- Name: asistencia_get(text, date, date, text); Type: FUNCTION; Schema: public; Owner: -
+--
+
+CREATE FUNCTION public.asistencia_get(p_doc text, p_desde date, p_hasta date, p_tz text DEFAULT 'America/Montevideo'::text) RETURNS jsonb
+ LANGUAGE sql
+ AS $$
+WITH u AS (
+ SELECT id_usuario, documento, nombre, apellido
+ FROM public.usuarios
+ WHERE regexp_replace(documento,'^\s*0+','','g') = regexp_replace(p_doc,'^\s*0+','','g')
+ LIMIT 1
+),
+r AS (
+ SELECT ar.id_raw,
+ (ar.ts AT TIME ZONE p_tz)::date AS fecha,
+ to_char(ar.ts AT TIME ZONE p_tz,'HH24:MI:SS') AS hora,
+ COALESCE(ar.modo,'') AS modo,
+ COALESCE(ar.origen,'') AS origen,
+ ar.ts
+ FROM public.asistencia_raw ar
+ JOIN u USING (id_usuario)
+ WHERE (ar.ts AT TIME ZONE p_tz)::date BETWEEN p_desde AND p_hasta
+),
+i AS (
+ SELECT ai.id_intervalo,
+ ai.fecha,
+ to_char(ai.desde AT TIME ZONE p_tz,'HH24:MI:SS') AS desde_hora,
+ to_char(ai.hasta AT TIME ZONE p_tz,'HH24:MI:SS') AS hasta_hora,
+ ai.dur_min
+ FROM public.asistencia_intervalo ai
+ JOIN u USING (id_usuario)
+ WHERE ai.fecha BETWEEN p_desde AND p_hasta
+)
+SELECT jsonb_build_object(
+ 'usuario', (SELECT to_jsonb(u.*) FROM u),
+ 'raw', COALESCE((SELECT jsonb_agg(to_jsonb(r.*) ORDER BY r.ts) FROM r),'[]'::jsonb),
+ 'intervalos', COALESCE((SELECT jsonb_agg(to_jsonb(i.*) ORDER BY i.fecha, i.id_intervalo) FROM i),'[]'::jsonb)
+);
+$$;
+
+
+--
+-- Name: asistencia_update_raw(bigint, date, text, text, text); Type: FUNCTION; Schema: public; Owner: -
+--
+
+CREATE FUNCTION public.asistencia_update_raw(p_id_raw bigint, p_fecha date, p_hora text, p_modo text, p_tz text DEFAULT 'America/Montevideo'::text) RETURNS jsonb
+ LANGUAGE plpgsql
+ AS $$
+DECLARE
+ v_id_usuario INT;
+ v_ts_old TIMESTAMPTZ;
+ v_ts_new TIMESTAMPTZ;
+ v_t0 TIMESTAMPTZ;
+ v_t1 TIMESTAMPTZ;
+ v_del INT;
+ v_ins INT;
+BEGIN
+ -- leer estado previo
+ SELECT id_usuario, ts INTO v_id_usuario, v_ts_old
+ FROM public.asistencia_raw WHERE id_raw = p_id_raw;
+ IF v_id_usuario IS NULL THEN
+ RETURN jsonb_build_object('updated',0,'msg','id_raw inexistente');
+ END IF;
+
+ -- construir ts nuevo
+ v_ts_new := make_timestamptz(
+ EXTRACT(YEAR FROM p_fecha)::INT,
+ EXTRACT(MONTH FROM p_fecha)::INT,
+ EXTRACT(DAY FROM p_fecha)::INT,
+ split_part(p_hora,':',1)::INT,
+ split_part(p_hora,':',2)::INT,
+ COALESCE(NULLIF(split_part(p_hora,':',3),''), '0')::INT,
+ p_tz);
+
+ -- aplicar update
+ UPDATE public.asistencia_raw
+ SET ts = v_ts_new,
+ modo = COALESCE(p_modo, modo)
+ WHERE id_raw = p_id_raw;
+
+ -- ventana de recálculo
+ v_t0 := LEAST(v_ts_old, v_ts_new) - INTERVAL '1 day';
+ v_t1 := GREATEST(v_ts_old, v_ts_new) + INTERVAL '1 day';
+
+ -- recomputar pares en la ventana: borrar los del rango y reinsertar
+ WITH tl AS (
+ SELECT ar.ts,
+ ROW_NUMBER() OVER (ORDER BY ar.ts) AS rn
+ FROM public.asistencia_raw ar
+ WHERE ar.id_usuario = v_id_usuario
+ AND ar.ts BETWEEN v_t0 AND v_t1
+ ),
+ ready AS (
+ SELECT (t1.ts AT TIME ZONE p_tz)::date AS fecha,
+ t1.ts AS desde,
+ t2.ts AS hasta,
+ EXTRACT(EPOCH FROM (t2.ts - t1.ts))/60.0 AS dur_min
+ FROM tl t1
+ JOIN tl t2 ON t2.rn = t1.rn + 1
+ WHERE (t1.rn % 2) = 1 AND t2.ts > t1.ts
+ ),
+ del AS (
+ DELETE FROM public.asistencia_intervalo ai
+ WHERE ai.id_usuario = v_id_usuario
+ AND (ai.desde BETWEEN v_t0 AND v_t1 OR ai.hasta BETWEEN v_t0 AND v_t1)
+ RETURNING 1
+ ),
+ ins AS (
+ INSERT INTO public.asistencia_intervalo (id_usuario, fecha, desde, hasta, dur_min, origen)
+ SELECT v_id_usuario, r.fecha, r.desde, r.hasta, r.dur_min, 'edit_manual'
+ FROM ready r
+ ON CONFLICT (id_usuario, desde, hasta) DO NOTHING
+ RETURNING 1
+ )
+ SELECT (SELECT COUNT(*) FROM del), (SELECT COUNT(*) FROM ins) INTO v_del, v_ins;
+
+ RETURN jsonb_build_object('updated',1,'deleted_pairs',v_del,'inserted_pairs',v_ins);
+END;
+$$;
+
+
+--
+-- Name: delete_compra(integer); Type: FUNCTION; Schema: public; Owner: -
+--
+
+CREATE FUNCTION public.delete_compra(p_id_compra integer) RETURNS void
+ LANGUAGE plpgsql
+ AS $$
+BEGIN
+ DELETE FROM public.deta_comp_materias WHERE id_compra = p_id_compra;
+ DELETE FROM public.deta_comp_producto WHERE id_compra = p_id_compra;
+ DELETE FROM public.compras WHERE id_compra = p_id_compra;
+END;
+$$;
+
+
+--
+-- Name: f_abrir_comanda(integer); Type: FUNCTION; Schema: public; Owner: -
+--
+
+CREATE FUNCTION public.f_abrir_comanda(p_id integer) RETURNS jsonb
+ LANGUAGE plpgsql
+ AS $$
+DECLARE r jsonb;
+BEGIN
+ UPDATE public.comandas
+ SET estado = 'abierta',
+ fec_cierre = NULL
+ WHERE id_comanda = p_id;
+
+ IF NOT FOUND THEN
+ RETURN NULL;
+ END IF;
+
+ SELECT to_jsonb(v) INTO r
+ FROM public.v_comandas_resumen v
+ WHERE v.id_comanda = p_id;
+
+ RETURN r;
+END;
+$$;
+
+
+--
+-- Name: f_cerrar_comanda(integer); Type: FUNCTION; Schema: public; Owner: -
+--
+
+CREATE FUNCTION public.f_cerrar_comanda(p_id integer) RETURNS jsonb
+ LANGUAGE plpgsql
+ AS $$
+DECLARE r jsonb;
+BEGIN
+ UPDATE public.comandas
+ SET estado = 'cerrada',
+ fec_cierre = COALESCE(fec_cierre, NOW())
+ WHERE id_comanda = p_id;
+
+ IF NOT FOUND THEN
+ RETURN NULL;
+ END IF;
+
+ SELECT to_jsonb(v) INTO r
+ FROM public.v_comandas_resumen v
+ WHERE v.id_comanda = p_id;
+
+ RETURN r;
+END;
+$$;
+
+
+--
+-- Name: f_comanda_detalle_json(integer); Type: FUNCTION; Schema: public; Owner: -
+--
+
+CREATE FUNCTION public.f_comanda_detalle_json(p_id_comanda integer) RETURNS jsonb
+ LANGUAGE sql
+ AS $$
+WITH base AS (
+ SELECT
+ c.id_comanda,
+ c.fec_creacion,
+ c.estado,
+ c.observaciones,
+ u.id_usuario,
+ u.nombre AS usuario_nombre,
+ u.apellido AS usuario_apellido,
+ m.id_mesa,
+ m.numero AS mesa_numero,
+ m.apodo AS mesa_apodo,
+ d.id_producto,
+ p.nombre AS producto_nombre,
+ d.cantidad,
+ d.pre_unitario,
+ (d.cantidad * d.pre_unitario) AS subtotal
+ FROM public.comandas c
+ JOIN public.usuarios u ON u.id_usuario = c.id_usuario
+ JOIN public.mesas m ON m.id_mesa = c.id_mesa
+ LEFT JOIN public.deta_comandas d ON d.id_comanda = c.id_comanda
+ LEFT JOIN public.productos p ON p.id_producto = d.id_producto
+ WHERE c.id_comanda = p_id_comanda
+),
+hdr AS (
+ -- 1 sola fila con los datos de cabecera
+ SELECT DISTINCT
+ id_comanda, fec_creacion, estado, observaciones,
+ id_usuario, usuario_nombre, usuario_apellido,
+ id_mesa, mesa_numero, mesa_apodo
+ FROM base
+),
+agg_items AS (
+ SELECT
+ COALESCE(
+ jsonb_agg(
+ jsonb_build_object(
+ 'producto_id', b.id_producto,
+ 'producto', b.producto_nombre,
+ 'cantidad', b.cantidad,
+ 'pre_unitario', b.pre_unitario,
+ 'subtotal', b.subtotal
+ )
+ ORDER BY b.producto_nombre NULLS LAST
+ ) FILTER (WHERE b.id_producto IS NOT NULL),
+ '[]'::jsonb
+ ) AS items
+ FROM base b
+),
+tot AS (
+ SELECT
+ COUNT(*) FILTER (WHERE id_producto IS NOT NULL) AS items,
+ COALESCE(SUM(subtotal), 0)::numeric AS total
+ FROM base
+)
+SELECT
+ CASE
+ WHEN EXISTS (SELECT 1 FROM hdr) THEN
+ jsonb_build_object(
+ 'id_comanda', h.id_comanda,
+ 'fec_creacion', h.fec_creacion,
+ 'estado', h.estado,
+ 'observaciones',h.observaciones,
+ 'usuario', jsonb_build_object(
+ 'id_usuario', h.id_usuario,
+ 'nombre', h.usuario_nombre,
+ 'apellido', h.usuario_apellido
+ ),
+ 'mesa', jsonb_build_object(
+ 'id_mesa', h.id_mesa,
+ 'numero', h.mesa_numero,
+ 'apodo', h.mesa_apodo
+ ),
+ 'items', i.items,
+ 'totales', jsonb_build_object(
+ 'items', t.items,
+ 'total', t.total
+ )
+ )
+ ELSE NULL
+ END
+FROM hdr h, agg_items i, tot t;
+$$;
+
+
+--
+-- Name: f_comanda_detalle_rows(integer); Type: FUNCTION; Schema: public; Owner: -
+--
+
+CREATE FUNCTION public.f_comanda_detalle_rows(p_id_comanda integer) RETURNS TABLE(id_comanda integer, fec_creacion timestamp without time zone, estado text, observaciones text, id_usuario integer, usuario_nombre text, usuario_apellido text, id_mesa integer, mesa_numero integer, mesa_apodo text, producto_id integer, producto_nombre text, cantidad numeric, pre_unitario numeric, subtotal numeric, items integer, total numeric)
+ LANGUAGE sql
+ AS $$
+WITH base AS (
+ SELECT
+ c.id_comanda, c.fec_creacion, c.estado, c.observaciones,
+ u.id_usuario, u.nombre AS usuario_nombre, u.apellido AS usuario_apellido,
+ m.id_mesa, m.numero AS mesa_numero, m.apodo AS mesa_apodo,
+ d.id_producto, p.nombre AS producto_nombre,
+ d.cantidad, d.pre_unitario,
+ (d.cantidad * d.pre_unitario) AS subtotal
+ FROM public.comandas c
+ JOIN public.usuarios u ON u.id_usuario = c.id_usuario
+ JOIN public.mesas m ON m.id_mesa = c.id_mesa
+ LEFT JOIN public.deta_comandas d ON d.id_comanda = c.id_comanda
+ LEFT JOIN public.productos p ON p.id_producto = d.id_producto
+ WHERE c.id_comanda = p_id_comanda
+),
+tot AS (
+ SELECT
+ COUNT(*) FILTER (WHERE id_producto IS NOT NULL) AS items,
+ COALESCE(SUM(subtotal), 0) AS total
+ FROM base
+)
+SELECT
+ b.id_comanda, b.fec_creacion, b.estado, b.observaciones,
+ b.id_usuario, b.usuario_nombre, b.usuario_apellido,
+ b.id_mesa, b.mesa_numero, b.mesa_apodo,
+ b.id_producto, b.producto_nombre,
+ b.cantidad, b.pre_unitario, b.subtotal,
+ t.items, t.total
+FROM base b CROSS JOIN tot t
+ORDER BY b.producto_nombre NULLS LAST;
+$$;
+
+
+SET default_tablespace = '';
+
+SET default_table_access_method = heap;
+
+--
+-- Name: comandas; Type: TABLE; Schema: public; Owner: -
+--
+
+CREATE TABLE public.comandas (
+ id_comanda integer NOT NULL,
+ id_usuario integer NOT NULL,
+ id_mesa integer NOT NULL,
+ fec_creacion timestamp without time zone DEFAULT now() NOT NULL,
+ estado text NOT NULL,
+ observaciones text,
+ fec_cierre timestamp with time zone,
+ CONSTRAINT comandas_estado_check CHECK ((estado = ANY (ARRAY['abierta'::text, 'cerrada'::text, 'pagada'::text, 'anulada'::text])))
+);
+
+
+--
+-- Name: COLUMN comandas.fec_cierre; Type: COMMENT; Schema: public; Owner: -
+--
+
+COMMENT ON COLUMN public.comandas.fec_cierre IS 'Fecha/hora de cierre de la comanda (NULL si está abierta)';
+
+
+--
+-- Name: deta_comandas; Type: TABLE; Schema: public; Owner: -
+--
+
+CREATE TABLE public.deta_comandas (
+ id_det_comanda integer NOT NULL,
+ id_comanda integer NOT NULL,
+ id_producto integer NOT NULL,
+ cantidad numeric(12,3) NOT NULL,
+ pre_unitario numeric(12,2) NOT NULL,
+ observaciones text,
+ CONSTRAINT deta_comandas_cantidad_check CHECK ((cantidad > (0)::numeric)),
+ CONSTRAINT deta_comandas_pre_unitario_check CHECK ((pre_unitario >= (0)::numeric))
+);
+
+
+--
+-- Name: mesas; Type: TABLE; Schema: public; Owner: -
+--
+
+CREATE TABLE public.mesas (
+ id_mesa integer NOT NULL,
+ numero integer NOT NULL,
+ apodo text NOT NULL,
+ estado text DEFAULT 'libre'::text NOT NULL,
+ CONSTRAINT mesas_estado_check CHECK ((estado = ANY (ARRAY['libre'::text, 'ocupada'::text, 'reservada'::text])))
+);
+
+
+--
+-- Name: usuarios; Type: TABLE; Schema: public; Owner: -
+--
+
+CREATE TABLE public.usuarios (
+ id_usuario integer NOT NULL,
+ documento text,
+ img_perfil character varying(255) DEFAULT 'img_perfil.png'::character varying NOT NULL,
+ nombre text NOT NULL,
+ apellido text NOT NULL,
+ correo text,
+ telefono text,
+ fec_nacimiento date,
+ activo boolean DEFAULT true
+);
+
+
+--
+-- Name: v_comandas_resumen; Type: VIEW; Schema: public; Owner: -
+--
+
+CREATE VIEW public.v_comandas_resumen AS
+ WITH items AS (
+ SELECT d.id_comanda,
+ count(*) AS items,
+ sum((d.cantidad * d.pre_unitario)) AS total
+ FROM public.deta_comandas d
+ GROUP BY d.id_comanda
+ )
+ SELECT c.id_comanda,
+ c.fec_creacion,
+ c.estado,
+ c.observaciones,
+ u.id_usuario,
+ u.nombre AS usuario_nombre,
+ u.apellido AS usuario_apellido,
+ m.id_mesa,
+ m.numero AS mesa_numero,
+ m.apodo AS mesa_apodo,
+ COALESCE(i.items, (0)::bigint) AS items,
+ COALESCE(i.total, (0)::numeric) AS total,
+ c.fec_cierre,
+ CASE
+ WHEN (c.fec_cierre IS NOT NULL) THEN round((EXTRACT(epoch FROM (c.fec_cierre - (c.fec_creacion)::timestamp with time zone)) / 60.0), 1)
+ ELSE NULL::numeric
+ END AS duracion_min
+ FROM (((public.comandas c
+ JOIN public.usuarios u ON ((u.id_usuario = c.id_usuario)))
+ JOIN public.mesas m ON ((m.id_mesa = c.id_mesa)))
+ LEFT JOIN items i ON ((i.id_comanda = c.id_comanda)));
+
+
+--
+-- Name: f_comandas_resumen(text, integer); Type: FUNCTION; Schema: public; Owner: -
+--
+
+CREATE FUNCTION public.f_comandas_resumen(p_estado text DEFAULT NULL::text, p_limit integer DEFAULT 200) RETURNS SETOF public.v_comandas_resumen
+ LANGUAGE sql
+ AS $$
+ SELECT *
+ FROM public.v_comandas_resumen
+ WHERE (p_estado IS NULL OR estado = p_estado)
+ ORDER BY id_comanda DESC
+ LIMIT p_limit;
+$$;
+
+
+--
+-- Name: find_usuarios_por_documentos(jsonb); Type: FUNCTION; Schema: public; Owner: -
+--
+
+CREATE FUNCTION public.find_usuarios_por_documentos(p_docs jsonb) RETURNS jsonb
+ LANGUAGE sql
+ AS $$
+WITH docs AS (
+ SELECT DISTINCT
+ regexp_replace(value::text, '^\s*0+', '', 'g') AS doc_clean,
+ value::text AS original
+ FROM jsonb_array_elements_text(COALESCE(p_docs,'[]'))
+),
+rows AS (
+ SELECT d.original AS documento,
+ u.nombre,
+ u.apellido,
+ (u.id_usuario IS NOT NULL) AS found
+ FROM docs d
+ LEFT JOIN public.usuarios u
+ ON regexp_replace(u.documento, '^\s*0+', '', 'g') = d.doc_clean
+)
+SELECT COALESCE(
+ jsonb_object_agg(
+ documento,
+ jsonb_build_object(
+ 'nombre', COALESCE(nombre, ''),
+ 'apellido', COALESCE(apellido, ''),
+ 'found', found
+ )
+ ),
+ '{}'::jsonb
+)
+FROM rows;
+$$;
+
+
+--
+-- Name: get_compra(integer); Type: FUNCTION; Schema: public; Owner: -
+--
+
+CREATE FUNCTION public.get_compra(p_id_compra integer) RETURNS jsonb
+ LANGUAGE sql
+ AS $$
+WITH cab AS (
+ SELECT c.id_compra, c.id_proveedor, c.fec_compra, c.total
+ FROM public.compras c
+ WHERE c.id_compra = p_id_compra
+),
+dm AS (
+ SELECT 'MAT'::text AS tipo, d.id_mat_prima AS id,
+ d.cantidad, d.pre_unitario AS precio
+ FROM public.deta_comp_materias d WHERE d.id_compra = p_id_compra
+),
+dp AS (
+ SELECT 'PROD'::text AS tipo, d.id_producto AS id,
+ d.cantidad, d.pre_unitario AS precio
+ FROM public.deta_comp_producto d WHERE d.id_compra = p_id_compra
+),
+det AS (
+ SELECT jsonb_agg(to_jsonb(x.*)) AS detalles
+ FROM (
+ SELECT * FROM dm
+ UNION ALL
+ SELECT * FROM dp
+ ) x
+)
+SELECT jsonb_build_object(
+ 'id_compra', (SELECT id_compra FROM cab),
+ 'id_proveedor',(SELECT id_proveedor FROM cab),
+ 'fec_compra', to_char((SELECT fec_compra FROM cab),'YYYY-MM-DD HH24:MI:SS'),
+ 'total', (SELECT total FROM cab),
+ 'detalles', COALESCE((SELECT detalles FROM det),'[]'::jsonb)
+);
+$$;
+
+
+--
+-- Name: get_materia_prima(integer); Type: FUNCTION; Schema: public; Owner: -
+--
+
+CREATE FUNCTION public.get_materia_prima(p_id integer) RETURNS jsonb
+ LANGUAGE sql
+ AS $$
+SELECT jsonb_build_object(
+ 'materia', to_jsonb(mp),
+ 'proveedores', COALESCE(
+ (
+ SELECT jsonb_agg(
+ jsonb_build_object(
+ 'id_proveedor', pr.id_proveedor,
+ 'raz_social', pr.raz_social,
+ 'rut', pr.rut,
+ 'contacto', pr.contacto,
+ 'direccion', pr.direccion
+ )
+ )
+ FROM public.prov_mate_prima pmp
+ JOIN public.proveedores pr ON pr.id_proveedor = pmp.id_proveedor
+ WHERE pmp.id_mat_prima = mp.id_mat_prima
+ ),
+ '[]'::jsonb
+ )
+)
+FROM public.mate_primas mp
+WHERE mp.id_mat_prima = p_id;
+$$;
+
+
+--
+-- Name: get_producto(integer); Type: FUNCTION; Schema: public; Owner: -
+--
+
+CREATE FUNCTION public.get_producto(p_id integer) RETURNS jsonb
+ LANGUAGE sql
+ AS $$
+SELECT jsonb_build_object(
+ 'producto', to_jsonb(p), -- el registro completo del producto en JSONB
+ 'receta', COALESCE(
+ (
+ SELECT jsonb_agg(
+ jsonb_build_object(
+ 'id_mat_prima', rp.id_mat_prima,
+ 'qty_por_unidad', rp.qty_por_unidad,
+ 'nombre', mp.nombre,
+ 'unidad', mp.unidad
+ )
+ )
+ FROM receta_producto rp
+ LEFT JOIN mate_primas mp USING (id_mat_prima)
+ WHERE rp.id_producto = p.id_producto
+ ),
+ '[]'::jsonb
+ )
+)
+FROM productos p
+WHERE p.id_producto = p_id;
+$$;
+
+
+--
+-- Name: import_asistencia(jsonb, text, text); Type: FUNCTION; Schema: public; Owner: -
+--
+
+CREATE FUNCTION public.import_asistencia(p_registros jsonb, p_origen text, p_tz text DEFAULT 'America/Montevideo'::text) RETURNS jsonb
+ LANGUAGE plpgsql
+ AS $_$
+DECLARE
+ v_ins_raw INT;
+ v_ins_pairs INT;
+ v_miss JSONB;
+BEGIN
+ WITH
+ -- 1) JSON -> filas
+ j AS (
+ SELECT
+ regexp_replace(elem->>'doc','^\s*0+','','g')::TEXT AS doc_clean,
+ (elem->>'isoDate')::DATE AS d,
+ elem->>'time' AS time_str,
+ NULLIF(elem->>'mode','') AS modo
+ FROM jsonb_array_elements(COALESCE(p_registros,'[]')) elem
+ ),
+ -- 2) Vincular a usuarios
+ u AS (
+ SELECT j.*, u.id_usuario
+ FROM j
+ LEFT JOIN public.usuarios u
+ ON regexp_replace(u.documento,'^\s*0+','','g') = j.doc_clean
+ ),
+ -- 3) Documentos faltantes
+ miss AS (
+ SELECT jsonb_agg(doc_clean) AS missing
+ FROM u WHERE id_usuario IS NULL
+ ),
+ -- 4) TS determinista en TZ del negocio
+ parsed AS (
+ SELECT
+ u.id_usuario,
+ u.modo,
+ make_timestamptz(
+ EXTRACT(YEAR FROM u.d)::INT,
+ EXTRACT(MONTH FROM u.d)::INT,
+ EXTRACT(DAY FROM u.d)::INT,
+ split_part(u.time_str,':',1)::INT,
+ split_part(u.time_str,':',2)::INT,
+ COALESCE(NULLIF(split_part(u.time_str,':',3),''),'0')::INT,
+ p_tz
+ ) AS ts_calc
+ FROM u
+ WHERE u.id_usuario IS NOT NULL
+ ),
+ -- 5) Ventana por usuario (±1 día de lo importado)
+ win AS (
+ SELECT id_usuario,
+ (MIN(ts_calc) - INTERVAL '1 day') AS t0,
+ (MAX(ts_calc) + INTERVAL '1 day') AS t1
+ FROM parsed
+ GROUP BY id_usuario
+ ),
+ -- 6) Lo existente en BD dentro de la ventana
+ existing AS (
+ SELECT ar.id_usuario, ar.ts
+ FROM public.asistencia_raw ar
+ JOIN win w ON w.id_usuario = ar.id_usuario
+ AND ar.ts BETWEEN w.t0 AND w.t1
+ ),
+ -- 7) CANDIDATE = existente ∪ archivo (sin duplicados)
+ candidate AS (
+ SELECT id_usuario, ts FROM existing
+ UNION -- ¡clave para evitar doble click!
+ SELECT id_usuario, ts_calc AS ts FROM parsed
+ ),
+ -- 8) Paridad previa (cuántas marcas había ANTES de la ventana)
+ before_cnt AS (
+ SELECT w.id_usuario, COUNT(*)::int AS cnt
+ FROM win w
+ JOIN public.asistencia_raw ar
+ ON ar.id_usuario = w.id_usuario
+ AND ar.ts < w.t0
+ GROUP BY w.id_usuario
+ ),
+ -- 9) Línea de tiempo candidata y pares (1→2, 3→4…), jornada = día local del inicio
+ timeline AS (
+ SELECT
+ c.id_usuario,
+ c.ts,
+ ROW_NUMBER() OVER (PARTITION BY c.id_usuario ORDER BY c.ts) AS rn
+ FROM candidate c
+ ),
+ ready AS (
+ SELECT
+ t1.id_usuario,
+ (t1.ts AT TIME ZONE p_tz)::date AS fecha,
+ t1.ts AS desde,
+ t2.ts AS hasta,
+ EXTRACT(EPOCH FROM (t2.ts - t1.ts))/60.0 AS dur_min
+ FROM timeline t1
+ JOIN timeline t2
+ ON t2.id_usuario = t1.id_usuario
+ AND t2.rn = t1.rn + 1
+ LEFT JOIN before_cnt b ON b.id_usuario = t1.id_usuario
+ WHERE ((COALESCE(b.cnt,0) + t1.rn) % 2) = 1 -- t1 es IN global
+ AND t2.ts > t1.ts
+ ),
+ -- 10) INSERT crudo (dedupe)
+ ins_raw AS (
+ INSERT INTO public.asistencia_raw (id_usuario, ts, modo, origen)
+ SELECT id_usuario, ts_calc,
+ NULLIF(modo,'')::text, -- puede quedar NULL para auto-etiquetado
+ p_origen
+ FROM parsed
+ ON CONFLICT (id_usuario, ts) DO NOTHING
+ RETURNING 1
+ ),
+ -- 11) Auto-etiquetar IN/OUT en BD para filas con modo vacío/'1' (tras insertar)
+ before_cnt2 AS (
+ SELECT w.id_usuario, COUNT(*)::int AS cnt
+ FROM win w
+ JOIN public.asistencia_raw ar
+ ON ar.id_usuario = w.id_usuario
+ AND ar.ts < w.t0
+ GROUP BY w.id_usuario
+ ),
+ tl2 AS (
+ SELECT
+ ar.id_usuario, ar.ts,
+ ROW_NUMBER() OVER (PARTITION BY ar.id_usuario ORDER BY ar.ts) AS rn
+ FROM public.asistencia_raw ar
+ JOIN win w ON w.id_usuario = ar.id_usuario
+ AND ar.ts BETWEEN w.t0 AND w.t1
+ ),
+ label2 AS (
+ SELECT
+ t.id_usuario,
+ t.ts,
+ CASE WHEN ((COALESCE(b.cnt,0) + t.rn) % 2) = 1 THEN 'IN' ELSE 'OUT' END AS new_mode
+ FROM tl2 t
+ LEFT JOIN before_cnt2 b ON b.id_usuario = t.id_usuario
+ ),
+ set_mode AS (
+ UPDATE public.asistencia_raw ar
+ SET modo = l.new_mode
+ FROM label2 l
+ WHERE ar.id_usuario = l.id_usuario
+ AND ar.ts = l.ts
+ AND (ar.modo IS NULL OR btrim(ar.modo) = '' OR ar.modo ~ '^\s*1\s*$')
+ RETURNING 1
+ ),
+ -- 12) INSERT pares (dedupe) calculados desde CANDIDATE (ya tiene todo el contexto)
+ ins_pairs AS (
+ INSERT INTO public.asistencia_intervalo (id_usuario, fecha, desde, hasta, dur_min, origen)
+ SELECT id_usuario, fecha, desde, hasta, dur_min, p_origen
+ FROM ready
+ ON CONFLICT (id_usuario, desde, hasta) DO NOTHING
+ RETURNING 1
+ )
+ SELECT
+ (SELECT COUNT(*) FROM ins_raw),
+ (SELECT COUNT(*) FROM ins_pairs),
+ (SELECT COALESCE(missing,'[]'::jsonb) FROM miss)
+ INTO v_ins_raw, v_ins_pairs, v_miss;
+
+ RETURN jsonb_build_object(
+ 'inserted_raw', v_ins_raw,
+ 'inserted_pairs', v_ins_pairs,
+ 'missing_docs', v_miss
+ );
+END;
+$_$;
+
+
+--
+-- Name: report_asistencia(date, date); Type: FUNCTION; Schema: public; Owner: -
+--
+
+CREATE FUNCTION public.report_asistencia(p_desde date, p_hasta date) RETURNS TABLE(documento text, nombre text, apellido text, fecha date, desde_hora text, hasta_hora text, dur_min numeric)
+ LANGUAGE sql
+ AS $$
+ SELECT
+ u.documento, u.nombre, u.apellido,
+ ai.fecha,
+ to_char(ai.desde AT TIME ZONE 'America/Montevideo','HH24:MI:SS') AS desde_hora,
+ to_char(ai.hasta AT TIME ZONE 'America/Montevideo','HH24:MI:SS') AS hasta_hora,
+ ai.dur_min
+ FROM public.asistencia_intervalo ai
+ JOIN public.usuarios u USING (id_usuario)
+ WHERE ai.fecha BETWEEN p_desde AND p_hasta
+ ORDER BY u.documento, ai.fecha, ai.desde;
+$$;
+
+
+--
+-- Name: report_gastos(integer); Type: FUNCTION; Schema: public; Owner: -
+--
+
+CREATE FUNCTION public.report_gastos(p_year integer) RETURNS jsonb
+ LANGUAGE sql STABLE
+ AS $$
+WITH mdata AS (
+ SELECT date_trunc('month', c.fec_compra)::date AS m,
+ SUM(c.total)::numeric AS importe
+ FROM public.compras c
+ WHERE EXTRACT(YEAR FROM c.fec_compra) = p_year
+ GROUP BY 1
+),
+mm AS (
+ SELECT EXTRACT(MONTH FROM m)::int AS mes, importe
+ FROM mdata
+)
+SELECT jsonb_build_object(
+ 'year', p_year,
+ 'total', COALESCE((SELECT SUM(importe) FROM mdata), 0),
+ 'avg', COALESCE((SELECT AVG(importe) FROM mdata), 0),
+ 'months',
+ (SELECT jsonb_agg(
+ jsonb_build_object(
+ 'mes', gs,
+ 'nombre', to_char(to_date(gs::text,'MM'),'Mon'),
+ 'importe', COALESCE(mm.importe,0)
+ )
+ ORDER BY gs
+ )
+ FROM generate_series(1,12) gs
+ LEFT JOIN mm ON mm.mes = gs)
+);
+$$;
+
+
+--
+-- Name: report_tickets_year(integer, text); Type: FUNCTION; Schema: public; Owner: -
+--
+
+CREATE FUNCTION public.report_tickets_year(p_year integer, p_tz text DEFAULT 'America/Montevideo'::text) RETURNS jsonb
+ LANGUAGE sql STABLE
+ AS $$
+WITH bounds AS (
+ SELECT
+ make_timestamp(p_year, 1, 1, 0,0,0) AS d0,
+ make_timestamp(p_year+1, 1, 1, 0,0,0) AS d1,
+ make_timestamptz(p_year, 1, 1, 0,0,0, p_tz) AS t0,
+ make_timestamptz(p_year+1, 1, 1, 0,0,0, p_tz) AS t1
+),
+base AS (
+ SELECT
+ c.id_comanda,
+ CASE WHEN c.fec_cierre IS NOT NULL
+ THEN (c.fec_cierre AT TIME ZONE p_tz)
+ ELSE c.fec_creacion
+ END AS fec_local,
+ v.total
+ FROM public.comandas c
+ JOIN public.vw_ticket_total v ON v.id_comanda = c.id_comanda
+ JOIN bounds b ON TRUE
+ WHERE
+ (c.fec_cierre IS NOT NULL AND c.fec_cierre >= b.t0 AND c.fec_cierre < b.t1)
+ OR
+ (c.fec_cierre IS NULL AND c.fec_creacion >= b.d0 AND c.fec_creacion < b.d1)
+),
+m AS (
+ SELECT
+ EXTRACT(MONTH FROM fec_local)::int AS mes,
+ COUNT(*)::int AS cant,
+ SUM(total)::numeric AS importe,
+ AVG(total)::numeric AS avg
+ FROM base
+ GROUP BY 1
+),
+ytd AS (
+ SELECT COUNT(*)::int AS total_ytd,
+ AVG(total)::numeric AS avg_ticket,
+ SUM(total)::numeric AS to_date
+ FROM base
+)
+SELECT jsonb_build_object(
+ 'year', p_year,
+ 'total_ytd', (SELECT total_ytd FROM ytd),
+ 'avg_ticket', (SELECT avg_ticket FROM ytd),
+ 'to_date', (SELECT to_date FROM ytd),
+ 'months',
+ (SELECT jsonb_agg(
+ jsonb_build_object(
+ 'mes', mes,
+ 'nombre', to_char(to_date(mes::text,'MM'),'Mon'),
+ 'cant', cant,
+ 'importe', importe,
+ 'avg', avg
+ )
+ ORDER BY mes
+ )
+ FROM m)
+);
+$$;
+
+
+--
+-- Name: save_compra(integer, integer, timestamp with time zone, jsonb); Type: FUNCTION; Schema: public; Owner: -
+--
+
+CREATE FUNCTION public.save_compra(p_id_compra integer, p_id_proveedor integer, p_fec_compra timestamp with time zone, p_detalles jsonb) RETURNS TABLE(id_compra integer, total numeric)
+ LANGUAGE plpgsql
+ AS $$
+DECLARE
+ v_id INT;
+ v_total numeric := 0;
+BEGIN
+ IF COALESCE(jsonb_array_length(p_detalles),0) = 0 THEN
+ RAISE EXCEPTION 'No hay renglones en la compra';
+ END IF;
+
+ -- Cabecera (insert/update)
+ IF p_id_compra IS NULL THEN
+ INSERT INTO public.compras (id_proveedor, fec_compra, total)
+ VALUES (p_id_proveedor, COALESCE(p_fec_compra, now()), 0)
+ RETURNING public.compras.id_compra INTO v_id;
+ ELSE
+ UPDATE public.compras c
+ SET id_proveedor = p_id_proveedor,
+ fec_compra = COALESCE(p_fec_compra, c.fec_compra)
+ WHERE c.id_compra = p_id_compra
+ RETURNING c.id_compra INTO v_id;
+
+ -- Reemplazamos los renglones
+ DELETE FROM public.deta_comp_materias d WHERE d.id_compra = v_id;
+ DELETE FROM public.deta_comp_producto p WHERE p.id_compra = v_id;
+ END IF;
+
+ -- Materias primas (sin CTE: parseo JSON inline)
+ INSERT INTO public.deta_comp_materias (id_compra, id_mat_prima, cantidad, pre_unitario)
+ SELECT
+ v_id,
+ x.id,
+ x.cantidad,
+ x.precio
+ FROM jsonb_to_recordset(COALESCE(p_detalles, '[]'::jsonb))
+ AS x(tipo text, id int, cantidad numeric, precio numeric)
+ WHERE UPPER(TRIM(x.tipo)) = 'MAT';
+
+ -- Productos (sin CTE)
+ INSERT INTO public.deta_comp_producto (id_compra, id_producto, cantidad, pre_unitario)
+ SELECT
+ v_id,
+ x.id,
+ x.cantidad,
+ x.precio
+ FROM jsonb_to_recordset(COALESCE(p_detalles, '[]'::jsonb))
+ AS x(tipo text, id int, cantidad numeric, precio numeric)
+ WHERE UPPER(TRIM(x.tipo)) = 'PROD';
+
+ -- Recalcular total (calificado) y redondear a ENTERO
+ SELECT
+ COALESCE( (SELECT SUM(dcm.cantidad*dcm.pre_unitario)
+ FROM public.deta_comp_materias dcm
+ WHERE dcm.id_compra = v_id), 0)
+ + COALESCE( (SELECT SUM(dcp.cantidad*dcp.pre_unitario)
+ FROM public.deta_comp_producto dcp
+ WHERE dcp.id_compra = v_id), 0)
+ INTO v_total;
+
+ UPDATE public.compras c
+ SET total = round(v_total, 0)
+ WHERE c.id_compra = v_id;
+
+ RETURN QUERY SELECT v_id, round(v_total, 0);
+END;
+$$;
+
+
+--
+-- Name: save_materia_prima(integer, text, text, boolean, jsonb); Type: FUNCTION; Schema: public; Owner: -
+--
+
+CREATE FUNCTION public.save_materia_prima(p_id_mat_prima integer, p_nombre text, p_unidad text, p_activo boolean, p_proveedores jsonb DEFAULT '[]'::jsonb) RETURNS integer
+ LANGUAGE plpgsql
+ AS $_$
+DECLARE
+ v_id INT;
+BEGIN
+ IF p_id_mat_prima IS NULL THEN
+ INSERT INTO public.mate_primas (nombre, unidad, activo)
+ VALUES (p_nombre, p_unidad, COALESCE(p_activo, TRUE))
+ RETURNING mate_primas.id_mat_prima INTO v_id;
+ ELSE
+ UPDATE public.mate_primas mp
+ SET nombre = p_nombre,
+ unidad = p_unidad,
+ activo = COALESCE(p_activo, TRUE)
+ WHERE mp.id_mat_prima = p_id_mat_prima;
+ v_id := p_id_mat_prima;
+ END IF;
+
+ -- Sincronizar proveedores: borrar todos y re-crear a partir de JSONB
+ DELETE FROM public.prov_mate_prima pmp WHERE pmp.id_mat_prima = v_id;
+
+ INSERT INTO public.prov_mate_prima (id_proveedor, id_mat_prima)
+ SELECT (e->>0)::INT AS id_proveedor, -- elementos JSON como enteros (array simple)
+ v_id AS id_mat_prima
+ FROM jsonb_array_elements(COALESCE(p_proveedores, '[]'::jsonb)) AS e
+ WHERE (e->>0) ~ '^\d+$'; -- solo enteros
+
+ RETURN v_id;
+END;
+$_$;
+
+
+--
+-- Name: save_producto(integer, text, text, numeric, boolean, integer, jsonb); Type: FUNCTION; Schema: public; Owner: -
+--
+
+CREATE FUNCTION public.save_producto(p_id_producto integer, p_nombre text, p_img_producto text, p_precio numeric, p_activo boolean, p_id_categoria integer, p_receta jsonb DEFAULT '[]'::jsonb) RETURNS integer
+ LANGUAGE plpgsql
+ AS $_$
+DECLARE
+ v_id INT;
+BEGIN
+ IF p_id_producto IS NULL THEN
+ INSERT INTO public.productos (nombre, img_producto, precio, activo, id_categoria)
+ VALUES (p_nombre, p_img_producto, p_precio, COALESCE(p_activo, TRUE), p_id_categoria)
+ RETURNING productos.id_producto INTO v_id;
+ ELSE
+ UPDATE public.productos p
+ SET nombre = p_nombre,
+ img_producto = p_img_producto,
+ precio = p_precio,
+ activo = COALESCE(p_activo, TRUE),
+ id_categoria = p_id_categoria
+ WHERE p.id_producto = p_id_producto;
+ v_id := p_id_producto;
+ END IF;
+
+ -- Limpia receta actual
+ DELETE FROM public.receta_producto rp WHERE rp.id_producto = v_id;
+
+ -- Inserta SOLO ítems válidos (id entero positivo y cantidad > 0), redondeo a 3 decimales
+ INSERT INTO public.receta_producto (id_producto, id_mat_prima, qty_por_unidad)
+ SELECT
+ v_id,
+ (rec->>'id_mat_prima')::INT,
+ ROUND((rec->>'qty_por_unidad')::NUMERIC, 3)
+ FROM jsonb_array_elements(COALESCE(p_receta, '[]'::jsonb)) AS rec
+ WHERE
+ (rec->>'id_mat_prima') ~ '^\d+$'
+ AND (rec->>'id_mat_prima')::INT > 0
+ AND (rec->>'qty_por_unidad') ~ '^\d+(\.\d+)?$'
+ AND (rec->>'qty_por_unidad')::NUMERIC > 0;
+
+ RETURN v_id;
+END;
+$_$;
+
+
+--
+-- Name: asistencia_intervalo; Type: TABLE; Schema: public; Owner: -
+--
+
+CREATE TABLE public.asistencia_intervalo (
+ id_intervalo bigint NOT NULL,
+ id_usuario integer NOT NULL,
+ fecha date NOT NULL,
+ desde timestamp with time zone NOT NULL,
+ hasta timestamp with time zone NOT NULL,
+ dur_min numeric(10,2) NOT NULL,
+ origen text,
+ created_at timestamp with time zone DEFAULT now() NOT NULL,
+ CONSTRAINT chk_ai_orden CHECK ((hasta > desde))
+);
+
+
+--
+-- Name: asistencia_intervalo_id_intervalo_seq; Type: SEQUENCE; Schema: public; Owner: -
+--
+
+CREATE SEQUENCE public.asistencia_intervalo_id_intervalo_seq
+ START WITH 1
+ INCREMENT BY 1
+ NO MINVALUE
+ NO MAXVALUE
+ CACHE 1;
+
+
+--
+-- Name: asistencia_intervalo_id_intervalo_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: -
+--
+
+ALTER SEQUENCE public.asistencia_intervalo_id_intervalo_seq OWNED BY public.asistencia_intervalo.id_intervalo;
+
+
+--
+-- Name: asistencia_raw; Type: TABLE; Schema: public; Owner: -
+--
+
+CREATE TABLE public.asistencia_raw (
+ id_raw bigint NOT NULL,
+ id_usuario integer NOT NULL,
+ ts timestamp with time zone NOT NULL,
+ modo text,
+ origen text,
+ created_at timestamp with time zone DEFAULT now() NOT NULL
+);
+
+
+--
+-- Name: asistencia_raw_id_raw_seq; Type: SEQUENCE; Schema: public; Owner: -
+--
+
+CREATE SEQUENCE public.asistencia_raw_id_raw_seq
+ START WITH 1
+ INCREMENT BY 1
+ NO MINVALUE
+ NO MAXVALUE
+ CACHE 1;
+
+
+--
+-- Name: asistencia_raw_id_raw_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: -
+--
+
+ALTER SEQUENCE public.asistencia_raw_id_raw_seq OWNED BY public.asistencia_raw.id_raw;
+
+
+--
+-- Name: asistencia_resumen_diario; Type: VIEW; Schema: public; Owner: -
+--
+
+CREATE VIEW public.asistencia_resumen_diario AS
+ SELECT ai.id_usuario,
+ u.documento,
+ u.nombre,
+ u.apellido,
+ ai.fecha,
+ sum(ai.dur_min) AS minutos_dia,
+ round((sum(ai.dur_min) / 60.0), 2) AS horas_dia,
+ count(*) AS pares_dia
+ FROM (public.asistencia_intervalo ai
+ JOIN public.usuarios u USING (id_usuario))
+ GROUP BY ai.id_usuario, u.documento, u.nombre, u.apellido, ai.fecha
+ ORDER BY ai.id_usuario, ai.fecha;
+
+
+--
+-- Name: categorias; Type: TABLE; Schema: public; Owner: -
+--
+
+CREATE TABLE public.categorias (
+ id_categoria integer NOT NULL,
+ nombre text NOT NULL,
+ visible boolean DEFAULT true
+);
+
+
+--
+-- Name: categorias_id_categoria_seq; Type: SEQUENCE; Schema: public; Owner: -
+--
+
+CREATE SEQUENCE public.categorias_id_categoria_seq
+ AS integer
+ START WITH 1
+ INCREMENT BY 1
+ NO MINVALUE
+ NO MAXVALUE
+ CACHE 1;
+
+
+--
+-- Name: categorias_id_categoria_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: -
+--
+
+ALTER SEQUENCE public.categorias_id_categoria_seq OWNED BY public.categorias.id_categoria;
+
+
+--
+-- Name: clientes; Type: TABLE; Schema: public; Owner: -
+--
+
+CREATE TABLE public.clientes (
+ id_cliente integer NOT NULL,
+ nombre text NOT NULL,
+ correo text,
+ telefono text,
+ fec_nacimiento date,
+ activo boolean DEFAULT true
+);
+
+
+--
+-- Name: clientes_id_cliente_seq; Type: SEQUENCE; Schema: public; Owner: -
+--
+
+CREATE SEQUENCE public.clientes_id_cliente_seq
+ AS integer
+ START WITH 1
+ INCREMENT BY 1
+ NO MINVALUE
+ NO MAXVALUE
+ CACHE 1;
+
+
+--
+-- Name: clientes_id_cliente_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: -
+--
+
+ALTER SEQUENCE public.clientes_id_cliente_seq OWNED BY public.clientes.id_cliente;
+
+
+--
+-- Name: comandas_id_comanda_seq; Type: SEQUENCE; Schema: public; Owner: -
+--
+
+CREATE SEQUENCE public.comandas_id_comanda_seq
+ AS integer
+ START WITH 1
+ INCREMENT BY 1
+ NO MINVALUE
+ NO MAXVALUE
+ CACHE 1;
+
+
+--
+-- Name: comandas_id_comanda_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: -
+--
+
+ALTER SEQUENCE public.comandas_id_comanda_seq OWNED BY public.comandas.id_comanda;
+
+
+--
+-- Name: compras; Type: TABLE; Schema: public; Owner: -
+--
+
+CREATE TABLE public.compras (
+ id_compra integer NOT NULL,
+ id_proveedor integer NOT NULL,
+ fec_compra timestamp without time zone NOT NULL,
+ total numeric(14,2)
+);
+
+
+--
+-- Name: compras_id_compra_seq; Type: SEQUENCE; Schema: public; Owner: -
+--
+
+CREATE SEQUENCE public.compras_id_compra_seq
+ AS integer
+ START WITH 1
+ INCREMENT BY 1
+ NO MINVALUE
+ NO MAXVALUE
+ CACHE 1;
+
+
+--
+-- Name: compras_id_compra_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: -
+--
+
+ALTER SEQUENCE public.compras_id_compra_seq OWNED BY public.compras.id_compra;
+
+
+--
+-- Name: deta_comandas_id_det_comanda_seq; Type: SEQUENCE; Schema: public; Owner: -
+--
+
+CREATE SEQUENCE public.deta_comandas_id_det_comanda_seq
+ AS integer
+ START WITH 1
+ INCREMENT BY 1
+ NO MINVALUE
+ NO MAXVALUE
+ CACHE 1;
+
+
+--
+-- Name: deta_comandas_id_det_comanda_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: -
+--
+
+ALTER SEQUENCE public.deta_comandas_id_det_comanda_seq OWNED BY public.deta_comandas.id_det_comanda;
+
+
+--
+-- Name: deta_comp_materias; Type: TABLE; Schema: public; Owner: -
+--
+
+CREATE TABLE public.deta_comp_materias (
+ id_compra integer NOT NULL,
+ id_mat_prima integer NOT NULL,
+ cantidad numeric(12,3) NOT NULL,
+ pre_unitario numeric(12,2) NOT NULL,
+ CONSTRAINT deta_comp_materias_cantidad_check CHECK ((cantidad > (0)::numeric)),
+ CONSTRAINT deta_comp_materias_pre_unitario_check CHECK ((pre_unitario >= (0)::numeric))
+);
+
+
+--
+-- Name: deta_comp_producto; Type: TABLE; Schema: public; Owner: -
+--
+
+CREATE TABLE public.deta_comp_producto (
+ id_compra integer NOT NULL,
+ id_producto integer NOT NULL,
+ cantidad numeric(12,3) NOT NULL,
+ pre_unitario numeric(12,2) NOT NULL,
+ CONSTRAINT deta_comp_producto_cantidad_check CHECK ((cantidad > (0)::numeric)),
+ CONSTRAINT deta_comp_producto_pre_unitario_check CHECK ((pre_unitario >= (0)::numeric))
+);
+
+
+--
+-- Name: mate_primas; Type: TABLE; Schema: public; Owner: -
+--
+
+CREATE TABLE public.mate_primas (
+ id_mat_prima integer NOT NULL,
+ nombre text NOT NULL,
+ unidad text NOT NULL,
+ activo boolean DEFAULT true
+);
+
+
+--
+-- Name: mate_primas_id_mat_prima_seq; Type: SEQUENCE; Schema: public; Owner: -
+--
+
+CREATE SEQUENCE public.mate_primas_id_mat_prima_seq
+ AS integer
+ START WITH 1
+ INCREMENT BY 1
+ NO MINVALUE
+ NO MAXVALUE
+ CACHE 1;
+
+
+--
+-- Name: mate_primas_id_mat_prima_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: -
+--
+
+ALTER SEQUENCE public.mate_primas_id_mat_prima_seq OWNED BY public.mate_primas.id_mat_prima;
+
+
+--
+-- Name: mesas_id_mesa_seq; Type: SEQUENCE; Schema: public; Owner: -
+--
+
+CREATE SEQUENCE public.mesas_id_mesa_seq
+ AS integer
+ START WITH 1
+ INCREMENT BY 1
+ NO MINVALUE
+ NO MAXVALUE
+ CACHE 1;
+
+
+--
+-- Name: mesas_id_mesa_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: -
+--
+
+ALTER SEQUENCE public.mesas_id_mesa_seq OWNED BY public.mesas.id_mesa;
+
+
+--
+-- Name: productos; Type: TABLE; Schema: public; Owner: -
+--
+
+CREATE TABLE public.productos (
+ id_producto integer NOT NULL,
+ nombre text NOT NULL,
+ img_producto character varying(255) DEFAULT 'img/productos/img_producto.png'::character varying NOT NULL,
+ precio integer NOT NULL,
+ activo boolean DEFAULT true,
+ id_categoria integer NOT NULL,
+ CONSTRAINT productos_precio_check CHECK (((precio)::numeric >= (0)::numeric)),
+ CONSTRAINT productos_precio_nn CHECK ((precio >= 0))
+);
+
+
+--
+-- Name: productos_id_producto_seq; Type: SEQUENCE; Schema: public; Owner: -
+--
+
+CREATE SEQUENCE public.productos_id_producto_seq
+ AS integer
+ START WITH 1
+ INCREMENT BY 1
+ NO MINVALUE
+ NO MAXVALUE
+ CACHE 1;
+
+
+--
+-- Name: productos_id_producto_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: -
+--
+
+ALTER SEQUENCE public.productos_id_producto_seq OWNED BY public.productos.id_producto;
+
+
+--
+-- Name: prov_mate_prima; Type: TABLE; Schema: public; Owner: -
+--
+
+CREATE TABLE public.prov_mate_prima (
+ id_proveedor integer NOT NULL,
+ id_mat_prima integer NOT NULL
+);
+
+
+--
+-- Name: prov_producto; Type: TABLE; Schema: public; Owner: -
+--
+
+CREATE TABLE public.prov_producto (
+ id_proveedor integer NOT NULL,
+ id_producto integer NOT NULL
+);
+
+
+--
+-- Name: proveedores; Type: TABLE; Schema: public; Owner: -
+--
+
+CREATE TABLE public.proveedores (
+ id_proveedor integer NOT NULL,
+ rut text,
+ raz_social text NOT NULL,
+ direccion text,
+ contacto text
+);
+
+
+--
+-- Name: proveedores_id_proveedor_seq; Type: SEQUENCE; Schema: public; Owner: -
+--
+
+CREATE SEQUENCE public.proveedores_id_proveedor_seq
+ AS integer
+ START WITH 1
+ INCREMENT BY 1
+ NO MINVALUE
+ NO MAXVALUE
+ CACHE 1;
+
+
+--
+-- Name: proveedores_id_proveedor_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: -
+--
+
+ALTER SEQUENCE public.proveedores_id_proveedor_seq OWNED BY public.proveedores.id_proveedor;
+
+
+--
+-- Name: receta_producto; Type: TABLE; Schema: public; Owner: -
+--
+
+CREATE TABLE public.receta_producto (
+ id_producto integer NOT NULL,
+ id_mat_prima integer NOT NULL,
+ qty_por_unidad numeric(12,3) NOT NULL,
+ CONSTRAINT receta_producto_qty_por_unidad_check CHECK ((qty_por_unidad > (0)::numeric))
+);
+
+
+--
+-- Name: roles; Type: TABLE; Schema: public; Owner: -
+--
+
+CREATE TABLE public.roles (
+ id_rol integer NOT NULL,
+ nombre text NOT NULL
+);
+
+
+--
+-- Name: roles_id_rol_seq; Type: SEQUENCE; Schema: public; Owner: -
+--
+
+CREATE SEQUENCE public.roles_id_rol_seq
+ AS integer
+ START WITH 1
+ INCREMENT BY 1
+ NO MINVALUE
+ NO MAXVALUE
+ CACHE 1;
+
+
+--
+-- Name: roles_id_rol_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: -
+--
+
+ALTER SEQUENCE public.roles_id_rol_seq OWNED BY public.roles.id_rol;
+
+
+--
+-- Name: usua_roles; Type: TABLE; Schema: public; Owner: -
+--
+
+CREATE TABLE public.usua_roles (
+ id_usuario integer NOT NULL,
+ id_rol integer NOT NULL,
+ fec_asignacion timestamp without time zone DEFAULT now(),
+ autor integer,
+ activo boolean DEFAULT true
+);
+
+
+--
+-- Name: usuarios_id_usuario_seq; Type: SEQUENCE; Schema: public; Owner: -
+--
+
+CREATE SEQUENCE public.usuarios_id_usuario_seq
+ AS integer
+ START WITH 1
+ INCREMENT BY 1
+ NO MINVALUE
+ NO MAXVALUE
+ CACHE 1;
+
+
+--
+-- Name: usuarios_id_usuario_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: -
+--
+
+ALTER SEQUENCE public.usuarios_id_usuario_seq OWNED BY public.usuarios.id_usuario;
+
+
+--
+-- Name: v_comandas_detalle_base; Type: VIEW; Schema: public; Owner: -
+--
+
+CREATE VIEW public.v_comandas_detalle_base AS
+ SELECT c.id_comanda,
+ c.fec_creacion,
+ c.fec_cierre,
+ c.estado,
+ c.observaciones,
+ u.id_usuario,
+ u.nombre AS usuario_nombre,
+ u.apellido AS usuario_apellido,
+ m.id_mesa,
+ m.numero AS mesa_numero,
+ m.apodo AS mesa_apodo,
+ d.id_producto,
+ p.nombre AS producto_nombre,
+ d.cantidad,
+ d.pre_unitario,
+ (d.cantidad * d.pre_unitario) AS subtotal
+ FROM ((((public.comandas c
+ JOIN public.usuarios u ON ((u.id_usuario = c.id_usuario)))
+ JOIN public.mesas m ON ((m.id_mesa = c.id_mesa)))
+ LEFT JOIN public.deta_comandas d ON ((d.id_comanda = c.id_comanda)))
+ LEFT JOIN public.productos p ON ((p.id_producto = d.id_producto)));
+
+
+--
+-- Name: v_comandas_detalle_items; Type: VIEW; Schema: public; Owner: -
+--
+
+CREATE VIEW public.v_comandas_detalle_items AS
+ SELECT d.id_comanda,
+ d.id_det_comanda,
+ d.id_producto,
+ p.nombre AS producto_nombre,
+ d.cantidad,
+ d.pre_unitario,
+ (d.cantidad * d.pre_unitario) AS subtotal,
+ d.observaciones
+ FROM (public.deta_comandas d
+ JOIN public.productos p ON ((p.id_producto = d.id_producto)));
+
+
+--
+-- Name: v_comandas_detalle_json; Type: VIEW; Schema: public; Owner: -
+--
+
+CREATE VIEW public.v_comandas_detalle_json AS
+ SELECT id_comanda,
+ jsonb_build_object('id_comanda', id_comanda, 'fec_creacion', fec_creacion, 'fec_cierre', fec_cierre, 'estado', estado, 'observaciones', observaciones, 'usuario', jsonb_build_object('id_usuario', id_usuario, 'nombre', usuario_nombre, 'apellido', usuario_apellido), 'mesa', jsonb_build_object('id_mesa', id_mesa, 'numero', mesa_numero, 'apodo', mesa_apodo), 'items', COALESCE(( SELECT jsonb_agg(jsonb_build_object('producto_id', b.id_producto, 'producto', b.producto_nombre, 'cantidad', b.cantidad, 'pre_unitario', b.pre_unitario, 'subtotal', b.subtotal) ORDER BY b.producto_nombre) AS jsonb_agg
+ FROM public.v_comandas_detalle_base b
+ WHERE ((b.id_comanda = h.id_comanda) AND (b.id_producto IS NOT NULL))), '[]'::jsonb), 'totales', jsonb_build_object('items', COALESCE(( SELECT count(*) AS count
+ FROM public.v_comandas_detalle_base b
+ WHERE ((b.id_comanda = h.id_comanda) AND (b.id_producto IS NOT NULL))), (0)::bigint), 'total', COALESCE(( SELECT sum(b.subtotal) AS sum
+ FROM public.v_comandas_detalle_base b
+ WHERE (b.id_comanda = h.id_comanda)), (0)::numeric))) AS data
+ FROM ( SELECT DISTINCT v_comandas_detalle_base.id_comanda,
+ v_comandas_detalle_base.fec_creacion,
+ v_comandas_detalle_base.fec_cierre,
+ v_comandas_detalle_base.estado,
+ v_comandas_detalle_base.observaciones,
+ v_comandas_detalle_base.id_usuario,
+ v_comandas_detalle_base.usuario_nombre,
+ v_comandas_detalle_base.usuario_apellido,
+ v_comandas_detalle_base.id_mesa,
+ v_comandas_detalle_base.mesa_numero,
+ v_comandas_detalle_base.mesa_apodo
+ FROM public.v_comandas_detalle_base) h;
+
+
+--
+-- Name: vw_compras; Type: VIEW; Schema: public; Owner: -
+--
+
+CREATE VIEW public.vw_compras AS
+ SELECT c.id_compra,
+ c.id_proveedor,
+ p.raz_social AS proveedor,
+ c.fec_compra,
+ c.total
+ FROM (public.compras c
+ JOIN public.proveedores p USING (id_proveedor))
+ ORDER BY c.fec_compra DESC, c.id_compra DESC;
+
+
+--
+-- Name: vw_ticket_total; Type: VIEW; Schema: public; Owner: -
+--
+
+CREATE VIEW public.vw_ticket_total AS
+ WITH lineas AS (
+ SELECT c.id_comanda,
+ COALESCE(c.fec_cierre, (c.fec_creacion)::timestamp with time zone) AS fec_ticket,
+ (COALESCE(dc.pre_unitario, (p.precio)::numeric, (0)::numeric))::numeric(14,2) AS pu,
+ (COALESCE(dc.cantidad, (1)::numeric))::numeric(14,3) AS qty
+ FROM ((public.comandas c
+ JOIN public.deta_comandas dc ON ((dc.id_comanda = c.id_comanda)))
+ LEFT JOIN public.productos p ON ((p.id_producto = dc.id_producto)))
+ )
+ SELECT id_comanda,
+ fec_ticket,
+ (sum((qty * pu)))::numeric(14,2) AS total
+ FROM lineas
+ GROUP BY id_comanda, fec_ticket;
+
+
+--
+-- Name: asistencia_intervalo id_intervalo; Type: DEFAULT; Schema: public; Owner: -
+--
+
+ALTER TABLE ONLY public.asistencia_intervalo ALTER COLUMN id_intervalo SET DEFAULT nextval('public.asistencia_intervalo_id_intervalo_seq'::regclass);
+
+
+--
+-- Name: asistencia_raw id_raw; Type: DEFAULT; Schema: public; Owner: -
+--
+
+ALTER TABLE ONLY public.asistencia_raw ALTER COLUMN id_raw SET DEFAULT nextval('public.asistencia_raw_id_raw_seq'::regclass);
+
+
+--
+-- Name: categorias id_categoria; Type: DEFAULT; Schema: public; Owner: -
+--
+
+ALTER TABLE ONLY public.categorias ALTER COLUMN id_categoria SET DEFAULT nextval('public.categorias_id_categoria_seq'::regclass);
+
+
+--
+-- Name: clientes id_cliente; Type: DEFAULT; Schema: public; Owner: -
+--
+
+ALTER TABLE ONLY public.clientes ALTER COLUMN id_cliente SET DEFAULT nextval('public.clientes_id_cliente_seq'::regclass);
+
+
+--
+-- Name: comandas id_comanda; Type: DEFAULT; Schema: public; Owner: -
+--
+
+ALTER TABLE ONLY public.comandas ALTER COLUMN id_comanda SET DEFAULT nextval('public.comandas_id_comanda_seq'::regclass);
+
+
+--
+-- Name: compras id_compra; Type: DEFAULT; Schema: public; Owner: -
+--
+
+ALTER TABLE ONLY public.compras ALTER COLUMN id_compra SET DEFAULT nextval('public.compras_id_compra_seq'::regclass);
+
+
+--
+-- Name: deta_comandas id_det_comanda; Type: DEFAULT; Schema: public; Owner: -
+--
+
+ALTER TABLE ONLY public.deta_comandas ALTER COLUMN id_det_comanda SET DEFAULT nextval('public.deta_comandas_id_det_comanda_seq'::regclass);
+
+
+--
+-- Name: mate_primas id_mat_prima; Type: DEFAULT; Schema: public; Owner: -
+--
+
+ALTER TABLE ONLY public.mate_primas ALTER COLUMN id_mat_prima SET DEFAULT nextval('public.mate_primas_id_mat_prima_seq'::regclass);
+
+
+--
+-- Name: mesas id_mesa; Type: DEFAULT; Schema: public; Owner: -
+--
+
+ALTER TABLE ONLY public.mesas ALTER COLUMN id_mesa SET DEFAULT nextval('public.mesas_id_mesa_seq'::regclass);
+
+
+--
+-- Name: productos id_producto; Type: DEFAULT; Schema: public; Owner: -
+--
+
+ALTER TABLE ONLY public.productos ALTER COLUMN id_producto SET DEFAULT nextval('public.productos_id_producto_seq'::regclass);
+
+
+--
+-- Name: proveedores id_proveedor; Type: DEFAULT; Schema: public; Owner: -
+--
+
+ALTER TABLE ONLY public.proveedores ALTER COLUMN id_proveedor SET DEFAULT nextval('public.proveedores_id_proveedor_seq'::regclass);
+
+
+--
+-- Name: roles id_rol; Type: DEFAULT; Schema: public; Owner: -
+--
+
+ALTER TABLE ONLY public.roles ALTER COLUMN id_rol SET DEFAULT nextval('public.roles_id_rol_seq'::regclass);
+
+
+--
+-- Name: usuarios id_usuario; Type: DEFAULT; Schema: public; Owner: -
+--
+
+ALTER TABLE ONLY public.usuarios ALTER COLUMN id_usuario SET DEFAULT nextval('public.usuarios_id_usuario_seq'::regclass);
+
+
+--
+-- Name: asistencia_intervalo asistencia_intervalo_id_usuario_desde_hasta_key; Type: CONSTRAINT; Schema: public; Owner: -
+--
+
+ALTER TABLE ONLY public.asistencia_intervalo
+ ADD CONSTRAINT asistencia_intervalo_id_usuario_desde_hasta_key UNIQUE (id_usuario, desde, hasta);
+
+
+--
+-- Name: asistencia_intervalo asistencia_intervalo_pkey; Type: CONSTRAINT; Schema: public; Owner: -
+--
+
+ALTER TABLE ONLY public.asistencia_intervalo
+ ADD CONSTRAINT asistencia_intervalo_pkey PRIMARY KEY (id_intervalo);
+
+
+--
+-- Name: asistencia_raw asistencia_raw_id_usuario_ts_key; Type: CONSTRAINT; Schema: public; Owner: -
+--
+
+ALTER TABLE ONLY public.asistencia_raw
+ ADD CONSTRAINT asistencia_raw_id_usuario_ts_key UNIQUE (id_usuario, ts);
+
+
+--
+-- Name: asistencia_raw asistencia_raw_pkey; Type: CONSTRAINT; Schema: public; Owner: -
+--
+
+ALTER TABLE ONLY public.asistencia_raw
+ ADD CONSTRAINT asistencia_raw_pkey PRIMARY KEY (id_raw);
+
+
+--
+-- Name: categorias categorias_nombre_key; Type: CONSTRAINT; Schema: public; Owner: -
+--
+
+ALTER TABLE ONLY public.categorias
+ ADD CONSTRAINT categorias_nombre_key UNIQUE (nombre);
+
+
+--
+-- Name: categorias categorias_pkey; Type: CONSTRAINT; Schema: public; Owner: -
+--
+
+ALTER TABLE ONLY public.categorias
+ ADD CONSTRAINT categorias_pkey PRIMARY KEY (id_categoria);
+
+
+--
+-- Name: clientes clientes_correo_key; Type: CONSTRAINT; Schema: public; Owner: -
+--
+
+ALTER TABLE ONLY public.clientes
+ ADD CONSTRAINT clientes_correo_key UNIQUE (correo);
+
+
+--
+-- Name: clientes clientes_pkey; Type: CONSTRAINT; Schema: public; Owner: -
+--
+
+ALTER TABLE ONLY public.clientes
+ ADD CONSTRAINT clientes_pkey PRIMARY KEY (id_cliente);
+
+
+--
+-- Name: clientes clientes_telefono_key; Type: CONSTRAINT; Schema: public; Owner: -
+--
+
+ALTER TABLE ONLY public.clientes
+ ADD CONSTRAINT clientes_telefono_key UNIQUE (telefono);
+
+
+--
+-- Name: comandas comandas_pkey; Type: CONSTRAINT; Schema: public; Owner: -
+--
+
+ALTER TABLE ONLY public.comandas
+ ADD CONSTRAINT comandas_pkey PRIMARY KEY (id_comanda);
+
+
+--
+-- Name: compras compras_pkey; Type: CONSTRAINT; Schema: public; Owner: -
+--
+
+ALTER TABLE ONLY public.compras
+ ADD CONSTRAINT compras_pkey PRIMARY KEY (id_compra);
+
+
+--
+-- Name: deta_comandas deta_comandas_pkey; Type: CONSTRAINT; Schema: public; Owner: -
+--
+
+ALTER TABLE ONLY public.deta_comandas
+ ADD CONSTRAINT deta_comandas_pkey PRIMARY KEY (id_det_comanda);
+
+
+--
+-- Name: deta_comp_materias deta_comp_materias_pkey; Type: CONSTRAINT; Schema: public; Owner: -
+--
+
+ALTER TABLE ONLY public.deta_comp_materias
+ ADD CONSTRAINT deta_comp_materias_pkey PRIMARY KEY (id_compra, id_mat_prima);
+
+
+--
+-- Name: deta_comp_producto deta_comp_producto_pkey; Type: CONSTRAINT; Schema: public; Owner: -
+--
+
+ALTER TABLE ONLY public.deta_comp_producto
+ ADD CONSTRAINT deta_comp_producto_pkey PRIMARY KEY (id_compra, id_producto);
+
+
+--
+-- Name: mate_primas mate_primas_nombre_key; Type: CONSTRAINT; Schema: public; Owner: -
+--
+
+ALTER TABLE ONLY public.mate_primas
+ ADD CONSTRAINT mate_primas_nombre_key UNIQUE (nombre);
+
+
+--
+-- Name: mate_primas mate_primas_pkey; Type: CONSTRAINT; Schema: public; Owner: -
+--
+
+ALTER TABLE ONLY public.mate_primas
+ ADD CONSTRAINT mate_primas_pkey PRIMARY KEY (id_mat_prima);
+
+
+--
+-- Name: mesas mesas_apodo_key; Type: CONSTRAINT; Schema: public; Owner: -
+--
+
+ALTER TABLE ONLY public.mesas
+ ADD CONSTRAINT mesas_apodo_key UNIQUE (apodo);
+
+
+--
+-- Name: mesas mesas_numero_key; Type: CONSTRAINT; Schema: public; Owner: -
+--
+
+ALTER TABLE ONLY public.mesas
+ ADD CONSTRAINT mesas_numero_key UNIQUE (numero);
+
+
+--
+-- Name: mesas mesas_pkey; Type: CONSTRAINT; Schema: public; Owner: -
+--
+
+ALTER TABLE ONLY public.mesas
+ ADD CONSTRAINT mesas_pkey PRIMARY KEY (id_mesa);
+
+
+--
+-- Name: productos productos_pkey; Type: CONSTRAINT; Schema: public; Owner: -
+--
+
+ALTER TABLE ONLY public.productos
+ ADD CONSTRAINT productos_pkey PRIMARY KEY (id_producto);
+
+
+--
+-- Name: prov_mate_prima prov_mate_prima_pkey; Type: CONSTRAINT; Schema: public; Owner: -
+--
+
+ALTER TABLE ONLY public.prov_mate_prima
+ ADD CONSTRAINT prov_mate_prima_pkey PRIMARY KEY (id_proveedor, id_mat_prima);
+
+
+--
+-- Name: prov_producto prov_producto_pkey; Type: CONSTRAINT; Schema: public; Owner: -
+--
+
+ALTER TABLE ONLY public.prov_producto
+ ADD CONSTRAINT prov_producto_pkey PRIMARY KEY (id_proveedor, id_producto);
+
+
+--
+-- Name: proveedores proveedores_pkey; Type: CONSTRAINT; Schema: public; Owner: -
+--
+
+ALTER TABLE ONLY public.proveedores
+ ADD CONSTRAINT proveedores_pkey PRIMARY KEY (id_proveedor);
+
+
+--
+-- Name: proveedores proveedores_rut_key; Type: CONSTRAINT; Schema: public; Owner: -
+--
+
+ALTER TABLE ONLY public.proveedores
+ ADD CONSTRAINT proveedores_rut_key UNIQUE (rut);
+
+
+--
+-- Name: receta_producto receta_producto_pkey; Type: CONSTRAINT; Schema: public; Owner: -
+--
+
+ALTER TABLE ONLY public.receta_producto
+ ADD CONSTRAINT receta_producto_pkey PRIMARY KEY (id_producto, id_mat_prima);
+
+
+--
+-- Name: roles roles_nombre_key; Type: CONSTRAINT; Schema: public; Owner: -
+--
+
+ALTER TABLE ONLY public.roles
+ ADD CONSTRAINT roles_nombre_key UNIQUE (nombre);
+
+
+--
+-- Name: roles roles_pkey; Type: CONSTRAINT; Schema: public; Owner: -
+--
+
+ALTER TABLE ONLY public.roles
+ ADD CONSTRAINT roles_pkey PRIMARY KEY (id_rol);
+
+
+--
+-- Name: usua_roles usua_roles_pkey; Type: CONSTRAINT; Schema: public; Owner: -
+--
+
+ALTER TABLE ONLY public.usua_roles
+ ADD CONSTRAINT usua_roles_pkey PRIMARY KEY (id_usuario, id_rol);
+
+
+--
+-- Name: usuarios usuarios_documento_key; Type: CONSTRAINT; Schema: public; Owner: -
+--
+
+ALTER TABLE ONLY public.usuarios
+ ADD CONSTRAINT usuarios_documento_key UNIQUE (documento);
+
+
+--
+-- Name: usuarios usuarios_pkey; Type: CONSTRAINT; Schema: public; Owner: -
+--
+
+ALTER TABLE ONLY public.usuarios
+ ADD CONSTRAINT usuarios_pkey PRIMARY KEY (id_usuario);
+
+
+--
+-- Name: compras_fec_compra_idx; Type: INDEX; Schema: public; Owner: -
+--
+
+CREATE INDEX compras_fec_compra_idx ON public.compras USING btree (fec_compra);
+
+
+--
+-- Name: idx_asist_int_usuario_fecha; Type: INDEX; Schema: public; Owner: -
+--
+
+CREATE INDEX idx_asist_int_usuario_fecha ON public.asistencia_intervalo USING btree (id_usuario, fecha);
+
+
+--
+-- Name: idx_asist_raw_usuario_ts; Type: INDEX; Schema: public; Owner: -
+--
+
+CREATE INDEX idx_asist_raw_usuario_ts ON public.asistencia_raw USING btree (id_usuario, ts);
+
+
+--
+-- Name: idx_detalle_comanda_comanda; Type: INDEX; Schema: public; Owner: -
+--
+
+CREATE INDEX idx_detalle_comanda_comanda ON public.deta_comandas USING btree (id_comanda);
+
+
+--
+-- Name: idx_detalle_comanda_producto; Type: INDEX; Schema: public; Owner: -
+--
+
+CREATE INDEX idx_detalle_comanda_producto ON public.deta_comandas USING btree (id_producto);
+
+
+--
+-- Name: ix_comandas_fec_cierre; Type: INDEX; Schema: public; Owner: -
+--
+
+CREATE INDEX ix_comandas_fec_cierre ON public.comandas USING btree (fec_cierre);
+
+
+--
+-- Name: ix_comandas_id; Type: INDEX; Schema: public; Owner: -
+--
+
+CREATE INDEX ix_comandas_id ON public.comandas USING btree (id_comanda);
+
+
+--
+-- Name: ix_deta_comandas_id_comanda; Type: INDEX; Schema: public; Owner: -
+--
+
+CREATE INDEX ix_deta_comandas_id_comanda ON public.deta_comandas USING btree (id_comanda);
+
+
+--
+-- Name: ix_deta_comandas_id_producto; Type: INDEX; Schema: public; Owner: -
+--
+
+CREATE INDEX ix_deta_comandas_id_producto ON public.deta_comandas USING btree (id_producto);
+
+
+--
+-- Name: asistencia_intervalo asistencia_intervalo_id_usuario_fkey; Type: FK CONSTRAINT; Schema: public; Owner: -
+--
+
+ALTER TABLE ONLY public.asistencia_intervalo
+ ADD CONSTRAINT asistencia_intervalo_id_usuario_fkey FOREIGN KEY (id_usuario) REFERENCES public.usuarios(id_usuario) ON DELETE CASCADE;
+
+
+--
+-- Name: asistencia_raw asistencia_raw_id_usuario_fkey; Type: FK CONSTRAINT; Schema: public; Owner: -
+--
+
+ALTER TABLE ONLY public.asistencia_raw
+ ADD CONSTRAINT asistencia_raw_id_usuario_fkey FOREIGN KEY (id_usuario) REFERENCES public.usuarios(id_usuario) ON DELETE CASCADE;
+
+
+--
+-- Name: comandas comandas_id_mesa_fkey; Type: FK CONSTRAINT; Schema: public; Owner: -
+--
+
+ALTER TABLE ONLY public.comandas
+ ADD CONSTRAINT comandas_id_mesa_fkey FOREIGN KEY (id_mesa) REFERENCES public.mesas(id_mesa);
+
+
+--
+-- Name: comandas comandas_id_usuario_fkey; Type: FK CONSTRAINT; Schema: public; Owner: -
+--
+
+ALTER TABLE ONLY public.comandas
+ ADD CONSTRAINT comandas_id_usuario_fkey FOREIGN KEY (id_usuario) REFERENCES public.usuarios(id_usuario);
+
+
+--
+-- Name: compras compras_id_proveedor_fkey; Type: FK CONSTRAINT; Schema: public; Owner: -
+--
+
+ALTER TABLE ONLY public.compras
+ ADD CONSTRAINT compras_id_proveedor_fkey FOREIGN KEY (id_proveedor) REFERENCES public.proveedores(id_proveedor);
+
+
+--
+-- Name: deta_comandas deta_comandas_id_comanda_fkey; Type: FK CONSTRAINT; Schema: public; Owner: -
+--
+
+ALTER TABLE ONLY public.deta_comandas
+ ADD CONSTRAINT deta_comandas_id_comanda_fkey FOREIGN KEY (id_comanda) REFERENCES public.comandas(id_comanda) ON DELETE CASCADE;
+
+
+--
+-- Name: deta_comandas deta_comandas_id_producto_fkey; Type: FK CONSTRAINT; Schema: public; Owner: -
+--
+
+ALTER TABLE ONLY public.deta_comandas
+ ADD CONSTRAINT deta_comandas_id_producto_fkey FOREIGN KEY (id_producto) REFERENCES public.productos(id_producto);
+
+
+--
+-- Name: deta_comp_materias deta_comp_materias_id_compra_fkey; Type: FK CONSTRAINT; Schema: public; Owner: -
+--
+
+ALTER TABLE ONLY public.deta_comp_materias
+ ADD CONSTRAINT deta_comp_materias_id_compra_fkey FOREIGN KEY (id_compra) REFERENCES public.compras(id_compra) ON DELETE CASCADE;
+
+
+--
+-- Name: deta_comp_materias deta_comp_materias_id_mat_prima_fkey; Type: FK CONSTRAINT; Schema: public; Owner: -
+--
+
+ALTER TABLE ONLY public.deta_comp_materias
+ ADD CONSTRAINT deta_comp_materias_id_mat_prima_fkey FOREIGN KEY (id_mat_prima) REFERENCES public.mate_primas(id_mat_prima);
+
+
+--
+-- Name: deta_comp_producto deta_comp_producto_id_compra_fkey; Type: FK CONSTRAINT; Schema: public; Owner: -
+--
+
+ALTER TABLE ONLY public.deta_comp_producto
+ ADD CONSTRAINT deta_comp_producto_id_compra_fkey FOREIGN KEY (id_compra) REFERENCES public.compras(id_compra) ON DELETE CASCADE;
+
+
+--
+-- Name: deta_comp_producto deta_comp_producto_id_producto_fkey; Type: FK CONSTRAINT; Schema: public; Owner: -
+--
+
+ALTER TABLE ONLY public.deta_comp_producto
+ ADD CONSTRAINT deta_comp_producto_id_producto_fkey FOREIGN KEY (id_producto) REFERENCES public.productos(id_producto);
+
+
+--
+-- Name: productos productos_id_categoria_fkey; Type: FK CONSTRAINT; Schema: public; Owner: -
+--
+
+ALTER TABLE ONLY public.productos
+ ADD CONSTRAINT productos_id_categoria_fkey FOREIGN KEY (id_categoria) REFERENCES public.categorias(id_categoria);
+
+
+--
+-- Name: prov_mate_prima prov_mate_prima_id_mat_prima_fkey; Type: FK CONSTRAINT; Schema: public; Owner: -
+--
+
+ALTER TABLE ONLY public.prov_mate_prima
+ ADD CONSTRAINT prov_mate_prima_id_mat_prima_fkey FOREIGN KEY (id_mat_prima) REFERENCES public.mate_primas(id_mat_prima);
+
+
+--
+-- Name: prov_mate_prima prov_mate_prima_id_proveedor_fkey; Type: FK CONSTRAINT; Schema: public; Owner: -
+--
+
+ALTER TABLE ONLY public.prov_mate_prima
+ ADD CONSTRAINT prov_mate_prima_id_proveedor_fkey FOREIGN KEY (id_proveedor) REFERENCES public.proveedores(id_proveedor) ON DELETE CASCADE;
+
+
+--
+-- Name: prov_producto prov_producto_id_producto_fkey; Type: FK CONSTRAINT; Schema: public; Owner: -
+--
+
+ALTER TABLE ONLY public.prov_producto
+ ADD CONSTRAINT prov_producto_id_producto_fkey FOREIGN KEY (id_producto) REFERENCES public.productos(id_producto);
+
+
+--
+-- Name: prov_producto prov_producto_id_proveedor_fkey; Type: FK CONSTRAINT; Schema: public; Owner: -
+--
+
+ALTER TABLE ONLY public.prov_producto
+ ADD CONSTRAINT prov_producto_id_proveedor_fkey FOREIGN KEY (id_proveedor) REFERENCES public.proveedores(id_proveedor) ON DELETE CASCADE;
+
+
+--
+-- Name: receta_producto receta_producto_id_mat_prima_fkey; Type: FK CONSTRAINT; Schema: public; Owner: -
+--
+
+ALTER TABLE ONLY public.receta_producto
+ ADD CONSTRAINT receta_producto_id_mat_prima_fkey FOREIGN KEY (id_mat_prima) REFERENCES public.mate_primas(id_mat_prima);
+
+
+--
+-- Name: receta_producto receta_producto_id_producto_fkey; Type: FK CONSTRAINT; Schema: public; Owner: -
+--
+
+ALTER TABLE ONLY public.receta_producto
+ ADD CONSTRAINT receta_producto_id_producto_fkey FOREIGN KEY (id_producto) REFERENCES public.productos(id_producto) ON DELETE CASCADE;
+
+
+--
+-- Name: usua_roles usua_roles_autor_fkey; Type: FK CONSTRAINT; Schema: public; Owner: -
+--
+
+ALTER TABLE ONLY public.usua_roles
+ ADD CONSTRAINT usua_roles_autor_fkey FOREIGN KEY (autor) REFERENCES public.usuarios(id_usuario);
+
+
+--
+-- Name: usua_roles usua_roles_id_rol_fkey; Type: FK CONSTRAINT; Schema: public; Owner: -
+--
+
+ALTER TABLE ONLY public.usua_roles
+ ADD CONSTRAINT usua_roles_id_rol_fkey FOREIGN KEY (id_rol) REFERENCES public.roles(id_rol);
+
+
+--
+-- Name: usua_roles usua_roles_id_usuario_fkey; Type: FK CONSTRAINT; Schema: public; Owner: -
+--
+
+ALTER TABLE ONLY public.usua_roles
+ ADD CONSTRAINT usua_roles_id_usuario_fkey FOREIGN KEY (id_usuario) REFERENCES public.usuarios(id_usuario) ON DELETE CASCADE;
+
+
+--
+-- PostgreSQL database dump complete
+--
+
+
+COMMIT;
diff --git a/services/auth/src/index.js b/services/auth/src/index.js
index 777e0f8..d596bb8 100644
--- a/services/auth/src/index.js
+++ b/services/auth/src/index.js
@@ -4,342 +4,35 @@
// - ESM compatible (Node >=18)
// - Sesiones con Redis (compartibles con otros servicios)
// - Vistas EJS (login)
-// - Rutas OIDC: /auth/login, /auth/callback, /auth/logout
// - Registro de usuario: /auth/api/users/register (DB + Authentik)
// ------------------------------------------------------------
import 'dotenv/config';
-import chalk from 'chalk';
-import express from 'express';
-import cors from 'cors';
import path from 'node:path';
-import { access, readFile } from 'node:fs/promises';
-import { constants as fsConstants } from 'node:fs';
+import fs from 'node:fs/promises';
import { fileURLToPath } from 'node:url';
import { Pool } from 'pg';
-import session from 'express-session';
-import { createClient } from 'redis';
-import expressLayouts from 'express-ejs-layouts';
-import { Issuer, generators } from 'openid-client';
+import express from 'express';
+
import crypto from 'node:crypto';
+import fetch from "node-fetch";
+
+import { createRedisSession } from "../shared/middlewares/redisConnect.js";
// -----------------------------------------------------------------------------
-// Importaciones desde archivos
+// Variables globales
// -----------------------------------------------------------------------------
-// Helpers de Authentik (admin API)
-import { akFindUserByEmail, akCreateUser,
- akSetPassword, akResolveGroupId } from './ak.js';
-
+const PORT = process.env.PORT || 4040;
+const ISSUER = process.env.AUTHENTIK_ISSUER?.replace(/\/?$/, "/"); // asegura barra final
+const CLIENT_ID = process.env.OIDC_CLIENT_ID;
+const CLIENT_SECRET = process.env.OIDC_CLIENT_SECRET;
+const REDIRECT_URI = process.env.OIDC_REDIRECT_URI || process.env.AUTH_CALLBACK_URL;
+const APP_BASE_URL = process.env.APP_BASE_URL || "http://localhost:3030";
// -----------------------------------------------------------------------------
-// Helpers
+// Utilidades / Helpers
// -----------------------------------------------------------------------------
-// Normaliza UUID (acepta con/sin guiones) → "hex" sin guiones
-const cleanUuid = (u) => (u ? String(u).toLowerCase().replace(/[^0-9a-f]/g, '') : '');
-
-// Nombre de schema/rol a partir de uuid limpio
-const schemaNameFor = (uuidHex) => `schema_tenant_${uuidHex}`;
-const roleNameFor = (uuidHex) => `tenant_${uuidHex}`;
-
-// Quoter seguro de identificadores SQL (roles, schemas, tablas)
-// Identificador SQL (schema, role, table, …)
-const qi = (ident) => `"${String(ident).replace(/"/g, '""')}"`;
-
-// Literal de texto SQL (valores: contraseñas, strings, …)
-const qs = (val) => `'${String(val).replace(/'/g, "''")}'`;
-
-
-const VALID_IDENT = /^[a-zA-Z_][a-zA-Z0-9_:$-]*$/;
-
-// --- Resolver y cachear el grupo por ID/UUID/NOMBRE una sola vez ---
-let DEFAULT_GROUP_ID = process.env.AUTHENTIK_DEFAULT_GROUP_ID
- ? Number(process.env.AUTHENTIK_DEFAULT_GROUP_ID)
- : null;
-
-if (!DEFAULT_GROUP_ID) {
- (async () => {
- try {
- // Si tenés akResolveGroupIdByName, usalo:
- // DEFAULT_GROUP_ID = await akResolveGroupIdByName(process.env.AUTHENTIK_DEFAULT_GROUP_NAME);
-
- // Con el helper genérico que te dejé en ak.js:
- DEFAULT_GROUP_ID = await akResolveGroupId({
- uuid: process.env.AUTHENTIK_DEFAULT_GROUP_UUID,
- name: process.env.AUTHENTIK_DEFAULT_GROUP_NAME,
- });
- console.log('[AK] DEFAULT_GROUP_ID resuelto:', DEFAULT_GROUP_ID);
- } catch (e) {
- console.warn('[AK] No se pudo resolver DEFAULT_GROUP_ID:', e?.message || e);
- }
- })();
-}
-
-function nukeSession(req, res, redirectTo = '/auth/login', reason = 'reset') {
- try {
- // Destruye la sesión en el store (Redis)
- req.session?.destroy(() => {
- // Limpia la cookie en el navegador
- res.clearCookie(SESSION_COOKIE_NAME, {
- path: '/',
- httpOnly: true,
- sameSite: 'lax',
- secure: process.env.NODE_ENV === 'production',
- });
- // Reinicia el flujo
- return res.redirect(303, `${redirectTo}?reason=${encodeURIComponent(reason)}`);
- });
- } catch {
- // Si algo falla, al menos intentamos redirigir
- return res.redirect(303, `${redirectTo}?reason=${encodeURIComponent(reason)}`);
- }
-}
-
-// Verificar existencia del tenant sin crear (en la DB de tenants)
-async function tenantExists(uuidHex) {
- if (!uuidHex) return false;
- const schema = schemaNameFor(uuidHex);
- const client = await tenantsPool.connect();
- try {
- const q = await client.query(
- 'SELECT 1 FROM information_schema.schemata WHERE schema_name=$1',
- [schema]
- );
- return q.rowCount > 0;
- } finally {
- client.release();
- }
-}
-
-// Intenta obtener el tenant por orden:
-// 1) DB principal (app_user por email)
-// 2) Authentik (attributes.tenant_uuid del usuario)
-// 3) valor provisto en el request (si viene)
-async function resolveExistingTenantUuid({ email, requestedTenantUuid }) {
- const normEmail = String(email).trim().toLowerCase();
-
- // 1) DB principal
- const dbRes = await pool.query(
- 'SELECT tenant_uuid FROM app_user WHERE LOWER(email)=LOWER($1) LIMIT 1',
- [normEmail]
- );
- if (dbRes.rowCount) {
- const fromDb = cleanUuid(dbRes.rows[0].tenant_uuid);
- if (fromDb) return fromDb;
- }
-
- // 2) Authentik
- const akUser = await akFindUserByEmail(normEmail).catch(() => null);
- const fromAk = cleanUuid(akUser?.attributes?.tenant_uuid);
- if (fromAk) return fromAk;
-
- // 3) Pedido del request
- const fromReq = cleanUuid(requestedTenantUuid);
- if (fromReq) return fromReq;
-
- return null; // no hay tenant conocido
-}
-
-// Helper para crear tenant si falta
-async function ensureTenant({ tenant_uuid }) {
- const admin = await tenantsPool.connect();
- try {
- await admin.query('BEGIN');
-
- // uuid y nombres
- const uuid = (tenant_uuid || crypto.randomUUID()).toLowerCase();
- const hex = uuid.replace(/-/g, '');
- if (!/^[a-f0-9]{32}$/.test(hex)) throw new Error('tenant_uuid inválido');
-
- const schema = `schema_tenant_${hex}`;
- const role = `tenant_${hex}`;
- const pwd = crypto.randomBytes(18).toString('base64url');
-
- if (!VALID_IDENT.test(schema) || !VALID_IDENT.test(role)) {
- throw new Error('Identificador de schema/rol inválido');
- }
-
- // 1) Crear ROL si no existe (PASSWORD debe ser LITERAL, no parámetro)
- const r = await admin.query('SELECT 1 FROM pg_roles WHERE rolname=$1', [role]);
- if (!r.rowCount) {
- await admin.query(`CREATE ROLE ${qi(role)} LOGIN PASSWORD ${qs(pwd)}`);
- // Si quisieras rotarla luego:
- // await admin.query(`ALTER ROLE ${qi(role)} PASSWORD ${qs(pwd)}`);
- }
-
- // 2) Crear SCHEMA si no existe y asignar owner
- const s = await admin.query(
- 'SELECT 1 FROM information_schema.schemata WHERE schema_name=$1',
- [schema]
- );
- if (!s.rowCount) {
- await admin.query(`CREATE SCHEMA ${qi(schema)} AUTHORIZATION ${qi(role)}`);
- } else {
- await admin.query(`ALTER SCHEMA ${qi(schema)} OWNER TO ${qi(role)}`);
- }
-
- // 3) Permisos por defecto
- await admin.query(`GRANT USAGE ON SCHEMA ${qi(schema)} TO ${qi(role)}`);
- await admin.query(
- `ALTER DEFAULT PRIVILEGES IN SCHEMA ${qi(schema)}
- GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO ${qi(role)}`
- );
- await admin.query(
- `ALTER DEFAULT PRIVILEGES IN SCHEMA ${qi(schema)}
- GRANT USAGE, SELECT, UPDATE ON SEQUENCES TO ${qi(role)}`
- );
-
- // 4) Aplicar 01_init.sql en la misma transacción
- const initSql = await loadInitSql(); // tu caché/loader actual
- if (initSql && initSql.trim()) {
- await admin.query(`SET LOCAL search_path TO ${qi(schema)}, public`);
- await admin.query(initSql);
- }
-
- await admin.query('COMMIT');
- return { tenant_uuid: uuid, schema, role, role_password: pwd };
- } catch (e) {
- try { await admin.query('ROLLBACK'); } catch {}
- throw e;
- } finally {
- admin.release();
- }
-}
-
-// async function ensureTenant({ tenant_uuid }) {
-// const client = await tenantsPool.connect();
-// try {
-// await client.query('BEGIN');
-
-// // Si no vino UUID, generamos uno
-// let uuid = (tenant_uuid || crypto.randomUUID()).toLowerCase();
-// const uuidNoHyphen = uuid.replace(/-/g, '');
-
-// const schema = `schema_tenant_${uuidNoHyphen}`;
-// const role = `tenant_${uuidNoHyphen}`;
-// const pwd = crypto.randomBytes(18).toString('base64url'); // password del rol
-
-// if (!VALID_IDENT.test(schema) || !VALID_IDENT.test(role)) {
-// throw new Error('Identificador de schema/rol inválido');
-// }
-
-// // 1) Crear ROL si no existe
-// const { rowCount: hasRole } = await client.query(
-// 'SELECT 1 FROM pg_roles WHERE rolname=$1',
-// [role]
-// );
-// if (!hasRole) {
-// // Para el identificador usamos qi(); el password sí va parametrizado
-// await client.query(`CREATE ROLE ${qi(role)} LOGIN PASSWORD ${qs(pwd)}`);
-// }
-
-// // 2) Crear SCHEMA si no existe y asignar owner al rol del tenant
-// const { rowCount: hasSchema } = await client.query(
-// 'SELECT 1 FROM information_schema.schemata WHERE schema_name=$1',
-// [schema]
-// );
-// if (!hasSchema) {
-// await client.query(`CREATE SCHEMA ${qi(schema)} AUTHORIZATION ${qi(role)}`);
-// }
-
-// // 3) Permisos mínimos para el rol del tenant en su schema
-// await client.query(`GRANT USAGE ON SCHEMA ${qi(schema)} TO ${qi(role)}`);
-// await client.query(
-// `ALTER DEFAULT PRIVILEGES IN SCHEMA ${qi(schema)}
-// GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO ${qi(role)}`
-// );
-// await client.query(
-// `ALTER DEFAULT PRIVILEGES IN SCHEMA ${qi(schema)}
-// GRANT USAGE, SELECT, UPDATE ON SEQUENCES TO ${qi(role)}`
-// );
-
-// await client.query('COMMIT');
-
-// // 4) Inicialización del esquema con 01_init.sql (solo si está vacío)
-// try {
-// await initializeTenantSchemaIfEmpty(client, schema);
-// } catch (e) {
-// // Podés decidir si esto es fatal o "best-effort".
-// // Si querés cortar el alta cuando falla la init, usa: throw e;
-// console.warn(`[TENANT INIT] Falló inicialización de ${schema}:`, e?.message || e);
-// }
-
-// return { tenant_uuid: uuid, schema, role, role_password: pwd };
-// } catch (e) {
-// try { await client.query('ROLLBACK'); } catch {}
-// throw e;
-// } finally {
-// client.release();
-// }
-// }
-
-// Carga el 01_init.sql del disco, elimina BEGIN/COMMIT y sustituye el schema.
-
-let _cachedInitSql = null;
-async function loadInitSql() {
- if (_cachedInitSql !== null) return _cachedInitSql;
- const candidates = [
- process.env.TENANT_INIT_SQL, // opcional
- path.resolve(__dirname, 'db', 'initTenant.sql'),
- path.resolve(__dirname, '..', 'src', 'db', 'initTenant.sql'),
- ].filter(Boolean);
- for (const p of candidates) {
- try {
- await access(p, fsConstants.R_OK);
- const txt = await readFile(p, 'utf8');
- _cachedInitSql = String(txt || '');
- console.log(`[TENANT INIT] initTenant.sql: ${p} (${_cachedInitSql.length} bytes)`);
- return _cachedInitSql;
- } catch {}
- }
- console.warn('[TENANT INIT] initTenant.sql no encontrado (se omitirá).');
- _cachedInitSql = '';
- return _cachedInitSql;
-}
-
-async function isSchemaEmpty(client, schema) {
- const { rows } = await client.query(
- `SELECT COUNT(*)::int AS c
- FROM information_schema.tables
- WHERE table_schema = $1`,
- [schema]
- );
- return rows[0].c === 0;
-}
-
-/** Ejecuta 01_init.sql para un tenant (solo si el esquema está vacío). */
-async function initializeTenantSchemaIfEmpty(schema) {
- const sql = await loadInitSql();
- if (!sql || !sql.trim()) {
- console.warn(`[TENANT INIT] Esquema ${schema}: 01_init.sql vacío/no disponible. Salteando.`);
- return;
- }
-
- const client = await tenantsPool.connect();
- try {
- // No usamos LOCAL: queremos que el search_path persista en esta conexión mientras dura el script
- await client.query('BEGIN');
- await client.query(`SET search_path TO ${qi(schema)}, public`);
-
- const empty = await isSchemaEmpty(client, schema);
- if (!empty) {
- await client.query('ROLLBACK');
- console.log(`[TENANT INIT] Esquema ${schema}: ya tiene tablas. No se aplica 01_init.sql.`);
- return;
- }
-
- await client.query(sql); // acepta múltiples sentencias separadas por ';'
- await client.query('COMMIT');
- console.log(`[TENANT INIT] Esquema ${schema}: 01_init.sql aplicado.`);
- } catch (e) {
- try { await client.query('ROLLBACK'); } catch {}
- console.error(`[TENANT INIT] Error aplicando 01_init.sql sobre ${schema}:`, e.message);
- throw e;
- } finally {
- client.release();
- }
-}
-
// -----------------------------------------------------------------------------
// Utilidades
@@ -350,29 +43,18 @@ const __dirname = path.dirname(__filename);
function requiredEnv(keys) {
const missing = keys.filter((k) => !process.env[k]);
if (missing.length) {
- console.warn(chalk.yellow(`⚠ Falta configurar variables de entorno: ${missing.join(', ')}`));
+ console.warn(`Falta configurar variables de entorno: ${missing.join(', ')}`);
}
}
-function onFatal(err, msg = 'Error fatal') {
- console.error(chalk.red(`\n${msg}:`));
- console.error(err);
- process.exit(1);
-}
-function genTempPassword(len = 12) {
- const base = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789!@$%';
- let out = '';
- for (let i = 0; i < len; i++) out += base[Math.floor(Math.random() * base.length)];
- return out;
-}
// -----------------------------------------------------------------------------
// Configuración Express
// -----------------------------------------------------------------------------
const app = express();
app.set('trust proxy', Number(process.env.TRUST_PROXY_HOPS || 2));
-app.use(cors({ origin: true, credentials: true }));
+app.disable("x-powered-by");
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
@@ -380,77 +62,81 @@ app.use(express.urlencoded({ extended: true }));
app.set('views', path.join(__dirname, 'views'));
app.set('view engine', 'ejs');
-
// Archivos estáticos opcionales (ajusta si tu estructura difiere)
app.use(express.static(path.join(__dirname, 'public')));
app.use('/pages', express.static(path.join(__dirname, 'pages')));
-// -----------------------------------------------------------------------------
-// Sesión (Redis) — misma cookie que APP
-// -----------------------------------------------------------------------------
-requiredEnv(['SESSION_SECRET', 'REDIS_URL']);
-const SESSION_COOKIE_NAME = process.env.SESSION_NAME || "sc.sid";
-const SESSION_SECRET = process.env.SESSION_SECRET || "pon-una-clave-larga-y-unica";
-const REDIS_URL = process.env.REDIS_URL || "redis://authentik-redis:6379";
-// 1) Redis client
-const redis = createClient({ url: REDIS_URL /*, legacyMode: true */ });
-redis.on("error", (err) => console.error("[Redis] Client Error:", err));
-await redis.connect();
-console.log("[Redis] connected");
+// -----------------------------------------------------------------------------
+// Sesión (Redis)
+// -----------------------------------------------------------------------------
+// --- Sesión/Redis ---
+const { sessionMw, trustProxy } = await createRedisSession();
+if (trustProxy) app.set("trust proxy", 1);
+app.use(sessionMw);
+app.use(express.json());
-// 2) Resolver RedisStore (soporta:
-// - v5: factory CJS -> connectRedis(session)
-// - v6/v7: export { RedisStore } ó export default class RedisStore)
-async function resolveRedisStore(session) {
- const mod = await import("connect-redis"); // ESM/CJS agnóstico
- // named export (v6/v7)
- if (typeof mod.RedisStore === "function") return mod.RedisStore;
- // default export (class ó factory)
- if (typeof mod.default === "function") {
- // ¿es clase neweable?
- if (mod.default.prototype && (mod.default.prototype.get || mod.default.prototype.set)) {
- return mod.default; // class RedisStore
- }
- // si no, asumimos factory antigua
- const Store = mod.default(session); // connectRedis(session)
- if (typeof Store === "function") return Store; // class devuelta por factory
- }
- // algunos builds CJS exponen la factory bajo mod (poco común)
- if (typeof mod === "function") {
- const Store = mod(session);
- if (typeof Store === "function") return Store;
- }
- throw new Error("connect-redis: no se pudo resolver RedisStore (API desconocida).");
+
+// --- Utiles OIDC ---
+function base64url(buf) {
+return Buffer.from(buf).toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}
-const RedisStore = await resolveRedisStore(session);
-// 3) Session middleware
-app.use(session({
- name: SESSION_COOKIE_NAME,
- secret: SESSION_SECRET,
- resave: false,
- saveUninitialized: false,
- store: new RedisStore({
- client: redis,
- prefix: "sc:", // opcional
- }),
- proxy: true,
- cookie: {
- secure: "auto",
- httpOnly: true,
- sameSite: "lax",
- path: "/", // ¡crítico! visible en / y /auth/*
- },
-}));
+function genPKCE() {
+const verifier = base64url(crypto.randomBytes(32));
+const challenge = base64url(crypto.createHash("sha256").update(verifier).digest());
+return { verifier, challenge };
+}
-// Exponer usuario a las vistas (no tocar req.session)
+
+function authorizeUrl({ state, challenge }) {
+const u = new URL(`${ISSUER}authorize/`);
+u.searchParams.set("client_id", CLIENT_ID);
+u.searchParams.set("redirect_uri", REDIRECT_URI);
+u.searchParams.set("response_type", "code");
+u.searchParams.set("scope", "openid email profile");
+u.searchParams.set("state", state);
+u.searchParams.set("code_challenge", challenge);
+u.searchParams.set("code_challenge_method", "S256");
+return u.toString();
+}
+
+
+async function exchangeCodeForTokens({ code, verifier }) {
+const tokenUrl = `${ISSUER}token/`;
+const body = new URLSearchParams({
+grant_type: "authorization_code",
+code,
+redirect_uri: REDIRECT_URI,
+client_id: CLIENT_ID,
+code_verifier: verifier,
+});
+// auth básica si el proveedor la requiere (Authentik soporta ambos modos)
+const basic = Buffer.from(`${CLIENT_ID}:${CLIENT_SECRET}`).toString("base64");
+const res = await fetch(tokenUrl, {
+method: "POST",
+headers: {
+"content-type": "application/x-www-form-urlencoded",
+"authorization": `Basic ${basic}`,
+},
+body,
+});
+if (!res.ok) throw new Error(`Token endpoint ${res.status}`);
+return res.json();
+}
+
+// ----------------------------------------------------------
+// Middleware para datos globales
+// ----------------------------------------------------------
app.use((req, res, next) => {
- res.locals.user = req.session?.user || null;
+ res.locals.currentPath = req.path;
+ res.locals.pageTitle = "SuiteCoffee";
+ res.locals.pageId = "";
next();
});
+
// -----------------------------------------------------------------------------
// PostgreSQL — DB tenants (usuarios de suitecoffee)
// -----------------------------------------------------------------------------
@@ -467,7 +153,7 @@ const tenantsPool = new Pool({
// PostgreSQL — DB principal (metadatos de negocio)
// -----------------------------------------------------------------------------
requiredEnv(['DB_HOST', 'DB_USER', 'DB_PASS', 'DB_NAME']);
-const pool = new Pool({
+const mainPool = new Pool({
host: process.env.DB_HOST || 'dev-db',
port: Number(process.env.DB_PORT || 5432),
user: process.env.DB_USER || 'dev-user-suitecoffee',
@@ -477,565 +163,212 @@ const pool = new Pool({
idleTimeoutMillis: 30_000,
});
+// ----------------------------------------------------------
+// Verificación de conexión
+// ----------------------------------------------------------
+
async function verificarConexion() {
try {
- const client = await pool.connect();
- const { rows } = await client.query('SELECT NOW() AS ahora');
- console.log(`\nConexión con ${chalk.green(process.env.DB_NAME)} OK. Hora DB:`, rows[0].ahora);
- client.release();
+ console.log(`[AUTH] Comprobando accesibilidad a la db ${process.env.DB_NAME} del host ${process.env.DB_HOST} ...`);
+ var client = await mainPool.connect();
+ var { rows } = await client.query('SELECT NOW() AS ahora');
+ console.log(`\n[AUTH] Conexión con ${process.env.DB_NAME} OK. Hora DB:`, rows[0].ahora);
} catch (error) {
- console.error('Error al conectar con la base de datos al iniciar:', error.message);
- console.error('Revisar DB_HOST/USER/PASS/NAME, accesos de red y firewall.');
+ console.error('[AUTH] Error al conectar con la base de datos al iniciar:', error.message);
+ console.error('[AUTH] Revisar DB_HOST/USER/PASS/NAME, accesos de red y firewall.');
+ } finally {
+ client.release();
}
}
-// -----------------------------------------------------------------------------
-// OIDC (Authentik) — discovery + cliente
-// -----------------------------------------------------------------------------
-requiredEnv(['OIDC_ISSUER', 'OIDC_CLIENT_ID', 'OIDC_CLIENT_SECRET', 'OIDC_REDIRECT_URI']);
-
-
-async function discoverOIDCWithRetry(issuerUrl, { retries = 30, delayMs = 2000 } = {}) {
- let lastErr;
- for (let i = 1; i <= retries; i++) {
- try {
- const issuer = await Issuer.discover(issuerUrl);
- console.log(`[OIDC] issuer OK en intento ${i}:`, issuer.metadata.issuer);
- return issuer;
- } catch (err) {
- lastErr = err;
- console.warn(`[OIDC] intento ${i}/${retries} falló: ${err.code || err.message}`);
- await sleep(delayMs);
- }
- }
- // No abortamos el proceso; dejamos el servidor vivo y seguimos reintentando en background
- throw lastErr;
-}
-
-let oidcClient;
-(async () => {
- try {
- const issuer = await discoverOIDCWithRetry(process.env.OIDC_ISSUER, { retries: 60, delayMs: 2000 });
- oidcClient = new issuer.Client({
- client_id: process.env.OIDC_CLIENT_ID,
- client_secret: process.env.OIDC_CLIENT_SECRET,
- redirect_uris: [process.env.OIDC_REDIRECT_URI],
- response_types: ['code'],
- });
- } catch (e) {
- console.error('⚠ No se pudo inicializar OIDC aún. Seguirá reintentando cada 10s en background.');
- // reintento en background cada 10s sin tumbar el proceso
- (async function loop() {
- try {
- const issuer = await Issuer.discover(process.env.OIDC_ISSUER);
- oidcClient = new issuer.Client({
- client_id: process.env.OIDC_CLIENT_ID,
- client_secret: process.env.OIDC_CLIENT_SECRET,
- redirect_uris: [process.env.OIDC_REDIRECT_URI],
- response_types: ['code'],
- });
- console.log('[OIDC] inicializado correctamente en reintento tardío');
- } catch {
- setTimeout(loop, 10000);
- }
- })();
- }
-})();
// -----------------------------------------------------------------------------
// Vistas
// -----------------------------------------------------------------------------
-// Página de login
-app.get("/auth/login", (_req, res) => {
- return res.render("login", { pageTitle: "Iniciar sesión" });
-});
-app.post("/auth/login", async (req, res, next) => {
+// =============================================
+// Registro de usuario (DB principal)
+// =============================================
+
+requiredEnv(['TENANT_INIT_SQL']);
+async function loadInitSqlFromEnv() {
+ const v = process.env.TENANT_INIT_SQL?.trim();
+ if (!v) return '';
try {
- const email = String(req.body.email || "").trim().toLowerCase();
- const password = String(req.body.password || "");
- const remember = req.body.remember === "on" || req.body.remember === true;
-
- if (!email || !password) {
- return res.status(400).render("login", { pageTitle: "Iniciar sesión", error: "Completa email y contraseña." });
- }
-
- // Tabla/columnas por defecto; ajustables por env si tu esquema difiere
- const USERS_TABLE = process.env.TENANTS_USERS_TABLE || "users";
- const COL_ID = process.env.TENANTS_COL_ID || "id";
- const COL_EMAIL = process.env.TENANTS_COL_EMAIL || "email";
- const COL_HASH = process.env.TENANTS_COL_HASH || "password_hash";
- const COL_ROLE = process.env.TENANTS_COL_ROLE || "role";
- const COL_TENANT = process.env.TENANTS_COL_TENANT || "tenant_id";
-
- const { rows } = await tenantsPool.query(
- `SELECT ${COL_ID} AS id, ${COL_EMAIL} AS email, ${COL_HASH} AS password_hash,
- ${COL_ROLE} AS role, ${COL_TENANT} AS tenant_id
- FROM ${USERS_TABLE}
- WHERE ${COL_EMAIL} = $1
- LIMIT 1`,
- [email]
- );
-
- if (rows.length === 0) {
- return res.status(401).render("login", { pageTitle: "Iniciar sesión", error: "Credenciales inválidas." });
- }
-
- const user = rows[0];
- const ok = await bcrypt.compare(password, user.password_hash || "");
- if (!ok) {
- return res.status(401).render("login", { pageTitle: "Iniciar sesión", error: "Credenciales inválidas." });
- }
-
- // (Opcional) registro de acceso en DB principal
- try {
- await pool.query(
- "INSERT INTO auth_audit_log(email, tenant_id, action, at) VALUES ($1, $2, $3, NOW())",
- [user.email, user.tenant_id, "login_success"]
- );
- } catch { /* noop si no existe la tabla */ }
-
- // Sesión compartida
- req.session.regenerate((err) => {
- if (err) return next(err);
-
- req.session.user = {
- id: user.id,
- email: user.email,
- role: user.role,
- tenant_id: user.tenant_id,
- loggedAt: Date.now(),
- };
-
- if (remember) {
- req.session.cookie.maxAge = 30 * 24 * 60 * 60 * 1000;
- } else {
- req.session.cookie.expires = false;
- }
-
- req.session.save((err2) => {
- if (err2) return next(err2);
- return res.redirect(303, "/"); // "/" → app decide /dashboard o /auth/login
- });
- });
- } catch (e) {
- next(e);
- }
-});
-
-
-// -----------------------------------------------------------------------------
-// Rutas OIDC
-// -----------------------------------------------------------------------------
-app.get('/auth/login', (req, res, next) => {
- try {
-
- if (req.session?.oidc) {
- return nukeSession(req, res, '/auth/login', 'stale_oidc');
- }
-
- const code_verifier = generators.codeVerifier();
- const code_challenge = generators.codeChallenge(code_verifier);
-
- // Podés usar generators.state() y generators.nonce(); ambas son válidas
- const state = generators.state(); // crypto.randomBytes(24).toString('base64url') también sirve
- const nonce = generators.nonce();
-
-
-
- // Guardamos TODO dentro de un objeto para evitar claves sueltas
- req.session.oidc = { code_verifier, state, nonce };
-
- // Guardar sesión ANTES de redirigir
- req.session.save((err) => {
- if (err) return next(err);
-
- const url = oidcClient.authorizationUrl({
- scope: 'openid profile email offline_access',
- code_challenge,
- code_challenge_method: 'S256',
- state,
- nonce,
- });
-
- return res.redirect(url); // importantísimo: return
- });
- } catch (e) {
- return next(e);
- }
-});
-
-app.get('/auth/callback', async (req, res, next) => {
- try {
- // Log útil para debug
- console.log('[OIDC] cb sid=%s query=%j', req.sessionID, req.query);
-
- // Recuperar lo que guardamos en /auth/login
- const { oidc } = req.session || {};
- const code_verifier = oidc?.code_verifier;
- const stateStored = oidc?.state;
- const nonceStored = oidc?.nonce;
-
- // Si por algún motivo no está la info (sesión perdida/expirada), reiniciamos el flujo
- if (!code_verifier || !stateStored) {
- console.warn('[OIDC] Falta code_verifier/state en sesión; reiniciando login');
- return res.redirect(303, '/auth/login');
- }
-
- const params = oidcClient.callbackParams(req);
-
- // openid-client validará que el "state" recibido coincida con el que pasamos aquí
- let tokenSet;
- try {
- tokenSet = await oidcClient.callback(
- process.env.OIDC_REDIRECT_URI,
- params,
- { code_verifier, state: stateStored, nonce: nonceStored }
- );
- } catch (err) {
- console.warn('[OIDC] callback error, resetting session:', err?.message || err);
- return nukeSession(req, res, '/auth/login', 'callback_error');
- }
-
- // Limpiar datos OIDC de la sesión
- delete req.session.oidc;
-
- const claims = tokenSet.claims();
- const email = (claims.email || '').toLowerCase();
-
- // tenant desde claim, Authentik o fallback a tu DB
- let tenantHex = cleanUuid(claims.tenant_uuid);
- if (!tenantHex) {
- const akUser = await akFindUserByEmail(email).catch(() => null);
- tenantHex = cleanUuid(akUser?.attributes?.tenant_uuid);
-
- if (!tenantHex) {
- const q = await pool.query(
- 'SELECT tenant_uuid FROM app_user WHERE LOWER(email)=LOWER($1) LIMIT 1',
- [email]
- );
- tenantHex = cleanUuid(q.rows?.[0]?.tenant_uuid);
- }
- }
-
- // Regenerar sesión para evitar fijación y guardar el usuario
- req.session.regenerate((err) => {
- if (err) {
- if (!res.headersSent) res.status(500).send('No se pudo crear la sesión.');
- return;
- }
- req.session.user = {
- sub: claims.sub,
- email,
- tenant_uuid: tenantHex || null,
- };
- req.session.save((e2) => {
- if (e2) {
- if (!res.headersSent) res.status(500).send('No se pudo guardar la sesión.');
- return;
- }
- if (!res.headersSent) return res.redirect('/');
- });
- });
-
- return res.redirect('/');
-
- } catch (e) {
- console.error('[OIDC] callback error:', e);
- if (!res.headersSent) return next(e);
- }
-});
-
-
-app.post('/auth/logout', (req, res) => {
- req.session.destroy(() => {
- res.clearCookie(SESSION_COOKIE_NAME);
- res.status(204).end();
- });
-});
-
-app.get('/auth/me', (req, res) => {
- if (!req.session?.user) return res.status(401).json({ error: 'no-auth' });
- res.json({ user: req.session.user });
-});
-
-// -----------------------------------------------------------------------------
-// Registro de usuario (DB principal + Authentik)
-// -----------------------------------------------------------------------------
-
-// Helpers defensivos (si ya los tenés, podés omitir estas definiciones)
-const extractAkUserUuid = (u) =>
- (u && (u.uuid || u?.user?.uuid || (Array.isArray(u.results) && u.results[0]?.uuid))) || null;
-const extractAkUserPk = (u) =>
- (u && (u.pk ?? u?.user?.pk ?? null));
-
-async function akDeleteUser(pkOrUuid) {
- try {
- if (!pkOrUuid || !globalThis.fetch) return;
- const base = process.env.AUTHENTIK_BASE_URL?.replace(/\/+$/, '') || '';
- const id = String(pkOrUuid);
- const url = `${base}/api/v3/core/users/${encodeURIComponent(id)}/`;
- await fetch(url, {
- method: 'DELETE',
- headers: { 'Authorization': `Bearer ${process.env.AUTHENTIK_TOKEN}` }
- });
- } catch (e) {
- console.warn('[AK] No se pudo borrar usuario (compensación):', e?.message || e);
+ // ¿Es una ruta existente?
+ const p = path.isAbsolute(v) ? v : path.resolve(__dirname, v);
+ const txt = await fs.readFile(p, 'utf8');
+ console.log(`[TENANT INIT] Cargado desde archivo: ${p} (${txt.length} bytes)`);
+ return String(txt || '');
+ } catch {
+ // Tratar como literal
+ console.log(`[TENANT INIT] Usando SQL literal desde TENANT_INIT_SQL (${v.length} chars).`);
+ return v;
}
}
-// ==============================
-// POST /auth/api/users/register
-// ==============================
-app.post('/auth/api/users/register', async (req, res, next) => {
- // 0) input
- const {
- email,
- display_name,
- role,
- tenant_uuid: requestedTenantUuid, // opcional
- } = req.body || {};
+// Reemplaza placeholders simples en la plantilla de SQL (opcional)
+function renderInitSqlTemplate(sql, { schema, owner }) {
+ return sql
+ .replaceAll(':TENANT_SCHEMA', `"${schema}"`)
+ .replaceAll(':OWNER', `"${owner}"`);
+}
+// Genera ids sencillos
+function newTenantIds() {
+ return {
+ tenant_uuid: crypto.randomUUID(),
+ tenant_role: null, // lo decidirás luego (owner, barman, staff)
+ };
+}
- const normEmail = String(email || '').trim().toLowerCase();
- if (!normEmail) return res.status(400).json({ error: 'email requerido' });
+async function createTenantUserAndSchema(tenClient, { tenant_uuid, password }) {
+ const roleName = `tenant_${tenant_uuid.replace(/-/g, '')}`;
+ const schemaName = `t_${tenant_uuid.replace(/-/g, '')}`;
+ const escapedPassword = `'${String(password).replace(/'/g, "''")}'`;
- // 1) Resolver tenant uuid (existente o nuevo)
- let tenantHex = null;
- try {
- if (typeof resolveExistingTenantUuid === 'function') {
- tenantHex = await resolveExistingTenantUuid({
- email: normEmail,
- requestedTenantUuid,
- });
- } else {
- tenantHex = cleanUuid(requestedTenantUuid);
- }
+ // 1) crear role y schema (misma conexión que ya viene en BEGIN desde la ruta)
+ await tenClient.query(`CREATE ROLE "${roleName}" LOGIN PASSWORD ${escapedPassword}`);
+ await tenClient.query(`CREATE SCHEMA "${schemaName}" AUTHORIZATION "${roleName}"`);
+ await tenClient.query(`GRANT USAGE ON SCHEMA "${schemaName}" TO "${roleName}"`);
+ await tenClient.query(`ALTER ROLE "${roleName}" INHERIT`);
+ // (idempotente)
+ await tenClient.query(`CREATE SCHEMA IF NOT EXISTS "${schemaName}"`);
- // Crear/asegurar tenant en una transacción (ahí adentro corre 01_init.sql)
- if (tenantHex) {
- // si no existe, ensureTenant lo crea
- await ensureTenant({ tenant_uuid: tenantHex });
- } else {
- const created = await ensureTenant({ tenant_uuid: null }); // genera uuid
- tenantHex = cleanUuid(created?.tenant_uuid);
- }
- } catch (e) {
- return next(new Error(`No se pudo preparar el tenant: ${e.message}`));
+ // 2) cargar y sanear la plantilla
+ let sql = await loadInitSqlFromEnv();
+ if (!sql?.trim()) {
+ console.log('[TENANT INIT] No hay SQL de plantilla; se omite.');
+ return { roleName, schemaName };
}
- // 2) Transacción DB principal + Authentik con compensación
- const client = await pool.connect();
- let createdAkUser = null; // para compensación
- try {
- await client.query('BEGIN');
+ // 👉 quita metacomandos psql '\' (por si alguno quedó) y cualquier cambio de search_path dentro del dump
+ sql = sql
+ .split(/\r?\n/)
+ .filter(line => !line.trim().startsWith('\\')) // \restrict, \unrestrict, \i, etc.
+ .filter(line => !/^SET\s+search_path\b/i.test(line)) // SET search_path = ...
+ .filter(line => !/set_config\(\s*'search_path'/i.test(line)) // SELECT set_config('search_path',...
+ .join('\n');
- // Duplicados (ajusta a tu constraint real: por email o por (email,tenant))
- const dup = await client.query(
- 'SELECT id FROM app_user WHERE LOWER(email)=LOWER($1)',
- [normEmail]
- );
- if (dup.rowCount) {
- await client.query('ROLLBACK');
- return res.status(409).json({
- error: 'user-exists',
- message: 'Ya existe un usuario con este email.',
- next: '/auth/login',
- });
- }
+ // si usás placeholders, renderealos acá (opcional)
+ // sql = renderInitSqlTemplate(sql, { schema: schemaName, owner: roleName });
- // Authentik: buscar o crear
- let akUser = await akFindUserByEmail(normEmail).catch(() => null);
- if (!akUser) {
- akUser = await akCreateUser({
- email: normEmail,
- displayName: display_name || null,
- tenantUuid: tenantHex, // attributes.tenant_uuid
- addToGroupId: DEFAULT_GROUP_ID || null,
- isActive: true,
- });
- createdAkUser = akUser; // marcar que lo creamos nosotros
- }
+ // 3) forzá el search_path SOLO dentro de esta transacción
+ await tenClient.query(`SET LOCAL search_path TO "${schemaName}", public`);
- // Asegurar uuid/pk
- let akUserUuid = extractAkUserUuid(akUser);
- let akUserPk = extractAkUserPk(akUser);
- if (!akUserUuid || akUserPk == null) {
- const ref = await akFindUserByEmail(normEmail).catch(() => null);
- akUserUuid = akUserUuid || extractAkUserUuid(ref);
- akUserPk = akUserPk ?? extractAkUserPk(ref);
- }
- if (!akUserUuid) throw new Error('No se pudo obtener uuid del usuario en Authentik');
+ // 4) ejecutar el dump (una sola vez, no lo partas por ';' para no romper $$...$$)
+ await tenClient.query(sql);
- // Insert en tu DB principal
- const finalRole = role || 'owner';
- await client.query(
- `INSERT INTO app_user (email, display_name, tenant_uuid, ak_user_uuid, role)
- VALUES ($1,$2,$3,$4,$5)`,
- [normEmail, display_name || null, tenantHex, akUserUuid, finalRole]
- );
+ console.log(`[TENANT INIT] OK usuario="${roleName}" schema="${schemaName}"`);
+ return { roleName, schemaName };
+}
- await client.query('COMMIT');
+//=============================================
+// ---------- Authentik (API & OIDC) ----------
+//=============================================
- // 3) Marcar sesión para set-password (si usás este flujo)
- req.session.pendingPassword = {
- email: normEmail,
- ak_user_uuid: akUserUuid,
- ak_user_pk: akUserPk,
- exp: Date.now() + 10 * 60 * 1000,
- };
- return req.session.save(() => {
- const accept = String(req.headers['accept'] || '');
- if (accept.includes('text/html')) {
- return res.redirect(303, '/set-password');
- }
- return res.status(201).json({
- message: 'Usuario registrado',
- email: normEmail,
- tenant_uuid: tenantHex,
- role: finalRole,
- authentik_user_uuid: akUserUuid,
- next: '/set-password',
- });
- });
- } catch (err) {
- // Rollbacks + Compensaciones
- try { await client.query('ROLLBACK'); } catch {}
- try {
- // Si creamos el usuario en Authentik y luego falló algo → borrar
- if (createdAkUser) {
- const id = extractAkUserPk(createdAkUser) ?? extractAkUserUuid(createdAkUser);
- if (id) await akDeleteUser(id);
- }
- } catch {}
- return next(err);
- } finally {
- client.release();
- }
+// ===========================
+// GET /auth/users/register
+// ===========================
+
+// ===========================
+// POST /auth/login
+// ===========================
+app.get("/auth/login", (req, res) => {
+const { verifier, challenge } = genPKCE();
+const state = base64url(crypto.randomBytes(24));
+req.session.pkce_verifier = verifier;
+req.session.oidc_state = state;
+const url = authorizeUrl({ state, challenge });
+res.redirect(302, url);
+});
+// ===========================
+// GET /auth/callback
+// ===========================
+app.get("/auth/callback", async (req, res) => {
+try {
+const { code, state } = req.query;
+if (!code || !state) return res.status(400).send("Faltan parámetros");
+if (state !== req.session.oidc_state) return res.status(400).send("State inválido");
+
+
+const verifier = req.session.pkce_verifier;
+if (!verifier) return res.status(400).send("PKCE verifier faltante");
+
+
+const tokens = await exchangeCodeForTokens({ code, verifier });
+// Guarda en sesión (ID Token, Access Token, Refresh Token si viene)
+req.session.tokens = {
+id_token: tokens.id_token,
+access_token: tokens.access_token,
+refresh_token: tokens.refresh_token,
+token_type: tokens.token_type,
+expires_in: tokens.expires_in,
+received_at: Date.now(),
+};
+// Limpia PKCE/state
+delete req.session.pkce_verifier;
+delete req.session.oidc_state;
+
+
+// Redirige al home de App
+res.redirect(303, `${APP_BASE_URL}/`);
+} catch (e) {
+console.error("/auth/callback error", e);
+res.status(500).send("Error en callback");
+}
});
-
-// Definir contraseña
-app.post('/auth/password/set', async (req, res, next) => {
- try {
- const pp = req.session?.pendingPassword;
- if (!pp || (pp.exp && Date.now() > pp.exp)) {
- // token de sesión vencido o ausente
- if (!res.headersSent) return res.redirect(303, '/set-password');
- return;
- }
-
- const { password, password2 } = req.body || {};
- if (!password || password.length < 8 || password !== password2) {
- return res.status(400).send('Contraseña inválida o no coincide.');
- }
-
- // Buscar el usuario en Authentik y setear la clave
- const u = await akFindUserByEmail(pp.email);
- if (!u) return res.status(404).send('No se encontró el usuario en Authentik.');
-
- await akSetPassword(u.pk, password, true); // true = force change handled; ajusta a tu helper
-
- // Limpiar marcador y continuar al SSO
- delete req.session.pendingPassword;
- return req.session.save(() => res.redirect(303, '/auth/login'));
- } catch (e) {
- next(e);
- }
+// ===========================
+// POST /auth/logout
+// ===========================
+app.get("/auth/logout", (req, res) => {
+req.session.destroy(() => {
+res.clearCookie(process.env.SESSION_COOKIE_NAME || "sc.sid");
+res.redirect(303, APP_BASE_URL || "/");
+});
});
-// Espera: { email, display_name?, tenant_uuid }
-// app.post('/auth/auth/api/users/register', async (req, res, next) => {
-
-// const { email, display_name, tenant_uuid: rawTenant, role } = req.body || {};
-// if (!email) return res.status(400).json({ error: 'email es obligatorio' });
-// // Si no vino tenant: lo creamos
-// const { tenant_uuid, schema, role: dbRole } = await ensureTenant({ tenant_uuid: rawTenant });
-
-// const client = await pool.connect();
-// try {
-// await client.query('BEGIN');
-
-// // ¿ya existe en tu DB?
-// const { rows: dup } = await client.query(
-// 'SELECT id FROM app_user WHERE email=$1 AND tenant_uuid=$2',
-// [email.toLowerCase(), tenant_uuid.replace(/-/g, '')]
-// );
-// if (dup.length) {
-// await client.query('ROLLBACK');
-// return res.status(409).json({ error: 'El usuario ya existe en SuiteCoffee' });
-// }
-
-// // Authentik: crear si no existe
-// let akUser = await akFindUserByEmail(email);
-// if (!akUser) {
-// akUser = await akCreateUser({
-// email,
-// displayName: display_name,
-// tenantUuid: tenant_uuid, // se normaliza dentro de ak.js
-// addToGroupId: DEFAULT_GROUP_ID || null,
-// isActive: true,
-// });
-// // Si querés forzar clave inicial (opcional; depende de tus políticas):
-// // await akSetPassword(akUser.pk, 'ClaveTemporal123!', true);
-// }
-
-// const _role = role || 'owner';
-// await client.query(
-// `INSERT INTO app_user (email, display_name, tenant_uuid, ak_user_uuid, role)
-// VALUES ($1,$2,$3,$4,$5)`,
-// [email.toLowerCase(), display_name || null, tenant_uuid.replace(/-/g, ''), akUser.uuid, _role]
-// );
-
-// await client.query('COMMIT');
-// return res.status(201).json({
-// message: 'Usuario registrado',
-// email,
-// tenant_uuid,
-// role: _role,
-// authentik_user_uuid: akUser.uuid,
-// next: '/auth/login'
-// });
-// } catch (err) {
-// try { await client.query('ROLLBACK'); } catch {}
-// next(err);
-// } finally {
-// client.release();
-// }
-// });
-
-
-// -----------------------------------------------------------------------------
+// =============================================
// Healthcheck
-// -----------------------------------------------------------------------------
-app.get('/health', (_req, res) => res.status(200).json({ status: 'ok', service: 'auth' }));
+// =============================================
+app.get('/health', (_req, res) => res.status(200).json({ status: 'ok'}));
-// -----------------------------------------------------------------------------
+// =============================================
// 404 + Manejo de errores
-// -----------------------------------------------------------------------------
+// =============================================
app.use((req, res) => res.status(404).json({ error: 'Error 404, No se encontró la página', path: req.originalUrl }));
app.use((err, _req, res, _next) => {
- console.error('❌ Error:', err);
+ console.error('[AUTH] ', err);
if (res.headersSent) return;
res.status(500).json({ error: '¡Oh! A ocurrido un error en el servidor auth.', detail: err.stack || String(err) });
});
+/*
+-----------------------------------------------------------------------------
+Exportación principal del módulo.
+Es típico exportar la instancia (app) y arrancarla en otro archivo.
+- Facilita tests (p.ej. con supertest: import app from './app.js')
+- Evita que el servidor se inicie al importar el módulo.
+
+# Default
+ export default app; // importar: import app from './app.js'
+
+# Con nombre
+ export const app = express(); // importar: import { app } from './app.js'
+-----------------------------------------------------------------------------
+*/
+export default app;
+
// -----------------------------------------------------------------------------
// Arranque
// -----------------------------------------------------------------------------
-const PORT = Number(process.env.PORT || 4040);
-
-(async () => {
- const env = (process.env.NODE_ENV || 'development').toUpperCase();
- console.log(`Activando entorno de -> ${env === 'PRODUCTION' ? chalk.red('PRODUCTION') : chalk.green('DEVELOPMENT')}`);
- await verificarConexion();
- app.listen(PORT, () => {
- console.log(`[AUTH] SuiteCoffee corriendo en http://localhost:${PORT}`);
- });
-})();
-
-export default app;
\ No newline at end of file
+app.listen(PORT, () => {
+ console.log(`[AUTH] Servicio de autenticación escuchando en http://localhost:${PORT}`);
+ verificarConexion();
+ // OIDCdiscover();
+});
\ No newline at end of file
diff --git a/services/auth/src/views/login.ejs b/services/auth/src/views/login.ejs
deleted file mode 100644
index 65dc771..0000000
--- a/services/auth/src/views/login.ejs
+++ /dev/null
@@ -1,164 +0,0 @@
-
-
-
-
-
-
<%= typeof pageTitle !== 'undefined' ? pageTitle : 'Iniciar sesión · SuiteCoffee' %>
-
-
-
-
-
-
-
-
-
-
-
-
SuiteCoffee
-
Accedé a tu cuenta
-
-
-
-
-
-
-
-
-
-
-
-
o
-
-
-
-
-
- Al continuar aceptás nuestros términos y políticas.
-
-
-
-
-
-
- ¿Ya tenés cuenta? Iniciá sesión con SSO
-
-
-
-
-
-
-
-
-
-
diff --git a/services/manso/src/views/comandas.ejs b/services/manso/src/views/comandas.ejs
index ae564e9..3426d6c 100644
--- a/services/manso/src/views/comandas.ejs
+++ b/services/manso/src/views/comandas.ejs
@@ -291,8 +291,14 @@
}
function hydrateMesas() {
- const sel = $('#selMesa'); sel.innerHTML = '';
- for (const m of state.mesas) {
+ const sel = $('#selMesa');
+ sel.innerHTML = '';
+ // Ordena por número de mesa (o por id si no hay número)
+ const rows = state.mesas
+ .slice()
+ .sort((a, b) => Number(a?.numero ?? a?.id_mesa ?? 0) - Number(b?.numero ?? b?.id_mesa ?? 0));
+
+ for (const m of rows) {
const o = document.createElement('option');
o.value = m.id_mesa;
o.textContent = `#${m.numero} · ${m.apodo} (${m.estado})`;
@@ -300,15 +306,20 @@
}
}
function hydrateUsuarios() {
- const sel = $('#selUsuario'); sel.innerHTML = '';
- for (const u of state.usuarios) {
+ const sel = $('#selUsuario');
+ sel.innerHTML = '';
+ // 🔽 Orden ascendente por id_usuario
+ const rows = state.usuarios
+ .slice()
+ .sort((a, b) => Number(a?.id_usuario ?? 0) - Number(b?.id_usuario ?? 0));
+
+ for (const u of rows) {
const o = document.createElement('option');
o.value = u.id_usuario;
o.textContent = `${u.nombre} ${u.apellido}`.trim();
sel.appendChild(o);
}
}
-
// Render productos
function renderProductos() {
let rows = state.productos.slice();
diff --git a/services/shared/middlewares/redisConnect.js b/services/shared/middlewares/redisConnect.js
new file mode 100644
index 0000000..378cf11
--- /dev/null
+++ b/services/shared/middlewares/redisConnect.js
@@ -0,0 +1,58 @@
+import session from "express-session";
+import { createClient } from "redis";
+
+
+export async function createRedisSession({
+redisUrl = process.env.REDIS_URL,
+cookieName = process.env.SESSION_COOKIE_NAME || "sc.sid",
+secret = process.env.SESSION_SECRET,
+trustProxy = process.env.TRUST_PROXY === "1",
+ttlSeconds = 60 * 60 * 12, // 12h
+} = {}) {
+if (!redisUrl) throw new Error("REDIS_URL no definido");
+if (!secret) throw new Error("SESSION_SECRET no definido");
+
+
+const redis = createClient({ url: redisUrl });
+redis.on("error", (err) => console.error("[Redis]", err));
+await redis.connect();
+console.log("[Redis] conectado");
+
+
+// Resolver RedisStore (v5 / v6 / v7)
+async function resolveRedisStore() {
+const mod = await import("connect-redis");
+// v6/v7: named export class
+if (typeof mod.RedisStore === "function") return mod.RedisStore;
+// v5: default factory connectRedis(session)
+if (typeof mod.default === "function") {
+const maybe = mod.default;
+if (maybe.prototype && (maybe.prototype.get || maybe.prototype.set)) return maybe; // clase
+const factory = mod.default(session);
+return factory;
+}
+throw new Error("No se pudo resolver RedisStore de connect-redis");
+}
+
+
+const RedisStore = await resolveRedisStore();
+const store = new RedisStore({ client: redis, prefix: "sc:sess:", ttl: ttlSeconds });
+
+
+const sessionMw = session({
+name: cookieName,
+secret,
+resave: false,
+saveUninitialized: false,
+store,
+cookie: {
+httpOnly: true,
+sameSite: "lax",
+secure: process.env.NODE_ENV === "production", // requiere https
+maxAge: ttlSeconds * 1000,
+},
+});
+
+
+return { sessionMw, redis, store, trustProxy };
+}
\ No newline at end of file