diff --git a/backend/src/app.ts b/backend/src/app.ts index c29f054..9e98001 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -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(); \ No newline at end of file +main(); diff --git a/backend/src/data/cache/notification-inbox.impl.ts b/backend/src/data/cache/notification-inbox.impl.ts new file mode 100644 index 0000000..abba7e1 --- /dev/null +++ b/backend/src/data/cache/notification-inbox.impl.ts @@ -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(); + + async addForUser(notification: InboxNotification): Promise { + const list = this.inbox.get(notification.userId) ?? []; + list.unshift(notification); + this.inbox.set(notification.userId, list); + } + + async getForUser(userId: number): Promise { + return this.inbox.get(userId) ?? []; + } + + async clearForUser(userId: number): Promise { + this.inbox.delete(userId); + } + + /** Helper para reset de demo. */ + async clearAll(): Promise { + this.inbox.clear(); + } +} diff --git a/backend/src/data/cache/route-state.impl.ts b/backend/src/data/cache/route-state.impl.ts new file mode 100644 index 0000000..79824ea --- /dev/null +++ b/backend/src/data/cache/route-state.impl.ts @@ -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(); + + async get(routeId: string): Promise { + return this.states.get(routeId) ?? null; + } + + async set(state: RouteState): Promise { + this.states.set(state.routeId, state); + } + + async getAll(): Promise { + return Array.from(this.states.values()); + } + + /** Helper para reset de demo. */ + async clearAll(): Promise { + this.states.clear(); + } +} diff --git a/backend/src/data/mocks/users.mock.ts b/backend/src/data/mocks/users.mock.ts index 1627414..f36ebf6 100644 --- a/backend/src/data/mocks/users.mock.ts +++ b/backend/src/data/mocks/users.mock.ts @@ -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" }, -]; \ No newline at end of file +]; + +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); + } +}; diff --git a/backend/src/data/repositories/feedback.repository.impl.ts b/backend/src/data/repositories/feedback.repository.impl.ts new file mode 100644 index 0000000..9f76b9f --- /dev/null +++ b/backend/src/data/repositories/feedback.repository.impl.ts @@ -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 { + this.items.unshift(item); + } + + async listForUser(userId: number): Promise { + return this.items.filter((i) => i.userId === userId); + } + + async listAll(): Promise { + return [...this.items]; + } +} diff --git a/backend/src/data/simulation/route-simulator.ts b/backend/src/data/simulation/route-simulator.ts new file mode 100644 index 0000000..213226d --- /dev/null +++ b/backend/src/data/simulation/route-simulator.ts @@ -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(); + + 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); + } + } +} diff --git a/backend/src/domain/dtos/addresses/set-address.dto.ts b/backend/src/domain/dtos/addresses/set-address.dto.ts new file mode 100644 index 0000000..0c76ad2 --- /dev/null +++ b/backend/src/domain/dtos/addresses/set-address.dto.ts @@ -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; + + 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; + } +} diff --git a/backend/src/domain/dtos/feedback/submit-feedback.dto.ts b/backend/src/domain/dtos/feedback/submit-feedback.dto.ts new file mode 100644 index 0000000..4664ec6 --- /dev/null +++ b/backend/src/domain/dtos/feedback/submit-feedback.dto.ts @@ -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; + + 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; + } +} diff --git a/backend/src/domain/dtos/index.ts b/backend/src/domain/dtos/index.ts index 8808ec7..cb2f996 100644 --- a/backend/src/domain/dtos/index.ts +++ b/backend/src/domain/dtos/index.ts @@ -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'; \ No newline at end of file +export * from './tracking/gps-update.dto.js'; +export * from './feedback/submit-feedback.dto.js'; +export * from './addresses/set-address.dto.js'; \ No newline at end of file diff --git a/backend/src/domain/repositories/feedback.repository.ts b/backend/src/domain/repositories/feedback.repository.ts new file mode 100644 index 0000000..7bc5b53 --- /dev/null +++ b/backend/src/domain/repositories/feedback.repository.ts @@ -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; + listForUser(userId: number): Promise; + listAll(): Promise; +} diff --git a/backend/src/domain/repositories/notification-inbox.repository.ts b/backend/src/domain/repositories/notification-inbox.repository.ts new file mode 100644 index 0000000..6565c58 --- /dev/null +++ b/backend/src/domain/repositories/notification-inbox.repository.ts @@ -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; + getForUser(userId: number): Promise; + clearForUser(userId: number): Promise; +} diff --git a/backend/src/domain/repositories/route-state.repository.ts b/backend/src/domain/repositories/route-state.repository.ts new file mode 100644 index 0000000..3313306 --- /dev/null +++ b/backend/src/domain/repositories/route-state.repository.ts @@ -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; + set(state: RouteState): Promise; + getAll(): Promise; +} diff --git a/backend/src/domain/use-cases/addresses/get-my-address.use-case.ts b/backend/src/domain/use-cases/addresses/get-my-address.use-case.ts new file mode 100644 index 0000000..9e3dccb --- /dev/null +++ b/backend/src/domain/use-cases/addresses/get-my-address.use-case.ts @@ -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, + }; + } +} diff --git a/backend/src/domain/use-cases/addresses/list-colonias.use-case.ts b/backend/src/domain/use-cases/addresses/list-colonias.use-case.ts new file mode 100644 index 0000000..94a92e2 --- /dev/null +++ b/backend/src/domain/use-cases/addresses/list-colonias.use-case.ts @@ -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; + } +} diff --git a/backend/src/domain/use-cases/addresses/set-my-address.use-case.ts b/backend/src/domain/use-cases/addresses/set-my-address.use-case.ts new file mode 100644 index 0000000..8b5f3f8 --- /dev/null +++ b/backend/src/domain/use-cases/addresses/set-my-address.use-case.ts @@ -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, + }; + } +} diff --git a/backend/src/domain/use-cases/auth/login-user.use-case.ts b/backend/src/domain/use-cases/auth/login-user.use-case.ts index 387c7cc..dd86e46 100644 --- a/backend/src/domain/use-cases/auth/login-user.use-case.ts +++ b/backend/src/domain/use-cases/auth/login-user.use-case.ts @@ -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, diff --git a/backend/src/domain/use-cases/auth/register-user.use-case.ts b/backend/src/domain/use-cases/auth/register-user.use-case.ts index 29fbacf..76b6864 100644 --- a/backend/src/domain/use-cases/auth/register-user.use-case.ts +++ b/backend/src/domain/use-cases/auth/register-user.use-case.ts @@ -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, diff --git a/backend/src/domain/use-cases/feedback/list-my-feedback.use-case.ts b/backend/src/domain/use-cases/feedback/list-my-feedback.use-case.ts new file mode 100644 index 0000000..6defab4 --- /dev/null +++ b/backend/src/domain/use-cases/feedback/list-my-feedback.use-case.ts @@ -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 { + return this.repository.listForUser(userId); + } +} diff --git a/backend/src/domain/use-cases/feedback/submit-feedback.use-case.ts b/backend/src/domain/use-cases/feedback/submit-feedback.use-case.ts new file mode 100644 index 0000000..ca0b288 --- /dev/null +++ b/backend/src/domain/use-cases/feedback/submit-feedback.use-case.ts @@ -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 { + 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; + } +} diff --git a/backend/src/domain/use-cases/notifications/process-gps-update.use-case.ts b/backend/src/domain/use-cases/notifications/process-gps-update.use-case.ts index c953c0b..3813313 100644 --- a/backend/src/domain/use-cases/notifications/process-gps-update.use-case.ts +++ b/backend/src/domain/use-cases/notifications/process-gps-update.use-case.ts @@ -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 { @@ -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, }; } -} \ No newline at end of file +} diff --git a/backend/src/domain/use-cases/tracking/get-user-status.use-case.ts b/backend/src/domain/use-cases/tracking/get-user-status.use-case.ts new file mode 100644 index 0000000..54191b1 --- /dev/null +++ b/backend/src/domain/use-cases/tracking/get-user-status.use-case.ts @@ -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 { + 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, + }; + } +} diff --git a/backend/src/presentation/addresses/controller.ts b/backend/src/presentation/addresses/controller.ts new file mode 100644 index 0000000..ec81d94 --- /dev/null +++ b/backend/src/presentation/addresses/controller.ts @@ -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" }); + } +} diff --git a/backend/src/presentation/addresses/routes.ts b/backend/src/presentation/addresses/routes.ts new file mode 100644 index 0000000..77106f0 --- /dev/null +++ b/backend/src/presentation/addresses/routes.ts @@ -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; + } +} diff --git a/backend/src/presentation/feedback/controller.ts b/backend/src/presentation/feedback/controller.ts new file mode 100644 index 0000000..d262547 --- /dev/null +++ b/backend/src/presentation/feedback/controller.ts @@ -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" }); + } +} diff --git a/backend/src/presentation/feedback/routes.ts b/backend/src/presentation/feedback/routes.ts new file mode 100644 index 0000000..001ce4a --- /dev/null +++ b/backend/src/presentation/feedback/routes.ts @@ -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; + } +} diff --git a/backend/src/presentation/routes.ts b/backend/src/presentation/routes.ts index c7aff04..f3cfe32 100644 --- a/backend/src/presentation/routes.ts +++ b/backend/src/presentation/routes.ts @@ -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; } diff --git a/backend/src/presentation/tracking/controller.ts b/backend/src/presentation/tracking/controller.ts index 6e3e348..1b5b4db 100644 --- a/backend/src/presentation/tracking/controller.ts +++ b/backend/src/presentation/tracking/controller.ts @@ -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" }); } -} \ No newline at end of file +} diff --git a/backend/src/presentation/tracking/routes.ts b/backend/src/presentation/tracking/routes.ts index 2649edd..f0aec3e 100644 --- a/backend/src/presentation/tracking/routes.ts +++ b/backend/src/presentation/tracking/routes.ts @@ -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; } -} \ No newline at end of file +}