This commit is contained in:
2025-10-16 19:49:50 +00:00
parent ba6b4fef4f
commit c4097bc737
119 changed files with 3765 additions and 14390 deletions
+26 -34
View File
@@ -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/
# =======================================================
-22
View File
@@ -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
+81
View File
@@ -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",
+12 -6
View File
@@ -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": ""
}
-181
View File
@@ -1,181 +0,0 @@
// // ----------------------------------------------------------
// // API
// // ----------------------------------------------------------
// app.get('/api/tables', async (_req, res) => {
// res.json(ALLOWED_TABLES);
// });
// app.get('/api/schema/:table', async (req, res) => {
// try {
// const table = ensureTable(req.params.table);
// const client = await getClient();
// try {
// const columns = await loadColumns(client, table);
// const fks = await loadForeignKeys(client, table);
// const enriched = columns.map(c => ({ ...c, foreign: fks[c.column_name] || null }));
// res.json({ table, columns: enriched });
// } finally { client.release(); }
// } catch (e) {
// res.status(400).json({ error: e.message });
// }
// });
// app.get('/api/options/:table/:column', async (req, res) => {
// try {
// const table = ensureTable(req.params.table);
// const column = req.params.column;
// if (!VALID_IDENT.test(column)) throw new Error('Columna inválida');
// const client = await getClient();
// try {
// const fks = await loadForeignKeys(client, table);
// const fk = fks[column];
// if (!fk) return res.json([]);
// const refTable = fk.foreign_table;
// const refId = fk.foreign_column;
// const labelCol = await pickLabelColumn(client, refTable);
// const sql = `SELECT ${q(refId)} AS id, ${q(labelCol)} AS label FROM ${q(refTable)} ORDER BY ${q(labelCol)} LIMIT 1000`;
// const result = await client.query(sql);
// res.json(result.rows);
// } finally { client.release(); }
// } catch (e) {
// res.status(400).json({ error: e.message });
// }
// });
// app.get('/api/table/:table', async (req, res) => {
// try {
// const table = ensureTable(req.params.table);
// const limit = Math.min(parseInt(req.query.limit || '100', 10), 1000);
// const client = await getClient();
// try {
// const pks = await loadPrimaryKey(client, table);
// const orderBy = pks.length ? `ORDER BY ${pks.map(q).join(', ')} DESC` : '';
// const sql = `SELECT * FROM ${q(table)} ${orderBy} LIMIT ${limit}`;
// const result = await client.query(sql);
// // Normalizar: siempre devolver objetos {col: valor}
// const colNames = result.fields.map(f => f.name);
// let rows = result.rows;
// if (rows.length && Array.isArray(rows[0])) {
// rows = rows.map(r => Object.fromEntries(r.map((v, i) => [colNames[i], v])));
// }
// res.json(rows);
// } finally { client.release(); }
// } catch (e) {
// res.status(400).json({ error: e.message, code: e.code, detail: e.detail });
// }
// });
// app.post('/api/table/:table', async (req, res) => {
// const table = ensureTable(req.params.table);
// const payload = req.body || {};
// try {
// const client = await getClient();
// try {
// const columns = await loadColumns(client, table);
// const insertable = columns.filter(c =>
// !c.is_primary && !c.is_identity && !(c.column_default || '').startsWith('nextval(')
// );
// const allowedCols = new Set(insertable.map(c => c.column_name));
// const cols = [];
// const vals = [];
// const params = [];
// let idx = 1;
// for (const [k, v] of Object.entries(payload)) {
// if (!allowedCols.has(k)) continue;
// if (!VALID_IDENT.test(k)) continue;
// cols.push(q(k));
// vals.push(`$${idx++}`);
// params.push(v);
// }
// if (!cols.length) {
// const { rows } = await client.query(`INSERT INTO ${q(table)} DEFAULT VALUES RETURNING *`);
// res.status(201).json({ inserted: rows[0] });
// } else {
// const { rows } = await client.query(
// `INSERT INTO ${q(table)} (${cols.join(', ')}) VALUES (${vals.join(', ')}) RETURNING *`,
// params
// );
// res.status(201).json({ inserted: rows[0] });
// }
// } catch (e) {
// if (e.code === '23503') return res.status(400).json({ error: 'Violación de clave foránea', detail: e.detail });
// if (e.code === '23505') return res.status(400).json({ error: 'Violación de unicidad', detail: e.detail });
// if (e.code === '23514') return res.status(400).json({ error: 'Violación de CHECK', detail: e.detail });
// if (e.code === '23502') return res.status(400).json({ error: 'Campo NOT NULL faltante', detail: e.detail });
// throw e;
// }
// } catch (e) {
// res.status(400).json({ error: e.message });
// }
// });
// app.get('/api/comandas', async (req, res, next) => {
// try {
// const estado = (req.query.estado || '').trim() || null;
// const limit = Math.min(parseInt(req.query.limit || '200', 10), 1000);
// const { rows } = await mainPool.query(
// `SELECT * FROM public.f_comandas_resumen($1, $2)`,
// [estado, limit]
// );
// res.json(rows);
// } catch (e) { next(e); }
// });
// // Detalle de una comanda (con nombres de productos)
// // GET /api/comandas/:id/detalle
// app.get('/api/comandas/:id/detalle', (req, res, next) =>
// mainPool.query(
// `SELECT id_det_comanda, id_producto, producto_nombre,
// cantidad, pre_unitario, subtotal, observaciones
// FROM public.v_comandas_detalle_items
// WHERE id_comanda = $1::int
// ORDER BY id_det_comanda`,
// [req.params.id]
// )
// .then(r => res.json(r.rows))
// .catch(next)
// );
// // Cerrar comanda (setea estado y fec_cierre en DB)
// app.post('/api/comandas/:id/cerrar', async (req, res, next) => {
// try {
// const id = Number(req.params.id);
// if (!Number.isInteger(id) || id <= 0) {
// return res.status(400).json({ error: 'id inválido' });
// }
// const { rows } = await mainPool.query(
// `SELECT public.f_cerrar_comanda($1) AS data`,
// [id]
// );
// if (!rows.length || rows[0].data === null) {
// return res.status(404).json({ error: 'Comanda no encontrada' });
// }
// res.json(rows[0].data);
// } catch (err) { next(err); }
// });
// Abrir (reabrir) comanda
app.post('/api/comandas/:id/abrir', async (req, res, next) => {
try {
const id = Number(req.params.id);
if (!Number.isInteger(id) || id <= 0) {
return res.status(400).json({ error: 'id inválido' });
}
const { rows } = await mainPool.query(
`SELECT public.f_abrir_comanda($1) AS data`,
[id]
);
if (!rows.length || rows[0].data === null) {
return res.status(404).json({ error: 'Comanda no encontrada' });
}
res.json(rows[0].data);
} catch (err) { next(err); }
});
-230
View File
@@ -1,230 +0,0 @@
// // GET producto + receta
// app.get('/api/rpc/get_producto/:id', async (req, res) => {
// const id = Number(req.params.id);
// const { rows } = await mainPool.query('SELECT public.get_producto($1) AS data', [id]);
// res.json(rows[0]?.data || {});
// });
// // POST guardar producto + receta
// app.post('/api/rpc/save_producto', async (req, res) => {
// try {
// // console.debug('receta payload:', req.body?.receta); // habilitalo si lo necesitás
// const q = 'SELECT public.save_producto($1,$2,$3,$4,$5,$6,$7::jsonb) AS id_producto';
// const { id_producto = null, nombre, img_producto = null, precio = 0, activo = true, id_categoria = null, receta = [] } = req.body || {};
// const params = [id_producto, nombre, img_producto, precio, activo, id_categoria, JSON.stringify(receta || [])];
// const { rows } = await mainPool.query(q, params);
// res.json(rows[0] || {});
// } catch (e) {
// console.error(e);
// res.status(500).json({ error: 'save_producto failed' });
// }
// });
// // GET MP + proveedores
// app.get('/api/rpc/get_materia/:id', async (req, res) => {
// const id = Number(req.params.id);
// try {
// const { rows } = await mainPool.query('SELECT public.get_materia_prima($1) AS data', [id]);
// res.json(rows[0]?.data || {});
// } catch (e) {
// console.error(e);
// res.status(500).json({ error: 'get_materia failed' });
// }
// });
// // SAVE MP + proveedores (array)
// app.post('/api/rpc/save_materia', async (req, res) => {
// const { id_mat_prima = null, nombre, unidad, activo = true, proveedores = [] } = req.body || {};
// try {
// const q = 'SELECT public.save_materia_prima($1,$2,$3,$4,$5::jsonb) AS id_mat_prima';
// const params = [id_mat_prima, nombre, unidad, activo, JSON.stringify(proveedores || [])];
// const { rows } = await mainPool.query(q, params);
// res.json(rows[0] || {});
// } catch (e) {
// console.error(e);
// res.status(500).json({ error: 'save_materia failed' });
// }
// });
// // POST /api/rpc/find_usuarios_por_documentos { docs: ["12345678","09123456", ...] }
// app.post('/api/rpc/find_usuarios_por_documentos', async (req, res) => {
// try {
// const docs = Array.isArray(req.body?.docs) ? req.body.docs : [];
// const sql = 'SELECT public.find_usuarios_por_documentos($1::jsonb) AS data';
// const { rows } = await mainPool.query(sql, [JSON.stringify(docs)]);
// res.json(rows[0]?.data || {});
// } catch (e) {
// console.error(e);
// res.status(500).json({ error: 'find_usuarios_por_documentos failed' });
// }
// });
// // POST /api/rpc/import_asistencia { registros: [...], origen?: "AGL_001.txt" }
// app.post('/api/rpc/import_asistencia', async (req, res) => {
// try {
// const registros = Array.isArray(req.body?.registros) ? req.body.registros : [];
// const origen = req.body?.origen || null;
// const sql = 'SELECT public.import_asistencia($1::jsonb,$2) AS data';
// const { rows } = await mainPool.query(sql, [JSON.stringify(registros), origen]);
// res.json(rows[0]?.data || {});
// } catch (e) {
// console.error(e);
// res.status(500).json({ error: 'import_asistencia failed' });
// }
// });
// // Consultar datos de asistencia (raw + pares) para un usuario y rango
// app.post('/api/rpc/asistencia_get', async (req, res) => {
// try {
// const { doc, desde, hasta } = req.body || {};
// const sql = 'SELECT public.asistencia_get($1::text,$2::date,$3::date) AS data';
// const { rows } = await mainPool.query(sql, [doc, desde, hasta]);
// res.json(rows[0]?.data || {});
// } catch (e) {
// console.error(e); res.status(500).json({ error: 'asistencia_get failed' });
// }
// });
// // Editar un registro crudo y recalcular pares
// app.post('/api/rpc/asistencia_update_raw', async (req, res) => {
// try {
// const { id_raw, fecha, hora, modo } = req.body || {};
// const sql = 'SELECT public.asistencia_update_raw($1::bigint,$2::date,$3::text,$4::text) AS data';
// const { rows } = await mainPool.query(sql, [id_raw, fecha, hora, modo ?? null]);
// res.json(rows[0]?.data || {});
// } catch (e) {
// console.error(e); res.status(500).json({ error: 'asistencia_update_raw failed' });
// }
// });
// // Eliminar un registro crudo y recalcular pares
// app.post('/api/rpc/asistencia_delete_raw', async (req, res) => {
// try {
// const { id_raw } = req.body || {};
// const sql = 'SELECT public.asistencia_delete_raw($1::bigint) AS data';
// const { rows } = await mainPool.query(sql, [id_raw]);
// res.json(rows[0]?.data || {});
// } catch (e) {
// console.error(e); res.status(500).json({ error: 'asistencia_delete_raw failed' });
// }
// });
// // POST /api/rpc/report_tickets { year }
// app.post('/api/rpc/report_tickets', async (req, res) => {
// try {
// const y = parseInt(req.body?.year ?? req.query?.year, 10);
// const year = (Number.isFinite(y) && y >= 2000 && y <= 2100)
// ? y
// : (new Date()).getFullYear();
// const { rows } = await mainPool.query(
// 'SELECT public.report_tickets_year($1::int) AS j', [year]
// );
// res.json(rows[0].j);
// } catch (e) {
// console.error('report_tickets error:', e);
// res.status(500).json({
// error: 'report_tickets failed',
// message: e.message, detail: e.detail, where: e.where, code: e.code
// });
// }
// });
// // POST /api/rpc/report_asistencia { desde: 'YYYY-MM-DD', hasta: 'YYYY-MM-DD' }
// app.post('/api/rpc/report_asistencia', async (req, res) => {
// try {
// let { desde, hasta } = req.body || {};
// // defaults si vienen vacíos/invalidos
// const re = /^\d{4}-\d{2}-\d{2}$/;
// if (!re.test(desde) || !re.test(hasta)) {
// const end = new Date();
// const start = new Date(end); start.setDate(end.getDate() - 30);
// desde = start.toISOString().slice(0, 10);
// hasta = end.toISOString().slice(0, 10);
// }
// const { rows } = await mainPool.query(
// 'SELECT public.report_asistencia($1::date,$2::date) AS j', [desde, hasta]
// );
// res.json(rows[0].j);
// } catch (e) {
// console.error('report_asistencia error:', e);
// res.status(500).json({
// error: 'report_asistencia failed',
// message: e.message, detail: e.detail, where: e.where, code: e.code
// });
// }
// });
// // Guardar (insert/update)
// app.post('/api/rpc/save_compra', async (req, res) => {
// try {
// const { id_compra, id_proveedor, fec_compra, detalles } = req.body || {};
// const sql = 'SELECT * FROM public.save_compra($1::int,$2::int,$3::timestamptz,$4::jsonb)';
// const args = [id_compra ?? null, id_proveedor, fec_compra ? new Date(fec_compra) : null, JSON.stringify(detalles)];
// const { rows } = await mainPool.query(sql, args);
// res.json(rows[0]); // { id_compra, total }
// } catch (e) {
// console.error('save_compra error:', e);
// res.status(500).json({ error: 'save_compra failed', message: e.message, detail: e.detail, where: e.where, code: e.code });
// }
// });
// // Obtener para editar
// app.post('/api/rpc/get_compra', async (req, res) => {
// try {
// const { id_compra } = req.body || {};
// const sql = `SELECT public.get_compra($1::int) AS data`;
// const { rows } = await mainPool.query(sql, [id_compra]);
// res.json(rows[0]?.data || {});
// } catch (e) {
// console.error(e); res.status(500).json({ error: 'get_compra failed' });
// }
// });
// // Eliminar
// app.post('/api/rpc/delete_compra', async (req, res) => {
// try {
// const { id_compra } = req.body || {};
// await mainPool.query(`SELECT public.delete_compra($1::int)`, [id_compra]);
// res.json({ ok: true });
// } catch (e) {
// console.error(e); res.status(500).json({ error: 'delete_compra failed' });
// }
// });
// // POST /api/rpc/report_gastos { year: 2025 }
// app.post('/api/rpc/report_gastos', async (req, res) => {
// try {
// const year = parseInt(req.body?.year ?? new Date().getFullYear(), 10);
// const { rows } = await mainPool.query(
// 'SELECT public.report_gastos($1::int) AS j', [year]
// );
// res.json(rows[0].j);
// } catch (e) {
// console.error('report_gastos error:', e);
// res.status(500).json({
// error: 'report_gastos failed',
// message: e.message, detail: e.detail, code: e.code
// });
// }
// });
// // (Opcional) GET para probar rápido desde el navegador:
// // /api/rpc/report_gastos?year=2025
// app.get('/api/rpc/report_gastos', async (req, res) => {
// try {
// const year = parseInt(req.query.year ?? new Date().getFullYear(), 10);
// const { rows } = await mainPool.query(
// 'SELECT public.report_gastos($1::int) AS j', [year]
// );
// res.json(rows[0].j);
// } catch (e) {
// console.error('report_gastos error:', e);
// res.status(500).json({
// error: 'report_gastos failed',
// message: e.message, detail: e.detail, code: e.code
// });
// }
// });
-340
View File
@@ -1,340 +0,0 @@
// services/manso/src/api/v1/routes/routes.js
import { Router } from 'express';
import pool from '#db'; // Pool Singleton
const router = Router();
// ==========================================================
// Rutas de API v1
// ==========================================================
// ----------------------------------------------------------
// API Comandas
// ----------------------------------------------------------
router.route('/comandas').get( async (req, res, next) => {
try {
var client = await pool.getClient()
const estado = (req.query.estado || '').trim() || null;
const limit = Math.min(parseInt(req.query.limit || '200', 10), 1000);
const { rows } = await client.query(
`SELECT * FROM public.f_comandas_resumen($1, $2)`,
[estado, limit]
);
res.json(rows);
} catch (e) {
next(e);
} finally {
client.release();
}
});
router.route('/comandas/:id/detalle').get( async (req, res, next) => {
try {
const client = await pool.getClient()
client.query(
`SELECT id_det_comanda, id_producto, producto_nombre,
cantidad, pre_unitario, subtotal, observaciones
FROM public.v_comandas_detalle_items
WHERE id_comanda = $1::int
ORDER BY id_det_comanda`,
[req.params.id]
)
.then(r => res.json(r.rows))
.catch(next)
client.release();
} catch (error) {
next(e);
}
});
router.route('/comandas/:id/cerrar').post( async (req, res, next) => {
try {
const client = await pool.getClient()
const id = Number(req.params.id);
if (!Number.isInteger(id) || id <= 0) {
return res.status(400).json({ error: 'id inválido' });
}
const { rows } = await client.query(
`SELECT public.f_cerrar_comanda($1) AS data`,
[id]
);
if (!rows.length || rows[0].data === null) {
return res.status(404).json({ error: 'Comanda no encontrada' });
}
res.json(rows[0].data);
client.release();
} catch (err) { next(err); }
});
router.route('/comandas/:id/abrir').post( async (req, res, next) => {
try {
const client = await pool.getClient()
const id = Number(req.params.id);
if (!Number.isInteger(id) || id <= 0) {
return res.status(400).json({ error: 'id inválido' });
}
const { rows } = await client.query(
`SELECT public.f_abrir_comanda($1) AS data`,
[id]
);
if (!rows.length || rows[0].data === null) {
return res.status(404).json({ error: 'Comanda no encontrada' });
}
res.json(rows[0].data);
client.release();
} catch (err) { next(err); }
});
// ----------------------------------------------------------
// API Productos
// ----------------------------------------------------------
// GET producto + receta
router.route('/rpc/get_producto/:id').get( async (req, res) => {
const client = await pool.getClient()
const id = Number(req.params.id);
const { rows } = await client.query('SELECT public.get_producto($1) AS data', [id]);
res.json(rows[0]?.data || {});
client.release();
});
// POST guardar producto + receta
router.route('/rpc/save_producto').post(async (req, res) => {
try {
// console.debug('receta payload:', req.body?.receta); // habilitalo si lo necesitás
const client = await pool.getClient()
const q = 'SELECT public.save_producto($1,$2,$3,$4,$5,$6,$7::jsonb) AS id_producto';
const { id_producto=null, nombre, img_producto=null, precio=0, activo=true, id_categoria=null, receta=[] } = req.body || {};
const params = [id_producto, nombre, img_producto, precio, activo, id_categoria, JSON.stringify(receta||[])];
const { rows } = await client.query(q, params);
res.json(rows[0] || {});
client.release();
} catch(e) {
console.error(e);
res.status(500).json({ error: 'save_producto failed' });
}
});
// ----------------------------------------------------------
// API Materias Primas
// ----------------------------------------------------------
// GET MP + proveedores
router.route('/rpc/get_materia/:id').get(async (req, res) => {
const id = Number(req.params.id);
try {
const client = await pool.getClient()
const { rows } = await client.query('SELECT public.get_materia_prima($1) AS data', [id]);
res.json(rows[0]?.data || {});
client.release();
} catch (e) {
console.error(e);
res.status(500).json({ error: 'get_materia failed' });
}
});
// SAVE MP + proveedores (array)
router.route('/rpc/save_materia').post( async (req, res) => {
const { id_mat_prima=null, nombre, unidad, activo=true, proveedores=[] } = req.body || {};
try {
const q = 'SELECT public.save_materia_prima($1,$2,$3,$4,$5::jsonb) AS id_mat_prima';
const params = [id_mat_prima, nombre, unidad, activo, JSON.stringify(proveedores||[])];
const { rows } = await pool.query(q, params);
res.json(rows[0] || {});
} catch (e) {
console.error(e);
res.status(500).json({ error: 'save_materia failed' });
}
});
// ----------------------------------------------------------
// API Usuarios y Asistencias
// ----------------------------------------------------------
// POST /api/rpc/find_usuarios_por_documentos { docs: ["12345678","09123456", ...] }
router.route('/rpc/find_usuarios_por_documentos').post( async (req, res) => {
try {
const docs = Array.isArray(req.body?.docs) ? req.body.docs : [];
const sql = 'SELECT public.find_usuarios_por_documentos($1::jsonb) AS data';
const { rows } = await pool.query(sql, [JSON.stringify(docs)]);
res.json(rows[0]?.data || {});
} catch (e) {
console.error(e);
res.status(500).json({ error: 'find_usuarios_por_documentos failed' });
}
});
// POST /api/rpc/import_asistencia { registros: [...], origen?: "AGL_001.txt" }
router.route('/rpc/import_asistencia').post( async (req, res) => {
try {
const registros = Array.isArray(req.body?.registros) ? req.body.registros : [];
const origen = req.body?.origen || null;
const sql = 'SELECT public.import_asistencia($1::jsonb,$2) AS data';
const { rows } = await pool.query(sql, [JSON.stringify(registros), origen]);
res.json(rows[0]?.data || {});
} catch (e) {
console.error(e);
res.status(500).json({ error: 'import_asistencia failed' });
}
});
// Consultar datos de asistencia (raw + pares) para un usuario y rango
router.route('/rpc/asistencia_get').post( async (req, res) => {
try {
const { doc, desde, hasta } = req.body || {};
const sql = 'SELECT public.asistencia_get($1::text,$2::date,$3::date) AS data';
const { rows } = await pool.query(sql, [doc, desde, hasta]);
res.json(rows[0]?.data || {});
} catch (e) {
console.error(e); res.status(500).json({ error: 'asistencia_get failed' });
}
});
// Editar un registro crudo y recalcular pares
router.route('/rpc/asistencia_update_raw').post( async (req, res) => {
try {
const { id_raw, fecha, hora, modo } = req.body || {};
const sql = 'SELECT public.asistencia_update_raw($1::bigint,$2::date,$3::text,$4::text) AS data';
const { rows } = await pool.query(sql, [id_raw, fecha, hora, modo ?? null]);
res.json(rows[0]?.data || {});
} catch (e) {
console.error(e); res.status(500).json({ error: 'asistencia_update_raw failed' });
}
});
// Eliminar un registro crudo y recalcular pares
router.route('/rpc/asistencia_delete_raw').post( async (req, res) => {
try {
const { id_raw } = req.body || {};
const sql = 'SELECT public.asistencia_delete_raw($1::bigint) AS data';
const { rows } = await pool.query(sql, [id_raw]);
res.json(rows[0]?.data || {});
} catch (e) {
console.error(e); res.status(500).json({ error: 'asistencia_delete_raw failed' });
}
});
// ----------------------------------------------------------
// API Reportes
// ----------------------------------------------------------
// POST /api/rpc/report_tickets { year }
router.route('/rpc/report_tickets').post( async (req, res) => {
try {
const y = parseInt(req.body?.year ?? req.query?.year, 10);
const year = (Number.isFinite(y) && y >= 2000 && y <= 2100)
? y
: (new Date()).getFullYear();
const { rows } = await pool.query(
'SELECT public.report_tickets_year($1::int) AS j', [year]
);
res.json(rows[0].j);
} catch (e) {
console.error('report_tickets error:', e);
res.status(500).json({
error: 'report_tickets failed',
message: e.message, detail: e.detail, where: e.where, code: e.code
});
}
});
// POST /api/rpc/report_asistencia { desde: 'YYYY-MM-DD', hasta: 'YYYY-MM-DD' }
router.route('/rpc/report_asistencia').post( async (req, res) => {
try {
let { desde, hasta } = req.body || {};
// defaults si vienen vacíos/invalidos
const re = /^\d{4}-\d{2}-\d{2}$/;
if (!re.test(desde) || !re.test(hasta)) {
const end = new Date();
const start = new Date(end); start.setDate(end.getDate()-30);
desde = start.toISOString().slice(0,10);
hasta = end.toISOString().slice(0,10);
}
const { rows } = await pool.query(
'SELECT public.report_asistencia($1::date,$2::date) AS j', [desde, hasta]
);
res.json(rows[0].j);
} catch (e) {
console.error('report_asistencia error:', e);
res.status(500).json({
error: 'report_asistencia failed',
message: e.message, detail: e.detail, where: e.where, code: e.code
});
}
});
// ----------------------------------------------------------
// API Compras y Gastos
// ----------------------------------------------------------
// Guardar (insert/update)
router.route('/rpc/save_compra').post( async (req, res) => {
try {
const { id_compra, id_proveedor, fec_compra, detalles } = req.body || {};
const sql = 'SELECT * FROM public.save_compra($1::int,$2::int,$3::timestamptz,$4::jsonb)';
const args = [id_compra ?? null, id_proveedor, fec_compra ? new Date(fec_compra) : null, JSON.stringify(detalles)];
const { rows } = await pool.query(sql, args);
res.json(rows[0]); // { id_compra, total }
} catch (e) {
console.error('save_compra error:', e);
res.status(500).json({ error: 'save_compra failed', message: e.message, detail: e.detail, where: e.where, code: e.code });
}
});
// Obtener para editar
router.route('/rpc/get_compra').post( async (req, res) => {
try {
const { id_compra } = req.body || {};
const sql = `SELECT public.get_compra($1::int) AS data`;
const { rows } = await pool.query(sql, [id_compra]);
res.json(rows[0]?.data || {});
} catch (e) {
console.error(e); res.status(500).json({ error: 'get_compra failed' });
}
});
// Eliminar
router.route('/rpc/delete_compra').post( async (req, res) => {
try {
const { id_compra } = req.body || {};
await pool.query(`SELECT public.delete_compra($1::int)`, [id_compra]);
res.json({ ok: true });
} catch (e) {
console.error(e); res.status(500).json({ error: 'delete_compra failed' });
}
});
// POST /api/rpc/report_gastos { year: 2025 }
router.route('/rpc/report_gastos').post( async (req, res) => {
try {
const year = parseInt(req.body?.year ?? new Date().getFullYear(), 10);
const { rows } = await pool.query(
'SELECT public.report_gastos($1::int) AS j', [year]
);
res.json(rows[0].j);
} catch (e) {
console.error('report_gastos error:', e);
res.status(500).json({
error: 'report_gastos failed',
message: e.message, detail: e.detail, code: e.code
});
}
});
export default router;
-83
View File
@@ -1,83 +0,0 @@
// Coneción Singleton a base de datos.
import { Pool } from 'pg';
class DatabaseCore {
constructor() {
if (DatabaseCore.instance) {
return Database.instance;
}
const config = {
host: process.env.DB_HOST,
user: process.env.DB_USER,
password: process.env.DB_PASS,
database: process.env.DB_NAME,
port: process.env.DB_LOCAL_PORT ? Number(process.env.DB_LOCAL_PORT) : undefined,
ssl: process.env.PGSSL === 'true' ? { rejectUnauthorized: false } : undefined,
};
this.connection = new Pool(config);
DatabaseCore.instance = this;
}
async query(sql, params) {
return this.connection.query(sql,params);
}
async connect() { /* Definida solo para evitar errores */
return this.connection.connect();
}
async getClient() {
return this.connection.connect();
}
async release() {
await this.connection.end();
}
}
class DatabaseTenants {
constructor() {
if (DatabaseTenants.instance) {
return Database.instance;
}
const config = {
host: process.env.DB_HOST,
user: process.env.DB_USER,
password: process.env.DB_PASS,
database: process.env.DB_NAME,
port: process.env.DB_LOCAL_PORT ? Number(process.env.DB_LOCAL_PORT) : undefined,
ssl: process.env.PGSSL === 'true' ? { rejectUnauthorized: false } : undefined,
};
this.connection = new Pool(config);
DatabaseTenants.instance = this;
}
async query(sql, params) {
return this.connection.query(sql,params);
}
async connect() { /* Definida solo para evitar errores */
return this.connection.connect();
}
async getClient() {
return this.connection.connect();
}
async release() {
await this.connection.end();
}
}
// const db = new Database();
// db.query('SELECT * FROM users');
const poolCore = new DatabaseCore();
const poolTenants = new DatabaseTenants();
export default {poolCore, poolTenants};
export { poolCore, poolTenants };
//export { DatabaseCore, DatabaseTenants };
+80 -371
View File
@@ -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`)
});
-37
View File
@@ -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();
// }
-67
View File
@@ -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';
}
-558
View File
@@ -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>
-532
View File
@@ -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> -->
-158
View File
@@ -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>
-130
View File
@@ -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>
+366
View File
@@ -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>
+84
View File
@@ -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;