diff --git a/.env b/.env new file mode 100644 index 0000000..e69de29 diff --git a/.env.development b/.env.development index edcde4e..14c25d8 100644 --- a/.env.development +++ b/.env.development @@ -5,31 +5,42 @@ COMPOSE_PROJECT_NAME=suitecoffee_dev NODE_ENV=development # app - app -APP_LOCAL_PORT=3030 -APP_DOCKER_PORT=3030 +APP_PORT=3030 # auth - app -AUTH_LOCAL_PORT=4040 -AUTH_DOCKER_PORT=4040 +AUTH_PORT=4040 # tenants - postgres TENANTS_DB_NAME=dev-postgres TENANTS_DB_USER=dev-user-postgres TENANTS_DB_PASS=dev-pass-postgres -TENANTS_DB_LOCAL_PORT=54321 -TENANTS_DB_DOCKER_PORT=5432 - # db primaria - postgres DB_NAME=dev-suitecoffee DB_USER=dev-user-suitecoffee DB_PASS=dev-pass-suitecoffee -DB_LOCAL_PORT=54322 -DB_DOCKER_PORT=5432 # --- secretos para Authentik -AUTHENTIK_SECRET_KEY=poné_un_valor_largo_y_unico -AUTHENTIK_DB_PASS=cambia_esto -AUTHENTIK_BOOTSTRAP_PASSWORD=cambia_esto -AUTHENTIK_BOOTSTRAP_EMAIL=info.suitecoffee@gmail.com +AK_SECRET_KEY=Timothy-Yearning-Unzip-Playmate3-Snowiness-Desecrate +AK_DB_PASS=Doable8 +AK_BOOTSTRAP_PASSWORD=Succulent-Sanded7 +AK_BOOTSTRAP_EMAIL=info.suitecoffee@gmail.com + +PG_PASS=gOWjL8V564vyh1aXUcqh4o/xo7eObraaCVZezPi3iw2LzPlU +AUTHENTIK_SECRET_KEY=11zMsUL57beO+okjeGh7OB3lQdGUWII+VaATHs/zsw1+6KMSTyGfAY0yHpq3C442+3CwrZ/KtjgHBfbv +AUTHENTIK_ERROR_REPORTING__ENABLED=true + +# SMTP Host Emails are sent to +AUTHENTIK_EMAIL__HOST=localhost +AUTHENTIK_EMAIL__PORT=25 +# Optionally authenticate (don't add quotation marks to your password) +AUTHENTIK_EMAIL__USERNAME=info.suitecoffee@gmail.com +AUTHENTIK_EMAIL__PASSWORD=Succulent-Sanded7 +# Use StartTLS +AUTHENTIK_EMAIL__USE_TLS=false +# Use SSL +AUTHENTIK_EMAIL__USE_SSL=false +AUTHENTIK_EMAIL__TIMEOUT=10 +# Email address authentik will send from, should have a correct @domain +AUTHENTIK_EMAIL__FROM=info.suitecoffee@gmail.com \ No newline at end of file diff --git a/.env.production b/.env.production index 5e344a2..054af27 100644 --- a/.env.production +++ b/.env.production @@ -5,12 +5,10 @@ COMPOSE_PROJECT_NAME=suitecoffee_prod NODE_ENV=production # app - app -APP_LOCAL_PORT=3000 -APP_DOCKER_PORT=3000 +APP_PORT=3000 # auth - app -AUTH_LOCAL_PORT=4000 -AUTH_DOCKER_PORT=4000 +AUTH_PORT=4000 # tenants - postgres TENANTS_DB_NAME=postgres diff --git a/app.index.js b/app.index.js new file mode 100644 index 0000000..1d95b89 --- /dev/null +++ b/app.index.js @@ -0,0 +1,344 @@ +// services/app/src/index.js +// ------------------------------------------------------------ +// SuiteCoffee — Servicio APP (UI + APIs negocio) +// - ESM (Node >=18) +// - Vistas EJS en ./views (dashboard.ejs, comandas.ejs, etc.) +// - Sesión compartida con AUTH (cookie: sc.sid, Redis) +// - Monta routes.legacy.js con requireAuth + withTenant +// ------------------------------------------------------------ + +import 'dotenv/config'; +import express from 'express'; +import cors from 'cors'; +import morgan from 'morgan'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import session from 'express-session'; +import expressLayouts from 'express-ejs-layouts'; +// import RedisStore from "connect-redis"; +import { createClient } from 'redis'; +import { Pool } from 'pg'; + +// ----------------------------------------------------------------------------- +// Utilidades base +// ----------------------------------------------------------------------------- +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const qi = (ident) => `"${String(ident).replace(/"/g, '""')}"`; +const CLEAN_HEX = (s) => (String(s || '').toLowerCase().replace(/[^0-9a-f]/g, '') || null); +const REQUIRED = (...keys) => { + const miss = keys.filter((k) => !process.env[k]); + if (miss.length) { + console.warn(`⚠ Faltan variables de entorno: ${miss.join(', ')}`); + } +}; + +// ----------------------------------------------------------------------------- +// Validación de entorno mínimo (ajusta nombres si difieren) +// ----------------------------------------------------------------------------- +REQUIRED( + // 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' +); + +// ----------------------------------------------------------------------------- +// Pools de PostgreSQL +// ----------------------------------------------------------------------------- +const mainPool = new Pool({ + host: process.env.DB_HOST, + port: Number(process.env.DB_PORT || 5432), + database: process.env.DB_NAME, + user: process.env.DB_USER, + password: process.env.DB_PASS, + max: 10, + idleTimeoutMillis: 30_000, +}); + +const tenantsPool = new Pool({ + host: process.env.TENANTS_HOST, + port: Number(process.env.TENANTS_PORT || 5432), + database: process.env.TENANTS_DB, + user: process.env.TENANTS_USER, + password: process.env.TENANTS_PASS, + max: 10, + idleTimeoutMillis: 30_000, +}); + +// Autotest (no rompe si falla; sólo loguea) +(async () => { + try { + const c = await mainPool.connect(); + const r = await c.query('SELECT NOW() now'); + console.log('[APP] DB principal OK. Hora:', r.rows[0].now); + c.release(); + } catch (e) { + console.error('[APP] Error al conectar DB principal:', e.message); + } + try { + const c = await tenantsPool.connect(); + const r = await c.query('SELECT NOW() now'); + console.log('[APP] DB tenants OK. Hora:', r.rows[0].now); + c.release(); + } catch (e) { + console.error('[APP] Error al conectar DB tenants:', e.message); + } +})(); + +// ----------------------------------------------------------------------------- +// Express + EJS +// ----------------------------------------------------------------------------- +const app = express(); +app.set('trust proxy', Number(process.env.TRUST_PROXY_HOPS || 2)); +app.use(cors({ origin: true, credentials: true })); +app.use(express.json()); +app.use(express.urlencoded({ extended: true })); +app.use(express.static(path.join(__dirname, 'public'))); + + +// ---------------------------------------------------------- +// Motor de vistas EJS +// ---------------------------------------------------------- + +// Views EJS en ./views +app.set('views', path.join(__dirname, 'views')); +app.set('view engine', 'ejs'); +app.use(expressLayouts); +app.set("layout", "layouts/main"); + +// Estáticos (si tenés carpeta public/, assets, etc.) +app.use('/public', express.static(path.join(__dirname, 'public'))); + +// Middlewares básicos +app.use(morgan('dev')); + + +// ---------------------------------------------------------- +// Middleware para datos globales +// ---------------------------------------------------------- +app.use((req, res, next) => { + res.locals.currentPath = req.path; + res.locals.pageTitle = "SuiteCoffee"; + res.locals.pageId = ""; + next(); +}); + +// ---------------------------------------------------------- +// Rutas de UI +// ---------------------------------------------------------- +app.get("/", (req, res) => { + res.locals.pageTitle = "Inicio"; + res.locals.pageId = "inicio"; + res.render("inicio"); +}); + +// ----------------------------------------------------------------------------- +// Sesión (Redis) — misma cookie que AUTH +// ----------------------------------------------------------------------------- + +const SESSION_COOKIE_NAME = process.env.SESSION_COOKIE_NAME || "sc.sid"; +const SESSION_SECRET = process.env.SESSION_SECRET || "pon-una-clave-larga-y-unica"; +const REDIS_URL = process.env.REDIS_URL || "redis://authentik-redis:6379"; + +// 1) Redis client +const redis = createClient({ url: REDIS_URL /*, legacyMode: true */ }); +redis.on("error", (err) => console.error("[Redis] Client Error:", err)); +await redis.connect(); +console.log("[Redis] connected"); + +// 2) Resolver RedisStore (soporta: +// - v5: factory CJS -> connectRedis(session) +// - v6/v7: export { RedisStore } ó export default class RedisStore) +async function resolveRedisStore(session) { + const mod = await import("connect-redis"); // ESM/CJS agnóstico + // named export (v6/v7) + if (typeof mod.RedisStore === "function") return mod.RedisStore; + // default export (class ó factory) + if (typeof mod.default === "function") { + // ¿es clase neweable? + if (mod.default.prototype && (mod.default.prototype.get || mod.default.prototype.set)) { + return mod.default; // class RedisStore + } + // si no, asumimos factory antigua + const Store = mod.default(session); // connectRedis(session) + if (typeof Store === "function") return Store; // class devuelta por factory + } + // algunos builds CJS exponen la factory bajo mod (poco común) + if (typeof mod === "function") { + const Store = mod(session); + if (typeof Store === "function") return Store; + } + throw new Error("connect-redis: no se pudo resolver RedisStore (API desconocida)."); +} + +const RedisStore = await resolveRedisStore(session); + +// 3) Session middleware +app.use(session({ + name: SESSION_COOKIE_NAME, + secret: SESSION_SECRET, + resave: false, + saveUninitialized: false, + store: new RedisStore({ + client: redis, + prefix: "sc:", // opcional + }), + proxy: true, + cookie: { + secure: "auto", + httpOnly: true, + sameSite: "lax", + path: "/", // ¡crítico! visible en / y /auth/* + }, +})); + +// ----------------------------------------------------------------------------- +// Middlewares de Auth/Tenant para routes.legacy.js +// ----------------------------------------------------------------------------- +function requireAuth(req, res, next) { + if (!req.session?.user) return res.redirect(303, "/auth/login"); + next(); +} + +// 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); + } +} + +// No-op (compatibilidad con el archivo legacy si lo pasa al final) +function done(_req, _res, next) { return next && next(); } + +// ----------------------------------------------------------------------------- +// Home / Landing +// ----------------------------------------------------------------------------- +// app.get('/', (req, res) => { +// if (req.session?.user) return res.redirect(303, "/inicio"); +// return res.redirect(303, "/auth/login"); +// }); + +// Página de login +app.get("/auth/login", (_req, res) => { + return res.render("login", { pageTitle: "Iniciar sesión" }); +}); + +app.get('/', (_req, res) => { + return res.render("inicio", { pageTitle: "Bienvenido" }); +}); + +app.get("/", (_req, res) => res.redirect(303, "/auth/login")); + +app.use([ + "/dashboard", + "/comandas", + "/estadoComandas", + "/productos", + "/usuarios", + "/reportes", + "/compras", +], requireAuth); + + +// 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('/comandas') : res.redirect('/auth/login'); + + res.type('html').send(` + + SuiteCoffee · Definir contraseña + +
+

Definir contraseña

+
+ + + + Luego te redirigiremos a iniciar sesión por SSO. +
+
+ `); +}); + +// ----------------------------------------------------------------------------- +// Montar rutas legacy (render de EJS y APIs de negocio) +// ----------------------------------------------------------------------------- +const legacy = await import('./routes.legacy.js'); +legacy.default(app, { + requireAuth, + withTenant, + done, + mainPool, + tenantsPool, + express, +}); + +// ---------------------------------------------------------- +// Verificación de conexión +// ---------------------------------------------------------- +async function verificarConexion() { + try { + const client = await pool.connect(); + const res = await client.query('SELECT NOW() AS hora'); + console.log(`Conexión con la base de datos ${process.env.DB_NAME} fue exitosa.`); + console.log('Fecha y hora actual de la base de datos:', res.rows[0].hora); + client.release(); + } catch (error) { + console.error('Error al conectar con la base de datos al iniciar:', error.message); + console.error('Revisar credenciales y accesos de red.'); + } +} + +// ----------------------------------------------------------------------------- +// Health + 404 + errores +// ----------------------------------------------------------------------------- +app.get('/health', (_req, res) => res.status(200).json({ status: 'ok', service: 'app' })); + +app.use((req, res) => res.status(404).json({ error: 'not-found', path: req.originalUrl })); + +app.use((err, _req, res, _next) => { + console.error('[APP] Error:', err); + if (res.headersSent) return; + res.status(500).json({ error: 'internal-error', detail: err?.message || String(err) }); +}); + +// ----------------------------------------------------------------------------- +// Arranque +// ----------------------------------------------------------------------------- +const PORT = Number(process.env.PORT || process.env.APP_LOCAL_PORT || 3030); +// app.listen(PORT, () => { +// console.log(`[APP] SuiteCoffee corriendo en http://localhost:${PORT}`); +// }); + +(async () => { + await verificarConexion(); + app.listen(PORT, () => { + console.log(`[APP] SuiteCoffee corriendo en http://localhost:${PORT}`); + }); +})(); \ No newline at end of file diff --git a/compose.dev.yaml b/compose.dev.yaml index 2d66784..8f401b7 100644 --- a/compose.dev.yaml +++ b/compose.dev.yaml @@ -15,7 +15,7 @@ services: environment: NODE_ENV: development # <- fuerza el entorno para que el loader tome .env.development expose: - - ${APP_LOCAL_PORT} + - ${APP_PORT} networks: net: aliases: [dev-app] @@ -33,7 +33,7 @@ services: environment: NODE_ENV: development # <- fuerza el entorno para que el loader tome .env.development expose: - - ${AUTH_LOCAL_PORT} + - ${AUTH_PORT} networks: net: aliases: [dev-auth] @@ -67,71 +67,84 @@ services: ### Authentik ### ################# - authentik-db: - image: postgres:16 + ak-db: + image: docker.io/library/postgres:16-alpine + env_file: + - .env.development environment: - POSTGRES_DB: authentik - POSTGRES_USER: authentik - POSTGRES_PASSWORD: ${AUTHENTIK_DB_PASS} + POSTGRES_DB: ${PG_DB:-authentik} + POSTGRES_PASSWORD: ${PG_PASS:?database password required} + POSTGRES_USER: ${PG_USER:-authentik} volumes: - authentik-db:/var/lib/postgresql/data networks: net: - aliases: [ak-db] + aliases: [dev-ak-db] - authentik-redis: - image: redis:7-alpine - command: ["redis-server", "--save", "", "--appendonly", "no"] + ak-redis: + image: docker.io/library/redis:alpine + command: --save 60 1 --loglevel warning networks: net: - aliases: [ak-redis] + aliases: [dev-ak-redis] + volumes: + - ak-redis:/data - authentik: + ak: image: ghcr.io/goauthentik/server:latest + env_file: + - .env.development command: server environment: - AUTHENTIK_SECRET_KEY: ${AUTHENTIK_SECRET_KEY} - AUTHENTIK_DEBUG: "false" - AUTHENTIK_POSTGRESQL__HOST: authentik-db - AUTHENTIK_POSTGRESQL__USER: authentik - AUTHENTIK_POSTGRESQL__NAME: authentik - AUTHENTIK_POSTGRESQL__PASSWORD: ${AUTHENTIK_DB_PASS} - AUTHENTIK_REDIS__HOST: authentik-redis - AUTHENTIK_BOOTSTRAP_PASSWORD: ${AUTHENTIK_BOOTSTRAP_PASSWORD} - AUTHENTIK_BOOTSTRAP_EMAIL: ${AUTHENTIK_BOOTSTRAP_EMAIL} + AUTHENTIK_SECRET_KEY: "Timothy-Yearning-Unzip-Playmate3-Snowiness-Desecrate" + AUTHENTIK_DEBUG: false + AUTHENTIK_POSTGRESQL__HOST: ak-db + AUTHENTIK_POSTGRESQL__NAME: ${PG_DB:-authentik} + AUTHENTIK_POSTGRESQL__PASSWORD: ${PG_PASS} + AUTHENTIK_POSTGRESQL__USER: ${PG_USER:-authentik} + AUTHENTIK_REDIS__HOST: ak-redis + + AUTHENTIK_BOOTSTRAP_PASSWORD: Succulent-Sanded7 + AUTHENTIK_BOOTSTRAP_EMAIL: info.suitecoffee@gmail.com AUTHENTIK_HTTP__TRUSTED_PROXY__CIDRS: "0.0.0.0/0,::/0" - AUTHENTIK_SECURITY__CSRF_TRUSTED_ORIGINS: "https://authentik.suitecoffee.mateosaldain.uy,https://suitecoffee.mateosaldain.uy" - AUTHENTIK_COOKIE__DOMAIN: "authentik.suitecoffee.mateosaldain.uy" + AUTHENTIK_SECURITY__CSRF_TRUSTED_ORIGINS: "https://authentik.suitecoffee.mateosaldain.uy,https://suitecoffee.mateosaldain.uy,https://sso.suitecoffee.uy,https://suitecoffee.uy" + AUTHENTIK_COOKIE__DOMAIN: sso.suitecoffee.uy networks: net: - aliases: [authentik] + aliases: [dev-authentik] + volumes: + - ./media:/media + - ./custom-templates:/templates - authentik-worker: + ak-worker: image: ghcr.io/goauthentik/server:latest command: worker - depends_on: - authentik-db: - condition: service_started - authentik-redis: - condition: service_started environment: - AUTHENTIK_SECRET_KEY: ${AUTHENTIK_SECRET_KEY} - AUTHENTIK_POSTGRESQL__HOST: authentik-db - AUTHENTIK_POSTGRESQL__USER: authentik - AUTHENTIK_POSTGRESQL__NAME: authentik - AUTHENTIK_POSTGRESQL__PASSWORD: ${AUTHENTIK_DB_PASS} - AUTHENTIK_REDIS__HOST: authentik-redis + AUTHENTIK_POSTGRESQL__HOST: ak-db + AUTHENTIK_POSTGRESQL__NAME: ${PG_DB:-authentik} + AUTHENTIK_POSTGRESQL__PASSWORD: ${PG_PASS} + AUTHENTIK_POSTGRESQL__USER: ${PG_USER:-authentik} + AUTHENTIK_REDIS__HOST: ak-redis + AUTHENTIK_SECRET_KEY: ${AUTHENTIK_SECRET_KEY:?secret key required} + AUTHENTIK_HTTP__TRUSTED_PROXY__CIDRS: "0.0.0.0/0,::/0" - AUTHENTIK_SECURITY__CSRF_TRUSTED_ORIGINS: "https://authentik.suitecoffee.mateosaldain.uy,https://suitecoffee.mateosaldain.uy" - AUTHENTIK_COOKIE__DOMAIN: "authentik.suitecoffee.mateosaldain.uy" + AUTHENTIK_SECURITY__CSRF_TRUSTED_ORIGINS: "https://authentik.suitecoffee.mateosaldain.uy,https://suitecoffee.mateosaldain.uy,https://sso.suitecoffee.uy,https://suitecoffee.uy" + AUTHENTIK_COOKIE__DOMAIN: "sso.suitecoffee.uy" networks: net: - aliases: [ak-work] + aliases: [dev-ak-work] + user: root + volumes: + - /var/run/docker.sock:/var/run/docker.sock + - ./media:/media + - ./certs:/certs + - ./custom-templates:/templates volumes: tenants-db: suitecoffee-db: authentik-db: + ak-redis: networks: net: diff --git a/compose.prod.yaml b/compose.prod.yaml index 3489b9a..4c990a2 100644 --- a/compose.prod.yaml +++ b/compose.prod.yaml @@ -9,7 +9,7 @@ services: context: ./services/app dockerfile: Dockerfile.production expose: - - ${APP_LOCAL_PORT} + - ${APP_PORT} volumes: - ./services/app:/app env_file: @@ -26,7 +26,7 @@ services: context: ./services/auth dockerfile: Dockerfile.production expose: - - ${AUTH_LOCAL_PORT} + - ${AUTH_PORT} volumes: - ./services/auth:/app env_file: diff --git a/compose.yaml b/compose.yaml index 1e0ca8b..c8d979b 100644 --- a/compose.yaml +++ b/compose.yaml @@ -3,7 +3,6 @@ name: ${COMPOSE_PROJECT_NAME:-suitecoffee} services: - app: depends_on: db: @@ -11,7 +10,7 @@ services: tenants: condition: service_healthy healthcheck: - test: ["CMD-SHELL", "curl -fsS http://localhost:${APP_DOCKER_PORT}/health || exit 1"] + test: ["CMD-SHELL", "curl -fsS http://localhost:${APP_PORT}/health || exit 1"] interval: 10s timeout: 3s retries: 10 @@ -22,10 +21,10 @@ services: depends_on: db: condition: service_healthy - authentik: + ak: condition: service_started healthcheck: - test: ["CMD-SHELL", "curl -fsS http://localhost:${AUTH_DOCKER_PORT}/health || exit 1"] + test: ["CMD-SHELL", "curl -fsS http://localhost:${AUTH_PORT}/health || exit 1"] interval: 10s timeout: 3s retries: 10 @@ -56,32 +55,45 @@ services: start_period: 10s restart: unless-stopped - authentik-db: + ak-db: image: postgres:16-alpine healthcheck: - test: ["CMD-SHELL", "pg_isready -U authentik -d authentik"] - interval: 5s - timeout: 3s - retries: 20 + interval: 30s + retries: 5 + start_period: 20s + test: + - CMD-SHELL + - pg_isready -d $${POSTGRES_DB} -U $${POSTGRES_USER} + timeout: 5s restart: unless-stopped - authentik-redis: + ak-redis: image: redis:7-alpine healthcheck: - test: ["CMD", "redis-cli", "ping"] - interval: 5s + interval: 30s + retries: 5 + start_period: 20s + test: + - CMD-SHELL + - redis-cli ping | grep PONG timeout: 3s - retries: 20 restart: unless-stopped - authentik: + ak: image: ghcr.io/goauthentik/server:latest depends_on: - authentik-db: { condition: service_healthy } - authentik-redis: { condition: service_healthy } + ak-db: + condition: service_healthy + ak-redis: + condition: service_healthy restart: unless-stopped - authentik-worker: + ak-worker: image: ghcr.io/goauthentik/server:latest + depends_on: + ak-db: + condition: service_started + ak-redis: + condition: service_started restart: unless-stopped \ No newline at end of file diff --git a/docs/ak.md b/docs/ak.md new file mode 100644 index 0000000..84c3b8e --- /dev/null +++ b/docs/ak.md @@ -0,0 +1,316 @@ +# Documentación detallada de funciones: `ak.js` + +Este documento fue generado automáticamente a partir del archivo `ak.js` proporcionado. Incluye una sección por función detectada, con firma, ubicación, descripción, parámetros, valores de retorno, posibles errores y un ejemplo de uso. + +> **Nota:** Las descripciones y tipos se infieren heurísticamente a partir de los nombres, comentarios y cuerpo de cada función. Revise y ajuste donde corresponda. + +--- + +### `getConfig` + +**Firma:** `function getConfig()` +**Ubicación:** línea 28 + +**Comentario previo en el código:** +```js +// ------------------------------------------------------------ +// Cliente para la API Admin de Authentik (v3) +// - Sin dependencias externas (usa fetch nativo de Node >=18) +// - ESM compatible +// - Timeouts, reintentos opcionales y mensajes de error claros +// - Compatible con services/auth/src/index.js actual +// ------------------------------------------------------------ + +// Utiliza AUTHENTIK_BASE_URL y AUTHENTIK_TOKEN para validar y devuelve la configuración (base URL y token) desde variables de entorno. +// Devuelve la URL base y el Token que se leyó desde .env + +/** + * @typedef {Object} AkCfg + * @property {string} BASE // p.ej. "https://idp.example.com" + * @property {string} TOKEN // bearer + */ + +/** + * @typedef {Object} AkOpts + * @property {Record>} [qs] + * @property {any} [body] + * @property {number} [timeoutMs=10000] + * @property {number} [retries=0] + * @property {Record} [headers] + */ +``` +**Descripción:** Obtiene Config. + +**Parámetros:** *(sin parámetros)* + +**Retorna (aprox.):** `{ BASE, TOKEN }` + +**Errores/excepciones:** +- Puede lanzar `Error('[AK_CONFIG] Falta variable AUTHENTIK_BASE_URL')`. +- Puede lanzar `Error('[AK_CONFIG] Falta variable AUTHENTIK_TOKEN')`. + +**Ejemplo de uso:** +```js +const result = getConfig(); +console.log(result); +``` + +--- +### `akListGroups` + +**Firma:** `export async function akListGroups(search = "")` +**Ubicación:** línea 60 + +**Comentario previo en el código:** +```js +// Listar grupos con búsqueda por nombre/slug +``` +**Descripción:** Función `akListGroups`. Interactúa con una API HTTP. + +**Parámetros:** +- `search` (opcional, por defecto = `""`): descripción. + +**Retorna (aprox.):** `[]` + +**Ejemplo de uso:** +```js +const result = await akListGroups(search); +console.log(result); +``` + +--- +### `akPatchUserAttributes` + +**Firma:** `export async function akPatchUserAttributes(userPk, partialAttrs = {})` +**Ubicación:** línea 73 + +**Descripción:** Función `akPatchUserAttributes`. + +**Parámetros:** +- `userPk`: descripción. +- `partialAttrs` (opcional, por defecto = `{}`): descripción. + +**Retorna:** Puede no retornar valor explícito (`void`) o retorna según la rama de ejecución. + +**Ejemplo de uso:** +```js +const result = await akPatchUserAttributes(userPk, partialAttrs); +console.log(result); +``` + +--- +### `akEnsureGroupForTenant` + +**Firma:** `export async function akEnsureGroupForTenant(tenantHex)` +**Ubicación:** línea 97 + +**Descripción:** Función `akEnsureGroupForTenant`. Interactúa con una API HTTP. Maneja errores con bloques try/catch. + +**Parámetros:** +- `tenantHex`: descripción. + +**Retorna (aprox.):** `found.pk ?? found.id` + +**Errores/excepciones:** +- Puede lanzar `TypeError("akEnsureGroupForTenant: `tenantHex` is required")`. + +**Ejemplo de uso:** +```js +const result = await akEnsureGroupForTenant(tenantHex); +console.log(result); +``` + +--- +### `akAddUserToGroup` + +**Firma:** `export async function akAddUserToGroup(userPk, groupPk)` +**Ubicación:** línea 130 + +**Descripción:** Función `akAddUserToGroup`. Interactúa con una API HTTP. Maneja errores con bloques try/catch. + +**Parámetros:** +- `userPk`: descripción. +- `groupPk`: descripción. + +**Retorna (aprox.):** `await akPOST("/core/group_memberships/", { body: { user, group } })` + +**Errores/excepciones:** +- Puede lanzar `TypeError("akAddUserToGroup: `userPk` is required")`. +- Puede lanzar `TypeError("akAddUserToGroup: `groupPk` is required")`. + +**Ejemplo de uso:** +```js +const result = await akAddUserToGroup(userPk, groupPk); +console.log(result); +``` + +--- +### `request` + +**Firma:** `export async function request(method, path, opts = {}, cfg)` +**Ubicación:** línea 167 + +**Comentario previo en el código:** +```js +/** + * Llamada HTTP genérica con fetch + timeout + manejo de errores. + * @param {'GET'|'POST'|'PUT'|'PATCH'|'DELETE'} method + * @param {string} path Ruta relativa (ej. "/core/users/") o absoluta; si es relativa se antepone "/api/v3". + * @param {AkOpts} [opts] + * @param {AkCfg} [cfg] Config inyectada; si se omite se usa getConfig() + * @returns {Promise} + */ +``` +**Descripción:** Función `request`. + +**Parámetros:** +- `method`: descripción. +- `path`: descripción. +- `opts` (opcional, por defecto = `{}`): descripción. +- `cfg`: descripción. + +**Retorna:** Puede no retornar valor explícito (`void`) o retorna según la rama de ejecución. + +**Ejemplo de uso:** +```js +const result = await request(method, path, opts, cfg); +console.log(result); +``` + +--- +### `akFindUserByEmail` + +**Firma:** `export async function akFindUserByEmail(email)` +**Ubicación:** línea 262 + +**Comentario previo en el código:** +```js +// ------------------------------------------------------------ +// Funciones públicas +// ------------------------------------------------------------ +``` +**Descripción:** Función `akFindUserByEmail`. Interactúa con una API HTTP. + +**Parámetros:** +- `email`: descripción. + +**Retorna (aprox.):** `null` + +**Errores/excepciones:** +- Puede lanzar `TypeError("akFindUserByEmail: `email` is required")`. + +**Ejemplo de uso:** +```js +const result = await akFindUserByEmail(email); +console.log(result); +``` + +--- +### `akCreateUser` + +**Firma:** `export async function akCreateUser(p = {})` +**Ubicación:** línea 298 + +**Descripción:** Función `akCreateUser`. + +**Parámetros:** +- `p` (opcional, por defecto = `{}`): descripción. + +**Retorna:** Puede no retornar valor explícito (`void`) o retorna según la rama de ejecución. + +**Ejemplo de uso:** +```js +const result = await akCreateUser(p); +console.log(result); +``` + +--- +### `akSetPassword` + +**Firma:** `export async function akSetPassword(userPk, password, requireChange = true)` +**Ubicación:** línea 349 + +**Descripción:** Función `akSetPassword`. Interactúa con una API HTTP. Maneja errores con bloques try/catch. + +**Parámetros:** +- `userPk`: descripción. +- `password`: descripción. +- `requireChange` (opcional, por defecto = `true`): descripción. + +**Retorna (aprox.):** `true` + +**Errores/excepciones:** +- Puede lanzar `TypeError("akSetPassword: `userPk` is required")`. +- Puede lanzar `TypeError("akSetPassword: `password` is required")`. + +**Ejemplo de uso:** +```js +const result = await akSetPassword(userPk, password, requireChange); +console.log(result); +``` + +--- +### `akResolveGroupIdByName` + +**Firma:** `export async function akResolveGroupIdByName(name)` +**Ubicación:** línea 373 + +**Descripción:** Función `akResolveGroupIdByName`. + +**Parámetros:** +- `name`: descripción. + +**Retorna (aprox.):** `byName?.pk ?? byName?.id ?? null` + +**Errores/excepciones:** +- Puede lanzar `TypeError("akResolveGroupIdByName: `name` is required")`. + +**Ejemplo de uso:** +```js +const result = await akResolveGroupIdByName(name); +console.log(result); +``` + +--- +### `akResolveGroupId` + +**Firma:** `export async function akResolveGroupId({ id, pk, uuid, name, slug } = {})` +**Ubicación:** línea 389 + +**Descripción:** Función `akResolveGroupId`. + +**Parámetros:** +- `{ id`: descripción. +- `pk`: descripción. +- `uuid`: descripción. +- `name`: descripción. +- `slug }` (opcional, por defecto = `{}`): descripción. + +**Retorna:** Puede no retornar valor explícito (`void`) o retorna según la rama de ejecución. + +**Ejemplo de uso:** +```js +const result = await akResolveGroupId({ id, pk, uuid, name, slug }); +console.log(result); +``` + +--- +### `toPk` + +**Firma:** `const => toPk(v)` +**Ubicación:** línea 390 + +**Descripción:** Función `toPk`. + +**Parámetros:** +- `v`: descripción. + +**Retorna (aprox.):** `Number.isFinite(n) ? n : String(v)` + +**Ejemplo de uso:** +```js +const result = toPk(v); +console.log(result); +``` + +--- \ No newline at end of file diff --git a/services/app/.env.development b/services/app/.env.development index 98e361f..d9d6986 100644 --- a/services/app/.env.development +++ b/services/app/.env.development @@ -1,29 +1,26 @@ # ===== Runtime ===== NODE_ENV=development PORT=3030 -APP_LOCAL_PORT=3030 # ===== Session (usa el Redis del stack) ===== # Para DEV podemos reutilizar el Redis de Authentik. En prod conviene uno separado. -SESSION_SECRET=pon-una-clave-larga-y-unica -REDIS_URL=redis://authentik-redis:6379 +SESSION_SECRET=Neon*Mammal*Boaster*Ludicrous*Fender8*Crablike +REDIS_URL=redis://ak-redis:6379 -# ===== DB principal (metadatos de SuiteCoffee) ===== -DB_HOST=dev-tenants +# # ===== DB principal (metadatos de SuiteCoffee) ===== +DB_HOST=dev-db +DB_NAME=dev-suitecoffee DB_PORT=5432 -DB_NAME=dev-postgres -DB_USER=dev-user-postgres -DB_PASS=dev-pass-postgres +DB_USER=dev-user-suitecoffee +DB_PASS=dev-pass-suitecoffee -# ===== DB tenants (Tenants de SuiteCoffee) ===== +# # ===== DB tenants (Tenants de SuiteCoffee) ===== TENANTS_HOST=dev-tenants TENANTS_DB=dev-postgres +TENANTS_PORT=5432 TENANTS_USER=dev-user-postgres TENANTS_PASS=dev-pass-postgres -TENANTS_PORT=5432 - -# ===== (Opcional) Colores UI, si alguna vista los lee ===== -COL_PRI=452D19 # Marrón oscuro -COL_SEC=D7A666 # Crema / Café -COL_BG=FFA500 # Naranja +SESSION_COOKIE_NAME=sc.sid +SESSION_SECRET=pon-una-clave-larga-y-unica +REDIS_URL=redis://authentik-redis:6379 \ No newline at end of file diff --git a/services/app/src/pages/comandas.html.bak b/services/app/src/bak/comandas.html.bak similarity index 100% rename from services/app/src/pages/comandas.html.bak rename to services/app/src/bak/comandas.html.bak diff --git a/services/app/src/pages/dashboard.html.bak b/services/app/src/bak/dashboard.html.bak similarity index 100% rename from services/app/src/pages/dashboard.html.bak rename to services/app/src/bak/dashboard.html.bak diff --git a/services/app/src/pages/estadoComandas.html.bak b/services/app/src/bak/estadoComandas.html.bak similarity index 100% rename from services/app/src/pages/estadoComandas.html.bak rename to services/app/src/bak/estadoComandas.html.bak diff --git a/services/app/src/bak/reportes.ejs.bak b/services/app/src/bak/reportes.ejs.bak new file mode 100644 index 0000000..7f5ce28 --- /dev/null +++ b/services/app/src/bak/reportes.ejs.bak @@ -0,0 +1,402 @@ +<% /* Reportes - Asistencias y Tickets (Comandas) */ %> +
+ +
+

Reportes

+ +
+ + +
+
+
+
+ +
+
+ +
+
+ +
+
+ + + +
+
+
+ +
+ +
+
+ +
+
+ + + +
+
+
+
+ +
+ Los archivos Excel se generan como CSV (compatibles). Los PDF se generan con “Imprimir área” del navegador. +
+
+
+ + + + + + + +
+ + + + diff --git a/services/app/src/index.js b/services/app/src/index.js index 66124f5..55f8170 100644 --- a/services/app/src/index.js +++ b/services/app/src/index.js @@ -1,44 +1,62 @@ // services/app/src/index.js // ------------------------------------------------------------ // SuiteCoffee — Servicio APP (UI + APIs negocio) -// - ESM (Node >=18) // - Vistas EJS en ./views (dashboard.ejs, comandas.ejs, etc.) // - Sesión compartida con AUTH (cookie: sc.sid, Redis) -// - Monta routes.legacy.js con requireAuth + withTenant // ------------------------------------------------------------ - -import 'dotenv/config'; -import express from 'express'; -import cors from 'cors'; -import morgan from 'morgan'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; -import session from 'express-session'; +import 'dotenv/config'; // Variables de entorno directamente +// import dotenv from 'dotenv'; +import favicon from 'serve-favicon'; // Favicon +import express from 'express'; // Framework para enderizado de apps Web import expressLayouts from 'express-ejs-layouts'; -import { createClient as createRedisClient } from 'redis'; -import * as connectRedis from 'connect-redis'; -import { Pool } from 'pg'; +import session from 'express-session'; // Sessiones de Usuarios +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 { createClient } from 'redis'; // Cnexión con servidor redis +import morgan from 'morgan'; // Middleware de Express -// ----------------------------------------------------------------------------- -// Utilidades base -// ----------------------------------------------------------------------------- + +// ---------------------------------------------------------- +// Utilidades +// ---------------------------------------------------------- const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); -const RedisStore = connectRedis.default || connectRedis.RedisStore; - +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); -const REQUIRED = (...keys) => { - const miss = keys.filter((k) => !process.env[k]); - if (miss.length) { - console.warn(`⚠ Faltan variables de entorno: ${miss.join(', ')}`); + +// 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) // ----------------------------------------------------------------------------- -REQUIRED( +checkRequiredEnvVars( // Sesión 'SESSION_SECRET', 'REDIS_URL', // DB principal @@ -47,117 +65,338 @@ REQUIRED( 'TENANTS_HOST', 'TENANTS_DB', 'TENANTS_USER', 'TENANTS_PASS' ); -// ----------------------------------------------------------------------------- -// Pools de PostgreSQL -// ----------------------------------------------------------------------------- -const mainPool = new Pool({ - host: process.env.DB_HOST, - port: Number(process.env.DB_PORT || 5432), - database: process.env.DB_NAME, - user: process.env.DB_USER, - password: process.env.DB_PASS, - max: 10, - idleTimeoutMillis: 30_000, -}); - -const tenantsPool = new Pool({ - host: process.env.TENANTS_HOST, - port: Number(process.env.TENANTS_PORT || 5432), - database: process.env.TENANTS_DB, - user: process.env.TENANTS_USER, - password: process.env.TENANTS_PASS, - max: 10, - idleTimeoutMillis: 30_000, -}); - -// Autotest (no rompe si falla; sólo loguea) -(async () => { - try { - const c = await mainPool.connect(); - const r = await c.query('SELECT NOW() now'); - console.log('[APP] DB principal OK. Hora:', r.rows[0].now); - c.release(); - } catch (e) { - console.error('[APP] Error al conectar DB principal:', e.message); - } - try { - const c = await tenantsPool.connect(); - const r = await c.query('SELECT NOW() now'); - console.log('[APP] DB tenants OK. Hora:', r.rows[0].now); - c.release(); - } catch (e) { - console.error('[APP] Error al conectar DB tenants:', e.message); - } -})(); - -// ----------------------------------------------------------------------------- -// Express + EJS -// ----------------------------------------------------------------------------- +// ---------------------------------------------------------- +// App +// ---------------------------------------------------------- const app = express(); -app.set('trust proxy', 1); - -// Views EJS en ./views -app.set('views', path.join(__dirname, 'views')); -app.set('view engine', 'ejs'); -app.use(expressLayouts); -app.set("layout", "layouts/main"); - -// Estáticos (si tenés carpeta public/, assets, etc.) -app.use('/public', express.static(path.join(__dirname, 'public'))); - -// Middlewares básicos -app.use(morgan('dev')); +app.set('trust proxy', Number(process.env.TRUST_PROXY_HOPS || 2)); 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(morgan('dev')); // Middlewares básicos + + +// 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 = ""; + 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' +]; + + + + + + + + + + + +// ---------------------------------------------------------- +// Rutas de UI +// ---------------------------------------------------------- + +app.get("/", (req, res) => { + if (req.session?.user) { + res.locals.pageTitle = "Bienvenida"; + res.locals.pageId = "inicio"; // para el sidebar contextual + res.render("inicio"); + }else{ + res.redirect(303, "/auth/login"); + } +}); + +app.get("/inicio", (req, res) => { + res.locals.pageTitle = "Inicio"; + res.locals.pageId = "inicio"; // <- importante + res.render("inicio"); +}); + +app.get("/dashboard", requireAuth,(req, res) => { + res.locals.pageTitle = "Dashboard"; + res.locals.pageId = "dashboard"; // <- importante + res.render("dashboard"); +}); + +app.get("/comandas", requireAuth,(req, res) => { + res.locals.pageTitle = "Comandas"; + res.locals.pageId = "comandas"; // <- importante para el sidebar contextual + res.render("comandas"); +}); + +app.get("/estadoComandas", requireAuth,(req, res) => { + res.locals.pageTitle = "Estado de Comandas"; + res.locals.pageId = "estadoComandas"; + res.render("estadoComandas"); +}); + +app.get("/productos", requireAuth,(req, res) => { + res.locals.pageTitle = "Productos"; + res.locals.pageId = "productos"; + res.render("productos"); +}); + +app.get('/usuarios', requireAuth,(req, res) => { + res.locals.pageTitle = 'Usuarios'; + res.locals.pageId = 'usuarios'; + res.render('usuarios'); +}); + +app.get('/reportes', requireAuth,(req, res) => { + res.locals.pageTitle = 'Reportes'; + res.locals.pageId = 'reportes'; + res.render('reportes'); +}); + +app.get('/compras', requireAuth,(req, res) => { + res.locals.pageTitle = 'Compras'; + res.locals.pageId = 'compras'; + res.render('compras'); +}); + + +// ---------------------------------------------------------- +// 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'; +} + + + + + + + + + + + + + + + + + + + + // ----------------------------------------------------------------------------- // Sesión (Redis) — misma cookie que AUTH // ----------------------------------------------------------------------------- -const SESSION_COOKIE_NAME = 'sc.sid'; -const redis = createRedisClient({ url: process.env.REDIS_URL }); -await redis.connect().catch((e) => console.error('[APP] Redis session error:', e.message)); +const SESSION_COOKIE_NAME = process.env.SESSION_COOKIE_NAME || "sc.sid"; +const SESSION_SECRET = process.env.SESSION_SECRET || "pon-una-clave-larga-y-unica"; +const REDIS_URL = process.env.REDIS_URL || "redis://ak-redis:6379"; -app.use( - session({ - name: SESSION_COOKIE_NAME, - store: new RedisStore({ client: redis, prefix: 'sess:' }), - secret: process.env.SESSION_SECRET || 'change-me', - resave: false, - saveUninitialized: false, - cookie: { - httpOnly: true, - sameSite: 'lax', - secure: process.env.NODE_ENV === 'production', - // domain: 'suitecoffee.mateosaldain.uy', // (opcional) si lo necesitás - }, - }) -); +// 1) Redis client +const redis = createClient({ url: REDIS_URL /*, legacyMode: true */ }); +redis.on("error", (err) => console.error("[Redis] Client Error:", err)); +await redis.connect(); +console.log("[Redis] connected"); -// Exponer usuario a las vistas -app.use((req, res, next) => { - res.locals.user = req.session?.user || null; - next(); -}); +// 2) Resolver RedisStore (soporta: +// - v5: factory CJS -> connectRedis(session) +// - v6/v7: export { RedisStore } ó export default class RedisStore) +async function resolveRedisStore(session) { + const mod = await import("connect-redis"); // ESM/CJS agnóstico + // named export (v6/v7) + if (typeof mod.RedisStore === "function") return mod.RedisStore; + // default export (class ó factory) + if (typeof mod.default === "function") { + // ¿es clase neweable? + if (mod.default.prototype && (mod.default.prototype.get || mod.default.prototype.set)) { + return mod.default; // class RedisStore + } + // si no, asumimos factory antigua + const Store = mod.default(session); // connectRedis(session) + if (typeof Store === "function") return Store; // class devuelta por factory + } + // algunos builds CJS exponen la factory bajo mod (poco común) + if (typeof mod === "function") { + const Store = mod(session); + if (typeof Store === "function") return Store; + } + throw new Error("connect-redis: no se pudo resolver RedisStore (API desconocida)."); +} + +const RedisStore = await resolveRedisStore(session); + +// 3) Session middleware +app.use(session({ + name: SESSION_COOKIE_NAME, + secret: SESSION_SECRET, + resave: false, + saveUninitialized: false, + store: new RedisStore({ + client: redis, + prefix: "sc:", // opcional + }), + proxy: true, + cookie: { + secure: "auto", + httpOnly: true, + sameSite: "lax", + path: "/", // ¡crítico! visible en / y /auth/* + }, +})); // ----------------------------------------------------------------------------- // Middlewares de Auth/Tenant para routes.legacy.js // ----------------------------------------------------------------------------- function requireAuth(req, res, next) { - if (!req.session?.user) { - // Si querés devolver 401 en lugar de redirigir, cambia esta línea - return res.redirect('/auth/login'); + if (!req.session || !req.session.user) { + // Podés usar 302 (found) o 303 (see other) para redirección + return res.redirect(303, "/auth/login"); } next(); } @@ -190,53 +429,436 @@ async function withTenant(req, res, next) { } } -// No-op (compatibilidad con el archivo legacy si lo pasa al final) -function done(_req, _res, next) { return next && next(); } +// ---------------------------------------------------------- +// API +// ---------------------------------------------------------- +app.get('/api/tables', async (_req, res) => { + res.json(ALLOWED_TABLES); +}); -// ----------------------------------------------------------------------------- -// Home / Landing -// ----------------------------------------------------------------------------- -app.get('/', (req, res) => { - if (req.session?.user) { - return res.redirect('/comandas'); // ya logueado → dashboard +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 }); } - return res.render('login', { pageTitle: 'Iniciar sesión' }); }); -// 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('/comandas') : res.redirect('/auth/login'); +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'); - res.type('html').send(` - - SuiteCoffee · Definir contraseña - -
-

Definir contraseña

-
- - - - Luego te redirigiremos a iniciar sesión por SSO. -
-
- `); + 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 }); + } }); -// ----------------------------------------------------------------------------- -// Montar rutas legacy (render de EJS y APIs de negocio) -// ----------------------------------------------------------------------------- -const legacy = await import('./routes.legacy.js'); -legacy.default(app, { - requireAuth, - withTenant, - done, - mainPool, - tenantsPool, - express, +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); } +}); + +// 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 + }); + } +}); + + +// ---------------------------------------------------------- +// Verificación de conexión +// ---------------------------------------------------------- +async function verificarConexion() { + try { + const client = await mainPool.connect(); + const 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:', res.rows[0].hora); + client.release(); + } catch (error) { + console.error('Error al conectar con la base de datos al iniciar:', error.message); + console.error('Revisar credenciales y accesos de red.'); + } +} + // ----------------------------------------------------------------------------- // Health + 404 + errores // ----------------------------------------------------------------------------- @@ -250,10 +872,15 @@ app.use((err, _req, res, _next) => { res.status(500).json({ error: 'internal-error', detail: err?.message || String(err) }); }); -// ----------------------------------------------------------------------------- -// Arranque -// ----------------------------------------------------------------------------- -const PORT = Number(process.env.PORT || process.env.APP_LOCAL_PORT || 3030); + +// ---------------------------------------------------------- +// Inicio del servidor +// ---------------------------------------------------------- + +const PORT = process.env.PORT ? Number(process.env.PORT) : 3000; app.listen(PORT, () => { - console.log(`[APP] SuiteCoffee corriendo en http://localhost:${PORT}`); + console.log(`[APP] Servidor de aplicación escuchando en ${`http://localhost:${PORT}`}`); + console.log(`[APP] Comprobando accesibilidad a la db ${process.env.DB_NAME} del host ${process.env.DB_HOST} ...`); + verificarConexion(); }); + diff --git a/services/app/src/routes.legacy.js b/services/app/src/routes.legacy.js deleted file mode 100644 index fee4e5d..0000000 --- a/services/app/src/routes.legacy.js +++ /dev/null @@ -1,337 +0,0 @@ -// services/app/src/routes.legacy.js -// ----------------------------------------------------------------------------- -// Endpoints legacy de SuiteCoffee extraídos del index original y montados -// como módulo. No elimina nada; sólo organiza y robustece. -// -// Cómo se usa: el nuevo services/app/src/index.js hace -// const legacy = await import('./routes.legacy.js') -// legacy.default(app, { requireAuth, withTenant, done, mainPool, tenantsPool, express }) -// ----------------------------------------------------------------------------- - -export default function mount(app, ctx) { - const { requireAuth, withTenant, done, mainPool, tenantsPool, express } = ctx; - - // Aliases de compatibilidad con el archivo original - const pool = mainPool; // el original usaba `pool` (DB principal) - - // --------------------------------------------------------------------------- - // Helpers y seguridad (copiados/adaptados del archivo original) - // --------------------------------------------------------------------------- - const ALLOWED_TABLES = [ - 'roles','usuarios','usua_roles', - 'categorias','productos', - 'clientes','mesas', - 'comandas','deta_comandas', - 'proveedores','compras','deta_comp_producto', - 'mate_primas','deta_comp_materias', - 'prov_producto','prov_mate_prima', - 'receta_producto', 'asistencia_resumen_diario', - 'asistencia_intervalo', 'vw_compras' - ]; - const VALID_IDENT = /^[a-zA-Z_][a-zA-Z0-9_]*$/; - const q = (s) => `"${String(s).replace(/"/g, '""')}"`; // quote ident simple - function ensureTable(name) { - const t = String(name || '').toLowerCase(); - if (!ALLOWED_TABLES.includes(t)) throw new Error('Tabla no permitida'); - return t; - } - - async function getClient() { // el original devolvía pool.connect() - const client = await pool.connect(); - return client; - } - - // Columnas de una tabla - async function loadColumns(client, table) { - const sql = ` - SELECT - c.column_name, - c.data_type, - c.is_nullable = 'YES' AS is_nullable, - c.column_default, - 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 t2 ON t2.oid = a.attrelid - WHERE t2.relname = $1 AND a.attname = c.column_name - ) AS is_generated - 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; - } - - // PKs de una tabla - 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); - } - - // FKs salientes de una tabla → { [column]: { foreign_table, foreign_column } } - async function loadForeignKeys(client, table) { - const sql = ` - SELECT - kcu.column_name AS 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.constraint_type = 'FOREIGN KEY' - AND tc.table_schema = 'public' - AND tc.table_name = $1`; - 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; - } - - // Heurística para elegir una columna "label" en tablas referenciadas - async function pickLabelColumn(client, refTable) { - const preferred = ['nombre','raz_social','apodo','documento','correo','telefono','descripcion','detalle']; - 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'; - } - - // --------------------------------------------------------------------------- - // RUTAS DE UI (vistas) - // --------------------------------------------------------------------------- - app.get('/', (req, res) => { - res.locals.pageTitle = 'Dashboard'; - res.locals.pageId = 'home'; - res.render('dashboard'); - }); - - app.get('/dashboard', (req, res) => { - res.locals.pageTitle = 'Dashboard'; - res.locals.pageId = 'dashboard'; - res.render('dashboard'); - }); - - app.get('/comandas', (req, res) => { - res.locals.pageTitle = 'Comandas'; - res.locals.pageId = 'comandas'; - res.render('comandas'); - }); - - app.get('/estadoComandas', (req, res) => { - res.locals.pageTitle = 'Estado de Comandas'; - res.locals.pageId = 'estadoComandas'; - res.render('estadoComandas'); - }); - - app.get('/productos', (req, res) => { - res.locals.pageTitle = 'Productos'; - res.locals.pageId = 'productos'; - res.render('productos'); - }); - - app.get('/usuarios', (req, res) => { - res.locals.pageTitle = 'Usuarios'; - res.locals.pageId = 'usuarios'; - res.render('usuarios'); - }); - - app.get('/reportes', (req, res) => { - res.locals.pageTitle = 'Reportes'; - res.locals.pageId = 'reportes'; - res.render('reportes'); - }); - - app.get('/compras', (req, res) => { - res.locals.pageTitle = 'Compras'; - res.locals.pageId = 'compras'; - res.render('compras'); - }); - - // --------------------------------------------------------------------------- - // API: ejemplos por-tenant y utilitarios (introspección) - // --------------------------------------------------------------------------- - // Ejemplo conservado del original (usar search_path via withTenant) - app.get('/api/productos', requireAuth, withTenant, async (req, res, next) => { - const { rows } = await req.pg.query('SELECT * FROM productos ORDER BY id'); - res.json(rows); - }, done); - - // Listado de tablas permitidas - app.get('/api/tables', async (_req, res) => { - res.json(ALLOWED_TABLES); - }); - - // Esquema de una tabla (columnas + FKs) - 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 }); - } - }); - - // Opciones para una columna con FK (id/label) - 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 }); - } - }); - - // Datos de una tabla (limitados) — vista rápida - 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 order = pks[0] ? q(pks[0]) : '1'; - const sql = `SELECT * FROM ${q(table)} ORDER BY ${order} LIMIT $1`; - const { rows } = await client.query(sql, [limit]); - res.json(rows); - } finally { client.release(); } - } catch (e) { - res.status(400).json({ error: e.message, code: e.code, detail: e.detail }); - } - }); - - // Crear/actualizar registros genéricos (placeholder: pega aquí tu lógica original) - app.post('/api/table/:table', async (req, res) => { - // TODO: Pegar implementación original (insert/update genérico) aquí. - // Sugerencia: validar payload contra loadColumns(client, table), - // construir INSERT/UPDATE dinámico ignorando columnas generadas y PKs cuando corresponda. - res.status(501).json({ error: 'not-implemented', detail: 'Pegar lógica original de POST /api/table/:table' }); - }); - - // --------------------------------------------------------------------------- - // Endpoints de negocio (conservados tal cual cuando fue posible) - // --------------------------------------------------------------------------- - - // Detalle de una comanda - app.get('/api/comandas/:id/detalle', (req, res, next) => - pool.query( - `SELECT id_det_comanda, id_producto, producto_nombre, - cantidad, pre_unitario, subtotal, observaciones - FROM public.v_comandas_detalle_items - WHERE id_comanda = $1::int - ORDER BY id_det_comanda`, - [req.params.id] - ) - .then(r => res.json(r.rows)) - .catch(next) - ); - - // --------------------------------------------------------------------------- - // RPC / Reportes / Procedimientos (stubs con TODO si no se extrajo el SQL) - // --------------------------------------------------------------------------- - app.post('/api/rpc/find_usuarios_por_documentos', async (req, res) => { - // TODO: Pegar el SQL original. Ejemplo: - // const { documentos } = req.body || {}; - // const { rows } = await pool.query('SELECT * FROM public.find_usuarios_por_documentos($1::jsonb)', [JSON.stringify(documentos||[])]) - // res.json(rows); - res.status(501).json({ error: 'not-implemented' }); - }); - - app.post('/api/rpc/import_asistencia', async (req, res) => { - // TODO: pegar lógica original - res.status(501).json({ error: 'not-implemented' }); - }); - - app.post('/api/rpc/asistencia_get', async (req, res) => { - // TODO - res.status(501).json({ error: 'not-implemented' }); - }); - - app.post('/api/rpc/asistencia_update_raw', async (req, res) => { - // TODO - res.status(501).json({ error: 'not-implemented' }); - }); - - app.post('/api/rpc/asistencia_delete_raw', async (req, res) => { - // TODO - res.status(501).json({ error: 'not-implemented' }); - }); - - app.post('/api/rpc/report_tickets', async (req, res) => { - // TODO: posiblemente public.report_tickets_year(year int) - res.status(501).json({ error: 'not-implemented' }); - }); - - app.post('/api/rpc/report_asistencia', async (req, res) => { - // TODO: posiblemente public.report_asistencia(desde date, hasta date) - res.status(501).json({ error: 'not-implemented' }); - }); - - app.get('/api/rpc/report_gastos', async (req, res) => { - // TODO: pegar la SELECT/función original - res.status(501).json({ error: 'not-implemented' }); - }); - - app.post('/api/rpc/report_gastos', async (req, res) => { - try { - // Ejemplo de carcasa robusta en base a nombres vistos - const { desde, hasta } = req.body || {}; - if (!desde || !hasta) return res.status(400).json({ error: 'desde y hasta son requeridos' }); - // TODO: reemplazar por tu SQL real; esto es un placeholder ilutrativo - const sql = 'SELECT * FROM public.report_gastos($1::date, $2::date)'; - try { - const { rows } = await pool.query(sql, [desde, hasta]); - res.json(rows); - } catch (e) { - res.status(500).json({ error: 'report_gastos failed', message: e.message, detail: e.detail, code: e.code }); - } - } catch (e) { - res.status(500).json({ error: 'report_gastos failed', message: e.message }); - } - }); - - app.post('/api/rpc/save_compra', async (req, res) => { res.status(501).json({ error: 'not-implemented' }); }); - app.post('/api/rpc/get_compra', async (req, res) => { res.status(501).json({ error: 'not-implemented' }); }); - app.post('/api/rpc/delete_compra', async (req, res) => { res.status(501).json({ error: 'not-implemented' }); }); -} diff --git a/services/app/src/views/inicio.ejs b/services/app/src/views/inicio.ejs new file mode 100644 index 0000000..392e712 --- /dev/null +++ b/services/app/src/views/inicio.ejs @@ -0,0 +1,130 @@ + + + + + + + + Inicio • SuiteCoffee + + + +
+
+ <% + const hasUser = typeof user !== 'undefined' && user; + const hasCookies = typeof cookies !== 'undefined' && cookies && Object.keys(cookies).length; + const displayName = + (hasUser && (user.name || user.displayName || user.email)) || + (hasCookies && (cookies.user_name || cookies.displayName || cookies.email)) || + 'usuario'; + %> +

Hola, <%= displayName %> 👋

+

Bienvenido a SuiteCoffee. Este es tu inicio.

+ + <% if (hasUser) { %> +

Sesión

+ + + <% for (const [k,v] of Object.entries(user)) { %> + + + + + <% } %> + +
<%= k %><%= typeof v === 'object' ? JSON.stringify(v) : String(v) %>
+ <% } %> + +
+
+

Cookies (servidor)

+ <% if (hasCookies) { %> + + + + + + <% for (const [name, value] of Object.entries(cookies)) { %> + + + + + <% } %> + +
NombreValor
<%= name %><%= typeof value === 'object' ? JSON.stringify(value) : String(value) %>
+ <% } else { %> +

No se recibieron cookies desde el servidor (req.cookies). ¿Estás usando cookie-parser o pasando cookies al render?

+ <% } %> +
+ +
+

Cookies (navegador)

+ + + + + + + +
NombreValor
Cargando…
+

Raw document.cookie:

+ +
+
+
+
+ + + + diff --git a/services/auth/.env.development b/services/auth/.env.development index f22c778..0130084 100644 --- a/services/auth/.env.development +++ b/services/auth/.env.development @@ -1,12 +1,11 @@ # ===== Runtime ===== NODE_ENV=development PORT=4040 -AUTH_LOCAL_PORT=4040 # coincide con 'expose' del servicio auth # ===== Session (usa el Redis del stack) ===== # Para DEV podemos reutilizar el Redis de Authentik. En prod conviene uno separado. -SESSION_SECRET=pon-una-clave-larga-y-unica -REDIS_URL=redis://authentik-redis:6379 +SESSION_SECRET=Neon*Mammal*Boaster*Ludicrous*Fender8*Crablike +REDIS_URL=redis://ak-redis:6379 # ===== DB principal (metadatos de SuiteCoffee) ===== # Usa el alias de red del servicio 'db' (compose: aliases [dev-db]) @@ -23,8 +22,7 @@ TENANTS_USER=dev-user-postgres TENANTS_PASS=dev-pass-postgres TENANTS_PORT=5432 -TENANT_INIT_SQL=/home/mateo/SuiteCoffee/services/auth/src/db/initTenant.sql -# TENANT_INIT_SQL=~/SuiteCoffee/services/app/src/db/01_init.sql +TENANT_INIT_SQL=/app/src/db/initTenant.sql # ===== (Opcional) Colores UI, si alguna vista los lee ===== COL_PRI=452D19 # Marrón oscuro @@ -33,7 +31,7 @@ COL_BG=FFA500 # Naranja # ===== Authentik — Admin API (server-to-server dentro de la red) ===== # Usa el alias de red del servicio 'authentik' y su puerto interno 9000 -AUTHENTIK_BASE_URL=http://authentik:9000 +AUTHENTIK_BASE_URL=http://dev-authentik:9000 AUTHENTIK_TOKEN=eE3bFTLd4Rpt3ZkcidTC1EppDYMIr023ev3SXt4ImHynOfAGRVtAZVBXSNxj AUTHENTIK_DEFAULT_GROUP_NAME=suitecoffee-users @@ -41,13 +39,15 @@ AUTHENTIK_DEFAULT_GROUP_NAME=suitecoffee-users # DEV (todo dentro de la red de Docker): # - El auth service redirige al navegador a este issuer. Si NO tenés reverse proxy hacia Authentik, # esta URL interna NO será accesible desde el navegador del host. En ese caso, ver nota más abajo. -OIDC_ISSUER=https://authentik.suitecoffee.mateosaldain.uy/application/o/suitecoffee/ -OIDC_CLIENT_ID=ydnp9s9I7G4p9Pwt5OsNlcpk1VKB9auN7AxqqNjC -OIDC_CLIENT_SECRET=yqdI00kYMeQF8VdmhwN5QWUzPLUzRBYeeAH193FynuVD19mo1nBRf5c5IRojzPrxDS0Hk33guUwHFzaj8vjTbTRetwK528uNJ6BfrYGUN2vzxgdMHFLQOHSTR0gR1LtG +# OIDC_ISSUER=https://authentik.suitecoffee.mateosaldain.uy/application/o/suitecoffee/ +OIDC_ISSUER=https://sso.suitecoffee.uy/application/o/suitecoffee/ +OIDC_CLIENT_ID=1orMM8vOvf3WkN2FejXYvUFpPtONG0Lx1eMlwIpW +OIDC_CLIENT_SECRET=t5wx13qBcM0EFQ3cGnUIAmLzvbdsQrUVPv1OGWjszWkEp35pJQ55t7vZeeShqG49kuRAaiXv6PSGJLhRfGaponGaJl8gH1uCL7KIxdmm7UihgYoAXB2dFhZV4zRxfze2 # Redirect URI que definiste en el Provider. Usa el alias de red del servicio 'auth' (dev-auth) # Si accedés desde el host sin proxy, usa mejor http://localhost:4040/auth/callback y añadilo al Provider. -OIDC_REDIRECT_URI=https://suitecoffee.mateosaldain.uy/auth/callback +# OIDC_REDIRECT_URI=https://suitecoffee.mateosaldain.uy/auth/callback +OIDC_REDIRECT_URI=https://suitecoffee.uy/auth/callback # Cómo querés que maneje la contraseña Authentik para usuarios NUEVOS creados por tu backend: # - TEMP_FORCE_CHANGE: crea un password temporal y obliga a cambiar en el primer login (recomendado si usás login con usuario/clave) diff --git a/services/auth/src/ak.js b/services/auth/src/ak.js index 739c7c0..e3b113e 100644 --- a/services/auth/src/ak.js +++ b/services/auth/src/ak.js @@ -1,126 +1,244 @@ // services/auth/src/ak.js // ------------------------------------------------------------ -// Cliente mínimo y robusto para la API Admin de Authentik (v3) +// Cliente para la API Admin de Authentik (v3) // - Sin dependencias externas (usa fetch nativo de Node >=18) // - ESM compatible // - Timeouts, reintentos opcionales y mensajes de error claros // - Compatible con services/auth/src/index.js actual // ------------------------------------------------------------ +// Utiliza AUTHENTIK_BASE_URL y AUTHENTIK_TOKEN para validar y devuelve la configuración (base URL y token) desde variables de entorno. +// Devuelve la URL base y el Token que se leyó desde .env + /** - * Lee configuración desde process.env en cada llamada (para evitar problemas - * de orden de imports con dotenv). No falla en import-time. + * @typedef {Object} AkCfg + * @property {string} BASE // p.ej. "https://idp.example.com" + * @property {string} TOKEN // bearer */ + +/** + * @typedef {Object} AkOpts + * @property {Record>} [qs] + * @property {any} [body] + * @property {number} [timeoutMs=10000] + * @property {number} [retries=0] + * @property {Record} [headers] + */ + function getConfig() { - const BASE = (process.env.AUTHENTIK_BASE_URL || '').replace(/\/+$/, ''); + const BASE = (process.env.AUTHENTIK_BASE_URL || "").trim().replace(/\/+$/, ""); const TOKEN = process.env.AUTHENTIK_TOKEN || ''; - if (!BASE) throw new Error('AK_CONFIG: Falta AUTHENTIK_BASE_URL'); - if (!TOKEN) throw new Error('AK_CONFIG: Falta AUTHENTIK_TOKEN'); + if (!BASE) throw new Error('[AK_CONFIG] Falta variable AUTHENTIK_BASE_URL'); + if (!TOKEN) throw new Error('[AK_CONFIG] Falta variable AUTHENTIK_TOKEN'); return { BASE, TOKEN }; } +// ------------------------------------------------------------ +// Utilidades +// ------------------------------------------------------------ + +// Espera +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + // ------------------------------------------------------------ // Helpers de sincronización // ------------------------------------------------------------ -// -- util GET contra la API admin (ajusta si ya tenés un helper igual) -async function akGET(path) { - const base = (process.env.AUTHENTIK_BASE_URL || '').replace(/\/+$/, ''); - const url = `${base}${path}`; - const res = await fetch(url, { - headers: { Authorization: `Bearer ${process.env.AUTHENTIK_TOKEN}` }, - }); - if (!res.ok) { - const body = await res.text().catch(() => ''); - throw new Error(`AK GET ${path} -> ${res.status}: ${body}`); - } - return res.json(); +export function createAkClient(cfg = getConfig()) { + return { + request: (method, path, opts = {}) => request(method, path, opts, cfg), + akGET: (path, opts) => request("GET", path, opts, cfg), + akPOST: (path, opts) => request("POST", path, opts, cfg), + akPUT: (path, opts) => request("PUT", path, opts, cfg), + akPATCH: (path, opts) => request("PATCH", path, opts, cfg), + akDELETE:(path, opts) => request("DELETE", path, opts, cfg), + }; } -// -- listar grupos con búsqueda por nombre/slug -export async function akListGroups(search = '') { - const q = search ? `?search=${encodeURIComponent(search)}` : ''; - const data = await akGET(`/api/v3/core/groups/${q}`); - // algunas versiones devuelven {results:[]}, otras un array directo - return Array.isArray(data) ? data : (data.results || []); +// Listar grupos con búsqueda por nombre/slug +export async function akListGroups(search = "") { + const { akGET } = createAkClient(); + const term = String(search ?? "").trim(); + + const data = await akGET("/core/groups/", { + qs: term ? { search: term } : undefined, + }); + + if (Array.isArray(data)) return data; + if (data && Array.isArray(data.results)) return data.results; + return []; } export async function akPatchUserAttributes(userPk, partialAttrs = {}) { - // PATCH del usuario para asegurar attributes.tenant_uuid - return akRequest('patch', `/api/v3/core/users/${userPk}/`, { - data: { attributes: partialAttrs }, + + const id = String(userPk ?? "").trim(); + if (!id) throw new TypeError("[AK_PATH_USER_ATRIBUTES] atribute `userPk` is required"); + + if (partialAttrs == null || typeof partialAttrs !== "object" || Array.isArray(partialAttrs)) { + throw new TypeError("[AK_PATH_USER_ATRIBUTES] atribute `partialAttrs` must be a plain object"); + } + + // Remove undefineds to avoid unintentionally nulling keys server-side + const cleaned = Object.fromEntries( + Object.entries(partialAttrs).filter(([, v]) => v !== undefined) + ); + + if (Object.keys(cleaned).length === 0) { + throw new TypeError("[AK_PATH_USER_ATRIBUTES] atribute `partialAttrs` is required"); + } + + // NOTE: pass path WITHOUT /api/v3; the client prefixes it + return akPATCH(`/core/users/${encodeURIComponent(id)}/`, { + body: { attributes: cleaned }, }); } export async function akEnsureGroupForTenant(tenantHex) { - const groupName = `tenant_${tenantHex}`; + const { akGET, akPOST } = createAkClient(); - // buscar por nombre - const data = await akRequest('get', '/api/v3/core/groups/', { params: { name: groupName }}); - const g = (data?.results || [])[0]; - if (g) return g.pk; + const hex = String(tenantHex ?? "").trim(); + if (!hex) throw new TypeError("akEnsureGroupForTenant: `tenantHex` is required"); - // crear si no existe - const created = await akRequest('post', '/api/v3/core/groups/', { - data: { name: groupName, attributes: { tenant_uuid: tenantHex } }, - }); - return created.pk; + const groupName = `tenant_${hex}`; + + // 1) Buscar existente (normaliza {results:[]}/[]) + const data = await akGET("/core/groups/", { qs: { search: groupName } }); + const list = Array.isArray(data) ? data : (Array.isArray(data?.results) ? data.results : []); + const existing = list.find(g => g?.name === groupName); + if (existing?.pk ?? existing?.id) return existing.pk ?? existing.id; + + // 2) Crear si no existe + try { + const created = await akPOST("/core/groups/", { + body: { name: groupName, attributes: { tenant_uuid: hex } }, + }); + return created?.pk ?? created?.id; + } catch (e) { + // 3) Condición de carrera (otro proceso lo creó): reconsulta y devuelve + const msg = String(e?.message || ""); + if (/already exists|unique|duplicate|409/i.test(msg)) { + const data2 = await akGET("/core/groups/", { qs: { search: groupName } }); + const list2 = Array.isArray(data2) ? data2 : (Array.isArray(data2?.results) ? data2.results : []); + const found = list2.find(g => g?.name === groupName); + if (found?.pk ?? found?.id) return found.pk ?? found.id; + } + throw e; + } } export async function akAddUserToGroup(userPk, groupPk) { - // Endpoint de membership (en versiones recientes, POST users//groups/) - return akRequest('post', `/api/v3/core/users/${userPk}/groups/`, { data: { group: groupPk } }); + const { akPOST } = createAkClient(); + + const user = String(userPk ?? "").trim(); + const group = String(groupPk ?? "").trim(); + if (!user) throw new TypeError("akAddUserToGroup: `userPk` is required"); + if (!group) throw new TypeError("akAddUserToGroup: `groupPk` is required"); + + // API reciente: POST /core/users//groups/ { group: } + const path = `/core/users/${encodeURIComponent(user)}/groups/`; + + try { + return await akPOST(path, { body: { group } }); + } catch (e) { + const msg = String(e?.message || ""); + // Si ya es miembro, tratamos como éxito idempotente + if (/already.*member|exists|duplicate|409/i.test(msg)) { + return { ok: true, alreadyMember: true, userPk: user, groupPk: group }; + } + // Fallback para instancias viejas: /core/group_memberships/ { user, group } + if (/404|not\s*found/i.test(msg)) { + return await akPOST("/core/group_memberships/", { body: { user, group } }); + } + throw e; + } } -// Utilidad de espera -const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); /** * Llamada HTTP genérica con fetch + timeout + manejo de errores. - * @param {('GET'|'POST'|'PUT'|'PATCH'|'DELETE')} method - * @param {string} path - Ruta a partir de /api/v3 (por ej. "/core/users/") - * @param {{qs?:Record, body?:any, timeoutMs?:number, retries?:number}} [opts] + * @param {'GET'|'POST'|'PUT'|'PATCH'|'DELETE'} method + * @param {string} path Ruta relativa (ej. "/core/users/") o absoluta; si es relativa se antepone "/api/v3". + * @param {AkOpts} [opts] + * @param {AkCfg} [cfg] Config inyectada; si se omite se usa getConfig() + * @returns {Promise} */ -async function request(method, path, opts = {}) { - const { BASE, TOKEN } = getConfig(); + +export async function request(method, path, opts = {}, cfg) { + const { BASE, TOKEN } = cfg ?? getConfig(); const { - qs = undefined, - body = undefined, - timeoutMs = 10000, + qs, + body, + timeoutMs = 10_000, retries = 0, + headers = {}, } = opts; - const url = new URL(`${BASE}/api/v3${path}`); - if (qs) Object.entries(qs).forEach(([k, v]) => url.searchParams.set(k, String(v))); + // Construcción segura de URL + QS + const base = BASE.endsWith("/") ? BASE : `${BASE}/`; + let p = /^https?:\/\//i.test(path) ? path : (path.startsWith("/") ? path : `/${path}`); + if (!/^https?:\/\//i.test(p) && !p.startsWith("/api/")) p = `/api/v3${p}`; + const url = new URL(p, base); + if (qs && typeof qs === "object") { + for (const [k, v] of Object.entries(qs)) { + if (v == null) continue; + if (Array.isArray(v)) v.forEach((x) => url.searchParams.append(k, String(x))); + else url.searchParams.set(k, String(v)); + } + } + // Reintentos + timeout + const maxAttempts = Math.max(1, retries + 1); let lastErr; - for (let attempt = 1; attempt <= Math.max(1, retries + 1); attempt++) { - const controller = new AbortController(); - const t = setTimeout(() => controller.abort(new Error('AK_TIMEOUT')), timeoutMs); + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + const ctrl = new AbortController(); + const t = setTimeout(() => ctrl.abort(new Error("AK_TIMEOUT")), timeoutMs); try { - const res = await fetch(url, { + const init = { method, - signal: controller.signal, + signal: ctrl.signal, headers: { - 'Authorization': `Bearer ${TOKEN}`, - 'Content-Type': 'application/json', - 'Accept': 'application/json', + Authorization: `Bearer ${TOKEN}`, + Accept: "application/json", + ...headers, }, - body: body !== undefined ? JSON.stringify(body) : undefined, - }); + }; + if (body !== undefined) { + // Sólo forzar JSON si es objeto plano + const isPlainObj = body && typeof body === "object" && + !(body instanceof FormData) && + !(body instanceof URLSearchParams) && + !(typeof Blob !== "undefined" && body instanceof Blob); + if (isPlainObj) { + init.headers["Content-Type"] = init.headers["Content-Type"] || "application/json"; + init.body = JSON.stringify(body); + } else { + init.body = body; // deja que fetch maneje el Content-Type + } + } + + const res = await fetch(url, init); clearTimeout(t); - if (res.status === 204) return null; // sin contenido - - // intenta parsear JSON; si no es JSON, devuelve texto - const ctype = res.headers.get('content-type') || ''; - const payload = ctype.includes('application/json') ? await res.json().catch(() => ({})) : await res.text(); + if (res.status === 204 || res.status === 205) return null; + const ctype = res.headers.get("content-type") || ""; + const payload = /\bapplication\/json\b/i.test(ctype) ? await res.json().catch(() => ({})) : await res.text(); if (!res.ok) { - const detail = typeof payload === 'string' ? payload : payload?.detail || payload?.error || JSON.stringify(payload); - const err = new Error(`AK ${method} ${url.pathname} → HTTP ${res.status}: ${detail}`); + const detail = typeof payload === "string" ? payload : payload?.detail || payload?.error || JSON.stringify(payload); + const err = new Error(`AK ${method} ${url.pathname}${url.search} → ${res.status}: ${detail}`); err.status = res.status; // @ts-ignore + // Reintenta 5xx y 429 + if ((res.status >= 500 && res.status <= 599) || res.status === 429) { + lastErr = err; + if (attempt < maxAttempts) { + let delay = 500 * 2 ** (attempt - 1); + const ra = parseInt(res.headers.get("retry-after") || "", 10); + if (!Number.isNaN(ra)) delay = Math.max(delay, ra * 1000); + await new Promise(r => setTimeout(r, delay)); + continue; + } + } throw err; } @@ -128,11 +246,10 @@ async function request(method, path, opts = {}) { } catch (e) { clearTimeout(t); lastErr = e; - // Reintentos sólo en ECONNREFUSED/timeout/5xx - const msg = String(e?.message || e); - const retriable = msg.includes('ECONNREFUSED') || msg.includes('AK_TIMEOUT') || /\b5\d\d\b/.test(e?.status?.toString?.() || ''); - if (!retriable || attempt > retries) throw e; - await sleep(500 * attempt); // backoff lineal suave + const msg = String(e?.message || ""); + const retriable = msg.includes("AK_TIMEOUT") || msg.includes("ECONNREFUSED") || msg.includes("fetch failed"); + if (!retriable || attempt >= maxAttempts) throw e; + await new Promise(r => setTimeout(r, 500 * 2 ** (attempt - 1))); } } throw lastErr; @@ -142,124 +259,178 @@ async function request(method, path, opts = {}) { // Funciones públicas // ------------------------------------------------------------ -/** - * Busca un usuario por email en Authentik (case-insensitive) usando ?search= - * Devuelve el usuario exacto o null si no existe. - */ export async function akFindUserByEmail(email) { - if (!email) throw new Error('akFindUserByEmail: email requerido'); - const data = await request('GET', '/core/users/', { qs: { search: email, page_size: 50 }, retries: 3 }); - const list = Array.isArray(data?.results) ? data.results : []; - const lower = String(email).toLowerCase(); - return list.find((u) => (u.email || '').toLowerCase() === lower) || null; + const { akGET } = createAkClient(); + + const needle = String(email ?? "").trim().toLowerCase(); + if (!needle) throw new TypeError("akFindUserByEmail: `email` is required"); + + const PAGE_SIZE = 100; + let page = 1; + const MAX_PAGES = 10; + + while (page <= MAX_PAGES) { + const data = await akGET("/core/users/", { + qs: { search: needle, page_size: PAGE_SIZE, page }, + retries: 2, + }); + + const list = Array.isArray(data) + ? data + : (Array.isArray(data?.results) ? data.results : []); + + const found = list.find(u => String(u?.email || "").toLowerCase() === needle); + if (found) return found || null; + + // Continuar paginando sólo si hay más resultados + const hasNext = + Array.isArray(data) + ? list.length === PAGE_SIZE // array plano: inferimos por tamaño + : Boolean(data?.next); // DRF: link "next" + if (!hasNext) break; + + page += 1; + } + + return null; } -/** - * Crea un usuario en Authentik con atributos de tenant y opcionalmente lo - * agrega a un grupo existente. - * @param {{email:string, displayName?:string, tenantUuid?:string, addToGroupId?: number|string, isActive?: boolean}} p - * @returns {Promise} el objeto usuario creado - */ -export async function akCreateUser(p) { - const email = p?.email; - if (!email) throw new Error('akCreateUser: email requerido'); - const name = p?.displayName || email; - const tenantUuid = (p?.tenantUuid || '').replace(/-/g, ''); - const isActive = p?.isActive ?? true; +export async function akCreateUser(p = {}) { + const { akPOST } = createAkClient(); - // 1) crear usuario - const user = await request('POST', '/core/users/', { - body: { - username: email, - name, - email, - is_active: isActive, - attributes: tenantUuid ? { tenant_uuid: tenantUuid } : {}, - }, - retries: 3, - }); + const email = String(p.email ?? "").trim().toLowerCase(); + if (!email) throw new TypeError("akCreateUser: `email` is required"); - // 2) agregar a grupo (opcional) - if (p?.addToGroupId) { + const name = String(p.displayName ?? email).trim() || email; + const tenantUuid = String(p.tenantUuid ?? "").replace(/-/g, "").trim(); + const isActive = p.isActive ?? true; + + const body = { + username: email, + name, + email, + is_active: !!isActive, + attributes: tenantUuid ? { tenant_uuid: tenantUuid } : {}, + }; + + let user; + try { + user = await akPOST("/core/users/", { body, retries: 2 }); + } catch (e) { + const msg = String(e?.message || ""); + if (/409|already\s*exists|unique|duplicate/i.test(msg)) { + // Idempotencia: si ya existe, lo buscamos por email y lo devolvemos + const existing = await akFindUserByEmail(email); + if (existing) return existing; + } + throw e; + } + + // Agregar a grupo (opcional, no rompe el flujo si falla) + const groupId = p.addToGroupId != null ? String(p.addToGroupId).trim() : ""; + if (groupId) { try { - await request('POST', `/core/users/${user.pk}/groups/`, { body: { group: p.addToGroupId }, retries: 2 }); - } catch (e) { - // No rompas todo por el grupo; deja registro del error para que el caller decida. - console.warn(`akCreateUser: no se pudo agregar al grupo ${p.addToGroupId}:`, e?.message || e); + const userPk = encodeURIComponent(user.pk ?? user.id); + await akPOST(`/core/users/${userPk}/groups/`, { + body: { group: groupId }, + retries: 2, + }); + } catch (err) { + console.warn( + `akCreateUser: could not add user ${user.pk ?? user.id} to group ${groupId}:`, + err?.message || err + ); } } return user; } -/** - * Establece/forza una contraseña a un usuario (si tu política lo permite). - * @param {number|string} userPk - * @param {string} password - * @param {boolean} requireChange - si el usuario debe cambiarla al siguiente login - */ export async function akSetPassword(userPk, password, requireChange = true) { - if (!userPk) throw new Error('akSetPassword: userPk requerido'); - if (!password) throw new Error('akSetPassword: password requerida'); + const { akPOST } = createAkClient(); + + const id = String(userPk ?? "").trim(); + if (!id) throw new TypeError("akSetPassword: `userPk` is required"); + + const pwd = String(password ?? ""); + if (!pwd) throw new TypeError("akSetPassword: `password` is required"); + try { - await request('POST', `/core/users/${userPk}/set_password/`, { - body: { password, require_change: !!requireChange }, + await akPOST(`/core/users/${encodeURIComponent(id)}/set_password/`, { + body: { password: pwd, require_change: !!requireChange }, retries: 1, + timeoutMs: 15_000, }); return true; } catch (e) { - // Algunas instalaciones no permiten setear password por API (políticas). Propaga un error legible. - const err = new Error(`akSetPassword: no se pudo establecer la contraseña: ${e?.message || e}`); + const status = e?.status ? `HTTP ${e.status}: ` : ""; + const err = new Error(`akSetPassword: failed to set password (${status}${e?.message || e})`); err.cause = e; throw err; } } -/** - * Helper opcional para obtener grupos por nombre/slug si en el futuro lo necesitas - * (no usado por index.js; se deja por conveniencia). - */ - export async function akResolveGroupIdByName(name) { - const data = await akListGroups(name); - const lower = String(name || '').toLowerCase(); - const found = data.find(g => - String(g.name || '').toLowerCase() === lower || - String(g.slug || '').toLowerCase() === lower - ); - return found?.pk ?? null; + const term = String(name ?? "").trim(); + if (!term) throw new TypeError("akResolveGroupIdByName: `name` is required"); + + const needle = term.toLowerCase(); + const groups = await akListGroups(term); + if (!Array.isArray(groups) || groups.length === 0) return null; + + // Prefer exact slug match, then exact name match + const bySlug = groups.find(g => String(g?.slug ?? "").toLowerCase() === needle); + if (bySlug) return bySlug.pk ?? bySlug.id ?? null; + + const byName = groups.find(g => String(g?.name ?? "").toLowerCase() === needle); + return byName?.pk ?? byName?.id ?? null; } export async function akResolveGroupId({ id, pk, uuid, name, slug } = {}) { - // si te pasan pk/id directo, devolvelo - if (pk != null) return Number(pk); - if (id != null) return Number(id); + const toPk = (v) => { + if (v == null || v === "") return null; + const n = Number(v); + return Number.isFinite(n) ? n : String(v); + }; - // por UUID (devuelve objeto con pk) - if (uuid) { + // 1) Direct pk/id + const direct = pk ?? id; + const directPk = toPk(direct); + if (directPk != null) return directPk; + + const { akGET } = createAkClient(); + + // 2) By UUID (detail endpoint) + const uuidStr = String(uuid ?? "").trim(); + if (uuidStr) { try { - const g = await akGET(`/api/v3/core/groups/${encodeURIComponent(uuid)}/`); - if (g?.pk != null) return Number(g.pk); - } catch (e) { - // sigue intentando por nombre/slug - } + const g = await akGET(`/core/groups/${encodeURIComponent(uuidStr)}/`, { retries: 1 }); + const fromDetail = toPk(g?.pk ?? g?.id); + if (fromDetail != null) return fromDetail; + } catch { /* continue with name/slug */ } } - // por nombre/slug - if (name || slug) { - const needle = (name || slug); - const list = await akListGroups(needle); - const lower = String(needle || '').toLowerCase(); - const found = list.find(g => - String(g.name || '').toLowerCase() === lower || - String(g.slug || '').toLowerCase() === lower - ); - if (found?.pk != null) return Number(found.pk); + // 3) By exact name/slug + const needle = String(name ?? slug ?? "").trim(); + if (needle) { + const lower = needle.toLowerCase(); + const list = await akListGroups(needle); // expects [] or {results:[]}, handled in akListGroups + const found = + list.find(g => String(g?.slug ?? "").toLowerCase() === lower) || + list.find(g => String(g?.name ?? "").toLowerCase() === lower); + const fromList = toPk(found?.pk ?? found?.id); + if (fromList != null) return fromList; } return null; } // ------------------------------------------------------------ -// Fin +// Exportación de constantes // ------------------------------------------------------------ + +export const akGET = (path, opts) => request("GET", path, opts); +export const akPOST = (path, opts) => request("POST", path, opts); +export const akPUT = (path, opts) => request("PUT", path, opts); +export const akPATCH = (path, opts) => request("PATCH", path, opts); +export const akDELETE = (path, opts) => request("DELETE", path, opts); \ No newline at end of file diff --git a/services/auth/src/index.js b/services/auth/src/index.js index 603fe8a..777e0f8 100644 --- a/services/auth/src/index.js +++ b/services/auth/src/index.js @@ -5,7 +5,7 @@ // - Sesiones con Redis (compartibles con otros servicios) // - Vistas EJS (login) // - Rutas OIDC: /auth/login, /auth/callback, /auth/logout -// - Registro de usuario: /api/users/register (DB + Authentik) +// - Registro de usuario: /auth/api/users/register (DB + Authentik) // ------------------------------------------------------------ import 'dotenv/config'; @@ -13,23 +13,28 @@ import chalk from 'chalk'; import express from 'express'; import cors from 'cors'; import path from 'node:path'; -import fs from 'node:fs/promises'; +import { access, readFile } from 'node:fs/promises'; +import { constants as fsConstants } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { Pool } from 'pg'; import session from 'express-session'; -import { createClient as createRedisClient } from 'redis'; -import * as connectRedis from 'connect-redis'; +import { createClient } from 'redis'; import expressLayouts from 'express-ejs-layouts'; import { Issuer, generators } from 'openid-client'; import crypto from 'node:crypto'; -import { readFile } from 'node:fs/promises'; + +// ----------------------------------------------------------------------------- +// Importaciones desde archivos +// ----------------------------------------------------------------------------- +// Helpers de Authentik (admin API) +import { akFindUserByEmail, akCreateUser, + akSetPassword, akResolveGroupId } from './ak.js'; + // ----------------------------------------------------------------------------- // Helpers // ----------------------------------------------------------------------------- -const SESSION_COOKIE_NAME = 'sc.sid'; - // Normaliza UUID (acepta con/sin guiones) → "hex" sin guiones const cleanUuid = (u) => (u ? String(u).toLowerCase().replace(/[^0-9a-f]/g, '') : ''); @@ -37,14 +42,6 @@ const cleanUuid = (u) => (u ? String(u).toLowerCase().replace(/[^0-9a-f]/g, '') const schemaNameFor = (uuidHex) => `schema_tenant_${uuidHex}`; const roleNameFor = (uuidHex) => `tenant_${uuidHex}`; -// Helpers de Authentik (admin API) -const { - akFindUserByEmail, - akCreateUser, - akSetPassword, - akResolveGroupId -} = await import('./ak.js'); - // Quoter seguro de identificadores SQL (roles, schemas, tablas) // Identificador SQL (schema, role, table, …) const qi = (ident) => `"${String(ident).replace(/"/g, '""')}"`; @@ -282,13 +279,13 @@ let _cachedInitSql = null; async function loadInitSql() { if (_cachedInitSql !== null) return _cachedInitSql; const candidates = [ - process.env.TENANT_INIT_SQL, // recomendado via .env + process.env.TENANT_INIT_SQL, // opcional path.resolve(__dirname, 'db', 'initTenant.sql'), path.resolve(__dirname, '..', 'src', 'db', 'initTenant.sql'), ].filter(Boolean); for (const p of candidates) { try { - await fs.promises.access(p, fs.constants.R_OK); + await access(p, fsConstants.R_OK); const txt = await readFile(p, 'utf8'); _cachedInitSql = String(txt || ''); console.log(`[TENANT INIT] initTenant.sql: ${p} (${_cachedInitSql.length} bytes)`); @@ -349,7 +346,6 @@ async function initializeTenantSchemaIfEmpty(schema) { // ----------------------------------------------------------------------------- const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); -const RedisStore = connectRedis.default || connectRedis.RedisStore; function requiredEnv(keys) { const missing = keys.filter((k) => !process.env[k]); @@ -375,7 +371,7 @@ function genTempPassword(len = 12) { // Configuración Express // ----------------------------------------------------------------------------- const app = express(); -app.set('trust proxy', 1); +app.set('trust proxy', Number(process.env.TRUST_PROXY_HOPS || 2)); app.use(cors({ origin: true, credentials: true })); app.use(express.json()); app.use(express.urlencoded({ extended: true })); @@ -384,31 +380,70 @@ app.use(express.urlencoded({ extended: true })); app.set('views', path.join(__dirname, 'views')); app.set('view engine', 'ejs'); + // Archivos estáticos opcionales (ajusta si tu estructura difiere) app.use(express.static(path.join(__dirname, 'public'))); app.use('/pages', express.static(path.join(__dirname, 'pages'))); // ----------------------------------------------------------------------------- -// Sesión (Redis) +// Sesión (Redis) — misma cookie que APP // ----------------------------------------------------------------------------- requiredEnv(['SESSION_SECRET', 'REDIS_URL']); -const redis = createRedisClient({ url: process.env.REDIS_URL || 'redis://sessions-redis:6379' }); -await redis.connect().catch((e) => onFatal(e, 'No se pudo conectar a Redis (sesiones)')); +const SESSION_COOKIE_NAME = process.env.SESSION_NAME || "sc.sid"; +const SESSION_SECRET = process.env.SESSION_SECRET || "pon-una-clave-larga-y-unica"; +const REDIS_URL = process.env.REDIS_URL || "redis://authentik-redis:6379"; -app.use( - session({ - name: SESSION_COOKIE_NAME, - store: new RedisStore({ client: redis, prefix: 'sess:' }), - secret: process.env.SESSION_SECRET || 'change-me', - resave: false, - saveUninitialized: false, - cookie: { - httpOnly: true, - sameSite: 'lax', - secure: process.env.NODE_ENV === 'production', - }, - }) -); +// 1) Redis client +const redis = createClient({ url: REDIS_URL /*, legacyMode: true */ }); +redis.on("error", (err) => console.error("[Redis] Client Error:", err)); +await redis.connect(); +console.log("[Redis] connected"); + +// 2) Resolver RedisStore (soporta: +// - v5: factory CJS -> connectRedis(session) +// - v6/v7: export { RedisStore } ó export default class RedisStore) +async function resolveRedisStore(session) { + const mod = await import("connect-redis"); // ESM/CJS agnóstico + // named export (v6/v7) + if (typeof mod.RedisStore === "function") return mod.RedisStore; + // default export (class ó factory) + if (typeof mod.default === "function") { + // ¿es clase neweable? + if (mod.default.prototype && (mod.default.prototype.get || mod.default.prototype.set)) { + return mod.default; // class RedisStore + } + // si no, asumimos factory antigua + const Store = mod.default(session); // connectRedis(session) + if (typeof Store === "function") return Store; // class devuelta por factory + } + // algunos builds CJS exponen la factory bajo mod (poco común) + if (typeof mod === "function") { + const Store = mod(session); + if (typeof Store === "function") return Store; + } + throw new Error("connect-redis: no se pudo resolver RedisStore (API desconocida)."); +} + +const RedisStore = await resolveRedisStore(session); + +// 3) Session middleware +app.use(session({ + name: SESSION_COOKIE_NAME, + secret: SESSION_SECRET, + resave: false, + saveUninitialized: false, + store: new RedisStore({ + client: redis, + prefix: "sc:", // opcional + }), + proxy: true, + cookie: { + secure: "auto", + httpOnly: true, + sameSite: "lax", + path: "/", // ¡crítico! visible en / y /auth/* + }, +})); // Exponer usuario a las vistas (no tocar req.session) app.use((req, res, next) => { @@ -507,6 +542,89 @@ let oidcClient; } })(); +// ----------------------------------------------------------------------------- +// Vistas +// ----------------------------------------------------------------------------- + +// Página de login +app.get("/auth/login", (_req, res) => { + return res.render("login", { pageTitle: "Iniciar sesión" }); +}); + +app.post("/auth/login", async (req, res, next) => { + try { + const email = String(req.body.email || "").trim().toLowerCase(); + const password = String(req.body.password || ""); + const remember = req.body.remember === "on" || req.body.remember === true; + + if (!email || !password) { + return res.status(400).render("login", { pageTitle: "Iniciar sesión", error: "Completa email y contraseña." }); + } + + // Tabla/columnas por defecto; ajustables por env si tu esquema difiere + const USERS_TABLE = process.env.TENANTS_USERS_TABLE || "users"; + const COL_ID = process.env.TENANTS_COL_ID || "id"; + const COL_EMAIL = process.env.TENANTS_COL_EMAIL || "email"; + const COL_HASH = process.env.TENANTS_COL_HASH || "password_hash"; + const COL_ROLE = process.env.TENANTS_COL_ROLE || "role"; + const COL_TENANT = process.env.TENANTS_COL_TENANT || "tenant_id"; + + const { rows } = await tenantsPool.query( + `SELECT ${COL_ID} AS id, ${COL_EMAIL} AS email, ${COL_HASH} AS password_hash, + ${COL_ROLE} AS role, ${COL_TENANT} AS tenant_id + FROM ${USERS_TABLE} + WHERE ${COL_EMAIL} = $1 + LIMIT 1`, + [email] + ); + + if (rows.length === 0) { + return res.status(401).render("login", { pageTitle: "Iniciar sesión", error: "Credenciales inválidas." }); + } + + const user = rows[0]; + const ok = await bcrypt.compare(password, user.password_hash || ""); + if (!ok) { + return res.status(401).render("login", { pageTitle: "Iniciar sesión", error: "Credenciales inválidas." }); + } + + // (Opcional) registro de acceso en DB principal + try { + await pool.query( + "INSERT INTO auth_audit_log(email, tenant_id, action, at) VALUES ($1, $2, $3, NOW())", + [user.email, user.tenant_id, "login_success"] + ); + } catch { /* noop si no existe la tabla */ } + + // Sesión compartida + req.session.regenerate((err) => { + if (err) return next(err); + + req.session.user = { + id: user.id, + email: user.email, + role: user.role, + tenant_id: user.tenant_id, + loggedAt: Date.now(), + }; + + if (remember) { + req.session.cookie.maxAge = 30 * 24 * 60 * 60 * 1000; + } else { + req.session.cookie.expires = false; + } + + req.session.save((err2) => { + if (err2) return next(err2); + return res.redirect(303, "/"); // "/" → app decide /dashboard o /auth/login + }); + }); + } catch (e) { + next(e); + } +}); + + // ----------------------------------------------------------------------------- // Rutas OIDC // ----------------------------------------------------------------------------- @@ -617,7 +735,7 @@ app.get('/auth/callback', async (req, res, next) => { if (!res.headersSent) res.status(500).send('No se pudo guardar la sesión.'); return; } - if (!res.headersSent) return res.redirect('/'); // te llevará a /comandas si ya implementaste ese redirect + if (!res.headersSent) return res.redirect('/'); }); }); @@ -668,9 +786,9 @@ async function akDeleteUser(pkOrUuid) { } // ============================== -// POST /api/users/register +// POST /auth/api/users/register // ============================== -app.post('/api/users/register', async (req, res, next) => { +app.post('/auth/api/users/register', async (req, res, next) => { // 0) input const { email, @@ -830,7 +948,7 @@ app.post('/auth/password/set', async (req, res, next) => { // Espera: { email, display_name?, tenant_uuid } -// app.post('/api/users/register', async (req, res, next) => { +// app.post('/auth/auth/api/users/register', async (req, res, next) => { // const { email, display_name, tenant_uuid: rawTenant, role } = req.body || {}; // if (!email) return res.status(400).json({ error: 'email es obligatorio' }); @@ -893,7 +1011,7 @@ app.post('/auth/password/set', async (req, res, next) => { // ----------------------------------------------------------------------------- // Healthcheck // ----------------------------------------------------------------------------- -app.get('/health', (_req, res) => res.status(200).json({ status: 'ok' })); +app.get('/health', (_req, res) => res.status(200).json({ status: 'ok', service: 'auth' })); // ----------------------------------------------------------------------------- // 404 + Manejo de errores @@ -916,6 +1034,8 @@ const PORT = Number(process.env.PORT || 4040); console.log(`Activando entorno de -> ${env === 'PRODUCTION' ? chalk.red('PRODUCTION') : chalk.green('DEVELOPMENT')}`); await verificarConexion(); app.listen(PORT, () => { - console.log(`Servidor de autenticación de SuiteCoffee corriendo en ${chalk.yellow(`http://localhost:${PORT}`)}`); + console.log(`[AUTH] SuiteCoffee corriendo en http://localhost:${PORT}`); }); })(); + +export default app; \ No newline at end of file diff --git a/services/auth/src/views/login.ejs b/services/auth/src/views/login.ejs new file mode 100644 index 0000000..65dc771 --- /dev/null +++ b/services/auth/src/views/login.ejs @@ -0,0 +1,164 @@ + + + + + + <%= typeof pageTitle !== 'undefined' ? pageTitle : 'Iniciar sesión · SuiteCoffee' %> + + + + + + + +
+
+
+
+

SuiteCoffee

+

Accedé a tu cuenta

+
+ + + + +
+
+ + + + +
o
+ + +
+
+ + +
Ingresá un email válido.
+
+ +
+ + +
Ingresá tu nombre.
+
+ +
+ + +
Si te invitaron a una organización existente, pegá aquí su UUID. Si sos el primero de tu empresa, dejalo vacío y el equipo te asignará uno.
+
+ +
+ + +
+ +
+ +
+
+ +

+ Al continuar aceptás nuestros términos y políticas. +

+ +
+
+ +

+ ¿Ya tenés cuenta? Iniciá sesión con SSO +

+
+
+
+ + + + + +