Re estructuración de patrones de diseño con el código de Manso

This commit is contained in:
Mateo Saldain 2025-10-08 18:12:58 +00:00
parent b4c5d2af4f
commit a31b411437
15 changed files with 806 additions and 869 deletions

View File

@ -26,7 +26,7 @@ services:
net: net:
aliases: [manso] aliases: [manso]
healthcheck: 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 interval: 10s
timeout: 3s timeout: 3s
retries: 10 retries: 10

View File

@ -1,11 +1,11 @@
{ {
"name": "workarround", "name": "workarround",
"version": "1.0.0", "version": "1.0.0",
"main": "src/index.js", "main": "src/index.mjs",
"scripts": { "scripts": {
"start": "NODE_ENV=production node ./src/index.js", "start": "NODE_ENV=production node ./src/index.mjs",
"dev": "NODE_ENV=development npx nodemon ./src/index.js", "dev": "NODE_ENV=development npx nodemon ./src/index.mjs",
"test": "NODE_ENV=stage node ./src/index.js" "test": "NODE_ENV=stage node ./src/index.mjs"
}, },
"author": "Mateo Saldain", "author": "Mateo Saldain",
"license": "ISC", "license": "ISC",
@ -25,6 +25,11 @@
"pg-format": "^1.0.4", "pg-format": "^1.0.4",
"serve-favicon": "^2.5.1" "serve-favicon": "^2.5.1"
}, },
"imports": {
"#v1Router": "./src/api/v1/routes/routes.js",
"#pages": "./src/pages/pages.js",
"#db": "./src/db/poolSingleton.js"
},
"keywords": [], "keywords": [],
"description": "Workarround para tener un MVP que llegue al verano para usarse" "description": "Workarround para tener un MVP que llegue al verano para usarse"
} }

View File

@ -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;

View File

@ -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 };

View File

@ -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' });
});

View File

@ -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'}));

View File

@ -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;

View File

@ -253,7 +253,7 @@ async function saveCompra(){
$('#btnGuardar').disabled = true; $('#status').textContent = 'Guardando…'; $('#btnGuardar').disabled = true; $('#status').textContent = 'Guardando…';
try{ 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; $('#id_compra').value = res.id_compra;
$('#btnEliminar').classList.remove('d-none'); $('#btnEliminar').classList.remove('d-none');
$('#formTitle').textContent = 'Editar compra #' + res.id_compra; $('#formTitle').textContent = 'Editar compra #' + res.id_compra;
@ -272,7 +272,7 @@ async function deleteCompra(){
if (!confirm('¿Eliminar compra #' + id + '?')) return; if (!confirm('¿Eliminar compra #' + id + '?')) return;
$('#btnEliminar').disabled = true; $('#btnEliminar').disabled = true;
try{ try{
await jpost('/api/rpc/delete_compra', { id_compra: id }); await jpost('/api/v1/rpc/delete_compra', { id_compra: id });
nuevaCompra(); nuevaCompra();
await loadListado(); await loadListado();
}catch(e){ }catch(e){
@ -295,7 +295,7 @@ function nuevaCompra(){
async function cargarCompra(id){ async function cargarCompra(id){
$('#status').textContent = 'Cargando compra…'; $('#status').textContent = 'Cargando compra…';
try{ 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_compra').value = data.id_compra;
$('#id_proveedor').value = data.id_proveedor; $('#id_proveedor').value = data.id_proveedor;
$('#fec_compra').value = (data.fec_compra || '').replace(' ', 'T').slice(0,16); $('#fec_compra').value = (data.fec_compra || '').replace(' ', 'T').slice(0,16);

View File

@ -105,14 +105,14 @@
// ===== Data ===== // ===== Data =====
async function loadLista() { async function loadLista() {
const estado = state.soloAbiertas ? 'abierta' : ''; 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); const rows = await jget(url);
state.lista = Array.isArray(rows) ? rows : []; state.lista = Array.isArray(rows) ? rows : [];
renderLista(); renderLista();
} }
async function loadDetalle(id) { 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 : []; state.detalle = Array.isArray(det) ? det : [];
renderDetalle(); renderDetalle();
} }
@ -241,7 +241,7 @@
async function accionComanda(accion){ // 'abrir' | 'cerrar' async function accionComanda(accion){ // 'abrir' | 'cerrar'
if (!state.sel) return; if (!state.sel) return;
try { 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); toast(`Comanda #${state.sel} ${accion === 'abrir' ? 'reabierta' : 'cerrada'}`, true);
// Recargar lista y re-aplicar cabecera/detalle si sigue visible // Recargar lista y re-aplicar cabecera/detalle si sigue visible
@ -346,14 +346,14 @@
// ===== Data ===== // ===== Data =====
async function loadLista() { async function loadLista() {
const estado = state.soloAbiertas ? 'abierta' : ''; 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); const rows = await jget(url);
state.lista = Array.isArray(rows) ? rows : []; state.lista = Array.isArray(rows) ? rows : [];
renderLista(); renderLista();
} }
async function loadDetalle(id) { 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 : []; state.detalle = Array.isArray(det) ? det : [];
renderDetalle(); renderDetalle();
} }
@ -478,7 +478,7 @@
async function setEstado(estado){ async function setEstado(estado){
if (!state.sel) return; if (!state.sel) return;
try { 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); toast(`Comanda #${updated.id_comanda} → ${updated.estado}`, true);
await loadLista(); await loadLista();
const found = state.lista.find(x => x.id_comanda === updated.id_comanda); const found = state.lista.find(x => x.id_comanda === updated.id_comanda);

View File

@ -271,7 +271,7 @@
async function loadProducto(id){ async function loadProducto(id){
try { try {
// Usamos la función SQL (RPC) para traer producto + receta en un solo tiro // 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 p = data.producto || {};
const r = Array.isArray(data.receta) ? data.receta : []; const r = Array.isArray(data.receta) ? data.receta : [];
@ -384,7 +384,7 @@
if (!payload.nombre) { toast('Nombre requerido'); return; } if (!payload.nombre) { toast('Nombre requerido'); return; }
if (!(payload.precio >= 0)) { toast('Precio inválido'); 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); toast(`Guardado #${id_producto}`, true);
// refrescar listado y reabrir seleccionado // refrescar listado y reabrir seleccionado
@ -489,7 +489,7 @@
// 5) cargar MP + proveedores asignados (via función SQL) // 5) cargar MP + proveedores asignados (via función SQL)
async function loadMp(id) { async function loadMp(id) {
try { 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 m = data.materia || {};
const provs = Array.isArray(data.proveedores) ? data.proveedores : []; const provs = Array.isArray(data.proveedores) ? data.proveedores : [];
@ -518,7 +518,7 @@
}; };
if (!payload.nombre) { mpToast('Nombre requerido'); return; } 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); mpToast(`Guardado #${r.id_mat_prima}`, true);
// refrescar listas globales // refrescar listas globales

View File

@ -339,7 +339,7 @@ function barsCompareSVG(a,b){ // a=ventas, b=gastos: arrays [{label,value}]
let cacheAsist=[]; let cacheAsist=[];
async function fetchAsistencias(desde,hasta){ 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); } catch { const url=`/api/table/asistencia_detalle?desde=${encodeURIComponent(desde)}&hasta=${encodeURIComponent(hasta)}&limit=10000`; return await jget(url); }
} }
function renderAsistTabla(rows){ function renderAsistTabla(rows){
@ -366,7 +366,7 @@ async function loadAsist(){
} }
$('#repStatus').textContent = 'Cargando asistencias…'; $('#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 ()=>{ .catch(async ()=>{
// fallback a tabla genérica si el RPC no está // fallback a tabla genérica si el RPC no está
const url=`/api/table/asistencia_detalle?desde=${encodeURIComponent(d)}&hasta=${encodeURIComponent(h)}&limit=10000`; 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){ async function fetchTickets(year){
const y = getYearSafe(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){ function renderTickets(data){
@ -523,7 +523,7 @@ function renderTickets(data){
let cacheGastos=null; // {year, months:[{mes,nombre,importe}], total, avg} let cacheGastos=null; // {year, months:[{mes,nombre,importe}], total, avg}
async function fetchGastos(year){ async function fetchGastos(year){
// 1) Intentar RPC // 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 // 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 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})); const months = Array.from({length:12},(_,i)=>({mes:i+1,nombre:monthNames[i],importe:0}));

View File

@ -229,7 +229,7 @@
// RPC helpers // RPC helpers
async function rpcGet(doc, desde, hasta){ 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'}, method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({ doc, desde, hasta }) body: JSON.stringify({ doc, desde, hasta })
}); });
@ -237,7 +237,7 @@
return await r.json(); return await r.json();
} }
async function rpcUpdateRaw(id_raw, fecha, hora, modo){ 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'}, method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({ id_raw, fecha, hora, modo }) body: JSON.stringify({ id_raw, fecha, hora, modo })
}); });
@ -245,7 +245,7 @@
return await r.json(); return await r.json();
} }
async function rpcDeleteRaw(id_raw){ 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'}, method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({ id_raw }) body: JSON.stringify({ id_raw })
}); });
@ -414,7 +414,7 @@
} }
async function importAsistencia(registros, origen){ async function importAsistencia(registros, origen){
const r = await fetch('/api/rpc/import_asistencia', { const r = await fetch('/api/v1/rpc/import_asistencia', {
method: 'POST', method: 'POST',
headers: {'Content-Type':'application/json'}, headers: {'Content-Type':'application/json'},
body: JSON.stringify({ registros, origen }) body: JSON.stringify({ registros, origen })
@ -579,7 +579,7 @@
async function fetchNamesForDocs(docs){ async function fetchNamesForDocs(docs){
try { try {
const r = await fetch('/api/rpc/find_usuarios_por_documentos', { const r = await fetch('/api/v1/rpc/find_usuarios_por_documentos', {
method: 'POST', method: 'POST',
headers: {'Content-Type':'application/json'}, headers: {'Content-Type':'application/json'},
body: JSON.stringify({ docs }) body: JSON.stringify({ docs })