feat: add more routes

This commit is contained in:
Diego Mireles
2026-05-23 12:24:52 -06:00
parent ad1bf1af3d
commit 01f01ebd0a
26 changed files with 934 additions and 24 deletions

313
backend/README.md Normal file
View File

@@ -0,0 +1,313 @@
# Backend — OptiRuta
API REST en **Node.js + Express + TypeScript** con **Prisma 7** sobre **PostgreSQL**.
> Para el README general del proyecto, ver [../README.md](../README.md).
---
## Tabla de contenidos
- [Stack](#stack)
- [Estructura](#estructura)
- [Arquitectura Clean](#arquitectura-clean)
- [Variables de entorno](#variables-de-entorno)
- [Modelo de datos (Prisma)](#modelo-de-datos-prisma)
- [Cómo correr](#cómo-correr)
- [Módulos](#módulos)
- [El simulador](#el-simulador)
- [Reglas de arquitectura](#reglas-de-arquitectura)
- [Troubleshooting](#troubleshooting)
---
## Stack
- **TypeScript** estricto (`exactOptionalPropertyTypes: true`)
- **Node.js 20+**, modules ESM
- **Express 5** para HTTP
- **Prisma 7** (cliente generado en `src/generated/prisma`)
- **PostgreSQL 15** vía Docker Compose
- **jsonwebtoken** para JWT, **bcryptjs** para hashes
- **tsx** como dev runner (sin Babel, sin reload manual)
---
## Estructura
```
backend/
├── docker-compose.yml ← Postgres en 5433:5432
├── prisma/
│ ├── schema.prisma
│ └── migrations/
├── prisma.config.ts
└── src/
├── app.ts ← entry point: conecta DB, seed, lanza simulador, server
├── config/
│ ├── env.ts ← lectura de variables del .env
│ ├── jwt.ts ← JwtAdapter (sign / validate)
│ └── bcrypt.ts ← BcryptAdapter (hash / compare)
├── data/
│ ├── cache/ ← caches en memoria (notification-cache, route-state, inbox)
│ ├── mocks/ ← rutas, colonias, users de prueba
│ ├── postgres/ ← Prisma client singleton
│ ├── repositories/ ← impls reales (Prisma)
│ ├── seed/ ← seed del admin
│ └── simulation/ ← RouteSimulator (cron interno)
├── domain/
│ ├── dtos/ ← DTOs con validators estáticos
│ ├── errors/ ← CustomError
│ ├── repositories/ ← interfaces abstractas
│ └── use-cases/ ← lógica de negocio
└── presentation/
├── admin/ ← controller + routes
├── auth/
├── addresses/
├── feedback/
├── middlewares/ ← AuthMiddleware (validate + requireAdmin)
├── tracking/
├── routes.ts ← AppRoutes: une todos los módulos
└── server.ts ← clase Server (Express setup)
```
---
## Arquitectura Clean
```
HTTP request
presentation (Express controller)
domain (use-case + DTO validator)
data (repository impl / mock / cache)
```
### Reglas
1. El **controller** solo recibe `req`, llama al use-case, responde JSON, maneja errores con `handleError()`.
2. Los **use-cases** llevan toda la lógica de negocio.
3. Los **DTOs** validan datos de entrada con métodos estáticos `static validate(data: unknown)`.
4. Los **repositories** son interfaces en `domain/repositories/`; las implementaciones reales viven en `data/`.
5. Los **errores** se manejan con `CustomError`: `badRequest`, `unauthorized`, `forbidden`, `notFound`, `conflict`, `internalServer`.
6. JWT solo se maneja desde `JwtAdapter`. Bcrypt solo desde `BcryptAdapter`.
7. Variables de entorno solo en `config/env.ts` (excepción: `.env`).
---
## Variables de entorno
Archivo `.env` (no se sube al repo):
```dotenv
PORT=8080
NODE_ENV=development
DATABASE_URL=postgresql://user:password@localhost:5433/optihack
POSTGRES_USER=user
POSTGRES_PASSWORD=password
POSTGRES_DB=optihack
JWT_SEED=your_super_secret_jwt_seed_here_change_in_production
JWT_EXPIRES_IN=7d
BCRYPT_ROUNDS=10
```
| Variable | Default | Notas |
|---|---|---|
| `PORT` | `3000` | Aquí usamos `8080` porque otra app ocupa 3000 |
| `DATABASE_URL` | — | Apunta a Postgres en 5433 (Docker mapping) |
| `JWT_SEED` | — | Requerido. Cambiar en producción |
| `JWT_EXPIRES_IN` | `7d` | Duración del token |
| `BCRYPT_ROUNDS` | `10` | Costo del hash |
| `POSTGRES_USER` / `POSTGRES_PASSWORD` / `POSTGRES_DB` | — | Usados por `docker-compose.yml` |
---
## Modelo de datos (Prisma)
```prisma
enum UserRole { USER ADMIN }
model User {
id Int @id @default(autoincrement())
name String
email String @unique
password String
phone String? @unique
role UserRole @default(USER)
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
addresses Address[]
@@map("users")
}
model Address {
id Int @id @default(autoincrement())
userId Int
label String
street String
neighborhood String?
postalCode String?
latitude Float?
longitude Float?
isDefault Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@map("addresses")
}
```
> En el MVP solo usamos `User`. `Address` está modelada pero los domicilios se guardan en mocks por ahora — se puede migrar sin tocar los controllers.
---
## Cómo correr
### 1. Postgres con Docker
```powershell
docker compose up -d
docker ps # verifica que postgres:15.3 esté Up
```
### 2. Migraciones + cliente
```powershell
npm install
npx prisma migrate deploy
npx prisma generate
```
### 3. Dev
```powershell
npm run dev
```
Output esperado:
```
Database connected
Seed admin: creado admin@test.com con password "admin123" (cambiar en producción)
RouteSimulator started — tick every 30s
Server is running on port 8080
```
### 4. Production build
```powershell
npm run build # compila a dist/
npm start # corre node dist/app.js
```
### Scripts disponibles
| Script | Hace |
|---|---|
| `npm run dev` | `tsx watch src/app.ts` — hot reload |
| `npm run build` | `tsc` — emite a `dist/` |
| `npm start` | `node dist/app.js` |
---
## Módulos
### Auth (`/api/auth/*`)
- **Registro**: hashea password con `BcryptAdapter`, crea con Prisma, agrega al mock de service con `routeId` default = `RUTA-01`. Siempre rol `USER`.
- **Login**: compara password con bcrypt, genera JWT con `JwtAdapter.generate({ id, email })`. El login response incluye el `role` del DB.
- **GetMe**: protegido con `AuthMiddleware.validate`, devuelve datos del user logueado.
- Si un user legacy no estaba en el mock, el login lo agrega (idempotente con `upsertUser`).
### Tracking (`/api/tracking/*`)
- **POST `/gps-update`**: punto de entrada del simulador (o admin manual). Calcula ETA, evalúa notificaciones, escribe en `route-state` + `inbox`. Respeta cancelación.
- **GET `/status`**: protegido. Devuelve la **visión de túnel** del user — solo su ruta, su ETA, sus notificaciones.
- **POST `/reset-demo`**: limpia caches y estados en memoria sin reiniciar el server.
### Direcciones (`/api/addresses/*`)
- **GET `/colonias`**: catálogo público (mock).
- **GET `/me`**: dirección actual del user.
- **PUT `/me`**: cambia la colonia del user. Valida contra el catálogo; rechaza colonias inexistentes (cumple "validación de zona permitida" del reto).
### Feedback (`/api/feedback/*`)
- **POST**: envía feedback con tipo + mensaje + rating opcional. Tipos: `TRUCK_DID_NOT_PASS`, `RATING`, `SUGGESTION`, `OTHER`.
- **GET `/me`**: feedbacks del user logueado (RBAC).
### Admin (`/api/admin/*`)
Todos requieren `AuthMiddleware.requireAdmin`.
- **GET `/routes`**: lista todas las rutas con estado actual (status, currentPositionId, arrivalResult, cancelled).
- **POST `/routes/:routeId/cancel`**: cancela la ruta. El simulador la pausa. Los users de esa ruta reciben notificación.
- **POST `/routes/:routeId/resume`**: reanuda. Simulator vuelve a positionId 1 y se limpia el dedup cache.
---
## El simulador
`data/simulation/route-simulator.ts`
- Corre `setInterval` cada **30 segundos**
- Solo simula `RUTA-01` por defecto (configurable en `SIMULATED_ROUTE_IDS`)
- Cada tick: avanza el positionIndex de la ruta, dispara `ProcessGpsUpdateUseCase` con la posición correspondiente del mock
- Cuando el ciclo se completa (positionIndex wrap a 0), limpia el dedup cache para que la siguiente vuelta vuelva a emitir notificaciones
- Respeta el flag `cancelled` del estado de ruta: si está cancelada, no avanza
- El admin puede llamar `resetPosition(routeId)` para volver el ciclo a 0
```
positionId 1 → (sin notif, salida del relleno)
positionId 2 → ROUTE_START "¡Ruta Iniciada!"
positionId 3 → (sin notif, en camino)
positionId 4 → TRUCK_PROXIMITY "Camión Cercano" (≈ 12 min de llegada)
positionId 5 → TRUCK_ARRIVED "El camión ya está aquí" (ETA = 0, arrivalResult = ARRIVED)
positionId 8 → ROUTE_COMPLETED "Servicio Finalizado"
wrap → cache cleared, loop back
```
---
## Reglas de arquitectura
Las reglas que mantenemos a lo largo del proyecto:
1. **No lógica pesada en controllers.** Solo: recibir, validar, llamar use-case, responder.
2. **Lógica de negocio en use-cases.**
3. **Prisma solo en `data/repositories` o `data/postgres`.**
4. **Use-cases dependen de interfaces** (`domain/repositories/*`), nunca de Prisma.
5. **DTOs validan entrada** con `static validate()`.
6. **Errores con `CustomError`**.
7. **JWT solo via `JwtAdapter`. Bcrypt solo via `BcryptAdapter`**.
8. **No regresar passwords en ninguna respuesta**.
9. **Las rutas en `presentation`**. Middlewares en `presentation/middlewares`.
10. **Variables de entorno solo en `config/env.ts`**.
11. **No editar archivos generados** (`src/generated/prisma/*`).
---
## Troubleshooting
| Síntoma | Causa | Fix |
|---|---|---|
| `Can't reach database server at localhost:5432` | DATABASE_URL apunta a 5432 pero Docker mapea 5433 | Cambiar puerto en `.env` a `5433` |
| `Prisma client not found` | Cliente no generado | `npx prisma generate` |
| Server arranca pero `/api/...` da 404 | Olvidaste registrar la ruta en `presentation/routes.ts` | Agregar `router.use('/api/X', XRoutes.routes)` |
| Login devuelve 400 con error de Prisma | Tabla `users` no existe | `npx prisma migrate deploy` |
| Admin tab no aparece en el front | Login devolvió role distinto a "ADMIN" | Verifica que el seed haya corrido (mira el log de arranque) |
| Notificaciones repetidas no salen | Dedup cache | Esperar al wrap del simulador o llamar `POST /api/tracking/reset-demo` |
---
## Para extender
- Añadir nuevo módulo X: copia la estructura de `feedback/` (DTO → repo → use-case → controller → route → registrar en `AppRoutes`)
- Migrar un mock a DB real: cambia solo la **impl** en `data/`, las interfaces y use-cases no se tocan
- Conectar Redis: nueva impl de `NotificationCacheRepository` y `RouteStateRepository`, cambia el `new InMemory...` por `new Redis...` en el controller

View File

@@ -6,12 +6,16 @@ import { AppRoutes } from "./presentation/routes.js";
import { TrackingRoutes } from "./presentation/tracking/routes.js"; import { TrackingRoutes } from "./presentation/tracking/routes.js";
import { Server } from "./presentation/server.js"; import { Server } from "./presentation/server.js";
import { prisma } from "./data/postgres/index.js"; import { prisma } from "./data/postgres/index.js";
import { seedAdmin } from "./data/seed/seed-admin.js";
async function main() { async function main() {
try { try {
await prisma.$connect(); await prisma.$connect();
console.log("Database connected"); console.log("Database connected");
// Garantiza que exista la cuenta admin del sistema
await seedAdmin();
// AppRoutes.routes debe leerse ANTES de pedir el simulator para que el // AppRoutes.routes debe leerse ANTES de pedir el simulator para que el
// controller singleton ya esté construido. // controller singleton ya esté construido.
const routes = AppRoutes.routes; const routes = AppRoutes.routes;

View File

@@ -4,10 +4,17 @@
*/ */
import type { import type {
ArrivalResult,
RouteState, RouteState,
RouteStateRepository, RouteStateRepository,
} from "../../domain/repositories/route-state.repository.js"; } from "../../domain/repositories/route-state.repository.js";
const emptyEta = {
etaMinutes: -1,
arrivalWindow: { from: "--:--", to: "--:--" },
message: "Sin datos",
};
export class InMemoryRouteStateRepository implements RouteStateRepository { export class InMemoryRouteStateRepository implements RouteStateRepository {
private readonly states = new Map<string, RouteState>(); private readonly states = new Map<string, RouteState>();
@@ -23,8 +30,50 @@ export class InMemoryRouteStateRepository implements RouteStateRepository {
return Array.from(this.states.values()); return Array.from(this.states.values());
} }
/** Helper para reset de demo. */
async clearAll(): Promise<void> { async clearAll(): Promise<void> {
this.states.clear(); this.states.clear();
} }
async cancel(routeId: string, reason?: string): Promise<void> {
const existing = this.states.get(routeId);
const base: RouteState = existing ?? {
routeId,
currentPositionId: 0,
eta: emptyEta,
status: "ESPERA",
updatedAt: new Date().toISOString(),
arrivalResult: "PENDING",
cancelled: false,
};
const next: RouteState = {
...base,
cancelled: true,
status: "CANCELADO",
arrivalResult: "CANCELLED",
updatedAt: new Date().toISOString(),
};
if (reason !== undefined) next.cancelReason = reason;
this.states.set(routeId, next);
}
async resume(routeId: string): Promise<void> {
const existing = this.states.get(routeId);
if (!existing) return;
const next: RouteState = {
...existing,
cancelled: false,
status: "EN_RUTA",
arrivalResult: "PENDING",
updatedAt: new Date().toISOString(),
};
delete next.cancelReason;
this.states.set(routeId, next);
}
/** Helper para actualizar el arrivalResult sin reescribir el resto. */
async updateArrival(routeId: string, result: ArrivalResult): Promise<void> {
const existing = this.states.get(routeId);
if (!existing) return;
this.states.set(routeId, { ...existing, arrivalResult: result });
}
} }

View File

@@ -14,4 +14,9 @@ export const colonias: Colonia[] = [
{ colonia: "Los Olivos", routeId: "RUTA-04", horarioEstimado: "Matutino (07:00 - 07:40)" }, { colonia: "Los Olivos", routeId: "RUTA-04", horarioEstimado: "Matutino (07:00 - 07:40)" },
{ colonia: "Rancho Seco", routeId: "RUTA-05", horarioEstimado: "Vespertino (14:15 - 15:00)" }, { colonia: "Rancho Seco", routeId: "RUTA-05", horarioEstimado: "Vespertino (14:15 - 15:00)" },
{ colonia: "Las Insurgentes", routeId: "RUTA-12", horarioEstimado: "Matutino (06:35 - 07:10)" }, { colonia: "Las Insurgentes", routeId: "RUTA-12", horarioEstimado: "Matutino (06:35 - 07:10)" },
// Colonias con calendario de separación específico (RUTA-40/65/80)
{ colonia: "Industrial Norte", routeId: "RUTA-40", horarioEstimado: "Matutino (06:00 - 07:00)" },
{ colonia: "Plaza Mayor", routeId: "RUTA-65", horarioEstimado: "Matutino (07:30 - 08:30)" },
{ colonia: "Fracc. Pinos Sur", routeId: "RUTA-80", horarioEstimado: "Matutino (07:00 - 08:00)" },
]; ];

View File

@@ -256,4 +256,52 @@ export const routes: MockRoute[] = [
{ positionId: 8, lat: 20.5111, lng: -100.9037, speed: 41, timestamp: "2026-05-22T07:54:00Z" }, { positionId: 8, lat: 20.5111, lng: -100.9037, speed: 41, timestamp: "2026-05-22T07:54:00Z" },
], ],
}, },
{
routeId: "RUTA-80",
name: "El Campanario - Galaxias del Parque",
truckId: 180,
status: "EN_RUTA",
positions: [
{ positionId: 1, lat: 20.5111, lng: -100.9037, speed: 0, timestamp: "2026-05-22T06:05:00Z" },
{ positionId: 2, lat: 20.5425, lng: -100.8720, speed: 42, timestamp: "2026-05-22T06:18:00Z" },
{ positionId: 3, lat: 20.5510, lng: -100.8550, speed: 28, timestamp: "2026-05-22T06:32:00Z" },
{ positionId: 4, lat: 20.5545, lng: -100.8480, speed: 16, timestamp: "2026-05-22T06:46:00Z" },
{ positionId: 5, lat: 20.5580, lng: -100.8410, speed: 0, timestamp: "2026-05-22T07:00:00Z" },
{ positionId: 6, lat: 20.5540, lng: -100.8360, speed: 15, timestamp: "2026-05-22T07:12:00Z" },
{ positionId: 7, lat: 20.5470, lng: -100.8480, speed: 26, timestamp: "2026-05-22T07:25:00Z" },
{ positionId: 8, lat: 20.5111, lng: -100.9037, speed: 40, timestamp: "2026-05-22T07:48:00Z" },
],
},
{
routeId: "RUTA-65",
name: "Arboledas 3ra Seccion - Av. Constituyentes",
truckId: 165,
status: "EN_RUTA",
positions: [
{ positionId: 1, lat: 20.5111, lng: -100.9037, speed: 0, timestamp: "2026-05-22T06:12:00Z" },
{ positionId: 2, lat: 20.4980, lng: -100.8790, speed: 36, timestamp: "2026-05-22T06:25:00Z" },
{ positionId: 3, lat: 20.4920, lng: -100.8620, speed: 25, timestamp: "2026-05-22T06:38:00Z" },
{ positionId: 4, lat: 20.4890, lng: -100.8500, speed: 14, timestamp: "2026-05-22T06:51:00Z" },
{ positionId: 5, lat: 20.4860, lng: -100.8380, speed: 0, timestamp: "2026-05-22T07:03:00Z" },
{ positionId: 6, lat: 20.4895, lng: -100.8340, speed: 18, timestamp: "2026-05-22T07:15:00Z" },
{ positionId: 7, lat: 20.4950, lng: -100.8480, speed: 24, timestamp: "2026-05-22T07:28:00Z" },
{ positionId: 8, lat: 20.5111, lng: -100.9037, speed: 38, timestamp: "2026-05-22T07:50:00Z" },
],
},
{
routeId: "RUTA-40",
name: "Frac. Parque Central Alameda - Bosques de la Alameda",
truckId: 140,
status: "EN_RUTA",
positions: [
{ positionId: 1, lat: 20.5111, lng: -100.9037, speed: 0, timestamp: "2026-05-22T06:08:00Z" },
{ positionId: 2, lat: 20.5295, lng: -100.7650, speed: 48, timestamp: "2026-05-22T06:20:00Z" },
{ positionId: 3, lat: 20.5340, lng: -100.7500, speed: 30, timestamp: "2026-05-22T06:33:00Z" },
{ positionId: 4, lat: 20.5365, lng: -100.7420, speed: 18, timestamp: "2026-05-22T06:46:00Z" },
{ positionId: 5, lat: 20.5390, lng: -100.7350, speed: 0, timestamp: "2026-05-22T06:59:00Z" },
{ positionId: 6, lat: 20.5350, lng: -100.7380, speed: 16, timestamp: "2026-05-22T07:11:00Z" },
{ positionId: 7, lat: 20.5300, lng: -100.7520, speed: 28, timestamp: "2026-05-22T07:24:00Z" },
{ positionId: 8, lat: 20.5111, lng: -100.9037, speed: 45, timestamp: "2026-05-22T07:49:00Z" },
],
},
]; ];

View File

@@ -16,17 +16,30 @@ export class AuthRepositoryImpl implements AuthRepository {
email: true, email: true,
password: true, password: true,
name: true, name: true,
role: true,
}, },
}); });
} }
async create(data: { name: string; email: string; password: string }) { async create(data: {
name: string;
email: string;
password: string;
role?: string;
}) {
return prisma.user.create({ return prisma.user.create({
data, data: {
name: data.name,
email: data.email,
password: data.password,
// role solo si fue pasado explícitamente; default = USER (en Prisma)
...(data.role ? { role: data.role as "USER" | "ADMIN" } : {}),
},
select: { select: {
id: true, id: true,
email: true, email: true,
name: true, name: true,
role: true,
}, },
}); });
} }

View File

@@ -0,0 +1,51 @@
/**
* seed-admin.ts
*
* Crea (o repara) el usuario admin del sistema al arrancar el server.
*
* - Si NO existe admin@test.com → lo crea con role=ADMIN
* - Si SÍ existe pero su rol fue degradado → lo eleva de nuevo a ADMIN
*
* Idempotente: se puede ejecutar en cada arranque sin efectos secundarios.
*
* Para producción: mover las credenciales a env vars y/o ejecutar este seed
* solo una vez via comando manual, no en cada startup.
*/
import { prisma } from "../postgres/index.js";
import { BcryptAdapter } from "../../config/bcrypt.js";
const ADMIN_EMAIL = "admin@test.com";
const ADMIN_PASSWORD = "admin123";
const ADMIN_NAME = "Administrador";
export async function seedAdmin(): Promise<void> {
const existing = await prisma.user.findUnique({
where: { email: ADMIN_EMAIL },
});
if (existing) {
if (existing.role !== "ADMIN") {
await prisma.user.update({
where: { id: existing.id },
data: { role: "ADMIN" },
});
console.log(`Seed admin: promovido ${ADMIN_EMAIL} a ADMIN`);
}
return;
}
const hash = await BcryptAdapter.hash(ADMIN_PASSWORD);
await prisma.user.create({
data: {
name: ADMIN_NAME,
email: ADMIN_EMAIL,
password: hash,
role: "ADMIN",
},
});
console.log(
`Seed admin: creado ${ADMIN_EMAIL} con password "${ADMIN_PASSWORD}" (cambiar en producción)`,
);
}

View File

@@ -4,12 +4,17 @@
* y dispara el ProcessGpsUpdateUseCase. Reemplaza al "pull to refresh" del * y dispara el ProcessGpsUpdateUseCase. Reemplaza al "pull to refresh" del
* frontend para que el flujo sea más realista en la demo. * frontend para que el flujo sea más realista en la demo.
* *
* Solo simula RUTA-01 por defecto para enfocar la demo, pero se puede * Cuando un camión termina su ciclo (positionId=8 → vuelve a 1), se limpia
* activar para todas las rutas si se desea. * el cache de dedup para que las notificaciones se vuelvan a disparar en
* la siguiente vuelta. El inbox NO se limpia para conservar el historial.
*
* Solo simula RUTA-01 por defecto para enfocar la demo.
*/ */
import { routes } from "../mocks/routes.mock.js"; import { routes } from "../mocks/routes.mock.js";
import type { ProcessGpsUpdateUseCase } from "../../domain/use-cases/notifications/process-gps-update.use-case.js"; import type { ProcessGpsUpdateUseCase } from "../../domain/use-cases/notifications/process-gps-update.use-case.js";
import type { NotificationCacheRepository } from "../../domain/repositories/notification-cache.repository.js";
import type { RouteStateRepository } from "../../domain/repositories/route-state.repository.js";
const TICK_INTERVAL_MS = 30_000; // 30 segundos const TICK_INTERVAL_MS = 30_000; // 30 segundos
const SIMULATED_ROUTE_IDS = ["RUTA-01"]; const SIMULATED_ROUTE_IDS = ["RUTA-01"];
@@ -18,7 +23,11 @@ export class RouteSimulator {
private timer: NodeJS.Timeout | null = null; private timer: NodeJS.Timeout | null = null;
private positionIndexByRoute = new Map<string, number>(); private positionIndexByRoute = new Map<string, number>();
constructor(private readonly processGpsUpdate: ProcessGpsUpdateUseCase) {} constructor(
private readonly processGpsUpdate: ProcessGpsUpdateUseCase,
private readonly dedupCache: NotificationCacheRepository,
private readonly routeState: RouteStateRepository,
) {}
start() { start() {
if (this.timer) return; if (this.timer) return;
@@ -30,7 +39,9 @@ export class RouteSimulator {
void this.tick(); void this.tick();
}, TICK_INTERVAL_MS); }, TICK_INTERVAL_MS);
console.log(`RouteSimulator started — tick every ${TICK_INTERVAL_MS / 1000}s`); console.log(
`RouteSimulator started — tick every ${TICK_INTERVAL_MS / 1000}s`,
);
} }
stop() { stop() {
@@ -40,11 +51,26 @@ export class RouteSimulator {
} }
} }
/**
* Reinicia el índice de posición de una ruta al inicio (positionId 1).
* Usado por el admin al reanudar una ruta cancelada para que el ciclo
* vuelva a comenzar desde cero.
*/
resetPosition(routeId: string): void {
this.positionIndexByRoute.set(routeId, 0);
}
private async tick() { private async tick() {
for (const routeId of SIMULATED_ROUTE_IDS) { for (const routeId of SIMULATED_ROUTE_IDS) {
const route = routes.find((r) => r.routeId === routeId); const route = routes.find((r) => r.routeId === routeId);
if (!route) continue; if (!route) continue;
// Si el admin canceló esta ruta, no avanzamos
const state = await this.routeState.get(routeId);
if (state?.cancelled) {
continue;
}
const index = this.positionIndexByRoute.get(routeId) ?? 0; const index = this.positionIndexByRoute.get(routeId) ?? 0;
const position = route.positions[index]; const position = route.positions[index];
if (!position) continue; if (!position) continue;
@@ -64,8 +90,15 @@ export class RouteSimulator {
console.error(`Simulator tick failed for ${routeId}:`, err); console.error(`Simulator tick failed for ${routeId}:`, err);
} }
// Avanza al siguiente; vuelve a empezar al terminar el array // Avanza al siguiente; al terminar el array vuelve al inicio Y limpia
// el cache de dedup para que el próximo ciclo sí re-emita notificaciones.
const nextIndex = (index + 1) % route.positions.length; const nextIndex = (index + 1) % route.positions.length;
if (nextIndex < index) {
await this.dedupCache.clear();
console.log(
`RouteSimulator: ${routeId} completó ciclo — cache de dedup limpiado`,
);
}
this.positionIndexByRoute.set(routeId, nextIndex); this.positionIndexByRoute.set(routeId, nextIndex);
} }
} }

View File

@@ -17,6 +17,10 @@ export class CustomError extends Error {
return new CustomError(message, 401); return new CustomError(message, 401);
} }
static forbidden(message: string = "Forbidden"): CustomError {
return new CustomError(message, 403);
}
static notFound(message: string): CustomError { static notFound(message: string): CustomError {
return new CustomError(message, 404); return new CustomError(message, 404);
} }

View File

@@ -1,7 +1,7 @@
export interface AuthRepository { export interface AuthRepository {
findByEmail(email: string): Promise<{ id: number; email: string; password: string; name: string } | null>; findByEmail(email: string): Promise<{ id: number; email: string; password: string; name: string; role: string } | null>;
create(data: { name: string; email: string; password: string }): Promise<{ id: number; email: string; name: string }>; create(data: { name: string; email: string; password: string; role?: string }): Promise<{ id: number; email: string; name: string; role: string }>;
findById(id: number): Promise<{ id: number; email: string; name: string; role: string } | null>; findById(id: number): Promise<{ id: number; email: string; name: string; role: string } | null>;
} }

View File

@@ -9,6 +9,11 @@ import type { FeedbackType } from "../dtos/feedback/submit-feedback.dto.js";
export interface FeedbackItem { export interface FeedbackItem {
id: string; id: string;
userId: number; userId: number;
/** routeId del usuario en el momento del reporte. */
routeId: string | null;
/** Nombre + colonia del usuario para que el admin no haga lookup extra. */
userName?: string;
colonia?: string;
type: FeedbackType; type: FeedbackType;
message: string; message: string;
rating?: number; rating?: number;

View File

@@ -10,16 +10,33 @@
import type { EtaResult } from "../use-cases/notifications/calculate-eta.use-case.js"; import type { EtaResult } from "../use-cases/notifications/calculate-eta.use-case.js";
/**
* Resultado de llegada del camión a la zona del usuario:
* PENDING - aún no llega o no se ha evaluado
* ARRIVED - el camión sí pasó (positionId >= 5)
* FAILED - hubo falla mecánica antes de llegar
* CANCELLED - admin canceló la ruta
*/
export type ArrivalResult = "PENDING" | "ARRIVED" | "FAILED" | "CANCELLED";
export interface RouteState { export interface RouteState {
routeId: string; routeId: string;
currentPositionId: number; currentPositionId: number;
eta: EtaResult; eta: EtaResult;
status: string; status: string;
updatedAt: string; updatedAt: string;
arrivalResult: ArrivalResult;
/** True si el admin canceló esta ruta — el simulador la pausa. */
cancelled: boolean;
cancelReason?: string;
} }
export interface RouteStateRepository { export interface RouteStateRepository {
get(routeId: string): Promise<RouteState | null>; get(routeId: string): Promise<RouteState | null>;
set(state: RouteState): Promise<void>; set(state: RouteState): Promise<void>;
getAll(): Promise<RouteState[]>; getAll(): Promise<RouteState[]>;
/** Cancela la ruta (la pausa). El simulador deja de avanzarla. */
cancel(routeId: string, reason?: string): Promise<void>;
/** Reactiva una ruta previamente cancelada. */
resume(routeId: string): Promise<void>;
} }

View File

@@ -0,0 +1,47 @@
/**
* cancel-route.use-case.ts
* El admin cancela una ruta. El simulador pausa esa ruta y se notifica
* a todos los usuarios suscritos a la misma.
*/
import { CustomError } from "../../errors/custom.error.js";
import { routes as routeCatalog } from "../../../data/mocks/routes.mock.js";
import { users } from "../../../data/mocks/users.mock.js";
import type { RouteStateRepository } from "../../repositories/route-state.repository.js";
import type {
InboxNotification,
NotificationInboxRepository,
} from "../../repositories/notification-inbox.repository.js";
export class CancelRouteUseCase {
constructor(
private readonly routeState: RouteStateRepository,
private readonly inbox: NotificationInboxRepository,
) {}
async execute(routeId: string, reason?: string): Promise<void> {
const route = routeCatalog.find((r) => r.routeId === routeId);
if (!route) {
throw CustomError.notFound(`Route ${routeId} not found`);
}
await this.routeState.cancel(routeId, reason);
// Avisa a todos los usuarios de esa ruta
const reasonText = reason ? ` Motivo: ${reason}` : "";
const message = `La ruta matutina de tu sector fue cancelada.${reasonText} Se reprogramará para la tarde.`;
const subscribers = users.filter((u) => u.routeId === routeId);
for (const user of subscribers) {
const item: InboxNotification = {
id: `${user.id}-${routeId}-CANCELLED-${Date.now()}`,
userId: user.id,
type: "ROUTE_COMPLETED", // reusamos el tipo "info" del mock
title: "Ruta cancelada",
body: message,
createdAt: new Date().toISOString(),
};
await this.inbox.addForUser(item);
}
}
}

View File

@@ -0,0 +1,19 @@
/**
* list-all-feedback.use-case.ts
* Solo accesible por admin. Devuelve TODOS los reportes que han mandado
* los ciudadanos a través del buzón, ordenados del más reciente al más
* antiguo.
*/
import type {
FeedbackItem,
FeedbackRepository,
} from "../../repositories/feedback.repository.js";
export class ListAllFeedbackUseCase {
constructor(private readonly repository: FeedbackRepository) {}
async execute(): Promise<FeedbackItem[]> {
return this.repository.listAll();
}
}

View File

@@ -0,0 +1,45 @@
/**
* list-routes.use-case.ts
* Lista TODAS las rutas con su estado operativo actual.
* Solo accesible por administradores.
*/
import { routes as routeCatalog } from "../../../data/mocks/routes.mock.js";
import type { RouteStateRepository } from "../../repositories/route-state.repository.js";
export interface AdminRouteItem {
routeId: string;
name: string;
truckId: number;
status: string;
currentPositionId: number;
arrivalResult: string;
cancelled: boolean;
cancelReason?: string;
updatedAt?: string;
}
export class ListRoutesUseCase {
constructor(private readonly routeState: RouteStateRepository) {}
async execute(): Promise<AdminRouteItem[]> {
const states = await this.routeState.getAll();
const stateMap = new Map(states.map((s) => [s.routeId, s]));
return routeCatalog.map((r) => {
const s = stateMap.get(r.routeId);
const item: AdminRouteItem = {
routeId: r.routeId,
name: r.name,
truckId: r.truckId,
status: s?.status ?? "ESPERA",
currentPositionId: s?.currentPositionId ?? 0,
arrivalResult: s?.arrivalResult ?? "PENDING",
cancelled: s?.cancelled ?? false,
};
if (s?.cancelReason) item.cancelReason = s.cancelReason;
if (s?.updatedAt) item.updatedAt = s.updatedAt;
return item;
});
}
}

View File

@@ -0,0 +1,42 @@
/**
* resume-route.use-case.ts
* El admin reanuda una ruta cancelada.
*/
import { CustomError } from "../../errors/custom.error.js";
import { routes as routeCatalog } from "../../../data/mocks/routes.mock.js";
import { users } from "../../../data/mocks/users.mock.js";
import type { RouteStateRepository } from "../../repositories/route-state.repository.js";
import type {
InboxNotification,
NotificationInboxRepository,
} from "../../repositories/notification-inbox.repository.js";
export class ResumeRouteUseCase {
constructor(
private readonly routeState: RouteStateRepository,
private readonly inbox: NotificationInboxRepository,
) {}
async execute(routeId: string): Promise<void> {
const route = routeCatalog.find((r) => r.routeId === routeId);
if (!route) {
throw CustomError.notFound(`Route ${routeId} not found`);
}
await this.routeState.resume(routeId);
const subscribers = users.filter((u) => u.routeId === routeId);
for (const user of subscribers) {
const item: InboxNotification = {
id: `${user.id}-${routeId}-RESUMED-${Date.now()}`,
userId: user.id,
type: "ROUTE_START",
title: "Ruta reanudada",
body: "El servicio de recolección de tu sector fue reanudado.",
createdAt: new Date().toISOString(),
};
await this.inbox.addForUser(item);
}
}
}

View File

@@ -17,6 +17,7 @@ export interface LoginUserResponse {
id: number; id: number;
email: string; email: string;
name: string; name: string;
role: string;
}; };
token: string; token: string;
} }
@@ -40,10 +41,11 @@ export class LoginUserUseCase {
throw CustomError.unauthorized("Invalid credentials"); throw CustomError.unauthorized("Invalid credentials");
} }
// Si el user no estaba en el mock de servicio (porque se creó antes de // Si el user normal no estaba en el mock de servicio, lo agregamos con
// esta lógica), lo agregamos con valores default. Idempotente. // valores default. Los admins NO se agregan al mock porque no reciben
// notificaciones de ruta. Idempotente.
const inMock = users.find((u) => u.id === user.id); const inMock = users.find((u) => u.id === user.id);
if (!inMock) { if (!inMock && user.role !== "ADMIN") {
upsertUser({ upsertUser({
id: user.id, id: user.id,
name: user.name, name: user.name,
@@ -64,6 +66,7 @@ export class LoginUserUseCase {
id: user.id, id: user.id,
email: user.email, email: user.email,
name: user.name, name: user.name,
role: user.role,
}, },
token, token,
}; };

View File

@@ -2,6 +2,8 @@
* submit-feedback.use-case.ts * submit-feedback.use-case.ts
* Recibe el DTO, lo valida y guarda el feedback. * Recibe el DTO, lo valida y guarda el feedback.
* El userId viene del JWT (req.user.id), no del body — RBAC. * El userId viene del JWT (req.user.id), no del body — RBAC.
* Adicionalmente, el use-case busca la ruta y colonia del user para que
* el admin pueda ver de qué ruta proviene cada reporte.
*/ */
import { SubmitFeedbackDtoValidator } from "../../dtos/index.js"; import { SubmitFeedbackDtoValidator } from "../../dtos/index.js";
@@ -9,6 +11,7 @@ import type {
FeedbackItem, FeedbackItem,
FeedbackRepository, FeedbackRepository,
} from "../../repositories/feedback.repository.js"; } from "../../repositories/feedback.repository.js";
import { users } from "../../../data/mocks/users.mock.js";
export class SubmitFeedbackUseCase { export class SubmitFeedbackUseCase {
constructor(private readonly repository: FeedbackRepository) {} constructor(private readonly repository: FeedbackRepository) {}
@@ -16,13 +19,19 @@ export class SubmitFeedbackUseCase {
async execute(userId: number, data: unknown): Promise<FeedbackItem> { async execute(userId: number, data: unknown): Promise<FeedbackItem> {
const dto = SubmitFeedbackDtoValidator.validate(data); const dto = SubmitFeedbackDtoValidator.validate(data);
// Lookup contextual del user para enriquecer el reporte
const user = users.find((u) => u.id === userId);
const item: FeedbackItem = { const item: FeedbackItem = {
id: `${userId}-${Date.now()}`, id: `${userId}-${Date.now()}`,
userId, userId,
routeId: user?.routeId ?? null,
type: dto.type, type: dto.type,
message: dto.message, message: dto.message,
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
}; };
if (user?.name) item.userName = user.name;
if (user?.colonia) item.colonia = user.colonia;
if (dto.rating !== undefined) item.rating = dto.rating; if (dto.rating !== undefined) item.rating = dto.rating;
await this.repository.save(item); await this.repository.save(item);

View File

@@ -35,9 +35,33 @@ export class ProcessGpsUpdateUseCase {
throw CustomError.notFound(`Route ${dto.routeId} not found`); throw CustomError.notFound(`Route ${dto.routeId} not found`);
} }
// Si el admin canceló esta ruta, no procesamos GPS updates
const existingState = await this.routeState.get(route.routeId);
if (existingState?.cancelled) {
return {
message: "Route cancelled — GPS update ignored",
truck: {
truckId: route.truckId,
routeId: route.routeId,
status: "CANCELADO",
},
eta: existingState.eta,
notifications: [],
};
}
const eta = this.calculateEta.execute(dto); const eta = this.calculateEta.execute(dto);
const notifications = await this.evaluateNotification.execute(dto); const notifications = await this.evaluateNotification.execute(dto);
// Derivar arrivalResult según positionId y status:
// - FALLA → FAILED
// - positionId >= 5 → ARRIVED (camión ya llegó a tu zona)
// - resto → PENDING
let arrivalResult: "PENDING" | "ARRIVED" | "FAILED" | "CANCELLED" =
"PENDING";
if (dto.status === "FALLA") arrivalResult = "FAILED";
else if ((dto.positionId ?? 0) >= 5) arrivalResult = "ARRIVED";
// 1) Guardar el estado actual de la ruta // 1) Guardar el estado actual de la ruta
await this.routeState.set({ await this.routeState.set({
routeId: route.routeId, routeId: route.routeId,
@@ -45,6 +69,8 @@ export class ProcessGpsUpdateUseCase {
eta, eta,
status: dto.status, status: dto.status,
updatedAt: dto.timestamp ?? new Date().toISOString(), updatedAt: dto.timestamp ?? new Date().toISOString(),
arrivalResult,
cancelled: false,
}); });
// 2) Empujar cada notificación al buzón del usuario destinatario // 2) Empujar cada notificación al buzón del usuario destinatario

View File

@@ -24,6 +24,8 @@ export interface UserStatusResponse {
status: string; status: string;
updatedAt: string; updatedAt: string;
horarioEstimado: string | null; horarioEstimado: string | null;
arrivalResult: "PENDING" | "ARRIVED" | "FAILED" | "CANCELLED";
cancelled: boolean;
}; };
eta: EtaResult | null; eta: EtaResult | null;
notifications: InboxNotification[]; notifications: InboxNotification[];
@@ -53,6 +55,8 @@ export class GetUserStatusUseCase {
status: state?.status ?? "ESPERA", status: state?.status ?? "ESPERA",
updatedAt: state?.updatedAt ?? new Date().toISOString(), updatedAt: state?.updatedAt ?? new Date().toISOString(),
horarioEstimado: colonia?.horarioEstimado ?? null, horarioEstimado: colonia?.horarioEstimado ?? null,
arrivalResult: state?.arrivalResult ?? "PENDING",
cancelled: state?.cancelled ?? false,
}, },
eta: state?.eta ?? null, eta: state?.eta ?? null,
notifications, notifications,

View File

@@ -0,0 +1,100 @@
/**
* controller.ts (admin)
*
* GET /api/admin/routes - listar todas las rutas + estado
* POST /api/admin/routes/:id/cancel - cancelar ruta (con motivo opcional)
* POST /api/admin/routes/:id/resume - reanudar ruta cancelada
*
* Todos los endpoints requieren AuthMiddleware.validate + requireAdmin.
*/
import { Response } from "express";
import { AuthRequest } from "../middlewares/auth.middleware.js";
import { CustomError } from "../../domain/errors/custom.error.js";
import { ListRoutesUseCase } from "../../domain/use-cases/admin/list-routes.use-case.js";
import { CancelRouteUseCase } from "../../domain/use-cases/admin/cancel-route.use-case.js";
import { ResumeRouteUseCase } from "../../domain/use-cases/admin/resume-route.use-case.js";
import { ListAllFeedbackUseCase } from "../../domain/use-cases/admin/list-all-feedback.use-case.js";
import { TrackingController } from "../tracking/controller.js";
import { FeedbackController } from "../feedback/controller.js";
export class AdminController {
private listRoutes = new ListRoutesUseCase(
TrackingController.getRouteStateRepository(),
);
private cancelRoute = new CancelRouteUseCase(
TrackingController.getRouteStateRepository(),
TrackingController.getNotificationInbox(),
);
private resumeRoute = new ResumeRouteUseCase(
TrackingController.getRouteStateRepository(),
TrackingController.getNotificationInbox(),
);
private listAllFeedback = new ListAllFeedbackUseCase(
FeedbackController.getRepository(),
);
routes = async (_req: AuthRequest, res: Response) => {
try {
const result = await this.listRoutes.execute();
res.status(200).json(result);
} catch (error) {
this.handleError(error, res);
}
};
feedback = async (_req: AuthRequest, res: Response) => {
try {
const result = await this.listAllFeedback.execute();
res.status(200).json(result);
} catch (error) {
this.handleError(error, res);
}
};
cancel = async (req: AuthRequest, res: Response) => {
try {
const routeId = String(req.params.routeId ?? "");
if (!routeId) throw CustomError.badRequest("routeId is required");
const reason =
typeof req.body?.reason === "string" ? req.body.reason : undefined;
await this.cancelRoute.execute(routeId, reason);
// Resetea el índice del simulador para que al reanudar vuelva a 1
TrackingController.getSimulator()?.resetPosition(routeId);
res.status(200).json({ message: "Route cancelled", routeId });
} catch (error) {
this.handleError(error, res);
}
};
resume = async (req: AuthRequest, res: Response) => {
try {
const routeId = String(req.params.routeId ?? "");
if (!routeId) throw CustomError.badRequest("routeId is required");
await this.resumeRoute.execute(routeId);
// Al reanudar: simulator vuelve a positionId 1 + limpia dedup cache
// para que las notificaciones del ciclo nuevo se vuelvan a emitir.
TrackingController.getSimulator()?.resetPosition(routeId);
await TrackingController.getDedupCache().clear();
res.status(200).json({ message: "Route resumed", routeId });
} catch (error) {
this.handleError(error, res);
}
};
private handleError(error: unknown, res: Response): void {
if (error instanceof CustomError) {
res.status(error.statusCode).json({ error: error.message });
return;
}
if (error instanceof Error) {
res.status(400).json({ error: error.message });
return;
}
res.status(500).json({ error: "Internal server error" });
}
}

View File

@@ -0,0 +1,20 @@
import { Router } from "express";
import { AdminController } from "./controller.js";
import { AuthMiddleware } from "../middlewares/auth.middleware.js";
export class AdminRoutes {
static get routes(): Router {
const router = Router();
const controller = new AdminController();
router.use(AuthMiddleware.validate, AuthMiddleware.requireAdmin);
router.get("/routes", controller.routes);
router.post("/routes/:routeId/cancel", controller.cancel);
router.post("/routes/:routeId/resume", controller.resume);
router.get("/feedback", controller.feedback);
return router;
}
}

View File

@@ -17,6 +17,11 @@ export class FeedbackController {
private submit = new SubmitFeedbackUseCase(FeedbackController.repository); private submit = new SubmitFeedbackUseCase(FeedbackController.repository);
private listMine = new ListMyFeedbackUseCase(FeedbackController.repository); private listMine = new ListMyFeedbackUseCase(FeedbackController.repository);
/** Expone el repo singleton al módulo admin. */
static getRepository() {
return FeedbackController.repository;
}
create = async (req: AuthRequest, res: Response) => { create = async (req: AuthRequest, res: Response) => {
try { try {
if (!req.user) throw CustomError.unauthorized("User not authenticated"); if (!req.user) throw CustomError.unauthorized("User not authenticated");

View File

@@ -1,18 +1,23 @@
import { Request, Response, NextFunction } from "express"; import { Request, Response, NextFunction } from "express";
import { JwtAdapter, JwtPayload } from "../../config/jwt.js"; import { JwtAdapter } from "../../config/jwt.js";
import { AuthRepositoryImpl } from "../../data/repositories/auth.repository.impl.js"; import { AuthRepositoryImpl } from "../../data/repositories/auth.repository.impl.js";
import { CustomError } from "../../domain/errors/custom.error.js"; import { CustomError } from "../../domain/errors/custom.error.js";
export interface AuthUser {
id: number;
email: string;
role: string;
}
export interface AuthRequest extends Request { export interface AuthRequest extends Request {
user?: JwtPayload; user?: AuthUser;
} }
export class AuthMiddleware { export class AuthMiddleware {
static async validate(req: AuthRequest, res: Response, next: NextFunction) { static async validate(req: AuthRequest, res: Response, next: NextFunction) {
try { try {
const authHeader = req.headers.authorization; const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith("Bearer ")) { if (!authHeader || !authHeader.startsWith("Bearer ")) {
throw CustomError.unauthorized("Missing or invalid Authorization header"); throw CustomError.unauthorized("Missing or invalid Authorization header");
@@ -20,21 +25,19 @@ export class AuthMiddleware {
const token = authHeader.substring(7); const token = authHeader.substring(7);
const payload = JwtAdapter.validate(token); const payload = JwtAdapter.validate(token);
if (!payload) { if (!payload) {
throw CustomError.unauthorized("Invalid token"); throw CustomError.unauthorized("Invalid token");
} }
const repository = new AuthRepositoryImpl(); const repository = new AuthRepositoryImpl();
const user = await repository.findById(payload.id); const user = await repository.findById(payload.id);
if (!user) { if (!user) {
throw CustomError.unauthorized("User not found"); throw CustomError.unauthorized("User not found");
} }
// Exponemos id + email + role (no name) en req.user.
req.user = payload; req.user = { id: user.id, email: user.email, role: user.role };
next(); next();
} catch (error) { } catch (error) {
@@ -44,4 +47,16 @@ export class AuthMiddleware {
res.status(500).json({ error: "Internal server error" }); res.status(500).json({ error: "Internal server error" });
} }
} }
/** Requiere que el usuario logueado tenga role=ADMIN. Debe encadenarse
* después de `validate`. */
static requireAdmin(req: AuthRequest, res: Response, next: NextFunction) {
if (!req.user) {
return res.status(401).json({ error: "Unauthorized" });
}
if (req.user.role !== "ADMIN") {
return res.status(403).json({ error: "Admin role required" });
}
next();
}
} }

View File

@@ -3,13 +3,17 @@ import { AuthRoutes } from "./auth/routes.js";
import { TrackingRoutes } from "./tracking/routes.js"; import { TrackingRoutes } from "./tracking/routes.js";
import { FeedbackRoutes } from "./feedback/routes.js"; import { FeedbackRoutes } from "./feedback/routes.js";
import { AddressesRoutes } from "./addresses/routes.js"; import { AddressesRoutes } from "./addresses/routes.js";
import { AdminRoutes } from "./admin/routes.js";
export class AppRoutes { export class AppRoutes {
static get routes(): Router { static get routes(): Router {
const router = Router(); const router = Router();
router.use('/api/auth', AuthRoutes.routes); router.use('/api/auth', AuthRoutes.routes);
// Importante: el módulo admin DEPENDE de los singletons de tracking
// (route-state, inbox). Por eso tracking debe registrarse ANTES que admin.
router.use('/api/tracking', TrackingRoutes.routes); router.use('/api/tracking', TrackingRoutes.routes);
router.use('/api/admin', AdminRoutes.routes);
router.use('/api/feedback', FeedbackRoutes.routes); router.use('/api/feedback', FeedbackRoutes.routes);
router.use('/api/addresses', AddressesRoutes.routes); router.use('/api/addresses', AddressesRoutes.routes);

View File

@@ -40,9 +40,38 @@ export class TrackingController {
TrackingController.inbox, TrackingController.inbox,
); );
/** Referencia al simulador para que el módulo admin pueda reiniciar posiciones. */
private static simulatorInstance: RouteSimulator | null = null;
/** Permite que app.ts lance el simulador usando estos mismos singletons. */ /** Permite que app.ts lance el simulador usando estos mismos singletons. */
buildSimulator(): RouteSimulator { buildSimulator(): RouteSimulator {
return new RouteSimulator(this.processGpsUpdate); const sim = new RouteSimulator(
this.processGpsUpdate,
TrackingController.dedupCache,
TrackingController.routeState,
);
TrackingController.simulatorInstance = sim;
return sim;
}
/** Expone el route-state singleton al módulo admin. */
static getRouteStateRepository() {
return TrackingController.routeState;
}
/** Expone el inbox singleton al módulo admin. */
static getNotificationInbox() {
return TrackingController.inbox;
}
/** Expone el dedup cache al módulo admin (para limpiarlo en resume). */
static getDedupCache() {
return TrackingController.dedupCache;
}
/** Expone el simulador (puede ser null si aún no se construyó). */
static getSimulator(): RouteSimulator | null {
return TrackingController.simulatorInstance;
} }
gpsUpdate = async (req: Request, res: Response) => { gpsUpdate = async (req: Request, res: Response) => {