feat: add more routes
This commit is contained in:
313
backend/README.md
Normal file
313
backend/README.md
Normal 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
|
||||||
@@ -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;
|
||||||
|
|||||||
51
backend/src/data/cache/route-state.impl.ts
vendored
51
backend/src/data/cache/route-state.impl.ts
vendored
@@ -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 });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)" },
|
||||||
];
|
];
|
||||||
@@ -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" },
|
||||||
|
],
|
||||||
|
},
|
||||||
];
|
];
|
||||||
@@ -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,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -42,4 +55,4 @@ export class AuthRepositoryImpl implements AuthRepository {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
51
backend/src/data/seed/seed-admin.ts
Normal file
51
backend/src/data/seed/seed-admin.ts
Normal 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)`,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>;
|
||||||
}
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>;
|
||||||
}
|
}
|
||||||
|
|||||||
47
backend/src/domain/use-cases/admin/cancel-route.use-case.ts
Normal file
47
backend/src/domain/use-cases/admin/cancel-route.use-case.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
45
backend/src/domain/use-cases/admin/list-routes.use-case.ts
Normal file
45
backend/src/domain/use-cases/admin/list-routes.use-case.ts
Normal 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;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
42
backend/src/domain/use-cases/admin/resume-route.use-case.ts
Normal file
42
backend/src/domain/use-cases/admin/resume-route.use-case.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
100
backend/src/presentation/admin/controller.ts
Normal file
100
backend/src/presentation/admin/controller.ts
Normal 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" });
|
||||||
|
}
|
||||||
|
}
|
||||||
20
backend/src/presentation/admin/routes.ts
Normal file
20
backend/src/presentation/admin/routes.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
|||||||
@@ -1,40 +1,43 @@
|
|||||||
|
|
||||||
|
|
||||||
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");
|
||||||
}
|
}
|
||||||
|
|
||||||
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user