feat(backend): tracking simulator, RBAC status, feedback & addresses
- Add automatic route simulator (30s tick) that advances trucks and dispatches notifications without needing client-driven pull - Add GET /api/tracking/status protected by JWT for tunnel-view (each user only sees their own route + own inbox) - Add POST /api/tracking/reset-demo to wipe in-memory state without restarting the server (useful for repeated demos) - Add feedback module (POST /api/feedback, GET /api/feedback/me) with 4 feedback types and optional rating - Add addresses module: GET /colonias, GET/PUT /me with colonia validation against the catalog (rejects unknown colonias) - Add in-memory repos for route-state and notification inbox - Auto-register new users in the service mock with default route on register/login so they receive notifications immediately
This commit is contained in:
@@ -3,6 +3,7 @@
|
||||
import "dotenv/config";
|
||||
import { env } from "./config/env.js";
|
||||
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";
|
||||
|
||||
@@ -11,10 +12,18 @@ async function main() {
|
||||
await prisma.$connect();
|
||||
console.log("Database connected");
|
||||
|
||||
// AppRoutes.routes debe leerse ANTES de pedir el simulator para que el
|
||||
// controller singleton ya esté construido.
|
||||
const routes = AppRoutes.routes;
|
||||
|
||||
const trackingController = TrackingRoutes.controllerInstance;
|
||||
if (trackingController) {
|
||||
trackingController.buildSimulator().start();
|
||||
}
|
||||
|
||||
const server = new Server({
|
||||
port: env.PORT,
|
||||
routes: AppRoutes.routes,
|
||||
routes,
|
||||
});
|
||||
|
||||
await server.start();
|
||||
@@ -24,4 +33,4 @@ async function main() {
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
main();
|
||||
|
||||
32
backend/src/data/cache/notification-inbox.impl.ts
vendored
Normal file
32
backend/src/data/cache/notification-inbox.impl.ts
vendored
Normal file
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* notification-inbox.impl.ts
|
||||
* Implementación en memoria del buzón de notificaciones por usuario.
|
||||
*/
|
||||
|
||||
import type {
|
||||
InboxNotification,
|
||||
NotificationInboxRepository,
|
||||
} from "../../domain/repositories/notification-inbox.repository.js";
|
||||
|
||||
export class InMemoryNotificationInbox implements NotificationInboxRepository {
|
||||
private readonly inbox = new Map<number, InboxNotification[]>();
|
||||
|
||||
async addForUser(notification: InboxNotification): Promise<void> {
|
||||
const list = this.inbox.get(notification.userId) ?? [];
|
||||
list.unshift(notification);
|
||||
this.inbox.set(notification.userId, list);
|
||||
}
|
||||
|
||||
async getForUser(userId: number): Promise<InboxNotification[]> {
|
||||
return this.inbox.get(userId) ?? [];
|
||||
}
|
||||
|
||||
async clearForUser(userId: number): Promise<void> {
|
||||
this.inbox.delete(userId);
|
||||
}
|
||||
|
||||
/** Helper para reset de demo. */
|
||||
async clearAll(): Promise<void> {
|
||||
this.inbox.clear();
|
||||
}
|
||||
}
|
||||
30
backend/src/data/cache/route-state.impl.ts
vendored
Normal file
30
backend/src/data/cache/route-state.impl.ts
vendored
Normal file
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* route-state.impl.ts
|
||||
* Implementación en memoria de RouteStateRepository.
|
||||
*/
|
||||
|
||||
import type {
|
||||
RouteState,
|
||||
RouteStateRepository,
|
||||
} from "../../domain/repositories/route-state.repository.js";
|
||||
|
||||
export class InMemoryRouteStateRepository implements RouteStateRepository {
|
||||
private readonly states = new Map<string, RouteState>();
|
||||
|
||||
async get(routeId: string): Promise<RouteState | null> {
|
||||
return this.states.get(routeId) ?? null;
|
||||
}
|
||||
|
||||
async set(state: RouteState): Promise<void> {
|
||||
this.states.set(state.routeId, state);
|
||||
}
|
||||
|
||||
async getAll(): Promise<RouteState[]> {
|
||||
return Array.from(this.states.values());
|
||||
}
|
||||
|
||||
/** Helper para reset de demo. */
|
||||
async clearAll(): Promise<void> {
|
||||
this.states.clear();
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,11 @@ export interface MockUser {
|
||||
routeId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lista de usuarios para el demo. Es mutable porque cuando un user nuevo
|
||||
* se registra desde la app, se agrega aquí en memoria (con RUTA-01 default).
|
||||
* En Bloque B1 (domicilios) cada user elegirá su colonia real.
|
||||
*/
|
||||
export const users: MockUser[] = [
|
||||
{ id: 1, name: "Ana López", email: "ana@test.com", colonia: "Zona Centro", routeId: "RUTA-01" },
|
||||
{ id: 2, name: "Carlos Pérez", email: "carlos@test.com",colonia: "Las Arboledas", routeId: "RUTA-01" },
|
||||
@@ -16,4 +21,17 @@ export const users: MockUser[] = [
|
||||
{ id: 5, name: "Laura Díaz", email: "laura@test.com", colonia: "Los Olivos", routeId: "RUTA-04" },
|
||||
{ id: 6, name: "Pedro Sánchez", email: "pedro@test.com", colonia: "Rancho Seco", routeId: "RUTA-05" },
|
||||
{ id: 7, name: "Sofía Gómez", email: "sofia@test.com", colonia: "Las Insurgentes", routeId: "RUTA-12" },
|
||||
];
|
||||
];
|
||||
|
||||
export const DEFAULT_ROUTE_ID = "RUTA-01";
|
||||
export const DEFAULT_COLONIA = "Zona Centro";
|
||||
|
||||
/** Agrega un user al mock (idempotente por id). */
|
||||
export const upsertUser = (user: MockUser): void => {
|
||||
const existing = users.findIndex((u) => u.id === user.id);
|
||||
if (existing >= 0) {
|
||||
users[existing] = user;
|
||||
} else {
|
||||
users.push(user);
|
||||
}
|
||||
};
|
||||
|
||||
25
backend/src/data/repositories/feedback.repository.impl.ts
Normal file
25
backend/src/data/repositories/feedback.repository.impl.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* feedback.repository.impl.ts
|
||||
* Implementación en memoria del FeedbackRepository.
|
||||
*/
|
||||
|
||||
import type {
|
||||
FeedbackItem,
|
||||
FeedbackRepository,
|
||||
} from "../../domain/repositories/feedback.repository.js";
|
||||
|
||||
export class InMemoryFeedbackRepository implements FeedbackRepository {
|
||||
private readonly items: FeedbackItem[] = [];
|
||||
|
||||
async save(item: FeedbackItem): Promise<void> {
|
||||
this.items.unshift(item);
|
||||
}
|
||||
|
||||
async listForUser(userId: number): Promise<FeedbackItem[]> {
|
||||
return this.items.filter((i) => i.userId === userId);
|
||||
}
|
||||
|
||||
async listAll(): Promise<FeedbackItem[]> {
|
||||
return [...this.items];
|
||||
}
|
||||
}
|
||||
72
backend/src/data/simulation/route-simulator.ts
Normal file
72
backend/src/data/simulation/route-simulator.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* route-simulator.ts
|
||||
* Simulador automático: cada N segundos avanza un positionId en cada ruta
|
||||
* 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.
|
||||
*/
|
||||
|
||||
import { routes } from "../mocks/routes.mock.js";
|
||||
import type { ProcessGpsUpdateUseCase } from "../../domain/use-cases/notifications/process-gps-update.use-case.js";
|
||||
|
||||
const TICK_INTERVAL_MS = 30_000; // 30 segundos
|
||||
const SIMULATED_ROUTE_IDS = ["RUTA-01"];
|
||||
|
||||
export class RouteSimulator {
|
||||
private timer: NodeJS.Timeout | null = null;
|
||||
private positionIndexByRoute = new Map<string, number>();
|
||||
|
||||
constructor(private readonly processGpsUpdate: ProcessGpsUpdateUseCase) {}
|
||||
|
||||
start() {
|
||||
if (this.timer) return;
|
||||
|
||||
// Inicializa cada ruta en positionId=1 inmediatamente
|
||||
void this.tick();
|
||||
|
||||
this.timer = setInterval(() => {
|
||||
void this.tick();
|
||||
}, TICK_INTERVAL_MS);
|
||||
|
||||
console.log(`RouteSimulator started — tick every ${TICK_INTERVAL_MS / 1000}s`);
|
||||
}
|
||||
|
||||
stop() {
|
||||
if (this.timer) {
|
||||
clearInterval(this.timer);
|
||||
this.timer = null;
|
||||
}
|
||||
}
|
||||
|
||||
private async tick() {
|
||||
for (const routeId of SIMULATED_ROUTE_IDS) {
|
||||
const route = routes.find((r) => r.routeId === routeId);
|
||||
if (!route) continue;
|
||||
|
||||
const index = this.positionIndexByRoute.get(routeId) ?? 0;
|
||||
const position = route.positions[index];
|
||||
if (!position) continue;
|
||||
|
||||
try {
|
||||
await this.processGpsUpdate.execute({
|
||||
truckId: String(route.truckId),
|
||||
routeId: route.routeId,
|
||||
lat: position.lat,
|
||||
lng: position.lng,
|
||||
speed: position.speed,
|
||||
status: route.status,
|
||||
positionId: position.positionId,
|
||||
timestamp: position.timestamp,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(`Simulator tick failed for ${routeId}:`, err);
|
||||
}
|
||||
|
||||
// Avanza al siguiente; vuelve a empezar al terminar el array
|
||||
const nextIndex = (index + 1) % route.positions.length;
|
||||
this.positionIndexByRoute.set(routeId, nextIndex);
|
||||
}
|
||||
}
|
||||
}
|
||||
28
backend/src/domain/dtos/addresses/set-address.dto.ts
Normal file
28
backend/src/domain/dtos/addresses/set-address.dto.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* set-address.dto.ts
|
||||
* DTO para asignar la colonia del domicilio del usuario.
|
||||
*/
|
||||
|
||||
export interface SetAddressDto {
|
||||
colonia: string;
|
||||
street?: string;
|
||||
}
|
||||
|
||||
export class SetAddressDtoValidator {
|
||||
static validate(data: unknown): SetAddressDto {
|
||||
if (typeof data !== "object" || data === null) {
|
||||
throw new Error("Invalid address payload");
|
||||
}
|
||||
const obj = data as Record<string, unknown>;
|
||||
|
||||
if (typeof obj.colonia !== "string" || obj.colonia.trim().length === 0) {
|
||||
throw new Error("colonia is required");
|
||||
}
|
||||
|
||||
const result: SetAddressDto = { colonia: obj.colonia.trim() };
|
||||
if (typeof obj.street === "string" && obj.street.trim().length > 0) {
|
||||
result.street = obj.street.trim();
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
63
backend/src/domain/dtos/feedback/submit-feedback.dto.ts
Normal file
63
backend/src/domain/dtos/feedback/submit-feedback.dto.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* submit-feedback.dto.ts
|
||||
* DTO para enviar retroalimentación sobre el servicio.
|
||||
*/
|
||||
|
||||
export type FeedbackType =
|
||||
| "TRUCK_DID_NOT_PASS"
|
||||
| "RATING"
|
||||
| "SUGGESTION"
|
||||
| "OTHER";
|
||||
|
||||
export interface SubmitFeedbackDto {
|
||||
type: FeedbackType;
|
||||
message: string;
|
||||
rating?: number; // 1-5, sólo cuando type === "RATING"
|
||||
}
|
||||
|
||||
const VALID_TYPES: FeedbackType[] = [
|
||||
"TRUCK_DID_NOT_PASS",
|
||||
"RATING",
|
||||
"SUGGESTION",
|
||||
"OTHER",
|
||||
];
|
||||
|
||||
export class SubmitFeedbackDtoValidator {
|
||||
static validate(data: unknown): SubmitFeedbackDto {
|
||||
if (typeof data !== "object" || data === null) {
|
||||
throw new Error("Invalid feedback payload");
|
||||
}
|
||||
|
||||
const obj = data as Record<string, unknown>;
|
||||
|
||||
if (
|
||||
typeof obj.type !== "string" ||
|
||||
!VALID_TYPES.includes(obj.type as FeedbackType)
|
||||
) {
|
||||
throw new Error(`type must be one of: ${VALID_TYPES.join(", ")}`);
|
||||
}
|
||||
|
||||
if (typeof obj.message !== "string" || obj.message.trim().length === 0) {
|
||||
throw new Error("message is required");
|
||||
}
|
||||
|
||||
let rating: number | undefined;
|
||||
if (obj.rating !== undefined) {
|
||||
if (
|
||||
typeof obj.rating !== "number" ||
|
||||
obj.rating < 1 ||
|
||||
obj.rating > 5
|
||||
) {
|
||||
throw new Error("rating must be between 1 and 5");
|
||||
}
|
||||
rating = obj.rating;
|
||||
}
|
||||
|
||||
const result: SubmitFeedbackDto = {
|
||||
type: obj.type as FeedbackType,
|
||||
message: obj.message.trim(),
|
||||
};
|
||||
if (rating !== undefined) result.rating = rating;
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
export * from './auth/register-user.dto.js';
|
||||
export * from './auth/login-user.dto.js';
|
||||
export * from './tracking/gps-update.dto.js';
|
||||
export * from './tracking/gps-update.dto.js';
|
||||
export * from './feedback/submit-feedback.dto.js';
|
||||
export * from './addresses/set-address.dto.js';
|
||||
22
backend/src/domain/repositories/feedback.repository.ts
Normal file
22
backend/src/domain/repositories/feedback.repository.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* feedback.repository.ts
|
||||
* Buzón de retroalimentación.
|
||||
* In-memory para el MVP. Cuando se migre a DB, se cambia la impl.
|
||||
*/
|
||||
|
||||
import type { FeedbackType } from "../dtos/feedback/submit-feedback.dto.js";
|
||||
|
||||
export interface FeedbackItem {
|
||||
id: string;
|
||||
userId: number;
|
||||
type: FeedbackType;
|
||||
message: string;
|
||||
rating?: number;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface FeedbackRepository {
|
||||
save(item: FeedbackItem): Promise<void>;
|
||||
listForUser(userId: number): Promise<FeedbackItem[]>;
|
||||
listAll(): Promise<FeedbackItem[]>;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* notification-inbox.repository.ts
|
||||
* Buzón de notificaciones por usuario.
|
||||
* El simulador empuja notificaciones aquí; el endpoint GET /status las lee.
|
||||
*/
|
||||
|
||||
import type { NotificationType } from "../../data/mocks/notification-types.mock.js";
|
||||
|
||||
export interface InboxNotification {
|
||||
id: string;
|
||||
userId: number;
|
||||
type: NotificationType;
|
||||
title: string;
|
||||
body: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface NotificationInboxRepository {
|
||||
addForUser(notification: InboxNotification): Promise<void>;
|
||||
getForUser(userId: number): Promise<InboxNotification[]>;
|
||||
clearForUser(userId: number): Promise<void>;
|
||||
}
|
||||
25
backend/src/domain/repositories/route-state.repository.ts
Normal file
25
backend/src/domain/repositories/route-state.repository.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* route-state.repository.ts
|
||||
* Guarda el estado operativo actual de cada ruta:
|
||||
* - en qué positionId va el camión
|
||||
* - cuál fue la última ETA calculada
|
||||
* - cuándo fue la última actualización
|
||||
*
|
||||
* Esto vive en memoria para el MVP. Más adelante se puede mover a Redis.
|
||||
*/
|
||||
|
||||
import type { EtaResult } from "../use-cases/notifications/calculate-eta.use-case.js";
|
||||
|
||||
export interface RouteState {
|
||||
routeId: string;
|
||||
currentPositionId: number;
|
||||
eta: EtaResult;
|
||||
status: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface RouteStateRepository {
|
||||
get(routeId: string): Promise<RouteState | null>;
|
||||
set(state: RouteState): Promise<void>;
|
||||
getAll(): Promise<RouteState[]>;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* get-my-address.use-case.ts
|
||||
* Devuelve la dirección activa del usuario logueado.
|
||||
* Se infiere del mock de servicio (colonia + routeId).
|
||||
*/
|
||||
|
||||
import { CustomError } from "../../errors/custom.error.js";
|
||||
import { users } from "../../../data/mocks/users.mock.js";
|
||||
import { colonias } from "../../../data/mocks/colonias.mock.js";
|
||||
|
||||
export interface MyAddressResponse {
|
||||
colonia: string;
|
||||
routeId: string;
|
||||
horarioEstimado: string | null;
|
||||
}
|
||||
|
||||
export class GetMyAddressUseCase {
|
||||
execute(userId: number): MyAddressResponse {
|
||||
const user = users.find((u) => u.id === userId);
|
||||
if (!user) {
|
||||
throw CustomError.notFound("User not found in service mock");
|
||||
}
|
||||
|
||||
const colonia = colonias.find((c) => c.colonia === user.colonia);
|
||||
|
||||
return {
|
||||
colonia: user.colonia,
|
||||
routeId: user.routeId,
|
||||
horarioEstimado: colonia?.horarioEstimado ?? null,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* list-colonias.use-case.ts
|
||||
* Devuelve las colonias disponibles para el catálogo del frontend.
|
||||
*/
|
||||
|
||||
import { colonias, type Colonia } from "../../../data/mocks/colonias.mock.js";
|
||||
|
||||
export class ListColoniasUseCase {
|
||||
execute(): Colonia[] {
|
||||
return colonias;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* set-my-address.use-case.ts
|
||||
* Asigna una colonia al usuario logueado. Valida que la colonia exista
|
||||
* en el catálogo y resuelve el routeId asociado.
|
||||
*
|
||||
* Cumple "validación de domicilio dentro de zona permitida" del reto:
|
||||
* si la colonia no está en el catálogo, no se acepta.
|
||||
*/
|
||||
|
||||
import { SetAddressDtoValidator } from "../../dtos/index.js";
|
||||
import { CustomError } from "../../errors/custom.error.js";
|
||||
import { colonias } from "../../../data/mocks/colonias.mock.js";
|
||||
import { upsertUser, users } from "../../../data/mocks/users.mock.js";
|
||||
|
||||
export interface SetMyAddressResponse {
|
||||
colonia: string;
|
||||
routeId: string;
|
||||
horarioEstimado: string;
|
||||
}
|
||||
|
||||
export class SetMyAddressUseCase {
|
||||
execute(
|
||||
userId: number,
|
||||
userName: string,
|
||||
userEmail: string,
|
||||
data: unknown,
|
||||
): SetMyAddressResponse {
|
||||
const dto = SetAddressDtoValidator.validate(data);
|
||||
|
||||
const match = colonias.find(
|
||||
(c) => c.colonia.toLowerCase() === dto.colonia.toLowerCase(),
|
||||
);
|
||||
if (!match) {
|
||||
throw CustomError.badRequest(
|
||||
`La colonia "${dto.colonia}" no está dentro de la zona de cobertura`,
|
||||
);
|
||||
}
|
||||
|
||||
const existing = users.find((u) => u.id === userId);
|
||||
upsertUser({
|
||||
id: userId,
|
||||
name: existing?.name ?? userName,
|
||||
email: existing?.email ?? userEmail,
|
||||
colonia: match.colonia,
|
||||
routeId: match.routeId,
|
||||
});
|
||||
|
||||
return {
|
||||
colonia: match.colonia,
|
||||
routeId: match.routeId,
|
||||
horarioEstimado: match.horarioEstimado,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,12 @@ import { AuthRepository } from "../../repositories/auth.repository.js";
|
||||
import { CustomError } from "../../errors/custom.error.js";
|
||||
import { BcryptAdapter } from "../../../config/bcrypt.js";
|
||||
import { JwtAdapter } from "../../../config/jwt.js";
|
||||
import {
|
||||
upsertUser,
|
||||
users,
|
||||
DEFAULT_ROUTE_ID,
|
||||
DEFAULT_COLONIA,
|
||||
} from "../../../data/mocks/users.mock.js";
|
||||
|
||||
export interface LoginUserResponse {
|
||||
user: {
|
||||
@@ -34,6 +40,19 @@ 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.
|
||||
const inMock = users.find((u) => u.id === user.id);
|
||||
if (!inMock) {
|
||||
upsertUser({
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
colonia: DEFAULT_COLONIA,
|
||||
routeId: DEFAULT_ROUTE_ID,
|
||||
});
|
||||
}
|
||||
|
||||
// Generar JWT
|
||||
const token = JwtAdapter.generate({
|
||||
id: user.id,
|
||||
|
||||
@@ -5,6 +5,11 @@ import { AuthRepository } from "../../repositories/auth.repository.js";
|
||||
import { CustomError } from "../../errors/custom.error.js";
|
||||
import { BcryptAdapter } from "../../../config/bcrypt.js";
|
||||
import { JwtAdapter } from "../../../config/jwt.js";
|
||||
import {
|
||||
upsertUser,
|
||||
DEFAULT_ROUTE_ID,
|
||||
DEFAULT_COLONIA,
|
||||
} from "../../../data/mocks/users.mock.js";
|
||||
|
||||
export interface RegisterUserResponse {
|
||||
user: {
|
||||
@@ -38,6 +43,17 @@ export class RegisterUserUseCase {
|
||||
password: hashedPassword,
|
||||
});
|
||||
|
||||
// Registrar al user en el "mock de servicio" con ruta default,
|
||||
// así el simulador le manda notificaciones desde el primer momento.
|
||||
// (En Bloque B1 el user elegirá su colonia/ruta real.)
|
||||
upsertUser({
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
colonia: DEFAULT_COLONIA,
|
||||
routeId: DEFAULT_ROUTE_ID,
|
||||
});
|
||||
|
||||
// Generar JWT
|
||||
const token = JwtAdapter.generate({
|
||||
id: user.id,
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* list-my-feedback.use-case.ts
|
||||
* Devuelve solo los feedbacks del usuario logueado (RBAC por userId).
|
||||
*/
|
||||
|
||||
import type {
|
||||
FeedbackItem,
|
||||
FeedbackRepository,
|
||||
} from "../../repositories/feedback.repository.js";
|
||||
|
||||
export class ListMyFeedbackUseCase {
|
||||
constructor(private readonly repository: FeedbackRepository) {}
|
||||
|
||||
async execute(userId: number): Promise<FeedbackItem[]> {
|
||||
return this.repository.listForUser(userId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
||||
import { SubmitFeedbackDtoValidator } from "../../dtos/index.js";
|
||||
import type {
|
||||
FeedbackItem,
|
||||
FeedbackRepository,
|
||||
} from "../../repositories/feedback.repository.js";
|
||||
|
||||
export class SubmitFeedbackUseCase {
|
||||
constructor(private readonly repository: FeedbackRepository) {}
|
||||
|
||||
async execute(userId: number, data: unknown): Promise<FeedbackItem> {
|
||||
const dto = SubmitFeedbackDtoValidator.validate(data);
|
||||
|
||||
const item: FeedbackItem = {
|
||||
id: `${userId}-${Date.now()}`,
|
||||
userId,
|
||||
type: dto.type,
|
||||
message: dto.message,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
if (dto.rating !== undefined) item.rating = dto.rating;
|
||||
|
||||
await this.repository.save(item);
|
||||
return item;
|
||||
}
|
||||
}
|
||||
@@ -3,9 +3,14 @@ import { CalculateEtaUseCase, type EtaResult } from "./calculate-eta.use-case.js
|
||||
import {
|
||||
EvaluateNotificationUseCase,
|
||||
type EvaluatedNotification,
|
||||
} from "../notifications/evaluate-notification.use-case.js";
|
||||
} from "./evaluate-notification.use-case.js";
|
||||
import { routes } from "../../../data/mocks/routes.mock.js";
|
||||
import { CustomError } from "../../errors/custom.error.js";
|
||||
import type { RouteStateRepository } from "../../repositories/route-state.repository.js";
|
||||
import type {
|
||||
InboxNotification,
|
||||
NotificationInboxRepository,
|
||||
} from "../../repositories/notification-inbox.repository.js";
|
||||
|
||||
export interface ProcessGpsUpdateResponse {
|
||||
message: string;
|
||||
@@ -18,6 +23,8 @@ export class ProcessGpsUpdateUseCase {
|
||||
constructor(
|
||||
private readonly calculateEta: CalculateEtaUseCase,
|
||||
private readonly evaluateNotification: EvaluateNotificationUseCase,
|
||||
private readonly routeState: RouteStateRepository,
|
||||
private readonly inbox: NotificationInboxRepository,
|
||||
) {}
|
||||
|
||||
async execute(data: unknown): Promise<ProcessGpsUpdateResponse> {
|
||||
@@ -31,6 +38,28 @@ export class ProcessGpsUpdateUseCase {
|
||||
const eta = this.calculateEta.execute(dto);
|
||||
const notifications = await this.evaluateNotification.execute(dto);
|
||||
|
||||
// 1) Guardar el estado actual de la ruta
|
||||
await this.routeState.set({
|
||||
routeId: route.routeId,
|
||||
currentPositionId: dto.positionId ?? 0,
|
||||
eta,
|
||||
status: dto.status,
|
||||
updatedAt: dto.timestamp ?? new Date().toISOString(),
|
||||
});
|
||||
|
||||
// 2) Empujar cada notificación al buzón del usuario destinatario
|
||||
for (const n of notifications) {
|
||||
const item: InboxNotification = {
|
||||
id: `${n.userId}-${route.routeId}-${n.type}-${Date.now()}`,
|
||||
userId: n.userId,
|
||||
type: n.type,
|
||||
title: n.title,
|
||||
body: n.body,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
await this.inbox.addForUser(item);
|
||||
}
|
||||
|
||||
return {
|
||||
message: "GPS update processed",
|
||||
truck: {
|
||||
@@ -42,4 +71,4 @@ export class ProcessGpsUpdateUseCase {
|
||||
notifications,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* get-user-status.use-case.ts
|
||||
* Devuelve la "visión de túnel" del usuario logueado:
|
||||
* - SOLO la ruta asignada a su domicilio
|
||||
* - SOLO sus propias notificaciones
|
||||
* No expone rutas ajenas ni telemetría completa.
|
||||
*/
|
||||
|
||||
import { CustomError } from "../../errors/custom.error.js";
|
||||
import type { EtaResult } from "../notifications/calculate-eta.use-case.js";
|
||||
import type { RouteStateRepository } from "../../repositories/route-state.repository.js";
|
||||
import type {
|
||||
InboxNotification,
|
||||
NotificationInboxRepository,
|
||||
} from "../../repositories/notification-inbox.repository.js";
|
||||
import { users } from "../../../data/mocks/users.mock.js";
|
||||
import { colonias } from "../../../data/mocks/colonias.mock.js";
|
||||
|
||||
export interface UserStatusResponse {
|
||||
user: { id: number; name: string; colonia: string };
|
||||
route: {
|
||||
routeId: string;
|
||||
currentPositionId: number;
|
||||
status: string;
|
||||
updatedAt: string;
|
||||
horarioEstimado: string | null;
|
||||
};
|
||||
eta: EtaResult | null;
|
||||
notifications: InboxNotification[];
|
||||
}
|
||||
|
||||
export class GetUserStatusUseCase {
|
||||
constructor(
|
||||
private readonly routeState: RouteStateRepository,
|
||||
private readonly inbox: NotificationInboxRepository,
|
||||
) {}
|
||||
|
||||
async execute(userId: number): Promise<UserStatusResponse> {
|
||||
const user = users.find((u) => u.id === userId);
|
||||
if (!user) {
|
||||
throw CustomError.notFound("User not registered in service mock");
|
||||
}
|
||||
|
||||
const state = await this.routeState.get(user.routeId);
|
||||
const notifications = await this.inbox.getForUser(userId);
|
||||
const colonia = colonias.find((c) => c.colonia === user.colonia);
|
||||
|
||||
return {
|
||||
user: { id: user.id, name: user.name, colonia: user.colonia },
|
||||
route: {
|
||||
routeId: user.routeId,
|
||||
currentPositionId: state?.currentPositionId ?? 0,
|
||||
status: state?.status ?? "ESPERA",
|
||||
updatedAt: state?.updatedAt ?? new Date().toISOString(),
|
||||
horarioEstimado: colonia?.horarioEstimado ?? null,
|
||||
},
|
||||
eta: state?.eta ?? null,
|
||||
notifications,
|
||||
};
|
||||
}
|
||||
}
|
||||
66
backend/src/presentation/addresses/controller.ts
Normal file
66
backend/src/presentation/addresses/controller.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* controller.ts (addresses)
|
||||
*
|
||||
* GET /api/addresses/colonias → catálogo público de colonias
|
||||
* GET /api/addresses/me → dirección activa del usuario logueado
|
||||
* PUT /api/addresses/me → asignar colonia al usuario logueado
|
||||
*/
|
||||
|
||||
import { Request, Response } from "express";
|
||||
import { AuthRequest } from "../middlewares/auth.middleware.js";
|
||||
import { CustomError } from "../../domain/errors/custom.error.js";
|
||||
import { ListColoniasUseCase } from "../../domain/use-cases/addresses/list-colonias.use-case.js";
|
||||
import { GetMyAddressUseCase } from "../../domain/use-cases/addresses/get-my-address.use-case.js";
|
||||
import { SetMyAddressUseCase } from "../../domain/use-cases/addresses/set-my-address.use-case.js";
|
||||
|
||||
export class AddressesController {
|
||||
private listColonias = new ListColoniasUseCase();
|
||||
private getMyAddress = new GetMyAddressUseCase();
|
||||
private setMyAddress = new SetMyAddressUseCase();
|
||||
|
||||
colonias = (_req: Request, res: Response) => {
|
||||
try {
|
||||
const result = this.listColonias.execute();
|
||||
res.status(200).json(result);
|
||||
} catch (error) {
|
||||
this.handleError(error, res);
|
||||
}
|
||||
};
|
||||
|
||||
myAddress = (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
if (!req.user) throw CustomError.unauthorized("User not authenticated");
|
||||
const result = this.getMyAddress.execute(req.user.id);
|
||||
res.status(200).json(result);
|
||||
} catch (error) {
|
||||
this.handleError(error, res);
|
||||
}
|
||||
};
|
||||
|
||||
setAddress = (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
if (!req.user) throw CustomError.unauthorized("User not authenticated");
|
||||
const result = this.setMyAddress.execute(
|
||||
req.user.id,
|
||||
req.user.email, // como fallback de nombre
|
||||
req.user.email,
|
||||
req.body,
|
||||
);
|
||||
res.status(200).json(result);
|
||||
} 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" });
|
||||
}
|
||||
}
|
||||
16
backend/src/presentation/addresses/routes.ts
Normal file
16
backend/src/presentation/addresses/routes.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { Router } from "express";
|
||||
import { AddressesController } from "./controller.js";
|
||||
import { AuthMiddleware } from "../middlewares/auth.middleware.js";
|
||||
|
||||
export class AddressesRoutes {
|
||||
static get routes(): Router {
|
||||
const router = Router();
|
||||
const controller = new AddressesController();
|
||||
|
||||
router.get("/colonias", controller.colonias);
|
||||
router.get("/me", AuthMiddleware.validate, controller.myAddress);
|
||||
router.put("/me", AuthMiddleware.validate, controller.setAddress);
|
||||
|
||||
return router;
|
||||
}
|
||||
}
|
||||
51
backend/src/presentation/feedback/controller.ts
Normal file
51
backend/src/presentation/feedback/controller.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* controller.ts (feedback)
|
||||
* POST /api/feedback → enviar retroalimentación (auth)
|
||||
* GET /api/feedback/me → listar mis feedbacks (auth)
|
||||
*/
|
||||
|
||||
import { Response } from "express";
|
||||
import { AuthRequest } from "../middlewares/auth.middleware.js";
|
||||
import { CustomError } from "../../domain/errors/custom.error.js";
|
||||
import { SubmitFeedbackUseCase } from "../../domain/use-cases/feedback/submit-feedback.use-case.js";
|
||||
import { ListMyFeedbackUseCase } from "../../domain/use-cases/feedback/list-my-feedback.use-case.js";
|
||||
import { InMemoryFeedbackRepository } from "../../data/repositories/feedback.repository.impl.js";
|
||||
|
||||
export class FeedbackController {
|
||||
private static repository = new InMemoryFeedbackRepository();
|
||||
|
||||
private submit = new SubmitFeedbackUseCase(FeedbackController.repository);
|
||||
private listMine = new ListMyFeedbackUseCase(FeedbackController.repository);
|
||||
|
||||
create = async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
if (!req.user) throw CustomError.unauthorized("User not authenticated");
|
||||
const result = await this.submit.execute(req.user.id, req.body);
|
||||
res.status(201).json(result);
|
||||
} catch (error) {
|
||||
this.handleError(error, res);
|
||||
}
|
||||
};
|
||||
|
||||
myFeedback = async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
if (!req.user) throw CustomError.unauthorized("User not authenticated");
|
||||
const result = await this.listMine.execute(req.user.id);
|
||||
res.status(200).json(result);
|
||||
} 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" });
|
||||
}
|
||||
}
|
||||
15
backend/src/presentation/feedback/routes.ts
Normal file
15
backend/src/presentation/feedback/routes.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { Router } from "express";
|
||||
import { FeedbackController } from "./controller.js";
|
||||
import { AuthMiddleware } from "../middlewares/auth.middleware.js";
|
||||
|
||||
export class FeedbackRoutes {
|
||||
static get routes(): Router {
|
||||
const router = Router();
|
||||
const controller = new FeedbackController();
|
||||
|
||||
router.post("/", AuthMiddleware.validate, controller.create);
|
||||
router.get("/me", AuthMiddleware.validate, controller.myFeedback);
|
||||
|
||||
return router;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Router } from "express";
|
||||
import { AuthRoutes } from "./auth/routes.js";
|
||||
import { TrackingRoutes } from "./tracking/routes.js";
|
||||
import { FeedbackRoutes } from "./feedback/routes.js";
|
||||
import { AddressesRoutes } from "./addresses/routes.js";
|
||||
|
||||
export class AppRoutes {
|
||||
static get routes(): Router {
|
||||
@@ -8,6 +10,8 @@ export class AppRoutes {
|
||||
|
||||
router.use('/api/auth', AuthRoutes.routes);
|
||||
router.use('/api/tracking', TrackingRoutes.routes);
|
||||
router.use('/api/feedback', FeedbackRoutes.routes);
|
||||
router.use('/api/addresses', AddressesRoutes.routes);
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
@@ -1,18 +1,49 @@
|
||||
/**
|
||||
* controller.ts (tracking)
|
||||
*
|
||||
* Dos endpoints:
|
||||
* POST /gps-update → interno (simulador / admin). No expone datos del user.
|
||||
* GET /status → protegido por AuthMiddleware. Devuelve SOLO la
|
||||
* información del usuario logueado (visión de túnel).
|
||||
*/
|
||||
|
||||
import { Request, Response } from "express";
|
||||
import { AuthRequest } from "../middlewares/auth.middleware.js";
|
||||
import { CustomError } from "../../domain/errors/custom.error.js";
|
||||
import { CalculateEtaUseCase } from "../../domain/use-cases/notifications/calculate-eta.use-case.js";
|
||||
import { EvaluateNotificationUseCase } from "../../domain/use-cases/notifications/evaluate-notification.use-case.js";
|
||||
import { ProcessGpsUpdateUseCase } from "../../domain/use-cases/notifications/process-gps-update.use-case.js";
|
||||
import { GetUserStatusUseCase } from "../../domain/use-cases/tracking/get-user-status.use-case.js";
|
||||
import { InMemoryNotificationCache } from "../../data/cache/notification-cache.impl.js";
|
||||
import { InMemoryRouteStateRepository } from "../../data/cache/route-state.impl.js";
|
||||
import { InMemoryNotificationInbox } from "../../data/cache/notification-inbox.impl.js";
|
||||
import { RouteSimulator } from "../../data/simulation/route-simulator.js";
|
||||
|
||||
export class TrackingController {
|
||||
private cache = new InMemoryNotificationCache();
|
||||
// Singletons del módulo (viven mientras corre el server)
|
||||
private static dedupCache = new InMemoryNotificationCache();
|
||||
private static routeState = new InMemoryRouteStateRepository();
|
||||
private static inbox = new InMemoryNotificationInbox();
|
||||
|
||||
private calculateEta = new CalculateEtaUseCase();
|
||||
private evaluateNotification = new EvaluateNotificationUseCase(this.cache);
|
||||
private evaluateNotification = new EvaluateNotificationUseCase(
|
||||
TrackingController.dedupCache,
|
||||
);
|
||||
private processGpsUpdate = new ProcessGpsUpdateUseCase(
|
||||
this.calculateEta,
|
||||
this.evaluateNotification,
|
||||
TrackingController.routeState,
|
||||
TrackingController.inbox,
|
||||
);
|
||||
private getUserStatus = new GetUserStatusUseCase(
|
||||
TrackingController.routeState,
|
||||
TrackingController.inbox,
|
||||
);
|
||||
|
||||
/** Permite que app.ts lance el simulador usando estos mismos singletons. */
|
||||
buildSimulator(): RouteSimulator {
|
||||
return new RouteSimulator(this.processGpsUpdate);
|
||||
}
|
||||
|
||||
gpsUpdate = async (req: Request, res: Response) => {
|
||||
try {
|
||||
@@ -23,6 +54,35 @@ export class TrackingController {
|
||||
}
|
||||
};
|
||||
|
||||
myStatus = async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
if (!req.user) throw CustomError.unauthorized("User not authenticated");
|
||||
const result = await this.getUserStatus.execute(req.user.id);
|
||||
res.status(200).json(result);
|
||||
} catch (error) {
|
||||
this.handleError(error, res);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* POST /reset-demo
|
||||
* Borra cache de dedup, inbox y estado de rutas.
|
||||
* Permite repetir la demo sin reiniciar el server. Solo para hackathon.
|
||||
*/
|
||||
resetDemo = async (_req: Request, res: Response) => {
|
||||
try {
|
||||
await TrackingController.dedupCache.clear();
|
||||
await TrackingController.inbox.clearAll();
|
||||
await TrackingController.routeState.clearAll();
|
||||
|
||||
res
|
||||
.status(200)
|
||||
.json({ message: "Demo state reset — simulator continues running" });
|
||||
} 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 });
|
||||
@@ -34,4 +94,4 @@ export class TrackingController {
|
||||
}
|
||||
res.status(500).json({ error: "Internal server error" });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,24 @@
|
||||
import { Router } from "express";
|
||||
import { TrackingController } from "./controller.js";
|
||||
import { AuthMiddleware } from "../middlewares/auth.middleware.js";
|
||||
|
||||
export class TrackingRoutes {
|
||||
static controllerInstance: TrackingController | null = null;
|
||||
|
||||
static get routes(): Router {
|
||||
const router = Router();
|
||||
const controller = new TrackingController();
|
||||
TrackingRoutes.controllerInstance = controller;
|
||||
|
||||
// Interno: simulador o admin. Sin auth para que el demo sea simple.
|
||||
router.post("/gps-update", controller.gpsUpdate);
|
||||
|
||||
// Visión de túnel: SOLO con JWT, SOLO datos del usuario logueado.
|
||||
router.get("/status", AuthMiddleware.validate, controller.myStatus);
|
||||
|
||||
// Reset del estado en memoria (solo para repetir la demo).
|
||||
router.post("/reset-demo", controller.resetDemo);
|
||||
|
||||
return router;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user