Todos los Microservicios saludables.
Nuevo microservicio Plugins + cambios a microservicios anteriores, creación de módulos para conexiones a bases de datos y ajustes en las variables de entorno.
This commit is contained in:
parent
a31b411437
commit
ba6b4fef4f
@ -4,21 +4,37 @@ COMPOSE_PROJECT_NAME=suitecoffee_dev
|
||||
# Entorno de desarrollo
|
||||
NODE_ENV=development
|
||||
|
||||
# app - app
|
||||
APP_PORT=3030
|
||||
# auth - app
|
||||
AUTH_PORT=4040
|
||||
MANSO_PORT=1010 # MVP Manso Microservicio -> services/manso/src/index.mjs
|
||||
APP_PORT=3030 # Microservicio APP-> services/app/src/index.mjs
|
||||
AUTH_PORT=4040 # Microservicio AUTH -> services/auth/src/index.mjs
|
||||
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 principal (metadatos de SuiteCoffee) =====
|
||||
# Usa el alias de red del servicio 'db' (compose: aliases [dev-db])
|
||||
DB_HOST=dev-db
|
||||
DB_NAME=dev_suitecoffee_core
|
||||
DB_PORT=5432
|
||||
DB_USER=dev-user-suitecoffee
|
||||
DB_PASS=dev-pass-suitecoffee
|
||||
|
||||
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
|
||||
|
||||
# ===== DB tenants (Tenants de SuiteCoffee) =====
|
||||
TENANTS_HOST=dev-tenants
|
||||
TENANTS_DB=dev_suitecoffee_tenants
|
||||
TENANTS_PORT=5432
|
||||
TENANTS_USER=suitecoffee
|
||||
TENANTS_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
|
||||
# Authentik PostgreSQL Setup
|
||||
AK_HOST_DB=ak-db
|
||||
AK_PG_DB=authentik
|
||||
@ -26,12 +42,20 @@ 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_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 Bootstrap
|
||||
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,26 +1,23 @@
|
||||
# Archivo de variables de entorno para docker-compose.yml
|
||||
COMPOSE_PROJECT_NAME=suitecoffee_prod
|
||||
COMPOSE_PROJECT_NAME=suitecoffee_
|
||||
|
||||
# Entorno de desarrollo
|
||||
NODE_ENV=production
|
||||
|
||||
# app - app
|
||||
APP_PORT=3000
|
||||
|
||||
# auth - app
|
||||
AUTH_PORT=4000
|
||||
APP_PORT=3000 # Microservicio APP-> services/app/src/index.mjs
|
||||
AUTH_PORT=4000 # Microservicio AUTH -> services/auth/src/index.mjs
|
||||
PLUGIN_PORT=5000 # Microservicio PLUGINS-> services/plugins/src/index.mjs
|
||||
|
||||
# tenants - postgres
|
||||
TENANTS_DB_NAME=postgres
|
||||
TENANTS_DB_USER=postgres
|
||||
TENANTS_DB_PASS=postgres
|
||||
TENANTS_DB_NAME=suitecoffee_tenants
|
||||
TENANTS_DB_USER=suitecoffee
|
||||
TENANTS_DB_PASS=suitecoffee
|
||||
|
||||
# db primaria - postgres
|
||||
DB_NAME=suitecoffee
|
||||
DB_NAME=suitecoffee_core
|
||||
DB_USER=suitecoffee
|
||||
DB_PASS=suitecoffee
|
||||
|
||||
|
||||
# Authentik PostgreSQL Setup
|
||||
AK_HOST_DB=ak-db
|
||||
AK_PG_DB=authentik
|
||||
|
||||
@ -4,13 +4,12 @@
|
||||
services:
|
||||
|
||||
app:
|
||||
image: node:20-bookworm
|
||||
image: node:20.19.5-bookworm
|
||||
working_dir: /app
|
||||
user: "${UID:-1000}:${GID:-1000}"
|
||||
volumes:
|
||||
- ./services/app:/app:rw
|
||||
- ./services/app/node_modules:/app/node_modules
|
||||
# - ./services/shared:/app/shared
|
||||
env_file:
|
||||
- ./services/app/.env.development
|
||||
environment:
|
||||
@ -22,26 +21,43 @@ services:
|
||||
aliases: [dev-app]
|
||||
command: npm run dev
|
||||
|
||||
# auth:
|
||||
# image: node:20-bookworm
|
||||
# working_dir: /app
|
||||
# user: "${UID:-1000}:${GID:-1000}"
|
||||
# volumes:
|
||||
# - ./services/auth:/app:rw
|
||||
# - ./services/auth/node_modules:/app/node_modules
|
||||
# - ./services/shared:/app/shared
|
||||
# env_file:
|
||||
# - ./services/auth/.env.development
|
||||
# environment:
|
||||
# NODE_ENV: development # <- fuerza el entorno para que el loader tome .env.development
|
||||
# expose:
|
||||
# - ${AUTH_PORT}
|
||||
# networks:
|
||||
# net:
|
||||
# aliases: [dev-auth]
|
||||
# command: npm run dev
|
||||
plugins:
|
||||
image: node:20.19.5-bookworm
|
||||
working_dir: /app
|
||||
user: "${UID:-1000}:${GID:-1000}"
|
||||
volumes:
|
||||
- ./services/plugins:/app:rw
|
||||
- ./services/plugins/node_modules:/app/node_modules
|
||||
env_file:
|
||||
- ./services/plugins/.env.development
|
||||
environment:
|
||||
NODE_ENV: development # <- fuerza el entorno para que el loader tome .env.development
|
||||
expose:
|
||||
- ${PLUGINS_PORT}
|
||||
networks:
|
||||
net:
|
||||
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
|
||||
env_file:
|
||||
- ./services/auth/.env.development
|
||||
environment:
|
||||
NODE_ENV: development # <- fuerza el entorno para que el loader tome .env.development
|
||||
expose:
|
||||
- ${AUTH_PORT}
|
||||
networks:
|
||||
net:
|
||||
aliases: [dev-auth]
|
||||
command: npm run dev
|
||||
|
||||
dbCore:
|
||||
image: postgres:16
|
||||
environment:
|
||||
POSTGRES_DB: ${DB_NAME}
|
||||
@ -53,7 +69,7 @@ services:
|
||||
net:
|
||||
aliases: [dev-db]
|
||||
|
||||
tenants:
|
||||
dbTenants:
|
||||
image: postgres:16
|
||||
environment:
|
||||
POSTGRES_DB: ${TENANTS_DB_NAME}
|
||||
@ -108,7 +124,6 @@ services:
|
||||
|
||||
AUTHENTIK_BOOTSTRAP_PASSWORD: ${AUTHENTIK_BOOTSTRAP_PASSWORD}
|
||||
AUTHENTIK_BOOTSTRAP_EMAIL: ${AUTHENTIK_BOOTSTRAP_EMAIL}
|
||||
|
||||
AUTHENTIK_HTTP__TRUSTED_PROXY__CIDRS: "0.0.0.0/0,::/0"
|
||||
AUTHENTIK_SECURITY__CSRF_TRUSTED_ORIGINS: ${AUTHENTIK_SECURITY__CSRF_TRUSTED_ORIGINS}
|
||||
AUTHENTIK_COOKIE__DOMAIN: ${AUTHENTIK_COOKIE__DOMAIN}
|
||||
@ -117,7 +132,7 @@ services:
|
||||
aliases: [dev-authentik]
|
||||
volumes:
|
||||
- ./authentik-media:/media
|
||||
- ./authentik-custom-templates:/templates
|
||||
- ./authentik-custom-templates:/templates
|
||||
|
||||
ak-worker:
|
||||
image: ghcr.io/goauthentik/server:latest
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
# docker-compose.overrride.yml
|
||||
# compose.manso.yml
|
||||
# Docker Comose para entorno de desarrollo o development.
|
||||
|
||||
|
||||
@ -11,8 +11,8 @@ services:
|
||||
# condition: service_healthy
|
||||
# tenants:
|
||||
# condition: service_healthy
|
||||
# expose:
|
||||
# - ${APP_LOCAL_PORT}
|
||||
expose:
|
||||
- ${MANSO_PORT}
|
||||
working_dir: /app
|
||||
user: "${UID:-1000}:${GID:-1000}"
|
||||
volumes:
|
||||
@ -21,16 +21,16 @@ services:
|
||||
env_file:
|
||||
- ./services/manso/.env.development
|
||||
environment:
|
||||
- NODE_ENV=${NODE_ENV}
|
||||
NODE_ENV: development
|
||||
networks:
|
||||
net:
|
||||
aliases: [manso]
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "curl -fsS http://localhost:${APP_PORT}/health || exit 1"]
|
||||
interval: 10s
|
||||
timeout: 3s
|
||||
retries: 10
|
||||
start_period: 20s
|
||||
#healthcheck:
|
||||
# test: ["CMD-SHELL", "curl -fsS http://localhost:${MANSO_PORT}/health || exit 1"]
|
||||
# interval: 10s
|
||||
# timeout: 3s
|
||||
# retries: 10
|
||||
# start_period: 20s
|
||||
command: npm run dev
|
||||
profiles: [manso]
|
||||
restart: unless-stopped
|
||||
|
||||
@ -15,10 +15,27 @@ services:
|
||||
env_file:
|
||||
- ./services/app/.env.production
|
||||
environment:
|
||||
- NODE_ENV=${NODE_ENV}
|
||||
- NODE_ENV: production
|
||||
networks:
|
||||
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
|
||||
|
||||
auth:
|
||||
@ -32,40 +49,41 @@ services:
|
||||
env_file:
|
||||
- ./services/auth/.env.production
|
||||
environment:
|
||||
- NODE_ENV=${NODE_ENV}
|
||||
command: npm run start
|
||||
- NODE_ENV: production
|
||||
networks:
|
||||
net:
|
||||
aliases: [prod-auth]
|
||||
aliases: [auth]
|
||||
command: npm run start
|
||||
|
||||
db:
|
||||
dbCore:
|
||||
image: postgres:16
|
||||
environment:
|
||||
POSTGRES_DB: ${DB_NAME}
|
||||
POSTGRES_USER: ${DB_USER}
|
||||
POSTGRES_PASSWORD: ${DB_PASS}
|
||||
volumes:
|
||||
- suitecoffee-db:/var/lib/postgresql/data
|
||||
- dbCore_data:/var/lib/postgresql/data
|
||||
networks:
|
||||
net:
|
||||
aliases: [prod-db]
|
||||
aliases: [dbCore]
|
||||
|
||||
tenants:
|
||||
dbTenants:
|
||||
image: postgres:16
|
||||
environment:
|
||||
POSTGRES_DB: ${TENANTS_DB_NAME}
|
||||
POSTGRES_USER: ${TENANTS_DB_USER}
|
||||
POSTGRES_PASSWORD: ${TENANTS_DB_PASS}
|
||||
volumes:
|
||||
- tenants-db:/var/lib/postgresql/data
|
||||
- dbTenants_data:/var/lib/postgresql/data
|
||||
networks:
|
||||
net:
|
||||
aliases: [prod-tenants]
|
||||
aliases: [dbTenants]
|
||||
|
||||
falta implementar authentik en compose.prod.yaml
|
||||
|
||||
volumes:
|
||||
tenants-db:
|
||||
suitecoffee-db:
|
||||
|
||||
dbCore_data:
|
||||
dbTenants_data:
|
||||
networks:
|
||||
net:
|
||||
driver: bridge
|
||||
52
compose.yaml
52
compose.yaml
@ -5,33 +5,47 @@ name: ${COMPOSE_PROJECT_NAME:-suitecoffee}
|
||||
services:
|
||||
app:
|
||||
depends_on:
|
||||
db:
|
||||
dbCore:
|
||||
condition: service_healthy
|
||||
tenants:
|
||||
dbTenants:
|
||||
condition: service_healthy
|
||||
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"]
|
||||
interval: 10s
|
||||
timeout: 3s
|
||||
retries: 10
|
||||
start_period: 20s
|
||||
restart: unless-stopped
|
||||
|
||||
plugins:
|
||||
depends_on:
|
||||
app:
|
||||
condition: service_healthy
|
||||
auth:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "curl -fsS http://localhost:${PLUGINS_PORT}/health || exit 1"]
|
||||
interval: 10s
|
||||
timeout: 3s
|
||||
retries: 10
|
||||
start_period: 20s
|
||||
restart: unless-stopped
|
||||
|
||||
# auth:
|
||||
# depends_on:
|
||||
# db:
|
||||
# condition: service_healthy
|
||||
# ak:
|
||||
# condition: service_started
|
||||
# healthcheck:
|
||||
# test: ["CMD-SHELL", "curl -fsS http://localhost:${AUTH_PORT}/health || exit 1"]
|
||||
# interval: 10s
|
||||
# timeout: 3s
|
||||
# retries: 10
|
||||
# start_period: 15s
|
||||
# restart: unless-stopped
|
||||
auth:
|
||||
depends_on:
|
||||
dbCore:
|
||||
condition: service_healthy
|
||||
ak:
|
||||
condition: service_started
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "curl -fsS http://localhost:${AUTH_PORT}/health || exit 1"]
|
||||
interval: 10s
|
||||
timeout: 3s
|
||||
retries: 10
|
||||
start_period: 20s
|
||||
restart: unless-stopped
|
||||
|
||||
db:
|
||||
dbCore:
|
||||
image: postgres:16
|
||||
environment:
|
||||
TZ: America/Montevideo
|
||||
@ -43,7 +57,7 @@ services:
|
||||
start_period: 10s
|
||||
restart: unless-stopped
|
||||
|
||||
tenants:
|
||||
dbTenants:
|
||||
image: postgres:16
|
||||
environment:
|
||||
TZ: America/Montevideo
|
||||
@ -92,4 +106,4 @@ services:
|
||||
ak-redis:
|
||||
condition: service_healthy
|
||||
restart: unless-stopped
|
||||
|
||||
|
||||
|
||||
@ -2,6 +2,8 @@
|
||||
"name": "suitecoffee",
|
||||
"version": "1.0.0",
|
||||
"description": "Software para gestión de cafeterías",
|
||||
"private": true,
|
||||
"workspaces": [],
|
||||
"keywords": [
|
||||
"coffee",
|
||||
"suite",
|
||||
|
||||
16
packages/db/package.json
Normal file
16
packages/db/package.json
Normal file
@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "@suitecoffee/db",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
2
packages/db/src/index.mjs
Normal file
2
packages/db/src/index.mjs
Normal file
@ -0,0 +1,2 @@
|
||||
export * from './pool-registry.mjs';
|
||||
export * from './poolSingleton.mjs';
|
||||
54
packages/db/src/pool-registry.mjs
Normal file
54
packages/db/src/pool-registry.mjs
Normal file
@ -0,0 +1,54 @@
|
||||
import { Pool } from 'pg';
|
||||
|
||||
const REGISTRY = new Map();
|
||||
|
||||
export function getPool(name = 'core', cfg = {}) {
|
||||
if (REGISTRY.has(name)) return REGISTRY.get(name);
|
||||
|
||||
const pool = new Pool({
|
||||
connectionString: process.env.PG_URL,
|
||||
max: Number(process.env.PG_POOL_MAX ?? 10),
|
||||
idleTimeoutMillis: Number(process.env.PG_IDLE_MS ?? 30000),
|
||||
connectionTimeoutMillis: Number(process.env.PG_CONN_MS ?? 5000),
|
||||
statement_timeout: Number(process.env.PG_STMT_MS ?? 15000),
|
||||
ssl: process.env.PGSSL === 'true' ? { rejectUnauthorized: false } : undefined,
|
||||
...cfg
|
||||
});
|
||||
|
||||
pool.on('error', (err) => {
|
||||
// ideal: reemplazar con pino/sentry
|
||||
console.error(`[pg:${name}] pool error`, err);
|
||||
});
|
||||
|
||||
REGISTRY.set(name, pool);
|
||||
return pool;
|
||||
}
|
||||
|
||||
function assertTenantSchema(schema) {
|
||||
if (!/^tenant_[a-f0-9-]{16,36}$/i.test(schema)) {
|
||||
throw new Error('Invalid tenant schema');
|
||||
}
|
||||
return `"${schema.replace(/"/g, '""')}"`;
|
||||
}
|
||||
|
||||
export async function withTenant(poolName, tenantSchema, fn) {
|
||||
const pool = getPool(poolName);
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
await client.query(`SET LOCAL search_path TO ${assertTenantSchema(tenantSchema)}`);
|
||||
const res = await fn(client);
|
||||
await client.query('COMMIT');
|
||||
return res;
|
||||
} catch (e) {
|
||||
try { await client.query('ROLLBACK'); } catch {}
|
||||
throw e;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
export async function shutdownAll() {
|
||||
await Promise.all([...REGISTRY.values()].map(p => p.end()));
|
||||
REGISTRY.clear();
|
||||
}
|
||||
46
packages/db/src/poolSingleton.mjs
Normal file
46
packages/db/src/poolSingleton.mjs
Normal file
@ -0,0 +1,46 @@
|
||||
// Coneción Singleton a base de datos.
|
||||
|
||||
import { Pool } from 'pg';
|
||||
|
||||
class Database {
|
||||
constructor() {
|
||||
|
||||
if (Database.instance) {
|
||||
return Database.instance;
|
||||
}
|
||||
|
||||
const config = {
|
||||
host: process.env.DB_HOST,
|
||||
user: process.env.DB_USER,
|
||||
password: process.env.DB_PASS,
|
||||
database: process.env.DB_NAME,
|
||||
port: process.env.DB_LOCAL_PORT ? Number(process.env.DB_LOCAL_PORT) : undefined,
|
||||
ssl: process.env.PGSSL === 'true' ? { rejectUnauthorized: false } : undefined,
|
||||
};
|
||||
|
||||
this.connection = new Pool(config);
|
||||
|
||||
Database.instance = this;
|
||||
}
|
||||
async query(sql, params) {
|
||||
return this.connection.query(sql,params);
|
||||
}
|
||||
|
||||
async connect() { /* Definida solo para evitar errores */
|
||||
return this.connection.connect();
|
||||
}
|
||||
async getClient() {
|
||||
return this.connection.connect();
|
||||
}
|
||||
|
||||
async release() {
|
||||
await this.connection.end();
|
||||
}
|
||||
}
|
||||
|
||||
// const db = new Database();
|
||||
// db.query('SELECT * FROM users');
|
||||
|
||||
const pool = new Database();
|
||||
export default pool;
|
||||
export { Database };
|
||||
@ -2,37 +2,61 @@
|
||||
NODE_ENV=development
|
||||
PORT=3030
|
||||
|
||||
|
||||
# ===== Session (usa el Redis del stack) =====
|
||||
# Para DEV podemos reutilizar el Redis de Authentik. En prod conviene uno separado.
|
||||
SESSION_SECRET=Neon*Mammal*Boaster*Ludicrous*Fender8*Crablike
|
||||
SESSION_COOKIE_NAME=sc.sid
|
||||
REDIS_URL=redis://ak-redis:6379
|
||||
|
||||
# ===== DB principal (metadatos de SuiteCoffee) =====
|
||||
# Usa el alias de red del servicio 'db' (compose: aliases [dev-db])
|
||||
DB_HOST=dev-db
|
||||
DB_NAME=dev_suitecoffee_core
|
||||
DB_PORT=5432
|
||||
DB_NAME=dev-suitecoffee
|
||||
DB_USER=dev-user-suitecoffee
|
||||
DB_PASS=dev-pass-suitecoffee
|
||||
|
||||
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
|
||||
|
||||
# ===== DB tenants (Tenants de SuiteCoffee) =====
|
||||
TENANTS_HOST=dev-tenants
|
||||
TENANTS_DB=dev-postgres
|
||||
TENANTS_USER=dev-user-postgres
|
||||
TENANTS_PASS=dev-pass-postgres
|
||||
TENANTS_DB=dev_suitecoffee_tenants
|
||||
TENANTS_PORT=5432
|
||||
TENANTS_USER=suitecoffee
|
||||
TENANTS_PASS=suitecoffee
|
||||
|
||||
TENANT_INIT_SQL=/app/src/db/initTenant_v2.sql
|
||||
TENANTS_DB_HOST=dev-tenants
|
||||
TENANTS_DB_NAME=dev_suitecoffee_tenants
|
||||
TENANTS_DB_PORT=5432
|
||||
TENANTS_DB_USER=suitecoffee
|
||||
TENANTS_DB_PASS=suitecoffee
|
||||
|
||||
|
||||
# ===== Authentik — Admin API (server-to-server dentro de la red) =====
|
||||
# Usa el alias de red del servicio 'authentik' y su puerto interno 9000
|
||||
AK_TOKEN=h2apVHbd3ApMcnnSwfQPXbvximkvP8HnUE25ot3zXWuEEtJFaNCcOzDHB6Xw
|
||||
AK_REDIS_URL=redis://ak-redis:6379
|
||||
|
||||
# ===== 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.
|
||||
|
||||
APP_BASE_URL=https://suitecoffee.uy
|
||||
|
||||
OIDC_LOGIN_URL=https://sso.suitecoffee.uy
|
||||
OIDC_REDIRECT_URI = https://suitecoffee.uy/auth/callback
|
||||
|
||||
OIDC_CLIEN_ID=1orMM8vOvf3WkN2FejXYvUFpPtONG0Lx1eMlwIpW
|
||||
OIDC_CONFIG_URL=https://sso.suitecoffee.uy/application/o/suitecoffee/.well-known/openid-configuration
|
||||
OIDC_ISSUER=https://sso.suitecoffee.uy/application/o/suitecoffee/
|
||||
OIDC_ISSUER_DISCOVERY=https://sso.suitecoffee.uy/application/o/suitecoffee/.well-known/openid-configuration
|
||||
OIDC_AUTHORIZE_URL=https://sso.suitecoffee.uy/application/o/authorize/
|
||||
OIDC_TOKEN_URL=https://sso.suitecoffee.uy/application/o/token/
|
||||
OIDC_USERINFO_URL=https://sso.suitecoffee.uy/application/o/userinfo/
|
||||
OIDC_LOGOUT_URL=https://sso.suitecoffee.uy/application/o/suitecoffee/end-session/
|
||||
OIDC_JWKS_URL=https://sso.suitecoffee.uy/application/o/suitecoffee/jwks/
|
||||
|
||||
OIDC_LOGIN_URL=https://sso.suitecoffee.uy
|
||||
APP_BASE_URL=https://suitecoffee.uy
|
||||
OIDC_JWKS_URL=https://sso.suitecoffee.uy/application/o/suitecoffee/jwks/
|
||||
@ -1,5 +1,5 @@
|
||||
# Dockerfile.dev
|
||||
FROM node:22.18
|
||||
FROM node:20.19.5-bookworm
|
||||
|
||||
# Definir variables de entorno con valores predeterminados
|
||||
# ARG NODE_ENV=production
|
||||
|
||||
193
services/app/package-lock.json
generated
193
services/app/package-lock.json
generated
@ -24,6 +24,7 @@
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"jwks-rsa": "^3.2.0",
|
||||
"morgan": "^1.10.1",
|
||||
"node-appwrite": "^20.2.1",
|
||||
"node-fetch": "^3.3.2",
|
||||
"pg": "^8.16.3",
|
||||
"pg-format": "^1.0.4",
|
||||
@ -42,14 +43,10 @@
|
||||
},
|
||||
"node_modules/@ioredis/commands": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.3.1.tgz",
|
||||
"integrity": "sha512-bYtU8avhGIcje3IhvF9aSjsa5URMZBHnwKtOvXsT4sfYy9gppW11gLPT/9oNqlJZD47yPKveQFTAFWpHjKvUoQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@redis/bloom": {
|
||||
"version": "5.8.2",
|
||||
"resolved": "https://registry.npmjs.org/@redis/bloom/-/bloom-5.8.2.tgz",
|
||||
"integrity": "sha512-855DR0ChetZLarblio5eM0yLwxA9Dqq50t8StXKp5bAtLT0G+rZ+eRzzqxl37sPqQKjUudSYypz55o6nNhbz0A==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 18"
|
||||
@ -60,8 +57,6 @@
|
||||
},
|
||||
"node_modules/@redis/client": {
|
||||
"version": "5.8.2",
|
||||
"resolved": "https://registry.npmjs.org/@redis/client/-/client-5.8.2.tgz",
|
||||
"integrity": "sha512-WtMScno3+eBpTac1Uav2zugXEoXqaU23YznwvFgkPwBQVwEHTDgOG7uEAObtZ/Nyn8SmAMbqkEubJaMOvnqdsQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cluster-key-slot": "1.1.2"
|
||||
@ -72,8 +67,6 @@
|
||||
},
|
||||
"node_modules/@redis/json": {
|
||||
"version": "5.8.2",
|
||||
"resolved": "https://registry.npmjs.org/@redis/json/-/json-5.8.2.tgz",
|
||||
"integrity": "sha512-uxpVfas3I0LccBX9rIfDgJ0dBrUa3+0Gc8sEwmQQH0vHi7C1Rx1Qn8Nv1QWz5bohoeIXMICFZRcyDONvum2l/w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 18"
|
||||
@ -84,8 +77,6 @@
|
||||
},
|
||||
"node_modules/@redis/search": {
|
||||
"version": "5.8.2",
|
||||
"resolved": "https://registry.npmjs.org/@redis/search/-/search-5.8.2.tgz",
|
||||
"integrity": "sha512-cNv7HlgayavCBXqPXgaS97DRPVWFznuzsAmmuemi2TMCx5scwLiP50TeZvUS06h/MG96YNPe6A0Zt57yayfxwA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 18"
|
||||
@ -96,8 +87,6 @@
|
||||
},
|
||||
"node_modules/@redis/time-series": {
|
||||
"version": "5.8.2",
|
||||
"resolved": "https://registry.npmjs.org/@redis/time-series/-/time-series-5.8.2.tgz",
|
||||
"integrity": "sha512-g2NlHM07fK8H4k+613NBsk3y70R2JIM2dPMSkhIjl2Z17SYvaYKdusz85d7VYOrZBWtDrHV/WD2E3vGu+zni8A==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 18"
|
||||
@ -108,8 +97,6 @@
|
||||
},
|
||||
"node_modules/@types/body-parser": {
|
||||
"version": "1.19.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
|
||||
"integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/connect": "*",
|
||||
@ -118,8 +105,6 @@
|
||||
},
|
||||
"node_modules/@types/connect": {
|
||||
"version": "3.4.38",
|
||||
"resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz",
|
||||
"integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
@ -127,8 +112,6 @@
|
||||
},
|
||||
"node_modules/@types/express": {
|
||||
"version": "4.17.23",
|
||||
"resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.23.tgz",
|
||||
"integrity": "sha512-Crp6WY9aTYP3qPi2wGDo9iUe/rceX01UMhnF1jmwDcKCFM6cx7YhGP/Mpr3y9AASpfHixIG0E6azCcL5OcDHsQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/body-parser": "*",
|
||||
@ -139,8 +122,6 @@
|
||||
},
|
||||
"node_modules/@types/express-serve-static-core": {
|
||||
"version": "4.19.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.6.tgz",
|
||||
"integrity": "sha512-N4LZ2xG7DatVqhCZzOGb1Yi5lMbXSZcmdLDe9EzSndPV2HpWYWzRbaerl2n27irrm94EPpprqa8KpskPT085+A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*",
|
||||
@ -151,14 +132,10 @@
|
||||
},
|
||||
"node_modules/@types/http-errors": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz",
|
||||
"integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/jsonwebtoken": {
|
||||
"version": "9.0.10",
|
||||
"resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz",
|
||||
"integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/ms": "*",
|
||||
@ -167,20 +144,14 @@
|
||||
},
|
||||
"node_modules/@types/mime": {
|
||||
"version": "1.3.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz",
|
||||
"integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/ms": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
|
||||
"integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "24.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.3.1.tgz",
|
||||
"integrity": "sha512-3vXmQDXy+woz+gnrTvuvNrPzekOi+Ds0ReMxw0LzBiK3a+1k0kQn9f2NWk+lgD4rJehFUmYy2gMhJ2ZI+7YP9g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~7.10.0"
|
||||
@ -188,20 +159,14 @@
|
||||
},
|
||||
"node_modules/@types/qs": {
|
||||
"version": "6.14.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz",
|
||||
"integrity": "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/range-parser": {
|
||||
"version": "1.2.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz",
|
||||
"integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/send": {
|
||||
"version": "0.17.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.5.tgz",
|
||||
"integrity": "sha512-z6F2D3cOStZvuk2SaP6YrwkNO65iTZcwA2ZkSABegdkAh/lf+Aa/YQndZVfmEXT5vgAp6zv06VQ3ejSVjAny4w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/mime": "^1",
|
||||
@ -210,8 +175,6 @@
|
||||
},
|
||||
"node_modules/@types/serve-static": {
|
||||
"version": "1.15.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.8.tgz",
|
||||
"integrity": "sha512-roei0UY3LhpOJvjbIP6ZZFngyLKl5dskOtDhxY5THRSpO+ZI+nzJ+m5yUMzGrp89YRa7lvknKkMYjqQFGwA7Sg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/http-errors": "*",
|
||||
@ -252,8 +215,6 @@
|
||||
},
|
||||
"node_modules/basic-auth": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/basic-auth/-/basic-auth-2.0.1.tgz",
|
||||
"integrity": "sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safe-buffer": "5.1.2"
|
||||
@ -264,14 +225,10 @@
|
||||
},
|
||||
"node_modules/basic-auth/node_modules/safe-buffer": {
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
|
||||
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/bcrypt": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-6.0.0.tgz",
|
||||
"integrity": "sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@ -333,8 +290,6 @@
|
||||
},
|
||||
"node_modules/buffer-equal-constant-time": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
|
||||
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/bytes": {
|
||||
@ -404,8 +359,6 @@
|
||||
},
|
||||
"node_modules/cluster-key-slot": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz",
|
||||
"integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
@ -418,8 +371,6 @@
|
||||
},
|
||||
"node_modules/connect-redis": {
|
||||
"version": "9.0.0",
|
||||
"resolved": "https://registry.npmjs.org/connect-redis/-/connect-redis-9.0.0.tgz",
|
||||
"integrity": "sha512-QwzyvUePTMvEzG1hy45gZYw3X3YHrjmEdSkayURlcZft7hqadQ3X39wYkmCqblK2rGlw+XItELYt6GnyG6DEIQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
@ -448,6 +399,8 @@
|
||||
},
|
||||
"node_modules/cookie": {
|
||||
"version": "0.7.2",
|
||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
|
||||
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
@ -521,8 +474,6 @@
|
||||
},
|
||||
"node_modules/data-uri-to-buffer": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz",
|
||||
"integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 12"
|
||||
@ -545,8 +496,6 @@
|
||||
},
|
||||
"node_modules/denque": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz",
|
||||
"integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.10"
|
||||
@ -583,8 +532,6 @@
|
||||
},
|
||||
"node_modules/ecdsa-sig-formatter": {
|
||||
"version": "1.0.11",
|
||||
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
|
||||
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"safe-buffer": "^5.0.1"
|
||||
@ -694,8 +641,6 @@
|
||||
},
|
||||
"node_modules/express-session": {
|
||||
"version": "1.18.2",
|
||||
"resolved": "https://registry.npmjs.org/express-session/-/express-session-1.18.2.tgz",
|
||||
"integrity": "sha512-SZjssGQC7TzTs9rpPDuUrR23GNZ9+2+IkA/+IJWmvQilTr5OSliEHGF+D9scbIpdC6yGtTI0/VhaHoVes2AN/A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cookie": "0.7.2",
|
||||
@ -713,14 +658,10 @@
|
||||
},
|
||||
"node_modules/express-session/node_modules/cookie-signature": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
|
||||
"integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/express-session/node_modules/debug": {
|
||||
"version": "2.6.9",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
|
||||
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "2.0.0"
|
||||
@ -728,14 +669,10 @@
|
||||
},
|
||||
"node_modules/express-session/node_modules/ms": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
|
||||
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fetch-blob": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz",
|
||||
"integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@ -807,8 +744,6 @@
|
||||
},
|
||||
"node_modules/formdata-polyfill": {
|
||||
"version": "4.0.10",
|
||||
"resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz",
|
||||
"integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fetch-blob": "^3.1.2"
|
||||
@ -962,8 +897,6 @@
|
||||
},
|
||||
"node_modules/ioredis": {
|
||||
"version": "5.7.0",
|
||||
"resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.7.0.tgz",
|
||||
"integrity": "sha512-NUcA93i1lukyXU+riqEyPtSEkyFq8tX90uL659J+qpCZ3rEdViB/APC58oAhIh3+bJln2hzdlZbBZsGNrlsR8g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@ioredis/commands": "^1.3.0",
|
||||
@ -1055,8 +988,6 @@
|
||||
},
|
||||
"node_modules/jose": {
|
||||
"version": "6.1.0",
|
||||
"resolved": "https://registry.npmjs.org/jose/-/jose-6.1.0.tgz",
|
||||
"integrity": "sha512-TTQJyoEoKcC1lscpVDCSsVgYzUDg/0Bt3WE//WiTPK6uOCQC2KZS4MpugbMWt/zyjkopgZoXhZuCi00gLudfUA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/panva"
|
||||
@ -1064,8 +995,6 @@
|
||||
},
|
||||
"node_modules/jsonwebtoken": {
|
||||
"version": "9.0.2",
|
||||
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz",
|
||||
"integrity": "sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"jws": "^3.2.2",
|
||||
@ -1086,8 +1015,6 @@
|
||||
},
|
||||
"node_modules/jwa": {
|
||||
"version": "1.4.2",
|
||||
"resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.2.tgz",
|
||||
"integrity": "sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"buffer-equal-constant-time": "^1.0.1",
|
||||
@ -1097,8 +1024,6 @@
|
||||
},
|
||||
"node_modules/jwks-rsa": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/jwks-rsa/-/jwks-rsa-3.2.0.tgz",
|
||||
"integrity": "sha512-PwchfHcQK/5PSydeKCs1ylNym0w/SSv8a62DgHJ//7x2ZclCoinlsjAfDxAAbpoTPybOum/Jgy+vkvMmKz89Ww==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/express": "^4.17.20",
|
||||
@ -1114,8 +1039,6 @@
|
||||
},
|
||||
"node_modules/jwks-rsa/node_modules/jose": {
|
||||
"version": "4.15.9",
|
||||
"resolved": "https://registry.npmjs.org/jose/-/jose-4.15.9.tgz",
|
||||
"integrity": "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/panva"
|
||||
@ -1123,8 +1046,6 @@
|
||||
},
|
||||
"node_modules/jws": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz",
|
||||
"integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"jwa": "^1.4.1",
|
||||
@ -1132,74 +1053,50 @@
|
||||
}
|
||||
},
|
||||
"node_modules/limiter": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/limiter/-/limiter-1.1.5.tgz",
|
||||
"integrity": "sha512-FWWMIEOxz3GwUI4Ts/IvgVy6LPvoMPgjMdQ185nN6psJyBJ4yOpzqm695/h5umdLJg2vW3GR5iG11MAkR2AzJA=="
|
||||
"version": "1.1.5"
|
||||
},
|
||||
"node_modules/lodash.clonedeep": {
|
||||
"version": "4.5.0",
|
||||
"resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz",
|
||||
"integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.defaults": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz",
|
||||
"integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.includes": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
|
||||
"integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.isarguments": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz",
|
||||
"integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.isboolean": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
|
||||
"integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.isinteger": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
|
||||
"integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.isnumber": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz",
|
||||
"integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.isplainobject": {
|
||||
"version": "4.0.6",
|
||||
"resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
|
||||
"integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.isstring": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz",
|
||||
"integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.once": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
|
||||
"integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lru-cache": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
|
||||
"integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"yallist": "^4.0.0"
|
||||
@ -1210,8 +1107,6 @@
|
||||
},
|
||||
"node_modules/lru-memoizer": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/lru-memoizer/-/lru-memoizer-2.3.0.tgz",
|
||||
"integrity": "sha512-GXn7gyHAMhO13WSKrIiNfztwxodVsP8IoZ3XfrJV4yH2x0/OeTO/FIaAHTY5YekdGgW94njfuKmyyt1E0mR6Ug==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"lodash.clonedeep": "^4.5.0",
|
||||
@ -1272,8 +1167,6 @@
|
||||
},
|
||||
"node_modules/morgan": {
|
||||
"version": "1.10.1",
|
||||
"resolved": "https://registry.npmjs.org/morgan/-/morgan-1.10.1.tgz",
|
||||
"integrity": "sha512-223dMRJtI/l25dJKWpgij2cMtywuG/WiUKXdvwfbhGKBhy1puASqXwFzmWZ7+K73vUPoR7SS2Qz2cI/g9MKw0A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"basic-auth": "~2.0.1",
|
||||
@ -1288,8 +1181,6 @@
|
||||
},
|
||||
"node_modules/morgan/node_modules/debug": {
|
||||
"version": "2.6.9",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
|
||||
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "2.0.0"
|
||||
@ -1297,14 +1188,10 @@
|
||||
},
|
||||
"node_modules/morgan/node_modules/ms": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
|
||||
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/morgan/node_modules/on-finished": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz",
|
||||
"integrity": "sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ee-first": "1.1.1"
|
||||
@ -1326,18 +1213,22 @@
|
||||
},
|
||||
"node_modules/node-addon-api": {
|
||||
"version": "8.5.0",
|
||||
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.5.0.tgz",
|
||||
"integrity": "sha512-/bRZty2mXUIFY/xU5HLvveNHlswNJej+RnxBjOMkidWfwZzgTbPG1E3K5TOxRLOR+5hX7bSofy8yf1hZevMS8A==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^18 || ^20 || >= 21"
|
||||
}
|
||||
},
|
||||
"node_modules/node-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": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz",
|
||||
"integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==",
|
||||
"deprecated": "Use your platform's native DOMException instead",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@ -1355,8 +1246,6 @@
|
||||
},
|
||||
"node_modules/node-fetch": {
|
||||
"version": "3.3.2",
|
||||
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz",
|
||||
"integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"data-uri-to-buffer": "^4.0.0",
|
||||
@ -1371,10 +1260,14 @@
|
||||
"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": {
|
||||
"version": "4.8.4",
|
||||
"resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz",
|
||||
"integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"node-gyp-build": "bin.js",
|
||||
@ -1446,8 +1339,6 @@
|
||||
},
|
||||
"node_modules/on-headers": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz",
|
||||
"integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
@ -1484,6 +1375,8 @@
|
||||
},
|
||||
"node_modules/pg": {
|
||||
"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",
|
||||
"dependencies": {
|
||||
"pg-connection-string": "^2.9.1",
|
||||
@ -1509,11 +1402,15 @@
|
||||
},
|
||||
"node_modules/pg-cloudflare": {
|
||||
"version": "1.2.7",
|
||||
"resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.2.7.tgz",
|
||||
"integrity": "sha512-YgCtzMH0ptvZJslLM1ffsY4EuGaU0cx4XSdXLRFae8bPP4dS5xL1tNB3k2o/N64cHJpwU7dxKli/nZ2lUa5fLg==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/pg-connection-string": {
|
||||
"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"
|
||||
},
|
||||
"node_modules/pg-format": {
|
||||
@ -1525,6 +1422,8 @@
|
||||
},
|
||||
"node_modules/pg-int8": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
|
||||
"integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=4.0.0"
|
||||
@ -1532,6 +1431,8 @@
|
||||
},
|
||||
"node_modules/pg-pool": {
|
||||
"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",
|
||||
"peerDependencies": {
|
||||
"pg": ">=8.0"
|
||||
@ -1539,10 +1440,14 @@
|
||||
},
|
||||
"node_modules/pg-protocol": {
|
||||
"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"
|
||||
},
|
||||
"node_modules/pg-types": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
|
||||
"integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pg-int8": "1.0.1",
|
||||
@ -1557,6 +1462,8 @@
|
||||
},
|
||||
"node_modules/pgpass": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
|
||||
"integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"split2": "^4.1.0"
|
||||
@ -1579,6 +1486,8 @@
|
||||
},
|
||||
"node_modules/postgres-array": {
|
||||
"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",
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
@ -1586,6 +1495,8 @@
|
||||
},
|
||||
"node_modules/postgres-bytea": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.0.tgz",
|
||||
"integrity": "sha512-xy3pmLuQqRBZBXDULy7KbaitYqLcmxigw14Q5sj8QBVLqEwXfeybIKVWiqAXTlcvdvb0+xkOtDbfQMOf4lST1w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
@ -1593,6 +1504,8 @@
|
||||
},
|
||||
"node_modules/postgres-date": {
|
||||
"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",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
@ -1600,6 +1513,8 @@
|
||||
},
|
||||
"node_modules/postgres-interval": {
|
||||
"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",
|
||||
"dependencies": {
|
||||
"xtend": "^4.0.0"
|
||||
@ -1639,8 +1554,6 @@
|
||||
},
|
||||
"node_modules/random-bytes": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/random-bytes/-/random-bytes-1.0.0.tgz",
|
||||
"integrity": "sha512-iv7LhNVO047HzYR3InF6pUcUsPQiHTM1Qal51DcGSuZFBil1aBBWG5eHPNek7bvILMaYJ/8RU1e8w1AMdHmLQQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
@ -1679,8 +1592,6 @@
|
||||
},
|
||||
"node_modules/redis": {
|
||||
"version": "5.8.2",
|
||||
"resolved": "https://registry.npmjs.org/redis/-/redis-5.8.2.tgz",
|
||||
"integrity": "sha512-31vunZj07++Y1vcFGcnNWEf5jPoTkGARgfWI4+Tk55vdwHxhAvug8VEtW7Cx+/h47NuJTEg/JL77zAwC6E0OeA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@redis/bloom": "5.8.2",
|
||||
@ -1695,8 +1606,6 @@
|
||||
},
|
||||
"node_modules/redis-errors": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz",
|
||||
"integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
@ -1704,8 +1613,6 @@
|
||||
},
|
||||
"node_modules/redis-parser": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz",
|
||||
"integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"redis-errors": "^1.0.0"
|
||||
@ -1914,6 +1821,8 @@
|
||||
},
|
||||
"node_modules/split2": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
|
||||
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">= 10.x"
|
||||
@ -1921,8 +1830,6 @@
|
||||
},
|
||||
"node_modules/standard-as-callback": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz",
|
||||
"integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/statuses": {
|
||||
@ -1983,8 +1890,6 @@
|
||||
},
|
||||
"node_modules/uid-safe": {
|
||||
"version": "2.1.5",
|
||||
"resolved": "https://registry.npmjs.org/uid-safe/-/uid-safe-2.1.5.tgz",
|
||||
"integrity": "sha512-KPHm4VL5dDXKz01UuEd88Df+KzynaohSL9fBh096KWAxSKZQDI2uBrVqtvRM4rwrIrRRKsdLNML/lnaaVSRioA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"random-bytes": "~1.0.0"
|
||||
@ -2000,8 +1905,6 @@
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "7.10.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.10.0.tgz",
|
||||
"integrity": "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/unpipe": {
|
||||
@ -2020,8 +1923,6 @@
|
||||
},
|
||||
"node_modules/web-streams-polyfill": {
|
||||
"version": "3.3.3",
|
||||
"resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz",
|
||||
"integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 8"
|
||||
@ -2047,6 +1948,8 @@
|
||||
},
|
||||
"node_modules/xtend": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
|
||||
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.4"
|
||||
@ -2054,8 +1957,6 @@
|
||||
},
|
||||
"node_modules/yallist": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
|
||||
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
|
||||
"license": "ISC"
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,11 +1,11 @@
|
||||
{
|
||||
"name": "aplication",
|
||||
"version": "1.0.0",
|
||||
"main": "src/index.js",
|
||||
"main": "src/index.mjs",
|
||||
"scripts": {
|
||||
"start": "NODE_ENV=production node ./src/index.js",
|
||||
"dev": "NODE_ENV=development npx nodemon ./src/index.js",
|
||||
"test": "NODE_ENV=stage node ./src/index.js"
|
||||
"start": "NODE_ENV=production node ./src/index.mjs",
|
||||
"dev": "NODE_ENV=development npx nodemon ./src/index.mjs",
|
||||
"test": "NODE_ENV=stage node ./src/index.mjs"
|
||||
},
|
||||
"author": "Mateo Saldain",
|
||||
"license": "ISC",
|
||||
@ -30,12 +30,18 @@
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"jwks-rsa": "^3.2.0",
|
||||
"morgan": "^1.10.1",
|
||||
"node-appwrite": "^20.2.1",
|
||||
"node-fetch": "^3.3.2",
|
||||
"pg": "^8.16.3",
|
||||
"pg-format": "^1.0.4",
|
||||
"redis": "^5.8.2",
|
||||
"serve-favicon": "^2.5.1"
|
||||
},
|
||||
"keywords": [],
|
||||
"imports": {
|
||||
"#v1Router": "./src/api/v1/routes/routes.js",
|
||||
"#pages": "./src/pages/pages.js",
|
||||
"#db": "./src/db/poolSingleton.js"
|
||||
},
|
||||
"keywords": [],
|
||||
"description": ""
|
||||
}
|
||||
|
||||
340
services/app/src/api/v1/routes/routes.js
Normal file
340
services/app/src/api/v1/routes/routes.js
Normal file
@ -0,0 +1,340 @@
|
||||
// services/manso/src/api/v1/routes/routes.js
|
||||
|
||||
import { Router } from 'express';
|
||||
import pool from '#db'; // Pool Singleton
|
||||
const router = Router();
|
||||
|
||||
// ==========================================================
|
||||
// Rutas de API v1
|
||||
// ==========================================================
|
||||
|
||||
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// API Comandas
|
||||
// ----------------------------------------------------------
|
||||
|
||||
router.route('/comandas').get( async (req, res, next) => {
|
||||
try {
|
||||
var client = await pool.getClient()
|
||||
const estado = (req.query.estado || '').trim() || null;
|
||||
const limit = Math.min(parseInt(req.query.limit || '200', 10), 1000);
|
||||
|
||||
const { rows } = await client.query(
|
||||
`SELECT * FROM public.f_comandas_resumen($1, $2)`,
|
||||
[estado, limit]
|
||||
);
|
||||
res.json(rows);
|
||||
} catch (e) {
|
||||
next(e);
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
});
|
||||
|
||||
router.route('/comandas/:id/detalle').get( async (req, res, next) => {
|
||||
try {
|
||||
const client = await pool.getClient()
|
||||
client.query(
|
||||
`SELECT id_det_comanda, id_producto, producto_nombre,
|
||||
cantidad, pre_unitario, subtotal, observaciones
|
||||
FROM public.v_comandas_detalle_items
|
||||
WHERE id_comanda = $1::int
|
||||
ORDER BY id_det_comanda`,
|
||||
[req.params.id]
|
||||
)
|
||||
.then(r => res.json(r.rows))
|
||||
.catch(next)
|
||||
client.release();
|
||||
} catch (error) {
|
||||
next(e);
|
||||
}
|
||||
});
|
||||
|
||||
router.route('/comandas/:id/cerrar').post( async (req, res, next) => {
|
||||
try {
|
||||
const client = await pool.getClient()
|
||||
const id = Number(req.params.id);
|
||||
if (!Number.isInteger(id) || id <= 0) {
|
||||
return res.status(400).json({ error: 'id inválido' });
|
||||
}
|
||||
const { rows } = await client.query(
|
||||
`SELECT public.f_cerrar_comanda($1) AS data`,
|
||||
[id]
|
||||
);
|
||||
if (!rows.length || rows[0].data === null) {
|
||||
return res.status(404).json({ error: 'Comanda no encontrada' });
|
||||
}
|
||||
res.json(rows[0].data);
|
||||
client.release();
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
router.route('/comandas/:id/abrir').post( async (req, res, next) => {
|
||||
try {
|
||||
const client = await pool.getClient()
|
||||
const id = Number(req.params.id);
|
||||
if (!Number.isInteger(id) || id <= 0) {
|
||||
return res.status(400).json({ error: 'id inválido' });
|
||||
}
|
||||
const { rows } = await client.query(
|
||||
`SELECT public.f_abrir_comanda($1) AS data`,
|
||||
[id]
|
||||
);
|
||||
if (!rows.length || rows[0].data === null) {
|
||||
return res.status(404).json({ error: 'Comanda no encontrada' });
|
||||
}
|
||||
res.json(rows[0].data);
|
||||
client.release();
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// API Productos
|
||||
// ----------------------------------------------------------
|
||||
|
||||
// GET producto + receta
|
||||
router.route('/rpc/get_producto/:id').get( async (req, res) => {
|
||||
const client = await pool.getClient()
|
||||
const id = Number(req.params.id);
|
||||
const { rows } = await client.query('SELECT public.get_producto($1) AS data', [id]);
|
||||
res.json(rows[0]?.data || {});
|
||||
client.release();
|
||||
});
|
||||
|
||||
// POST guardar producto + receta
|
||||
router.route('/rpc/save_producto').post(async (req, res) => {
|
||||
try {
|
||||
// console.debug('receta payload:', req.body?.receta); // habilitalo si lo necesitás
|
||||
const client = await pool.getClient()
|
||||
const q = 'SELECT public.save_producto($1,$2,$3,$4,$5,$6,$7::jsonb) AS id_producto';
|
||||
const { id_producto=null, nombre, img_producto=null, precio=0, activo=true, id_categoria=null, receta=[] } = req.body || {};
|
||||
const params = [id_producto, nombre, img_producto, precio, activo, id_categoria, JSON.stringify(receta||[])];
|
||||
const { rows } = await client.query(q, params);
|
||||
res.json(rows[0] || {});
|
||||
client.release();
|
||||
} catch(e) {
|
||||
console.error(e);
|
||||
res.status(500).json({ error: 'save_producto failed' });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// API Materias Primas
|
||||
// ----------------------------------------------------------
|
||||
|
||||
// GET MP + proveedores
|
||||
router.route('/rpc/get_materia/:id').get(async (req, res) => {
|
||||
const id = Number(req.params.id);
|
||||
try {
|
||||
const client = await pool.getClient()
|
||||
const { rows } = await client.query('SELECT public.get_materia_prima($1) AS data', [id]);
|
||||
res.json(rows[0]?.data || {});
|
||||
client.release();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
res.status(500).json({ error: 'get_materia failed' });
|
||||
}
|
||||
});
|
||||
|
||||
// SAVE MP + proveedores (array)
|
||||
router.route('/rpc/save_materia').post( async (req, res) => {
|
||||
const { id_mat_prima=null, nombre, unidad, activo=true, proveedores=[] } = req.body || {};
|
||||
try {
|
||||
const q = 'SELECT public.save_materia_prima($1,$2,$3,$4,$5::jsonb) AS id_mat_prima';
|
||||
const params = [id_mat_prima, nombre, unidad, activo, JSON.stringify(proveedores||[])];
|
||||
const { rows } = await pool.query(q, params);
|
||||
res.json(rows[0] || {});
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
res.status(500).json({ error: 'save_materia failed' });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// API Usuarios y Asistencias
|
||||
// ----------------------------------------------------------
|
||||
|
||||
// POST /api/rpc/find_usuarios_por_documentos { docs: ["12345678","09123456", ...] }
|
||||
router.route('/rpc/find_usuarios_por_documentos').post( async (req, res) => {
|
||||
try {
|
||||
const docs = Array.isArray(req.body?.docs) ? req.body.docs : [];
|
||||
const sql = 'SELECT public.find_usuarios_por_documentos($1::jsonb) AS data';
|
||||
const { rows } = await pool.query(sql, [JSON.stringify(docs)]);
|
||||
res.json(rows[0]?.data || {});
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
res.status(500).json({ error: 'find_usuarios_por_documentos failed' });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/rpc/import_asistencia { registros: [...], origen?: "AGL_001.txt" }
|
||||
router.route('/rpc/import_asistencia').post( async (req, res) => {
|
||||
try {
|
||||
const registros = Array.isArray(req.body?.registros) ? req.body.registros : [];
|
||||
const origen = req.body?.origen || null;
|
||||
const sql = 'SELECT public.import_asistencia($1::jsonb,$2) AS data';
|
||||
const { rows } = await pool.query(sql, [JSON.stringify(registros), origen]);
|
||||
res.json(rows[0]?.data || {});
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
res.status(500).json({ error: 'import_asistencia failed' });
|
||||
}
|
||||
});
|
||||
|
||||
// Consultar datos de asistencia (raw + pares) para un usuario y rango
|
||||
router.route('/rpc/asistencia_get').post( async (req, res) => {
|
||||
try {
|
||||
const { doc, desde, hasta } = req.body || {};
|
||||
const sql = 'SELECT public.asistencia_get($1::text,$2::date,$3::date) AS data';
|
||||
const { rows } = await pool.query(sql, [doc, desde, hasta]);
|
||||
res.json(rows[0]?.data || {});
|
||||
} catch (e) {
|
||||
console.error(e); res.status(500).json({ error: 'asistencia_get failed' });
|
||||
}
|
||||
});
|
||||
|
||||
// Editar un registro crudo y recalcular pares
|
||||
router.route('/rpc/asistencia_update_raw').post( async (req, res) => {
|
||||
try {
|
||||
const { id_raw, fecha, hora, modo } = req.body || {};
|
||||
const sql = 'SELECT public.asistencia_update_raw($1::bigint,$2::date,$3::text,$4::text) AS data';
|
||||
const { rows } = await pool.query(sql, [id_raw, fecha, hora, modo ?? null]);
|
||||
res.json(rows[0]?.data || {});
|
||||
} catch (e) {
|
||||
console.error(e); res.status(500).json({ error: 'asistencia_update_raw failed' });
|
||||
}
|
||||
});
|
||||
|
||||
// Eliminar un registro crudo y recalcular pares
|
||||
router.route('/rpc/asistencia_delete_raw').post( async (req, res) => {
|
||||
try {
|
||||
const { id_raw } = req.body || {};
|
||||
const sql = 'SELECT public.asistencia_delete_raw($1::bigint) AS data';
|
||||
const { rows } = await pool.query(sql, [id_raw]);
|
||||
res.json(rows[0]?.data || {});
|
||||
} catch (e) {
|
||||
console.error(e); res.status(500).json({ error: 'asistencia_delete_raw failed' });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// API Reportes
|
||||
// ----------------------------------------------------------
|
||||
|
||||
// POST /api/rpc/report_tickets { year }
|
||||
router.route('/rpc/report_tickets').post( async (req, res) => {
|
||||
try {
|
||||
const y = parseInt(req.body?.year ?? req.query?.year, 10);
|
||||
const year = (Number.isFinite(y) && y >= 2000 && y <= 2100)
|
||||
? y
|
||||
: (new Date()).getFullYear();
|
||||
|
||||
const { rows } = await pool.query(
|
||||
'SELECT public.report_tickets_year($1::int) AS j', [year]
|
||||
);
|
||||
res.json(rows[0].j);
|
||||
} catch (e) {
|
||||
console.error('report_tickets error:', e);
|
||||
res.status(500).json({
|
||||
error: 'report_tickets failed',
|
||||
message: e.message, detail: e.detail, where: e.where, code: e.code
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/rpc/report_asistencia { desde: 'YYYY-MM-DD', hasta: 'YYYY-MM-DD' }
|
||||
router.route('/rpc/report_asistencia').post( async (req, res) => {
|
||||
try {
|
||||
let { desde, hasta } = req.body || {};
|
||||
// defaults si vienen vacíos/invalidos
|
||||
const re = /^\d{4}-\d{2}-\d{2}$/;
|
||||
if (!re.test(desde) || !re.test(hasta)) {
|
||||
const end = new Date();
|
||||
const start = new Date(end); start.setDate(end.getDate()-30);
|
||||
desde = start.toISOString().slice(0,10);
|
||||
hasta = end.toISOString().slice(0,10);
|
||||
}
|
||||
|
||||
const { rows } = await pool.query(
|
||||
'SELECT public.report_asistencia($1::date,$2::date) AS j', [desde, hasta]
|
||||
);
|
||||
res.json(rows[0].j);
|
||||
} catch (e) {
|
||||
console.error('report_asistencia error:', e);
|
||||
res.status(500).json({
|
||||
error: 'report_asistencia failed',
|
||||
message: e.message, detail: e.detail, where: e.where, code: e.code
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// API Compras y Gastos
|
||||
// ----------------------------------------------------------
|
||||
|
||||
// Guardar (insert/update)
|
||||
router.route('/rpc/save_compra').post( async (req, res) => {
|
||||
try {
|
||||
const { id_compra, id_proveedor, fec_compra, detalles } = req.body || {};
|
||||
const sql = 'SELECT * FROM public.save_compra($1::int,$2::int,$3::timestamptz,$4::jsonb)';
|
||||
const args = [id_compra ?? null, id_proveedor, fec_compra ? new Date(fec_compra) : null, JSON.stringify(detalles)];
|
||||
const { rows } = await pool.query(sql, args);
|
||||
res.json(rows[0]); // { id_compra, total }
|
||||
} catch (e) {
|
||||
console.error('save_compra error:', e);
|
||||
res.status(500).json({ error: 'save_compra failed', message: e.message, detail: e.detail, where: e.where, code: e.code });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// Obtener para editar
|
||||
router.route('/rpc/get_compra').post( async (req, res) => {
|
||||
try {
|
||||
const { id_compra } = req.body || {};
|
||||
const sql = `SELECT public.get_compra($1::int) AS data`;
|
||||
const { rows } = await pool.query(sql, [id_compra]);
|
||||
res.json(rows[0]?.data || {});
|
||||
} catch (e) {
|
||||
console.error(e); res.status(500).json({ error: 'get_compra failed' });
|
||||
}
|
||||
});
|
||||
|
||||
// Eliminar
|
||||
router.route('/rpc/delete_compra').post( async (req, res) => {
|
||||
try {
|
||||
const { id_compra } = req.body || {};
|
||||
await pool.query(`SELECT public.delete_compra($1::int)`, [id_compra]);
|
||||
res.json({ ok: true });
|
||||
} catch (e) {
|
||||
console.error(e); res.status(500).json({ error: 'delete_compra failed' });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// POST /api/rpc/report_gastos { year: 2025 }
|
||||
router.route('/rpc/report_gastos').post( async (req, res) => {
|
||||
try {
|
||||
const year = parseInt(req.body?.year ?? new Date().getFullYear(), 10);
|
||||
const { rows } = await pool.query(
|
||||
'SELECT public.report_gastos($1::int) AS j', [year]
|
||||
);
|
||||
res.json(rows[0].j);
|
||||
} catch (e) {
|
||||
console.error('report_gastos error:', e);
|
||||
res.status(500).json({
|
||||
error: 'report_gastos failed',
|
||||
message: e.message, detail: e.detail, code: e.code
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
export default router;
|
||||
83
services/app/src/db/poolSingleton.js
Normal file
83
services/app/src/db/poolSingleton.js
Normal file
@ -0,0 +1,83 @@
|
||||
// Coneción Singleton a base de datos.
|
||||
|
||||
import { Pool } from 'pg';
|
||||
|
||||
class DatabaseCore {
|
||||
constructor() {
|
||||
|
||||
if (DatabaseCore.instance) {
|
||||
return Database.instance;
|
||||
}
|
||||
|
||||
const config = {
|
||||
host: process.env.DB_HOST,
|
||||
user: process.env.DB_USER,
|
||||
password: process.env.DB_PASS,
|
||||
database: process.env.DB_NAME,
|
||||
port: process.env.DB_LOCAL_PORT ? Number(process.env.DB_LOCAL_PORT) : undefined,
|
||||
ssl: process.env.PGSSL === 'true' ? { rejectUnauthorized: false } : undefined,
|
||||
};
|
||||
|
||||
this.connection = new Pool(config);
|
||||
|
||||
DatabaseCore.instance = this;
|
||||
}
|
||||
async query(sql, params) {
|
||||
return this.connection.query(sql,params);
|
||||
}
|
||||
|
||||
async connect() { /* Definida solo para evitar errores */
|
||||
return this.connection.connect();
|
||||
}
|
||||
async getClient() {
|
||||
return this.connection.connect();
|
||||
}
|
||||
|
||||
async release() {
|
||||
await this.connection.end();
|
||||
}
|
||||
}
|
||||
class DatabaseTenants {
|
||||
constructor() {
|
||||
|
||||
if (DatabaseTenants.instance) {
|
||||
return Database.instance;
|
||||
}
|
||||
|
||||
const config = {
|
||||
host: process.env.DB_HOST,
|
||||
user: process.env.DB_USER,
|
||||
password: process.env.DB_PASS,
|
||||
database: process.env.DB_NAME,
|
||||
port: process.env.DB_LOCAL_PORT ? Number(process.env.DB_LOCAL_PORT) : undefined,
|
||||
ssl: process.env.PGSSL === 'true' ? { rejectUnauthorized: false } : undefined,
|
||||
};
|
||||
|
||||
this.connection = new Pool(config);
|
||||
|
||||
DatabaseTenants.instance = this;
|
||||
}
|
||||
async query(sql, params) {
|
||||
return this.connection.query(sql,params);
|
||||
}
|
||||
|
||||
async connect() { /* Definida solo para evitar errores */
|
||||
return this.connection.connect();
|
||||
}
|
||||
async getClient() {
|
||||
return this.connection.connect();
|
||||
}
|
||||
|
||||
async release() {
|
||||
await this.connection.end();
|
||||
}
|
||||
}
|
||||
|
||||
// const db = new Database();
|
||||
// db.query('SELECT * FROM users');
|
||||
|
||||
const poolCore = new DatabaseCore();
|
||||
const poolTenants = new DatabaseTenants();
|
||||
export default {poolCore, poolTenants};
|
||||
export { poolCore, poolTenants };
|
||||
//export { DatabaseCore, DatabaseTenants };
|
||||
@ -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();
|
||||
});
|
||||
|
||||
437
services/app/src/index.mjs
Normal file
437
services/app/src/index.mjs
Normal file
@ -0,0 +1,437 @@
|
||||
// services/app/src/index.js
|
||||
// ------------------------------------------------------------
|
||||
// SuiteCoffee — Aplicación Principal (Express)
|
||||
// ------------------------------------------------------------
|
||||
|
||||
import 'dotenv/config';
|
||||
import favicon from 'serve-favicon'; // Favicon
|
||||
import express from 'express'; // Framework para enderizado de apps Web
|
||||
import expressLayouts from 'express-ejs-layouts';
|
||||
// import { poolCore, poolTenants } from '@suitecoffee/db'; // dbCore y dbTenants desde módulo
|
||||
import { poolCore, poolTenants } from '#db'; // dbCore y dbTenants
|
||||
import v1Router from '#v1Router'; // Rutas API v1
|
||||
import expressPages from '#pages'; // 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)
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
// 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: \n\n-> ${missingKeys.join('\n-> ')}`+
|
||||
`\n`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
checkRequiredEnvVars(
|
||||
'PORT', 'APP_BASE_URL',
|
||||
'CORE_DB_HOST', 'CORE_DB_PORT', 'CORE_DB_NAME',
|
||||
'TENANTS_DB_HOST', 'TENANTS_DB_PORT', 'TENANTS_DB_NAME',
|
||||
|
||||
'OIDC_LOGIN_URL', 'OIDC_REDIRECT_URI',
|
||||
'OIDC_CLIEN_ID', 'OIDC_CONFIG_URL', 'OIDC_ISSUER',
|
||||
'OIDC_ISSUER_DISCOVERY', 'OIDC_AUTHORIZE_URL', 'OIDC_TOKEN_URL',
|
||||
'OIDC_USERINFO_URL', 'OIDC_LOGOUT_URL', 'OIDC_JWKS_URL',
|
||||
|
||||
'SESSION_SECRET', 'SESSION_COOKIE_NAME',
|
||||
'AK_REDIS_URL', 'AK_TOKEN'
|
||||
);
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// Variables del sistema
|
||||
// ----------------------------------------------------------
|
||||
|
||||
// De entorno
|
||||
const PORT = process.env.PORT;
|
||||
const APP_BASE_URL = process.env.APP_BASE_URL;
|
||||
|
||||
const CORE_DB_HOST = process.env.CORE_DB_HOST;
|
||||
const CORE_DB_PORT = process.env.CORE_DB_PORT;
|
||||
const CORE_DB_NAME = process.env.CORE_DB_NAME;
|
||||
|
||||
const TENANTS_DB_HOST = process.env.TENANTS_DB_HOST;
|
||||
const TENANTS_DB_PORT = process.env.TENANTS_DB_PORT;
|
||||
const TENANTS_DB_NAME = process.env.TENANTS_DB_NAME;
|
||||
|
||||
const OIDC_LOGIN_URL = process.env.OIDC_LOGIN_URL;
|
||||
const OIDC_REDIRECT_URI = process.env.OIDC_REDIRECT_URI;
|
||||
|
||||
const OIDC_CLIEN_ID = process.env.OIDC_CLIEN_ID;
|
||||
const OIDC_CONFIG_URL = process.env.OIDC_CONFIG_URL;
|
||||
const OIDC_ISSUER = process.env.OIDC_ISSUER;
|
||||
const OIDC_ISSUER_DISCOVERY = process.env.OIDC_ISSUER_DISCOVERY;
|
||||
const OIDC_AUTHORIZE_URL = process.env.OIDC_AUTHORIZE_URL;
|
||||
const OIDC_TOKEN_URL = process.env.OIDC_TOKEN_URL;
|
||||
const OIDC_USERINFO_URL = process.env.OIDC_USERINFO_URL;
|
||||
const OIDC_LOGOUT_URL = process.env.OIDC_LOGOUT_URL;
|
||||
const OIDC_JWKS_URL = process.env.OIDC_JWKS_URL;
|
||||
|
||||
const AK_SESSION_SECRET = process.env.AK_SESSION_SECRET;
|
||||
const AK_SESSION_COOKIE_NAME = process.env.AK_SESSION_COOKIE_NAME;
|
||||
const AK_REDIS_URL = process.env.AK_REDIS_URL;
|
||||
|
||||
|
||||
|
||||
const url = v => !v ? "" : (v.startsWith("http") ? v : `/img/productos/${v}`);
|
||||
const VALID_IDENT = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
|
||||
const q = (s) => `"${String(s).replace(/"/g, '""')}"`; // Identificadores SQL -> comillas dobles y escape correcto
|
||||
|
||||
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// App + Motor de vistas EJS
|
||||
// ----------------------------------------------------------
|
||||
|
||||
const app = express();
|
||||
app.set('trust proxy', true);
|
||||
app.set("views", path.join(__dirname, "views"));
|
||||
app.set("view engine", "ejs");
|
||||
app.set("layout", "layouts/main");
|
||||
app.disable("x-powered-by");
|
||||
|
||||
app.use(express.json());
|
||||
app.use(express.json({ limit: '1mb' }));
|
||||
app.use(express.urlencoded({ extended: true }));
|
||||
// Archivos estáticos que fuerzan la re-descarga de arhivos
|
||||
app.use(favicon(path.join(__dirname, 'public', 'favicon', 'favicon.ico'), { maxAge: '1y' }));
|
||||
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);
|
||||
app.use(cookieParser(process.env.SESSION_SECRET));
|
||||
app.use(expressPages); // Renderizado trae las paginas desde ./services/manso/src/routes/routes.js
|
||||
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// Uso de API v1
|
||||
// ----------------------------------------------------------
|
||||
app.use("/api/v1", v1Router);
|
||||
|
||||
// /api/rpc/get_producto/:id
|
||||
// /api/v1/rpc/get_producto/:id -> /rpc/get_producto/:id
|
||||
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// Seguridad: Tablas permitidas
|
||||
// ----------------------------------------------------------
|
||||
const ALLOWED_TABLES = [
|
||||
'roles','usuarios','usua_roles',
|
||||
'categorias','productos',
|
||||
'clientes','mesas',
|
||||
'comandas','deta_comandas',
|
||||
'proveedores','compras','deta_comp_producto',
|
||||
'mate_primas','deta_comp_materias',
|
||||
'prov_producto','prov_mate_prima',
|
||||
'receta_producto', 'asistencia_resumen_diario',
|
||||
'asistencia_intervalo', 'asistencia_detalle',
|
||||
'vw_compras'
|
||||
];
|
||||
|
||||
function ensureTable(name) {
|
||||
const t = String(name || '').toLowerCase();
|
||||
if (!ALLOWED_TABLES.includes(t)) throw new Error('Tabla no permitida');
|
||||
return t;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// Introspección de esquema
|
||||
// ----------------------------------------------------------
|
||||
async function loadColumns(client, table) {
|
||||
const sql = `
|
||||
SELECT
|
||||
c.column_name,
|
||||
c.data_type,
|
||||
c.is_nullable = 'YES' AS is_nullable,
|
||||
c.column_default,
|
||||
(SELECT EXISTS (
|
||||
SELECT 1 FROM pg_attribute a
|
||||
JOIN pg_class t ON t.oid = a.attrelid
|
||||
JOIN pg_index i ON i.indrelid = t.oid AND a.attnum = ANY(i.indkey)
|
||||
WHERE t.relname = $1 AND i.indisprimary AND a.attname = c.column_name
|
||||
)) AS is_primary,
|
||||
(SELECT a.attgenerated = 's' OR a.attidentity IN ('a','d')
|
||||
FROM pg_attribute a
|
||||
JOIN pg_class t ON t.oid = a.attrelid
|
||||
WHERE t.relname = $1 AND a.attname = c.column_name
|
||||
) AS is_identity
|
||||
FROM information_schema.columns c
|
||||
WHERE c.table_schema='public' AND c.table_name=$1
|
||||
ORDER BY c.ordinal_position
|
||||
`;
|
||||
const { rows } = await client.query(sql, [table]);
|
||||
return rows;
|
||||
}
|
||||
|
||||
async function loadForeignKeys(client, table) {
|
||||
const sql = `
|
||||
SELECT
|
||||
kcu.column_name,
|
||||
ccu.table_name AS foreign_table,
|
||||
ccu.column_name AS foreign_column
|
||||
FROM information_schema.table_constraints tc
|
||||
JOIN information_schema.key_column_usage kcu
|
||||
ON tc.constraint_name = kcu.constraint_name AND tc.table_schema = kcu.table_schema
|
||||
JOIN information_schema.constraint_column_usage ccu
|
||||
ON ccu.constraint_name = tc.constraint_name AND ccu.table_schema = tc.table_schema
|
||||
WHERE tc.table_schema='public' AND tc.table_name=$1 AND tc.constraint_type='FOREIGN KEY'
|
||||
`;
|
||||
const { rows } = await client.query(sql, [table]);
|
||||
const map = {};
|
||||
for (const r of rows) map[r.column_name] = { foreign_table: r.foreign_table, foreign_column: r.foreign_column };
|
||||
return map;
|
||||
}
|
||||
|
||||
async function loadPrimaryKey(client, table) {
|
||||
const sql = `
|
||||
SELECT a.attname AS column_name
|
||||
FROM pg_index i
|
||||
JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = ANY(i.indkey)
|
||||
JOIN pg_class t ON t.oid = i.indrelid
|
||||
WHERE t.relname = $1 AND i.indisprimary
|
||||
`;
|
||||
const { rows } = await client.query(sql, [table]);
|
||||
return rows.map(r => r.column_name);
|
||||
}
|
||||
|
||||
// label column for FK options
|
||||
async function pickLabelColumn(client, refTable) {
|
||||
const preferred = ['nombre','raz_social','apodo','documento','correo','telefono'];
|
||||
const { rows } = await client.query(
|
||||
`SELECT column_name, data_type
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema='public' AND table_name=$1
|
||||
ORDER BY ordinal_position`, [refTable]
|
||||
);
|
||||
for (const cand of preferred) {
|
||||
if (rows.find(r => r.column_name === cand)) return cand;
|
||||
}
|
||||
const textish = rows.find(r => /text|character varying|varchar/i.test(r.data_type));
|
||||
if (textish) return textish.column_name;
|
||||
return rows[0]?.column_name || 'id';
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// Middleware para datos globales
|
||||
// ----------------------------------------------------------
|
||||
app.use((req, res, next) => {
|
||||
res.locals.currentPath = req.path;
|
||||
res.locals.pageTitle = "SuiteCoffee";
|
||||
res.locals.pageId = "";
|
||||
next();
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// API
|
||||
// ----------------------------------------------------------
|
||||
app.get('/api/tables', async (_req, res) => {
|
||||
res.json(ALLOWED_TABLES);
|
||||
});
|
||||
|
||||
app.get('/api/schema/:table', async (req, res) => {
|
||||
try {
|
||||
const table = ensureTable(req.params.table);
|
||||
const client = await pool.getClient();
|
||||
try {
|
||||
const columns = await loadColumns(client, table);
|
||||
const fks = await loadForeignKeys(client, table);
|
||||
const enriched = columns.map(c => ({ ...c, foreign: fks[c.column_name] || null }));
|
||||
res.json({ table, columns: enriched });
|
||||
} finally { client.release(); }
|
||||
} catch (e) {
|
||||
res.status(400).json({ error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/options/:table/:column', async (req, res) => {
|
||||
try {
|
||||
const table = ensureTable(req.params.table);
|
||||
const column = req.params.column;
|
||||
if (!VALID_IDENT.test(column)) throw new Error('Columna inválida');
|
||||
|
||||
const client = await pool.getClient();
|
||||
try {
|
||||
const fks = await loadForeignKeys(client, table);
|
||||
const fk = fks[column];
|
||||
if (!fk) return res.json([]);
|
||||
|
||||
const refTable = fk.foreign_table;
|
||||
const refId = fk.foreign_column;
|
||||
const labelCol = await pickLabelColumn(client, refTable);
|
||||
|
||||
const sql = `SELECT ${q(refId)} AS id, ${q(labelCol)} AS label FROM ${q(refTable)} ORDER BY ${q(labelCol)} LIMIT 1000`;
|
||||
const result = await client.query(sql);
|
||||
res.json(result.rows);
|
||||
} finally { client.release(); }
|
||||
} catch (e) {
|
||||
res.status(400).json({ error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/table/:table', async (req, res) => {
|
||||
try {
|
||||
const table = ensureTable(req.params.table);
|
||||
const limit = Math.min(parseInt(req.query.limit || '100', 10), 1000);
|
||||
const client = await pool.getClient();
|
||||
try {
|
||||
const pks = await loadPrimaryKey(client, table);
|
||||
const orderBy = pks.length ? `ORDER BY ${pks.map(q).join(', ')} DESC` : '';
|
||||
const sql = `SELECT * FROM ${q(table)} ${orderBy} LIMIT ${limit}`;
|
||||
const result = await client.query(sql);
|
||||
|
||||
// Normalizar: siempre devolver objetos {col: valor}
|
||||
const colNames = result.fields.map(f => f.name);
|
||||
let rows = result.rows;
|
||||
if (rows.length && Array.isArray(rows[0])) {
|
||||
rows = rows.map(r => Object.fromEntries(r.map((v, i) => [colNames[i], v])));
|
||||
}
|
||||
res.json(rows);
|
||||
} finally { client.release(); }
|
||||
} catch (e) {
|
||||
res.status(400).json({ error: e.message, code: e.code, detail: e.detail });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/table/:table', async (req, res) => {
|
||||
const table = ensureTable(req.params.table);
|
||||
const payload = req.body || {};
|
||||
try {
|
||||
const client = await pool.getClient();
|
||||
try {
|
||||
const columns = await loadColumns(client, table);
|
||||
const insertable = columns.filter(c =>
|
||||
!c.is_primary && !c.is_identity && !(c.column_default || '').startsWith('nextval(')
|
||||
);
|
||||
const allowedCols = new Set(insertable.map(c => c.column_name));
|
||||
|
||||
const cols = [];
|
||||
const vals = [];
|
||||
const params = [];
|
||||
let idx = 1;
|
||||
for (const [k, v] of Object.entries(payload)) {
|
||||
if (!allowedCols.has(k)) continue;
|
||||
if (!VALID_IDENT.test(k)) continue;
|
||||
cols.push(q(k));
|
||||
vals.push(`$${idx++}`);
|
||||
params.push(v);
|
||||
}
|
||||
|
||||
if (!cols.length) {
|
||||
const { rows } = await client.query(`INSERT INTO ${q(table)} DEFAULT VALUES RETURNING *`);
|
||||
res.status(201).json({ inserted: rows[0] });
|
||||
} else {
|
||||
const { rows } = await client.query(
|
||||
`INSERT INTO ${q(table)} (${cols.join(', ')}) VALUES (${vals.join(', ')}) RETURNING *`,
|
||||
params
|
||||
);
|
||||
res.status(201).json({ inserted: rows[0] });
|
||||
}
|
||||
} catch (e) {
|
||||
if (e.code === '23503') return res.status(400).json({ error: 'Violación de clave foránea', detail: e.detail });
|
||||
if (e.code === '23505') return res.status(400).json({ error: 'Violación de unicidad', detail: e.detail });
|
||||
if (e.code === '23514') return res.status(400).json({ error: 'Violación de CHECK', detail: e.detail });
|
||||
if (e.code === '23502') return res.status(400).json({ error: 'Campo NOT NULL faltante', detail: e.detail });
|
||||
throw e;
|
||||
} finally { client.release(); }
|
||||
} catch (e) {
|
||||
res.status(400).json({ error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// Verificación de conexión
|
||||
// ----------------------------------------------------------
|
||||
|
||||
async function verificarConexionCore() {
|
||||
try {
|
||||
console.log(`[APP] Comprobando accesibilidad a la db ${CORE_DB_NAME} del host ${CORE_DB_HOST} ...`);
|
||||
const client = await poolCore.connect();
|
||||
const { rows } = await client.query('SELECT NOW() AS ahora');
|
||||
console.log(`\n[APP] Conexión con ${CORE_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 credenciales, accesos de red y firewall.');
|
||||
}
|
||||
}
|
||||
async function verificarConexionTenants() {
|
||||
try {
|
||||
console.log(`[APP] Comprobando accesibilidad a la db ${TENANTS_DB_NAME} del host ${TENANTS_DB_HOST} ...`);
|
||||
const client = await poolTenants.connect();
|
||||
const { rows } = await client.query('SELECT NOW() AS ahora');
|
||||
console.log(`\n[APP] Conexión con ${TENANTS_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 credenciales, accesos de red y firewall.');
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// 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(PORT, () => {
|
||||
console.log(`[APP] SuiteCoffee corriendo en http://localhost:${PORT}`);
|
||||
verificarConexionCore();
|
||||
verificarConexionTenants();
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Healthcheck
|
||||
// -----------------------------------------------------------------------------
|
||||
app.get('/health', (_req, res) => {
|
||||
res.status(200).json({ status: 'ok'}),
|
||||
console.log(`[APP] Saludable`)
|
||||
});
|
||||
67
services/app/src/pages/pages.js
Normal file
67
services/app/src/pages/pages.js
Normal file
@ -0,0 +1,67 @@
|
||||
// services/manso/src/api/v1/routes/routes.js
|
||||
|
||||
import { Router } from 'express';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// Rutas de UI
|
||||
// ----------------------------------------------------------
|
||||
|
||||
router.get('/', (req, res) => {
|
||||
res.locals.pageTitle = "Inicio"; // Título de pestaña
|
||||
res.locals.pageId = "home"; // Sidebar contextual
|
||||
res.render("dashboard"); // Archivo .ejs a renderizar
|
||||
// res.json({ ok: true, route: '/inicio' }); // Debug json
|
||||
});
|
||||
|
||||
router.get('/dashboard', (req, res) => {
|
||||
res.locals.pageTitle = "Dashboard";
|
||||
res.locals.pageId = "dashboard";
|
||||
res.render("dashboard");
|
||||
// res.json({ ok: true, route: '/dashboard' });
|
||||
});
|
||||
|
||||
router.get('/comandas', (req, res) => {
|
||||
res.locals.pageTitle = "Comandas";
|
||||
res.locals.pageId = "comandas";
|
||||
res.render("comandas");
|
||||
// res.json({ ok: true, route: '/comandas' });
|
||||
});
|
||||
|
||||
router.get('/estadoComandas', (req, res) => {
|
||||
res.locals.pageTitle = "Estado";
|
||||
res.locals.pageId = "estadoComandas";
|
||||
res.render("estadoComandas");
|
||||
// res.json({ ok: true, route: '/estadoComandas' });
|
||||
});
|
||||
|
||||
router.get('/productos', (req, res) => {
|
||||
res.locals.pageTitle = "Propductos";
|
||||
res.locals.pageId = "productos";
|
||||
res.render("productos");
|
||||
// res.json({ ok: true, route: '/productos' });
|
||||
});
|
||||
|
||||
router.get('/usuarios', (req, res) => {
|
||||
res.locals.pageTitle = "Usuarios";
|
||||
res.locals.pageId = "usuarios";
|
||||
res.render("usuarios");
|
||||
// res.json({ ok: true, route: '/usuarios' });
|
||||
});
|
||||
|
||||
router.get('/reportes', (req, res) => {
|
||||
res.locals.pageTitle = "Reportes";
|
||||
res.locals.pageId = "reportes";
|
||||
res.render("reportes");
|
||||
// res.json({ ok: true, route: '/reportes' });
|
||||
});
|
||||
|
||||
router.get('/compras', (req, res) => {
|
||||
res.locals.pageTitle = "Compras";
|
||||
res.locals.pageId = "compras";
|
||||
res.render("compras");
|
||||
// res.json({ ok: true, route: '/compras' });
|
||||
});
|
||||
|
||||
export default router;
|
||||
@ -2,43 +2,61 @@
|
||||
NODE_ENV=development
|
||||
PORT=4040
|
||||
|
||||
|
||||
# ===== Session (usa el Redis del stack) =====
|
||||
# Para DEV podemos reutilizar el Redis de Authentik. En prod conviene uno separado.
|
||||
SESSION_SECRET=Neon*Mammal*Boaster*Ludicrous*Fender8*Crablike
|
||||
SESSION_COOKIE_NAME=sc.sid
|
||||
REDIS_URL=redis://ak-redis:6379
|
||||
|
||||
# ===== DB principal (metadatos de SuiteCoffee) =====
|
||||
# Usa el alias de red del servicio 'db' (compose: aliases [dev-db])
|
||||
DB_HOST=dev-db
|
||||
DB_NAME=dev_suitecoffee_core
|
||||
DB_PORT=5432
|
||||
DB_NAME=dev-suitecoffee
|
||||
DB_USER=dev-user-suitecoffee
|
||||
DB_PASS=dev-pass-suitecoffee
|
||||
|
||||
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
|
||||
|
||||
# ===== DB tenants (Tenants de SuiteCoffee) =====
|
||||
TENANTS_HOST=dev-tenants
|
||||
TENANTS_DB=dev-postgres
|
||||
TENANTS_USER=dev-user-postgres
|
||||
TENANTS_PASS=dev-pass-postgres
|
||||
TENANTS_DB=dev_suitecoffee_tenants
|
||||
TENANTS_PORT=5432
|
||||
TENANTS_USER=suitecoffee
|
||||
TENANTS_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
|
||||
|
||||
TENANT_INIT_SQL=/app/src/db/initTenant_v2.sql
|
||||
|
||||
# ===== Authentik — Admin API (server-to-server dentro de la red) =====
|
||||
# Usa el alias de red del servicio 'authentik' y su puerto interno 9000
|
||||
AUTHENTIK_TOKEN=h2apVHbd3ApMcnnSwfQPXbvximkvP8HnUE25ot3zXWuEEtJFaNCcOzDHB6Xw
|
||||
AUTH_CALLBACK_URL=https://suitecoffee.uy/auth/callback
|
||||
AK_TOKEN=h2apVHbd3ApMcnnSwfQPXbvximkvP8HnUE25ot3zXWuEEtJFaNCcOzDHB6Xw
|
||||
AK_REDIS_URL=redis://ak-redis:6379
|
||||
|
||||
# ===== OIDC (DEBE coincidir con el Provider) =====
|
||||
# DEV (todo dentro de la red de Docker):
|
||||
# - El auth service redirige al navegador a este issuer. Si NO tenés reverse proxy hacia Authentik,
|
||||
# esta URL interna NO será accesible desde el navegador del host. En ese caso, ver nota más abajo.
|
||||
# AUTHENTIK_ISSUER=https://sso.suitecoffee.mateosaldain.uy/application/o/suitecoffee/
|
||||
AUTHENTIK_ISSUER=https://sso.suitecoffee.uy/application/o/suitecoffee/
|
||||
|
||||
OIDC_CLIENT_ID=1orMM8vOvf3WkN2FejXYvUFpPtONG0Lx1eMlwIpW
|
||||
OIDC_CLIENT_SECRET=t5wx13qBcM0EFQ3cGnUIAmLzvbdsQrUVPv1OGWjszWkEp35pJQ55t7vZeeShqG49kuRAaiXv6PSGJLhRfGaponGaJl8gH1uCL7KIxdmm7UihgYoAXB2dFhZV4zRxfze2
|
||||
OIDC_REDIRECT_URI=https://suitecoffee.uy/auth/callback
|
||||
APP_BASE_URL=https://suitecoffee.uy
|
||||
|
||||
OIDC_ENROLLMENT_URL=https://sso.suitecoffee.uy/if/flow/registro-suitecoffee/
|
||||
OIDC_LOGIN_URL=https://sso.suitecoffee.uy
|
||||
OIDC_REDIRECT_URI = https://suitecoffee.uy/auth/callback
|
||||
|
||||
OIDC_CLIEN_ID=1orMM8vOvf3WkN2FejXYvUFpPtONG0Lx1eMlwIpW
|
||||
OIDC_CONFIG_URL=https://sso.suitecoffee.uy/application/o/suitecoffee/.well-known/openid-configuration
|
||||
OIDC_ISSUER=https://sso.suitecoffee.uy/application/o/suitecoffee/
|
||||
OIDC_ISSUER_DISCOVERY=https://sso.suitecoffee.uy/application/o/suitecoffee/.well-known/openid-configuration
|
||||
OIDC_AUTHORIZE_URL=https://sso.suitecoffee.uy/application/o/authorize/
|
||||
OIDC_TOKEN_URL=https://sso.suitecoffee.uy/application/o/token/
|
||||
OIDC_USERINFO_URL=https://sso.suitecoffee.uy/application/o/userinfo/
|
||||
OIDC_LOGOUT_URL=https://sso.suitecoffee.uy/application/o/suitecoffee/end-session/
|
||||
OIDC_JWKS_URL=https://sso.suitecoffee.uy/application/o/suitecoffee/jwks/
|
||||
@ -1,5 +1,5 @@
|
||||
# Dockerfile.dev
|
||||
FROM node:22.18
|
||||
FROM node:20.19.5-bookworm
|
||||
|
||||
# Definir variables de entorno con valores predeterminados
|
||||
# ARG NODE_ENV=production
|
||||
|
||||
508
services/auth/package-lock.json
generated
508
services/auth/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -1,11 +1,11 @@
|
||||
{
|
||||
"name": "authentication",
|
||||
"version": "1.0.0",
|
||||
"main": "src/index.js",
|
||||
"main": "src/index.mjs",
|
||||
"scripts": {
|
||||
"start": "NODE_ENV=production node ./src/index.js",
|
||||
"dev": "NODE_ENV=development npx nodemon ./src/index.js",
|
||||
"test": "NODE_ENV=stage node ./src/index.js"
|
||||
"start": "NODE_ENV=production node ./src/index.mjs",
|
||||
"dev": "NODE_ENV=development npx nodemon ./src/index.mjs",
|
||||
"test": "NODE_ENV=stage node ./src/index.mjs"
|
||||
},
|
||||
"author": "Mateo Saldain",
|
||||
"license": "ISC",
|
||||
@ -19,6 +19,7 @@
|
||||
"bcrypt": "^5.1.1",
|
||||
"chalk": "^5.6.0",
|
||||
"connect-redis": "^9.0.0",
|
||||
"cookie-parser": "^1.4.7",
|
||||
"cookie-session": "^2.0.0",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^17.2.1",
|
||||
@ -30,12 +31,18 @@
|
||||
"jose": "^6.1.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"jwks-rsa": "^3.2.0",
|
||||
"node-appwrite": "^20.2.1",
|
||||
"node-fetch": "^3.3.2",
|
||||
"openid-client": "^5.7.1",
|
||||
"pg": "^8.16.3",
|
||||
"pg-format": "^1.0.4",
|
||||
"redis": "^5.8.2"
|
||||
},
|
||||
"imports": {
|
||||
"#v1Router": "./src/api/v1/routes/routes.js",
|
||||
"#pages": "./src/pages/pages.js",
|
||||
"#db": "./src/db/poolSingleton.js"
|
||||
},
|
||||
"keywords": [],
|
||||
"description": ""
|
||||
}
|
||||
|
||||
181
services/auth/src/api/api.js
Normal file
181
services/auth/src/api/api.js
Normal file
@ -0,0 +1,181 @@
|
||||
// // ----------------------------------------------------------
|
||||
// // API
|
||||
// // ----------------------------------------------------------
|
||||
// app.get('/api/tables', async (_req, res) => {
|
||||
// res.json(ALLOWED_TABLES);
|
||||
// });
|
||||
|
||||
// app.get('/api/schema/:table', async (req, res) => {
|
||||
// try {
|
||||
// const table = ensureTable(req.params.table);
|
||||
// const client = await getClient();
|
||||
// try {
|
||||
// const columns = await loadColumns(client, table);
|
||||
// const fks = await loadForeignKeys(client, table);
|
||||
// const enriched = columns.map(c => ({ ...c, foreign: fks[c.column_name] || null }));
|
||||
// res.json({ table, columns: enriched });
|
||||
// } finally { client.release(); }
|
||||
// } catch (e) {
|
||||
// res.status(400).json({ error: e.message });
|
||||
// }
|
||||
// });
|
||||
|
||||
// app.get('/api/options/:table/:column', async (req, res) => {
|
||||
// try {
|
||||
// const table = ensureTable(req.params.table);
|
||||
// const column = req.params.column;
|
||||
// if (!VALID_IDENT.test(column)) throw new Error('Columna inválida');
|
||||
|
||||
// const client = await getClient();
|
||||
// try {
|
||||
// const fks = await loadForeignKeys(client, table);
|
||||
// const fk = fks[column];
|
||||
// if (!fk) return res.json([]);
|
||||
|
||||
// const refTable = fk.foreign_table;
|
||||
// const refId = fk.foreign_column;
|
||||
// const labelCol = await pickLabelColumn(client, refTable);
|
||||
|
||||
// const sql = `SELECT ${q(refId)} AS id, ${q(labelCol)} AS label FROM ${q(refTable)} ORDER BY ${q(labelCol)} LIMIT 1000`;
|
||||
// const result = await client.query(sql);
|
||||
// res.json(result.rows);
|
||||
// } finally { client.release(); }
|
||||
// } catch (e) {
|
||||
// res.status(400).json({ error: e.message });
|
||||
// }
|
||||
// });
|
||||
|
||||
// app.get('/api/table/:table', async (req, res) => {
|
||||
// try {
|
||||
// const table = ensureTable(req.params.table);
|
||||
// const limit = Math.min(parseInt(req.query.limit || '100', 10), 1000);
|
||||
// const client = await getClient();
|
||||
// try {
|
||||
// const pks = await loadPrimaryKey(client, table);
|
||||
// const orderBy = pks.length ? `ORDER BY ${pks.map(q).join(', ')} DESC` : '';
|
||||
// const sql = `SELECT * FROM ${q(table)} ${orderBy} LIMIT ${limit}`;
|
||||
// const result = await client.query(sql);
|
||||
|
||||
// // Normalizar: siempre devolver objetos {col: valor}
|
||||
// const colNames = result.fields.map(f => f.name);
|
||||
// let rows = result.rows;
|
||||
// if (rows.length && Array.isArray(rows[0])) {
|
||||
// rows = rows.map(r => Object.fromEntries(r.map((v, i) => [colNames[i], v])));
|
||||
// }
|
||||
// res.json(rows);
|
||||
// } finally { client.release(); }
|
||||
// } catch (e) {
|
||||
// res.status(400).json({ error: e.message, code: e.code, detail: e.detail });
|
||||
// }
|
||||
// });
|
||||
|
||||
// app.post('/api/table/:table', async (req, res) => {
|
||||
// const table = ensureTable(req.params.table);
|
||||
// const payload = req.body || {};
|
||||
// try {
|
||||
// const client = await getClient();
|
||||
// try {
|
||||
// const columns = await loadColumns(client, table);
|
||||
// const insertable = columns.filter(c =>
|
||||
// !c.is_primary && !c.is_identity && !(c.column_default || '').startsWith('nextval(')
|
||||
// );
|
||||
// const allowedCols = new Set(insertable.map(c => c.column_name));
|
||||
|
||||
// const cols = [];
|
||||
// const vals = [];
|
||||
// const params = [];
|
||||
// let idx = 1;
|
||||
// for (const [k, v] of Object.entries(payload)) {
|
||||
// if (!allowedCols.has(k)) continue;
|
||||
// if (!VALID_IDENT.test(k)) continue;
|
||||
// cols.push(q(k));
|
||||
// vals.push(`$${idx++}`);
|
||||
// params.push(v);
|
||||
// }
|
||||
|
||||
// if (!cols.length) {
|
||||
// const { rows } = await client.query(`INSERT INTO ${q(table)} DEFAULT VALUES RETURNING *`);
|
||||
// res.status(201).json({ inserted: rows[0] });
|
||||
// } else {
|
||||
// const { rows } = await client.query(
|
||||
// `INSERT INTO ${q(table)} (${cols.join(', ')}) VALUES (${vals.join(', ')}) RETURNING *`,
|
||||
// params
|
||||
// );
|
||||
// res.status(201).json({ inserted: rows[0] });
|
||||
// }
|
||||
// } catch (e) {
|
||||
// if (e.code === '23503') return res.status(400).json({ error: 'Violación de clave foránea', detail: e.detail });
|
||||
// if (e.code === '23505') return res.status(400).json({ error: 'Violación de unicidad', detail: e.detail });
|
||||
// if (e.code === '23514') return res.status(400).json({ error: 'Violación de CHECK', detail: e.detail });
|
||||
// if (e.code === '23502') return res.status(400).json({ error: 'Campo NOT NULL faltante', detail: e.detail });
|
||||
// throw e;
|
||||
// }
|
||||
// } catch (e) {
|
||||
// res.status(400).json({ error: e.message });
|
||||
// }
|
||||
// });
|
||||
|
||||
// app.get('/api/comandas', async (req, res, next) => {
|
||||
// try {
|
||||
// const estado = (req.query.estado || '').trim() || null;
|
||||
// const limit = Math.min(parseInt(req.query.limit || '200', 10), 1000);
|
||||
|
||||
// const { rows } = await mainPool.query(
|
||||
// `SELECT * FROM public.f_comandas_resumen($1, $2)`,
|
||||
// [estado, limit]
|
||||
// );
|
||||
// res.json(rows);
|
||||
// } catch (e) { next(e); }
|
||||
// });
|
||||
|
||||
|
||||
// // Detalle de una comanda (con nombres de productos)
|
||||
// // GET /api/comandas/:id/detalle
|
||||
// app.get('/api/comandas/:id/detalle', (req, res, next) =>
|
||||
// mainPool.query(
|
||||
// `SELECT id_det_comanda, id_producto, producto_nombre,
|
||||
// cantidad, pre_unitario, subtotal, observaciones
|
||||
// FROM public.v_comandas_detalle_items
|
||||
// WHERE id_comanda = $1::int
|
||||
// ORDER BY id_det_comanda`,
|
||||
// [req.params.id]
|
||||
// )
|
||||
// .then(r => res.json(r.rows))
|
||||
// .catch(next)
|
||||
// );
|
||||
|
||||
// // Cerrar comanda (setea estado y fec_cierre en DB)
|
||||
// app.post('/api/comandas/:id/cerrar', async (req, res, next) => {
|
||||
// try {
|
||||
// const id = Number(req.params.id);
|
||||
// if (!Number.isInteger(id) || id <= 0) {
|
||||
// return res.status(400).json({ error: 'id inválido' });
|
||||
// }
|
||||
// const { rows } = await mainPool.query(
|
||||
// `SELECT public.f_cerrar_comanda($1) AS data`,
|
||||
// [id]
|
||||
// );
|
||||
// if (!rows.length || rows[0].data === null) {
|
||||
// return res.status(404).json({ error: 'Comanda no encontrada' });
|
||||
// }
|
||||
// res.json(rows[0].data);
|
||||
// } catch (err) { next(err); }
|
||||
// });
|
||||
|
||||
// Abrir (reabrir) comanda
|
||||
app.post('/api/comandas/:id/abrir', async (req, res, next) => {
|
||||
try {
|
||||
const id = Number(req.params.id);
|
||||
if (!Number.isInteger(id) || id <= 0) {
|
||||
return res.status(400).json({ error: 'id inválido' });
|
||||
}
|
||||
const { rows } = await mainPool.query(
|
||||
`SELECT public.f_abrir_comanda($1) AS data`,
|
||||
[id]
|
||||
);
|
||||
if (!rows.length || rows[0].data === null) {
|
||||
return res.status(404).json({ error: 'Comanda no encontrada' });
|
||||
}
|
||||
res.json(rows[0].data);
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
230
services/auth/src/api/rpc.js
Normal file
230
services/auth/src/api/rpc.js
Normal file
@ -0,0 +1,230 @@
|
||||
// // GET producto + receta
|
||||
// app.get('/api/rpc/get_producto/:id', async (req, res) => {
|
||||
// const id = Number(req.params.id);
|
||||
// const { rows } = await mainPool.query('SELECT public.get_producto($1) AS data', [id]);
|
||||
// res.json(rows[0]?.data || {});
|
||||
// });
|
||||
|
||||
// // POST guardar producto + receta
|
||||
|
||||
// app.post('/api/rpc/save_producto', async (req, res) => {
|
||||
// try {
|
||||
// // console.debug('receta payload:', req.body?.receta); // habilitalo si lo necesitás
|
||||
// const q = 'SELECT public.save_producto($1,$2,$3,$4,$5,$6,$7::jsonb) AS id_producto';
|
||||
// const { id_producto = null, nombre, img_producto = null, precio = 0, activo = true, id_categoria = null, receta = [] } = req.body || {};
|
||||
// const params = [id_producto, nombre, img_producto, precio, activo, id_categoria, JSON.stringify(receta || [])];
|
||||
// const { rows } = await mainPool.query(q, params);
|
||||
// res.json(rows[0] || {});
|
||||
// } catch (e) {
|
||||
// console.error(e);
|
||||
// res.status(500).json({ error: 'save_producto failed' });
|
||||
// }
|
||||
// });
|
||||
|
||||
// // GET MP + proveedores
|
||||
// app.get('/api/rpc/get_materia/:id', async (req, res) => {
|
||||
// const id = Number(req.params.id);
|
||||
// try {
|
||||
// const { rows } = await mainPool.query('SELECT public.get_materia_prima($1) AS data', [id]);
|
||||
// res.json(rows[0]?.data || {});
|
||||
// } catch (e) {
|
||||
// console.error(e);
|
||||
// res.status(500).json({ error: 'get_materia failed' });
|
||||
// }
|
||||
// });
|
||||
|
||||
// // SAVE MP + proveedores (array)
|
||||
// app.post('/api/rpc/save_materia', async (req, res) => {
|
||||
// const { id_mat_prima = null, nombre, unidad, activo = true, proveedores = [] } = req.body || {};
|
||||
// try {
|
||||
// const q = 'SELECT public.save_materia_prima($1,$2,$3,$4,$5::jsonb) AS id_mat_prima';
|
||||
// const params = [id_mat_prima, nombre, unidad, activo, JSON.stringify(proveedores || [])];
|
||||
// const { rows } = await mainPool.query(q, params);
|
||||
// res.json(rows[0] || {});
|
||||
// } catch (e) {
|
||||
// console.error(e);
|
||||
// res.status(500).json({ error: 'save_materia failed' });
|
||||
// }
|
||||
// });
|
||||
|
||||
// // POST /api/rpc/find_usuarios_por_documentos { docs: ["12345678","09123456", ...] }
|
||||
// app.post('/api/rpc/find_usuarios_por_documentos', async (req, res) => {
|
||||
// try {
|
||||
// const docs = Array.isArray(req.body?.docs) ? req.body.docs : [];
|
||||
// const sql = 'SELECT public.find_usuarios_por_documentos($1::jsonb) AS data';
|
||||
// const { rows } = await mainPool.query(sql, [JSON.stringify(docs)]);
|
||||
// res.json(rows[0]?.data || {});
|
||||
// } catch (e) {
|
||||
// console.error(e);
|
||||
// res.status(500).json({ error: 'find_usuarios_por_documentos failed' });
|
||||
// }
|
||||
// });
|
||||
|
||||
// // POST /api/rpc/import_asistencia { registros: [...], origen?: "AGL_001.txt" }
|
||||
// app.post('/api/rpc/import_asistencia', async (req, res) => {
|
||||
// try {
|
||||
// const registros = Array.isArray(req.body?.registros) ? req.body.registros : [];
|
||||
// const origen = req.body?.origen || null;
|
||||
// const sql = 'SELECT public.import_asistencia($1::jsonb,$2) AS data';
|
||||
// const { rows } = await mainPool.query(sql, [JSON.stringify(registros), origen]);
|
||||
// res.json(rows[0]?.data || {});
|
||||
// } catch (e) {
|
||||
// console.error(e);
|
||||
// res.status(500).json({ error: 'import_asistencia failed' });
|
||||
// }
|
||||
// });
|
||||
|
||||
// // Consultar datos de asistencia (raw + pares) para un usuario y rango
|
||||
// app.post('/api/rpc/asistencia_get', async (req, res) => {
|
||||
// try {
|
||||
// const { doc, desde, hasta } = req.body || {};
|
||||
// const sql = 'SELECT public.asistencia_get($1::text,$2::date,$3::date) AS data';
|
||||
// const { rows } = await mainPool.query(sql, [doc, desde, hasta]);
|
||||
// res.json(rows[0]?.data || {});
|
||||
// } catch (e) {
|
||||
// console.error(e); res.status(500).json({ error: 'asistencia_get failed' });
|
||||
// }
|
||||
// });
|
||||
|
||||
// // Editar un registro crudo y recalcular pares
|
||||
// app.post('/api/rpc/asistencia_update_raw', async (req, res) => {
|
||||
// try {
|
||||
// const { id_raw, fecha, hora, modo } = req.body || {};
|
||||
// const sql = 'SELECT public.asistencia_update_raw($1::bigint,$2::date,$3::text,$4::text) AS data';
|
||||
// const { rows } = await mainPool.query(sql, [id_raw, fecha, hora, modo ?? null]);
|
||||
// res.json(rows[0]?.data || {});
|
||||
// } catch (e) {
|
||||
// console.error(e); res.status(500).json({ error: 'asistencia_update_raw failed' });
|
||||
// }
|
||||
// });
|
||||
|
||||
// // Eliminar un registro crudo y recalcular pares
|
||||
// app.post('/api/rpc/asistencia_delete_raw', async (req, res) => {
|
||||
// try {
|
||||
// const { id_raw } = req.body || {};
|
||||
// const sql = 'SELECT public.asistencia_delete_raw($1::bigint) AS data';
|
||||
// const { rows } = await mainPool.query(sql, [id_raw]);
|
||||
// res.json(rows[0]?.data || {});
|
||||
// } catch (e) {
|
||||
// console.error(e); res.status(500).json({ error: 'asistencia_delete_raw failed' });
|
||||
// }
|
||||
// });
|
||||
|
||||
// // POST /api/rpc/report_tickets { year }
|
||||
// app.post('/api/rpc/report_tickets', async (req, res) => {
|
||||
// try {
|
||||
// const y = parseInt(req.body?.year ?? req.query?.year, 10);
|
||||
// const year = (Number.isFinite(y) && y >= 2000 && y <= 2100)
|
||||
// ? y
|
||||
// : (new Date()).getFullYear();
|
||||
|
||||
// const { rows } = await mainPool.query(
|
||||
// 'SELECT public.report_tickets_year($1::int) AS j', [year]
|
||||
// );
|
||||
// res.json(rows[0].j);
|
||||
// } catch (e) {
|
||||
// console.error('report_tickets error:', e);
|
||||
// res.status(500).json({
|
||||
// error: 'report_tickets failed',
|
||||
// message: e.message, detail: e.detail, where: e.where, code: e.code
|
||||
// });
|
||||
// }
|
||||
// });
|
||||
|
||||
// // POST /api/rpc/report_asistencia { desde: 'YYYY-MM-DD', hasta: 'YYYY-MM-DD' }
|
||||
// app.post('/api/rpc/report_asistencia', async (req, res) => {
|
||||
// try {
|
||||
// let { desde, hasta } = req.body || {};
|
||||
// // defaults si vienen vacíos/invalidos
|
||||
// const re = /^\d{4}-\d{2}-\d{2}$/;
|
||||
// if (!re.test(desde) || !re.test(hasta)) {
|
||||
// const end = new Date();
|
||||
// const start = new Date(end); start.setDate(end.getDate() - 30);
|
||||
// desde = start.toISOString().slice(0, 10);
|
||||
// hasta = end.toISOString().slice(0, 10);
|
||||
// }
|
||||
|
||||
// const { rows } = await mainPool.query(
|
||||
// 'SELECT public.report_asistencia($1::date,$2::date) AS j', [desde, hasta]
|
||||
// );
|
||||
// res.json(rows[0].j);
|
||||
// } catch (e) {
|
||||
// console.error('report_asistencia error:', e);
|
||||
// res.status(500).json({
|
||||
// error: 'report_asistencia failed',
|
||||
// message: e.message, detail: e.detail, where: e.where, code: e.code
|
||||
// });
|
||||
// }
|
||||
// });
|
||||
|
||||
// // Guardar (insert/update)
|
||||
// app.post('/api/rpc/save_compra', async (req, res) => {
|
||||
// try {
|
||||
// const { id_compra, id_proveedor, fec_compra, detalles } = req.body || {};
|
||||
// const sql = 'SELECT * FROM public.save_compra($1::int,$2::int,$3::timestamptz,$4::jsonb)';
|
||||
// const args = [id_compra ?? null, id_proveedor, fec_compra ? new Date(fec_compra) : null, JSON.stringify(detalles)];
|
||||
// const { rows } = await mainPool.query(sql, args);
|
||||
// res.json(rows[0]); // { id_compra, total }
|
||||
// } catch (e) {
|
||||
// console.error('save_compra error:', e);
|
||||
// res.status(500).json({ error: 'save_compra failed', message: e.message, detail: e.detail, where: e.where, code: e.code });
|
||||
// }
|
||||
// });
|
||||
|
||||
// // Obtener para editar
|
||||
// app.post('/api/rpc/get_compra', async (req, res) => {
|
||||
// try {
|
||||
// const { id_compra } = req.body || {};
|
||||
// const sql = `SELECT public.get_compra($1::int) AS data`;
|
||||
// const { rows } = await mainPool.query(sql, [id_compra]);
|
||||
// res.json(rows[0]?.data || {});
|
||||
// } catch (e) {
|
||||
// console.error(e); res.status(500).json({ error: 'get_compra failed' });
|
||||
// }
|
||||
// });
|
||||
|
||||
// // Eliminar
|
||||
// app.post('/api/rpc/delete_compra', async (req, res) => {
|
||||
// try {
|
||||
// const { id_compra } = req.body || {};
|
||||
// await mainPool.query(`SELECT public.delete_compra($1::int)`, [id_compra]);
|
||||
// res.json({ ok: true });
|
||||
// } catch (e) {
|
||||
// console.error(e); res.status(500).json({ error: 'delete_compra failed' });
|
||||
// }
|
||||
// });
|
||||
|
||||
// // POST /api/rpc/report_gastos { year: 2025 }
|
||||
// app.post('/api/rpc/report_gastos', async (req, res) => {
|
||||
// try {
|
||||
// const year = parseInt(req.body?.year ?? new Date().getFullYear(), 10);
|
||||
// const { rows } = await mainPool.query(
|
||||
// 'SELECT public.report_gastos($1::int) AS j', [year]
|
||||
// );
|
||||
// res.json(rows[0].j);
|
||||
// } catch (e) {
|
||||
// console.error('report_gastos error:', e);
|
||||
// res.status(500).json({
|
||||
// error: 'report_gastos failed',
|
||||
// message: e.message, detail: e.detail, code: e.code
|
||||
// });
|
||||
// }
|
||||
// });
|
||||
|
||||
// // (Opcional) GET para probar rápido desde el navegador:
|
||||
// // /api/rpc/report_gastos?year=2025
|
||||
// app.get('/api/rpc/report_gastos', async (req, res) => {
|
||||
// try {
|
||||
// const year = parseInt(req.query.year ?? new Date().getFullYear(), 10);
|
||||
// const { rows } = await mainPool.query(
|
||||
// 'SELECT public.report_gastos($1::int) AS j', [year]
|
||||
// );
|
||||
// res.json(rows[0].j);
|
||||
// } catch (e) {
|
||||
// console.error('report_gastos error:', e);
|
||||
// res.status(500).json({
|
||||
// error: 'report_gastos failed',
|
||||
// message: e.message, detail: e.detail, code: e.code
|
||||
// });
|
||||
// }
|
||||
// });
|
||||
340
services/auth/src/api/v1/routes/routes.js
Normal file
340
services/auth/src/api/v1/routes/routes.js
Normal file
@ -0,0 +1,340 @@
|
||||
// services/manso/src/api/v1/routes/routes.js
|
||||
|
||||
import { Router } from 'express';
|
||||
import pool from '#db'; // Pool Singleton
|
||||
const router = Router();
|
||||
|
||||
// ==========================================================
|
||||
// Rutas de API v1
|
||||
// ==========================================================
|
||||
|
||||
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// API Comandas
|
||||
// ----------------------------------------------------------
|
||||
|
||||
router.route('/comandas').get( async (req, res, next) => {
|
||||
try {
|
||||
var client = await pool.getClient()
|
||||
const estado = (req.query.estado || '').trim() || null;
|
||||
const limit = Math.min(parseInt(req.query.limit || '200', 10), 1000);
|
||||
|
||||
const { rows } = await client.query(
|
||||
`SELECT * FROM public.f_comandas_resumen($1, $2)`,
|
||||
[estado, limit]
|
||||
);
|
||||
res.json(rows);
|
||||
} catch (e) {
|
||||
next(e);
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
});
|
||||
|
||||
router.route('/comandas/:id/detalle').get( async (req, res, next) => {
|
||||
try {
|
||||
const client = await pool.getClient()
|
||||
client.query(
|
||||
`SELECT id_det_comanda, id_producto, producto_nombre,
|
||||
cantidad, pre_unitario, subtotal, observaciones
|
||||
FROM public.v_comandas_detalle_items
|
||||
WHERE id_comanda = $1::int
|
||||
ORDER BY id_det_comanda`,
|
||||
[req.params.id]
|
||||
)
|
||||
.then(r => res.json(r.rows))
|
||||
.catch(next)
|
||||
client.release();
|
||||
} catch (error) {
|
||||
next(e);
|
||||
}
|
||||
});
|
||||
|
||||
router.route('/comandas/:id/cerrar').post( async (req, res, next) => {
|
||||
try {
|
||||
const client = await pool.getClient()
|
||||
const id = Number(req.params.id);
|
||||
if (!Number.isInteger(id) || id <= 0) {
|
||||
return res.status(400).json({ error: 'id inválido' });
|
||||
}
|
||||
const { rows } = await client.query(
|
||||
`SELECT public.f_cerrar_comanda($1) AS data`,
|
||||
[id]
|
||||
);
|
||||
if (!rows.length || rows[0].data === null) {
|
||||
return res.status(404).json({ error: 'Comanda no encontrada' });
|
||||
}
|
||||
res.json(rows[0].data);
|
||||
client.release();
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
router.route('/comandas/:id/abrir').post( async (req, res, next) => {
|
||||
try {
|
||||
const client = await pool.getClient()
|
||||
const id = Number(req.params.id);
|
||||
if (!Number.isInteger(id) || id <= 0) {
|
||||
return res.status(400).json({ error: 'id inválido' });
|
||||
}
|
||||
const { rows } = await client.query(
|
||||
`SELECT public.f_abrir_comanda($1) AS data`,
|
||||
[id]
|
||||
);
|
||||
if (!rows.length || rows[0].data === null) {
|
||||
return res.status(404).json({ error: 'Comanda no encontrada' });
|
||||
}
|
||||
res.json(rows[0].data);
|
||||
client.release();
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// API Productos
|
||||
// ----------------------------------------------------------
|
||||
|
||||
// GET producto + receta
|
||||
router.route('/rpc/get_producto/:id').get( async (req, res) => {
|
||||
const client = await pool.getClient()
|
||||
const id = Number(req.params.id);
|
||||
const { rows } = await client.query('SELECT public.get_producto($1) AS data', [id]);
|
||||
res.json(rows[0]?.data || {});
|
||||
client.release();
|
||||
});
|
||||
|
||||
// POST guardar producto + receta
|
||||
router.route('/rpc/save_producto').post(async (req, res) => {
|
||||
try {
|
||||
// console.debug('receta payload:', req.body?.receta); // habilitalo si lo necesitás
|
||||
const client = await pool.getClient()
|
||||
const q = 'SELECT public.save_producto($1,$2,$3,$4,$5,$6,$7::jsonb) AS id_producto';
|
||||
const { id_producto=null, nombre, img_producto=null, precio=0, activo=true, id_categoria=null, receta=[] } = req.body || {};
|
||||
const params = [id_producto, nombre, img_producto, precio, activo, id_categoria, JSON.stringify(receta||[])];
|
||||
const { rows } = await client.query(q, params);
|
||||
res.json(rows[0] || {});
|
||||
client.release();
|
||||
} catch(e) {
|
||||
console.error(e);
|
||||
res.status(500).json({ error: 'save_producto failed' });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// API Materias Primas
|
||||
// ----------------------------------------------------------
|
||||
|
||||
// GET MP + proveedores
|
||||
router.route('/rpc/get_materia/:id').get(async (req, res) => {
|
||||
const id = Number(req.params.id);
|
||||
try {
|
||||
const client = await pool.getClient()
|
||||
const { rows } = await client.query('SELECT public.get_materia_prima($1) AS data', [id]);
|
||||
res.json(rows[0]?.data || {});
|
||||
client.release();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
res.status(500).json({ error: 'get_materia failed' });
|
||||
}
|
||||
});
|
||||
|
||||
// SAVE MP + proveedores (array)
|
||||
router.route('/rpc/save_materia').post( async (req, res) => {
|
||||
const { id_mat_prima=null, nombre, unidad, activo=true, proveedores=[] } = req.body || {};
|
||||
try {
|
||||
const q = 'SELECT public.save_materia_prima($1,$2,$3,$4,$5::jsonb) AS id_mat_prima';
|
||||
const params = [id_mat_prima, nombre, unidad, activo, JSON.stringify(proveedores||[])];
|
||||
const { rows } = await pool.query(q, params);
|
||||
res.json(rows[0] || {});
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
res.status(500).json({ error: 'save_materia failed' });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// API Usuarios y Asistencias
|
||||
// ----------------------------------------------------------
|
||||
|
||||
// POST /api/rpc/find_usuarios_por_documentos { docs: ["12345678","09123456", ...] }
|
||||
router.route('/rpc/find_usuarios_por_documentos').post( async (req, res) => {
|
||||
try {
|
||||
const docs = Array.isArray(req.body?.docs) ? req.body.docs : [];
|
||||
const sql = 'SELECT public.find_usuarios_por_documentos($1::jsonb) AS data';
|
||||
const { rows } = await pool.query(sql, [JSON.stringify(docs)]);
|
||||
res.json(rows[0]?.data || {});
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
res.status(500).json({ error: 'find_usuarios_por_documentos failed' });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/rpc/import_asistencia { registros: [...], origen?: "AGL_001.txt" }
|
||||
router.route('/rpc/import_asistencia').post( async (req, res) => {
|
||||
try {
|
||||
const registros = Array.isArray(req.body?.registros) ? req.body.registros : [];
|
||||
const origen = req.body?.origen || null;
|
||||
const sql = 'SELECT public.import_asistencia($1::jsonb,$2) AS data';
|
||||
const { rows } = await pool.query(sql, [JSON.stringify(registros), origen]);
|
||||
res.json(rows[0]?.data || {});
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
res.status(500).json({ error: 'import_asistencia failed' });
|
||||
}
|
||||
});
|
||||
|
||||
// Consultar datos de asistencia (raw + pares) para un usuario y rango
|
||||
router.route('/rpc/asistencia_get').post( async (req, res) => {
|
||||
try {
|
||||
const { doc, desde, hasta } = req.body || {};
|
||||
const sql = 'SELECT public.asistencia_get($1::text,$2::date,$3::date) AS data';
|
||||
const { rows } = await pool.query(sql, [doc, desde, hasta]);
|
||||
res.json(rows[0]?.data || {});
|
||||
} catch (e) {
|
||||
console.error(e); res.status(500).json({ error: 'asistencia_get failed' });
|
||||
}
|
||||
});
|
||||
|
||||
// Editar un registro crudo y recalcular pares
|
||||
router.route('/rpc/asistencia_update_raw').post( async (req, res) => {
|
||||
try {
|
||||
const { id_raw, fecha, hora, modo } = req.body || {};
|
||||
const sql = 'SELECT public.asistencia_update_raw($1::bigint,$2::date,$3::text,$4::text) AS data';
|
||||
const { rows } = await pool.query(sql, [id_raw, fecha, hora, modo ?? null]);
|
||||
res.json(rows[0]?.data || {});
|
||||
} catch (e) {
|
||||
console.error(e); res.status(500).json({ error: 'asistencia_update_raw failed' });
|
||||
}
|
||||
});
|
||||
|
||||
// Eliminar un registro crudo y recalcular pares
|
||||
router.route('/rpc/asistencia_delete_raw').post( async (req, res) => {
|
||||
try {
|
||||
const { id_raw } = req.body || {};
|
||||
const sql = 'SELECT public.asistencia_delete_raw($1::bigint) AS data';
|
||||
const { rows } = await pool.query(sql, [id_raw]);
|
||||
res.json(rows[0]?.data || {});
|
||||
} catch (e) {
|
||||
console.error(e); res.status(500).json({ error: 'asistencia_delete_raw failed' });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// API Reportes
|
||||
// ----------------------------------------------------------
|
||||
|
||||
// POST /api/rpc/report_tickets { year }
|
||||
router.route('/rpc/report_tickets').post( async (req, res) => {
|
||||
try {
|
||||
const y = parseInt(req.body?.year ?? req.query?.year, 10);
|
||||
const year = (Number.isFinite(y) && y >= 2000 && y <= 2100)
|
||||
? y
|
||||
: (new Date()).getFullYear();
|
||||
|
||||
const { rows } = await pool.query(
|
||||
'SELECT public.report_tickets_year($1::int) AS j', [year]
|
||||
);
|
||||
res.json(rows[0].j);
|
||||
} catch (e) {
|
||||
console.error('report_tickets error:', e);
|
||||
res.status(500).json({
|
||||
error: 'report_tickets failed',
|
||||
message: e.message, detail: e.detail, where: e.where, code: e.code
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/rpc/report_asistencia { desde: 'YYYY-MM-DD', hasta: 'YYYY-MM-DD' }
|
||||
router.route('/rpc/report_asistencia').post( async (req, res) => {
|
||||
try {
|
||||
let { desde, hasta } = req.body || {};
|
||||
// defaults si vienen vacíos/invalidos
|
||||
const re = /^\d{4}-\d{2}-\d{2}$/;
|
||||
if (!re.test(desde) || !re.test(hasta)) {
|
||||
const end = new Date();
|
||||
const start = new Date(end); start.setDate(end.getDate()-30);
|
||||
desde = start.toISOString().slice(0,10);
|
||||
hasta = end.toISOString().slice(0,10);
|
||||
}
|
||||
|
||||
const { rows } = await pool.query(
|
||||
'SELECT public.report_asistencia($1::date,$2::date) AS j', [desde, hasta]
|
||||
);
|
||||
res.json(rows[0].j);
|
||||
} catch (e) {
|
||||
console.error('report_asistencia error:', e);
|
||||
res.status(500).json({
|
||||
error: 'report_asistencia failed',
|
||||
message: e.message, detail: e.detail, where: e.where, code: e.code
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// API Compras y Gastos
|
||||
// ----------------------------------------------------------
|
||||
|
||||
// Guardar (insert/update)
|
||||
router.route('/rpc/save_compra').post( async (req, res) => {
|
||||
try {
|
||||
const { id_compra, id_proveedor, fec_compra, detalles } = req.body || {};
|
||||
const sql = 'SELECT * FROM public.save_compra($1::int,$2::int,$3::timestamptz,$4::jsonb)';
|
||||
const args = [id_compra ?? null, id_proveedor, fec_compra ? new Date(fec_compra) : null, JSON.stringify(detalles)];
|
||||
const { rows } = await pool.query(sql, args);
|
||||
res.json(rows[0]); // { id_compra, total }
|
||||
} catch (e) {
|
||||
console.error('save_compra error:', e);
|
||||
res.status(500).json({ error: 'save_compra failed', message: e.message, detail: e.detail, where: e.where, code: e.code });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// Obtener para editar
|
||||
router.route('/rpc/get_compra').post( async (req, res) => {
|
||||
try {
|
||||
const { id_compra } = req.body || {};
|
||||
const sql = `SELECT public.get_compra($1::int) AS data`;
|
||||
const { rows } = await pool.query(sql, [id_compra]);
|
||||
res.json(rows[0]?.data || {});
|
||||
} catch (e) {
|
||||
console.error(e); res.status(500).json({ error: 'get_compra failed' });
|
||||
}
|
||||
});
|
||||
|
||||
// Eliminar
|
||||
router.route('/rpc/delete_compra').post( async (req, res) => {
|
||||
try {
|
||||
const { id_compra } = req.body || {};
|
||||
await pool.query(`SELECT public.delete_compra($1::int)`, [id_compra]);
|
||||
res.json({ ok: true });
|
||||
} catch (e) {
|
||||
console.error(e); res.status(500).json({ error: 'delete_compra failed' });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// POST /api/rpc/report_gastos { year: 2025 }
|
||||
router.route('/rpc/report_gastos').post( async (req, res) => {
|
||||
try {
|
||||
const year = parseInt(req.body?.year ?? new Date().getFullYear(), 10);
|
||||
const { rows } = await pool.query(
|
||||
'SELECT public.report_gastos($1::int) AS j', [year]
|
||||
);
|
||||
res.json(rows[0].j);
|
||||
} catch (e) {
|
||||
console.error('report_gastos error:', e);
|
||||
res.status(500).json({
|
||||
error: 'report_gastos failed',
|
||||
message: e.message, detail: e.detail, code: e.code
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
export default router;
|
||||
83
services/auth/src/db/poolSingleton.js
Normal file
83
services/auth/src/db/poolSingleton.js
Normal file
@ -0,0 +1,83 @@
|
||||
// Coneción Singleton a base de datos.
|
||||
|
||||
import { Pool } from 'pg';
|
||||
|
||||
class DatabaseCore {
|
||||
constructor() {
|
||||
|
||||
if (DatabaseCore.instance) {
|
||||
return Database.instance;
|
||||
}
|
||||
|
||||
const config = {
|
||||
host: process.env.DB_HOST,
|
||||
user: process.env.DB_USER,
|
||||
password: process.env.DB_PASS,
|
||||
database: process.env.DB_NAME,
|
||||
port: process.env.DB_LOCAL_PORT ? Number(process.env.DB_LOCAL_PORT) : undefined,
|
||||
ssl: process.env.PGSSL === 'true' ? { rejectUnauthorized: false } : undefined,
|
||||
};
|
||||
|
||||
this.connection = new Pool(config);
|
||||
|
||||
DatabaseCore.instance = this;
|
||||
}
|
||||
async query(sql, params) {
|
||||
return this.connection.query(sql,params);
|
||||
}
|
||||
|
||||
async connect() { /* Definida solo para evitar errores */
|
||||
return this.connection.connect();
|
||||
}
|
||||
async getClient() {
|
||||
return this.connection.connect();
|
||||
}
|
||||
|
||||
async release() {
|
||||
await this.connection.end();
|
||||
}
|
||||
}
|
||||
class DatabaseTenants {
|
||||
constructor() {
|
||||
|
||||
if (DatabaseTenants.instance) {
|
||||
return Database.instance;
|
||||
}
|
||||
|
||||
const config = {
|
||||
host: process.env.DB_HOST,
|
||||
user: process.env.DB_USER,
|
||||
password: process.env.DB_PASS,
|
||||
database: process.env.DB_NAME,
|
||||
port: process.env.DB_LOCAL_PORT ? Number(process.env.DB_LOCAL_PORT) : undefined,
|
||||
ssl: process.env.PGSSL === 'true' ? { rejectUnauthorized: false } : undefined,
|
||||
};
|
||||
|
||||
this.connection = new Pool(config);
|
||||
|
||||
DatabaseTenants.instance = this;
|
||||
}
|
||||
async query(sql, params) {
|
||||
return this.connection.query(sql,params);
|
||||
}
|
||||
|
||||
async connect() { /* Definida solo para evitar errores */
|
||||
return this.connection.connect();
|
||||
}
|
||||
async getClient() {
|
||||
return this.connection.connect();
|
||||
}
|
||||
|
||||
async release() {
|
||||
await this.connection.end();
|
||||
}
|
||||
}
|
||||
|
||||
// const db = new Database();
|
||||
// db.query('SELECT * FROM users');
|
||||
|
||||
const poolCore = new DatabaseCore();
|
||||
const poolTenants = new DatabaseTenants();
|
||||
export default {poolCore, poolTenants};
|
||||
export { poolCore, poolTenants };
|
||||
//export { DatabaseCore, DatabaseTenants };
|
||||
@ -1,374 +0,0 @@
|
||||
// services/auth/src/index.js
|
||||
// ------------------------------------------------------------
|
||||
// SuiteCoffee — Servicio de Autenticación (Express + OIDC)
|
||||
// - ESM compatible (Node >=18)
|
||||
// - Sesiones con Redis (compartibles con otros servicios)
|
||||
// - Vistas EJS (login)
|
||||
// - Registro de usuario: /auth/api/users/register (DB + Authentik)
|
||||
// ------------------------------------------------------------
|
||||
|
||||
import 'dotenv/config';
|
||||
import path from 'node:path';
|
||||
import fs from 'node:fs/promises';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { Pool } from 'pg';
|
||||
import express from 'express';
|
||||
|
||||
import crypto from 'node:crypto';
|
||||
import fetch from "node-fetch";
|
||||
|
||||
import { createRedisSession } from "../shared/middlewares/redisConnect.js";
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Variables globales
|
||||
// -----------------------------------------------------------------------------
|
||||
const PORT = process.env.PORT || 4040;
|
||||
const ISSUER = process.env.AUTHENTIK_ISSUER?.replace(/\/?$/, "/"); // asegura barra final
|
||||
const CLIENT_ID = process.env.OIDC_CLIENT_ID;
|
||||
const CLIENT_SECRET = process.env.OIDC_CLIENT_SECRET;
|
||||
const REDIRECT_URI = process.env.OIDC_REDIRECT_URI || process.env.AUTH_CALLBACK_URL;
|
||||
const APP_BASE_URL = process.env.APP_BASE_URL || "http://localhost:3030";
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Utilidades / Helpers
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Utilidades
|
||||
// -----------------------------------------------------------------------------
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
function requiredEnv(keys) {
|
||||
const missing = keys.filter((k) => !process.env[k]);
|
||||
if (missing.length) {
|
||||
console.warn(`Falta configurar variables de entorno: ${missing.join(', ')}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Configuración Express
|
||||
// -----------------------------------------------------------------------------
|
||||
const app = express();
|
||||
app.set('trust proxy', Number(process.env.TRUST_PROXY_HOPS || 2));
|
||||
app.disable("x-powered-by");
|
||||
app.use(express.json());
|
||||
app.use(express.urlencoded({ extended: true }));
|
||||
|
||||
// Vistas EJS
|
||||
app.set('views', path.join(__dirname, 'views'));
|
||||
app.set('view engine', 'ejs');
|
||||
|
||||
// Archivos estáticos opcionales (ajusta si tu estructura difiere)
|
||||
app.use(express.static(path.join(__dirname, 'public')));
|
||||
app.use('/pages', express.static(path.join(__dirname, 'pages')));
|
||||
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Sesión (Redis)
|
||||
// -----------------------------------------------------------------------------
|
||||
// --- Sesión/Redis ---
|
||||
const { sessionMw, trustProxy } = await createRedisSession();
|
||||
if (trustProxy) app.set("trust proxy", 1);
|
||||
app.use(sessionMw);
|
||||
app.use(express.json());
|
||||
|
||||
|
||||
// --- Utiles OIDC ---
|
||||
function base64url(buf) {
|
||||
return Buffer.from(buf).toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
||||
}
|
||||
|
||||
|
||||
function genPKCE() {
|
||||
const verifier = base64url(crypto.randomBytes(32));
|
||||
const challenge = base64url(crypto.createHash("sha256").update(verifier).digest());
|
||||
return { verifier, challenge };
|
||||
}
|
||||
|
||||
|
||||
function authorizeUrl({ state, challenge }) {
|
||||
const u = new URL(`${ISSUER}authorize/`);
|
||||
u.searchParams.set("client_id", CLIENT_ID);
|
||||
u.searchParams.set("redirect_uri", REDIRECT_URI);
|
||||
u.searchParams.set("response_type", "code");
|
||||
u.searchParams.set("scope", "openid email profile");
|
||||
u.searchParams.set("state", state);
|
||||
u.searchParams.set("code_challenge", challenge);
|
||||
u.searchParams.set("code_challenge_method", "S256");
|
||||
return u.toString();
|
||||
}
|
||||
|
||||
|
||||
async function exchangeCodeForTokens({ code, verifier }) {
|
||||
const tokenUrl = `${ISSUER}token/`;
|
||||
const body = new URLSearchParams({
|
||||
grant_type: "authorization_code",
|
||||
code,
|
||||
redirect_uri: REDIRECT_URI,
|
||||
client_id: CLIENT_ID,
|
||||
code_verifier: verifier,
|
||||
});
|
||||
// auth básica si el proveedor la requiere (Authentik soporta ambos modos)
|
||||
const basic = Buffer.from(`${CLIENT_ID}:${CLIENT_SECRET}`).toString("base64");
|
||||
const res = await fetch(tokenUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/x-www-form-urlencoded",
|
||||
"authorization": `Basic ${basic}`,
|
||||
},
|
||||
body,
|
||||
});
|
||||
if (!res.ok) throw new Error(`Token endpoint ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// Middleware para datos globales
|
||||
// ----------------------------------------------------------
|
||||
app.use((req, res, next) => {
|
||||
res.locals.currentPath = req.path;
|
||||
res.locals.pageTitle = "SuiteCoffee";
|
||||
res.locals.pageId = "";
|
||||
next();
|
||||
});
|
||||
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// PostgreSQL — DB tenants (usuarios de suitecoffee)
|
||||
// -----------------------------------------------------------------------------
|
||||
const tenantsPool = new Pool({
|
||||
host: process.env.TENANTS_HOST || 'dev-tenants',
|
||||
port: Number(process.env.TENANTS_PORT || 5432),
|
||||
user: process.env.TENANTS_USER || 'dev-user-postgres',
|
||||
password: process.env.TENANTS_PASS || 'dev-pass-postgres',
|
||||
database: process.env.TENANTS_DB || 'dev-postgres',
|
||||
max: 10,
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// PostgreSQL — DB principal (metadatos de negocio)
|
||||
// -----------------------------------------------------------------------------
|
||||
requiredEnv(['DB_HOST', 'DB_USER', 'DB_PASS', 'DB_NAME']);
|
||||
const mainPool = new Pool({
|
||||
host: process.env.DB_HOST || 'dev-db',
|
||||
port: Number(process.env.DB_PORT || 5432),
|
||||
user: process.env.DB_USER || 'dev-user-suitecoffee',
|
||||
password: process.env.DB_PASS || 'dev-pass-suitecoffee',
|
||||
database: process.env.DB_NAME || 'dev-suitecoffee',
|
||||
max: 10,
|
||||
idleTimeoutMillis: 30_000,
|
||||
});
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// Verificación de conexión
|
||||
// ----------------------------------------------------------
|
||||
|
||||
async function verificarConexion() {
|
||||
try {
|
||||
console.log(`[AUTH] Comprobando accesibilidad a la db ${process.env.DB_NAME} del host ${process.env.DB_HOST} ...`);
|
||||
var client = await mainPool.connect();
|
||||
var { rows } = await client.query('SELECT NOW() AS ahora');
|
||||
console.log(`\n[AUTH] Conexión con ${process.env.DB_NAME} OK. Hora DB:`, rows[0].ahora);
|
||||
} catch (error) {
|
||||
console.error('[AUTH] Error al conectar con la base de datos al iniciar:', error.message);
|
||||
console.error('[AUTH] Revisar DB_HOST/USER/PASS/NAME, accesos de red y firewall.');
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Vistas
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
// =============================================
|
||||
// Registro de usuario (DB principal)
|
||||
// =============================================
|
||||
|
||||
requiredEnv(['TENANT_INIT_SQL']);
|
||||
async function loadInitSqlFromEnv() {
|
||||
const v = process.env.TENANT_INIT_SQL?.trim();
|
||||
if (!v) return '';
|
||||
try {
|
||||
// ¿Es una ruta existente?
|
||||
const p = path.isAbsolute(v) ? v : path.resolve(__dirname, v);
|
||||
const txt = await fs.readFile(p, 'utf8');
|
||||
console.log(`[TENANT INIT] Cargado desde archivo: ${p} (${txt.length} bytes)`);
|
||||
return String(txt || '');
|
||||
} catch {
|
||||
// Tratar como literal
|
||||
console.log(`[TENANT INIT] Usando SQL literal desde TENANT_INIT_SQL (${v.length} chars).`);
|
||||
return v;
|
||||
}
|
||||
}
|
||||
|
||||
// Reemplaza placeholders simples en la plantilla de SQL (opcional)
|
||||
function renderInitSqlTemplate(sql, { schema, owner }) {
|
||||
return sql
|
||||
.replaceAll(':TENANT_SCHEMA', `"${schema}"`)
|
||||
.replaceAll(':OWNER', `"${owner}"`);
|
||||
}
|
||||
// Genera ids sencillos
|
||||
function newTenantIds() {
|
||||
return {
|
||||
tenant_uuid: crypto.randomUUID(),
|
||||
tenant_role: null, // lo decidirás luego (owner, barman, staff)
|
||||
};
|
||||
}
|
||||
|
||||
async function createTenantUserAndSchema(tenClient, { tenant_uuid, password }) {
|
||||
const roleName = `tenant_${tenant_uuid.replace(/-/g, '')}`;
|
||||
const schemaName = `t_${tenant_uuid.replace(/-/g, '')}`;
|
||||
const escapedPassword = `'${String(password).replace(/'/g, "''")}'`;
|
||||
|
||||
// 1) crear role y schema (misma conexión que ya viene en BEGIN desde la ruta)
|
||||
await tenClient.query(`CREATE ROLE "${roleName}" LOGIN PASSWORD ${escapedPassword}`);
|
||||
await tenClient.query(`CREATE SCHEMA "${schemaName}" AUTHORIZATION "${roleName}"`);
|
||||
await tenClient.query(`GRANT USAGE ON SCHEMA "${schemaName}" TO "${roleName}"`);
|
||||
await tenClient.query(`ALTER ROLE "${roleName}" INHERIT`);
|
||||
// (idempotente)
|
||||
await tenClient.query(`CREATE SCHEMA IF NOT EXISTS "${schemaName}"`);
|
||||
|
||||
// 2) cargar y sanear la plantilla
|
||||
let sql = await loadInitSqlFromEnv();
|
||||
if (!sql?.trim()) {
|
||||
console.log('[TENANT INIT] No hay SQL de plantilla; se omite.');
|
||||
return { roleName, schemaName };
|
||||
}
|
||||
|
||||
// 👉 quita metacomandos psql '\' (por si alguno quedó) y cualquier cambio de search_path dentro del dump
|
||||
sql = sql
|
||||
.split(/\r?\n/)
|
||||
.filter(line => !line.trim().startsWith('\\')) // \restrict, \unrestrict, \i, etc.
|
||||
.filter(line => !/^SET\s+search_path\b/i.test(line)) // SET search_path = ...
|
||||
.filter(line => !/set_config\(\s*'search_path'/i.test(line)) // SELECT set_config('search_path',...
|
||||
.join('\n');
|
||||
|
||||
// si usás placeholders, renderealos acá (opcional)
|
||||
// sql = renderInitSqlTemplate(sql, { schema: schemaName, owner: roleName });
|
||||
|
||||
// 3) forzá el search_path SOLO dentro de esta transacción
|
||||
await tenClient.query(`SET LOCAL search_path TO "${schemaName}", public`);
|
||||
|
||||
// 4) ejecutar el dump (una sola vez, no lo partas por ';' para no romper $$...$$)
|
||||
await tenClient.query(sql);
|
||||
|
||||
console.log(`[TENANT INIT] OK usuario="${roleName}" schema="${schemaName}"`);
|
||||
return { roleName, schemaName };
|
||||
}
|
||||
|
||||
//=============================================
|
||||
// ---------- Authentik (API & OIDC) ----------
|
||||
//=============================================
|
||||
|
||||
|
||||
// ===========================
|
||||
// GET /auth/users/register
|
||||
// ===========================
|
||||
|
||||
// ===========================
|
||||
// POST /auth/login
|
||||
// ===========================
|
||||
app.get("/auth/login", (req, res) => {
|
||||
const { verifier, challenge } = genPKCE();
|
||||
const state = base64url(crypto.randomBytes(24));
|
||||
req.session.pkce_verifier = verifier;
|
||||
req.session.oidc_state = state;
|
||||
const url = authorizeUrl({ state, challenge });
|
||||
res.redirect(302, url);
|
||||
});
|
||||
// ===========================
|
||||
// GET /auth/callback
|
||||
// ===========================
|
||||
app.get("/auth/callback", async (req, res) => {
|
||||
try {
|
||||
const { code, state } = req.query;
|
||||
if (!code || !state) return res.status(400).send("Faltan parámetros");
|
||||
if (state !== req.session.oidc_state) return res.status(400).send("State inválido");
|
||||
|
||||
|
||||
const verifier = req.session.pkce_verifier;
|
||||
if (!verifier) return res.status(400).send("PKCE verifier faltante");
|
||||
|
||||
|
||||
const tokens = await exchangeCodeForTokens({ code, verifier });
|
||||
// Guarda en sesión (ID Token, Access Token, Refresh Token si viene)
|
||||
req.session.tokens = {
|
||||
id_token: tokens.id_token,
|
||||
access_token: tokens.access_token,
|
||||
refresh_token: tokens.refresh_token,
|
||||
token_type: tokens.token_type,
|
||||
expires_in: tokens.expires_in,
|
||||
received_at: Date.now(),
|
||||
};
|
||||
// Limpia PKCE/state
|
||||
delete req.session.pkce_verifier;
|
||||
delete req.session.oidc_state;
|
||||
|
||||
|
||||
// Redirige al home de App
|
||||
res.redirect(303, `${APP_BASE_URL}/`);
|
||||
} catch (e) {
|
||||
console.error("/auth/callback error", e);
|
||||
res.status(500).send("Error en callback");
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// ===========================
|
||||
// POST /auth/logout
|
||||
// ===========================
|
||||
app.get("/auth/logout", (req, res) => {
|
||||
req.session.destroy(() => {
|
||||
res.clearCookie(process.env.SESSION_COOKIE_NAME || "sc.sid");
|
||||
res.redirect(303, APP_BASE_URL || "/");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
// =============================================
|
||||
// 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('[AUTH] ', err);
|
||||
if (res.headersSent) return;
|
||||
res.status(500).json({ error: '¡Oh! A ocurrido un error en el servidor auth.', detail: err.stack || String(err) });
|
||||
});
|
||||
|
||||
/*
|
||||
-----------------------------------------------------------------------------
|
||||
Exportación principal del módulo.
|
||||
Es típico exportar la instancia (app) y arrancarla en otro archivo.
|
||||
- Facilita tests (p.ej. con supertest: import app from './app.js')
|
||||
- Evita que el servidor se inicie al importar el módulo.
|
||||
|
||||
# Default
|
||||
export default app; // importar: import app from './app.js'
|
||||
|
||||
# Con nombre
|
||||
export const app = express(); // importar: import { app } from './app.js'
|
||||
-----------------------------------------------------------------------------
|
||||
*/
|
||||
export default app;
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Arranque
|
||||
// -----------------------------------------------------------------------------
|
||||
app.listen(PORT, () => {
|
||||
console.log(`[AUTH] Servicio de autenticación escuchando en http://localhost:${PORT}`);
|
||||
verificarConexion();
|
||||
// OIDCdiscover();
|
||||
});
|
||||
240
services/auth/src/index.mjs
Normal file
240
services/auth/src/index.mjs
Normal file
@ -0,0 +1,240 @@
|
||||
// services/auth/src/index.js
|
||||
// ------------------------------------------------------------
|
||||
// SuiteCoffee — Servicio de Autenticación (Express + OIDC)
|
||||
// ------------------------------------------------------------
|
||||
|
||||
import 'dotenv/config';
|
||||
import express from 'express'; // Framework para enderizado de apps Web
|
||||
import expressLayouts from 'express-ejs-layouts';
|
||||
// import { poolCore, poolTenants } from '@suitecoffee/db'; // dbCore y dbTenants desde módulo
|
||||
import { poolCore, poolTenants } from '#db'; // dbCore y dbTenants
|
||||
import v1Router from '#v1Router'; // Rutas API v1
|
||||
import expressPages from '#pages'; // 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);
|
||||
|
||||
import fs from 'node:fs/promises';
|
||||
import crypto from 'node:crypto';
|
||||
import fetch from "node-fetch";
|
||||
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Validación de entorno mínimo (ajusta nombres si difieren)
|
||||
// -----------------------------------------------------------------------------
|
||||
// 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: \n\n-> ${missingKeys.join('\n-> ')}`+
|
||||
`\n`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
checkRequiredEnvVars(
|
||||
'PORT', 'APP_BASE_URL',
|
||||
'CORE_DB_HOST', 'CORE_DB_PORT', 'CORE_DB_NAME',
|
||||
'TENANTS_DB_HOST', 'TENANTS_DB_PORT', 'TENANTS_DB_NAME',
|
||||
|
||||
'OIDC_LOGIN_URL', 'OIDC_REDIRECT_URI',
|
||||
'OIDC_CLIEN_ID', 'OIDC_CONFIG_URL', 'OIDC_ISSUER',
|
||||
'OIDC_ISSUER_DISCOVERY', 'OIDC_AUTHORIZE_URL', 'OIDC_TOKEN_URL',
|
||||
'OIDC_USERINFO_URL', 'OIDC_LOGOUT_URL', 'OIDC_JWKS_URL',
|
||||
|
||||
'SESSION_SECRET', 'SESSION_COOKIE_NAME',
|
||||
'AK_REDIS_URL', 'AK_TOKEN'
|
||||
);
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// Variables del sistema
|
||||
// ----------------------------------------------------------
|
||||
|
||||
// De entorno
|
||||
const PORT = process.env.PORT;
|
||||
const APP_BASE_URL = process.env.APP_BASE_URL;
|
||||
|
||||
const CORE_DB_HOST = process.env.CORE_DB_HOST;
|
||||
const CORE_DB_PORT = process.env.CORE_DB_PORT;
|
||||
const CORE_DB_NAME = process.env.CORE_DB_NAME;
|
||||
|
||||
const TENANTS_DB_HOST = process.env.TENANTS_DB_HOST;
|
||||
const TENANTS_DB_PORT = process.env.TENANTS_DB_PORT;
|
||||
const TENANTS_DB_NAME = process.env.TENANTS_DB_NAME;
|
||||
|
||||
const OIDC_LOGIN_URL = process.env.OIDC_LOGIN_URL;
|
||||
const OIDC_REDIRECT_URI = process.env.OIDC_REDIRECT_URI;
|
||||
|
||||
const OIDC_CLIEN_ID = process.env.OIDC_CLIEN_ID;
|
||||
const OIDC_CONFIG_URL = process.env.OIDC_CONFIG_URL;
|
||||
const OIDC_ISSUER = process.env.OIDC_ISSUER;
|
||||
const OIDC_ISSUER_DISCOVERY = process.env.OIDC_ISSUER_DISCOVERY;
|
||||
const OIDC_AUTHORIZE_URL = process.env.OIDC_AUTHORIZE_URL;
|
||||
const OIDC_TOKEN_URL = process.env.OIDC_TOKEN_URL;
|
||||
const OIDC_USERINFO_URL = process.env.OIDC_USERINFO_URL;
|
||||
const OIDC_LOGOUT_URL = process.env.OIDC_LOGOUT_URL;
|
||||
const OIDC_JWKS_URL = process.env.OIDC_JWKS_URL;
|
||||
|
||||
const AK_SESSION_SECRET = process.env.AK_SESSION_SECRET;
|
||||
const AK_SESSION_COOKIE_NAME = process.env.AK_SESSION_COOKIE_NAME;
|
||||
const AK_REDIS_URL = process.env.AK_REDIS_URL;
|
||||
|
||||
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Utilidades / Helpers
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Configuración Express
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
const app = express();
|
||||
app.set('trust proxy', true);
|
||||
app.set("views", path.join(__dirname, "views"));
|
||||
app.set("view engine", "ejs");
|
||||
app.set("layout", "layouts/main");
|
||||
app.disable("x-powered-by");
|
||||
|
||||
app.use(express.json());
|
||||
app.use(express.json({ limit: '1mb' }));
|
||||
app.use(express.urlencoded({ extended: true }));
|
||||
// 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(cookieParser(process.env.SESSION_SECRET));
|
||||
app.use(expressPages); // Renderizado trae las paginas desde ./services/manso/src/routes/routes.js
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// Middleware para datos globales
|
||||
// ----------------------------------------------------------
|
||||
app.use((req, res, next) => {
|
||||
res.locals.currentPath = req.path;
|
||||
res.locals.pageTitle = "SuiteCoffee";
|
||||
res.locals.pageId = "";
|
||||
next();
|
||||
});
|
||||
|
||||
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// Verificación de conexión
|
||||
// ----------------------------------------------------------
|
||||
|
||||
async function verificarConexionCore() {
|
||||
try {
|
||||
console.log(`[APP] Comprobando accesibilidad a la db ${CORE_DB_NAME} del host ${CORE_DB_HOST} ...`);
|
||||
const client = await poolCore.connect();
|
||||
const { rows } = await client.query('SELECT NOW() AS ahora');
|
||||
console.log(`\n[APP] Conexión con ${CORE_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 credenciales, accesos de red y firewall.');
|
||||
}
|
||||
}
|
||||
async function verificarConexionTenants() {
|
||||
try {
|
||||
console.log(`[APP] Comprobando accesibilidad a la db ${TENANTS_DB_NAME} del host ${TENANTS_DB_HOST} ...`);
|
||||
const client = await poolTenants.connect();
|
||||
const { rows } = await client.query('SELECT NOW() AS ahora');
|
||||
console.log(`\n[APP] Conexión con ${TENANTS_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 credenciales, accesos de red y firewall.');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
// =============================================
|
||||
// Registro de usuario (DB principal)
|
||||
// =============================================
|
||||
|
||||
|
||||
|
||||
|
||||
//=============================================
|
||||
// ---------- Authentik (API & OIDC) ----------
|
||||
//=============================================
|
||||
|
||||
|
||||
// ===========================
|
||||
// GET /auth/users/register
|
||||
// ===========================
|
||||
|
||||
// ===========================
|
||||
// POST /auth/login
|
||||
// ===========================
|
||||
|
||||
app.get("/auth/login", (req, res) => {
|
||||
const { verifier, challenge } = genPKCE();
|
||||
const state = base64url(crypto.randomBytes(24));
|
||||
req.session.pkce_verifier = verifier;
|
||||
req.session.oidc_state = state;
|
||||
const url = authorizeUrl({ state, challenge });
|
||||
res.redirect(302, url);
|
||||
});
|
||||
|
||||
|
||||
// ===========================
|
||||
// GET /auth/callback
|
||||
// ===========================
|
||||
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Healthcheck
|
||||
// -----------------------------------------------------------------------------
|
||||
app.get('/health', (_req, res) => {
|
||||
res.status(200).json({ status: 'ok'}),
|
||||
console.log(`[AUTH] Saludable`)
|
||||
});
|
||||
|
||||
|
||||
|
||||
// =============================================
|
||||
// 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('[AUTH] ', err);
|
||||
if (res.headersSent) return;
|
||||
res.status(500).json({ error: '¡Oh! A ocurrido un error en el servidor auth.', detail: err.stack || String(err) });
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Arranque
|
||||
// -----------------------------------------------------------------------------
|
||||
app.listen(PORT, () => {
|
||||
console.log(`[AUTH] Servicio de autenticación escuchando en http://localhost:${PORT}`);
|
||||
verificarConexionCore();
|
||||
verificarConexionTenants();
|
||||
});
|
||||
20
services/auth/src/pages/pages.js
Normal file
20
services/auth/src/pages/pages.js
Normal file
@ -0,0 +1,20 @@
|
||||
// services/manso/src/api/v1/routes/routes.js
|
||||
|
||||
import { Router } from 'express';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// Rutas de UI
|
||||
// ----------------------------------------------------------
|
||||
|
||||
/*router.get('/', (req, res) => {
|
||||
res.locals.pageTitle = "Inicio"; // Título de pestaña
|
||||
res.locals.pageId = "home"; // Sidebar contextual
|
||||
res.render("dashboard"); // Archivo .ejs a renderizar
|
||||
// res.json({ ok: true, route: '/inicio' }); // Debug json
|
||||
});*/
|
||||
|
||||
|
||||
|
||||
export default router;
|
||||
@ -1,6 +1,6 @@
|
||||
NODE_ENV=development
|
||||
|
||||
PORT=3030
|
||||
PORT=1010
|
||||
|
||||
DB_HOST=dev-tenants
|
||||
DB_NAME=manso
|
||||
|
||||
20
services/manso/package-lock.json
generated
20
services/manso/package-lock.json
generated
@ -10,6 +10,7 @@
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"chalk": "^5.6.0",
|
||||
"cookie-parser": "^1.4.7",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^17.2.1",
|
||||
"ejs": "^3.1.10",
|
||||
@ -239,6 +240,25 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/cookie-parser": {
|
||||
"version": "1.4.7",
|
||||
"resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz",
|
||||
"integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cookie": "0.7.2",
|
||||
"cookie-signature": "1.0.6"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/cookie-parser/node_modules/cookie-signature": {
|
||||
"version": "1.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
|
||||
"integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cookie-signature": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz",
|
||||
|
||||
@ -16,6 +16,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"chalk": "^5.6.0",
|
||||
"cookie-parser": "^1.4.7",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^17.2.1",
|
||||
"ejs": "^3.1.10",
|
||||
|
||||
@ -1,8 +1,10 @@
|
||||
// ./services/manso/src/index.mjs
|
||||
// ------------------------------------------------------------
|
||||
// MVP Manso de SuiteCoffee — Aplicación MVP
|
||||
// ------------------------------------------------------------
|
||||
|
||||
import 'dotenv/config';// Variables de Entorno
|
||||
|
||||
import favicon from 'serve-favicon'; // Favicon
|
||||
import 'dotenv/config'; // Variables de Entorno
|
||||
import favicon from 'serve-favicon'; // Favicon
|
||||
import express from 'express';
|
||||
import expressLayouts from 'express-ejs-layouts';
|
||||
|
||||
@ -13,10 +15,34 @@ import expressPages from '#pages'; // Rutas "/", "/dashboard", ...
|
||||
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
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)
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// Variables del sistema
|
||||
@ -39,9 +65,13 @@ app.set("layout", "layouts/main");
|
||||
|
||||
app.use(express.json());
|
||||
app.use(express.json({ limit: '1mb' }));
|
||||
app.use(express.static(path.join(__dirname, 'public'))); // Carga de archivos estaticos
|
||||
app.use(expressLayouts); // Carga los layouts que usara el renderizado
|
||||
app.use(express.urlencoded({ extended: true }));
|
||||
app.use(favicon(path.join(__dirname, 'public', 'favicon', 'favicon.ico'), {maxAge: '1y'}));
|
||||
app.use(express.static(path.join(__dirname, 'public'))); // Carga de archivos estaticos
|
||||
|
||||
|
||||
app.use(expressLayouts); // Carga los layouts que usara el renderizado
|
||||
app.use(cookieParser(process.env.SESSION_SECRET));
|
||||
app.use(expressPages); // Renderizado trae las paginas desde ./services/manso/src/routes/routes.js
|
||||
|
||||
|
||||
@ -291,31 +321,77 @@ app.post('/api/table/:table', async (req, res) => {
|
||||
// ----------------------------------------------------------
|
||||
// Verificación de conexión
|
||||
// ----------------------------------------------------------
|
||||
|
||||
async function verificarConexion() {
|
||||
try {
|
||||
console.log(`[Manso] Comprobando accesibilidad a la db ${process.env.DB_NAME} ...`);
|
||||
const client = await pool.connect();
|
||||
const { rows } = await client.query('SELECT NOW() AS ahora');
|
||||
console.log(`\n[Manso] Conexión con ${process.env.DB_NAME} OK. Hora DB:`, rows[0].ahora);
|
||||
client.release();
|
||||
} catch (error) {
|
||||
console.error('[Manso] Error al conectar con la base de datos al iniciar:', error.message);
|
||||
console.error('[Manso] Revisar credenciales, accesos de red y firewall.');
|
||||
}
|
||||
}
|
||||
/*async function verificarConexion() {
|
||||
try {
|
||||
var client = await pool.getClient();
|
||||
let res = await client.query('SELECT NOW() AS hora');
|
||||
console.log(`\nConexión con la base de datos ${process.env.DB_NAME} fue exitosa.`);
|
||||
console.log('Fecha y hora actual de la base de datos:\n ->', res.rows[0].hora);
|
||||
console.log(`\n[Manso] Conexión con la base de datos ${process.env.DB_NAME} fue exitosa.`);
|
||||
console.log('[Manso] Fecha y hora actual de la base de datos:\n ->', res.rows[0].hora);
|
||||
} catch (error) {
|
||||
console.error('Error al conectar con la base de datos al iniciar: \n ->', error.message);
|
||||
console.error('Revisar credenciales y accesos de red.');
|
||||
console.error('[Manso] Error al conectar con la base de datos al iniciar: \n ->', error.message);
|
||||
console.error('[Manso] Revisar credenciales y accesos de red.');
|
||||
} finally{
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
}*/
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// 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(PORT, () => {
|
||||
console.log(`Servidor de aplicación escuchando en ${`http://localhost:${PORT}`}`);
|
||||
console.log(`Comprobando accesibilidad a la db ${process.env.DB_NAME} del host ${process.env.DB_HOST} ...`);
|
||||
console.log(`[Manso] Servidor de aplicación escuchando en ${`http://localhost:${PORT}`}`);
|
||||
console.log(`[Manso] Comprobando accesibilidad a la db ${process.env.DB_NAME} del host ${process.env.DB_HOST} ...`);
|
||||
verificarConexion();
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Healthcheck
|
||||
app.get('/health', (_req, res) => res.status(200).json({ status: 'ok'}));
|
||||
// -----------------------------------------------------------------------------
|
||||
app.get('/health', (_req, res) => {
|
||||
res.status(200).json({ status: 'ok'}),
|
||||
console.log(`[Manso] Saludable`)
|
||||
});
|
||||
@ -469,7 +469,7 @@ async function loadAsist(){
|
||||
const key = `${r.documento}::${r.nombre||''}::${r.apellido||''}`;
|
||||
if (!byKey.has(key)) byKey.set(key, { doc:r.documento, nombre:r.nombre||'', apellido:r.apellido||'', rows:[] });
|
||||
byKey.get(key).rows.push({
|
||||
fecha: fStr,
|
||||
fecha: fStr,
|
||||
horas: Number(r.horas_dia ?? r.horas ?? (r.minutos_dia||0)/60),
|
||||
pares: Number(r.pares_dia ?? r.pares ?? 0)
|
||||
});
|
||||
|
||||
62
services/plugins/.env.development
Normal file
62
services/plugins/.env.development
Normal file
@ -0,0 +1,62 @@
|
||||
# ===== Runtime =====
|
||||
NODE_ENV=development
|
||||
PORT=5050
|
||||
|
||||
|
||||
# ===== Session (usa el Redis del stack) =====
|
||||
# Para DEV podemos reutilizar el Redis de Authentik. En prod conviene uno separado.
|
||||
SESSION_SECRET=Neon*Mammal*Boaster*Ludicrous*Fender8*Crablike
|
||||
SESSION_COOKIE_NAME=sc.sid
|
||||
|
||||
# ===== DB principal (metadatos de SuiteCoffee) =====
|
||||
# Usa el alias de red del servicio 'db' (compose: aliases [dev-db])
|
||||
DB_HOST=dev-db
|
||||
DB_NAME=dev_suitecoffee_core
|
||||
DB_PORT=5432
|
||||
DB_USER=dev-user-suitecoffee
|
||||
DB_PASS=dev-pass-suitecoffee
|
||||
|
||||
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
|
||||
|
||||
# ===== DB tenants (Tenants de SuiteCoffee) =====
|
||||
TENANTS_HOST=dev-tenants
|
||||
TENANTS_DB=dev_suitecoffee_tenants
|
||||
TENANTS_PORT=5432
|
||||
TENANTS_USER=suitecoffee
|
||||
TENANTS_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
|
||||
|
||||
|
||||
# ===== Authentik — Admin API (server-to-server dentro de la red) =====
|
||||
# Usa el alias de red del servicio 'authentik' y su puerto interno 9000
|
||||
AK_TOKEN=h2apVHbd3ApMcnnSwfQPXbvximkvP8HnUE25ot3zXWuEEtJFaNCcOzDHB6Xw
|
||||
AK_REDIS_URL=redis://ak-redis:6379
|
||||
|
||||
# ===== 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.
|
||||
|
||||
APP_BASE_URL=https://suitecoffee.uy
|
||||
|
||||
OIDC_LOGIN_URL=https://sso.suitecoffee.uy
|
||||
OIDC_REDIRECT_URI = https://suitecoffee.uy/auth/callback
|
||||
|
||||
OIDC_CLIEN_ID=1orMM8vOvf3WkN2FejXYvUFpPtONG0Lx1eMlwIpW
|
||||
OIDC_CONFIG_URL=https://sso.suitecoffee.uy/application/o/suitecoffee/.well-known/openid-configuration
|
||||
OIDC_ISSUER=https://sso.suitecoffee.uy/application/o/suitecoffee/
|
||||
OIDC_ISSUER_DISCOVERY=https://sso.suitecoffee.uy/application/o/suitecoffee/.well-known/openid-configuration
|
||||
OIDC_AUTHORIZE_URL=https://sso.suitecoffee.uy/application/o/authorize/
|
||||
OIDC_TOKEN_URL=https://sso.suitecoffee.uy/application/o/token/
|
||||
OIDC_USERINFO_URL=https://sso.suitecoffee.uy/application/o/userinfo/
|
||||
OIDC_LOGOUT_URL=https://sso.suitecoffee.uy/application/o/suitecoffee/end-session/
|
||||
OIDC_JWKS_URL=https://sso.suitecoffee.uy/application/o/suitecoffee/jwks/
|
||||
0
services/plugins/.env.production
Normal file
0
services/plugins/.env.production
Normal file
2381
services/plugins/package-lock.json
generated
Normal file
2381
services/plugins/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
47
services/plugins/package.json
Normal file
47
services/plugins/package.json
Normal file
@ -0,0 +1,47 @@
|
||||
{
|
||||
"name": "plugins",
|
||||
"version": "1.0.0",
|
||||
"main": "src/index.mjs",
|
||||
"scripts": {
|
||||
"start": "NODE_ENV=production node ./src/index.js",
|
||||
"dev": "NODE_ENV=development npx nodemon ./src/index.js",
|
||||
"test": "NODE_ENV=stage node ./src/index.js"
|
||||
},
|
||||
"author": "Mateo Saldain",
|
||||
"license": "ISC",
|
||||
"type": "module",
|
||||
"devDependencies": {
|
||||
"cross-env": "^10.0.0",
|
||||
"nodemon": "^3.1.10"
|
||||
},
|
||||
"dependencies": {
|
||||
"bcrypt": "^6.0.0",
|
||||
"chalk": "^5.6.0",
|
||||
"connect-redis": "^9.0.0",
|
||||
"cookie-parser": "^1.4.7",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^17.2.1",
|
||||
"ejs": "^3.1.10",
|
||||
"express": "^5.1.0",
|
||||
"express-ejs-layouts": "^2.5.1",
|
||||
"express-session": "^1.18.2",
|
||||
"ioredis": "^5.7.0",
|
||||
"jose": "^6.1.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"jwks-rsa": "^3.2.0",
|
||||
"morgan": "^1.10.1",
|
||||
"node-appwrite": "^20.2.1",
|
||||
"node-fetch": "^3.3.2",
|
||||
"pg": "^8.16.3",
|
||||
"pg-format": "^1.0.4",
|
||||
"redis": "^5.8.2",
|
||||
"serve-favicon": "^2.5.1"
|
||||
},
|
||||
"imports": {
|
||||
"#v1Router": "./src/api/v1/routes/routes.js",
|
||||
"#pages": "./src/pages/pages.js",
|
||||
"#db": "./src/db/poolSingleton.js"
|
||||
},
|
||||
"keywords": [],
|
||||
"description": ""
|
||||
}
|
||||
82
services/plugins/src/db/poolSingleton.js
Normal file
82
services/plugins/src/db/poolSingleton.js
Normal file
@ -0,0 +1,82 @@
|
||||
// Coneción Singleton a base de datos.
|
||||
|
||||
import { Pool } from 'pg';
|
||||
|
||||
class DatabaseCore {
|
||||
constructor() {
|
||||
|
||||
if (DatabaseCore.instance) {
|
||||
return Database.instance;
|
||||
}
|
||||
|
||||
const config = {
|
||||
host: process.env.DB_HOST,
|
||||
user: process.env.DB_USER,
|
||||
password: process.env.DB_PASS,
|
||||
database: process.env.DB_NAME,
|
||||
port: process.env.DB_LOCAL_PORT ? Number(process.env.DB_LOCAL_PORT) : undefined,
|
||||
ssl: process.env.PGSSL === 'true' ? { rejectUnauthorized: false } : undefined,
|
||||
};
|
||||
|
||||
this.connection = new Pool(config);
|
||||
|
||||
DatabaseCore.instance = this;
|
||||
}
|
||||
async query(sql, params) {
|
||||
return this.connection.query(sql,params);
|
||||
}
|
||||
|
||||
async connect() { /* Definida solo para evitar errores */
|
||||
return this.connection.connect();
|
||||
}
|
||||
async getClient() {
|
||||
return this.connection.connect();
|
||||
}
|
||||
|
||||
async release() {
|
||||
await this.connection.end();
|
||||
}
|
||||
}
|
||||
class DatabaseTenants {
|
||||
constructor() {
|
||||
|
||||
if (DatabaseTenants.instance) {
|
||||
return Database.instance;
|
||||
}
|
||||
|
||||
const config = {
|
||||
host: process.env.DB_HOST,
|
||||
user: process.env.DB_USER,
|
||||
password: process.env.DB_PASS,
|
||||
database: process.env.DB_NAME,
|
||||
port: process.env.DB_LOCAL_PORT ? Number(process.env.DB_LOCAL_PORT) : undefined,
|
||||
ssl: process.env.PGSSL === 'true' ? { rejectUnauthorized: false } : undefined,
|
||||
};
|
||||
|
||||
this.connection = new Pool(config);
|
||||
|
||||
DatabaseTenants.instance = this;
|
||||
}
|
||||
async query(sql, params) {
|
||||
return this.connection.query(sql,params);
|
||||
}
|
||||
|
||||
async connect() { /* Definida solo para evitar errores */
|
||||
return this.connection.connect();
|
||||
}
|
||||
async getClient() {
|
||||
return this.connection.connect();
|
||||
}
|
||||
|
||||
async release() {
|
||||
await this.connection.end();
|
||||
}
|
||||
}
|
||||
|
||||
// const db = new Database();
|
||||
// db.query('SELECT * FROM users');
|
||||
|
||||
const poolCore = new DatabaseCore();
|
||||
const poolTenants = new DatabaseTenants();
|
||||
export default {poolCore, poolTenants};
|
||||
export { poolCore, poolTenants };
|
||||
140
services/plugins/src/index.mjs
Normal file
140
services/plugins/src/index.mjs
Normal file
@ -0,0 +1,140 @@
|
||||
// services/plugins/asistencias/index.mjs
|
||||
|
||||
// ------------------------------------------------------------
|
||||
|
||||
// ------------------------------------------------------------
|
||||
|
||||
|
||||
import 'dotenv/config'; // Variables de Entorno
|
||||
import express from 'express';
|
||||
import expressLayouts from 'express-ejs-layouts';
|
||||
|
||||
import { poolCore, poolTenants } from '#db'; // dbCore y dbTenants
|
||||
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
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)
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
// 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(
|
||||
`[PLUGIN] No se encontraron las siguientes variables de entorno: \n\n-> ${missingKeys.join('\n-> ')}`+
|
||||
`\n`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
checkRequiredEnvVars(
|
||||
'PORT',
|
||||
'CORE_DB_HOST', 'CORE_DB_PORT', 'CORE_DB_NAME',
|
||||
'TENANTS_DB_HOST', 'TENANTS_DB_PORT', 'TENANTS_DB_NAME'
|
||||
);
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// Variables del sistema
|
||||
// ----------------------------------------------------------
|
||||
const PORT = process.env.PORT;
|
||||
|
||||
const CORE_DB_HOST = process.env.CORE_DB_HOST;
|
||||
const CORE_DB_PORT = process.env.CORE_DB_PORT;
|
||||
const CORE_DB_NAME = process.env.CORE_DB_NAME;
|
||||
|
||||
const TENANTS_DB_HOST = process.env.TENANTS_DB_HOST;
|
||||
const TENANTS_DB_PORT = process.env.TENANTS_DB_PORT;
|
||||
const TENANTS_DB_NAME = process.env.TENANTS_DB_NAME;
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// App + Motor de vistas EJS
|
||||
// ----------------------------------------------------------
|
||||
const app = express();
|
||||
app.set('trust proxy', true);
|
||||
app.set("views", path.join(__dirname, "views"));
|
||||
app.set("view engine", "ejs");
|
||||
app.set("layout", "layouts/main");
|
||||
|
||||
app.use(express.json());
|
||||
app.use(express.json({ limit: '1mb' }));
|
||||
app.use(express.urlencoded({ extended: true }));
|
||||
app.use(express.static(path.join(__dirname, 'public'))); // Carga de archivos estaticos
|
||||
|
||||
|
||||
app.use(expressLayouts); // Carga los layouts que usara el renderizado
|
||||
app.use(cookieParser(process.env.SESSION_SECRET));
|
||||
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// Verificación de conexión
|
||||
// ----------------------------------------------------------
|
||||
|
||||
async function verificarConexionCore() {
|
||||
try {
|
||||
console.log(`[PLUGINS] Comprobando accesibilidad a la db ${CORE_DB_NAME} del host ${CORE_DB_HOST} ...`);
|
||||
const client = await poolCore.connect();
|
||||
const { rows } = await client.query('SELECT NOW() AS ahora');
|
||||
console.log(`\n[PLUGINS] Conexión con ${CORE_DB_NAME} OK. Hora DB:`, rows[0].ahora);
|
||||
client.release();
|
||||
} catch (error) {
|
||||
console.error('[PLUGINS] Error al conectar con la base de datos al iniciar:', error.message);
|
||||
console.error('[PLUGINS] Revisar credenciales, accesos de red y firewall.');
|
||||
}
|
||||
}
|
||||
async function verificarConexionTenants() {
|
||||
try {
|
||||
console.log(`[PLUGINS] Comprobando accesibilidad a la db ${TENANTS_DB_NAME} del host ${TENANTS_DB_HOST} ...`);
|
||||
const client = await poolTenants.connect();
|
||||
const { rows } = await client.query('SELECT NOW() AS ahora');
|
||||
console.log(`\n[PLUGINS] Conexión con ${TENANTS_DB_NAME} OK. Hora DB:`, rows[0].ahora);
|
||||
client.release();
|
||||
} catch (error) {
|
||||
console.error('[PLUGINS] Error al conectar con la base de datos al iniciar:', error.message);
|
||||
console.error('[PLUGINS] Revisar credenciales, accesos de red y firewall.');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// Middleware para datos globales
|
||||
// ----------------------------------------------------------
|
||||
app.use((req, res, next) => {
|
||||
res.locals.currentPath = req.path;
|
||||
res.locals.pageTitle = "SuiteCoffee";
|
||||
res.locals.pageId = "";
|
||||
next();
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// Inicio del servidor
|
||||
// ----------------------------------------------------------
|
||||
|
||||
app.listen(PORT, () => {
|
||||
console.log(`[PLUGINS] http://localhost:${PORT}`);
|
||||
verificarConexionCore();
|
||||
verificarConexionTenants();
|
||||
});
|
||||
|
||||
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Healthcheck
|
||||
// -----------------------------------------------------------------------------
|
||||
app.get('/health', (_req, res) => {
|
||||
res.status(200).json({ status: 'ok'}),
|
||||
console.log(`[PLUGINS] Saludable`)
|
||||
});
|
||||
@ -1,355 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
||||
<title>Comandas</title>
|
||||
<style>
|
||||
:root { --gap: 12px; --radius: 10px; }
|
||||
* { box-sizing: border-box; }
|
||||
body { margin:0; font-family: system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, 'Helvetica Neue', Arial; background:#f6f7fb; color:#111; }
|
||||
header { position: sticky; top: 0; background:#fff; border-bottom:1px solid #e7e7ef; padding:14px 18px; display:flex; gap:10px; align-items:center; z-index:2; }
|
||||
header h1 { margin:0; font-size:16px; font-weight:600; }
|
||||
main { padding: 18px; max-width: 1200px; margin: 0 auto; display:grid; grid-template-columns: 1.15fr 0.85fr; gap: var(--gap); }
|
||||
.card { background:#fff; border:1px solid #e7e7ef; border-radius: var(--radius); }
|
||||
.card .hd { padding:12px 14px; border-bottom:1px solid #eee; display:flex; align-items:center; gap:10px; }
|
||||
.card .bd { padding:14px; }
|
||||
.row { display:flex; gap:10px; align-items:center; flex-wrap: wrap; }
|
||||
.grid { display:grid; gap:10px; }
|
||||
.grid.cols-2 { grid-template-columns: 1fr 1fr; }
|
||||
.muted { color:#666; }
|
||||
select, input, textarea, button { font: inherit; padding:10px; border:1px solid #dadbe4; border-radius:8px; background:#fff; }
|
||||
select:focus, input:focus, textarea:focus { outline:none; border-color:#999; }
|
||||
input[type="number"] { width: 100%; }
|
||||
textarea { width:100%; min-height: 68px; resize: vertical; }
|
||||
button { cursor: pointer; }
|
||||
.btn { padding:10px 12px; border-radius:8px; border:1px solid #dadbe4; background:#fafafa; }
|
||||
.btn.primary { background:#111; border-color:#111; color:#fff; }
|
||||
.btn.ghost { background:#fff; }
|
||||
.btn.small { padding:6px 8px; font-size: 13px; }
|
||||
.pill { font-size:12px; padding:2px 8px; border-radius:99px; border:1px solid #e0e0ea; background:#fafafa; display:inline-block; }
|
||||
.toolbar { display:flex; gap:10px; align-items:center; }
|
||||
.spacer { flex:1 }
|
||||
.search { display:flex; gap:8px; }
|
||||
.search input { flex:1; }
|
||||
table { width:100%; border-collapse: collapse; }
|
||||
thead th { text-align:left; font-size:12px; text-transform: uppercase; letter-spacing:.04em; color:#555; background:#fafafa; }
|
||||
th, td { padding:8px 10px; border-bottom:1px solid #eee; vertical-align: middle; }
|
||||
.qty { display:flex; align-items:center; gap:6px; }
|
||||
.qty input { width: 90px; }
|
||||
.right { text-align:right; }
|
||||
.total { font-size: 22px; font-weight: 700; }
|
||||
.notice { padding:10px; border-radius:8px; border:1px solid #e7e7ef; background:#fafafa; }
|
||||
.ok { color:#0a7d28; }
|
||||
.err { color:#b00020; }
|
||||
.sticky-footer { position: sticky; bottom: 0; background:#fff; padding:12px 14px; border-top:1px solid #eee; display:flex; gap:10px; align-items:center; }
|
||||
.kpi { display:flex; gap:6px; align-items: baseline; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>📋 Nueva Comanda</h1>
|
||||
<div class="spacer"></div>
|
||||
<span class="pill muted">/api/*</span>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<!-- Panel izquierdo: productos -->
|
||||
<section class="card" id="panelProductos">
|
||||
<div class="hd">
|
||||
<strong>Productos</strong>
|
||||
<div class="spacer"></div>
|
||||
<div class="toolbar">
|
||||
<span class="muted" id="prodCount">0 ítems</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bd">
|
||||
<div class="row search" style="margin-bottom:10px;">
|
||||
<input id="busqueda" type="search" placeholder="Buscar por nombre o categoría…"/>
|
||||
<button class="btn" id="limpiarBusqueda">Limpiar</button>
|
||||
</div>
|
||||
<div id="listadoProductos" style="max-height: 58vh; overflow:auto;">
|
||||
<!-- tabla productos -->
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Panel derecho: datos + carrito + crear -->
|
||||
<section class="card" id="panelComanda">
|
||||
<div class="hd"><strong>Detalles</strong></div>
|
||||
<div class="bd grid" style="gap:14px;">
|
||||
<div class="grid cols-2">
|
||||
<div>
|
||||
<label class="muted">Mesa</label>
|
||||
<select id="selMesa"></select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="muted">Usuario</label>
|
||||
<select id="selUsuario"></select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="muted">Observaciones</label>
|
||||
<textarea id="obs"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="notice muted">La fecha se completa automáticamente y los estados/activos usan sus valores por defecto.</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="hd"><strong>Carrito</strong></div>
|
||||
<div class="bd" id="carritoWrap">
|
||||
<div class="muted">Aún no agregaste productos.</div>
|
||||
</div>
|
||||
<div class="sticky-footer">
|
||||
<div class="kpi"><span class="muted">Ítems:</span><strong id="kpiItems">0</strong></div>
|
||||
<div class="kpi" style="margin-left:8px;"><span class="muted">Total:</span><strong id="kpiTotal">$ 0.00</strong></div>
|
||||
<div class="spacer"></div>
|
||||
<button class="btn ghost" id="vaciar">Vaciar</button>
|
||||
<button class="btn primary" id="crear">Crear Comanda</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="msg" class="muted"></div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
const $ = (s, r=document) => r.querySelector(s);
|
||||
const $$ = (s, r=document) => Array.from(r.querySelectorAll(s));
|
||||
|
||||
const state = {
|
||||
productos: [],
|
||||
mesas: [],
|
||||
usuarios: [],
|
||||
carrito: [], // [{id_producto, nombre, pre_unitario, cantidad}]
|
||||
filtro: ''
|
||||
};
|
||||
|
||||
// ---------- Utils ----------
|
||||
const money = (n) => (isNaN(n) ? '—' : new Intl.NumberFormat('es-UY', { style:'currency', currency:'UYU' }).format(Number(n)));
|
||||
const toast = (msg, ok=false) => { const el = $('#msg'); el.className = ok ? 'ok' : 'err'; el.textContent = msg; setTimeout(()=>{ el.textContent=''; el.className='muted'; }, 3500); };
|
||||
async function jget(url) {
|
||||
const res = await fetch(url);
|
||||
let data; try { data = await res.json(); } catch { data = null; }
|
||||
if (!res.ok) throw new Error(data?.error || `${res.status} ${res.statusText}`);
|
||||
return data;
|
||||
}
|
||||
async function jpost(url, body) {
|
||||
const res = await fetch(url, { method:'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify(body) });
|
||||
const data = await res.json().catch(()=>null);
|
||||
if (!res.ok) throw new Error(data?.error || `${res.status} ${res.statusText}`);
|
||||
return data;
|
||||
}
|
||||
|
||||
// ---------- Load data ----------
|
||||
async function init() {
|
||||
// productos, mesas, usuarios
|
||||
const [prods, mesas, usuarios] = await Promise.all([
|
||||
jget('/api/table/productos?limit=1000'),
|
||||
jget('/api/table/mesas?limit=1000'),
|
||||
jget('/api/table/usuarios?limit=1000')
|
||||
]);
|
||||
|
||||
state.productos = prods.filter(p => p.activo !== false); // si existe activo=false, filtrarlo
|
||||
state.mesas = mesas;
|
||||
state.usuarios = usuarios.filter(u => u.activo !== false);
|
||||
|
||||
hydrateMesas();
|
||||
hydrateUsuarios();
|
||||
renderProductos();
|
||||
renderCarrito();
|
||||
|
||||
$('#busqueda').addEventListener('input', () => { state.filtro = $('#busqueda').value.trim().toLowerCase(); renderProductos(); });
|
||||
$('#limpiarBusqueda').addEventListener('click', () => { $('#busqueda').value=''; state.filtro=''; renderProductos(); });
|
||||
$('#vaciar').addEventListener('click', () => { state.carrito=[]; renderCarrito(); });
|
||||
$('#crear').addEventListener('click', crearComanda);
|
||||
}
|
||||
|
||||
function hydrateMesas() {
|
||||
const sel = $('#selMesa'); sel.innerHTML = '';
|
||||
for (const m of state.mesas) {
|
||||
const o = document.createElement('option');
|
||||
o.value = m.id_mesa;
|
||||
o.textContent = `#${m.numero} · ${m.apodo} (${m.estado})`;
|
||||
sel.appendChild(o);
|
||||
}
|
||||
}
|
||||
|
||||
function hydrateUsuarios() {
|
||||
const sel = $('#selUsuario'); sel.innerHTML = '';
|
||||
for (const u of state.usuarios) {
|
||||
const o = document.createElement('option');
|
||||
o.value = u.id_usuario;
|
||||
o.textContent = `${u.nombre} ${u.apellido}`.trim();
|
||||
sel.appendChild(o);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Productos ----------
|
||||
function renderProductos() {
|
||||
let rows = state.productos.slice();
|
||||
if (state.filtro) {
|
||||
rows = rows.filter(p =>
|
||||
(p.nombre || '').toLowerCase().includes(state.filtro) ||
|
||||
String(p.id_categoria ?? '').includes(state.filtro)
|
||||
);
|
||||
}
|
||||
$('#prodCount').textContent = `${rows.length} ítems`;
|
||||
|
||||
if (!rows.length) {
|
||||
$('#listadoProductos').innerHTML = '<div class="muted">Sin resultados.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
const tbl = document.createElement('table');
|
||||
tbl.innerHTML = `
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Producto</th>
|
||||
<th class="right">Precio</th>
|
||||
<th style="width:180px;">Cantidad</th>
|
||||
<th style="width:90px;"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody></tbody>
|
||||
`;
|
||||
const tb = tbl.querySelector('tbody');
|
||||
|
||||
for (const p of rows) {
|
||||
const tr = document.createElement('tr');
|
||||
tr.innerHTML = `
|
||||
<td>${p.nombre}</td>
|
||||
<td class="right">${money(p.precio)}</td>
|
||||
<td>
|
||||
<div class="qty">
|
||||
<input type="number" min="0.001" step="0.001" value="1.000" data-qty />
|
||||
<button class="btn small" data-dec>-</button>
|
||||
<button class="btn small" data-inc>+</button>
|
||||
</div>
|
||||
</td>
|
||||
<td><button class="btn primary small" data-add>Agregar</button></td>
|
||||
`;
|
||||
const qty = tr.querySelector('[data-qty]');
|
||||
tr.querySelector('[data-dec]').addEventListener('click', () => { qty.value = Math.max(0.001, (parseFloat(qty.value||'0') - 1)).toFixed(3); });
|
||||
tr.querySelector('[data-inc]').addEventListener('click', () => { qty.value = (parseFloat(qty.value||'0') + 1).toFixed(3); });
|
||||
tr.querySelector('[data-add]').addEventListener('click', () => addToCart(p, parseFloat(qty.value||'1')) );
|
||||
tb.appendChild(tr);
|
||||
}
|
||||
|
||||
$('#listadoProductos').innerHTML = '';
|
||||
$('#listadoProductos').appendChild(tbl);
|
||||
}
|
||||
|
||||
function addToCart(prod, cantidad) {
|
||||
if (!(cantidad > 0)) { toast('Cantidad inválida'); return; }
|
||||
const precio = parseFloat(prod.precio);
|
||||
const it = state.carrito.find(i => i.id_producto === prod.id_producto && i.pre_unitario === precio);
|
||||
if (it) it.cantidad = Number((it.cantidad + cantidad).toFixed(3));
|
||||
else state.carrito.push({ id_producto: prod.id_producto, nombre: prod.nombre, pre_unitario: precio, cantidad: Number(cantidad.toFixed(3)) });
|
||||
renderCarrito();
|
||||
}
|
||||
|
||||
// ---------- Carrito ----------
|
||||
function renderCarrito() {
|
||||
const wrap = $('#carritoWrap');
|
||||
if (!state.carrito.length) { wrap.innerHTML = '<div class="muted">Aún no agregaste productos.</div>'; $('#kpiItems').textContent='0'; $('#kpiTotal').textContent=money(0); return; }
|
||||
|
||||
const tbl = document.createElement('table');
|
||||
tbl.innerHTML = `
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Producto</th>
|
||||
<th class="right">Unitario</th>
|
||||
<th class="right">Cantidad</th>
|
||||
<th class="right">Subtotal</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody></tbody>
|
||||
`;
|
||||
const tb = tbl.querySelector('tbody');
|
||||
|
||||
let items = 0, total = 0;
|
||||
state.carrito.forEach((it, idx) => {
|
||||
items += 1;
|
||||
const sub = Number(it.pre_unitario) * Number(it.cantidad);
|
||||
total += sub;
|
||||
|
||||
const tr = document.createElement('tr');
|
||||
tr.innerHTML = `
|
||||
<td>${it.nombre}</td>
|
||||
<td class="right">${money(it.pre_unitario)}</td>
|
||||
<td class="right">
|
||||
<input type="number" min="0.001" step="0.001" value="${it.cantidad.toFixed(3)}" style="width:120px"/>
|
||||
</td>
|
||||
<td class="right">${money(sub)}</td>
|
||||
<td class="right">
|
||||
<button class="btn small" data-del>Quitar</button>
|
||||
</td>
|
||||
`;
|
||||
const qty = tr.querySelector('input[type="number"]');
|
||||
qty.addEventListener('change', () => {
|
||||
const v = parseFloat(qty.value||'0');
|
||||
if (!(v>0)) { toast('Cantidad inválida'); qty.value = it.cantidad.toFixed(3); return; }
|
||||
it.cantidad = Number(v.toFixed(3));
|
||||
renderCarrito();
|
||||
});
|
||||
tr.querySelector('[data-del]').addEventListener('click', () => {
|
||||
state.carrito.splice(idx,1);
|
||||
renderCarrito();
|
||||
});
|
||||
|
||||
tb.appendChild(tr);
|
||||
});
|
||||
|
||||
wrap.innerHTML = '';
|
||||
wrap.appendChild(tbl);
|
||||
$('#kpiItems').textContent = String(items);
|
||||
$('#kpiTotal').textContent = money(total);
|
||||
}
|
||||
|
||||
// ---------- Crear comanda ----------
|
||||
async function crearComanda() {
|
||||
if (!state.carrito.length) { toast('Agrega al menos un producto'); return; }
|
||||
const id_mesa = parseInt($('#selMesa').value, 10);
|
||||
const id_usuario = parseInt($('#selUsuario').value, 10);
|
||||
if (!id_mesa || !id_usuario) { toast('Selecciona mesa y usuario'); return; }
|
||||
|
||||
const observaciones = $('#obs').value.trim() || null;
|
||||
|
||||
try {
|
||||
// 1) encabezado comanda (estado por defecto: 'abierta'; fecha la pone la DB)
|
||||
const { inserted: com } = await jpost('/api/table/comandas', {
|
||||
id_usuario,
|
||||
id_mesa,
|
||||
estado: 'abierta',
|
||||
observaciones
|
||||
});
|
||||
|
||||
// 2) detalle (una inserción por renglón)
|
||||
const id_comanda = com.id_comanda;
|
||||
const payloads = state.carrito.map(it => ({
|
||||
id_comanda,
|
||||
id_producto: it.id_producto,
|
||||
cantidad: it.cantidad,
|
||||
pre_unitario: it.pre_unitario
|
||||
}));
|
||||
|
||||
await Promise.all(payloads.map(p => jpost('/api/table/deta_comandas', p)));
|
||||
|
||||
state.carrito = [];
|
||||
renderCarrito();
|
||||
$('#obs').value = '';
|
||||
toast(`Comanda #${id_comanda} creada`, true);
|
||||
} catch (e) {
|
||||
toast(e.message || 'No se pudo crear la comanda');
|
||||
}
|
||||
}
|
||||
|
||||
// GO
|
||||
init().catch(err => toast(err.message || 'Error cargando datos'));
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@ -1,293 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
||||
<title>Dashboard</title>
|
||||
<style>
|
||||
:root { --radius: 10px; }
|
||||
* { box-sizing: border-box; }
|
||||
body { margin:0; font-family: system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, 'Helvetica Neue', Arial, 'Noto Sans', 'Apple Color Emoji', 'Segoe UI Emoji'; background:#f6f7fb; color:#111; }
|
||||
header { position: sticky; top: 0; background:#fff; border-bottom:1px solid #e8e8ef; padding:16px 20px; display:flex; gap:12px; align-items:center; z-index:1;}
|
||||
header h1 { margin:0; font-size:18px; font-weight:600;}
|
||||
main { padding: 20px; max-width: 1200px; margin: 0 auto; }
|
||||
.card { background:#fff; border:1px solid #e8e8ef; border-radius: var(--radius); padding:16px; }
|
||||
.row { display:flex; gap:16px; align-items: center; flex-wrap:wrap; }
|
||||
select, input, button, textarea { font: inherit; padding:10px; border-radius:8px; border:1px solid #d7d7e0; background:#fff; }
|
||||
select:focus, input:focus, textarea:focus { outline: none; border-color:#888; }
|
||||
button { cursor:pointer; }
|
||||
button.primary { background:#111; color:#fff; border-color:#111; }
|
||||
table { width:100%; border-collapse: collapse; }
|
||||
thead th { text-align:left; font-size:12px; text-transform: uppercase; letter-spacing:.04em; color:#555; background:#fafafa; }
|
||||
th, td { padding:10px 12px; border-bottom: 1px solid #eee; vertical-align: top; }
|
||||
.muted { color:#666; }
|
||||
.tabs { display:flex; gap:6px; margin-top:12px; }
|
||||
.tab { padding:8px 10px; border:1px solid #e0e0ea; border-bottom:none; background:#fafafa; border-top-left-radius:8px; border-top-right-radius:8px; cursor:pointer; font-size:14px; }
|
||||
.tab.active { background:#fff; border-color:#e0e0ea; }
|
||||
.panel { border:1px solid #e0e0ea; border-radius: 0 8px 8px 8px; padding:16px; background:#fff; }
|
||||
.grid { display:grid; grid-template-columns: repeat(auto-fill,minmax(220px,1fr)); gap:12px; }
|
||||
.help { font-size:12px; color:#777; margin-top:6px; }
|
||||
.pill { font-size:12px; padding:2px 8px; border-radius:99px; border:1px solid #e0e0ea; background:#fafafa; display:inline-block; }
|
||||
.toolbar { display:flex; gap:10px; align-items:center; }
|
||||
.spacer { flex:1 }
|
||||
.error { color:#b00020; }
|
||||
.success { color:#0a7d28; }
|
||||
.sr-only{ position:absolute; width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0;}
|
||||
details summary { cursor:pointer; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>Dashboard</h1>
|
||||
<div class="spacer"></div>
|
||||
<span class="pill muted">/api/*</span>
|
||||
</header>
|
||||
|
||||
<main class="card">
|
||||
<div class="row" style="margin-bottom:12px;">
|
||||
<label for="tableSelect" class="sr-only">Tabla</label>
|
||||
<select id="tableSelect"></select>
|
||||
<div class="spacer"></div>
|
||||
<div class="toolbar">
|
||||
<button id="refreshBtn">Recargar</button>
|
||||
<span id="status" class="muted"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tabs">
|
||||
<button class="tab active" data-tab="datos">Datos</button>
|
||||
<button class="tab" data-tab="nuevo">Nuevo</button>
|
||||
<button class="tab" data-tab="esquema">Esquema</button>
|
||||
</div>
|
||||
<section class="panel" id="panel-datos">
|
||||
<div class="help">Mostrando hasta <span id="limitInfo">100</span> filas.</div>
|
||||
<div id="tableContainer" style="overflow:auto;"></div>
|
||||
</section>
|
||||
<section class="panel" id="panel-nuevo" hidden>
|
||||
<form id="insertForm" class="grid"></form>
|
||||
<div class="row" style="margin-top:10px;">
|
||||
<div class="spacer"></div>
|
||||
<button id="insertBtn" class="primary">Insertar</button>
|
||||
</div>
|
||||
<div id="insertMsg" class="help"></div>
|
||||
</section>
|
||||
<section class="panel" id="panel-esquema" hidden>
|
||||
<pre id="schemaPre" style="white-space:pre-wrap;"></pre>
|
||||
</section>
|
||||
|
||||
<details style="margin-top:16px;">
|
||||
<summary>Endpoints</summary>
|
||||
<div class="help">GET /api/tables • GET /api/schema/:tabla • GET /api/table/:tabla?limit=100 • POST /api/table/:tabla</div>
|
||||
</details>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
const $ = (s, r=document) => r.querySelector(s);
|
||||
const $$ = (s, r=document) => Array.from(r.querySelectorAll(s));
|
||||
const state = { tables: [], table: null, schema: null, limit: 100 };
|
||||
|
||||
// Tabs
|
||||
$$('.tab').forEach(t => t.addEventListener('click', () => {
|
||||
$$('.tab').forEach(x => x.classList.remove('active'));
|
||||
t.classList.add('active');
|
||||
const tab = t.dataset.tab;
|
||||
$('#panel-datos').hidden = tab !== 'datos';
|
||||
$('#panel-nuevo').hidden = tab !== 'nuevo';
|
||||
$('#panel-esquema').hidden = tab !== 'esquema';
|
||||
}));
|
||||
|
||||
$('#refreshBtn').addEventListener('click', () => {
|
||||
if (state.table) {
|
||||
loadSchema(state.table);
|
||||
loadData(state.table);
|
||||
}
|
||||
});
|
||||
|
||||
async function init() {
|
||||
setStatus('Cargando tablas…');
|
||||
const res = await fetch('/api/tables');
|
||||
const tables = await res.json();
|
||||
state.tables = tables;
|
||||
const sel = $('#tableSelect');
|
||||
sel.innerHTML = '';
|
||||
tables.forEach(name => {
|
||||
const o = document.createElement('option');
|
||||
o.value = name; o.textContent = name;
|
||||
sel.appendChild(o);
|
||||
});
|
||||
sel.addEventListener('change', () => selectTable(sel.value));
|
||||
if (tables.length) {
|
||||
selectTable(tables[0]);
|
||||
} else {
|
||||
setStatus('No hay tablas disponibles.');
|
||||
}
|
||||
}
|
||||
|
||||
async function selectTable(tbl) {
|
||||
state.table = tbl;
|
||||
await loadSchema(tbl);
|
||||
await loadData(tbl);
|
||||
buildForm();
|
||||
}
|
||||
|
||||
async function loadSchema(tbl) {
|
||||
const res = await fetch(`/api/schema/${tbl}`);
|
||||
state.schema = await res.json();
|
||||
$('#schemaPre').textContent = JSON.stringify(state.schema, null, 2);
|
||||
}
|
||||
|
||||
async function loadData(tbl) {
|
||||
setStatus('Cargando datos…');
|
||||
const res = await fetch(`/api/table/${tbl}?limit=${state.limit}`);
|
||||
const data = await res.json();
|
||||
$('#limitInfo').textContent = String(state.limit);
|
||||
renderTable(data);
|
||||
clearStatus();
|
||||
}
|
||||
|
||||
function renderTable(rows) {
|
||||
const c = $('#tableContainer');
|
||||
c.innerHTML = '';
|
||||
if (!rows.length) { c.innerHTML = '<div class="muted">Sin datos.</div>'; return; }
|
||||
const headers = Object.keys(rows[0]);
|
||||
const table = document.createElement('table');
|
||||
table.innerHTML = `
|
||||
<thead><tr>${headers.map(h => '<th>'+h+'</th>').join('')}</tr></thead>
|
||||
<tbody></tbody>
|
||||
`;
|
||||
const tbody = table.querySelector('tbody');
|
||||
for (const row of rows) {
|
||||
const tr = document.createElement('tr');
|
||||
tr.innerHTML = headers.map(h => '<td>'+formatCell(row[h])+'</td>').join('');
|
||||
tbody.appendChild(tr);
|
||||
}
|
||||
c.appendChild(table);
|
||||
}
|
||||
|
||||
function formatCell(v) {
|
||||
if (v === null || v === undefined) return '<span class="muted">NULL</span>';
|
||||
if (typeof v === 'boolean') return v ? '✓' : '—';
|
||||
if (typeof v === 'string' && v.match(/^\\d{4}-\\d{2}-\\d{2}/)) return new Date(v).toLocaleString();
|
||||
return String(v);
|
||||
}
|
||||
|
||||
function buildForm() {
|
||||
const form = $('#insertForm');
|
||||
form.innerHTML = '';
|
||||
if (!state.schema) return;
|
||||
for (const col of state.schema.columns) {
|
||||
// Omitir PK auto y columnas generadas
|
||||
if (col.is_primary || col.is_identity || (col.column_default || '').startsWith('nextval(')) continue;
|
||||
|
||||
const wrap = document.createElement('div');
|
||||
const id = 'f_'+col.column_name;
|
||||
wrap.innerHTML = `
|
||||
<label for="${id}" class="muted">${col.column_name} <span class="muted">${col.data_type}</span> ${col.is_nullable ? '' : '<span class="pill">requerido</span>'}</label>
|
||||
<div data-input></div>
|
||||
${col.column_default ? '<div class="help">DEFAULT: '+col.column_default+'</div>' : ''}
|
||||
`;
|
||||
const holder = wrap.querySelector('[data-input]');
|
||||
|
||||
if (col.foreign) {
|
||||
const sel = document.createElement('select');
|
||||
sel.id = id;
|
||||
holder.appendChild(sel);
|
||||
hydrateOptions(sel, state.schema.table, col.column_name);
|
||||
} else if (col.data_type.includes('boolean')) {
|
||||
const inp = document.createElement('input');
|
||||
inp.id = id; inp.type = 'checkbox';
|
||||
holder.appendChild(inp);
|
||||
} else if (col.data_type.includes('timestamp')) {
|
||||
const inp = document.createElement('input');
|
||||
inp.id = id; inp.type = 'datetime-local'; inp.required = !col.is_nullable && !col.column_default;
|
||||
holder.appendChild(inp);
|
||||
} else if (col.data_type.includes('date')) {
|
||||
const inp = document.createElement('input');
|
||||
inp.id = id; inp.type = 'date'; inp.required = !col.is_nullable && !col.column_default;
|
||||
holder.appendChild(inp);
|
||||
} else if (col.data_type.match(/numeric|integer|real|double/)) {
|
||||
const inp = document.createElement('input');
|
||||
inp.id = id; inp.type = 'number'; inp.step = 'any'; inp.required = !col.is_nullable && !col.column_default;
|
||||
holder.appendChild(inp);
|
||||
} else if (col.data_type.includes('text') || col.data_type.includes('character')) {
|
||||
const inp = document.createElement('input');
|
||||
inp.id = id; inp.type = 'text'; inp.required = !col.is_nullable && !col.column_default;
|
||||
holder.appendChild(inp);
|
||||
} else {
|
||||
const inp = document.createElement('input');
|
||||
inp.id = id; inp.type = 'text'; inp.required = !col.is_nullable && !col.column_default;
|
||||
holder.appendChild(inp);
|
||||
}
|
||||
form.appendChild(wrap);
|
||||
}
|
||||
}
|
||||
|
||||
async function hydrateOptions(selectEl, table, column) {
|
||||
selectEl.innerHTML = '<option value="">Cargando…</option>';
|
||||
const res = await fetch(`/api/options/${table}/${column}`);
|
||||
const opts = await res.json();
|
||||
selectEl.innerHTML = '<option value="">Seleccione…</option>' + opts.map(o => `<option value="${o.id}">${o.label}</option>`).join('');
|
||||
}
|
||||
|
||||
$('#insertBtn').addEventListener('click', async (e) => {
|
||||
e.preventDefault();
|
||||
if (!state.table) return;
|
||||
const payload = {};
|
||||
for (const col of state.schema.columns) {
|
||||
if (col.is_primary || col.is_identity || (col.column_default || '').startsWith('nextval(')) continue;
|
||||
const id = 'f_'+col.column_name;
|
||||
const el = document.getElementById(id);
|
||||
if (!el) continue;
|
||||
|
||||
let val = null;
|
||||
if (el.type === 'checkbox') {
|
||||
val = el.checked;
|
||||
} else if (el.type === 'datetime-local' && el.value) {
|
||||
// Convertir a ISO
|
||||
val = new Date(el.value).toISOString().slice(0,19).replace('T',' ');
|
||||
} else if (el.tagName === 'SELECT') {
|
||||
val = el.value ? (isNaN(el.value) ? el.value : Number(el.value)) : null;
|
||||
} else if (el.type === 'number') {
|
||||
val = el.value === '' ? null : Number(el.value);
|
||||
} else {
|
||||
val = el.value === '' ? null : el.value;
|
||||
}
|
||||
|
||||
if (val === null && !col.is_nullable && !col.column_default) {
|
||||
showInsertMsg('Completa: '+col.column_name, true);
|
||||
return;
|
||||
}
|
||||
if (val !== null) payload[col.column_name] = val;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/table/${state.table}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type':'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || 'Error');
|
||||
showInsertMsg('Insertado correctamente (id: '+(data.inserted?.id || '?')+')', false);
|
||||
// Reset form
|
||||
$('#insertForm').reset?.();
|
||||
await loadData(state.table);
|
||||
} catch (e) {
|
||||
showInsertMsg(e.message, true);
|
||||
}
|
||||
});
|
||||
|
||||
function showInsertMsg(msg, isError=false) {
|
||||
const m = $('#insertMsg');
|
||||
m.className = 'help ' + (isError ? 'error' : 'success');
|
||||
m.textContent = msg;
|
||||
}
|
||||
|
||||
function setStatus(text) { $('#status').textContent = text; }
|
||||
function clearStatus() { setStatus(''); }
|
||||
|
||||
// Start
|
||||
init();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@ -1,280 +0,0 @@
|
||||
<!-- pages/estadoComandas.html -->
|
||||
<!DOCTYPE html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
||||
<title>Estado de Comandas</title>
|
||||
<style>
|
||||
:root { --gap: 12px; --radius: 10px; }
|
||||
* { box-sizing: border-box; }
|
||||
body { margin:0; font-family: system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, 'Helvetica Neue', Arial; background:#f6f7fb; color:#111; }
|
||||
header { position: sticky; top: 0; background:#fff; border-bottom:1px solid #e7e7ef; padding:14px 18px; display:flex; gap:10px; align-items:center; z-index:2; }
|
||||
header h1 { margin:0; font-size:16px; font-weight:600; }
|
||||
main { padding: 18px; max-width: 1200px; margin: 0 auto; display:grid; grid-template-columns: 0.9fr 1.1fr; gap: var(--gap); }
|
||||
.card { background:#fff; border:1px solid #e7e7ef; border-radius: var(--radius); }
|
||||
.card .hd { padding:12px 14px; border-bottom:1px solid #eee; display:flex; align-items:center; gap:10px; }
|
||||
.card .bd { padding:14px; }
|
||||
.row { display:flex; gap:10px; align-items:center; flex-wrap: wrap; }
|
||||
.grid { display:grid; gap:10px; }
|
||||
.muted { color:#666; }
|
||||
.pill { font-size:12px; padding:2px 8px; border-radius:99px; border:1px solid #e0e0ea; background:#fafafa; display:inline-block; }
|
||||
.list { max-height: 70vh; overflow:auto; }
|
||||
.list table { width:100%; border-collapse: collapse; }
|
||||
.list th, .list td { padding:8px 10px; border-bottom:1px solid #eee; }
|
||||
.list thead th { text-align:left; font-size:12px; text-transform: uppercase; letter-spacing:.04em; color:#555; background:#fafafa; }
|
||||
.right { text-align:right; }
|
||||
.btn { padding:10px 12px; border-radius:8px; border:1px solid #dadbe4; background:#fafafa; cursor:pointer; }
|
||||
.btn.primary { background:#111; color:#fff; border-color:#111; }
|
||||
.btn.danger { background:#b00020; color:#fff; border-color:#b00020; }
|
||||
.btn.small { font-size: 13px; padding:6px 8px; }
|
||||
select, input, textarea { font: inherit; padding:10px; border:1px solid #dadbe4; border-radius:8px; background:#fff; }
|
||||
.kpi { display:flex; gap:6px; align-items: baseline; }
|
||||
.sticky-footer { position: sticky; bottom: 0; background:#fff; padding:12px 14px; border-top:1px solid #eee; display:flex; gap:10px; align-items:center; }
|
||||
.ok { color:#0a7d28; }
|
||||
.err { color:#b00020; }
|
||||
.tag { font-size:12px; padding:2px 8px; border-radius:6px; border:1px solid #e7e7ef; background:#fafafa; }
|
||||
.tag.abierta { border-color:#0a7d28; color:#0a7d28; }
|
||||
.tag.cerrada { border-color:#555; color:#555; }
|
||||
.tag.pagada { border-color:#1b5e20; color:#1b5e20; }
|
||||
.tag.anulada { border-color:#b00020; color:#b00020; }
|
||||
table { width:100%; border-collapse: collapse; }
|
||||
th, td { padding:8px 10px; border-bottom:1px solid #eee; }
|
||||
thead th { text-align:left; font-size:12px; text-transform: uppercase; letter-spacing:.04em; color:#555; background:#fafafa; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>🧾 Estado de Comandas</h1>
|
||||
<div style="flex:1"></div>
|
||||
<a class="pill" href="/comandas">➕ Nueva comanda</a>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<!-- Izquierda: listado -->
|
||||
<section class="card">
|
||||
<div class="hd">
|
||||
<strong>Listado</strong>
|
||||
<div style="flex:1"></div>
|
||||
<label class="muted" style="display:flex; gap:6px; align-items:center;">
|
||||
<input id="soloAbiertas" type="checkbox" checked />
|
||||
Solo abiertas
|
||||
</label>
|
||||
</div>
|
||||
<div class="bd">
|
||||
<div class="row" style="margin-bottom:10px;">
|
||||
<input id="buscar" type="search" placeholder="Buscar por #, mesa o usuario…" style="flex:1"/>
|
||||
<button class="btn" id="limpiar">Limpiar</button>
|
||||
</div>
|
||||
<div class="list" id="lista"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Derecha: detalle -->
|
||||
<section class="card">
|
||||
<div class="hd">
|
||||
<strong>Detalle</strong>
|
||||
<div style="flex:1"></div>
|
||||
<span id="detalleEstado" class="tag">—</span>
|
||||
</div>
|
||||
<div class="bd" id="detalle">
|
||||
<div class="muted">Selecciona una comanda para ver el detalle.</div>
|
||||
</div>
|
||||
<div class="sticky-footer">
|
||||
<div class="kpi"><span class="muted">ID:</span><strong id="kpiId">—</strong></div>
|
||||
<div class="kpi" style="margin-left:8px;"><span class="muted">Mesa:</span><strong id="kpiMesa">—</strong></div>
|
||||
<div class="kpi" style="margin-left:8px;"><span class="muted">Total:</span><strong id="kpiTotal">$ 0.00</strong></div>
|
||||
<div style="flex:1"></div>
|
||||
<button class="btn" id="reabrir">Reabrir</button>
|
||||
<button class="btn primary" id="cerrar">Cerrar</button>
|
||||
</div>
|
||||
<div class="bd">
|
||||
<div id="msg" class="muted"></div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
const $ = (s, r=document) => r.querySelector(s);
|
||||
const $$ = (s, r=document) => Array.from(r.querySelectorAll(s));
|
||||
|
||||
const state = {
|
||||
filtro: '',
|
||||
soloAbiertas: true,
|
||||
lista: [], // [{ id_comanda, mesa_numero, mesa_apodo, usuario_nombre, usuario_apellido, fec_creacion, estado, observaciones }]
|
||||
sel: null, // id seleccionado
|
||||
detalle: [] // [{ id_det_comanda, producto_nombre, cantidad, pre_unitario, subtotal, observaciones }]
|
||||
};
|
||||
|
||||
const money = (n) => (isNaN(n) ? '—' : new Intl.NumberFormat('es-UY', { style:'currency', currency:'UYU' }).format(Number(n)));
|
||||
const toast = (msg, ok=false) => { const el = $('#msg'); el.className = ok ? 'ok' : 'err'; el.textContent = msg; setTimeout(()=>{ el.textContent=''; el.className='muted'; }, 3500); };
|
||||
|
||||
async function jget(url) {
|
||||
const res = await fetch(url);
|
||||
const data = await res.json().catch(()=>null);
|
||||
if (!res.ok) throw new Error(data?.error || `${res.status} ${res.statusText}`);
|
||||
return data;
|
||||
}
|
||||
async function jpost(url, body) {
|
||||
const res = await fetch(url, { method:'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify(body) });
|
||||
const data = await res.json().catch(()=>null);
|
||||
if (!res.ok) throw new Error(data?.error || `${res.status} ${res.statusText}`);
|
||||
return data;
|
||||
}
|
||||
|
||||
// ----------- Data -----------
|
||||
async function loadLista() {
|
||||
const estado = state.soloAbiertas ? 'abierta' : '';
|
||||
const url = estado ? `/api/comandas?estado=${encodeURIComponent(estado)}&limit=300` : '/api/comandas?limit=300';
|
||||
const rows = await jget(url);
|
||||
state.lista = rows;
|
||||
renderLista();
|
||||
}
|
||||
|
||||
async function loadDetalle(id) {
|
||||
const det = await jget(`/api/comandas/${id}/detalle`);
|
||||
state.detalle = det;
|
||||
renderDetalle();
|
||||
}
|
||||
|
||||
// ----------- UI: Lista -----------
|
||||
function renderLista() {
|
||||
let rows = state.lista.slice();
|
||||
const f = state.filtro;
|
||||
if (f) {
|
||||
const k = f.toLowerCase();
|
||||
rows = rows.filter(r =>
|
||||
String(r.id_comanda).includes(k) ||
|
||||
(String(r.mesa_numero ?? '').includes(k)) ||
|
||||
((`${r.usuario_nombre||''} ${r.usuario_apellido||''}`).toLowerCase().includes(k))
|
||||
);
|
||||
}
|
||||
const box = $('#lista');
|
||||
if (!rows.length) { box.innerHTML = '<div class="muted">Sin resultados.</div>'; return; }
|
||||
|
||||
const tbl = document.createElement('table');
|
||||
tbl.innerHTML = `
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>Mesa</th>
|
||||
<th>Usuario</th>
|
||||
<th>Fecha</th>
|
||||
<th>Estado</th>
|
||||
<th class="right">Items</th>
|
||||
<th class="right">Total</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody></tbody>
|
||||
`;
|
||||
const tb = tbl.querySelector('tbody');
|
||||
|
||||
rows.forEach(r => {
|
||||
const tr = document.createElement('tr');
|
||||
tr.style.cursor = 'pointer';
|
||||
tr.innerHTML = `
|
||||
<td>${r.id_comanda}</td>
|
||||
<td>#${r.mesa_numero} · ${r.mesa_apodo || ''}</td>
|
||||
<td>${(r.usuario_nombre||'') + ' ' + (r.usuario_apellido||'')}</td>
|
||||
<td>${new Date(r.fec_creacion).toLocaleString()}</td>
|
||||
<td><span class="tag ${r.estado}">${r.estado}</span></td>
|
||||
<td class="right">${r.items ?? '—'}</td>
|
||||
<td class="right">${money(r.total ?? 0)}</td>
|
||||
`;
|
||||
tr.addEventListener('click', () => { state.sel = r.id_comanda; loadDetalle(r.id_comanda); applyHeader(r); });
|
||||
tb.appendChild(tr);
|
||||
});
|
||||
|
||||
box.innerHTML = '';
|
||||
box.appendChild(tbl);
|
||||
}
|
||||
|
||||
// ----------- UI: Detalle -----------
|
||||
function applyHeader(r) {
|
||||
$('#kpiId').textContent = r.id_comanda ?? '—';
|
||||
$('#kpiMesa').textContent = r.mesa_numero ? `#${r.mesa_numero}` : '—';
|
||||
$('#detalleEstado').className = `tag ${r.estado}`;
|
||||
$('#detalleEstado').textContent = r.estado;
|
||||
$('#kpiTotal').textContent = money(r.total ?? 0);
|
||||
|
||||
// Botones según estado
|
||||
const cerr = $('#cerrar'), reab = $('#reabrir');
|
||||
if (r.estado === 'abierta') {
|
||||
cerr.disabled = false; cerr.title = '';
|
||||
reab.disabled = true; reab.title = 'Ya está abierta';
|
||||
} else {
|
||||
cerr.disabled = false; // permitir cerrar (idempotente/override)
|
||||
reab.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
function renderDetalle() {
|
||||
const box = $('#detalle');
|
||||
if (!state.detalle.length) { box.innerHTML = '<div class="muted">Sin detalle.</div>'; return; }
|
||||
|
||||
const tbl = document.createElement('table');
|
||||
tbl.innerHTML = `
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Producto</th>
|
||||
<th class="right">Unitario</th>
|
||||
<th class="right">Cantidad</th>
|
||||
<th class="right">Subtotal</th>
|
||||
<th>Observaciones</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody></tbody>
|
||||
`;
|
||||
const tb = tbl.querySelector('tbody');
|
||||
|
||||
let total = 0;
|
||||
state.detalle.forEach(r => {
|
||||
total += Number(r.subtotal||0);
|
||||
const tr = document.createElement('tr');
|
||||
tr.innerHTML = `
|
||||
<td>${r.producto_nombre}</td>
|
||||
<td class="right">${money(r.pre_unitario)}</td>
|
||||
<td class="right">${Number(r.cantidad).toFixed(3)}</td>
|
||||
<td class="right">${money(r.subtotal)}</td>
|
||||
<td>${r.observaciones||''}</td>
|
||||
`;
|
||||
tb.appendChild(tr);
|
||||
});
|
||||
|
||||
box.innerHTML = '';
|
||||
box.appendChild(tbl);
|
||||
$('#kpiTotal').textContent = money(total);
|
||||
}
|
||||
|
||||
// ----------- Actions -----------
|
||||
async function setEstado(estado) {
|
||||
if (!state.sel) return;
|
||||
try {
|
||||
const { updated } = await jpost(`/api/comandas/${state.sel}/estado`, { estado });
|
||||
toast(`Comanda #${updated.id_comanda} → ${updated.estado}`, true);
|
||||
await loadLista();
|
||||
// mantener seleccionada si sigue existiendo en filtro
|
||||
const found = state.lista.find(x => x.id_comanda === updated.id_comanda);
|
||||
if (found) { applyHeader(found); await loadDetalle(found.id_comanda); }
|
||||
else {
|
||||
state.sel = null;
|
||||
$('#detalle').innerHTML = '<div class="muted">Selecciona una comanda para ver el detalle.</div>';
|
||||
$('#detalleEstado').textContent = '—'; $('#detalleEstado').className = 'tag';
|
||||
$('#kpiId').textContent = '—'; $('#kpiMesa').textContent='—'; $('#kpiTotal').textContent = money(0);
|
||||
}
|
||||
} catch (e) {
|
||||
toast(e.message || 'No se pudo cambiar el estado');
|
||||
}
|
||||
}
|
||||
|
||||
// ----------- Init -----------
|
||||
$('#soloAbiertas').addEventListener('change', async (e) => { state.soloAbiertas = e.target.checked; await loadLista(); });
|
||||
$('#buscar').addEventListener('input', () => { state.filtro = $('#buscar').value.trim(); renderLista(); });
|
||||
$('#limpiar').addEventListener('click', () => { $('#buscar').value=''; state.filtro=''; renderLista(); });
|
||||
$('#cerrar').addEventListener('click', () => setEstado('cerrada'));
|
||||
$('#reabrir').addEventListener('click', () => setEstado('abierta'));
|
||||
|
||||
(async function main(){ try { await loadLista(); } catch(e){ toast(e.message||'Error cargando comandas'); }})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@ -1,58 +0,0 @@
|
||||
import session from "express-session";
|
||||
import { createClient } from "redis";
|
||||
|
||||
|
||||
export async function createRedisSession({
|
||||
redisUrl = process.env.REDIS_URL,
|
||||
cookieName = process.env.SESSION_COOKIE_NAME || "sc.sid",
|
||||
secret = process.env.SESSION_SECRET,
|
||||
trustProxy = process.env.TRUST_PROXY === "1",
|
||||
ttlSeconds = 60 * 60 * 12, // 12h
|
||||
} = {}) {
|
||||
if (!redisUrl) throw new Error("REDIS_URL no definido");
|
||||
if (!secret) throw new Error("SESSION_SECRET no definido");
|
||||
|
||||
|
||||
const redis = createClient({ url: redisUrl });
|
||||
redis.on("error", (err) => console.error("[Redis]", err));
|
||||
await redis.connect();
|
||||
console.log("[Redis] conectado");
|
||||
|
||||
|
||||
// Resolver RedisStore (v5 / v6 / v7)
|
||||
async function resolveRedisStore() {
|
||||
const mod = await import("connect-redis");
|
||||
// v6/v7: named export class
|
||||
if (typeof mod.RedisStore === "function") return mod.RedisStore;
|
||||
// v5: default factory connectRedis(session)
|
||||
if (typeof mod.default === "function") {
|
||||
const maybe = mod.default;
|
||||
if (maybe.prototype && (maybe.prototype.get || maybe.prototype.set)) return maybe; // clase
|
||||
const factory = mod.default(session);
|
||||
return factory;
|
||||
}
|
||||
throw new Error("No se pudo resolver RedisStore de connect-redis");
|
||||
}
|
||||
|
||||
|
||||
const RedisStore = await resolveRedisStore();
|
||||
const store = new RedisStore({ client: redis, prefix: "sc:sess:", ttl: ttlSeconds });
|
||||
|
||||
|
||||
const sessionMw = session({
|
||||
name: cookieName,
|
||||
secret,
|
||||
resave: false,
|
||||
saveUninitialized: false,
|
||||
store,
|
||||
cookie: {
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
secure: process.env.NODE_ENV === "production", // requiere https
|
||||
maxAge: ttlSeconds * 1000,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
return { sessionMw, redis, store, trustProxy };
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user