Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c4097bc737 | |||
| ba6b4fef4f |
+40
-19
@@ -1,37 +1,58 @@
|
|||||||
# Archivo de variables de entorno para docker-compose.yml
|
# Archivo de variables de entorno para docker-compose.yml
|
||||||
COMPOSE_PROJECT_NAME=suitecoffee_dev
|
COMPOSE_PROJECT_NAME=suitecoffee_dev
|
||||||
|
|
||||||
# Entorno de desarrollo
|
# =======================================================
|
||||||
|
# Runtime
|
||||||
NODE_ENV=development
|
NODE_ENV=development
|
||||||
|
|
||||||
# app - app
|
MANSO_PORT=1010 # MVP Manso Microservicio -> services/manso/src/index.mjs
|
||||||
APP_PORT=3030
|
APP_PORT=3030 # Microservicio APP-> services/app/src/index.mjs
|
||||||
# auth - app
|
AUTH_PORT=4040 # Microservicio AUTH -> services/auth/src/index.mjs
|
||||||
AUTH_PORT=4040
|
PLUGINS_PORT=5050 # Microservicio PLUGINS-> services/plugins/src/index.mjs
|
||||||
|
# =======================================================
|
||||||
|
|
||||||
# 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
|
# =======================================================
|
||||||
|
# Configuración de Dases de Datos
|
||||||
|
CORE_DB_HOST=dev-db
|
||||||
|
CORE_DB_NAME=dev_suitecoffee_core
|
||||||
|
CORE_DB_PORT=5432
|
||||||
|
CORE_DB_USER=dev-user-suitecoffee
|
||||||
|
CORE_DB_PASS=dev-pass-suitecoffee
|
||||||
|
|
||||||
|
TENANTS_DB_HOST=dev-tenants
|
||||||
|
TENANTS_DB_NAME=dev_suitecoffee_tenants
|
||||||
|
TENANTS_DB_PORT=5432
|
||||||
|
TENANTS_DB_USER=suitecoffee
|
||||||
|
TENANTS_DB_PASS=suitecoffee
|
||||||
|
|
||||||
AK_HOST_DB=ak-db
|
AK_HOST_DB=ak-db
|
||||||
AK_PG_DB=authentik
|
AK_PG_DB=authentik
|
||||||
AK_PG_USER=authentik
|
AK_PG_USER=authentik
|
||||||
AK_PG_PASS=gOWjL8V564vyh1aXUcqh4o/xo7eObraaCVZezPi3iw2LzPlU
|
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
|
||||||
|
# Authentik Cookies
|
||||||
|
AUTHENTIK_COOKIE__DOMAIN=dev.sso.suitecoffee.uy
|
||||||
|
AUTHENTIK_SECURITY__CSRF_TRUSTED_ORIGINS=https://dev.sso.suitecoffee.uy,https://dev.suitecoffee.uy
|
||||||
|
|
||||||
|
# Authentik Security
|
||||||
AUTHENTIK_SECRET_KEY=11zMsUL57beO+okjeGh7OB3lQdGUWII+VaATHs/zsw1+6KMSTyGfAY0yHpq3C442+3CwrZ/KtjgHBfbv
|
AUTHENTIK_SECRET_KEY=11zMsUL57beO+okjeGh7OB3lQdGUWII+VaATHs/zsw1+6KMSTyGfAY0yHpq3C442+3CwrZ/KtjgHBfbv
|
||||||
|
|
||||||
# Authentik Bootstrap
|
# Authentik Bootstrap
|
||||||
AUTHENTIK_BOOTSTRAP_PASSWORD=info.suitecoffee@gmail.com
|
AUTHENTIK_BOOTSTRAP_PASSWORD=info.suitecoffee@gmail.com
|
||||||
AUTHENTIK_BOOTSTRAP_EMAIL=info.suitecoffee@gmail.com
|
AUTHENTIK_BOOTSTRAP_EMAIL=info.suitecoffee@gmail.com
|
||||||
|
|
||||||
|
AUTHENTIK_EMAIL__HOST=smtp.gmail.com
|
||||||
|
AUTHENTIK_EMAIL__PORT=25
|
||||||
|
AUTHENTIK_EMAIL__USERNAME=info.suitecoffee@gmail.com
|
||||||
|
AUTHENTIK_EMAIL__PASSWORD=Succulent-Sanded7
|
||||||
|
AUTHENTIK_EMAIL__USE_TLS=true # Or false if not using TLS
|
||||||
|
AUTHENTIK_EMAIL__USE_SSL=false # Or true if using SSL directly
|
||||||
|
AUTHENTIK_EMAIL__FROM=info.suitecoffee@gmail.com
|
||||||
|
# =======================================================
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
# 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
|
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 1005 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 186 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 199 KiB |
@@ -0,0 +1,2 @@
|
|||||||
|
|
||||||
|
|
||||||
+47
-26
@@ -4,17 +4,18 @@
|
|||||||
services:
|
services:
|
||||||
|
|
||||||
app:
|
app:
|
||||||
image: node:20-bookworm
|
image: node:20.19.5-bookworm
|
||||||
working_dir: /app
|
working_dir: /app
|
||||||
user: "${UID:-1000}:${GID:-1000}"
|
user: "${UID:-1000}:${GID:-1000}"
|
||||||
volumes:
|
volumes:
|
||||||
- ./services/app:/app:rw
|
- ./services/app:/app:rw
|
||||||
- ./services/app/node_modules:/app/node_modules
|
- ./services/app/node_modules:/app/node_modules
|
||||||
# - ./services/shared:/app/shared
|
- ./packages:/packages
|
||||||
env_file:
|
env_file:
|
||||||
- ./services/app/.env.development
|
- ./services/app/.env.development
|
||||||
environment:
|
environment:
|
||||||
NODE_ENV: development # <- fuerza el entorno para que el loader tome .env.development
|
NODE_ENV: development # <- fuerza el entorno para que el loader tome .env.development
|
||||||
|
NODE_OPTIONS: --preserve-symlinks # la resolución por symlinks (y que @suitecoffee/db encuentre pg instalado en services/app/node_modules
|
||||||
expose:
|
expose:
|
||||||
- ${APP_PORT}
|
- ${APP_PORT}
|
||||||
networks:
|
networks:
|
||||||
@@ -22,38 +23,59 @@ services:
|
|||||||
aliases: [dev-app]
|
aliases: [dev-app]
|
||||||
command: npm run dev
|
command: npm run dev
|
||||||
|
|
||||||
# auth:
|
plugins:
|
||||||
# image: node:20-bookworm
|
image: node:20.19.5-bookworm
|
||||||
# working_dir: /app
|
working_dir: /app
|
||||||
# user: "${UID:-1000}:${GID:-1000}"
|
user: "${UID:-1000}:${GID:-1000}"
|
||||||
# volumes:
|
volumes:
|
||||||
# - ./services/auth:/app:rw
|
- ./services/plugins:/app:rw
|
||||||
# - ./services/auth/node_modules:/app/node_modules
|
- ./services/plugins/node_modules:/app/node_modules
|
||||||
# - ./services/shared:/app/shared
|
- ./packages:/packages
|
||||||
# env_file:
|
env_file:
|
||||||
# - ./services/auth/.env.development
|
- ./services/plugins/.env.development
|
||||||
# environment:
|
environment:
|
||||||
# NODE_ENV: development # <- fuerza el entorno para que el loader tome .env.development
|
NODE_ENV: development # <- fuerza el entorno para que el loader tome .env.development
|
||||||
# expose:
|
NODE_OPTIONS: --preserve-symlinks
|
||||||
# - ${AUTH_PORT}
|
expose:
|
||||||
# networks:
|
- ${PLUGINS_PORT}
|
||||||
# net:
|
networks:
|
||||||
# aliases: [dev-auth]
|
net:
|
||||||
# command: npm run dev
|
aliases: [dev-plugins]
|
||||||
|
command: npm run dev
|
||||||
|
|
||||||
db:
|
auth:
|
||||||
|
image: node:20.19.5-bookworm
|
||||||
|
working_dir: /app
|
||||||
|
user: "${UID:-1000}:${GID:-1000}"
|
||||||
|
volumes:
|
||||||
|
- ./services/auth:/app:rw
|
||||||
|
- ./services/auth/node_modules:/app/node_modules
|
||||||
|
- ./packages:/packages
|
||||||
|
env_file:
|
||||||
|
- ./services/auth/.env.development
|
||||||
|
environment:
|
||||||
|
NODE_ENV: development # <- fuerza el entorno para que el loader tome .env.development
|
||||||
|
NODE_OPTIONS: --preserve-symlinks
|
||||||
|
expose:
|
||||||
|
- ${AUTH_PORT}
|
||||||
|
networks:
|
||||||
|
net:
|
||||||
|
aliases: [dev-auth]
|
||||||
|
command: npm run dev
|
||||||
|
|
||||||
|
dbCore:
|
||||||
image: postgres:16
|
image: postgres:16
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_DB: ${DB_NAME}
|
POSTGRES_DB: ${CORE_DB_NAME}
|
||||||
POSTGRES_USER: ${DB_USER}
|
POSTGRES_USER: ${CORE_DB_USER}
|
||||||
POSTGRES_PASSWORD: ${DB_PASS}
|
POSTGRES_PASSWORD: ${CORE_DB_PASS}
|
||||||
volumes:
|
volumes:
|
||||||
- suitecoffee-db:/var/lib/postgresql/data
|
- suitecoffee-db:/var/lib/postgresql/data
|
||||||
networks:
|
networks:
|
||||||
net:
|
net:
|
||||||
aliases: [dev-db]
|
aliases: [dev-db]
|
||||||
|
|
||||||
tenants:
|
dbTenants:
|
||||||
image: postgres:16
|
image: postgres:16
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_DB: ${TENANTS_DB_NAME}
|
POSTGRES_DB: ${TENANTS_DB_NAME}
|
||||||
@@ -108,7 +130,6 @@ services:
|
|||||||
|
|
||||||
AUTHENTIK_BOOTSTRAP_PASSWORD: ${AUTHENTIK_BOOTSTRAP_PASSWORD}
|
AUTHENTIK_BOOTSTRAP_PASSWORD: ${AUTHENTIK_BOOTSTRAP_PASSWORD}
|
||||||
AUTHENTIK_BOOTSTRAP_EMAIL: ${AUTHENTIK_BOOTSTRAP_EMAIL}
|
AUTHENTIK_BOOTSTRAP_EMAIL: ${AUTHENTIK_BOOTSTRAP_EMAIL}
|
||||||
|
|
||||||
AUTHENTIK_HTTP__TRUSTED_PROXY__CIDRS: "0.0.0.0/0,::/0"
|
AUTHENTIK_HTTP__TRUSTED_PROXY__CIDRS: "0.0.0.0/0,::/0"
|
||||||
AUTHENTIK_SECURITY__CSRF_TRUSTED_ORIGINS: ${AUTHENTIK_SECURITY__CSRF_TRUSTED_ORIGINS}
|
AUTHENTIK_SECURITY__CSRF_TRUSTED_ORIGINS: ${AUTHENTIK_SECURITY__CSRF_TRUSTED_ORIGINS}
|
||||||
AUTHENTIK_COOKIE__DOMAIN: ${AUTHENTIK_COOKIE__DOMAIN}
|
AUTHENTIK_COOKIE__DOMAIN: ${AUTHENTIK_COOKIE__DOMAIN}
|
||||||
|
|||||||
+10
-10
@@ -1,4 +1,4 @@
|
|||||||
# docker-compose.overrride.yml
|
# compose.manso.yml
|
||||||
# Docker Comose para entorno de desarrollo o development.
|
# Docker Comose para entorno de desarrollo o development.
|
||||||
|
|
||||||
|
|
||||||
@@ -11,8 +11,8 @@ services:
|
|||||||
# condition: service_healthy
|
# condition: service_healthy
|
||||||
# tenants:
|
# tenants:
|
||||||
# condition: service_healthy
|
# condition: service_healthy
|
||||||
# expose:
|
expose:
|
||||||
# - ${APP_LOCAL_PORT}
|
- ${MANSO_PORT}
|
||||||
working_dir: /app
|
working_dir: /app
|
||||||
user: "${UID:-1000}:${GID:-1000}"
|
user: "${UID:-1000}:${GID:-1000}"
|
||||||
volumes:
|
volumes:
|
||||||
@@ -21,16 +21,16 @@ services:
|
|||||||
env_file:
|
env_file:
|
||||||
- ./services/manso/.env.development
|
- ./services/manso/.env.development
|
||||||
environment:
|
environment:
|
||||||
- NODE_ENV=${NODE_ENV}
|
NODE_ENV: development
|
||||||
networks:
|
networks:
|
||||||
net:
|
net:
|
||||||
aliases: [manso]
|
aliases: [manso]
|
||||||
healthcheck:
|
#healthcheck:
|
||||||
test: ["CMD-SHELL", "curl -fsS http://localhost:${APP_PORT}/health || exit 1"]
|
# test: ["CMD-SHELL", "curl -fsS http://localhost:${MANSO_PORT}/health || exit 1"]
|
||||||
interval: 10s
|
# interval: 10s
|
||||||
timeout: 3s
|
# timeout: 3s
|
||||||
retries: 10
|
# retries: 10
|
||||||
start_period: 20s
|
# start_period: 20s
|
||||||
command: npm run dev
|
command: npm run dev
|
||||||
profiles: [manso]
|
profiles: [manso]
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|||||||
+32
-14
@@ -15,10 +15,27 @@ services:
|
|||||||
env_file:
|
env_file:
|
||||||
- ./services/app/.env.production
|
- ./services/app/.env.production
|
||||||
environment:
|
environment:
|
||||||
- NODE_ENV=${NODE_ENV}
|
- NODE_ENV: production
|
||||||
networks:
|
networks:
|
||||||
net:
|
net:
|
||||||
aliases: [prod-app]
|
aliases: [app]
|
||||||
|
command: npm run start
|
||||||
|
|
||||||
|
plugins:
|
||||||
|
build:
|
||||||
|
context: ./services/plugins
|
||||||
|
dockerfile: Dockerfile.production
|
||||||
|
expose:
|
||||||
|
- ${PLUGIN_PORT}
|
||||||
|
volumes:
|
||||||
|
- ./services/plugins:/app
|
||||||
|
env_file:
|
||||||
|
- ./services/plugins/.env.production
|
||||||
|
environment:
|
||||||
|
- NODE_ENV: production
|
||||||
|
networks:
|
||||||
|
net:
|
||||||
|
aliases: [plugins]
|
||||||
command: npm run start
|
command: npm run start
|
||||||
|
|
||||||
auth:
|
auth:
|
||||||
@@ -32,40 +49,41 @@ services:
|
|||||||
env_file:
|
env_file:
|
||||||
- ./services/auth/.env.production
|
- ./services/auth/.env.production
|
||||||
environment:
|
environment:
|
||||||
- NODE_ENV=${NODE_ENV}
|
- NODE_ENV: production
|
||||||
command: npm run start
|
|
||||||
networks:
|
networks:
|
||||||
net:
|
net:
|
||||||
aliases: [prod-auth]
|
aliases: [auth]
|
||||||
|
command: npm run start
|
||||||
|
|
||||||
db:
|
dbCore:
|
||||||
image: postgres:16
|
image: postgres:16
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_DB: ${DB_NAME}
|
POSTGRES_DB: ${DB_NAME}
|
||||||
POSTGRES_USER: ${DB_USER}
|
POSTGRES_USER: ${DB_USER}
|
||||||
POSTGRES_PASSWORD: ${DB_PASS}
|
POSTGRES_PASSWORD: ${DB_PASS}
|
||||||
volumes:
|
volumes:
|
||||||
- suitecoffee-db:/var/lib/postgresql/data
|
- dbCore_data:/var/lib/postgresql/data
|
||||||
networks:
|
networks:
|
||||||
net:
|
net:
|
||||||
aliases: [prod-db]
|
aliases: [dbCore]
|
||||||
|
|
||||||
tenants:
|
dbTenants:
|
||||||
image: postgres:16
|
image: postgres:16
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_DB: ${TENANTS_DB_NAME}
|
POSTGRES_DB: ${TENANTS_DB_NAME}
|
||||||
POSTGRES_USER: ${TENANTS_DB_USER}
|
POSTGRES_USER: ${TENANTS_DB_USER}
|
||||||
POSTGRES_PASSWORD: ${TENANTS_DB_PASS}
|
POSTGRES_PASSWORD: ${TENANTS_DB_PASS}
|
||||||
volumes:
|
volumes:
|
||||||
- tenants-db:/var/lib/postgresql/data
|
- dbTenants_data:/var/lib/postgresql/data
|
||||||
networks:
|
networks:
|
||||||
net:
|
net:
|
||||||
aliases: [prod-tenants]
|
aliases: [dbTenants]
|
||||||
|
|
||||||
|
falta implementar authentik en compose.prod.yaml
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
tenants-db:
|
dbCore_data:
|
||||||
suitecoffee-db:
|
dbTenants_data:
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
net:
|
net:
|
||||||
driver: bridge
|
driver: bridge
|
||||||
+32
-18
@@ -5,9 +5,9 @@ name: ${COMPOSE_PROJECT_NAME:-suitecoffee}
|
|||||||
services:
|
services:
|
||||||
app:
|
app:
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
dbCore:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
tenants:
|
dbTenants:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "curl -fsS http://localhost:${APP_PORT}/health || exit 1"]
|
test: ["CMD-SHELL", "curl -fsS http://localhost:${APP_PORT}/health || exit 1"]
|
||||||
@@ -17,33 +17,47 @@ services:
|
|||||||
start_period: 20s
|
start_period: 20s
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
# auth:
|
plugins:
|
||||||
# depends_on:
|
depends_on:
|
||||||
# db:
|
app:
|
||||||
# condition: service_healthy
|
condition: service_healthy
|
||||||
# ak:
|
auth:
|
||||||
# condition: service_started
|
condition: service_healthy
|
||||||
# healthcheck:
|
healthcheck:
|
||||||
# test: ["CMD-SHELL", "curl -fsS http://localhost:${AUTH_PORT}/health || exit 1"]
|
test: ["CMD-SHELL", "curl -fsS http://localhost:${PLUGINS_PORT}/health || exit 1"]
|
||||||
# interval: 10s
|
interval: 10s
|
||||||
# timeout: 3s
|
timeout: 3s
|
||||||
# retries: 10
|
retries: 10
|
||||||
# start_period: 15s
|
start_period: 20s
|
||||||
# restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
db:
|
auth:
|
||||||
|
depends_on:
|
||||||
|
dbCore:
|
||||||
|
condition: service_healthy
|
||||||
|
ak:
|
||||||
|
condition: service_healthy
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "curl -fsS http://localhost:${AUTH_PORT}/health || exit 1"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 10
|
||||||
|
start_period: 20s
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
dbCore:
|
||||||
image: postgres:16
|
image: postgres:16
|
||||||
environment:
|
environment:
|
||||||
TZ: America/Montevideo
|
TZ: America/Montevideo
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "pg_isready -U ${DB_USER} -d ${DB_NAME}"]
|
test: ["CMD-SHELL", "pg_isready -U ${CORE_DB_USER} -d ${CORE_DB_NAME}"]
|
||||||
interval: 5s
|
interval: 5s
|
||||||
timeout: 3s
|
timeout: 3s
|
||||||
retries: 20
|
retries: 20
|
||||||
start_period: 10s
|
start_period: 10s
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
tenants:
|
dbTenants:
|
||||||
image: postgres:16
|
image: postgres:16
|
||||||
environment:
|
environment:
|
||||||
TZ: America/Montevideo
|
TZ: America/Montevideo
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
"name": "suitecoffee",
|
"name": "suitecoffee",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"description": "Software para gestión de cafeterías",
|
"description": "Software para gestión de cafeterías",
|
||||||
|
"private": true,
|
||||||
|
"workspaces": [],
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"coffee",
|
"coffee",
|
||||||
"suite",
|
"suite",
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
// @suitecoffee/api/api.mjs
|
||||||
|
// packages/api/api.mjs
|
||||||
|
// Punto de entrada general del paquete de api.
|
||||||
|
|
||||||
|
export { default as apiv1 } from './v1/apiv1.mjs';
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"name": "@suitecoffee/api",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"main": "./api.mjs",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"import": "./api.mjs",
|
||||||
|
"default": "./api.mjs"
|
||||||
|
},
|
||||||
|
"./package.json": "./package.json"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
".api.mjs"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
// packages/api/v1/apiv1.mjs
|
||||||
|
import { Router } from 'express';
|
||||||
|
|
||||||
|
// Sub-routers (cada uno define sus propios paths absolutos)
|
||||||
|
import comandasApiRouter from './routes/comandas.mjs';
|
||||||
|
// import productosApiRouter from './routes/productos.mjs'; // cuando exista
|
||||||
|
// import clientesApiRouter from './routes/clientes.mjs'; // etc.
|
||||||
|
|
||||||
|
const apiv1 = Router();
|
||||||
|
|
||||||
|
// Monta routers (no pongas prefijo aquí porque ya lo tienen adentro)
|
||||||
|
apiv1.use(comandasApiRouter);
|
||||||
|
// apiv1.use(productosApiRouter);
|
||||||
|
// apiv1.use(clientesApiRouter);
|
||||||
|
|
||||||
|
|
||||||
|
export default apiv1;
|
||||||
|
|
||||||
|
// (Opcional) re-export para tests puntuales
|
||||||
|
// export { comandasApiRouter };
|
||||||
|
// export { productosApiRouter };
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
// packages/api/v1/repositories/comandasRepo.mjs
|
||||||
|
|
||||||
|
import { withTenantClient } from './db.mjs';
|
||||||
|
import { loadColumns, loadPrimaryKey } from '../routes/utils/schemaInspector.mjs';
|
||||||
|
|
||||||
|
const TABLE = 'comandas';
|
||||||
|
const VALID_IDENT = /^[a-z_][a-z0-9_]*$/i;
|
||||||
|
|
||||||
|
export async function listComandas({ schema, abierta, limit }) {
|
||||||
|
return withTenantClient(schema, async (db) => {
|
||||||
|
const max = Math.min(parseInt(limit || 200, 10), 1000);
|
||||||
|
const { rows } = await db.query(
|
||||||
|
`SELECT * FROM public.f_comandas_resumen($1, $2)`,
|
||||||
|
[abierta, max]
|
||||||
|
);
|
||||||
|
return rows;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getDetalleItems({ schema, id }) {
|
||||||
|
return withTenantClient(schema, async (db) => {
|
||||||
|
const { rows } = await db.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`,
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
return rows;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function abrirComanda({ schema, id }) {
|
||||||
|
return withTenantClient(schema, async (db) => {
|
||||||
|
const st = await db.query(`SELECT eliminada FROM public.${q(TABLE)} WHERE id_comanda = $1`, [id]);
|
||||||
|
if (!st.rowCount) return null;
|
||||||
|
if (st.rows[0].eliminada === true) {
|
||||||
|
const err = new Error('Comanda eliminada. Debe restaurarse antes de abrir.');
|
||||||
|
err.http = { status: 409 };
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
const { rows } = await db.query(`SELECT public.f_abrir_comanda($1) AS data`, [id]);
|
||||||
|
return rows[0]?.data || null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function cerrarComanda({ schema, id }) {
|
||||||
|
return withTenantClient(schema, async (db) => {
|
||||||
|
const { rows } = await db.query(`SELECT public.f_cerrar_comanda($1) AS data`, [id]);
|
||||||
|
return rows[0]?.data || null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function restaurarComanda({ schema, id }) {
|
||||||
|
return withTenantClient(schema, async (db) => {
|
||||||
|
const { rows } = await db.query(`SELECT public.f_restaurar_comanda($1) AS data`, [id]);
|
||||||
|
return rows[0]?.data || null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function eliminarComanda({ schema, id }) {
|
||||||
|
return withTenantClient(schema, async (db) => {
|
||||||
|
const { rows } = await db.query(`SELECT public.f_eliminar_comanda($1) AS data`, [id]);
|
||||||
|
return rows[0]?.data || null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function patchComanda({ schema, id, payload }) {
|
||||||
|
return withTenantClient(schema, async (db) => {
|
||||||
|
const columns = await loadColumns(db, TABLE);
|
||||||
|
const updatable = new Set(
|
||||||
|
columns
|
||||||
|
.filter(c =>
|
||||||
|
!c.is_primary &&
|
||||||
|
!c.is_identity &&
|
||||||
|
!(String(c.column_default || '').startsWith('nextval('))
|
||||||
|
)
|
||||||
|
.map(c => c.column_name)
|
||||||
|
);
|
||||||
|
|
||||||
|
const sets = [];
|
||||||
|
const params = [];
|
||||||
|
let idx = 1;
|
||||||
|
for (const [k, v] of Object.entries(payload || {})) {
|
||||||
|
if (!VALID_IDENT.test(k)) continue;
|
||||||
|
if (!updatable.has(k)) continue;
|
||||||
|
sets.push(`${q(k)} = $${idx++}`);
|
||||||
|
params.push(v);
|
||||||
|
}
|
||||||
|
if (!sets.length) return { error: 'Nada para actualizar' };
|
||||||
|
|
||||||
|
const pks = await loadPrimaryKey(db, TABLE);
|
||||||
|
if (pks.length !== 1) {
|
||||||
|
const err = new Error('PK compuesta no soportada');
|
||||||
|
err.http = { status: 400 };
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
params.push(id);
|
||||||
|
|
||||||
|
const { rows } = await db.query(
|
||||||
|
`UPDATE ${q(TABLE)} SET ${sets.join(', ')} WHERE ${q(pks[0])} = $${idx} RETURNING *`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
return rows[0] || null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function q(ident) {
|
||||||
|
return `"${String(ident).replace(/"/g, '""')}"`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
// packages/api/v1/repositories/db.mjs
|
||||||
|
|
||||||
|
import { poolTenants } from '@suitecoffee/db';
|
||||||
|
|
||||||
|
const VALID_IDENT = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
|
||||||
|
|
||||||
|
export async function withTenantClient(req, fn, { trx = false } = {}) {
|
||||||
|
const schema = req?.tenant?.schema;
|
||||||
|
if (!schema || !VALID_IDENT.test(schema)) {
|
||||||
|
throw new Error('Schema de tenant no resuelto/ inválido');
|
||||||
|
}
|
||||||
|
const client = await poolTenants.connect();
|
||||||
|
try {
|
||||||
|
if (trx) await client.query('BEGIN');
|
||||||
|
await client.query(`SET LOCAL search_path = "${schema}", public`);
|
||||||
|
const result = await fn(client);
|
||||||
|
if (trx) await client.query('COMMIT');
|
||||||
|
return result;
|
||||||
|
} catch (e) {
|
||||||
|
if (trx) await client.query('ROLLBACK');
|
||||||
|
throw e;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function tquery(req, sql, params = [], opts = {}) {
|
||||||
|
return withTenantClient(req, (c) => c.query(sql, params), opts);
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
// packages/api/v1/routes/comandas.mjs
|
||||||
|
|
||||||
|
import { Router } from 'express';
|
||||||
|
|
||||||
|
import { tenantContext } from '@suitecoffee/middlewares';
|
||||||
|
import { listarComandas, detalleComanda, actualizarComanda, eliminarComanda } from './handlers/comandasHand.mjs';
|
||||||
|
|
||||||
|
const comandasApiRouter = Router();
|
||||||
|
|
||||||
|
comandasApiRouter.use(tenantContext);
|
||||||
|
|
||||||
|
// Colección
|
||||||
|
comandasApiRouter.route('/comandas').get(listarComandas);
|
||||||
|
|
||||||
|
// Recurso
|
||||||
|
comandasApiRouter.route('/comandas/:id').get(detalleComanda)
|
||||||
|
.put(actualizarComanda)
|
||||||
|
.delete(eliminarComanda);
|
||||||
|
|
||||||
|
export default comandasApiRouter;
|
||||||
|
|
||||||
|
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
// API Comandas
|
||||||
|
/*
|
||||||
|
Escalabilidad: si luego agregás PUT /comandas/:id o DELETE /comandas/:id,
|
||||||
|
lo hacés en la misma ruta encadenando métodos:
|
||||||
|
|
||||||
|
router
|
||||||
|
.route('/comandas/:id')
|
||||||
|
.get(detalleComanda)
|
||||||
|
.put(actualizarComanda)
|
||||||
|
.delete(eliminarComanda);
|
||||||
|
|
||||||
|
Middleware común: podrías usar .all(requireAuth) o .all(validarTenant) si necesitás autenticación o contexto del tenant.
|
||||||
|
*/
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
/*
|
||||||
|
router.route('/comandas').get(listarComandas); // GET /comandas
|
||||||
|
|
||||||
|
// router.route('/comandas/:id').get(detalleComanda); // GET /comandas/:id
|
||||||
|
// router.route('/comandas/:id/abrir').post(abrirComanda); // POST /comandas/:id/abrir
|
||||||
|
// router.route('/comandas/:id/cerrar').post(cerrarComanda); // POST /comandas/:id/cerrar
|
||||||
|
|
||||||
|
// Recurso
|
||||||
|
router.route('/comandas/:id')
|
||||||
|
.get(detalleComanda) // GET /comandas/:id
|
||||||
|
.put(actualizarComanda) // PUT /comandas/:id (accion: abrir|cerrar|restaurar) o patch genérico
|
||||||
|
.delete(eliminarComanda); // DELETE /comandas/:id -> borrado lógico (eliminada=true)
|
||||||
|
*/
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
// packages/api/v1/routes/handlers/comandas.js
|
||||||
|
|
||||||
|
import {
|
||||||
|
listComandas,
|
||||||
|
getDetalleItems,
|
||||||
|
abrirComanda,
|
||||||
|
cerrarComanda,
|
||||||
|
restaurarComanda,
|
||||||
|
eliminarComanda as eliminarComandaRepo,
|
||||||
|
patchComanda
|
||||||
|
} from '../../repositories/comandasRepo.mjs';
|
||||||
|
|
||||||
|
const asBoolean = (v) => {
|
||||||
|
const s = (v ?? '').toString().trim().toLowerCase();
|
||||||
|
return s === 'true' ? true : s === 'false' ? false : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function listarComandas(req, res, next) {
|
||||||
|
try {
|
||||||
|
const abierta = asBoolean(req.query.abierta);
|
||||||
|
const limit = req.query.limit;
|
||||||
|
const rows = await listComandas({ schema: req.tenant.schema, abierta, limit });
|
||||||
|
res.json(rows);
|
||||||
|
} catch (e) { next(e); }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function detalleComanda(req, res, next) {
|
||||||
|
try {
|
||||||
|
const id = parseId(req.params.id);
|
||||||
|
const rows = await getDetalleItems({ schema: req.tenant.schema, id });
|
||||||
|
res.json(rows);
|
||||||
|
} catch (e) { next(e); }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function actualizarComanda(req, res, next) {
|
||||||
|
try {
|
||||||
|
const id = parseId(req.params.id);
|
||||||
|
const { accion, ...patch } = req.body || {};
|
||||||
|
|
||||||
|
if (accion === 'abrir') {
|
||||||
|
const data = await abrirComanda({ schema: req.tenant.schema, id });
|
||||||
|
return data ? res.json(data) : res.status(404).json({ error: 'Comanda no encontrada' });
|
||||||
|
}
|
||||||
|
if (accion === 'cerrar') {
|
||||||
|
const data = await cerrarComanda({ schema: req.tenant.schema, id });
|
||||||
|
return data ? res.json(data) : res.status(404).json({ error: 'Comanda no encontrada' });
|
||||||
|
}
|
||||||
|
if (accion === 'restaurar') {
|
||||||
|
const data = await restaurarComanda({ schema: req.tenant.schema, id });
|
||||||
|
return data ? res.json(data) : res.status(404).json({ error: 'Comanda no encontrada' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await patchComanda({ schema: req.tenant.schema, id, payload: patch });
|
||||||
|
if (!result) return res.status(404).json({ error: 'Comanda no encontrada' });
|
||||||
|
if (result?.error) return res.status(400).json({ error: result.error });
|
||||||
|
res.json(result);
|
||||||
|
} catch (e) {
|
||||||
|
if (e?.http?.status) return res.status(e.http.status).json({ error: e.message });
|
||||||
|
// PG codes comunes
|
||||||
|
if (e?.code === '23503') return res.status(409).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 });
|
||||||
|
next(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function eliminarComanda(req, res, next) {
|
||||||
|
try {
|
||||||
|
const id = parseId(req.params.id);
|
||||||
|
const data = await eliminarComandaRepo({ schema: req.tenant.schema, id });
|
||||||
|
return data ? res.json(data) : res.status(404).json({ error: 'Comanda no encontrada' });
|
||||||
|
} catch (e) {
|
||||||
|
if (e?.http?.status) return res.status(e.http.status).json({ error: e.message });
|
||||||
|
if (e?.code === '23503') return res.status(409).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 });
|
||||||
|
next(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseId(value) {
|
||||||
|
const id = Number(value);
|
||||||
|
if (!Number.isInteger(id) || id <= 0) {
|
||||||
|
const err = new Error('id inválido');
|
||||||
|
err.http = { status: 400 };
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
return id;
|
||||||
|
}
|
||||||
@@ -0,0 +1,449 @@
|
|||||||
|
// packages/api/v1/routes/routes.js
|
||||||
|
|
||||||
|
import { Router } from 'express';
|
||||||
|
import { withTenantClient, tquery } from '../repositories/db.mjs'
|
||||||
|
import { listarComandas, detalleComanda, actualizarComanda, eliminarComanda } from './handlers/comandasHand.mjs';
|
||||||
|
import { loadColumns, loadForeignKeys, loadPrimaryKey, pickLabelColumn } from './utils/schemaInspector.mjs';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
const ALLOWED_TABLES = [
|
||||||
|
'roles', 'usuarios', 'usua_roles',
|
||||||
|
'categorias', 'productos',
|
||||||
|
'clientes', 'mesas',
|
||||||
|
'comandas', 'deta_comandas',
|
||||||
|
'proveedores', 'compras', 'deta_comp_producto',
|
||||||
|
'mate_primas', 'deta_comp_materias',
|
||||||
|
'prov_producto', 'prov_mate_prima',
|
||||||
|
'receta_producto', 'asistencia_resumen_diario',
|
||||||
|
'asistencia_intervalo', 'asistencia_detalle',
|
||||||
|
'vw_compras'
|
||||||
|
];
|
||||||
|
|
||||||
|
const VALID_IDENT = /^[a-z_][a-z0-9_]*$/i;
|
||||||
|
const q = (ident) => `"${String(ident).replace(/"/g, '""')}"`;
|
||||||
|
|
||||||
|
function ensureTable(name) {
|
||||||
|
if (!VALID_IDENT.test(name)) throw new Error('Identificador inválido');
|
||||||
|
if (!ALLOWED_TABLES.includes(name)) throw new Error('Tabla no permitida');
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================================
|
||||||
|
// Rutas de API v1
|
||||||
|
// ==========================================================
|
||||||
|
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
// API Tablas
|
||||||
|
/*router.route('/tables').get( async (_req, res) => {
|
||||||
|
res.json(ALLOWED_TABLES);
|
||||||
|
});*/
|
||||||
|
|
||||||
|
// GET /api/schema/:table → columnas + foreign keys
|
||||||
|
/*router.get('/schema/:table', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const table = ensureTable(req.params.table);
|
||||||
|
const client = await poolTenants.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 });
|
||||||
|
}
|
||||||
|
});*/
|
||||||
|
|
||||||
|
// GET /api/options/:table/:column → opciones FK
|
||||||
|
/*router.get('/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 poolTenants.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 });
|
||||||
|
}
|
||||||
|
});*/
|
||||||
|
|
||||||
|
// GET /api/table/:table → preview de datos
|
||||||
|
/*router.get('/table/:table', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const table = ensureTable(req.params.table);
|
||||||
|
const limit = Math.min(parseInt(req.query.limit || '100', 10), 1000);
|
||||||
|
await withTenantClient(req, async (client) => {
|
||||||
|
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 { rows } = await client.query(sql);
|
||||||
|
res.json(rows);
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
res.status(400).json({ error: e.message, code: e.code, detail: e.detail });
|
||||||
|
}
|
||||||
|
});*/
|
||||||
|
|
||||||
|
// POST /api/table/:table → insertar fila
|
||||||
|
/*router.post('/table/:table', async (req, res) => {
|
||||||
|
const table = ensureTable(req.params.table);
|
||||||
|
const payload = req.body || {};
|
||||||
|
try {
|
||||||
|
const client = await poolTenants.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);
|
||||||
|
}
|
||||||
|
|
||||||
|
let rows;
|
||||||
|
if (!cols.length) {
|
||||||
|
({ rows } = await client.query(
|
||||||
|
`INSERT INTO ${q(table)} DEFAULT VALUES RETURNING *`
|
||||||
|
));
|
||||||
|
} else {
|
||||||
|
({ rows } = await client.query(
|
||||||
|
`INSERT INTO ${q(table)} (${cols.join(', ')})
|
||||||
|
VALUES (${vals.join(', ')}) RETURNING *`,
|
||||||
|
params
|
||||||
|
));
|
||||||
|
}
|
||||||
|
res.status(201).json({ inserted: rows[0] });
|
||||||
|
} catch (e) {
|
||||||
|
if (e.code === '23503') return res.status(400).json({ error: 'Violación de clave foránea', detail: e.detail });
|
||||||
|
if (e.code === '23505') return res.status(400).json({ error: 'Violación de unicidad', detail: e.detail });
|
||||||
|
if (e.code === '23514') return res.status(400).json({ error: 'Violación de CHECK', detail: e.detail });
|
||||||
|
if (e.code === '23502') return res.status(400).json({ error: 'Campo NOT NULL faltante', detail: e.detail });
|
||||||
|
throw e;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
res.status(400).json({ error: e.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
*/
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
// API Productos
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
|
||||||
|
// GET producto + receta
|
||||||
|
/*router.route('/rpc/get_producto/:id').get( async (req, res) => {
|
||||||
|
const client = await poolTenants.getClient()
|
||||||
|
const id = Number(req.params.id);
|
||||||
|
const { rows } = await client.query('SELECT public.get_producto($1) AS data', [id]);
|
||||||
|
res.json(rows[0]?.data || {});
|
||||||
|
client.release();
|
||||||
|
});*/
|
||||||
|
|
||||||
|
// POST guardar producto + receta
|
||||||
|
/*router.route('/rpc/save_producto').post(async (req, res) => {
|
||||||
|
try {
|
||||||
|
// console.debug('receta payload:', req.body?.receta); // habilitalo si lo necesitás
|
||||||
|
const client = await poolTenants.getClient()
|
||||||
|
const q = 'SELECT public.save_producto($1,$2,$3,$4,$5,$6,$7::jsonb) AS id_producto';
|
||||||
|
const { id_producto=null, nombre, img_producto=null, precio=0, activo=true, id_categoria=null, receta=[] } = req.body || {};
|
||||||
|
const params = [id_producto, nombre, img_producto, precio, activo, id_categoria, JSON.stringify(receta||[])];
|
||||||
|
const { rows } = await client.query(q, params);
|
||||||
|
res.json(rows[0] || {});
|
||||||
|
client.release();
|
||||||
|
} catch(e) {
|
||||||
|
console.error(e);
|
||||||
|
res.status(500).json({ error: 'save_producto failed' });
|
||||||
|
}
|
||||||
|
});*/
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
// API Materias Primas
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
|
||||||
|
// GET MP + proveedores
|
||||||
|
/*router.route('/rpc/get_materia/:id').get(async (req, res) => {
|
||||||
|
const id = Number(req.params.id);
|
||||||
|
try {
|
||||||
|
const client = await poolTenants.getClient()
|
||||||
|
const { rows } = await client.query('SELECT public.get_materia_prima($1) AS data', [id]);
|
||||||
|
res.json(rows[0]?.data || {});
|
||||||
|
client.release();
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
res.status(500).json({ error: 'get_materia failed' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// SAVE MP + proveedores (array)
|
||||||
|
router.route('/rpc/save_materia').post( async (req, res) => {
|
||||||
|
const { id_mat_prima=null, nombre, unidad, activo=true, proveedores=[] } = req.body || {};
|
||||||
|
try {
|
||||||
|
const q = 'SELECT public.save_materia_prima($1,$2,$3,$4,$5::jsonb) AS id_mat_prima';
|
||||||
|
const params = [id_mat_prima, nombre, unidad, activo, JSON.stringify(proveedores||[])];
|
||||||
|
const { rows } = await poolTenants.query(q, params);
|
||||||
|
res.json(rows[0] || {});
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
res.status(500).json({ error: 'save_materia failed' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
// API Usuarios y Asistencias
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
|
||||||
|
// POST /api/rpc/find_usuarios_por_documentos { docs: ["12345678","09123456", ...] }
|
||||||
|
router.route('/rpc/find_usuarios_por_documentos').post( async (req, res) => {
|
||||||
|
try {
|
||||||
|
const docs = Array.isArray(req.body?.docs) ? req.body.docs : [];
|
||||||
|
const sql = 'SELECT public.find_usuarios_por_documentos($1::jsonb) AS data';
|
||||||
|
const { rows } = await poolTenants.query(sql, [JSON.stringify(docs)]);
|
||||||
|
res.json(rows[0]?.data || {});
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
res.status(500).json({ error: 'find_usuarios_por_documentos failed' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/rpc/import_asistencia { registros: [...], origen?: "AGL_001.txt" }
|
||||||
|
router.route('/rpc/import_asistencia').post( async (req, res) => {
|
||||||
|
try {
|
||||||
|
const registros = Array.isArray(req.body?.registros) ? req.body.registros : [];
|
||||||
|
const origen = req.body?.origen || null;
|
||||||
|
const sql = 'SELECT public.import_asistencia($1::jsonb,$2) AS data';
|
||||||
|
const { rows } = await poolTenants.query(sql, [JSON.stringify(registros), origen]);
|
||||||
|
res.json(rows[0]?.data || {});
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
res.status(500).json({ error: 'import_asistencia failed' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Consultar datos de asistencia (raw + pares) para un usuario y rango
|
||||||
|
router.route('/rpc/asistencia_get').post( async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { doc, desde, hasta } = req.body || {};
|
||||||
|
const sql = 'SELECT public.asistencia_get($1::text,$2::date,$3::date) AS data';
|
||||||
|
const { rows } = await poolTenants.query(sql, [doc, desde, hasta]);
|
||||||
|
res.json(rows[0]?.data || {});
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e); res.status(500).json({ error: 'asistencia_get failed' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Editar un registro crudo y recalcular pares
|
||||||
|
router.route('/rpc/asistencia_update_raw').post( async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { id_raw, fecha, hora, modo } = req.body || {};
|
||||||
|
const sql = 'SELECT public.asistencia_update_raw($1::bigint,$2::date,$3::text,$4::text) AS data';
|
||||||
|
const { rows } = await poolTenants.query(sql, [id_raw, fecha, hora, modo ?? null]);
|
||||||
|
res.json(rows[0]?.data || {});
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e); res.status(500).json({ error: 'asistencia_update_raw failed' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Eliminar un registro crudo y recalcular pares
|
||||||
|
router.route('/rpc/asistencia_delete_raw').post( async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { id_raw } = req.body || {};
|
||||||
|
const sql = 'SELECT public.asistencia_delete_raw($1::bigint) AS data';
|
||||||
|
const { rows } = await poolTenants.query(sql, [id_raw]);
|
||||||
|
res.json(rows[0]?.data || {});
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e); res.status(500).json({ error: 'asistencia_delete_raw failed' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
// API Reportes
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
|
||||||
|
// POST /api/rpc/report_tickets { year }
|
||||||
|
router.route('/rpc/report_tickets').post( async (req, res) => {
|
||||||
|
try {
|
||||||
|
const y = parseInt(req.body?.year ?? req.query?.year, 10);
|
||||||
|
const year = (Number.isFinite(y) && y >= 2000 && y <= 2100)
|
||||||
|
? y
|
||||||
|
: (new Date()).getFullYear();
|
||||||
|
|
||||||
|
const { rows } = await poolTenants.query(
|
||||||
|
'SELECT public.report_tickets_year($1::int) AS j', [year]
|
||||||
|
);
|
||||||
|
res.json(rows[0].j);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('report_tickets error:', e);
|
||||||
|
res.status(500).json({
|
||||||
|
error: 'report_tickets failed',
|
||||||
|
message: e.message, detail: e.detail, where: e.where, code: e.code
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/rpc/report_asistencia { desde: 'YYYY-MM-DD', hasta: 'YYYY-MM-DD' }
|
||||||
|
router.route('/rpc/report_asistencia').post( async (req, res) => {
|
||||||
|
try {
|
||||||
|
let { desde, hasta } = req.body || {};
|
||||||
|
// defaults si vienen vacíos/invalidos
|
||||||
|
const re = /^\d{4}-\d{2}-\d{2}$/;
|
||||||
|
if (!re.test(desde) || !re.test(hasta)) {
|
||||||
|
const end = new Date();
|
||||||
|
const start = new Date(end); start.setDate(end.getDate()-30);
|
||||||
|
desde = start.toISOString().slice(0,10);
|
||||||
|
hasta = end.toISOString().slice(0,10);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { rows } = await poolTenants.query(
|
||||||
|
'SELECT public.report_asistencia($1::date,$2::date) AS j', [desde, hasta]
|
||||||
|
);
|
||||||
|
res.json(rows[0].j);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('report_asistencia error:', e);
|
||||||
|
res.status(500).json({
|
||||||
|
error: 'report_asistencia failed',
|
||||||
|
message: e.message, detail: e.detail, where: e.where, code: e.code
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
// API Compras y Gastos
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
|
||||||
|
// Guardar (insert/update)
|
||||||
|
router.route('/rpc/save_compra').post( async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { id_compra, id_proveedor, fec_compra, detalles } = req.body || {};
|
||||||
|
const sql = 'SELECT * FROM public.save_compra($1::int,$2::int,$3::timestamptz,$4::jsonb)';
|
||||||
|
const args = [id_compra ?? null, id_proveedor, fec_compra ? new Date(fec_compra) : null, JSON.stringify(detalles)];
|
||||||
|
const { rows } = await poolTenants.query(sql, args);
|
||||||
|
res.json(rows[0]); // { id_compra, total }
|
||||||
|
} catch (e) {
|
||||||
|
console.error('save_compra error:', e);
|
||||||
|
res.status(500).json({ error: 'save_compra failed', message: e.message, detail: e.detail, where: e.where, code: e.code });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
// Obtener para editar
|
||||||
|
router.route('/rpc/get_compra').post( async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { id_compra } = req.body || {};
|
||||||
|
const sql = `SELECT public.get_compra($1::int) AS data`;
|
||||||
|
const { rows } = await poolTenants.query(sql, [id_compra]);
|
||||||
|
res.json(rows[0]?.data || {});
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e); res.status(500).json({ error: 'get_compra failed' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Eliminar
|
||||||
|
router.route('/rpc/delete_compra').post( async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { id_compra } = req.body || {};
|
||||||
|
await poolTenants.query(`SELECT public.delete_compra($1::int)`, [id_compra]);
|
||||||
|
res.json({ ok: true });
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e); res.status(500).json({ error: 'delete_compra failed' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
// POST /api/rpc/report_gastos { year: 2025 }
|
||||||
|
router.route('/rpc/report_gastos').post( async (req, res) => {
|
||||||
|
try {
|
||||||
|
const year = parseInt(req.body?.year ?? new Date().getFullYear(), 10);
|
||||||
|
const { rows } = await poolTenants.query(
|
||||||
|
'SELECT public.report_gastos($1::int) AS j', [year]
|
||||||
|
);
|
||||||
|
res.json(rows[0].j);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('report_gastos error:', e);
|
||||||
|
res.status(500).json({
|
||||||
|
error: 'report_gastos failed',
|
||||||
|
message: e.message, detail: e.detail, code: e.code
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});*/
|
||||||
|
|
||||||
|
|
||||||
|
export default router;
|
||||||
+8
-8
@@ -1,6 +1,6 @@
|
|||||||
// ----------------------------------------------------------
|
// services/app/src/api/v1/routes/utils/schemaInspector.mjs
|
||||||
// Introspección de esquema
|
// Utilidades para inspeccionar columnas, claves y relaciones en PostgreSQL.
|
||||||
// ----------------------------------------------------------
|
|
||||||
export async function loadColumns(client, table) {
|
export async function loadColumns(client, table) {
|
||||||
const sql = `
|
const sql = `
|
||||||
SELECT
|
SELECT
|
||||||
@@ -42,7 +42,8 @@ export async function loadForeignKeys(client, table) {
|
|||||||
`;
|
`;
|
||||||
const { rows } = await client.query(sql, [table]);
|
const { rows } = await client.query(sql, [table]);
|
||||||
const map = {};
|
const map = {};
|
||||||
for (const r of rows) map[r.column_name] = { foreign_table: r.foreign_table, foreign_column: r.foreign_column };
|
for (const r of rows)
|
||||||
|
map[r.column_name] = { foreign_table: r.foreign_table, foreign_column: r.foreign_column };
|
||||||
return map;
|
return map;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,18 +59,17 @@ export async function loadPrimaryKey(client, table) {
|
|||||||
return rows.map(r => r.column_name);
|
return rows.map(r => r.column_name);
|
||||||
}
|
}
|
||||||
|
|
||||||
// label column for FK options
|
|
||||||
export async function pickLabelColumn(client, refTable) {
|
export async function pickLabelColumn(client, refTable) {
|
||||||
const preferred = ['nombre', 'raz_social', 'apodo', 'documento', 'correo', 'telefono'];
|
const preferred = ['nombre','raz_social','apodo','documento','correo','telefono'];
|
||||||
const { rows } = await client.query(
|
const { rows } = await client.query(
|
||||||
`SELECT column_name, data_type
|
`SELECT column_name, data_type
|
||||||
FROM information_schema.columns
|
FROM information_schema.columns
|
||||||
WHERE table_schema='public' AND table_name=$1
|
WHERE table_schema='public' AND table_name=$1
|
||||||
ORDER BY ordinal_position`, [refTable]
|
ORDER BY ordinal_position`, [refTable]
|
||||||
);
|
);
|
||||||
for (const cand of preferred) {
|
for (const cand of preferred)
|
||||||
if (rows.find(r => r.column_name === cand)) return cand;
|
if (rows.find(r => r.column_name === cand)) return cand;
|
||||||
}
|
|
||||||
const textish = rows.find(r => /text|character varying|varchar/i.test(r.data_type));
|
const textish = rows.find(r => /text|character varying|varchar/i.test(r.data_type));
|
||||||
if (textish) return textish.column_name;
|
if (textish) return textish.column_name;
|
||||||
return rows[0]?.column_name || 'id';
|
return rows[0]?.column_name || 'id';
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"name": "@suitecoffee/db",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"main": "./poolSingleton.mjs",
|
||||||
|
"types": "./poolSingleton.d.ts",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./poolSingleton.d.ts",
|
||||||
|
"import": "./poolSingleton.mjs",
|
||||||
|
"default": "./poolSingleton.mjs"
|
||||||
|
},
|
||||||
|
"./package.json": "./package.json"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"pg": "^8.16.3"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"poolSingleton.mjs",
|
||||||
|
"poolSingleton.d.ts"
|
||||||
|
]
|
||||||
|
}
|
||||||
Vendored
+68
@@ -0,0 +1,68 @@
|
|||||||
|
// packages/core/db/poolSingleton.d.ts
|
||||||
|
// Declaraciones de tipos para @suitecoffee/db
|
||||||
|
// Refleja el módulo ESM que expone poolCore y poolTenants (ambos Singletons)
|
||||||
|
|
||||||
|
import type {
|
||||||
|
Pool,
|
||||||
|
PoolClient,
|
||||||
|
PoolConfig,
|
||||||
|
QueryResult,
|
||||||
|
QueryResultRow,
|
||||||
|
QueryConfig
|
||||||
|
} from 'pg';
|
||||||
|
|
||||||
|
export type { Pool, PoolClient, PoolConfig, QueryResult, QueryResultRow, QueryConfig };
|
||||||
|
|
||||||
|
// Clases modeladas según la implementación JS (no se exportan como valores en runtime,
|
||||||
|
// pero se exponen como tipos para el consumidor que quiera tipar sus variables).
|
||||||
|
export declare class DatabaseCore {
|
||||||
|
/** Instancia singleton interna (solo informativa para tipado). */
|
||||||
|
static instance?: DatabaseCore;
|
||||||
|
|
||||||
|
/** Pool real de `pg`. */
|
||||||
|
connection: Pool;
|
||||||
|
|
||||||
|
constructor();
|
||||||
|
|
||||||
|
/** Ejecuta una consulta utilizando el pool. */
|
||||||
|
query<T extends QueryResultRow = any>(
|
||||||
|
sql: string | QueryConfig<any[]>,
|
||||||
|
params?: any[]
|
||||||
|
): Promise<QueryResult<T>>;
|
||||||
|
|
||||||
|
/** Alias al `pool.connect()`; devuelve un `PoolClient`. */
|
||||||
|
connect(): Promise<PoolClient>;
|
||||||
|
|
||||||
|
/** Alias al `pool.connect()`; devuelve un `PoolClient`. */
|
||||||
|
getClient(): Promise<PoolClient>;
|
||||||
|
|
||||||
|
/** Cierra el pool subyacente. */
|
||||||
|
release(): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export declare class DatabaseTenants {
|
||||||
|
static instance?: DatabaseTenants;
|
||||||
|
connection: Pool;
|
||||||
|
|
||||||
|
constructor();
|
||||||
|
|
||||||
|
query<T extends QueryResultRow = any>(
|
||||||
|
sql: string | QueryConfig<any[]>,
|
||||||
|
params?: any[]
|
||||||
|
): Promise<QueryResult<T>>;
|
||||||
|
|
||||||
|
connect(): Promise<PoolClient>;
|
||||||
|
getClient(): Promise<PoolClient>;
|
||||||
|
release(): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Singletons creados por el módulo. */
|
||||||
|
export declare const poolCore: DatabaseCore;
|
||||||
|
export declare const poolTenants: DatabaseTenants;
|
||||||
|
|
||||||
|
/** Export por defecto del módulo: objeto con ambos pools. */
|
||||||
|
declare const _default: {
|
||||||
|
poolCore: DatabaseCore;
|
||||||
|
poolTenants: DatabaseTenants;
|
||||||
|
};
|
||||||
|
export default _default;
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
// poolSingleton.mjs
|
||||||
|
// Conexión Singleton a base de datos (pg/Pool) para CORE y TENANTS.
|
||||||
|
// Cambios mínimos respecto a tu versión original.
|
||||||
|
|
||||||
|
import { Pool } from 'pg';
|
||||||
|
|
||||||
|
// Utilidad mínima para booleans
|
||||||
|
const isTrue = (v) => String(v).toLowerCase() === 'true';
|
||||||
|
|
||||||
|
// --------------------- CORE ---------------------
|
||||||
|
class DatabaseCore {
|
||||||
|
static instance = null;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
if (DatabaseCore.instance) {
|
||||||
|
return DatabaseCore.instance; // <-- corrección: antes devolvía Database.instance
|
||||||
|
}
|
||||||
|
|
||||||
|
const host = process.env.CORE_DB_HOST;
|
||||||
|
const user = process.env.CORE_DB_USER;
|
||||||
|
const password = process.env.CORE_DB_PASS;
|
||||||
|
const database = process.env.CORE_DB_NAME;
|
||||||
|
const port = process.env.CORE_DB_PORT;
|
||||||
|
const ssl =
|
||||||
|
isTrue(process.env.CORE_PGSSL ?? process.env.PGSSL)
|
||||||
|
? { rejectUnauthorized: false }
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const config = {
|
||||||
|
host,
|
||||||
|
user,
|
||||||
|
password,
|
||||||
|
database,
|
||||||
|
port: port ? Number(port) : undefined,
|
||||||
|
ssl,
|
||||||
|
};
|
||||||
|
|
||||||
|
this.host = host;
|
||||||
|
this.dbName = database;
|
||||||
|
this.connection = new Pool(config);
|
||||||
|
|
||||||
|
DatabaseCore.instance = this;
|
||||||
|
}
|
||||||
|
|
||||||
|
async query(sql, params) {
|
||||||
|
return this.connection.query(sql, params);
|
||||||
|
}
|
||||||
|
|
||||||
|
async connect() { // idempotente a nivel de pool; retorna un client
|
||||||
|
return this.connection.connect();
|
||||||
|
}
|
||||||
|
|
||||||
|
async getClient() { // alias simple, conserva tu API
|
||||||
|
return this.connection.connect();
|
||||||
|
}
|
||||||
|
|
||||||
|
async release() { // cierra TODO el pool (uso excepcional)
|
||||||
|
await this.connection.end();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------- TENANTS ---------------------
|
||||||
|
class DatabaseTenants {
|
||||||
|
static instance = null;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
if (DatabaseTenants.instance) {
|
||||||
|
return DatabaseTenants.instance; // <-- corrección: antes devolvía Database.instance
|
||||||
|
}
|
||||||
|
|
||||||
|
const host = process.env.TENANTS_DB_HOST;
|
||||||
|
const user = process.env.TENANTS_DB_USER;
|
||||||
|
const password = process.env.TENANTS_DB_PASS;
|
||||||
|
const database = process.env.TENANTS_DB_NAME;
|
||||||
|
const port = process.env.TENANTS_DB_PORT;
|
||||||
|
const ssl =
|
||||||
|
isTrue(process.env.TENANTS_PGSSL ?? process.env.PGSSL)
|
||||||
|
? { rejectUnauthorized: false }
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const config = {
|
||||||
|
host,
|
||||||
|
user,
|
||||||
|
password,
|
||||||
|
database,
|
||||||
|
port: port ? Number(port) : undefined,
|
||||||
|
ssl,
|
||||||
|
};
|
||||||
|
|
||||||
|
this.host = host;
|
||||||
|
this.dbName = database;
|
||||||
|
this.connection = new Pool(config);
|
||||||
|
|
||||||
|
DatabaseTenants.instance = this;
|
||||||
|
}
|
||||||
|
|
||||||
|
async query(sql, params) {
|
||||||
|
return this.connection.query(sql, params);
|
||||||
|
}
|
||||||
|
|
||||||
|
async connect() { // idempotente a nivel de pool; retorna un client
|
||||||
|
return this.connection.connect();
|
||||||
|
}
|
||||||
|
|
||||||
|
async getClient() { // alias simple, conserva tu API
|
||||||
|
return this.connection.connect();
|
||||||
|
}
|
||||||
|
|
||||||
|
async release() { // cierra TODO el pool (uso excepcional)
|
||||||
|
await this.connection.end();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Instancias únicas por el cache de módulos de Node/ESM + guardas estáticas
|
||||||
|
const poolCore = new DatabaseCore();
|
||||||
|
const poolTenants = new DatabaseTenants();
|
||||||
|
|
||||||
|
// --------------------- Healthchecks aquí dentro ---------------------
|
||||||
|
async function verificarConexionCore() {
|
||||||
|
try {
|
||||||
|
console.log(`[ PG ] Comprobando accesibilidad a la db ${poolCore.dbName} del host ${poolCore.host} ...`);
|
||||||
|
const client = await poolCore.getClient();
|
||||||
|
const { rows } = await client.query('SELECT NOW() AS ahora');
|
||||||
|
console.log(`[ PG ] Conexión con ${poolCore.dbName} OK. Hora DB:`, rows[0].ahora);
|
||||||
|
client.release();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[ PG ] Error al conectar con la base de datos al iniciar:', error.message);
|
||||||
|
console.error('[ PG ] Revisar credenciales, accesos de red y firewall.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function verificarConexionTenants() {
|
||||||
|
try {
|
||||||
|
console.log(`[ PG ] Comprobando accesibilidad a la db ${poolTenants.dbName} del host ${poolTenants.host} ...`);
|
||||||
|
const client = await poolTenants.getClient();
|
||||||
|
const { rows } = await client.query('SELECT NOW() AS ahora');
|
||||||
|
console.log(`[ PG ] Conexión con ${poolTenants.dbName} OK. Hora DB:`, rows[0].ahora);
|
||||||
|
client.release();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[ PG ] Error al conectar con la base de datos al iniciar:', error.message);
|
||||||
|
console.error('[ PG ] Revisar credenciales, accesos de red y firewall.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exports (mantengo tu patrón)
|
||||||
|
export default { poolCore, poolTenants, verificarConexionCore, verificarConexionTenants };
|
||||||
|
export { poolCore, poolTenants, verificarConexionCore, verificarConexionTenants };
|
||||||
|
// export { DatabaseCore, DatabaseTenants }; // si lo necesitás para tests
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
// @suitecoffee/middlewares/datosGlobales.mjs
|
||||||
|
// packages/core/middlewares/datosGlobales.mjs
|
||||||
|
|
||||||
|
import { Router } from 'express';
|
||||||
|
export const datosGlobales = Router();
|
||||||
|
|
||||||
|
datosGlobales.use((req, res, next) => {
|
||||||
|
res.locals.currentPath = req.path;
|
||||||
|
res.locals.pageTitle = 'SuiteCoffee';
|
||||||
|
res.locals.pageId = '';
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
|
export default datosGlobales; // opcional, pero útil si alguien quiere import default
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
// @suitecoffee/middlewares/src/index.mjs
|
||||||
|
// Punto de entrada general del paquete de middlewares.
|
||||||
|
|
||||||
|
export * from './requireAuth.mjs';
|
||||||
|
export * from './datosGlobales.mjs';
|
||||||
|
export * from './tenantContext.mjs';
|
||||||
|
export * from './resolveTenantFromCore.mjs';
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"name": "@suitecoffee/middlewares",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"main": ".index.mjs",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"import": "./index.mjs",
|
||||||
|
"default": "./index.mjs"
|
||||||
|
},
|
||||||
|
"./package.json": "./package.json"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
".index.mjs"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
// packages/core/middlewares/src/requireAuth.mjs
|
||||||
|
// @suitecoffee/middlewares/src/requireAuth.mjs
|
||||||
|
|
||||||
|
/**
|
||||||
|
* requireAuth
|
||||||
|
* Verifica que exista una sesión válida en req.session.user (con `sub`).
|
||||||
|
* - Si hay sesión, llama a next().
|
||||||
|
* - Si no hay sesión:
|
||||||
|
* - Si se define `redirectTo`, redirige (302) cuando el cliente acepta HTML.
|
||||||
|
* - En caso contrario, responde 401 con { error: 'unauthenticated' }.
|
||||||
|
*
|
||||||
|
* @param {Object} [options]
|
||||||
|
* @param {string|null} [options.redirectTo=null] Ruta a la que redirigir si no hay sesión (p.ej. '/auth/login')
|
||||||
|
* @param {(req: import('express').Request) => any} [options.getSessionUser] Cómo leer el usuario de la sesión
|
||||||
|
* @returns {import('express').RequestHandler}
|
||||||
|
*
|
||||||
|
* Uso típico:
|
||||||
|
* import { requireAuth } from '@suitecoffee/middlewares';
|
||||||
|
* app.get('/me', requireAuth(), (req,res)=> res.json({ user: req.session.user }));
|
||||||
|
* app.get('/dashboard', requireAuth({ redirectTo: '/auth/login' }), handler);
|
||||||
|
*/
|
||||||
|
export function requireAuth(options = {}) {
|
||||||
|
const {
|
||||||
|
redirectTo = null,
|
||||||
|
getSessionUser = (req) => req?.session?.user,
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
return function requireAuthMiddleware(req, res, next) {
|
||||||
|
const user = getSessionUser(req);
|
||||||
|
|
||||||
|
if (user && user.sub) {
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Si el cliente acepta HTML y tenemos redirectTo, redirigimos (útil para front web)
|
||||||
|
if (redirectTo && req.accepts('html')) {
|
||||||
|
return res.redirect(302, redirectTo);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback JSON
|
||||||
|
return res.status(401).json({ error: 'unauthenticated' });
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
// packages/core/middlewares/resolveTenantFromCore.mjs
|
||||||
|
import { poolCore, poolTenants } from '@suitecoffee/db';
|
||||||
|
|
||||||
|
const UUID_RX = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifica si existe el esquema en la DB de tenants.
|
||||||
|
* No requiere setear search_path.
|
||||||
|
*/
|
||||||
|
async function schemaExists(schemaName) {
|
||||||
|
if (!schemaName) return false;
|
||||||
|
const q = `
|
||||||
|
SELECT 1
|
||||||
|
FROM information_schema.schemata
|
||||||
|
WHERE schema_name = $1
|
||||||
|
LIMIT 1
|
||||||
|
`;
|
||||||
|
const { rowCount } = await poolTenants.query(q, [schemaName]);
|
||||||
|
return rowCount === 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Devuelve { id, schema } o null.
|
||||||
|
* Reglas:
|
||||||
|
* 1) Si el usuario tiene default_tenant => usarlo (y validar estado y existencia del schema).
|
||||||
|
* 2) Si no, buscar membresías:
|
||||||
|
* - si hay exactamente 1 => usarla (validando schema).
|
||||||
|
* - si hay 0 o >1 => devolver null (forzar selección explícita).
|
||||||
|
*
|
||||||
|
* @param {import('express').Request} req
|
||||||
|
* @param {any} sess (req.session)
|
||||||
|
* @param {Object} [opts]
|
||||||
|
* @param {boolean} [opts.debug=false]
|
||||||
|
* @param {Console} [opts.logger=console]
|
||||||
|
* @param {string[]} [opts.acceptStates=['ready']] // estados de sc_tenants aceptados
|
||||||
|
* @returns {Promise<{id:string, schema:string} | null>}
|
||||||
|
*/
|
||||||
|
export async function resolveTenantFromCore(req, sess, opts = {}) {
|
||||||
|
const {
|
||||||
|
debug = false,
|
||||||
|
logger = console,
|
||||||
|
acceptStates = ['ready'],
|
||||||
|
} = opts;
|
||||||
|
|
||||||
|
const log = (msg, obj) => {
|
||||||
|
if (debug) logger.debug?.(`[resolveTenantFromCore] ${msg}`, obj ?? '');
|
||||||
|
};
|
||||||
|
|
||||||
|
const sub = sess?.user?.sub;
|
||||||
|
if (!sub) {
|
||||||
|
log('no-sub-in-session');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1) sc_users: obtener user_id y default_tenant
|
||||||
|
const uSql = `
|
||||||
|
SELECT user_id, default_tenant
|
||||||
|
FROM sc_users
|
||||||
|
WHERE sub = $1
|
||||||
|
LIMIT 1
|
||||||
|
`;
|
||||||
|
const ures = await poolCore.query(uSql, [sub]);
|
||||||
|
if (ures.rowCount === 0) {
|
||||||
|
log('user-not-found', { sub });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { user_id, default_tenant } = ures.rows[0] ?? {};
|
||||||
|
|
||||||
|
// Helper para validar fila de tenant y existencia de schema
|
||||||
|
const validateTenantRow = async (row) => {
|
||||||
|
if (!row) return null;
|
||||||
|
const { tenant_id, schema_name, state } = row;
|
||||||
|
if (!UUID_RX.test(String(tenant_id))) return null;
|
||||||
|
if (!schema_name) return null;
|
||||||
|
if (acceptStates.length && !acceptStates.includes(String(state))) return null;
|
||||||
|
|
||||||
|
// Comprobar que el schema exista realmente en la DB de tenants
|
||||||
|
const exists = await schemaExists(schema_name);
|
||||||
|
if (!exists) {
|
||||||
|
log('schema-missing-in-tenants-db', { schema_name });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return { id: String(tenant_id), schema: String(schema_name) };
|
||||||
|
};
|
||||||
|
|
||||||
|
// 2) Si hay default_tenant, cargar su schema y validar
|
||||||
|
if (default_tenant) {
|
||||||
|
const tSql = `
|
||||||
|
SELECT tenant_id, schema_name, state
|
||||||
|
FROM sc_tenants
|
||||||
|
WHERE tenant_id = $1
|
||||||
|
LIMIT 1
|
||||||
|
`;
|
||||||
|
const tres = await poolCore.query(tSql, [default_tenant]);
|
||||||
|
if (tres.rowCount === 1) {
|
||||||
|
const ok = await validateTenantRow(tres.rows[0]);
|
||||||
|
if (ok) {
|
||||||
|
sess.tenant = ok;
|
||||||
|
log('resolved-from-default_tenant', ok);
|
||||||
|
return ok;
|
||||||
|
}
|
||||||
|
// default_tenant roto → seguimos a membresías
|
||||||
|
log('default_tenant-invalid', { default_tenant });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3) Sin default_tenant válido: ver membresías (aceptando sólo tenants en estados permitidos)
|
||||||
|
const mSql = `
|
||||||
|
SELECT m.tenant_id, t.schema_name, t.state, t.created_at, m.role
|
||||||
|
FROM sc_memberships m
|
||||||
|
JOIN sc_tenants t USING (tenant_id)
|
||||||
|
WHERE m.user_id = $1
|
||||||
|
${acceptStates.length ? `AND t.state = ANY($2)` : ''}
|
||||||
|
ORDER BY (m.role = 'owner') DESC, t.created_at ASC
|
||||||
|
LIMIT 2
|
||||||
|
`;
|
||||||
|
const mParams = acceptStates.length ? [user_id, acceptStates] : [user_id];
|
||||||
|
const mres = await poolCore.query(mSql, mParams);
|
||||||
|
|
||||||
|
if (mres.rowCount === 1) {
|
||||||
|
const ok = await validateTenantRow(mres.rows[0]);
|
||||||
|
if (ok) {
|
||||||
|
sess.tenant = ok;
|
||||||
|
log('resolved-from-single-membership', ok);
|
||||||
|
return ok;
|
||||||
|
}
|
||||||
|
log('single-membership-invalid-row', mres.rows[0]);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 0 o >1 membresías → el usuario debe elegir explícitamente
|
||||||
|
log('ambiguous-or-no-memberships', { count: mres.rowCount });
|
||||||
|
return null;
|
||||||
|
} catch (err) {
|
||||||
|
logger.error?.('[resolveTenantFromCore] error', { message: err?.message });
|
||||||
|
return null; // preferimos no romper el request; el middleware decidirá
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
// packages/core/middlewares/src/tenantContext.mjs
|
||||||
|
|
||||||
|
const VALID_IDENT = /^[a-zA-Z_][a-zA-Z0-9_]*$/; // schema seguro
|
||||||
|
const UUID_RX = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||||
|
|
||||||
|
function redact(obj) {
|
||||||
|
// Evita loggear datos sensibles; muestra sólo lo útil para diagnóstico
|
||||||
|
if (!obj || typeof obj !== 'object') return obj;
|
||||||
|
const out = {};
|
||||||
|
for (const k of Object.keys(obj)) {
|
||||||
|
if (['token', 'access_token', 'id_token', 'refresh_token'].includes(k)) {
|
||||||
|
out[k] = '[redacted]';
|
||||||
|
} else if (k === 'sub' || k === 'email' || k === 'name') {
|
||||||
|
out[k] = obj[k];
|
||||||
|
} else if (k === 'tenant') {
|
||||||
|
const t = obj[k] || {};
|
||||||
|
out[k] = { id: t.id ?? null, schema: t.schema ?? null };
|
||||||
|
} else if (k === 'user') {
|
||||||
|
const u = obj[k] || {};
|
||||||
|
out[k] = {
|
||||||
|
sub: u.sub ?? null,
|
||||||
|
email: u.email ?? null,
|
||||||
|
default_tenant: u.default_tenant ?? u.defaultTenant ?? null,
|
||||||
|
memberships: Array.isArray(u.memberships) ? `[${u.memberships.length}]` : null,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
// no inundar el log; deja constancia de que existe
|
||||||
|
out[k] = '[present]';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function tenantContext(opts = {}) {
|
||||||
|
const {
|
||||||
|
requireUser = true,
|
||||||
|
debug = false,
|
||||||
|
log = console, // podés inyectar tu logger
|
||||||
|
autoDeriveFromDefault = true,
|
||||||
|
// callback opcional para buscar tenant (p.ej., en CORE) si no está en sesión
|
||||||
|
// Debe devolver { id: uuid, schema: string } o null
|
||||||
|
resolveTenant = null,
|
||||||
|
schemaPrefixes = [
|
||||||
|
process.env.TENANT_SCHEMA_PREFIX || 'empresa_',
|
||||||
|
].filter(Boolean),
|
||||||
|
} = opts;
|
||||||
|
|
||||||
|
const diag = (msg, data) => {
|
||||||
|
if (!debug) return;
|
||||||
|
try { log.debug?.(`[tenantContext] ${msg}`, data !== undefined ? redact(data) : ''); }
|
||||||
|
catch { /* noop */ }
|
||||||
|
};
|
||||||
|
const setDiagHeader = (res, kv) => {
|
||||||
|
if (!debug) return;
|
||||||
|
const cur = res.getHeader('X-Tenant-Diag');
|
||||||
|
const base = typeof cur === 'string' ? String(cur) + '; ' : '';
|
||||||
|
res.setHeader('X-Tenant-Diag', base + kv);
|
||||||
|
};
|
||||||
|
|
||||||
|
return async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
diag('incoming', { sid: req.sessionID, headers: { accept: req.headers.accept } });
|
||||||
|
|
||||||
|
const sess = req.session;
|
||||||
|
if (!sess) {
|
||||||
|
setDiagHeader(res, 'no-session');
|
||||||
|
return res.status(401).json({ error: 'unauthenticated' });
|
||||||
|
}
|
||||||
|
diag('session.present', { keys: Object.keys(sess) });
|
||||||
|
|
||||||
|
if (requireUser && !sess.user?.sub) {
|
||||||
|
diag('user.missing', { session: sess });
|
||||||
|
setDiagHeader(res, 'no-user');
|
||||||
|
return res.status(401).json({ error: 'unauthenticated' });
|
||||||
|
}
|
||||||
|
if (requireUser) diag('user.ok', sess.user);
|
||||||
|
|
||||||
|
// 1) Leer tenant desde sesión
|
||||||
|
let t = sess.tenant ?? null;
|
||||||
|
diag('session.tenant', t);
|
||||||
|
|
||||||
|
// 2) Derivar automáticamente si falta
|
||||||
|
if ((!t?.id || !t?.schema) && autoDeriveFromDefault) {
|
||||||
|
const fallbackId =
|
||||||
|
sess.user?.tenant?.id ||
|
||||||
|
sess.user?.default_tenant ||
|
||||||
|
sess.user?.defaultTenant ||
|
||||||
|
null;
|
||||||
|
|
||||||
|
if (fallbackId && UUID_RX.test(String(fallbackId))) {
|
||||||
|
const prefix = String(schemaPrefixes[0] || 'empresa_');
|
||||||
|
const schema = `${prefix}${String(fallbackId).replace(/-/g, '').toLowerCase()}`;
|
||||||
|
t = { id: String(fallbackId), schema };
|
||||||
|
sess.tenant = t; // persistir para siguientes requests
|
||||||
|
diag('derived.fromDefault', t);
|
||||||
|
setDiagHeader(res, 'derived-default');
|
||||||
|
} else {
|
||||||
|
diag('derived.fromDefault.skipped', { fallbackId });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3) Resolver con callback si aún falta
|
||||||
|
if ((!t?.id || !t?.schema) && typeof resolveTenant === 'function') {
|
||||||
|
try {
|
||||||
|
t = await resolveTenant(req, sess);
|
||||||
|
if (t) {
|
||||||
|
sess.tenant = t;
|
||||||
|
diag('derived.fromResolver', t);
|
||||||
|
setDiagHeader(res, 'derived-resolver');
|
||||||
|
} else {
|
||||||
|
diag('resolver.returned-null');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
diag('resolver.error', { message: e?.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4) Validaciones
|
||||||
|
if (!t?.id || !t?.schema) {
|
||||||
|
diag('missing-tenant.final');
|
||||||
|
setDiagHeader(res, 'missing-tenant');
|
||||||
|
return res.status(401).json({ error: 'Sesión inválida o tenant no seleccionado' });
|
||||||
|
}
|
||||||
|
if (!UUID_RX.test(String(t.id))) {
|
||||||
|
diag('invalid-tenant-id', t);
|
||||||
|
setDiagHeader(res, 'bad-tenant-id');
|
||||||
|
return res.status(400).json({ error: 'TenantID inválido' });
|
||||||
|
}
|
||||||
|
if (!VALID_IDENT.test(t.schema)) {
|
||||||
|
diag('invalid-schema', t);
|
||||||
|
setDiagHeader(res, 'bad-schema');
|
||||||
|
return res.status(400).json({ error: 'Schema inválido' });
|
||||||
|
}
|
||||||
|
const okPrefix = schemaPrefixes.some(p =>
|
||||||
|
t.schema.toLowerCase().startsWith(String(p).toLowerCase()),
|
||||||
|
);
|
||||||
|
if (!okPrefix) {
|
||||||
|
diag('schema-prefix.rejected', { schema: t.schema, schemaPrefixes });
|
||||||
|
setDiagHeader(res, 'schema-prefix-rejected');
|
||||||
|
return res.status(400).json({ error: 'Schema no permitido' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5) OK
|
||||||
|
req.tenant = { id: String(t.id), schema: String(t.schema) };
|
||||||
|
res.locals.tenant = req.tenant;
|
||||||
|
setDiagHeader(res, `ok schema=${req.tenant.schema}`);
|
||||||
|
diag('attach.req.tenant', req.tenant);
|
||||||
|
|
||||||
|
return next();
|
||||||
|
} catch (err) {
|
||||||
|
diag('exception', { message: err?.message });
|
||||||
|
return next(err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"name": "@suitecoffee/redis",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"main": "./redisSingleton.mjs",
|
||||||
|
"types": "./redisSingleton.d.ts",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./redisSingleton.d.ts",
|
||||||
|
"import": "./redisSingleton.mjs",
|
||||||
|
"default": "./redisSingleton.mjs"
|
||||||
|
},
|
||||||
|
"./package.json": "./package.json"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"pg": "^8.16.3"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"redisSingleton.mjs",
|
||||||
|
"redisSingleton.d.ts"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
// redisSingleton.mjs
|
||||||
|
// Conexión Singleton a Redis para Authentik (AK)
|
||||||
|
|
||||||
|
import { createClient } from 'redis';
|
||||||
|
|
||||||
|
class RedisAuthentik {
|
||||||
|
static instance = null;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
if (RedisAuthentik.instance) {
|
||||||
|
return RedisAuthentik.instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = process.env.AK_REDIS_URL;
|
||||||
|
if (!url) {
|
||||||
|
throw new Error('Falta AK_REDIS_URL Ej: redis://:pass@host:6379/0');
|
||||||
|
}
|
||||||
|
if (!/^redis(s)?:\/\//i.test(url)) {
|
||||||
|
throw new Error('AK_REDIS_URL inválida: debe comenzar con "redis://" o "rediss://".');
|
||||||
|
}
|
||||||
|
|
||||||
|
this.url = url;
|
||||||
|
this.client = createClient({
|
||||||
|
url: this.url,
|
||||||
|
socket: { connectTimeout: 5000 },
|
||||||
|
});
|
||||||
|
|
||||||
|
this.client.on('connect', () => console.log(`[REDIS AK] Conectando a ${this.url}`));
|
||||||
|
this.client.on('ready', () => console.log('[REDIS AK] Conexión lista.'));
|
||||||
|
this.client.on('end', () => console.warn('[REDIS AK] Conexión cerrada.'));
|
||||||
|
this.client.on('reconnecting', () => console.warn('[REDIS AK] Reintentando conexión...'));
|
||||||
|
this.client.on('error', (err) => console.error('[REDIS AK] Error:', err?.message || err));
|
||||||
|
|
||||||
|
this._connectingPromise = null;
|
||||||
|
RedisAuthentik.instance = this;
|
||||||
|
}
|
||||||
|
|
||||||
|
async connect() {
|
||||||
|
if (this.client.isOpen) return this.client;
|
||||||
|
if (this._connectingPromise) return this._connectingPromise;
|
||||||
|
|
||||||
|
this._connectingPromise = this.client.connect()
|
||||||
|
.then(() => this.client)
|
||||||
|
.catch((err) => {
|
||||||
|
this._connectingPromise = null;
|
||||||
|
console.error('[REDIS AK] Falló la conexión inicial:', err?.message || err);
|
||||||
|
throw err;
|
||||||
|
});
|
||||||
|
|
||||||
|
return this._connectingPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
getClient() {
|
||||||
|
return this.client;
|
||||||
|
}
|
||||||
|
|
||||||
|
async release() {
|
||||||
|
try {
|
||||||
|
if (this.client?.isOpen) await this.client.quit();
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[REDIS AK] Error al cerrar:', e?.message || e);
|
||||||
|
} finally {
|
||||||
|
this._connectingPromise = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Instancia única
|
||||||
|
const redisAuthentik = new RedisAuthentik();
|
||||||
|
|
||||||
|
// --------------------- Healthcheck ---------------------
|
||||||
|
async function verificarConexionRedisAuthentik() {
|
||||||
|
try {
|
||||||
|
console.log(`[REDIS AK] Comprobando accesibilidad a Redis en ${redisAuthentik.url} ...`);
|
||||||
|
await redisAuthentik.connect();
|
||||||
|
const client = redisAuthentik.getClient();
|
||||||
|
|
||||||
|
const pong = await client.ping();
|
||||||
|
const timeArr = await client.sendCommand(['TIME']);
|
||||||
|
const serverDate = new Date(Number(timeArr?.[0] || 0) * 1000);
|
||||||
|
|
||||||
|
await client.set('hc:authentik', String(Date.now()), { EX: 10 });
|
||||||
|
|
||||||
|
console.log(`[REDIS AK] Conexión OK. PING=${pong}. Hora Redis:`, serverDate.toISOString());
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[REDIS AK] Error al conectar:', error?.message || error);
|
||||||
|
console.error('[REDIS AK] Revisar AK_REDIS_URL, credenciales, red y firewall.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export al estilo de poolSingleton.mjs
|
||||||
|
export default { redisAuthentik, verificarConexionRedisAuthentik };
|
||||||
|
export { redisAuthentik, verificarConexionRedisAuthentik };
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"name": "@suitecoffee/scripts",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"main": ".src/index.mjs",
|
||||||
|
"types": ".src/index.d.ts",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./src/index.d.ts",
|
||||||
|
"import": "./src/index.mjs",
|
||||||
|
"default": "./src/index.mjs"
|
||||||
|
},
|
||||||
|
"./package.json": "./package.json"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"srcindex.mjs",
|
||||||
|
"srcindex.d.ts"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
// @suitecoffee/scripts/src/index.mjs
|
||||||
|
// Punto de entrada general del paquete de utilidades.
|
||||||
|
|
||||||
|
export * from './utils/env.mjs';
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
// @suitecoffee/scripts/src/utils/env.mjs
|
||||||
|
|
||||||
|
/**
|
||||||
|
* checkRequiredEnvVars
|
||||||
|
* Verifica que todas las variables de entorno requeridas existan en process.env.
|
||||||
|
* Muestra advertencias si alguna falta.
|
||||||
|
*
|
||||||
|
* @param {...string} requiredKeys - Lista de nombres de variables esperadas
|
||||||
|
*/
|
||||||
|
|
||||||
|
|
||||||
|
export function checkRequiredEnvVars(...requiredKeys) {
|
||||||
|
const missingKeys = requiredKeys.filter((key) => !process.env[key]);
|
||||||
|
|
||||||
|
if (missingKeys.length > 0) {
|
||||||
|
console.warn(
|
||||||
|
`[ ENV ] No se encontraron las siguientes variables de entorno:\n\n` +
|
||||||
|
missingKeys.map((k) => `-> ${k}`).join('\n') +
|
||||||
|
`\n`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
console.log(`[ ENV ] Todas las variables de entorno requeridas están definidas.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
// BaseFileDriver.mjs
|
||||||
|
import { DeviceInterface } from './DeviceInterface.mjs';
|
||||||
|
import { fmtHMSUTC, fmtHM } from '../utils/dates.mjs';
|
||||||
|
import * as intervalsCross from '../strategies/intervals/cross-day.mjs';
|
||||||
|
import * as intervalsSame from '../strategies/intervals/same-day.mjs';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Template Method para drivers basados en archivos .txt
|
||||||
|
* Define el pipeline y delega el parseo de línea en this.parserStrategy.parseLine
|
||||||
|
*/
|
||||||
|
export class BaseFileDriver extends DeviceInterface {
|
||||||
|
constructor(opts = {}) {
|
||||||
|
super(opts);
|
||||||
|
if (!this.parserStrategy || typeof this.parserStrategy.parseLine !== 'function') {
|
||||||
|
throw new Error('BaseFileDriver requiere parserStrategy.parseLine(line)');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} text contenido completo del .txt en UTF-8
|
||||||
|
*/
|
||||||
|
async processFile(text) {
|
||||||
|
if (!text || typeof text !== 'string') {
|
||||||
|
this.setStatus('Elegí un .txt válido');
|
||||||
|
return { parsedRows: [], pairs: [], payloadDB: [], missing_docs: [], error: 'Archivo vacío o inválido' };
|
||||||
|
}
|
||||||
|
|
||||||
|
this.setStatus('Leyendo archivo…');
|
||||||
|
|
||||||
|
// 1) Parseo línea a línea (Strategy)
|
||||||
|
const lines = text.split(/\n/);
|
||||||
|
const parsedRows = [];
|
||||||
|
for (let i = 0; i < lines.length; i++) {
|
||||||
|
const r = this.parserStrategy.parseLine(lines[i]);
|
||||||
|
if (r) parsedRows.push(r);
|
||||||
|
if ((i & 511) === 0) this.emit('progress', { at: i, total: lines.length });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) Resolver nombres por documento (inyectado)
|
||||||
|
const uniqueDocs = [...new Set(parsedRows.map(r => r.doc))];
|
||||||
|
this.setStatus(`Leyendo archivo… | consultando ${uniqueDocs.length} documentos…`);
|
||||||
|
const map = await this._safeNamesResolver(uniqueDocs);
|
||||||
|
|
||||||
|
// 3) Detectar documentos faltantes
|
||||||
|
const missing_docs = uniqueDocs.filter(d => {
|
||||||
|
const hit = map?.[d];
|
||||||
|
if (!hit) return true;
|
||||||
|
if (typeof hit.found === 'boolean') return !hit.found;
|
||||||
|
return !(hit?.nombre || '').trim() && !(hit?.apellido || '').trim();
|
||||||
|
});
|
||||||
|
|
||||||
|
if (missing_docs.length) {
|
||||||
|
this.setStatus('Hay documentos sin usuario. Corrigí y volvé a procesar.');
|
||||||
|
return { parsedRows, pairs: [], payloadDB: [], missing_docs,
|
||||||
|
error: `No se encontraron ${missing_docs.length} documento(s) en la base` };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4) Enriquecer nombre desde DB
|
||||||
|
parsedRows.forEach(r => {
|
||||||
|
const hit = map?.[r.doc];
|
||||||
|
if (hit && (hit.nombre || hit.apellido)) r.name = `${hit.nombre || ''} ${hit.apellido || ''}`.trim();
|
||||||
|
});
|
||||||
|
|
||||||
|
// 5) Construcción de intervalos (Strategy)
|
||||||
|
const pairs = (this.intervalBuilder === 'sameDay')
|
||||||
|
? intervalsSame.buildIntervals(parsedRows)
|
||||||
|
: intervalsCross.buildIntervalsCrossDay(parsedRows);
|
||||||
|
|
||||||
|
// 6) Payload "raw" para DB
|
||||||
|
const payloadDB = parsedRows.map(r => ({
|
||||||
|
doc: r.doc, isoDate: r.isoDate, time: r.time, mode: r.mode || null
|
||||||
|
}));
|
||||||
|
|
||||||
|
this.setStatus(`${parsedRows.length} registros · ${pairs.length} intervalos`);
|
||||||
|
return { parsedRows, pairs, payloadDB, missing_docs: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
exportCSV(pairs) {
|
||||||
|
const list = Array.isArray(pairs) ? pairs : [];
|
||||||
|
if (!list.length) return '';
|
||||||
|
const head = ['documento','nombre','fecha','desde','hasta','duracion_hhmm','duracion_min','obs'];
|
||||||
|
const rows = list.map(p => {
|
||||||
|
const iso = p.isoDate || p.fecha || '';
|
||||||
|
const desdeStr = (p.desde_ms!=null) ? fmtHMSUTC(p.desde_ms) : '';
|
||||||
|
const hastaStr = (p.hasta_ms!=null) ? fmtHMSUTC(p.hasta_ms) : '';
|
||||||
|
const durStr = (p.durMins!=null) ? fmtHM(p.durMins) : '';
|
||||||
|
const durMin = (p.durMins!=null) ? Math.round(p.durMins) : '';
|
||||||
|
return [
|
||||||
|
p.doc, p.name || '', iso, desdeStr, hastaStr, durStr, durMin, p.obs || ''
|
||||||
|
].map(v => `"${String(v).replaceAll('"','""')}"`).join(',');
|
||||||
|
});
|
||||||
|
return head.join(',') + '\n' + rows.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
async _safeNamesResolver(docs) {
|
||||||
|
try { return await this.namesResolver(docs); }
|
||||||
|
catch { return {}; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
// DeviceInterface.mjs
|
||||||
|
import { EventEmitter } from 'node:events';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Contrato común que todos los drivers deben implementar.
|
||||||
|
* Drivers de archivo (.txt) pueden dejar connect/fetchLogs/parseLogData como no-op.
|
||||||
|
*/
|
||||||
|
export class DeviceInterface extends EventEmitter {
|
||||||
|
/**
|
||||||
|
* @param {object} [opts]
|
||||||
|
* @param {(docs:string[])=>Promise<Record<string,{nombre?:string,apellido?:string,found?:boolean}>>} [opts.namesResolver]
|
||||||
|
* @param {'crossDay'|'sameDay'} [opts.intervalBuilder]
|
||||||
|
* @param {{ parseLine:(line:string)=>object|null }} [opts.parserStrategy]
|
||||||
|
*/
|
||||||
|
constructor(opts = {}) {
|
||||||
|
super();
|
||||||
|
this.namesResolver = typeof opts.namesResolver === 'function' ? opts.namesResolver : async () => ({});
|
||||||
|
this.intervalBuilder = opts.intervalBuilder || 'crossDay';
|
||||||
|
this.parserStrategy = opts.parserStrategy || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------- API esperada (drivers file) -------
|
||||||
|
/**
|
||||||
|
* Procesa el contenido completo de un .txt y devuelve:
|
||||||
|
* { parsedRows, pairs, payloadDB, missing_docs, error? }
|
||||||
|
*/
|
||||||
|
async processFile(/* text:string */) {
|
||||||
|
throw new Error('processFile not implemented');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retorna CSV como string (no descarga).
|
||||||
|
*/
|
||||||
|
exportCSV(/* pairs?:object[] */) {
|
||||||
|
throw new Error('exportCSV not implemented');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------- API opcional (drivers TCP/IP) ----
|
||||||
|
async connect() { /* no-op */ }
|
||||||
|
async disconnect() { /* no-op */ }
|
||||||
|
async fetchLogs() { throw new Error('fetchLogs not implemented'); }
|
||||||
|
async parseLogData(/* raw */) { throw new Error('parseLogData not implemented'); }
|
||||||
|
|
||||||
|
// ------- Utilidad: emitir estado -------
|
||||||
|
setStatus(text) { this.emit('status', text || ''); }
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
// DeviceErrors.mjs
|
||||||
|
export class DeviceError extends Error { constructor(msg){ super(msg); this.name='DeviceError'; } }
|
||||||
|
export class DriverNotFoundError extends DeviceError { constructor(key){ super(`Driver no registrado: ${key}`); this.name='DriverNotFoundError'; } }
|
||||||
|
export class ParseError extends DeviceError { constructor(line){ super(`No se pudo parsear la línea: ${line}`); this.name='ParseError'; } }
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
// DeviceFactory.mjs
|
||||||
|
import { DriverRegistry } from './DriverRegistry.mjs';
|
||||||
|
|
||||||
|
export class DeviceFactory {
|
||||||
|
static register(key, ctor, manifest) {
|
||||||
|
DriverRegistry.register(key, ctor, manifest);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} key "vendor:model"
|
||||||
|
* @param {object} opts opciones para el constructor del driver
|
||||||
|
*/
|
||||||
|
static create(key, opts = {}) {
|
||||||
|
const reg = DriverRegistry.get(key);
|
||||||
|
if (!reg) throw new Error(`DeviceFactory: driver no registrado: ${key}`);
|
||||||
|
return new reg.ctor(opts);
|
||||||
|
}
|
||||||
|
|
||||||
|
static listSupported() {
|
||||||
|
return DriverRegistry.list();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
// DriverRegistry.mjs
|
||||||
|
const _registry = new Map();
|
||||||
|
/**
|
||||||
|
* Clave: "vendor:model" en minúsculas
|
||||||
|
* Valor: { ctor: DriverClass, manifest?: object }
|
||||||
|
*/
|
||||||
|
export const DriverRegistry = {
|
||||||
|
register(key, ctor, manifest = null) {
|
||||||
|
const k = String(key || '').trim().toLowerCase();
|
||||||
|
if (!k) throw new Error('DriverRegistry.register: key vacío');
|
||||||
|
if (typeof ctor !== 'function') throw new Error('DriverRegistry.register: ctor inválido');
|
||||||
|
_registry.set(k, { ctor, manifest: manifest || {} });
|
||||||
|
},
|
||||||
|
get(key) {
|
||||||
|
return _registry.get(String(key || '').trim().toLowerCase()) || null;
|
||||||
|
},
|
||||||
|
list() {
|
||||||
|
return [..._registry.entries()].map(([k, v]) => ({ key: k, manifest: v.manifest || {} }));
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
// index.mjs (Facade del dominio attendance)
|
||||||
|
export { DeviceInterface } from './DeviceInterface.mjs';
|
||||||
|
export { BaseFileDriver } from './BaseFileDriver.mjs';
|
||||||
|
export { DeviceFactory } from './factories/DeviceFactory.mjs';
|
||||||
|
export { DriverRegistry } from './factories/DriverRegistry.mjs';
|
||||||
|
|
||||||
|
// Facade helpers
|
||||||
|
import { DeviceFactory } from './factories/DeviceFactory.mjs';
|
||||||
|
|
||||||
|
export function registerDriver(key, Ctor, manifest) {
|
||||||
|
DeviceFactory.register(key, Ctor, manifest);
|
||||||
|
}
|
||||||
|
export function createDevice(key, opts) {
|
||||||
|
return DeviceFactory.create(key, opts);
|
||||||
|
}
|
||||||
|
export function listSupported() {
|
||||||
|
return DeviceFactory.listSupported();
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"title": "Device Driver Manifest",
|
||||||
|
"type": "object",
|
||||||
|
"required": ["vendor", "model", "transport", "version"],
|
||||||
|
"properties": {
|
||||||
|
"vendor": { "type": "string", "minLength": 1 },
|
||||||
|
"model": { "type": "string", "minLength": 1 },
|
||||||
|
"transport": { "type": "string", "enum": ["file", "tcp", "http"] },
|
||||||
|
"capabilities": { "type": "array", "items": { "type": "string" } },
|
||||||
|
"version": { "type": "string" }
|
||||||
|
},
|
||||||
|
"additionalProperties": true
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
// cross-day.mjs
|
||||||
|
// Pares ordenados para jornadas que pueden cruzar medianoche.
|
||||||
|
// rows: [{ doc, name, isoDate, dt_ms, ... }, ...]
|
||||||
|
export function buildIntervalsCrossDay(rows){
|
||||||
|
const byDoc = new Map();
|
||||||
|
rows.forEach(r => {
|
||||||
|
(byDoc.get(r.doc) || byDoc.set(r.doc, []).get(r.doc))
|
||||||
|
.push({ ms: r.dt_ms, date: r.isoDate, name: r.name });
|
||||||
|
});
|
||||||
|
|
||||||
|
const out = [];
|
||||||
|
for (const [doc, arr] of byDoc.entries()){
|
||||||
|
arr.sort((a,b)=>a.ms-b.ms);
|
||||||
|
for (let i=0;i<arr.length;i+=2){
|
||||||
|
const a = arr[i], b = arr[i+1];
|
||||||
|
if (!b){
|
||||||
|
out.push({doc, name:a.name, fecha:a.date, desde_ms:a.ms, hasta_ms:null, durMins:null, obs:'incompleto'});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
const dur = Math.max(0,(b.ms-a.ms)/60000);
|
||||||
|
out.push({doc, name:a.name, fecha:a.date, desde_ms:a.ms, hasta_ms:b.ms, durMins:dur, obs:''});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
out.sort((x,y)=> x.doc.localeCompare(y.doc) ||
|
||||||
|
x.fecha.localeCompare(y.fecha) ||
|
||||||
|
(x.desde_ms - y.desde_ms));
|
||||||
|
return out;
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
// same-day.mjs
|
||||||
|
// Agrupa por (doc, fecha) y arma pares 1-2, 3-4, ...
|
||||||
|
export function buildIntervals(rows) {
|
||||||
|
const nameByDoc = new Map();
|
||||||
|
const byKey = new Map(); // doc|isoDate -> [ms]
|
||||||
|
|
||||||
|
for (const r of rows) {
|
||||||
|
nameByDoc.set(r.doc, r.name);
|
||||||
|
const key = `${r.doc}|${r.isoDate}`;
|
||||||
|
(byKey.get(key) || byKey.set(key, []).get(key)).push(r.dt_ms);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = [];
|
||||||
|
for (const [key, arr] of byKey.entries()) {
|
||||||
|
arr.sort((a,b)=>a-b);
|
||||||
|
const [doc, isoDate] = key.split('|');
|
||||||
|
const name = nameByDoc.get(doc) || '';
|
||||||
|
for (let i=0; i<arr.length; i+=2) {
|
||||||
|
const desde = arr[i];
|
||||||
|
const hasta = arr[i+1] ?? null;
|
||||||
|
let durMins = null, obs = '';
|
||||||
|
if (hasta != null) durMins = Math.max(0, (hasta - desde)/60000);
|
||||||
|
else obs = 'incompleto';
|
||||||
|
result.push({ doc, name, isoDate, desde_ms: desde, hasta_ms: hasta, durMins, obs });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result.sort((a,b)=>{
|
||||||
|
if (a.doc !== b.doc) return a.doc.localeCompare(b.doc);
|
||||||
|
if (a.isoDate !== b.isoDate) return a.isoDate.localeCompare(b.isoDate);
|
||||||
|
return (a.desde_ms||0) - (b.desde_ms||0);
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
// LineParserInterface.mjs
|
||||||
|
export class LineParserInterface {
|
||||||
|
parseLine(/* line:string */) {
|
||||||
|
throw new Error('parseLine not implemented');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
// dates.mjs
|
||||||
|
export const z2 = n => String(n).padStart(2,'0');
|
||||||
|
|
||||||
|
export function toUTCms(isoDate, time) {
|
||||||
|
const [Y,M,D] = isoDate.split('-').map(n=>parseInt(n,10));
|
||||||
|
const [h,m,s] = time.split(':').map(n=>parseInt(n,10));
|
||||||
|
return Date.UTC(Y, (M||1)-1, D||1, h||0, m||0, s||0);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fmtHMSUTC(ms){
|
||||||
|
const d = new Date(ms);
|
||||||
|
const z = n => String(n).padStart(2,'0');
|
||||||
|
return `${z(d.getUTCHours())}:${z(d.getUTCMinutes())}:${z(d.getUTCSeconds())}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const fmtHM = mins => {
|
||||||
|
const h = Math.floor(mins/60); const m = Math.round(mins%60);
|
||||||
|
return `${z2(h)}:${z2(m)}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// "YY/MM/DD" o "YYYY/MM/DD" (o '-') -> "YYYY-MM-DD"
|
||||||
|
export function normDateStr(s) {
|
||||||
|
const m = String(s || '').trim().match(/^(\d{2,4})[\/\-](\d{1,2})[\/\-](\d{1,2})$/);
|
||||||
|
if (!m) return null;
|
||||||
|
let [_, y, mo, d] = m;
|
||||||
|
let yy = parseInt(y, 10);
|
||||||
|
if (y.length === 2) yy = 2000 + yy;
|
||||||
|
const mm = parseInt(mo, 10), dd = parseInt(d, 10);
|
||||||
|
if (!(mm >= 1 && mm <= 12 && dd >= 1 && dd <= 31)) return null;
|
||||||
|
return `${yy}-${String(mm).padStart(2,'0')}-${String(dd).padStart(2,'0')}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
// docs.mjs
|
||||||
|
import { z2 } from './dates.mjs';
|
||||||
|
|
||||||
|
export const normDoc = s => {
|
||||||
|
const v = String(s||'').replace(/\D/g,'').replace(/^0+/,'');
|
||||||
|
return v || '0';
|
||||||
|
};
|
||||||
|
|
||||||
|
export const cleanDoc = s => {
|
||||||
|
const v = String(s||'').trim().replace(/^0+/, '');
|
||||||
|
return v === '' ? '0' : v;
|
||||||
|
};
|
||||||
|
|
||||||
|
// HH:MM o HH:MM:SS -> HH:MM:SS
|
||||||
|
export const normTime = s => {
|
||||||
|
if (!s) return '';
|
||||||
|
const m = String(s).trim().match(/^(\d{1,2}):(\d{2})(?::(\d{2}))?$/);
|
||||||
|
if (!m) return '';
|
||||||
|
return `${z2(+m[1])}:${z2(+m[2])}:${z2(+m[3]||0)}`;
|
||||||
|
};
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
// GenericI60Driver.mjs
|
||||||
|
import { BaseFileDriver } from '../../core/BaseFileDriver.mjs';
|
||||||
|
import * as Parser from './parser.mjs';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Driver genérico i60 (sin conectividad). Lee archivos .txt exportados del equipo.
|
||||||
|
* Implementa el "Template Method" heredado de BaseFileDriver.
|
||||||
|
*/
|
||||||
|
export default class GenericI60Driver extends BaseFileDriver {
|
||||||
|
constructor(opts = {}) {
|
||||||
|
super({
|
||||||
|
...opts,
|
||||||
|
parserStrategy: { parseLine: Parser.parseLine },
|
||||||
|
intervalBuilder: opts.intervalBuilder || 'crossDay'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
// index.mjs
|
||||||
|
import GenericI60Driver from './GenericI60Driver.mjs';
|
||||||
|
|
||||||
|
export const manifest = {
|
||||||
|
vendor: 'generic',
|
||||||
|
model: 'i60',
|
||||||
|
transport: 'file',
|
||||||
|
capabilities: ['import', 'intervals:cross-day'],
|
||||||
|
version: '1.0.0'
|
||||||
|
};
|
||||||
|
|
||||||
|
export default GenericI60Driver;
|
||||||
|
export { manifest };
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
// parser.mjs
|
||||||
|
import { normDateStr, toUTCms } from '../../core/utils/dates.mjs';
|
||||||
|
import { cleanDoc, normTime } from '../../core/utils/docs.mjs';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parsea una línea con prioridad por TAB; si no hay, cae a espacios;
|
||||||
|
* separa fecha/hora si vienen juntas.
|
||||||
|
* Devuelve { doc, name, isoDate, time, dt_ms, mode } o null.
|
||||||
|
*/
|
||||||
|
export function parseLine(line) {
|
||||||
|
const raw = String(line || '').replace(/\r/g, '').trim();
|
||||||
|
if (!raw) return null;
|
||||||
|
|
||||||
|
// omitir encabezados comunes
|
||||||
|
if (/^no[\t ]|^mchn[\t ]|^enno[\t ]|^name[\t ]|^datetime[\t ]/i.test(raw)) return null;
|
||||||
|
|
||||||
|
let parts = raw.split(/\t+/);
|
||||||
|
|
||||||
|
// Fallback: dos o más espacios + DateTime al final
|
||||||
|
if (parts.length < 7) {
|
||||||
|
const dtMatch = raw.match(/(\d{2,4}[\/-]\d{1,2}[\/-]\d{1,2})\s+(\d{1,2}:\d{2}:\d{2})$/);
|
||||||
|
if (dtMatch) {
|
||||||
|
const head = raw.slice(0, dtMatch.index).trim();
|
||||||
|
const headParts = head.split(/\t+|\s{2,}/).filter(Boolean);
|
||||||
|
parts = [...headParts, dtMatch[1], dtMatch[2]];
|
||||||
|
} else {
|
||||||
|
parts = raw.split(/\s{2,}/).filter(Boolean);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (parts.length < 7) return null;
|
||||||
|
|
||||||
|
// Indices "normales": 0:No, 1:Mchn, 2:EnNo(doc), 3:Name, 4:Mode, 5:Fecha, 6:Hora
|
||||||
|
const doc = cleanDoc(parts[2]);
|
||||||
|
const name = String(parts[3] || '').trim();
|
||||||
|
const mode = String(parts[4] || '').trim();
|
||||||
|
|
||||||
|
let dateStr = String(parts[5] || '').trim();
|
||||||
|
let timeStr = String(parts[6] || '').trim();
|
||||||
|
|
||||||
|
// Caso: la última columna es "YYYY/MM/DD HH:MM:SS"
|
||||||
|
const last = parts[parts.length - 1];
|
||||||
|
const dtBoth = /(\d{2,4}[\/-]\d{1,2}[\/-]\d{1,2})\s+(\d{1,2}:\d{2}:\d{2})/.exec(last);
|
||||||
|
if (dtBoth) { dateStr = dtBoth[1]; timeStr = dtBoth[2]; }
|
||||||
|
else if (!timeStr && /\d{1,2}:\d{2}:\d{2}/.test(dateStr)) {
|
||||||
|
const m = dateStr.match(/^(.+?)\s+(\d{1,2}:\d{2}:\d{2})$/);
|
||||||
|
if (m) { dateStr = m[1]; timeStr = m[2]; }
|
||||||
|
}
|
||||||
|
|
||||||
|
const iso = normDateStr(dateStr);
|
||||||
|
const timeNorm = normTime(timeStr);
|
||||||
|
if (!iso || !timeNorm) return null;
|
||||||
|
|
||||||
|
return { doc, name, isoDate: iso, time: timeNorm, dt_ms: toUTCms(iso, timeNorm), mode };
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { GenericI60Driver } from './drivers/Generic/i60/GenericI60Driver';
|
||||||
|
|
||||||
|
export class DeviceFactory {
|
||||||
|
static create(model, config) {
|
||||||
|
switch (model) {
|
||||||
|
case 'Generic-i60': return new GenericI60Driver(config);
|
||||||
|
default:
|
||||||
|
throw new Error(`El modelo indicado no esta soportado. ${model}\n Porfavor ponerse en contacto con el equipo para implementarlo.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
// DeviceInterface.mjs
|
||||||
|
export class DeviceInterface {
|
||||||
|
async connect() { throw new Error('Not implemented'); }
|
||||||
|
async fetchLogs() { throw new Error('Not implemented'); }
|
||||||
|
async parseLogData(raw) { throw new Error('Not implemented'); }
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { GenericDriver } from './GenericDriver.mjs';
|
||||||
|
|
||||||
|
export class DriverFactory {
|
||||||
|
static create(model = 'Generico'){
|
||||||
|
switch (String(model).toLowerCase()) {
|
||||||
|
case 'generico':
|
||||||
|
case 'generic':
|
||||||
|
default:
|
||||||
|
// El constructor de GenericDriver es Singleton; devolverá siempre la misma instancia
|
||||||
|
return new GenericDriver();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { readFile } from 'node:fs/promises';
|
||||||
|
import { parseLine } from './parsing.mjs';
|
||||||
|
import { buildIntervalsCrossDay } from './intervals.mjs';
|
||||||
|
import { exportCSV } from './csv.mjs';
|
||||||
|
import { NamesServiceProxy } from './namesProxy.mjs';
|
||||||
|
|
||||||
|
class GenericDriver {
|
||||||
|
constructor(){
|
||||||
|
if (GenericDriver._instance) return GenericDriver._instance;
|
||||||
|
/** @type {Array<Object>} */ this.parsedRows = [];
|
||||||
|
/** @type {Array<Object>} */ this.payloadDB = [];
|
||||||
|
/** @type {Array<Object>} */ this.pairs = [];
|
||||||
|
GenericDriver._instance = this;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Orquesta el proceso a partir de texto plano
|
||||||
|
async processText(text, { fetchNamesForDocs } = {}){
|
||||||
|
const lines = String(text||'').split(/\n/);
|
||||||
|
const rows = [];
|
||||||
|
for (const line of lines) {
|
||||||
|
const r = parseLine(line);
|
||||||
|
if (r) rows.push(r);
|
||||||
|
}
|
||||||
|
this.parsedRows = rows;
|
||||||
|
|
||||||
|
const uniqueDocs = [...new Set(this.parsedRows.map(r => r.doc))];
|
||||||
|
|
||||||
|
const namesProxy = new NamesServiceProxy(fetchNamesForDocs);
|
||||||
|
const map = await namesProxy.get(uniqueDocs);
|
||||||
|
|
||||||
|
const missingDocs = uniqueDocs.filter(d => {
|
||||||
|
const hit = map?.[d];
|
||||||
|
if (!hit) return true;
|
||||||
|
if (typeof hit.found === 'boolean') return !hit.found;
|
||||||
|
return !(hit?.nombre||'').trim() && !(hit?.apellido||'').trim();
|
||||||
|
});
|
||||||
|
|
||||||
|
// sobreescribir nombre cuando DB provee
|
||||||
|
this.parsedRows.forEach(r => {
|
||||||
|
const hit = map?.[r.doc];
|
||||||
|
if (hit && (hit.nombre || hit.apellido)) {
|
||||||
|
r.name = `${hit.nombre || ''} ${hit.apellido || ''}`.trim();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Pairs (permitiendo cruce de medianoche)
|
||||||
|
this.pairs = buildIntervalsCrossDay(this.parsedRows);
|
||||||
|
|
||||||
|
// Payload crudo para insertar
|
||||||
|
this.payloadDB = this.parsedRows.map(r => ({
|
||||||
|
doc: r.doc,
|
||||||
|
isoDate: r.isoDate,
|
||||||
|
time: r.time,
|
||||||
|
mode: r.mode || null
|
||||||
|
}));
|
||||||
|
|
||||||
|
return { parsedRows: this.parsedRows, pairs: this.pairs, payloadDB: this.payloadDB, missingDocs };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Conveniencia: leer desde ruta en disco
|
||||||
|
async processFileFromPath(filePath, opts = {}){
|
||||||
|
const txt = await readFile(filePath, 'utf8');
|
||||||
|
return await this.processText(txt, opts);
|
||||||
|
}
|
||||||
|
|
||||||
|
// CSV server-side (devuelve string)
|
||||||
|
exportCSV(pairs = this.pairs){
|
||||||
|
return exportCSV(pairs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const instance = new GenericDriver();
|
||||||
|
export default instance;
|
||||||
|
export { GenericDriver };
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { DriverFactory } from './DriverFactory.mjs';
|
||||||
|
|
||||||
|
export class GenericDriverFacade {
|
||||||
|
constructor(driver = DriverFactory.create('Generico')){ this.driver = driver; }
|
||||||
|
async processTxt(text, services = {}){ return await this.driver.processText(text, services); }
|
||||||
|
async processFile(filePath, services = {}){ return await this.driver.processFileFromPath(filePath, services); }
|
||||||
|
exportCSV(pairs){ return this.driver.exportCSV(pairs); }
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { fmtHM, fmtHMSUTC } from './helpers.mjs';
|
||||||
|
|
||||||
|
// Genera CSV (server-side: retorna string) — nombre preservado
|
||||||
|
export function exportCSV(pairs) {
|
||||||
|
if (!pairs?.length) return '';
|
||||||
|
const head = ['documento','nombre','fecha','desde','hasta','duracion_hhmm','duracion_min','obs'];
|
||||||
|
const rows = pairs.map(p => {
|
||||||
|
const fecha = p.fecha || p.isoDate || '';
|
||||||
|
const desde = p.desde_ms!=null ? fmtHMSUTC(p.desde_ms) : '';
|
||||||
|
const hasta = p.hasta_ms!=null ? fmtHMSUTC(p.hasta_ms) : '';
|
||||||
|
const durHHMM = p.durMins!=null ? fmtHM(p.durMins) : '';
|
||||||
|
const durMin = p.durMins!=null ? Math.round(p.durMins) : '';
|
||||||
|
return [p.doc, p.name || '', fecha, desde, hasta, durHHMM, durMin, p.obs || '']
|
||||||
|
.map(v => `"${String(v).replaceAll('"','""')}"`).join(',');
|
||||||
|
});
|
||||||
|
return head.join(',') + '\n' + rows.join('\n');
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
// Helpers comunes (nombres preservados)
|
||||||
|
export const z2 = n => String(n).padStart(2,'0');
|
||||||
|
export const pad2 = z2;
|
||||||
|
export const fmtHM = mins => { const h = Math.floor(mins/60); const m = Math.round(mins%60); return `${z2(h)}:${z2(m)}`; };
|
||||||
|
export const ymd = s => String(s||'').slice(0,10); // '2025-08-29T..' -> '2025-08-29'
|
||||||
|
|
||||||
|
// Normaliza fecha "YY/MM/DD" o "YYYY/MM/DD" a "YYYY-MM-DD"
|
||||||
|
export function normDateStr(s) {
|
||||||
|
const m = String(s || '').trim().match(/^(\d{2,4})[\/\-](\d{1,2})[\/\-](\d{1,2})$/);
|
||||||
|
if (!m) return null;
|
||||||
|
let [_, y, mo, d] = m;
|
||||||
|
let yy = parseInt(y, 10);
|
||||||
|
if (y.length === 2) yy = 2000 + yy; // 20YY
|
||||||
|
const mm = parseInt(mo, 10), dd = parseInt(d, 10);
|
||||||
|
if (!(mm >= 1 && mm <= 12 && dd >= 1 && dd <= 31)) return null;
|
||||||
|
return `${yy}-${String(mm).padStart(2,'0')}-${String(dd).padStart(2,'0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normaliza documento quitando ceros a la izquierda
|
||||||
|
export const cleanDoc = s => {
|
||||||
|
const v = String(s||'').trim().replace(/^0+/, '');
|
||||||
|
return v === '' ? '0' : v;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Compat alias (mantener nombre)
|
||||||
|
export const normDoc = s => {
|
||||||
|
const v = String(s||'').replace(/\D/g,'').replace(/^0+/,'');
|
||||||
|
return v || '0';
|
||||||
|
};
|
||||||
|
|
||||||
|
export function toUTCms(isoDate, time) {
|
||||||
|
const [Y,M,D] = isoDate.split('-').map(n=>parseInt(n,10));
|
||||||
|
const [h,m,s] = time.split(':').map(n=>parseInt(n,10));
|
||||||
|
return Date.UTC(Y, (M||1)-1, D||1, h||0, m||0, s||0); // UTC fijo
|
||||||
|
}
|
||||||
|
export function fmtHMSUTC(ms){
|
||||||
|
const d = new Date(ms);
|
||||||
|
const z = n => String(n).padStart(2,'0');
|
||||||
|
return `${z(d.getUTCHours())}:${z(d.getUTCMinutes())}:${z(d.getUTCSeconds())}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
export { default as GenericDriverDefault, GenericDriver } from './GenericDriver.mjs';
|
||||||
|
export { DriverFactory } from './DriverFactory.mjs';
|
||||||
|
export { GenericDriverFacade } from './GenericDriverFacade.mjs';
|
||||||
|
export { NamesServiceProxy } from './namesProxy.mjs';
|
||||||
|
|
||||||
|
export * from './helpers.mjs';
|
||||||
|
export * from './parsing.mjs';
|
||||||
|
export * from './intervals.mjs';
|
||||||
|
export * from './csv.mjs';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
/*
|
||||||
|
Uso mínimo (en tu servidor, al recibir un .txt subido):
|
||||||
|
|
||||||
|
// ejemplo en tu ruta de subida
|
||||||
|
import { GenericDriverFacade } from './drivers/generic/i60/GenericDriverFacade.mjs';
|
||||||
|
|
||||||
|
const facade = new GenericDriverFacade();
|
||||||
|
|
||||||
|
const { parsedRows, pairs, payloadDB, missingDocs } =
|
||||||
|
await facade.processFile(tempFilePath, {
|
||||||
|
// opcional: integra tu búsqueda de usuarios por documento
|
||||||
|
fetchNamesForDocs: async (docs) => {
|
||||||
|
// devuelve: { "12345678": { nombre, apellido, found:true } , ... }
|
||||||
|
return await dbFindUsuariosPorDocumentos(docs);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// luego persistes payloadDB y/o pairs según tu lógica
|
||||||
|
|
||||||
|
*/
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
// Agrupa por empleado, ordena cronológicamente y arma pares 1-2, 3-4, ... permitiendo cruzar medianoche.
|
||||||
|
export function buildIntervalsCrossDay(rows){
|
||||||
|
const byDoc = new Map();
|
||||||
|
for (const r of rows) {
|
||||||
|
if (!byDoc.has(r.doc)) byDoc.set(r.doc, []);
|
||||||
|
byDoc.get(r.doc).push({ ms: r.dt_ms, date: r.isoDate, name: r.name });
|
||||||
|
}
|
||||||
|
const out = [];
|
||||||
|
for (const [doc, arr] of byDoc.entries()){
|
||||||
|
arr.sort((a,b)=>a.ms-b.ms);
|
||||||
|
for (let i=0;i<arr.length;i+=2){
|
||||||
|
const a = arr[i], b = arr[i+1];
|
||||||
|
if (!b){ out.push({doc, name:a.name, fecha:a.date, desde_ms:a.ms, hasta_ms:null, durMins:null, obs:'incompleto'}); break; }
|
||||||
|
const dur = Math.max(0,(b.ms-a.ms)/60000);
|
||||||
|
out.push({doc, name:a.name, fecha:a.date, desde_ms:a.ms, hasta_ms:b.ms, durMins:dur, obs:''});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// ordenar por doc, fecha (inicio), desde
|
||||||
|
out.sort((x,y)=> x.doc.localeCompare(y.doc) || x.fecha.localeCompare(y.fecha) || (x.desde_ms - y.desde_ms));
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Alternativa por (doc, fecha) exacta (conservar nombre y firma)
|
||||||
|
export function buildIntervals(rows) {
|
||||||
|
const nameByDoc = new Map();
|
||||||
|
const byKey = new Map(); // doc|isoDate -> [ms]
|
||||||
|
for (const r of rows) {
|
||||||
|
nameByDoc.set(r.doc, r.name);
|
||||||
|
const key = `${r.doc}|${r.isoDate}`;
|
||||||
|
if (!byKey.has(key)) byKey.set(key, []);
|
||||||
|
byKey.get(key).push(r.dt_ms);
|
||||||
|
}
|
||||||
|
const result = [];
|
||||||
|
for (const [key, arr] of byKey.entries()) {
|
||||||
|
arr.sort((a,b)=>a-b);
|
||||||
|
const [doc, isoDate] = key.split('|');
|
||||||
|
const name = nameByDoc.get(doc) || '';
|
||||||
|
for (let i=0; i<arr.length; i+=2) {
|
||||||
|
const desde = arr[i];
|
||||||
|
const hasta = arr[i+1] ?? null;
|
||||||
|
let durMins = null, obs = '';
|
||||||
|
if (hasta != null) durMins = Math.max(0, (hasta - desde)/60000);
|
||||||
|
else obs = 'incompleto';
|
||||||
|
result.push({ doc, name, isoDate, desde_ms: desde, hasta_ms: hasta, durMins, obs });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result.sort((a,b)=>{
|
||||||
|
if (a.doc !== b.doc) return a.doc.localeCompare(b.doc);
|
||||||
|
if (a.isoDate !== b.isoDate) return a.isoDate.localeCompare(b.isoDate);
|
||||||
|
return (a.desde_ms||0) - (b.desde_ms||0);
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
// Proxy de servicio de nombres (caché + normalización)
|
||||||
|
export class NamesServiceProxy {
|
||||||
|
constructor(fetchNamesForDocs){
|
||||||
|
this._fetch = typeof fetchNamesForDocs === 'function' ? fetchNamesForDocs : async () => ({});
|
||||||
|
this._cache = new Map();
|
||||||
|
}
|
||||||
|
async get(docs){
|
||||||
|
const ask = [];
|
||||||
|
for (const d of docs) if (!this._cache.has(d)) ask.push(d);
|
||||||
|
if (ask.length){
|
||||||
|
const map = await this._fetch(ask);
|
||||||
|
for (const [k,v] of Object.entries(map || {})) this._cache.set(String(k), v || {});
|
||||||
|
}
|
||||||
|
const out = {};
|
||||||
|
for (const d of docs) out[d] = this._cache.get(d) || {};
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"name": "@suitecoffee/driver-i60",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"private": true,
|
||||||
|
"description": "Driver genérico para lector I60 (asistencia)",
|
||||||
|
"exports": {
|
||||||
|
".": "./src/index.mjs"
|
||||||
|
},
|
||||||
|
"files": ["src"],
|
||||||
|
"dependencies": {
|
||||||
|
"@suitecoffee/db": "workspace:*",
|
||||||
|
"@suitecoffee/utils": "workspace:*"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { cleanDoc, normDateStr, toUTCms } from './helpers.mjs';
|
||||||
|
|
||||||
|
// Parsea una línea (nombres preservados)
|
||||||
|
export function parseLine(line) {
|
||||||
|
const raw = String(line || '').replace(/\r/g, '').trim();
|
||||||
|
if (!raw) return null;
|
||||||
|
|
||||||
|
// omitir encabezado
|
||||||
|
if (/^no[\t ]|^mchn[\t ]|^enno[\t ]|^name[\t ]|^datetime[\t ]/i.test(raw)) return null;
|
||||||
|
|
||||||
|
let parts = raw.split(/\t+/);
|
||||||
|
|
||||||
|
// Si no alcanzan 7 campos, intentar fallback con dos o más espacios
|
||||||
|
if (parts.length < 7) {
|
||||||
|
const dtMatch = raw.match(/(\d{2,4}[\/-]\d{1,2}[\/-]\d{1,2})\s+(\d{1,2}:\d{2}:\d{2})$/);
|
||||||
|
if (dtMatch) {
|
||||||
|
const head = raw.slice(0, dtMatch.index).trim();
|
||||||
|
const headParts = head.split(/\t+|\s{2,}/).filter(Boolean);
|
||||||
|
parts = [...headParts, dtMatch[1], dtMatch[2]];
|
||||||
|
} else {
|
||||||
|
parts = raw.split(/\s{2,}/).filter(Boolean);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (parts.length < 7) return null;
|
||||||
|
|
||||||
|
// 0:No, 1:Mchn, 2:EnNo(doc), 3:Name, 4:Mode, 5:Fecha, 6:Hora
|
||||||
|
const DOC_IDX = 2;
|
||||||
|
const NAME_IDX = 3;
|
||||||
|
const MODE_IDX = 4;
|
||||||
|
|
||||||
|
const doc = cleanDoc(parts[DOC_IDX]);
|
||||||
|
const name = String(parts[NAME_IDX] || '').trim();
|
||||||
|
const mode = String(parts[MODE_IDX] || '').trim();
|
||||||
|
|
||||||
|
let dateStr = String(parts[5] || '').trim();
|
||||||
|
let timeStr = String(parts[6] || '').trim();
|
||||||
|
|
||||||
|
// Caso: 7 columnas y última es "DateTime"
|
||||||
|
const last = parts[parts.length - 1];
|
||||||
|
const dtBoth = /(\d{2,4}[\/-]\d{1,2}[\/-]\d{1,2})\s+(\d{1,2}:\d{2}:\d{2})/.exec(last);
|
||||||
|
if (dtBoth) {
|
||||||
|
dateStr = dtBoth[1];
|
||||||
|
timeStr = dtBoth[2];
|
||||||
|
} else if (!timeStr && /\d{1,2}:\d{2}:\d{2}/.test(dateStr)) {
|
||||||
|
const m = dateStr.match(/^(.+?)\s+(\d{1,2}:\d{2}:\d{2})$/);
|
||||||
|
if (m) { dateStr = m[1]; timeStr = m[2]; }
|
||||||
|
}
|
||||||
|
|
||||||
|
const iso = normDateStr(dateStr); // YY/MM/DD o YYYY/MM/DD -> YYYY-MM-DD
|
||||||
|
if (!iso || !/^\d{1,2}:\d{2}:\d{2}$/.test(timeStr)) return null;
|
||||||
|
|
||||||
|
const [H, M, S] = timeStr.split(':').map(n => parseInt(n, 10));
|
||||||
|
// mantener construcción local solo por paridad con el snippet original
|
||||||
|
// eslint-disable-next-line no-unused-vars
|
||||||
|
const dt = new Date(`${iso}T${String(H).padStart(2,'0')}:${String(M).padStart(2,'0')}:${String(S).padStart(2,'0')}`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
doc, name,
|
||||||
|
isoDate: iso,
|
||||||
|
time: timeStr,
|
||||||
|
dt_ms: toUTCms(iso, timeStr), // ⬅️ clave
|
||||||
|
mode
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"name": "@suitecoffee/devices",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"description": "Utilidades de acceso a Postgres para SuiteCoffee (pool por proceso + helpers multi-tenant).",
|
||||||
|
"exports": {
|
||||||
|
".": "./src/index.mjs"
|
||||||
|
},
|
||||||
|
"main": "./src/index.mjs",
|
||||||
|
"files": ["src"],
|
||||||
|
"sideEffects": false,
|
||||||
|
"peerDependencies": {
|
||||||
|
"pg": "^8.12.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"name": "@suitecoffee/oidc",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"main": "src/index.mjs",
|
||||||
|
"exports": {
|
||||||
|
".": "./src/index.mjs"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"openid-client": "^6.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
// @suitecoffee/oidc/src/index.mjs
|
||||||
|
// OIDC minimal (ESM) — siempre usa discovery vía OIDC_CONFIG_URL
|
||||||
|
|
||||||
|
import { Issuer } from 'openid-client';
|
||||||
|
|
||||||
|
let _cached = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ENV requeridas:
|
||||||
|
* - OIDC_CONFIG_URL -> https://.../.well-known/openid-configuration
|
||||||
|
* - OIDC_CLIENT_ID
|
||||||
|
* - OIDC_CLIENT_SECRET -> opcional (si tu client es confidencial)
|
||||||
|
* - OIDC_REDIRECT_URI
|
||||||
|
*/
|
||||||
|
export async function initOIDCFromEnv() {
|
||||||
|
if (_cached) return _cached;
|
||||||
|
|
||||||
|
const configUrl = process.env.OIDC_CONFIG_URL;
|
||||||
|
const clientId = process.env.OIDC_CLIENT_ID;
|
||||||
|
const clientSecret = process.env.OIDC_CLIENT_SECRET || undefined;
|
||||||
|
const redirectUri = process.env.OIDC_REDIRECT_URI;
|
||||||
|
|
||||||
|
// Discovery directo (assume OK)
|
||||||
|
const issuer = await Issuer.discover(configUrl);
|
||||||
|
|
||||||
|
const client = new issuer.Client({
|
||||||
|
client_id: clientId,
|
||||||
|
client_secret: clientSecret,
|
||||||
|
redirect_uris: [redirectUri],
|
||||||
|
response_types: ['code'],
|
||||||
|
token_endpoint_auth_method: clientSecret ? 'client_secret_post' : 'none',
|
||||||
|
});
|
||||||
|
|
||||||
|
_cached = {
|
||||||
|
issuer,
|
||||||
|
client,
|
||||||
|
|
||||||
|
// Construye la URL de autorización (PKCE)
|
||||||
|
getAuthUrl({ state, nonce, code_challenge, scope = 'openid email profile' }) {
|
||||||
|
return client.authorizationUrl({
|
||||||
|
scope,
|
||||||
|
redirect_uri: redirectUri,
|
||||||
|
code_challenge,
|
||||||
|
code_challenge_method: 'S256',
|
||||||
|
state,
|
||||||
|
nonce,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// Intercambia el authorization code en el callback
|
||||||
|
async handleCallback(req, expected) {
|
||||||
|
const params = client.callbackParams(req);
|
||||||
|
return client.callback(redirectUri, params, expected);
|
||||||
|
},
|
||||||
|
|
||||||
|
// URL de fin de sesión (si el OP la expone)
|
||||||
|
endSessionUrl({ id_token_hint, post_logout_redirect_uri }) {
|
||||||
|
return client.endSessionUrl
|
||||||
|
? client.endSessionUrl({ id_token_hint, post_logout_redirect_uri })
|
||||||
|
: null;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return _cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getOIDC() {
|
||||||
|
if (!_cached) throw new Error('[OIDC] initOIDCFromEnv() no fue llamado aún');
|
||||||
|
return _cached;
|
||||||
|
}
|
||||||
@@ -1,38 +1,54 @@
|
|||||||
# ===== Runtime =====
|
# =======================================================
|
||||||
|
# Runtime
|
||||||
NODE_ENV=development
|
NODE_ENV=development
|
||||||
PORT=3030
|
PORT=3030
|
||||||
|
APP_BASE_URL=https://dev.suitecoffee.uy
|
||||||
|
# =======================================================
|
||||||
|
|
||||||
# ===== Session (usa el Redis del stack) =====
|
|
||||||
# Para DEV podemos reutilizar el Redis de Authentik. En prod conviene uno separado.
|
|
||||||
|
# =======================================================
|
||||||
|
# Configuración de Dases de Datos
|
||||||
|
CORE_DB_HOST=dev-db
|
||||||
|
CORE_DB_NAME=dev_suitecoffee_core
|
||||||
|
CORE_DB_PORT=5432
|
||||||
|
CORE_DB_USER=dev-user-suitecoffee
|
||||||
|
CORE_DB_PASS=dev-pass-suitecoffee
|
||||||
|
|
||||||
|
TENANTS_DB_HOST=dev-tenants
|
||||||
|
TENANTS_DB_NAME=dev_suitecoffee_tenants
|
||||||
|
TENANTS_DB_PORT=5432
|
||||||
|
TENANTS_DB_USER=suitecoffee
|
||||||
|
TENANTS_DB_PASS=suitecoffee
|
||||||
|
# =======================================================
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# =======================================================
|
||||||
|
# Sesiones
|
||||||
SESSION_SECRET=Neon*Mammal*Boaster*Ludicrous*Fender8*Crablike
|
SESSION_SECRET=Neon*Mammal*Boaster*Ludicrous*Fender8*Crablike
|
||||||
SESSION_COOKIE_NAME=sc.sid
|
SESSION_NAME=sc.sid
|
||||||
REDIS_URL=redis://ak-redis:6379
|
# COOKIE_DOMAIN=dev.suitecoffee.uy
|
||||||
|
# =======================================================
|
||||||
|
|
||||||
# ===== 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 y OIDC
|
||||||
|
AK_TOKEN=h2apVHbd3ApMcnnSwfQPXbvximkvP8HnUE25ot3zXWuEEtJFaNCcOzDHB6Xw
|
||||||
|
AK_REDIS_URL=redis://ak-redis:6379
|
||||||
|
|
||||||
|
OIDC_LOGIN_URL=https://sso.suitecoffee.uy
|
||||||
|
OIDC_REDIRECT_URI=https://suitecoffee.uy/auth/callback
|
||||||
|
|
||||||
|
OIDC_CLIENT_ID=1orMM8vOvf3WkN2FejXYvUFpPtONG0Lx1eMlwIpW
|
||||||
|
OIDC_CLIENT_SECRET=t5wx13qBcM0EFQ3cGnUIAmLzvbdsQrUVPv1OGWjszWkEp35pJQ55t7vZeeShqG49kuRAaiXv6PSGJLhRfGaponGaJl8gH1uCL7KIxdmm7UihgYoAXB2dFhZV4zRxfze2
|
||||||
|
|
||||||
OIDC_CLIEN_ID=1orMM8vOvf3WkN2FejXYvUFpPtONG0Lx1eMlwIpW
|
|
||||||
OIDC_CONFIG_URL=https://sso.suitecoffee.uy/application/o/suitecoffee/.well-known/openid-configuration
|
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_AUTHORIZE_URL=https://sso.suitecoffee.uy/application/o/authorize/
|
||||||
OIDC_TOKEN_URL=https://sso.suitecoffee.uy/application/o/token/
|
OIDC_TOKEN_URL=https://sso.suitecoffee.uy/application/o/token/
|
||||||
OIDC_USERINFO_URL=https://sso.suitecoffee.uy/application/o/userinfo/
|
OIDC_USERINFO_URL=https://sso.suitecoffee.uy/application/o/userinfo/
|
||||||
OIDC_LOGOUT_URL=https://sso.suitecoffee.uy/application/o/suitecoffee/end-session/
|
OIDC_LOGOUT_URL=https://sso.suitecoffee.uy/application/o/suitecoffee/end-session/
|
||||||
OIDC_JWKS_URL=https://sso.suitecoffee.uy/application/o/suitecoffee/jwks/
|
OIDC_JWKS_URL=https://sso.suitecoffee.uy/application/o/suitecoffee/jwks/
|
||||||
|
|
||||||
OIDC_LOGIN_URL=https://sso.suitecoffee.uy
|
# =======================================================
|
||||||
APP_BASE_URL=https://suitecoffee.uy
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
# Dockerfile.dev
|
# Dockerfile.dev
|
||||||
FROM node:22.18
|
FROM node:20.19.5-bookworm
|
||||||
|
|
||||||
# Definir variables de entorno con valores predeterminados
|
# Definir variables de entorno con valores predeterminados
|
||||||
# ARG NODE_ENV=production
|
# ARG NODE_ENV=production
|
||||||
|
|||||||
Generated
+124
-142
@@ -9,6 +9,12 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@suitecoffee/api": "file:../../packages/api/",
|
||||||
|
"@suitecoffee/db": "file:../../packages/core/db",
|
||||||
|
"@suitecoffee/middlewares": "file:../../packages/core/middlewares",
|
||||||
|
"@suitecoffee/redis": "file:../../packages/core/redis",
|
||||||
|
"@suitecoffee/repositories": "file:../../packages/core/repositories",
|
||||||
|
"@suitecoffee/scripts": "file:../../packages/core/scripts",
|
||||||
"bcrypt": "^6.0.0",
|
"bcrypt": "^6.0.0",
|
||||||
"chalk": "^5.6.0",
|
"chalk": "^5.6.0",
|
||||||
"connect-redis": "^9.0.0",
|
"connect-redis": "^9.0.0",
|
||||||
@@ -23,7 +29,9 @@
|
|||||||
"jose": "^6.1.0",
|
"jose": "^6.1.0",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"jwks-rsa": "^3.2.0",
|
"jwks-rsa": "^3.2.0",
|
||||||
|
"mime": "^4.1.0",
|
||||||
"morgan": "^1.10.1",
|
"morgan": "^1.10.1",
|
||||||
|
"node-appwrite": "^20.2.1",
|
||||||
"node-fetch": "^3.3.2",
|
"node-fetch": "^3.3.2",
|
||||||
"pg": "^8.16.3",
|
"pg": "^8.16.3",
|
||||||
"pg-format": "^1.0.4",
|
"pg-format": "^1.0.4",
|
||||||
@@ -35,6 +43,37 @@
|
|||||||
"nodemon": "^3.1.10"
|
"nodemon": "^3.1.10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"../../packages/api": {
|
||||||
|
"name": "@suitecoffee/api",
|
||||||
|
"version": "1.0.0"
|
||||||
|
},
|
||||||
|
"../../packages/core": {},
|
||||||
|
"../../packages/core/db": {
|
||||||
|
"name": "@suitecoffee/db",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"peerDependencies": {
|
||||||
|
"pg": "^8.16.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"../../packages/core/middlewares": {
|
||||||
|
"name": "@suitecoffee/middlewares",
|
||||||
|
"version": "1.0.0"
|
||||||
|
},
|
||||||
|
"../../packages/core/redis": {
|
||||||
|
"name": "@suitecoffee/redis",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"peerDependencies": {
|
||||||
|
"pg": "^8.16.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"../../packages/core/repositories": {
|
||||||
|
"name": "@suitecoffee/repositories",
|
||||||
|
"version": "0.0.1"
|
||||||
|
},
|
||||||
|
"../../packages/core/scripts": {
|
||||||
|
"name": "@suitecoffee/scripts",
|
||||||
|
"version": "1.0.0"
|
||||||
|
},
|
||||||
"node_modules/@epic-web/invariant": {
|
"node_modules/@epic-web/invariant": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
@@ -42,14 +81,10 @@
|
|||||||
},
|
},
|
||||||
"node_modules/@ioredis/commands": {
|
"node_modules/@ioredis/commands": {
|
||||||
"version": "1.3.1",
|
"version": "1.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.3.1.tgz",
|
|
||||||
"integrity": "sha512-bYtU8avhGIcje3IhvF9aSjsa5URMZBHnwKtOvXsT4sfYy9gppW11gLPT/9oNqlJZD47yPKveQFTAFWpHjKvUoQ==",
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@redis/bloom": {
|
"node_modules/@redis/bloom": {
|
||||||
"version": "5.8.2",
|
"version": "5.8.2",
|
||||||
"resolved": "https://registry.npmjs.org/@redis/bloom/-/bloom-5.8.2.tgz",
|
|
||||||
"integrity": "sha512-855DR0ChetZLarblio5eM0yLwxA9Dqq50t8StXKp5bAtLT0G+rZ+eRzzqxl37sPqQKjUudSYypz55o6nNhbz0A==",
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 18"
|
"node": ">= 18"
|
||||||
@@ -60,8 +95,6 @@
|
|||||||
},
|
},
|
||||||
"node_modules/@redis/client": {
|
"node_modules/@redis/client": {
|
||||||
"version": "5.8.2",
|
"version": "5.8.2",
|
||||||
"resolved": "https://registry.npmjs.org/@redis/client/-/client-5.8.2.tgz",
|
|
||||||
"integrity": "sha512-WtMScno3+eBpTac1Uav2zugXEoXqaU23YznwvFgkPwBQVwEHTDgOG7uEAObtZ/Nyn8SmAMbqkEubJaMOvnqdsQ==",
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"cluster-key-slot": "1.1.2"
|
"cluster-key-slot": "1.1.2"
|
||||||
@@ -72,8 +105,6 @@
|
|||||||
},
|
},
|
||||||
"node_modules/@redis/json": {
|
"node_modules/@redis/json": {
|
||||||
"version": "5.8.2",
|
"version": "5.8.2",
|
||||||
"resolved": "https://registry.npmjs.org/@redis/json/-/json-5.8.2.tgz",
|
|
||||||
"integrity": "sha512-uxpVfas3I0LccBX9rIfDgJ0dBrUa3+0Gc8sEwmQQH0vHi7C1Rx1Qn8Nv1QWz5bohoeIXMICFZRcyDONvum2l/w==",
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 18"
|
"node": ">= 18"
|
||||||
@@ -84,8 +115,6 @@
|
|||||||
},
|
},
|
||||||
"node_modules/@redis/search": {
|
"node_modules/@redis/search": {
|
||||||
"version": "5.8.2",
|
"version": "5.8.2",
|
||||||
"resolved": "https://registry.npmjs.org/@redis/search/-/search-5.8.2.tgz",
|
|
||||||
"integrity": "sha512-cNv7HlgayavCBXqPXgaS97DRPVWFznuzsAmmuemi2TMCx5scwLiP50TeZvUS06h/MG96YNPe6A0Zt57yayfxwA==",
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 18"
|
"node": ">= 18"
|
||||||
@@ -96,8 +125,6 @@
|
|||||||
},
|
},
|
||||||
"node_modules/@redis/time-series": {
|
"node_modules/@redis/time-series": {
|
||||||
"version": "5.8.2",
|
"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",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 18"
|
"node": ">= 18"
|
||||||
@@ -106,10 +133,32 @@
|
|||||||
"@redis/client": "^5.8.2"
|
"@redis/client": "^5.8.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@suitecoffee/api": {
|
||||||
|
"resolved": "../../packages/api",
|
||||||
|
"link": true
|
||||||
|
},
|
||||||
|
"node_modules/@suitecoffee/db": {
|
||||||
|
"resolved": "../../packages/core/db",
|
||||||
|
"link": true
|
||||||
|
},
|
||||||
|
"node_modules/@suitecoffee/middlewares": {
|
||||||
|
"resolved": "../../packages/core/middlewares",
|
||||||
|
"link": true
|
||||||
|
},
|
||||||
|
"node_modules/@suitecoffee/redis": {
|
||||||
|
"resolved": "../../packages/core/redis",
|
||||||
|
"link": true
|
||||||
|
},
|
||||||
|
"node_modules/@suitecoffee/repositories": {
|
||||||
|
"resolved": "../../packages/core/repositories",
|
||||||
|
"link": true
|
||||||
|
},
|
||||||
|
"node_modules/@suitecoffee/scripts": {
|
||||||
|
"resolved": "../../packages/core/scripts",
|
||||||
|
"link": true
|
||||||
|
},
|
||||||
"node_modules/@types/body-parser": {
|
"node_modules/@types/body-parser": {
|
||||||
"version": "1.19.6",
|
"version": "1.19.6",
|
||||||
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
|
|
||||||
"integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==",
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/connect": "*",
|
"@types/connect": "*",
|
||||||
@@ -118,8 +167,6 @@
|
|||||||
},
|
},
|
||||||
"node_modules/@types/connect": {
|
"node_modules/@types/connect": {
|
||||||
"version": "3.4.38",
|
"version": "3.4.38",
|
||||||
"resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz",
|
|
||||||
"integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==",
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/node": "*"
|
"@types/node": "*"
|
||||||
@@ -127,8 +174,6 @@
|
|||||||
},
|
},
|
||||||
"node_modules/@types/express": {
|
"node_modules/@types/express": {
|
||||||
"version": "4.17.23",
|
"version": "4.17.23",
|
||||||
"resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.23.tgz",
|
|
||||||
"integrity": "sha512-Crp6WY9aTYP3qPi2wGDo9iUe/rceX01UMhnF1jmwDcKCFM6cx7YhGP/Mpr3y9AASpfHixIG0E6azCcL5OcDHsQ==",
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/body-parser": "*",
|
"@types/body-parser": "*",
|
||||||
@@ -139,8 +184,6 @@
|
|||||||
},
|
},
|
||||||
"node_modules/@types/express-serve-static-core": {
|
"node_modules/@types/express-serve-static-core": {
|
||||||
"version": "4.19.6",
|
"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",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/node": "*",
|
"@types/node": "*",
|
||||||
@@ -151,14 +194,10 @@
|
|||||||
},
|
},
|
||||||
"node_modules/@types/http-errors": {
|
"node_modules/@types/http-errors": {
|
||||||
"version": "2.0.5",
|
"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"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@types/jsonwebtoken": {
|
"node_modules/@types/jsonwebtoken": {
|
||||||
"version": "9.0.10",
|
"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",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/ms": "*",
|
"@types/ms": "*",
|
||||||
@@ -167,20 +206,14 @@
|
|||||||
},
|
},
|
||||||
"node_modules/@types/mime": {
|
"node_modules/@types/mime": {
|
||||||
"version": "1.3.5",
|
"version": "1.3.5",
|
||||||
"resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz",
|
|
||||||
"integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==",
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@types/ms": {
|
"node_modules/@types/ms": {
|
||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
|
|
||||||
"integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@types/node": {
|
"node_modules/@types/node": {
|
||||||
"version": "24.3.1",
|
"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",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~7.10.0"
|
"undici-types": "~7.10.0"
|
||||||
@@ -188,20 +221,14 @@
|
|||||||
},
|
},
|
||||||
"node_modules/@types/qs": {
|
"node_modules/@types/qs": {
|
||||||
"version": "6.14.0",
|
"version": "6.14.0",
|
||||||
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz",
|
|
||||||
"integrity": "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==",
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@types/range-parser": {
|
"node_modules/@types/range-parser": {
|
||||||
"version": "1.2.7",
|
"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"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@types/send": {
|
"node_modules/@types/send": {
|
||||||
"version": "0.17.5",
|
"version": "0.17.5",
|
||||||
"resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.5.tgz",
|
|
||||||
"integrity": "sha512-z6F2D3cOStZvuk2SaP6YrwkNO65iTZcwA2ZkSABegdkAh/lf+Aa/YQndZVfmEXT5vgAp6zv06VQ3ejSVjAny4w==",
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/mime": "^1",
|
"@types/mime": "^1",
|
||||||
@@ -210,8 +237,6 @@
|
|||||||
},
|
},
|
||||||
"node_modules/@types/serve-static": {
|
"node_modules/@types/serve-static": {
|
||||||
"version": "1.15.8",
|
"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",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/http-errors": "*",
|
"@types/http-errors": "*",
|
||||||
@@ -252,8 +277,6 @@
|
|||||||
},
|
},
|
||||||
"node_modules/basic-auth": {
|
"node_modules/basic-auth": {
|
||||||
"version": "2.0.1",
|
"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",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"safe-buffer": "5.1.2"
|
"safe-buffer": "5.1.2"
|
||||||
@@ -264,14 +287,10 @@
|
|||||||
},
|
},
|
||||||
"node_modules/basic-auth/node_modules/safe-buffer": {
|
"node_modules/basic-auth/node_modules/safe-buffer": {
|
||||||
"version": "5.1.2",
|
"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"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/bcrypt": {
|
"node_modules/bcrypt": {
|
||||||
"version": "6.0.0",
|
"version": "6.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-6.0.0.tgz",
|
|
||||||
"integrity": "sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg==",
|
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -333,8 +352,6 @@
|
|||||||
},
|
},
|
||||||
"node_modules/buffer-equal-constant-time": {
|
"node_modules/buffer-equal-constant-time": {
|
||||||
"version": "1.0.1",
|
"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"
|
"license": "BSD-3-Clause"
|
||||||
},
|
},
|
||||||
"node_modules/bytes": {
|
"node_modules/bytes": {
|
||||||
@@ -404,8 +421,6 @@
|
|||||||
},
|
},
|
||||||
"node_modules/cluster-key-slot": {
|
"node_modules/cluster-key-slot": {
|
||||||
"version": "1.1.2",
|
"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",
|
"license": "Apache-2.0",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
@@ -448,6 +463,8 @@
|
|||||||
},
|
},
|
||||||
"node_modules/cookie": {
|
"node_modules/cookie": {
|
||||||
"version": "0.7.2",
|
"version": "0.7.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
|
||||||
|
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
@@ -521,8 +538,6 @@
|
|||||||
},
|
},
|
||||||
"node_modules/data-uri-to-buffer": {
|
"node_modules/data-uri-to-buffer": {
|
||||||
"version": "4.0.1",
|
"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",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 12"
|
"node": ">= 12"
|
||||||
@@ -545,8 +560,6 @@
|
|||||||
},
|
},
|
||||||
"node_modules/denque": {
|
"node_modules/denque": {
|
||||||
"version": "2.1.0",
|
"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",
|
"license": "Apache-2.0",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10"
|
"node": ">=0.10"
|
||||||
@@ -583,8 +596,6 @@
|
|||||||
},
|
},
|
||||||
"node_modules/ecdsa-sig-formatter": {
|
"node_modules/ecdsa-sig-formatter": {
|
||||||
"version": "1.0.11",
|
"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",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"safe-buffer": "^5.0.1"
|
"safe-buffer": "^5.0.1"
|
||||||
@@ -713,14 +724,10 @@
|
|||||||
},
|
},
|
||||||
"node_modules/express-session/node_modules/cookie-signature": {
|
"node_modules/express-session/node_modules/cookie-signature": {
|
||||||
"version": "1.0.7",
|
"version": "1.0.7",
|
||||||
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
|
|
||||||
"integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/express-session/node_modules/debug": {
|
"node_modules/express-session/node_modules/debug": {
|
||||||
"version": "2.6.9",
|
"version": "2.6.9",
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
|
|
||||||
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ms": "2.0.0"
|
"ms": "2.0.0"
|
||||||
@@ -728,14 +735,10 @@
|
|||||||
},
|
},
|
||||||
"node_modules/express-session/node_modules/ms": {
|
"node_modules/express-session/node_modules/ms": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
|
|
||||||
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/fetch-blob": {
|
"node_modules/fetch-blob": {
|
||||||
"version": "3.2.0",
|
"version": "3.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz",
|
|
||||||
"integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==",
|
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "github",
|
"type": "github",
|
||||||
@@ -807,8 +810,6 @@
|
|||||||
},
|
},
|
||||||
"node_modules/formdata-polyfill": {
|
"node_modules/formdata-polyfill": {
|
||||||
"version": "4.0.10",
|
"version": "4.0.10",
|
||||||
"resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz",
|
|
||||||
"integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==",
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"fetch-blob": "^3.1.2"
|
"fetch-blob": "^3.1.2"
|
||||||
@@ -962,8 +963,6 @@
|
|||||||
},
|
},
|
||||||
"node_modules/ioredis": {
|
"node_modules/ioredis": {
|
||||||
"version": "5.7.0",
|
"version": "5.7.0",
|
||||||
"resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.7.0.tgz",
|
|
||||||
"integrity": "sha512-NUcA93i1lukyXU+riqEyPtSEkyFq8tX90uL659J+qpCZ3rEdViB/APC58oAhIh3+bJln2hzdlZbBZsGNrlsR8g==",
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ioredis/commands": "^1.3.0",
|
"@ioredis/commands": "^1.3.0",
|
||||||
@@ -1055,8 +1054,6 @@
|
|||||||
},
|
},
|
||||||
"node_modules/jose": {
|
"node_modules/jose": {
|
||||||
"version": "6.1.0",
|
"version": "6.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/jose/-/jose-6.1.0.tgz",
|
|
||||||
"integrity": "sha512-TTQJyoEoKcC1lscpVDCSsVgYzUDg/0Bt3WE//WiTPK6uOCQC2KZS4MpugbMWt/zyjkopgZoXhZuCi00gLudfUA==",
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/panva"
|
"url": "https://github.com/sponsors/panva"
|
||||||
@@ -1064,8 +1061,6 @@
|
|||||||
},
|
},
|
||||||
"node_modules/jsonwebtoken": {
|
"node_modules/jsonwebtoken": {
|
||||||
"version": "9.0.2",
|
"version": "9.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz",
|
|
||||||
"integrity": "sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==",
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"jws": "^3.2.2",
|
"jws": "^3.2.2",
|
||||||
@@ -1086,8 +1081,6 @@
|
|||||||
},
|
},
|
||||||
"node_modules/jwa": {
|
"node_modules/jwa": {
|
||||||
"version": "1.4.2",
|
"version": "1.4.2",
|
||||||
"resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.2.tgz",
|
|
||||||
"integrity": "sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw==",
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"buffer-equal-constant-time": "^1.0.1",
|
"buffer-equal-constant-time": "^1.0.1",
|
||||||
@@ -1097,8 +1090,6 @@
|
|||||||
},
|
},
|
||||||
"node_modules/jwks-rsa": {
|
"node_modules/jwks-rsa": {
|
||||||
"version": "3.2.0",
|
"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",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/express": "^4.17.20",
|
"@types/express": "^4.17.20",
|
||||||
@@ -1114,8 +1105,6 @@
|
|||||||
},
|
},
|
||||||
"node_modules/jwks-rsa/node_modules/jose": {
|
"node_modules/jwks-rsa/node_modules/jose": {
|
||||||
"version": "4.15.9",
|
"version": "4.15.9",
|
||||||
"resolved": "https://registry.npmjs.org/jose/-/jose-4.15.9.tgz",
|
|
||||||
"integrity": "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==",
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/panva"
|
"url": "https://github.com/sponsors/panva"
|
||||||
@@ -1123,8 +1112,6 @@
|
|||||||
},
|
},
|
||||||
"node_modules/jws": {
|
"node_modules/jws": {
|
||||||
"version": "3.2.2",
|
"version": "3.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz",
|
|
||||||
"integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==",
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"jwa": "^1.4.1",
|
"jwa": "^1.4.1",
|
||||||
@@ -1132,74 +1119,50 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/limiter": {
|
"node_modules/limiter": {
|
||||||
"version": "1.1.5",
|
"version": "1.1.5"
|
||||||
"resolved": "https://registry.npmjs.org/limiter/-/limiter-1.1.5.tgz",
|
|
||||||
"integrity": "sha512-FWWMIEOxz3GwUI4Ts/IvgVy6LPvoMPgjMdQ185nN6psJyBJ4yOpzqm695/h5umdLJg2vW3GR5iG11MAkR2AzJA=="
|
|
||||||
},
|
},
|
||||||
"node_modules/lodash.clonedeep": {
|
"node_modules/lodash.clonedeep": {
|
||||||
"version": "4.5.0",
|
"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"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/lodash.defaults": {
|
"node_modules/lodash.defaults": {
|
||||||
"version": "4.2.0",
|
"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"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/lodash.includes": {
|
"node_modules/lodash.includes": {
|
||||||
"version": "4.3.0",
|
"version": "4.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
|
|
||||||
"integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/lodash.isarguments": {
|
"node_modules/lodash.isarguments": {
|
||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz",
|
|
||||||
"integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==",
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/lodash.isboolean": {
|
"node_modules/lodash.isboolean": {
|
||||||
"version": "3.0.3",
|
"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"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/lodash.isinteger": {
|
"node_modules/lodash.isinteger": {
|
||||||
"version": "4.0.4",
|
"version": "4.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
|
|
||||||
"integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==",
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/lodash.isnumber": {
|
"node_modules/lodash.isnumber": {
|
||||||
"version": "3.0.3",
|
"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"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/lodash.isplainobject": {
|
"node_modules/lodash.isplainobject": {
|
||||||
"version": "4.0.6",
|
"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"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/lodash.isstring": {
|
"node_modules/lodash.isstring": {
|
||||||
"version": "4.0.1",
|
"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"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/lodash.once": {
|
"node_modules/lodash.once": {
|
||||||
"version": "4.1.1",
|
"version": "4.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
|
|
||||||
"integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/lru-cache": {
|
"node_modules/lru-cache": {
|
||||||
"version": "6.0.0",
|
"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",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"yallist": "^4.0.0"
|
"yallist": "^4.0.0"
|
||||||
@@ -1210,8 +1173,6 @@
|
|||||||
},
|
},
|
||||||
"node_modules/lru-memoizer": {
|
"node_modules/lru-memoizer": {
|
||||||
"version": "2.3.0",
|
"version": "2.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/lru-memoizer/-/lru-memoizer-2.3.0.tgz",
|
|
||||||
"integrity": "sha512-GXn7gyHAMhO13WSKrIiNfztwxodVsP8IoZ3XfrJV4yH2x0/OeTO/FIaAHTY5YekdGgW94njfuKmyyt1E0mR6Ug==",
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"lodash.clonedeep": "^4.5.0",
|
"lodash.clonedeep": "^4.5.0",
|
||||||
@@ -1242,6 +1203,21 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/mime": {
|
||||||
|
"version": "4.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/mime/-/mime-4.1.0.tgz",
|
||||||
|
"integrity": "sha512-X5ju04+cAzsojXKes0B/S4tcYtFAJ6tTMuSPBEn9CPGlrWr8Fiw7qYeLT0XyH80HSoAoqWCaz+MWKh22P7G1cw==",
|
||||||
|
"funding": [
|
||||||
|
"https://github.com/sponsors/broofa"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"mime": "bin/cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/mime-db": {
|
"node_modules/mime-db": {
|
||||||
"version": "1.54.0",
|
"version": "1.54.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
@@ -1272,8 +1248,6 @@
|
|||||||
},
|
},
|
||||||
"node_modules/morgan": {
|
"node_modules/morgan": {
|
||||||
"version": "1.10.1",
|
"version": "1.10.1",
|
||||||
"resolved": "https://registry.npmjs.org/morgan/-/morgan-1.10.1.tgz",
|
|
||||||
"integrity": "sha512-223dMRJtI/l25dJKWpgij2cMtywuG/WiUKXdvwfbhGKBhy1puASqXwFzmWZ7+K73vUPoR7SS2Qz2cI/g9MKw0A==",
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"basic-auth": "~2.0.1",
|
"basic-auth": "~2.0.1",
|
||||||
@@ -1288,8 +1262,6 @@
|
|||||||
},
|
},
|
||||||
"node_modules/morgan/node_modules/debug": {
|
"node_modules/morgan/node_modules/debug": {
|
||||||
"version": "2.6.9",
|
"version": "2.6.9",
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
|
|
||||||
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ms": "2.0.0"
|
"ms": "2.0.0"
|
||||||
@@ -1297,14 +1269,10 @@
|
|||||||
},
|
},
|
||||||
"node_modules/morgan/node_modules/ms": {
|
"node_modules/morgan/node_modules/ms": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
|
|
||||||
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/morgan/node_modules/on-finished": {
|
"node_modules/morgan/node_modules/on-finished": {
|
||||||
"version": "2.3.0",
|
"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",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ee-first": "1.1.1"
|
"ee-first": "1.1.1"
|
||||||
@@ -1326,18 +1294,22 @@
|
|||||||
},
|
},
|
||||||
"node_modules/node-addon-api": {
|
"node_modules/node-addon-api": {
|
||||||
"version": "8.5.0",
|
"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",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^18 || ^20 || >= 21"
|
"node": "^18 || ^20 || >= 21"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/node-appwrite": {
|
||||||
|
"version": "20.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/node-appwrite/-/node-appwrite-20.2.1.tgz",
|
||||||
|
"integrity": "sha512-RweIh+3RHjprsxhWaJzcQr/UDMBMsZCma50TIJ9t3onVgs5jAT9aqFnsMlaaC9QZn1sXpPUQV90W6uvtm64DnQ==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"node-fetch-native-with-agent": "1.7.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/node-domexception": {
|
"node_modules/node-domexception": {
|
||||||
"version": "1.0.0",
|
"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": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "github",
|
"type": "github",
|
||||||
@@ -1355,8 +1327,6 @@
|
|||||||
},
|
},
|
||||||
"node_modules/node-fetch": {
|
"node_modules/node-fetch": {
|
||||||
"version": "3.3.2",
|
"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",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"data-uri-to-buffer": "^4.0.0",
|
"data-uri-to-buffer": "^4.0.0",
|
||||||
@@ -1371,10 +1341,14 @@
|
|||||||
"url": "https://opencollective.com/node-fetch"
|
"url": "https://opencollective.com/node-fetch"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/node-fetch-native-with-agent": {
|
||||||
|
"version": "1.7.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/node-fetch-native-with-agent/-/node-fetch-native-with-agent-1.7.2.tgz",
|
||||||
|
"integrity": "sha512-5MaOOCuJEvcckoz7/tjdx1M6OusOY6Xc5f459IaruGStWnKzlI1qpNgaAwmn4LmFYcsSlj+jBMk84wmmRxfk5g==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/node-gyp-build": {
|
"node_modules/node-gyp-build": {
|
||||||
"version": "4.8.4",
|
"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",
|
"license": "MIT",
|
||||||
"bin": {
|
"bin": {
|
||||||
"node-gyp-build": "bin.js",
|
"node-gyp-build": "bin.js",
|
||||||
@@ -1446,8 +1420,6 @@
|
|||||||
},
|
},
|
||||||
"node_modules/on-headers": {
|
"node_modules/on-headers": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz",
|
|
||||||
"integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==",
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
@@ -1484,6 +1456,8 @@
|
|||||||
},
|
},
|
||||||
"node_modules/pg": {
|
"node_modules/pg": {
|
||||||
"version": "8.16.3",
|
"version": "8.16.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz",
|
||||||
|
"integrity": "sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"pg-connection-string": "^2.9.1",
|
"pg-connection-string": "^2.9.1",
|
||||||
@@ -1509,11 +1483,15 @@
|
|||||||
},
|
},
|
||||||
"node_modules/pg-cloudflare": {
|
"node_modules/pg-cloudflare": {
|
||||||
"version": "1.2.7",
|
"version": "1.2.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.2.7.tgz",
|
||||||
|
"integrity": "sha512-YgCtzMH0ptvZJslLM1ffsY4EuGaU0cx4XSdXLRFae8bPP4dS5xL1tNB3k2o/N64cHJpwU7dxKli/nZ2lUa5fLg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true
|
"optional": true
|
||||||
},
|
},
|
||||||
"node_modules/pg-connection-string": {
|
"node_modules/pg-connection-string": {
|
||||||
"version": "2.9.1",
|
"version": "2.9.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.9.1.tgz",
|
||||||
|
"integrity": "sha512-nkc6NpDcvPVpZXxrreI/FOtX3XemeLl8E0qFr6F2Lrm/I8WOnaWNhIPK2Z7OHpw7gh5XJThi6j6ppgNoaT1w4w==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/pg-format": {
|
"node_modules/pg-format": {
|
||||||
@@ -1525,6 +1503,8 @@
|
|||||||
},
|
},
|
||||||
"node_modules/pg-int8": {
|
"node_modules/pg-int8": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=4.0.0"
|
"node": ">=4.0.0"
|
||||||
@@ -1532,6 +1512,8 @@
|
|||||||
},
|
},
|
||||||
"node_modules/pg-pool": {
|
"node_modules/pg-pool": {
|
||||||
"version": "3.10.1",
|
"version": "3.10.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.10.1.tgz",
|
||||||
|
"integrity": "sha512-Tu8jMlcX+9d8+QVzKIvM/uJtp07PKr82IUOYEphaWcoBhIYkoHpLXN3qO59nAI11ripznDsEzEv8nUxBVWajGg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"pg": ">=8.0"
|
"pg": ">=8.0"
|
||||||
@@ -1539,10 +1521,14 @@
|
|||||||
},
|
},
|
||||||
"node_modules/pg-protocol": {
|
"node_modules/pg-protocol": {
|
||||||
"version": "1.10.3",
|
"version": "1.10.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.10.3.tgz",
|
||||||
|
"integrity": "sha512-6DIBgBQaTKDJyxnXaLiLR8wBpQQcGWuAESkRBX/t6OwA8YsqP+iVSiond2EDy6Y/dsGk8rh/jtax3js5NeV7JQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/pg-types": {
|
"node_modules/pg-types": {
|
||||||
"version": "2.2.0",
|
"version": "2.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
|
||||||
|
"integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"pg-int8": "1.0.1",
|
"pg-int8": "1.0.1",
|
||||||
@@ -1557,6 +1543,8 @@
|
|||||||
},
|
},
|
||||||
"node_modules/pgpass": {
|
"node_modules/pgpass": {
|
||||||
"version": "1.0.5",
|
"version": "1.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
|
||||||
|
"integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"split2": "^4.1.0"
|
"split2": "^4.1.0"
|
||||||
@@ -1579,6 +1567,8 @@
|
|||||||
},
|
},
|
||||||
"node_modules/postgres-array": {
|
"node_modules/postgres-array": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=4"
|
"node": ">=4"
|
||||||
@@ -1586,6 +1576,8 @@
|
|||||||
},
|
},
|
||||||
"node_modules/postgres-bytea": {
|
"node_modules/postgres-bytea": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-xy3pmLuQqRBZBXDULy7KbaitYqLcmxigw14Q5sj8QBVLqEwXfeybIKVWiqAXTlcvdvb0+xkOtDbfQMOf4lST1w==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
@@ -1593,6 +1585,8 @@
|
|||||||
},
|
},
|
||||||
"node_modules/postgres-date": {
|
"node_modules/postgres-date": {
|
||||||
"version": "1.0.7",
|
"version": "1.0.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
|
||||||
|
"integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
@@ -1600,6 +1594,8 @@
|
|||||||
},
|
},
|
||||||
"node_modules/postgres-interval": {
|
"node_modules/postgres-interval": {
|
||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"xtend": "^4.0.0"
|
"xtend": "^4.0.0"
|
||||||
@@ -1639,8 +1635,6 @@
|
|||||||
},
|
},
|
||||||
"node_modules/random-bytes": {
|
"node_modules/random-bytes": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/random-bytes/-/random-bytes-1.0.0.tgz",
|
|
||||||
"integrity": "sha512-iv7LhNVO047HzYR3InF6pUcUsPQiHTM1Qal51DcGSuZFBil1aBBWG5eHPNek7bvILMaYJ/8RU1e8w1AMdHmLQQ==",
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
@@ -1679,8 +1673,6 @@
|
|||||||
},
|
},
|
||||||
"node_modules/redis": {
|
"node_modules/redis": {
|
||||||
"version": "5.8.2",
|
"version": "5.8.2",
|
||||||
"resolved": "https://registry.npmjs.org/redis/-/redis-5.8.2.tgz",
|
|
||||||
"integrity": "sha512-31vunZj07++Y1vcFGcnNWEf5jPoTkGARgfWI4+Tk55vdwHxhAvug8VEtW7Cx+/h47NuJTEg/JL77zAwC6E0OeA==",
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@redis/bloom": "5.8.2",
|
"@redis/bloom": "5.8.2",
|
||||||
@@ -1695,8 +1687,6 @@
|
|||||||
},
|
},
|
||||||
"node_modules/redis-errors": {
|
"node_modules/redis-errors": {
|
||||||
"version": "1.2.0",
|
"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",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=4"
|
"node": ">=4"
|
||||||
@@ -1704,8 +1694,6 @@
|
|||||||
},
|
},
|
||||||
"node_modules/redis-parser": {
|
"node_modules/redis-parser": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz",
|
|
||||||
"integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==",
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"redis-errors": "^1.0.0"
|
"redis-errors": "^1.0.0"
|
||||||
@@ -1914,6 +1902,8 @@
|
|||||||
},
|
},
|
||||||
"node_modules/split2": {
|
"node_modules/split2": {
|
||||||
"version": "4.2.0",
|
"version": "4.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
|
||||||
|
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 10.x"
|
"node": ">= 10.x"
|
||||||
@@ -1921,8 +1911,6 @@
|
|||||||
},
|
},
|
||||||
"node_modules/standard-as-callback": {
|
"node_modules/standard-as-callback": {
|
||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz",
|
|
||||||
"integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==",
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/statuses": {
|
"node_modules/statuses": {
|
||||||
@@ -1983,8 +1971,6 @@
|
|||||||
},
|
},
|
||||||
"node_modules/uid-safe": {
|
"node_modules/uid-safe": {
|
||||||
"version": "2.1.5",
|
"version": "2.1.5",
|
||||||
"resolved": "https://registry.npmjs.org/uid-safe/-/uid-safe-2.1.5.tgz",
|
|
||||||
"integrity": "sha512-KPHm4VL5dDXKz01UuEd88Df+KzynaohSL9fBh096KWAxSKZQDI2uBrVqtvRM4rwrIrRRKsdLNML/lnaaVSRioA==",
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"random-bytes": "~1.0.0"
|
"random-bytes": "~1.0.0"
|
||||||
@@ -2000,8 +1986,6 @@
|
|||||||
},
|
},
|
||||||
"node_modules/undici-types": {
|
"node_modules/undici-types": {
|
||||||
"version": "7.10.0",
|
"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"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/unpipe": {
|
"node_modules/unpipe": {
|
||||||
@@ -2020,8 +2004,6 @@
|
|||||||
},
|
},
|
||||||
"node_modules/web-streams-polyfill": {
|
"node_modules/web-streams-polyfill": {
|
||||||
"version": "3.3.3",
|
"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",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 8"
|
"node": ">= 8"
|
||||||
@@ -2047,6 +2029,8 @@
|
|||||||
},
|
},
|
||||||
"node_modules/xtend": {
|
"node_modules/xtend": {
|
||||||
"version": "4.0.2",
|
"version": "4.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
|
||||||
|
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.4"
|
"node": ">=0.4"
|
||||||
@@ -2054,8 +2038,6 @@
|
|||||||
},
|
},
|
||||||
"node_modules/yallist": {
|
"node_modules/yallist": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
|
|
||||||
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
|
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
{
|
{
|
||||||
"name": "aplication",
|
"name": "aplication",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"main": "src/index.js",
|
"main": "src/index.mjs",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "NODE_ENV=production node ./src/index.js",
|
"start": "NODE_ENV=production node ./src/index.mjs",
|
||||||
"dev": "NODE_ENV=development npx nodemon ./src/index.js",
|
"dev": "NODE_ENV=development npx nodemon ./src/index.mjs",
|
||||||
"test": "NODE_ENV=stage node ./src/index.js"
|
"test": "NODE_ENV=stage node ./src/index.mjs"
|
||||||
},
|
},
|
||||||
"author": "Mateo Saldain",
|
"author": "Mateo Saldain",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
@@ -15,6 +15,15 @@
|
|||||||
"nodemon": "^3.1.10"
|
"nodemon": "^3.1.10"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|
||||||
|
"@suitecoffee/scripts": "file:../../packages/core/scripts",
|
||||||
|
"@suitecoffee/db": "file:../../packages/core/db",
|
||||||
|
"@suitecoffee/redis": "file:../../packages/core/redis",
|
||||||
|
"@suitecoffee/middlewares": "file:../../packages/core/middlewares",
|
||||||
|
|
||||||
|
"@suitecoffee/api": "file:../../packages/api/",
|
||||||
|
"@suitecoffee/repositories": "file:../../packages/core/repositories",
|
||||||
|
|
||||||
"bcrypt": "^6.0.0",
|
"bcrypt": "^6.0.0",
|
||||||
"chalk": "^5.6.0",
|
"chalk": "^5.6.0",
|
||||||
"connect-redis": "^9.0.0",
|
"connect-redis": "^9.0.0",
|
||||||
@@ -29,13 +38,16 @@
|
|||||||
"jose": "^6.1.0",
|
"jose": "^6.1.0",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"jwks-rsa": "^3.2.0",
|
"jwks-rsa": "^3.2.0",
|
||||||
|
"mime": "^4.1.0",
|
||||||
"morgan": "^1.10.1",
|
"morgan": "^1.10.1",
|
||||||
|
"node-appwrite": "^20.2.1",
|
||||||
"node-fetch": "^3.3.2",
|
"node-fetch": "^3.3.2",
|
||||||
"pg": "^8.16.3",
|
"pg": "^8.16.3",
|
||||||
"pg-format": "^1.0.4",
|
"pg-format": "^1.0.4",
|
||||||
"redis": "^5.8.2",
|
"redis": "^5.8.2",
|
||||||
"serve-favicon": "^2.5.1"
|
"serve-favicon": "^2.5.1"
|
||||||
},
|
},
|
||||||
|
"imports": { },
|
||||||
"keywords": [],
|
"keywords": [],
|
||||||
"description": ""
|
"description": ""
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,181 +0,0 @@
|
|||||||
// // ----------------------------------------------------------
|
|
||||||
// // 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); }
|
|
||||||
});
|
|
||||||
@@ -1,230 +0,0 @@
|
|||||||
// // 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
|
|
||||||
// });
|
|
||||||
// }
|
|
||||||
// });
|
|
||||||
@@ -1,377 +0,0 @@
|
|||||||
// // services/app/src/index.js
|
|
||||||
// // ------------------------------------------------------------
|
|
||||||
// // SuiteCoffee — Servicio APP (UI + APIs negocio)
|
|
||||||
// // - Vistas EJS en ./views (dashboard.ejs, comandas.ejs, etc.)
|
|
||||||
// // - Sesión compartida con AUTH (cookie: sc.sid, Redis)
|
|
||||||
// // ------------------------------------------------------------
|
|
||||||
import 'dotenv/config'; // Variables de entorno directamente
|
|
||||||
// import dotenv from 'dotenv';
|
|
||||||
import favicon from 'serve-favicon'; // Favicon
|
|
||||||
|
|
||||||
import cors from 'cors'; // Seguridad en solicitudes de orige
|
|
||||||
import { Pool } from 'pg'; // Controlador node-postgres
|
|
||||||
import path from 'node:path'; // Rutas del servidor
|
|
||||||
import { fileURLToPath } from 'url'; // Converts a file:// URL string or URL object into a platform-specific file
|
|
||||||
|
|
||||||
import expressLayouts from 'express-ejs-layouts';
|
|
||||||
import express from 'express'; // Framework para enderizado de apps Web
|
|
||||||
import { jwtVerify, createRemoteJWKSet } from "jose";
|
|
||||||
|
|
||||||
import cookieParser from 'cookie-parser';
|
|
||||||
|
|
||||||
import { loadColumns, loadForeignKeys, loadPrimaryKey, pickLabelColumn } from "./utilities/cargaEnVista.js";
|
|
||||||
|
|
||||||
import { createRedisSession } from "../shared/middlewares/redisConnect.js";
|
|
||||||
// // ----------------------------------------------------------
|
|
||||||
// // Utilidades
|
|
||||||
// // ----------------------------------------------------------
|
|
||||||
|
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
|
||||||
const __dirname = path.dirname(__filename);
|
|
||||||
// const url = v => !v ? "" : (v.startsWith("http") ? v : `/img/productos/${v}`);
|
|
||||||
// const VALID_IDENT = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
|
|
||||||
|
|
||||||
// // Identificadores SQL -> comillas dobles y escape correcto
|
|
||||||
// const q = (s) => `"${String(s).replace(/"/g, '""')}"`;
|
|
||||||
// const qi = (ident) => `"${String(ident).replace(/"/g, '""')}"`;
|
|
||||||
// const CLEAN_HEX = (s) => (String(s || '').toLowerCase().replace(/[^0-9a-f]/g, '') || null);
|
|
||||||
|
|
||||||
// Función para verificar que ciertas variables de entorno estén definida
|
|
||||||
function checkRequiredEnvVars(...requiredKeys) {
|
|
||||||
const missingKeys = requiredKeys.filter((key) => !process.env[key]); // Filtramos las que NO existen en process.env
|
|
||||||
if (missingKeys.length > 0) { // Si falta alguna, mostramos una advertencia
|
|
||||||
console.warn(
|
|
||||||
`[APP] No se encontraron las siguientes variables de entorno: ${missingKeys.join(', ')}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ¿Está permitida la tabla?
|
|
||||||
function ensureTable(name) {
|
|
||||||
const t = String(name || '').toLowerCase();
|
|
||||||
if (!ALLOWED_TABLES.includes(t)) throw new Error(`Tabla ${t} no permitida`);
|
|
||||||
return t;
|
|
||||||
}
|
|
||||||
|
|
||||||
//
|
|
||||||
async function getClient() {
|
|
||||||
const client = await mainPool.connect();
|
|
||||||
return client;
|
|
||||||
}
|
|
||||||
|
|
||||||
// -----------------------------------------------------------------------------
|
|
||||||
// Validación de entorno mínimo (ajusta nombres si difieren)
|
|
||||||
// -----------------------------------------------------------------------------
|
|
||||||
checkRequiredEnvVars(
|
|
||||||
// Sesión
|
|
||||||
'SESSION_SECRET', 'REDIS_URL',
|
|
||||||
// DB principal
|
|
||||||
'DB_HOST', 'DB_NAME', 'DB_USER', 'DB_PASS',
|
|
||||||
// DB de tenants
|
|
||||||
'TENANTS_HOST', 'TENANTS_DB', 'TENANTS_USER', 'TENANTS_PASS'
|
|
||||||
);
|
|
||||||
|
|
||||||
// ----------------------------------------------------------
|
|
||||||
// App
|
|
||||||
// ----------------------------------------------------------
|
|
||||||
const app = express();
|
|
||||||
app.set('trust proxy', Number(process.env.TRUST_PROXY_HOPS || 2));
|
|
||||||
app.disable("x-powered-by");
|
|
||||||
app.use(cors({ origin: true, credentials: true }));
|
|
||||||
app.use(express.json());
|
|
||||||
app.use(express.json({ limit: '1mb' }));
|
|
||||||
app.use(express.urlencoded({ extended: true }));
|
|
||||||
app.use(express.static(path.join(__dirname, 'pages')));
|
|
||||||
|
|
||||||
|
|
||||||
// ----------------------------------------------------------
|
|
||||||
// Motor de vistas EJS
|
|
||||||
// ----------------------------------------------------------
|
|
||||||
app.set("views", path.join(__dirname, "views"));
|
|
||||||
app.set("view engine", "ejs");
|
|
||||||
app.set("layout", "layouts/main");
|
|
||||||
app.use(expressLayouts);
|
|
||||||
app.use(cookieParser(process.env.SESSION_SECRET));
|
|
||||||
|
|
||||||
// Archivos estáticos que fuerzan la re-descarga de arhivos
|
|
||||||
app.use(express.static(path.join(__dirname, "public"), {
|
|
||||||
etag: false, maxAge: 0,
|
|
||||||
setHeaders: (res, path) => {
|
|
||||||
res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
|
|
||||||
app.use('/favicon', express.static(path.join(__dirname, 'public', 'favicon'), { maxAge: '1y' }));
|
|
||||||
app.use(favicon(path.join(__dirname, 'public', 'favicon', 'favicon.ico'), { maxAge: '1y' }));
|
|
||||||
|
|
||||||
// ----------------------------------------------------------
|
|
||||||
// Middleware para datos globales
|
|
||||||
// ----------------------------------------------------------
|
|
||||||
app.use((req, res, next) => {
|
|
||||||
res.locals.currentPath = req.path;
|
|
||||||
res.locals.pageTitle = "SuiteCoffee";
|
|
||||||
res.locals.pageId = "";
|
|
||||||
next();
|
|
||||||
});
|
|
||||||
|
|
||||||
// ----------------------------------------------------------
|
|
||||||
// Configuración de Pool principal a PostgreSQL
|
|
||||||
// ----------------------------------------------------------
|
|
||||||
const mainPool = new Pool({
|
|
||||||
host: process.env.DB_HOST || '',
|
|
||||||
database: process.env.DB_NAME || '',
|
|
||||||
port: Number(process.env.DB_PORT || 5432),
|
|
||||||
user: process.env.DB_USER || '',
|
|
||||||
password: process.env.DB_PASS || '',
|
|
||||||
// ssl: process.env.PGSSL === 'true' ? { rejectUnauthorized: false } : undefined,
|
|
||||||
max: -1,
|
|
||||||
idleTimeoutMillis: 30_000,
|
|
||||||
});
|
|
||||||
|
|
||||||
// ----------------------------------------------------------
|
|
||||||
// Configuración Pool de Tenants a PostgreSQL
|
|
||||||
// ----------------------------------------------------------
|
|
||||||
const tenantsPool = new Pool({
|
|
||||||
host: process.env.TENANTS_HOST,
|
|
||||||
database: process.env.TENANTS_DB,
|
|
||||||
port: Number(process.env.TENANTS_PORT || 5432),
|
|
||||||
user: process.env.TENANTS_USER,
|
|
||||||
password: process.env.TENANTS_PASS,
|
|
||||||
max: -1,
|
|
||||||
idleTimeoutMillis: 30_000,
|
|
||||||
});
|
|
||||||
|
|
||||||
// ----------------------------------------------------------
|
|
||||||
// Seguridad: Tablas permitidas
|
|
||||||
// ----------------------------------------------------------
|
|
||||||
|
|
||||||
// const ALLOWED_TABLES = [
|
|
||||||
// 'roles', 'usuarios', 'usua_roles',
|
|
||||||
// 'categorias', 'productos',
|
|
||||||
// 'clientes', 'mesas',
|
|
||||||
// 'comandas', 'deta_comandas',
|
|
||||||
// 'proveedores', 'compras', 'deta_comp_producto',
|
|
||||||
// 'mate_primas', 'deta_comp_materias',
|
|
||||||
// 'prov_producto', 'prov_mate_prima',
|
|
||||||
// 'receta_producto', 'asistencia_resumen_diario',
|
|
||||||
// 'asistencia_intervalo', 'vw_compras'
|
|
||||||
// ];
|
|
||||||
|
|
||||||
|
|
||||||
// -----------------------------------------------------------------------------
|
|
||||||
// Sesión (Redis) — misma cookie que AUTH
|
|
||||||
// -----------------------------------------------------------------------------
|
|
||||||
|
|
||||||
const PORT = process.env.PORT || 3030;
|
|
||||||
const ISSUER = process.env.AUTHENTIK_ISSUER?.replace(/\/?$/, "/");
|
|
||||||
const CLIENT_ID = process.env.OIDC_CLIENT_ID;
|
|
||||||
const SSO_ENTRY_URL = process.env.SSO_ENTRY_URL || "https://sso.suitecoffee.uy";
|
|
||||||
|
|
||||||
// 1) SIEMPRE montar sesión ANTES de las rutas
|
|
||||||
const { sessionMw, trustProxy } = await createRedisSession();
|
|
||||||
|
|
||||||
app.use(sessionMw);
|
|
||||||
|
|
||||||
const JWKS = createRemoteJWKSet(new URL(`${ISSUER}jwks/`));
|
|
||||||
|
|
||||||
async function verifyIdToken(idToken) {
|
|
||||||
const { payload } = await jwtVerify(idToken, JWKS, {
|
|
||||||
issuer: ISSUER.replace(/\/$/, ""),
|
|
||||||
audience: CLIENT_ID,
|
|
||||||
});
|
|
||||||
return payload;
|
|
||||||
}
|
|
||||||
|
|
||||||
function requireToken(req, res, next) {
|
|
||||||
const id = req.session?.tokens?.id_token; // <- defensivo
|
|
||||||
if (!id) return res.redirect(302, SSO_ENTRY_URL);
|
|
||||||
next();
|
|
||||||
}
|
|
||||||
|
|
||||||
app.get("/", requireToken, async (req, res) => {
|
|
||||||
try {
|
|
||||||
const idToken = req.session?.tokens?.id_token; // <- defensivo
|
|
||||||
if (!idToken) return res.redirect(302, SSO_ENTRY_URL);
|
|
||||||
const claims = await verifyIdToken(idToken);
|
|
||||||
const email = claims.email || claims.preferred_username || "sin-email";
|
|
||||||
res.json({ usuario: { email, sub: claims.sub } });
|
|
||||||
} catch (e) {
|
|
||||||
console.error("/ verificación ID token", e);
|
|
||||||
res.redirect(302, SSO_ENTRY_URL);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
// // -----------------------------------------------------------------------------
|
|
||||||
// // Comprobaciones de tenants en DB principal
|
|
||||||
// // -----------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// // Abre un client al DB de tenants y fija search_path al esquema del usuario
|
|
||||||
// async function withTenant(req, res, next) {
|
|
||||||
// try {
|
|
||||||
// const hex = CLEAN_HEX(req.session?.user?.tenant_uuid);
|
|
||||||
// if (!hex) return res.status(400).json({ error: 'tenant-missing' });
|
|
||||||
|
|
||||||
// const schema = `schema_tenant_${hex}`;
|
|
||||||
// const client = await tenantsPool.connect();
|
|
||||||
|
|
||||||
// // Fijar search_path para que las consultas apunten al esquema del tenant
|
|
||||||
// await client.query(`SET SESSION search_path TO ${qi(schema)}, public`);
|
|
||||||
|
|
||||||
// // Hacemos el client accesible para los handlers de routes.legacy.js
|
|
||||||
// req.pg = client;
|
|
||||||
|
|
||||||
// // Liberar el client al finalizar la respuesta
|
|
||||||
// const release = () => {
|
|
||||||
// try { client.release(); } catch {}
|
|
||||||
// };
|
|
||||||
// res.on('finish', release);
|
|
||||||
// res.on('close', release);
|
|
||||||
|
|
||||||
// next();
|
|
||||||
// } catch (e) {
|
|
||||||
// next(e);
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// // ----------------------------------------------------------
|
|
||||||
// // Rutas de UI
|
|
||||||
// // ----------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
// app.get("/inicio", (req, res) => {
|
|
||||||
// try {
|
|
||||||
// const safeUser = req.session?.user || null;
|
|
||||||
// const safeCookies = req.cookies || {};
|
|
||||||
// const safeSession = req.session ? JSON.parse(JSON.stringify(req.session)) : {};
|
|
||||||
// res.locals.pageTitle = "Inicio";
|
|
||||||
// res.locals.pageId = "inicio"; // <- importante
|
|
||||||
// return res.render('inicio', {
|
|
||||||
// user: safeUser,
|
|
||||||
// cookies: safeCookies,
|
|
||||||
// session: safeSession,
|
|
||||||
// });
|
|
||||||
// } catch (e) {
|
|
||||||
// next(e);
|
|
||||||
// }
|
|
||||||
// });
|
|
||||||
|
|
||||||
// app.get("/dashboard", requireToken,(req, res) => {
|
|
||||||
// res.locals.pageTitle = "Dashboard";
|
|
||||||
// res.locals.pageId = "dashboard"; // <- importante
|
|
||||||
// res.render("dashboard");
|
|
||||||
// });
|
|
||||||
|
|
||||||
// app.get("/comandas", requireToken,(req, res) => {
|
|
||||||
// res.locals.pageTitle = "Comandas";
|
|
||||||
// res.locals.pageId = "comandas"; // <- importante para el sidebar contextual
|
|
||||||
// res.render("comandas");
|
|
||||||
// });
|
|
||||||
|
|
||||||
// app.get("/estadoComandas", requireToken,(req, res) => {
|
|
||||||
// res.locals.pageTitle = "Estado de Comandas";
|
|
||||||
// res.locals.pageId = "estadoComandas";
|
|
||||||
// res.render("estadoComandas");
|
|
||||||
// });
|
|
||||||
|
|
||||||
// app.get("/productos", requireToken,(req, res) => {
|
|
||||||
// res.locals.pageTitle = "Productos";
|
|
||||||
// res.locals.pageId = "productos";
|
|
||||||
// res.render("productos");
|
|
||||||
// });
|
|
||||||
|
|
||||||
// app.get('/usuarios', requireToken,(req, res) => {
|
|
||||||
// res.locals.pageTitle = 'Usuarios';
|
|
||||||
// res.locals.pageId = 'usuarios';
|
|
||||||
// res.render('usuarios');
|
|
||||||
// });
|
|
||||||
|
|
||||||
// app.get('/reportes', requireToken,(req, res) => {
|
|
||||||
// res.locals.pageTitle = 'Reportes';
|
|
||||||
// res.locals.pageId = 'reportes';
|
|
||||||
// res.render('reportes');
|
|
||||||
// });
|
|
||||||
|
|
||||||
// app.get('/compras', requireToken,(req, res) => {
|
|
||||||
// res.locals.pageTitle = 'Compras';
|
|
||||||
// res.locals.pageId = 'compras';
|
|
||||||
// res.render('compras');
|
|
||||||
// });
|
|
||||||
|
|
||||||
// // Página para definir contraseña (el form envía al servicio AUTH)
|
|
||||||
// app.get('/set-password', (req, res) => {
|
|
||||||
// const pp = req.session?.pendingPassword;
|
|
||||||
// if (!pp) return req.session?.user ? res.redirect('/') : res.redirect('https://sso.suitecoffee.uy/if/flow/default-authentication-flow/');
|
|
||||||
|
|
||||||
// res.type('html').send(`
|
|
||||||
// <!doctype html><meta charset="utf-8">
|
|
||||||
// <title>SuiteCoffee · Definir contraseña</title>
|
|
||||||
// <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css">
|
|
||||||
// <div class="container py-5" style="max-width:520px;">
|
|
||||||
// <h2 class="mb-4">Definir contraseña</h2>
|
|
||||||
// <form method="post" action="/auth/password/set" class="vstack gap-3">
|
|
||||||
// <input class="form-control" type="password" name="password" placeholder="Nueva contraseña" minlength="8" required>
|
|
||||||
// <input class="form-control" type="password" name="password2" placeholder="Repetí la contraseña" minlength="8" required>
|
|
||||||
// <button class="btn btn-primary" type="submit">Guardar y continuar</button>
|
|
||||||
// <small class="text-muted">Luego te redirigiremos a iniciar sesión por SSO.</small>
|
|
||||||
// </form>
|
|
||||||
// </div>
|
|
||||||
// `);
|
|
||||||
// });
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// ----------------------------------------------------------
|
|
||||||
// Verificación de conexión
|
|
||||||
// ----------------------------------------------------------
|
|
||||||
async function verificarConexion() {
|
|
||||||
try {
|
|
||||||
console.log(`[APP] Comprobando accesibilidad a la db ${process.env.DB_NAME} del host ${process.env.DB_HOST} ...`);
|
|
||||||
const client = await mainPool.connect();
|
|
||||||
const { rows } = await client.query('SELECT NOW() AS ahora');
|
|
||||||
console.log(`\n[APP] Conexión con ${process.env.DB_NAME} OK. Hora DB:`, rows[0].ahora);
|
|
||||||
client.release();
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[APP] Error al conectar con la base de datos al iniciar:', error.message);
|
|
||||||
console.error('[APP] Revisar DB_HOST/USER/PASS/NAME, accesos de red y firewall.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// // -----------------------------------------------------------------------------
|
|
||||||
// // Healthcheck
|
|
||||||
// -----------------------------------------------------------------------------
|
|
||||||
app.get('/health', (_req, res) => res.status(200).json({ status: 'ok'}));
|
|
||||||
|
|
||||||
// -----------------------------------------------------------------------------
|
|
||||||
// 404 + Manejo de errores
|
|
||||||
// -----------------------------------------------------------------------------
|
|
||||||
app.use((req, res) => res.status(404).json({ error: 'Error 404, No se encontró la página', path: req.originalUrl }));
|
|
||||||
|
|
||||||
app.use((err, _req, res, _next) => {
|
|
||||||
console.error('[APP] ', err);
|
|
||||||
if (res.headersSent) return;
|
|
||||||
res.status(500).json({ error: '¡Oh! A ocurrido un error en el servidor app.', detail: err.stack || String(err) });
|
|
||||||
});
|
|
||||||
|
|
||||||
// ----------------------------------------------------------
|
|
||||||
// Inicio del servidor
|
|
||||||
// ----------------------------------------------------------
|
|
||||||
app.listen(3030, () => {
|
|
||||||
console.log(`[APP] SuiteCoffee corriendo en http://localhost:${3030}`);
|
|
||||||
verificarConexion();
|
|
||||||
});
|
|
||||||
|
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
// services/app/src/index.js
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
// SuiteCoffee — Aplicación Principal (Express)
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
|
||||||
|
import 'dotenv/config';
|
||||||
|
import favicon from 'serve-favicon'; // Favicon
|
||||||
|
import session from 'express-session';
|
||||||
|
import express from 'express'; // Framework para enderizado de apps Web
|
||||||
|
import expressLayouts from 'express-ejs-layouts';
|
||||||
|
import { RedisStore } from 'connect-redis';
|
||||||
|
|
||||||
|
import { checkRequiredEnvVars } from '@suitecoffee/scripts';
|
||||||
|
import { verificarConexionCore, verificarConexionTenants } from '@suitecoffee/db'; // dbCore y dbTenants desde paquete
|
||||||
|
import { redisAuthentik, verificarConexionRedisAuthentik} from '@suitecoffee/redis';
|
||||||
|
import { requireAuth, datosGlobales, tenantContext, resolveTenantFromCore } from '@suitecoffee/middlewares';
|
||||||
|
import { apiv1 } from '@suitecoffee/api'; // Rutas API v1
|
||||||
|
|
||||||
|
import expressPages from './views/routes.js'; // Rutas "/", "/dashboard", ...
|
||||||
|
import path from 'path';
|
||||||
|
import { fileURLToPath } from 'url'; // Converts a file:// URL string or URL object into a platform-specific file
|
||||||
|
import cookieParser from 'cookie-parser';
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = path.dirname(__filename);
|
||||||
|
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// Validación de entorno mínimo (ajusta nombres si difieren)
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
checkRequiredEnvVars(
|
||||||
|
'PORT', 'APP_BASE_URL',
|
||||||
|
'SESSION_SECRET', 'SESSION_NAME', 'AK_REDIS_URL',
|
||||||
|
'OIDC_CLIENT_ID', 'OIDC_REDIRECT_URI',
|
||||||
|
'OIDC_CONFIG_URL' // o 'OIDC_ISSUER'
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
// Variables del sistema
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
|
||||||
|
// De entorno
|
||||||
|
const PORT = process.env.PORT;
|
||||||
|
const SESSION_NAME = process.env.SESSION_NAME;
|
||||||
|
const SESSION_SECRET = process.env.SESSION_SECRET;
|
||||||
|
const COOKIE_DOMAIN = process.env.COOKIE_DOMAIN;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
// App + Motor de vistas EJS
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
const app = express();
|
||||||
|
app.disable("x-powered-by");
|
||||||
|
app.set('trust proxy', true);
|
||||||
|
app.set("views", path.join(__dirname, "views/pages"));
|
||||||
|
app.set("layout", path.join(__dirname, "views/layouts/main"));
|
||||||
|
// app.set("layout", "layouts/main");
|
||||||
|
app.set("view engine", "ejs");
|
||||||
|
app.use(favicon(path.join(__dirname, 'public', 'favicon', 'favicon.ico'), { maxAge: '1y' }));
|
||||||
|
|
||||||
|
app.use(express.json());
|
||||||
|
app.use(express.json({ limit: '1mb' }));
|
||||||
|
app.use(express.urlencoded({ extended: true }));
|
||||||
|
|
||||||
|
app.use(express.static(path.join(__dirname,"public"), { etag: false, maxAge: 0, setHeaders: (res, path) => { res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate'); }}));
|
||||||
|
app.use(expressLayouts);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// Redis
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
await redisAuthentik.connect();
|
||||||
|
const redisClient = redisAuthentik.getClient();
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// Cookies de sesión
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
app.use(cookieParser(SESSION_SECRET));
|
||||||
|
|
||||||
|
app.use(session({
|
||||||
|
name: SESSION_NAME,
|
||||||
|
store: new RedisStore({ client: redisClient, prefix: 'sess:' }),
|
||||||
|
secret: SESSION_SECRET,
|
||||||
|
resave: false,
|
||||||
|
saveUninitialized: false,
|
||||||
|
cookie: {
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: 'lax', // 'none' si necesitás third-party estricto
|
||||||
|
secure: process.env.NODE_ENV === 'production',
|
||||||
|
...(COOKIE_DOMAIN ? { domain: COOKIE_DOMAIN } : {}), // ✅ compatibilidad subdominios
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
// Renderizado de las páginas importadas desde '#pages' + configuración global
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
// app.use(expressPages); // Renderizado trae las paginas desde ./services/manso/src/routes/routes.js
|
||||||
|
// app.use(requireAuth({ redirectTo: '/auth/login' }), expressPages); // Renderizado trae las paginas desde ./services/manso/src/routes/routes.js
|
||||||
|
// app.use(requireAuth({ redirectTo: '/auth/login' }), tenantContext({ debug: true }), expressPages); // Renderizado trae las paginas desde ./services/manso/src/routes/routes.js
|
||||||
|
|
||||||
|
app.use(
|
||||||
|
requireAuth({ redirectTo: '/auth/login' }),
|
||||||
|
tenantContext({
|
||||||
|
debug: true,
|
||||||
|
resolveTenant: (req, sess) => resolveTenantFromCore(req, sess, { debug: true }),
|
||||||
|
// acceptStates: ['ready'] // (default) o ['ready','provisioning'] si querés permitir provisión
|
||||||
|
}),
|
||||||
|
expressPages
|
||||||
|
);
|
||||||
|
|
||||||
|
app.use(datosGlobales);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
// API v1
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
app.use("/api/v1", requireAuth({ redirectTo: '/auth/login' }), tenantContext(), apiv1);
|
||||||
|
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
// Inicio del servidor
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
|
||||||
|
app.listen(PORT, async () => {
|
||||||
|
console.log(`[APP] SuiteCoffee corriendo en http://localhost:${PORT}`);
|
||||||
|
await verificarConexionCore();
|
||||||
|
await verificarConexionTenants();
|
||||||
|
await verificarConexionRedisAuthentik();
|
||||||
|
});
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// Healthcheck
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
app.get('/health', (_req, res) => {
|
||||||
|
res.status(200).json({ status: 'ok'})
|
||||||
|
// console.log(`[APP] Saludable`)
|
||||||
|
});
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
// -----------------------------------------------------------------------------
|
|
||||||
// Middlewares de Auth/Tenant
|
|
||||||
// -----------------------------------------------------------------------------
|
|
||||||
|
|
||||||
export function requireAuth(req, res, next) {
|
|
||||||
const authHeader = req.headers["authorization"];
|
|
||||||
if (!authHeader) return res.status(401).send("Falta token");
|
|
||||||
|
|
||||||
const token = authHeader.split(" ")[1];
|
|
||||||
|
|
||||||
jwt.verify(token, getKey, { algorithms: ["RS256"] }, (err, decoded) => {
|
|
||||||
if (err) return res.status(403).send("Token inválido o vencido");
|
|
||||||
|
|
||||||
// Guardamos los claims del token en req.user
|
|
||||||
req.user = {
|
|
||||||
id: decoded.sub,
|
|
||||||
email: decoded.email,
|
|
||||||
username: decoded.preferred_username,
|
|
||||||
name: decoded.name,
|
|
||||||
roles: decoded.groups || []
|
|
||||||
};
|
|
||||||
|
|
||||||
next();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// export function exposeViewState(req, res, next) {
|
|
||||||
// res.locals.pageTitle = res.locals.pageTitle || '';
|
|
||||||
// res.locals.pageId = res.locals.pageId || '';
|
|
||||||
// res.locals.tenant_uuid = req.session?.tenant?.uuid || null;
|
|
||||||
// res.locals.ak_user_uuid = req.session?.tenant?.ak_user_uuid || null;
|
|
||||||
// // también pásalos como props al render
|
|
||||||
// res.locals.viewUser = req.session?.user || null;
|
|
||||||
// res.locals.viewCookies = req.cookies || {};
|
|
||||||
// res.locals.viewSession = req.session ? JSON.parse(JSON.stringify(req.session)) : {};
|
|
||||||
// next();
|
|
||||||
// }
|
|
||||||
@@ -0,0 +1,341 @@
|
|||||||
|
// services/app/src/public/scripts/comandas/index.mjs
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
// SuiteCoffee — Comandas (frontend script)
|
||||||
|
// - Resuelve el schema activo desde la sesión (/auth/debug/session)
|
||||||
|
// - Setea header X-Tenant-Schema en todos los fetch
|
||||||
|
// - Asegura envío de cookies (credentials:'same-origin')
|
||||||
|
// - Carga y cachea el catálogo de productos (descubrimiento de endpoint)
|
||||||
|
// - Expone helpers globales (jget/jpost/jput/jdel, getActiveSchema, productos)
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
/* =======================
|
||||||
|
* Utils
|
||||||
|
* ======================= */
|
||||||
|
const noDash = (v) => (v == null ? '' : String(v).replace(/-/g, ''));
|
||||||
|
const isJson = (res) => (res.headers.get('content-type') || '').includes('application/json');
|
||||||
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||||
|
const byId = (id) => document.getElementById(id);
|
||||||
|
|
||||||
|
/* =======================
|
||||||
|
* Tenant schema cache
|
||||||
|
* ======================= */
|
||||||
|
let SC_ACTIVE_SCHEMA = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lee la sesión desde /auth/debug/session y determina el esquema activo.
|
||||||
|
* Prioriza:
|
||||||
|
* 1) user.active_schema / user.activeSchema
|
||||||
|
* 2) membership de user.current_tenant / user.currentTenant
|
||||||
|
* 3) primer membership disponible
|
||||||
|
* Lanza Error si no puede determinarlo.
|
||||||
|
*/
|
||||||
|
async function getActiveSchema() {
|
||||||
|
if (SC_ACTIVE_SCHEMA) return SC_ACTIVE_SCHEMA;
|
||||||
|
|
||||||
|
let ses = null;
|
||||||
|
try {
|
||||||
|
const res = await fetch('/auth/debug/session', { credentials: 'same-origin' });
|
||||||
|
ses = res.ok ? await res.json() : null;
|
||||||
|
} catch (_) { /* ignore */ }
|
||||||
|
|
||||||
|
const u = ses?.user ?? null;
|
||||||
|
const memberships = Array.isArray(u?.memberships) ? u.memberships : [];
|
||||||
|
|
||||||
|
// 1) Esquema activo directo
|
||||||
|
let schema =
|
||||||
|
u?.active_schema ||
|
||||||
|
u?.activeSchema ||
|
||||||
|
// 2) Membership del current tenant
|
||||||
|
memberships.find(
|
||||||
|
(m) =>
|
||||||
|
String(m.tenant_id) === String(u?.current_tenant) ||
|
||||||
|
String(m.tenantId) === String(u?.currentTenant)
|
||||||
|
)?.schema_name ||
|
||||||
|
// 3) Primer membership
|
||||||
|
memberships[0]?.schema_name ||
|
||||||
|
null;
|
||||||
|
|
||||||
|
if (!schema) throw new Error('Sesión inválida o tenant no seleccionado');
|
||||||
|
|
||||||
|
SC_ACTIVE_SCHEMA = schema;
|
||||||
|
return SC_ACTIVE_SCHEMA;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* =======================
|
||||||
|
* Fetch helpers (con schema)
|
||||||
|
* ======================= */
|
||||||
|
async function buildHeaders(extra = {}) {
|
||||||
|
const schema = await getActiveSchema();
|
||||||
|
return { Accept: 'application/json', 'X-Tenant-Schema': schema, ...extra };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function jfetch(url, opts = {}) {
|
||||||
|
const headers = await buildHeaders(opts.headers || {});
|
||||||
|
const res = await fetch(url, {
|
||||||
|
...opts,
|
||||||
|
headers,
|
||||||
|
credentials: 'same-origin', // imprescindible para que viajen las cookies
|
||||||
|
});
|
||||||
|
|
||||||
|
let payload = null;
|
||||||
|
try {
|
||||||
|
payload = isJson(res) ? await res.json() : await res.text();
|
||||||
|
} catch (_) { /* ignore parse errors */ }
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const msg = (payload && payload.error) || `${res.status} ${res.statusText}`;
|
||||||
|
throw new Error(msg);
|
||||||
|
}
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function jget(url) { return jfetch(url, { method: 'GET' }); }
|
||||||
|
async function jpost(url, body) {
|
||||||
|
return jfetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body ?? {}) });
|
||||||
|
}
|
||||||
|
async function jput(url, body) {
|
||||||
|
return jfetch(url, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body ?? {}) });
|
||||||
|
}
|
||||||
|
async function jdel(url, body) {
|
||||||
|
return jfetch(url, { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, body: body ? JSON.stringify(body) : undefined });
|
||||||
|
}
|
||||||
|
|
||||||
|
/* =======================
|
||||||
|
* Productos (catálogo)
|
||||||
|
* - Descubre endpoint
|
||||||
|
* - Cachea y normaliza
|
||||||
|
* ======================= */
|
||||||
|
const productos = (() => {
|
||||||
|
// Posibles endpoints (se prueban en orden)
|
||||||
|
const CANDIDATES = [
|
||||||
|
'/api/v1/productos?limit=1000',
|
||||||
|
'/api/v1/productos',
|
||||||
|
'/api/v1/catalogo?limit=1000',
|
||||||
|
'/api/v1/catalogo',
|
||||||
|
'/api/v1/items?limit=1000',
|
||||||
|
'/api/v1/items',
|
||||||
|
];
|
||||||
|
|
||||||
|
// Estado en memoria
|
||||||
|
let discoveredEndpoint = null;
|
||||||
|
let cache = /** @type {Array| null} */(null);
|
||||||
|
let lastLoadedAt = null;
|
||||||
|
|
||||||
|
// Normalización a { id, nombre, precio, categoria?, activo? }
|
||||||
|
function normalizeOne(p) {
|
||||||
|
const id =
|
||||||
|
p.id ?? p.id_producto ?? p.producto_id ?? p.productId ?? p.pk ?? null;
|
||||||
|
|
||||||
|
const nombre =
|
||||||
|
p.nombre ?? p.producto_nombre ?? p.name ?? p.titulo ?? p.title ?? '';
|
||||||
|
|
||||||
|
const precio =
|
||||||
|
p.precio ?? p.price ?? p.pre_unitario ?? p.pu ?? p.monto ?? 0;
|
||||||
|
|
||||||
|
const categoria =
|
||||||
|
p.categoria ?? p.category ?? p.nombre_categoria ?? null;
|
||||||
|
|
||||||
|
const activo =
|
||||||
|
p.activo ?? p.enabled ?? p.habilitado ?? true;
|
||||||
|
|
||||||
|
return { id, nombre, precio, categoria, activo, raw: p };
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePayload(data) {
|
||||||
|
if (Array.isArray(data)) return data.map(normalizeOne);
|
||||||
|
// objetos comunes: { items: [...] } | { rows: [...] } | { data: [...] }
|
||||||
|
const arr = data?.items || data?.rows || data?.data || data?.productos || null;
|
||||||
|
return Array.isArray(arr) ? arr.map(normalizeOne) : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function discoverEndpoint() {
|
||||||
|
// si ya lo descubrimos, reusar
|
||||||
|
if (discoveredEndpoint) return discoveredEndpoint;
|
||||||
|
|
||||||
|
for (const url of CANDIDATES) {
|
||||||
|
try {
|
||||||
|
const data = await jget(url);
|
||||||
|
const list = normalizePayload(data);
|
||||||
|
if (list.length >= 0) { // aceptar 0+ items (catálogo vacío)
|
||||||
|
discoveredEndpoint = url;
|
||||||
|
cache = list;
|
||||||
|
lastLoadedAt = new Date();
|
||||||
|
return discoveredEndpoint;
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
// probar el siguiente
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error('No se pudo descubrir el endpoint de productos');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureLoaded({ force = false } = {}) {
|
||||||
|
await discoverEndpoint();
|
||||||
|
if (!force && cache && Array.isArray(cache)) return cache;
|
||||||
|
|
||||||
|
// recargar desde endpoint descubierto
|
||||||
|
const base = discoveredEndpoint.split('?')[0];
|
||||||
|
const url = base.includes('?') ? base : `${base}?limit=1000`;
|
||||||
|
const data = await jget(url);
|
||||||
|
cache = normalizePayload(data);
|
||||||
|
lastLoadedAt = new Date();
|
||||||
|
return cache;
|
||||||
|
}
|
||||||
|
|
||||||
|
function all() {
|
||||||
|
return Array.isArray(cache) ? cache.slice() : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function search(q) {
|
||||||
|
const term = String(q || '').trim().toLowerCase();
|
||||||
|
if (!term) return all();
|
||||||
|
return all().filter(p =>
|
||||||
|
String(p.nombre || '').toLowerCase().includes(term) ||
|
||||||
|
String(p.categoria || '').toLowerCase().includes(term)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getById(id) {
|
||||||
|
return all().find(p => String(p.id) === String(id)) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Renderización opcional si existe algún selector en la página
|
||||||
|
function renderSelect({ selectorList = ['#productos', '#selProducto', 'select[name="producto"]'] } = {}) {
|
||||||
|
const el = selectorList.map((s) => document.querySelector(s)).find(Boolean);
|
||||||
|
if (!el) return; // nada que renderizar
|
||||||
|
|
||||||
|
const list = all();
|
||||||
|
if (el.tagName === 'SELECT') {
|
||||||
|
el.innerHTML = ''; // limpiar
|
||||||
|
const opt0 = document.createElement('option');
|
||||||
|
opt0.value = ''; opt0.textContent = '— Seleccionar producto —';
|
||||||
|
el.appendChild(opt0);
|
||||||
|
|
||||||
|
for (const p of list) {
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = p.id;
|
||||||
|
opt.textContent = `${p.nombre} — ${formatPrecio(p.precio)}`;
|
||||||
|
el.appendChild(opt);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// contenedor genérico (lista)
|
||||||
|
el.innerHTML = '';
|
||||||
|
const ul = document.createElement('ul');
|
||||||
|
ul.className = 'lista-productos';
|
||||||
|
for (const p of list) {
|
||||||
|
const li = document.createElement('li');
|
||||||
|
li.textContent = `${p.nombre} — ${formatPrecio(p.precio)}`;
|
||||||
|
li.dataset.productId = p.id;
|
||||||
|
ul.appendChild(li);
|
||||||
|
}
|
||||||
|
el.appendChild(ul);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindSearch({ inputSelectors = ['#buscarProducto', 'input[name="buscar_producto"]'], selectorList } = {}) {
|
||||||
|
const input = inputSelectors.map((s) => document.querySelector(s)).find(Boolean);
|
||||||
|
if (!input) return;
|
||||||
|
|
||||||
|
input.addEventListener('input', () => {
|
||||||
|
const q = input.value;
|
||||||
|
const list = search(q);
|
||||||
|
// re-render mínimo para SELECTs
|
||||||
|
const el = selectorList?.map((s) => document.querySelector(s)).find(Boolean)
|
||||||
|
|| document.querySelector('#selProducto')
|
||||||
|
|| document.querySelector('#productos');
|
||||||
|
if (!el) return;
|
||||||
|
|
||||||
|
if (el.tagName === 'SELECT') {
|
||||||
|
el.innerHTML = '';
|
||||||
|
const opt0 = document.createElement('option');
|
||||||
|
opt0.value = ''; opt0.textContent = '— Seleccionar producto —';
|
||||||
|
el.appendChild(opt0);
|
||||||
|
|
||||||
|
for (const p of list) {
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = p.id;
|
||||||
|
opt.textContent = `${p.nombre} — ${formatPrecio(p.precio)}`;
|
||||||
|
el.appendChild(opt);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
el.innerHTML = '';
|
||||||
|
const ul = document.createElement('ul');
|
||||||
|
ul.className = 'lista-productos';
|
||||||
|
for (const p of list) {
|
||||||
|
const li = document.createElement('li');
|
||||||
|
li.textContent = `${p.nombre} — ${formatPrecio(p.precio)}`;
|
||||||
|
li.dataset.productId = p.id;
|
||||||
|
ul.appendChild(li);
|
||||||
|
}
|
||||||
|
el.appendChild(ul);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatPrecio(v) {
|
||||||
|
const n = Number(v || 0);
|
||||||
|
try { return n.toLocaleString(undefined, { style: 'currency', currency: 'UYU' }); }
|
||||||
|
catch { return `${n.toFixed(2)} UYU`; }
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
ensureLoaded,
|
||||||
|
all,
|
||||||
|
search,
|
||||||
|
getById,
|
||||||
|
renderSelect,
|
||||||
|
bindSearch,
|
||||||
|
get endpoint() { return discoveredEndpoint; },
|
||||||
|
get lastLoadedAt() { return lastLoadedAt; },
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
|
||||||
|
/* =======================
|
||||||
|
* Init de página
|
||||||
|
* ======================= */
|
||||||
|
async function initPage() {
|
||||||
|
// 1) Fijar esquema activo (lanza si no hay sesión/tenant)
|
||||||
|
await getActiveSchema();
|
||||||
|
|
||||||
|
// 2) Cargar catálogo de productos (descubrimiento + caché)
|
||||||
|
await productos.ensureLoaded().catch((e) => {
|
||||||
|
console.error('[productos.ensureLoaded] fallo:', e);
|
||||||
|
// No cortamos la init de la página; pero mostramos feedback
|
||||||
|
alert(e?.message || 'No fue posible cargar el catálogo de productos');
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3) Render opcional si existen selectores conocidos
|
||||||
|
productos.renderSelect({ selectorList: ['#selProducto', '#productos', 'select[name="producto"]'] });
|
||||||
|
productos.bindSearch({ inputSelectors: ['#buscarProducto', 'input[name="buscar_producto"]'], selectorList: ['#selProducto', '#productos'] });
|
||||||
|
|
||||||
|
// 4) Hooks opcionales del código original (si existen)
|
||||||
|
if (window.nueva?.init) await window.nueva.init();
|
||||||
|
if (window.estado?.bind) window.estado.bind();
|
||||||
|
if (window.estado?.loadLista) await window.estado.loadLista();
|
||||||
|
}
|
||||||
|
|
||||||
|
/* =======================
|
||||||
|
* Arranque
|
||||||
|
* ======================= */
|
||||||
|
document.addEventListener('DOMContentLoaded', async () => {
|
||||||
|
try {
|
||||||
|
await initPage();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error:', err);
|
||||||
|
alert(err?.message || 'Error inicializando Comandas');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/* =======================
|
||||||
|
* Export helpers globales
|
||||||
|
* ======================= */
|
||||||
|
Object.assign(window, {
|
||||||
|
jget,
|
||||||
|
jpost,
|
||||||
|
jput,
|
||||||
|
jdel,
|
||||||
|
getActiveSchema,
|
||||||
|
productos,
|
||||||
|
});
|
||||||
@@ -1,558 +0,0 @@
|
|||||||
<!-- services/manso/src/views/comandas.ejs -->
|
|
||||||
<div class="d-flex align-items-center justify-content-between mb-3">
|
|
||||||
<h1 class="h4 m-0">📋 Nueva Comanda</h1>
|
|
||||||
<span class="badge rounded-pill text-bg-light">/api/*</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="row g-3">
|
|
||||||
<!-- Columna izquierda: Productos -->
|
|
||||||
<div class="col-12 col-lg-7">
|
|
||||||
<div class="card shadow-sm">
|
|
||||||
<div class="card-header d-flex align-items-center">
|
|
||||||
<strong>Productos</strong>
|
|
||||||
<div class="ms-auto small text-muted" id="prodCount">0 ítems</div>
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="row g-2 align-items-center mb-2">
|
|
||||||
<div class="col-12 col-sm">
|
|
||||||
<input id="busqueda" type="search" class="form-control" placeholder="Buscar por nombre o categoría…">
|
|
||||||
</div>
|
|
||||||
<div class="col-auto">
|
|
||||||
<button class="btn btn-outline-secondary" id="limpiarBusqueda">Limpiar</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="listadoProductos" class="border rounded" style="max-height:58vh; overflow:auto;">
|
|
||||||
<!-- tabla de productos renderizada por JS -->
|
|
||||||
<div class="p-3 text-muted">Cargando…</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Columna derecha: Detalles + Carrito -->
|
|
||||||
<div class="col-12 col-lg-5">
|
|
||||||
<div class="card shadow-sm mb-3">
|
|
||||||
<div class="card-header"><strong>Detalles</strong></div>
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="row g-2">
|
|
||||||
<div class="col-12 col-sm-6">
|
|
||||||
<label for="selMesa" class="form-label text-muted small mb-1">Mesa</label>
|
|
||||||
<select id="selMesa" class="form-select"></select>
|
|
||||||
</div>
|
|
||||||
<div class="col-12 col-sm-6">
|
|
||||||
<label for="selUsuario" class="form-label text-muted small mb-1">Usuario</label>
|
|
||||||
<select id="selUsuario" class="form-select"></select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-2">
|
|
||||||
<label for="obs" class="form-label text-muted small mb-1">Observaciones</label>
|
|
||||||
<textarea id="obs" class="form-control" rows="3"></textarea>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="alert alert-secondary mt-3 mb-0 small">
|
|
||||||
La fecha se completa automáticamente y los estados/activos usan sus valores por defecto.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card shadow-sm">
|
|
||||||
<div class="card-header"><strong>Carrito</strong></div>
|
|
||||||
<div class="card-body p-0" id="carritoWrap">
|
|
||||||
<div class="p-3 text-muted">Aún no agregaste productos.</div>
|
|
||||||
</div>
|
|
||||||
<div class="d-flex align-items-center gap-2 p-3 border-top" style="position:sticky; bottom:0; background:#fff;">
|
|
||||||
<div class="small"><span class="text-muted">Ítems:</span> <strong id="kpiItems">0</strong></div>
|
|
||||||
<div class="small ms-2"><span class="text-muted">Total:</span> <strong id="kpiTotal">$ 0.00</strong></div>
|
|
||||||
<div class="ms-auto"></div>
|
|
||||||
<button class="btn btn-outline-secondary" id="vaciar">Vaciar</button>
|
|
||||||
<button class="btn btn-primary" id="crear">Crear Comanda</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="msg" class="mt-2 small text-muted"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- ====== LÓGICA ====== -->
|
|
||||||
<script>
|
|
||||||
// Helpers DOM
|
|
||||||
const $ = (s, r=document) => r.querySelector(s);
|
|
||||||
const $$ = (s, r=document) => Array.from(r.querySelectorAll(s));
|
|
||||||
|
|
||||||
// Estado
|
|
||||||
const state = {
|
|
||||||
productos: [],
|
|
||||||
mesas: [],
|
|
||||||
usuarios: [],
|
|
||||||
categorias: [], // <--- NUEVO
|
|
||||||
carrito: [],
|
|
||||||
filtro: ''
|
|
||||||
};
|
|
||||||
|
|
||||||
function norm(s='') {
|
|
||||||
return s.toString().toLowerCase()
|
|
||||||
.normalize('NFD').replace(/\p{Diacritic}/gu,''); // "café" -> "cafe"
|
|
||||||
}
|
|
||||||
|
|
||||||
function isTakeaway(apodo) {
|
|
||||||
return /^takeaway$/i.test(String(apodo || '').trim());
|
|
||||||
}
|
|
||||||
|
|
||||||
function groupOrderByCatName(catName='') {
|
|
||||||
const n = norm(catName);
|
|
||||||
if (n.includes('bar')) return 1;
|
|
||||||
if (n.includes('cafe')) return 2;
|
|
||||||
if (n.includes('cafeter')) return 3;
|
|
||||||
if (n.includes('trago') || n.includes('refresc')) return 4;
|
|
||||||
return 99; // otros
|
|
||||||
}
|
|
||||||
|
|
||||||
// Genera el HTML del ticket de cocina (80mm aprox)
|
|
||||||
function buildKitchenTicketHTML(data) {
|
|
||||||
const apodo = String(data.mesa_apodo ?? '').trim();
|
|
||||||
const numero = data.mesa_numero ?? '';
|
|
||||||
const take = isTakeaway(apodo);
|
|
||||||
|
|
||||||
const mesaTxt = take ? apodo.toUpperCase() : `Mesa #${numero}${apodo ? ' · ' + apodo : ''}`;
|
|
||||||
// const isTakeaway = /Takeaway/i.test(String(data.mesa_apodo ?? '')) || /Takeaway/i.test(String(data.mesa_numero ?? ''));
|
|
||||||
const mesaClass = take ? 'bigline' : 'mesa-medium';
|
|
||||||
const obs = (data.observaciones && data.observaciones.trim()) ? data.observaciones.trim() : '';
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// Productos ya vienen con su "g" (grupo numérico 1..4/99) y cantidad formateada
|
|
||||||
const items = data.productos.slice().sort((a,b)=> (a.g||99) - (b.g||99));
|
|
||||||
|
|
||||||
let productosHtml = '';
|
|
||||||
let prevG = null;
|
|
||||||
for (const p of items) {
|
|
||||||
if (prevG !== null && p.g !== prevG) {
|
|
||||||
productosHtml += `<div class="hr dotted"></div>`; // separación punteada entre grupos
|
|
||||||
}
|
|
||||||
productosHtml += `
|
|
||||||
<div class="row">
|
|
||||||
<div class="qty">x${p.cantidad}</div>
|
|
||||||
<div class="name">${p.nombre}</div>
|
|
||||||
</div>`;
|
|
||||||
prevG = p.g;
|
|
||||||
}
|
|
||||||
|
|
||||||
return `<!doctype html>
|
|
||||||
<html>
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<title>Ticket Cocina</title>
|
|
||||||
<style>
|
|
||||||
:root {
|
|
||||||
--w: 80mm;
|
|
||||||
--fz-base: 16px;
|
|
||||||
--fz-md: 16px; /* observaciones */
|
|
||||||
--fz-item: 18px; /* filas */
|
|
||||||
--fz-xl: 26px; /* <--- NUEVO: tamaño “grande” (mesa) */
|
|
||||||
--fz-xxl: 34px; /* título (#comanda) */
|
|
||||||
--fz-sm: 12px;
|
|
||||||
}
|
|
||||||
html, body { margin:0; padding:0; }
|
|
||||||
body {
|
|
||||||
width: var(--w);
|
|
||||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
|
||||||
font-size: var(--fz-base);
|
|
||||||
line-height: 1.35;
|
|
||||||
color:#000;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
#ticket { padding: 10px 8px; }
|
|
||||||
.center { text-align:center; }
|
|
||||||
.row { display:flex; gap:8px; margin: 4px 0; }
|
|
||||||
.row .qty { min-width: 22mm; font-size: var(--fz-item); letter-spacing:.2px; }
|
|
||||||
.row .name { flex:1; font-size: var(--fz-item); text-transform: uppercase; word-break: break-word; }
|
|
||||||
.hr { border-top: 2px dashed #000; margin: 8px 0; }
|
|
||||||
.hr.dotted { border-top: 2px dotted #000; }
|
|
||||||
.small { font-size: var(--fz-sm); }
|
|
||||||
.bigline { font-size: var(--fz-xxl); text-transform: uppercase; }
|
|
||||||
.mesa-medium { font-size: var(--fz-xl); text-transform: uppercase; }
|
|
||||||
.obs { font-size: var(--fz-md); }
|
|
||||||
.mt4{margin-top:4px}.mt8{margin-top:8px}.mb4{margin-bottom:4px}.mb8{margin-bottom:8px}
|
|
||||||
@page { size: var(--w) auto; margin: 0; }
|
|
||||||
@media print { body { width: var(--w); } }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div id="ticket">
|
|
||||||
<!-- SIN TÍTULO -->
|
|
||||||
<div class="center bigline">#${data.id_comanda}</div>
|
|
||||||
<div class="center ${mesaClass}">${mesaTxt}</div>
|
|
||||||
|
|
||||||
<div class="small mt4">Fecha: ${data.fecha} ${data.hora}</div>
|
|
||||||
<div class="small mt4">Mozo: ${data.usuario || '—'}</div>
|
|
||||||
${obs ? `<div class="obs mt8">Obs: ${obs}</div>` : ''}
|
|
||||||
|
|
||||||
<div class="hr"></div>
|
|
||||||
${productosHtml}
|
|
||||||
|
|
||||||
<div class="hr"></div>
|
|
||||||
<div class="small">Ítems: ${data.items} · Unidades: ${data.units}</div>
|
|
||||||
<div class="center mt8 small">— fin —</div>
|
|
||||||
</div>
|
|
||||||
<script>window.onload = () => { window.focus(); window.print(); }<\/script>
|
|
||||||
</body>
|
|
||||||
</html>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Imprime HTML usando un iframe oculto (menos bloqueos de pop-up)
|
|
||||||
function printHtmlViaIframe(html) {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
let iframe = document.getElementById('printFrame');
|
|
||||||
if (!iframe) {
|
|
||||||
iframe = document.createElement('iframe');
|
|
||||||
iframe.id = 'printFrame';
|
|
||||||
iframe.style.position = 'fixed';
|
|
||||||
iframe.style.right = '-9999px';
|
|
||||||
iframe.style.bottom = '0';
|
|
||||||
iframe.style.width = '0';
|
|
||||||
iframe.style.height = '0';
|
|
||||||
iframe.style.border = '0';
|
|
||||||
document.body.appendChild(iframe);
|
|
||||||
}
|
|
||||||
const doc = iframe.contentWindow.document;
|
|
||||||
doc.open();
|
|
||||||
doc.write(html);
|
|
||||||
doc.close();
|
|
||||||
|
|
||||||
// Salida: remover iframe después de un rato para no acumular
|
|
||||||
setTimeout(() => {
|
|
||||||
resolve();
|
|
||||||
// (si prefieres mantenerlo para reimpresiones, no lo quites)
|
|
||||||
// document.body.removeChild(iframe);
|
|
||||||
}, 1500);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// 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 ? 'mt-2 small ok text-success' : 'mt-2 small err text-danger';
|
|
||||||
el.textContent = msg;
|
|
||||||
setTimeout(()=>{ el.textContent=''; el.className='mt-2 small text-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;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Carga inicial
|
|
||||||
async function init() {
|
|
||||||
const [prods, mesas, usuarios, categorias] = await Promise.all([
|
|
||||||
jget('/api/table/productos?limit=1000'),
|
|
||||||
jget('/api/table/mesas?limit=1000'),
|
|
||||||
jget('/api/table/usuarios?limit=1000'),
|
|
||||||
jget('/api/table/categorias?limit=1000') // <--- NUEVO
|
|
||||||
]);
|
|
||||||
|
|
||||||
state.productos = prods.filter(p => p.activo !== false);
|
|
||||||
state.mesas = mesas;
|
|
||||||
state.usuarios = usuarios.filter(u => u.activo !== false);
|
|
||||||
state.categorias = Array.isArray(categorias) ? categorias : [];
|
|
||||||
|
|
||||||
// Mapas para buscar categoría por id de producto
|
|
||||||
state.catById = new Map(state.categorias.map(c => [c.id_categoria, (c.nombre||'').toString()]));
|
|
||||||
state.prodCatNameById = new Map(state.productos.map(p => [p.id_producto, state.catById.get(p.id_categoria)||'']));
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Render 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="p-3 text-muted">Sin resultados.</div>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const tbl = document.createElement('table');
|
|
||||||
tbl.className = 'table table-sm align-middle mb-0';
|
|
||||||
tbl.innerHTML = `
|
|
||||||
<thead class="table-light">
|
|
||||||
<tr>
|
|
||||||
<th>Producto</th>
|
|
||||||
<th class="text-end">Precio</th>
|
|
||||||
<th style="width:210px;">Cantidad</th>
|
|
||||||
<th style="width:100px;"></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="text-end">${money(p.precio)}</td>
|
|
||||||
<td>
|
|
||||||
<div class="d-flex align-items-center gap-2">
|
|
||||||
<input type="number" min="0.001" step="0.001" value="1.000" data-qty class="form-control form-control-sm" style="max-width:120px;">
|
|
||||||
<button class="btn btn-sm btn-outline-secondary" data-dec>-</button>
|
|
||||||
<button class="btn btn-sm btn-outline-secondary" data-inc>+</button>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td><button class="btn btn-sm btn-primary" 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();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Render carrito
|
|
||||||
function renderCarrito() {
|
|
||||||
const wrap = $('#carritoWrap');
|
|
||||||
if (!state.carrito.length) {
|
|
||||||
wrap.innerHTML = '<div class="p-3 text-muted">Aún no agregaste productos.</div>';
|
|
||||||
$('#kpiItems').textContent='0'; $('#kpiTotal').textContent=money(0);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const tbl = document.createElement('table');
|
|
||||||
tbl.className = 'table table-sm align-middle mb-0';
|
|
||||||
tbl.innerHTML = `
|
|
||||||
<thead class="table-light">
|
|
||||||
<tr>
|
|
||||||
<th>Producto</th>
|
|
||||||
<th class="text-end">Unitario</th>
|
|
||||||
<th class="text-end">Cantidad</th>
|
|
||||||
<th class="text-end">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="text-end">${money(it.pre_unitario)}</td>
|
|
||||||
<td class="text-end">
|
|
||||||
<input type="number" min="0.001" step="0.001" value="${it.cantidad.toFixed(3)}" class="form-control form-control-sm text-end" style="max-width:120px;">
|
|
||||||
</td>
|
|
||||||
<td class="text-end">${money(sub)}</td>
|
|
||||||
<td class="text-end">
|
|
||||||
<button class="btn btn-sm btn-outline-secondary" 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);
|
|
||||||
}
|
|
||||||
|
|
||||||
const fmtQty = (n) => Number(n).toFixed(3).replace(/\.?0+$/,'');
|
|
||||||
|
|
||||||
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; }
|
|
||||||
|
|
||||||
// Snapshot del carrito ANTES de limpiar (para imprimir)
|
|
||||||
const cartSnapshot = state.carrito.map(it => ({ ...it }));
|
|
||||||
|
|
||||||
const observaciones = $('#obs').value.trim() || null;
|
|
||||||
|
|
||||||
try {
|
|
||||||
// 1) encabezado comanda
|
|
||||||
const { inserted: com } = await jpost('/api/table/comandas', {
|
|
||||||
id_usuario,
|
|
||||||
id_mesa,
|
|
||||||
estado: 'abierta',
|
|
||||||
observaciones
|
|
||||||
});
|
|
||||||
|
|
||||||
// 2) detalle
|
|
||||||
const id_comanda = com.id_comanda;
|
|
||||||
const payloads = cartSnapshot.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)));
|
|
||||||
|
|
||||||
// 3) Datos auxiliares para ticket
|
|
||||||
const mesa = state.mesas.find(m => m.id_mesa === id_mesa) || {};
|
|
||||||
const usuario = state.usuarios.find(u => u.id_usuario === id_usuario) || {};
|
|
||||||
const now = new Date();
|
|
||||||
const fecha = now.toLocaleDateString();
|
|
||||||
const hora = now.toLocaleTimeString();
|
|
||||||
|
|
||||||
// 4) Construir e imprimir Ticket de Cocina (sin precios)
|
|
||||||
const units = cartSnapshot.reduce((acc, it) => acc + Number(it.cantidad || 0), 0);
|
|
||||||
const items = cartSnapshot.length;
|
|
||||||
|
|
||||||
// map producto -> nombre de categoría
|
|
||||||
const prodCat = state.prodCatNameById || new Map();
|
|
||||||
|
|
||||||
const productosParaTicket = cartSnapshot.map(it => ({
|
|
||||||
nombre: it.nombre,
|
|
||||||
cantidad: fmtQty(it.cantidad),
|
|
||||||
g: groupOrderByCatName(prodCat.get(it.id_producto) || '') // 1..4/99
|
|
||||||
}));
|
|
||||||
|
|
||||||
const ticketHtml = buildKitchenTicketHTML({
|
|
||||||
id_comanda,
|
|
||||||
fecha, hora,
|
|
||||||
mesa_numero: mesa?.numero,
|
|
||||||
mesa_apodo: mesa?.apodo,
|
|
||||||
usuario: `${usuario?.nombre || ''} ${usuario?.apellido || ''}`.trim(),
|
|
||||||
observaciones,
|
|
||||||
items,
|
|
||||||
units,
|
|
||||||
productos: productosParaTicket // <--- con grupos
|
|
||||||
});
|
|
||||||
|
|
||||||
await printHtmlViaIframe(ticketHtml);
|
|
||||||
|
|
||||||
// 5) Reset UI
|
|
||||||
state.carrito = [];
|
|
||||||
renderCarrito();
|
|
||||||
$('#obs').value = '';
|
|
||||||
toast(`Comanda #${id_comanda} creada e impresa`, true);
|
|
||||||
|
|
||||||
} catch (e) {
|
|
||||||
toast(e.message || 'No se pudo crear la comanda');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// // 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
|
|
||||||
// const { inserted: com } = await jpost('/api/table/comandas', {
|
|
||||||
// id_usuario,
|
|
||||||
// id_mesa,
|
|
||||||
// estado: 'abierta',
|
|
||||||
// observaciones
|
|
||||||
// });
|
|
||||||
|
|
||||||
// // 2) detalle
|
|
||||||
// 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>
|
|
||||||
@@ -1,532 +0,0 @@
|
|||||||
<!-- services/manso/src/views/estadoComandas.ejs -->
|
|
||||||
<div class="d-flex align-items-center justify-content-between mb-3">
|
|
||||||
<h1 class="h4 m-0">🧾 Estado de Comandas</h1>
|
|
||||||
<a class="btn btn-sm btn-dark" href="/comandas">➕ Nueva comanda</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="row g-3">
|
|
||||||
<!-- ===== Listado (izquierda) ===== -->
|
|
||||||
<div class="col-12 col-lg-7">
|
|
||||||
<div class="card shadow-sm">
|
|
||||||
<div class="card-header d-flex align-items-center">
|
|
||||||
<strong>Listado</strong>
|
|
||||||
<div class="form-check form-switch ms-auto">
|
|
||||||
<input class="form-check-input" type="checkbox" id="soloAbiertas" checked>
|
|
||||||
<label class="form-check-label" for="soloAbiertas">Solo abiertas</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="row g-2 align-items-center mb-2">
|
|
||||||
<div class="col">
|
|
||||||
<input id="buscar" type="search" class="form-control" placeholder="Buscar por #, mesa o usuario…">
|
|
||||||
</div>
|
|
||||||
<div class="col-auto">
|
|
||||||
<button class="btn btn-outline-secondary" id="limpiar">Limpiar</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="lista" class="table-responsive" style="max-height:62vh; overflow:auto;">
|
|
||||||
<div class="p-3 text-muted">Cargando…</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- ===== Detalle (derecha) ===== -->
|
|
||||||
<div class="col-12 col-lg-5">
|
|
||||||
<div class="card shadow-sm">
|
|
||||||
<div class="card-header d-flex align-items-center">
|
|
||||||
<strong>Detalle</strong>
|
|
||||||
<span id="detalleEstado" class="badge badge-outline ms-auto">—</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card-body" id="detalle">
|
|
||||||
<div class="text-muted">Selecciona una comanda para ver el detalle.</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="d-flex align-items-center gap-3 p-3 border-top" style="position:sticky; bottom:0; background:#fff;">
|
|
||||||
<div class="small"><span class="text-muted">ID:</span> <strong id="kpiId">—</strong></div>
|
|
||||||
<div class="small"><span class="text-muted">Mesa:</span> <strong id="kpiMesa">—</strong></div>
|
|
||||||
<div class="small"><span class="text-muted">Total:</span> <strong id="kpiTotal">$ 0.00</strong></div>
|
|
||||||
<div class="ms-auto"></div>
|
|
||||||
<button class="btn btn-outline-secondary" id="reabrir">Reabrir</button>
|
|
||||||
<button class="btn btn-primary" id="cerrar">Cerrar</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card-body">
|
|
||||||
<div id="msg" class="text-muted small"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
// ===== Helpers =====
|
|
||||||
const $ = (s, r=document) => r.querySelector(s);
|
|
||||||
const $$ = (s, r=document) => Array.from(r.querySelectorAll(s));
|
|
||||||
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 ? 'text-success small' : 'text-danger small';
|
|
||||||
el.textContent = msg;
|
|
||||||
setTimeout(()=>{ el.textContent=''; el.className='text-muted small'; }, 3000);
|
|
||||||
};
|
|
||||||
const badgeClass = (estadoRaw) => {
|
|
||||||
const s = String(estadoRaw||'').toLowerCase();
|
|
||||||
if (s.includes('abier')) return 'badge badge-outline badge-estado-abierta';
|
|
||||||
if (s.includes('pagad') || s.includes('paga')) return 'badge badge-outline badge-estado-pagada';
|
|
||||||
if (s.includes('cerr')) return 'badge badge-outline badge-estado-cerrada';
|
|
||||||
if (s.includes('anul') || s.includes('cancel')) return 'badge badge-outline badge-estado-anulada';
|
|
||||||
return 'badge badge-outline';
|
|
||||||
};
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== Estado =====
|
|
||||||
const state = {
|
|
||||||
filtro: '',
|
|
||||||
soloAbiertas: true,
|
|
||||||
lista: [], // [{ id_comanda, mesa_numero, mesa_apodo, usuario_nombre, usuario_apellido, fec_creacion, estado, items, total }]
|
|
||||||
sel: null, // id seleccionado
|
|
||||||
detalle: [] // [{ id_det_comanda, producto_nombre, cantidad, pre_unitario, subtotal, observaciones }]
|
|
||||||
};
|
|
||||||
|
|
||||||
// ===== 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 = Array.isArray(rows) ? rows : [];
|
|
||||||
renderLista();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadDetalle(id) {
|
|
||||||
const det = await jget(`/api/comandas/${id}/detalle`);
|
|
||||||
state.detalle = Array.isArray(det) ? det : [];
|
|
||||||
renderDetalle();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== UI: Lista =====
|
|
||||||
function renderLista(){
|
|
||||||
let rows = state.lista.slice();
|
|
||||||
const f = state.filtro?.trim().toLowerCase();
|
|
||||||
if (f) {
|
|
||||||
rows = rows.filter(r =>
|
|
||||||
String(r.id_comanda).includes(f) ||
|
|
||||||
String(r.mesa_numero ?? '').includes(f) ||
|
|
||||||
(`${r.usuario_nombre||''} ${r.usuario_apellido||''}`).toLowerCase().includes(f)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const box = $('#lista');
|
|
||||||
if (!rows.length) { box.innerHTML = '<div class="p-3 text-muted">Sin resultados.</div>'; return; }
|
|
||||||
|
|
||||||
const tbl = document.createElement('table');
|
|
||||||
tbl.className = 'table table-sm align-middle mb-0';
|
|
||||||
tbl.innerHTML = `
|
|
||||||
<thead class="table-light">
|
|
||||||
<tr>
|
|
||||||
<th>#</th>
|
|
||||||
<th>Mesa</th>
|
|
||||||
<th>Usuario</th>
|
|
||||||
<th>Fecha</th>
|
|
||||||
<th>Estado</th>
|
|
||||||
<th class="text-end">Ítems</th>
|
|
||||||
<th class="text-end">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 ? '· '+r.mesa_apodo : ''}</td>
|
|
||||||
<td>${(r.usuario_nombre||'') + ' ' + (r.usuario_apellido||'')}</td>
|
|
||||||
<td>${r.fec_creacion ? new Date(r.fec_creacion).toLocaleString() : '—'}</td>
|
|
||||||
<td><span class="${badgeClass(r.estado)}">${r.estado ?? '—'}</span></td>
|
|
||||||
<td class="text-end">${r.items ?? '—'}</td>
|
|
||||||
<td class="text-end">${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 + KPIs =====
|
|
||||||
function applyHeader(r){
|
|
||||||
$('#kpiId').textContent = r.id_comanda ?? '—';
|
|
||||||
$('#kpiMesa').textContent = r.mesa_numero ? `#${r.mesa_numero}` : '—';
|
|
||||||
$('#detalleEstado').className = badgeClass(r.estado);
|
|
||||||
$('#detalleEstado').textContent = r.estado ?? '—';
|
|
||||||
$('#kpiTotal').textContent = money(r.total ?? 0);
|
|
||||||
|
|
||||||
// Botones (más precisos según estado)
|
|
||||||
const cerr = $('#cerrar'), reab = $('#reabrir');
|
|
||||||
const s = String(r.estado||'').toLowerCase();
|
|
||||||
if (s.includes('abier')) {
|
|
||||||
cerr.disabled = false; cerr.title = '';
|
|
||||||
reab.disabled = true; reab.title = 'Ya está abierta';
|
|
||||||
} else if (s.includes('cerr')) {
|
|
||||||
cerr.disabled = true; cerr.title = 'Ya está cerrada';
|
|
||||||
reab.disabled = false; reab.title = '';
|
|
||||||
} else {
|
|
||||||
// Otros estados: permitir ambas acciones
|
|
||||||
cerr.disabled = false; cerr.title = '';
|
|
||||||
reab.disabled = false; reab.title = '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderDetalle(){
|
|
||||||
const box = $('#detalle');
|
|
||||||
if (!state.detalle.length) {
|
|
||||||
box.innerHTML = '<div class="text-muted">Sin detalle.</div>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const tbl = document.createElement('table');
|
|
||||||
tbl.className = 'table table-sm align-middle mb-0';
|
|
||||||
tbl.innerHTML = `
|
|
||||||
<thead class="table-light">
|
|
||||||
<tr>
|
|
||||||
<th>Producto</th>
|
|
||||||
<th class="text-end">Unitario</th>
|
|
||||||
<th class="text-end">Cantidad</th>
|
|
||||||
<th class="text-end">Subtotal</th>
|
|
||||||
<th>Observaciones</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody></tbody>
|
|
||||||
`;
|
|
||||||
const tb = tbl.querySelector('tbody');
|
|
||||||
|
|
||||||
let total = 0;
|
|
||||||
state.detalle.forEach(r => {
|
|
||||||
const sub = Number(r.subtotal || (Number(r.pre_unitario||0) * Number(r.cantidad||0)));
|
|
||||||
total += sub;
|
|
||||||
const tr = document.createElement('tr');
|
|
||||||
tr.innerHTML = `
|
|
||||||
<td>${r.producto_nombre ?? '—'}</td>
|
|
||||||
<td class="text-end">${money(r.pre_unitario)}</td>
|
|
||||||
<td class="text-end">${Number(r.cantidad||0).toFixed(3)}</td>
|
|
||||||
<td class="text-end">${money(sub)}</td>
|
|
||||||
<td>${r.observaciones || ''}</td>
|
|
||||||
`;
|
|
||||||
tb.appendChild(tr);
|
|
||||||
});
|
|
||||||
|
|
||||||
box.innerHTML = '';
|
|
||||||
box.appendChild(tbl);
|
|
||||||
$('#kpiTotal').textContent = money(total);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== Actions (usa /abrir y /cerrar) =====
|
|
||||||
async function accionComanda(accion){ // 'abrir' | 'cerrar'
|
|
||||||
if (!state.sel) return;
|
|
||||||
try {
|
|
||||||
await jpost(`/api/comandas/${state.sel}/${accion}`, {}); // el body no se usa en el backend
|
|
||||||
toast(`Comanda #${state.sel} ${accion === 'abrir' ? 'reabierta' : 'cerrada'}`, true);
|
|
||||||
|
|
||||||
// Recargar lista y re-aplicar cabecera/detalle si sigue visible
|
|
||||||
const id = state.sel;
|
|
||||||
await loadLista();
|
|
||||||
const found = state.lista.find(x => x.id_comanda === id);
|
|
||||||
if (found) {
|
|
||||||
applyHeader(found);
|
|
||||||
await loadDetalle(found.id_comanda);
|
|
||||||
} else {
|
|
||||||
// Puede desaparecer del listado si está activado "Solo abiertas" y la cerramos
|
|
||||||
state.sel = null;
|
|
||||||
$('#detalle').innerHTML = '<div class="text-muted">Selecciona una comanda para ver el detalle.</div>';
|
|
||||||
$('#detalleEstado').textContent = '—'; $('#detalleEstado').className = 'badge badge-outline';
|
|
||||||
$('#kpiId').textContent = '—'; $('#kpiMesa').textContent='—'; $('#kpiTotal').textContent = money(0);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
toast(e.message || 'No se pudo actualizar la comanda');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== Hooks con Sidebar (offcanvas) =====
|
|
||||||
window.scRefreshList = loadLista;
|
|
||||||
window.scExportCsv = function(){
|
|
||||||
const rows = state.lista.slice();
|
|
||||||
const header = ["id_comanda","mesa_numero","mesa_apodo","usuario","fec_creacion","estado","items","total"];
|
|
||||||
const csv = [header.join(",")].concat(rows.map(r => {
|
|
||||||
const usuario = `${r.usuario_nombre||''} ${r.usuario_apellido||''}`.trim();
|
|
||||||
const vals = [
|
|
||||||
r.id_comanda,
|
|
||||||
r.mesa_numero ?? '',
|
|
||||||
(r.mesa_apodo ?? '').replaceAll('"','""'),
|
|
||||||
usuario.replaceAll('"','""'),
|
|
||||||
r.fec_creacion ?? '',
|
|
||||||
r.estado ?? '',
|
|
||||||
r.items ?? '',
|
|
||||||
r.total ?? ''
|
|
||||||
];
|
|
||||||
return vals.map(v => `"${String(v).replaceAll('"','""')}"`).join(",");
|
|
||||||
})).join("\n");
|
|
||||||
const blob = new Blob([csv], {type:"text/csv;charset=utf-8"});
|
|
||||||
const url = URL.createObjectURL(blob);
|
|
||||||
const a = Object.assign(document.createElement("a"), {href:url, download:`estadoComandas_${new Date().toISOString().slice(0,10)}.csv`});
|
|
||||||
document.body.appendChild(a); a.click(); a.remove(); URL.revokeObjectURL(url);
|
|
||||||
};
|
|
||||||
|
|
||||||
// ===== 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(); });
|
|
||||||
|
|
||||||
// Ahora los botones llaman a los nuevos endpoints
|
|
||||||
$('#cerrar').addEventListener('click', () => accionComanda('cerrar'));
|
|
||||||
$('#reabrir').addEventListener('click', () => accionComanda('abrir'));
|
|
||||||
|
|
||||||
(async function main(){ try { await loadLista(); } catch(e){ toast(e.message||'Error cargando comandas'); }})();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
|
|
||||||
<!-- <script>
|
|
||||||
// ===== Helpers =====
|
|
||||||
const $ = (s, r=document) => r.querySelector(s);
|
|
||||||
const $$ = (s, r=document) => Array.from(r.querySelectorAll(s));
|
|
||||||
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 ? 'text-success small' : 'text-danger small';
|
|
||||||
el.textContent = msg;
|
|
||||||
setTimeout(()=>{ el.textContent=''; el.className='text-muted small'; }, 3000);
|
|
||||||
};
|
|
||||||
const badgeClass = (estadoRaw) => {
|
|
||||||
const s = String(estadoRaw||'').toLowerCase();
|
|
||||||
if (s.includes('abier')) return 'badge badge-outline badge-estado-abierta';
|
|
||||||
if (s.includes('pagad') || s.includes('paga')) return 'badge badge-outline badge-estado-pagada';
|
|
||||||
if (s.includes('cerr')) return 'badge badge-outline badge-estado-cerrada';
|
|
||||||
if (s.includes('anul') || s.includes('cancel')) return 'badge badge-outline badge-estado-anulada';
|
|
||||||
return 'badge badge-outline';
|
|
||||||
};
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== Estado =====
|
|
||||||
const state = {
|
|
||||||
filtro: '',
|
|
||||||
soloAbiertas: true,
|
|
||||||
lista: [], // [{ id_comanda, mesa_numero, mesa_apodo, usuario_nombre, usuario_apellido, fec_creacion, estado, items, total }]
|
|
||||||
sel: null, // id seleccionado
|
|
||||||
detalle: [] // [{ id_det_comanda, producto_nombre, cantidad, pre_unitario, subtotal, observaciones }]
|
|
||||||
};
|
|
||||||
|
|
||||||
// ===== 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 = Array.isArray(rows) ? rows : [];
|
|
||||||
renderLista();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadDetalle(id) {
|
|
||||||
const det = await jget(`/api/comandas/${id}/detalle`);
|
|
||||||
state.detalle = Array.isArray(det) ? det : [];
|
|
||||||
renderDetalle();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== UI: Lista =====
|
|
||||||
function renderLista(){
|
|
||||||
let rows = state.lista.slice();
|
|
||||||
const f = state.filtro?.trim().toLowerCase();
|
|
||||||
if (f) {
|
|
||||||
rows = rows.filter(r =>
|
|
||||||
String(r.id_comanda).includes(f) ||
|
|
||||||
String(r.mesa_numero ?? '').includes(f) ||
|
|
||||||
(`${r.usuario_nombre||''} ${r.usuario_apellido||''}`).toLowerCase().includes(f)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const box = $('#lista');
|
|
||||||
if (!rows.length) { box.innerHTML = '<div class="p-3 text-muted">Sin resultados.</div>'; return; }
|
|
||||||
|
|
||||||
const tbl = document.createElement('table');
|
|
||||||
tbl.className = 'table table-sm align-middle mb-0';
|
|
||||||
tbl.innerHTML = `
|
|
||||||
<thead class="table-light">
|
|
||||||
<tr>
|
|
||||||
<th>#</th>
|
|
||||||
<th>Mesa</th>
|
|
||||||
<th>Usuario</th>
|
|
||||||
<th>Fecha</th>
|
|
||||||
<th>Estado</th>
|
|
||||||
<th class="text-end">Ítems</th>
|
|
||||||
<th class="text-end">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 ? '· '+r.mesa_apodo : ''}</td>
|
|
||||||
<td>${(r.usuario_nombre||'') + ' ' + (r.usuario_apellido||'')}</td>
|
|
||||||
<td>${r.fec_creacion ? new Date(r.fec_creacion).toLocaleString() : '—'}</td>
|
|
||||||
<td><span class="${badgeClass(r.estado)}">${r.estado ?? '—'}</span></td>
|
|
||||||
<td class="text-end">${r.items ?? '—'}</td>
|
|
||||||
<td class="text-end">${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 + KPIs =====
|
|
||||||
function applyHeader(r){
|
|
||||||
$('#kpiId').textContent = r.id_comanda ?? '—';
|
|
||||||
$('#kpiMesa').textContent = r.mesa_numero ? `#${r.mesa_numero}` : '—';
|
|
||||||
$('#detalleEstado').className = badgeClass(r.estado);
|
|
||||||
$('#detalleEstado').textContent = r.estado ?? '—';
|
|
||||||
$('#kpiTotal').textContent = money(r.total ?? 0);
|
|
||||||
|
|
||||||
// Botones
|
|
||||||
const cerr = $('#cerrar'), reab = $('#reabrir');
|
|
||||||
if ((r.estado||'').toLowerCase().includes('abier')) {
|
|
||||||
cerr.disabled = false; cerr.title = '';
|
|
||||||
reab.disabled = true; reab.title = 'Ya está abierta';
|
|
||||||
} else {
|
|
||||||
cerr.disabled = false;
|
|
||||||
reab.disabled = false;
|
|
||||||
cerr.title = ''; reab.title = '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderDetalle(){
|
|
||||||
const box = $('#detalle');
|
|
||||||
if (!state.detalle.length) {
|
|
||||||
box.innerHTML = '<div class="text-muted">Sin detalle.</div>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const tbl = document.createElement('table');
|
|
||||||
tbl.className = 'table table-sm align-middle mb-0';
|
|
||||||
tbl.innerHTML = `
|
|
||||||
<thead class="table-light">
|
|
||||||
<tr>
|
|
||||||
<th>Producto</th>
|
|
||||||
<th class="text-end">Unitario</th>
|
|
||||||
<th class="text-end">Cantidad</th>
|
|
||||||
<th class="text-end">Subtotal</th>
|
|
||||||
<th>Observaciones</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody></tbody>
|
|
||||||
`;
|
|
||||||
const tb = tbl.querySelector('tbody');
|
|
||||||
|
|
||||||
let total = 0;
|
|
||||||
state.detalle.forEach(r => {
|
|
||||||
const sub = Number(r.subtotal || (Number(r.pre_unitario||0) * Number(r.cantidad||0)));
|
|
||||||
total += sub;
|
|
||||||
const tr = document.createElement('tr');
|
|
||||||
tr.innerHTML = `
|
|
||||||
<td>${r.producto_nombre ?? '—'}</td>
|
|
||||||
<td class="text-end">${money(r.pre_unitario)}</td>
|
|
||||||
<td class="text-end">${Number(r.cantidad||0).toFixed(3)}</td>
|
|
||||||
<td class="text-end">${money(sub)}</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();
|
|
||||||
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="text-muted">Selecciona una comanda para ver el detalle.</div>';
|
|
||||||
$('#detalleEstado').textContent = '—'; $('#detalleEstado').className = 'badge badge-outline';
|
|
||||||
$('#kpiId').textContent = '—'; $('#kpiMesa').textContent='—'; $('#kpiTotal').textContent = money(0);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
toast(e.message || 'No se pudo cambiar el estado');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== Hooks con Sidebar (offcanvas) =====
|
|
||||||
// Permite que el botón "Actualizar" del sidebar recargue este listado
|
|
||||||
window.scRefreshList = loadLista;
|
|
||||||
// Exportación simple del listado actual
|
|
||||||
window.scExportCsv = function(){
|
|
||||||
const rows = state.lista.slice();
|
|
||||||
const header = ["id_comanda","mesa_numero","mesa_apodo","usuario","fec_creacion","estado","items","total"];
|
|
||||||
const csv = [header.join(",")].concat(rows.map(r => {
|
|
||||||
const usuario = `${r.usuario_nombre||''} ${r.usuario_apellido||''}`.trim();
|
|
||||||
const vals = [
|
|
||||||
r.id_comanda,
|
|
||||||
r.mesa_numero ?? '',
|
|
||||||
(r.mesa_apodo ?? '').replaceAll('"','""'),
|
|
||||||
usuario.replaceAll('"','""'),
|
|
||||||
r.fec_creacion ?? '',
|
|
||||||
r.estado ?? '',
|
|
||||||
r.items ?? '',
|
|
||||||
r.total ?? ''
|
|
||||||
];
|
|
||||||
return vals.map(v => `"${String(v).replaceAll('"','""')}"`).join(",");
|
|
||||||
})).join("\n");
|
|
||||||
const blob = new Blob([csv], {type:"text/csv;charset=utf-8"});
|
|
||||||
const url = URL.createObjectURL(blob);
|
|
||||||
const a = Object.assign(document.createElement("a"), {href:url, download:`estadoComandas_${new Date().toISOString().slice(0,10)}.csv`});
|
|
||||||
document.body.appendChild(a); a.click(); a.remove(); URL.revokeObjectURL(url);
|
|
||||||
};
|
|
||||||
|
|
||||||
// ===== 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> -->
|
|
||||||
@@ -1,158 +0,0 @@
|
|||||||
<!-- 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>
|
|
||||||
@@ -1,130 +0,0 @@
|
|||||||
<!-- 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,366 @@
|
|||||||
|
<!-- 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, [data-theme="dark"] {
|
||||||
|
--bg:#0b0b0c; --card:#141519; --text:#e7e9ee; --muted:#a5acb8; --accent:#6ee7b7; --border:#232733;
|
||||||
|
}
|
||||||
|
[data-theme="light"] {
|
||||||
|
--bg:#f7f8fb; --card:#ffffff; --text:#0b0b0c; --muted:#5b6472; --accent:#0ea5e9; --border:#e6e8ee;
|
||||||
|
}
|
||||||
|
* { 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>
|
||||||
|
<%
|
||||||
|
// ============ SERVIDOR ============ //
|
||||||
|
// Espera: { user, cookies, session } (pásalos en res.render)
|
||||||
|
|
||||||
|
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 cookieKeys = hasCookies ? Object.keys(cookies) : [];
|
||||||
|
const sidKey = cookieKeys.find(k => /^(sc\.sid|connect\.sid|.*sid|.*sessionid)$/i.test(k)) || null;
|
||||||
|
const sidVal = sidKey ? String(cookies[sidKey] ?? '') : null;
|
||||||
|
const sidValMasked = sidVal ? (sidVal.length > 20 ? (sidVal.slice(0, 12) + '…' + sidVal.slice(-6)) : sidVal) : '(sin valor)';
|
||||||
|
const sessionId = (hasSession && session.id) || (typeof sidVal === 'string' ? '(derivado de cookie)' : '(no disponible)');
|
||||||
|
|
||||||
|
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';
|
||||||
|
|
||||||
|
// ---- Detección de cookie de configuración (servidor)
|
||||||
|
const tenantId32 = hasUser && user.default_tenant ? String(user.default_tenant).toLowerCase() : null;
|
||||||
|
const cfgRe = /^(?:sc\.)?(config|prefs|ui)(?:\.([0-9a-f]{32}))?$/i;
|
||||||
|
|
||||||
|
function pickConfigCookieName(keys, tenant) {
|
||||||
|
const matches = keys
|
||||||
|
.map(k => [k, k.match(cfgRe)])
|
||||||
|
.filter(([, m]) => !!m);
|
||||||
|
if (!matches.length) return null;
|
||||||
|
if (tenant) {
|
||||||
|
const exact = matches.find(([, m]) => (m[2] || '').toLowerCase() === tenant);
|
||||||
|
if (exact) return exact[0];
|
||||||
|
}
|
||||||
|
return matches[0][0];
|
||||||
|
}
|
||||||
|
|
||||||
|
const configCookieKey = hasCookies ? pickConfigCookieName(cookieKeys, tenantId32) : null;
|
||||||
|
const rawConfigCookie = configCookieKey ? cookies[configCookieKey] : null;
|
||||||
|
|
||||||
|
function tryParseConfig(val) {
|
||||||
|
if (!val) return null;
|
||||||
|
const candidates = [];
|
||||||
|
try { candidates.push(String(val)); } catch {}
|
||||||
|
try { candidates.push(decodeURIComponent(String(val))); } catch {}
|
||||||
|
try { candidates.push(Buffer.from(String(val), 'base64').toString('utf8')); } catch {}
|
||||||
|
|
||||||
|
for (const c of candidates) {
|
||||||
|
try {
|
||||||
|
const obj = JSON.parse(c);
|
||||||
|
if (obj && typeof obj === 'object') return obj;
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const configFromCookie = tryParseConfig(rawConfigCookie);
|
||||||
|
|
||||||
|
// Tema inicial (si la cookie define theme: 'light' | 'dark')
|
||||||
|
const initialTheme = (configFromCookie && typeof configFromCookie.theme === 'string')
|
||||||
|
? (configFromCookie.theme.toLowerCase() === 'light' ? 'light' : 'dark')
|
||||||
|
: 'dark';
|
||||||
|
|
||||||
|
// ====== Normalización de "user" para evitar duplicados ======
|
||||||
|
const preferredOrder = ['sub','email','user_id','name','default_tenant','memberships'];
|
||||||
|
const normalizedUser = {};
|
||||||
|
if (hasUser) {
|
||||||
|
// Tomamos valores canónicos
|
||||||
|
normalizedUser.sub = user.sub ?? null;
|
||||||
|
normalizedUser.email = user.email ?? null;
|
||||||
|
normalizedUser.user_id = user.user_id ?? user.userId ?? null;
|
||||||
|
normalizedUser.name = user.name ?? user.displayName ?? null;
|
||||||
|
|
||||||
|
// Unificar current_tenant/currentTenant -> default_tenant si éste no viene
|
||||||
|
const fallbackTenant = user.current_tenant ?? user.currentTenant ?? null;
|
||||||
|
normalizedUser.default_tenant = user.default_tenant ?? fallbackTenant ?? null;
|
||||||
|
|
||||||
|
if (Array.isArray(user.memberships)) normalizedUser.memberships = user.memberships;
|
||||||
|
|
||||||
|
// Extras: todo lo demás excepto duplicados y legacy
|
||||||
|
const skip = new Set([
|
||||||
|
...preferredOrder,
|
||||||
|
'current_tenant','currentTenant',
|
||||||
|
'user_uid_nodash','userUidNoDash'
|
||||||
|
]);
|
||||||
|
const extras = Object.entries(user)
|
||||||
|
.filter(([k]) => !skip.has(k) && !/nodash/i.test(k));
|
||||||
|
// Los adjuntamos en un objeto aparte para mostrarlos (si existieran)
|
||||||
|
normalizedUser.__extras = Object.fromEntries(extras);
|
||||||
|
}
|
||||||
|
%>
|
||||||
|
<body data-theme="<%= initialTheme %>">
|
||||||
|
<div class="wrap">
|
||||||
|
<div class="card">
|
||||||
|
<h1>Hola, <span><%= displayName %></span> 👋</h1>
|
||||||
|
<p class="lead">Bienvenido a SuiteCoffee. Este inicio lee la <strong>cookie de configuraciones actuales</strong> (servidor y navegador) y aplica el tema.</p>
|
||||||
|
|
||||||
|
<!-- Bloque mínimo para ver sessionID y cookie de sesión -->
|
||||||
|
<h2>Diagnóstico de Sesión</h2>
|
||||||
|
<table>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<th><code class="k">req.sessionID</code></th>
|
||||||
|
<td><code class="v"><%= typeof req !== 'undefined' && req.sessionID ? req.sessionID : sessionId %></code></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th><code class="k">Cookie de sesión</code></th>
|
||||||
|
<td>
|
||||||
|
<% if (sidKey) { %>
|
||||||
|
<code class="v"><%= sidKey %></code>
|
||||||
|
<div class="muted" style="margin-top:6px;">valor: <code class="v"><%= sidValMasked %></code></div>
|
||||||
|
<% } else { %>
|
||||||
|
<em class="muted">No detectada en <code>req.cookies</code> (ej. <code>sc.sid</code>).</em>
|
||||||
|
<% } %>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<% if (hasUser) { %>
|
||||||
|
<h2>Sesión de Aplicación (user)</h2>
|
||||||
|
<table>
|
||||||
|
<tbody>
|
||||||
|
<% for (const key of preferredOrder) {
|
||||||
|
const v = normalizedUser[key];
|
||||||
|
if (typeof v === 'undefined' || v === null) continue;
|
||||||
|
%>
|
||||||
|
<tr>
|
||||||
|
<th><code class="k"><%= key %></code></th>
|
||||||
|
<td><code class="v"><%= typeof v === 'object' ? JSON.stringify(v) : String(v) %></code></td>
|
||||||
|
</tr>
|
||||||
|
<% } %>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<% if (normalizedUser.__extras && Object.keys(normalizedUser.__extras).length) { %>
|
||||||
|
<h3 style="margin-top:14px;">Otros campos</h3>
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>Campo</th><th>Valor</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<% for (const [k,v] of Object.entries(normalizedUser.__extras)) { %>
|
||||||
|
<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>
|
||||||
|
<% } %>
|
||||||
|
<% } %>
|
||||||
|
|
||||||
|
<!-- Configuraciones actuales (desde cookie, lado servidor) -->
|
||||||
|
<h2>Configuraciones actuales (cookie servidor)</h2>
|
||||||
|
<table>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<th><code class="k">Cookie detectada</code></th>
|
||||||
|
<td>
|
||||||
|
<% if (configCookieKey) { %>
|
||||||
|
<code class="v"><%= configCookieKey %></code>
|
||||||
|
<% if (tenantId32) { %>
|
||||||
|
<span class="pill" style="margin-left:6px;">tenant=<%= tenantId32 %></span>
|
||||||
|
<% } %>
|
||||||
|
<% } else { %>
|
||||||
|
<em class="muted">No se encontró cookie de configuración (busco: <code>sc.config</code>, <code>sc.prefs</code>, <code>sc.ui</code> o con sufijo <code>.{tenantId32}</code>).</em>
|
||||||
|
<% } %>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th><code class="k">Tema aplicado</code></th>
|
||||||
|
<td><code class="v"><%= initialTheme %></code></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th><code class="k">Contenido parseado</code></th>
|
||||||
|
<td>
|
||||||
|
<% if (configFromCookie) { %>
|
||||||
|
<pre class="muted" style="white-space: pre-wrap; word-break: break-all;"><%= JSON.stringify(configFromCookie, null, 2) %></pre>
|
||||||
|
<% } else if (configCookieKey) { %>
|
||||||
|
<em class="muted">No fue posible parsear JSON. Valor crudo:</em>
|
||||||
|
<pre class="muted" style="white-space: pre-wrap; word-break: break-all;"><%= String(rawConfigCookie).slice(0, 2000) %></pre>
|
||||||
|
<% } else { %>
|
||||||
|
<em class="muted">—</em>
|
||||||
|
<% } %>
|
||||||
|
</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', { user: req.session.user, cookies: req.cookies, session: req.session })</code>
|
||||||
|
</p>
|
||||||
|
<% } %>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<h2>Cookies (navegador) + Config</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>
|
||||||
|
|
||||||
|
<h3 style="margin-top:14px;">Config detectada (navegador)</h3>
|
||||||
|
<p class="muted">Nombre: <code id="cfg-name-browser">(buscando…)</code></p>
|
||||||
|
<pre id="cfg-json-browser" class="muted" style="white-space: pre-wrap; word-break: break-all;">(sin config)</pre>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script id="__sc_boot__" type="application/json">
|
||||||
|
<%- JSON.stringify({ user: (typeof user !== 'undefined' ? user : null) }) %>
|
||||||
|
</script>
|
||||||
|
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// ============ CLIENTE ============ //
|
||||||
|
(function () {
|
||||||
|
try {
|
||||||
|
const tbody = document.querySelector('#client-cookies tbody');
|
||||||
|
const raw = document.cookie || '';
|
||||||
|
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 (las httpOnly no aparecen en el navegador)</em></td></tr>';
|
||||||
|
} else {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
function findConfigCookieName(tenantId32) {
|
||||||
|
const re = /^(?:sc\.)?(config|prefs|ui)(?:\.([0-9a-f]{32}))?$/i;
|
||||||
|
const list = (document.cookie || '')
|
||||||
|
.split(/;\s*/)
|
||||||
|
.map(s => s.split('=').shift());
|
||||||
|
if (tenantId32) {
|
||||||
|
const exact = list.find(n => {
|
||||||
|
const m = n.match(re);
|
||||||
|
return m && (m[2] || '').toLowerCase() === String(tenantId32).toLowerCase();
|
||||||
|
});
|
||||||
|
if (exact) return exact;
|
||||||
|
}
|
||||||
|
return list.find(n => re.test(n)) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCookie(name) {
|
||||||
|
if (!name) return null;
|
||||||
|
const m = document.cookie.match(new RegExp('(?:^|; )' + name.replace(/([.$?*|{}()[\]\\/+^])/g, '\\$1') + '=([^;]*)'));
|
||||||
|
return m ? decodeURIComponent(m[1]) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseConfig(val) {
|
||||||
|
if (!val) return null;
|
||||||
|
const candidates = [val];
|
||||||
|
try { candidates.push(decodeURIComponent(val)); } catch {}
|
||||||
|
try { candidates.push(atob(val)); } catch {}
|
||||||
|
for (const c of candidates) {
|
||||||
|
try { const obj = JSON.parse(c); if (obj && typeof obj === 'object') return obj; } catch {}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tenant del usuario embebido por el servidor (si existe)
|
||||||
|
// const serverUser = <%- JSON.stringify((typeof user !== 'undefined' ? user : null)) %>;
|
||||||
|
const bootNode = document.getElementById('__sc_boot__');
|
||||||
|
const boot = bootNode ? JSON.parse(bootNode.textContent || 'null') : null;
|
||||||
|
const serverUser = boot && typeof boot === 'object' ? boot.user ?? null : null;
|
||||||
|
const tenantId32 = serverUser && serverUser.default_tenant ? String(serverUser.default_tenant).toLowerCase() : null;
|
||||||
|
|
||||||
|
// Detectar/leer cookie de configuración (lado navegador)
|
||||||
|
const cfgName = findConfigCookieName(tenantId32);
|
||||||
|
document.getElementById('cfg-name-browser').textContent = cfgName || '(no encontrada)';
|
||||||
|
const cfgRaw = getCookie(cfgName);
|
||||||
|
const cfg = parseConfig(cfgRaw);
|
||||||
|
|
||||||
|
if (cfg) {
|
||||||
|
document.getElementById('cfg-json-browser').textContent = JSON.stringify(cfg, null, 2);
|
||||||
|
if (typeof cfg.theme === 'string') {
|
||||||
|
const theme = String(cfg.theme).toLowerCase() === 'light' ? 'light' : 'dark';
|
||||||
|
document.body.setAttribute('data-theme', theme);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
document.getElementById('cfg-json-browser').textContent = '(sin config o no parseable)';
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
// services/app/src/views/routes.js
|
||||||
|
|
||||||
|
import { Router } from 'express';
|
||||||
|
import { requireAuth } from '@suitecoffee/middlewares';
|
||||||
|
|
||||||
|
const SESSION_NAME = process.env.SESSION_NAME;
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
// Rutas de UI
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
|
||||||
|
router.get('/', async (req, res) => {
|
||||||
|
// combinamos cookies comunes + firmadas (signed se “desfirma”: queda el SID limpio)
|
||||||
|
res.locals.pageTitle = "Inicio2"; // Título de pestaña
|
||||||
|
res.locals.pageId = "inicio"; // Sidebar contextual
|
||||||
|
const mergedCookies = { ...(req.cookies || {}), ...(req.signedCookies || {}) };
|
||||||
|
|
||||||
|
res.render('inicio', {
|
||||||
|
user: req.session?.user ?? null,
|
||||||
|
session: req.session ?? {},
|
||||||
|
cookies: mergedCookies, // <-- lo que la vista va a leer
|
||||||
|
cookieName: SESSION_NAME, // <-- para no hardcodear 'sc.sid'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
/*
|
||||||
|
router.get('/comandas', (req, res) => {
|
||||||
|
res.locals.pageTitle = "Comandas";
|
||||||
|
res.locals.pageId = "comandas";
|
||||||
|
res.render("./pages/comandas");
|
||||||
|
// res.json({ ok: true, route: '/comandas' });
|
||||||
|
});
|
||||||
|
*/
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// Rutas de testeo de Cookies
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
/*
|
||||||
|
router.get('/inicio', requireAuth(), async (req, res) => {
|
||||||
|
res.locals.pageTitle = "Inicio2"; // Título de pestaña
|
||||||
|
res.locals.pageId = "inicio"; // Sidebar contextual
|
||||||
|
res.render("inicio", {
|
||||||
|
user: req.session?.user ?? null,
|
||||||
|
cookies: req.cookies ?? {},
|
||||||
|
session: req.session ?? {},
|
||||||
|
req, // para que el EJS pueda usar req.sessionID si quiere
|
||||||
|
});
|
||||||
|
});
|
||||||
|
*/
|
||||||
|
|
||||||
|
router.get('/inicio', requireAuth(), async (req, res) => {
|
||||||
|
// combinamos cookies comunes + firmadas (signed se “desfirma”: queda el SID limpio)
|
||||||
|
res.locals.pageTitle = "Inicio2"; // Título de pestaña
|
||||||
|
res.locals.pageId = "inicio"; // Sidebar contextual
|
||||||
|
const mergedCookies = { ...(req.cookies || {}), ...(req.signedCookies || {}) };
|
||||||
|
|
||||||
|
res.render('inicio', {
|
||||||
|
user: req.session?.user ?? null,
|
||||||
|
session: req.session ?? {},
|
||||||
|
cookies: mergedCookies, // <-- lo que la vista va a leer
|
||||||
|
cookieName: SESSION_NAME, // <-- para no hardcodear 'sc.sid'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/debug/tenant', (req, res) => {
|
||||||
|
res.json({
|
||||||
|
sid: req.sessionID ?? null,
|
||||||
|
hasSession: !!req.session,
|
||||||
|
user: req.session?.user
|
||||||
|
? {
|
||||||
|
sub: req.session.user.sub ?? null,
|
||||||
|
email: req.session.user.email ?? null,
|
||||||
|
default_tenant: req.session.user.default_tenant ?? req.session.user.defaultTenant ?? null,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
tenant: req.session?.tenant ?? null,
|
||||||
|
reqTenant: req.tenant ?? null,
|
||||||
|
accept: req.headers.accept,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -1,44 +1,54 @@
|
|||||||
# ===== Runtime =====
|
# =======================================================
|
||||||
|
# Runtime
|
||||||
NODE_ENV=development
|
NODE_ENV=development
|
||||||
PORT=4040
|
PORT=4040
|
||||||
|
APP_BASE_URL=https://dev.suitecoffee.uy
|
||||||
|
# =======================================================
|
||||||
|
|
||||||
# ===== Session (usa el Redis del stack) =====
|
|
||||||
# Para DEV podemos reutilizar el Redis de Authentik. En prod conviene uno separado.
|
|
||||||
|
# =======================================================
|
||||||
|
# Configuración de Dases de Datos
|
||||||
|
CORE_DB_HOST=dev-db
|
||||||
|
CORE_DB_NAME=dev_suitecoffee_core
|
||||||
|
CORE_DB_PORT=5432
|
||||||
|
CORE_DB_USER=dev-user-suitecoffee
|
||||||
|
CORE_DB_PASS=dev-pass-suitecoffee
|
||||||
|
|
||||||
|
TENANTS_DB_HOST=dev-tenants
|
||||||
|
TENANTS_DB_NAME=dev_suitecoffee_tenants
|
||||||
|
TENANTS_DB_PORT=5432
|
||||||
|
TENANTS_DB_USER=suitecoffee
|
||||||
|
TENANTS_DB_PASS=suitecoffee
|
||||||
|
# =======================================================
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# =======================================================
|
||||||
|
# Sesiones
|
||||||
SESSION_SECRET=Neon*Mammal*Boaster*Ludicrous*Fender8*Crablike
|
SESSION_SECRET=Neon*Mammal*Boaster*Ludicrous*Fender8*Crablike
|
||||||
SESSION_COOKIE_NAME=sc.sid
|
SESSION_NAME=sc.sid
|
||||||
REDIS_URL=redis://ak-redis:6379
|
COOKIE_DOMAIN=dev.suitecoffee.uy
|
||||||
|
# =======================================================
|
||||||
|
|
||||||
# ===== 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 y OIDC
|
||||||
|
AK_TOKEN=h2apVHbd3ApMcnnSwfQPXbvximkvP8HnUE25ot3zXWuEEtJFaNCcOzDHB6Xw
|
||||||
|
AK_REDIS_URL=redis://ak-redis:6379
|
||||||
|
|
||||||
# ===== Authentik — Admin API (server-to-server dentro de la red) =====
|
OIDC_LOGIN_URL=https://dev.sso.suitecoffee.uy
|
||||||
# Usa el alias de red del servicio 'authentik' y su puerto interno 9000
|
OIDC_REDIRECT_URI=https://dev.suitecoffee.uy/auth/callback
|
||||||
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_ID=1orMM8vOvf3WkN2FejXYvUFpPtONG0Lx1eMlwIpW
|
||||||
OIDC_CLIENT_SECRET=t5wx13qBcM0EFQ3cGnUIAmLzvbdsQrUVPv1OGWjszWkEp35pJQ55t7vZeeShqG49kuRAaiXv6PSGJLhRfGaponGaJl8gH1uCL7KIxdmm7UihgYoAXB2dFhZV4zRxfze2
|
OIDC_CLIENT_SECRET=t5wx13qBcM0EFQ3cGnUIAmLzvbdsQrUVPv1OGWjszWkEp35pJQ55t7vZeeShqG49kuRAaiXv6PSGJLhRfGaponGaJl8gH1uCL7KIxdmm7UihgYoAXB2dFhZV4zRxfze2
|
||||||
OIDC_REDIRECT_URI=https://suitecoffee.uy/auth/callback
|
|
||||||
|
|
||||||
OIDC_ENROLLMENT_URL=https://sso.suitecoffee.uy/if/flow/registro-suitecoffee/
|
OIDC_CONFIG_URL=https://dev.sso.suitecoffee.uy/application/o/suitecoffee/.well-known/openid-configuration
|
||||||
|
OIDC_AUTHORIZE_URL=https://dev.sso.suitecoffee.uy/application/o/authorize/
|
||||||
|
OIDC_TOKEN_URL=https://dev.sso.suitecoffee.uy/application/o/token/
|
||||||
|
OIDC_USERINFO_URL=https://dev.sso.suitecoffee.uy/application/o/userinfo/
|
||||||
|
OIDC_LOGOUT_URL=https://dev.sso.suitecoffee.uy/application/o/suitecoffee/end-session/
|
||||||
|
OIDC_JWKS_URL=https://dev.sso.suitecoffee.uy/application/o/suitecoffee/jwks/
|
||||||
|
|
||||||
|
# =======================================================
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
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
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user