feat: endpoints RBAC - GET /mi-ruta/eta y GET /mi-ruta/estado

This commit is contained in:
2026-05-23 01:31:21 -06:00
parent d1bfc9fbac
commit 0c68e41963
2 changed files with 276 additions and 3 deletions

View File

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

View File

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