.
This commit is contained in:
@@ -1,62 +1,54 @@
|
||||
# ===== Runtime =====
|
||||
# =======================================================
|
||||
# Runtime
|
||||
NODE_ENV=development
|
||||
PORT=3030
|
||||
APP_BASE_URL=https://dev.suitecoffee.uy
|
||||
# =======================================================
|
||||
|
||||
|
||||
# ===== Session (usa el Redis del stack) =====
|
||||
# Para DEV podemos reutilizar el Redis de Authentik. En prod conviene uno separado.
|
||||
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.
|
||||
|
||||
APP_BASE_URL=https://suitecoffee.uy
|
||||
|
||||
OIDC_LOGIN_URL=https://sso.suitecoffee.uy
|
||||
OIDC_REDIRECT_URI = https://suitecoffee.uy/auth/callback
|
||||
OIDC_REDIRECT_URI=https://suitecoffee.uy/auth/callback
|
||||
|
||||
OIDC_CLIENT_ID=1orMM8vOvf3WkN2FejXYvUFpPtONG0Lx1eMlwIpW
|
||||
OIDC_CLIENT_SECRET=t5wx13qBcM0EFQ3cGnUIAmLzvbdsQrUVPv1OGWjszWkEp35pJQ55t7vZeeShqG49kuRAaiXv6PSGJLhRfGaponGaJl8gH1uCL7KIxdmm7UihgYoAXB2dFhZV4zRxfze2
|
||||
|
||||
OIDC_CLIEN_ID=1orMM8vOvf3WkN2FejXYvUFpPtONG0Lx1eMlwIpW
|
||||
OIDC_CONFIG_URL=https://sso.suitecoffee.uy/application/o/suitecoffee/.well-known/openid-configuration
|
||||
OIDC_ISSUER=https://sso.suitecoffee.uy/application/o/suitecoffee/
|
||||
OIDC_ISSUER_DISCOVERY=https://sso.suitecoffee.uy/application/o/suitecoffee/.well-known/openid-configuration
|
||||
OIDC_AUTHORIZE_URL=https://sso.suitecoffee.uy/application/o/authorize/
|
||||
OIDC_TOKEN_URL=https://sso.suitecoffee.uy/application/o/token/
|
||||
OIDC_USERINFO_URL=https://sso.suitecoffee.uy/application/o/userinfo/
|
||||
OIDC_LOGOUT_URL=https://sso.suitecoffee.uy/application/o/suitecoffee/end-session/
|
||||
OIDC_JWKS_URL=https://sso.suitecoffee.uy/application/o/suitecoffee/jwks/
|
||||
OIDC_JWKS_URL=https://sso.suitecoffee.uy/application/o/suitecoffee/jwks/
|
||||
|
||||
# =======================================================
|
||||
@@ -1,22 +0,0 @@
|
||||
NODE_ENV=production # Entorno de desarrollo
|
||||
|
||||
PORT=3000 # Variables del servicio -> suitecoffee-app
|
||||
|
||||
# Variables del servicio -> suitecoffee-db de suitecoffee-app
|
||||
|
||||
DB_HOST=prod-tenants
|
||||
# Nombre de la base de datos
|
||||
DB_NAME=postgres
|
||||
|
||||
# Usuario y contraseña
|
||||
DB_USER=postgres
|
||||
DB_PASS=postgres
|
||||
|
||||
# Puertos del servicio de db
|
||||
DB_LOCAL_PORT=5432
|
||||
DB_DOCKER_PORT=5432
|
||||
|
||||
# Colores personalizados
|
||||
COL_PRI=452D19 # Marrón oscuro
|
||||
COL_SEC=D7A666 # Crema / Café
|
||||
COL_BG=FFA500 # Naranja
|
||||
Generated
+81
@@ -9,6 +9,12 @@
|
||||
"version": "1.0.0",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@suitecoffee/api": "file:../../packages/api/",
|
||||
"@suitecoffee/db": "file:../../packages/core/db",
|
||||
"@suitecoffee/middlewares": "file:../../packages/core/middlewares",
|
||||
"@suitecoffee/redis": "file:../../packages/core/redis",
|
||||
"@suitecoffee/repositories": "file:../../packages/core/repositories",
|
||||
"@suitecoffee/scripts": "file:../../packages/core/scripts",
|
||||
"bcrypt": "^6.0.0",
|
||||
"chalk": "^5.6.0",
|
||||
"connect-redis": "^9.0.0",
|
||||
@@ -23,6 +29,7 @@
|
||||
"jose": "^6.1.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"jwks-rsa": "^3.2.0",
|
||||
"mime": "^4.1.0",
|
||||
"morgan": "^1.10.1",
|
||||
"node-appwrite": "^20.2.1",
|
||||
"node-fetch": "^3.3.2",
|
||||
@@ -36,6 +43,37 @@
|
||||
"nodemon": "^3.1.10"
|
||||
}
|
||||
},
|
||||
"../../packages/api": {
|
||||
"name": "@suitecoffee/api",
|
||||
"version": "1.0.0"
|
||||
},
|
||||
"../../packages/core": {},
|
||||
"../../packages/core/db": {
|
||||
"name": "@suitecoffee/db",
|
||||
"version": "1.0.0",
|
||||
"peerDependencies": {
|
||||
"pg": "^8.16.3"
|
||||
}
|
||||
},
|
||||
"../../packages/core/middlewares": {
|
||||
"name": "@suitecoffee/middlewares",
|
||||
"version": "1.0.0"
|
||||
},
|
||||
"../../packages/core/redis": {
|
||||
"name": "@suitecoffee/redis",
|
||||
"version": "1.0.0",
|
||||
"peerDependencies": {
|
||||
"pg": "^8.16.3"
|
||||
}
|
||||
},
|
||||
"../../packages/core/repositories": {
|
||||
"name": "@suitecoffee/repositories",
|
||||
"version": "0.0.1"
|
||||
},
|
||||
"../../packages/core/scripts": {
|
||||
"name": "@suitecoffee/scripts",
|
||||
"version": "1.0.0"
|
||||
},
|
||||
"node_modules/@epic-web/invariant": {
|
||||
"version": "1.0.0",
|
||||
"dev": true,
|
||||
@@ -95,6 +133,30 @@
|
||||
"@redis/client": "^5.8.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@suitecoffee/api": {
|
||||
"resolved": "../../packages/api",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@suitecoffee/db": {
|
||||
"resolved": "../../packages/core/db",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@suitecoffee/middlewares": {
|
||||
"resolved": "../../packages/core/middlewares",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@suitecoffee/redis": {
|
||||
"resolved": "../../packages/core/redis",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@suitecoffee/repositories": {
|
||||
"resolved": "../../packages/core/repositories",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@suitecoffee/scripts": {
|
||||
"resolved": "../../packages/core/scripts",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@types/body-parser": {
|
||||
"version": "1.19.6",
|
||||
"license": "MIT",
|
||||
@@ -371,6 +433,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"
|
||||
@@ -641,6 +705,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",
|
||||
@@ -1137,6 +1203,21 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/mime": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/mime/-/mime-4.1.0.tgz",
|
||||
"integrity": "sha512-X5ju04+cAzsojXKes0B/S4tcYtFAJ6tTMuSPBEn9CPGlrWr8Fiw7qYeLT0XyH80HSoAoqWCaz+MWKh22P7G1cw==",
|
||||
"funding": [
|
||||
"https://github.com/sponsors/broofa"
|
||||
],
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"mime": "bin/cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
}
|
||||
},
|
||||
"node_modules/mime-db": {
|
||||
"version": "1.54.0",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -15,6 +15,15 @@
|
||||
"nodemon": "^3.1.10"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
"@suitecoffee/scripts": "file:../../packages/core/scripts",
|
||||
"@suitecoffee/db": "file:../../packages/core/db",
|
||||
"@suitecoffee/redis": "file:../../packages/core/redis",
|
||||
"@suitecoffee/middlewares": "file:../../packages/core/middlewares",
|
||||
|
||||
"@suitecoffee/api": "file:../../packages/api/",
|
||||
"@suitecoffee/repositories": "file:../../packages/core/repositories",
|
||||
|
||||
"bcrypt": "^6.0.0",
|
||||
"chalk": "^5.6.0",
|
||||
"connect-redis": "^9.0.0",
|
||||
@@ -29,6 +38,7 @@
|
||||
"jose": "^6.1.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"jwks-rsa": "^3.2.0",
|
||||
"mime": "^4.1.0",
|
||||
"morgan": "^1.10.1",
|
||||
"node-appwrite": "^20.2.1",
|
||||
"node-fetch": "^3.3.2",
|
||||
@@ -37,11 +47,7 @@
|
||||
"redis": "^5.8.2",
|
||||
"serve-favicon": "^2.5.1"
|
||||
},
|
||||
"imports": {
|
||||
"#v1Router": "./src/api/v1/routes/routes.js",
|
||||
"#pages": "./src/pages/pages.js",
|
||||
"#db": "./src/db/poolSingleton.js"
|
||||
},
|
||||
"keywords": [],
|
||||
"imports": { },
|
||||
"keywords": [],
|
||||
"description": ""
|
||||
}
|
||||
|
||||
@@ -1,181 +0,0 @@
|
||||
// // ----------------------------------------------------------
|
||||
// // API
|
||||
// // ----------------------------------------------------------
|
||||
// app.get('/api/tables', async (_req, res) => {
|
||||
// res.json(ALLOWED_TABLES);
|
||||
// });
|
||||
|
||||
// app.get('/api/schema/:table', async (req, res) => {
|
||||
// try {
|
||||
// const table = ensureTable(req.params.table);
|
||||
// const client = await getClient();
|
||||
// try {
|
||||
// const columns = await loadColumns(client, table);
|
||||
// const fks = await loadForeignKeys(client, table);
|
||||
// const enriched = columns.map(c => ({ ...c, foreign: fks[c.column_name] || null }));
|
||||
// res.json({ table, columns: enriched });
|
||||
// } finally { client.release(); }
|
||||
// } catch (e) {
|
||||
// res.status(400).json({ error: e.message });
|
||||
// }
|
||||
// });
|
||||
|
||||
// app.get('/api/options/:table/:column', async (req, res) => {
|
||||
// try {
|
||||
// const table = ensureTable(req.params.table);
|
||||
// const column = req.params.column;
|
||||
// if (!VALID_IDENT.test(column)) throw new Error('Columna inválida');
|
||||
|
||||
// const client = await getClient();
|
||||
// try {
|
||||
// const fks = await loadForeignKeys(client, table);
|
||||
// const fk = fks[column];
|
||||
// if (!fk) return res.json([]);
|
||||
|
||||
// const refTable = fk.foreign_table;
|
||||
// const refId = fk.foreign_column;
|
||||
// const labelCol = await pickLabelColumn(client, refTable);
|
||||
|
||||
// const sql = `SELECT ${q(refId)} AS id, ${q(labelCol)} AS label FROM ${q(refTable)} ORDER BY ${q(labelCol)} LIMIT 1000`;
|
||||
// const result = await client.query(sql);
|
||||
// res.json(result.rows);
|
||||
// } finally { client.release(); }
|
||||
// } catch (e) {
|
||||
// res.status(400).json({ error: e.message });
|
||||
// }
|
||||
// });
|
||||
|
||||
// app.get('/api/table/:table', async (req, res) => {
|
||||
// try {
|
||||
// const table = ensureTable(req.params.table);
|
||||
// const limit = Math.min(parseInt(req.query.limit || '100', 10), 1000);
|
||||
// const client = await getClient();
|
||||
// try {
|
||||
// const pks = await loadPrimaryKey(client, table);
|
||||
// const orderBy = pks.length ? `ORDER BY ${pks.map(q).join(', ')} DESC` : '';
|
||||
// const sql = `SELECT * FROM ${q(table)} ${orderBy} LIMIT ${limit}`;
|
||||
// const result = await client.query(sql);
|
||||
|
||||
// // Normalizar: siempre devolver objetos {col: valor}
|
||||
// const colNames = result.fields.map(f => f.name);
|
||||
// let rows = result.rows;
|
||||
// if (rows.length && Array.isArray(rows[0])) {
|
||||
// rows = rows.map(r => Object.fromEntries(r.map((v, i) => [colNames[i], v])));
|
||||
// }
|
||||
// res.json(rows);
|
||||
// } finally { client.release(); }
|
||||
// } catch (e) {
|
||||
// res.status(400).json({ error: e.message, code: e.code, detail: e.detail });
|
||||
// }
|
||||
// });
|
||||
|
||||
// app.post('/api/table/:table', async (req, res) => {
|
||||
// const table = ensureTable(req.params.table);
|
||||
// const payload = req.body || {};
|
||||
// try {
|
||||
// const client = await getClient();
|
||||
// try {
|
||||
// const columns = await loadColumns(client, table);
|
||||
// const insertable = columns.filter(c =>
|
||||
// !c.is_primary && !c.is_identity && !(c.column_default || '').startsWith('nextval(')
|
||||
// );
|
||||
// const allowedCols = new Set(insertable.map(c => c.column_name));
|
||||
|
||||
// const cols = [];
|
||||
// const vals = [];
|
||||
// const params = [];
|
||||
// let idx = 1;
|
||||
// for (const [k, v] of Object.entries(payload)) {
|
||||
// if (!allowedCols.has(k)) continue;
|
||||
// if (!VALID_IDENT.test(k)) continue;
|
||||
// cols.push(q(k));
|
||||
// vals.push(`$${idx++}`);
|
||||
// params.push(v);
|
||||
// }
|
||||
|
||||
// if (!cols.length) {
|
||||
// const { rows } = await client.query(`INSERT INTO ${q(table)} DEFAULT VALUES RETURNING *`);
|
||||
// res.status(201).json({ inserted: rows[0] });
|
||||
// } else {
|
||||
// const { rows } = await client.query(
|
||||
// `INSERT INTO ${q(table)} (${cols.join(', ')}) VALUES (${vals.join(', ')}) RETURNING *`,
|
||||
// params
|
||||
// );
|
||||
// res.status(201).json({ inserted: rows[0] });
|
||||
// }
|
||||
// } catch (e) {
|
||||
// if (e.code === '23503') return res.status(400).json({ error: 'Violación de clave foránea', detail: e.detail });
|
||||
// if (e.code === '23505') return res.status(400).json({ error: 'Violación de unicidad', detail: e.detail });
|
||||
// if (e.code === '23514') return res.status(400).json({ error: 'Violación de CHECK', detail: e.detail });
|
||||
// if (e.code === '23502') return res.status(400).json({ error: 'Campo NOT NULL faltante', detail: e.detail });
|
||||
// throw e;
|
||||
// }
|
||||
// } catch (e) {
|
||||
// res.status(400).json({ error: e.message });
|
||||
// }
|
||||
// });
|
||||
|
||||
// app.get('/api/comandas', async (req, res, next) => {
|
||||
// try {
|
||||
// const estado = (req.query.estado || '').trim() || null;
|
||||
// const limit = Math.min(parseInt(req.query.limit || '200', 10), 1000);
|
||||
|
||||
// const { rows } = await mainPool.query(
|
||||
// `SELECT * FROM public.f_comandas_resumen($1, $2)`,
|
||||
// [estado, limit]
|
||||
// );
|
||||
// res.json(rows);
|
||||
// } catch (e) { next(e); }
|
||||
// });
|
||||
|
||||
|
||||
// // Detalle de una comanda (con nombres de productos)
|
||||
// // GET /api/comandas/:id/detalle
|
||||
// app.get('/api/comandas/:id/detalle', (req, res, next) =>
|
||||
// mainPool.query(
|
||||
// `SELECT id_det_comanda, id_producto, producto_nombre,
|
||||
// cantidad, pre_unitario, subtotal, observaciones
|
||||
// FROM public.v_comandas_detalle_items
|
||||
// WHERE id_comanda = $1::int
|
||||
// ORDER BY id_det_comanda`,
|
||||
// [req.params.id]
|
||||
// )
|
||||
// .then(r => res.json(r.rows))
|
||||
// .catch(next)
|
||||
// );
|
||||
|
||||
// // Cerrar comanda (setea estado y fec_cierre en DB)
|
||||
// app.post('/api/comandas/:id/cerrar', async (req, res, next) => {
|
||||
// try {
|
||||
// const id = Number(req.params.id);
|
||||
// if (!Number.isInteger(id) || id <= 0) {
|
||||
// return res.status(400).json({ error: 'id inválido' });
|
||||
// }
|
||||
// const { rows } = await mainPool.query(
|
||||
// `SELECT public.f_cerrar_comanda($1) AS data`,
|
||||
// [id]
|
||||
// );
|
||||
// if (!rows.length || rows[0].data === null) {
|
||||
// return res.status(404).json({ error: 'Comanda no encontrada' });
|
||||
// }
|
||||
// res.json(rows[0].data);
|
||||
// } catch (err) { next(err); }
|
||||
// });
|
||||
|
||||
// Abrir (reabrir) comanda
|
||||
app.post('/api/comandas/:id/abrir', async (req, res, next) => {
|
||||
try {
|
||||
const id = Number(req.params.id);
|
||||
if (!Number.isInteger(id) || id <= 0) {
|
||||
return res.status(400).json({ error: 'id inválido' });
|
||||
}
|
||||
const { rows } = await mainPool.query(
|
||||
`SELECT public.f_abrir_comanda($1) AS data`,
|
||||
[id]
|
||||
);
|
||||
if (!rows.length || rows[0].data === null) {
|
||||
return res.status(404).json({ error: 'Comanda no encontrada' });
|
||||
}
|
||||
res.json(rows[0].data);
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
@@ -1,230 +0,0 @@
|
||||
// // GET producto + receta
|
||||
// app.get('/api/rpc/get_producto/:id', async (req, res) => {
|
||||
// const id = Number(req.params.id);
|
||||
// const { rows } = await mainPool.query('SELECT public.get_producto($1) AS data', [id]);
|
||||
// res.json(rows[0]?.data || {});
|
||||
// });
|
||||
|
||||
// // POST guardar producto + receta
|
||||
|
||||
// app.post('/api/rpc/save_producto', async (req, res) => {
|
||||
// try {
|
||||
// // console.debug('receta payload:', req.body?.receta); // habilitalo si lo necesitás
|
||||
// const q = 'SELECT public.save_producto($1,$2,$3,$4,$5,$6,$7::jsonb) AS id_producto';
|
||||
// const { id_producto = null, nombre, img_producto = null, precio = 0, activo = true, id_categoria = null, receta = [] } = req.body || {};
|
||||
// const params = [id_producto, nombre, img_producto, precio, activo, id_categoria, JSON.stringify(receta || [])];
|
||||
// const { rows } = await mainPool.query(q, params);
|
||||
// res.json(rows[0] || {});
|
||||
// } catch (e) {
|
||||
// console.error(e);
|
||||
// res.status(500).json({ error: 'save_producto failed' });
|
||||
// }
|
||||
// });
|
||||
|
||||
// // GET MP + proveedores
|
||||
// app.get('/api/rpc/get_materia/:id', async (req, res) => {
|
||||
// const id = Number(req.params.id);
|
||||
// try {
|
||||
// const { rows } = await mainPool.query('SELECT public.get_materia_prima($1) AS data', [id]);
|
||||
// res.json(rows[0]?.data || {});
|
||||
// } catch (e) {
|
||||
// console.error(e);
|
||||
// res.status(500).json({ error: 'get_materia failed' });
|
||||
// }
|
||||
// });
|
||||
|
||||
// // SAVE MP + proveedores (array)
|
||||
// app.post('/api/rpc/save_materia', async (req, res) => {
|
||||
// const { id_mat_prima = null, nombre, unidad, activo = true, proveedores = [] } = req.body || {};
|
||||
// try {
|
||||
// const q = 'SELECT public.save_materia_prima($1,$2,$3,$4,$5::jsonb) AS id_mat_prima';
|
||||
// const params = [id_mat_prima, nombre, unidad, activo, JSON.stringify(proveedores || [])];
|
||||
// const { rows } = await mainPool.query(q, params);
|
||||
// res.json(rows[0] || {});
|
||||
// } catch (e) {
|
||||
// console.error(e);
|
||||
// res.status(500).json({ error: 'save_materia failed' });
|
||||
// }
|
||||
// });
|
||||
|
||||
// // POST /api/rpc/find_usuarios_por_documentos { docs: ["12345678","09123456", ...] }
|
||||
// app.post('/api/rpc/find_usuarios_por_documentos', async (req, res) => {
|
||||
// try {
|
||||
// const docs = Array.isArray(req.body?.docs) ? req.body.docs : [];
|
||||
// const sql = 'SELECT public.find_usuarios_por_documentos($1::jsonb) AS data';
|
||||
// const { rows } = await mainPool.query(sql, [JSON.stringify(docs)]);
|
||||
// res.json(rows[0]?.data || {});
|
||||
// } catch (e) {
|
||||
// console.error(e);
|
||||
// res.status(500).json({ error: 'find_usuarios_por_documentos failed' });
|
||||
// }
|
||||
// });
|
||||
|
||||
// // POST /api/rpc/import_asistencia { registros: [...], origen?: "AGL_001.txt" }
|
||||
// app.post('/api/rpc/import_asistencia', async (req, res) => {
|
||||
// try {
|
||||
// const registros = Array.isArray(req.body?.registros) ? req.body.registros : [];
|
||||
// const origen = req.body?.origen || null;
|
||||
// const sql = 'SELECT public.import_asistencia($1::jsonb,$2) AS data';
|
||||
// const { rows } = await mainPool.query(sql, [JSON.stringify(registros), origen]);
|
||||
// res.json(rows[0]?.data || {});
|
||||
// } catch (e) {
|
||||
// console.error(e);
|
||||
// res.status(500).json({ error: 'import_asistencia failed' });
|
||||
// }
|
||||
// });
|
||||
|
||||
// // Consultar datos de asistencia (raw + pares) para un usuario y rango
|
||||
// app.post('/api/rpc/asistencia_get', async (req, res) => {
|
||||
// try {
|
||||
// const { doc, desde, hasta } = req.body || {};
|
||||
// const sql = 'SELECT public.asistencia_get($1::text,$2::date,$3::date) AS data';
|
||||
// const { rows } = await mainPool.query(sql, [doc, desde, hasta]);
|
||||
// res.json(rows[0]?.data || {});
|
||||
// } catch (e) {
|
||||
// console.error(e); res.status(500).json({ error: 'asistencia_get failed' });
|
||||
// }
|
||||
// });
|
||||
|
||||
// // Editar un registro crudo y recalcular pares
|
||||
// app.post('/api/rpc/asistencia_update_raw', async (req, res) => {
|
||||
// try {
|
||||
// const { id_raw, fecha, hora, modo } = req.body || {};
|
||||
// const sql = 'SELECT public.asistencia_update_raw($1::bigint,$2::date,$3::text,$4::text) AS data';
|
||||
// const { rows } = await mainPool.query(sql, [id_raw, fecha, hora, modo ?? null]);
|
||||
// res.json(rows[0]?.data || {});
|
||||
// } catch (e) {
|
||||
// console.error(e); res.status(500).json({ error: 'asistencia_update_raw failed' });
|
||||
// }
|
||||
// });
|
||||
|
||||
// // Eliminar un registro crudo y recalcular pares
|
||||
// app.post('/api/rpc/asistencia_delete_raw', async (req, res) => {
|
||||
// try {
|
||||
// const { id_raw } = req.body || {};
|
||||
// const sql = 'SELECT public.asistencia_delete_raw($1::bigint) AS data';
|
||||
// const { rows } = await mainPool.query(sql, [id_raw]);
|
||||
// res.json(rows[0]?.data || {});
|
||||
// } catch (e) {
|
||||
// console.error(e); res.status(500).json({ error: 'asistencia_delete_raw failed' });
|
||||
// }
|
||||
// });
|
||||
|
||||
// // POST /api/rpc/report_tickets { year }
|
||||
// app.post('/api/rpc/report_tickets', async (req, res) => {
|
||||
// try {
|
||||
// const y = parseInt(req.body?.year ?? req.query?.year, 10);
|
||||
// const year = (Number.isFinite(y) && y >= 2000 && y <= 2100)
|
||||
// ? y
|
||||
// : (new Date()).getFullYear();
|
||||
|
||||
// const { rows } = await mainPool.query(
|
||||
// 'SELECT public.report_tickets_year($1::int) AS j', [year]
|
||||
// );
|
||||
// res.json(rows[0].j);
|
||||
// } catch (e) {
|
||||
// console.error('report_tickets error:', e);
|
||||
// res.status(500).json({
|
||||
// error: 'report_tickets failed',
|
||||
// message: e.message, detail: e.detail, where: e.where, code: e.code
|
||||
// });
|
||||
// }
|
||||
// });
|
||||
|
||||
// // POST /api/rpc/report_asistencia { desde: 'YYYY-MM-DD', hasta: 'YYYY-MM-DD' }
|
||||
// app.post('/api/rpc/report_asistencia', async (req, res) => {
|
||||
// try {
|
||||
// let { desde, hasta } = req.body || {};
|
||||
// // defaults si vienen vacíos/invalidos
|
||||
// const re = /^\d{4}-\d{2}-\d{2}$/;
|
||||
// if (!re.test(desde) || !re.test(hasta)) {
|
||||
// const end = new Date();
|
||||
// const start = new Date(end); start.setDate(end.getDate() - 30);
|
||||
// desde = start.toISOString().slice(0, 10);
|
||||
// hasta = end.toISOString().slice(0, 10);
|
||||
// }
|
||||
|
||||
// const { rows } = await mainPool.query(
|
||||
// 'SELECT public.report_asistencia($1::date,$2::date) AS j', [desde, hasta]
|
||||
// );
|
||||
// res.json(rows[0].j);
|
||||
// } catch (e) {
|
||||
// console.error('report_asistencia error:', e);
|
||||
// res.status(500).json({
|
||||
// error: 'report_asistencia failed',
|
||||
// message: e.message, detail: e.detail, where: e.where, code: e.code
|
||||
// });
|
||||
// }
|
||||
// });
|
||||
|
||||
// // Guardar (insert/update)
|
||||
// app.post('/api/rpc/save_compra', async (req, res) => {
|
||||
// try {
|
||||
// const { id_compra, id_proveedor, fec_compra, detalles } = req.body || {};
|
||||
// const sql = 'SELECT * FROM public.save_compra($1::int,$2::int,$3::timestamptz,$4::jsonb)';
|
||||
// const args = [id_compra ?? null, id_proveedor, fec_compra ? new Date(fec_compra) : null, JSON.stringify(detalles)];
|
||||
// const { rows } = await mainPool.query(sql, args);
|
||||
// res.json(rows[0]); // { id_compra, total }
|
||||
// } catch (e) {
|
||||
// console.error('save_compra error:', e);
|
||||
// res.status(500).json({ error: 'save_compra failed', message: e.message, detail: e.detail, where: e.where, code: e.code });
|
||||
// }
|
||||
// });
|
||||
|
||||
// // Obtener para editar
|
||||
// app.post('/api/rpc/get_compra', async (req, res) => {
|
||||
// try {
|
||||
// const { id_compra } = req.body || {};
|
||||
// const sql = `SELECT public.get_compra($1::int) AS data`;
|
||||
// const { rows } = await mainPool.query(sql, [id_compra]);
|
||||
// res.json(rows[0]?.data || {});
|
||||
// } catch (e) {
|
||||
// console.error(e); res.status(500).json({ error: 'get_compra failed' });
|
||||
// }
|
||||
// });
|
||||
|
||||
// // Eliminar
|
||||
// app.post('/api/rpc/delete_compra', async (req, res) => {
|
||||
// try {
|
||||
// const { id_compra } = req.body || {};
|
||||
// await mainPool.query(`SELECT public.delete_compra($1::int)`, [id_compra]);
|
||||
// res.json({ ok: true });
|
||||
// } catch (e) {
|
||||
// console.error(e); res.status(500).json({ error: 'delete_compra failed' });
|
||||
// }
|
||||
// });
|
||||
|
||||
// // POST /api/rpc/report_gastos { year: 2025 }
|
||||
// app.post('/api/rpc/report_gastos', async (req, res) => {
|
||||
// try {
|
||||
// const year = parseInt(req.body?.year ?? new Date().getFullYear(), 10);
|
||||
// const { rows } = await mainPool.query(
|
||||
// 'SELECT public.report_gastos($1::int) AS j', [year]
|
||||
// );
|
||||
// res.json(rows[0].j);
|
||||
// } catch (e) {
|
||||
// console.error('report_gastos error:', e);
|
||||
// res.status(500).json({
|
||||
// error: 'report_gastos failed',
|
||||
// message: e.message, detail: e.detail, code: e.code
|
||||
// });
|
||||
// }
|
||||
// });
|
||||
|
||||
// // (Opcional) GET para probar rápido desde el navegador:
|
||||
// // /api/rpc/report_gastos?year=2025
|
||||
// app.get('/api/rpc/report_gastos', async (req, res) => {
|
||||
// try {
|
||||
// const year = parseInt(req.query.year ?? new Date().getFullYear(), 10);
|
||||
// const { rows } = await mainPool.query(
|
||||
// 'SELECT public.report_gastos($1::int) AS j', [year]
|
||||
// );
|
||||
// res.json(rows[0].j);
|
||||
// } catch (e) {
|
||||
// console.error('report_gastos error:', e);
|
||||
// res.status(500).json({
|
||||
// error: 'report_gastos failed',
|
||||
// message: e.message, detail: e.detail, code: e.code
|
||||
// });
|
||||
// }
|
||||
// });
|
||||
@@ -1,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;
|
||||
@@ -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 };
|
||||
+80
-371
@@ -5,14 +5,18 @@
|
||||
|
||||
import 'dotenv/config';
|
||||
import favicon from 'serve-favicon'; // Favicon
|
||||
import session from 'express-session';
|
||||
import express from 'express'; // Framework para enderizado de apps Web
|
||||
import expressLayouts from 'express-ejs-layouts';
|
||||
// import { 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 { RedisStore } from 'connect-redis';
|
||||
|
||||
import { checkRequiredEnvVars } from '@suitecoffee/scripts';
|
||||
import { verificarConexionCore, verificarConexionTenants } from '@suitecoffee/db'; // dbCore y dbTenants desde paquete
|
||||
import { redisAuthentik, verificarConexionRedisAuthentik} from '@suitecoffee/redis';
|
||||
import { requireAuth, datosGlobales, tenantContext, resolveTenantFromCore } from '@suitecoffee/middlewares';
|
||||
import { apiv1 } from '@suitecoffee/api'; // Rutas API v1
|
||||
|
||||
import expressPages from './views/routes.js'; // Rutas "/", "/dashboard", ...
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url'; // Converts a file:// URL string or URL object into a platform-specific file
|
||||
import cookieParser from 'cookie-parser';
|
||||
@@ -21,417 +25,122 @@ const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Validación de entorno mínimo (ajusta nombres si difieren)
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
// Función para verificar que ciertas variables de entorno estén definida
|
||||
function checkRequiredEnvVars(...requiredKeys) {
|
||||
const missingKeys = requiredKeys.filter((key) => !process.env[key]); // Filtramos las que NO existen en process.env
|
||||
if (missingKeys.length > 0) { // Si falta alguna, mostramos una advertencia
|
||||
console.warn(
|
||||
`[APP] No se encontraron las siguientes variables de entorno: \n\n-> ${missingKeys.join('\n-> ')}`+
|
||||
`\n`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
checkRequiredEnvVars(
|
||||
'PORT', 'APP_BASE_URL',
|
||||
'CORE_DB_HOST', 'CORE_DB_PORT', 'CORE_DB_NAME',
|
||||
'TENANTS_DB_HOST', 'TENANTS_DB_PORT', 'TENANTS_DB_NAME',
|
||||
|
||||
'OIDC_LOGIN_URL', 'OIDC_REDIRECT_URI',
|
||||
'OIDC_CLIEN_ID', 'OIDC_CONFIG_URL', 'OIDC_ISSUER',
|
||||
'OIDC_ISSUER_DISCOVERY', 'OIDC_AUTHORIZE_URL', 'OIDC_TOKEN_URL',
|
||||
'OIDC_USERINFO_URL', 'OIDC_LOGOUT_URL', 'OIDC_JWKS_URL',
|
||||
|
||||
'SESSION_SECRET', 'SESSION_COOKIE_NAME',
|
||||
'AK_REDIS_URL', 'AK_TOKEN'
|
||||
'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 url = v => !v ? "" : (v.startsWith("http") ? v : `/img/productos/${v}`);
|
||||
const VALID_IDENT = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
|
||||
const q = (s) => `"${String(s).replace(/"/g, '""')}"`; // Identificadores SQL -> comillas dobles y escape correcto
|
||||
const PORT = process.env.PORT;
|
||||
const SESSION_NAME = process.env.SESSION_NAME;
|
||||
const SESSION_SECRET = process.env.SESSION_SECRET;
|
||||
const COOKIE_DOMAIN = process.env.COOKIE_DOMAIN;
|
||||
|
||||
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// App + Motor de vistas EJS
|
||||
// ----------------------------------------------------------
|
||||
|
||||
const app = express();
|
||||
app.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.set('trust proxy', true);
|
||||
app.set("views", path.join(__dirname, "views/pages"));
|
||||
app.set("layout", path.join(__dirname, "views/layouts/main"));
|
||||
// app.set("layout", "layouts/main");
|
||||
app.set("view engine", "ejs");
|
||||
app.use(favicon(path.join(__dirname, 'public', 'favicon', 'favicon.ico'), { maxAge: '1y' }));
|
||||
|
||||
app.use(express.json());
|
||||
app.use(express.json({ limit: '1mb' }));
|
||||
app.use(express.urlencoded({ extended: true }));
|
||||
// Archivos estáticos que fuerzan la re-descarga de arhivos
|
||||
app.use(favicon(path.join(__dirname, 'public', 'favicon', 'favicon.ico'), { maxAge: '1y' }));
|
||||
app.use(express.static(path.join(__dirname, "public"), {
|
||||
etag: false, maxAge: 0,
|
||||
setHeaders: (res, path) => {
|
||||
res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
|
||||
}
|
||||
}));
|
||||
|
||||
app.use(express.static(path.join(__dirname,"public"), { etag: false, maxAge: 0, setHeaders: (res, path) => { res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate'); }}));
|
||||
app.use(expressLayouts);
|
||||
app.use(cookieParser(process.env.SESSION_SECRET));
|
||||
app.use(expressPages); // Renderizado trae las paginas desde ./services/manso/src/routes/routes.js
|
||||
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// Uso de API v1
|
||||
// ----------------------------------------------------------
|
||||
app.use("/api/v1", v1Router);
|
||||
|
||||
// /api/rpc/get_producto/:id
|
||||
// /api/v1/rpc/get_producto/:id -> /rpc/get_producto/:id
|
||||
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// Seguridad: Tablas permitidas
|
||||
// ----------------------------------------------------------
|
||||
const ALLOWED_TABLES = [
|
||||
'roles','usuarios','usua_roles',
|
||||
'categorias','productos',
|
||||
'clientes','mesas',
|
||||
'comandas','deta_comandas',
|
||||
'proveedores','compras','deta_comp_producto',
|
||||
'mate_primas','deta_comp_materias',
|
||||
'prov_producto','prov_mate_prima',
|
||||
'receta_producto', 'asistencia_resumen_diario',
|
||||
'asistencia_intervalo', 'asistencia_detalle',
|
||||
'vw_compras'
|
||||
];
|
||||
|
||||
function ensureTable(name) {
|
||||
const t = String(name || '').toLowerCase();
|
||||
if (!ALLOWED_TABLES.includes(t)) throw new Error('Tabla no permitida');
|
||||
return t;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// Introspección de esquema
|
||||
// ----------------------------------------------------------
|
||||
async function loadColumns(client, table) {
|
||||
const sql = `
|
||||
SELECT
|
||||
c.column_name,
|
||||
c.data_type,
|
||||
c.is_nullable = 'YES' AS is_nullable,
|
||||
c.column_default,
|
||||
(SELECT EXISTS (
|
||||
SELECT 1 FROM pg_attribute a
|
||||
JOIN pg_class t ON t.oid = a.attrelid
|
||||
JOIN pg_index i ON i.indrelid = t.oid AND a.attnum = ANY(i.indkey)
|
||||
WHERE t.relname = $1 AND i.indisprimary AND a.attname = c.column_name
|
||||
)) AS is_primary,
|
||||
(SELECT a.attgenerated = 's' OR a.attidentity IN ('a','d')
|
||||
FROM pg_attribute a
|
||||
JOIN pg_class t ON t.oid = a.attrelid
|
||||
WHERE t.relname = $1 AND a.attname = c.column_name
|
||||
) AS is_identity
|
||||
FROM information_schema.columns c
|
||||
WHERE c.table_schema='public' AND c.table_name=$1
|
||||
ORDER BY c.ordinal_position
|
||||
`;
|
||||
const { rows } = await client.query(sql, [table]);
|
||||
return rows;
|
||||
}
|
||||
|
||||
async function loadForeignKeys(client, table) {
|
||||
const sql = `
|
||||
SELECT
|
||||
kcu.column_name,
|
||||
ccu.table_name AS foreign_table,
|
||||
ccu.column_name AS foreign_column
|
||||
FROM information_schema.table_constraints tc
|
||||
JOIN information_schema.key_column_usage kcu
|
||||
ON tc.constraint_name = kcu.constraint_name AND tc.table_schema = kcu.table_schema
|
||||
JOIN information_schema.constraint_column_usage ccu
|
||||
ON ccu.constraint_name = tc.constraint_name AND ccu.table_schema = tc.table_schema
|
||||
WHERE tc.table_schema='public' AND tc.table_name=$1 AND tc.constraint_type='FOREIGN KEY'
|
||||
`;
|
||||
const { rows } = await client.query(sql, [table]);
|
||||
const map = {};
|
||||
for (const r of rows) map[r.column_name] = { foreign_table: r.foreign_table, foreign_column: r.foreign_column };
|
||||
return map;
|
||||
}
|
||||
|
||||
async function loadPrimaryKey(client, table) {
|
||||
const sql = `
|
||||
SELECT a.attname AS column_name
|
||||
FROM pg_index i
|
||||
JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = ANY(i.indkey)
|
||||
JOIN pg_class t ON t.oid = i.indrelid
|
||||
WHERE t.relname = $1 AND i.indisprimary
|
||||
`;
|
||||
const { rows } = await client.query(sql, [table]);
|
||||
return rows.map(r => r.column_name);
|
||||
}
|
||||
|
||||
// label column for FK options
|
||||
async function pickLabelColumn(client, refTable) {
|
||||
const preferred = ['nombre','raz_social','apodo','documento','correo','telefono'];
|
||||
const { rows } = await client.query(
|
||||
`SELECT column_name, data_type
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema='public' AND table_name=$1
|
||||
ORDER BY ordinal_position`, [refTable]
|
||||
);
|
||||
for (const cand of preferred) {
|
||||
if (rows.find(r => r.column_name === cand)) return cand;
|
||||
}
|
||||
const textish = rows.find(r => /text|character varying|varchar/i.test(r.data_type));
|
||||
if (textish) return textish.column_name;
|
||||
return rows[0]?.column_name || 'id';
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// Middleware para datos globales
|
||||
// ----------------------------------------------------------
|
||||
app.use((req, res, next) => {
|
||||
res.locals.currentPath = req.path;
|
||||
res.locals.pageTitle = "SuiteCoffee";
|
||||
res.locals.pageId = "";
|
||||
next();
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// API
|
||||
// ----------------------------------------------------------
|
||||
app.get('/api/tables', async (_req, res) => {
|
||||
res.json(ALLOWED_TABLES);
|
||||
});
|
||||
|
||||
app.get('/api/schema/:table', async (req, res) => {
|
||||
try {
|
||||
const table = ensureTable(req.params.table);
|
||||
const client = await pool.getClient();
|
||||
try {
|
||||
const columns = await loadColumns(client, table);
|
||||
const fks = await loadForeignKeys(client, table);
|
||||
const enriched = columns.map(c => ({ ...c, foreign: fks[c.column_name] || null }));
|
||||
res.json({ table, columns: enriched });
|
||||
} finally { client.release(); }
|
||||
} catch (e) {
|
||||
res.status(400).json({ error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/options/:table/:column', async (req, res) => {
|
||||
try {
|
||||
const table = ensureTable(req.params.table);
|
||||
const column = req.params.column;
|
||||
if (!VALID_IDENT.test(column)) throw new Error('Columna inválida');
|
||||
|
||||
const client = await pool.getClient();
|
||||
try {
|
||||
const fks = await loadForeignKeys(client, table);
|
||||
const fk = fks[column];
|
||||
if (!fk) return res.json([]);
|
||||
|
||||
const refTable = fk.foreign_table;
|
||||
const refId = fk.foreign_column;
|
||||
const labelCol = await pickLabelColumn(client, refTable);
|
||||
|
||||
const sql = `SELECT ${q(refId)} AS id, ${q(labelCol)} AS label FROM ${q(refTable)} ORDER BY ${q(labelCol)} LIMIT 1000`;
|
||||
const result = await client.query(sql);
|
||||
res.json(result.rows);
|
||||
} finally { client.release(); }
|
||||
} catch (e) {
|
||||
res.status(400).json({ error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/table/:table', async (req, res) => {
|
||||
try {
|
||||
const table = ensureTable(req.params.table);
|
||||
const limit = Math.min(parseInt(req.query.limit || '100', 10), 1000);
|
||||
const client = await pool.getClient();
|
||||
try {
|
||||
const pks = await loadPrimaryKey(client, table);
|
||||
const orderBy = pks.length ? `ORDER BY ${pks.map(q).join(', ')} DESC` : '';
|
||||
const sql = `SELECT * FROM ${q(table)} ${orderBy} LIMIT ${limit}`;
|
||||
const result = await client.query(sql);
|
||||
|
||||
// Normalizar: siempre devolver objetos {col: valor}
|
||||
const colNames = result.fields.map(f => f.name);
|
||||
let rows = result.rows;
|
||||
if (rows.length && Array.isArray(rows[0])) {
|
||||
rows = rows.map(r => Object.fromEntries(r.map((v, i) => [colNames[i], v])));
|
||||
}
|
||||
res.json(rows);
|
||||
} finally { client.release(); }
|
||||
} catch (e) {
|
||||
res.status(400).json({ error: e.message, code: e.code, detail: e.detail });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/table/:table', async (req, res) => {
|
||||
const table = ensureTable(req.params.table);
|
||||
const payload = req.body || {};
|
||||
try {
|
||||
const client = await pool.getClient();
|
||||
try {
|
||||
const columns = await loadColumns(client, table);
|
||||
const insertable = columns.filter(c =>
|
||||
!c.is_primary && !c.is_identity && !(c.column_default || '').startsWith('nextval(')
|
||||
);
|
||||
const allowedCols = new Set(insertable.map(c => c.column_name));
|
||||
|
||||
const cols = [];
|
||||
const vals = [];
|
||||
const params = [];
|
||||
let idx = 1;
|
||||
for (const [k, v] of Object.entries(payload)) {
|
||||
if (!allowedCols.has(k)) continue;
|
||||
if (!VALID_IDENT.test(k)) continue;
|
||||
cols.push(q(k));
|
||||
vals.push(`$${idx++}`);
|
||||
params.push(v);
|
||||
}
|
||||
|
||||
if (!cols.length) {
|
||||
const { rows } = await client.query(`INSERT INTO ${q(table)} DEFAULT VALUES RETURNING *`);
|
||||
res.status(201).json({ inserted: rows[0] });
|
||||
} else {
|
||||
const { rows } = await client.query(
|
||||
`INSERT INTO ${q(table)} (${cols.join(', ')}) VALUES (${vals.join(', ')}) RETURNING *`,
|
||||
params
|
||||
);
|
||||
res.status(201).json({ inserted: rows[0] });
|
||||
}
|
||||
} catch (e) {
|
||||
if (e.code === '23503') return res.status(400).json({ error: 'Violación de clave foránea', detail: e.detail });
|
||||
if (e.code === '23505') return res.status(400).json({ error: 'Violación de unicidad', detail: e.detail });
|
||||
if (e.code === '23514') return res.status(400).json({ error: 'Violación de CHECK', detail: e.detail });
|
||||
if (e.code === '23502') return res.status(400).json({ error: 'Campo NOT NULL faltante', detail: e.detail });
|
||||
throw e;
|
||||
} finally { client.release(); }
|
||||
} catch (e) {
|
||||
res.status(400).json({ error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// Verificación de conexión
|
||||
// ----------------------------------------------------------
|
||||
|
||||
async function verificarConexionCore() {
|
||||
try {
|
||||
console.log(`[APP] Comprobando accesibilidad a la db ${CORE_DB_NAME} del host ${CORE_DB_HOST} ...`);
|
||||
const client = await poolCore.connect();
|
||||
const { rows } = await client.query('SELECT NOW() AS ahora');
|
||||
console.log(`\n[APP] Conexión con ${CORE_DB_NAME} OK. Hora DB:`, rows[0].ahora);
|
||||
client.release();
|
||||
} catch (error) {
|
||||
console.error('[APP] Error al conectar con la base de datos al iniciar:', error.message);
|
||||
console.error('[APP] Revisar credenciales, accesos de red y firewall.');
|
||||
}
|
||||
}
|
||||
async function verificarConexionTenants() {
|
||||
try {
|
||||
console.log(`[APP] Comprobando accesibilidad a la db ${TENANTS_DB_NAME} del host ${TENANTS_DB_HOST} ...`);
|
||||
const client = await poolTenants.connect();
|
||||
const { rows } = await client.query('SELECT NOW() AS ahora');
|
||||
console.log(`\n[APP] Conexión con ${TENANTS_DB_NAME} OK. Hora DB:`, rows[0].ahora);
|
||||
client.release();
|
||||
} catch (error) {
|
||||
console.error('[APP] Error al conectar con la base de datos al iniciar:', error.message);
|
||||
console.error('[APP] Revisar credenciales, accesos de red y firewall.');
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// 404 + Manejo de errores
|
||||
// Redis
|
||||
// -----------------------------------------------------------------------------
|
||||
await redisAuthentik.connect();
|
||||
const redisClient = redisAuthentik.getClient();
|
||||
|
||||
/*app.use((req, res) => res.status(404).json({ error: 'Error 404, No se encontró la página', path: req.originalUrl }));
|
||||
|
||||
app.use((err, _req, res, _next) => {
|
||||
console.error('[APP] ', err);
|
||||
if (res.headersSent) return;
|
||||
res.status(500).json({ error: '¡Oh! A ocurrido un error en el servidor app.', detail: err.stack || String(err) });
|
||||
});*/
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Cookies de sesión
|
||||
// -----------------------------------------------------------------------------
|
||||
app.use(cookieParser(SESSION_SECRET));
|
||||
|
||||
app.use(session({
|
||||
name: SESSION_NAME,
|
||||
store: new RedisStore({ client: redisClient, prefix: 'sess:' }),
|
||||
secret: SESSION_SECRET,
|
||||
resave: false,
|
||||
saveUninitialized: false,
|
||||
cookie: {
|
||||
httpOnly: true,
|
||||
sameSite: 'lax', // 'none' si necesitás third-party estricto
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
...(COOKIE_DOMAIN ? { domain: COOKIE_DOMAIN } : {}), // ✅ compatibilidad subdominios
|
||||
},
|
||||
}));
|
||||
|
||||
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// Renderizado de las páginas importadas desde '#pages' + configuración global
|
||||
// ----------------------------------------------------------
|
||||
// app.use(expressPages); // Renderizado trae las paginas desde ./services/manso/src/routes/routes.js
|
||||
// app.use(requireAuth({ redirectTo: '/auth/login' }), expressPages); // Renderizado trae las paginas desde ./services/manso/src/routes/routes.js
|
||||
// app.use(requireAuth({ redirectTo: '/auth/login' }), tenantContext({ debug: true }), expressPages); // Renderizado trae las paginas desde ./services/manso/src/routes/routes.js
|
||||
|
||||
app.use(
|
||||
requireAuth({ redirectTo: '/auth/login' }),
|
||||
tenantContext({
|
||||
debug: true,
|
||||
resolveTenant: (req, sess) => resolveTenantFromCore(req, sess, { debug: true }),
|
||||
// acceptStates: ['ready'] // (default) o ['ready','provisioning'] si querés permitir provisión
|
||||
}),
|
||||
expressPages
|
||||
);
|
||||
|
||||
app.use(datosGlobales);
|
||||
|
||||
|
||||
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// API v1
|
||||
// ----------------------------------------------------------
|
||||
app.use("/api/v1", requireAuth({ redirectTo: '/auth/login' }), tenantContext(), apiv1);
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// Inicio del servidor
|
||||
// ----------------------------------------------------------
|
||||
|
||||
app.listen(PORT, () => {
|
||||
app.listen(PORT, async () => {
|
||||
console.log(`[APP] SuiteCoffee corriendo en http://localhost:${PORT}`);
|
||||
verificarConexionCore();
|
||||
verificarConexionTenants();
|
||||
await verificarConexionCore();
|
||||
await verificarConexionTenants();
|
||||
await verificarConexionRedisAuthentik();
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Healthcheck
|
||||
// -----------------------------------------------------------------------------
|
||||
app.get('/health', (_req, res) => {
|
||||
res.status(200).json({ status: 'ok'}),
|
||||
console.log(`[APP] Saludable`)
|
||||
res.status(200).json({ status: 'ok'})
|
||||
// console.log(`[APP] Saludable`)
|
||||
});
|
||||
@@ -1,37 +0,0 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// Middlewares de Auth/Tenant
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
export function requireAuth(req, res, next) {
|
||||
const authHeader = req.headers["authorization"];
|
||||
if (!authHeader) return res.status(401).send("Falta token");
|
||||
|
||||
const token = authHeader.split(" ")[1];
|
||||
|
||||
jwt.verify(token, getKey, { algorithms: ["RS256"] }, (err, decoded) => {
|
||||
if (err) return res.status(403).send("Token inválido o vencido");
|
||||
|
||||
// Guardamos los claims del token en req.user
|
||||
req.user = {
|
||||
id: decoded.sub,
|
||||
email: decoded.email,
|
||||
username: decoded.preferred_username,
|
||||
name: decoded.name,
|
||||
roles: decoded.groups || []
|
||||
};
|
||||
|
||||
next();
|
||||
});
|
||||
}
|
||||
|
||||
// export function exposeViewState(req, res, next) {
|
||||
// res.locals.pageTitle = res.locals.pageTitle || '';
|
||||
// res.locals.pageId = res.locals.pageId || '';
|
||||
// res.locals.tenant_uuid = req.session?.tenant?.uuid || null;
|
||||
// res.locals.ak_user_uuid = req.session?.tenant?.ak_user_uuid || null;
|
||||
// // también pásalos como props al render
|
||||
// res.locals.viewUser = req.session?.user || null;
|
||||
// res.locals.viewCookies = req.cookies || {};
|
||||
// res.locals.viewSession = req.session ? JSON.parse(JSON.stringify(req.session)) : {};
|
||||
// next();
|
||||
// }
|
||||
@@ -1,67 +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
|
||||
});
|
||||
|
||||
router.get('/dashboard', (req, res) => {
|
||||
res.locals.pageTitle = "Dashboard";
|
||||
res.locals.pageId = "dashboard";
|
||||
res.render("dashboard");
|
||||
// res.json({ ok: true, route: '/dashboard' });
|
||||
});
|
||||
|
||||
router.get('/comandas', (req, res) => {
|
||||
res.locals.pageTitle = "Comandas";
|
||||
res.locals.pageId = "comandas";
|
||||
res.render("comandas");
|
||||
// res.json({ ok: true, route: '/comandas' });
|
||||
});
|
||||
|
||||
router.get('/estadoComandas', (req, res) => {
|
||||
res.locals.pageTitle = "Estado";
|
||||
res.locals.pageId = "estadoComandas";
|
||||
res.render("estadoComandas");
|
||||
// res.json({ ok: true, route: '/estadoComandas' });
|
||||
});
|
||||
|
||||
router.get('/productos', (req, res) => {
|
||||
res.locals.pageTitle = "Propductos";
|
||||
res.locals.pageId = "productos";
|
||||
res.render("productos");
|
||||
// res.json({ ok: true, route: '/productos' });
|
||||
});
|
||||
|
||||
router.get('/usuarios', (req, res) => {
|
||||
res.locals.pageTitle = "Usuarios";
|
||||
res.locals.pageId = "usuarios";
|
||||
res.render("usuarios");
|
||||
// res.json({ ok: true, route: '/usuarios' });
|
||||
});
|
||||
|
||||
router.get('/reportes', (req, res) => {
|
||||
res.locals.pageTitle = "Reportes";
|
||||
res.locals.pageId = "reportes";
|
||||
res.render("reportes");
|
||||
// res.json({ ok: true, route: '/reportes' });
|
||||
});
|
||||
|
||||
router.get('/compras', (req, res) => {
|
||||
res.locals.pageTitle = "Compras";
|
||||
res.locals.pageId = "compras";
|
||||
res.render("compras");
|
||||
// res.json({ ok: true, route: '/compras' });
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,341 @@
|
||||
// services/app/src/public/scripts/comandas/index.mjs
|
||||
// ------------------------------------------------------------
|
||||
// SuiteCoffee — Comandas (frontend script)
|
||||
// - Resuelve el schema activo desde la sesión (/auth/debug/session)
|
||||
// - Setea header X-Tenant-Schema en todos los fetch
|
||||
// - Asegura envío de cookies (credentials:'same-origin')
|
||||
// - Carga y cachea el catálogo de productos (descubrimiento de endpoint)
|
||||
// - Expone helpers globales (jget/jpost/jput/jdel, getActiveSchema, productos)
|
||||
// ------------------------------------------------------------
|
||||
|
||||
'use strict';
|
||||
|
||||
/* =======================
|
||||
* Utils
|
||||
* ======================= */
|
||||
const noDash = (v) => (v == null ? '' : String(v).replace(/-/g, ''));
|
||||
const isJson = (res) => (res.headers.get('content-type') || '').includes('application/json');
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
const byId = (id) => document.getElementById(id);
|
||||
|
||||
/* =======================
|
||||
* Tenant schema cache
|
||||
* ======================= */
|
||||
let SC_ACTIVE_SCHEMA = null;
|
||||
|
||||
/**
|
||||
* Lee la sesión desde /auth/debug/session y determina el esquema activo.
|
||||
* Prioriza:
|
||||
* 1) user.active_schema / user.activeSchema
|
||||
* 2) membership de user.current_tenant / user.currentTenant
|
||||
* 3) primer membership disponible
|
||||
* Lanza Error si no puede determinarlo.
|
||||
*/
|
||||
async function getActiveSchema() {
|
||||
if (SC_ACTIVE_SCHEMA) return SC_ACTIVE_SCHEMA;
|
||||
|
||||
let ses = null;
|
||||
try {
|
||||
const res = await fetch('/auth/debug/session', { credentials: 'same-origin' });
|
||||
ses = res.ok ? await res.json() : null;
|
||||
} catch (_) { /* ignore */ }
|
||||
|
||||
const u = ses?.user ?? null;
|
||||
const memberships = Array.isArray(u?.memberships) ? u.memberships : [];
|
||||
|
||||
// 1) Esquema activo directo
|
||||
let schema =
|
||||
u?.active_schema ||
|
||||
u?.activeSchema ||
|
||||
// 2) Membership del current tenant
|
||||
memberships.find(
|
||||
(m) =>
|
||||
String(m.tenant_id) === String(u?.current_tenant) ||
|
||||
String(m.tenantId) === String(u?.currentTenant)
|
||||
)?.schema_name ||
|
||||
// 3) Primer membership
|
||||
memberships[0]?.schema_name ||
|
||||
null;
|
||||
|
||||
if (!schema) throw new Error('Sesión inválida o tenant no seleccionado');
|
||||
|
||||
SC_ACTIVE_SCHEMA = schema;
|
||||
return SC_ACTIVE_SCHEMA;
|
||||
}
|
||||
|
||||
/* =======================
|
||||
* Fetch helpers (con schema)
|
||||
* ======================= */
|
||||
async function buildHeaders(extra = {}) {
|
||||
const schema = await getActiveSchema();
|
||||
return { Accept: 'application/json', 'X-Tenant-Schema': schema, ...extra };
|
||||
}
|
||||
|
||||
async function jfetch(url, opts = {}) {
|
||||
const headers = await buildHeaders(opts.headers || {});
|
||||
const res = await fetch(url, {
|
||||
...opts,
|
||||
headers,
|
||||
credentials: 'same-origin', // imprescindible para que viajen las cookies
|
||||
});
|
||||
|
||||
let payload = null;
|
||||
try {
|
||||
payload = isJson(res) ? await res.json() : await res.text();
|
||||
} catch (_) { /* ignore parse errors */ }
|
||||
|
||||
if (!res.ok) {
|
||||
const msg = (payload && payload.error) || `${res.status} ${res.statusText}`;
|
||||
throw new Error(msg);
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
async function jget(url) { return jfetch(url, { method: 'GET' }); }
|
||||
async function jpost(url, body) {
|
||||
return jfetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body ?? {}) });
|
||||
}
|
||||
async function jput(url, body) {
|
||||
return jfetch(url, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body ?? {}) });
|
||||
}
|
||||
async function jdel(url, body) {
|
||||
return jfetch(url, { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, body: body ? JSON.stringify(body) : undefined });
|
||||
}
|
||||
|
||||
/* =======================
|
||||
* Productos (catálogo)
|
||||
* - Descubre endpoint
|
||||
* - Cachea y normaliza
|
||||
* ======================= */
|
||||
const productos = (() => {
|
||||
// Posibles endpoints (se prueban en orden)
|
||||
const CANDIDATES = [
|
||||
'/api/v1/productos?limit=1000',
|
||||
'/api/v1/productos',
|
||||
'/api/v1/catalogo?limit=1000',
|
||||
'/api/v1/catalogo',
|
||||
'/api/v1/items?limit=1000',
|
||||
'/api/v1/items',
|
||||
];
|
||||
|
||||
// Estado en memoria
|
||||
let discoveredEndpoint = null;
|
||||
let cache = /** @type {Array| null} */(null);
|
||||
let lastLoadedAt = null;
|
||||
|
||||
// Normalización a { id, nombre, precio, categoria?, activo? }
|
||||
function normalizeOne(p) {
|
||||
const id =
|
||||
p.id ?? p.id_producto ?? p.producto_id ?? p.productId ?? p.pk ?? null;
|
||||
|
||||
const nombre =
|
||||
p.nombre ?? p.producto_nombre ?? p.name ?? p.titulo ?? p.title ?? '';
|
||||
|
||||
const precio =
|
||||
p.precio ?? p.price ?? p.pre_unitario ?? p.pu ?? p.monto ?? 0;
|
||||
|
||||
const categoria =
|
||||
p.categoria ?? p.category ?? p.nombre_categoria ?? null;
|
||||
|
||||
const activo =
|
||||
p.activo ?? p.enabled ?? p.habilitado ?? true;
|
||||
|
||||
return { id, nombre, precio, categoria, activo, raw: p };
|
||||
}
|
||||
|
||||
function normalizePayload(data) {
|
||||
if (Array.isArray(data)) return data.map(normalizeOne);
|
||||
// objetos comunes: { items: [...] } | { rows: [...] } | { data: [...] }
|
||||
const arr = data?.items || data?.rows || data?.data || data?.productos || null;
|
||||
return Array.isArray(arr) ? arr.map(normalizeOne) : [];
|
||||
}
|
||||
|
||||
async function discoverEndpoint() {
|
||||
// si ya lo descubrimos, reusar
|
||||
if (discoveredEndpoint) return discoveredEndpoint;
|
||||
|
||||
for (const url of CANDIDATES) {
|
||||
try {
|
||||
const data = await jget(url);
|
||||
const list = normalizePayload(data);
|
||||
if (list.length >= 0) { // aceptar 0+ items (catálogo vacío)
|
||||
discoveredEndpoint = url;
|
||||
cache = list;
|
||||
lastLoadedAt = new Date();
|
||||
return discoveredEndpoint;
|
||||
}
|
||||
} catch (_) {
|
||||
// probar el siguiente
|
||||
}
|
||||
}
|
||||
throw new Error('No se pudo descubrir el endpoint de productos');
|
||||
}
|
||||
|
||||
async function ensureLoaded({ force = false } = {}) {
|
||||
await discoverEndpoint();
|
||||
if (!force && cache && Array.isArray(cache)) return cache;
|
||||
|
||||
// recargar desde endpoint descubierto
|
||||
const base = discoveredEndpoint.split('?')[0];
|
||||
const url = base.includes('?') ? base : `${base}?limit=1000`;
|
||||
const data = await jget(url);
|
||||
cache = normalizePayload(data);
|
||||
lastLoadedAt = new Date();
|
||||
return cache;
|
||||
}
|
||||
|
||||
function all() {
|
||||
return Array.isArray(cache) ? cache.slice() : [];
|
||||
}
|
||||
|
||||
function search(q) {
|
||||
const term = String(q || '').trim().toLowerCase();
|
||||
if (!term) return all();
|
||||
return all().filter(p =>
|
||||
String(p.nombre || '').toLowerCase().includes(term) ||
|
||||
String(p.categoria || '').toLowerCase().includes(term)
|
||||
);
|
||||
}
|
||||
|
||||
function getById(id) {
|
||||
return all().find(p => String(p.id) === String(id)) || null;
|
||||
}
|
||||
|
||||
// Renderización opcional si existe algún selector en la página
|
||||
function renderSelect({ selectorList = ['#productos', '#selProducto', 'select[name="producto"]'] } = {}) {
|
||||
const el = selectorList.map((s) => document.querySelector(s)).find(Boolean);
|
||||
if (!el) return; // nada que renderizar
|
||||
|
||||
const list = all();
|
||||
if (el.tagName === 'SELECT') {
|
||||
el.innerHTML = ''; // limpiar
|
||||
const opt0 = document.createElement('option');
|
||||
opt0.value = ''; opt0.textContent = '— Seleccionar producto —';
|
||||
el.appendChild(opt0);
|
||||
|
||||
for (const p of list) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = p.id;
|
||||
opt.textContent = `${p.nombre} — ${formatPrecio(p.precio)}`;
|
||||
el.appendChild(opt);
|
||||
}
|
||||
} else {
|
||||
// contenedor genérico (lista)
|
||||
el.innerHTML = '';
|
||||
const ul = document.createElement('ul');
|
||||
ul.className = 'lista-productos';
|
||||
for (const p of list) {
|
||||
const li = document.createElement('li');
|
||||
li.textContent = `${p.nombre} — ${formatPrecio(p.precio)}`;
|
||||
li.dataset.productId = p.id;
|
||||
ul.appendChild(li);
|
||||
}
|
||||
el.appendChild(ul);
|
||||
}
|
||||
}
|
||||
|
||||
function bindSearch({ inputSelectors = ['#buscarProducto', 'input[name="buscar_producto"]'], selectorList } = {}) {
|
||||
const input = inputSelectors.map((s) => document.querySelector(s)).find(Boolean);
|
||||
if (!input) return;
|
||||
|
||||
input.addEventListener('input', () => {
|
||||
const q = input.value;
|
||||
const list = search(q);
|
||||
// re-render mínimo para SELECTs
|
||||
const el = selectorList?.map((s) => document.querySelector(s)).find(Boolean)
|
||||
|| document.querySelector('#selProducto')
|
||||
|| document.querySelector('#productos');
|
||||
if (!el) return;
|
||||
|
||||
if (el.tagName === 'SELECT') {
|
||||
el.innerHTML = '';
|
||||
const opt0 = document.createElement('option');
|
||||
opt0.value = ''; opt0.textContent = '— Seleccionar producto —';
|
||||
el.appendChild(opt0);
|
||||
|
||||
for (const p of list) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = p.id;
|
||||
opt.textContent = `${p.nombre} — ${formatPrecio(p.precio)}`;
|
||||
el.appendChild(opt);
|
||||
}
|
||||
} else {
|
||||
el.innerHTML = '';
|
||||
const ul = document.createElement('ul');
|
||||
ul.className = 'lista-productos';
|
||||
for (const p of list) {
|
||||
const li = document.createElement('li');
|
||||
li.textContent = `${p.nombre} — ${formatPrecio(p.precio)}`;
|
||||
li.dataset.productId = p.id;
|
||||
ul.appendChild(li);
|
||||
}
|
||||
el.appendChild(ul);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function formatPrecio(v) {
|
||||
const n = Number(v || 0);
|
||||
try { return n.toLocaleString(undefined, { style: 'currency', currency: 'UYU' }); }
|
||||
catch { return `${n.toFixed(2)} UYU`; }
|
||||
}
|
||||
|
||||
return {
|
||||
ensureLoaded,
|
||||
all,
|
||||
search,
|
||||
getById,
|
||||
renderSelect,
|
||||
bindSearch,
|
||||
get endpoint() { return discoveredEndpoint; },
|
||||
get lastLoadedAt() { return lastLoadedAt; },
|
||||
};
|
||||
})();
|
||||
|
||||
/* =======================
|
||||
* Init de página
|
||||
* ======================= */
|
||||
async function initPage() {
|
||||
// 1) Fijar esquema activo (lanza si no hay sesión/tenant)
|
||||
await getActiveSchema();
|
||||
|
||||
// 2) Cargar catálogo de productos (descubrimiento + caché)
|
||||
await productos.ensureLoaded().catch((e) => {
|
||||
console.error('[productos.ensureLoaded] fallo:', e);
|
||||
// No cortamos la init de la página; pero mostramos feedback
|
||||
alert(e?.message || 'No fue posible cargar el catálogo de productos');
|
||||
});
|
||||
|
||||
// 3) Render opcional si existen selectores conocidos
|
||||
productos.renderSelect({ selectorList: ['#selProducto', '#productos', 'select[name="producto"]'] });
|
||||
productos.bindSearch({ inputSelectors: ['#buscarProducto', 'input[name="buscar_producto"]'], selectorList: ['#selProducto', '#productos'] });
|
||||
|
||||
// 4) Hooks opcionales del código original (si existen)
|
||||
if (window.nueva?.init) await window.nueva.init();
|
||||
if (window.estado?.bind) window.estado.bind();
|
||||
if (window.estado?.loadLista) await window.estado.loadLista();
|
||||
}
|
||||
|
||||
/* =======================
|
||||
* Arranque
|
||||
* ======================= */
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
try {
|
||||
await initPage();
|
||||
} catch (err) {
|
||||
console.error('Error:', err);
|
||||
alert(err?.message || 'Error inicializando Comandas');
|
||||
}
|
||||
});
|
||||
|
||||
/* =======================
|
||||
* Export helpers globales
|
||||
* ======================= */
|
||||
Object.assign(window, {
|
||||
jget,
|
||||
jpost,
|
||||
jput,
|
||||
jdel,
|
||||
getActiveSchema,
|
||||
productos,
|
||||
});
|
||||
@@ -1,76 +0,0 @@
|
||||
// ----------------------------------------------------------
|
||||
// Introspección de esquema
|
||||
// ----------------------------------------------------------
|
||||
export async function loadColumns(client, table) {
|
||||
const sql = `
|
||||
SELECT
|
||||
c.column_name,
|
||||
c.data_type,
|
||||
c.is_nullable = 'YES' AS is_nullable,
|
||||
c.column_default,
|
||||
(SELECT EXISTS (
|
||||
SELECT 1 FROM pg_attribute a
|
||||
JOIN pg_class t ON t.oid = a.attrelid
|
||||
JOIN pg_index i ON i.indrelid = t.oid AND a.attnum = ANY(i.indkey)
|
||||
WHERE t.relname = $1 AND i.indisprimary AND a.attname = c.column_name
|
||||
)) AS is_primary,
|
||||
(SELECT a.attgenerated = 's' OR a.attidentity IN ('a','d')
|
||||
FROM pg_attribute a
|
||||
JOIN pg_class t ON t.oid = a.attrelid
|
||||
WHERE t.relname = $1 AND a.attname = c.column_name
|
||||
) AS is_identity
|
||||
FROM information_schema.columns c
|
||||
WHERE c.table_schema='public' AND c.table_name=$1
|
||||
ORDER BY c.ordinal_position
|
||||
`;
|
||||
const { rows } = await client.query(sql, [table]);
|
||||
return rows;
|
||||
}
|
||||
|
||||
export async function loadForeignKeys(client, table) {
|
||||
const sql = `
|
||||
SELECT
|
||||
kcu.column_name,
|
||||
ccu.table_name AS foreign_table,
|
||||
ccu.column_name AS foreign_column
|
||||
FROM information_schema.table_constraints tc
|
||||
JOIN information_schema.key_column_usage kcu
|
||||
ON tc.constraint_name = kcu.constraint_name AND tc.table_schema = kcu.table_schema
|
||||
JOIN information_schema.constraint_column_usage ccu
|
||||
ON ccu.constraint_name = tc.constraint_name AND ccu.table_schema = tc.table_schema
|
||||
WHERE tc.table_schema='public' AND tc.table_name=$1 AND tc.constraint_type='FOREIGN KEY'
|
||||
`;
|
||||
const { rows } = await client.query(sql, [table]);
|
||||
const map = {};
|
||||
for (const r of rows) map[r.column_name] = { foreign_table: r.foreign_table, foreign_column: r.foreign_column };
|
||||
return map;
|
||||
}
|
||||
|
||||
export async function loadPrimaryKey(client, table) {
|
||||
const sql = `
|
||||
SELECT a.attname AS column_name
|
||||
FROM pg_index i
|
||||
JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = ANY(i.indkey)
|
||||
JOIN pg_class t ON t.oid = i.indrelid
|
||||
WHERE t.relname = $1 AND i.indisprimary
|
||||
`;
|
||||
const { rows } = await client.query(sql, [table]);
|
||||
return rows.map(r => r.column_name);
|
||||
}
|
||||
|
||||
// label column for FK options
|
||||
export async function pickLabelColumn(client, refTable) {
|
||||
const preferred = ['nombre', 'raz_social', 'apodo', 'documento', 'correo', 'telefono'];
|
||||
const { rows } = await client.query(
|
||||
`SELECT column_name, data_type
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema='public' AND table_name=$1
|
||||
ORDER BY ordinal_position`, [refTable]
|
||||
);
|
||||
for (const cand of preferred) {
|
||||
if (rows.find(r => r.column_name === cand)) return cand;
|
||||
}
|
||||
const textish = rows.find(r => /text|character varying|varchar/i.test(r.data_type));
|
||||
if (textish) return textish.column_name;
|
||||
return rows[0]?.column_name || 'id';
|
||||
}
|
||||
@@ -1,558 +0,0 @@
|
||||
<!-- services/manso/src/views/comandas.ejs -->
|
||||
<div class="d-flex align-items-center justify-content-between mb-3">
|
||||
<h1 class="h4 m-0">📋 Nueva Comanda</h1>
|
||||
<span class="badge rounded-pill text-bg-light">/api/*</span>
|
||||
</div>
|
||||
|
||||
<div class="row g-3">
|
||||
<!-- Columna izquierda: Productos -->
|
||||
<div class="col-12 col-lg-7">
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header d-flex align-items-center">
|
||||
<strong>Productos</strong>
|
||||
<div class="ms-auto small text-muted" id="prodCount">0 ítems</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row g-2 align-items-center mb-2">
|
||||
<div class="col-12 col-sm">
|
||||
<input id="busqueda" type="search" class="form-control" placeholder="Buscar por nombre o categoría…">
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<button class="btn btn-outline-secondary" id="limpiarBusqueda">Limpiar</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="listadoProductos" class="border rounded" style="max-height:58vh; overflow:auto;">
|
||||
<!-- tabla de productos renderizada por JS -->
|
||||
<div class="p-3 text-muted">Cargando…</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Columna derecha: Detalles + Carrito -->
|
||||
<div class="col-12 col-lg-5">
|
||||
<div class="card shadow-sm mb-3">
|
||||
<div class="card-header"><strong>Detalles</strong></div>
|
||||
<div class="card-body">
|
||||
<div class="row g-2">
|
||||
<div class="col-12 col-sm-6">
|
||||
<label for="selMesa" class="form-label text-muted small mb-1">Mesa</label>
|
||||
<select id="selMesa" class="form-select"></select>
|
||||
</div>
|
||||
<div class="col-12 col-sm-6">
|
||||
<label for="selUsuario" class="form-label text-muted small mb-1">Usuario</label>
|
||||
<select id="selUsuario" class="form-select"></select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-2">
|
||||
<label for="obs" class="form-label text-muted small mb-1">Observaciones</label>
|
||||
<textarea id="obs" class="form-control" rows="3"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-secondary mt-3 mb-0 small">
|
||||
La fecha se completa automáticamente y los estados/activos usan sus valores por defecto.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header"><strong>Carrito</strong></div>
|
||||
<div class="card-body p-0" id="carritoWrap">
|
||||
<div class="p-3 text-muted">Aún no agregaste productos.</div>
|
||||
</div>
|
||||
<div class="d-flex align-items-center gap-2 p-3 border-top" style="position:sticky; bottom:0; background:#fff;">
|
||||
<div class="small"><span class="text-muted">Ítems:</span> <strong id="kpiItems">0</strong></div>
|
||||
<div class="small ms-2"><span class="text-muted">Total:</span> <strong id="kpiTotal">$ 0.00</strong></div>
|
||||
<div class="ms-auto"></div>
|
||||
<button class="btn btn-outline-secondary" id="vaciar">Vaciar</button>
|
||||
<button class="btn btn-primary" id="crear">Crear Comanda</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="msg" class="mt-2 small text-muted"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ====== LÓGICA ====== -->
|
||||
<script>
|
||||
// Helpers DOM
|
||||
const $ = (s, r=document) => r.querySelector(s);
|
||||
const $$ = (s, r=document) => Array.from(r.querySelectorAll(s));
|
||||
|
||||
// Estado
|
||||
const state = {
|
||||
productos: [],
|
||||
mesas: [],
|
||||
usuarios: [],
|
||||
categorias: [], // <--- NUEVO
|
||||
carrito: [],
|
||||
filtro: ''
|
||||
};
|
||||
|
||||
function norm(s='') {
|
||||
return s.toString().toLowerCase()
|
||||
.normalize('NFD').replace(/\p{Diacritic}/gu,''); // "café" -> "cafe"
|
||||
}
|
||||
|
||||
function isTakeaway(apodo) {
|
||||
return /^takeaway$/i.test(String(apodo || '').trim());
|
||||
}
|
||||
|
||||
function groupOrderByCatName(catName='') {
|
||||
const n = norm(catName);
|
||||
if (n.includes('bar')) return 1;
|
||||
if (n.includes('cafe')) return 2;
|
||||
if (n.includes('cafeter')) return 3;
|
||||
if (n.includes('trago') || n.includes('refresc')) return 4;
|
||||
return 99; // otros
|
||||
}
|
||||
|
||||
// Genera el HTML del ticket de cocina (80mm aprox)
|
||||
function buildKitchenTicketHTML(data) {
|
||||
const apodo = String(data.mesa_apodo ?? '').trim();
|
||||
const numero = data.mesa_numero ?? '';
|
||||
const take = isTakeaway(apodo);
|
||||
|
||||
const mesaTxt = take ? apodo.toUpperCase() : `Mesa #${numero}${apodo ? ' · ' + apodo : ''}`;
|
||||
// const isTakeaway = /Takeaway/i.test(String(data.mesa_apodo ?? '')) || /Takeaway/i.test(String(data.mesa_numero ?? ''));
|
||||
const mesaClass = take ? 'bigline' : 'mesa-medium';
|
||||
const obs = (data.observaciones && data.observaciones.trim()) ? data.observaciones.trim() : '';
|
||||
|
||||
|
||||
|
||||
// Productos ya vienen con su "g" (grupo numérico 1..4/99) y cantidad formateada
|
||||
const items = data.productos.slice().sort((a,b)=> (a.g||99) - (b.g||99));
|
||||
|
||||
let productosHtml = '';
|
||||
let prevG = null;
|
||||
for (const p of items) {
|
||||
if (prevG !== null && p.g !== prevG) {
|
||||
productosHtml += `<div class="hr dotted"></div>`; // separación punteada entre grupos
|
||||
}
|
||||
productosHtml += `
|
||||
<div class="row">
|
||||
<div class="qty">x${p.cantidad}</div>
|
||||
<div class="name">${p.nombre}</div>
|
||||
</div>`;
|
||||
prevG = p.g;
|
||||
}
|
||||
|
||||
return `<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Ticket Cocina</title>
|
||||
<style>
|
||||
:root {
|
||||
--w: 80mm;
|
||||
--fz-base: 16px;
|
||||
--fz-md: 16px; /* observaciones */
|
||||
--fz-item: 18px; /* filas */
|
||||
--fz-xl: 26px; /* <--- NUEVO: tamaño “grande” (mesa) */
|
||||
--fz-xxl: 34px; /* título (#comanda) */
|
||||
--fz-sm: 12px;
|
||||
}
|
||||
html, body { margin:0; padding:0; }
|
||||
body {
|
||||
width: var(--w);
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||||
font-size: var(--fz-base);
|
||||
line-height: 1.35;
|
||||
color:#000;
|
||||
font-weight: 700;
|
||||
}
|
||||
#ticket { padding: 10px 8px; }
|
||||
.center { text-align:center; }
|
||||
.row { display:flex; gap:8px; margin: 4px 0; }
|
||||
.row .qty { min-width: 22mm; font-size: var(--fz-item); letter-spacing:.2px; }
|
||||
.row .name { flex:1; font-size: var(--fz-item); text-transform: uppercase; word-break: break-word; }
|
||||
.hr { border-top: 2px dashed #000; margin: 8px 0; }
|
||||
.hr.dotted { border-top: 2px dotted #000; }
|
||||
.small { font-size: var(--fz-sm); }
|
||||
.bigline { font-size: var(--fz-xxl); text-transform: uppercase; }
|
||||
.mesa-medium { font-size: var(--fz-xl); text-transform: uppercase; }
|
||||
.obs { font-size: var(--fz-md); }
|
||||
.mt4{margin-top:4px}.mt8{margin-top:8px}.mb4{margin-bottom:4px}.mb8{margin-bottom:8px}
|
||||
@page { size: var(--w) auto; margin: 0; }
|
||||
@media print { body { width: var(--w); } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="ticket">
|
||||
<!-- SIN TÍTULO -->
|
||||
<div class="center bigline">#${data.id_comanda}</div>
|
||||
<div class="center ${mesaClass}">${mesaTxt}</div>
|
||||
|
||||
<div class="small mt4">Fecha: ${data.fecha} ${data.hora}</div>
|
||||
<div class="small mt4">Mozo: ${data.usuario || '—'}</div>
|
||||
${obs ? `<div class="obs mt8">Obs: ${obs}</div>` : ''}
|
||||
|
||||
<div class="hr"></div>
|
||||
${productosHtml}
|
||||
|
||||
<div class="hr"></div>
|
||||
<div class="small">Ítems: ${data.items} · Unidades: ${data.units}</div>
|
||||
<div class="center mt8 small">— fin —</div>
|
||||
</div>
|
||||
<script>window.onload = () => { window.focus(); window.print(); }<\/script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
// Imprime HTML usando un iframe oculto (menos bloqueos de pop-up)
|
||||
function printHtmlViaIframe(html) {
|
||||
return new Promise((resolve) => {
|
||||
let iframe = document.getElementById('printFrame');
|
||||
if (!iframe) {
|
||||
iframe = document.createElement('iframe');
|
||||
iframe.id = 'printFrame';
|
||||
iframe.style.position = 'fixed';
|
||||
iframe.style.right = '-9999px';
|
||||
iframe.style.bottom = '0';
|
||||
iframe.style.width = '0';
|
||||
iframe.style.height = '0';
|
||||
iframe.style.border = '0';
|
||||
document.body.appendChild(iframe);
|
||||
}
|
||||
const doc = iframe.contentWindow.document;
|
||||
doc.open();
|
||||
doc.write(html);
|
||||
doc.close();
|
||||
|
||||
// Salida: remover iframe después de un rato para no acumular
|
||||
setTimeout(() => {
|
||||
resolve();
|
||||
// (si prefieres mantenerlo para reimpresiones, no lo quites)
|
||||
// document.body.removeChild(iframe);
|
||||
}, 1500);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
// Utils
|
||||
const money = (n) => (isNaN(n) ? '—' : new Intl.NumberFormat('es-UY', { style:'currency', currency:'UYU' }).format(Number(n)));
|
||||
const toast = (msg, ok=false) => {
|
||||
const el = $('#msg');
|
||||
el.className = ok ? 'mt-2 small ok text-success' : 'mt-2 small err text-danger';
|
||||
el.textContent = msg;
|
||||
setTimeout(()=>{ el.textContent=''; el.className='mt-2 small text-muted'; }, 3500);
|
||||
};
|
||||
|
||||
async function jget(url) {
|
||||
const res = await fetch(url);
|
||||
let data; try { data = await res.json(); } catch { data = null; }
|
||||
if (!res.ok) throw new Error(data?.error || `${res.status} ${res.statusText}`);
|
||||
return data;
|
||||
}
|
||||
async function jpost(url, body) {
|
||||
const res = await fetch(url, { method:'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify(body) });
|
||||
const data = await res.json().catch(()=>null);
|
||||
if (!res.ok) throw new Error(data?.error || `${res.status} ${res.statusText}`);
|
||||
return data;
|
||||
}
|
||||
|
||||
// Carga inicial
|
||||
async function init() {
|
||||
const [prods, mesas, usuarios, categorias] = await Promise.all([
|
||||
jget('/api/table/productos?limit=1000'),
|
||||
jget('/api/table/mesas?limit=1000'),
|
||||
jget('/api/table/usuarios?limit=1000'),
|
||||
jget('/api/table/categorias?limit=1000') // <--- NUEVO
|
||||
]);
|
||||
|
||||
state.productos = prods.filter(p => p.activo !== false);
|
||||
state.mesas = mesas;
|
||||
state.usuarios = usuarios.filter(u => u.activo !== false);
|
||||
state.categorias = Array.isArray(categorias) ? categorias : [];
|
||||
|
||||
// Mapas para buscar categoría por id de producto
|
||||
state.catById = new Map(state.categorias.map(c => [c.id_categoria, (c.nombre||'').toString()]));
|
||||
state.prodCatNameById = new Map(state.productos.map(p => [p.id_producto, state.catById.get(p.id_categoria)||'']));
|
||||
|
||||
hydrateMesas();
|
||||
hydrateUsuarios();
|
||||
renderProductos();
|
||||
renderCarrito();
|
||||
|
||||
$('#busqueda').addEventListener('input', () => {
|
||||
state.filtro = $('#busqueda').value.trim().toLowerCase();
|
||||
renderProductos();
|
||||
});
|
||||
$('#limpiarBusqueda').addEventListener('click', () => {
|
||||
$('#busqueda').value = '';
|
||||
state.filtro = '';
|
||||
renderProductos();
|
||||
});
|
||||
$('#vaciar').addEventListener('click', () => { state.carrito = []; renderCarrito(); });
|
||||
$('#crear').addEventListener('click', crearComanda);
|
||||
}
|
||||
|
||||
function hydrateMesas() {
|
||||
const sel = $('#selMesa'); sel.innerHTML = '';
|
||||
for (const m of state.mesas) {
|
||||
const o = document.createElement('option');
|
||||
o.value = m.id_mesa;
|
||||
o.textContent = `#${m.numero} · ${m.apodo} (${m.estado})`;
|
||||
sel.appendChild(o);
|
||||
}
|
||||
}
|
||||
function hydrateUsuarios() {
|
||||
const sel = $('#selUsuario'); sel.innerHTML = '';
|
||||
for (const u of state.usuarios) {
|
||||
const o = document.createElement('option');
|
||||
o.value = u.id_usuario;
|
||||
o.textContent = `${u.nombre} ${u.apellido}`.trim();
|
||||
sel.appendChild(o);
|
||||
}
|
||||
}
|
||||
|
||||
// Render productos
|
||||
function renderProductos() {
|
||||
let rows = state.productos.slice();
|
||||
if (state.filtro) {
|
||||
rows = rows.filter(p =>
|
||||
(p.nombre || '').toLowerCase().includes(state.filtro) ||
|
||||
String(p.id_categoria ?? '').includes(state.filtro)
|
||||
);
|
||||
}
|
||||
$('#prodCount').textContent = `${rows.length} ítems`;
|
||||
|
||||
if (!rows.length) {
|
||||
$('#listadoProductos').innerHTML = '<div class="p-3 text-muted">Sin resultados.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
const tbl = document.createElement('table');
|
||||
tbl.className = 'table table-sm align-middle mb-0';
|
||||
tbl.innerHTML = `
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Producto</th>
|
||||
<th class="text-end">Precio</th>
|
||||
<th style="width:210px;">Cantidad</th>
|
||||
<th style="width:100px;"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody></tbody>
|
||||
`;
|
||||
const tb = tbl.querySelector('tbody');
|
||||
|
||||
for (const p of rows) {
|
||||
const tr = document.createElement('tr');
|
||||
tr.innerHTML = `
|
||||
<td>${p.nombre}</td>
|
||||
<td class="text-end">${money(p.precio)}</td>
|
||||
<td>
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<input type="number" min="0.001" step="0.001" value="1.000" data-qty class="form-control form-control-sm" style="max-width:120px;">
|
||||
<button class="btn btn-sm btn-outline-secondary" data-dec>-</button>
|
||||
<button class="btn btn-sm btn-outline-secondary" data-inc>+</button>
|
||||
</div>
|
||||
</td>
|
||||
<td><button class="btn btn-sm btn-primary" data-add>Agregar</button></td>
|
||||
`;
|
||||
const qty = tr.querySelector('[data-qty]');
|
||||
tr.querySelector('[data-dec]').addEventListener('click', () => { qty.value = Math.max(0.001, (parseFloat(qty.value||'0') - 1)).toFixed(3); });
|
||||
tr.querySelector('[data-inc]').addEventListener('click', () => { qty.value = (parseFloat(qty.value||'0') + 1).toFixed(3); });
|
||||
tr.querySelector('[data-add]').addEventListener('click', () => addToCart(p, parseFloat(qty.value||'1')) );
|
||||
tb.appendChild(tr);
|
||||
}
|
||||
|
||||
$('#listadoProductos').innerHTML = '';
|
||||
$('#listadoProductos').appendChild(tbl);
|
||||
}
|
||||
|
||||
function addToCart(prod, cantidad) {
|
||||
if (!(cantidad > 0)) { toast('Cantidad inválida'); return; }
|
||||
const precio = parseFloat(prod.precio);
|
||||
const it = state.carrito.find(i => i.id_producto === prod.id_producto && i.pre_unitario === precio);
|
||||
if (it) it.cantidad = Number((it.cantidad + cantidad).toFixed(3));
|
||||
else state.carrito.push({ id_producto: prod.id_producto, nombre: prod.nombre, pre_unitario: precio, cantidad: Number(cantidad.toFixed(3)) });
|
||||
renderCarrito();
|
||||
}
|
||||
|
||||
// Render carrito
|
||||
function renderCarrito() {
|
||||
const wrap = $('#carritoWrap');
|
||||
if (!state.carrito.length) {
|
||||
wrap.innerHTML = '<div class="p-3 text-muted">Aún no agregaste productos.</div>';
|
||||
$('#kpiItems').textContent='0'; $('#kpiTotal').textContent=money(0);
|
||||
return;
|
||||
}
|
||||
|
||||
const tbl = document.createElement('table');
|
||||
tbl.className = 'table table-sm align-middle mb-0';
|
||||
tbl.innerHTML = `
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Producto</th>
|
||||
<th class="text-end">Unitario</th>
|
||||
<th class="text-end">Cantidad</th>
|
||||
<th class="text-end">Subtotal</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody></tbody>
|
||||
`;
|
||||
const tb = tbl.querySelector('tbody');
|
||||
|
||||
let items = 0, total = 0;
|
||||
state.carrito.forEach((it, idx) => {
|
||||
items += 1;
|
||||
const sub = Number(it.pre_unitario) * Number(it.cantidad);
|
||||
total += sub;
|
||||
|
||||
const tr = document.createElement('tr');
|
||||
tr.innerHTML = `
|
||||
<td>${it.nombre}</td>
|
||||
<td class="text-end">${money(it.pre_unitario)}</td>
|
||||
<td class="text-end">
|
||||
<input type="number" min="0.001" step="0.001" value="${it.cantidad.toFixed(3)}" class="form-control form-control-sm text-end" style="max-width:120px;">
|
||||
</td>
|
||||
<td class="text-end">${money(sub)}</td>
|
||||
<td class="text-end">
|
||||
<button class="btn btn-sm btn-outline-secondary" data-del>Quitar</button>
|
||||
</td>
|
||||
`;
|
||||
const qty = tr.querySelector('input[type="number"]');
|
||||
qty.addEventListener('change', () => {
|
||||
const v = parseFloat(qty.value||'0');
|
||||
if (!(v>0)) { toast('Cantidad inválida'); qty.value = it.cantidad.toFixed(3); return; }
|
||||
it.cantidad = Number(v.toFixed(3));
|
||||
renderCarrito();
|
||||
});
|
||||
tr.querySelector('[data-del]').addEventListener('click', () => {
|
||||
state.carrito.splice(idx,1);
|
||||
renderCarrito();
|
||||
});
|
||||
|
||||
tb.appendChild(tr);
|
||||
});
|
||||
|
||||
wrap.innerHTML = '';
|
||||
wrap.appendChild(tbl);
|
||||
$('#kpiItems').textContent = String(items);
|
||||
$('#kpiTotal').textContent = money(total);
|
||||
}
|
||||
|
||||
const fmtQty = (n) => Number(n).toFixed(3).replace(/\.?0+$/,'');
|
||||
|
||||
async function crearComanda() {
|
||||
if (!state.carrito.length) { toast('Agrega al menos un producto'); return; }
|
||||
const id_mesa = parseInt($('#selMesa').value, 10);
|
||||
const id_usuario = parseInt($('#selUsuario').value, 10);
|
||||
if (!id_mesa || !id_usuario) { toast('Selecciona mesa y usuario'); return; }
|
||||
|
||||
// Snapshot del carrito ANTES de limpiar (para imprimir)
|
||||
const cartSnapshot = state.carrito.map(it => ({ ...it }));
|
||||
|
||||
const observaciones = $('#obs').value.trim() || null;
|
||||
|
||||
try {
|
||||
// 1) encabezado comanda
|
||||
const { inserted: com } = await jpost('/api/table/comandas', {
|
||||
id_usuario,
|
||||
id_mesa,
|
||||
estado: 'abierta',
|
||||
observaciones
|
||||
});
|
||||
|
||||
// 2) detalle
|
||||
const id_comanda = com.id_comanda;
|
||||
const payloads = cartSnapshot.map(it => ({
|
||||
id_comanda,
|
||||
id_producto: it.id_producto,
|
||||
cantidad: it.cantidad,
|
||||
pre_unitario: it.pre_unitario
|
||||
}));
|
||||
await Promise.all(payloads.map(p => jpost('/api/table/deta_comandas', p)));
|
||||
|
||||
// 3) Datos auxiliares para ticket
|
||||
const mesa = state.mesas.find(m => m.id_mesa === id_mesa) || {};
|
||||
const usuario = state.usuarios.find(u => u.id_usuario === id_usuario) || {};
|
||||
const now = new Date();
|
||||
const fecha = now.toLocaleDateString();
|
||||
const hora = now.toLocaleTimeString();
|
||||
|
||||
// 4) Construir e imprimir Ticket de Cocina (sin precios)
|
||||
const units = cartSnapshot.reduce((acc, it) => acc + Number(it.cantidad || 0), 0);
|
||||
const items = cartSnapshot.length;
|
||||
|
||||
// map producto -> nombre de categoría
|
||||
const prodCat = state.prodCatNameById || new Map();
|
||||
|
||||
const productosParaTicket = cartSnapshot.map(it => ({
|
||||
nombre: it.nombre,
|
||||
cantidad: fmtQty(it.cantidad),
|
||||
g: groupOrderByCatName(prodCat.get(it.id_producto) || '') // 1..4/99
|
||||
}));
|
||||
|
||||
const ticketHtml = buildKitchenTicketHTML({
|
||||
id_comanda,
|
||||
fecha, hora,
|
||||
mesa_numero: mesa?.numero,
|
||||
mesa_apodo: mesa?.apodo,
|
||||
usuario: `${usuario?.nombre || ''} ${usuario?.apellido || ''}`.trim(),
|
||||
observaciones,
|
||||
items,
|
||||
units,
|
||||
productos: productosParaTicket // <--- con grupos
|
||||
});
|
||||
|
||||
await printHtmlViaIframe(ticketHtml);
|
||||
|
||||
// 5) Reset UI
|
||||
state.carrito = [];
|
||||
renderCarrito();
|
||||
$('#obs').value = '';
|
||||
toast(`Comanda #${id_comanda} creada e impresa`, true);
|
||||
|
||||
} catch (e) {
|
||||
toast(e.message || 'No se pudo crear la comanda');
|
||||
}
|
||||
}
|
||||
|
||||
// // Crear comanda
|
||||
// async function crearComanda() {
|
||||
// if (!state.carrito.length) { toast('Agrega al menos un producto'); return; }
|
||||
// const id_mesa = parseInt($('#selMesa').value, 10);
|
||||
// const id_usuario = parseInt($('#selUsuario').value, 10);
|
||||
// if (!id_mesa || !id_usuario) { toast('Selecciona mesa y usuario'); return; }
|
||||
|
||||
// const observaciones = $('#obs').value.trim() || null;
|
||||
|
||||
// try {
|
||||
// // 1) encabezado comanda
|
||||
// const { inserted: com } = await jpost('/api/table/comandas', {
|
||||
// id_usuario,
|
||||
// id_mesa,
|
||||
// estado: 'abierta',
|
||||
// observaciones
|
||||
// });
|
||||
|
||||
// // 2) detalle
|
||||
// const id_comanda = com.id_comanda;
|
||||
// const payloads = state.carrito.map(it => ({
|
||||
// id_comanda,
|
||||
// id_producto: it.id_producto,
|
||||
// cantidad: it.cantidad,
|
||||
// pre_unitario: it.pre_unitario
|
||||
// }));
|
||||
|
||||
// await Promise.all(payloads.map(p => jpost('/api/table/deta_comandas', p)));
|
||||
|
||||
// state.carrito = [];
|
||||
// renderCarrito();
|
||||
// $('#obs').value = '';
|
||||
// toast(`Comanda #${id_comanda} creada`, true);
|
||||
// } catch (e) {
|
||||
// toast(e.message || 'No se pudo crear la comanda');
|
||||
// }
|
||||
// }
|
||||
|
||||
// GO
|
||||
init().catch(err => toast(err.message || 'Error cargando datos'));
|
||||
</script>
|
||||
@@ -1,532 +0,0 @@
|
||||
<!-- services/manso/src/views/estadoComandas.ejs -->
|
||||
<div class="d-flex align-items-center justify-content-between mb-3">
|
||||
<h1 class="h4 m-0">🧾 Estado de Comandas</h1>
|
||||
<a class="btn btn-sm btn-dark" href="/comandas">➕ Nueva comanda</a>
|
||||
</div>
|
||||
|
||||
<div class="row g-3">
|
||||
<!-- ===== Listado (izquierda) ===== -->
|
||||
<div class="col-12 col-lg-7">
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header d-flex align-items-center">
|
||||
<strong>Listado</strong>
|
||||
<div class="form-check form-switch ms-auto">
|
||||
<input class="form-check-input" type="checkbox" id="soloAbiertas" checked>
|
||||
<label class="form-check-label" for="soloAbiertas">Solo abiertas</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row g-2 align-items-center mb-2">
|
||||
<div class="col">
|
||||
<input id="buscar" type="search" class="form-control" placeholder="Buscar por #, mesa o usuario…">
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<button class="btn btn-outline-secondary" id="limpiar">Limpiar</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="lista" class="table-responsive" style="max-height:62vh; overflow:auto;">
|
||||
<div class="p-3 text-muted">Cargando…</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ===== Detalle (derecha) ===== -->
|
||||
<div class="col-12 col-lg-5">
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header d-flex align-items-center">
|
||||
<strong>Detalle</strong>
|
||||
<span id="detalleEstado" class="badge badge-outline ms-auto">—</span>
|
||||
</div>
|
||||
|
||||
<div class="card-body" id="detalle">
|
||||
<div class="text-muted">Selecciona una comanda para ver el detalle.</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex align-items-center gap-3 p-3 border-top" style="position:sticky; bottom:0; background:#fff;">
|
||||
<div class="small"><span class="text-muted">ID:</span> <strong id="kpiId">—</strong></div>
|
||||
<div class="small"><span class="text-muted">Mesa:</span> <strong id="kpiMesa">—</strong></div>
|
||||
<div class="small"><span class="text-muted">Total:</span> <strong id="kpiTotal">$ 0.00</strong></div>
|
||||
<div class="ms-auto"></div>
|
||||
<button class="btn btn-outline-secondary" id="reabrir">Reabrir</button>
|
||||
<button class="btn btn-primary" id="cerrar">Cerrar</button>
|
||||
</div>
|
||||
|
||||
<div class="card-body">
|
||||
<div id="msg" class="text-muted small"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// ===== Helpers =====
|
||||
const $ = (s, r=document) => r.querySelector(s);
|
||||
const $$ = (s, r=document) => Array.from(r.querySelectorAll(s));
|
||||
const money = (n) => (isNaN(n) ? '—' : new Intl.NumberFormat('es-UY', { style:'currency', currency:'UYU' }).format(Number(n)));
|
||||
const toast = (msg, ok=false) => {
|
||||
const el = $('#msg');
|
||||
el.className = ok ? 'text-success small' : 'text-danger small';
|
||||
el.textContent = msg;
|
||||
setTimeout(()=>{ el.textContent=''; el.className='text-muted small'; }, 3000);
|
||||
};
|
||||
const badgeClass = (estadoRaw) => {
|
||||
const s = String(estadoRaw||'').toLowerCase();
|
||||
if (s.includes('abier')) return 'badge badge-outline badge-estado-abierta';
|
||||
if (s.includes('pagad') || s.includes('paga')) return 'badge badge-outline badge-estado-pagada';
|
||||
if (s.includes('cerr')) return 'badge badge-outline badge-estado-cerrada';
|
||||
if (s.includes('anul') || s.includes('cancel')) return 'badge badge-outline badge-estado-anulada';
|
||||
return 'badge badge-outline';
|
||||
};
|
||||
|
||||
async function jget(url){
|
||||
const res = await fetch(url);
|
||||
const data = await res.json().catch(()=>null);
|
||||
if (!res.ok) throw new Error(data?.error || `${res.status} ${res.statusText}`);
|
||||
return data;
|
||||
}
|
||||
async function jpost(url, body){
|
||||
const res = await fetch(url, { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(body ?? {}) });
|
||||
const data = await res.json().catch(()=>null);
|
||||
if (!res.ok) throw new Error(data?.error || `${res.status} ${res.statusText}`);
|
||||
return data;
|
||||
}
|
||||
|
||||
// ===== Estado =====
|
||||
const state = {
|
||||
filtro: '',
|
||||
soloAbiertas: true,
|
||||
lista: [], // [{ id_comanda, mesa_numero, mesa_apodo, usuario_nombre, usuario_apellido, fec_creacion, estado, items, total }]
|
||||
sel: null, // id seleccionado
|
||||
detalle: [] // [{ id_det_comanda, producto_nombre, cantidad, pre_unitario, subtotal, observaciones }]
|
||||
};
|
||||
|
||||
// ===== Data =====
|
||||
async function loadLista() {
|
||||
const estado = state.soloAbiertas ? 'abierta' : '';
|
||||
const url = estado ? `/api/comandas?estado=${encodeURIComponent(estado)}&limit=300` : '/api/comandas?limit=300';
|
||||
const rows = await jget(url);
|
||||
state.lista = Array.isArray(rows) ? rows : [];
|
||||
renderLista();
|
||||
}
|
||||
|
||||
async function loadDetalle(id) {
|
||||
const det = await jget(`/api/comandas/${id}/detalle`);
|
||||
state.detalle = Array.isArray(det) ? det : [];
|
||||
renderDetalle();
|
||||
}
|
||||
|
||||
// ===== UI: Lista =====
|
||||
function renderLista(){
|
||||
let rows = state.lista.slice();
|
||||
const f = state.filtro?.trim().toLowerCase();
|
||||
if (f) {
|
||||
rows = rows.filter(r =>
|
||||
String(r.id_comanda).includes(f) ||
|
||||
String(r.mesa_numero ?? '').includes(f) ||
|
||||
(`${r.usuario_nombre||''} ${r.usuario_apellido||''}`).toLowerCase().includes(f)
|
||||
);
|
||||
}
|
||||
|
||||
const box = $('#lista');
|
||||
if (!rows.length) { box.innerHTML = '<div class="p-3 text-muted">Sin resultados.</div>'; return; }
|
||||
|
||||
const tbl = document.createElement('table');
|
||||
tbl.className = 'table table-sm align-middle mb-0';
|
||||
tbl.innerHTML = `
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>Mesa</th>
|
||||
<th>Usuario</th>
|
||||
<th>Fecha</th>
|
||||
<th>Estado</th>
|
||||
<th class="text-end">Ítems</th>
|
||||
<th class="text-end">Total</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody></tbody>
|
||||
`;
|
||||
const tb = tbl.querySelector('tbody');
|
||||
|
||||
rows.forEach(r => {
|
||||
const tr = document.createElement('tr');
|
||||
tr.style.cursor = 'pointer';
|
||||
tr.innerHTML = `
|
||||
<td>${r.id_comanda}</td>
|
||||
<td>#${r.mesa_numero ?? '—'} ${r.mesa_apodo ? '· '+r.mesa_apodo : ''}</td>
|
||||
<td>${(r.usuario_nombre||'') + ' ' + (r.usuario_apellido||'')}</td>
|
||||
<td>${r.fec_creacion ? new Date(r.fec_creacion).toLocaleString() : '—'}</td>
|
||||
<td><span class="${badgeClass(r.estado)}">${r.estado ?? '—'}</span></td>
|
||||
<td class="text-end">${r.items ?? '—'}</td>
|
||||
<td class="text-end">${money(r.total ?? 0)}</td>
|
||||
`;
|
||||
tr.addEventListener('click', () => { state.sel = r.id_comanda; loadDetalle(r.id_comanda); applyHeader(r); });
|
||||
tb.appendChild(tr);
|
||||
});
|
||||
|
||||
box.innerHTML = '';
|
||||
box.appendChild(tbl);
|
||||
}
|
||||
|
||||
// ===== UI: Detalle + KPIs =====
|
||||
function applyHeader(r){
|
||||
$('#kpiId').textContent = r.id_comanda ?? '—';
|
||||
$('#kpiMesa').textContent = r.mesa_numero ? `#${r.mesa_numero}` : '—';
|
||||
$('#detalleEstado').className = badgeClass(r.estado);
|
||||
$('#detalleEstado').textContent = r.estado ?? '—';
|
||||
$('#kpiTotal').textContent = money(r.total ?? 0);
|
||||
|
||||
// Botones (más precisos según estado)
|
||||
const cerr = $('#cerrar'), reab = $('#reabrir');
|
||||
const s = String(r.estado||'').toLowerCase();
|
||||
if (s.includes('abier')) {
|
||||
cerr.disabled = false; cerr.title = '';
|
||||
reab.disabled = true; reab.title = 'Ya está abierta';
|
||||
} else if (s.includes('cerr')) {
|
||||
cerr.disabled = true; cerr.title = 'Ya está cerrada';
|
||||
reab.disabled = false; reab.title = '';
|
||||
} else {
|
||||
// Otros estados: permitir ambas acciones
|
||||
cerr.disabled = false; cerr.title = '';
|
||||
reab.disabled = false; reab.title = '';
|
||||
}
|
||||
}
|
||||
|
||||
function renderDetalle(){
|
||||
const box = $('#detalle');
|
||||
if (!state.detalle.length) {
|
||||
box.innerHTML = '<div class="text-muted">Sin detalle.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
const tbl = document.createElement('table');
|
||||
tbl.className = 'table table-sm align-middle mb-0';
|
||||
tbl.innerHTML = `
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Producto</th>
|
||||
<th class="text-end">Unitario</th>
|
||||
<th class="text-end">Cantidad</th>
|
||||
<th class="text-end">Subtotal</th>
|
||||
<th>Observaciones</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody></tbody>
|
||||
`;
|
||||
const tb = tbl.querySelector('tbody');
|
||||
|
||||
let total = 0;
|
||||
state.detalle.forEach(r => {
|
||||
const sub = Number(r.subtotal || (Number(r.pre_unitario||0) * Number(r.cantidad||0)));
|
||||
total += sub;
|
||||
const tr = document.createElement('tr');
|
||||
tr.innerHTML = `
|
||||
<td>${r.producto_nombre ?? '—'}</td>
|
||||
<td class="text-end">${money(r.pre_unitario)}</td>
|
||||
<td class="text-end">${Number(r.cantidad||0).toFixed(3)}</td>
|
||||
<td class="text-end">${money(sub)}</td>
|
||||
<td>${r.observaciones || ''}</td>
|
||||
`;
|
||||
tb.appendChild(tr);
|
||||
});
|
||||
|
||||
box.innerHTML = '';
|
||||
box.appendChild(tbl);
|
||||
$('#kpiTotal').textContent = money(total);
|
||||
}
|
||||
|
||||
// ===== Actions (usa /abrir y /cerrar) =====
|
||||
async function accionComanda(accion){ // 'abrir' | 'cerrar'
|
||||
if (!state.sel) return;
|
||||
try {
|
||||
await jpost(`/api/comandas/${state.sel}/${accion}`, {}); // el body no se usa en el backend
|
||||
toast(`Comanda #${state.sel} ${accion === 'abrir' ? 'reabierta' : 'cerrada'}`, true);
|
||||
|
||||
// Recargar lista y re-aplicar cabecera/detalle si sigue visible
|
||||
const id = state.sel;
|
||||
await loadLista();
|
||||
const found = state.lista.find(x => x.id_comanda === id);
|
||||
if (found) {
|
||||
applyHeader(found);
|
||||
await loadDetalle(found.id_comanda);
|
||||
} else {
|
||||
// Puede desaparecer del listado si está activado "Solo abiertas" y la cerramos
|
||||
state.sel = null;
|
||||
$('#detalle').innerHTML = '<div class="text-muted">Selecciona una comanda para ver el detalle.</div>';
|
||||
$('#detalleEstado').textContent = '—'; $('#detalleEstado').className = 'badge badge-outline';
|
||||
$('#kpiId').textContent = '—'; $('#kpiMesa').textContent='—'; $('#kpiTotal').textContent = money(0);
|
||||
}
|
||||
} catch (e) {
|
||||
toast(e.message || 'No se pudo actualizar la comanda');
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Hooks con Sidebar (offcanvas) =====
|
||||
window.scRefreshList = loadLista;
|
||||
window.scExportCsv = function(){
|
||||
const rows = state.lista.slice();
|
||||
const header = ["id_comanda","mesa_numero","mesa_apodo","usuario","fec_creacion","estado","items","total"];
|
||||
const csv = [header.join(",")].concat(rows.map(r => {
|
||||
const usuario = `${r.usuario_nombre||''} ${r.usuario_apellido||''}`.trim();
|
||||
const vals = [
|
||||
r.id_comanda,
|
||||
r.mesa_numero ?? '',
|
||||
(r.mesa_apodo ?? '').replaceAll('"','""'),
|
||||
usuario.replaceAll('"','""'),
|
||||
r.fec_creacion ?? '',
|
||||
r.estado ?? '',
|
||||
r.items ?? '',
|
||||
r.total ?? ''
|
||||
];
|
||||
return vals.map(v => `"${String(v).replaceAll('"','""')}"`).join(",");
|
||||
})).join("\n");
|
||||
const blob = new Blob([csv], {type:"text/csv;charset=utf-8"});
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = Object.assign(document.createElement("a"), {href:url, download:`estadoComandas_${new Date().toISOString().slice(0,10)}.csv`});
|
||||
document.body.appendChild(a); a.click(); a.remove(); URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
// ===== Init =====
|
||||
$('#soloAbiertas').addEventListener('change', async (e) => { state.soloAbiertas = e.target.checked; await loadLista(); });
|
||||
$('#buscar').addEventListener('input', () => { state.filtro = $('#buscar').value.trim(); renderLista(); });
|
||||
$('#limpiar').addEventListener('click', () => { $('#buscar').value=''; state.filtro=''; renderLista(); });
|
||||
|
||||
// Ahora los botones llaman a los nuevos endpoints
|
||||
$('#cerrar').addEventListener('click', () => accionComanda('cerrar'));
|
||||
$('#reabrir').addEventListener('click', () => accionComanda('abrir'));
|
||||
|
||||
(async function main(){ try { await loadLista(); } catch(e){ toast(e.message||'Error cargando comandas'); }})();
|
||||
</script>
|
||||
|
||||
|
||||
<!-- <script>
|
||||
// ===== Helpers =====
|
||||
const $ = (s, r=document) => r.querySelector(s);
|
||||
const $$ = (s, r=document) => Array.from(r.querySelectorAll(s));
|
||||
const money = (n) => (isNaN(n) ? '—' : new Intl.NumberFormat('es-UY', { style:'currency', currency:'UYU' }).format(Number(n)));
|
||||
const toast = (msg, ok=false) => {
|
||||
const el = $('#msg');
|
||||
el.className = ok ? 'text-success small' : 'text-danger small';
|
||||
el.textContent = msg;
|
||||
setTimeout(()=>{ el.textContent=''; el.className='text-muted small'; }, 3000);
|
||||
};
|
||||
const badgeClass = (estadoRaw) => {
|
||||
const s = String(estadoRaw||'').toLowerCase();
|
||||
if (s.includes('abier')) return 'badge badge-outline badge-estado-abierta';
|
||||
if (s.includes('pagad') || s.includes('paga')) return 'badge badge-outline badge-estado-pagada';
|
||||
if (s.includes('cerr')) return 'badge badge-outline badge-estado-cerrada';
|
||||
if (s.includes('anul') || s.includes('cancel')) return 'badge badge-outline badge-estado-anulada';
|
||||
return 'badge badge-outline';
|
||||
};
|
||||
|
||||
async function jget(url){
|
||||
const res = await fetch(url);
|
||||
const data = await res.json().catch(()=>null);
|
||||
if (!res.ok) throw new Error(data?.error || `${res.status} ${res.statusText}`);
|
||||
return data;
|
||||
}
|
||||
async function jpost(url, body){
|
||||
const res = await fetch(url, { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(body) });
|
||||
const data = await res.json().catch(()=>null);
|
||||
if (!res.ok) throw new Error(data?.error || `${res.status} ${res.statusText}`);
|
||||
return data;
|
||||
}
|
||||
|
||||
// ===== Estado =====
|
||||
const state = {
|
||||
filtro: '',
|
||||
soloAbiertas: true,
|
||||
lista: [], // [{ id_comanda, mesa_numero, mesa_apodo, usuario_nombre, usuario_apellido, fec_creacion, estado, items, total }]
|
||||
sel: null, // id seleccionado
|
||||
detalle: [] // [{ id_det_comanda, producto_nombre, cantidad, pre_unitario, subtotal, observaciones }]
|
||||
};
|
||||
|
||||
// ===== Data =====
|
||||
async function loadLista() {
|
||||
const estado = state.soloAbiertas ? 'abierta' : '';
|
||||
const url = estado ? `/api/comandas?estado=${encodeURIComponent(estado)}&limit=300` : '/api/comandas?limit=300';
|
||||
const rows = await jget(url);
|
||||
state.lista = Array.isArray(rows) ? rows : [];
|
||||
renderLista();
|
||||
}
|
||||
|
||||
async function loadDetalle(id) {
|
||||
const det = await jget(`/api/comandas/${id}/detalle`);
|
||||
state.detalle = Array.isArray(det) ? det : [];
|
||||
renderDetalle();
|
||||
}
|
||||
|
||||
// ===== UI: Lista =====
|
||||
function renderLista(){
|
||||
let rows = state.lista.slice();
|
||||
const f = state.filtro?.trim().toLowerCase();
|
||||
if (f) {
|
||||
rows = rows.filter(r =>
|
||||
String(r.id_comanda).includes(f) ||
|
||||
String(r.mesa_numero ?? '').includes(f) ||
|
||||
(`${r.usuario_nombre||''} ${r.usuario_apellido||''}`).toLowerCase().includes(f)
|
||||
);
|
||||
}
|
||||
|
||||
const box = $('#lista');
|
||||
if (!rows.length) { box.innerHTML = '<div class="p-3 text-muted">Sin resultados.</div>'; return; }
|
||||
|
||||
const tbl = document.createElement('table');
|
||||
tbl.className = 'table table-sm align-middle mb-0';
|
||||
tbl.innerHTML = `
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>Mesa</th>
|
||||
<th>Usuario</th>
|
||||
<th>Fecha</th>
|
||||
<th>Estado</th>
|
||||
<th class="text-end">Ítems</th>
|
||||
<th class="text-end">Total</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody></tbody>
|
||||
`;
|
||||
const tb = tbl.querySelector('tbody');
|
||||
|
||||
rows.forEach(r => {
|
||||
const tr = document.createElement('tr');
|
||||
tr.style.cursor = 'pointer';
|
||||
tr.innerHTML = `
|
||||
<td>${r.id_comanda}</td>
|
||||
<td>#${r.mesa_numero ?? '—'} ${r.mesa_apodo ? '· '+r.mesa_apodo : ''}</td>
|
||||
<td>${(r.usuario_nombre||'') + ' ' + (r.usuario_apellido||'')}</td>
|
||||
<td>${r.fec_creacion ? new Date(r.fec_creacion).toLocaleString() : '—'}</td>
|
||||
<td><span class="${badgeClass(r.estado)}">${r.estado ?? '—'}</span></td>
|
||||
<td class="text-end">${r.items ?? '—'}</td>
|
||||
<td class="text-end">${money(r.total ?? 0)}</td>
|
||||
`;
|
||||
tr.addEventListener('click', () => { state.sel = r.id_comanda; loadDetalle(r.id_comanda); applyHeader(r); });
|
||||
tb.appendChild(tr);
|
||||
});
|
||||
|
||||
box.innerHTML = '';
|
||||
box.appendChild(tbl);
|
||||
}
|
||||
|
||||
// ===== UI: Detalle + KPIs =====
|
||||
function applyHeader(r){
|
||||
$('#kpiId').textContent = r.id_comanda ?? '—';
|
||||
$('#kpiMesa').textContent = r.mesa_numero ? `#${r.mesa_numero}` : '—';
|
||||
$('#detalleEstado').className = badgeClass(r.estado);
|
||||
$('#detalleEstado').textContent = r.estado ?? '—';
|
||||
$('#kpiTotal').textContent = money(r.total ?? 0);
|
||||
|
||||
// Botones
|
||||
const cerr = $('#cerrar'), reab = $('#reabrir');
|
||||
if ((r.estado||'').toLowerCase().includes('abier')) {
|
||||
cerr.disabled = false; cerr.title = '';
|
||||
reab.disabled = true; reab.title = 'Ya está abierta';
|
||||
} else {
|
||||
cerr.disabled = false;
|
||||
reab.disabled = false;
|
||||
cerr.title = ''; reab.title = '';
|
||||
}
|
||||
}
|
||||
|
||||
function renderDetalle(){
|
||||
const box = $('#detalle');
|
||||
if (!state.detalle.length) {
|
||||
box.innerHTML = '<div class="text-muted">Sin detalle.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
const tbl = document.createElement('table');
|
||||
tbl.className = 'table table-sm align-middle mb-0';
|
||||
tbl.innerHTML = `
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Producto</th>
|
||||
<th class="text-end">Unitario</th>
|
||||
<th class="text-end">Cantidad</th>
|
||||
<th class="text-end">Subtotal</th>
|
||||
<th>Observaciones</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody></tbody>
|
||||
`;
|
||||
const tb = tbl.querySelector('tbody');
|
||||
|
||||
let total = 0;
|
||||
state.detalle.forEach(r => {
|
||||
const sub = Number(r.subtotal || (Number(r.pre_unitario||0) * Number(r.cantidad||0)));
|
||||
total += sub;
|
||||
const tr = document.createElement('tr');
|
||||
tr.innerHTML = `
|
||||
<td>${r.producto_nombre ?? '—'}</td>
|
||||
<td class="text-end">${money(r.pre_unitario)}</td>
|
||||
<td class="text-end">${Number(r.cantidad||0).toFixed(3)}</td>
|
||||
<td class="text-end">${money(sub)}</td>
|
||||
<td>${r.observaciones || ''}</td>
|
||||
`;
|
||||
tb.appendChild(tr);
|
||||
});
|
||||
|
||||
box.innerHTML = '';
|
||||
box.appendChild(tbl);
|
||||
$('#kpiTotal').textContent = money(total);
|
||||
}
|
||||
|
||||
// ===== Actions =====
|
||||
async function setEstado(estado){
|
||||
if (!state.sel) return;
|
||||
try {
|
||||
const { updated } = await jpost(`/api/comandas/${state.sel}/estado`, { estado });
|
||||
toast(`Comanda #${updated.id_comanda} → ${updated.estado}`, true);
|
||||
await loadLista();
|
||||
const found = state.lista.find(x => x.id_comanda === updated.id_comanda);
|
||||
if (found) { applyHeader(found); await loadDetalle(found.id_comanda); }
|
||||
else {
|
||||
state.sel = null;
|
||||
$('#detalle').innerHTML = '<div class="text-muted">Selecciona una comanda para ver el detalle.</div>';
|
||||
$('#detalleEstado').textContent = '—'; $('#detalleEstado').className = 'badge badge-outline';
|
||||
$('#kpiId').textContent = '—'; $('#kpiMesa').textContent='—'; $('#kpiTotal').textContent = money(0);
|
||||
}
|
||||
} catch (e) {
|
||||
toast(e.message || 'No se pudo cambiar el estado');
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Hooks con Sidebar (offcanvas) =====
|
||||
// Permite que el botón "Actualizar" del sidebar recargue este listado
|
||||
window.scRefreshList = loadLista;
|
||||
// Exportación simple del listado actual
|
||||
window.scExportCsv = function(){
|
||||
const rows = state.lista.slice();
|
||||
const header = ["id_comanda","mesa_numero","mesa_apodo","usuario","fec_creacion","estado","items","total"];
|
||||
const csv = [header.join(",")].concat(rows.map(r => {
|
||||
const usuario = `${r.usuario_nombre||''} ${r.usuario_apellido||''}`.trim();
|
||||
const vals = [
|
||||
r.id_comanda,
|
||||
r.mesa_numero ?? '',
|
||||
(r.mesa_apodo ?? '').replaceAll('"','""'),
|
||||
usuario.replaceAll('"','""'),
|
||||
r.fec_creacion ?? '',
|
||||
r.estado ?? '',
|
||||
r.items ?? '',
|
||||
r.total ?? ''
|
||||
];
|
||||
return vals.map(v => `"${String(v).replaceAll('"','""')}"`).join(",");
|
||||
})).join("\n");
|
||||
const blob = new Blob([csv], {type:"text/csv;charset=utf-8"});
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = Object.assign(document.createElement("a"), {href:url, download:`estadoComandas_${new Date().toISOString().slice(0,10)}.csv`});
|
||||
document.body.appendChild(a); a.click(); a.remove(); URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
// ===== Init =====
|
||||
$('#soloAbiertas').addEventListener('change', async (e) => { state.soloAbiertas = e.target.checked; await loadLista(); });
|
||||
$('#buscar').addEventListener('input', () => { state.filtro = $('#buscar').value.trim(); renderLista(); });
|
||||
$('#limpiar').addEventListener('click', () => { $('#buscar').value=''; state.filtro=''; renderLista(); });
|
||||
$('#cerrar').addEventListener('click', () => setEstado('cerrada'));
|
||||
$('#reabrir').addEventListener('click', () => setEstado('abierta'));
|
||||
|
||||
(async function main(){ try { await loadLista(); } catch(e){ toast(e.message||'Error cargando comandas'); }})();
|
||||
</script> -->
|
||||
@@ -1,158 +0,0 @@
|
||||
<!-- views/inicio_v2.ejs -->
|
||||
<!doctype html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta http-equiv="x-ua-compatible" content="IE=edge" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<title>Inicio • SuiteCoffee</title>
|
||||
<style>
|
||||
:root { --bg:#0b0b0c; --card:#141519; --text:#e7e9ee; --muted:#a5acb8; --accent:#6ee7b7; --border:#232733; }
|
||||
* { box-sizing: border-box; }
|
||||
body { margin:0; font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, Noto Sans, Helvetica Neue, Arial, "Apple Color Emoji","Segoe UI Emoji"; background: var(--bg); color: var(--text); }
|
||||
.wrap { max-width: 960px; margin: 48px auto; padding: 0 20px; }
|
||||
.card { background: var(--card); border: 1px solid var(--border); border-radius: 16px; padding: 20px; box-shadow: 0 10px 30px rgba(0,0,0,.25); }
|
||||
h1 { font-size: 26px; margin: 0 0 8px; }
|
||||
p.lead { color: var(--muted); margin: 0 0 20px; }
|
||||
h2 { margin: 24px 0 10px; font-size: 18px; color: var(--accent); }
|
||||
table { width: 100%; border-collapse: collapse; overflow: hidden; border-radius: 12px; border: 1px solid var(--border); }
|
||||
th, td { padding: 10px 12px; text-align: left; border-bottom: 1px solid var(--border); vertical-align: top; }
|
||||
th { color: var(--muted); font-weight: 600; letter-spacing: .02em; }
|
||||
tbody tr:last-child td { border-bottom: 0; }
|
||||
code, pre { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; }
|
||||
.grid { display: grid; gap: 18px; grid-template-columns: 1fr; }
|
||||
@media (min-width: 900px) { .grid { grid-template-columns: 1fr 1fr; } }
|
||||
.muted { color: var(--muted); }
|
||||
.pill { display:inline-block; padding:2px 8px; border:1px solid var(--border); border-radius:999px; color:var(--muted); font-size:12px; }
|
||||
.k { color:#93c5fd; }
|
||||
.v { color:#fca5a5; word-break: break-all; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<div class="card">
|
||||
<%
|
||||
// Espera que el backend pase: { user, cookies, session }
|
||||
const hasUser = typeof user !== 'undefined' && user;
|
||||
const hasCookies = typeof cookies !== 'undefined' && cookies && Object.keys(cookies).length;
|
||||
const hasSession = typeof session !== 'undefined' && session && Object.keys(session).length;
|
||||
|
||||
const displayName =
|
||||
(hasUser && (user.name || user.displayName || user.email)) ||
|
||||
(hasCookies && (cookies.user_name || cookies.displayName || cookies.email)) ||
|
||||
(hasSession && (session.user?.email || session.user?.name)) ||
|
||||
'usuario';
|
||||
%>
|
||||
|
||||
<h1>Hola, <span><%= displayName %></span> 👋</h1>
|
||||
<p class="lead">Bienvenido a SuiteCoffee. Este es tu inicio y panel de diagnóstico de cookies/sesión.</p>
|
||||
|
||||
<% if (hasUser) { %>
|
||||
<h2>Sesión de Aplicación (user)</h2>
|
||||
<table>
|
||||
<tbody>
|
||||
<% for (const [k,v] of Object.entries(user)) { %>
|
||||
<tr>
|
||||
<th><code class="k"><%= k %></code></th>
|
||||
<td><code class="v"><%= typeof v === 'object' ? JSON.stringify(v) : String(v) %></code></td>
|
||||
</tr>
|
||||
<% } %>
|
||||
</tbody>
|
||||
</table>
|
||||
<% } %>
|
||||
|
||||
<% if (hasSession) { %>
|
||||
<h2>Sesión Express (req.session)</h2>
|
||||
<table>
|
||||
<tbody>
|
||||
<% for (const [k,v] of Object.entries(session)) { %>
|
||||
<tr>
|
||||
<th><code class="k"><%= k %></code></th>
|
||||
<td><code class="v"><%= typeof v === 'object' ? JSON.stringify(v) : String(v) %></code></td>
|
||||
</tr>
|
||||
<% } %>
|
||||
</tbody>
|
||||
</table>
|
||||
<% } %>
|
||||
|
||||
<div class="grid" style="margin-top:18px;">
|
||||
<section class="card">
|
||||
<h2>Cookies (servidor: <code>req.cookies</code>)</h2>
|
||||
<% if (hasCookies) { %>
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Nombre</th><th>Valor</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<% for (const [name, value] of Object.entries(cookies)) { %>
|
||||
<tr>
|
||||
<td><code class="k"><%= name %></code></td>
|
||||
<td><code class="v"><%= typeof value === 'object' ? JSON.stringify(value) : String(value) %></code></td>
|
||||
</tr>
|
||||
<% } %>
|
||||
</tbody>
|
||||
</table>
|
||||
<% } else { %>
|
||||
<p class="muted">
|
||||
No se recibieron cookies del lado servidor (<code>req.cookies</code>).
|
||||
Asegurate de usar <code>cookie-parser</code> y de pasar <code>cookies</code> al render:
|
||||
<br /><code>res.render('inicio_v2', { user: req.session.user, cookies: req.cookies, session: req.session })</code>
|
||||
</p>
|
||||
<% } %>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>Cookies (navegador: <code>document.cookie</code>)</h2>
|
||||
<table id="client-cookies">
|
||||
<thead>
|
||||
<tr><th>Nombre</th><th>Valor</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td colspan="2" class="muted">Cargando…</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p class="muted" style="margin-top:10px;">
|
||||
Total cookies en navegador: <span id="cookie-count">0</span>
|
||||
</p>
|
||||
<p class="muted" style="margin-top:10px;">Raw <code>document.cookie</code>:</p>
|
||||
<pre id="cookie-raw" class="muted" style="white-space: pre-wrap; word-break: break-all;"></pre>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
try {
|
||||
const tbody = document.querySelector('#client-cookies tbody');
|
||||
const raw = document.cookie || '';
|
||||
document.getElementById('cookie-raw').textContent = raw || '(sin cookies)';
|
||||
const pairs = raw ? raw.split(/;\s*/) : [];
|
||||
document.getElementById('cookie-count').textContent = pairs.length;
|
||||
|
||||
if (!pairs.length) {
|
||||
tbody.innerHTML = '<tr><td colspan="2"><em class="muted">sin cookies</em></td></tr>';
|
||||
return;
|
||||
}
|
||||
tbody.innerHTML = '';
|
||||
for (const kv of pairs) {
|
||||
const i = kv.indexOf('=');
|
||||
const name = i >= 0 ? kv.slice(0, i) : kv;
|
||||
const value = i >= 0 ? kv.slice(i + 1) : '';
|
||||
const tr = document.createElement('tr');
|
||||
const td1 = document.createElement('td');
|
||||
const td2 = document.createElement('td');
|
||||
td1.innerHTML = '<code class="k"></code>';
|
||||
td2.innerHTML = '<code class="v"></code>';
|
||||
td1.querySelector('code').textContent = decodeURIComponent(name);
|
||||
td2.querySelector('code').textContent = decodeURIComponent(value);
|
||||
tr.append(td1, td2);
|
||||
tbody.appendChild(tr);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('cookie render error:', err);
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,130 +0,0 @@
|
||||
<!-- views/inicio.ejs -->
|
||||
<!doctype html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta http-equiv="x-ua-compatible" content="IE=edge" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<title>Inicio • SuiteCoffee</title>
|
||||
<style>
|
||||
:root { --bg:#0b0b0c; --card:#141519; --text:#e7e9ee; --muted:#a5acb8; --accent:#6ee7b7; --border:#232733; }
|
||||
* { box-sizing: border-box; }
|
||||
body { margin:0; font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, Noto Sans, Helvetica Neue, Arial, "Apple Color Emoji","Segoe UI Emoji"; background: var(--bg); color: var(--text); }
|
||||
.wrap { max-width: 960px; margin: 48px auto; padding: 0 20px; }
|
||||
.card { background: var(--card); border: 1px solid var(--border); border-radius: 16px; padding: 20px; box-shadow: 0 10px 30px rgba(0,0,0,.25); }
|
||||
h1 { font-size: 26px; margin: 0 0 8px; }
|
||||
p.lead { color: var(--muted); margin: 0 0 20px; }
|
||||
h2 { margin: 24px 0 10px; font-size: 18px; color: var(--accent); }
|
||||
table { width: 100%; border-collapse: collapse; overflow: hidden; border-radius: 12px; border: 1px solid var(--border); }
|
||||
th, td { padding: 10px 12px; text-align: left; border-bottom: 1px solid var(--border); vertical-align: top; }
|
||||
th { color: var(--muted); font-weight: 600; letter-spacing: .02em; }
|
||||
tbody tr:last-child td { border-bottom: 0; }
|
||||
code, pre { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; }
|
||||
.grid { display: grid; gap: 18px; grid-template-columns: 1fr; }
|
||||
@media (min-width: 900px) { .grid { grid-template-columns: 1fr 1fr; } }
|
||||
.muted { color: var(--muted); }
|
||||
.pill { display:inline-block; padding:2px 8px; border:1px solid var(--border); border-radius:999px; color:var(--muted); font-size:12px; }
|
||||
.k { color:#93c5fd; }
|
||||
.v { color:#fca5a5; word-break: break-all; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<div class="card">
|
||||
<%
|
||||
const hasUser = typeof user !== 'undefined' && user;
|
||||
const hasCookies = typeof cookies !== 'undefined' && cookies && Object.keys(cookies).length;
|
||||
const displayName =
|
||||
(hasUser && (user.name || user.displayName || user.email)) ||
|
||||
(hasCookies && (cookies.user_name || cookies.displayName || cookies.email)) ||
|
||||
'usuario';
|
||||
%>
|
||||
<h1>Hola, <span><%= displayName %></span> 👋</h1>
|
||||
<p class="lead">Bienvenido a SuiteCoffee. Este es tu inicio.</p>
|
||||
|
||||
<% if (hasUser) { %>
|
||||
<h2>Sesión</h2>
|
||||
<table>
|
||||
<tbody>
|
||||
<% for (const [k,v] of Object.entries(user)) { %>
|
||||
<tr>
|
||||
<th><code class="k"><%= k %></code></th>
|
||||
<td><code class="v"><%= typeof v === 'object' ? JSON.stringify(v) : String(v) %></code></td>
|
||||
</tr>
|
||||
<% } %>
|
||||
</tbody>
|
||||
</table>
|
||||
<% } %>
|
||||
|
||||
<div class="grid" style="margin-top:18px;">
|
||||
<section class="card">
|
||||
<h2>Cookies (servidor)</h2>
|
||||
<% if (hasCookies) { %>
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Nombre</th><th>Valor</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<% for (const [name, value] of Object.entries(cookies)) { %>
|
||||
<tr>
|
||||
<td><code class="k"><%= name %></code></td>
|
||||
<td><code class="v"><%= typeof value === 'object' ? JSON.stringify(value) : String(value) %></code></td>
|
||||
</tr>
|
||||
<% } %>
|
||||
</tbody>
|
||||
</table>
|
||||
<% } else { %>
|
||||
<p class="muted">No se recibieron cookies desde el servidor (req.cookies). ¿Estás usando <code>cookie-parser</code> o pasando <code>cookies</code> al render?</p>
|
||||
<% } %>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>Cookies (navegador)</h2>
|
||||
<table id="client-cookies">
|
||||
<thead>
|
||||
<tr><th>Nombre</th><th>Valor</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td colspan="2" class="muted">Cargando…</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p class="muted" style="margin-top:10px;">Raw <code>document.cookie</code>:</p>
|
||||
<pre id="cookie-raw" class="muted" style="white-space: pre-wrap; word-break: break-all;"></pre>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
try {
|
||||
const tbody = document.querySelector('#client-cookies tbody');
|
||||
const raw = document.cookie || '';
|
||||
document.getElementById('cookie-raw').textContent = raw || '(sin cookies)';
|
||||
const pairs = raw ? raw.split(/;\s*/) : [];
|
||||
if (!pairs.length) {
|
||||
tbody.innerHTML = '<tr><td colspan="2"><em class="muted">sin cookies</em></td></tr>';
|
||||
return;
|
||||
}
|
||||
tbody.innerHTML = '';
|
||||
for (const kv of pairs) {
|
||||
const i = kv.indexOf('=');
|
||||
const name = i >= 0 ? kv.slice(0, i) : kv;
|
||||
const value = i >= 0 ? kv.slice(i + 1) : '';
|
||||
const tr = document.createElement('tr');
|
||||
const td1 = document.createElement('td');
|
||||
const td2 = document.createElement('td');
|
||||
td1.innerHTML = '<code class="k"></code>';
|
||||
td2.innerHTML = '<code class="v"></code>';
|
||||
td1.querySelector('code').textContent = decodeURIComponent(name);
|
||||
td2.querySelector('code').textContent = decodeURIComponent(value);
|
||||
tr.append(td1, td2);
|
||||
tbody.appendChild(tr);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('cookie render error:', err);
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,366 @@
|
||||
<!-- views/inicio.ejs -->
|
||||
<!doctype html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta http-equiv="x-ua-compatible" content="IE=edge" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<title>Inicio • SuiteCoffee</title>
|
||||
<style>
|
||||
:root, [data-theme="dark"] {
|
||||
--bg:#0b0b0c; --card:#141519; --text:#e7e9ee; --muted:#a5acb8; --accent:#6ee7b7; --border:#232733;
|
||||
}
|
||||
[data-theme="light"] {
|
||||
--bg:#f7f8fb; --card:#ffffff; --text:#0b0b0c; --muted:#5b6472; --accent:#0ea5e9; --border:#e6e8ee;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body { margin:0; font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, Noto Sans, Helvetica Neue, Arial, "Apple Color Emoji","Segoe UI Emoji"; background: var(--bg); color: var(--text); }
|
||||
.wrap { max-width: 960px; margin: 48px auto; padding: 0 20px; }
|
||||
.card { background: var(--card); border: 1px solid var(--border); border-radius: 16px; padding: 20px; box-shadow: 0 10px 30px rgba(0,0,0,.25); }
|
||||
h1 { font-size: 26px; margin: 0 0 8px; }
|
||||
p.lead { color: var(--muted); margin: 0 0 20px; }
|
||||
h2 { margin: 24px 0 10px; font-size: 18px; color: var(--accent); }
|
||||
table { width: 100%; border-collapse: collapse; overflow: hidden; border-radius: 12px; border: 1px solid var(--border); }
|
||||
th, td { padding: 10px 12px; text-align: left; border-bottom: 1px solid var(--border); vertical-align: top; }
|
||||
th { color: var(--muted); font-weight: 600; letter-spacing: .02em; }
|
||||
tbody tr:last-child td { border-bottom: 0; }
|
||||
code, pre { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; }
|
||||
.grid { display: grid; gap: 18px; grid-template-columns: 1fr; }
|
||||
@media (min-width: 900px) { .grid { grid-template-columns: 1fr 1fr; } }
|
||||
.muted { color: var(--muted); }
|
||||
.pill { display:inline-block; padding:2px 8px; border:1px solid var(--border); border-radius:999px; color:var(--muted); font-size:12px; }
|
||||
.k { color:#93c5fd; }
|
||||
.v { color:#fca5a5; word-break: break-all; }
|
||||
</style>
|
||||
</head>
|
||||
<%
|
||||
// ============ SERVIDOR ============ //
|
||||
// Espera: { user, cookies, session } (pásalos en res.render)
|
||||
|
||||
const hasUser = typeof user !== 'undefined' && user;
|
||||
const hasCookies = typeof cookies !== 'undefined' && cookies && Object.keys(cookies).length;
|
||||
const hasSession = typeof session !== 'undefined' && session && Object.keys(session).length;
|
||||
|
||||
const cookieKeys = hasCookies ? Object.keys(cookies) : [];
|
||||
const sidKey = cookieKeys.find(k => /^(sc\.sid|connect\.sid|.*sid|.*sessionid)$/i.test(k)) || null;
|
||||
const sidVal = sidKey ? String(cookies[sidKey] ?? '') : null;
|
||||
const sidValMasked = sidVal ? (sidVal.length > 20 ? (sidVal.slice(0, 12) + '…' + sidVal.slice(-6)) : sidVal) : '(sin valor)';
|
||||
const sessionId = (hasSession && session.id) || (typeof sidVal === 'string' ? '(derivado de cookie)' : '(no disponible)');
|
||||
|
||||
const displayName =
|
||||
(hasUser && (user.name || user.displayName || user.email)) ||
|
||||
(hasCookies && (cookies.user_name || cookies.displayName || cookies.email)) ||
|
||||
(hasSession && (session.user?.email || session.user?.name)) ||
|
||||
'usuario';
|
||||
|
||||
// ---- Detección de cookie de configuración (servidor)
|
||||
const tenantId32 = hasUser && user.default_tenant ? String(user.default_tenant).toLowerCase() : null;
|
||||
const cfgRe = /^(?:sc\.)?(config|prefs|ui)(?:\.([0-9a-f]{32}))?$/i;
|
||||
|
||||
function pickConfigCookieName(keys, tenant) {
|
||||
const matches = keys
|
||||
.map(k => [k, k.match(cfgRe)])
|
||||
.filter(([, m]) => !!m);
|
||||
if (!matches.length) return null;
|
||||
if (tenant) {
|
||||
const exact = matches.find(([, m]) => (m[2] || '').toLowerCase() === tenant);
|
||||
if (exact) return exact[0];
|
||||
}
|
||||
return matches[0][0];
|
||||
}
|
||||
|
||||
const configCookieKey = hasCookies ? pickConfigCookieName(cookieKeys, tenantId32) : null;
|
||||
const rawConfigCookie = configCookieKey ? cookies[configCookieKey] : null;
|
||||
|
||||
function tryParseConfig(val) {
|
||||
if (!val) return null;
|
||||
const candidates = [];
|
||||
try { candidates.push(String(val)); } catch {}
|
||||
try { candidates.push(decodeURIComponent(String(val))); } catch {}
|
||||
try { candidates.push(Buffer.from(String(val), 'base64').toString('utf8')); } catch {}
|
||||
|
||||
for (const c of candidates) {
|
||||
try {
|
||||
const obj = JSON.parse(c);
|
||||
if (obj && typeof obj === 'object') return obj;
|
||||
} catch (_) {}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const configFromCookie = tryParseConfig(rawConfigCookie);
|
||||
|
||||
// Tema inicial (si la cookie define theme: 'light' | 'dark')
|
||||
const initialTheme = (configFromCookie && typeof configFromCookie.theme === 'string')
|
||||
? (configFromCookie.theme.toLowerCase() === 'light' ? 'light' : 'dark')
|
||||
: 'dark';
|
||||
|
||||
// ====== Normalización de "user" para evitar duplicados ======
|
||||
const preferredOrder = ['sub','email','user_id','name','default_tenant','memberships'];
|
||||
const normalizedUser = {};
|
||||
if (hasUser) {
|
||||
// Tomamos valores canónicos
|
||||
normalizedUser.sub = user.sub ?? null;
|
||||
normalizedUser.email = user.email ?? null;
|
||||
normalizedUser.user_id = user.user_id ?? user.userId ?? null;
|
||||
normalizedUser.name = user.name ?? user.displayName ?? null;
|
||||
|
||||
// Unificar current_tenant/currentTenant -> default_tenant si éste no viene
|
||||
const fallbackTenant = user.current_tenant ?? user.currentTenant ?? null;
|
||||
normalizedUser.default_tenant = user.default_tenant ?? fallbackTenant ?? null;
|
||||
|
||||
if (Array.isArray(user.memberships)) normalizedUser.memberships = user.memberships;
|
||||
|
||||
// Extras: todo lo demás excepto duplicados y legacy
|
||||
const skip = new Set([
|
||||
...preferredOrder,
|
||||
'current_tenant','currentTenant',
|
||||
'user_uid_nodash','userUidNoDash'
|
||||
]);
|
||||
const extras = Object.entries(user)
|
||||
.filter(([k]) => !skip.has(k) && !/nodash/i.test(k));
|
||||
// Los adjuntamos en un objeto aparte para mostrarlos (si existieran)
|
||||
normalizedUser.__extras = Object.fromEntries(extras);
|
||||
}
|
||||
%>
|
||||
<body data-theme="<%= initialTheme %>">
|
||||
<div class="wrap">
|
||||
<div class="card">
|
||||
<h1>Hola, <span><%= displayName %></span> 👋</h1>
|
||||
<p class="lead">Bienvenido a SuiteCoffee. Este inicio lee la <strong>cookie de configuraciones actuales</strong> (servidor y navegador) y aplica el tema.</p>
|
||||
|
||||
<!-- Bloque mínimo para ver sessionID y cookie de sesión -->
|
||||
<h2>Diagnóstico de Sesión</h2>
|
||||
<table>
|
||||
<tbody>
|
||||
<tr>
|
||||
<th><code class="k">req.sessionID</code></th>
|
||||
<td><code class="v"><%= typeof req !== 'undefined' && req.sessionID ? req.sessionID : sessionId %></code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th><code class="k">Cookie de sesión</code></th>
|
||||
<td>
|
||||
<% if (sidKey) { %>
|
||||
<code class="v"><%= sidKey %></code>
|
||||
<div class="muted" style="margin-top:6px;">valor: <code class="v"><%= sidValMasked %></code></div>
|
||||
<% } else { %>
|
||||
<em class="muted">No detectada en <code>req.cookies</code> (ej. <code>sc.sid</code>).</em>
|
||||
<% } %>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<% if (hasUser) { %>
|
||||
<h2>Sesión de Aplicación (user)</h2>
|
||||
<table>
|
||||
<tbody>
|
||||
<% for (const key of preferredOrder) {
|
||||
const v = normalizedUser[key];
|
||||
if (typeof v === 'undefined' || v === null) continue;
|
||||
%>
|
||||
<tr>
|
||||
<th><code class="k"><%= key %></code></th>
|
||||
<td><code class="v"><%= typeof v === 'object' ? JSON.stringify(v) : String(v) %></code></td>
|
||||
</tr>
|
||||
<% } %>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<% if (normalizedUser.__extras && Object.keys(normalizedUser.__extras).length) { %>
|
||||
<h3 style="margin-top:14px;">Otros campos</h3>
|
||||
<table>
|
||||
<thead><tr><th>Campo</th><th>Valor</th></tr></thead>
|
||||
<tbody>
|
||||
<% for (const [k,v] of Object.entries(normalizedUser.__extras)) { %>
|
||||
<tr>
|
||||
<th><code class="k"><%= k %></code></th>
|
||||
<td><code class="v"><%= typeof v === 'object' ? JSON.stringify(v) : String(v) %></code></td>
|
||||
</tr>
|
||||
<% } %>
|
||||
</tbody>
|
||||
</table>
|
||||
<% } %>
|
||||
<% } %>
|
||||
|
||||
<!-- Configuraciones actuales (desde cookie, lado servidor) -->
|
||||
<h2>Configuraciones actuales (cookie servidor)</h2>
|
||||
<table>
|
||||
<tbody>
|
||||
<tr>
|
||||
<th><code class="k">Cookie detectada</code></th>
|
||||
<td>
|
||||
<% if (configCookieKey) { %>
|
||||
<code class="v"><%= configCookieKey %></code>
|
||||
<% if (tenantId32) { %>
|
||||
<span class="pill" style="margin-left:6px;">tenant=<%= tenantId32 %></span>
|
||||
<% } %>
|
||||
<% } else { %>
|
||||
<em class="muted">No se encontró cookie de configuración (busco: <code>sc.config</code>, <code>sc.prefs</code>, <code>sc.ui</code> o con sufijo <code>.{tenantId32}</code>).</em>
|
||||
<% } %>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th><code class="k">Tema aplicado</code></th>
|
||||
<td><code class="v"><%= initialTheme %></code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th><code class="k">Contenido parseado</code></th>
|
||||
<td>
|
||||
<% if (configFromCookie) { %>
|
||||
<pre class="muted" style="white-space: pre-wrap; word-break: break-all;"><%= JSON.stringify(configFromCookie, null, 2) %></pre>
|
||||
<% } else if (configCookieKey) { %>
|
||||
<em class="muted">No fue posible parsear JSON. Valor crudo:</em>
|
||||
<pre class="muted" style="white-space: pre-wrap; word-break: break-all;"><%= String(rawConfigCookie).slice(0, 2000) %></pre>
|
||||
<% } else { %>
|
||||
<em class="muted">—</em>
|
||||
<% } %>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div class="grid" style="margin-top:18px;">
|
||||
<section class="card">
|
||||
<h2>Cookies (servidor: <code>req.cookies</code>)</h2>
|
||||
<% if (hasCookies) { %>
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Nombre</th><th>Valor</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<% for (const [name, value] of Object.entries(cookies)) { %>
|
||||
<tr>
|
||||
<td><code class="k"><%= name %></code></td>
|
||||
<td><code class="v"><%= typeof value === 'object' ? JSON.stringify(value) : String(value) %></code></td>
|
||||
</tr>
|
||||
<% } %>
|
||||
</tbody>
|
||||
</table>
|
||||
<% } else { %>
|
||||
<p class="muted">
|
||||
No se recibieron cookies del lado servidor (<code>req.cookies</code>).
|
||||
Asegurate de usar <code>cookie-parser</code> y de pasar <code>cookies</code> al render:
|
||||
<br /><code>res.render('inicio', { user: req.session.user, cookies: req.cookies, session: req.session })</code>
|
||||
</p>
|
||||
<% } %>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>Cookies (navegador) + Config</h2>
|
||||
<table id="client-cookies">
|
||||
<thead>
|
||||
<tr><th>Nombre</th><th>Valor</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td colspan="2" class="muted">Cargando…</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<p class="muted" style="margin-top:10px;">
|
||||
Total cookies en navegador: <span id="cookie-count">0</span>
|
||||
</p>
|
||||
|
||||
<h3 style="margin-top:14px;">Config detectada (navegador)</h3>
|
||||
<p class="muted">Nombre: <code id="cfg-name-browser">(buscando…)</code></p>
|
||||
<pre id="cfg-json-browser" class="muted" style="white-space: pre-wrap; word-break: break-all;">(sin config)</pre>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script id="__sc_boot__" type="application/json">
|
||||
<%- JSON.stringify({ user: (typeof user !== 'undefined' ? user : null) }) %>
|
||||
</script>
|
||||
|
||||
|
||||
<script>
|
||||
// ============ CLIENTE ============ //
|
||||
(function () {
|
||||
try {
|
||||
const tbody = document.querySelector('#client-cookies tbody');
|
||||
const raw = document.cookie || '';
|
||||
const pairs = raw ? raw.split(/;\s*/) : [];
|
||||
document.getElementById('cookie-count').textContent = pairs.length;
|
||||
|
||||
if (!pairs.length) {
|
||||
tbody.innerHTML = '<tr><td colspan="2"><em class="muted">sin cookies (las httpOnly no aparecen en el navegador)</em></td></tr>';
|
||||
} else {
|
||||
tbody.innerHTML = '';
|
||||
for (const kv of pairs) {
|
||||
const i = kv.indexOf('=');
|
||||
const name = i >= 0 ? kv.slice(0, i) : kv;
|
||||
const value = i >= 0 ? kv.slice(i + 1) : '';
|
||||
const tr = document.createElement('tr');
|
||||
const td1 = document.createElement('td');
|
||||
const td2 = document.createElement('td');
|
||||
td1.innerHTML = '<code class="k"></code>';
|
||||
td2.innerHTML = '<code class="v"></code>';
|
||||
td1.querySelector('code').textContent = decodeURIComponent(name);
|
||||
td2.querySelector('code').textContent = decodeURIComponent(value);
|
||||
tr.append(td1, td2);
|
||||
tbody.appendChild(tr);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('cookie render error:', err);
|
||||
}
|
||||
|
||||
function findConfigCookieName(tenantId32) {
|
||||
const re = /^(?:sc\.)?(config|prefs|ui)(?:\.([0-9a-f]{32}))?$/i;
|
||||
const list = (document.cookie || '')
|
||||
.split(/;\s*/)
|
||||
.map(s => s.split('=').shift());
|
||||
if (tenantId32) {
|
||||
const exact = list.find(n => {
|
||||
const m = n.match(re);
|
||||
return m && (m[2] || '').toLowerCase() === String(tenantId32).toLowerCase();
|
||||
});
|
||||
if (exact) return exact;
|
||||
}
|
||||
return list.find(n => re.test(n)) || null;
|
||||
}
|
||||
|
||||
function getCookie(name) {
|
||||
if (!name) return null;
|
||||
const m = document.cookie.match(new RegExp('(?:^|; )' + name.replace(/([.$?*|{}()[\]\\/+^])/g, '\\$1') + '=([^;]*)'));
|
||||
return m ? decodeURIComponent(m[1]) : null;
|
||||
}
|
||||
|
||||
function parseConfig(val) {
|
||||
if (!val) return null;
|
||||
const candidates = [val];
|
||||
try { candidates.push(decodeURIComponent(val)); } catch {}
|
||||
try { candidates.push(atob(val)); } catch {}
|
||||
for (const c of candidates) {
|
||||
try { const obj = JSON.parse(c); if (obj && typeof obj === 'object') return obj; } catch {}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Tenant del usuario embebido por el servidor (si existe)
|
||||
// const serverUser = <%- JSON.stringify((typeof user !== 'undefined' ? user : null)) %>;
|
||||
const bootNode = document.getElementById('__sc_boot__');
|
||||
const boot = bootNode ? JSON.parse(bootNode.textContent || 'null') : null;
|
||||
const serverUser = boot && typeof boot === 'object' ? boot.user ?? null : null;
|
||||
const tenantId32 = serverUser && serverUser.default_tenant ? String(serverUser.default_tenant).toLowerCase() : null;
|
||||
|
||||
// Detectar/leer cookie de configuración (lado navegador)
|
||||
const cfgName = findConfigCookieName(tenantId32);
|
||||
document.getElementById('cfg-name-browser').textContent = cfgName || '(no encontrada)';
|
||||
const cfgRaw = getCookie(cfgName);
|
||||
const cfg = parseConfig(cfgRaw);
|
||||
|
||||
if (cfg) {
|
||||
document.getElementById('cfg-json-browser').textContent = JSON.stringify(cfg, null, 2);
|
||||
if (typeof cfg.theme === 'string') {
|
||||
const theme = String(cfg.theme).toLowerCase() === 'light' ? 'light' : 'dark';
|
||||
document.body.setAttribute('data-theme', theme);
|
||||
}
|
||||
} else {
|
||||
document.getElementById('cfg-json-browser').textContent = '(sin config o no parseable)';
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,84 @@
|
||||
// services/app/src/views/routes.js
|
||||
|
||||
import { Router } from 'express';
|
||||
import { requireAuth } from '@suitecoffee/middlewares';
|
||||
|
||||
const SESSION_NAME = process.env.SESSION_NAME;
|
||||
|
||||
const router = Router();
|
||||
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// Rutas de UI
|
||||
// ----------------------------------------------------------
|
||||
|
||||
router.get('/', async (req, res) => {
|
||||
// combinamos cookies comunes + firmadas (signed se “desfirma”: queda el SID limpio)
|
||||
res.locals.pageTitle = "Inicio2"; // Título de pestaña
|
||||
res.locals.pageId = "inicio"; // Sidebar contextual
|
||||
const mergedCookies = { ...(req.cookies || {}), ...(req.signedCookies || {}) };
|
||||
|
||||
res.render('inicio', {
|
||||
user: req.session?.user ?? null,
|
||||
session: req.session ?? {},
|
||||
cookies: mergedCookies, // <-- lo que la vista va a leer
|
||||
cookieName: SESSION_NAME, // <-- para no hardcodear 'sc.sid'
|
||||
});
|
||||
});
|
||||
/*
|
||||
router.get('/comandas', (req, res) => {
|
||||
res.locals.pageTitle = "Comandas";
|
||||
res.locals.pageId = "comandas";
|
||||
res.render("./pages/comandas");
|
||||
// res.json({ ok: true, route: '/comandas' });
|
||||
});
|
||||
*/
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Rutas de testeo de Cookies
|
||||
// -----------------------------------------------------------------------------
|
||||
/*
|
||||
router.get('/inicio', requireAuth(), async (req, res) => {
|
||||
res.locals.pageTitle = "Inicio2"; // Título de pestaña
|
||||
res.locals.pageId = "inicio"; // Sidebar contextual
|
||||
res.render("inicio", {
|
||||
user: req.session?.user ?? null,
|
||||
cookies: req.cookies ?? {},
|
||||
session: req.session ?? {},
|
||||
req, // para que el EJS pueda usar req.sessionID si quiere
|
||||
});
|
||||
});
|
||||
*/
|
||||
|
||||
router.get('/inicio', requireAuth(), async (req, res) => {
|
||||
// combinamos cookies comunes + firmadas (signed se “desfirma”: queda el SID limpio)
|
||||
res.locals.pageTitle = "Inicio2"; // Título de pestaña
|
||||
res.locals.pageId = "inicio"; // Sidebar contextual
|
||||
const mergedCookies = { ...(req.cookies || {}), ...(req.signedCookies || {}) };
|
||||
|
||||
res.render('inicio', {
|
||||
user: req.session?.user ?? null,
|
||||
session: req.session ?? {},
|
||||
cookies: mergedCookies, // <-- lo que la vista va a leer
|
||||
cookieName: SESSION_NAME, // <-- para no hardcodear 'sc.sid'
|
||||
});
|
||||
});
|
||||
|
||||
router.get('/debug/tenant', (req, res) => {
|
||||
res.json({
|
||||
sid: req.sessionID ?? null,
|
||||
hasSession: !!req.session,
|
||||
user: req.session?.user
|
||||
? {
|
||||
sub: req.session.user.sub ?? null,
|
||||
email: req.session.user.email ?? null,
|
||||
default_tenant: req.session.user.default_tenant ?? req.session.user.defaultTenant ?? null,
|
||||
}
|
||||
: null,
|
||||
tenant: req.session?.tenant ?? null,
|
||||
reqTenant: req.tenant ?? null,
|
||||
accept: req.headers.accept,
|
||||
});
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -1,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/
|
||||
# =======================================================
|
||||
@@ -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
|
||||
Generated
+60
@@ -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",
|
||||
|
||||
@@ -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": ""
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
@@ -1,181 +0,0 @@
|
||||
// // ----------------------------------------------------------
|
||||
// // API
|
||||
// // ----------------------------------------------------------
|
||||
// app.get('/api/tables', async (_req, res) => {
|
||||
// res.json(ALLOWED_TABLES);
|
||||
// });
|
||||
|
||||
// app.get('/api/schema/:table', async (req, res) => {
|
||||
// try {
|
||||
// const table = ensureTable(req.params.table);
|
||||
// const client = await getClient();
|
||||
// try {
|
||||
// const columns = await loadColumns(client, table);
|
||||
// const fks = await loadForeignKeys(client, table);
|
||||
// const enriched = columns.map(c => ({ ...c, foreign: fks[c.column_name] || null }));
|
||||
// res.json({ table, columns: enriched });
|
||||
// } finally { client.release(); }
|
||||
// } catch (e) {
|
||||
// res.status(400).json({ error: e.message });
|
||||
// }
|
||||
// });
|
||||
|
||||
// app.get('/api/options/:table/:column', async (req, res) => {
|
||||
// try {
|
||||
// const table = ensureTable(req.params.table);
|
||||
// const column = req.params.column;
|
||||
// if (!VALID_IDENT.test(column)) throw new Error('Columna inválida');
|
||||
|
||||
// const client = await getClient();
|
||||
// try {
|
||||
// const fks = await loadForeignKeys(client, table);
|
||||
// const fk = fks[column];
|
||||
// if (!fk) return res.json([]);
|
||||
|
||||
// const refTable = fk.foreign_table;
|
||||
// const refId = fk.foreign_column;
|
||||
// const labelCol = await pickLabelColumn(client, refTable);
|
||||
|
||||
// const sql = `SELECT ${q(refId)} AS id, ${q(labelCol)} AS label FROM ${q(refTable)} ORDER BY ${q(labelCol)} LIMIT 1000`;
|
||||
// const result = await client.query(sql);
|
||||
// res.json(result.rows);
|
||||
// } finally { client.release(); }
|
||||
// } catch (e) {
|
||||
// res.status(400).json({ error: e.message });
|
||||
// }
|
||||
// });
|
||||
|
||||
// app.get('/api/table/:table', async (req, res) => {
|
||||
// try {
|
||||
// const table = ensureTable(req.params.table);
|
||||
// const limit = Math.min(parseInt(req.query.limit || '100', 10), 1000);
|
||||
// const client = await getClient();
|
||||
// try {
|
||||
// const pks = await loadPrimaryKey(client, table);
|
||||
// const orderBy = pks.length ? `ORDER BY ${pks.map(q).join(', ')} DESC` : '';
|
||||
// const sql = `SELECT * FROM ${q(table)} ${orderBy} LIMIT ${limit}`;
|
||||
// const result = await client.query(sql);
|
||||
|
||||
// // Normalizar: siempre devolver objetos {col: valor}
|
||||
// const colNames = result.fields.map(f => f.name);
|
||||
// let rows = result.rows;
|
||||
// if (rows.length && Array.isArray(rows[0])) {
|
||||
// rows = rows.map(r => Object.fromEntries(r.map((v, i) => [colNames[i], v])));
|
||||
// }
|
||||
// res.json(rows);
|
||||
// } finally { client.release(); }
|
||||
// } catch (e) {
|
||||
// res.status(400).json({ error: e.message, code: e.code, detail: e.detail });
|
||||
// }
|
||||
// });
|
||||
|
||||
// app.post('/api/table/:table', async (req, res) => {
|
||||
// const table = ensureTable(req.params.table);
|
||||
// const payload = req.body || {};
|
||||
// try {
|
||||
// const client = await getClient();
|
||||
// try {
|
||||
// const columns = await loadColumns(client, table);
|
||||
// const insertable = columns.filter(c =>
|
||||
// !c.is_primary && !c.is_identity && !(c.column_default || '').startsWith('nextval(')
|
||||
// );
|
||||
// const allowedCols = new Set(insertable.map(c => c.column_name));
|
||||
|
||||
// const cols = [];
|
||||
// const vals = [];
|
||||
// const params = [];
|
||||
// let idx = 1;
|
||||
// for (const [k, v] of Object.entries(payload)) {
|
||||
// if (!allowedCols.has(k)) continue;
|
||||
// if (!VALID_IDENT.test(k)) continue;
|
||||
// cols.push(q(k));
|
||||
// vals.push(`$${idx++}`);
|
||||
// params.push(v);
|
||||
// }
|
||||
|
||||
// if (!cols.length) {
|
||||
// const { rows } = await client.query(`INSERT INTO ${q(table)} DEFAULT VALUES RETURNING *`);
|
||||
// res.status(201).json({ inserted: rows[0] });
|
||||
// } else {
|
||||
// const { rows } = await client.query(
|
||||
// `INSERT INTO ${q(table)} (${cols.join(', ')}) VALUES (${vals.join(', ')}) RETURNING *`,
|
||||
// params
|
||||
// );
|
||||
// res.status(201).json({ inserted: rows[0] });
|
||||
// }
|
||||
// } catch (e) {
|
||||
// if (e.code === '23503') return res.status(400).json({ error: 'Violación de clave foránea', detail: e.detail });
|
||||
// if (e.code === '23505') return res.status(400).json({ error: 'Violación de unicidad', detail: e.detail });
|
||||
// if (e.code === '23514') return res.status(400).json({ error: 'Violación de CHECK', detail: e.detail });
|
||||
// if (e.code === '23502') return res.status(400).json({ error: 'Campo NOT NULL faltante', detail: e.detail });
|
||||
// throw e;
|
||||
// }
|
||||
// } catch (e) {
|
||||
// res.status(400).json({ error: e.message });
|
||||
// }
|
||||
// });
|
||||
|
||||
// app.get('/api/comandas', async (req, res, next) => {
|
||||
// try {
|
||||
// const estado = (req.query.estado || '').trim() || null;
|
||||
// const limit = Math.min(parseInt(req.query.limit || '200', 10), 1000);
|
||||
|
||||
// const { rows } = await mainPool.query(
|
||||
// `SELECT * FROM public.f_comandas_resumen($1, $2)`,
|
||||
// [estado, limit]
|
||||
// );
|
||||
// res.json(rows);
|
||||
// } catch (e) { next(e); }
|
||||
// });
|
||||
|
||||
|
||||
// // Detalle de una comanda (con nombres de productos)
|
||||
// // GET /api/comandas/:id/detalle
|
||||
// app.get('/api/comandas/:id/detalle', (req, res, next) =>
|
||||
// mainPool.query(
|
||||
// `SELECT id_det_comanda, id_producto, producto_nombre,
|
||||
// cantidad, pre_unitario, subtotal, observaciones
|
||||
// FROM public.v_comandas_detalle_items
|
||||
// WHERE id_comanda = $1::int
|
||||
// ORDER BY id_det_comanda`,
|
||||
// [req.params.id]
|
||||
// )
|
||||
// .then(r => res.json(r.rows))
|
||||
// .catch(next)
|
||||
// );
|
||||
|
||||
// // Cerrar comanda (setea estado y fec_cierre en DB)
|
||||
// app.post('/api/comandas/:id/cerrar', async (req, res, next) => {
|
||||
// try {
|
||||
// const id = Number(req.params.id);
|
||||
// if (!Number.isInteger(id) || id <= 0) {
|
||||
// return res.status(400).json({ error: 'id inválido' });
|
||||
// }
|
||||
// const { rows } = await mainPool.query(
|
||||
// `SELECT public.f_cerrar_comanda($1) AS data`,
|
||||
// [id]
|
||||
// );
|
||||
// if (!rows.length || rows[0].data === null) {
|
||||
// return res.status(404).json({ error: 'Comanda no encontrada' });
|
||||
// }
|
||||
// res.json(rows[0].data);
|
||||
// } catch (err) { next(err); }
|
||||
// });
|
||||
|
||||
// Abrir (reabrir) comanda
|
||||
app.post('/api/comandas/:id/abrir', async (req, res, next) => {
|
||||
try {
|
||||
const id = Number(req.params.id);
|
||||
if (!Number.isInteger(id) || id <= 0) {
|
||||
return res.status(400).json({ error: 'id inválido' });
|
||||
}
|
||||
const { rows } = await mainPool.query(
|
||||
`SELECT public.f_abrir_comanda($1) AS data`,
|
||||
[id]
|
||||
);
|
||||
if (!rows.length || rows[0].data === null) {
|
||||
return res.status(404).json({ error: 'Comanda no encontrada' });
|
||||
}
|
||||
res.json(rows[0].data);
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
@@ -1,230 +0,0 @@
|
||||
// // GET producto + receta
|
||||
// app.get('/api/rpc/get_producto/:id', async (req, res) => {
|
||||
// const id = Number(req.params.id);
|
||||
// const { rows } = await mainPool.query('SELECT public.get_producto($1) AS data', [id]);
|
||||
// res.json(rows[0]?.data || {});
|
||||
// });
|
||||
|
||||
// // POST guardar producto + receta
|
||||
|
||||
// app.post('/api/rpc/save_producto', async (req, res) => {
|
||||
// try {
|
||||
// // console.debug('receta payload:', req.body?.receta); // habilitalo si lo necesitás
|
||||
// const q = 'SELECT public.save_producto($1,$2,$3,$4,$5,$6,$7::jsonb) AS id_producto';
|
||||
// const { id_producto = null, nombre, img_producto = null, precio = 0, activo = true, id_categoria = null, receta = [] } = req.body || {};
|
||||
// const params = [id_producto, nombre, img_producto, precio, activo, id_categoria, JSON.stringify(receta || [])];
|
||||
// const { rows } = await mainPool.query(q, params);
|
||||
// res.json(rows[0] || {});
|
||||
// } catch (e) {
|
||||
// console.error(e);
|
||||
// res.status(500).json({ error: 'save_producto failed' });
|
||||
// }
|
||||
// });
|
||||
|
||||
// // GET MP + proveedores
|
||||
// app.get('/api/rpc/get_materia/:id', async (req, res) => {
|
||||
// const id = Number(req.params.id);
|
||||
// try {
|
||||
// const { rows } = await mainPool.query('SELECT public.get_materia_prima($1) AS data', [id]);
|
||||
// res.json(rows[0]?.data || {});
|
||||
// } catch (e) {
|
||||
// console.error(e);
|
||||
// res.status(500).json({ error: 'get_materia failed' });
|
||||
// }
|
||||
// });
|
||||
|
||||
// // SAVE MP + proveedores (array)
|
||||
// app.post('/api/rpc/save_materia', async (req, res) => {
|
||||
// const { id_mat_prima = null, nombre, unidad, activo = true, proveedores = [] } = req.body || {};
|
||||
// try {
|
||||
// const q = 'SELECT public.save_materia_prima($1,$2,$3,$4,$5::jsonb) AS id_mat_prima';
|
||||
// const params = [id_mat_prima, nombre, unidad, activo, JSON.stringify(proveedores || [])];
|
||||
// const { rows } = await mainPool.query(q, params);
|
||||
// res.json(rows[0] || {});
|
||||
// } catch (e) {
|
||||
// console.error(e);
|
||||
// res.status(500).json({ error: 'save_materia failed' });
|
||||
// }
|
||||
// });
|
||||
|
||||
// // POST /api/rpc/find_usuarios_por_documentos { docs: ["12345678","09123456", ...] }
|
||||
// app.post('/api/rpc/find_usuarios_por_documentos', async (req, res) => {
|
||||
// try {
|
||||
// const docs = Array.isArray(req.body?.docs) ? req.body.docs : [];
|
||||
// const sql = 'SELECT public.find_usuarios_por_documentos($1::jsonb) AS data';
|
||||
// const { rows } = await mainPool.query(sql, [JSON.stringify(docs)]);
|
||||
// res.json(rows[0]?.data || {});
|
||||
// } catch (e) {
|
||||
// console.error(e);
|
||||
// res.status(500).json({ error: 'find_usuarios_por_documentos failed' });
|
||||
// }
|
||||
// });
|
||||
|
||||
// // POST /api/rpc/import_asistencia { registros: [...], origen?: "AGL_001.txt" }
|
||||
// app.post('/api/rpc/import_asistencia', async (req, res) => {
|
||||
// try {
|
||||
// const registros = Array.isArray(req.body?.registros) ? req.body.registros : [];
|
||||
// const origen = req.body?.origen || null;
|
||||
// const sql = 'SELECT public.import_asistencia($1::jsonb,$2) AS data';
|
||||
// const { rows } = await mainPool.query(sql, [JSON.stringify(registros), origen]);
|
||||
// res.json(rows[0]?.data || {});
|
||||
// } catch (e) {
|
||||
// console.error(e);
|
||||
// res.status(500).json({ error: 'import_asistencia failed' });
|
||||
// }
|
||||
// });
|
||||
|
||||
// // Consultar datos de asistencia (raw + pares) para un usuario y rango
|
||||
// app.post('/api/rpc/asistencia_get', async (req, res) => {
|
||||
// try {
|
||||
// const { doc, desde, hasta } = req.body || {};
|
||||
// const sql = 'SELECT public.asistencia_get($1::text,$2::date,$3::date) AS data';
|
||||
// const { rows } = await mainPool.query(sql, [doc, desde, hasta]);
|
||||
// res.json(rows[0]?.data || {});
|
||||
// } catch (e) {
|
||||
// console.error(e); res.status(500).json({ error: 'asistencia_get failed' });
|
||||
// }
|
||||
// });
|
||||
|
||||
// // Editar un registro crudo y recalcular pares
|
||||
// app.post('/api/rpc/asistencia_update_raw', async (req, res) => {
|
||||
// try {
|
||||
// const { id_raw, fecha, hora, modo } = req.body || {};
|
||||
// const sql = 'SELECT public.asistencia_update_raw($1::bigint,$2::date,$3::text,$4::text) AS data';
|
||||
// const { rows } = await mainPool.query(sql, [id_raw, fecha, hora, modo ?? null]);
|
||||
// res.json(rows[0]?.data || {});
|
||||
// } catch (e) {
|
||||
// console.error(e); res.status(500).json({ error: 'asistencia_update_raw failed' });
|
||||
// }
|
||||
// });
|
||||
|
||||
// // Eliminar un registro crudo y recalcular pares
|
||||
// app.post('/api/rpc/asistencia_delete_raw', async (req, res) => {
|
||||
// try {
|
||||
// const { id_raw } = req.body || {};
|
||||
// const sql = 'SELECT public.asistencia_delete_raw($1::bigint) AS data';
|
||||
// const { rows } = await mainPool.query(sql, [id_raw]);
|
||||
// res.json(rows[0]?.data || {});
|
||||
// } catch (e) {
|
||||
// console.error(e); res.status(500).json({ error: 'asistencia_delete_raw failed' });
|
||||
// }
|
||||
// });
|
||||
|
||||
// // POST /api/rpc/report_tickets { year }
|
||||
// app.post('/api/rpc/report_tickets', async (req, res) => {
|
||||
// try {
|
||||
// const y = parseInt(req.body?.year ?? req.query?.year, 10);
|
||||
// const year = (Number.isFinite(y) && y >= 2000 && y <= 2100)
|
||||
// ? y
|
||||
// : (new Date()).getFullYear();
|
||||
|
||||
// const { rows } = await mainPool.query(
|
||||
// 'SELECT public.report_tickets_year($1::int) AS j', [year]
|
||||
// );
|
||||
// res.json(rows[0].j);
|
||||
// } catch (e) {
|
||||
// console.error('report_tickets error:', e);
|
||||
// res.status(500).json({
|
||||
// error: 'report_tickets failed',
|
||||
// message: e.message, detail: e.detail, where: e.where, code: e.code
|
||||
// });
|
||||
// }
|
||||
// });
|
||||
|
||||
// // POST /api/rpc/report_asistencia { desde: 'YYYY-MM-DD', hasta: 'YYYY-MM-DD' }
|
||||
// app.post('/api/rpc/report_asistencia', async (req, res) => {
|
||||
// try {
|
||||
// let { desde, hasta } = req.body || {};
|
||||
// // defaults si vienen vacíos/invalidos
|
||||
// const re = /^\d{4}-\d{2}-\d{2}$/;
|
||||
// if (!re.test(desde) || !re.test(hasta)) {
|
||||
// const end = new Date();
|
||||
// const start = new Date(end); start.setDate(end.getDate() - 30);
|
||||
// desde = start.toISOString().slice(0, 10);
|
||||
// hasta = end.toISOString().slice(0, 10);
|
||||
// }
|
||||
|
||||
// const { rows } = await mainPool.query(
|
||||
// 'SELECT public.report_asistencia($1::date,$2::date) AS j', [desde, hasta]
|
||||
// );
|
||||
// res.json(rows[0].j);
|
||||
// } catch (e) {
|
||||
// console.error('report_asistencia error:', e);
|
||||
// res.status(500).json({
|
||||
// error: 'report_asistencia failed',
|
||||
// message: e.message, detail: e.detail, where: e.where, code: e.code
|
||||
// });
|
||||
// }
|
||||
// });
|
||||
|
||||
// // Guardar (insert/update)
|
||||
// app.post('/api/rpc/save_compra', async (req, res) => {
|
||||
// try {
|
||||
// const { id_compra, id_proveedor, fec_compra, detalles } = req.body || {};
|
||||
// const sql = 'SELECT * FROM public.save_compra($1::int,$2::int,$3::timestamptz,$4::jsonb)';
|
||||
// const args = [id_compra ?? null, id_proveedor, fec_compra ? new Date(fec_compra) : null, JSON.stringify(detalles)];
|
||||
// const { rows } = await mainPool.query(sql, args);
|
||||
// res.json(rows[0]); // { id_compra, total }
|
||||
// } catch (e) {
|
||||
// console.error('save_compra error:', e);
|
||||
// res.status(500).json({ error: 'save_compra failed', message: e.message, detail: e.detail, where: e.where, code: e.code });
|
||||
// }
|
||||
// });
|
||||
|
||||
// // Obtener para editar
|
||||
// app.post('/api/rpc/get_compra', async (req, res) => {
|
||||
// try {
|
||||
// const { id_compra } = req.body || {};
|
||||
// const sql = `SELECT public.get_compra($1::int) AS data`;
|
||||
// const { rows } = await mainPool.query(sql, [id_compra]);
|
||||
// res.json(rows[0]?.data || {});
|
||||
// } catch (e) {
|
||||
// console.error(e); res.status(500).json({ error: 'get_compra failed' });
|
||||
// }
|
||||
// });
|
||||
|
||||
// // Eliminar
|
||||
// app.post('/api/rpc/delete_compra', async (req, res) => {
|
||||
// try {
|
||||
// const { id_compra } = req.body || {};
|
||||
// await mainPool.query(`SELECT public.delete_compra($1::int)`, [id_compra]);
|
||||
// res.json({ ok: true });
|
||||
// } catch (e) {
|
||||
// console.error(e); res.status(500).json({ error: 'delete_compra failed' });
|
||||
// }
|
||||
// });
|
||||
|
||||
// // POST /api/rpc/report_gastos { year: 2025 }
|
||||
// app.post('/api/rpc/report_gastos', async (req, res) => {
|
||||
// try {
|
||||
// const year = parseInt(req.body?.year ?? new Date().getFullYear(), 10);
|
||||
// const { rows } = await mainPool.query(
|
||||
// 'SELECT public.report_gastos($1::int) AS j', [year]
|
||||
// );
|
||||
// res.json(rows[0].j);
|
||||
// } catch (e) {
|
||||
// console.error('report_gastos error:', e);
|
||||
// res.status(500).json({
|
||||
// error: 'report_gastos failed',
|
||||
// message: e.message, detail: e.detail, code: e.code
|
||||
// });
|
||||
// }
|
||||
// });
|
||||
|
||||
// // (Opcional) GET para probar rápido desde el navegador:
|
||||
// // /api/rpc/report_gastos?year=2025
|
||||
// app.get('/api/rpc/report_gastos', async (req, res) => {
|
||||
// try {
|
||||
// const year = parseInt(req.query.year ?? new Date().getFullYear(), 10);
|
||||
// const { rows } = await mainPool.query(
|
||||
// 'SELECT public.report_gastos($1::int) AS j', [year]
|
||||
// );
|
||||
// res.json(rows[0].j);
|
||||
// } catch (e) {
|
||||
// console.error('report_gastos error:', e);
|
||||
// res.status(500).json({
|
||||
// error: 'report_gastos failed',
|
||||
// message: e.message, detail: e.detail, code: e.code
|
||||
// });
|
||||
// }
|
||||
// });
|
||||
@@ -1,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
@@ -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
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
Generated
+36
@@ -9,6 +9,9 @@
|
||||
"version": "1.0.0",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@suitecoffee/db": "file:../../packages/core/db",
|
||||
"@suitecoffee/redis": "file:../../packages/core/redis",
|
||||
"@suitecoffee/scripts": "file:../../packages/core/scripts",
|
||||
"bcrypt": "^6.0.0",
|
||||
"chalk": "^5.6.0",
|
||||
"connect-redis": "^9.0.0",
|
||||
@@ -36,6 +39,27 @@
|
||||
"nodemon": "^3.1.10"
|
||||
}
|
||||
},
|
||||
"../../packages/core/db": {
|
||||
"name": "@suitecoffee/db",
|
||||
"version": "1.0.0",
|
||||
"peerDependencies": {
|
||||
"pg": "^8.16.3"
|
||||
}
|
||||
},
|
||||
"../../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",
|
||||
"peerDependencies": {
|
||||
"pg": "^8.16.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@epic-web/invariant": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@epic-web/invariant/-/invariant-1.0.0.tgz",
|
||||
@@ -109,6 +133,18 @@
|
||||
"@redis/client": "^5.8.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@suitecoffee/db": {
|
||||
"resolved": "../../packages/core/db",
|
||||
"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",
|
||||
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
|
||||
|
||||
@@ -15,6 +15,12 @@
|
||||
"nodemon": "^3.1.10"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
"@suitecoffee/db": "file:../../packages/core/db",
|
||||
"@suitecoffee/redis": "file:../../packages/core/redis",
|
||||
"@suitecoffee/scripts": "file:../../packages/core/scripts",
|
||||
"@suitecoffee/middlewares": "file:../../packages/core/middlewares",
|
||||
|
||||
"bcrypt": "^6.0.0",
|
||||
"chalk": "^5.6.0",
|
||||
"connect-redis": "^9.0.0",
|
||||
@@ -39,9 +45,8 @@
|
||||
},
|
||||
"imports": {
|
||||
"#v1Router": "./src/api/v1/routes/routes.js",
|
||||
"#pages": "./src/pages/pages.js",
|
||||
"#db": "./src/db/poolSingleton.js"
|
||||
"#pages": "./src/pages/pages.js"
|
||||
},
|
||||
"keywords": [],
|
||||
"description": ""
|
||||
}
|
||||
}
|
||||
@@ -1,82 +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 };
|
||||
@@ -9,7 +9,9 @@ import 'dotenv/config'; // Variables de Entorno
|
||||
import express from 'express';
|
||||
import expressLayouts from 'express-ejs-layouts';
|
||||
|
||||
import { poolCore, poolTenants } from '#db'; // dbCore y dbTenants
|
||||
import { poolCore, poolTenants, verificarConexionCore, verificarConexionTenants } from '@suitecoffee/db'; // dbCore y dbTenants desde paquete
|
||||
import { redisAuthentik, verificarConexionRedisAuthentik} from '@suitecoffee/redis';
|
||||
import { checkRequiredEnvVars } from '@suitecoffee/scripts';
|
||||
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
@@ -23,35 +25,17 @@ const __dirname = path.dirname(__filename);
|
||||
// Validación de entorno mínimo (ajusta nombres si difieren)
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
// Función para verificar que ciertas variables de entorno estén definida
|
||||
function checkRequiredEnvVars(...requiredKeys) {
|
||||
const missingKeys = requiredKeys.filter((key) => !process.env[key]); // Filtramos las que NO existen en process.env
|
||||
if (missingKeys.length > 0) { // Si falta alguna, mostramos una advertencia
|
||||
console.warn(
|
||||
`[PLUGIN] No se encontraron las siguientes variables de entorno: \n\n-> ${missingKeys.join('\n-> ')}`+
|
||||
`\n`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
checkRequiredEnvVars(
|
||||
'PORT',
|
||||
'CORE_DB_HOST', 'CORE_DB_PORT', 'CORE_DB_NAME',
|
||||
'TENANTS_DB_HOST', 'TENANTS_DB_PORT', 'TENANTS_DB_NAME'
|
||||
'PORT'
|
||||
);
|
||||
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// Variables del sistema
|
||||
// ----------------------------------------------------------
|
||||
const PORT = process.env.PORT;
|
||||
|
||||
const CORE_DB_HOST = process.env.CORE_DB_HOST;
|
||||
const CORE_DB_PORT = process.env.CORE_DB_PORT;
|
||||
const CORE_DB_NAME = process.env.CORE_DB_NAME;
|
||||
|
||||
const TENANTS_DB_HOST = process.env.TENANTS_DB_HOST;
|
||||
const TENANTS_DB_PORT = process.env.TENANTS_DB_PORT;
|
||||
const TENANTS_DB_NAME = process.env.TENANTS_DB_NAME;
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// App + Motor de vistas EJS
|
||||
@@ -72,38 +56,6 @@ app.use(expressLayouts); // Carga los layouts que usara el renderizado
|
||||
app.use(cookieParser(process.env.SESSION_SECRET));
|
||||
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// Verificación de conexión
|
||||
// ----------------------------------------------------------
|
||||
|
||||
async function verificarConexionCore() {
|
||||
try {
|
||||
console.log(`[PLUGINS] Comprobando accesibilidad a la db ${CORE_DB_NAME} del host ${CORE_DB_HOST} ...`);
|
||||
const client = await poolCore.connect();
|
||||
const { rows } = await client.query('SELECT NOW() AS ahora');
|
||||
console.log(`\n[PLUGINS] Conexión con ${CORE_DB_NAME} OK. Hora DB:`, rows[0].ahora);
|
||||
client.release();
|
||||
} catch (error) {
|
||||
console.error('[PLUGINS] Error al conectar con la base de datos al iniciar:', error.message);
|
||||
console.error('[PLUGINS] Revisar credenciales, accesos de red y firewall.');
|
||||
}
|
||||
}
|
||||
async function verificarConexionTenants() {
|
||||
try {
|
||||
console.log(`[PLUGINS] Comprobando accesibilidad a la db ${TENANTS_DB_NAME} del host ${TENANTS_DB_HOST} ...`);
|
||||
const client = await poolTenants.connect();
|
||||
const { rows } = await client.query('SELECT NOW() AS ahora');
|
||||
console.log(`\n[PLUGINS] Conexión con ${TENANTS_DB_NAME} OK. Hora DB:`, rows[0].ahora);
|
||||
client.release();
|
||||
} catch (error) {
|
||||
console.error('[PLUGINS] Error al conectar con la base de datos al iniciar:', error.message);
|
||||
console.error('[PLUGINS] Revisar credenciales, accesos de red y firewall.');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
// ----------------------------------------------------------
|
||||
@@ -123,10 +75,11 @@ app.use((req, res, next) => {
|
||||
// Inicio del servidor
|
||||
// ----------------------------------------------------------
|
||||
|
||||
app.listen(PORT, () => {
|
||||
app.listen(PORT, async () => {
|
||||
console.log(`[PLUGINS] http://localhost:${PORT}`);
|
||||
verificarConexionCore();
|
||||
verificarConexionTenants();
|
||||
await verificarConexionCore();
|
||||
await verificarConexionTenants();
|
||||
await verificarConexionRedisAuthentik();
|
||||
});
|
||||
|
||||
|
||||
@@ -135,6 +88,6 @@ app.listen(PORT, () => {
|
||||
// Healthcheck
|
||||
// -----------------------------------------------------------------------------
|
||||
app.get('/health', (_req, res) => {
|
||||
res.status(200).json({ status: 'ok'}),
|
||||
console.log(`[PLUGINS] Saludable`)
|
||||
res.status(200).json({ status: 'ok'})
|
||||
// console.log(`[PLUGINS] Saludable`)
|
||||
});
|
||||
Reference in New Issue
Block a user