feat: endpoints RBAC - GET /mi-ruta/eta y GET /mi-ruta/estado
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const cors = require('cors');
|
const cors = require('cors');
|
||||||
const authRoutes = require('./routes/auth.routes');
|
const authRoutes = require('./routes/auth.routes');
|
||||||
|
const rutaRoutes = require('./routes/ruta.routes');
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
|
|
||||||
@@ -8,12 +9,13 @@ app.use(cors());
|
|||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
|
|
||||||
// ── Rutas ────────────────────────────────────────────────────────
|
// ── Rutas ────────────────────────────────────────────────────────
|
||||||
app.use('/auth', authRoutes);
|
app.use('/auth', authRoutes);
|
||||||
|
app.use('/mi-ruta', rutaRoutes); // GET /mi-ruta/eta GET /mi-ruta/estado
|
||||||
|
|
||||||
// ── Health check ─────────────────────────────────────────────────
|
// ── Health check ─────────────────────────────────────────────────
|
||||||
app.get('/health', (_req, res) => res.json({ status: 'ok' }));
|
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' }));
|
app.use((_req, res) => res.status(404).json({ error: 'Ruta no encontrada' }));
|
||||||
|
|
||||||
module.exports = app;
|
module.exports = app;
|
||||||
271
backend/src/routes/ruta.routes.js
Normal file
271
backend/src/routes/ruta.routes.js
Normal 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;
|
||||||
Reference in New Issue
Block a user