diff --git a/backend/README.md b/backend/README.md new file mode 100644 index 0000000..fa93496 --- /dev/null +++ b/backend/README.md @@ -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 diff --git a/backend/src/app.ts b/backend/src/app.ts index 9e98001..3e022c4 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -6,12 +6,16 @@ import { AppRoutes } from "./presentation/routes.js"; import { TrackingRoutes } from "./presentation/tracking/routes.js"; import { Server } from "./presentation/server.js"; import { prisma } from "./data/postgres/index.js"; +import { seedAdmin } from "./data/seed/seed-admin.js"; async function main() { try { await prisma.$connect(); 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 // controller singleton ya esté construido. const routes = AppRoutes.routes; diff --git a/backend/src/data/cache/route-state.impl.ts b/backend/src/data/cache/route-state.impl.ts index 79824ea..f12fc00 100644 --- a/backend/src/data/cache/route-state.impl.ts +++ b/backend/src/data/cache/route-state.impl.ts @@ -4,10 +4,17 @@ */ import type { + ArrivalResult, RouteState, RouteStateRepository, } from "../../domain/repositories/route-state.repository.js"; +const emptyEta = { + etaMinutes: -1, + arrivalWindow: { from: "--:--", to: "--:--" }, + message: "Sin datos", +}; + export class InMemoryRouteStateRepository implements RouteStateRepository { private readonly states = new Map(); @@ -23,8 +30,50 @@ export class InMemoryRouteStateRepository implements RouteStateRepository { return Array.from(this.states.values()); } - /** Helper para reset de demo. */ async clearAll(): Promise { this.states.clear(); } + + async cancel(routeId: string, reason?: string): Promise { + 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 { + 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 { + const existing = this.states.get(routeId); + if (!existing) return; + this.states.set(routeId, { ...existing, arrivalResult: result }); + } } diff --git a/backend/src/data/mocks/colonias.mock.ts b/backend/src/data/mocks/colonias.mock.ts index 04037c0..2ee84e7 100644 --- a/backend/src/data/mocks/colonias.mock.ts +++ b/backend/src/data/mocks/colonias.mock.ts @@ -14,4 +14,9 @@ export const colonias: Colonia[] = [ { 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: "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)" }, ]; \ No newline at end of file diff --git a/backend/src/data/mocks/routes.mock.ts b/backend/src/data/mocks/routes.mock.ts index 1a61b93..da0b40d 100644 --- a/backend/src/data/mocks/routes.mock.ts +++ b/backend/src/data/mocks/routes.mock.ts @@ -256,4 +256,52 @@ export const routes: MockRoute[] = [ { 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" }, + ], + }, ]; \ No newline at end of file diff --git a/backend/src/data/repositories/auth.repository.impl.ts b/backend/src/data/repositories/auth.repository.impl.ts index 3fec4d7..c51eb1c 100644 --- a/backend/src/data/repositories/auth.repository.impl.ts +++ b/backend/src/data/repositories/auth.repository.impl.ts @@ -16,17 +16,30 @@ export class AuthRepositoryImpl implements AuthRepository { email: true, password: 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({ - 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: { id: true, email: true, name: true, + role: true, }, }); } @@ -42,4 +55,4 @@ export class AuthRepositoryImpl implements AuthRepository { }, }); } -} \ No newline at end of file +} diff --git a/backend/src/data/seed/seed-admin.ts b/backend/src/data/seed/seed-admin.ts new file mode 100644 index 0000000..7a11616 --- /dev/null +++ b/backend/src/data/seed/seed-admin.ts @@ -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 { + 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)`, + ); +} diff --git a/backend/src/data/simulation/route-simulator.ts b/backend/src/data/simulation/route-simulator.ts index 213226d..22d9d7c 100644 --- a/backend/src/data/simulation/route-simulator.ts +++ b/backend/src/data/simulation/route-simulator.ts @@ -4,12 +4,17 @@ * y dispara el ProcessGpsUpdateUseCase. Reemplaza al "pull to refresh" del * 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 - * activar para todas las rutas si se desea. + * Cuando un camión termina su ciclo (positionId=8 → vuelve a 1), se limpia + * 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 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 SIMULATED_ROUTE_IDS = ["RUTA-01"]; @@ -18,7 +23,11 @@ export class RouteSimulator { private timer: NodeJS.Timeout | null = null; private positionIndexByRoute = new Map(); - constructor(private readonly processGpsUpdate: ProcessGpsUpdateUseCase) {} + constructor( + private readonly processGpsUpdate: ProcessGpsUpdateUseCase, + private readonly dedupCache: NotificationCacheRepository, + private readonly routeState: RouteStateRepository, + ) {} start() { if (this.timer) return; @@ -30,7 +39,9 @@ export class RouteSimulator { void this.tick(); }, 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() { @@ -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() { for (const routeId of SIMULATED_ROUTE_IDS) { const route = routes.find((r) => r.routeId === routeId); 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 position = route.positions[index]; if (!position) continue; @@ -64,8 +90,15 @@ export class RouteSimulator { 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; + if (nextIndex < index) { + await this.dedupCache.clear(); + console.log( + `RouteSimulator: ${routeId} completó ciclo — cache de dedup limpiado`, + ); + } this.positionIndexByRoute.set(routeId, nextIndex); } } diff --git a/backend/src/domain/errors/custom.error.ts b/backend/src/domain/errors/custom.error.ts index 06bcb6b..6de10c8 100644 --- a/backend/src/domain/errors/custom.error.ts +++ b/backend/src/domain/errors/custom.error.ts @@ -17,6 +17,10 @@ export class CustomError extends Error { return new CustomError(message, 401); } + static forbidden(message: string = "Forbidden"): CustomError { + return new CustomError(message, 403); + } + static notFound(message: string): CustomError { return new CustomError(message, 404); } diff --git a/backend/src/domain/repositories/auth.repository.ts b/backend/src/domain/repositories/auth.repository.ts index 7fc9865..9f83c02 100644 --- a/backend/src/domain/repositories/auth.repository.ts +++ b/backend/src/domain/repositories/auth.repository.ts @@ -1,7 +1,7 @@ export interface AuthRepository { - findByEmail(email: string): Promise<{ id: number; email: string; password: string; name: string } | null>; - create(data: { name: string; email: string; password: string }): Promise<{ id: number; email: string; name: string }>; + findByEmail(email: string): Promise<{ id: number; email: string; password: string; name: string; role: string } | null>; + 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>; } \ No newline at end of file diff --git a/backend/src/domain/repositories/feedback.repository.ts b/backend/src/domain/repositories/feedback.repository.ts index 7bc5b53..a0f6d65 100644 --- a/backend/src/domain/repositories/feedback.repository.ts +++ b/backend/src/domain/repositories/feedback.repository.ts @@ -9,6 +9,11 @@ import type { FeedbackType } from "../dtos/feedback/submit-feedback.dto.js"; export interface FeedbackItem { id: string; 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; message: string; rating?: number; diff --git a/backend/src/domain/repositories/route-state.repository.ts b/backend/src/domain/repositories/route-state.repository.ts index 3313306..1b5adca 100644 --- a/backend/src/domain/repositories/route-state.repository.ts +++ b/backend/src/domain/repositories/route-state.repository.ts @@ -10,16 +10,33 @@ 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 { routeId: string; currentPositionId: number; eta: EtaResult; status: string; updatedAt: string; + arrivalResult: ArrivalResult; + /** True si el admin canceló esta ruta — el simulador la pausa. */ + cancelled: boolean; + cancelReason?: string; } export interface RouteStateRepository { get(routeId: string): Promise; set(state: RouteState): Promise; getAll(): Promise; + /** Cancela la ruta (la pausa). El simulador deja de avanzarla. */ + cancel(routeId: string, reason?: string): Promise; + /** Reactiva una ruta previamente cancelada. */ + resume(routeId: string): Promise; } diff --git a/backend/src/domain/use-cases/admin/cancel-route.use-case.ts b/backend/src/domain/use-cases/admin/cancel-route.use-case.ts new file mode 100644 index 0000000..6d627f1 --- /dev/null +++ b/backend/src/domain/use-cases/admin/cancel-route.use-case.ts @@ -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 { + 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); + } + } +} diff --git a/backend/src/domain/use-cases/admin/list-all-feedback.use-case.ts b/backend/src/domain/use-cases/admin/list-all-feedback.use-case.ts new file mode 100644 index 0000000..1ff881c --- /dev/null +++ b/backend/src/domain/use-cases/admin/list-all-feedback.use-case.ts @@ -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 { + return this.repository.listAll(); + } +} diff --git a/backend/src/domain/use-cases/admin/list-routes.use-case.ts b/backend/src/domain/use-cases/admin/list-routes.use-case.ts new file mode 100644 index 0000000..41c1a41 --- /dev/null +++ b/backend/src/domain/use-cases/admin/list-routes.use-case.ts @@ -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 { + 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; + }); + } +} diff --git a/backend/src/domain/use-cases/admin/resume-route.use-case.ts b/backend/src/domain/use-cases/admin/resume-route.use-case.ts new file mode 100644 index 0000000..2fc4174 --- /dev/null +++ b/backend/src/domain/use-cases/admin/resume-route.use-case.ts @@ -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 { + 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); + } + } +} diff --git a/backend/src/domain/use-cases/auth/login-user.use-case.ts b/backend/src/domain/use-cases/auth/login-user.use-case.ts index dd86e46..a0874cf 100644 --- a/backend/src/domain/use-cases/auth/login-user.use-case.ts +++ b/backend/src/domain/use-cases/auth/login-user.use-case.ts @@ -17,6 +17,7 @@ export interface LoginUserResponse { id: number; email: string; name: string; + role: string; }; token: string; } @@ -40,10 +41,11 @@ export class LoginUserUseCase { throw CustomError.unauthorized("Invalid credentials"); } - // Si el user no estaba en el mock de servicio (porque se creó antes de - // esta lógica), lo agregamos con valores default. Idempotente. + // Si el user normal no estaba en el mock de servicio, lo agregamos con + // 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); - if (!inMock) { + if (!inMock && user.role !== "ADMIN") { upsertUser({ id: user.id, name: user.name, @@ -64,6 +66,7 @@ export class LoginUserUseCase { id: user.id, email: user.email, name: user.name, + role: user.role, }, token, }; diff --git a/backend/src/domain/use-cases/feedback/submit-feedback.use-case.ts b/backend/src/domain/use-cases/feedback/submit-feedback.use-case.ts index ca0b288..bb4d185 100644 --- a/backend/src/domain/use-cases/feedback/submit-feedback.use-case.ts +++ b/backend/src/domain/use-cases/feedback/submit-feedback.use-case.ts @@ -2,6 +2,8 @@ * submit-feedback.use-case.ts * Recibe el DTO, lo valida y guarda el feedback. * 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"; @@ -9,6 +11,7 @@ import type { FeedbackItem, FeedbackRepository, } from "../../repositories/feedback.repository.js"; +import { users } from "../../../data/mocks/users.mock.js"; export class SubmitFeedbackUseCase { constructor(private readonly repository: FeedbackRepository) {} @@ -16,13 +19,19 @@ export class SubmitFeedbackUseCase { async execute(userId: number, data: unknown): Promise { const dto = SubmitFeedbackDtoValidator.validate(data); + // Lookup contextual del user para enriquecer el reporte + const user = users.find((u) => u.id === userId); + const item: FeedbackItem = { id: `${userId}-${Date.now()}`, userId, + routeId: user?.routeId ?? null, type: dto.type, message: dto.message, 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; await this.repository.save(item); diff --git a/backend/src/domain/use-cases/notifications/process-gps-update.use-case.ts b/backend/src/domain/use-cases/notifications/process-gps-update.use-case.ts index 3813313..82e8155 100644 --- a/backend/src/domain/use-cases/notifications/process-gps-update.use-case.ts +++ b/backend/src/domain/use-cases/notifications/process-gps-update.use-case.ts @@ -35,9 +35,33 @@ export class ProcessGpsUpdateUseCase { 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 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 await this.routeState.set({ routeId: route.routeId, @@ -45,6 +69,8 @@ export class ProcessGpsUpdateUseCase { eta, status: dto.status, updatedAt: dto.timestamp ?? new Date().toISOString(), + arrivalResult, + cancelled: false, }); // 2) Empujar cada notificación al buzón del usuario destinatario diff --git a/backend/src/domain/use-cases/tracking/get-user-status.use-case.ts b/backend/src/domain/use-cases/tracking/get-user-status.use-case.ts index 54191b1..6034837 100644 --- a/backend/src/domain/use-cases/tracking/get-user-status.use-case.ts +++ b/backend/src/domain/use-cases/tracking/get-user-status.use-case.ts @@ -24,6 +24,8 @@ export interface UserStatusResponse { status: string; updatedAt: string; horarioEstimado: string | null; + arrivalResult: "PENDING" | "ARRIVED" | "FAILED" | "CANCELLED"; + cancelled: boolean; }; eta: EtaResult | null; notifications: InboxNotification[]; @@ -53,6 +55,8 @@ export class GetUserStatusUseCase { status: state?.status ?? "ESPERA", updatedAt: state?.updatedAt ?? new Date().toISOString(), horarioEstimado: colonia?.horarioEstimado ?? null, + arrivalResult: state?.arrivalResult ?? "PENDING", + cancelled: state?.cancelled ?? false, }, eta: state?.eta ?? null, notifications, diff --git a/backend/src/presentation/admin/controller.ts b/backend/src/presentation/admin/controller.ts new file mode 100644 index 0000000..4827221 --- /dev/null +++ b/backend/src/presentation/admin/controller.ts @@ -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" }); + } +} diff --git a/backend/src/presentation/admin/routes.ts b/backend/src/presentation/admin/routes.ts new file mode 100644 index 0000000..f05eea8 --- /dev/null +++ b/backend/src/presentation/admin/routes.ts @@ -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; + } +} diff --git a/backend/src/presentation/feedback/controller.ts b/backend/src/presentation/feedback/controller.ts index d262547..36760de 100644 --- a/backend/src/presentation/feedback/controller.ts +++ b/backend/src/presentation/feedback/controller.ts @@ -17,6 +17,11 @@ export class FeedbackController { private submit = new SubmitFeedbackUseCase(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) => { try { if (!req.user) throw CustomError.unauthorized("User not authenticated"); diff --git a/backend/src/presentation/middlewares/auth.middleware.ts b/backend/src/presentation/middlewares/auth.middleware.ts index 2dcf054..4a68156 100644 --- a/backend/src/presentation/middlewares/auth.middleware.ts +++ b/backend/src/presentation/middlewares/auth.middleware.ts @@ -1,40 +1,43 @@ 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 { CustomError } from "../../domain/errors/custom.error.js"; +export interface AuthUser { + id: number; + email: string; + role: string; +} + export interface AuthRequest extends Request { - user?: JwtPayload; + user?: AuthUser; } export class AuthMiddleware { static async validate(req: AuthRequest, res: Response, next: NextFunction) { try { - const authHeader = req.headers.authorization; if (!authHeader || !authHeader.startsWith("Bearer ")) { throw CustomError.unauthorized("Missing or invalid Authorization header"); } - const token = authHeader.substring(7); + const token = authHeader.substring(7); - const payload = JwtAdapter.validate(token); if (!payload) { throw CustomError.unauthorized("Invalid token"); } - const repository = new AuthRepositoryImpl(); const user = await repository.findById(payload.id); if (!user) { throw CustomError.unauthorized("User not found"); } - - req.user = payload; + // Exponemos id + email + role (no name) en req.user. + req.user = { id: user.id, email: user.email, role: user.role }; next(); } catch (error) { @@ -44,4 +47,16 @@ export class AuthMiddleware { res.status(500).json({ error: "Internal server error" }); } } -} \ No newline at end of file + + /** 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(); + } +} diff --git a/backend/src/presentation/routes.ts b/backend/src/presentation/routes.ts index f3cfe32..e5c30b4 100644 --- a/backend/src/presentation/routes.ts +++ b/backend/src/presentation/routes.ts @@ -3,13 +3,17 @@ import { AuthRoutes } from "./auth/routes.js"; import { TrackingRoutes } from "./tracking/routes.js"; import { FeedbackRoutes } from "./feedback/routes.js"; import { AddressesRoutes } from "./addresses/routes.js"; +import { AdminRoutes } from "./admin/routes.js"; export class AppRoutes { static get routes(): Router { const router = Router(); 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/admin', AdminRoutes.routes); router.use('/api/feedback', FeedbackRoutes.routes); router.use('/api/addresses', AddressesRoutes.routes); diff --git a/backend/src/presentation/tracking/controller.ts b/backend/src/presentation/tracking/controller.ts index 1b5b4db..e62c059 100644 --- a/backend/src/presentation/tracking/controller.ts +++ b/backend/src/presentation/tracking/controller.ts @@ -40,9 +40,38 @@ export class TrackingController { 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. */ 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) => {