This commit is contained in:
2025-10-16 19:49:50 +00:00
parent ba6b4fef4f
commit c4097bc737
119 changed files with 3765 additions and 14390 deletions
+30 -38
View File
@@ -1,62 +1,54 @@
# ===== Runtime =====
# =======================================================
# Runtime
NODE_ENV=development
PORT=4040
APP_BASE_URL=https://dev.suitecoffee.uy
# =======================================================
# ===== Session (usa el Redis del stack) =====
# Para DEV podemos reutilizar el Redis de Authentik. En prod conviene uno separado.
SESSION_SECRET=Neon*Mammal*Boaster*Ludicrous*Fender8*Crablike
SESSION_COOKIE_NAME=sc.sid
# ===== DB principal (metadatos de SuiteCoffee) =====
# Usa el alias de red del servicio 'db' (compose: aliases [dev-db])
DB_HOST=dev-db
DB_NAME=dev_suitecoffee_core
DB_PORT=5432
DB_USER=dev-user-suitecoffee
DB_PASS=dev-pass-suitecoffee
# =======================================================
# Configuración de Dases de Datos
CORE_DB_HOST=dev-db
CORE_DB_NAME=dev_suitecoffee_core
CORE_DB_PORT=5432
CORE_DB_USER=dev-user-suitecoffee
CORE_DB_PASS=dev-pass-suitecoffee
# ===== DB tenants (Tenants de SuiteCoffee) =====
TENANTS_HOST=dev-tenants
TENANTS_DB=dev_suitecoffee_tenants
TENANTS_PORT=5432
TENANTS_USER=suitecoffee
TENANTS_PASS=suitecoffee
TENANTS_DB_HOST=dev-tenants
TENANTS_DB_NAME=dev_suitecoffee_tenants
TENANTS_DB_PORT=5432
TENANTS_DB_USER=suitecoffee
TENANTS_DB_PASS=suitecoffee
# =======================================================
# ===== Authentik — Admin API (server-to-server dentro de la red) =====
# Usa el alias de red del servicio 'authentik' y su puerto interno 9000
# =======================================================
# Sesiones
SESSION_SECRET=Neon*Mammal*Boaster*Ludicrous*Fender8*Crablike
SESSION_NAME=sc.sid
COOKIE_DOMAIN=dev.suitecoffee.uy
# =======================================================
# =======================================================
# Authentik y OIDC
AK_TOKEN=h2apVHbd3ApMcnnSwfQPXbvximkvP8HnUE25ot3zXWuEEtJFaNCcOzDHB6Xw
AK_REDIS_URL=redis://ak-redis:6379
# ===== OIDC (DEBE coincidir con el Provider) =====
# DEV (todo dentro de la red de Docker):
# - El auth service redirige al navegador a este issuer. Si NO tenés reverse proxy hacia Authentik,
# esta URL interna NO será accesible desde el navegador del host. En ese caso, ver nota más abajo.
OIDC_LOGIN_URL=https://dev.sso.suitecoffee.uy
OIDC_REDIRECT_URI=https://dev.suitecoffee.uy/auth/callback
APP_BASE_URL=https://suitecoffee.uy
OIDC_CLIENT_ID=1orMM8vOvf3WkN2FejXYvUFpPtONG0Lx1eMlwIpW
OIDC_CLIENT_SECRET=t5wx13qBcM0EFQ3cGnUIAmLzvbdsQrUVPv1OGWjszWkEp35pJQ55t7vZeeShqG49kuRAaiXv6PSGJLhRfGaponGaJl8gH1uCL7KIxdmm7UihgYoAXB2dFhZV4zRxfze2
OIDC_LOGIN_URL=https://sso.suitecoffee.uy
OIDC_REDIRECT_URI = https://suitecoffee.uy/auth/callback
OIDC_CONFIG_URL=https://dev.sso.suitecoffee.uy/application/o/suitecoffee/.well-known/openid-configuration
OIDC_AUTHORIZE_URL=https://dev.sso.suitecoffee.uy/application/o/authorize/
OIDC_TOKEN_URL=https://dev.sso.suitecoffee.uy/application/o/token/
OIDC_USERINFO_URL=https://dev.sso.suitecoffee.uy/application/o/userinfo/
OIDC_LOGOUT_URL=https://dev.sso.suitecoffee.uy/application/o/suitecoffee/end-session/
OIDC_JWKS_URL=https://dev.sso.suitecoffee.uy/application/o/suitecoffee/jwks/
OIDC_CLIEN_ID=1orMM8vOvf3WkN2FejXYvUFpPtONG0Lx1eMlwIpW
OIDC_CONFIG_URL=https://sso.suitecoffee.uy/application/o/suitecoffee/.well-known/openid-configuration
OIDC_ISSUER=https://sso.suitecoffee.uy/application/o/suitecoffee/
OIDC_ISSUER_DISCOVERY=https://sso.suitecoffee.uy/application/o/suitecoffee/.well-known/openid-configuration
OIDC_AUTHORIZE_URL=https://sso.suitecoffee.uy/application/o/authorize/
OIDC_TOKEN_URL=https://sso.suitecoffee.uy/application/o/token/
OIDC_USERINFO_URL=https://sso.suitecoffee.uy/application/o/userinfo/
OIDC_LOGOUT_URL=https://sso.suitecoffee.uy/application/o/suitecoffee/end-session/
OIDC_JWKS_URL=https://sso.suitecoffee.uy/application/o/suitecoffee/jwks/
# =======================================================
-22
View File
@@ -1,22 +0,0 @@
NODE_ENV=production # Entorno de desarrollo
PORT=4000 # Variables del servicio -> suitecoffee-app
# AUTH_HOST=prod-auth
DB_HOST=prod-db
# Nombre de la base de datos
DB_NAME=suitecoffee
# Usuario y contraseña
DB_USER=suitecoffee
DB_PASS=suitecoffee
# Puertos del servicio de db
DB_LOCAL_PORT=5432
DB_DOCKER_PORT=5432
# Colores personalizados
COL_PRI=452D19 # Marrón oscuro
COL_SEC=D7A666 # Crema / Café
COL_BG=FFA500 # Naranja
+60
View File
@@ -9,6 +9,11 @@
"version": "1.0.0",
"license": "ISC",
"dependencies": {
"@suitecoffee/db": "file:../../packages/core/db",
"@suitecoffee/middlewares": "file:../../packages/core/middlewares",
"@suitecoffee/oidc": "file:../../packages/oidc",
"@suitecoffee/redis": "file:../../packages/core/redis",
"@suitecoffee/scripts": "file:../../packages/core/scripts",
"axios": "^1.11.0",
"bcrypt": "^5.1.1",
"chalk": "^5.6.0",
@@ -37,6 +42,35 @@
"nodemon": "^3.1.10"
}
},
"../../packages/core/db": {
"name": "@suitecoffee/db",
"version": "1.0.0",
"peerDependencies": {
"pg": "^8.16.3"
}
},
"../../packages/core/middlewares": {
"name": "@suitecoffee/middlewares",
"version": "1.0.0"
},
"../../packages/core/redis": {
"name": "@suitecoffee/redis",
"version": "1.0.0",
"peerDependencies": {
"pg": "^8.16.3"
}
},
"../../packages/core/scripts": {
"name": "@suitecoffee/scripts",
"version": "1.0.0"
},
"../../packages/oidc": {
"name": "@suitecoffee/oidc",
"version": "1.0.0",
"dependencies": {
"openid-client": "^6.0.0"
}
},
"node_modules/@epic-web/invariant": {
"version": "1.0.0",
"dev": true,
@@ -132,6 +166,26 @@
"@redis/client": "^5.8.2"
}
},
"node_modules/@suitecoffee/db": {
"resolved": "../../packages/core/db",
"link": true
},
"node_modules/@suitecoffee/middlewares": {
"resolved": "../../packages/core/middlewares",
"link": true
},
"node_modules/@suitecoffee/oidc": {
"resolved": "../../packages/oidc",
"link": true
},
"node_modules/@suitecoffee/redis": {
"resolved": "../../packages/core/redis",
"link": true
},
"node_modules/@suitecoffee/scripts": {
"resolved": "../../packages/core/scripts",
"link": true
},
"node_modules/@types/body-parser": {
"version": "1.19.6",
"license": "MIT",
@@ -473,6 +527,8 @@
},
"node_modules/connect-redis": {
"version": "9.0.0",
"resolved": "https://registry.npmjs.org/connect-redis/-/connect-redis-9.0.0.tgz",
"integrity": "sha512-QwzyvUePTMvEzG1hy45gZYw3X3YHrjmEdSkayURlcZft7hqadQ3X39wYkmCqblK2rGlw+XItELYt6GnyG6DEIQ==",
"license": "MIT",
"engines": {
"node": ">=18"
@@ -825,6 +881,8 @@
},
"node_modules/express-session": {
"version": "1.18.2",
"resolved": "https://registry.npmjs.org/express-session/-/express-session-1.18.2.tgz",
"integrity": "sha512-SZjssGQC7TzTs9rpPDuUrR23GNZ9+2+IkA/+IJWmvQilTr5OSliEHGF+D9scbIpdC6yGtTI0/VhaHoVes2AN/A==",
"license": "MIT",
"dependencies": {
"cookie": "0.7.2",
@@ -1770,6 +1828,8 @@
},
"node_modules/openid-client": {
"version": "5.7.1",
"resolved": "https://registry.npmjs.org/openid-client/-/openid-client-5.7.1.tgz",
"integrity": "sha512-jDBPgSVfTnkIh71Hg9pRvtJc6wTwqjRkN88+gCFtYWrlP4Yx2Dsrow8uPi3qLr/aeymPF3o2+dS+wOpglK04ew==",
"license": "MIT",
"dependencies": {
"jose": "^4.15.9",
+7 -5
View File
@@ -15,6 +15,13 @@
"nodemon": "^3.1.10"
},
"dependencies": {
"@suitecoffee/db": "file:../../packages/core/db",
"@suitecoffee/middlewares": "file:../../packages/core/middlewares",
"@suitecoffee/oidc": "file:../../packages/oidc",
"@suitecoffee/redis": "file:../../packages/core/redis",
"@suitecoffee/scripts": "file:../../packages/core/scripts",
"axios": "^1.11.0",
"bcrypt": "^5.1.1",
"chalk": "^5.6.0",
@@ -38,11 +45,6 @@
"pg-format": "^1.0.4",
"redis": "^5.8.2"
},
"imports": {
"#v1Router": "./src/api/v1/routes/routes.js",
"#pages": "./src/pages/pages.js",
"#db": "./src/db/poolSingleton.js"
},
"keywords": [],
"description": ""
}
-436
View File
@@ -1,436 +0,0 @@
// services/auth/src/ak.js
// ------------------------------------------------------------
// Cliente para la API Admin de Authentik (v3)
// - Sin dependencias externas (usa fetch nativo de Node >=18)
// - ESM compatible
// - Timeouts, reintentos opcionales y mensajes de error claros
// - Compatible con services/auth/src/index.js actual
// ------------------------------------------------------------
// Utiliza AUTHENTIK_BASE_URL y AUTHENTIK_TOKEN para validar y devuelve la configuración (base URL y token) desde variables de entorno.
// Devuelve la URL base y el Token que se leyó desde .env
/**
* @typedef {Object} AkCfg
* @property {string} BASE // p.ej. "https://idp.example.com"
* @property {string} TOKEN // bearer
*/
/**
* @typedef {Object} AkOpts
* @property {Record<string, string|number|boolean|Array<string|number|boolean>>} [qs]
* @property {any} [body]
* @property {number} [timeoutMs=10000]
* @property {number} [retries=0]
* @property {Record<string,string>} [headers]
*/
function getConfig() {
const BASE = (process.env.AUTHENTIK_BASE_URL || "").trim().replace(/\/+$/, "");
const TOKEN = process.env.AUTHENTIK_TOKEN || '';
if (!BASE) throw new Error('[AK_CONFIG] Falta variable AUTHENTIK_BASE_URL');
if (!TOKEN) throw new Error('[AK_CONFIG] Falta variable AUTHENTIK_TOKEN');
return { BASE, TOKEN };
}
// ------------------------------------------------------------
// Utilidades
// ------------------------------------------------------------
// Espera
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
// ------------------------------------------------------------
// Helpers de sincronización
// ------------------------------------------------------------
export function createAkClient(cfg = getConfig()) {
return {
request: (method, path, opts = {}) => request(method, path, opts, cfg),
akGET: (path, opts) => request("GET", path, opts, cfg),
akPOST: (path, opts) => request("POST", path, opts, cfg),
akPUT: (path, opts) => request("PUT", path, opts, cfg),
akPATCH: (path, opts) => request("PATCH", path, opts, cfg),
akDELETE:(path, opts) => request("DELETE", path, opts, cfg),
};
}
// Listar grupos con búsqueda por nombre/slug
export async function akListGroups(search = "") {
const { akGET } = createAkClient();
const term = String(search ?? "").trim();
const data = await akGET("/core/groups/", {
qs: term ? { search: term } : undefined,
});
if (Array.isArray(data)) return data;
if (data && Array.isArray(data.results)) return data.results;
return [];
}
export async function akPatchUserAttributes(userPk, partialAttrs = {}) {
const id = String(userPk ?? "").trim();
if (!id) throw new TypeError("[AK_PATH_USER_ATRIBUTES] atribute `userPk` is required");
if (partialAttrs == null || typeof partialAttrs !== "object" || Array.isArray(partialAttrs)) {
throw new TypeError("[AK_PATH_USER_ATRIBUTES] atribute `partialAttrs` must be a plain object");
}
// Remove undefineds to avoid unintentionally nulling keys server-side
const cleaned = Object.fromEntries(
Object.entries(partialAttrs).filter(([, v]) => v !== undefined)
);
if (Object.keys(cleaned).length === 0) {
throw new TypeError("[AK_PATH_USER_ATRIBUTES] atribute `partialAttrs` is required");
}
// NOTE: pass path WITHOUT /api/v3; the client prefixes it
return akPATCH(`/core/users/${encodeURIComponent(id)}/`, {
body: { attributes: cleaned },
});
}
export async function akEnsureGroupForTenant(tenantHex) {
const { akGET, akPOST } = createAkClient();
const hex = String(tenantHex ?? "").trim();
if (!hex) throw new TypeError("akEnsureGroupForTenant: `tenantHex` is required");
const groupName = `tenant_${hex}`;
// 1) Buscar existente (normaliza {results:[]}/[])
const data = await akGET("/core/groups/", { qs: { search: groupName } });
const list = Array.isArray(data) ? data : (Array.isArray(data?.results) ? data.results : []);
const existing = list.find(g => g?.name === groupName);
if (existing?.pk ?? existing?.id) return existing.pk ?? existing.id;
// 2) Crear si no existe
try {
const created = await akPOST("/core/groups/", {
body: { name: groupName, attributes: { tenant_uuid: hex } },
});
return created?.pk ?? created?.id;
} catch (e) {
// 3) Condición de carrera (otro proceso lo creó): reconsulta y devuelve
const msg = String(e?.message || "");
if (/already exists|unique|duplicate|409/i.test(msg)) {
const data2 = await akGET("/core/groups/", { qs: { search: groupName } });
const list2 = Array.isArray(data2) ? data2 : (Array.isArray(data2?.results) ? data2.results : []);
const found = list2.find(g => g?.name === groupName);
if (found?.pk ?? found?.id) return found.pk ?? found.id;
}
throw e;
}
}
export async function akAddUserToGroup(userPk, groupPk) {
const { akPOST } = createAkClient();
const user = String(userPk ?? "").trim();
const group = String(groupPk ?? "").trim();
if (!user) throw new TypeError("akAddUserToGroup: `userPk` is required");
if (!group) throw new TypeError("akAddUserToGroup: `groupPk` is required");
// API reciente: POST /core/users/<pk>/groups/ { group: <pk> }
const path = `/core/users/${encodeURIComponent(user)}/groups/`;
try {
return await akPOST(path, { body: { group } });
} catch (e) {
const msg = String(e?.message || "");
// Si ya es miembro, tratamos como éxito idempotente
if (/already.*member|exists|duplicate|409/i.test(msg)) {
return { ok: true, alreadyMember: true, userPk: user, groupPk: group };
}
// Fallback para instancias viejas: /core/group_memberships/ { user, group }
if (/404|not\s*found/i.test(msg)) {
return await akPOST("/core/group_memberships/", { body: { user, group } });
}
throw e;
}
}
/**
* Llamada HTTP genérica con fetch + timeout + manejo de errores.
* @param {'GET'|'POST'|'PUT'|'PATCH'|'DELETE'} method
* @param {string} path Ruta relativa (ej. "/core/users/") o absoluta; si es relativa se antepone "/api/v3".
* @param {AkOpts} [opts]
* @param {AkCfg} [cfg] Config inyectada; si se omite se usa getConfig()
* @returns {Promise<any|null>}
*/
export async function request(method, path, opts = {}, cfg) {
const { BASE, TOKEN } = cfg ?? getConfig();
const {
qs,
body,
timeoutMs = 10_000,
retries = 0,
headers = {},
} = opts;
// Construcción segura de URL + QS
const base = BASE.endsWith("/") ? BASE : `${BASE}/`;
let p = /^https?:\/\//i.test(path) ? path : (path.startsWith("/") ? path : `/${path}`);
if (!/^https?:\/\//i.test(p) && !p.startsWith("/api/")) p = `/api/v3${p}`;
const url = new URL(p, base);
if (qs && typeof qs === "object") {
for (const [k, v] of Object.entries(qs)) {
if (v == null) continue;
if (Array.isArray(v)) v.forEach((x) => url.searchParams.append(k, String(x)));
else url.searchParams.set(k, String(v));
}
}
// Reintentos + timeout
const maxAttempts = Math.max(1, retries + 1);
let lastErr;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
const ctrl = new AbortController();
const t = setTimeout(() => ctrl.abort(new Error("AK_TIMEOUT")), timeoutMs);
try {
const init = {
method,
signal: ctrl.signal,
headers: {
Authorization: `Bearer ${TOKEN}`,
Accept: "application/json",
...headers,
},
};
if (body !== undefined) {
// Sólo forzar JSON si es objeto plano
const isPlainObj = body && typeof body === "object" &&
!(body instanceof FormData) &&
!(body instanceof URLSearchParams) &&
!(typeof Blob !== "undefined" && body instanceof Blob);
if (isPlainObj) {
init.headers["Content-Type"] = init.headers["Content-Type"] || "application/json";
init.body = JSON.stringify(body);
} else {
init.body = body; // deja que fetch maneje el Content-Type
}
}
const res = await fetch(url, init);
clearTimeout(t);
if (res.status === 204 || res.status === 205) return null;
const ctype = res.headers.get("content-type") || "";
const payload = /\bapplication\/json\b/i.test(ctype) ? await res.json().catch(() => ({})) : await res.text();
if (!res.ok) {
const detail = typeof payload === "string" ? payload : payload?.detail || payload?.error || JSON.stringify(payload);
const err = new Error(`AK ${method} ${url.pathname}${url.search}${res.status}: ${detail}`);
err.status = res.status; // @ts-ignore
// Reintenta 5xx y 429
if ((res.status >= 500 && res.status <= 599) || res.status === 429) {
lastErr = err;
if (attempt < maxAttempts) {
let delay = 500 * 2 ** (attempt - 1);
const ra = parseInt(res.headers.get("retry-after") || "", 10);
if (!Number.isNaN(ra)) delay = Math.max(delay, ra * 1000);
await new Promise(r => setTimeout(r, delay));
continue;
}
}
throw err;
}
return payload;
} catch (e) {
clearTimeout(t);
lastErr = e;
const msg = String(e?.message || "");
const retriable = msg.includes("AK_TIMEOUT") || msg.includes("ECONNREFUSED") || msg.includes("fetch failed");
if (!retriable || attempt >= maxAttempts) throw e;
await new Promise(r => setTimeout(r, 500 * 2 ** (attempt - 1)));
}
}
throw lastErr;
}
// ------------------------------------------------------------
// Funciones públicas
// ------------------------------------------------------------
export async function akFindUserByEmail(email) {
const { akGET } = createAkClient();
const needle = String(email ?? "").trim().toLowerCase();
if (!needle) throw new TypeError("akFindUserByEmail: `email` is required");
const PAGE_SIZE = 100;
let page = 1;
const MAX_PAGES = 10;
while (page <= MAX_PAGES) {
const data = await akGET("/core/users/", {
qs: { search: needle, page_size: PAGE_SIZE, page },
retries: 2,
});
const list = Array.isArray(data)
? data
: (Array.isArray(data?.results) ? data.results : []);
const found = list.find(u => String(u?.email || "").toLowerCase() === needle);
if (found) return found || null;
// Continuar paginando sólo si hay más resultados
const hasNext =
Array.isArray(data)
? list.length === PAGE_SIZE // array plano: inferimos por tamaño
: Boolean(data?.next); // DRF: link "next"
if (!hasNext) break;
page += 1;
}
return null;
}
export async function akCreateUser(p = {}) {
const { akPOST } = createAkClient();
const email = String(p.email ?? "").trim().toLowerCase();
if (!email) throw new TypeError("akCreateUser: `email` is required");
const name = String(p.displayName ?? email).trim() || email;
const tenantUuid = String(p.tenantUuid ?? "").replace(/-/g, "").trim();
const isActive = p.isActive ?? true;
const body = {
username: email,
name,
email,
is_active: !!isActive,
attributes: tenantUuid ? { tenant_uuid: tenantUuid } : {},
};
let user;
try {
user = await akPOST("/core/users/", { body, retries: 2 });
} catch (e) {
const msg = String(e?.message || "");
if (/409|already\s*exists|unique|duplicate/i.test(msg)) {
// Idempotencia: si ya existe, lo buscamos por email y lo devolvemos
const existing = await akFindUserByEmail(email);
if (existing) return existing;
}
throw e;
}
// Agregar a grupo (opcional, no rompe el flujo si falla)
const groupId = p.addToGroupId != null ? String(p.addToGroupId).trim() : "";
if (groupId) {
try {
const userPk = encodeURIComponent(user.pk ?? user.id);
await akPOST(`/core/users/${userPk}/groups/`, {
body: { group: groupId },
retries: 2,
});
} catch (err) {
console.warn(
`akCreateUser: could not add user ${user.pk ?? user.id} to group ${groupId}:`,
err?.message || err
);
}
}
return user;
}
export async function akSetPassword(userPk, password, requireChange = true) {
const { akPOST } = createAkClient();
const id = String(userPk ?? "").trim();
if (!id) throw new TypeError("akSetPassword: `userPk` is required");
const pwd = String(password ?? "");
if (!pwd) throw new TypeError("akSetPassword: `password` is required");
try {
await akPOST(`/core/users/${encodeURIComponent(id)}/set_password/`, {
body: { password: pwd, require_change: !!requireChange },
retries: 1,
timeoutMs: 15_000,
});
return true;
} catch (e) {
const status = e?.status ? `HTTP ${e.status}: ` : "";
const err = new Error(`akSetPassword: failed to set password (${status}${e?.message || e})`);
err.cause = e;
throw err;
}
}
export async function akResolveGroupIdByName(name) {
const term = String(name ?? "").trim();
if (!term) throw new TypeError("akResolveGroupIdByName: `name` is required");
const needle = term.toLowerCase();
const groups = await akListGroups(term);
if (!Array.isArray(groups) || groups.length === 0) return null;
// Prefer exact slug match, then exact name match
const bySlug = groups.find(g => String(g?.slug ?? "").toLowerCase() === needle);
if (bySlug) return bySlug.pk ?? bySlug.id ?? null;
const byName = groups.find(g => String(g?.name ?? "").toLowerCase() === needle);
return byName?.pk ?? byName?.id ?? null;
}
export async function akResolveGroupId({ id, pk, uuid, name, slug } = {}) {
const toPk = (v) => {
if (v == null || v === "") return null;
const n = Number(v);
return Number.isFinite(n) ? n : String(v);
};
// 1) Direct pk/id
const direct = pk ?? id;
const directPk = toPk(direct);
if (directPk != null) return directPk;
const { akGET } = createAkClient();
// 2) By UUID (detail endpoint)
const uuidStr = String(uuid ?? "").trim();
if (uuidStr) {
try {
const g = await akGET(`/core/groups/${encodeURIComponent(uuidStr)}/`, { retries: 1 });
const fromDetail = toPk(g?.pk ?? g?.id);
if (fromDetail != null) return fromDetail;
} catch { /* continue with name/slug */ }
}
// 3) By exact name/slug
const needle = String(name ?? slug ?? "").trim();
if (needle) {
const lower = needle.toLowerCase();
const list = await akListGroups(needle); // expects [] or {results:[]}, handled in akListGroups
const found =
list.find(g => String(g?.slug ?? "").toLowerCase() === lower) ||
list.find(g => String(g?.name ?? "").toLowerCase() === lower);
const fromList = toPk(found?.pk ?? found?.id);
if (fromList != null) return fromList;
}
return null;
}
// ------------------------------------------------------------
// Exportación de constantes
// ------------------------------------------------------------
export const akGET = (path, opts) => request("GET", path, opts);
export const akPOST = (path, opts) => request("POST", path, opts);
export const akPUT = (path, opts) => request("PUT", path, opts);
export const akPATCH = (path, opts) => request("PATCH", path, opts);
export const akDELETE = (path, opts) => request("DELETE", path, opts);
-181
View File
@@ -1,181 +0,0 @@
// // ----------------------------------------------------------
// // API
// // ----------------------------------------------------------
// app.get('/api/tables', async (_req, res) => {
// res.json(ALLOWED_TABLES);
// });
// app.get('/api/schema/:table', async (req, res) => {
// try {
// const table = ensureTable(req.params.table);
// const client = await getClient();
// try {
// const columns = await loadColumns(client, table);
// const fks = await loadForeignKeys(client, table);
// const enriched = columns.map(c => ({ ...c, foreign: fks[c.column_name] || null }));
// res.json({ table, columns: enriched });
// } finally { client.release(); }
// } catch (e) {
// res.status(400).json({ error: e.message });
// }
// });
// app.get('/api/options/:table/:column', async (req, res) => {
// try {
// const table = ensureTable(req.params.table);
// const column = req.params.column;
// if (!VALID_IDENT.test(column)) throw new Error('Columna inválida');
// const client = await getClient();
// try {
// const fks = await loadForeignKeys(client, table);
// const fk = fks[column];
// if (!fk) return res.json([]);
// const refTable = fk.foreign_table;
// const refId = fk.foreign_column;
// const labelCol = await pickLabelColumn(client, refTable);
// const sql = `SELECT ${q(refId)} AS id, ${q(labelCol)} AS label FROM ${q(refTable)} ORDER BY ${q(labelCol)} LIMIT 1000`;
// const result = await client.query(sql);
// res.json(result.rows);
// } finally { client.release(); }
// } catch (e) {
// res.status(400).json({ error: e.message });
// }
// });
// app.get('/api/table/:table', async (req, res) => {
// try {
// const table = ensureTable(req.params.table);
// const limit = Math.min(parseInt(req.query.limit || '100', 10), 1000);
// const client = await getClient();
// try {
// const pks = await loadPrimaryKey(client, table);
// const orderBy = pks.length ? `ORDER BY ${pks.map(q).join(', ')} DESC` : '';
// const sql = `SELECT * FROM ${q(table)} ${orderBy} LIMIT ${limit}`;
// const result = await client.query(sql);
// // Normalizar: siempre devolver objetos {col: valor}
// const colNames = result.fields.map(f => f.name);
// let rows = result.rows;
// if (rows.length && Array.isArray(rows[0])) {
// rows = rows.map(r => Object.fromEntries(r.map((v, i) => [colNames[i], v])));
// }
// res.json(rows);
// } finally { client.release(); }
// } catch (e) {
// res.status(400).json({ error: e.message, code: e.code, detail: e.detail });
// }
// });
// app.post('/api/table/:table', async (req, res) => {
// const table = ensureTable(req.params.table);
// const payload = req.body || {};
// try {
// const client = await getClient();
// try {
// const columns = await loadColumns(client, table);
// const insertable = columns.filter(c =>
// !c.is_primary && !c.is_identity && !(c.column_default || '').startsWith('nextval(')
// );
// const allowedCols = new Set(insertable.map(c => c.column_name));
// const cols = [];
// const vals = [];
// const params = [];
// let idx = 1;
// for (const [k, v] of Object.entries(payload)) {
// if (!allowedCols.has(k)) continue;
// if (!VALID_IDENT.test(k)) continue;
// cols.push(q(k));
// vals.push(`$${idx++}`);
// params.push(v);
// }
// if (!cols.length) {
// const { rows } = await client.query(`INSERT INTO ${q(table)} DEFAULT VALUES RETURNING *`);
// res.status(201).json({ inserted: rows[0] });
// } else {
// const { rows } = await client.query(
// `INSERT INTO ${q(table)} (${cols.join(', ')}) VALUES (${vals.join(', ')}) RETURNING *`,
// params
// );
// res.status(201).json({ inserted: rows[0] });
// }
// } catch (e) {
// if (e.code === '23503') return res.status(400).json({ error: 'Violación de clave foránea', detail: e.detail });
// if (e.code === '23505') return res.status(400).json({ error: 'Violación de unicidad', detail: e.detail });
// if (e.code === '23514') return res.status(400).json({ error: 'Violación de CHECK', detail: e.detail });
// if (e.code === '23502') return res.status(400).json({ error: 'Campo NOT NULL faltante', detail: e.detail });
// throw e;
// }
// } catch (e) {
// res.status(400).json({ error: e.message });
// }
// });
// app.get('/api/comandas', async (req, res, next) => {
// try {
// const estado = (req.query.estado || '').trim() || null;
// const limit = Math.min(parseInt(req.query.limit || '200', 10), 1000);
// const { rows } = await mainPool.query(
// `SELECT * FROM public.f_comandas_resumen($1, $2)`,
// [estado, limit]
// );
// res.json(rows);
// } catch (e) { next(e); }
// });
// // Detalle de una comanda (con nombres de productos)
// // GET /api/comandas/:id/detalle
// app.get('/api/comandas/:id/detalle', (req, res, next) =>
// mainPool.query(
// `SELECT id_det_comanda, id_producto, producto_nombre,
// cantidad, pre_unitario, subtotal, observaciones
// FROM public.v_comandas_detalle_items
// WHERE id_comanda = $1::int
// ORDER BY id_det_comanda`,
// [req.params.id]
// )
// .then(r => res.json(r.rows))
// .catch(next)
// );
// // Cerrar comanda (setea estado y fec_cierre en DB)
// app.post('/api/comandas/:id/cerrar', async (req, res, next) => {
// try {
// const id = Number(req.params.id);
// if (!Number.isInteger(id) || id <= 0) {
// return res.status(400).json({ error: 'id inválido' });
// }
// const { rows } = await mainPool.query(
// `SELECT public.f_cerrar_comanda($1) AS data`,
// [id]
// );
// if (!rows.length || rows[0].data === null) {
// return res.status(404).json({ error: 'Comanda no encontrada' });
// }
// res.json(rows[0].data);
// } catch (err) { next(err); }
// });
// Abrir (reabrir) comanda
app.post('/api/comandas/:id/abrir', async (req, res, next) => {
try {
const id = Number(req.params.id);
if (!Number.isInteger(id) || id <= 0) {
return res.status(400).json({ error: 'id inválido' });
}
const { rows } = await mainPool.query(
`SELECT public.f_abrir_comanda($1) AS data`,
[id]
);
if (!rows.length || rows[0].data === null) {
return res.status(404).json({ error: 'Comanda no encontrada' });
}
res.json(rows[0].data);
} catch (err) { next(err); }
});
-230
View File
@@ -1,230 +0,0 @@
// // GET producto + receta
// app.get('/api/rpc/get_producto/:id', async (req, res) => {
// const id = Number(req.params.id);
// const { rows } = await mainPool.query('SELECT public.get_producto($1) AS data', [id]);
// res.json(rows[0]?.data || {});
// });
// // POST guardar producto + receta
// app.post('/api/rpc/save_producto', async (req, res) => {
// try {
// // console.debug('receta payload:', req.body?.receta); // habilitalo si lo necesitás
// const q = 'SELECT public.save_producto($1,$2,$3,$4,$5,$6,$7::jsonb) AS id_producto';
// const { id_producto = null, nombre, img_producto = null, precio = 0, activo = true, id_categoria = null, receta = [] } = req.body || {};
// const params = [id_producto, nombre, img_producto, precio, activo, id_categoria, JSON.stringify(receta || [])];
// const { rows } = await mainPool.query(q, params);
// res.json(rows[0] || {});
// } catch (e) {
// console.error(e);
// res.status(500).json({ error: 'save_producto failed' });
// }
// });
// // GET MP + proveedores
// app.get('/api/rpc/get_materia/:id', async (req, res) => {
// const id = Number(req.params.id);
// try {
// const { rows } = await mainPool.query('SELECT public.get_materia_prima($1) AS data', [id]);
// res.json(rows[0]?.data || {});
// } catch (e) {
// console.error(e);
// res.status(500).json({ error: 'get_materia failed' });
// }
// });
// // SAVE MP + proveedores (array)
// app.post('/api/rpc/save_materia', async (req, res) => {
// const { id_mat_prima = null, nombre, unidad, activo = true, proveedores = [] } = req.body || {};
// try {
// const q = 'SELECT public.save_materia_prima($1,$2,$3,$4,$5::jsonb) AS id_mat_prima';
// const params = [id_mat_prima, nombre, unidad, activo, JSON.stringify(proveedores || [])];
// const { rows } = await mainPool.query(q, params);
// res.json(rows[0] || {});
// } catch (e) {
// console.error(e);
// res.status(500).json({ error: 'save_materia failed' });
// }
// });
// // POST /api/rpc/find_usuarios_por_documentos { docs: ["12345678","09123456", ...] }
// app.post('/api/rpc/find_usuarios_por_documentos', async (req, res) => {
// try {
// const docs = Array.isArray(req.body?.docs) ? req.body.docs : [];
// const sql = 'SELECT public.find_usuarios_por_documentos($1::jsonb) AS data';
// const { rows } = await mainPool.query(sql, [JSON.stringify(docs)]);
// res.json(rows[0]?.data || {});
// } catch (e) {
// console.error(e);
// res.status(500).json({ error: 'find_usuarios_por_documentos failed' });
// }
// });
// // POST /api/rpc/import_asistencia { registros: [...], origen?: "AGL_001.txt" }
// app.post('/api/rpc/import_asistencia', async (req, res) => {
// try {
// const registros = Array.isArray(req.body?.registros) ? req.body.registros : [];
// const origen = req.body?.origen || null;
// const sql = 'SELECT public.import_asistencia($1::jsonb,$2) AS data';
// const { rows } = await mainPool.query(sql, [JSON.stringify(registros), origen]);
// res.json(rows[0]?.data || {});
// } catch (e) {
// console.error(e);
// res.status(500).json({ error: 'import_asistencia failed' });
// }
// });
// // Consultar datos de asistencia (raw + pares) para un usuario y rango
// app.post('/api/rpc/asistencia_get', async (req, res) => {
// try {
// const { doc, desde, hasta } = req.body || {};
// const sql = 'SELECT public.asistencia_get($1::text,$2::date,$3::date) AS data';
// const { rows } = await mainPool.query(sql, [doc, desde, hasta]);
// res.json(rows[0]?.data || {});
// } catch (e) {
// console.error(e); res.status(500).json({ error: 'asistencia_get failed' });
// }
// });
// // Editar un registro crudo y recalcular pares
// app.post('/api/rpc/asistencia_update_raw', async (req, res) => {
// try {
// const { id_raw, fecha, hora, modo } = req.body || {};
// const sql = 'SELECT public.asistencia_update_raw($1::bigint,$2::date,$3::text,$4::text) AS data';
// const { rows } = await mainPool.query(sql, [id_raw, fecha, hora, modo ?? null]);
// res.json(rows[0]?.data || {});
// } catch (e) {
// console.error(e); res.status(500).json({ error: 'asistencia_update_raw failed' });
// }
// });
// // Eliminar un registro crudo y recalcular pares
// app.post('/api/rpc/asistencia_delete_raw', async (req, res) => {
// try {
// const { id_raw } = req.body || {};
// const sql = 'SELECT public.asistencia_delete_raw($1::bigint) AS data';
// const { rows } = await mainPool.query(sql, [id_raw]);
// res.json(rows[0]?.data || {});
// } catch (e) {
// console.error(e); res.status(500).json({ error: 'asistencia_delete_raw failed' });
// }
// });
// // POST /api/rpc/report_tickets { year }
// app.post('/api/rpc/report_tickets', async (req, res) => {
// try {
// const y = parseInt(req.body?.year ?? req.query?.year, 10);
// const year = (Number.isFinite(y) && y >= 2000 && y <= 2100)
// ? y
// : (new Date()).getFullYear();
// const { rows } = await mainPool.query(
// 'SELECT public.report_tickets_year($1::int) AS j', [year]
// );
// res.json(rows[0].j);
// } catch (e) {
// console.error('report_tickets error:', e);
// res.status(500).json({
// error: 'report_tickets failed',
// message: e.message, detail: e.detail, where: e.where, code: e.code
// });
// }
// });
// // POST /api/rpc/report_asistencia { desde: 'YYYY-MM-DD', hasta: 'YYYY-MM-DD' }
// app.post('/api/rpc/report_asistencia', async (req, res) => {
// try {
// let { desde, hasta } = req.body || {};
// // defaults si vienen vacíos/invalidos
// const re = /^\d{4}-\d{2}-\d{2}$/;
// if (!re.test(desde) || !re.test(hasta)) {
// const end = new Date();
// const start = new Date(end); start.setDate(end.getDate() - 30);
// desde = start.toISOString().slice(0, 10);
// hasta = end.toISOString().slice(0, 10);
// }
// const { rows } = await mainPool.query(
// 'SELECT public.report_asistencia($1::date,$2::date) AS j', [desde, hasta]
// );
// res.json(rows[0].j);
// } catch (e) {
// console.error('report_asistencia error:', e);
// res.status(500).json({
// error: 'report_asistencia failed',
// message: e.message, detail: e.detail, where: e.where, code: e.code
// });
// }
// });
// // Guardar (insert/update)
// app.post('/api/rpc/save_compra', async (req, res) => {
// try {
// const { id_compra, id_proveedor, fec_compra, detalles } = req.body || {};
// const sql = 'SELECT * FROM public.save_compra($1::int,$2::int,$3::timestamptz,$4::jsonb)';
// const args = [id_compra ?? null, id_proveedor, fec_compra ? new Date(fec_compra) : null, JSON.stringify(detalles)];
// const { rows } = await mainPool.query(sql, args);
// res.json(rows[0]); // { id_compra, total }
// } catch (e) {
// console.error('save_compra error:', e);
// res.status(500).json({ error: 'save_compra failed', message: e.message, detail: e.detail, where: e.where, code: e.code });
// }
// });
// // Obtener para editar
// app.post('/api/rpc/get_compra', async (req, res) => {
// try {
// const { id_compra } = req.body || {};
// const sql = `SELECT public.get_compra($1::int) AS data`;
// const { rows } = await mainPool.query(sql, [id_compra]);
// res.json(rows[0]?.data || {});
// } catch (e) {
// console.error(e); res.status(500).json({ error: 'get_compra failed' });
// }
// });
// // Eliminar
// app.post('/api/rpc/delete_compra', async (req, res) => {
// try {
// const { id_compra } = req.body || {};
// await mainPool.query(`SELECT public.delete_compra($1::int)`, [id_compra]);
// res.json({ ok: true });
// } catch (e) {
// console.error(e); res.status(500).json({ error: 'delete_compra failed' });
// }
// });
// // POST /api/rpc/report_gastos { year: 2025 }
// app.post('/api/rpc/report_gastos', async (req, res) => {
// try {
// const year = parseInt(req.body?.year ?? new Date().getFullYear(), 10);
// const { rows } = await mainPool.query(
// 'SELECT public.report_gastos($1::int) AS j', [year]
// );
// res.json(rows[0].j);
// } catch (e) {
// console.error('report_gastos error:', e);
// res.status(500).json({
// error: 'report_gastos failed',
// message: e.message, detail: e.detail, code: e.code
// });
// }
// });
// // (Opcional) GET para probar rápido desde el navegador:
// // /api/rpc/report_gastos?year=2025
// app.get('/api/rpc/report_gastos', async (req, res) => {
// try {
// const year = parseInt(req.query.year ?? new Date().getFullYear(), 10);
// const { rows } = await mainPool.query(
// 'SELECT public.report_gastos($1::int) AS j', [year]
// );
// res.json(rows[0].j);
// } catch (e) {
// console.error('report_gastos error:', e);
// res.status(500).json({
// error: 'report_gastos failed',
// message: e.message, detail: e.detail, code: e.code
// });
// }
// });
-340
View File
@@ -1,340 +0,0 @@
// services/manso/src/api/v1/routes/routes.js
import { Router } from 'express';
import pool from '#db'; // Pool Singleton
const router = Router();
// ==========================================================
// Rutas de API v1
// ==========================================================
// ----------------------------------------------------------
// API Comandas
// ----------------------------------------------------------
router.route('/comandas').get( async (req, res, next) => {
try {
var client = await pool.getClient()
const estado = (req.query.estado || '').trim() || null;
const limit = Math.min(parseInt(req.query.limit || '200', 10), 1000);
const { rows } = await client.query(
`SELECT * FROM public.f_comandas_resumen($1, $2)`,
[estado, limit]
);
res.json(rows);
} catch (e) {
next(e);
} finally {
client.release();
}
});
router.route('/comandas/:id/detalle').get( async (req, res, next) => {
try {
const client = await pool.getClient()
client.query(
`SELECT id_det_comanda, id_producto, producto_nombre,
cantidad, pre_unitario, subtotal, observaciones
FROM public.v_comandas_detalle_items
WHERE id_comanda = $1::int
ORDER BY id_det_comanda`,
[req.params.id]
)
.then(r => res.json(r.rows))
.catch(next)
client.release();
} catch (error) {
next(e);
}
});
router.route('/comandas/:id/cerrar').post( async (req, res, next) => {
try {
const client = await pool.getClient()
const id = Number(req.params.id);
if (!Number.isInteger(id) || id <= 0) {
return res.status(400).json({ error: 'id inválido' });
}
const { rows } = await client.query(
`SELECT public.f_cerrar_comanda($1) AS data`,
[id]
);
if (!rows.length || rows[0].data === null) {
return res.status(404).json({ error: 'Comanda no encontrada' });
}
res.json(rows[0].data);
client.release();
} catch (err) { next(err); }
});
router.route('/comandas/:id/abrir').post( async (req, res, next) => {
try {
const client = await pool.getClient()
const id = Number(req.params.id);
if (!Number.isInteger(id) || id <= 0) {
return res.status(400).json({ error: 'id inválido' });
}
const { rows } = await client.query(
`SELECT public.f_abrir_comanda($1) AS data`,
[id]
);
if (!rows.length || rows[0].data === null) {
return res.status(404).json({ error: 'Comanda no encontrada' });
}
res.json(rows[0].data);
client.release();
} catch (err) { next(err); }
});
// ----------------------------------------------------------
// API Productos
// ----------------------------------------------------------
// GET producto + receta
router.route('/rpc/get_producto/:id').get( async (req, res) => {
const client = await pool.getClient()
const id = Number(req.params.id);
const { rows } = await client.query('SELECT public.get_producto($1) AS data', [id]);
res.json(rows[0]?.data || {});
client.release();
});
// POST guardar producto + receta
router.route('/rpc/save_producto').post(async (req, res) => {
try {
// console.debug('receta payload:', req.body?.receta); // habilitalo si lo necesitás
const client = await pool.getClient()
const q = 'SELECT public.save_producto($1,$2,$3,$4,$5,$6,$7::jsonb) AS id_producto';
const { id_producto=null, nombre, img_producto=null, precio=0, activo=true, id_categoria=null, receta=[] } = req.body || {};
const params = [id_producto, nombre, img_producto, precio, activo, id_categoria, JSON.stringify(receta||[])];
const { rows } = await client.query(q, params);
res.json(rows[0] || {});
client.release();
} catch(e) {
console.error(e);
res.status(500).json({ error: 'save_producto failed' });
}
});
// ----------------------------------------------------------
// API Materias Primas
// ----------------------------------------------------------
// GET MP + proveedores
router.route('/rpc/get_materia/:id').get(async (req, res) => {
const id = Number(req.params.id);
try {
const client = await pool.getClient()
const { rows } = await client.query('SELECT public.get_materia_prima($1) AS data', [id]);
res.json(rows[0]?.data || {});
client.release();
} catch (e) {
console.error(e);
res.status(500).json({ error: 'get_materia failed' });
}
});
// SAVE MP + proveedores (array)
router.route('/rpc/save_materia').post( async (req, res) => {
const { id_mat_prima=null, nombre, unidad, activo=true, proveedores=[] } = req.body || {};
try {
const q = 'SELECT public.save_materia_prima($1,$2,$3,$4,$5::jsonb) AS id_mat_prima';
const params = [id_mat_prima, nombre, unidad, activo, JSON.stringify(proveedores||[])];
const { rows } = await pool.query(q, params);
res.json(rows[0] || {});
} catch (e) {
console.error(e);
res.status(500).json({ error: 'save_materia failed' });
}
});
// ----------------------------------------------------------
// API Usuarios y Asistencias
// ----------------------------------------------------------
// POST /api/rpc/find_usuarios_por_documentos { docs: ["12345678","09123456", ...] }
router.route('/rpc/find_usuarios_por_documentos').post( async (req, res) => {
try {
const docs = Array.isArray(req.body?.docs) ? req.body.docs : [];
const sql = 'SELECT public.find_usuarios_por_documentos($1::jsonb) AS data';
const { rows } = await pool.query(sql, [JSON.stringify(docs)]);
res.json(rows[0]?.data || {});
} catch (e) {
console.error(e);
res.status(500).json({ error: 'find_usuarios_por_documentos failed' });
}
});
// POST /api/rpc/import_asistencia { registros: [...], origen?: "AGL_001.txt" }
router.route('/rpc/import_asistencia').post( async (req, res) => {
try {
const registros = Array.isArray(req.body?.registros) ? req.body.registros : [];
const origen = req.body?.origen || null;
const sql = 'SELECT public.import_asistencia($1::jsonb,$2) AS data';
const { rows } = await pool.query(sql, [JSON.stringify(registros), origen]);
res.json(rows[0]?.data || {});
} catch (e) {
console.error(e);
res.status(500).json({ error: 'import_asistencia failed' });
}
});
// Consultar datos de asistencia (raw + pares) para un usuario y rango
router.route('/rpc/asistencia_get').post( async (req, res) => {
try {
const { doc, desde, hasta } = req.body || {};
const sql = 'SELECT public.asistencia_get($1::text,$2::date,$3::date) AS data';
const { rows } = await pool.query(sql, [doc, desde, hasta]);
res.json(rows[0]?.data || {});
} catch (e) {
console.error(e); res.status(500).json({ error: 'asistencia_get failed' });
}
});
// Editar un registro crudo y recalcular pares
router.route('/rpc/asistencia_update_raw').post( async (req, res) => {
try {
const { id_raw, fecha, hora, modo } = req.body || {};
const sql = 'SELECT public.asistencia_update_raw($1::bigint,$2::date,$3::text,$4::text) AS data';
const { rows } = await pool.query(sql, [id_raw, fecha, hora, modo ?? null]);
res.json(rows[0]?.data || {});
} catch (e) {
console.error(e); res.status(500).json({ error: 'asistencia_update_raw failed' });
}
});
// Eliminar un registro crudo y recalcular pares
router.route('/rpc/asistencia_delete_raw').post( async (req, res) => {
try {
const { id_raw } = req.body || {};
const sql = 'SELECT public.asistencia_delete_raw($1::bigint) AS data';
const { rows } = await pool.query(sql, [id_raw]);
res.json(rows[0]?.data || {});
} catch (e) {
console.error(e); res.status(500).json({ error: 'asistencia_delete_raw failed' });
}
});
// ----------------------------------------------------------
// API Reportes
// ----------------------------------------------------------
// POST /api/rpc/report_tickets { year }
router.route('/rpc/report_tickets').post( async (req, res) => {
try {
const y = parseInt(req.body?.year ?? req.query?.year, 10);
const year = (Number.isFinite(y) && y >= 2000 && y <= 2100)
? y
: (new Date()).getFullYear();
const { rows } = await pool.query(
'SELECT public.report_tickets_year($1::int) AS j', [year]
);
res.json(rows[0].j);
} catch (e) {
console.error('report_tickets error:', e);
res.status(500).json({
error: 'report_tickets failed',
message: e.message, detail: e.detail, where: e.where, code: e.code
});
}
});
// POST /api/rpc/report_asistencia { desde: 'YYYY-MM-DD', hasta: 'YYYY-MM-DD' }
router.route('/rpc/report_asistencia').post( async (req, res) => {
try {
let { desde, hasta } = req.body || {};
// defaults si vienen vacíos/invalidos
const re = /^\d{4}-\d{2}-\d{2}$/;
if (!re.test(desde) || !re.test(hasta)) {
const end = new Date();
const start = new Date(end); start.setDate(end.getDate()-30);
desde = start.toISOString().slice(0,10);
hasta = end.toISOString().slice(0,10);
}
const { rows } = await pool.query(
'SELECT public.report_asistencia($1::date,$2::date) AS j', [desde, hasta]
);
res.json(rows[0].j);
} catch (e) {
console.error('report_asistencia error:', e);
res.status(500).json({
error: 'report_asistencia failed',
message: e.message, detail: e.detail, where: e.where, code: e.code
});
}
});
// ----------------------------------------------------------
// API Compras y Gastos
// ----------------------------------------------------------
// Guardar (insert/update)
router.route('/rpc/save_compra').post( async (req, res) => {
try {
const { id_compra, id_proveedor, fec_compra, detalles } = req.body || {};
const sql = 'SELECT * FROM public.save_compra($1::int,$2::int,$3::timestamptz,$4::jsonb)';
const args = [id_compra ?? null, id_proveedor, fec_compra ? new Date(fec_compra) : null, JSON.stringify(detalles)];
const { rows } = await pool.query(sql, args);
res.json(rows[0]); // { id_compra, total }
} catch (e) {
console.error('save_compra error:', e);
res.status(500).json({ error: 'save_compra failed', message: e.message, detail: e.detail, where: e.where, code: e.code });
}
});
// Obtener para editar
router.route('/rpc/get_compra').post( async (req, res) => {
try {
const { id_compra } = req.body || {};
const sql = `SELECT public.get_compra($1::int) AS data`;
const { rows } = await pool.query(sql, [id_compra]);
res.json(rows[0]?.data || {});
} catch (e) {
console.error(e); res.status(500).json({ error: 'get_compra failed' });
}
});
// Eliminar
router.route('/rpc/delete_compra').post( async (req, res) => {
try {
const { id_compra } = req.body || {};
await pool.query(`SELECT public.delete_compra($1::int)`, [id_compra]);
res.json({ ok: true });
} catch (e) {
console.error(e); res.status(500).json({ error: 'delete_compra failed' });
}
});
// POST /api/rpc/report_gastos { year: 2025 }
router.route('/rpc/report_gastos').post( async (req, res) => {
try {
const year = parseInt(req.body?.year ?? new Date().getFullYear(), 10);
const { rows } = await pool.query(
'SELECT public.report_gastos($1::int) AS j', [year]
);
res.json(rows[0].j);
} catch (e) {
console.error('report_gastos error:', e);
res.status(500).json({
error: 'report_gastos failed',
message: e.message, detail: e.detail, code: e.code
});
}
});
export default router;
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
-83
View File
@@ -1,83 +0,0 @@
// Coneción Singleton a base de datos.
import { Pool } from 'pg';
class DatabaseCore {
constructor() {
if (DatabaseCore.instance) {
return Database.instance;
}
const config = {
host: process.env.DB_HOST,
user: process.env.DB_USER,
password: process.env.DB_PASS,
database: process.env.DB_NAME,
port: process.env.DB_LOCAL_PORT ? Number(process.env.DB_LOCAL_PORT) : undefined,
ssl: process.env.PGSSL === 'true' ? { rejectUnauthorized: false } : undefined,
};
this.connection = new Pool(config);
DatabaseCore.instance = this;
}
async query(sql, params) {
return this.connection.query(sql,params);
}
async connect() { /* Definida solo para evitar errores */
return this.connection.connect();
}
async getClient() {
return this.connection.connect();
}
async release() {
await this.connection.end();
}
}
class DatabaseTenants {
constructor() {
if (DatabaseTenants.instance) {
return Database.instance;
}
const config = {
host: process.env.DB_HOST,
user: process.env.DB_USER,
password: process.env.DB_PASS,
database: process.env.DB_NAME,
port: process.env.DB_LOCAL_PORT ? Number(process.env.DB_LOCAL_PORT) : undefined,
ssl: process.env.PGSSL === 'true' ? { rejectUnauthorized: false } : undefined,
};
this.connection = new Pool(config);
DatabaseTenants.instance = this;
}
async query(sql, params) {
return this.connection.query(sql,params);
}
async connect() { /* Definida solo para evitar errores */
return this.connection.connect();
}
async getClient() {
return this.connection.connect();
}
async release() {
await this.connection.end();
}
}
// const db = new Database();
// db.query('SELECT * FROM users');
const poolCore = new DatabaseCore();
const poolTenants = new DatabaseTenants();
export default {poolCore, poolTenants};
export { poolCore, poolTenants };
//export { DatabaseCore, DatabaseTenants };
+276 -188
View File
@@ -1,240 +1,328 @@
// services/auth/src/index.js
// services/auth/src/index.mjs
// ------------------------------------------------------------
// SuiteCoffee — Servicio de Autenticación (Express + OIDC)
// ------------------------------------------------------------
import 'dotenv/config';
import express from 'express'; // Framework para enderizado de apps Web
import expressLayouts from 'express-ejs-layouts';
// import { poolCore, poolTenants } from '@suitecoffee/db'; // dbCore y dbTenants desde módulo
import { poolCore, poolTenants } from '#db'; // dbCore y dbTenants
import v1Router from '#v1Router'; // Rutas API v1
import expressPages from '#pages'; // Rutas "/", "/dashboard", ...
import express from 'express';
import session from 'express-session';
import { RedisStore } from 'connect-redis';
import { generators } from 'openid-client';
// import { initOIDCFromEnv } from '@suitecoffee/oidc';
import { initOIDCFromEnv, getOIDC } from '@suitecoffee/oidc';
import { verificarConexionCore, verificarConexionTenants } from '@suitecoffee/db';
import { redisAuthentik, verificarConexionRedisAuthentik } from '@suitecoffee/redis';
import { checkRequiredEnvVars } from '@suitecoffee/scripts';
import path from 'path';
import { fileURLToPath } from 'url'; // Converts a file:// URL string or URL object into a platform-specific file
import { fileURLToPath } from 'url';
import cookieParser from 'cookie-parser';
import { ensureUserAndTenantOnFirstLogin } from './registration/bootstrap.mjs';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
import fs from 'node:fs/promises';
import crypto from 'node:crypto';
import fetch from "node-fetch";
// -----------------------------------------------------------------------------
// Validación de entorno mínimo (ajusta nombres si difieren)
// Validación de entorno mínimo
// -----------------------------------------------------------------------------
// Función para verificar que ciertas variables de entorno estén definida
function checkRequiredEnvVars(...requiredKeys) {
const missingKeys = requiredKeys.filter((key) => !process.env[key]); // Filtramos las que NO existen en process.env
if (missingKeys.length > 0) { // Si falta alguna, mostramos una advertencia
console.warn(
`[APP] No se encontraron las siguientes variables de entorno: \n\n-> ${missingKeys.join('\n-> ')}`+
`\n`
);
}
}
checkRequiredEnvVars(
'PORT', 'APP_BASE_URL',
'CORE_DB_HOST', 'CORE_DB_PORT', 'CORE_DB_NAME',
'TENANTS_DB_HOST', 'TENANTS_DB_PORT', 'TENANTS_DB_NAME',
'OIDC_LOGIN_URL', 'OIDC_REDIRECT_URI',
'OIDC_CLIEN_ID', 'OIDC_CONFIG_URL', 'OIDC_ISSUER',
'OIDC_ISSUER_DISCOVERY', 'OIDC_AUTHORIZE_URL', 'OIDC_TOKEN_URL',
'OIDC_USERINFO_URL', 'OIDC_LOGOUT_URL', 'OIDC_JWKS_URL',
'SESSION_SECRET', 'SESSION_COOKIE_NAME',
'AK_REDIS_URL', 'AK_TOKEN'
'SESSION_SECRET', 'SESSION_NAME', 'AK_REDIS_URL',
'OIDC_CLIENT_ID', 'OIDC_REDIRECT_URI',
'OIDC_CONFIG_URL' // o 'OIDC_ISSUER'
);
// ----------------------------------------------------------
// Variables del sistema
// ----------------------------------------------------------
// De entorno
const PORT = process.env.PORT;
const APP_BASE_URL = process.env.APP_BASE_URL;
const CORE_DB_HOST = process.env.CORE_DB_HOST;
const CORE_DB_PORT = process.env.CORE_DB_PORT;
const CORE_DB_NAME = process.env.CORE_DB_NAME;
const TENANTS_DB_HOST = process.env.TENANTS_DB_HOST;
const TENANTS_DB_PORT = process.env.TENANTS_DB_PORT;
const TENANTS_DB_NAME = process.env.TENANTS_DB_NAME;
const OIDC_LOGIN_URL = process.env.OIDC_LOGIN_URL;
const OIDC_REDIRECT_URI = process.env.OIDC_REDIRECT_URI;
const OIDC_CLIEN_ID = process.env.OIDC_CLIEN_ID;
const OIDC_CONFIG_URL = process.env.OIDC_CONFIG_URL;
const OIDC_ISSUER = process.env.OIDC_ISSUER;
const OIDC_ISSUER_DISCOVERY = process.env.OIDC_ISSUER_DISCOVERY;
const OIDC_AUTHORIZE_URL = process.env.OIDC_AUTHORIZE_URL;
const OIDC_TOKEN_URL = process.env.OIDC_TOKEN_URL;
const OIDC_USERINFO_URL = process.env.OIDC_USERINFO_URL;
const OIDC_LOGOUT_URL = process.env.OIDC_LOGOUT_URL;
const OIDC_JWKS_URL = process.env.OIDC_JWKS_URL;
const AK_SESSION_SECRET = process.env.AK_SESSION_SECRET;
const AK_SESSION_COOKIE_NAME = process.env.AK_SESSION_COOKIE_NAME;
const AK_REDIS_URL = process.env.AK_REDIS_URL;
const PORT = process.env.PORT;
const SESSION_NAME = process.env.SESSION_NAME;
const SESSION_SECRET = process.env.SESSION_SECRET;
const COOKIE_DOMAIN = process.env.COOKIE_DOMAIN;
const LOGGED_OUT_PATH = process.env.LOGGED_OUT_PATH || '/logged-out';
const APP_BASE_URL = process.env.APP_BASE_URL;
// -----------------------------------------------------------------------------
// Utilidades / Helpers
// Config Express
// -----------------------------------------------------------------------------
// -----------------------------------------------------------------------------
// Configuración Express
// -----------------------------------------------------------------------------
const app = express();
app.set('trust proxy', true);
app.set("views", path.join(__dirname, "views"));
app.set("view engine", "ejs");
app.set("layout", "layouts/main");
app.disable("x-powered-by");
app.use(express.json());
app.use(express.json({ limit: '1mb' }));
app.use(express.urlencoded({ extended: true }));
// Archivos estáticos que fuerzan la re-descarga de arhivos
app.use(express.static(path.join(__dirname, "public"), {
etag: false, maxAge: 0,
setHeaders: (res, path) => {
res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
}
app.use(cookieParser(SESSION_SECRET));
// -----------------------------------------------------------------------------
// Sesión (Redis)
// -----------------------------------------------------------------------------
await redisAuthentik.connect();
const redisClient = redisAuthentik.getClient();
app.use(session({
name: SESSION_NAME,
store: new RedisStore({ client: redisClient, prefix: 'sess:' }),
secret: SESSION_SECRET,
resave: false,
saveUninitialized: false,
cookie: {
httpOnly: true,
sameSite: 'lax',
secure: process.env.NODE_ENV === 'production',
...(COOKIE_DOMAIN ? { domain: COOKIE_DOMAIN } : {}),
},
}));
app.use(cookieParser(process.env.SESSION_SECRET));
app.use(expressPages); // Renderizado trae las paginas desde ./services/manso/src/routes/routes.js
// ----------------------------------------------------------
// Middleware para datos globales
// ----------------------------------------------------------
app.use((req, res, next) => {
res.locals.currentPath = req.path;
res.locals.pageTitle = "SuiteCoffee";
res.locals.pageId = "";
next();
});
// ----------------------------------------------------------
// Verificación de conexión
// ----------------------------------------------------------
async function verificarConexionCore() {
try {
console.log(`[APP] Comprobando accesibilidad a la db ${CORE_DB_NAME} del host ${CORE_DB_HOST} ...`);
const client = await poolCore.connect();
const { rows } = await client.query('SELECT NOW() AS ahora');
console.log(`\n[APP] Conexión con ${CORE_DB_NAME} OK. Hora DB:`, rows[0].ahora);
client.release();
} catch (error) {
console.error('[APP] Error al conectar con la base de datos al iniciar:', error.message);
console.error('[APP] Revisar credenciales, accesos de red y firewall.');
}
}
async function verificarConexionTenants() {
try {
console.log(`[APP] Comprobando accesibilidad a la db ${TENANTS_DB_NAME} del host ${TENANTS_DB_HOST} ...`);
const client = await poolTenants.connect();
const { rows } = await client.query('SELECT NOW() AS ahora');
console.log(`\n[APP] Conexión con ${TENANTS_DB_NAME} OK. Hora DB:`, rows[0].ahora);
client.release();
} catch (error) {
console.error('[APP] Error al conectar con la base de datos al iniciar:', error.message);
console.error('[APP] Revisar credenciales, accesos de red y firewall.');
}
}
// =============================================
// Registro de usuario (DB principal)
// =============================================
//=============================================
// ---------- Authentik (API & OIDC) ----------
//=============================================
// const { client, getAuthUrl, handleCallback, endSessionUrl } = await initOIDCFromEnv();
await initOIDCFromEnv();
const oidc = getOIDC();
// ===========================
// GET /auth/users/register
// ===========================
// ===========================
// POST /auth/login
// ===========================
app.get("/auth/login", (req, res) => {
const { verifier, challenge } = genPKCE();
const state = base64url(crypto.randomBytes(24));
req.session.pkce_verifier = verifier;
req.session.oidc_state = state;
const url = authorizeUrl({ state, challenge });
res.redirect(302, url);
app.get('/auth/debug/session', (req, res) => {
res.json({ sid: req.sessionID, user: req.session?.user ?? null });
});
// ===========================
// GET /auth/login
// ===========================
/*
app.get('/auth/login', async (req, res) => {
const code_verifier = generators.codeVerifier();
const code_challenge = generators.codeChallenge(code_verifier);
const state = generators.state();
const nonce = generators.nonce();
const returnTo = typeof req.query.returnTo === 'string' ? req.query.returnTo : '/';
req.session.oidc = { code_verifier, state, nonce, returnTo };
await new Promise(r => req.session.save(r));
const authUrl = getAuthUrl({ state, nonce, code_challenge });
console.log('[OIDC] Redirect auth URL:', authUrl);
return res.redirect(authUrl);
});
*/
app.get('/auth/login', async (req, res, next) => {
try {
const state = generators.state();
const nonce = generators.nonce();
const code_verifier = generators.codeVerifier();
const code_challenge = generators.codeChallenge(code_verifier);
// Guardamos artefactos para el callback
req.session.oidc = { state, nonce, code_verifier };
// Usamos la API del paquete @suitecoffee/oidc
const authUrl = oidc.getAuthUrl({ state, nonce, code_challenge });
res.redirect(authUrl);
} catch (err) {
next(err);
}
});
// ===========================
// GET /auth/callback
// ===========================
/*
app.get('/auth/callback', async (req, res) => {
const { oidc } = req.session || {};
const code_verifier = oidc?.code_verifier;
const stateStored = oidc?.state;
const nonceStored = oidc?.nonce;
const returnTo = oidc?.returnTo || '/';
if (!code_verifier || !stateStored) {
console.warn('[OIDC] Falta code_verifier/state en sesión; reiniciando login');
return res.redirect(303, '/auth/login');
}
// helper para quitar guiones
const noDash = (v) => (typeof v === 'string'
? v.replace(/-/g, '')
: String(v ?? '').replace(/-/g, ''));
try {
const tokenSet = await handleCallback(req, {
code_verifier,
state: stateStored,
nonce: nonceStored
});
// Limpiar datos transitorios OIDC
req.session.oidc = undefined;
// Claims del IdP
const claims = tokenSet.claims();
const email = String(claims.email || '').toLowerCase();
const sub = claims.sub;
// 1) Asegurar usuario en CORE y tenant (si es primer login)
const { user, memberships, current_tenant } =
await ensureUserAndTenantOnFirstLogin(claims);
// Normalizaciones sin guiones
const userUidNoDash = noDash(user.user_id);
const currentTenantNoDash = noDash(current_tenant);
// Normalizar memberships + derivar schemaName cuando falte
const prefix = process.env.TENANT_SCHEMA_PREFIX || 'empresa_';
const normalizedMemberships = (memberships || []).map(m => {
const tenantUidNoDash = noDash(m.tenant_id);
const schemaName = m.schema_name || `${prefix}${tenantUidNoDash}`;
return {
tenant_id: m.tenant_id,
schema_name: schemaName,
role: m.role,
// duplicados camelCase y sin guiones
tenantId: m.tenant_id,
schemaName,
tenant_uid_nodash: tenantUidNoDash,
tenantUidNoDash,
};
});
// Membership activo (por current_tenant o primero)
const active = normalizedMemberships.find(m => String(m.tenant_id) === String(current_tenant))
|| normalizedMemberships[0]
|| null;
// 2) Regenerar sesión y guardar identidad + memberships/tenant actual
req.session.regenerate(err => {
if (err) {
console.warn('[OIDC] error al regenerar sesión:', err);
return res.redirect(303, '/auth/login');
}
req.session.user = {
sub,
email,
user_id: user.user_id,
name: user.name,
// ids sin guiones (para comparar con schema_name, slugs, etc.)
user_uid_nodash: userUidNoDash,
userUidNoDash,
// tenant activo (snake + camel + sin guiones)
current_tenant, // UUID
currentTenant: current_tenant, // UUID
current_tenant_nodash: currentTenantNoDash,
currentTenantNoDash: currentTenantNoDash,
// esquema activo (muchos middlewares esperan esto)
active_schema: active?.schema_name || null,
activeSchema: active?.schemaName || null,
// membresías normalizadas
memberships: normalizedMemberships,
// id_token para logout federado
id_token: tokenSet.id_token,
};
req.session.save(() => {
const dest = returnTo.startsWith('/') ? returnTo : '/';
return res.redirect(303, dest);
});
});
} catch (err) {
console.warn('[OIDC] callback error:', err?.message || err);
req.session.oidc = undefined;
req.session.save(() => res.redirect(303, '/auth/login'));
}
});
*/
app.get('/auth/callback', async (req, res, next) => {
try {
const ctx = req.session.oidc ?? {};
const { state, nonce, code_verifier } = ctx;
if (!state || !nonce || !code_verifier) {
return res.status(400).json({ error: 'missing OIDC PKCE artifacts in session' });
}
// Intercambio del code por tokens (usa client.callbackParams internamente)
const tokenSet = await oidc.handleCallback(req, { state, nonce, code_verifier });
// Carga de claims complementarios (userinfo)
const userinfo = await oidc.client.userinfo(tokenSet.access_token);
const claims = {
sub: userinfo.sub,
email: userinfo.email,
name: userinfo.name ?? userinfo.preferred_username ?? null,
};
// Bootstrap en BD CORE/TENANTS (IDs 32-hex, sin guiones)
const result = await ensureUserAndTenantOnFirstLogin(claims);
// Guardar sesión para la App (sin hash/noHash)
req.session.user = {
sub: result.user.sub,
email: result.user.email,
name: result.user.name,
user_id: result.user.user_id, // 32-hex
default_tenant: result.user.default_tenant, // 32-hex
memberships: result.memberships.map(m => ({
tenant_id: m.tenant_id, // 32-hex
role: m.role,
})),
};
// limpiar artefactos OIDC
delete req.session.oidc;
// Redirige a la App
res.redirect(`${APP_BASE_URL}/inicio`);
} catch (err) {
next(err);
}
});
// ==============================
// POST /auth/logout
// ==============================
/*
app.post('/auth/logout', (req, res) => {
const idToken = req.session?.user?.id_token;
const postLogout = `${APP_BASE_URL}${LOGGED_OUT_PATH}`;
req.session.destroy(() => {
const url = endSessionUrl({ id_token_hint: idToken, post_logout_redirect_uri: postLogout });
return url ? res.redirect(url) : res.redirect(postLogout);
});
});
*/
app.post('/auth/logout', (req, res, next) => {
req.session.destroy((e) => {
if (e) return next(e);
res.clearCookie(SESSION_NAME, { path: '/' });
res.status(204).end();
});
});
// -----------------------------------------------------------------------------
// Healthcheck
// -----------------------------------------------------------------------------
app.get('/health', (_req, res) => {
res.status(200).json({ status: 'ok'}),
console.log(`[AUTH] Saludable`)
res.status(200).json({ status: 'ok' });
});
// =============================================
// 404 + Manejo de errores
// =============================================
app.use((req, res) => res.status(404).json({ error: 'Error 404, No se encontró la página', path: req.originalUrl }));
app.use((err, _req, res, _next) => {
console.error('[AUTH] ', err);
if (res.headersSent) return;
res.status(500).json({ error: '¡Oh! A ocurrido un error en el servidor auth.', detail: err.stack || String(err) });
});
// -----------------------------------------------------------------------------
// Arranque
// -----------------------------------------------------------------------------
app.listen(PORT, () => {
app.listen(PORT, async () => {
console.log(`[AUTH] Servicio de autenticación escuchando en http://localhost:${PORT}`);
verificarConexionCore();
verificarConexionTenants();
});
await verificarConexionCore();
await verificarConexionTenants();
await verificarConexionRedisAuthentik();
});
-154
View File
@@ -1,154 +0,0 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<title>SuiteCoffee - Autenticación</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
</head>
<body class="bg-light d-flex justify-content-center align-items-center vh-100">
<div class="card shadow p-4" style="width: 100%; max-width: 400px;">
<h4 class="text-center mb-3" id="form-title">Iniciar Sesión</h4>
<!-- Mensajes -->
<div id="mensaje" class="alert d-none" role="alert"></div>
<!-- Formulario compartido -->
<form id="formulario">
<div id="registro-extra" style="display: none;">
<div class="mb-2">
<input type="text" class="form-control" id="nombre_empresa" placeholder="Nombre de la empresa" required>
</div>
<div class="mb-2">
<input type="text" class="form-control" id="rut" placeholder="RUT (opcional)" required>
</div>
<div class="mb-2">
<input type="text" class="form-control" id="telefono" placeholder="Teléfono">
</div>
<div class="mb-2">
<input type="text" class="form-control" id="direccion" placeholder="Dirección">
</div>
<div class="mb-2">
<input type="text" class="form-control" id="logo" placeholder="Logo URL (opcional)">
</div>
<div class="mb-2">
<select class="form-select" id="plan_id" required>
<option value="">Cargando planes...</option>
</select>
</div>
</div>
<div class="mb-2">
<input type="email" class="form-control" id="correo" placeholder="Correo" required>
</div>
<div class="mb-3">
<input type="password" class="form-control" id="clave" placeholder="Contraseña" required>
</div>
<button type="submit" class="btn btn-primary w-100" id="btn-submit">Entrar</button>
</form>
<div class="text-center mt-3">
<button class="btn btn-link btn-sm" id="toggle-mode">¿No tienes cuenta? Regístrate</button>
</div>
</div>
<script>
const form = document.getElementById('formulario');
const mensaje = document.getElementById('mensaje');
const toggleModeBtn = document.getElementById('toggle-mode');
const registroExtra = document.getElementById('registro-extra');
const formTitle = document.getElementById('form-title');
const btnSubmit = document.getElementById('btn-submit');
let modoRegistro = false;
toggleModeBtn.addEventListener('click', () => {
modoRegistro = !modoRegistro;
registroExtra.style.display = modoRegistro ? 'block' : 'none';
formTitle.textContent = modoRegistro ? 'Registrar Cuenta' : 'Iniciar Sesión';
btnSubmit.textContent = modoRegistro ? 'Registrarse' : 'Entrar';
toggleModeBtn.textContent = modoRegistro ? '¿Ya tienes cuenta? Inicia sesión' : '¿No tienes cuenta? Regístrate';
if (modoRegistro) {
cargarPlanes(); // ✅ ahora sí se ejecutará correctamente
}
});
form.addEventListener('submit', async (e) => {
e.preventDefault();
mensaje.classList.add('d-none');
const datos = {
correo: document.getElementById('correo').value,
clave_acceso: document.getElementById('clave').value
};
if (modoRegistro) {
Object.assign(datos, {
nombre_empresa: document.getElementById('nombre_empresa').value,
rut: document.getElementById('rut').value,
telefono: document.getElementById('telefono').value,
direccion: document.getElementById('direccion').value,
logo: document.getElementById('logo').value,
plan_id: document.getElementById('plan_id').value
});
}
try {
const url = modoRegistro ? '/api/registro' : '/api/login';
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(datos)
});
const resultado = await res.json();
if (!res.ok) {
throw new Error(resultado.error || 'Error inesperado');
}
mensaje.className = 'alert alert-success';
mensaje.textContent = resultado.message || (modoRegistro ? 'Registro exitoso' : 'Inicio exitoso');
mensaje.classList.remove('d-none');
if (!modoRegistro) {
// Redirigir a dashboard, por ejemplo
// window.location.href = `/dashboard?tenant=${resultado.uuid}`;
}
} catch (err) {
mensaje.className = 'alert alert-danger';
mensaje.textContent = err.message;
mensaje.classList.remove('d-none');
}
});
// ✅ Ahora la función está declarada correctamente
async function cargarPlanes() {
const select = document.getElementById('plan_id');
select.innerHTML = '<option value="">Cargando planes...</option>';
try {
const res = await fetch('/planes');
const planes = await res.json();
select.innerHTML = '<option value="">Seleccione un plan</option>';
planes.forEach(plan => {
const opt = document.createElement('option');
opt.value = plan.id;
opt.textContent = plan.nombre.charAt(0).toUpperCase() + plan.nombre.slice(1);
select.appendChild(opt);
});
} catch (err) {
select.innerHTML = '<option value="">Error al cargar planes</option>';
console.error('Error cargando planes:', err);
}
}
</script>
</body>
</html>
-20
View File
@@ -1,20 +0,0 @@
// services/manso/src/api/v1/routes/routes.js
import { Router } from 'express';
const router = Router();
// ----------------------------------------------------------
// Rutas de UI
// ----------------------------------------------------------
/*router.get('/', (req, res) => {
res.locals.pageTitle = "Inicio"; // Título de pestaña
res.locals.pageId = "home"; // Sidebar contextual
res.render("dashboard"); // Archivo .ejs a renderizar
// res.json({ ok: true, route: '/inicio' }); // Debug json
});*/
export default router;
@@ -0,0 +1,147 @@
// services/auth/src/registration/bootstrap.mjs
import { poolCore, poolTenants } from '@suitecoffee/db';
/**
* ensureUserAndTenantOnFirstLogin
* Crea/actualiza el usuario en CORE y, si no tiene membresías,
* provisiona su primer tenant (IDs 32-hex SIN guiones).
*
* @param {object} claims OIDC claims { sub, email, name? }
* @returns {Promise<{ user, memberships, current_tenant }>}
*/
export async function ensureUserAndTenantOnFirstLogin(claims) {
const { sub, email, name = null } = claims ?? {};
if (!sub || !email) {
throw new Error('ensureUserAndTenantOnFirstLogin: faltan claims requeridos (sub, email)');
}
// 1) Upsert del usuario por sub
const core = await poolCore.connect();
try {
await core.query('BEGIN');
// Existe?
const ures = await core.query(
`SELECT user_id, sub, email, name, default_tenant
FROM sc_users
WHERE sub = $1`,
[sub]
);
let userRow;
if (ures.rowCount === 0) {
// crea usando defaults de la BD (uuid_nodash())
const ins = await core.query(
`INSERT INTO sc_users (sub, email, name)
VALUES ($1, $2, $3)
RETURNING user_id, sub, email, name, default_tenant`,
[sub, email, name]
);
userRow = ins.rows[0];
} else {
userRow = ures.rows[0];
// actualización mínima de datos cambiantes
if (userRow.email !== email || userRow.name !== name) {
const upd = await core.query(
`UPDATE sc_users
SET email = $2,
name = $3
WHERE sub = $1
RETURNING user_id, sub, email, name, default_tenant`,
[sub, email, name]
);
userRow = upd.rows[0];
}
}
// 2) ¿Tiene membresías?
const mres = await core.query(
`SELECT m.user_id, m.tenant_id, m.role, t.schema_name, t.state, t.created_at
FROM sc_memberships m
JOIN sc_tenants t ON t.tenant_id = m.tenant_id
WHERE m.user_id = $1
ORDER BY t.created_at ASC`,
[userRow.user_id]
);
// Si ya tiene, salimos dejando todo igual
if (mres.rowCount > 0) {
await core.query('COMMIT');
return {
user: {
user_id: userRow.user_id, sub: userRow.sub, email: userRow.email,
name: userRow.name, default_tenant: userRow.default_tenant
},
memberships: mres.rows,
current_tenant: userRow.default_tenant ?? mres.rows[0].tenant_id
};
}
// 3) Crear primer tenant en CORE (IDs sin guiones)
// - Generamos tenant_id y derivamos schema_name/owner_role en una sola sentencia
const tins = await core.query(
`WITH g AS (SELECT uuid_nodash() AS tid)
INSERT INTO sc_tenants (tenant_id, schema_name, owner_role, state)
SELECT g.tid, 'empresa_' || g.tid, 'owner_' || g.tid, 'provisioning'
FROM g
RETURNING tenant_id, schema_name, owner_role, state`,
);
const tenant = tins.rows[0];
// 4) Ejecutar provisión física en DB TENANTS
// - Crea el esquema empresa_<tenant_id> y objetos del tenant
await poolTenants.query(
`SELECT public.f_crear_empresa($1, $2)`,
[tenant.tenant_id, 'empresa_']
);
// 5) Marcar tenant listo y setear default_tenant del usuario
await core.query(
`UPDATE sc_tenants SET state = 'ready' WHERE tenant_id = $1`,
[tenant.tenant_id]
);
await core.query(
`INSERT INTO sc_memberships (user_id, tenant_id, role)
VALUES ($1, $2, 'owner')
ON CONFLICT (user_id, tenant_id) DO NOTHING`,
[userRow.user_id, tenant.tenant_id]
);
const updUser = await core.query(
`UPDATE sc_users
SET default_tenant = $2
WHERE user_id = $1
RETURNING user_id, sub, email, name, default_tenant`,
[userRow.user_id, tenant.tenant_id]
);
userRow = updUser.rows[0];
// 6) Cargar membresías finales
const mres2 = await core.query(
`SELECT m.user_id, m.tenant_id, m.role, t.schema_name, t.state, t.created_at
FROM sc_memberships m
JOIN sc_tenants t ON t.tenant_id = m.tenant_id
WHERE m.user_id = $1
ORDER BY t.created_at ASC`,
[userRow.user_id]
);
await core.query('COMMIT');
return {
user: {
user_id: userRow.user_id, sub: userRow.sub, email: userRow.email,
name: userRow.name, default_tenant: userRow.default_tenant
},
memberships: mres2.rows,
current_tenant: userRow.default_tenant
};
} catch (err) {
await core.query('ROLLBACK');
throw err;
} finally {
core.release();
}
}