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:
Diego Mireles
2026-05-23 02:33:54 -06:00
parent 53c345d984
commit 59fcad643a
28 changed files with 852 additions and 10 deletions

View File

@@ -3,6 +3,7 @@
import "dotenv/config"; import "dotenv/config";
import { env } from "./config/env.js"; import { env } from "./config/env.js";
import { AppRoutes } from "./presentation/routes.js"; import { AppRoutes } from "./presentation/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";
@@ -11,10 +12,18 @@ async function main() {
await prisma.$connect(); await prisma.$connect();
console.log("Database connected"); 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({ const server = new Server({
port: env.PORT, port: env.PORT,
routes: AppRoutes.routes, routes,
}); });
await server.start(); await server.start();
@@ -24,4 +33,4 @@ async function main() {
} }
} }
main(); main();

View 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();
}
}

View 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();
}
}

View File

@@ -8,6 +8,11 @@ export interface MockUser {
routeId: string; 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[] = [ export const users: MockUser[] = [
{ id: 1, name: "Ana López", email: "ana@test.com", colonia: "Zona Centro", routeId: "RUTA-01" }, { 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" }, { 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: 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: 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" }, { 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);
}
};

View 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];
}
}

View 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);
}
}
}

View 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;
}
}

View 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;
}
}

View File

@@ -1,3 +1,5 @@
export * from './auth/register-user.dto.js'; export * from './auth/register-user.dto.js';
export * from './auth/login-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';

View 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[]>;
}

View File

@@ -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>;
}

View 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[]>;
}

View File

@@ -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,
};
}
}

View File

@@ -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;
}
}

View File

@@ -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,
};
}
}

View File

@@ -5,6 +5,12 @@ import { AuthRepository } from "../../repositories/auth.repository.js";
import { CustomError } from "../../errors/custom.error.js"; import { CustomError } from "../../errors/custom.error.js";
import { BcryptAdapter } from "../../../config/bcrypt.js"; import { BcryptAdapter } from "../../../config/bcrypt.js";
import { JwtAdapter } from "../../../config/jwt.js"; import { JwtAdapter } from "../../../config/jwt.js";
import {
upsertUser,
users,
DEFAULT_ROUTE_ID,
DEFAULT_COLONIA,
} from "../../../data/mocks/users.mock.js";
export interface LoginUserResponse { export interface LoginUserResponse {
user: { user: {
@@ -34,6 +40,19 @@ 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
// 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 // Generar JWT
const token = JwtAdapter.generate({ const token = JwtAdapter.generate({
id: user.id, id: user.id,

View File

@@ -5,6 +5,11 @@ import { AuthRepository } from "../../repositories/auth.repository.js";
import { CustomError } from "../../errors/custom.error.js"; import { CustomError } from "../../errors/custom.error.js";
import { BcryptAdapter } from "../../../config/bcrypt.js"; import { BcryptAdapter } from "../../../config/bcrypt.js";
import { JwtAdapter } from "../../../config/jwt.js"; import { JwtAdapter } from "../../../config/jwt.js";
import {
upsertUser,
DEFAULT_ROUTE_ID,
DEFAULT_COLONIA,
} from "../../../data/mocks/users.mock.js";
export interface RegisterUserResponse { export interface RegisterUserResponse {
user: { user: {
@@ -38,6 +43,17 @@ export class RegisterUserUseCase {
password: hashedPassword, 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 // Generar JWT
const token = JwtAdapter.generate({ const token = JwtAdapter.generate({
id: user.id, id: user.id,

View File

@@ -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);
}
}

View File

@@ -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;
}
}

View File

@@ -3,9 +3,14 @@ import { CalculateEtaUseCase, type EtaResult } from "./calculate-eta.use-case.js
import { import {
EvaluateNotificationUseCase, EvaluateNotificationUseCase,
type EvaluatedNotification, type EvaluatedNotification,
} from "../notifications/evaluate-notification.use-case.js"; } from "./evaluate-notification.use-case.js";
import { routes } from "../../../data/mocks/routes.mock.js"; import { routes } from "../../../data/mocks/routes.mock.js";
import { CustomError } from "../../errors/custom.error.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 { export interface ProcessGpsUpdateResponse {
message: string; message: string;
@@ -18,6 +23,8 @@ export class ProcessGpsUpdateUseCase {
constructor( constructor(
private readonly calculateEta: CalculateEtaUseCase, private readonly calculateEta: CalculateEtaUseCase,
private readonly evaluateNotification: EvaluateNotificationUseCase, private readonly evaluateNotification: EvaluateNotificationUseCase,
private readonly routeState: RouteStateRepository,
private readonly inbox: NotificationInboxRepository,
) {} ) {}
async execute(data: unknown): Promise<ProcessGpsUpdateResponse> { async execute(data: unknown): Promise<ProcessGpsUpdateResponse> {
@@ -31,6 +38,28 @@ export class ProcessGpsUpdateUseCase {
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);
// 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 { return {
message: "GPS update processed", message: "GPS update processed",
truck: { truck: {
@@ -42,4 +71,4 @@ export class ProcessGpsUpdateUseCase {
notifications, notifications,
}; };
} }
} }

View File

@@ -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,
};
}
}

View 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" });
}
}

View 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;
}
}

View 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" });
}
}

View 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;
}
}

View File

@@ -1,6 +1,8 @@
import { Router } from "express"; import { Router } from "express";
import { AuthRoutes } from "./auth/routes.js"; 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 { AddressesRoutes } from "./addresses/routes.js";
export class AppRoutes { export class AppRoutes {
static get routes(): Router { static get routes(): Router {
@@ -8,6 +10,8 @@ export class AppRoutes {
router.use('/api/auth', AuthRoutes.routes); router.use('/api/auth', AuthRoutes.routes);
router.use('/api/tracking', TrackingRoutes.routes); router.use('/api/tracking', TrackingRoutes.routes);
router.use('/api/feedback', FeedbackRoutes.routes);
router.use('/api/addresses', AddressesRoutes.routes);
return router; return router;
} }

View File

@@ -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 { Request, Response } from "express";
import { AuthRequest } from "../middlewares/auth.middleware.js";
import { CustomError } from "../../domain/errors/custom.error.js"; import { CustomError } from "../../domain/errors/custom.error.js";
import { CalculateEtaUseCase } from "../../domain/use-cases/notifications/calculate-eta.use-case.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 { 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 { 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 { 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 { 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 calculateEta = new CalculateEtaUseCase();
private evaluateNotification = new EvaluateNotificationUseCase(this.cache); private evaluateNotification = new EvaluateNotificationUseCase(
TrackingController.dedupCache,
);
private processGpsUpdate = new ProcessGpsUpdateUseCase( private processGpsUpdate = new ProcessGpsUpdateUseCase(
this.calculateEta, this.calculateEta,
this.evaluateNotification, 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) => { gpsUpdate = async (req: Request, res: Response) => {
try { 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 { private handleError(error: unknown, res: Response): void {
if (error instanceof CustomError) { if (error instanceof CustomError) {
res.status(error.statusCode).json({ error: error.message }); res.status(error.statusCode).json({ error: error.message });
@@ -34,4 +94,4 @@ export class TrackingController {
} }
res.status(500).json({ error: "Internal server error" }); res.status(500).json({ error: "Internal server error" });
} }
} }

View File

@@ -1,13 +1,24 @@
import { Router } from "express"; import { Router } from "express";
import { TrackingController } from "./controller.js"; import { TrackingController } from "./controller.js";
import { AuthMiddleware } from "../middlewares/auth.middleware.js";
export class TrackingRoutes { export class TrackingRoutes {
static controllerInstance: TrackingController | null = null;
static get routes(): Router { static get routes(): Router {
const router = Router(); const router = Router();
const controller = new TrackingController(); 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); 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; return router;
} }
} }