From a31b411437989e1b1e1bd34f0dfe2cad587213c4 Mon Sep 17 00:00:00 2001 From: msaldain Date: Wed, 8 Oct 2025 18:12:58 +0000 Subject: [PATCH] =?UTF-8?q?Re=20estructuraci=C3=B3n=20de=20patrones=20de?= =?UTF-8?q?=20dise=C3=B1o=20con=20el=20c=C3=B3digo=20de=20Manso?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- compose.manso.yaml | 2 +- services/manso/package.json | 13 +- services/manso/src/api/v1/routes/routes.js | 340 +++++++ services/manso/src/db/poolSingleton.js | 46 + services/manso/src/index.js | 842 ------------------ services/manso/src/index.mjs | 321 +++++++ services/manso/src/pages/pages.js | 67 ++ services/manso/src/views/compras.ejs | 6 +- services/manso/src/views/estadoComandas.ejs | 12 +- services/manso/src/views/productos.ejs | 8 +- services/manso/src/views/reportes.ejs | 8 +- services/manso/src/views/usuarios.ejs | 10 +- .../middlewares}/comandas.html.bak | 0 .../middlewares}/dashboard.html.bak | 0 .../middlewares}/estadoComandas.html.bak | 0 15 files changed, 806 insertions(+), 869 deletions(-) create mode 100644 services/manso/src/api/v1/routes/routes.js create mode 100644 services/manso/src/db/poolSingleton.js delete mode 100644 services/manso/src/index.js create mode 100644 services/manso/src/index.mjs create mode 100644 services/manso/src/pages/pages.js rename services/{manso/src/pages => shared/middlewares}/comandas.html.bak (100%) rename services/{manso/src/pages => shared/middlewares}/dashboard.html.bak (100%) rename services/{manso/src/pages => shared/middlewares}/estadoComandas.html.bak (100%) diff --git a/compose.manso.yaml b/compose.manso.yaml index a325076..33f5a33 100644 --- a/compose.manso.yaml +++ b/compose.manso.yaml @@ -26,7 +26,7 @@ services: net: aliases: [manso] healthcheck: - test: ["CMD-SHELL", "curl -fsS http://localhost:${APP_DOCKER_PORT}/health || exit 1"] + test: ["CMD-SHELL", "curl -fsS http://localhost:${APP_PORT}/health || exit 1"] interval: 10s timeout: 3s retries: 10 diff --git a/services/manso/package.json b/services/manso/package.json index 0723c09..aac4b30 100644 --- a/services/manso/package.json +++ b/services/manso/package.json @@ -1,11 +1,11 @@ { "name": "workarround", "version": "1.0.0", - "main": "src/index.js", + "main": "src/index.mjs", "scripts": { - "start": "NODE_ENV=production node ./src/index.js", - "dev": "NODE_ENV=development npx nodemon ./src/index.js", - "test": "NODE_ENV=stage node ./src/index.js" + "start": "NODE_ENV=production node ./src/index.mjs", + "dev": "NODE_ENV=development npx nodemon ./src/index.mjs", + "test": "NODE_ENV=stage node ./src/index.mjs" }, "author": "Mateo Saldain", "license": "ISC", @@ -25,6 +25,11 @@ "pg-format": "^1.0.4", "serve-favicon": "^2.5.1" }, + "imports": { + "#v1Router": "./src/api/v1/routes/routes.js", + "#pages": "./src/pages/pages.js", + "#db": "./src/db/poolSingleton.js" + }, "keywords": [], "description": "Workarround para tener un MVP que llegue al verano para usarse" } diff --git a/services/manso/src/api/v1/routes/routes.js b/services/manso/src/api/v1/routes/routes.js new file mode 100644 index 0000000..c17f2cc --- /dev/null +++ b/services/manso/src/api/v1/routes/routes.js @@ -0,0 +1,340 @@ +// services/manso/src/api/v1/routes/routes.js + +import { Router } from 'express'; +import pool from '#db'; // Pool Singleton +const router = Router(); + +// ========================================================== +// Rutas de API v1 +// ========================================================== + + + +// ---------------------------------------------------------- +// API Comandas +// ---------------------------------------------------------- + +router.route('/comandas').get( async (req, res, next) => { + try { + var client = await pool.getClient() + const estado = (req.query.estado || '').trim() || null; + const limit = Math.min(parseInt(req.query.limit || '200', 10), 1000); + + const { rows } = await client.query( + `SELECT * FROM public.f_comandas_resumen($1, $2)`, + [estado, limit] + ); + res.json(rows); + } catch (e) { + next(e); + } finally { + client.release(); + } +}); + +router.route('/comandas/:id/detalle').get( async (req, res, next) => { + try { + const client = await pool.getClient() + client.query( + `SELECT id_det_comanda, id_producto, producto_nombre, + cantidad, pre_unitario, subtotal, observaciones + FROM public.v_comandas_detalle_items + WHERE id_comanda = $1::int + ORDER BY id_det_comanda`, + [req.params.id] + ) + .then(r => res.json(r.rows)) + .catch(next) + client.release(); + } catch (error) { + next(e); + } +}); + +router.route('/comandas/:id/cerrar').post( async (req, res, next) => { + try { + const client = await pool.getClient() + const id = Number(req.params.id); + if (!Number.isInteger(id) || id <= 0) { + return res.status(400).json({ error: 'id inválido' }); + } + const { rows } = await client.query( + `SELECT public.f_cerrar_comanda($1) AS data`, + [id] + ); + if (!rows.length || rows[0].data === null) { + return res.status(404).json({ error: 'Comanda no encontrada' }); + } + res.json(rows[0].data); + client.release(); + } catch (err) { next(err); } +}); + +router.route('/comandas/:id/abrir').post( async (req, res, next) => { + try { + const client = await pool.getClient() + const id = Number(req.params.id); + if (!Number.isInteger(id) || id <= 0) { + return res.status(400).json({ error: 'id inválido' }); + } + const { rows } = await client.query( + `SELECT public.f_abrir_comanda($1) AS data`, + [id] + ); + if (!rows.length || rows[0].data === null) { + return res.status(404).json({ error: 'Comanda no encontrada' }); + } + res.json(rows[0].data); + client.release(); + } catch (err) { next(err); } +}); + + + +// ---------------------------------------------------------- +// API Productos +// ---------------------------------------------------------- + +// GET producto + receta +router.route('/rpc/get_producto/:id').get( async (req, res) => { + const client = await pool.getClient() + const id = Number(req.params.id); + const { rows } = await client.query('SELECT public.get_producto($1) AS data', [id]); + res.json(rows[0]?.data || {}); + client.release(); +}); + +// POST guardar producto + receta +router.route('/rpc/save_producto').post(async (req, res) => { + try { + // console.debug('receta payload:', req.body?.receta); // habilitalo si lo necesitás + const client = await pool.getClient() + const q = 'SELECT public.save_producto($1,$2,$3,$4,$5,$6,$7::jsonb) AS id_producto'; + const { id_producto=null, nombre, img_producto=null, precio=0, activo=true, id_categoria=null, receta=[] } = req.body || {}; + const params = [id_producto, nombre, img_producto, precio, activo, id_categoria, JSON.stringify(receta||[])]; + const { rows } = await client.query(q, params); + res.json(rows[0] || {}); + client.release(); + } catch(e) { + console.error(e); + res.status(500).json({ error: 'save_producto failed' }); + } +}); + + + +// ---------------------------------------------------------- +// API Materias Primas +// ---------------------------------------------------------- + +// GET MP + proveedores +router.route('/rpc/get_materia/:id').get(async (req, res) => { + const id = Number(req.params.id); + try { + const client = await pool.getClient() + const { rows } = await client.query('SELECT public.get_materia_prima($1) AS data', [id]); + res.json(rows[0]?.data || {}); + client.release(); + } catch (e) { + console.error(e); + res.status(500).json({ error: 'get_materia failed' }); + } +}); + +// SAVE MP + proveedores (array) +router.route('/rpc/save_materia').post( async (req, res) => { + const { id_mat_prima=null, nombre, unidad, activo=true, proveedores=[] } = req.body || {}; + try { + const q = 'SELECT public.save_materia_prima($1,$2,$3,$4,$5::jsonb) AS id_mat_prima'; + const params = [id_mat_prima, nombre, unidad, activo, JSON.stringify(proveedores||[])]; + const { rows } = await pool.query(q, params); + res.json(rows[0] || {}); + } catch (e) { + console.error(e); + res.status(500).json({ error: 'save_materia failed' }); + } +}); + + + +// ---------------------------------------------------------- +// API Usuarios y Asistencias +// ---------------------------------------------------------- + +// POST /api/rpc/find_usuarios_por_documentos { docs: ["12345678","09123456", ...] } +router.route('/rpc/find_usuarios_por_documentos').post( async (req, res) => { + try { + const docs = Array.isArray(req.body?.docs) ? req.body.docs : []; + const sql = 'SELECT public.find_usuarios_por_documentos($1::jsonb) AS data'; + const { rows } = await pool.query(sql, [JSON.stringify(docs)]); + res.json(rows[0]?.data || {}); + } catch (e) { + console.error(e); + res.status(500).json({ error: 'find_usuarios_por_documentos failed' }); + } +}); + +// POST /api/rpc/import_asistencia { registros: [...], origen?: "AGL_001.txt" } +router.route('/rpc/import_asistencia').post( async (req, res) => { + try { + const registros = Array.isArray(req.body?.registros) ? req.body.registros : []; + const origen = req.body?.origen || null; + const sql = 'SELECT public.import_asistencia($1::jsonb,$2) AS data'; + const { rows } = await pool.query(sql, [JSON.stringify(registros), origen]); + res.json(rows[0]?.data || {}); + } catch (e) { + console.error(e); + res.status(500).json({ error: 'import_asistencia failed' }); + } +}); + +// Consultar datos de asistencia (raw + pares) para un usuario y rango +router.route('/rpc/asistencia_get').post( async (req, res) => { + try { + const { doc, desde, hasta } = req.body || {}; + const sql = 'SELECT public.asistencia_get($1::text,$2::date,$3::date) AS data'; + const { rows } = await pool.query(sql, [doc, desde, hasta]); + res.json(rows[0]?.data || {}); + } catch (e) { + console.error(e); res.status(500).json({ error: 'asistencia_get failed' }); + } +}); + +// Editar un registro crudo y recalcular pares +router.route('/rpc/asistencia_update_raw').post( async (req, res) => { + try { + const { id_raw, fecha, hora, modo } = req.body || {}; + const sql = 'SELECT public.asistencia_update_raw($1::bigint,$2::date,$3::text,$4::text) AS data'; + const { rows } = await pool.query(sql, [id_raw, fecha, hora, modo ?? null]); + res.json(rows[0]?.data || {}); + } catch (e) { + console.error(e); res.status(500).json({ error: 'asistencia_update_raw failed' }); + } +}); + +// Eliminar un registro crudo y recalcular pares +router.route('/rpc/asistencia_delete_raw').post( async (req, res) => { + try { + const { id_raw } = req.body || {}; + const sql = 'SELECT public.asistencia_delete_raw($1::bigint) AS data'; + const { rows } = await pool.query(sql, [id_raw]); + res.json(rows[0]?.data || {}); + } catch (e) { + console.error(e); res.status(500).json({ error: 'asistencia_delete_raw failed' }); + } +}); + + +// ---------------------------------------------------------- +// API Reportes +// ---------------------------------------------------------- + +// POST /api/rpc/report_tickets { year } +router.route('/rpc/report_tickets').post( async (req, res) => { + try { + const y = parseInt(req.body?.year ?? req.query?.year, 10); + const year = (Number.isFinite(y) && y >= 2000 && y <= 2100) + ? y + : (new Date()).getFullYear(); + + const { rows } = await pool.query( + 'SELECT public.report_tickets_year($1::int) AS j', [year] + ); + res.json(rows[0].j); + } catch (e) { + console.error('report_tickets error:', e); + res.status(500).json({ + error: 'report_tickets failed', + message: e.message, detail: e.detail, where: e.where, code: e.code + }); + } +}); + +// POST /api/rpc/report_asistencia { desde: 'YYYY-MM-DD', hasta: 'YYYY-MM-DD' } +router.route('/rpc/report_asistencia').post( async (req, res) => { + try { + let { desde, hasta } = req.body || {}; + // defaults si vienen vacíos/invalidos + const re = /^\d{4}-\d{2}-\d{2}$/; + if (!re.test(desde) || !re.test(hasta)) { + const end = new Date(); + const start = new Date(end); start.setDate(end.getDate()-30); + desde = start.toISOString().slice(0,10); + hasta = end.toISOString().slice(0,10); + } + + const { rows } = await pool.query( + 'SELECT public.report_asistencia($1::date,$2::date) AS j', [desde, hasta] + ); + res.json(rows[0].j); + } catch (e) { + console.error('report_asistencia error:', e); + res.status(500).json({ + error: 'report_asistencia failed', + message: e.message, detail: e.detail, where: e.where, code: e.code + }); + } +}); + +// ---------------------------------------------------------- +// API Compras y Gastos +// ---------------------------------------------------------- + +// Guardar (insert/update) +router.route('/rpc/save_compra').post( async (req, res) => { + try { + const { id_compra, id_proveedor, fec_compra, detalles } = req.body || {}; + const sql = 'SELECT * FROM public.save_compra($1::int,$2::int,$3::timestamptz,$4::jsonb)'; + const args = [id_compra ?? null, id_proveedor, fec_compra ? new Date(fec_compra) : null, JSON.stringify(detalles)]; + const { rows } = await pool.query(sql, args); + res.json(rows[0]); // { id_compra, total } + } catch (e) { + console.error('save_compra error:', e); + res.status(500).json({ error: 'save_compra failed', message: e.message, detail: e.detail, where: e.where, code: e.code }); + } +}); + + +// Obtener para editar +router.route('/rpc/get_compra').post( async (req, res) => { + try { + const { id_compra } = req.body || {}; + const sql = `SELECT public.get_compra($1::int) AS data`; + const { rows } = await pool.query(sql, [id_compra]); + res.json(rows[0]?.data || {}); + } catch (e) { + console.error(e); res.status(500).json({ error: 'get_compra failed' }); + } +}); + +// Eliminar +router.route('/rpc/delete_compra').post( async (req, res) => { + try { + const { id_compra } = req.body || {}; + await pool.query(`SELECT public.delete_compra($1::int)`, [id_compra]); + res.json({ ok: true }); + } catch (e) { + console.error(e); res.status(500).json({ error: 'delete_compra failed' }); + } +}); + + +// POST /api/rpc/report_gastos { year: 2025 } +router.route('/rpc/report_gastos').post( async (req, res) => { + try { + const year = parseInt(req.body?.year ?? new Date().getFullYear(), 10); + const { rows } = await pool.query( + 'SELECT public.report_gastos($1::int) AS j', [year] + ); + res.json(rows[0].j); + } catch (e) { + console.error('report_gastos error:', e); + res.status(500).json({ + error: 'report_gastos failed', + message: e.message, detail: e.detail, code: e.code + }); + } +}); + + +export default router; \ No newline at end of file diff --git a/services/manso/src/db/poolSingleton.js b/services/manso/src/db/poolSingleton.js new file mode 100644 index 0000000..35d0fae --- /dev/null +++ b/services/manso/src/db/poolSingleton.js @@ -0,0 +1,46 @@ +// Coneción Singleton a base de datos. + +import { Pool } from 'pg'; + +class Database { + constructor() { + + if (Database.instance) { + return Database.instance; + } + + const config = { + host: process.env.DB_HOST, + user: process.env.DB_USER, + password: process.env.DB_PASS, + database: process.env.DB_NAME, + port: process.env.DB_LOCAL_PORT ? Number(process.env.DB_LOCAL_PORT) : undefined, + ssl: process.env.PGSSL === 'true' ? { rejectUnauthorized: false } : undefined, + }; + + this.connection = new Pool(config); + + Database.instance = this; + } + async query(sql, params) { + return this.connection.query(sql,params); + } + + async connect() { /* Definida solo para evitar errores */ + return this.connection.connect(); + } + async getClient() { + return this.connection.connect(); + } + + async release() { + await this.connection.end(); + } +} + +// const db = new Database(); +// db.query('SELECT * FROM users'); + +const pool = new Database(); +export default pool; +export { Database }; \ No newline at end of file diff --git a/services/manso/src/index.js b/services/manso/src/index.js deleted file mode 100644 index b093dee..0000000 --- a/services/manso/src/index.js +++ /dev/null @@ -1,842 +0,0 @@ -// app/src/index.js -import chalk from 'chalk'; // Colores! -import favicon from 'serve-favicon'; // Favicon -import express from 'express'; -import expressLayouts from 'express-ejs-layouts'; -import cors from 'cors'; -import { Pool } from 'pg'; - -// Rutas -import path from 'path'; -import { fileURLToPath } from 'url'; -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); - -// Variables de Entorno -import dotenv from 'dotenv'; - -// Cargar .env según entorno -if (process.env.NODE_ENV === 'development') { - dotenv.config({ path: path.resolve(__dirname, '../.env.development') }); -} else if (process.env.NODE_ENV === 'test') { - dotenv.config({ path: path.resolve(__dirname, '../.env.test') }); -} else if (process.env.NODE_ENV === 'production') { - dotenv.config({ path: path.resolve(__dirname, '../.env.production') }); -} else { - dotenv.config(); // .env por defecto -} - -// ---------------------------------------------------------- -// App -// ---------------------------------------------------------- -const app = express(); -app.set('trust proxy', true); -app.use(cors()); -app.use(express.json()); -app.use(express.json({ limit: '1mb' })); -app.use(express.static(path.join(__dirname, 'pages'))); - - -// ---------------------------------------------------------- -// Motor de vistas EJS -// ---------------------------------------------------------- -app.set("views", path.join(__dirname, "views")); -app.set("view engine", "ejs"); -app.use(expressLayouts); -app.set("layout", "layouts/main"); - -// Archivos estáticos -app.use(express.static(path.join(__dirname, "public"))); - -app.use('/favicon', express.static(path.join(__dirname, 'public', 'favicon'), { - maxAge: '1y' -})); - -app.use(favicon(path.join(__dirname, 'public', 'favicon', 'favicon.ico'), { - maxAge: '1y' -})); - -const url = v => !v ? "" : (v.startsWith("http") ? v : `/img/productos/${v}`); - -// ---------------------------------------------------------- -// Configuración de conexión PostgreSQL -// ---------------------------------------------------------- -const dbConfig = { - 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, -}; - -const pool = new Pool(dbConfig); - -// ---------------------------------------------------------- -// Seguridad: Tablas permitidas -// ---------------------------------------------------------- -const ALLOWED_TABLES = [ - 'roles','usuarios','usua_roles', - 'categorias','productos', - 'clientes','mesas', - 'comandas','deta_comandas', - 'proveedores','compras','deta_comp_producto', - 'mate_primas','deta_comp_materias', - 'prov_producto','prov_mate_prima', - 'receta_producto', 'asistencia_resumen_diario', - 'asistencia_intervalo', 'vw_compras' -]; - -const VALID_IDENT = /^[a-zA-Z_][a-zA-Z0-9_]*$/; - -// Identificadores SQL -> comillas dobles y escape correcto -const q = (s) => `"${String(s).replace(/"/g, '""')}"`; - -function ensureTable(name) { - const t = String(name || '').toLowerCase(); - if (!ALLOWED_TABLES.includes(t)) throw new Error('Tabla no permitida'); - return t; -} - -async function getClient() { - const client = await pool.connect(); - return client; -} - -// ---------------------------------------------------------- -// 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(); -}); - - -// ---------------------------------------------------------- -// Rutas de UI -// ---------------------------------------------------------- - -app.get("/", (req, res) => { - res.locals.pageTitle = "Dashboard"; - res.locals.pageId = "home"; // para el sidebar contextual - res.render("dashboard"); -}); - -app.get("/dashboard", (req, res) => { - res.locals.pageTitle = "Dashboard"; - res.locals.pageId = "dashboard"; // <- importante - res.render("dashboard"); -}); - -// app.get('/', (req, res) => { -// res.sendFile(path.join(__dirname, 'pages', 'dashboard.html')); -// }); - -app.get("/comandas", (req, res) => { - res.locals.pageTitle = "Comandas"; - res.locals.pageId = "comandas"; // <- importante para el sidebar contextual - res.render("comandas"); -}); - -// app.get('/comandas', (req, res) => { -// res.sendFile(path.join(__dirname, 'pages', 'comandas.html')); -// }); - -app.get("/estadoComandas", (req, res) => { - res.locals.pageTitle = "Estado de Comandas"; - res.locals.pageId = "estadoComandas"; - res.render("estadoComandas"); -}); - -// app.get('/estadoComandas', (req, res) => { -// res.sendFile(path.join(__dirname, 'pages', 'estadoComandas.html')); -// }); - -app.get("/productos", (req, res) => { - res.locals.pageTitle = "Productos"; - res.locals.pageId = "productos"; - res.render("productos"); -}); - -app.get('/usuarios', (req, res) => { - res.locals.pageTitle = 'Usuarios'; - res.locals.pageId = 'usuarios'; - res.render('usuarios'); -}); - -app.get('/reportes', (req, res) => { - res.locals.pageTitle = 'Reportes'; - res.locals.pageId = 'reportes'; - res.render('reportes'); -}); - -app.get('/compras', (req, res) => { - res.locals.pageTitle = 'Compras'; - res.locals.pageId = 'compras'; - res.render('compras'); -}); - -// ---------------------------------------------------------- -// API -// ---------------------------------------------------------- -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 pool.query( - `SELECT * FROM public.f_comandas_resumen($1, $2)`, - [estado, limit] - ); - res.json(rows); - } catch (e) { next(e); } -}); - - -// app.get('/api/comandas', async (req, res, next) => { -// try { -// const estado = (req.query.estado || '').trim(); -// const limit = Math.min(parseInt(req.query.limit || '200', 10), 1000); -// const params = []; -// let where = ''; -// if (estado) { params.push(estado); where = `WHERE c.estado = $${params.length}`; } -// params.push(limit); - -// const sql = ` -// WITH items AS ( -// SELECT d.id_comanda, -// COUNT(*) AS items, -// SUM(d.cantidad * d.pre_unitario) AS total -// FROM deta_comandas d -// GROUP BY d.id_comanda -// ) -// SELECT -// c.id_comanda, c.fec_creacion, c.estado, c.observaciones, -// u.id_usuario, u.nombre AS usuario_nombre, u.apellido AS usuario_apellido, -// m.id_mesa, m.numero AS mesa_numero, m.apodo AS mesa_apodo, -// COALESCE(i.items, 0) AS items, -// COALESCE(i.total, 0) AS total -// FROM comandas c -// JOIN usuarios u ON u.id_usuario = c.id_usuario -// JOIN mesas m ON m.id_mesa = c.id_mesa -// LEFT JOIN items i ON i.id_comanda = c.id_comanda -// ${where} -// ORDER BY c.id_comanda DESC -// LIMIT $${params.length} -// `; -// const client = await pool.connect(); -// try { -// const { rows } = await client.query(sql, params); -// res.json(rows); -// } finally { client.release(); } -// } 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) => - pool.query( - `SELECT id_det_comanda, id_producto, producto_nombre, - cantidad, pre_unitario, subtotal, observaciones - FROM public.v_comandas_detalle_items - WHERE id_comanda = $1::int - ORDER BY id_det_comanda`, - [req.params.id] - ) - .then(r => res.json(r.rows)) - .catch(next) -); - - -// app.get('/api/comandas/:id/detalle', async (req, res, next) => { -// try { -// const id = parseInt(req.params.id, 10); -// if (!Number.isInteger(id) || id <= 0) { -// return res.status(400).json({ error: 'id inválido' }); -// } - -// const sql = ` -// 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 -// `; -// const { rows } = await pool.query(sql, [id]); -// res.json(rows); -// } catch (e) { next(e); } -// }); - - -// app.get('/api/comandas/:id/detalle', async (req, res, next) => { -// try { -// const id = parseInt(req.params.id, 10); -// if (!id) return res.status(400).json({ error: 'id inválido' }); - -// const sql = ` -// SELECT d.id_det_comanda, d.id_producto, p.nombre AS producto_nombre, -// d.cantidad, d.pre_unitario, (d.cantidad * d.pre_unitario) AS subtotal, -// d.observaciones -// FROM deta_comandas d -// JOIN productos p ON p.id_producto = d.id_producto -// WHERE d.id_comanda = $1 -// ORDER BY d.id_det_comanda -// `; -// const { rows } = await pool.query(sql, [id]); -// res.json(rows); -// } catch (e) { next(e); } -// }); - - - -// 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 pool.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 pool.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); } -}); - -// // Cambiar estado (abrir/cerrar) -// app.post('/api/comandas/:id/estado', async (req, res, next) => { -// try { -// const id = parseInt(req.params.id, 10); -// let { estado } = req.body || {}; -// if (!id) return res.status(400).json({ error: 'id inválido' }); - -// const allowed = new Set(['abierta','cerrada','pagada','anulada']); -// if (!allowed.has(estado)) return res.status(400).json({ error: 'estado inválido' }); - -// const { rows } = await pool.query( -// `UPDATE comandas SET estado = $2 WHERE id_comanda = $1 RETURNING *`, -// [id, estado] -// ); -// if (!rows.length) return res.status(404).json({ error: 'comanda no encontrada' }); -// res.json({ updated: rows[0] }); -// } catch (e) { next(e); } -// }); - -// GET producto + receta -app.get('/api/rpc/get_producto/:id', async (req, res) => { - const id = Number(req.params.id); - const { rows } = await pool.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 pool.query(q, params); - res.json(rows[0] || {}); - } catch(e) { - console.error(e); - res.status(500).json({ error: 'save_producto failed' }); - } -}); - - -// app.post('/api/rpc/save_producto', async (req, res) => { -// const { id_producto=null, nombre, img_producto=null, precio=0, activo=true, id_categoria=null, receta=[] } = req.body || {}; -// const q = 'SELECT * FROM public.save_producto($1,$2,$3,$4,$5,$6,$7::jsonb)'; -// const params = [id_producto, nombre, img_producto, precio, activo, id_categoria, JSON.stringify(receta||[])]; -// const { rows } = await pool.query(q, params); -// res.json(rows[0] || {}); -// }); - -// GET MP + proveedores -app.get('/api/rpc/get_materia/:id', async (req, res) => { - const id = Number(req.params.id); - try { - const { rows } = await pool.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 pool.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 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" } -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 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 -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 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 -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 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 -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 pool.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 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' } -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 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 - }); - } -}); - - -// app.post('/api/rpc/report_asistencia', async (req,res)=>{ -// try{ -// const {desde, hasta} = req.body||{}; -// const sql = 'SELECT * FROM public.report_asistencia($1::date,$2::date)'; -// const {rows} = await pool.query(sql,[desde, hasta]); -// res.json(rows); -// } catch (e) { -// console.error(e); -// res.status(500).json({ error: 'report_tickets failed' + e }); -// } -// }); - -// app.post('/api/rpc/report_tickets', async (req, res) => { -// try { -// const { year } = req.body || {}; -// const sql = 'SELECT public.report_tickets_year($1::int) AS data'; -// const { rows } = await pool.query(sql, [year]); -// res.json(rows[0]?.data || {}); -// } catch (e) { -// console.error(e); -// res.status(500).json({ error: 'report_tickets failed' + e }); -// } -// }); - - -// 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 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 -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 pool.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 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 } -app.post('/api/rpc/report_gastos', 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 - }); - } -}); - -// (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 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 - }); - } -}); - - -// ---------------------------------------------------------- -// Verificación de conexión -// ---------------------------------------------------------- -async function verificarConexion() { - try { - const client = await pool.connect(); - const res = await client.query('SELECT NOW() AS hora'); - console.log(`\nConexión con la base de datos ${chalk.green(process.env.DB_NAME)} fue exitosa.`); - console.log('Fecha y hora actual de la base de datos:', res.rows[0].hora); - client.release(); - } catch (error) { - console.error('Error al conectar con la base de datos al iniciar:', error.message); - console.error('Revisar credenciales y accesos de red.'); - } -} - -// ---------------------------------------------------------- -// Inicio del servidor -// ---------------------------------------------------------- - -const PORT = process.env.PORT ? Number(process.env.PORT) : 3000; -app.listen(PORT, () => { - console.log(`Servidor de aplicación escuchando en ${chalk.yellow(`http://localhost:${PORT}`)}`); - console.log(chalk.grey(`Comprobando accesibilidad a la db ${chalk.white(process.env.DB_NAME)} del host ${chalk.white(process.env.DB_HOST)} ...`)); - verificarConexion(); -}); - -// Healthcheck -app.get('/health', async (_req, res) => { - res.status(200).json({ status: 'ok' }); -}); diff --git a/services/manso/src/index.mjs b/services/manso/src/index.mjs new file mode 100644 index 0000000..4df8237 --- /dev/null +++ b/services/manso/src/index.mjs @@ -0,0 +1,321 @@ +// ./services/manso/src/index.mjs + +import 'dotenv/config';// Variables de Entorno + +import favicon from 'serve-favicon'; // Favicon +import express from 'express'; +import expressLayouts from 'express-ejs-layouts'; + +import pool from '#db'; // Pool Singleton +import v1Router from '#v1Router'; // Rutas API v1 +import expressPages from '#pages'; // Rutas "/", "/dashboard", ... + + +import path from 'path'; +import { fileURLToPath } from 'url'; +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + + + +// ---------------------------------------------------------- +// Variables del sistema +// ---------------------------------------------------------- +const PORT = process.env.PORT ? Number(process.env.PORT) : 3000; +const url = v => !v ? "" : (v.startsWith("http") ? v : `/img/productos/${v}`); +const VALID_IDENT = /^[a-zA-Z_][a-zA-Z0-9_]*$/; +const q = (s) => `"${String(s).replace(/"/g, '""')}"`; // Identificadores SQL -> comillas dobles y escape correcto + + + +// ---------------------------------------------------------- +// App + Motor de vistas EJS +// ---------------------------------------------------------- +const app = express(); +app.set('trust proxy', true); +app.set("views", path.join(__dirname, "views")); +app.set("view engine", "ejs"); +app.set("layout", "layouts/main"); + +app.use(express.json()); +app.use(express.json({ limit: '1mb' })); +app.use(express.static(path.join(__dirname, 'public'))); // Carga de archivos estaticos +app.use(expressLayouts); // Carga los layouts que usara el renderizado +app.use(favicon(path.join(__dirname, 'public', 'favicon', 'favicon.ico'), {maxAge: '1y'})); +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 verificarConexion() { + try { + var client = await pool.getClient(); + let res = await client.query('SELECT NOW() AS hora'); + console.log(`\nConexión con la base de datos ${process.env.DB_NAME} fue exitosa.`); + console.log('Fecha y hora actual de la base de datos:\n ->', res.rows[0].hora); + } catch (error) { + console.error('Error al conectar con la base de datos al iniciar: \n ->', error.message); + console.error('Revisar credenciales y accesos de red.'); + } finally{ + client.release(); + } +} + + + +// ---------------------------------------------------------- +// Inicio del servidor +// ---------------------------------------------------------- + +app.listen(PORT, () => { + console.log(`Servidor de aplicación escuchando en ${`http://localhost:${PORT}`}`); + console.log(`Comprobando accesibilidad a la db ${process.env.DB_NAME} del host ${process.env.DB_HOST} ...`); + verificarConexion(); +}); + +// Healthcheck +app.get('/health', (_req, res) => res.status(200).json({ status: 'ok'})); diff --git a/services/manso/src/pages/pages.js b/services/manso/src/pages/pages.js new file mode 100644 index 0000000..68d657a --- /dev/null +++ b/services/manso/src/pages/pages.js @@ -0,0 +1,67 @@ +// services/manso/src/api/v1/routes/routes.js + +import { Router } from 'express'; + +const router = Router(); + +// ---------------------------------------------------------- +// Rutas de UI +// ---------------------------------------------------------- + +router.get('/', (req, res) => { + res.locals.pageTitle = "Inicio"; // Título de pestaña + res.locals.pageId = "home"; // Sidebar contextual + res.render("dashboard"); // Archivo .ejs a renderizar + // res.json({ ok: true, route: '/inicio' }); // Debug json +}); + +router.get('/dashboard', (req, res) => { + res.locals.pageTitle = "Dashboard"; + res.locals.pageId = "dashboard"; + res.render("dashboard"); + // res.json({ ok: true, route: '/dashboard' }); +}); + +router.get('/comandas', (req, res) => { + res.locals.pageTitle = "Comandas"; + res.locals.pageId = "comandas"; + res.render("comandas"); + // res.json({ ok: true, route: '/comandas' }); +}); + +router.get('/estadoComandas', (req, res) => { + res.locals.pageTitle = "Estado"; + res.locals.pageId = "estadoComandas"; + res.render("estadoComandas"); + // res.json({ ok: true, route: '/estadoComandas' }); +}); + +router.get('/productos', (req, res) => { + res.locals.pageTitle = "Propductos"; + res.locals.pageId = "productos"; + res.render("productos"); + // res.json({ ok: true, route: '/productos' }); +}); + +router.get('/usuarios', (req, res) => { + res.locals.pageTitle = "Usuarios"; + res.locals.pageId = "usuarios"; + res.render("usuarios"); + // res.json({ ok: true, route: '/usuarios' }); +}); + +router.get('/reportes', (req, res) => { + res.locals.pageTitle = "Reportes"; + res.locals.pageId = "reportes"; + res.render("reportes"); + // res.json({ ok: true, route: '/reportes' }); +}); + +router.get('/compras', (req, res) => { + res.locals.pageTitle = "Compras"; + res.locals.pageId = "compras"; + res.render("compras"); + // res.json({ ok: true, route: '/compras' }); +}); + +export default router; \ No newline at end of file diff --git a/services/manso/src/views/compras.ejs b/services/manso/src/views/compras.ejs index 6111cdd..a19bab2 100644 --- a/services/manso/src/views/compras.ejs +++ b/services/manso/src/views/compras.ejs @@ -253,7 +253,7 @@ async function saveCompra(){ $('#btnGuardar').disabled = true; $('#status').textContent = 'Guardando…'; try{ - const res = await jpost('/api/rpc/save_compra', payload); + const res = await jpost('/api/v1/rpc/save_compra', payload); $('#id_compra').value = res.id_compra; $('#btnEliminar').classList.remove('d-none'); $('#formTitle').textContent = 'Editar compra #' + res.id_compra; @@ -272,7 +272,7 @@ async function deleteCompra(){ if (!confirm('¿Eliminar compra #' + id + '?')) return; $('#btnEliminar').disabled = true; try{ - await jpost('/api/rpc/delete_compra', { id_compra: id }); + await jpost('/api/v1/rpc/delete_compra', { id_compra: id }); nuevaCompra(); await loadListado(); }catch(e){ @@ -295,7 +295,7 @@ function nuevaCompra(){ async function cargarCompra(id){ $('#status').textContent = 'Cargando compra…'; try{ - const data = await jpost('/api/rpc/get_compra', { id_compra: id }); + const data = await jpost('/api/v1/rpc/get_compra', { id_compra: id }); $('#id_compra').value = data.id_compra; $('#id_proveedor').value = data.id_proveedor; $('#fec_compra').value = (data.fec_compra || '').replace(' ', 'T').slice(0,16); diff --git a/services/manso/src/views/estadoComandas.ejs b/services/manso/src/views/estadoComandas.ejs index 2d3615b..4310d16 100644 --- a/services/manso/src/views/estadoComandas.ejs +++ b/services/manso/src/views/estadoComandas.ejs @@ -105,14 +105,14 @@ // ===== Data ===== async function loadLista() { const estado = state.soloAbiertas ? 'abierta' : ''; - const url = estado ? `/api/comandas?estado=${encodeURIComponent(estado)}&limit=300` : '/api/comandas?limit=300'; + const url = estado ? `/api/v1/comandas?estado=${encodeURIComponent(estado)}&limit=300` : '/api/v1/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`); + const det = await jget(`/api/v1/comandas/${id}/detalle`); state.detalle = Array.isArray(det) ? det : []; renderDetalle(); } @@ -241,7 +241,7 @@ 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 + await jpost(`/api/v1/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 @@ -346,14 +346,14 @@ // ===== Data ===== async function loadLista() { const estado = state.soloAbiertas ? 'abierta' : ''; - const url = estado ? `/api/comandas?estado=${encodeURIComponent(estado)}&limit=300` : '/api/comandas?limit=300'; + const url = estado ? `/api/v1/comandas?estado=${encodeURIComponent(estado)}&limit=300` : '/api/v1/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`); + const det = await jget(`/api/v1/comandas/${id}/detalle`); state.detalle = Array.isArray(det) ? det : []; renderDetalle(); } @@ -478,7 +478,7 @@ async function setEstado(estado){ if (!state.sel) return; try { - const { updated } = await jpost(`/api/comandas/${state.sel}/estado`, { estado }); + const { updated } = await jpost(`/api/v1/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); diff --git a/services/manso/src/views/productos.ejs b/services/manso/src/views/productos.ejs index c67fe8b..802c49d 100644 --- a/services/manso/src/views/productos.ejs +++ b/services/manso/src/views/productos.ejs @@ -271,7 +271,7 @@ async function loadProducto(id){ try { // Usamos la función SQL (RPC) para traer producto + receta en un solo tiro - const data = await jget(`/api/rpc/get_producto/${id}`); + const data = await jget(`/api/v1/rpc/get_producto/${id}`); const p = data.producto || {}; const r = Array.isArray(data.receta) ? data.receta : []; @@ -384,7 +384,7 @@ if (!payload.nombre) { toast('Nombre requerido'); return; } if (!(payload.precio >= 0)) { toast('Precio inválido'); return; } - const { id_producto } = await jpost('/api/rpc/save_producto', payload); + const { id_producto } = await jpost('/api/v1/rpc/save_producto', payload); toast(`Guardado #${id_producto}`, true); // refrescar listado y reabrir seleccionado @@ -489,7 +489,7 @@ // 5) cargar MP + proveedores asignados (via función SQL) async function loadMp(id) { try { - const data = await jget(`/api/rpc/get_materia/${id}`); // { materia: {...}, proveedores: [...] } + const data = await jget(`/api/v1/rpc/get_materia/${id}`); // { materia: {...}, proveedores: [...] } const m = data.materia || {}; const provs = Array.isArray(data.proveedores) ? data.proveedores : []; @@ -518,7 +518,7 @@ }; if (!payload.nombre) { mpToast('Nombre requerido'); return; } - const r = await jpost('/api/rpc/save_materia', payload); // => { id_mat_prima } + const r = await jpost('/api/v1/rpc/save_materia', payload); // => { id_mat_prima } mpToast(`Guardado #${r.id_mat_prima}`, true); // refrescar listas globales diff --git a/services/manso/src/views/reportes.ejs b/services/manso/src/views/reportes.ejs index 7d9feb7..96640e8 100644 --- a/services/manso/src/views/reportes.ejs +++ b/services/manso/src/views/reportes.ejs @@ -339,7 +339,7 @@ function barsCompareSVG(a,b){ // a=ventas, b=gastos: arrays [{label,value}] let cacheAsist=[]; async function fetchAsistencias(desde,hasta){ - try { return await jpost('/api/rpc/report_asistencia', { desde, hasta }); } + try { return await jpost('/api/v1/rpc/report_asistencia', { desde, hasta }); } catch { const url=`/api/table/asistencia_detalle?desde=${encodeURIComponent(desde)}&hasta=${encodeURIComponent(hasta)}&limit=10000`; return await jget(url); } } function renderAsistTabla(rows){ @@ -366,7 +366,7 @@ async function loadAsist(){ } $('#repStatus').textContent = 'Cargando asistencias…'; - cacheAsist = await jpost('/api/rpc/report_asistencia', { desde: d, hasta: h }) + cacheAsist = await jpost('/api/v1/rpc/report_asistencia', { desde: d, hasta: h }) .catch(async ()=>{ // fallback a tabla genérica si el RPC no está const url=`/api/table/asistencia_detalle?desde=${encodeURIComponent(d)}&hasta=${encodeURIComponent(h)}&limit=10000`; @@ -502,7 +502,7 @@ function getYearSafe(val){ async function fetchTickets(year){ const y = getYearSafe(year); - return await jpost('/api/rpc/report_tickets', { year: y }); + return await jpost('/api/v1/rpc/report_tickets', { year: y }); } function renderTickets(data){ @@ -523,7 +523,7 @@ function renderTickets(data){ let cacheGastos=null; // {year, months:[{mes,nombre,importe}], total, avg} async function fetchGastos(year){ // 1) Intentar RPC - try { return await jpost('/api/rpc/report_gastos', { year }); } catch {} + try { return await jpost('/api/v1/rpc/report_gastos', { year }); } catch {} // 2) Fallback: traer compras y agrupar en el cliente const rows = await jget('/api/table/compras?limit=10000&order_by=fec_compra%20asc').catch(()=>[]); const months = Array.from({length:12},(_,i)=>({mes:i+1,nombre:monthNames[i],importe:0})); diff --git a/services/manso/src/views/usuarios.ejs b/services/manso/src/views/usuarios.ejs index 7a558da..7b5a8a2 100644 --- a/services/manso/src/views/usuarios.ejs +++ b/services/manso/src/views/usuarios.ejs @@ -229,7 +229,7 @@ // RPC helpers async function rpcGet(doc, desde, hasta){ - const r = await fetch('/api/rpc/asistencia_get', { + const r = await fetch('/api/v1/rpc/asistencia_get', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ doc, desde, hasta }) }); @@ -237,7 +237,7 @@ return await r.json(); } async function rpcUpdateRaw(id_raw, fecha, hora, modo){ - const r = await fetch('/api/rpc/asistencia_update_raw', { + const r = await fetch('/api/v1/rpc/asistencia_update_raw', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ id_raw, fecha, hora, modo }) }); @@ -245,7 +245,7 @@ return await r.json(); } async function rpcDeleteRaw(id_raw){ - const r = await fetch('/api/rpc/asistencia_delete_raw', { + const r = await fetch('/api/v1/rpc/asistencia_delete_raw', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ id_raw }) }); @@ -414,7 +414,7 @@ } async function importAsistencia(registros, origen){ - const r = await fetch('/api/rpc/import_asistencia', { + const r = await fetch('/api/v1/rpc/import_asistencia', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({ registros, origen }) @@ -579,7 +579,7 @@ async function fetchNamesForDocs(docs){ try { - const r = await fetch('/api/rpc/find_usuarios_por_documentos', { + const r = await fetch('/api/v1/rpc/find_usuarios_por_documentos', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({ docs }) diff --git a/services/manso/src/pages/comandas.html.bak b/services/shared/middlewares/comandas.html.bak similarity index 100% rename from services/manso/src/pages/comandas.html.bak rename to services/shared/middlewares/comandas.html.bak diff --git a/services/manso/src/pages/dashboard.html.bak b/services/shared/middlewares/dashboard.html.bak similarity index 100% rename from services/manso/src/pages/dashboard.html.bak rename to services/shared/middlewares/dashboard.html.bak diff --git a/services/manso/src/pages/estadoComandas.html.bak b/services/shared/middlewares/estadoComandas.html.bak similarity index 100% rename from services/manso/src/pages/estadoComandas.html.bak rename to services/shared/middlewares/estadoComandas.html.bak