From 0c68e419634a83769adc8eeada155fdc3a8b60f1 Mon Sep 17 00:00:00 2001 From: Tu Nombre <22030946@itcelaya.edu.mx> Date: Sat, 23 May 2026 01:31:21 -0600 Subject: [PATCH] feat: endpoints RBAC - GET /mi-ruta/eta y GET /mi-ruta/estado --- backend/src/app.js | 8 +- backend/src/routes/ruta.routes.js | 271 ++++++++++++++++++++++++++++++ 2 files changed, 276 insertions(+), 3 deletions(-) create mode 100644 backend/src/routes/ruta.routes.js diff --git a/backend/src/app.js b/backend/src/app.js index 0ec1737..dfe4b26 100644 --- a/backend/src/app.js +++ b/backend/src/app.js @@ -1,6 +1,7 @@ const express = require('express'); const cors = require('cors'); const authRoutes = require('./routes/auth.routes'); +const rutaRoutes = require('./routes/ruta.routes'); const app = express(); @@ -8,12 +9,13 @@ app.use(cors()); app.use(express.json()); // ── Rutas ──────────────────────────────────────────────────────── -app.use('/auth', authRoutes); +app.use('/auth', authRoutes); +app.use('/mi-ruta', rutaRoutes); // GET /mi-ruta/eta GET /mi-ruta/estado // ── Health check ───────────────────────────────────────────────── app.get('/health', (_req, res) => res.json({ status: 'ok' })); -// ── Manejo de rutas no encontradas ─────────────────────────────── +// ── Ruta no encontrada ──────────────────────────────────────────── app.use((_req, res) => res.status(404).json({ error: 'Ruta no encontrada' })); -module.exports = app; +module.exports = app; \ No newline at end of file diff --git a/backend/src/routes/ruta.routes.js b/backend/src/routes/ruta.routes.js new file mode 100644 index 0000000..ec937eb --- /dev/null +++ b/backend/src/routes/ruta.routes.js @@ -0,0 +1,271 @@ +/** + * ================================================================ + * RUTAS RBAC - Endpoints del ciudadano + * + * GET /mi-ruta/eta → ventana horaria de llegada + * GET /mi-ruta/estado → estado actual + mensaje preventivo + * + * REGLA DE ORO: nunca exponer position_id_actual ni coordenadas. + * El ciudadano solo ve mensajes legibles y su propia ruta. + * ================================================================ + */ + +const express = require('express'); +const pool = require('../config/db'); +const { verificarToken} = require('../middleware/auth.middleware'); + +const router = express.Router(); + +// Todos los endpoints de este router requieren JWT válido +router.use(verificarToken); + +// ── Helpers ────────────────────────────────────────────────────── + +/** + * Formatea un TIME de Postgres ("06:30:00") a "6:30 a.m." / "2:15 p.m." + */ +function formatearHora(timeStr) { + const [h, m] = timeStr.split(':').map(Number); + const periodo = h >= 12 ? 'p.m.' : 'a.m.'; + const h12 = h % 12 || 12; + return `${h12}:${String(m).padStart(2, '0')} ${periodo}`; +} + +/** + * Calcula minutos restantes hasta una fecha estimada + */ +function minutosRestantes(horaEstimFin) { + if (!horaEstimFin) return null; + const diff = new Date(horaEstimFin) - new Date(); + return Math.max(0, Math.round(diff / 60000)); +} + +/** + * Genera el mensaje de ETA legible para el ciudadano + */ +function mensajeETA(estado, horaEstimFin, horaInicioEst, horaFinEst) { + if (estado === 'FINALIZADA') { + return 'El servicio de recolección de hoy ya finalizó en tu zona.'; + } + + if (estado === 'INACTIVA') { + if (horaInicioEst && horaFinEst) { + return `El camión pasará por tu zona entre las ${formatearHora(horaInicioEst)} y las ${formatearHora(horaFinEst)}.`; + } + return 'El camión aún no ha iniciado su ruta hoy.'; + } + + if (estado === 'EN_RUTA' || estado === 'RETRASADA') { + const mins = minutosRestantes(horaEstimFin); + + if (mins === null) { + return horaInicioEst && horaFinEst + ? `El camión llegará a tu zona entre las ${formatearHora(horaInicioEst)} y las ${formatearHora(horaFinEst)}.` + : 'El camión está en camino a tu zona.'; + } + + if (mins <= 0) return 'El camión está llegando a tu zona ahora.'; + if (mins <= 10) return `El camión llegará a tu zona en aproximadamente ${mins} minutos.`; + if (mins <= 30) return `El camión llegará a tu zona en aproximadamente ${mins} minutos.`; + + // Si falta más de 30 min, dar ventana horaria con la colonia + if (horaInicioEst && horaFinEst) { + return `El camión llegará a tu zona entre las ${formatearHora(horaInicioEst)} y las ${formatearHora(horaFinEst)}.`; + } + + return `El camión llegará a tu zona en aproximadamente ${mins} minutos.`; + } + + return 'No hay información disponible en este momento.'; +} + +// ================================================================ +// GET /mi-ruta/eta +// Devuelve la ventana horaria de llegada del camión +// La app Android usa este endpoint para la pantalla principal +// ================================================================ +router.get('/eta', async (req, res) => { + const { routeId, domicilioId } = req.user; + + if (!routeId) { + return res.status(404).json({ + error: 'Tu domicilio aún no tiene una ruta asignada.', + detalle: 'El sistema validará tu dirección pronto.', + }); + } + + try { + const { rows } = await pool.query( + `SELECT + e.estado, + e.hora_estim_fin, + e.minutos_retraso, + e.actualizado_at, + r.route_id, + r.hora_inicio, + r.hora_fin_ref, + c.nombre AS colonia_nombre, + c.horario_turno, + c.hora_inicio_est, + c.hora_fin_est + FROM estado_ruta e + JOIN rutas r ON r.id = e.ruta_id + LEFT JOIN domicilios d ON d.id = $2 + LEFT JOIN colonias c ON c.id = d.colonia_id + WHERE r.route_id = $1`, + [routeId, domicilioId] + ); + + if (rows.length === 0) { + return res.status(404).json({ error: 'Ruta no encontrada' }); + } + + const datos = rows[0]; + const estado = datos.estado; + + const eta = mensajeETA( + estado, + datos.hora_estim_fin, + datos.hora_inicio_est, + datos.hora_fin_est + ); + + // Ventana horaria estructurada (para que la app construya su UI) + const ventana = datos.hora_inicio_est && datos.hora_fin_est + ? { + inicio: formatearHora(datos.hora_inicio_est), + fin: formatearHora(datos.hora_fin_est), + turno: datos.horario_turno, + } + : null; + + return res.json({ + eta, + ventana, + estado, + retrasado: datos.minutos_retraso > 0, + minutos_retraso: datos.minutos_retraso || 0, + actualizado_hace: Math.round( + (new Date() - new Date(datos.actualizado_at)) / 60000 + ) + ' min', + // Mensaje preventivo (requisito del reto) + aviso: estado === 'EN_RUTA' + ? '⚠️ No salgas a buscar el camión. Espera en tu domicilio y ten tus residuos listos.' + : null, + }); + + } catch (err) { + console.error('❌ Error en GET /mi-ruta/eta:', err.message); + return res.status(500).json({ error: 'Error interno del servidor' }); + } +}); + +// ================================================================ +// GET /mi-ruta/estado +// Devuelve el estado completo de la ruta del ciudadano +// Incluye horario de colonia, estado del camión y avisos +// ================================================================ +router.get('/estado', async (req, res) => { + const { routeId, domicilioId } = req.user; + + if (!routeId) { + return res.status(404).json({ + error: 'Tu domicilio aún no tiene una ruta asignada.', + detalle: 'El sistema validará tu dirección pronto.', + }); + } + + try { + const { rows } = await pool.query( + `SELECT + e.estado, + e.hora_estim_fin, + e.minutos_retraso, + e.motivo_retraso, + e.actualizado_at, + r.route_id, + d.alias AS domicilio_alias, + d.direccion_texto, + d.validado, + c.nombre AS colonia_nombre, + c.horario_turno, + c.hora_inicio_est, + c.hora_fin_est + FROM estado_ruta e + JOIN rutas r ON r.id = e.ruta_id + LEFT JOIN domicilios d ON d.id = $2 + LEFT JOIN colonias c ON c.id = d.colonia_id + WHERE r.route_id = $1`, + [routeId, domicilioId] + ); + + if (rows.length === 0) { + return res.status(404).json({ error: 'Ruta no encontrada' }); + } + + const datos = rows[0]; + const estado = datos.estado; + + // Mensajes por estado (citizen-friendly) + const mensajes = { + INACTIVA: 'El camión aún no ha iniciado su ruta hoy.', + EN_RUTA: 'El camión está en camino hacia tu zona.', + RETRASADA: `El camión presenta un retraso de ${datos.minutos_retraso} minutos.`, + FINALIZADA: 'El servicio de recolección de hoy ya finalizó.', + }; + + // Avisos preventivos según estado + const avisos = { + INACTIVA: 'No saques tu basura antes del horario indicado.', + EN_RUTA: '⚠️ No persigas al camión. Espera en tu domicilio con los residuos listos.', + RETRASADA: '⚠️ Tu camión está retrasado. Mantén los residuos en tu domicilio hasta que llegue.', + FINALIZADA: 'Si el camión no pasó hoy, repórtalo en la sección de incidencias.', + }; + + return res.json({ + estado, + mensaje: mensajes[estado] || 'Sin información disponible.', + aviso: avisos[estado] || null, + + // Horario programado de la colonia + horario: datos.hora_inicio_est && datos.hora_fin_est + ? { + turno: datos.horario_turno, + inicio: formatearHora(datos.hora_inicio_est), + fin: formatearHora(datos.hora_fin_est), + } + : null, + + // ETA calculado + eta: mensajeETA( + estado, + datos.hora_estim_fin, + datos.hora_inicio_est, + datos.hora_fin_est + ), + + // Info del domicilio registrado (solo el del usuario) + domicilio: { + alias: datos.domicilio_alias, + colonia: datos.colonia_nombre, + validado: datos.validado, + }, + + // Retraso + retraso: datos.minutos_retraso > 0 + ? { + minutos: datos.minutos_retraso, + motivo: datos.motivo_retraso || 'Causa operativa', + } + : null, + + actualizado_at: datos.actualizado_at, + }); + + } catch (err) { + console.error('❌ Error en GET /mi-ruta/estado:', err.message); + return res.status(500).json({ error: 'Error interno del servidor' }); + } +}); + +module.exports = router;