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 { 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;
|
||||
|
||||
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 {
|
||||
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<string, RouteState>();
|
||||
|
||||
@@ -23,8 +30,50 @@ export class InMemoryRouteStateRepository implements RouteStateRepository {
|
||||
return Array.from(this.states.values());
|
||||
}
|
||||
|
||||
/** Helper para reset de demo. */
|
||||
async clearAll(): Promise<void> {
|
||||
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: "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)" },
|
||||
];
|
||||
@@ -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" },
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
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
|
||||
* 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<string, number>();
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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<RouteState | null>;
|
||||
set(state: RouteState): Promise<void>;
|
||||
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;
|
||||
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,
|
||||
};
|
||||
|
||||
@@ -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<FeedbackItem> {
|
||||
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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
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 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");
|
||||
|
||||
@@ -1,18 +1,23 @@
|
||||
|
||||
|
||||
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");
|
||||
@@ -20,21 +25,19 @@ export class AuthMiddleware {
|
||||
|
||||
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" });
|
||||
}
|
||||
}
|
||||
|
||||
/** 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 { 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);
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user