Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b4c5d2af4f | |||
| 69f5860b7f | |||
| 5d078f3932 | |||
| 237a5427dd | |||
| 80778c0ed9 |
@@ -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
|
||||
@@ -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
|
||||
@@ -33,6 +33,6 @@ tests/
|
||||
.gitmodules
|
||||
|
||||
# Ignorar archivos personales o privados (si existen)
|
||||
.env.*
|
||||
# .env.*
|
||||
*.pem
|
||||
*.key
|
||||
|
After Width: | Height: | Size: 717 KiB |
|
After Width: | Height: | Size: 717 KiB |
|
After Width: | Height: | Size: 1005 KiB |
|
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 |
|
After Width: | Height: | Size: 186 KiB |
|
After Width: | Height: | Size: 199 KiB |
|
After Width: | Height: | Size: 169 KiB |
|
After Width: | Height: | Size: 57 KiB |
|
After Width: | Height: | Size: 528 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 488 B |
|
After Width: | Height: | Size: 1.5 KiB |
|
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"}
|
||||
@@ -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
|
||||
@@ -67,86 +68,86 @@ services:
|
||||
#################
|
||||
### 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
|
||||
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 Worker
|
||||
authentik-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
|
||||
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: [ak-work]
|
||||
aliases: [dev-authentik]
|
||||
volumes:
|
||||
- ./authentik-media:/media
|
||||
- ./authentik-custom-templates:/templates
|
||||
|
||||
ak-worker:
|
||||
image: ghcr.io/goauthentik/server:latest
|
||||
command: worker
|
||||
environment:
|
||||
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:
|
||||
|
||||
@@ -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:
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
```
|
||||
|
||||
---
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": [],
|
||||
|
||||
@@ -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); }
|
||||
});
|
||||
@@ -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
|
||||
// });
|
||||
// }
|
||||
// });
|
||||
@@ -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();
|
||||
// }
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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/
|
||||
@@ -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
|
||||
@@ -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": ""
|
||||
|
||||
@@ -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);
|
||||
@@ -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}"`);
|
||||
|
||||
|
||||
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' });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
app.post('/api/login', async (req, res) => {
|
||||
const { correo, clave_acceso } = req.body;
|
||||
|
||||
try {
|
||||
const client = await pool.connect();
|
||||
|
||||
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' });
|
||||
// 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 };
|
||||
}
|
||||
|
||||
const tenant = result.rows[0];
|
||||
const coincide = await bcrypt.compare(clave_acceso, tenant.clave_acceso);
|
||||
// 👉 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');
|
||||
|
||||
if (!coincide) {
|
||||
return res.status(401).json({ error: 'Clave incorrecta' });
|
||||
}
|
||||
// si usás placeholders, renderealos acá (opcional)
|
||||
// sql = renderInitSqlTemplate(sql, { schema: schemaName, owner: roleName });
|
||||
|
||||
return res.status(200).json({
|
||||
message: 'Login correcto',
|
||||
uuid: tenant.uuid,
|
||||
nombre_empresa: tenant.nombre_empresa,
|
||||
base_datos: tenant.nombre_base_datos
|
||||
});
|
||||
// 3) forzá el search_path SOLO dentro de esta transacción
|
||||
await tenClient.query(`SET LOCAL search_path TO "${schemaName}", public`);
|
||||
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
return res.status(500).json({ error: 'Error al validar login' });
|
||||
}
|
||||
// 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");
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// --- login: redirige a Authentik con PKCE
|
||||
app.get('/auth/login', async (req, res) => {
|
||||
const client = await getClient();
|
||||
const state = generators.state();
|
||||
const code_verifier = generators.codeVerifier();
|
||||
const code_challenge = generators.codeChallenge(code_verifier);
|
||||
|
||||
req.session.state = state;
|
||||
req.session.code_verifier = code_verifier;
|
||||
|
||||
const authUrl = client.authorizationUrl({
|
||||
scope: 'openid profile email',
|
||||
state,
|
||||
code_challenge,
|
||||
code_challenge_method: 'S256'
|
||||
});
|
||||
|
||||
res.redirect(authUrl);
|
||||
// ===========================
|
||||
// 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 || "/");
|
||||
});
|
||||
});
|
||||
|
||||
// --- 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 });
|
||||
// =============================================
|
||||
// Healthcheck
|
||||
// =============================================
|
||||
app.get('/health', (_req, res) => res.status(200).json({ status: 'ok'}));
|
||||
|
||||
// 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;
|
||||
// =============================================
|
||||
// 404 + Manejo de errores
|
||||
// =============================================
|
||||
app.use((req, res) => res.status(404).json({ error: 'Error 404, No se encontró la página', path: req.originalUrl }));
|
||||
|
||||
// Redirigí a donde quieras (página de bienvenida)
|
||||
res.redirect('/auth/me');
|
||||
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) });
|
||||
});
|
||||
|
||||
// --- 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 });
|
||||
});
|
||||
/*
|
||||
-----------------------------------------------------------------------------
|
||||
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.
|
||||
|
||||
// --- logout simple (borra cookie)
|
||||
app.post('/auth/logout', (req, res) => {
|
||||
req.session = null;
|
||||
res.status(204).end();
|
||||
});
|
||||
# Default
|
||||
export default app; // importar: import app from './app.js'
|
||||
|
||||
// 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}`);
|
||||
# Con nombre
|
||||
export const app = express(); // importar: import { app } from './app.js'
|
||||
-----------------------------------------------------------------------------
|
||||
*/
|
||||
export default app;
|
||||
|
||||
|
||||
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();
|
||||
});
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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();
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||