6 Commits

Author SHA1 Message Date
msaldain a31b411437 Re estructuración de patrones de diseño con el código de Manso 2025-10-08 18:12:58 +00:00
msaldain b4c5d2af4f Puesta a punto 2025-09-22 16:59:29 +00:00
msaldain 69f5860b7f Inclusión del dominio suitecoffee.uy al NPM.
Se ajustaron los problemas de renderizado y redirección mayores de https://suitecoffee.uy/
Se re-creó el archivo ~/SuiteCoffee/services/app/src/index.js para mantener un orden adecuado
Las rutas exigen una cookie de seción para cargarse, de o contrario redireccionan a  https://suitecoffee.uy/auth/login para iniciar o crear sesión de usuario, excepto https://suitecoffee.uy/inicio que se mantene de esta manera con motivos de desarrollo
2025-09-09 14:20:05 +00:00
msaldain 5d078f3932 Carga completa 2025-09-06 11:19:42 +00:00
msaldain 237a5427dd Mucha cosa y es muy tarde.
- Anda parte del registro
2025-09-05 08:13:09 +00:00
msaldain 80778c0ed9 Pre-reordenación 2025-09-05 04:02:39 +00:00
68 changed files with 16141 additions and 2947 deletions
+37
View File
@@ -0,0 +1,37 @@
# Archivo de variables de entorno para docker-compose.yml
COMPOSE_PROJECT_NAME=suitecoffee_dev
# Entorno de desarrollo
NODE_ENV=development
# app - app
APP_PORT=3030
# auth - app
AUTH_PORT=4040
# tenants - postgres
TENANTS_DB_NAME=dev-postgres
TENANTS_DB_USER=dev-user-postgres
TENANTS_DB_PASS=dev-pass-postgres
# db primaria - postgres
DB_NAME=dev-suitecoffee
DB_USER=dev-user-suitecoffee
DB_PASS=dev-pass-suitecoffee
# Authentik PostgreSQL Setup
AK_HOST_DB=ak-db
AK_PG_DB=authentik
AK_PG_USER=authentik
AK_PG_PASS=gOWjL8V564vyh1aXUcqh4o/xo7eObraaCVZezPi3iw2LzPlU
# Authentik Cookies
AUTHENTIK_COOKIE__DOMAIN=sso.suitecoffee.uy
AUTHENTIK_SECURITY__CSRF_TRUSTED_ORIGINS=https://sso.suitecoffee.uy,https://suitecoffee.uy
# Authentik Security
AUTHENTIK_SECRET_KEY=11zMsUL57beO+okjeGh7OB3lQdGUWII+VaATHs/zsw1+6KMSTyGfAY0yHpq3C442+3CwrZ/KtjgHBfbv
# Authentik Bootstrap
AUTHENTIK_BOOTSTRAP_PASSWORD=info.suitecoffee@gmail.com
AUTHENTIK_BOOTSTRAP_EMAIL=info.suitecoffee@gmail.com
+53
View File
@@ -0,0 +1,53 @@
# Archivo de variables de entorno para docker-compose.yml
COMPOSE_PROJECT_NAME=suitecoffee_prod
# Entorno de desarrollo
NODE_ENV=production
# app - app
APP_PORT=3000
# auth - app
AUTH_PORT=4000
# tenants - postgres
TENANTS_DB_NAME=postgres
TENANTS_DB_USER=postgres
TENANTS_DB_PASS=postgres
# db primaria - postgres
DB_NAME=suitecoffee
DB_USER=suitecoffee
DB_PASS=suitecoffee
# Authentik PostgreSQL Setup
AK_HOST_DB=ak-db
AK_PG_DB=authentik
AK_PG_USER=authentik
AK_PG_PASS=gOWjL8V564vyh1aXUcqh4o/xo7eObraaCVZezPi3iw2LzPlU
# Authentik Cookies
AUTHENTIK_COOKIE__DOMAIN=sso.suitecoffee.uy
AUTHENTIK_SECURITY__CSRF_TRUSTED_ORIGINS=https://sso.suitecoffee.uy,https://suitecoffee.uy
# Authentik Security
AUTHENTIK_SECRET_KEY=11zMsUL57beO+okjeGh7OB3lQdGUWII+VaATHs/zsw1+6KMSTyGfAY0yHpq3C442+3CwrZ/KtjgHBfbv
AUTHENTIK_ERROR_REPORTING__ENABLED=true
# Authentik Email
AUTHENTIK_EMAIL__HOST=smtp.gmail.com # SMTP Host Emails are sent to
AUTHENTIK_EMAIL__PORT=465
AUTHENTIK_EMAIL__PASSWORD=Succulent-Sanded7
AUTHENTIK_EMAIL__USE_TLS=false # Use StartTLS
AUTHENTIK_EMAIL__USE_SSL=true # Use SSL
AUTHENTIK_EMAIL__TIMEOUT=10
# Email address authentik will send from, should have a correct @domain
AUTHENTIK_EMAIL__FROM=info.suitecoffee@gmail.com
# Authentik Bootstrap
AUTHENTIK_BOOTSTRAP_PASSWORD=info.suitecoffee@gmail.com
AUTHENTIK_BOOTSTRAP_EMAIL=info.suitecoffee@gmail.com
+1 -1
View File
@@ -33,6 +33,6 @@ tests/
.gitmodules
# Ignorar archivos personales o privados (si existen)
.env.*
# .env.*
*.pem
*.key
+1015
View File
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 717 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 717 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1005 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 173 KiB

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Capa_1" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 1024 1024">
<!-- Generator: Adobe Illustrator 29.6.1, SVG Export Plug-In . SVG Version: 2.1.1 Build 9) -->
<defs>
<style>
.st0 {
fill: #fff;
}
</style>
</defs>
<path class="st0" d="M1024,0v1024H0V0h1024ZM555.65,53.34c-9.62.5-42.47,33.75-50.29,42.27-52.83,57.58-92.54,133.71-99.27,212.63-9.61,112.64,65.25,175.4,107.41,269.2,52.92,117.75,31.19,241.15-37.67,346.37-5.24,8.01-19.02,22.41-21.61,30.02-2.38,7.01,4.2,10.95,10.05,6.97,98.88-80.26,173.94-198.57,145.59-331.12-19.98-93.4-85.71-170.1-121.65-256.47-40.46-97.24-10.37-194.22,47.61-276.58,5.77-8.2,22.16-24.87,25.06-32.31.97-2.5,1.81-4.69.97-7.43-.72-2.16-3.99-3.67-6.19-3.56Z"/>
<path d="M555.65,53.34c2.2-.12,5.46,1.4,6.19,3.56.85,2.74,0,4.92-.97,7.43-2.89,7.44-19.28,24.11-25.06,32.31-57.98,82.36-88.07,179.34-47.61,276.58,35.94,86.37,101.67,163.07,121.65,256.47,28.35,132.55-46.71,250.87-145.59,331.12-5.85,3.99-12.43.04-10.05-6.97,2.59-7.61,16.36-22.01,21.61-30.02,68.86-105.22,90.59-228.62,37.67-346.37-42.16-93.81-117.02-156.57-107.41-269.2,6.74-78.93,46.45-155.06,99.27-212.63,7.81-8.51,40.66-41.77,50.29-42.27Z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 186 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 199 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 169 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 528 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 488 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

@@ -0,0 +1 @@
{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"}
+87 -86
View File
@@ -1,44 +1,45 @@
# docker-compose.overrride.yml
# Docker Comose para entorno de desarrollo o development.
# compose.dev.yaml
# Docker Compose para entorno de desarrollo.
services:
app:
image: node:20-bookworm
expose:
- ${APP_LOCAL_PORT}
working_dir: /app
user: "${UID:-1000}:${GID:-1000}"
volumes:
- ./services/app:/app:rw
- ./services/app/node_modules:/app/node_modules
# - ./services/shared:/app/shared
env_file:
- ./services/app/.env.development
environment:
- NODE_ENV=${NODE_ENV}
NODE_ENV: development # <- fuerza el entorno para que el loader tome .env.development
expose:
- ${APP_PORT}
networks:
net:
aliases: [dev-app]
command: npm run dev
auth:
image: node:20-bookworm
expose:
- ${AUTH_LOCAL_PORT}
working_dir: /app
user: "${UID:-1000}:${GID:-1000}"
volumes:
- ./services/auth:/app:rw
- ./services/auth/node_modules:/app/node_modules
env_file:
- ./services/auth/.env.development
environment:
- NODE_ENV=${NODE_ENV}
command: npm run dev
networks:
net:
aliases: [dev-auth]
# auth:
# image: node:20-bookworm
# working_dir: /app
# user: "${UID:-1000}:${GID:-1000}"
# volumes:
# - ./services/auth:/app:rw
# - ./services/auth/node_modules:/app/node_modules
# - ./services/shared:/app/shared
# env_file:
# - ./services/auth/.env.development
# environment:
# NODE_ENV: development # <- fuerza el entorno para que el loader tome .env.development
# expose:
# - ${AUTH_PORT}
# networks:
# net:
# aliases: [dev-auth]
# command: npm run dev
db:
image: postgres:16
@@ -63,91 +64,91 @@ services:
networks:
net:
aliases: [dev-tenants]
#################
### Authentik ###
#################
# --- Authentik db (solo interno)
authentik-db:
# image: postgres:16-alpine
ak-db:
image: docker.io/library/postgres:16-alpine
env_file:
- .env.development
environment:
POSTGRES_DB: authentik
POSTGRES_USER: authentik
POSTGRES_PASSWORD: ${AUTHENTIK_DB_PASS}
# healthcheck:
# test: ["CMD-SHELL", "pg_isready -U authentik -d authentik"]
# interval: 10s
# timeout: 3s
# retries: 10
POSTGRES_DB: ${AK_PG_DB:-authentik}
POSTGRES_PASSWORD: ${AK_PG_PASS:?database password required}
POSTGRES_USER: ${AK_PG_USER:-authentik}
volumes:
- authentik-db:/var/lib/postgresql/data
networks:
net:
aliases: [ak-db]
# restart: unless-stopped
aliases: [dev-ak-db]
# --- Authentik Redis (solo interno)
authentik-redis:
# image: redis:7-alpine
command: ["redis-server", "--save", "", "--appendonly", "no"]
ak-redis:
image: docker.io/library/redis:alpine
command: --save 60 1 --loglevel warning
networks:
net:
aliases: [ak-redis]
# restart: unless-stopped
net:
aliases: [dev-ak-redis]
volumes:
- ak-redis:/data
# --- Authentik Server (sin puertos públicos)
authentik:
# image: ghcr.io/goauthentik/server:latest
# depends_on:
# authentik-db: { condition: service_healthy }
# authentik-redis: { condition: service_started }
ak:
image: ghcr.io/goauthentik/server:latest
env_file:
- .env.development
command: server
environment:
AUTHENTIK_SECRET_KEY: ${AUTHENTIK_SECRET_KEY}
AUTHENTIK_DEBUG: "false"
AUTHENTIK_POSTGRESQL__HOST: authentik-db
AUTHENTIK_POSTGRESQL__USER: authentik
AUTHENTIK_POSTGRESQL__NAME: authentik
AUTHENTIK_POSTGRESQL__PASSWORD: ${AUTHENTIK_DB_PASS}
AUTHENTIK_REDIS__HOST: authentik-redis
# Opcional: bootstrap automático del admin
AUTHENTIK_DEBUG: false
AUTHENTIK_POSTGRESQL__HOST: ak-db
AUTHENTIK_POSTGRESQL__NAME: ${AK_PG_DB:-authentik}
AUTHENTIK_POSTGRESQL__PASSWORD: ${AK_PG_PASS}
AUTHENTIK_POSTGRESQL__USER: ${AK_PG_USER:-authentik}
AUTHENTIK_REDIS__HOST: ak-redis
AUTHENTIK_SECRET_KEY: ${AUTHENTIK_SECRET_KEY:?secret key required}
AUTHENTIK_BOOTSTRAP_PASSWORD: ${AUTHENTIK_BOOTSTRAP_PASSWORD}
AUTHENTIK_BOOTSTRAP_EMAIL: ${AUTHENTIK_BOOTSTRAP_EMAIL}
# expose:
# - "9000" # HTTP interno
# - "9443" # HTTPS interno
networks:
net:
aliases: [authentik]
# restart: unless-stopped
# Habilitá ESTO SOLO si querés abrir la UI local:
profiles: ["ak-ui"]
ports:
- 9000:9000
- 9443:9443
AUTHENTIK_HTTP__TRUSTED_PROXY__CIDRS: "0.0.0.0/0,::/0"
AUTHENTIK_SECURITY__CSRF_TRUSTED_ORIGINS: ${AUTHENTIK_SECURITY__CSRF_TRUSTED_ORIGINS}
AUTHENTIK_COOKIE__DOMAIN: ${AUTHENTIK_COOKIE__DOMAIN}
networks:
net:
aliases: [dev-authentik]
volumes:
- ./authentik-media:/media
- ./authentik-custom-templates:/templates
# --- Authentik Worker
authentik-worker:
# image: ghcr.io/goauthentik/server:latest
ak-worker:
image: ghcr.io/goauthentik/server:latest
command: worker
depends_on:
authentik-db: { condition: service_healthy }
authentik-redis: { condition: service_started }
environment:
AUTHENTIK_SECRET_KEY: ${AUTHENTIK_SECRET_KEY}
AUTHENTIK_POSTGRESQL__HOST: authentik-db
AUTHENTIK_POSTGRESQL__USER: authentik
AUTHENTIK_POSTGRESQL__NAME: authentik
AUTHENTIK_POSTGRESQL__PASSWORD: ${AUTHENTIK_DB_PASS}
AUTHENTIK_REDIS__HOST: authentik-redis
networks:
net:
aliases: [ak-work]
AUTHENTIK_POSTGRESQL__HOST: ak-db
AUTHENTIK_POSTGRESQL__NAME: ${AK_PG_DB:-authentik}
AUTHENTIK_POSTGRESQL__PASSWORD: ${AK_PG_PASS}
AUTHENTIK_POSTGRESQL__USER: ${AK_PG_USER:-authentik}
AUTHENTIK_REDIS__HOST: ak-redis
AUTHENTIK_SECRET_KEY: ${AUTHENTIK_SECRET_KEY:?secret key required}
AUTHENTIK_HTTP__TRUSTED_PROXY__CIDRS: "0.0.0.0/0,::/0"
AUTHENTIK_SECURITY__CSRF_TRUSTED_ORIGINS: ${AUTHENTIK_SECURITY__CSRF_TRUSTED_ORIGINS}
AUTHENTIK_COOKIE__DOMAIN: ${AUTHENTIK_COOKIE__DOMAIN}
networks:
net:
aliases: [dev-ak-work]
user: root
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- ./authentik-media:/media
- ./authentik-certs:/certs
- ./authentik-custom-templates:/templates
volumes:
tenants-db:
suitecoffee-db:
authentik-db:
ak-redis:
networks:
net:
driver: bridge
driver: bridge
+30 -30
View File
@@ -6,13 +6,13 @@ services:
manso:
image: node:20-bookworm
depends_on:
db:
condition: service_healthy
tenants:
condition: service_healthy
expose:
- ${APP_LOCAL_PORT}
# depends_on:
# db:
# condition: service_healthy
# tenants:
# condition: service_healthy
# expose:
# - ${APP_LOCAL_PORT}
working_dir: /app
user: "${UID:-1000}:${GID:-1000}"
volumes:
@@ -26,7 +26,7 @@ services:
net:
aliases: [manso]
healthcheck:
test: ["CMD-SHELL", "curl -fsS http://localhost:${APP_DOCKER_PORT}/health || exit 1"]
test: ["CMD-SHELL", "curl -fsS http://localhost:${APP_PORT}/health || exit 1"]
interval: 10s
timeout: 3s
retries: 10
@@ -35,29 +35,29 @@ services:
profiles: [manso]
restart: unless-stopped
db:
image: postgres:16
environment:
POSTGRES_DB: ${DB_NAME}
POSTGRES_USER: ${DB_USER}
POSTGRES_PASSWORD: ${DB_PASS}
volumes:
- suitecoffee-db:/var/lib/postgresql/data
networks:
net:
aliases: [dev-db]
# db:
# image: postgres:16
# environment:
# POSTGRES_DB: ${DB_NAME}
# POSTGRES_USER: ${DB_USER}
# POSTGRES_PASSWORD: ${DB_PASS}
# volumes:
# - suitecoffee-db:/var/lib/postgresql/data
# networks:
# net:
# aliases: [dev-db]
tenants:
image: postgres:16
environment:
POSTGRES_DB: ${TENANTS_DB_NAME}
POSTGRES_USER: ${TENANTS_DB_USER}
POSTGRES_PASSWORD: ${TENANTS_DB_PASS}
volumes:
- tenants-db:/var/lib/postgresql/data
networks:
net:
aliases: [dev-tenants]
# tenants:
# image: postgres:16
# environment:
# POSTGRES_DB: ${TENANTS_DB_NAME}
# POSTGRES_USER: ${TENANTS_DB_USER}
# POSTGRES_PASSWORD: ${TENANTS_DB_PASS}
# volumes:
# - tenants-db:/var/lib/postgresql/data
# networks:
# net:
# aliases: [dev-tenants]
volumes:
tenants-db:
+2 -2
View File
@@ -9,7 +9,7 @@ services:
context: ./services/app
dockerfile: Dockerfile.production
expose:
- ${APP_LOCAL_PORT}
- ${APP_PORT}
volumes:
- ./services/app:/app
env_file:
@@ -26,7 +26,7 @@ services:
context: ./services/auth
dockerfile: Dockerfile.production
expose:
- ${AUTH_LOCAL_PORT}
- ${AUTH_PORT}
volumes:
- ./services/auth:/app
env_file:
+38 -23
View File
@@ -3,7 +3,6 @@
name: ${COMPOSE_PROJECT_NAME:-suitecoffee}
services:
app:
depends_on:
db:
@@ -11,24 +10,26 @@ services:
tenants:
condition: service_healthy
healthcheck:
test: ["CMD-SHELL", "curl -fsS http://localhost:${APP_DOCKER_PORT}/health || exit 1"]
test: ["CMD-SHELL", "curl -fsS http://localhost:${APP_PORT}/health || exit 1"]
interval: 10s
timeout: 3s
retries: 10
start_period: 20s
restart: unless-stopped
auth:
depends_on:
db:
condition: service_healthy
healthcheck:
test: ["CMD-SHELL", "curl -fsS http://localhost:${AUTH_DOCKER_PORT}/health || exit 1"]
interval: 10s
timeout: 3s
retries: 10
start_period: 15s
restart: unless-stopped
# auth:
# depends_on:
# db:
# condition: service_healthy
# ak:
# condition: service_started
# healthcheck:
# test: ["CMD-SHELL", "curl -fsS http://localhost:${AUTH_PORT}/health || exit 1"]
# interval: 10s
# timeout: 3s
# retries: 10
# start_period: 15s
# restart: unless-stopped
db:
image: postgres:16
@@ -54,27 +55,41 @@ services:
start_period: 10s
restart: unless-stopped
authentik-db:
ak-db:
image: postgres:16-alpine
healthcheck:
test: ["CMD-SHELL", "pg_isready -U authentik -d authentik"]
interval: 10s
timeout: 3s
retries: 10
test: ["CMD-SHELL", "pg_isready -d ${AK_PG_DB} -U ${AK_PG_USER} || exit 1"]
interval: 30s
retries: 5
start_period: 20s
timeout: 5s
restart: unless-stopped
authentik-redis:
ak-redis:
image: redis:7-alpine
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 30s
timeout: 5s
retries: 5
start_period: 10s
restart: unless-stopped
authentik:
ak:
image: ghcr.io/goauthentik/server:latest
depends_on:
authentik-db: { condition: service_healthy }
authentik-redis: { condition: service_started }
ak-db:
condition: service_healthy
ak-redis:
condition: service_healthy
restart: unless-stopped
authentik-worker:
ak-worker:
image: ghcr.io/goauthentik/server:latest
depends_on:
ak-db:
condition: service_healthy
ak-redis:
condition: service_healthy
restart: unless-stopped
+316
View File
@@ -0,0 +1,316 @@
# Documentación detallada de funciones: `ak.js`
Este documento fue generado automáticamente a partir del archivo `ak.js` proporcionado. Incluye una sección por función detectada, con firma, ubicación, descripción, parámetros, valores de retorno, posibles errores y un ejemplo de uso.
> **Nota:** Las descripciones y tipos se infieren heurísticamente a partir de los nombres, comentarios y cuerpo de cada función. Revise y ajuste donde corresponda.
---
### `getConfig`
**Firma:** `function getConfig()`
**Ubicación:** línea 28
**Comentario previo en el código:**
```js
// ------------------------------------------------------------
// Cliente para la API Admin de Authentik (v3)
// - Sin dependencias externas (usa fetch nativo de Node >=18)
// - ESM compatible
// - Timeouts, reintentos opcionales y mensajes de error claros
// - Compatible con services/auth/src/index.js actual
// ------------------------------------------------------------
// Utiliza AUTHENTIK_BASE_URL y AUTHENTIK_TOKEN para validar y devuelve la configuración (base URL y token) desde variables de entorno.
// Devuelve la URL base y el Token que se leyó desde .env
/**
* @typedef {Object} AkCfg
* @property {string} BASE // p.ej. "https://idp.example.com"
* @property {string} TOKEN // bearer
*/
/**
* @typedef {Object} AkOpts
* @property {Record<string, string|number|boolean|Array<string|number|boolean>>} [qs]
* @property {any} [body]
* @property {number} [timeoutMs=10000]
* @property {number} [retries=0]
* @property {Record<string,string>} [headers]
*/
```
**Descripción:** Obtiene Config.
**Parámetros:** *(sin parámetros)*
**Retorna (aprox.):** `{ BASE, TOKEN }`
**Errores/excepciones:**
- Puede lanzar `Error('[AK_CONFIG] Falta variable AUTHENTIK_BASE_URL')`.
- Puede lanzar `Error('[AK_CONFIG] Falta variable AUTHENTIK_TOKEN')`.
**Ejemplo de uso:**
```js
const result = getConfig();
console.log(result);
```
---
### `akListGroups`
**Firma:** `export async function akListGroups(search = "")`
**Ubicación:** línea 60
**Comentario previo en el código:**
```js
// Listar grupos con búsqueda por nombre/slug
```
**Descripción:** Función `akListGroups`. Interactúa con una API HTTP.
**Parámetros:**
- `search` (opcional, por defecto = `""`): descripción.
**Retorna (aprox.):** `[]`
**Ejemplo de uso:**
```js
const result = await akListGroups(search);
console.log(result);
```
---
### `akPatchUserAttributes`
**Firma:** `export async function akPatchUserAttributes(userPk, partialAttrs = {})`
**Ubicación:** línea 73
**Descripción:** Función `akPatchUserAttributes`.
**Parámetros:**
- `userPk`: descripción.
- `partialAttrs` (opcional, por defecto = `{}`): descripción.
**Retorna:** Puede no retornar valor explícito (`void`) o retorna según la rama de ejecución.
**Ejemplo de uso:**
```js
const result = await akPatchUserAttributes(userPk, partialAttrs);
console.log(result);
```
---
### `akEnsureGroupForTenant`
**Firma:** `export async function akEnsureGroupForTenant(tenantHex)`
**Ubicación:** línea 97
**Descripción:** Función `akEnsureGroupForTenant`. Interactúa con una API HTTP. Maneja errores con bloques try/catch.
**Parámetros:**
- `tenantHex`: descripción.
**Retorna (aprox.):** `found.pk ?? found.id`
**Errores/excepciones:**
- Puede lanzar `TypeError("akEnsureGroupForTenant: `tenantHex` is required")`.
**Ejemplo de uso:**
```js
const result = await akEnsureGroupForTenant(tenantHex);
console.log(result);
```
---
### `akAddUserToGroup`
**Firma:** `export async function akAddUserToGroup(userPk, groupPk)`
**Ubicación:** línea 130
**Descripción:** Función `akAddUserToGroup`. Interactúa con una API HTTP. Maneja errores con bloques try/catch.
**Parámetros:**
- `userPk`: descripción.
- `groupPk`: descripción.
**Retorna (aprox.):** `await akPOST("/core/group_memberships/", { body: { user, group } })`
**Errores/excepciones:**
- Puede lanzar `TypeError("akAddUserToGroup: `userPk` is required")`.
- Puede lanzar `TypeError("akAddUserToGroup: `groupPk` is required")`.
**Ejemplo de uso:**
```js
const result = await akAddUserToGroup(userPk, groupPk);
console.log(result);
```
---
### `request`
**Firma:** `export async function request(method, path, opts = {}, cfg)`
**Ubicación:** línea 167
**Comentario previo en el código:**
```js
/**
* Llamada HTTP genérica con fetch + timeout + manejo de errores.
* @param {'GET'|'POST'|'PUT'|'PATCH'|'DELETE'} method
* @param {string} path Ruta relativa (ej. "/core/users/") o absoluta; si es relativa se antepone "/api/v3".
* @param {AkOpts} [opts]
* @param {AkCfg} [cfg] Config inyectada; si se omite se usa getConfig()
* @returns {Promise<any|null>}
*/
```
**Descripción:** Función `request`.
**Parámetros:**
- `method`: descripción.
- `path`: descripción.
- `opts` (opcional, por defecto = `{}`): descripción.
- `cfg`: descripción.
**Retorna:** Puede no retornar valor explícito (`void`) o retorna según la rama de ejecución.
**Ejemplo de uso:**
```js
const result = await request(method, path, opts, cfg);
console.log(result);
```
---
### `akFindUserByEmail`
**Firma:** `export async function akFindUserByEmail(email)`
**Ubicación:** línea 262
**Comentario previo en el código:**
```js
// ------------------------------------------------------------
// Funciones públicas
// ------------------------------------------------------------
```
**Descripción:** Función `akFindUserByEmail`. Interactúa con una API HTTP.
**Parámetros:**
- `email`: descripción.
**Retorna (aprox.):** `null`
**Errores/excepciones:**
- Puede lanzar `TypeError("akFindUserByEmail: `email` is required")`.
**Ejemplo de uso:**
```js
const result = await akFindUserByEmail(email);
console.log(result);
```
---
### `akCreateUser`
**Firma:** `export async function akCreateUser(p = {})`
**Ubicación:** línea 298
**Descripción:** Función `akCreateUser`.
**Parámetros:**
- `p` (opcional, por defecto = `{}`): descripción.
**Retorna:** Puede no retornar valor explícito (`void`) o retorna según la rama de ejecución.
**Ejemplo de uso:**
```js
const result = await akCreateUser(p);
console.log(result);
```
---
### `akSetPassword`
**Firma:** `export async function akSetPassword(userPk, password, requireChange = true)`
**Ubicación:** línea 349
**Descripción:** Función `akSetPassword`. Interactúa con una API HTTP. Maneja errores con bloques try/catch.
**Parámetros:**
- `userPk`: descripción.
- `password`: descripción.
- `requireChange` (opcional, por defecto = `true`): descripción.
**Retorna (aprox.):** `true`
**Errores/excepciones:**
- Puede lanzar `TypeError("akSetPassword: `userPk` is required")`.
- Puede lanzar `TypeError("akSetPassword: `password` is required")`.
**Ejemplo de uso:**
```js
const result = await akSetPassword(userPk, password, requireChange);
console.log(result);
```
---
### `akResolveGroupIdByName`
**Firma:** `export async function akResolveGroupIdByName(name)`
**Ubicación:** línea 373
**Descripción:** Función `akResolveGroupIdByName`.
**Parámetros:**
- `name`: descripción.
**Retorna (aprox.):** `byName?.pk ?? byName?.id ?? null`
**Errores/excepciones:**
- Puede lanzar `TypeError("akResolveGroupIdByName: `name` is required")`.
**Ejemplo de uso:**
```js
const result = await akResolveGroupIdByName(name);
console.log(result);
```
---
### `akResolveGroupId`
**Firma:** `export async function akResolveGroupId({ id, pk, uuid, name, slug } = {})`
**Ubicación:** línea 389
**Descripción:** Función `akResolveGroupId`.
**Parámetros:**
- `{ id`: descripción.
- `pk`: descripción.
- `uuid`: descripción.
- `name`: descripción.
- `slug }` (opcional, por defecto = `{}`): descripción.
**Retorna:** Puede no retornar valor explícito (`void`) o retorna según la rama de ejecución.
**Ejemplo de uso:**
```js
const result = await akResolveGroupId({ id, pk, uuid, name, slug });
console.log(result);
```
---
### `toPk`
**Firma:** `const => toPk(v)`
**Ubicación:** línea 390
**Descripción:** Función `toPk`.
**Parámetros:**
- `v`: descripción.
**Retorna (aprox.):** `Number.isFinite(n) ? n : String(v)`
**Ejemplo de uso:**
```js
const result = toPk(v);
console.log(result);
```
---
View File
+38
View File
@@ -0,0 +1,38 @@
# ===== Runtime =====
NODE_ENV=development
PORT=3030
# ===== Session (usa el Redis del stack) =====
# Para DEV podemos reutilizar el Redis de Authentik. En prod conviene uno separado.
SESSION_SECRET=Neon*Mammal*Boaster*Ludicrous*Fender8*Crablike
SESSION_COOKIE_NAME=sc.sid
REDIS_URL=redis://ak-redis:6379
# ===== DB principal (metadatos de SuiteCoffee) =====
# Usa el alias de red del servicio 'db' (compose: aliases [dev-db])
DB_HOST=dev-db
DB_PORT=5432
DB_NAME=dev-suitecoffee
DB_USER=dev-user-suitecoffee
DB_PASS=dev-pass-suitecoffee
# ===== DB tenants (Tenants de SuiteCoffee) =====
TENANTS_HOST=dev-tenants
TENANTS_DB=dev-postgres
TENANTS_USER=dev-user-postgres
TENANTS_PASS=dev-pass-postgres
TENANTS_PORT=5432
TENANT_INIT_SQL=/app/src/db/initTenant_v2.sql
OIDC_CLIEN_ID=1orMM8vOvf3WkN2FejXYvUFpPtONG0Lx1eMlwIpW
OIDC_CONFIG_URL=https://sso.suitecoffee.uy/application/o/suitecoffee/.well-known/openid-configuration
OIDC_ISSUER=https://sso.suitecoffee.uy/application/o/suitecoffee/
OIDC_AUTHORIZE_URL=https://sso.suitecoffee.uy/application/o/authorize/
OIDC_TOKEN_URL=https://sso.suitecoffee.uy/application/o/token/
OIDC_USERINFO_URL=https://sso.suitecoffee.uy/application/o/userinfo/
OIDC_LOGOUT_URL=https://sso.suitecoffee.uy/application/o/suitecoffee/end-session/
OIDC_JWKS_URL=https://sso.suitecoffee.uy/application/o/suitecoffee/jwks/
OIDC_LOGIN_URL=https://sso.suitecoffee.uy
APP_BASE_URL=https://suitecoffee.uy
+22
View File
@@ -0,0 +1,22 @@
NODE_ENV=production # Entorno de desarrollo
PORT=3000 # Variables del servicio -> suitecoffee-app
# Variables del servicio -> suitecoffee-db de suitecoffee-app
DB_HOST=prod-tenants
# Nombre de la base de datos
DB_NAME=postgres
# Usuario y contraseña
DB_USER=postgres
DB_PASS=postgres
# Puertos del servicio de db
DB_LOCAL_PORT=5432
DB_DOCKER_PORT=5432
# Colores personalizados
COL_PRI=452D19 # Marrón oscuro
COL_SEC=D7A666 # Crema / Café
COL_BG=FFA500 # Naranja
+755 -1
View File
@@ -9,14 +9,25 @@
"version": "1.0.0",
"license": "ISC",
"dependencies": {
"bcrypt": "^6.0.0",
"chalk": "^5.6.0",
"connect-redis": "^9.0.0",
"cookie-parser": "^1.4.7",
"cors": "^2.8.5",
"dotenv": "^17.2.1",
"ejs": "^3.1.10",
"express": "^5.1.0",
"express-ejs-layouts": "^2.5.1",
"express-session": "^1.18.2",
"ioredis": "^5.7.0",
"jose": "^6.1.0",
"jsonwebtoken": "^9.0.2",
"jwks-rsa": "^3.2.0",
"morgan": "^1.10.1",
"node-fetch": "^3.3.2",
"pg": "^8.16.3",
"pg-format": "^1.0.4",
"redis": "^5.8.2",
"serve-favicon": "^2.5.1"
},
"devDependencies": {
@@ -29,6 +40,185 @@
"dev": true,
"license": "MIT"
},
"node_modules/@ioredis/commands": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.3.1.tgz",
"integrity": "sha512-bYtU8avhGIcje3IhvF9aSjsa5URMZBHnwKtOvXsT4sfYy9gppW11gLPT/9oNqlJZD47yPKveQFTAFWpHjKvUoQ==",
"license": "MIT"
},
"node_modules/@redis/bloom": {
"version": "5.8.2",
"resolved": "https://registry.npmjs.org/@redis/bloom/-/bloom-5.8.2.tgz",
"integrity": "sha512-855DR0ChetZLarblio5eM0yLwxA9Dqq50t8StXKp5bAtLT0G+rZ+eRzzqxl37sPqQKjUudSYypz55o6nNhbz0A==",
"license": "MIT",
"engines": {
"node": ">= 18"
},
"peerDependencies": {
"@redis/client": "^5.8.2"
}
},
"node_modules/@redis/client": {
"version": "5.8.2",
"resolved": "https://registry.npmjs.org/@redis/client/-/client-5.8.2.tgz",
"integrity": "sha512-WtMScno3+eBpTac1Uav2zugXEoXqaU23YznwvFgkPwBQVwEHTDgOG7uEAObtZ/Nyn8SmAMbqkEubJaMOvnqdsQ==",
"license": "MIT",
"dependencies": {
"cluster-key-slot": "1.1.2"
},
"engines": {
"node": ">= 18"
}
},
"node_modules/@redis/json": {
"version": "5.8.2",
"resolved": "https://registry.npmjs.org/@redis/json/-/json-5.8.2.tgz",
"integrity": "sha512-uxpVfas3I0LccBX9rIfDgJ0dBrUa3+0Gc8sEwmQQH0vHi7C1Rx1Qn8Nv1QWz5bohoeIXMICFZRcyDONvum2l/w==",
"license": "MIT",
"engines": {
"node": ">= 18"
},
"peerDependencies": {
"@redis/client": "^5.8.2"
}
},
"node_modules/@redis/search": {
"version": "5.8.2",
"resolved": "https://registry.npmjs.org/@redis/search/-/search-5.8.2.tgz",
"integrity": "sha512-cNv7HlgayavCBXqPXgaS97DRPVWFznuzsAmmuemi2TMCx5scwLiP50TeZvUS06h/MG96YNPe6A0Zt57yayfxwA==",
"license": "MIT",
"engines": {
"node": ">= 18"
},
"peerDependencies": {
"@redis/client": "^5.8.2"
}
},
"node_modules/@redis/time-series": {
"version": "5.8.2",
"resolved": "https://registry.npmjs.org/@redis/time-series/-/time-series-5.8.2.tgz",
"integrity": "sha512-g2NlHM07fK8H4k+613NBsk3y70R2JIM2dPMSkhIjl2Z17SYvaYKdusz85d7VYOrZBWtDrHV/WD2E3vGu+zni8A==",
"license": "MIT",
"engines": {
"node": ">= 18"
},
"peerDependencies": {
"@redis/client": "^5.8.2"
}
},
"node_modules/@types/body-parser": {
"version": "1.19.6",
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
"integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==",
"license": "MIT",
"dependencies": {
"@types/connect": "*",
"@types/node": "*"
}
},
"node_modules/@types/connect": {
"version": "3.4.38",
"resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz",
"integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==",
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/express": {
"version": "4.17.23",
"resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.23.tgz",
"integrity": "sha512-Crp6WY9aTYP3qPi2wGDo9iUe/rceX01UMhnF1jmwDcKCFM6cx7YhGP/Mpr3y9AASpfHixIG0E6azCcL5OcDHsQ==",
"license": "MIT",
"dependencies": {
"@types/body-parser": "*",
"@types/express-serve-static-core": "^4.17.33",
"@types/qs": "*",
"@types/serve-static": "*"
}
},
"node_modules/@types/express-serve-static-core": {
"version": "4.19.6",
"resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.6.tgz",
"integrity": "sha512-N4LZ2xG7DatVqhCZzOGb1Yi5lMbXSZcmdLDe9EzSndPV2HpWYWzRbaerl2n27irrm94EPpprqa8KpskPT085+A==",
"license": "MIT",
"dependencies": {
"@types/node": "*",
"@types/qs": "*",
"@types/range-parser": "*",
"@types/send": "*"
}
},
"node_modules/@types/http-errors": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz",
"integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==",
"license": "MIT"
},
"node_modules/@types/jsonwebtoken": {
"version": "9.0.10",
"resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz",
"integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==",
"license": "MIT",
"dependencies": {
"@types/ms": "*",
"@types/node": "*"
}
},
"node_modules/@types/mime": {
"version": "1.3.5",
"resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz",
"integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==",
"license": "MIT"
},
"node_modules/@types/ms": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
"integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
"license": "MIT"
},
"node_modules/@types/node": {
"version": "24.3.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.3.1.tgz",
"integrity": "sha512-3vXmQDXy+woz+gnrTvuvNrPzekOi+Ds0ReMxw0LzBiK3a+1k0kQn9f2NWk+lgD4rJehFUmYy2gMhJ2ZI+7YP9g==",
"license": "MIT",
"dependencies": {
"undici-types": "~7.10.0"
}
},
"node_modules/@types/qs": {
"version": "6.14.0",
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz",
"integrity": "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==",
"license": "MIT"
},
"node_modules/@types/range-parser": {
"version": "1.2.7",
"resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz",
"integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==",
"license": "MIT"
},
"node_modules/@types/send": {
"version": "0.17.5",
"resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.5.tgz",
"integrity": "sha512-z6F2D3cOStZvuk2SaP6YrwkNO65iTZcwA2ZkSABegdkAh/lf+Aa/YQndZVfmEXT5vgAp6zv06VQ3ejSVjAny4w==",
"license": "MIT",
"dependencies": {
"@types/mime": "^1",
"@types/node": "*"
}
},
"node_modules/@types/serve-static": {
"version": "1.15.8",
"resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.8.tgz",
"integrity": "sha512-roei0UY3LhpOJvjbIP6ZZFngyLKl5dskOtDhxY5THRSpO+ZI+nzJ+m5yUMzGrp89YRa7lvknKkMYjqQFGwA7Sg==",
"license": "MIT",
"dependencies": {
"@types/http-errors": "*",
"@types/node": "*",
"@types/send": "*"
}
},
"node_modules/accepts": {
"version": "2.0.0",
"license": "MIT",
@@ -60,6 +250,38 @@
"version": "1.0.2",
"license": "MIT"
},
"node_modules/basic-auth": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/basic-auth/-/basic-auth-2.0.1.tgz",
"integrity": "sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==",
"license": "MIT",
"dependencies": {
"safe-buffer": "5.1.2"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/basic-auth/node_modules/safe-buffer": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"license": "MIT"
},
"node_modules/bcrypt": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-6.0.0.tgz",
"integrity": "sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"node-addon-api": "^8.3.0",
"node-gyp-build": "^4.8.4"
},
"engines": {
"node": ">= 18"
}
},
"node_modules/binary-extensions": {
"version": "2.3.0",
"dev": true,
@@ -109,6 +331,12 @@
"node": ">=8"
}
},
"node_modules/buffer-equal-constant-time": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
"license": "BSD-3-Clause"
},
"node_modules/bytes": {
"version": "3.1.2",
"license": "MIT",
@@ -174,11 +402,33 @@
"fsevents": "~2.3.2"
}
},
"node_modules/cluster-key-slot": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz",
"integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/concat-map": {
"version": "0.0.1",
"dev": true,
"license": "MIT"
},
"node_modules/connect-redis": {
"version": "9.0.0",
"resolved": "https://registry.npmjs.org/connect-redis/-/connect-redis-9.0.0.tgz",
"integrity": "sha512-QwzyvUePTMvEzG1hy45gZYw3X3YHrjmEdSkayURlcZft7hqadQ3X39wYkmCqblK2rGlw+XItELYt6GnyG6DEIQ==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"peerDependencies": {
"express-session": ">=1",
"redis": ">=5"
}
},
"node_modules/content-disposition": {
"version": "1.0.0",
"license": "MIT",
@@ -203,6 +453,25 @@
"node": ">= 0.6"
}
},
"node_modules/cookie-parser": {
"version": "1.4.7",
"resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz",
"integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==",
"license": "MIT",
"dependencies": {
"cookie": "0.7.2",
"cookie-signature": "1.0.6"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/cookie-parser/node_modules/cookie-signature": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
"integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
"license": "MIT"
},
"node_modules/cookie-signature": {
"version": "1.2.2",
"license": "MIT",
@@ -250,6 +519,15 @@
"node": ">= 8"
}
},
"node_modules/data-uri-to-buffer": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz",
"integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==",
"license": "MIT",
"engines": {
"node": ">= 12"
}
},
"node_modules/debug": {
"version": "4.4.1",
"license": "MIT",
@@ -265,6 +543,15 @@
}
}
},
"node_modules/denque": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz",
"integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.10"
}
},
"node_modules/depd": {
"version": "2.0.0",
"license": "MIT",
@@ -294,6 +581,15 @@
"node": ">= 0.4"
}
},
"node_modules/ecdsa-sig-formatter": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
"license": "Apache-2.0",
"dependencies": {
"safe-buffer": "^5.0.1"
}
},
"node_modules/ee-first": {
"version": "1.1.1",
"license": "MIT"
@@ -396,6 +692,69 @@
"node_modules/express-ejs-layouts": {
"version": "2.5.1"
},
"node_modules/express-session": {
"version": "1.18.2",
"resolved": "https://registry.npmjs.org/express-session/-/express-session-1.18.2.tgz",
"integrity": "sha512-SZjssGQC7TzTs9rpPDuUrR23GNZ9+2+IkA/+IJWmvQilTr5OSliEHGF+D9scbIpdC6yGtTI0/VhaHoVes2AN/A==",
"license": "MIT",
"dependencies": {
"cookie": "0.7.2",
"cookie-signature": "1.0.7",
"debug": "2.6.9",
"depd": "~2.0.0",
"on-headers": "~1.1.0",
"parseurl": "~1.3.3",
"safe-buffer": "5.2.1",
"uid-safe": "~2.1.5"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/express-session/node_modules/cookie-signature": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
"integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
"license": "MIT"
},
"node_modules/express-session/node_modules/debug": {
"version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
"license": "MIT",
"dependencies": {
"ms": "2.0.0"
}
},
"node_modules/express-session/node_modules/ms": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT"
},
"node_modules/fetch-blob": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz",
"integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/jimmywarting"
},
{
"type": "paypal",
"url": "https://paypal.me/jimmywarting"
}
],
"license": "MIT",
"dependencies": {
"node-domexception": "^1.0.0",
"web-streams-polyfill": "^3.0.3"
},
"engines": {
"node": "^12.20 || >= 14.13"
}
},
"node_modules/filelist": {
"version": "1.0.4",
"license": "Apache-2.0",
@@ -446,6 +805,18 @@
"node": ">= 0.8"
}
},
"node_modules/formdata-polyfill": {
"version": "4.0.10",
"resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz",
"integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==",
"license": "MIT",
"dependencies": {
"fetch-blob": "^3.1.2"
},
"engines": {
"node": ">=12.20.0"
}
},
"node_modules/forwarded": {
"version": "0.2.0",
"license": "MIT",
@@ -589,6 +960,30 @@
"version": "2.0.4",
"license": "ISC"
},
"node_modules/ioredis": {
"version": "5.7.0",
"resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.7.0.tgz",
"integrity": "sha512-NUcA93i1lukyXU+riqEyPtSEkyFq8tX90uL659J+qpCZ3rEdViB/APC58oAhIh3+bJln2hzdlZbBZsGNrlsR8g==",
"license": "MIT",
"dependencies": {
"@ioredis/commands": "^1.3.0",
"cluster-key-slot": "^1.1.0",
"debug": "^4.3.4",
"denque": "^2.1.0",
"lodash.defaults": "^4.2.0",
"lodash.isarguments": "^3.1.0",
"redis-errors": "^1.2.0",
"redis-parser": "^3.0.0",
"standard-as-callback": "^2.1.0"
},
"engines": {
"node": ">=12.22.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/ioredis"
}
},
"node_modules/ipaddr.js": {
"version": "1.9.1",
"license": "MIT",
@@ -658,6 +1053,171 @@
"node": ">=10"
}
},
"node_modules/jose": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/jose/-/jose-6.1.0.tgz",
"integrity": "sha512-TTQJyoEoKcC1lscpVDCSsVgYzUDg/0Bt3WE//WiTPK6uOCQC2KZS4MpugbMWt/zyjkopgZoXhZuCi00gLudfUA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/jsonwebtoken": {
"version": "9.0.2",
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz",
"integrity": "sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==",
"license": "MIT",
"dependencies": {
"jws": "^3.2.2",
"lodash.includes": "^4.3.0",
"lodash.isboolean": "^3.0.3",
"lodash.isinteger": "^4.0.4",
"lodash.isnumber": "^3.0.3",
"lodash.isplainobject": "^4.0.6",
"lodash.isstring": "^4.0.1",
"lodash.once": "^4.0.0",
"ms": "^2.1.1",
"semver": "^7.5.4"
},
"engines": {
"node": ">=12",
"npm": ">=6"
}
},
"node_modules/jwa": {
"version": "1.4.2",
"resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.2.tgz",
"integrity": "sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw==",
"license": "MIT",
"dependencies": {
"buffer-equal-constant-time": "^1.0.1",
"ecdsa-sig-formatter": "1.0.11",
"safe-buffer": "^5.0.1"
}
},
"node_modules/jwks-rsa": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/jwks-rsa/-/jwks-rsa-3.2.0.tgz",
"integrity": "sha512-PwchfHcQK/5PSydeKCs1ylNym0w/SSv8a62DgHJ//7x2ZclCoinlsjAfDxAAbpoTPybOum/Jgy+vkvMmKz89Ww==",
"license": "MIT",
"dependencies": {
"@types/express": "^4.17.20",
"@types/jsonwebtoken": "^9.0.4",
"debug": "^4.3.4",
"jose": "^4.15.4",
"limiter": "^1.1.5",
"lru-memoizer": "^2.2.0"
},
"engines": {
"node": ">=14"
}
},
"node_modules/jwks-rsa/node_modules/jose": {
"version": "4.15.9",
"resolved": "https://registry.npmjs.org/jose/-/jose-4.15.9.tgz",
"integrity": "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/jws": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz",
"integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==",
"license": "MIT",
"dependencies": {
"jwa": "^1.4.1",
"safe-buffer": "^5.0.1"
}
},
"node_modules/limiter": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/limiter/-/limiter-1.1.5.tgz",
"integrity": "sha512-FWWMIEOxz3GwUI4Ts/IvgVy6LPvoMPgjMdQ185nN6psJyBJ4yOpzqm695/h5umdLJg2vW3GR5iG11MAkR2AzJA=="
},
"node_modules/lodash.clonedeep": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz",
"integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==",
"license": "MIT"
},
"node_modules/lodash.defaults": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz",
"integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==",
"license": "MIT"
},
"node_modules/lodash.includes": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
"integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
"license": "MIT"
},
"node_modules/lodash.isarguments": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz",
"integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==",
"license": "MIT"
},
"node_modules/lodash.isboolean": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
"integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",
"license": "MIT"
},
"node_modules/lodash.isinteger": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
"integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==",
"license": "MIT"
},
"node_modules/lodash.isnumber": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz",
"integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==",
"license": "MIT"
},
"node_modules/lodash.isplainobject": {
"version": "4.0.6",
"resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
"integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
"license": "MIT"
},
"node_modules/lodash.isstring": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz",
"integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==",
"license": "MIT"
},
"node_modules/lodash.once": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
"integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
"license": "MIT"
},
"node_modules/lru-cache": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
"integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
"license": "ISC",
"dependencies": {
"yallist": "^4.0.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/lru-memoizer": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/lru-memoizer/-/lru-memoizer-2.3.0.tgz",
"integrity": "sha512-GXn7gyHAMhO13WSKrIiNfztwxodVsP8IoZ3XfrJV4yH2x0/OeTO/FIaAHTY5YekdGgW94njfuKmyyt1E0mR6Ug==",
"license": "MIT",
"dependencies": {
"lodash.clonedeep": "^4.5.0",
"lru-cache": "6.0.0"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"license": "MIT",
@@ -710,6 +1270,49 @@
"node": "*"
}
},
"node_modules/morgan": {
"version": "1.10.1",
"resolved": "https://registry.npmjs.org/morgan/-/morgan-1.10.1.tgz",
"integrity": "sha512-223dMRJtI/l25dJKWpgij2cMtywuG/WiUKXdvwfbhGKBhy1puASqXwFzmWZ7+K73vUPoR7SS2Qz2cI/g9MKw0A==",
"license": "MIT",
"dependencies": {
"basic-auth": "~2.0.1",
"debug": "2.6.9",
"depd": "~2.0.0",
"on-finished": "~2.3.0",
"on-headers": "~1.1.0"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/morgan/node_modules/debug": {
"version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
"license": "MIT",
"dependencies": {
"ms": "2.0.0"
}
},
"node_modules/morgan/node_modules/ms": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT"
},
"node_modules/morgan/node_modules/on-finished": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz",
"integrity": "sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==",
"license": "MIT",
"dependencies": {
"ee-first": "1.1.1"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/ms": {
"version": "2.1.3",
"license": "MIT"
@@ -721,6 +1324,64 @@
"node": ">= 0.6"
}
},
"node_modules/node-addon-api": {
"version": "8.5.0",
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.5.0.tgz",
"integrity": "sha512-/bRZty2mXUIFY/xU5HLvveNHlswNJej+RnxBjOMkidWfwZzgTbPG1E3K5TOxRLOR+5hX7bSofy8yf1hZevMS8A==",
"license": "MIT",
"engines": {
"node": "^18 || ^20 || >= 21"
}
},
"node_modules/node-domexception": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz",
"integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==",
"deprecated": "Use your platform's native DOMException instead",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/jimmywarting"
},
{
"type": "github",
"url": "https://paypal.me/jimmywarting"
}
],
"license": "MIT",
"engines": {
"node": ">=10.5.0"
}
},
"node_modules/node-fetch": {
"version": "3.3.2",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz",
"integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==",
"license": "MIT",
"dependencies": {
"data-uri-to-buffer": "^4.0.0",
"fetch-blob": "^3.1.4",
"formdata-polyfill": "^4.0.10"
},
"engines": {
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/node-fetch"
}
},
"node_modules/node-gyp-build": {
"version": "4.8.4",
"resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz",
"integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==",
"license": "MIT",
"bin": {
"node-gyp-build": "bin.js",
"node-gyp-build-optional": "optional.js",
"node-gyp-build-test": "build-test.js"
}
},
"node_modules/nodemon": {
"version": "3.1.10",
"dev": true,
@@ -783,6 +1444,15 @@
"node": ">= 0.8"
}
},
"node_modules/on-headers": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz",
"integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/once": {
"version": "1.4.0",
"license": "ISC",
@@ -967,6 +1637,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/random-bytes": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/random-bytes/-/random-bytes-1.0.0.tgz",
"integrity": "sha512-iv7LhNVO047HzYR3InF6pUcUsPQiHTM1Qal51DcGSuZFBil1aBBWG5eHPNek7bvILMaYJ/8RU1e8w1AMdHmLQQ==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/range-parser": {
"version": "1.2.1",
"license": "MIT",
@@ -998,6 +1677,43 @@
"node": ">=8.10.0"
}
},
"node_modules/redis": {
"version": "5.8.2",
"resolved": "https://registry.npmjs.org/redis/-/redis-5.8.2.tgz",
"integrity": "sha512-31vunZj07++Y1vcFGcnNWEf5jPoTkGARgfWI4+Tk55vdwHxhAvug8VEtW7Cx+/h47NuJTEg/JL77zAwC6E0OeA==",
"license": "MIT",
"dependencies": {
"@redis/bloom": "5.8.2",
"@redis/client": "5.8.2",
"@redis/json": "5.8.2",
"@redis/search": "5.8.2",
"@redis/time-series": "5.8.2"
},
"engines": {
"node": ">= 18"
}
},
"node_modules/redis-errors": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz",
"integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==",
"license": "MIT",
"engines": {
"node": ">=4"
}
},
"node_modules/redis-parser": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz",
"integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==",
"license": "MIT",
"dependencies": {
"redis-errors": "^1.0.0"
},
"engines": {
"node": ">=4"
}
},
"node_modules/router": {
"version": "2.2.0",
"license": "MIT",
@@ -1036,7 +1752,6 @@
},
"node_modules/semver": {
"version": "7.7.2",
"dev": true,
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
@@ -1204,6 +1919,12 @@
"node": ">= 10.x"
}
},
"node_modules/standard-as-callback": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz",
"integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==",
"license": "MIT"
},
"node_modules/statuses": {
"version": "2.0.2",
"license": "MIT",
@@ -1260,11 +1981,29 @@
"node": ">= 0.6"
}
},
"node_modules/uid-safe": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/uid-safe/-/uid-safe-2.1.5.tgz",
"integrity": "sha512-KPHm4VL5dDXKz01UuEd88Df+KzynaohSL9fBh096KWAxSKZQDI2uBrVqtvRM4rwrIrRRKsdLNML/lnaaVSRioA==",
"license": "MIT",
"dependencies": {
"random-bytes": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/undefsafe": {
"version": "2.0.5",
"dev": true,
"license": "MIT"
},
"node_modules/undici-types": {
"version": "7.10.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.10.0.tgz",
"integrity": "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag==",
"license": "MIT"
},
"node_modules/unpipe": {
"version": "1.0.0",
"license": "MIT",
@@ -1279,6 +2018,15 @@
"node": ">= 0.8"
}
},
"node_modules/web-streams-polyfill": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz",
"integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==",
"license": "MIT",
"engines": {
"node": ">= 8"
}
},
"node_modules/which": {
"version": "2.0.2",
"dev": true,
@@ -1303,6 +2051,12 @@
"engines": {
"node": ">=0.4"
}
},
"node_modules/yallist": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
"license": "ISC"
}
}
}
+11
View File
@@ -15,14 +15,25 @@
"nodemon": "^3.1.10"
},
"dependencies": {
"bcrypt": "^6.0.0",
"chalk": "^5.6.0",
"connect-redis": "^9.0.0",
"cookie-parser": "^1.4.7",
"cors": "^2.8.5",
"dotenv": "^17.2.1",
"ejs": "^3.1.10",
"express": "^5.1.0",
"express-ejs-layouts": "^2.5.1",
"express-session": "^1.18.2",
"ioredis": "^5.7.0",
"jose": "^6.1.0",
"jsonwebtoken": "^9.0.2",
"jwks-rsa": "^3.2.0",
"morgan": "^1.10.1",
"node-fetch": "^3.3.2",
"pg": "^8.16.3",
"pg-format": "^1.0.4",
"redis": "^5.8.2",
"serve-favicon": "^2.5.1"
},
"keywords": [],
+181
View File
@@ -0,0 +1,181 @@
// // ----------------------------------------------------------
// // API
// // ----------------------------------------------------------
// app.get('/api/tables', async (_req, res) => {
// res.json(ALLOWED_TABLES);
// });
// app.get('/api/schema/:table', async (req, res) => {
// try {
// const table = ensureTable(req.params.table);
// const client = await getClient();
// try {
// const columns = await loadColumns(client, table);
// const fks = await loadForeignKeys(client, table);
// const enriched = columns.map(c => ({ ...c, foreign: fks[c.column_name] || null }));
// res.json({ table, columns: enriched });
// } finally { client.release(); }
// } catch (e) {
// res.status(400).json({ error: e.message });
// }
// });
// app.get('/api/options/:table/:column', async (req, res) => {
// try {
// const table = ensureTable(req.params.table);
// const column = req.params.column;
// if (!VALID_IDENT.test(column)) throw new Error('Columna inválida');
// const client = await getClient();
// try {
// const fks = await loadForeignKeys(client, table);
// const fk = fks[column];
// if (!fk) return res.json([]);
// const refTable = fk.foreign_table;
// const refId = fk.foreign_column;
// const labelCol = await pickLabelColumn(client, refTable);
// const sql = `SELECT ${q(refId)} AS id, ${q(labelCol)} AS label FROM ${q(refTable)} ORDER BY ${q(labelCol)} LIMIT 1000`;
// const result = await client.query(sql);
// res.json(result.rows);
// } finally { client.release(); }
// } catch (e) {
// res.status(400).json({ error: e.message });
// }
// });
// app.get('/api/table/:table', async (req, res) => {
// try {
// const table = ensureTable(req.params.table);
// const limit = Math.min(parseInt(req.query.limit || '100', 10), 1000);
// const client = await getClient();
// try {
// const pks = await loadPrimaryKey(client, table);
// const orderBy = pks.length ? `ORDER BY ${pks.map(q).join(', ')} DESC` : '';
// const sql = `SELECT * FROM ${q(table)} ${orderBy} LIMIT ${limit}`;
// const result = await client.query(sql);
// // Normalizar: siempre devolver objetos {col: valor}
// const colNames = result.fields.map(f => f.name);
// let rows = result.rows;
// if (rows.length && Array.isArray(rows[0])) {
// rows = rows.map(r => Object.fromEntries(r.map((v, i) => [colNames[i], v])));
// }
// res.json(rows);
// } finally { client.release(); }
// } catch (e) {
// res.status(400).json({ error: e.message, code: e.code, detail: e.detail });
// }
// });
// app.post('/api/table/:table', async (req, res) => {
// const table = ensureTable(req.params.table);
// const payload = req.body || {};
// try {
// const client = await getClient();
// try {
// const columns = await loadColumns(client, table);
// const insertable = columns.filter(c =>
// !c.is_primary && !c.is_identity && !(c.column_default || '').startsWith('nextval(')
// );
// const allowedCols = new Set(insertable.map(c => c.column_name));
// const cols = [];
// const vals = [];
// const params = [];
// let idx = 1;
// for (const [k, v] of Object.entries(payload)) {
// if (!allowedCols.has(k)) continue;
// if (!VALID_IDENT.test(k)) continue;
// cols.push(q(k));
// vals.push(`$${idx++}`);
// params.push(v);
// }
// if (!cols.length) {
// const { rows } = await client.query(`INSERT INTO ${q(table)} DEFAULT VALUES RETURNING *`);
// res.status(201).json({ inserted: rows[0] });
// } else {
// const { rows } = await client.query(
// `INSERT INTO ${q(table)} (${cols.join(', ')}) VALUES (${vals.join(', ')}) RETURNING *`,
// params
// );
// res.status(201).json({ inserted: rows[0] });
// }
// } catch (e) {
// if (e.code === '23503') return res.status(400).json({ error: 'Violación de clave foránea', detail: e.detail });
// if (e.code === '23505') return res.status(400).json({ error: 'Violación de unicidad', detail: e.detail });
// if (e.code === '23514') return res.status(400).json({ error: 'Violación de CHECK', detail: e.detail });
// if (e.code === '23502') return res.status(400).json({ error: 'Campo NOT NULL faltante', detail: e.detail });
// throw e;
// }
// } catch (e) {
// res.status(400).json({ error: e.message });
// }
// });
// app.get('/api/comandas', async (req, res, next) => {
// try {
// const estado = (req.query.estado || '').trim() || null;
// const limit = Math.min(parseInt(req.query.limit || '200', 10), 1000);
// const { rows } = await mainPool.query(
// `SELECT * FROM public.f_comandas_resumen($1, $2)`,
// [estado, limit]
// );
// res.json(rows);
// } catch (e) { next(e); }
// });
// // Detalle de una comanda (con nombres de productos)
// // GET /api/comandas/:id/detalle
// app.get('/api/comandas/:id/detalle', (req, res, next) =>
// mainPool.query(
// `SELECT id_det_comanda, id_producto, producto_nombre,
// cantidad, pre_unitario, subtotal, observaciones
// FROM public.v_comandas_detalle_items
// WHERE id_comanda = $1::int
// ORDER BY id_det_comanda`,
// [req.params.id]
// )
// .then(r => res.json(r.rows))
// .catch(next)
// );
// // Cerrar comanda (setea estado y fec_cierre en DB)
// app.post('/api/comandas/:id/cerrar', async (req, res, next) => {
// try {
// const id = Number(req.params.id);
// if (!Number.isInteger(id) || id <= 0) {
// return res.status(400).json({ error: 'id inválido' });
// }
// const { rows } = await mainPool.query(
// `SELECT public.f_cerrar_comanda($1) AS data`,
// [id]
// );
// if (!rows.length || rows[0].data === null) {
// return res.status(404).json({ error: 'Comanda no encontrada' });
// }
// res.json(rows[0].data);
// } catch (err) { next(err); }
// });
// Abrir (reabrir) comanda
app.post('/api/comandas/:id/abrir', async (req, res, next) => {
try {
const id = Number(req.params.id);
if (!Number.isInteger(id) || id <= 0) {
return res.status(400).json({ error: 'id inválido' });
}
const { rows } = await mainPool.query(
`SELECT public.f_abrir_comanda($1) AS data`,
[id]
);
if (!rows.length || rows[0].data === null) {
return res.status(404).json({ error: 'Comanda no encontrada' });
}
res.json(rows[0].data);
} catch (err) { next(err); }
});
+230
View File
@@ -0,0 +1,230 @@
// // GET producto + receta
// app.get('/api/rpc/get_producto/:id', async (req, res) => {
// const id = Number(req.params.id);
// const { rows } = await mainPool.query('SELECT public.get_producto($1) AS data', [id]);
// res.json(rows[0]?.data || {});
// });
// // POST guardar producto + receta
// app.post('/api/rpc/save_producto', async (req, res) => {
// try {
// // console.debug('receta payload:', req.body?.receta); // habilitalo si lo necesitás
// const q = 'SELECT public.save_producto($1,$2,$3,$4,$5,$6,$7::jsonb) AS id_producto';
// const { id_producto = null, nombre, img_producto = null, precio = 0, activo = true, id_categoria = null, receta = [] } = req.body || {};
// const params = [id_producto, nombre, img_producto, precio, activo, id_categoria, JSON.stringify(receta || [])];
// const { rows } = await mainPool.query(q, params);
// res.json(rows[0] || {});
// } catch (e) {
// console.error(e);
// res.status(500).json({ error: 'save_producto failed' });
// }
// });
// // GET MP + proveedores
// app.get('/api/rpc/get_materia/:id', async (req, res) => {
// const id = Number(req.params.id);
// try {
// const { rows } = await mainPool.query('SELECT public.get_materia_prima($1) AS data', [id]);
// res.json(rows[0]?.data || {});
// } catch (e) {
// console.error(e);
// res.status(500).json({ error: 'get_materia failed' });
// }
// });
// // SAVE MP + proveedores (array)
// app.post('/api/rpc/save_materia', async (req, res) => {
// const { id_mat_prima = null, nombre, unidad, activo = true, proveedores = [] } = req.body || {};
// try {
// const q = 'SELECT public.save_materia_prima($1,$2,$3,$4,$5::jsonb) AS id_mat_prima';
// const params = [id_mat_prima, nombre, unidad, activo, JSON.stringify(proveedores || [])];
// const { rows } = await mainPool.query(q, params);
// res.json(rows[0] || {});
// } catch (e) {
// console.error(e);
// res.status(500).json({ error: 'save_materia failed' });
// }
// });
// // POST /api/rpc/find_usuarios_por_documentos { docs: ["12345678","09123456", ...] }
// app.post('/api/rpc/find_usuarios_por_documentos', async (req, res) => {
// try {
// const docs = Array.isArray(req.body?.docs) ? req.body.docs : [];
// const sql = 'SELECT public.find_usuarios_por_documentos($1::jsonb) AS data';
// const { rows } = await mainPool.query(sql, [JSON.stringify(docs)]);
// res.json(rows[0]?.data || {});
// } catch (e) {
// console.error(e);
// res.status(500).json({ error: 'find_usuarios_por_documentos failed' });
// }
// });
// // POST /api/rpc/import_asistencia { registros: [...], origen?: "AGL_001.txt" }
// app.post('/api/rpc/import_asistencia', async (req, res) => {
// try {
// const registros = Array.isArray(req.body?.registros) ? req.body.registros : [];
// const origen = req.body?.origen || null;
// const sql = 'SELECT public.import_asistencia($1::jsonb,$2) AS data';
// const { rows } = await mainPool.query(sql, [JSON.stringify(registros), origen]);
// res.json(rows[0]?.data || {});
// } catch (e) {
// console.error(e);
// res.status(500).json({ error: 'import_asistencia failed' });
// }
// });
// // Consultar datos de asistencia (raw + pares) para un usuario y rango
// app.post('/api/rpc/asistencia_get', async (req, res) => {
// try {
// const { doc, desde, hasta } = req.body || {};
// const sql = 'SELECT public.asistencia_get($1::text,$2::date,$3::date) AS data';
// const { rows } = await mainPool.query(sql, [doc, desde, hasta]);
// res.json(rows[0]?.data || {});
// } catch (e) {
// console.error(e); res.status(500).json({ error: 'asistencia_get failed' });
// }
// });
// // Editar un registro crudo y recalcular pares
// app.post('/api/rpc/asistencia_update_raw', async (req, res) => {
// try {
// const { id_raw, fecha, hora, modo } = req.body || {};
// const sql = 'SELECT public.asistencia_update_raw($1::bigint,$2::date,$3::text,$4::text) AS data';
// const { rows } = await mainPool.query(sql, [id_raw, fecha, hora, modo ?? null]);
// res.json(rows[0]?.data || {});
// } catch (e) {
// console.error(e); res.status(500).json({ error: 'asistencia_update_raw failed' });
// }
// });
// // Eliminar un registro crudo y recalcular pares
// app.post('/api/rpc/asistencia_delete_raw', async (req, res) => {
// try {
// const { id_raw } = req.body || {};
// const sql = 'SELECT public.asistencia_delete_raw($1::bigint) AS data';
// const { rows } = await mainPool.query(sql, [id_raw]);
// res.json(rows[0]?.data || {});
// } catch (e) {
// console.error(e); res.status(500).json({ error: 'asistencia_delete_raw failed' });
// }
// });
// // POST /api/rpc/report_tickets { year }
// app.post('/api/rpc/report_tickets', async (req, res) => {
// try {
// const y = parseInt(req.body?.year ?? req.query?.year, 10);
// const year = (Number.isFinite(y) && y >= 2000 && y <= 2100)
// ? y
// : (new Date()).getFullYear();
// const { rows } = await mainPool.query(
// 'SELECT public.report_tickets_year($1::int) AS j', [year]
// );
// res.json(rows[0].j);
// } catch (e) {
// console.error('report_tickets error:', e);
// res.status(500).json({
// error: 'report_tickets failed',
// message: e.message, detail: e.detail, where: e.where, code: e.code
// });
// }
// });
// // POST /api/rpc/report_asistencia { desde: 'YYYY-MM-DD', hasta: 'YYYY-MM-DD' }
// app.post('/api/rpc/report_asistencia', async (req, res) => {
// try {
// let { desde, hasta } = req.body || {};
// // defaults si vienen vacíos/invalidos
// const re = /^\d{4}-\d{2}-\d{2}$/;
// if (!re.test(desde) || !re.test(hasta)) {
// const end = new Date();
// const start = new Date(end); start.setDate(end.getDate() - 30);
// desde = start.toISOString().slice(0, 10);
// hasta = end.toISOString().slice(0, 10);
// }
// const { rows } = await mainPool.query(
// 'SELECT public.report_asistencia($1::date,$2::date) AS j', [desde, hasta]
// );
// res.json(rows[0].j);
// } catch (e) {
// console.error('report_asistencia error:', e);
// res.status(500).json({
// error: 'report_asistencia failed',
// message: e.message, detail: e.detail, where: e.where, code: e.code
// });
// }
// });
// // Guardar (insert/update)
// app.post('/api/rpc/save_compra', async (req, res) => {
// try {
// const { id_compra, id_proveedor, fec_compra, detalles } = req.body || {};
// const sql = 'SELECT * FROM public.save_compra($1::int,$2::int,$3::timestamptz,$4::jsonb)';
// const args = [id_compra ?? null, id_proveedor, fec_compra ? new Date(fec_compra) : null, JSON.stringify(detalles)];
// const { rows } = await mainPool.query(sql, args);
// res.json(rows[0]); // { id_compra, total }
// } catch (e) {
// console.error('save_compra error:', e);
// res.status(500).json({ error: 'save_compra failed', message: e.message, detail: e.detail, where: e.where, code: e.code });
// }
// });
// // Obtener para editar
// app.post('/api/rpc/get_compra', async (req, res) => {
// try {
// const { id_compra } = req.body || {};
// const sql = `SELECT public.get_compra($1::int) AS data`;
// const { rows } = await mainPool.query(sql, [id_compra]);
// res.json(rows[0]?.data || {});
// } catch (e) {
// console.error(e); res.status(500).json({ error: 'get_compra failed' });
// }
// });
// // Eliminar
// app.post('/api/rpc/delete_compra', async (req, res) => {
// try {
// const { id_compra } = req.body || {};
// await mainPool.query(`SELECT public.delete_compra($1::int)`, [id_compra]);
// res.json({ ok: true });
// } catch (e) {
// console.error(e); res.status(500).json({ error: 'delete_compra failed' });
// }
// });
// // POST /api/rpc/report_gastos { year: 2025 }
// app.post('/api/rpc/report_gastos', async (req, res) => {
// try {
// const year = parseInt(req.body?.year ?? new Date().getFullYear(), 10);
// const { rows } = await mainPool.query(
// 'SELECT public.report_gastos($1::int) AS j', [year]
// );
// res.json(rows[0].j);
// } catch (e) {
// console.error('report_gastos error:', e);
// res.status(500).json({
// error: 'report_gastos failed',
// message: e.message, detail: e.detail, code: e.code
// });
// }
// });
// // (Opcional) GET para probar rápido desde el navegador:
// // /api/rpc/report_gastos?year=2025
// app.get('/api/rpc/report_gastos', async (req, res) => {
// try {
// const year = parseInt(req.query.year ?? new Date().getFullYear(), 10);
// const { rows } = await mainPool.query(
// 'SELECT public.report_gastos($1::int) AS j', [year]
// );
// res.json(rows[0].j);
// } catch (e) {
// console.error('report_gastos error:', e);
// res.status(500).json({
// error: 'report_gastos failed',
// message: e.message, detail: e.detail, code: e.code
// });
// }
// });
+291 -756
View File
File diff suppressed because it is too large Load Diff
+37
View File
@@ -0,0 +1,37 @@
// -----------------------------------------------------------------------------
// Middlewares de Auth/Tenant
// -----------------------------------------------------------------------------
export function requireAuth(req, res, next) {
const authHeader = req.headers["authorization"];
if (!authHeader) return res.status(401).send("Falta token");
const token = authHeader.split(" ")[1];
jwt.verify(token, getKey, { algorithms: ["RS256"] }, (err, decoded) => {
if (err) return res.status(403).send("Token inválido o vencido");
// Guardamos los claims del token en req.user
req.user = {
id: decoded.sub,
email: decoded.email,
username: decoded.preferred_username,
name: decoded.name,
roles: decoded.groups || []
};
next();
});
}
// export function exposeViewState(req, res, next) {
// res.locals.pageTitle = res.locals.pageTitle || '';
// res.locals.pageId = res.locals.pageId || '';
// res.locals.tenant_uuid = req.session?.tenant?.uuid || null;
// res.locals.ak_user_uuid = req.session?.tenant?.ak_user_uuid || null;
// // también pásalos como props al render
// res.locals.viewUser = req.session?.user || null;
// res.locals.viewCookies = req.cookies || {};
// res.locals.viewSession = req.session ? JSON.parse(JSON.stringify(req.session)) : {};
// next();
// }
@@ -0,0 +1,76 @@
// ----------------------------------------------------------
// Introspección de esquema
// ----------------------------------------------------------
export async function loadColumns(client, table) {
const sql = `
SELECT
c.column_name,
c.data_type,
c.is_nullable = 'YES' AS is_nullable,
c.column_default,
(SELECT EXISTS (
SELECT 1 FROM pg_attribute a
JOIN pg_class t ON t.oid = a.attrelid
JOIN pg_index i ON i.indrelid = t.oid AND a.attnum = ANY(i.indkey)
WHERE t.relname = $1 AND i.indisprimary AND a.attname = c.column_name
)) AS is_primary,
(SELECT a.attgenerated = 's' OR a.attidentity IN ('a','d')
FROM pg_attribute a
JOIN pg_class t ON t.oid = a.attrelid
WHERE t.relname = $1 AND a.attname = c.column_name
) AS is_identity
FROM information_schema.columns c
WHERE c.table_schema='public' AND c.table_name=$1
ORDER BY c.ordinal_position
`;
const { rows } = await client.query(sql, [table]);
return rows;
}
export async function loadForeignKeys(client, table) {
const sql = `
SELECT
kcu.column_name,
ccu.table_name AS foreign_table,
ccu.column_name AS foreign_column
FROM information_schema.table_constraints tc
JOIN information_schema.key_column_usage kcu
ON tc.constraint_name = kcu.constraint_name AND tc.table_schema = kcu.table_schema
JOIN information_schema.constraint_column_usage ccu
ON ccu.constraint_name = tc.constraint_name AND ccu.table_schema = tc.table_schema
WHERE tc.table_schema='public' AND tc.table_name=$1 AND tc.constraint_type='FOREIGN KEY'
`;
const { rows } = await client.query(sql, [table]);
const map = {};
for (const r of rows) map[r.column_name] = { foreign_table: r.foreign_table, foreign_column: r.foreign_column };
return map;
}
export async function loadPrimaryKey(client, table) {
const sql = `
SELECT a.attname AS column_name
FROM pg_index i
JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = ANY(i.indkey)
JOIN pg_class t ON t.oid = i.indrelid
WHERE t.relname = $1 AND i.indisprimary
`;
const { rows } = await client.query(sql, [table]);
return rows.map(r => r.column_name);
}
// label column for FK options
export async function pickLabelColumn(client, refTable) {
const preferred = ['nombre', 'raz_social', 'apodo', 'documento', 'correo', 'telefono'];
const { rows } = await client.query(
`SELECT column_name, data_type
FROM information_schema.columns
WHERE table_schema='public' AND table_name=$1
ORDER BY ordinal_position`, [refTable]
);
for (const cand of preferred) {
if (rows.find(r => r.column_name === cand)) return cand;
}
const textish = rows.find(r => /text|character varying|varchar/i.test(r.data_type));
if (textish) return textish.column_name;
return rows[0]?.column_name || 'id';
}
+158
View File
@@ -0,0 +1,158 @@
<!-- views/inicio_v2.ejs -->
<!doctype html>
<html lang="es">
<head>
<meta charset="utf-8" />
<meta http-equiv="x-ua-compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Inicio • SuiteCoffee</title>
<style>
:root { --bg:#0b0b0c; --card:#141519; --text:#e7e9ee; --muted:#a5acb8; --accent:#6ee7b7; --border:#232733; }
* { box-sizing: border-box; }
body { margin:0; font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, Noto Sans, Helvetica Neue, Arial, "Apple Color Emoji","Segoe UI Emoji"; background: var(--bg); color: var(--text); }
.wrap { max-width: 960px; margin: 48px auto; padding: 0 20px; }
.card { background: var(--card); border: 1px solid var(--border); border-radius: 16px; padding: 20px; box-shadow: 0 10px 30px rgba(0,0,0,.25); }
h1 { font-size: 26px; margin: 0 0 8px; }
p.lead { color: var(--muted); margin: 0 0 20px; }
h2 { margin: 24px 0 10px; font-size: 18px; color: var(--accent); }
table { width: 100%; border-collapse: collapse; overflow: hidden; border-radius: 12px; border: 1px solid var(--border); }
th, td { padding: 10px 12px; text-align: left; border-bottom: 1px solid var(--border); vertical-align: top; }
th { color: var(--muted); font-weight: 600; letter-spacing: .02em; }
tbody tr:last-child td { border-bottom: 0; }
code, pre { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; }
.grid { display: grid; gap: 18px; grid-template-columns: 1fr; }
@media (min-width: 900px) { .grid { grid-template-columns: 1fr 1fr; } }
.muted { color: var(--muted); }
.pill { display:inline-block; padding:2px 8px; border:1px solid var(--border); border-radius:999px; color:var(--muted); font-size:12px; }
.k { color:#93c5fd; }
.v { color:#fca5a5; word-break: break-all; }
</style>
</head>
<body>
<div class="wrap">
<div class="card">
<%
// 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)) ||
(hasSession && (session.user?.email || session.user?.name)) ||
'usuario';
%>
<h1>Hola, <span><%= displayName %></span> 👋</h1>
<p class="lead">Bienvenido a SuiteCoffee. Este es tu inicio y panel de diagnóstico de cookies/sesión.</p>
<% if (hasUser) { %>
<h2>Sesión de Aplicación (user)</h2>
<table>
<tbody>
<% for (const [k,v] of Object.entries(user)) { %>
<tr>
<th><code class="k"><%= k %></code></th>
<td><code class="v"><%= typeof v === 'object' ? JSON.stringify(v) : String(v) %></code></td>
</tr>
<% } %>
</tbody>
</table>
<% } %>
<% if (hasSession) { %>
<h2>Sesión Express (req.session)</h2>
<table>
<tbody>
<% for (const [k,v] of Object.entries(session)) { %>
<tr>
<th><code class="k"><%= k %></code></th>
<td><code class="v"><%= typeof v === 'object' ? JSON.stringify(v) : String(v) %></code></td>
</tr>
<% } %>
</tbody>
</table>
<% } %>
<div class="grid" style="margin-top:18px;">
<section class="card">
<h2>Cookies (servidor: <code>req.cookies</code>)</h2>
<% if (hasCookies) { %>
<table>
<thead>
<tr><th>Nombre</th><th>Valor</th></tr>
</thead>
<tbody>
<% for (const [name, value] of Object.entries(cookies)) { %>
<tr>
<td><code class="k"><%= name %></code></td>
<td><code class="v"><%= typeof value === 'object' ? JSON.stringify(value) : String(value) %></code></td>
</tr>
<% } %>
</tbody>
</table>
<% } else { %>
<p class="muted">
No se recibieron cookies del lado servidor (<code>req.cookies</code>).
Asegurate de usar <code>cookie-parser</code> y de pasar <code>cookies</code> al render:
<br /><code>res.render('inicio_v2', { user: req.session.user, cookies: req.cookies, session: req.session })</code>
</p>
<% } %>
</section>
<section class="card">
<h2>Cookies (navegador: <code>document.cookie</code>)</h2>
<table id="client-cookies">
<thead>
<tr><th>Nombre</th><th>Valor</th></tr>
</thead>
<tbody>
<tr><td colspan="2" class="muted">Cargando…</td></tr>
</tbody>
</table>
<p class="muted" style="margin-top:10px;">
Total cookies en navegador: <span id="cookie-count">0</span>
</p>
<p class="muted" style="margin-top:10px;">Raw <code>document.cookie</code>:</p>
<pre id="cookie-raw" class="muted" style="white-space: pre-wrap; word-break: break-all;"></pre>
</section>
</div>
</div>
</div>
<script>
(function () {
try {
const tbody = document.querySelector('#client-cookies tbody');
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 = '<tr><td colspan="2"><em class="muted">sin cookies</em></td></tr>';
return;
}
tbody.innerHTML = '';
for (const kv of pairs) {
const i = kv.indexOf('=');
const name = i >= 0 ? kv.slice(0, i) : kv;
const value = i >= 0 ? kv.slice(i + 1) : '';
const tr = document.createElement('tr');
const td1 = document.createElement('td');
const td2 = document.createElement('td');
td1.innerHTML = '<code class="k"></code>';
td2.innerHTML = '<code class="v"></code>';
td1.querySelector('code').textContent = decodeURIComponent(name);
td2.querySelector('code').textContent = decodeURIComponent(value);
tr.append(td1, td2);
tbody.appendChild(tr);
}
} catch (err) {
console.error('cookie render error:', err);
}
})();
</script>
</body>
</html>
+130
View File
@@ -0,0 +1,130 @@
<!-- views/inicio.ejs -->
<!doctype html>
<html lang="es">
<head>
<meta charset="utf-8" />
<meta http-equiv="x-ua-compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Inicio • SuiteCoffee</title>
<style>
:root { --bg:#0b0b0c; --card:#141519; --text:#e7e9ee; --muted:#a5acb8; --accent:#6ee7b7; --border:#232733; }
* { box-sizing: border-box; }
body { margin:0; font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, Noto Sans, Helvetica Neue, Arial, "Apple Color Emoji","Segoe UI Emoji"; background: var(--bg); color: var(--text); }
.wrap { max-width: 960px; margin: 48px auto; padding: 0 20px; }
.card { background: var(--card); border: 1px solid var(--border); border-radius: 16px; padding: 20px; box-shadow: 0 10px 30px rgba(0,0,0,.25); }
h1 { font-size: 26px; margin: 0 0 8px; }
p.lead { color: var(--muted); margin: 0 0 20px; }
h2 { margin: 24px 0 10px; font-size: 18px; color: var(--accent); }
table { width: 100%; border-collapse: collapse; overflow: hidden; border-radius: 12px; border: 1px solid var(--border); }
th, td { padding: 10px 12px; text-align: left; border-bottom: 1px solid var(--border); vertical-align: top; }
th { color: var(--muted); font-weight: 600; letter-spacing: .02em; }
tbody tr:last-child td { border-bottom: 0; }
code, pre { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; }
.grid { display: grid; gap: 18px; grid-template-columns: 1fr; }
@media (min-width: 900px) { .grid { grid-template-columns: 1fr 1fr; } }
.muted { color: var(--muted); }
.pill { display:inline-block; padding:2px 8px; border:1px solid var(--border); border-radius:999px; color:var(--muted); font-size:12px; }
.k { color:#93c5fd; }
.v { color:#fca5a5; word-break: break-all; }
</style>
</head>
<body>
<div class="wrap">
<div class="card">
<%
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';
%>
<h1>Hola, <span><%= displayName %></span> 👋</h1>
<p class="lead">Bienvenido a SuiteCoffee. Este es tu inicio.</p>
<% if (hasUser) { %>
<h2>Sesión</h2>
<table>
<tbody>
<% for (const [k,v] of Object.entries(user)) { %>
<tr>
<th><code class="k"><%= k %></code></th>
<td><code class="v"><%= typeof v === 'object' ? JSON.stringify(v) : String(v) %></code></td>
</tr>
<% } %>
</tbody>
</table>
<% } %>
<div class="grid" style="margin-top:18px;">
<section class="card">
<h2>Cookies (servidor)</h2>
<% if (hasCookies) { %>
<table>
<thead>
<tr><th>Nombre</th><th>Valor</th></tr>
</thead>
<tbody>
<% for (const [name, value] of Object.entries(cookies)) { %>
<tr>
<td><code class="k"><%= name %></code></td>
<td><code class="v"><%= typeof value === 'object' ? JSON.stringify(value) : String(value) %></code></td>
</tr>
<% } %>
</tbody>
</table>
<% } else { %>
<p class="muted">No se recibieron cookies desde el servidor (req.cookies). ¿Estás usando <code>cookie-parser</code> o pasando <code>cookies</code> al render?</p>
<% } %>
</section>
<section class="card">
<h2>Cookies (navegador)</h2>
<table id="client-cookies">
<thead>
<tr><th>Nombre</th><th>Valor</th></tr>
</thead>
<tbody>
<tr><td colspan="2" class="muted">Cargando…</td></tr>
</tbody>
</table>
<p class="muted" style="margin-top:10px;">Raw <code>document.cookie</code>:</p>
<pre id="cookie-raw" class="muted" style="white-space: pre-wrap; word-break: break-all;"></pre>
</section>
</div>
</div>
</div>
<script>
(function () {
try {
const tbody = document.querySelector('#client-cookies tbody');
const raw = document.cookie || '';
document.getElementById('cookie-raw').textContent = raw || '(sin cookies)';
const pairs = raw ? raw.split(/;\s*/) : [];
if (!pairs.length) {
tbody.innerHTML = '<tr><td colspan="2"><em class="muted">sin cookies</em></td></tr>';
return;
}
tbody.innerHTML = '';
for (const kv of pairs) {
const i = kv.indexOf('=');
const name = i >= 0 ? kv.slice(0, i) : kv;
const value = i >= 0 ? kv.slice(i + 1) : '';
const tr = document.createElement('tr');
const td1 = document.createElement('td');
const td2 = document.createElement('td');
td1.innerHTML = '<code class="k"></code>';
td2.innerHTML = '<code class="v"></code>';
td1.querySelector('code').textContent = decodeURIComponent(name);
td2.querySelector('code').textContent = decodeURIComponent(value);
tr.append(td1, td2);
tbody.appendChild(tr);
}
} catch (err) {
console.error('cookie render error:', err);
}
})();
</script>
</body>
</html>
+164
View File
@@ -0,0 +1,164 @@
<!doctype html>
<html lang="es">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title><%= typeof pageTitle !== 'undefined' ? pageTitle : 'Iniciar sesión · SuiteCoffee' %></title>
<!-- Bootstrap 5 (minimal) -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" crossorigin="anonymous">
<style>
:root {
--col-pri: #<%= (process.env.COL_PRI || '452D19') %>;
--col-sec: #<%= (process.env.COL_SEC || 'D7A666') %>;
--col-bg: #<%= (process.env.COL_BG || 'FFA500') %>33; /* con alpha */
}
body { background: radial-gradient(1200px 600px at 10% -10%, var(--col-bg), transparent), #f8f9fa; }
.brand { color: var(--col-pri); }
.btn-sso { background: var(--col-pri); color: #fff; border-color: var(--col-pri); }
.btn-sso:hover { filter: brightness(1.05); color: #fff; }
.card { border-radius: 14px; }
.form-hint { font-size: .875rem; color: #6c757d; }
.divider { display:flex; align-items:center; text-transform:uppercase; font-size:.8rem; color:#6c757d; }
.divider::before, .divider::after { content:""; height:1px; background:#dee2e6; flex:1; }
.divider:not(:empty)::before { margin-right:.75rem; }
.divider:not(:empty)::after { margin-left:.75rem; }
</style>
</head>
<body>
<div class="container py-5">
<div class="row justify-content-center">
<div class="col-12 col-sm-10 col-md-8 col-lg-6 col-xl-5">
<div class="text-center mb-4">
<h1 class="brand fw-bold">SuiteCoffee</h1>
<p class="text-secondary mb-0">Accedé a tu cuenta</p>
</div>
<!-- Mensajes (query ?msg= / ?error=) -->
<div id="flash" class="mb-3" style="display:none"></div>
<div class="card shadow-sm">
<div class="card-body p-4 p-md-5">
<!-- SSO con Authentik -->
<div class="d-grid gap-2 mb-3">
<a href="/auth/login" class="btn btn-sso btn-lg" id="btn-sso">
Ingresar con SSO (Authentik)
</a>
</div>
<div class="divider my-3">o</div>
<!-- Registro mínimo (usa POST /api/users/register) -->
<form id="form-register" class="needs-validation" novalidate>
<div class="mb-3">
<label for="email" class="form-label">Email</label>
<input type="email" class="form-control" id="email" name="email" placeholder="tu@correo.com" required>
<div class="invalid-feedback">Ingresá un email válido.</div>
</div>
<div class="mb-3">
<label for="display_name" class="form-label">Nombre a mostrar</label>
<input type="text" class="form-control" id="display_name" name="display_name" placeholder="Ej.: Juan Pérez" required>
<div class="invalid-feedback">Ingresá tu nombre.</div>
</div>
<div class="mb-3">
<label for="tenant_uuid" class="form-label">Código de organización (tenant UUID)</label>
<input type="text" class="form-control" id="tenant_uuid" name="tenant_uuid" placeholder="Ej.: 4b8d0f6a-...">
<div class="form-hint">Si te invitaron a una organización existente, pegá aquí su UUID. Si sos el primero de tu empresa, dejalo vacío y el equipo te asignará uno.</div>
</div>
<div class="mb-3">
<label for="role" class="form-label">Rol</label>
<select id="role" name="role" class="form-select">
<option value="owner">Owner</option>
<option value="admin">Admin</option>
<option value="staff">Staff</option>
</select>
</div>
<div class="d-grid gap-2">
<button type="submit" class="btn btn-outline-dark">Crear cuenta</button>
</div>
</form>
<p class="text-center text-muted mt-3 mb-0" style="font-size:.9rem;">
Al continuar aceptás nuestros términos y políticas.
</p>
</div>
</div>
<p class="text-center text-secondary mt-3" style="font-size:.9rem;">
¿Ya tenés cuenta? <a href="/auth/login" class="link-dark">Iniciá sesión con SSO</a>
</p>
</div>
</div>
</div>
<script>
// Mostrar mensajes por querystring (?msg=... / ?error=...)
(function() {
const params = new URLSearchParams(location.search);
const el = document.getElementById('flash');
const msg = params.get('msg');
const err = params.get('error');
if (msg) {
el.innerHTML = `<div class="alert alert-success mb-0" role="alert">${decodeURIComponent(msg)}</div>`;
el.style.display = '';
} else if (err) {
el.innerHTML = `<div class="alert alert-danger mb-0" role="alert">${decodeURIComponent(err)}</div>`;
el.style.display = '';
}
})();
// Validación Bootstrap + envío del registro contra /api/users/register
(function() {
const form = document.getElementById('form-register');
form.addEventListener('submit', async function(e) {
e.preventDefault();
e.stopPropagation();
form.classList.add('was-validated');
if (!form.checkValidity()) return;
const btn = form.querySelector('button[type="submit"]');
btn.disabled = true; btn.innerText = 'Creando...';
try {
const payload = {
email: document.getElementById('email').value.trim(),
display_name: document.getElementById('display_name').value.trim(),
tenant_uuid: document.getElementById('tenant_uuid').value.trim() || undefined,
role: document.getElementById('role').value
};
const res = await fetch('/api/users/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
credentials: 'include'
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
throw new Error(data?.error || data?.message || 'No se pudo registrar');
}
// Registro OK → redirigimos a login SSO
const redir = '/auth/login';
location.href = redir + '?msg=' + encodeURIComponent('Registro exitoso. Iniciá sesión con SSO.');
} catch (err) {
alert(err.message || String(err));
} finally {
btn.disabled = false; btn.innerText = 'Crear cuenta';
}
});
})();
</script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js" integrity="sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz" crossorigin="anonymous"></script>
</body>
</html>
+44
View File
@@ -0,0 +1,44 @@
# ===== Runtime =====
NODE_ENV=development
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) =====
# Usa el alias de red del servicio 'db' (compose: aliases [dev-db])
DB_HOST=dev-db
DB_PORT=5432
DB_NAME=dev-suitecoffee
DB_USER=dev-user-suitecoffee
DB_PASS=dev-pass-suitecoffee
# ===== DB tenants (Tenants de SuiteCoffee) =====
TENANTS_HOST=dev-tenants
TENANTS_DB=dev-postgres
TENANTS_USER=dev-user-postgres
TENANTS_PASS=dev-pass-postgres
TENANTS_PORT=5432
TENANT_INIT_SQL=/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_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.
# 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
OIDC_REDIRECT_URI=https://suitecoffee.uy/auth/callback
OIDC_ENROLLMENT_URL=https://sso.suitecoffee.uy/if/flow/registro-suitecoffee/
+22
View File
@@ -0,0 +1,22 @@
NODE_ENV=production # Entorno de desarrollo
PORT=4000 # Variables del servicio -> suitecoffee-app
# AUTH_HOST=prod-auth
DB_HOST=prod-db
# Nombre de la base de datos
DB_NAME=suitecoffee
# Usuario y contraseña
DB_USER=suitecoffee
DB_PASS=suitecoffee
# Puertos del servicio de db
DB_LOCAL_PORT=5432
DB_DOCKER_PORT=5432
# Colores personalizados
COL_PRI=452D19 # Marrón oscuro
COL_SEC=D7A666 # Crema / Café
COL_BG=FFA500 # Naranja
+832 -14
View File
File diff suppressed because it is too large Load Diff
+12 -2
View File
@@ -15,16 +15,26 @@
"nodemon": "^3.1.10"
},
"dependencies": {
"axios": "^1.11.0",
"bcrypt": "^5.1.1",
"chalk": "^5.6.0",
"connect-redis": "^9.0.0",
"cookie-session": "^2.0.0",
"cors": "^2.8.5",
"dotenv": "^17.2.1",
"ejs": "^3.1.10",
"express": "^5.1.0",
"express-ejs-layouts": "^2.5.1",
"express-session": "^1.18.2",
"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",
"openid-client": "^5.6.5",
"cookie-session": "^2.0.0"
"redis": "^5.8.2"
},
"keywords": [],
"description": ""
+436
View File
@@ -0,0 +1,436 @@
// services/auth/src/ak.js
// ------------------------------------------------------------
// Cliente para la API Admin de Authentik (v3)
// - Sin dependencias externas (usa fetch nativo de Node >=18)
// - ESM compatible
// - Timeouts, reintentos opcionales y mensajes de error claros
// - Compatible con services/auth/src/index.js actual
// ------------------------------------------------------------
// Utiliza AUTHENTIK_BASE_URL y AUTHENTIK_TOKEN para validar y devuelve la configuración (base URL y token) desde variables de entorno.
// Devuelve la URL base y el Token que se leyó desde .env
/**
* @typedef {Object} AkCfg
* @property {string} BASE // p.ej. "https://idp.example.com"
* @property {string} TOKEN // bearer
*/
/**
* @typedef {Object} AkOpts
* @property {Record<string, string|number|boolean|Array<string|number|boolean>>} [qs]
* @property {any} [body]
* @property {number} [timeoutMs=10000]
* @property {number} [retries=0]
* @property {Record<string,string>} [headers]
*/
function getConfig() {
const BASE = (process.env.AUTHENTIK_BASE_URL || "").trim().replace(/\/+$/, "");
const TOKEN = process.env.AUTHENTIK_TOKEN || '';
if (!BASE) throw new Error('[AK_CONFIG] Falta variable AUTHENTIK_BASE_URL');
if (!TOKEN) throw new Error('[AK_CONFIG] Falta variable AUTHENTIK_TOKEN');
return { BASE, TOKEN };
}
// ------------------------------------------------------------
// Utilidades
// ------------------------------------------------------------
// Espera
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
// ------------------------------------------------------------
// Helpers de sincronización
// ------------------------------------------------------------
export function createAkClient(cfg = getConfig()) {
return {
request: (method, path, opts = {}) => request(method, path, opts, cfg),
akGET: (path, opts) => request("GET", path, opts, cfg),
akPOST: (path, opts) => request("POST", path, opts, cfg),
akPUT: (path, opts) => request("PUT", path, opts, cfg),
akPATCH: (path, opts) => request("PATCH", path, opts, cfg),
akDELETE:(path, opts) => request("DELETE", path, opts, cfg),
};
}
// Listar grupos con búsqueda por nombre/slug
export async function akListGroups(search = "") {
const { akGET } = createAkClient();
const term = String(search ?? "").trim();
const data = await akGET("/core/groups/", {
qs: term ? { search: term } : undefined,
});
if (Array.isArray(data)) return data;
if (data && Array.isArray(data.results)) return data.results;
return [];
}
export async function akPatchUserAttributes(userPk, partialAttrs = {}) {
const id = String(userPk ?? "").trim();
if (!id) throw new TypeError("[AK_PATH_USER_ATRIBUTES] atribute `userPk` is required");
if (partialAttrs == null || typeof partialAttrs !== "object" || Array.isArray(partialAttrs)) {
throw new TypeError("[AK_PATH_USER_ATRIBUTES] atribute `partialAttrs` must be a plain object");
}
// Remove undefineds to avoid unintentionally nulling keys server-side
const cleaned = Object.fromEntries(
Object.entries(partialAttrs).filter(([, v]) => v !== undefined)
);
if (Object.keys(cleaned).length === 0) {
throw new TypeError("[AK_PATH_USER_ATRIBUTES] atribute `partialAttrs` is required");
}
// NOTE: pass path WITHOUT /api/v3; the client prefixes it
return akPATCH(`/core/users/${encodeURIComponent(id)}/`, {
body: { attributes: cleaned },
});
}
export async function akEnsureGroupForTenant(tenantHex) {
const { akGET, akPOST } = createAkClient();
const hex = String(tenantHex ?? "").trim();
if (!hex) throw new TypeError("akEnsureGroupForTenant: `tenantHex` is required");
const groupName = `tenant_${hex}`;
// 1) Buscar existente (normaliza {results:[]}/[])
const data = await akGET("/core/groups/", { qs: { search: groupName } });
const list = Array.isArray(data) ? data : (Array.isArray(data?.results) ? data.results : []);
const existing = list.find(g => g?.name === groupName);
if (existing?.pk ?? existing?.id) return existing.pk ?? existing.id;
// 2) Crear si no existe
try {
const created = await akPOST("/core/groups/", {
body: { name: groupName, attributes: { tenant_uuid: hex } },
});
return created?.pk ?? created?.id;
} catch (e) {
// 3) Condición de carrera (otro proceso lo creó): reconsulta y devuelve
const msg = String(e?.message || "");
if (/already exists|unique|duplicate|409/i.test(msg)) {
const data2 = await akGET("/core/groups/", { qs: { search: groupName } });
const list2 = Array.isArray(data2) ? data2 : (Array.isArray(data2?.results) ? data2.results : []);
const found = list2.find(g => g?.name === groupName);
if (found?.pk ?? found?.id) return found.pk ?? found.id;
}
throw e;
}
}
export async function akAddUserToGroup(userPk, groupPk) {
const { akPOST } = createAkClient();
const user = String(userPk ?? "").trim();
const group = String(groupPk ?? "").trim();
if (!user) throw new TypeError("akAddUserToGroup: `userPk` is required");
if (!group) throw new TypeError("akAddUserToGroup: `groupPk` is required");
// API reciente: POST /core/users/<pk>/groups/ { group: <pk> }
const path = `/core/users/${encodeURIComponent(user)}/groups/`;
try {
return await akPOST(path, { body: { group } });
} catch (e) {
const msg = String(e?.message || "");
// Si ya es miembro, tratamos como éxito idempotente
if (/already.*member|exists|duplicate|409/i.test(msg)) {
return { ok: true, alreadyMember: true, userPk: user, groupPk: group };
}
// Fallback para instancias viejas: /core/group_memberships/ { user, group }
if (/404|not\s*found/i.test(msg)) {
return await akPOST("/core/group_memberships/", { body: { user, group } });
}
throw e;
}
}
/**
* Llamada HTTP genérica con fetch + timeout + manejo de errores.
* @param {'GET'|'POST'|'PUT'|'PATCH'|'DELETE'} method
* @param {string} path Ruta relativa (ej. "/core/users/") o absoluta; si es relativa se antepone "/api/v3".
* @param {AkOpts} [opts]
* @param {AkCfg} [cfg] Config inyectada; si se omite se usa getConfig()
* @returns {Promise<any|null>}
*/
export async function request(method, path, opts = {}, cfg) {
const { BASE, TOKEN } = cfg ?? getConfig();
const {
qs,
body,
timeoutMs = 10_000,
retries = 0,
headers = {},
} = opts;
// Construcción segura de URL + QS
const base = BASE.endsWith("/") ? BASE : `${BASE}/`;
let p = /^https?:\/\//i.test(path) ? path : (path.startsWith("/") ? path : `/${path}`);
if (!/^https?:\/\//i.test(p) && !p.startsWith("/api/")) p = `/api/v3${p}`;
const url = new URL(p, base);
if (qs && typeof qs === "object") {
for (const [k, v] of Object.entries(qs)) {
if (v == null) continue;
if (Array.isArray(v)) v.forEach((x) => url.searchParams.append(k, String(x)));
else url.searchParams.set(k, String(v));
}
}
// Reintentos + timeout
const maxAttempts = Math.max(1, retries + 1);
let lastErr;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
const ctrl = new AbortController();
const t = setTimeout(() => ctrl.abort(new Error("AK_TIMEOUT")), timeoutMs);
try {
const init = {
method,
signal: ctrl.signal,
headers: {
Authorization: `Bearer ${TOKEN}`,
Accept: "application/json",
...headers,
},
};
if (body !== undefined) {
// Sólo forzar JSON si es objeto plano
const isPlainObj = body && typeof body === "object" &&
!(body instanceof FormData) &&
!(body instanceof URLSearchParams) &&
!(typeof Blob !== "undefined" && body instanceof Blob);
if (isPlainObj) {
init.headers["Content-Type"] = init.headers["Content-Type"] || "application/json";
init.body = JSON.stringify(body);
} else {
init.body = body; // deja que fetch maneje el Content-Type
}
}
const res = await fetch(url, init);
clearTimeout(t);
if (res.status === 204 || res.status === 205) return null;
const ctype = res.headers.get("content-type") || "";
const payload = /\bapplication\/json\b/i.test(ctype) ? await res.json().catch(() => ({})) : await res.text();
if (!res.ok) {
const detail = typeof payload === "string" ? payload : payload?.detail || payload?.error || JSON.stringify(payload);
const err = new Error(`AK ${method} ${url.pathname}${url.search}${res.status}: ${detail}`);
err.status = res.status; // @ts-ignore
// Reintenta 5xx y 429
if ((res.status >= 500 && res.status <= 599) || res.status === 429) {
lastErr = err;
if (attempt < maxAttempts) {
let delay = 500 * 2 ** (attempt - 1);
const ra = parseInt(res.headers.get("retry-after") || "", 10);
if (!Number.isNaN(ra)) delay = Math.max(delay, ra * 1000);
await new Promise(r => setTimeout(r, delay));
continue;
}
}
throw err;
}
return payload;
} catch (e) {
clearTimeout(t);
lastErr = e;
const msg = String(e?.message || "");
const retriable = msg.includes("AK_TIMEOUT") || msg.includes("ECONNREFUSED") || msg.includes("fetch failed");
if (!retriable || attempt >= maxAttempts) throw e;
await new Promise(r => setTimeout(r, 500 * 2 ** (attempt - 1)));
}
}
throw lastErr;
}
// ------------------------------------------------------------
// Funciones públicas
// ------------------------------------------------------------
export async function akFindUserByEmail(email) {
const { akGET } = createAkClient();
const needle = String(email ?? "").trim().toLowerCase();
if (!needle) throw new TypeError("akFindUserByEmail: `email` is required");
const PAGE_SIZE = 100;
let page = 1;
const MAX_PAGES = 10;
while (page <= MAX_PAGES) {
const data = await akGET("/core/users/", {
qs: { search: needle, page_size: PAGE_SIZE, page },
retries: 2,
});
const list = Array.isArray(data)
? data
: (Array.isArray(data?.results) ? data.results : []);
const found = list.find(u => String(u?.email || "").toLowerCase() === needle);
if (found) return found || null;
// Continuar paginando sólo si hay más resultados
const hasNext =
Array.isArray(data)
? list.length === PAGE_SIZE // array plano: inferimos por tamaño
: Boolean(data?.next); // DRF: link "next"
if (!hasNext) break;
page += 1;
}
return null;
}
export async function akCreateUser(p = {}) {
const { akPOST } = createAkClient();
const email = String(p.email ?? "").trim().toLowerCase();
if (!email) throw new TypeError("akCreateUser: `email` is required");
const name = String(p.displayName ?? email).trim() || email;
const tenantUuid = String(p.tenantUuid ?? "").replace(/-/g, "").trim();
const isActive = p.isActive ?? true;
const body = {
username: email,
name,
email,
is_active: !!isActive,
attributes: tenantUuid ? { tenant_uuid: tenantUuid } : {},
};
let user;
try {
user = await akPOST("/core/users/", { body, retries: 2 });
} catch (e) {
const msg = String(e?.message || "");
if (/409|already\s*exists|unique|duplicate/i.test(msg)) {
// Idempotencia: si ya existe, lo buscamos por email y lo devolvemos
const existing = await akFindUserByEmail(email);
if (existing) return existing;
}
throw e;
}
// Agregar a grupo (opcional, no rompe el flujo si falla)
const groupId = p.addToGroupId != null ? String(p.addToGroupId).trim() : "";
if (groupId) {
try {
const userPk = encodeURIComponent(user.pk ?? user.id);
await akPOST(`/core/users/${userPk}/groups/`, {
body: { group: groupId },
retries: 2,
});
} catch (err) {
console.warn(
`akCreateUser: could not add user ${user.pk ?? user.id} to group ${groupId}:`,
err?.message || err
);
}
}
return user;
}
export async function akSetPassword(userPk, password, requireChange = true) {
const { akPOST } = createAkClient();
const id = String(userPk ?? "").trim();
if (!id) throw new TypeError("akSetPassword: `userPk` is required");
const pwd = String(password ?? "");
if (!pwd) throw new TypeError("akSetPassword: `password` is required");
try {
await akPOST(`/core/users/${encodeURIComponent(id)}/set_password/`, {
body: { password: pwd, require_change: !!requireChange },
retries: 1,
timeoutMs: 15_000,
});
return true;
} catch (e) {
const status = e?.status ? `HTTP ${e.status}: ` : "";
const err = new Error(`akSetPassword: failed to set password (${status}${e?.message || e})`);
err.cause = e;
throw err;
}
}
export async function akResolveGroupIdByName(name) {
const term = String(name ?? "").trim();
if (!term) throw new TypeError("akResolveGroupIdByName: `name` is required");
const needle = term.toLowerCase();
const groups = await akListGroups(term);
if (!Array.isArray(groups) || groups.length === 0) return null;
// Prefer exact slug match, then exact name match
const bySlug = groups.find(g => String(g?.slug ?? "").toLowerCase() === needle);
if (bySlug) return bySlug.pk ?? bySlug.id ?? null;
const byName = groups.find(g => String(g?.name ?? "").toLowerCase() === needle);
return byName?.pk ?? byName?.id ?? null;
}
export async function akResolveGroupId({ id, pk, uuid, name, slug } = {}) {
const toPk = (v) => {
if (v == null || v === "") return null;
const n = Number(v);
return Number.isFinite(n) ? n : String(v);
};
// 1) Direct pk/id
const direct = pk ?? id;
const directPk = toPk(direct);
if (directPk != null) return directPk;
const { akGET } = createAkClient();
// 2) By UUID (detail endpoint)
const uuidStr = String(uuid ?? "").trim();
if (uuidStr) {
try {
const g = await akGET(`/core/groups/${encodeURIComponent(uuidStr)}/`, { retries: 1 });
const fromDetail = toPk(g?.pk ?? g?.id);
if (fromDetail != null) return fromDetail;
} catch { /* continue with name/slug */ }
}
// 3) By exact name/slug
const needle = String(name ?? slug ?? "").trim();
if (needle) {
const lower = needle.toLowerCase();
const list = await akListGroups(needle); // expects [] or {results:[]}, handled in akListGroups
const found =
list.find(g => String(g?.slug ?? "").toLowerCase() === lower) ||
list.find(g => String(g?.name ?? "").toLowerCase() === lower);
const fromList = toPk(found?.pk ?? found?.id);
if (fromList != null) return fromList;
}
return null;
}
// ------------------------------------------------------------
// Exportación de constantes
// ------------------------------------------------------------
export const akGET = (path, opts) => request("GET", path, opts);
export const akPOST = (path, opts) => request("POST", path, opts);
export const akPUT = (path, opts) => request("PUT", path, opts);
export const akPATCH = (path, opts) => request("PATCH", path, opts);
export const akDELETE = (path, opts) => request("DELETE", path, opts);
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+330 -231
View File
@@ -1,275 +1,374 @@
// auth/src/index.js
import chalk from 'chalk';
import express from 'express';
import expressLayouts from 'express-ejs-layouts';
import cors from 'cors';
// services/auth/src/index.js
// ------------------------------------------------------------
// SuiteCoffee — Servicio de Autenticación (Express + OIDC)
// - ESM compatible (Node >=18)
// - Sesiones con Redis (compartibles con otros servicios)
// - Vistas EJS (login)
// - Registro de usuario: /auth/api/users/register (DB + Authentik)
// ------------------------------------------------------------
import 'dotenv/config';
import path from 'node:path';
import fs from 'node:fs/promises';
import { fileURLToPath } from 'node:url';
import { Pool } from 'pg';
import bcrypt from'bcrypt';
import express from 'express';
import { Issuer, generators } from 'openid-client';
import cookieSession from 'cookie-session';
import crypto from 'node:crypto';
import fetch from "node-fetch";
// Rutas
import path from 'path';
import { fileURLToPath } from 'url';
import { createRedisSession } from "../shared/middlewares/redisConnect.js";
// -----------------------------------------------------------------------------
// Variables globales
// -----------------------------------------------------------------------------
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";
// -----------------------------------------------------------------------------
// Utilidades / Helpers
// -----------------------------------------------------------------------------
// -----------------------------------------------------------------------------
// Utilidades
// -----------------------------------------------------------------------------
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// Variables de Entorno
import dotenv, { config } from 'dotenv';
// Obtención de la ruta de la variable de entorno correspondiente a NODE_ENV
try {
if (process.env.NODE_ENV === 'development') {
dotenv.config({ path: path.resolve(__dirname, '../.env.development' )});
console.log(`Activando entorno de ->${chalk.green(` DEVELOPMENT `)}`);
} else if (process.env.NODE_ENV === 'stage') {
dotenv.config({ path: path.resolve(__dirname, '../.env.test' )});
console.log(`Activando entorno de ->${chalk.yellow(` TESTING `)}`);
} else if (process.env.NODE_ENV === 'production') {
dotenv.config({ path: path.resolve(__dirname, '../.env.production' )});
console.log(`Activando entorno de ->${chalk.red(` PRODUCTION `)}`);
function requiredEnv(keys) {
const missing = keys.filter((k) => !process.env[k]);
if (missing.length) {
console.warn(`Falta configurar variables de entorno: ${missing.join(', ')}`);
}
} catch (error) {
console.log("A ocurrido un error al seleccionar el entorno. \nError: " + error);
}
// Configuración de renderizado
// -----------------------------------------------------------------------------
// Configuración Express
// -----------------------------------------------------------------------------
const app = express();
app.use(cors());
app.set('trust proxy', Number(process.env.TRUST_PROXY_HOPS || 2));
app.disable("x-powered-by");
app.use(express.json());
app.set('trust proxy', true);
app.use(express.static(path.join(__dirname, 'pages')));
app.use(express.urlencoded({ extended: true }));
// Vistas EJS
app.set('views', path.join(__dirname, 'views'));
app.set('view engine', 'ejs');
// Archivos estáticos opcionales (ajusta si tu estructura difiere)
app.use(express.static(path.join(__dirname, 'public')));
app.use('/pages', express.static(path.join(__dirname, 'pages')));
app.use(cookieSession({
name: 'sid',
secret: process.env.SESSION_SECRET,
httpOnly: true,
sameSite: 'lax',
secure: false // en prod detrás de https: true
}));
// -----------------------------------------------------------------------------
// 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());
// Configuración de conexión PostgreSQL
const dbConfig = {
host: process.env.DB_HOST,
user: process.env.DB_USER,
password: process.env.DB_PASS,
database: process.env.DB_NAME,
port: process.env.DB_LOCAL_PORT
};
// --- Utiles OIDC ---
function base64url(buf) {
return Buffer.from(buf).toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}
const pool = new Pool(dbConfig);
function genPKCE() {
const verifier = base64url(crypto.randomBytes(32));
const challenge = base64url(crypto.createHash("sha256").update(verifier).digest());
return { verifier, challenge };
}
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.currentPath = req.path;
res.locals.pageTitle = "SuiteCoffee";
res.locals.pageId = "";
next();
});
// -----------------------------------------------------------------------------
// PostgreSQL — DB tenants (usuarios de suitecoffee)
// -----------------------------------------------------------------------------
const tenantsPool = new Pool({
host: process.env.TENANTS_HOST || 'dev-tenants',
port: Number(process.env.TENANTS_PORT || 5432),
user: process.env.TENANTS_USER || 'dev-user-postgres',
password: process.env.TENANTS_PASS || 'dev-pass-postgres',
database: process.env.TENANTS_DB || 'dev-postgres',
max: 10,
});
// -----------------------------------------------------------------------------
// PostgreSQL — DB principal (metadatos de negocio)
// -----------------------------------------------------------------------------
requiredEnv(['DB_HOST', 'DB_USER', 'DB_PASS', 'DB_NAME']);
const mainPool = new Pool({
host: process.env.DB_HOST || 'dev-db',
port: Number(process.env.DB_PORT || 5432),
user: process.env.DB_USER || 'dev-user-suitecoffee',
password: process.env.DB_PASS || 'dev-pass-suitecoffee',
database: process.env.DB_NAME || 'dev-suitecoffee',
max: 10,
idleTimeoutMillis: 30_000,
});
// ----------------------------------------------------------
// Verificación de conexión
// ----------------------------------------------------------
async function verificarConexion() {
try {
const client = await pool.connect();
const res = await client.query('SELECT NOW() AS hora');
console.log(`\nConexión con la base de datos ${chalk.green(process.env.DB_NAME)} fue exitosa.`);
console.log('Fecha y hora actual de la base de datos:', res.rows[0].hora);
client.release(); // liberar el cliente de nuevo al pool
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(`Troubleshooting:\n1. Compruebe que las bases de datos se iniciaron correctamente.\n2. Verifique las credenciales y puertos de acceso a la base de datos.\n3. Si está conectandose a una base de datos externa a localhost, verifique las reglas del firewal de entrada y salida de ambos dispositivos.`);
console.error('[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();
}
}
// Descubrimiento OIDC (una sola vez)
let oidcClient;
async function getClient() {
if (oidcClient) return oidcClient;
const ISSUER = process.env.OIDC_ISSUER_INTERNAL; // ej: http://authentik:9000/application/o/suitecoffee/
const issuer = await Issuer.discover(`${ISSUER}.well-known/openid-configuration`);
oidcClient = new issuer.Client({
client_id: process.env.OIDC_CLIENT_ID,
client_secret: process.env.OIDC_CLIENT_SECRET,
redirect_uris: [`${process.env.BASE_URL}${process.env.OIDC_REDIRECT_PATH}`],
response_types: ['code']
});
return oidcClient;
// -----------------------------------------------------------------------------
// Vistas
// -----------------------------------------------------------------------------
// =============================================
// Registro de usuario (DB principal)
// =============================================
requiredEnv(['TENANT_INIT_SQL']);
async function loadInitSqlFromEnv() {
const v = process.env.TENANT_INIT_SQL?.trim();
if (!v) return '';
try {
// ¿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;
}
}
// === Servir páginas estáticas ===
// 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)
};
}
app.get('/',(req, res) => res.sendFile(path.join(__dirname, 'pages', 'index.html')));
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, "''")}'`;
app.get('/planes', async (req, res) => {
try {
const { rows: [row] } = await pool.query(
'SELECT api.get_planes_json($1) AS data;',
[true]
);
res.type('application/json').send(row.data);
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Error al cargar planes' });
// 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}"`);
// 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 };
}
// 👉 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');
// si usás placeholders, renderealos acá (opcional)
// sql = renderInitSqlTemplate(sql, { schema: schemaName, owner: roleName });
// 3) forzá el search_path SOLO dentro de esta transacción
await tenClient.query(`SET LOCAL search_path TO "${schemaName}", public`);
// 4) ejecutar el dump (una sola vez, no lo partas por ';' para no romper $$...$$)
await tenClient.query(sql);
console.log(`[TENANT INIT] OK usuario="${roleName}" schema="${schemaName}"`);
return { roleName, schemaName };
}
//=============================================
// ---------- Authentik (API & OIDC) ----------
//=============================================
// ===========================
// 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");
}
});
app.post('/api/registro', async (req, res) => {
const {
nombre_empresa,
rut,
correo,
telefono,
direccion,
logo,
clave_acceso,
plan_id
} = req.body;
try {
const client = await pool.connect();
// 1. Hashear la contraseña
const hash = await bcrypt.hash(clave_acceso, 10);
// 2. Insertar el tenant
const result = await client.query(`
INSERT INTO tenant (
nombre_empresa, rut, correo, telefono, direccion, logo,
clave_acceso, plan_id, nombre_base_datos
) VALUES (
$1, $2, $3, $4, $5, $6,
$7, $8, 'TEMPORAL'
)
RETURNING uuid;
`, [
nombre_empresa, rut, correo, telefono, direccion, logo,
hash, plan_id
]);
const uuid = result.rows[0].uuid;
const nombre_base_datos = `tenantdb_${uuid}`.replace(/-/g, '').substring(0, 24); // ajustamos para longitud segura
// 3. Actualizar el campo nombre_base_datos
await client.query(`
UPDATE tenant SET nombre_base_datos = $1 WHERE uuid = $2
`, [nombre_base_datos, uuid]);
client.release();
return res.status(201).json({
message: 'Tenant registrado correctamente',
uuid,
nombre_base_datos
});
} catch (err) {
console.error(err);
return res.status(500).json({ error: 'Error al registrar tenant' });
}
// ===========================
// 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 || "/");
});
});
app.post('/api/login', async (req, res) => {
const { correo, clave_acceso } = req.body;
// =============================================
// Healthcheck
// =============================================
app.get('/health', (_req, res) => res.status(200).json({ status: 'ok'}));
try {
const client = await pool.connect();
// =============================================
// 404 + Manejo de errores
// =============================================
app.use((req, res) => res.status(404).json({ error: 'Error 404, No se encontró la página', path: req.originalUrl }));
const result = await client.query(`
SELECT uuid, clave_acceso, nombre_empresa, nombre_base_datos
FROM tenant
WHERE correo = $1 AND estado = true
`, [correo]);
client.release();
if (result.rows.length === 0) {
return res.status(401).json({ error: 'Correo no registrado o inactivo' });
}
const tenant = result.rows[0];
const coincide = await bcrypt.compare(clave_acceso, tenant.clave_acceso);
if (!coincide) {
return res.status(401).json({ error: 'Clave incorrecta' });
}
return res.status(200).json({
message: 'Login correcto',
uuid: tenant.uuid,
nombre_empresa: tenant.nombre_empresa,
base_datos: tenant.nombre_base_datos
});
} catch (err) {
console.error(err);
return res.status(500).json({ error: 'Error al validar login' });
}
app.use((err, _req, res, _next) => {
console.error('[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.
// --- login: redirige a Authentik con PKCE
app.get('/auth/login', async (req, res) => {
const client = await getClient();
const state = generators.state();
const code_verifier = generators.codeVerifier();
const code_challenge = generators.codeChallenge(code_verifier);
# Default
export default app; // importar: import app from './app.js'
req.session.state = state;
req.session.code_verifier = code_verifier;
# Con nombre
export const app = express(); // importar: import { app } from './app.js'
-----------------------------------------------------------------------------
*/
export default app;
const authUrl = client.authorizationUrl({
scope: 'openid profile email',
state,
code_challenge,
code_challenge_method: 'S256'
});
res.redirect(authUrl);
});
// --- callback: intercambia code por tokens y guarda sesión mínima
app.get(process.env.OIDC_REDIRECT_PATH || '/auth/callback', async (req, res) => {
const client = await getClient();
const { state, code } = req.query;
if (!state || state !== req.session.state) {
return res.status(400).send('state inválido');
}
const params = { state, code, code_verifier: req.session.code_verifier };
const tokenSet = await client.callback(`${process.env.BASE_URL}${process.env.OIDC_REDIRECT_PATH}`, params, { state });
// Guarda lo que necesites para pruebas (id_token y claims)
req.session.user = tokenSet.claims();
req.session.id_token = tokenSet.id_token;
req.session.access_token = tokenSet.access_token;
// Redirigí a donde quieras (página de bienvenida)
res.redirect('/auth/me');
});
// --- ver quién soy (para probar)
app.get('/auth/me', (req, res) => {
if (!req.session?.user) return res.status(401).json({ error: 'no autenticado' });
res.json({ user: req.session.user });
});
// --- logout simple (borra cookie)
app.post('/auth/logout', (req, res) => {
req.session = null;
res.status(204).end();
});
// Colores personalizados
let primaryColor = chalk.hex('#'+`${process.env.COL_PRI}`);
let secondaryColor = chalk.hex('#'+`${process.env.COL_SEC}`);
// let backgroundColor = chalk.hex('#'+`${process.env.COL_BG}`);
app.use(expressLayouts);
// Iniciar servidor
app.listen( process.env.PORT, () => {
console.log(`Servidor de ${chalk.yellow('autenticación')} de ${secondaryColor('SuiteCoffee')} corriendo en ${chalk.yellow(`http://localhost:${process.env.PORT}\n`)}` );
console.log(chalk.grey(`Comprobando accesibilidad a la db ${chalk.green(process.env.DB_NAME)} del host ${chalk.white(`${process.env.DB_HOST}`)} ...`));
// -----------------------------------------------------------------------------
// Arranque
// -----------------------------------------------------------------------------
app.listen(PORT, () => {
console.log(`[AUTH] Servicio de autenticación escuchando en http://localhost:${PORT}`);
verificarConexion();
});
app.get("/health", async (req, res) => {
// Podés chequear DB aquí. 200 = healthy; 503 = not ready.
res.status(200).json({ status: "ok" });
// OIDCdiscover();
});
+19
View File
@@ -0,0 +1,19 @@
NODE_ENV=development
PORT=3030
DB_HOST=dev-tenants
DB_NAME=manso
# Usuario y contraseña
DB_USER=manso
DB_PASS=manso
# Puertos del servicio de db
DB_LOCAL_PORT=5432
DB_DOCKER_PORT=5432
# Colores personalizados
COL_PRI=452D19 # Marrón oscuro
COL_SEC=D7A666 # Crema / Café
COL_BG=FFA500 # Naranja
+20
View File
@@ -0,0 +1,20 @@
NODE_ENV=production # Entorno de desarrollo
PORT=3000 # Variables del servicio -> suitecoffee-app
# Variables del servicio -> suitecoffee-db de suitecoffee-app
DB_HOST=dev-tenants
DB_NAME=manso
# Usuario y contraseña
DB_USER=manso
DB_PASS=manso
# Puertos del servicio de db
DB_LOCAL_PORT=5432
DB_DOCKER_PORT=5432
# Colores personalizados
COL_PRI=452D19 # Marrón oscuro
COL_SEC=D7A666 # Crema / Café
COL_BG=FFA500 # Naranja
+9 -4
View File
@@ -1,11 +1,11 @@
{
"name": "workarround",
"version": "1.0.0",
"main": "src/index.js",
"main": "src/index.mjs",
"scripts": {
"start": "NODE_ENV=production node ./src/index.js",
"dev": "NODE_ENV=development npx nodemon ./src/index.js",
"test": "NODE_ENV=stage node ./src/index.js"
"start": "NODE_ENV=production node ./src/index.mjs",
"dev": "NODE_ENV=development npx nodemon ./src/index.mjs",
"test": "NODE_ENV=stage node ./src/index.mjs"
},
"author": "Mateo Saldain",
"license": "ISC",
@@ -25,6 +25,11 @@
"pg-format": "^1.0.4",
"serve-favicon": "^2.5.1"
},
"imports": {
"#v1Router": "./src/api/v1/routes/routes.js",
"#pages": "./src/pages/pages.js",
"#db": "./src/db/poolSingleton.js"
},
"keywords": [],
"description": "Workarround para tener un MVP que llegue al verano para usarse"
}
+340
View File
@@ -0,0 +1,340 @@
// services/manso/src/api/v1/routes/routes.js
import { Router } from 'express';
import pool from '#db'; // Pool Singleton
const router = Router();
// ==========================================================
// Rutas de API v1
// ==========================================================
// ----------------------------------------------------------
// API Comandas
// ----------------------------------------------------------
router.route('/comandas').get( async (req, res, next) => {
try {
var client = await pool.getClient()
const estado = (req.query.estado || '').trim() || null;
const limit = Math.min(parseInt(req.query.limit || '200', 10), 1000);
const { rows } = await client.query(
`SELECT * FROM public.f_comandas_resumen($1, $2)`,
[estado, limit]
);
res.json(rows);
} catch (e) {
next(e);
} finally {
client.release();
}
});
router.route('/comandas/:id/detalle').get( async (req, res, next) => {
try {
const client = await pool.getClient()
client.query(
`SELECT id_det_comanda, id_producto, producto_nombre,
cantidad, pre_unitario, subtotal, observaciones
FROM public.v_comandas_detalle_items
WHERE id_comanda = $1::int
ORDER BY id_det_comanda`,
[req.params.id]
)
.then(r => res.json(r.rows))
.catch(next)
client.release();
} catch (error) {
next(e);
}
});
router.route('/comandas/:id/cerrar').post( async (req, res, next) => {
try {
const client = await pool.getClient()
const id = Number(req.params.id);
if (!Number.isInteger(id) || id <= 0) {
return res.status(400).json({ error: 'id inválido' });
}
const { rows } = await client.query(
`SELECT public.f_cerrar_comanda($1) AS data`,
[id]
);
if (!rows.length || rows[0].data === null) {
return res.status(404).json({ error: 'Comanda no encontrada' });
}
res.json(rows[0].data);
client.release();
} catch (err) { next(err); }
});
router.route('/comandas/:id/abrir').post( async (req, res, next) => {
try {
const client = await pool.getClient()
const id = Number(req.params.id);
if (!Number.isInteger(id) || id <= 0) {
return res.status(400).json({ error: 'id inválido' });
}
const { rows } = await client.query(
`SELECT public.f_abrir_comanda($1) AS data`,
[id]
);
if (!rows.length || rows[0].data === null) {
return res.status(404).json({ error: 'Comanda no encontrada' });
}
res.json(rows[0].data);
client.release();
} catch (err) { next(err); }
});
// ----------------------------------------------------------
// API Productos
// ----------------------------------------------------------
// GET producto + receta
router.route('/rpc/get_producto/:id').get( async (req, res) => {
const client = await pool.getClient()
const id = Number(req.params.id);
const { rows } = await client.query('SELECT public.get_producto($1) AS data', [id]);
res.json(rows[0]?.data || {});
client.release();
});
// POST guardar producto + receta
router.route('/rpc/save_producto').post(async (req, res) => {
try {
// console.debug('receta payload:', req.body?.receta); // habilitalo si lo necesitás
const client = await pool.getClient()
const q = 'SELECT public.save_producto($1,$2,$3,$4,$5,$6,$7::jsonb) AS id_producto';
const { id_producto=null, nombre, img_producto=null, precio=0, activo=true, id_categoria=null, receta=[] } = req.body || {};
const params = [id_producto, nombre, img_producto, precio, activo, id_categoria, JSON.stringify(receta||[])];
const { rows } = await client.query(q, params);
res.json(rows[0] || {});
client.release();
} catch(e) {
console.error(e);
res.status(500).json({ error: 'save_producto failed' });
}
});
// ----------------------------------------------------------
// API Materias Primas
// ----------------------------------------------------------
// GET MP + proveedores
router.route('/rpc/get_materia/:id').get(async (req, res) => {
const id = Number(req.params.id);
try {
const client = await pool.getClient()
const { rows } = await client.query('SELECT public.get_materia_prima($1) AS data', [id]);
res.json(rows[0]?.data || {});
client.release();
} catch (e) {
console.error(e);
res.status(500).json({ error: 'get_materia failed' });
}
});
// SAVE MP + proveedores (array)
router.route('/rpc/save_materia').post( async (req, res) => {
const { id_mat_prima=null, nombre, unidad, activo=true, proveedores=[] } = req.body || {};
try {
const q = 'SELECT public.save_materia_prima($1,$2,$3,$4,$5::jsonb) AS id_mat_prima';
const params = [id_mat_prima, nombre, unidad, activo, JSON.stringify(proveedores||[])];
const { rows } = await pool.query(q, params);
res.json(rows[0] || {});
} catch (e) {
console.error(e);
res.status(500).json({ error: 'save_materia failed' });
}
});
// ----------------------------------------------------------
// API Usuarios y Asistencias
// ----------------------------------------------------------
// POST /api/rpc/find_usuarios_por_documentos { docs: ["12345678","09123456", ...] }
router.route('/rpc/find_usuarios_por_documentos').post( async (req, res) => {
try {
const docs = Array.isArray(req.body?.docs) ? req.body.docs : [];
const sql = 'SELECT public.find_usuarios_por_documentos($1::jsonb) AS data';
const { rows } = await pool.query(sql, [JSON.stringify(docs)]);
res.json(rows[0]?.data || {});
} catch (e) {
console.error(e);
res.status(500).json({ error: 'find_usuarios_por_documentos failed' });
}
});
// POST /api/rpc/import_asistencia { registros: [...], origen?: "AGL_001.txt" }
router.route('/rpc/import_asistencia').post( async (req, res) => {
try {
const registros = Array.isArray(req.body?.registros) ? req.body.registros : [];
const origen = req.body?.origen || null;
const sql = 'SELECT public.import_asistencia($1::jsonb,$2) AS data';
const { rows } = await pool.query(sql, [JSON.stringify(registros), origen]);
res.json(rows[0]?.data || {});
} catch (e) {
console.error(e);
res.status(500).json({ error: 'import_asistencia failed' });
}
});
// Consultar datos de asistencia (raw + pares) para un usuario y rango
router.route('/rpc/asistencia_get').post( async (req, res) => {
try {
const { doc, desde, hasta } = req.body || {};
const sql = 'SELECT public.asistencia_get($1::text,$2::date,$3::date) AS data';
const { rows } = await pool.query(sql, [doc, desde, hasta]);
res.json(rows[0]?.data || {});
} catch (e) {
console.error(e); res.status(500).json({ error: 'asistencia_get failed' });
}
});
// Editar un registro crudo y recalcular pares
router.route('/rpc/asistencia_update_raw').post( async (req, res) => {
try {
const { id_raw, fecha, hora, modo } = req.body || {};
const sql = 'SELECT public.asistencia_update_raw($1::bigint,$2::date,$3::text,$4::text) AS data';
const { rows } = await pool.query(sql, [id_raw, fecha, hora, modo ?? null]);
res.json(rows[0]?.data || {});
} catch (e) {
console.error(e); res.status(500).json({ error: 'asistencia_update_raw failed' });
}
});
// Eliminar un registro crudo y recalcular pares
router.route('/rpc/asistencia_delete_raw').post( async (req, res) => {
try {
const { id_raw } = req.body || {};
const sql = 'SELECT public.asistencia_delete_raw($1::bigint) AS data';
const { rows } = await pool.query(sql, [id_raw]);
res.json(rows[0]?.data || {});
} catch (e) {
console.error(e); res.status(500).json({ error: 'asistencia_delete_raw failed' });
}
});
// ----------------------------------------------------------
// API Reportes
// ----------------------------------------------------------
// POST /api/rpc/report_tickets { year }
router.route('/rpc/report_tickets').post( async (req, res) => {
try {
const y = parseInt(req.body?.year ?? req.query?.year, 10);
const year = (Number.isFinite(y) && y >= 2000 && y <= 2100)
? y
: (new Date()).getFullYear();
const { rows } = await pool.query(
'SELECT public.report_tickets_year($1::int) AS j', [year]
);
res.json(rows[0].j);
} catch (e) {
console.error('report_tickets error:', e);
res.status(500).json({
error: 'report_tickets failed',
message: e.message, detail: e.detail, where: e.where, code: e.code
});
}
});
// POST /api/rpc/report_asistencia { desde: 'YYYY-MM-DD', hasta: 'YYYY-MM-DD' }
router.route('/rpc/report_asistencia').post( async (req, res) => {
try {
let { desde, hasta } = req.body || {};
// defaults si vienen vacíos/invalidos
const re = /^\d{4}-\d{2}-\d{2}$/;
if (!re.test(desde) || !re.test(hasta)) {
const end = new Date();
const start = new Date(end); start.setDate(end.getDate()-30);
desde = start.toISOString().slice(0,10);
hasta = end.toISOString().slice(0,10);
}
const { rows } = await pool.query(
'SELECT public.report_asistencia($1::date,$2::date) AS j', [desde, hasta]
);
res.json(rows[0].j);
} catch (e) {
console.error('report_asistencia error:', e);
res.status(500).json({
error: 'report_asistencia failed',
message: e.message, detail: e.detail, where: e.where, code: e.code
});
}
});
// ----------------------------------------------------------
// API Compras y Gastos
// ----------------------------------------------------------
// Guardar (insert/update)
router.route('/rpc/save_compra').post( async (req, res) => {
try {
const { id_compra, id_proveedor, fec_compra, detalles } = req.body || {};
const sql = 'SELECT * FROM public.save_compra($1::int,$2::int,$3::timestamptz,$4::jsonb)';
const args = [id_compra ?? null, id_proveedor, fec_compra ? new Date(fec_compra) : null, JSON.stringify(detalles)];
const { rows } = await pool.query(sql, args);
res.json(rows[0]); // { id_compra, total }
} catch (e) {
console.error('save_compra error:', e);
res.status(500).json({ error: 'save_compra failed', message: e.message, detail: e.detail, where: e.where, code: e.code });
}
});
// Obtener para editar
router.route('/rpc/get_compra').post( async (req, res) => {
try {
const { id_compra } = req.body || {};
const sql = `SELECT public.get_compra($1::int) AS data`;
const { rows } = await pool.query(sql, [id_compra]);
res.json(rows[0]?.data || {});
} catch (e) {
console.error(e); res.status(500).json({ error: 'get_compra failed' });
}
});
// Eliminar
router.route('/rpc/delete_compra').post( async (req, res) => {
try {
const { id_compra } = req.body || {};
await pool.query(`SELECT public.delete_compra($1::int)`, [id_compra]);
res.json({ ok: true });
} catch (e) {
console.error(e); res.status(500).json({ error: 'delete_compra failed' });
}
});
// POST /api/rpc/report_gastos { year: 2025 }
router.route('/rpc/report_gastos').post( async (req, res) => {
try {
const year = parseInt(req.body?.year ?? new Date().getFullYear(), 10);
const { rows } = await pool.query(
'SELECT public.report_gastos($1::int) AS j', [year]
);
res.json(rows[0].j);
} catch (e) {
console.error('report_gastos error:', e);
res.status(500).json({
error: 'report_gastos failed',
message: e.message, detail: e.detail, code: e.code
});
}
});
export default router;
+46
View File
@@ -0,0 +1,46 @@
// Coneción Singleton a base de datos.
import { Pool } from 'pg';
class Database {
constructor() {
if (Database.instance) {
return Database.instance;
}
const config = {
host: process.env.DB_HOST,
user: process.env.DB_USER,
password: process.env.DB_PASS,
database: process.env.DB_NAME,
port: process.env.DB_LOCAL_PORT ? Number(process.env.DB_LOCAL_PORT) : undefined,
ssl: process.env.PGSSL === 'true' ? { rejectUnauthorized: false } : undefined,
};
this.connection = new Pool(config);
Database.instance = this;
}
async query(sql, params) {
return this.connection.query(sql,params);
}
async connect() { /* Definida solo para evitar errores */
return this.connection.connect();
}
async getClient() {
return this.connection.connect();
}
async release() {
await this.connection.end();
}
}
// const db = new Database();
// db.query('SELECT * FROM users');
const pool = new Database();
export default pool;
export { Database };
-842
View File
@@ -1,842 +0,0 @@
// app/src/index.js
import chalk from 'chalk'; // Colores!
import favicon from 'serve-favicon'; // Favicon
import express from 'express';
import expressLayouts from 'express-ejs-layouts';
import cors from 'cors';
import { Pool } from 'pg';
// Rutas
import path from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// Variables de Entorno
import dotenv from 'dotenv';
// Cargar .env según entorno
if (process.env.NODE_ENV === 'development') {
dotenv.config({ path: path.resolve(__dirname, '../.env.development') });
} else if (process.env.NODE_ENV === 'test') {
dotenv.config({ path: path.resolve(__dirname, '../.env.test') });
} else if (process.env.NODE_ENV === 'production') {
dotenv.config({ path: path.resolve(__dirname, '../.env.production') });
} else {
dotenv.config(); // .env por defecto
}
// ----------------------------------------------------------
// App
// ----------------------------------------------------------
const app = express();
app.set('trust proxy', true);
app.use(cors());
app.use(express.json());
app.use(express.json({ limit: '1mb' }));
app.use(express.static(path.join(__dirname, 'pages')));
// ----------------------------------------------------------
// Motor de vistas EJS
// ----------------------------------------------------------
app.set("views", path.join(__dirname, "views"));
app.set("view engine", "ejs");
app.use(expressLayouts);
app.set("layout", "layouts/main");
// Archivos estáticos
app.use(express.static(path.join(__dirname, "public")));
app.use('/favicon', express.static(path.join(__dirname, 'public', 'favicon'), {
maxAge: '1y'
}));
app.use(favicon(path.join(__dirname, 'public', 'favicon', 'favicon.ico'), {
maxAge: '1y'
}));
const url = v => !v ? "" : (v.startsWith("http") ? v : `/img/productos/${v}`);
// ----------------------------------------------------------
// Configuración de conexión PostgreSQL
// ----------------------------------------------------------
const dbConfig = {
host: process.env.DB_HOST,
user: process.env.DB_USER,
password: process.env.DB_PASS,
database: process.env.DB_NAME,
port: process.env.DB_LOCAL_PORT ? Number(process.env.DB_LOCAL_PORT) : undefined,
ssl: process.env.PGSSL === 'true' ? { rejectUnauthorized: false } : undefined,
};
const pool = new Pool(dbConfig);
// ----------------------------------------------------------
// Seguridad: Tablas permitidas
// ----------------------------------------------------------
const ALLOWED_TABLES = [
'roles','usuarios','usua_roles',
'categorias','productos',
'clientes','mesas',
'comandas','deta_comandas',
'proveedores','compras','deta_comp_producto',
'mate_primas','deta_comp_materias',
'prov_producto','prov_mate_prima',
'receta_producto', 'asistencia_resumen_diario',
'asistencia_intervalo', 'vw_compras'
];
const VALID_IDENT = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
// Identificadores SQL -> comillas dobles y escape correcto
const q = (s) => `"${String(s).replace(/"/g, '""')}"`;
function ensureTable(name) {
const t = String(name || '').toLowerCase();
if (!ALLOWED_TABLES.includes(t)) throw new Error('Tabla no permitida');
return t;
}
async function getClient() {
const client = await pool.connect();
return client;
}
// ----------------------------------------------------------
// Introspección de esquema
// ----------------------------------------------------------
async function loadColumns(client, table) {
const sql = `
SELECT
c.column_name,
c.data_type,
c.is_nullable = 'YES' AS is_nullable,
c.column_default,
(SELECT EXISTS (
SELECT 1 FROM pg_attribute a
JOIN pg_class t ON t.oid = a.attrelid
JOIN pg_index i ON i.indrelid = t.oid AND a.attnum = ANY(i.indkey)
WHERE t.relname = $1 AND i.indisprimary AND a.attname = c.column_name
)) AS is_primary,
(SELECT a.attgenerated = 's' OR a.attidentity IN ('a','d')
FROM pg_attribute a
JOIN pg_class t ON t.oid = a.attrelid
WHERE t.relname = $1 AND a.attname = c.column_name
) AS is_identity
FROM information_schema.columns c
WHERE c.table_schema='public' AND c.table_name=$1
ORDER BY c.ordinal_position
`;
const { rows } = await client.query(sql, [table]);
return rows;
}
async function loadForeignKeys(client, table) {
const sql = `
SELECT
kcu.column_name,
ccu.table_name AS foreign_table,
ccu.column_name AS foreign_column
FROM information_schema.table_constraints tc
JOIN information_schema.key_column_usage kcu
ON tc.constraint_name = kcu.constraint_name AND tc.table_schema = kcu.table_schema
JOIN information_schema.constraint_column_usage ccu
ON ccu.constraint_name = tc.constraint_name AND ccu.table_schema = tc.table_schema
WHERE tc.table_schema='public' AND tc.table_name=$1 AND tc.constraint_type='FOREIGN KEY'
`;
const { rows } = await client.query(sql, [table]);
const map = {};
for (const r of rows) map[r.column_name] = { foreign_table: r.foreign_table, foreign_column: r.foreign_column };
return map;
}
async function loadPrimaryKey(client, table) {
const sql = `
SELECT a.attname AS column_name
FROM pg_index i
JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = ANY(i.indkey)
JOIN pg_class t ON t.oid = i.indrelid
WHERE t.relname = $1 AND i.indisprimary
`;
const { rows } = await client.query(sql, [table]);
return rows.map(r => r.column_name);
}
// label column for FK options
async function pickLabelColumn(client, refTable) {
const preferred = ['nombre','raz_social','apodo','documento','correo','telefono'];
const { rows } = await client.query(
`SELECT column_name, data_type
FROM information_schema.columns
WHERE table_schema='public' AND table_name=$1
ORDER BY ordinal_position`, [refTable]
);
for (const cand of preferred) {
if (rows.find(r => r.column_name === cand)) return cand;
}
const textish = rows.find(r => /text|character varying|varchar/i.test(r.data_type));
if (textish) return textish.column_name;
return rows[0]?.column_name || 'id';
}
// ----------------------------------------------------------
// Middleware para datos globales
// ----------------------------------------------------------
app.use((req, res, next) => {
res.locals.currentPath = req.path;
res.locals.pageTitle = "SuiteCoffee";
res.locals.pageId = "";
next();
});
// ----------------------------------------------------------
// Rutas de UI
// ----------------------------------------------------------
app.get("/", (req, res) => {
res.locals.pageTitle = "Dashboard";
res.locals.pageId = "home"; // para el sidebar contextual
res.render("dashboard");
});
app.get("/dashboard", (req, res) => {
res.locals.pageTitle = "Dashboard";
res.locals.pageId = "dashboard"; // <- importante
res.render("dashboard");
});
// app.get('/', (req, res) => {
// res.sendFile(path.join(__dirname, 'pages', 'dashboard.html'));
// });
app.get("/comandas", (req, res) => {
res.locals.pageTitle = "Comandas";
res.locals.pageId = "comandas"; // <- importante para el sidebar contextual
res.render("comandas");
});
// app.get('/comandas', (req, res) => {
// res.sendFile(path.join(__dirname, 'pages', 'comandas.html'));
// });
app.get("/estadoComandas", (req, res) => {
res.locals.pageTitle = "Estado de Comandas";
res.locals.pageId = "estadoComandas";
res.render("estadoComandas");
});
// app.get('/estadoComandas', (req, res) => {
// res.sendFile(path.join(__dirname, 'pages', 'estadoComandas.html'));
// });
app.get("/productos", (req, res) => {
res.locals.pageTitle = "Productos";
res.locals.pageId = "productos";
res.render("productos");
});
app.get('/usuarios', (req, res) => {
res.locals.pageTitle = 'Usuarios';
res.locals.pageId = 'usuarios';
res.render('usuarios');
});
app.get('/reportes', (req, res) => {
res.locals.pageTitle = 'Reportes';
res.locals.pageId = 'reportes';
res.render('reportes');
});
app.get('/compras', (req, res) => {
res.locals.pageTitle = 'Compras';
res.locals.pageId = 'compras';
res.render('compras');
});
// ----------------------------------------------------------
// API
// ----------------------------------------------------------
app.get('/api/tables', async (_req, res) => {
res.json(ALLOWED_TABLES);
});
app.get('/api/schema/:table', async (req, res) => {
try {
const table = ensureTable(req.params.table);
const client = await getClient();
try {
const columns = await loadColumns(client, table);
const fks = await loadForeignKeys(client, table);
const enriched = columns.map(c => ({ ...c, foreign: fks[c.column_name] || null }));
res.json({ table, columns: enriched });
} finally { client.release(); }
} catch (e) {
res.status(400).json({ error: e.message });
}
});
app.get('/api/options/:table/:column', async (req, res) => {
try {
const table = ensureTable(req.params.table);
const column = req.params.column;
if (!VALID_IDENT.test(column)) throw new Error('Columna inválida');
const client = await getClient();
try {
const fks = await loadForeignKeys(client, table);
const fk = fks[column];
if (!fk) return res.json([]);
const refTable = fk.foreign_table;
const refId = fk.foreign_column;
const labelCol = await pickLabelColumn(client, refTable);
const sql = `SELECT ${q(refId)} AS id, ${q(labelCol)} AS label FROM ${q(refTable)} ORDER BY ${q(labelCol)} LIMIT 1000`;
const result = await client.query(sql);
res.json(result.rows);
} finally { client.release(); }
} catch (e) {
res.status(400).json({ error: e.message });
}
});
app.get('/api/table/:table', async (req, res) => {
try {
const table = ensureTable(req.params.table);
const limit = Math.min(parseInt(req.query.limit || '100', 10), 1000);
const client = await getClient();
try {
const pks = await loadPrimaryKey(client, table);
const orderBy = pks.length ? `ORDER BY ${pks.map(q).join(', ')} DESC` : '';
const sql = `SELECT * FROM ${q(table)} ${orderBy} LIMIT ${limit}`;
const result = await client.query(sql);
// Normalizar: siempre devolver objetos {col: valor}
const colNames = result.fields.map(f => f.name);
let rows = result.rows;
if (rows.length && Array.isArray(rows[0])) {
rows = rows.map(r => Object.fromEntries(r.map((v, i) => [colNames[i], v])));
}
res.json(rows);
} finally { client.release(); }
} catch (e) {
res.status(400).json({ error: e.message, code: e.code, detail: e.detail });
}
});
app.post('/api/table/:table', async (req, res) => {
const table = ensureTable(req.params.table);
const payload = req.body || {};
try {
const client = await getClient();
try {
const columns = await loadColumns(client, table);
const insertable = columns.filter(c =>
!c.is_primary && !c.is_identity && !(c.column_default || '').startsWith('nextval(')
);
const allowedCols = new Set(insertable.map(c => c.column_name));
const cols = [];
const vals = [];
const params = [];
let idx = 1;
for (const [k, v] of Object.entries(payload)) {
if (!allowedCols.has(k)) continue;
if (!VALID_IDENT.test(k)) continue;
cols.push(q(k));
vals.push(`$${idx++}`);
params.push(v);
}
if (!cols.length) {
const { rows } = await client.query(`INSERT INTO ${q(table)} DEFAULT VALUES RETURNING *`);
res.status(201).json({ inserted: rows[0] });
} else {
const { rows } = await client.query(
`INSERT INTO ${q(table)} (${cols.join(', ')}) VALUES (${vals.join(', ')}) RETURNING *`,
params
);
res.status(201).json({ inserted: rows[0] });
}
} catch (e) {
if (e.code === '23503') return res.status(400).json({ error: 'Violación de clave foránea', detail: e.detail });
if (e.code === '23505') return res.status(400).json({ error: 'Violación de unicidad', detail: e.detail });
if (e.code === '23514') return res.status(400).json({ error: 'Violación de CHECK', detail: e.detail });
if (e.code === '23502') return res.status(400).json({ error: 'Campo NOT NULL faltante', detail: e.detail });
throw e;
}
} catch (e) {
res.status(400).json({ error: e.message });
}
});
app.get('/api/comandas', async (req, res, next) => {
try {
const estado = (req.query.estado || '').trim() || null;
const limit = Math.min(parseInt(req.query.limit || '200', 10), 1000);
const { rows } = await pool.query(
`SELECT * FROM public.f_comandas_resumen($1, $2)`,
[estado, limit]
);
res.json(rows);
} catch (e) { next(e); }
});
// app.get('/api/comandas', async (req, res, next) => {
// try {
// const estado = (req.query.estado || '').trim();
// const limit = Math.min(parseInt(req.query.limit || '200', 10), 1000);
// const params = [];
// let where = '';
// if (estado) { params.push(estado); where = `WHERE c.estado = $${params.length}`; }
// params.push(limit);
// const sql = `
// WITH items AS (
// SELECT d.id_comanda,
// COUNT(*) AS items,
// SUM(d.cantidad * d.pre_unitario) AS total
// FROM deta_comandas d
// GROUP BY d.id_comanda
// )
// SELECT
// c.id_comanda, c.fec_creacion, c.estado, c.observaciones,
// u.id_usuario, u.nombre AS usuario_nombre, u.apellido AS usuario_apellido,
// m.id_mesa, m.numero AS mesa_numero, m.apodo AS mesa_apodo,
// COALESCE(i.items, 0) AS items,
// COALESCE(i.total, 0) AS total
// FROM comandas c
// JOIN usuarios u ON u.id_usuario = c.id_usuario
// JOIN mesas m ON m.id_mesa = c.id_mesa
// LEFT JOIN items i ON i.id_comanda = c.id_comanda
// ${where}
// ORDER BY c.id_comanda DESC
// LIMIT $${params.length}
// `;
// const client = await pool.connect();
// try {
// const { rows } = await client.query(sql, params);
// res.json(rows);
// } finally { client.release(); }
// } catch (e) { next(e); }
// });
// Detalle de una comanda (con nombres de productos)
// GET /api/comandas/:id/detalle
app.get('/api/comandas/:id/detalle', (req, res, next) =>
pool.query(
`SELECT id_det_comanda, id_producto, producto_nombre,
cantidad, pre_unitario, subtotal, observaciones
FROM public.v_comandas_detalle_items
WHERE id_comanda = $1::int
ORDER BY id_det_comanda`,
[req.params.id]
)
.then(r => res.json(r.rows))
.catch(next)
);
// app.get('/api/comandas/:id/detalle', async (req, res, next) => {
// try {
// const id = parseInt(req.params.id, 10);
// if (!Number.isInteger(id) || id <= 0) {
// return res.status(400).json({ error: 'id inválido' });
// }
// const sql = `
// SELECT
// id_det_comanda, id_producto, producto_nombre,
// cantidad, pre_unitario, subtotal, observaciones
// FROM public.v_comandas_detalle_items
// WHERE id_comanda = $1::int
// ORDER BY id_det_comanda
// `;
// const { rows } = await pool.query(sql, [id]);
// res.json(rows);
// } catch (e) { next(e); }
// });
// app.get('/api/comandas/:id/detalle', async (req, res, next) => {
// try {
// const id = parseInt(req.params.id, 10);
// if (!id) return res.status(400).json({ error: 'id inválido' });
// const sql = `
// SELECT d.id_det_comanda, d.id_producto, p.nombre AS producto_nombre,
// d.cantidad, d.pre_unitario, (d.cantidad * d.pre_unitario) AS subtotal,
// d.observaciones
// FROM deta_comandas d
// JOIN productos p ON p.id_producto = d.id_producto
// WHERE d.id_comanda = $1
// ORDER BY d.id_det_comanda
// `;
// const { rows } = await pool.query(sql, [id]);
// res.json(rows);
// } catch (e) { next(e); }
// });
// Cerrar comanda (setea estado y fec_cierre en DB)
app.post('/api/comandas/:id/cerrar', async (req, res, next) => {
try {
const id = Number(req.params.id);
if (!Number.isInteger(id) || id <= 0) {
return res.status(400).json({ error: 'id inválido' });
}
const { rows } = await pool.query(
`SELECT public.f_cerrar_comanda($1) AS data`,
[id]
);
if (!rows.length || rows[0].data === null) {
return res.status(404).json({ error: 'Comanda no encontrada' });
}
res.json(rows[0].data);
} catch (err) { next(err); }
});
// Abrir (reabrir) comanda
app.post('/api/comandas/:id/abrir', async (req, res, next) => {
try {
const id = Number(req.params.id);
if (!Number.isInteger(id) || id <= 0) {
return res.status(400).json({ error: 'id inválido' });
}
const { rows } = await pool.query(
`SELECT public.f_abrir_comanda($1) AS data`,
[id]
);
if (!rows.length || rows[0].data === null) {
return res.status(404).json({ error: 'Comanda no encontrada' });
}
res.json(rows[0].data);
} catch (err) { next(err); }
});
// // Cambiar estado (abrir/cerrar)
// app.post('/api/comandas/:id/estado', async (req, res, next) => {
// try {
// const id = parseInt(req.params.id, 10);
// let { estado } = req.body || {};
// if (!id) return res.status(400).json({ error: 'id inválido' });
// const allowed = new Set(['abierta','cerrada','pagada','anulada']);
// if (!allowed.has(estado)) return res.status(400).json({ error: 'estado inválido' });
// const { rows } = await pool.query(
// `UPDATE comandas SET estado = $2 WHERE id_comanda = $1 RETURNING *`,
// [id, estado]
// );
// if (!rows.length) return res.status(404).json({ error: 'comanda no encontrada' });
// res.json({ updated: rows[0] });
// } catch (e) { next(e); }
// });
// GET producto + receta
app.get('/api/rpc/get_producto/:id', async (req, res) => {
const id = Number(req.params.id);
const { rows } = await pool.query('SELECT public.get_producto($1) AS data', [id]);
res.json(rows[0]?.data || {});
});
// POST guardar producto + receta
app.post('/api/rpc/save_producto', async (req, res) => {
try {
// console.debug('receta payload:', req.body?.receta); // habilitalo si lo necesitás
const q = 'SELECT public.save_producto($1,$2,$3,$4,$5,$6,$7::jsonb) AS id_producto';
const { id_producto=null, nombre, img_producto=null, precio=0, activo=true, id_categoria=null, receta=[] } = req.body || {};
const params = [id_producto, nombre, img_producto, precio, activo, id_categoria, JSON.stringify(receta||[])];
const { rows } = await pool.query(q, params);
res.json(rows[0] || {});
} catch(e) {
console.error(e);
res.status(500).json({ error: 'save_producto failed' });
}
});
// app.post('/api/rpc/save_producto', async (req, res) => {
// const { id_producto=null, nombre, img_producto=null, precio=0, activo=true, id_categoria=null, receta=[] } = req.body || {};
// const q = 'SELECT * FROM public.save_producto($1,$2,$3,$4,$5,$6,$7::jsonb)';
// const params = [id_producto, nombre, img_producto, precio, activo, id_categoria, JSON.stringify(receta||[])];
// const { rows } = await pool.query(q, params);
// res.json(rows[0] || {});
// });
// GET MP + proveedores
app.get('/api/rpc/get_materia/:id', async (req, res) => {
const id = Number(req.params.id);
try {
const { rows } = await pool.query('SELECT public.get_materia_prima($1) AS data', [id]);
res.json(rows[0]?.data || {});
} catch (e) {
console.error(e);
res.status(500).json({ error: 'get_materia failed' });
}
});
// SAVE MP + proveedores (array)
app.post('/api/rpc/save_materia', async (req, res) => {
const { id_mat_prima=null, nombre, unidad, activo=true, proveedores=[] } = req.body || {};
try {
const q = 'SELECT public.save_materia_prima($1,$2,$3,$4,$5::jsonb) AS id_mat_prima';
const params = [id_mat_prima, nombre, unidad, activo, JSON.stringify(proveedores||[])];
const { rows } = await pool.query(q, params);
res.json(rows[0] || {});
} catch (e) {
console.error(e);
res.status(500).json({ error: 'save_materia failed' });
}
});
// POST /api/rpc/find_usuarios_por_documentos { docs: ["12345678","09123456", ...] }
app.post('/api/rpc/find_usuarios_por_documentos', async (req, res) => {
try {
const docs = Array.isArray(req.body?.docs) ? req.body.docs : [];
const sql = 'SELECT public.find_usuarios_por_documentos($1::jsonb) AS data';
const { rows } = await pool.query(sql, [JSON.stringify(docs)]);
res.json(rows[0]?.data || {});
} catch (e) {
console.error(e);
res.status(500).json({ error: 'find_usuarios_por_documentos failed' });
}
});
// POST /api/rpc/import_asistencia { registros: [...], origen?: "AGL_001.txt" }
app.post('/api/rpc/import_asistencia', async (req, res) => {
try {
const registros = Array.isArray(req.body?.registros) ? req.body.registros : [];
const origen = req.body?.origen || null;
const sql = 'SELECT public.import_asistencia($1::jsonb,$2) AS data';
const { rows } = await pool.query(sql, [JSON.stringify(registros), origen]);
res.json(rows[0]?.data || {});
} catch (e) {
console.error(e);
res.status(500).json({ error: 'import_asistencia failed' });
}
});
// Consultar datos de asistencia (raw + pares) para un usuario y rango
app.post('/api/rpc/asistencia_get', async (req, res) => {
try {
const { doc, desde, hasta } = req.body || {};
const sql = 'SELECT public.asistencia_get($1::text,$2::date,$3::date) AS data';
const { rows } = await pool.query(sql, [doc, desde, hasta]);
res.json(rows[0]?.data || {});
} catch (e) {
console.error(e); res.status(500).json({ error: 'asistencia_get failed' });
}
});
// Editar un registro crudo y recalcular pares
app.post('/api/rpc/asistencia_update_raw', async (req, res) => {
try {
const { id_raw, fecha, hora, modo } = req.body || {};
const sql = 'SELECT public.asistencia_update_raw($1::bigint,$2::date,$3::text,$4::text) AS data';
const { rows } = await pool.query(sql, [id_raw, fecha, hora, modo ?? null]);
res.json(rows[0]?.data || {});
} catch (e) {
console.error(e); res.status(500).json({ error: 'asistencia_update_raw failed' });
}
});
// Eliminar un registro crudo y recalcular pares
app.post('/api/rpc/asistencia_delete_raw', async (req, res) => {
try {
const { id_raw } = req.body || {};
const sql = 'SELECT public.asistencia_delete_raw($1::bigint) AS data';
const { rows } = await pool.query(sql, [id_raw]);
res.json(rows[0]?.data || {});
} catch (e) {
console.error(e); res.status(500).json({ error: 'asistencia_delete_raw failed' });
}
});
// POST /api/rpc/report_tickets { year }
app.post('/api/rpc/report_tickets', async (req, res) => {
try {
const y = parseInt(req.body?.year ?? req.query?.year, 10);
const year = (Number.isFinite(y) && y >= 2000 && y <= 2100)
? y
: (new Date()).getFullYear();
const { rows } = await pool.query(
'SELECT public.report_tickets_year($1::int) AS j', [year]
);
res.json(rows[0].j);
} catch (e) {
console.error('report_tickets error:', e);
res.status(500).json({
error: 'report_tickets failed',
message: e.message, detail: e.detail, where: e.where, code: e.code
});
}
});
// POST /api/rpc/report_asistencia { desde: 'YYYY-MM-DD', hasta: 'YYYY-MM-DD' }
app.post('/api/rpc/report_asistencia', async (req, res) => {
try {
let { desde, hasta } = req.body || {};
// defaults si vienen vacíos/invalidos
const re = /^\d{4}-\d{2}-\d{2}$/;
if (!re.test(desde) || !re.test(hasta)) {
const end = new Date();
const start = new Date(end); start.setDate(end.getDate()-30);
desde = start.toISOString().slice(0,10);
hasta = end.toISOString().slice(0,10);
}
const { rows } = await pool.query(
'SELECT public.report_asistencia($1::date,$2::date) AS j', [desde, hasta]
);
res.json(rows[0].j);
} catch (e) {
console.error('report_asistencia error:', e);
res.status(500).json({
error: 'report_asistencia failed',
message: e.message, detail: e.detail, where: e.where, code: e.code
});
}
});
// app.post('/api/rpc/report_asistencia', async (req,res)=>{
// try{
// const {desde, hasta} = req.body||{};
// const sql = 'SELECT * FROM public.report_asistencia($1::date,$2::date)';
// const {rows} = await pool.query(sql,[desde, hasta]);
// res.json(rows);
// } catch (e) {
// console.error(e);
// res.status(500).json({ error: 'report_tickets failed' + e });
// }
// });
// app.post('/api/rpc/report_tickets', async (req, res) => {
// try {
// const { year } = req.body || {};
// const sql = 'SELECT public.report_tickets_year($1::int) AS data';
// const { rows } = await pool.query(sql, [year]);
// res.json(rows[0]?.data || {});
// } catch (e) {
// console.error(e);
// res.status(500).json({ error: 'report_tickets failed' + e });
// }
// });
// Guardar (insert/update)
app.post('/api/rpc/save_compra', async (req, res) => {
try {
const { id_compra, id_proveedor, fec_compra, detalles } = req.body || {};
const sql = 'SELECT * FROM public.save_compra($1::int,$2::int,$3::timestamptz,$4::jsonb)';
const args = [id_compra ?? null, id_proveedor, fec_compra ? new Date(fec_compra) : null, JSON.stringify(detalles)];
const { rows } = await pool.query(sql, args);
res.json(rows[0]); // { id_compra, total }
} catch (e) {
console.error('save_compra error:', e);
res.status(500).json({ error: 'save_compra failed', message: e.message, detail: e.detail, where: e.where, code: e.code });
}
});
// Obtener para editar
app.post('/api/rpc/get_compra', async (req, res) => {
try {
const { id_compra } = req.body || {};
const sql = `SELECT public.get_compra($1::int) AS data`;
const { rows } = await pool.query(sql, [id_compra]);
res.json(rows[0]?.data || {});
} catch (e) {
console.error(e); res.status(500).json({ error: 'get_compra failed' });
}
});
// Eliminar
app.post('/api/rpc/delete_compra', async (req, res) => {
try {
const { id_compra } = req.body || {};
await pool.query(`SELECT public.delete_compra($1::int)`, [id_compra]);
res.json({ ok: true });
} catch (e) {
console.error(e); res.status(500).json({ error: 'delete_compra failed' });
}
});
// POST /api/rpc/report_gastos { year: 2025 }
app.post('/api/rpc/report_gastos', async (req, res) => {
try {
const year = parseInt(req.body?.year ?? new Date().getFullYear(), 10);
const { rows } = await pool.query(
'SELECT public.report_gastos($1::int) AS j', [year]
);
res.json(rows[0].j);
} catch (e) {
console.error('report_gastos error:', e);
res.status(500).json({
error: 'report_gastos failed',
message: e.message, detail: e.detail, code: e.code
});
}
});
// (Opcional) GET para probar rápido desde el navegador:
// /api/rpc/report_gastos?year=2025
app.get('/api/rpc/report_gastos', async (req, res) => {
try {
const year = parseInt(req.query.year ?? new Date().getFullYear(), 10);
const { rows } = await pool.query(
'SELECT public.report_gastos($1::int) AS j', [year]
);
res.json(rows[0].j);
} catch (e) {
console.error('report_gastos error:', e);
res.status(500).json({
error: 'report_gastos failed',
message: e.message, detail: e.detail, code: e.code
});
}
});
// ----------------------------------------------------------
// Verificación de conexión
// ----------------------------------------------------------
async function verificarConexion() {
try {
const client = await pool.connect();
const res = await client.query('SELECT NOW() AS hora');
console.log(`\nConexión con la base de datos ${chalk.green(process.env.DB_NAME)} fue exitosa.`);
console.log('Fecha y hora actual de la base de datos:', res.rows[0].hora);
client.release();
} catch (error) {
console.error('Error al conectar con la base de datos al iniciar:', error.message);
console.error('Revisar credenciales y accesos de red.');
}
}
// ----------------------------------------------------------
// Inicio del servidor
// ----------------------------------------------------------
const PORT = process.env.PORT ? Number(process.env.PORT) : 3000;
app.listen(PORT, () => {
console.log(`Servidor de aplicación escuchando en ${chalk.yellow(`http://localhost:${PORT}`)}`);
console.log(chalk.grey(`Comprobando accesibilidad a la db ${chalk.white(process.env.DB_NAME)} del host ${chalk.white(process.env.DB_HOST)} ...`));
verificarConexion();
});
// Healthcheck
app.get('/health', async (_req, res) => {
res.status(200).json({ status: 'ok' });
});
+321
View File
@@ -0,0 +1,321 @@
// ./services/manso/src/index.mjs
import 'dotenv/config';// Variables de Entorno
import favicon from 'serve-favicon'; // Favicon
import express from 'express';
import expressLayouts from 'express-ejs-layouts';
import pool from '#db'; // Pool Singleton
import v1Router from '#v1Router'; // Rutas API v1
import expressPages from '#pages'; // Rutas "/", "/dashboard", ...
import path from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// ----------------------------------------------------------
// Variables del sistema
// ----------------------------------------------------------
const PORT = process.env.PORT ? Number(process.env.PORT) : 3000;
const url = v => !v ? "" : (v.startsWith("http") ? v : `/img/productos/${v}`);
const VALID_IDENT = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
const q = (s) => `"${String(s).replace(/"/g, '""')}"`; // Identificadores SQL -> comillas dobles y escape correcto
// ----------------------------------------------------------
// App + Motor de vistas EJS
// ----------------------------------------------------------
const app = express();
app.set('trust proxy', true);
app.set("views", path.join(__dirname, "views"));
app.set("view engine", "ejs");
app.set("layout", "layouts/main");
app.use(express.json());
app.use(express.json({ limit: '1mb' }));
app.use(express.static(path.join(__dirname, 'public'))); // Carga de archivos estaticos
app.use(expressLayouts); // Carga los layouts que usara el renderizado
app.use(favicon(path.join(__dirname, 'public', 'favicon', 'favicon.ico'), {maxAge: '1y'}));
app.use(expressPages); // Renderizado trae las paginas desde ./services/manso/src/routes/routes.js
// ----------------------------------------------------------
// Uso de API v1
// ----------------------------------------------------------
app.use("/api/v1", v1Router);
// /api/rpc/get_producto/:id
// /api/v1/rpc/get_producto/:id -> /rpc/get_producto/:id
// ----------------------------------------------------------
// Seguridad: Tablas permitidas
// ----------------------------------------------------------
const ALLOWED_TABLES = [
'roles','usuarios','usua_roles',
'categorias','productos',
'clientes','mesas',
'comandas','deta_comandas',
'proveedores','compras','deta_comp_producto',
'mate_primas','deta_comp_materias',
'prov_producto','prov_mate_prima',
'receta_producto', 'asistencia_resumen_diario',
'asistencia_intervalo', 'asistencia_detalle',
'vw_compras'
];
function ensureTable(name) {
const t = String(name || '').toLowerCase();
if (!ALLOWED_TABLES.includes(t)) throw new Error('Tabla no permitida');
return t;
}
// ----------------------------------------------------------
// Introspección de esquema
// ----------------------------------------------------------
async function loadColumns(client, table) {
const sql = `
SELECT
c.column_name,
c.data_type,
c.is_nullable = 'YES' AS is_nullable,
c.column_default,
(SELECT EXISTS (
SELECT 1 FROM pg_attribute a
JOIN pg_class t ON t.oid = a.attrelid
JOIN pg_index i ON i.indrelid = t.oid AND a.attnum = ANY(i.indkey)
WHERE t.relname = $1 AND i.indisprimary AND a.attname = c.column_name
)) AS is_primary,
(SELECT a.attgenerated = 's' OR a.attidentity IN ('a','d')
FROM pg_attribute a
JOIN pg_class t ON t.oid = a.attrelid
WHERE t.relname = $1 AND a.attname = c.column_name
) AS is_identity
FROM information_schema.columns c
WHERE c.table_schema='public' AND c.table_name=$1
ORDER BY c.ordinal_position
`;
const { rows } = await client.query(sql, [table]);
return rows;
}
async function loadForeignKeys(client, table) {
const sql = `
SELECT
kcu.column_name,
ccu.table_name AS foreign_table,
ccu.column_name AS foreign_column
FROM information_schema.table_constraints tc
JOIN information_schema.key_column_usage kcu
ON tc.constraint_name = kcu.constraint_name AND tc.table_schema = kcu.table_schema
JOIN information_schema.constraint_column_usage ccu
ON ccu.constraint_name = tc.constraint_name AND ccu.table_schema = tc.table_schema
WHERE tc.table_schema='public' AND tc.table_name=$1 AND tc.constraint_type='FOREIGN KEY'
`;
const { rows } = await client.query(sql, [table]);
const map = {};
for (const r of rows) map[r.column_name] = { foreign_table: r.foreign_table, foreign_column: r.foreign_column };
return map;
}
async function loadPrimaryKey(client, table) {
const sql = `
SELECT a.attname AS column_name
FROM pg_index i
JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = ANY(i.indkey)
JOIN pg_class t ON t.oid = i.indrelid
WHERE t.relname = $1 AND i.indisprimary
`;
const { rows } = await client.query(sql, [table]);
return rows.map(r => r.column_name);
}
// label column for FK options
async function pickLabelColumn(client, refTable) {
const preferred = ['nombre','raz_social','apodo','documento','correo','telefono'];
const { rows } = await client.query(
`SELECT column_name, data_type
FROM information_schema.columns
WHERE table_schema='public' AND table_name=$1
ORDER BY ordinal_position`, [refTable]
);
for (const cand of preferred) {
if (rows.find(r => r.column_name === cand)) return cand;
}
const textish = rows.find(r => /text|character varying|varchar/i.test(r.data_type));
if (textish) return textish.column_name;
return rows[0]?.column_name || 'id';
}
// ----------------------------------------------------------
// Middleware para datos globales
// ----------------------------------------------------------
app.use((req, res, next) => {
res.locals.currentPath = req.path;
res.locals.pageTitle = "SuiteCoffee";
res.locals.pageId = "";
next();
});
// ----------------------------------------------------------
// API
// ----------------------------------------------------------
app.get('/api/tables', async (_req, res) => {
res.json(ALLOWED_TABLES);
});
app.get('/api/schema/:table', async (req, res) => {
try {
const table = ensureTable(req.params.table);
const client = await pool.getClient();
try {
const columns = await loadColumns(client, table);
const fks = await loadForeignKeys(client, table);
const enriched = columns.map(c => ({ ...c, foreign: fks[c.column_name] || null }));
res.json({ table, columns: enriched });
} finally { client.release(); }
} catch (e) {
res.status(400).json({ error: e.message });
}
});
app.get('/api/options/:table/:column', async (req, res) => {
try {
const table = ensureTable(req.params.table);
const column = req.params.column;
if (!VALID_IDENT.test(column)) throw new Error('Columna inválida');
const client = await pool.getClient();
try {
const fks = await loadForeignKeys(client, table);
const fk = fks[column];
if (!fk) return res.json([]);
const refTable = fk.foreign_table;
const refId = fk.foreign_column;
const labelCol = await pickLabelColumn(client, refTable);
const sql = `SELECT ${q(refId)} AS id, ${q(labelCol)} AS label FROM ${q(refTable)} ORDER BY ${q(labelCol)} LIMIT 1000`;
const result = await client.query(sql);
res.json(result.rows);
} finally { client.release(); }
} catch (e) {
res.status(400).json({ error: e.message });
}
});
app.get('/api/table/:table', async (req, res) => {
try {
const table = ensureTable(req.params.table);
const limit = Math.min(parseInt(req.query.limit || '100', 10), 1000);
const client = await pool.getClient();
try {
const pks = await loadPrimaryKey(client, table);
const orderBy = pks.length ? `ORDER BY ${pks.map(q).join(', ')} DESC` : '';
const sql = `SELECT * FROM ${q(table)} ${orderBy} LIMIT ${limit}`;
const result = await client.query(sql);
// Normalizar: siempre devolver objetos {col: valor}
const colNames = result.fields.map(f => f.name);
let rows = result.rows;
if (rows.length && Array.isArray(rows[0])) {
rows = rows.map(r => Object.fromEntries(r.map((v, i) => [colNames[i], v])));
}
res.json(rows);
} finally { client.release(); }
} catch (e) {
res.status(400).json({ error: e.message, code: e.code, detail: e.detail });
}
});
app.post('/api/table/:table', async (req, res) => {
const table = ensureTable(req.params.table);
const payload = req.body || {};
try {
const client = await pool.getClient();
try {
const columns = await loadColumns(client, table);
const insertable = columns.filter(c =>
!c.is_primary && !c.is_identity && !(c.column_default || '').startsWith('nextval(')
);
const allowedCols = new Set(insertable.map(c => c.column_name));
const cols = [];
const vals = [];
const params = [];
let idx = 1;
for (const [k, v] of Object.entries(payload)) {
if (!allowedCols.has(k)) continue;
if (!VALID_IDENT.test(k)) continue;
cols.push(q(k));
vals.push(`$${idx++}`);
params.push(v);
}
if (!cols.length) {
const { rows } = await client.query(`INSERT INTO ${q(table)} DEFAULT VALUES RETURNING *`);
res.status(201).json({ inserted: rows[0] });
} else {
const { rows } = await client.query(
`INSERT INTO ${q(table)} (${cols.join(', ')}) VALUES (${vals.join(', ')}) RETURNING *`,
params
);
res.status(201).json({ inserted: rows[0] });
}
} catch (e) {
if (e.code === '23503') return res.status(400).json({ error: 'Violación de clave foránea', detail: e.detail });
if (e.code === '23505') return res.status(400).json({ error: 'Violación de unicidad', detail: e.detail });
if (e.code === '23514') return res.status(400).json({ error: 'Violación de CHECK', detail: e.detail });
if (e.code === '23502') return res.status(400).json({ error: 'Campo NOT NULL faltante', detail: e.detail });
throw e;
} finally { client.release(); }
} catch (e) {
res.status(400).json({ error: e.message });
}
});
// ----------------------------------------------------------
// Verificación de conexión
// ----------------------------------------------------------
async function verificarConexion() {
try {
var client = await pool.getClient();
let res = await client.query('SELECT NOW() AS hora');
console.log(`\nConexión con la base de datos ${process.env.DB_NAME} fue exitosa.`);
console.log('Fecha y hora actual de la base de datos:\n ->', res.rows[0].hora);
} catch (error) {
console.error('Error al conectar con la base de datos al iniciar: \n ->', error.message);
console.error('Revisar credenciales y accesos de red.');
} finally{
client.release();
}
}
// ----------------------------------------------------------
// Inicio del servidor
// ----------------------------------------------------------
app.listen(PORT, () => {
console.log(`Servidor de aplicación escuchando en ${`http://localhost:${PORT}`}`);
console.log(`Comprobando accesibilidad a la db ${process.env.DB_NAME} del host ${process.env.DB_HOST} ...`);
verificarConexion();
});
// Healthcheck
app.get('/health', (_req, res) => res.status(200).json({ status: 'ok'}));
-355
View File
@@ -1,355 +0,0 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>Comandas</title>
<style>
:root { --gap: 12px; --radius: 10px; }
* { box-sizing: border-box; }
body { margin:0; font-family: system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, 'Helvetica Neue', Arial; background:#f6f7fb; color:#111; }
header { position: sticky; top: 0; background:#fff; border-bottom:1px solid #e7e7ef; padding:14px 18px; display:flex; gap:10px; align-items:center; z-index:2; }
header h1 { margin:0; font-size:16px; font-weight:600; }
main { padding: 18px; max-width: 1200px; margin: 0 auto; display:grid; grid-template-columns: 1.15fr 0.85fr; gap: var(--gap); }
.card { background:#fff; border:1px solid #e7e7ef; border-radius: var(--radius); }
.card .hd { padding:12px 14px; border-bottom:1px solid #eee; display:flex; align-items:center; gap:10px; }
.card .bd { padding:14px; }
.row { display:flex; gap:10px; align-items:center; flex-wrap: wrap; }
.grid { display:grid; gap:10px; }
.grid.cols-2 { grid-template-columns: 1fr 1fr; }
.muted { color:#666; }
select, input, textarea, button { font: inherit; padding:10px; border:1px solid #dadbe4; border-radius:8px; background:#fff; }
select:focus, input:focus, textarea:focus { outline:none; border-color:#999; }
input[type="number"] { width: 100%; }
textarea { width:100%; min-height: 68px; resize: vertical; }
button { cursor: pointer; }
.btn { padding:10px 12px; border-radius:8px; border:1px solid #dadbe4; background:#fafafa; }
.btn.primary { background:#111; border-color:#111; color:#fff; }
.btn.ghost { background:#fff; }
.btn.small { padding:6px 8px; font-size: 13px; }
.pill { font-size:12px; padding:2px 8px; border-radius:99px; border:1px solid #e0e0ea; background:#fafafa; display:inline-block; }
.toolbar { display:flex; gap:10px; align-items:center; }
.spacer { flex:1 }
.search { display:flex; gap:8px; }
.search input { flex:1; }
table { width:100%; border-collapse: collapse; }
thead th { text-align:left; font-size:12px; text-transform: uppercase; letter-spacing:.04em; color:#555; background:#fafafa; }
th, td { padding:8px 10px; border-bottom:1px solid #eee; vertical-align: middle; }
.qty { display:flex; align-items:center; gap:6px; }
.qty input { width: 90px; }
.right { text-align:right; }
.total { font-size: 22px; font-weight: 700; }
.notice { padding:10px; border-radius:8px; border:1px solid #e7e7ef; background:#fafafa; }
.ok { color:#0a7d28; }
.err { color:#b00020; }
.sticky-footer { position: sticky; bottom: 0; background:#fff; padding:12px 14px; border-top:1px solid #eee; display:flex; gap:10px; align-items:center; }
.kpi { display:flex; gap:6px; align-items: baseline; }
</style>
</head>
<body>
<header>
<h1>📋 Nueva Comanda</h1>
<div class="spacer"></div>
<span class="pill muted">/api/*</span>
</header>
<main>
<!-- Panel izquierdo: productos -->
<section class="card" id="panelProductos">
<div class="hd">
<strong>Productos</strong>
<div class="spacer"></div>
<div class="toolbar">
<span class="muted" id="prodCount">0 ítems</span>
</div>
</div>
<div class="bd">
<div class="row search" style="margin-bottom:10px;">
<input id="busqueda" type="search" placeholder="Buscar por nombre o categoría…"/>
<button class="btn" id="limpiarBusqueda">Limpiar</button>
</div>
<div id="listadoProductos" style="max-height: 58vh; overflow:auto;">
<!-- tabla productos -->
</div>
</div>
</section>
<!-- Panel derecho: datos + carrito + crear -->
<section class="card" id="panelComanda">
<div class="hd"><strong>Detalles</strong></div>
<div class="bd grid" style="gap:14px;">
<div class="grid cols-2">
<div>
<label class="muted">Mesa</label>
<select id="selMesa"></select>
</div>
<div>
<label class="muted">Usuario</label>
<select id="selUsuario"></select>
</div>
</div>
<div>
<label class="muted">Observaciones</label>
<textarea id="obs"></textarea>
</div>
<div class="notice muted">La fecha se completa automáticamente y los estados/activos usan sus valores por defecto.</div>
<div class="card">
<div class="hd"><strong>Carrito</strong></div>
<div class="bd" id="carritoWrap">
<div class="muted">Aún no agregaste productos.</div>
</div>
<div class="sticky-footer">
<div class="kpi"><span class="muted">Ítems:</span><strong id="kpiItems">0</strong></div>
<div class="kpi" style="margin-left:8px;"><span class="muted">Total:</span><strong id="kpiTotal">$ 0.00</strong></div>
<div class="spacer"></div>
<button class="btn ghost" id="vaciar">Vaciar</button>
<button class="btn primary" id="crear">Crear Comanda</button>
</div>
</div>
<div id="msg" class="muted"></div>
</div>
</section>
</main>
<script>
const $ = (s, r=document) => r.querySelector(s);
const $$ = (s, r=document) => Array.from(r.querySelectorAll(s));
const state = {
productos: [],
mesas: [],
usuarios: [],
carrito: [], // [{id_producto, nombre, pre_unitario, cantidad}]
filtro: ''
};
// ---------- Utils ----------
const money = (n) => (isNaN(n) ? '—' : new Intl.NumberFormat('es-UY', { style:'currency', currency:'UYU' }).format(Number(n)));
const toast = (msg, ok=false) => { const el = $('#msg'); el.className = ok ? 'ok' : 'err'; el.textContent = msg; setTimeout(()=>{ el.textContent=''; el.className='muted'; }, 3500); };
async function jget(url) {
const res = await fetch(url);
let data; try { data = await res.json(); } catch { data = null; }
if (!res.ok) throw new Error(data?.error || `${res.status} ${res.statusText}`);
return data;
}
async function jpost(url, body) {
const res = await fetch(url, { method:'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify(body) });
const data = await res.json().catch(()=>null);
if (!res.ok) throw new Error(data?.error || `${res.status} ${res.statusText}`);
return data;
}
// ---------- Load data ----------
async function init() {
// productos, mesas, usuarios
const [prods, mesas, usuarios] = await Promise.all([
jget('/api/table/productos?limit=1000'),
jget('/api/table/mesas?limit=1000'),
jget('/api/table/usuarios?limit=1000')
]);
state.productos = prods.filter(p => p.activo !== false); // si existe activo=false, filtrarlo
state.mesas = mesas;
state.usuarios = usuarios.filter(u => u.activo !== false);
hydrateMesas();
hydrateUsuarios();
renderProductos();
renderCarrito();
$('#busqueda').addEventListener('input', () => { state.filtro = $('#busqueda').value.trim().toLowerCase(); renderProductos(); });
$('#limpiarBusqueda').addEventListener('click', () => { $('#busqueda').value=''; state.filtro=''; renderProductos(); });
$('#vaciar').addEventListener('click', () => { state.carrito=[]; renderCarrito(); });
$('#crear').addEventListener('click', crearComanda);
}
function hydrateMesas() {
const sel = $('#selMesa'); sel.innerHTML = '';
for (const m of state.mesas) {
const o = document.createElement('option');
o.value = m.id_mesa;
o.textContent = `#${m.numero} · ${m.apodo} (${m.estado})`;
sel.appendChild(o);
}
}
function hydrateUsuarios() {
const sel = $('#selUsuario'); sel.innerHTML = '';
for (const u of state.usuarios) {
const o = document.createElement('option');
o.value = u.id_usuario;
o.textContent = `${u.nombre} ${u.apellido}`.trim();
sel.appendChild(o);
}
}
// ---------- Productos ----------
function renderProductos() {
let rows = state.productos.slice();
if (state.filtro) {
rows = rows.filter(p =>
(p.nombre || '').toLowerCase().includes(state.filtro) ||
String(p.id_categoria ?? '').includes(state.filtro)
);
}
$('#prodCount').textContent = `${rows.length} ítems`;
if (!rows.length) {
$('#listadoProductos').innerHTML = '<div class="muted">Sin resultados.</div>';
return;
}
const tbl = document.createElement('table');
tbl.innerHTML = `
<thead>
<tr>
<th>Producto</th>
<th class="right">Precio</th>
<th style="width:180px;">Cantidad</th>
<th style="width:90px;"></th>
</tr>
</thead>
<tbody></tbody>
`;
const tb = tbl.querySelector('tbody');
for (const p of rows) {
const tr = document.createElement('tr');
tr.innerHTML = `
<td>${p.nombre}</td>
<td class="right">${money(p.precio)}</td>
<td>
<div class="qty">
<input type="number" min="0.001" step="0.001" value="1.000" data-qty />
<button class="btn small" data-dec>-</button>
<button class="btn small" data-inc>+</button>
</div>
</td>
<td><button class="btn primary small" data-add>Agregar</button></td>
`;
const qty = tr.querySelector('[data-qty]');
tr.querySelector('[data-dec]').addEventListener('click', () => { qty.value = Math.max(0.001, (parseFloat(qty.value||'0') - 1)).toFixed(3); });
tr.querySelector('[data-inc]').addEventListener('click', () => { qty.value = (parseFloat(qty.value||'0') + 1).toFixed(3); });
tr.querySelector('[data-add]').addEventListener('click', () => addToCart(p, parseFloat(qty.value||'1')) );
tb.appendChild(tr);
}
$('#listadoProductos').innerHTML = '';
$('#listadoProductos').appendChild(tbl);
}
function addToCart(prod, cantidad) {
if (!(cantidad > 0)) { toast('Cantidad inválida'); return; }
const precio = parseFloat(prod.precio);
const it = state.carrito.find(i => i.id_producto === prod.id_producto && i.pre_unitario === precio);
if (it) it.cantidad = Number((it.cantidad + cantidad).toFixed(3));
else state.carrito.push({ id_producto: prod.id_producto, nombre: prod.nombre, pre_unitario: precio, cantidad: Number(cantidad.toFixed(3)) });
renderCarrito();
}
// ---------- Carrito ----------
function renderCarrito() {
const wrap = $('#carritoWrap');
if (!state.carrito.length) { wrap.innerHTML = '<div class="muted">Aún no agregaste productos.</div>'; $('#kpiItems').textContent='0'; $('#kpiTotal').textContent=money(0); return; }
const tbl = document.createElement('table');
tbl.innerHTML = `
<thead>
<tr>
<th>Producto</th>
<th class="right">Unitario</th>
<th class="right">Cantidad</th>
<th class="right">Subtotal</th>
<th></th>
</tr>
</thead>
<tbody></tbody>
`;
const tb = tbl.querySelector('tbody');
let items = 0, total = 0;
state.carrito.forEach((it, idx) => {
items += 1;
const sub = Number(it.pre_unitario) * Number(it.cantidad);
total += sub;
const tr = document.createElement('tr');
tr.innerHTML = `
<td>${it.nombre}</td>
<td class="right">${money(it.pre_unitario)}</td>
<td class="right">
<input type="number" min="0.001" step="0.001" value="${it.cantidad.toFixed(3)}" style="width:120px"/>
</td>
<td class="right">${money(sub)}</td>
<td class="right">
<button class="btn small" data-del>Quitar</button>
</td>
`;
const qty = tr.querySelector('input[type="number"]');
qty.addEventListener('change', () => {
const v = parseFloat(qty.value||'0');
if (!(v>0)) { toast('Cantidad inválida'); qty.value = it.cantidad.toFixed(3); return; }
it.cantidad = Number(v.toFixed(3));
renderCarrito();
});
tr.querySelector('[data-del]').addEventListener('click', () => {
state.carrito.splice(idx,1);
renderCarrito();
});
tb.appendChild(tr);
});
wrap.innerHTML = '';
wrap.appendChild(tbl);
$('#kpiItems').textContent = String(items);
$('#kpiTotal').textContent = money(total);
}
// ---------- Crear comanda ----------
async function crearComanda() {
if (!state.carrito.length) { toast('Agrega al menos un producto'); return; }
const id_mesa = parseInt($('#selMesa').value, 10);
const id_usuario = parseInt($('#selUsuario').value, 10);
if (!id_mesa || !id_usuario) { toast('Selecciona mesa y usuario'); return; }
const observaciones = $('#obs').value.trim() || null;
try {
// 1) encabezado comanda (estado por defecto: 'abierta'; fecha la pone la DB)
const { inserted: com } = await jpost('/api/table/comandas', {
id_usuario,
id_mesa,
estado: 'abierta',
observaciones
});
// 2) detalle (una inserción por renglón)
const id_comanda = com.id_comanda;
const payloads = state.carrito.map(it => ({
id_comanda,
id_producto: it.id_producto,
cantidad: it.cantidad,
pre_unitario: it.pre_unitario
}));
await Promise.all(payloads.map(p => jpost('/api/table/deta_comandas', p)));
state.carrito = [];
renderCarrito();
$('#obs').value = '';
toast(`Comanda #${id_comanda} creada`, true);
} catch (e) {
toast(e.message || 'No se pudo crear la comanda');
}
}
// GO
init().catch(err => toast(err.message || 'Error cargando datos'));
</script>
</body>
</html>
-293
View File
@@ -1,293 +0,0 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>Dashboard</title>
<style>
:root { --radius: 10px; }
* { box-sizing: border-box; }
body { margin:0; font-family: system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, 'Helvetica Neue', Arial, 'Noto Sans', 'Apple Color Emoji', 'Segoe UI Emoji'; background:#f6f7fb; color:#111; }
header { position: sticky; top: 0; background:#fff; border-bottom:1px solid #e8e8ef; padding:16px 20px; display:flex; gap:12px; align-items:center; z-index:1;}
header h1 { margin:0; font-size:18px; font-weight:600;}
main { padding: 20px; max-width: 1200px; margin: 0 auto; }
.card { background:#fff; border:1px solid #e8e8ef; border-radius: var(--radius); padding:16px; }
.row { display:flex; gap:16px; align-items: center; flex-wrap:wrap; }
select, input, button, textarea { font: inherit; padding:10px; border-radius:8px; border:1px solid #d7d7e0; background:#fff; }
select:focus, input:focus, textarea:focus { outline: none; border-color:#888; }
button { cursor:pointer; }
button.primary { background:#111; color:#fff; border-color:#111; }
table { width:100%; border-collapse: collapse; }
thead th { text-align:left; font-size:12px; text-transform: uppercase; letter-spacing:.04em; color:#555; background:#fafafa; }
th, td { padding:10px 12px; border-bottom: 1px solid #eee; vertical-align: top; }
.muted { color:#666; }
.tabs { display:flex; gap:6px; margin-top:12px; }
.tab { padding:8px 10px; border:1px solid #e0e0ea; border-bottom:none; background:#fafafa; border-top-left-radius:8px; border-top-right-radius:8px; cursor:pointer; font-size:14px; }
.tab.active { background:#fff; border-color:#e0e0ea; }
.panel { border:1px solid #e0e0ea; border-radius: 0 8px 8px 8px; padding:16px; background:#fff; }
.grid { display:grid; grid-template-columns: repeat(auto-fill,minmax(220px,1fr)); gap:12px; }
.help { font-size:12px; color:#777; margin-top:6px; }
.pill { font-size:12px; padding:2px 8px; border-radius:99px; border:1px solid #e0e0ea; background:#fafafa; display:inline-block; }
.toolbar { display:flex; gap:10px; align-items:center; }
.spacer { flex:1 }
.error { color:#b00020; }
.success { color:#0a7d28; }
.sr-only{ position:absolute; width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0;}
details summary { cursor:pointer; }
</style>
</head>
<body>
<header>
<h1>Dashboard</h1>
<div class="spacer"></div>
<span class="pill muted">/api/*</span>
</header>
<main class="card">
<div class="row" style="margin-bottom:12px;">
<label for="tableSelect" class="sr-only">Tabla</label>
<select id="tableSelect"></select>
<div class="spacer"></div>
<div class="toolbar">
<button id="refreshBtn">Recargar</button>
<span id="status" class="muted"></span>
</div>
</div>
<div class="tabs">
<button class="tab active" data-tab="datos">Datos</button>
<button class="tab" data-tab="nuevo">Nuevo</button>
<button class="tab" data-tab="esquema">Esquema</button>
</div>
<section class="panel" id="panel-datos">
<div class="help">Mostrando hasta <span id="limitInfo">100</span> filas.</div>
<div id="tableContainer" style="overflow:auto;"></div>
</section>
<section class="panel" id="panel-nuevo" hidden>
<form id="insertForm" class="grid"></form>
<div class="row" style="margin-top:10px;">
<div class="spacer"></div>
<button id="insertBtn" class="primary">Insertar</button>
</div>
<div id="insertMsg" class="help"></div>
</section>
<section class="panel" id="panel-esquema" hidden>
<pre id="schemaPre" style="white-space:pre-wrap;"></pre>
</section>
<details style="margin-top:16px;">
<summary>Endpoints</summary>
<div class="help">GET /api/tables • GET /api/schema/:tabla • GET /api/table/:tabla?limit=100 • POST /api/table/:tabla</div>
</details>
</main>
<script>
const $ = (s, r=document) => r.querySelector(s);
const $$ = (s, r=document) => Array.from(r.querySelectorAll(s));
const state = { tables: [], table: null, schema: null, limit: 100 };
// Tabs
$$('.tab').forEach(t => t.addEventListener('click', () => {
$$('.tab').forEach(x => x.classList.remove('active'));
t.classList.add('active');
const tab = t.dataset.tab;
$('#panel-datos').hidden = tab !== 'datos';
$('#panel-nuevo').hidden = tab !== 'nuevo';
$('#panel-esquema').hidden = tab !== 'esquema';
}));
$('#refreshBtn').addEventListener('click', () => {
if (state.table) {
loadSchema(state.table);
loadData(state.table);
}
});
async function init() {
setStatus('Cargando tablas…');
const res = await fetch('/api/tables');
const tables = await res.json();
state.tables = tables;
const sel = $('#tableSelect');
sel.innerHTML = '';
tables.forEach(name => {
const o = document.createElement('option');
o.value = name; o.textContent = name;
sel.appendChild(o);
});
sel.addEventListener('change', () => selectTable(sel.value));
if (tables.length) {
selectTable(tables[0]);
} else {
setStatus('No hay tablas disponibles.');
}
}
async function selectTable(tbl) {
state.table = tbl;
await loadSchema(tbl);
await loadData(tbl);
buildForm();
}
async function loadSchema(tbl) {
const res = await fetch(`/api/schema/${tbl}`);
state.schema = await res.json();
$('#schemaPre').textContent = JSON.stringify(state.schema, null, 2);
}
async function loadData(tbl) {
setStatus('Cargando datos…');
const res = await fetch(`/api/table/${tbl}?limit=${state.limit}`);
const data = await res.json();
$('#limitInfo').textContent = String(state.limit);
renderTable(data);
clearStatus();
}
function renderTable(rows) {
const c = $('#tableContainer');
c.innerHTML = '';
if (!rows.length) { c.innerHTML = '<div class="muted">Sin datos.</div>'; return; }
const headers = Object.keys(rows[0]);
const table = document.createElement('table');
table.innerHTML = `
<thead><tr>${headers.map(h => '<th>'+h+'</th>').join('')}</tr></thead>
<tbody></tbody>
`;
const tbody = table.querySelector('tbody');
for (const row of rows) {
const tr = document.createElement('tr');
tr.innerHTML = headers.map(h => '<td>'+formatCell(row[h])+'</td>').join('');
tbody.appendChild(tr);
}
c.appendChild(table);
}
function formatCell(v) {
if (v === null || v === undefined) return '<span class="muted">NULL</span>';
if (typeof v === 'boolean') return v ? '✓' : '—';
if (typeof v === 'string' && v.match(/^\\d{4}-\\d{2}-\\d{2}/)) return new Date(v).toLocaleString();
return String(v);
}
function buildForm() {
const form = $('#insertForm');
form.innerHTML = '';
if (!state.schema) return;
for (const col of state.schema.columns) {
// Omitir PK auto y columnas generadas
if (col.is_primary || col.is_identity || (col.column_default || '').startsWith('nextval(')) continue;
const wrap = document.createElement('div');
const id = 'f_'+col.column_name;
wrap.innerHTML = `
<label for="${id}" class="muted">${col.column_name} <span class="muted">${col.data_type}</span> ${col.is_nullable ? '' : '<span class="pill">requerido</span>'}</label>
<div data-input></div>
${col.column_default ? '<div class="help">DEFAULT: '+col.column_default+'</div>' : ''}
`;
const holder = wrap.querySelector('[data-input]');
if (col.foreign) {
const sel = document.createElement('select');
sel.id = id;
holder.appendChild(sel);
hydrateOptions(sel, state.schema.table, col.column_name);
} else if (col.data_type.includes('boolean')) {
const inp = document.createElement('input');
inp.id = id; inp.type = 'checkbox';
holder.appendChild(inp);
} else if (col.data_type.includes('timestamp')) {
const inp = document.createElement('input');
inp.id = id; inp.type = 'datetime-local'; inp.required = !col.is_nullable && !col.column_default;
holder.appendChild(inp);
} else if (col.data_type.includes('date')) {
const inp = document.createElement('input');
inp.id = id; inp.type = 'date'; inp.required = !col.is_nullable && !col.column_default;
holder.appendChild(inp);
} else if (col.data_type.match(/numeric|integer|real|double/)) {
const inp = document.createElement('input');
inp.id = id; inp.type = 'number'; inp.step = 'any'; inp.required = !col.is_nullable && !col.column_default;
holder.appendChild(inp);
} else if (col.data_type.includes('text') || col.data_type.includes('character')) {
const inp = document.createElement('input');
inp.id = id; inp.type = 'text'; inp.required = !col.is_nullable && !col.column_default;
holder.appendChild(inp);
} else {
const inp = document.createElement('input');
inp.id = id; inp.type = 'text'; inp.required = !col.is_nullable && !col.column_default;
holder.appendChild(inp);
}
form.appendChild(wrap);
}
}
async function hydrateOptions(selectEl, table, column) {
selectEl.innerHTML = '<option value="">Cargando…</option>';
const res = await fetch(`/api/options/${table}/${column}`);
const opts = await res.json();
selectEl.innerHTML = '<option value="">Seleccione…</option>' + opts.map(o => `<option value="${o.id}">${o.label}</option>`).join('');
}
$('#insertBtn').addEventListener('click', async (e) => {
e.preventDefault();
if (!state.table) return;
const payload = {};
for (const col of state.schema.columns) {
if (col.is_primary || col.is_identity || (col.column_default || '').startsWith('nextval(')) continue;
const id = 'f_'+col.column_name;
const el = document.getElementById(id);
if (!el) continue;
let val = null;
if (el.type === 'checkbox') {
val = el.checked;
} else if (el.type === 'datetime-local' && el.value) {
// Convertir a ISO
val = new Date(el.value).toISOString().slice(0,19).replace('T',' ');
} else if (el.tagName === 'SELECT') {
val = el.value ? (isNaN(el.value) ? el.value : Number(el.value)) : null;
} else if (el.type === 'number') {
val = el.value === '' ? null : Number(el.value);
} else {
val = el.value === '' ? null : el.value;
}
if (val === null && !col.is_nullable && !col.column_default) {
showInsertMsg('Completa: '+col.column_name, true);
return;
}
if (val !== null) payload[col.column_name] = val;
}
try {
const res = await fetch(`/api/table/${state.table}`, {
method: 'POST',
headers: { 'Content-Type':'application/json' },
body: JSON.stringify(payload)
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Error');
showInsertMsg('Insertado correctamente (id: '+(data.inserted?.id || '?')+')', false);
// Reset form
$('#insertForm').reset?.();
await loadData(state.table);
} catch (e) {
showInsertMsg(e.message, true);
}
});
function showInsertMsg(msg, isError=false) {
const m = $('#insertMsg');
m.className = 'help ' + (isError ? 'error' : 'success');
m.textContent = msg;
}
function setStatus(text) { $('#status').textContent = text; }
function clearStatus() { setStatus(''); }
// Start
init();
</script>
</body>
</html>
@@ -1,280 +0,0 @@
<!-- pages/estadoComandas.html -->
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>Estado de Comandas</title>
<style>
:root { --gap: 12px; --radius: 10px; }
* { box-sizing: border-box; }
body { margin:0; font-family: system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, 'Helvetica Neue', Arial; background:#f6f7fb; color:#111; }
header { position: sticky; top: 0; background:#fff; border-bottom:1px solid #e7e7ef; padding:14px 18px; display:flex; gap:10px; align-items:center; z-index:2; }
header h1 { margin:0; font-size:16px; font-weight:600; }
main { padding: 18px; max-width: 1200px; margin: 0 auto; display:grid; grid-template-columns: 0.9fr 1.1fr; gap: var(--gap); }
.card { background:#fff; border:1px solid #e7e7ef; border-radius: var(--radius); }
.card .hd { padding:12px 14px; border-bottom:1px solid #eee; display:flex; align-items:center; gap:10px; }
.card .bd { padding:14px; }
.row { display:flex; gap:10px; align-items:center; flex-wrap: wrap; }
.grid { display:grid; gap:10px; }
.muted { color:#666; }
.pill { font-size:12px; padding:2px 8px; border-radius:99px; border:1px solid #e0e0ea; background:#fafafa; display:inline-block; }
.list { max-height: 70vh; overflow:auto; }
.list table { width:100%; border-collapse: collapse; }
.list th, .list td { padding:8px 10px; border-bottom:1px solid #eee; }
.list thead th { text-align:left; font-size:12px; text-transform: uppercase; letter-spacing:.04em; color:#555; background:#fafafa; }
.right { text-align:right; }
.btn { padding:10px 12px; border-radius:8px; border:1px solid #dadbe4; background:#fafafa; cursor:pointer; }
.btn.primary { background:#111; color:#fff; border-color:#111; }
.btn.danger { background:#b00020; color:#fff; border-color:#b00020; }
.btn.small { font-size: 13px; padding:6px 8px; }
select, input, textarea { font: inherit; padding:10px; border:1px solid #dadbe4; border-radius:8px; background:#fff; }
.kpi { display:flex; gap:6px; align-items: baseline; }
.sticky-footer { position: sticky; bottom: 0; background:#fff; padding:12px 14px; border-top:1px solid #eee; display:flex; gap:10px; align-items:center; }
.ok { color:#0a7d28; }
.err { color:#b00020; }
.tag { font-size:12px; padding:2px 8px; border-radius:6px; border:1px solid #e7e7ef; background:#fafafa; }
.tag.abierta { border-color:#0a7d28; color:#0a7d28; }
.tag.cerrada { border-color:#555; color:#555; }
.tag.pagada { border-color:#1b5e20; color:#1b5e20; }
.tag.anulada { border-color:#b00020; color:#b00020; }
table { width:100%; border-collapse: collapse; }
th, td { padding:8px 10px; border-bottom:1px solid #eee; }
thead th { text-align:left; font-size:12px; text-transform: uppercase; letter-spacing:.04em; color:#555; background:#fafafa; }
</style>
</head>
<body>
<header>
<h1>🧾 Estado de Comandas</h1>
<div style="flex:1"></div>
<a class="pill" href="/comandas"> Nueva comanda</a>
</header>
<main>
<!-- Izquierda: listado -->
<section class="card">
<div class="hd">
<strong>Listado</strong>
<div style="flex:1"></div>
<label class="muted" style="display:flex; gap:6px; align-items:center;">
<input id="soloAbiertas" type="checkbox" checked />
Solo abiertas
</label>
</div>
<div class="bd">
<div class="row" style="margin-bottom:10px;">
<input id="buscar" type="search" placeholder="Buscar por #, mesa o usuario…" style="flex:1"/>
<button class="btn" id="limpiar">Limpiar</button>
</div>
<div class="list" id="lista"></div>
</div>
</section>
<!-- Derecha: detalle -->
<section class="card">
<div class="hd">
<strong>Detalle</strong>
<div style="flex:1"></div>
<span id="detalleEstado" class="tag">—</span>
</div>
<div class="bd" id="detalle">
<div class="muted">Selecciona una comanda para ver el detalle.</div>
</div>
<div class="sticky-footer">
<div class="kpi"><span class="muted">ID:</span><strong id="kpiId">—</strong></div>
<div class="kpi" style="margin-left:8px;"><span class="muted">Mesa:</span><strong id="kpiMesa">—</strong></div>
<div class="kpi" style="margin-left:8px;"><span class="muted">Total:</span><strong id="kpiTotal">$ 0.00</strong></div>
<div style="flex:1"></div>
<button class="btn" id="reabrir">Reabrir</button>
<button class="btn primary" id="cerrar">Cerrar</button>
</div>
<div class="bd">
<div id="msg" class="muted"></div>
</div>
</section>
</main>
<script>
const $ = (s, r=document) => r.querySelector(s);
const $$ = (s, r=document) => Array.from(r.querySelectorAll(s));
const state = {
filtro: '',
soloAbiertas: true,
lista: [], // [{ id_comanda, mesa_numero, mesa_apodo, usuario_nombre, usuario_apellido, fec_creacion, estado, observaciones }]
sel: null, // id seleccionado
detalle: [] // [{ id_det_comanda, producto_nombre, cantidad, pre_unitario, subtotal, observaciones }]
};
const money = (n) => (isNaN(n) ? '—' : new Intl.NumberFormat('es-UY', { style:'currency', currency:'UYU' }).format(Number(n)));
const toast = (msg, ok=false) => { const el = $('#msg'); el.className = ok ? 'ok' : 'err'; el.textContent = msg; setTimeout(()=>{ el.textContent=''; el.className='muted'; }, 3500); };
async function jget(url) {
const res = await fetch(url);
const data = await res.json().catch(()=>null);
if (!res.ok) throw new Error(data?.error || `${res.status} ${res.statusText}`);
return data;
}
async function jpost(url, body) {
const res = await fetch(url, { method:'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify(body) });
const data = await res.json().catch(()=>null);
if (!res.ok) throw new Error(data?.error || `${res.status} ${res.statusText}`);
return data;
}
// ----------- Data -----------
async function loadLista() {
const estado = state.soloAbiertas ? 'abierta' : '';
const url = estado ? `/api/comandas?estado=${encodeURIComponent(estado)}&limit=300` : '/api/comandas?limit=300';
const rows = await jget(url);
state.lista = rows;
renderLista();
}
async function loadDetalle(id) {
const det = await jget(`/api/comandas/${id}/detalle`);
state.detalle = det;
renderDetalle();
}
// ----------- UI: Lista -----------
function renderLista() {
let rows = state.lista.slice();
const f = state.filtro;
if (f) {
const k = f.toLowerCase();
rows = rows.filter(r =>
String(r.id_comanda).includes(k) ||
(String(r.mesa_numero ?? '').includes(k)) ||
((`${r.usuario_nombre||''} ${r.usuario_apellido||''}`).toLowerCase().includes(k))
);
}
const box = $('#lista');
if (!rows.length) { box.innerHTML = '<div class="muted">Sin resultados.</div>'; return; }
const tbl = document.createElement('table');
tbl.innerHTML = `
<thead>
<tr>
<th>#</th>
<th>Mesa</th>
<th>Usuario</th>
<th>Fecha</th>
<th>Estado</th>
<th class="right">Items</th>
<th class="right">Total</th>
</tr>
</thead>
<tbody></tbody>
`;
const tb = tbl.querySelector('tbody');
rows.forEach(r => {
const tr = document.createElement('tr');
tr.style.cursor = 'pointer';
tr.innerHTML = `
<td>${r.id_comanda}</td>
<td>#${r.mesa_numero} · ${r.mesa_apodo || ''}</td>
<td>${(r.usuario_nombre||'') + ' ' + (r.usuario_apellido||'')}</td>
<td>${new Date(r.fec_creacion).toLocaleString()}</td>
<td><span class="tag ${r.estado}">${r.estado}</span></td>
<td class="right">${r.items ?? '—'}</td>
<td class="right">${money(r.total ?? 0)}</td>
`;
tr.addEventListener('click', () => { state.sel = r.id_comanda; loadDetalle(r.id_comanda); applyHeader(r); });
tb.appendChild(tr);
});
box.innerHTML = '';
box.appendChild(tbl);
}
// ----------- UI: Detalle -----------
function applyHeader(r) {
$('#kpiId').textContent = r.id_comanda ?? '—';
$('#kpiMesa').textContent = r.mesa_numero ? `#${r.mesa_numero}` : '—';
$('#detalleEstado').className = `tag ${r.estado}`;
$('#detalleEstado').textContent = r.estado;
$('#kpiTotal').textContent = money(r.total ?? 0);
// Botones según estado
const cerr = $('#cerrar'), reab = $('#reabrir');
if (r.estado === 'abierta') {
cerr.disabled = false; cerr.title = '';
reab.disabled = true; reab.title = 'Ya está abierta';
} else {
cerr.disabled = false; // permitir cerrar (idempotente/override)
reab.disabled = false;
}
}
function renderDetalle() {
const box = $('#detalle');
if (!state.detalle.length) { box.innerHTML = '<div class="muted">Sin detalle.</div>'; return; }
const tbl = document.createElement('table');
tbl.innerHTML = `
<thead>
<tr>
<th>Producto</th>
<th class="right">Unitario</th>
<th class="right">Cantidad</th>
<th class="right">Subtotal</th>
<th>Observaciones</th>
</tr>
</thead>
<tbody></tbody>
`;
const tb = tbl.querySelector('tbody');
let total = 0;
state.detalle.forEach(r => {
total += Number(r.subtotal||0);
const tr = document.createElement('tr');
tr.innerHTML = `
<td>${r.producto_nombre}</td>
<td class="right">${money(r.pre_unitario)}</td>
<td class="right">${Number(r.cantidad).toFixed(3)}</td>
<td class="right">${money(r.subtotal)}</td>
<td>${r.observaciones||''}</td>
`;
tb.appendChild(tr);
});
box.innerHTML = '';
box.appendChild(tbl);
$('#kpiTotal').textContent = money(total);
}
// ----------- Actions -----------
async function setEstado(estado) {
if (!state.sel) return;
try {
const { updated } = await jpost(`/api/comandas/${state.sel}/estado`, { estado });
toast(`Comanda #${updated.id_comanda} → ${updated.estado}`, true);
await loadLista();
// mantener seleccionada si sigue existiendo en filtro
const found = state.lista.find(x => x.id_comanda === updated.id_comanda);
if (found) { applyHeader(found); await loadDetalle(found.id_comanda); }
else {
state.sel = null;
$('#detalle').innerHTML = '<div class="muted">Selecciona una comanda para ver el detalle.</div>';
$('#detalleEstado').textContent = '—'; $('#detalleEstado').className = 'tag';
$('#kpiId').textContent = '—'; $('#kpiMesa').textContent='—'; $('#kpiTotal').textContent = money(0);
}
} catch (e) {
toast(e.message || 'No se pudo cambiar el estado');
}
}
// ----------- Init -----------
$('#soloAbiertas').addEventListener('change', async (e) => { state.soloAbiertas = e.target.checked; await loadLista(); });
$('#buscar').addEventListener('input', () => { state.filtro = $('#buscar').value.trim(); renderLista(); });
$('#limpiar').addEventListener('click', () => { $('#buscar').value=''; state.filtro=''; renderLista(); });
$('#cerrar').addEventListener('click', () => setEstado('cerrada'));
$('#reabrir').addEventListener('click', () => setEstado('abierta'));
(async function main(){ try { await loadLista(); } catch(e){ toast(e.message||'Error cargando comandas'); }})();
</script>
</body>
</html>
+67
View File
@@ -0,0 +1,67 @@
// services/manso/src/api/v1/routes/routes.js
import { Router } from 'express';
const router = Router();
// ----------------------------------------------------------
// Rutas de UI
// ----------------------------------------------------------
router.get('/', (req, res) => {
res.locals.pageTitle = "Inicio"; // Título de pestaña
res.locals.pageId = "home"; // Sidebar contextual
res.render("dashboard"); // Archivo .ejs a renderizar
// res.json({ ok: true, route: '/inicio' }); // Debug json
});
router.get('/dashboard', (req, res) => {
res.locals.pageTitle = "Dashboard";
res.locals.pageId = "dashboard";
res.render("dashboard");
// res.json({ ok: true, route: '/dashboard' });
});
router.get('/comandas', (req, res) => {
res.locals.pageTitle = "Comandas";
res.locals.pageId = "comandas";
res.render("comandas");
// res.json({ ok: true, route: '/comandas' });
});
router.get('/estadoComandas', (req, res) => {
res.locals.pageTitle = "Estado";
res.locals.pageId = "estadoComandas";
res.render("estadoComandas");
// res.json({ ok: true, route: '/estadoComandas' });
});
router.get('/productos', (req, res) => {
res.locals.pageTitle = "Propductos";
res.locals.pageId = "productos";
res.render("productos");
// res.json({ ok: true, route: '/productos' });
});
router.get('/usuarios', (req, res) => {
res.locals.pageTitle = "Usuarios";
res.locals.pageId = "usuarios";
res.render("usuarios");
// res.json({ ok: true, route: '/usuarios' });
});
router.get('/reportes', (req, res) => {
res.locals.pageTitle = "Reportes";
res.locals.pageId = "reportes";
res.render("reportes");
// res.json({ ok: true, route: '/reportes' });
});
router.get('/compras', (req, res) => {
res.locals.pageTitle = "Compras";
res.locals.pageId = "compras";
res.render("compras");
// res.json({ ok: true, route: '/compras' });
});
export default router;
+16 -5
View File
@@ -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();
+3 -3
View File
@@ -253,7 +253,7 @@ async function saveCompra(){
$('#btnGuardar').disabled = true; $('#status').textContent = 'Guardando…';
try{
const res = await jpost('/api/rpc/save_compra', payload);
const res = await jpost('/api/v1/rpc/save_compra', payload);
$('#id_compra').value = res.id_compra;
$('#btnEliminar').classList.remove('d-none');
$('#formTitle').textContent = 'Editar compra #' + res.id_compra;
@@ -272,7 +272,7 @@ async function deleteCompra(){
if (!confirm('¿Eliminar compra #' + id + '?')) return;
$('#btnEliminar').disabled = true;
try{
await jpost('/api/rpc/delete_compra', { id_compra: id });
await jpost('/api/v1/rpc/delete_compra', { id_compra: id });
nuevaCompra();
await loadListado();
}catch(e){
@@ -295,7 +295,7 @@ function nuevaCompra(){
async function cargarCompra(id){
$('#status').textContent = 'Cargando compra…';
try{
const data = await jpost('/api/rpc/get_compra', { id_compra: id });
const data = await jpost('/api/v1/rpc/get_compra', { id_compra: id });
$('#id_compra').value = data.id_compra;
$('#id_proveedor').value = data.id_proveedor;
$('#fec_compra').value = (data.fec_compra || '').replace(' ', 'T').slice(0,16);
+6 -6
View File
@@ -105,14 +105,14 @@
// ===== Data =====
async function loadLista() {
const estado = state.soloAbiertas ? 'abierta' : '';
const url = estado ? `/api/comandas?estado=${encodeURIComponent(estado)}&limit=300` : '/api/comandas?limit=300';
const url = estado ? `/api/v1/comandas?estado=${encodeURIComponent(estado)}&limit=300` : '/api/v1/comandas?limit=300';
const rows = await jget(url);
state.lista = Array.isArray(rows) ? rows : [];
renderLista();
}
async function loadDetalle(id) {
const det = await jget(`/api/comandas/${id}/detalle`);
const det = await jget(`/api/v1/comandas/${id}/detalle`);
state.detalle = Array.isArray(det) ? det : [];
renderDetalle();
}
@@ -241,7 +241,7 @@
async function accionComanda(accion){ // 'abrir' | 'cerrar'
if (!state.sel) return;
try {
await jpost(`/api/comandas/${state.sel}/${accion}`, {}); // el body no se usa en el backend
await jpost(`/api/v1/comandas/${state.sel}/${accion}`, {}); // el body no se usa en el backend
toast(`Comanda #${state.sel} ${accion === 'abrir' ? 'reabierta' : 'cerrada'}`, true);
// Recargar lista y re-aplicar cabecera/detalle si sigue visible
@@ -346,14 +346,14 @@
// ===== Data =====
async function loadLista() {
const estado = state.soloAbiertas ? 'abierta' : '';
const url = estado ? `/api/comandas?estado=${encodeURIComponent(estado)}&limit=300` : '/api/comandas?limit=300';
const url = estado ? `/api/v1/comandas?estado=${encodeURIComponent(estado)}&limit=300` : '/api/v1/comandas?limit=300';
const rows = await jget(url);
state.lista = Array.isArray(rows) ? rows : [];
renderLista();
}
async function loadDetalle(id) {
const det = await jget(`/api/comandas/${id}/detalle`);
const det = await jget(`/api/v1/comandas/${id}/detalle`);
state.detalle = Array.isArray(det) ? det : [];
renderDetalle();
}
@@ -478,7 +478,7 @@
async function setEstado(estado){
if (!state.sel) return;
try {
const { updated } = await jpost(`/api/comandas/${state.sel}/estado`, { estado });
const { updated } = await jpost(`/api/v1/comandas/${state.sel}/estado`, { estado });
toast(`Comanda #${updated.id_comanda} → ${updated.estado}`, true);
await loadLista();
const found = state.lista.find(x => x.id_comanda === updated.id_comanda);
+4 -4
View File
@@ -271,7 +271,7 @@
async function loadProducto(id){
try {
// Usamos la función SQL (RPC) para traer producto + receta en un solo tiro
const data = await jget(`/api/rpc/get_producto/${id}`);
const data = await jget(`/api/v1/rpc/get_producto/${id}`);
const p = data.producto || {};
const r = Array.isArray(data.receta) ? data.receta : [];
@@ -384,7 +384,7 @@
if (!payload.nombre) { toast('Nombre requerido'); return; }
if (!(payload.precio >= 0)) { toast('Precio inválido'); return; }
const { id_producto } = await jpost('/api/rpc/save_producto', payload);
const { id_producto } = await jpost('/api/v1/rpc/save_producto', payload);
toast(`Guardado #${id_producto}`, true);
// refrescar listado y reabrir seleccionado
@@ -489,7 +489,7 @@
// 5) cargar MP + proveedores asignados (via función SQL)
async function loadMp(id) {
try {
const data = await jget(`/api/rpc/get_materia/${id}`); // { materia: {...}, proveedores: [...] }
const data = await jget(`/api/v1/rpc/get_materia/${id}`); // { materia: {...}, proveedores: [...] }
const m = data.materia || {};
const provs = Array.isArray(data.proveedores) ? data.proveedores : [];
@@ -518,7 +518,7 @@
};
if (!payload.nombre) { mpToast('Nombre requerido'); return; }
const r = await jpost('/api/rpc/save_materia', payload); // => { id_mat_prima }
const r = await jpost('/api/v1/rpc/save_materia', payload); // => { id_mat_prima }
mpToast(`Guardado #${r.id_mat_prima}`, true);
// refrescar listas globales
+4 -4
View File
@@ -339,7 +339,7 @@ function barsCompareSVG(a,b){ // a=ventas, b=gastos: arrays [{label,value}]
let cacheAsist=[];
async function fetchAsistencias(desde,hasta){
try { return await jpost('/api/rpc/report_asistencia', { desde, hasta }); }
try { return await jpost('/api/v1/rpc/report_asistencia', { desde, hasta }); }
catch { const url=`/api/table/asistencia_detalle?desde=${encodeURIComponent(desde)}&hasta=${encodeURIComponent(hasta)}&limit=10000`; return await jget(url); }
}
function renderAsistTabla(rows){
@@ -366,7 +366,7 @@ async function loadAsist(){
}
$('#repStatus').textContent = 'Cargando asistencias…';
cacheAsist = await jpost('/api/rpc/report_asistencia', { desde: d, hasta: h })
cacheAsist = await jpost('/api/v1/rpc/report_asistencia', { desde: d, hasta: h })
.catch(async ()=>{
// fallback a tabla genérica si el RPC no está
const url=`/api/table/asistencia_detalle?desde=${encodeURIComponent(d)}&hasta=${encodeURIComponent(h)}&limit=10000`;
@@ -502,7 +502,7 @@ function getYearSafe(val){
async function fetchTickets(year){
const y = getYearSafe(year);
return await jpost('/api/rpc/report_tickets', { year: y });
return await jpost('/api/v1/rpc/report_tickets', { year: y });
}
function renderTickets(data){
@@ -523,7 +523,7 @@ function renderTickets(data){
let cacheGastos=null; // {year, months:[{mes,nombre,importe}], total, avg}
async function fetchGastos(year){
// 1) Intentar RPC
try { return await jpost('/api/rpc/report_gastos', { year }); } catch {}
try { return await jpost('/api/v1/rpc/report_gastos', { year }); } catch {}
// 2) Fallback: traer compras y agrupar en el cliente
const rows = await jget('/api/table/compras?limit=10000&order_by=fec_compra%20asc').catch(()=>[]);
const months = Array.from({length:12},(_,i)=>({mes:i+1,nombre:monthNames[i],importe:0}));
+5 -5
View File
@@ -229,7 +229,7 @@
// RPC helpers
async function rpcGet(doc, desde, hasta){
const r = await fetch('/api/rpc/asistencia_get', {
const r = await fetch('/api/v1/rpc/asistencia_get', {
method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({ doc, desde, hasta })
});
@@ -237,7 +237,7 @@
return await r.json();
}
async function rpcUpdateRaw(id_raw, fecha, hora, modo){
const r = await fetch('/api/rpc/asistencia_update_raw', {
const r = await fetch('/api/v1/rpc/asistencia_update_raw', {
method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({ id_raw, fecha, hora, modo })
});
@@ -245,7 +245,7 @@
return await r.json();
}
async function rpcDeleteRaw(id_raw){
const r = await fetch('/api/rpc/asistencia_delete_raw', {
const r = await fetch('/api/v1/rpc/asistencia_delete_raw', {
method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({ id_raw })
});
@@ -414,7 +414,7 @@
}
async function importAsistencia(registros, origen){
const r = await fetch('/api/rpc/import_asistencia', {
const r = await fetch('/api/v1/rpc/import_asistencia', {
method: 'POST',
headers: {'Content-Type':'application/json'},
body: JSON.stringify({ registros, origen })
@@ -579,7 +579,7 @@
async function fetchNamesForDocs(docs){
try {
const r = await fetch('/api/rpc/find_usuarios_por_documentos', {
const r = await fetch('/api/v1/rpc/find_usuarios_por_documentos', {
method: 'POST',
headers: {'Content-Type':'application/json'},
body: JSON.stringify({ docs })
@@ -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 };
}