feat: implements notify sistem

This commit is contained in:
Diego Mireles
2026-05-22 20:53:31 -06:00
parent f65681d2bd
commit 9f3815d047
29 changed files with 690 additions and 102 deletions

View File

@@ -1,10 +1,4 @@
/**
* app.ts
* Punto de entrada de la aplicación.
* - Inicia el servidor
* - Conecta a la base de datos
* - Maneja errores globales
*/
import "dotenv/config";
import { env } from "./config/env.js";
@@ -14,11 +8,10 @@ import { prisma } from "./data/postgres/index.js";
async function main() {
try {
// Verificar conexión a la base de datos
await prisma.$connect();
console.log("Database connected");
console.log("Database connected");
// Crear y iniciar el servidor
const server = new Server({
port: env.PORT,
routes: AppRoutes.routes,
@@ -26,7 +19,7 @@ async function main() {
await server.start();
} catch (error) {
console.error(" Error starting application:", error);
console.error(" Error starting application:", error);
process.exit(1);
}
}

View File

@@ -1,8 +1,4 @@
/**
* bcrypt.ts
* BcryptAdapter: hashea y compara contraseñas.
* Encapsula toda la lógica de bcrypt en un lugar.
*/
import bcrypt from "bcryptjs";
import { env } from "./env.js";

View File

@@ -1,8 +1,4 @@
/**
* env.ts
* Lee y valida las variables de entorno.
* Centraliza toda la configuración que viene del .env
*/
const getEnvVar = (key: string, defaultValue?: string): string => {
const value = process.env[key];

View File

@@ -0,0 +1,17 @@
import type { NotificationCacheRepository } from "../../domain/repositories/notification-cache.repository.js";
export class InMemoryNotificationCache implements NotificationCacheRepository {
private readonly sent = new Set<string>();
async wasSent(key: string): Promise<boolean> {
return this.sent.has(key);
}
async markSent(key: string): Promise<void> {
this.sent.add(key);
}
async clear(): Promise<void> {
this.sent.clear();
}
}

View File

@@ -0,0 +1,17 @@
export interface Colonia {
colonia: string;
routeId: string;
horarioEstimado: string;
}
export const colonias: Colonia[] = [
{ colonia: "Zona Centro", routeId: "RUTA-01", horarioEstimado: "Matutino (06:30 - 07:15)" },
{ colonia: "Las Arboledas", routeId: "RUTA-01", horarioEstimado: "Matutino (07:00 - 07:30)" },
{ colonia: "Trojes", routeId: "RUTA-13", horarioEstimado: "Matutino (06:40 - 07:10)" },
{ colonia: "San Juanico", routeId: "RUTA-03", horarioEstimado: "Matutino (06:45 - 07:15)" },
{ colonia: "Los Olivos", routeId: "RUTA-04", horarioEstimado: "Matutino (07:00 - 07:40)" },
{ colonia: "Rancho Seco", routeId: "RUTA-05", horarioEstimado: "Vespertino (14:15 - 15:00)" },
{ colonia: "Las Insurgentes", routeId: "RUTA-12", horarioEstimado: "Matutino (06:35 - 07:10)" },
];

View File

@@ -0,0 +1,47 @@
export const NotificationType = {
ROUTE_START: "ROUTE_START",
TRUCK_PROXIMITY: "TRUCK_PROXIMITY",
ROUTE_COMPLETED: "ROUTE_COMPLETED",
DELAY: "DELAY",
MECHANICAL_FAILURE: "MECHANICAL_FAILURE",
} as const;
export type NotificationType =
(typeof NotificationType)[keyof typeof NotificationType];
export interface NotificationPayload {
title: string;
body: string;
}
export const notificationPayloads: Record<NotificationType, NotificationPayload> = {
ROUTE_START: {
title: "¡Ruta Iniciada!",
body: "El camión recolector ha salido del Relleno Sanitario rumbo a tu sector. Asegúrate de tener listos tus residuos.",
},
TRUCK_PROXIMITY: {
title: "Camión Cercano",
body: "El camión está a menos de 15 minutos de tu domicilio. Es momento de sacar tus bolsas a la acera.",
},
ROUTE_COMPLETED: {
title: "Servicio Finalizado",
body: "El camión de tu sector ha concluido su jornada de recolección diaria.",
},
DELAY: {
title: "Retraso en la ruta",
body: "La ruta presenta retraso por tráfico. Te avisaremos cuando el camión esté cerca.",
},
MECHANICAL_FAILURE: {
title: "Falla mecánica",
body: "El camión de tu sector presenta una falla mecánica. La recolección puede retrasarse.",
},
};
export const positionTriggers: Record<number, NotificationType> = {
2: NotificationType.ROUTE_START,
4: NotificationType.TRUCK_PROXIMITY,
8: NotificationType.ROUTE_COMPLETED,
};

View File

@@ -0,0 +1,259 @@
export interface RoutePosition {
positionId: number;
lat: number;
lng: number;
speed: number;
timestamp: string;
}
export interface MockRoute {
routeId: string;
name: string;
truckId: number;
status: "EN_RUTA" | "DETENIDO" | "FINALIZADO" | "FALLA";
positions: RoutePosition[];
}
export const routes: MockRoute[] = [
{
routeId: "RUTA-01",
name: "Zona Centro - Las Arboledas",
truckId: 101,
status: "EN_RUTA",
positions: [
{ positionId: 1, lat: 20.5111, lng: -100.9037, speed: 0, timestamp: "2026-05-22T06:00:00Z" },
{ positionId: 2, lat: 20.5185, lng: -100.8450, speed: 45, timestamp: "2026-05-22T06:12:00Z" },
{ positionId: 3, lat: 20.5215, lng: -100.8142, speed: 22, timestamp: "2026-05-22T06:25:00Z" },
{ positionId: 4, lat: 20.5212, lng: -100.8175, speed: 15, timestamp: "2026-05-22T06:38:00Z" },
{ positionId: 5, lat: 20.5210, lng: -100.8210, speed: 0, timestamp: "2026-05-22T06:50:00Z" },
{ positionId: 6, lat: 20.5235, lng: -100.8212, speed: 18, timestamp: "2026-05-22T07:05:00Z" },
{ positionId: 7, lat: 20.5260, lng: -100.8215, speed: 20, timestamp: "2026-05-22T07:18:00Z" },
{ positionId: 8, lat: 20.5111, lng: -100.9037, speed: 40, timestamp: "2026-05-22T07:40:00Z" },
],
},
{
routeId: "RUTA-02",
name: "Sector Norte - Av. Tecnológico",
truckId: 102,
status: "EN_RUTA",
positions: [
{ positionId: 1, lat: 20.5111, lng: -100.9037, speed: 0, timestamp: "2026-05-22T06:05:00Z" },
{ positionId: 2, lat: 20.5280, lng: -100.8135, speed: 38, timestamp: "2026-05-22T06:18:00Z" },
{ positionId: 3, lat: 20.5410, lng: -100.8130, speed: 25, timestamp: "2026-05-22T06:30:00Z" },
{ positionId: 4, lat: 20.5445, lng: -100.8132, speed: 12, timestamp: "2026-05-22T06:45:00Z" },
{ positionId: 5, lat: 20.5480, lng: -100.8135, speed: 0, timestamp: "2026-05-22T06:58:00Z" },
{ positionId: 6, lat: 20.5515, lng: -100.8138, speed: 15, timestamp: "2026-05-22T07:10:00Z" },
{ positionId: 7, lat: 20.5540, lng: -100.8110, speed: 22, timestamp: "2026-05-22T07:25:00Z" },
{ positionId: 8, lat: 20.5111, lng: -100.9037, speed: 45, timestamp: "2026-05-22T07:50:00Z" },
],
},
{
routeId: "RUTA-03",
name: "Sector Poniente - San Juanico",
truckId: 103,
status: "EN_RUTA",
positions: [
{ positionId: 1, lat: 20.5111, lng: -100.9037, speed: 0, timestamp: "2026-05-22T06:10:00Z" },
{ positionId: 2, lat: 20.5250, lng: -100.8510, speed: 42, timestamp: "2026-05-22T06:20:00Z" },
{ positionId: 3, lat: 20.5290, lng: -100.8320, speed: 20, timestamp: "2026-05-22T06:35:00Z" },
{ positionId: 4, lat: 20.5315, lng: -100.8355, speed: 15, timestamp: "2026-05-22T06:48:00Z" },
{ positionId: 5, lat: 20.5340, lng: -100.8390, speed: 0, timestamp: "2026-05-22T07:00:00Z" },
{ positionId: 6, lat: 20.5362, lng: -100.8425, speed: 10, timestamp: "2026-05-22T07:15:00Z" },
{ positionId: 7, lat: 20.5330, lng: -100.8430, speed: 18, timestamp: "2026-05-22T07:28:00Z" },
{ positionId: 8, lat: 20.5111, lng: -100.9037, speed: 35, timestamp: "2026-05-22T07:45:00Z" },
],
},
{
routeId: "RUTA-04",
name: "Oriente - Los Olivos",
truckId: 104,
status: "EN_RUTA",
positions: [
{ positionId: 1, lat: 20.5111, lng: -100.9037, speed: 0, timestamp: "2026-05-22T06:15:00Z" },
{ positionId: 2, lat: 20.5260, lng: -100.8010, speed: 45, timestamp: "2026-05-22T06:30:00Z" },
{ positionId: 3, lat: 20.5295, lng: -100.7890, speed: 24, timestamp: "2026-05-22T06:45:00Z" },
{ positionId: 4, lat: 20.5320, lng: -100.7850, speed: 12, timestamp: "2026-05-22T06:58:00Z" },
{ positionId: 5, lat: 20.5350, lng: -100.7790, speed: 0, timestamp: "2026-05-22T07:12:00Z" },
{ positionId: 6, lat: 20.5310, lng: -100.7760, speed: 15, timestamp: "2026-05-22T07:25:00Z" },
{ positionId: 7, lat: 20.5270, lng: -100.7820, speed: 26, timestamp: "2026-05-22T07:38:00Z" },
{ positionId: 8, lat: 20.5111, lng: -100.9037, speed: 48, timestamp: "2026-05-22T07:58:00Z" },
],
},
{
routeId: "RUTA-05",
name: "Sector Sur - Rancho Seco",
truckId: 105,
status: "EN_RUTA",
positions: [
{ positionId: 1, lat: 20.5111, lng: -100.9037, speed: 0, timestamp: "2026-05-22T06:20:00Z" },
{ positionId: 2, lat: 20.5050, lng: -100.8620, speed: 35, timestamp: "2026-05-22T06:32:00Z" },
{ positionId: 3, lat: 20.5020, lng: -100.8350, speed: 22, timestamp: "2026-05-22T06:45:00Z" },
{ positionId: 4, lat: 20.4995, lng: -100.8210, speed: 14, timestamp: "2026-05-22T06:58:00Z" },
{ positionId: 5, lat: 20.4970, lng: -100.8150, speed: 0, timestamp: "2026-05-22T07:10:00Z" },
{ positionId: 6, lat: 20.5010, lng: -100.8120, speed: 16, timestamp: "2026-05-22T07:22:00Z" },
{ positionId: 7, lat: 20.5060, lng: -100.8160, speed: 25, timestamp: "2026-05-22T07:35:00Z" },
{ positionId: 8, lat: 20.5111, lng: -100.9037, speed: 40, timestamp: "2026-05-22T07:55:00Z" },
],
},
{
routeId: "RUTA-06",
name: "Norte Extremo - Rumbos de Roque",
truckId: 106,
status: "EN_RUTA",
positions: [
{ positionId: 1, lat: 20.5111, lng: -100.9037, speed: 0, timestamp: "2026-05-22T06:00:00Z" },
{ positionId: 2, lat: 20.5380, lng: -100.8380, speed: 40, timestamp: "2026-05-22T06:15:00Z" },
{ positionId: 3, lat: 20.5610, lng: -100.8370, speed: 30, timestamp: "2026-05-22T06:30:00Z" },
{ positionId: 4, lat: 20.5750, lng: -100.8360, speed: 15, timestamp: "2026-05-22T06:45:00Z" },
{ positionId: 5, lat: 20.5820, lng: -100.8350, speed: 0, timestamp: "2026-05-22T07:00:00Z" },
{ positionId: 6, lat: 20.5780, lng: -100.8310, speed: 20, timestamp: "2026-05-22T07:15:00Z" },
{ positionId: 7, lat: 20.5650, lng: -100.8320, speed: 28, timestamp: "2026-05-22T07:30:00Z" },
{ positionId: 8, lat: 20.5111, lng: -100.9037, speed: 45, timestamp: "2026-05-22T07:55:00Z" },
],
},
{
routeId: "RUTA-07",
name: "Nororiente - Ciudad Industrial",
truckId: 107,
status: "EN_RUTA",
positions: [
{ positionId: 1, lat: 20.5111, lng: -100.9037, speed: 0, timestamp: "2026-05-22T06:10:00Z" },
{ positionId: 2, lat: 20.5350, lng: -100.8050, speed: 44, timestamp: "2026-05-22T06:24:00Z" },
{ positionId: 3, lat: 20.5450, lng: -100.7950, speed: 25, timestamp: "2026-05-22T06:38:00Z" },
{ positionId: 4, lat: 20.5480, lng: -100.7850, speed: 18, timestamp: "2026-05-22T06:52:00Z" },
{ positionId: 5, lat: 20.5510, lng: -100.7750, speed: 0, timestamp: "2026-05-22T07:05:00Z" },
{ positionId: 6, lat: 20.5460, lng: -100.7720, speed: 12, timestamp: "2026-05-22T07:18:00Z" },
{ positionId: 7, lat: 20.5390, lng: -100.7820, speed: 30, timestamp: "2026-05-22T07:30:00Z" },
{ positionId: 8, lat: 20.5111, lng: -100.9037, speed: 42, timestamp: "2026-05-22T07:52:00Z" },
],
},
{
routeId: "RUTA-08",
name: "Suroriente - Universidad Latina",
truckId: 108,
status: "EN_RUTA",
positions: [
{ positionId: 1, lat: 20.5111, lng: -100.9037, speed: 0, timestamp: "2026-05-22T06:15:00Z" },
{ positionId: 2, lat: 20.5180, lng: -100.8310, speed: 38, timestamp: "2026-05-22T06:28:00Z" },
{ positionId: 3, lat: 20.5245, lng: -100.7980, speed: 30, timestamp: "2026-05-22T06:42:00Z" },
{ positionId: 4, lat: 20.5210, lng: -100.7995, speed: 14, timestamp: "2026-05-22T06:55:00Z" },
{ positionId: 5, lat: 20.5175, lng: -100.8010, speed: 0, timestamp: "2026-05-22T07:08:00Z" },
{ positionId: 6, lat: 20.5140, lng: -100.8030, speed: 18, timestamp: "2026-05-22T07:20:00Z" },
{ positionId: 7, lat: 20.5110, lng: -100.8055, speed: 22, timestamp: "2026-05-22T07:32:00Z" },
{ positionId: 8, lat: 20.5111, lng: -100.9037, speed: 40, timestamp: "2026-05-22T07:54:00Z" },
],
},
{
routeId: "RUTA-09",
name: "Poniente - Hospital General",
truckId: 109,
status: "EN_RUTA",
positions: [
{ positionId: 1, lat: 20.5111, lng: -100.9037, speed: 0, timestamp: "2026-05-22T06:02:00Z" },
{ positionId: 2, lat: 20.5210, lng: -100.8650, speed: 45, timestamp: "2026-05-22T06:12:00Z" },
{ positionId: 3, lat: 20.5260, lng: -100.8520, speed: 26, timestamp: "2026-05-22T06:24:00Z" },
{ positionId: 4, lat: 20.5275, lng: -100.8490, speed: 12, timestamp: "2026-05-22T06:36:00Z" },
{ positionId: 5, lat: 20.5285, lng: -100.8460, speed: 0, timestamp: "2026-05-22T06:48:00Z" },
{ positionId: 6, lat: 20.5250, lng: -100.8470, speed: 15, timestamp: "2026-05-22T07:00:00Z" },
{ positionId: 7, lat: 20.5220, lng: -100.8550, speed: 32, timestamp: "2026-05-22T07:12:00Z" },
{ positionId: 8, lat: 20.5111, lng: -100.9037, speed: 44, timestamp: "2026-05-22T07:30:00Z" },
],
},
{
routeId: "RUTA-10",
name: "Eje Juan Pablo II - Sede UG Sur",
truckId: 110,
status: "EN_RUTA",
positions: [
{ positionId: 1, lat: 20.5111, lng: -100.9037, speed: 0, timestamp: "2026-05-22T06:22:00Z" },
{ positionId: 2, lat: 20.5015, lng: -100.8520, speed: 40, timestamp: "2026-05-22T06:34:00Z" },
{ positionId: 3, lat: 20.4990, lng: -100.8390, speed: 28, timestamp: "2026-05-22T06:46:00Z" },
{ positionId: 4, lat: 20.4950, lng: -100.8320, speed: 18, timestamp: "2026-05-22T06:58:00Z" },
{ positionId: 5, lat: 20.4920, lng: -100.8280, speed: 0, timestamp: "2026-05-22T07:10:00Z" },
{ positionId: 6, lat: 20.4945, lng: -100.8240, speed: 14, timestamp: "2026-05-22T07:22:00Z" },
{ positionId: 7, lat: 20.4980, lng: -100.8300, speed: 30, timestamp: "2026-05-22T07:34:00Z" },
{ positionId: 8, lat: 20.5111, lng: -100.9037, speed: 38, timestamp: "2026-05-22T07:52:00Z" },
],
},
{
routeId: "RUTA-11",
name: "Zona de Oro - Torres Landa",
truckId: 111,
status: "EN_RUTA",
positions: [
{ positionId: 1, lat: 20.5111, lng: -100.9037, speed: 0, timestamp: "2026-05-22T06:04:00Z" },
{ positionId: 2, lat: 20.5240, lng: -100.8350, speed: 36, timestamp: "2026-05-22T06:16:00Z" },
{ positionId: 3, lat: 20.5280, lng: -100.8250, speed: 22, timestamp: "2026-05-22T06:29:00Z" },
{ positionId: 4, lat: 20.5295, lng: -100.8210, speed: 10, timestamp: "2026-05-22T06:42:00Z" },
{ positionId: 5, lat: 20.5310, lng: -100.8170, speed: 0, timestamp: "2026-05-22T06:55:00Z" },
{ positionId: 6, lat: 20.5290, lng: -100.8140, speed: 16, timestamp: "2026-05-22T07:08:00Z" },
{ positionId: 7, lat: 20.5260, lng: -100.8220, speed: 28, timestamp: "2026-05-22T07:21:00Z" },
{ positionId: 8, lat: 20.5111, lng: -100.9037, speed: 42, timestamp: "2026-05-22T07:42:00Z" },
],
},
{
routeId: "RUTA-12",
name: "Nororiente - Las Insurgentes",
truckId: 112,
status: "EN_RUTA",
positions: [
{ positionId: 1, lat: 20.5111, lng: -100.9037, speed: 0, timestamp: "2026-05-22T06:08:00Z" },
{ positionId: 2, lat: 20.5280, lng: -100.8080, speed: 40, timestamp: "2026-05-22T06:22:00Z" },
{ positionId: 3, lat: 20.5320, lng: -100.7980, speed: 24, timestamp: "2026-05-22T06:35:00Z" },
{ positionId: 4, lat: 20.5340, lng: -100.7940, speed: 15, timestamp: "2026-05-22T06:48:00Z" },
{ positionId: 5, lat: 20.5360, lng: -100.7900, speed: 0, timestamp: "2026-05-22T07:00:00Z" },
{ positionId: 6, lat: 20.5310, lng: -100.7920, speed: 12, timestamp: "2026-05-22T07:12:00Z" },
{ positionId: 7, lat: 20.5270, lng: -100.8020, speed: 26, timestamp: "2026-05-22T07:25:00Z" },
{ positionId: 8, lat: 20.5111, lng: -100.9037, speed: 44, timestamp: "2026-05-22T07:48:00Z" },
],
},
{
routeId: "RUTA-13",
name: "Sector Norte - Trojes e Irrigación",
truckId: 113,
status: "EN_RUTA",
positions: [
{ positionId: 1, lat: 20.5111, lng: -100.9037, speed: 0, timestamp: "2026-05-22T06:12:00Z" },
{ positionId: 2, lat: 20.5360, lng: -100.8190, speed: 35, timestamp: "2026-05-22T06:26:00Z" },
{ positionId: 3, lat: 20.5420, lng: -100.8080, speed: 28, timestamp: "2026-05-22T06:40:00Z" },
{ positionId: 4, lat: 20.5440, lng: -100.8040, speed: 14, timestamp: "2026-05-22T06:54:00Z" },
{ positionId: 5, lat: 20.5460, lng: -100.8000, speed: 0, timestamp: "2026-05-22T07:06:00Z" },
{ positionId: 6, lat: 20.5410, lng: -100.8020, speed: 18, timestamp: "2026-05-22T07:18:00Z" },
{ positionId: 7, lat: 20.5370, lng: -100.8120, speed: 25, timestamp: "2026-05-22T07:30:00Z" },
{ positionId: 8, lat: 20.5111, lng: -100.9037, speed: 39, timestamp: "2026-05-22T07:54:00Z" },
],
},
{
routeId: "RUTA-14",
name: "Sur Poniente - La Toscana",
truckId: 114,
status: "EN_RUTA",
positions: [
{ positionId: 1, lat: 20.5111, lng: -100.9037, speed: 0, timestamp: "2026-05-22T06:16:00Z" },
{ positionId: 2, lat: 20.5150, lng: -100.8580, speed: 42, timestamp: "2026-05-22T06:28:00Z" },
{ positionId: 3, lat: 20.5140, lng: -100.8390, speed: 26, timestamp: "2026-05-22T06:41:00Z" },
{ positionId: 4, lat: 20.5125, lng: -100.8310, speed: 16, timestamp: "2026-05-22T06:54:00Z" },
{ positionId: 5, lat: 20.5110, lng: -100.8250, speed: 0, timestamp: "2026-05-22T07:06:00Z" },
{ positionId: 6, lat: 20.5135, lng: -100.8280, speed: 12, timestamp: "2026-05-22T07:18:00Z" },
{ positionId: 7, lat: 20.5160, lng: -100.8420, speed: 32, timestamp: "2026-05-22T07:30:00Z" },
{ positionId: 8, lat: 20.5111, lng: -100.9037, speed: 45, timestamp: "2026-05-22T07:51:00Z" },
],
},
{
routeId: "RUTA-15",
name: "Norponiente - Camino a San José de Celaya",
truckId: 115,
status: "EN_RUTA",
positions: [
{ positionId: 1, lat: 20.5111, lng: -100.9037, speed: 0, timestamp: "2026-05-22T06:18:00Z" },
{ positionId: 2, lat: 20.5320, lng: -100.8590, speed: 38, timestamp: "2026-05-22T06:31:00Z" },
{ positionId: 3, lat: 20.5390, lng: -100.8480, speed: 24, timestamp: "2026-05-22T06:44:00Z" },
{ positionId: 4, lat: 20.5420, lng: -100.8440, speed: 15, timestamp: "2026-05-22T06:57:00Z" },
{ positionId: 5, lat: 20.5450, lng: -100.8410, speed: 0, timestamp: "2026-05-22T07:09:00Z" },
{ positionId: 6, lat: 20.5410, lng: -100.8430, speed: 14, timestamp: "2026-05-22T07:21:00Z" },
{ positionId: 7, lat: 20.5360, lng: -100.8520, speed: 28, timestamp: "2026-05-22T07:33:00Z" },
{ positionId: 8, lat: 20.5111, lng: -100.9037, speed: 41, timestamp: "2026-05-22T07:54:00Z" },
],
},
];

View File

@@ -0,0 +1,19 @@
export interface MockUser {
id: number;
name: string;
email: string;
colonia: string;
routeId: string;
}
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" },
{ id: 3, name: "María Ruiz", email: "maria@test.com", colonia: "Trojes", routeId: "RUTA-13" },
{ id: 4, name: "Jorge Martínez", email: "jorge@test.com", colonia: "San Juanico", routeId: "RUTA-03" },
{ 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" },
];

View File

@@ -1,8 +1,4 @@
/**
* login-user.dto.ts
* DTO para iniciar sesión.
* Define qué datos esperamos al hacer login.
*/
export interface LoginUserDto {
email: string;

View File

@@ -1,8 +1,4 @@
/**
* register-user.dto.ts
* DTO para registrar un usuario.
* Define qué datos esperamos al registrar.
*/
export interface RegisterUserDto {
name: string;

View File

@@ -1,2 +1,3 @@
export * from './auth/register-user.dto.js';
export * from './auth/login-user.dto.js';
export * from './tracking/gps-update.dto.js';

View File

@@ -0,0 +1,68 @@
export type TruckStatus = "EN_RUTA" | "DETENIDO" | "FINALIZADO" | "FALLA";
export interface GpsUpdateDto {
truckId: string;
routeId: string;
lat: number;
lng: number;
speed: number;
status: TruckStatus;
positionId: number | undefined;
timestamp: string | undefined;
}
const VALID_STATUSES: TruckStatus[] = ["EN_RUTA", "DETENIDO", "FINALIZADO", "FALLA"];
export class GpsUpdateDtoValidator {
static validate(data: unknown): GpsUpdateDto {
if (typeof data !== "object" || data === null) {
throw new Error("Invalid GPS update payload");
}
const obj = data as Record<string, unknown>;
const truckId = obj.truckId;
if (typeof truckId !== "string" && typeof truckId !== "number") {
throw new Error("truckId is required");
}
if (typeof obj.routeId !== "string" || obj.routeId.trim().length === 0) {
throw new Error("routeId is required");
}
if (typeof obj.lat !== "number" || Number.isNaN(obj.lat)) {
throw new Error("lat is required and must be numeric");
}
if (typeof obj.lng !== "number" || Number.isNaN(obj.lng)) {
throw new Error("lng is required and must be numeric");
}
const speed =
typeof obj.speed === "number" && !Number.isNaN(obj.speed) ? obj.speed : 0;
const status: TruckStatus =
typeof obj.status === "string" && VALID_STATUSES.includes(obj.status as TruckStatus)
? (obj.status as TruckStatus)
: "EN_RUTA";
const positionId =
typeof obj.positionId === "number" ? obj.positionId : undefined;
const timestamp =
typeof obj.timestamp === "string" ? obj.timestamp : undefined;
return {
truckId: String(truckId),
routeId: obj.routeId.trim(),
lat: obj.lat,
lng: obj.lng,
speed,
status,
positionId,
timestamp,
};
}
}

View File

@@ -1,8 +1,4 @@
/**
* custom.error.ts
* CustomError: clase base para errores personalizados.
* Facilita el manejo de errores en toda la aplicación.
*/
export class CustomError extends Error {
constructor(

View File

@@ -1,8 +1,4 @@
/**
* auth.repository.ts
* AuthRepository (interfaz): define qué métodos debe tener el repositorio de auth.
* El use-case usa esta interfaz sin saber cómo se implementa.
*/
export interface AuthRepository {
findByEmail(email: string): Promise<{ id: number; email: string; password: string; name: string } | null>;

View File

@@ -0,0 +1,12 @@
export interface NotificationCacheRepository {
wasSent(key: string): Promise<boolean>;
markSent(key: string): Promise<void>;
clear(): Promise<void>;
}

View File

@@ -1,9 +1,4 @@
/**
* get-me.use-case.ts
* GetMeUseCase: obtiene los datos del usuario autenticado.
* - Busca el usuario por ID (que viene del JWT)
* - Retorna los datos del usuario
*/
import { AuthRepository } from "../../repositories/auth.repository.js";
import { CustomError } from "../../errors/custom.error.js";

View File

@@ -1,11 +1,4 @@
/**
* login-user.use-case.ts
* LoginUserUseCase: lógica para iniciar sesión.
* - Valida datos
* - Busca el usuario por email
* - Compara las contraseñas
* - Genera el JWT
*/
import { LoginUserDto, LoginUserDtoValidator } from "../../dtos/index.js";
import { AuthRepository } from "../../repositories/auth.repository.js";

View File

@@ -1,12 +1,4 @@
/**
* register-user.use-case.ts
* RegisterUserUseCase: lógica para registrar un usuario.
* - Valida datos
* - Verifica si el email ya existe
* - Hashea la contraseña
* - Crea el usuario
* - Genera el JWT
*/
import { RegisterUserDto, RegisterUserDtoValidator } from "../../dtos/index.js";
import { AuthRepository } from "../../repositories/auth.repository.js";

View File

@@ -0,0 +1,67 @@
import { CustomError } from "../../errors/custom.error.js";
import { routes } from "../../../data/mocks/routes.mock.js";
import { GpsUpdateDto } from "../../dtos/tracking/gps-update.dto.js";
export interface EtaResult {
etaMinutes: number;
arrivalWindow: { from: string; to: string };
message: string;
}
const PROXIMITY_POSITION_ID = 4;
const WINDOW_MARGIN_MIN = 5;
export class CalculateEtaUseCase {
execute(gps: GpsUpdateDto): EtaResult {
const route = routes.find((r) => r.routeId === gps.routeId);
if (!route) {
throw CustomError.notFound(`Route ${gps.routeId} not found`);
}
if (gps.status === "FALLA") {
return {
etaMinutes: -1,
arrivalWindow: { from: "--:--", to: "--:--" },
message:
"La ruta presenta una falla mecánica. La recolección puede retrasarse.",
};
}
const target = route.positions.find(
(p) => p.positionId === PROXIMITY_POSITION_ID,
);
if (!target) {
throw CustomError.internalServer(
`Route ${route.routeId} has no proximity position`,
);
}
const nowMs = new Date(gps.timestamp ?? new Date().toISOString()).getTime();
const targetMs = new Date(target.timestamp).getTime();
const etaMinutes = Math.round((targetMs - nowMs) / 60000);
const fromDate = new Date(targetMs - WINDOW_MARGIN_MIN * 60000);
const toDate = new Date(targetMs + WINDOW_MARGIN_MIN * 60000);
const arrivalWindow = {
from: this.toHHMM(fromDate),
to: this.toHHMM(toDate),
};
let message: string;
if (etaMinutes < 0) {
message = `El camión ya pasó por tu zona (aprox. ${arrivalWindow.from} - ${arrivalWindow.to}).`;
} else if (etaMinutes <= 15) {
message = `Llega en aproximadamente ${etaMinutes} minutos.`;
} else {
message = `El camión llegará a tu zona entre las ${arrivalWindow.from} y ${arrivalWindow.to}.`;
}
return { etaMinutes, arrivalWindow, message };
}
private toHHMM(date: Date): string {
const hh = String(date.getUTCHours()).padStart(2, "0");
const mm = String(date.getUTCMinutes()).padStart(2, "0");
return `${hh}:${mm}`;
}
}

View File

@@ -0,0 +1,55 @@
import type { GpsUpdateDto } from "../../dtos/index.js";
import type { NotificationCacheRepository } from "../../repositories/notification-cache.repository.js";
import {
NotificationType,
notificationPayloads,
positionTriggers,
} from "../../../data/mocks/notification-types.mock.js";
import { users } from "../../../data/mocks/users.mock.js";
export interface EvaluatedNotification {
userId: number;
type: NotificationType;
title: string;
body: string;
}
export class EvaluateNotificationUseCase {
constructor(private readonly cache: NotificationCacheRepository) {}
async execute(gps: GpsUpdateDto): Promise<EvaluatedNotification[]> {
const targets = users.filter((u) => u.routeId === gps.routeId);
if (targets.length === 0) return [];
const type = this.resolveType(gps);
if (!type) return [];
const payload = notificationPayloads[type];
const result: EvaluatedNotification[] = [];
for (const user of targets) {
const key = `${user.id}-${gps.routeId}-${type}`;
if (await this.cache.wasSent(key)) continue;
await this.cache.markSent(key);
result.push({
userId: user.id,
type,
title: payload.title,
body: payload.body,
});
}
return result;
}
private resolveType(gps: GpsUpdateDto): NotificationType | null {
if (gps.status === "FALLA") {
return NotificationType.MECHANICAL_FAILURE;
}
if (gps.positionId !== undefined && positionTriggers[gps.positionId]) {
return positionTriggers[gps.positionId] ?? null;
}
return null;
}
}

View File

@@ -0,0 +1,45 @@
import { GpsUpdateDtoValidator } from "../../dtos/index.js";
import { CalculateEtaUseCase, type EtaResult } from "./calculate-eta.use-case.js";
import {
EvaluateNotificationUseCase,
type EvaluatedNotification,
} from "../notifications/evaluate-notification.use-case.js";
import { routes } from "../../../data/mocks/routes.mock.js";
import { CustomError } from "../../errors/custom.error.js";
export interface ProcessGpsUpdateResponse {
message: string;
truck: { truckId: number; routeId: string; status: string };
eta: EtaResult;
notifications: EvaluatedNotification[];
}
export class ProcessGpsUpdateUseCase {
constructor(
private readonly calculateEta: CalculateEtaUseCase,
private readonly evaluateNotification: EvaluateNotificationUseCase,
) {}
async execute(data: unknown): Promise<ProcessGpsUpdateResponse> {
const dto = GpsUpdateDtoValidator.validate(data);
const route = routes.find((r) => r.routeId === dto.routeId);
if (!route) {
throw CustomError.notFound(`Route ${dto.routeId} not found`);
}
const eta = this.calculateEta.execute(dto);
const notifications = await this.evaluateNotification.execute(dto);
return {
message: "GPS update processed",
truck: {
truckId: route.truckId,
routeId: route.routeId,
status: dto.status,
},
eta,
notifications,
};
}
}

View File

@@ -1,11 +1,4 @@
/**
* controller.ts
* AuthController: maneja las peticiones HTTP de autenticación.
* - Recibe los datos
* - Llama al use-case correspondiente
* - Responde al cliente
* - Maneja los errores
*/
import { Response } from "express";
import { AuthRequest } from "../middlewares/auth.middleware.js";

View File

@@ -1,8 +1,4 @@
/**
* routes.ts
* Rutas de autenticación.
* Define los endpoints de register, login y getMe.
*/
import { Router } from "express";
import { AuthController } from "./controller.js";

View File

@@ -1,11 +1,4 @@
/**
* auth.middleware.ts
* Middleware para validar el JWT.
* - Lee el token del header Authorization: Bearer TOKEN
* - Valida el token
* - Busca el usuario en base de datos
* - Agrega el usuario a req.user
*/
import { Request, Response, NextFunction } from "express";
import { JwtAdapter, JwtPayload } from "../../config/jwt.js";
@@ -19,28 +12,28 @@ export interface AuthRequest extends Request {
export class AuthMiddleware {
static async validate(req: AuthRequest, res: Response, next: NextFunction) {
try {
// Obtener el token del header
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith("Bearer ")) {
throw CustomError.unauthorized("Missing or invalid Authorization header");
}
const token = authHeader.substring(7); // Remover "Bearer "
const token = authHeader.substring(7);
// Validar el token
const payload = JwtAdapter.validate(token);
if (!payload) {
throw CustomError.unauthorized("Invalid token");
}
// Verificar que el usuario existe en BD
const repository = new AuthRepositoryImpl();
const user = await repository.findById(payload.id);
if (!user) {
throw CustomError.unauthorized("User not found");
}
// Agregar el usuario al request
req.user = payload;
next();

View File

@@ -1,13 +1,13 @@
import { Router } from "express";
import { AuthRoutes } from "./auth/routes.js";
import { TrackingRoutes } from "./tracking/routes.js";
export class AppRoutes {
static get routes(): Router {
const router = Router();
router.use('/api/auth', AuthRoutes.routes);
router.use('/api/tracking', TrackingRoutes.routes);
return router;
}

View File

@@ -24,9 +24,9 @@ export class Server {
async start() {
/*middleware*/
// para recebir el body como json
this.app.use(express.json());
// para recebir el body como urlencoded
this.app.use(express.urlencoded({ extended: true }));
//* Public folder
@@ -36,7 +36,7 @@ export class Server {
// ponner servido a escuchar en el puerto 8080
this.app.listen(this.port, () => {
console.log(`Server is running on port ${this.port}`);
});

View File

@@ -0,0 +1,37 @@
import { Request, Response } from "express";
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 { InMemoryNotificationCache } from "../../data/cache/notification-cache.impl.js";
export class TrackingController {
private cache = new InMemoryNotificationCache();
private calculateEta = new CalculateEtaUseCase();
private evaluateNotification = new EvaluateNotificationUseCase(this.cache);
private processGpsUpdate = new ProcessGpsUpdateUseCase(
this.calculateEta,
this.evaluateNotification,
);
gpsUpdate = async (req: Request, res: Response) => {
try {
const result = await this.processGpsUpdate.execute(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,13 @@
import { Router } from "express";
import { TrackingController } from "./controller.js";
export class TrackingRoutes {
static get routes(): Router {
const router = Router();
const controller = new TrackingController();
router.post("/gps-update", controller.gpsUpdate);
return router;
}
}