From 74b52751d2f98494467de9b0872b99f168284a01 Mon Sep 17 00:00:00 2001 From: abdulmujibOladayo Date: Thu, 26 Mar 2026 15:29:44 +0100 Subject: [PATCH 1/2] notifications service --- .../notifications/notifications.service.ts | 306 ++++++++++++++++++ 1 file changed, 306 insertions(+) create mode 100644 backend/src/notifications/notifications.service.ts diff --git a/backend/src/notifications/notifications.service.ts b/backend/src/notifications/notifications.service.ts new file mode 100644 index 00000000..0af1c356 --- /dev/null +++ b/backend/src/notifications/notifications.service.ts @@ -0,0 +1,306 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { OnEvent } from '@nestjs/event-emitter'; +import { MailerService } from '@nestjs-modules/mailer'; +import { + SHIPMENT_ACCEPTED, + SHIPMENT_IN_TRANSIT, + SHIPMENT_DELIVERED, + SHIPMENT_COMPLETED, + SHIPMENT_CANCELLED, + SHIPMENT_DISPUTED, + SHIPMENT_DISPUTE_RESOLVED, + ShipmentEvent, +} from '../shipments/events/shipment.events'; +import { Shipment } from '../shipments/entities/shipment.entity'; + +@Injectable() +export class NotificationsService { + private readonly logger = new Logger(NotificationsService.name); + + constructor(private readonly mailerService: MailerService) {} + + // ── Helpers ───────────────────────────────────────────────────────────────── + + private async sendSafe( + to: string, + subject: string, + html: string, + ): Promise { + try { + await this.mailerService.sendMail({ to, subject, html }); + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + this.logger.warn(`Failed to send email to ${to}: ${msg}`); + } + } + + private shipmentSummary(s: Shipment): string { + return ` + + + + + + +
Tracking #${s.trackingNumber}
Route${s.origin} → ${s.destination}
Cargo${s.cargoDescription}
Weight${s.weightKg} kg
Value${s.currency} ${Number(s.price).toLocaleString()}
+ `; + } + + private baseTemplate(title: string, body: string): string { + return ` +
+

${title}

+ ${body} +
+

FreightFlow — Decentralized Freight Management

+
+ `; + } + + // ── Event Handlers ─────────────────────────────────────────────────────────── + + @OnEvent(SHIPMENT_ACCEPTED) + async onShipmentAccepted({ shipment }: ShipmentEvent): Promise { + const { shipper, carrier } = shipment; + if (!shipper || !carrier) return; + + // Notify shipper: a carrier accepted their shipment + await this.sendSafe( + shipper.email, + `✅ Carrier found for your shipment ${shipment.trackingNumber}`, + this.baseTemplate( + 'Your shipment has been accepted!', + ` +

Hi ${shipper.firstName},

+

Great news! ${carrier.firstName} ${carrier.lastName} has accepted your shipment and will be handling the delivery.

+ ${this.shipmentSummary(shipment)} +

You will receive another update when your cargo is picked up.

+ `, + ), + ); + + // Notify carrier: confirm they accepted + await this.sendSafe( + carrier.email, + `📦 You accepted shipment ${shipment.trackingNumber}`, + this.baseTemplate( + 'Shipment accepted — pickup next', + ` +

Hi ${carrier.firstName},

+

You have successfully accepted a shipment. Please proceed to pick up the cargo.

+ ${this.shipmentSummary(shipment)} +

Pickup location: ${shipment.origin}

+ `, + ), + ); + } + + @OnEvent(SHIPMENT_IN_TRANSIT) + async onShipmentInTransit({ shipment }: ShipmentEvent): Promise { + const { shipper, carrier } = shipment; + if (!shipper || !carrier) return; + + await this.sendSafe( + shipper.email, + `🚚 Your shipment ${shipment.trackingNumber} is on the way`, + this.baseTemplate( + 'Shipment picked up — in transit', + ` +

Hi ${shipper.firstName},

+

Your cargo has been picked up by ${carrier.firstName} ${carrier.lastName} and is now in transit.

+ ${this.shipmentSummary(shipment)} + ${shipment.estimatedDeliveryDate ? `

Estimated delivery: ${new Date(shipment.estimatedDeliveryDate).toDateString()}

` : ''} + `, + ), + ); + } + + @OnEvent(SHIPMENT_DELIVERED) + async onShipmentDelivered({ shipment }: ShipmentEvent): Promise { + const { shipper, carrier } = shipment; + if (!shipper || !carrier) return; + + // Notify shipper: please confirm delivery + await this.sendSafe( + shipper.email, + `📬 Your shipment ${shipment.trackingNumber} has been delivered — action required`, + this.baseTemplate( + 'Delivery reported — please confirm', + ` +

Hi ${shipper.firstName},

+

${carrier.firstName} ${carrier.lastName} has marked your shipment as delivered on ${new Date().toDateString()}.

+ ${this.shipmentSummary(shipment)} +

Please log in to FreightFlow to confirm delivery and complete the transaction, or raise a dispute if there is an issue.

+ `, + ), + ); + + // Notify carrier: delivery marked, waiting for shipper confirmation + await this.sendSafe( + carrier.email, + `✔️ Delivery marked for ${shipment.trackingNumber} — awaiting confirmation`, + this.baseTemplate( + 'Delivery marked — awaiting shipper confirmation', + ` +

Hi ${carrier.firstName},

+

You have successfully marked shipment ${shipment.trackingNumber} as delivered. The shipper has been notified and will confirm receipt shortly.

+ ${this.shipmentSummary(shipment)} + `, + ), + ); + } + + @OnEvent(SHIPMENT_COMPLETED) + async onShipmentCompleted({ shipment }: ShipmentEvent): Promise { + const { shipper, carrier } = shipment; + if (!shipper || !carrier) return; + + // Notify carrier: delivery confirmed, job done + await this.sendSafe( + carrier.email, + `🎉 Shipment ${shipment.trackingNumber} completed — delivery confirmed`, + this.baseTemplate( + 'Job complete — delivery confirmed by shipper', + ` +

Hi ${carrier.firstName},

+

The shipper has confirmed receipt of shipment ${shipment.trackingNumber}. This job is now complete.

+ ${this.shipmentSummary(shipment)} +

Thank you for your service on FreightFlow!

+ `, + ), + ); + + // Notify shipper: transaction complete + await this.sendSafe( + shipper.email, + `✅ Shipment ${shipment.trackingNumber} completed successfully`, + this.baseTemplate( + 'Shipment completed', + ` +

Hi ${shipper.firstName},

+

Your shipment ${shipment.trackingNumber} has been completed successfully. Thank you for using FreightFlow!

+ ${this.shipmentSummary(shipment)} + `, + ), + ); + } + + @OnEvent(SHIPMENT_CANCELLED) + async onShipmentCancelled({ + shipment, + reason, + }: ShipmentEvent): Promise { + const { shipper, carrier } = shipment; + const reasonNote = reason + ? `

Reason: ${reason}

` + : ''; + + if (shipper) { + await this.sendSafe( + shipper.email, + `❌ Shipment ${shipment.trackingNumber} has been cancelled`, + this.baseTemplate( + 'Shipment cancelled', + ` +

Hi ${shipper.firstName},

+

Shipment ${shipment.trackingNumber} has been cancelled.

+ ${reasonNote} + ${this.shipmentSummary(shipment)} + `, + ), + ); + } + + if (carrier) { + await this.sendSafe( + carrier.email, + `❌ Shipment ${shipment.trackingNumber} has been cancelled`, + this.baseTemplate( + 'Shipment cancelled', + ` +

Hi ${carrier.firstName},

+

Shipment ${shipment.trackingNumber} that you were assigned to has been cancelled.

+ ${reasonNote} + ${this.shipmentSummary(shipment)} + `, + ), + ); + } + } + + @OnEvent(SHIPMENT_DISPUTED) + async onShipmentDisputed({ shipment, reason }: ShipmentEvent): Promise { + const { shipper, carrier } = shipment; + const reasonNote = reason + ? `

Reason for dispute: ${reason}

` + : ''; + + if (shipper) { + await this.sendSafe( + shipper.email, + `⚠️ Dispute raised on shipment ${shipment.trackingNumber}`, + this.baseTemplate( + 'A dispute has been raised', + ` +

Hi ${shipper.firstName},

+

A dispute has been raised on shipment ${shipment.trackingNumber}. Our team will review and resolve it.

+ ${reasonNote} + ${this.shipmentSummary(shipment)} + `, + ), + ); + } + + if (carrier) { + await this.sendSafe( + carrier.email, + `⚠️ Dispute raised on shipment ${shipment.trackingNumber}`, + this.baseTemplate( + 'A dispute has been raised', + ` +

Hi ${carrier.firstName},

+

A dispute has been raised on shipment ${shipment.trackingNumber}. Our team will review and resolve it.

+ ${reasonNote} + ${this.shipmentSummary(shipment)} + `, + ), + ); + } + } + + @OnEvent(SHIPMENT_DISPUTE_RESOLVED) + async onDisputeResolved({ shipment, reason }: ShipmentEvent): Promise { + const { shipper, carrier } = shipment; + const outcome = shipment.status.toUpperCase(); + const reasonNote = reason + ? `

Resolution note: ${reason}

` + : ''; + + const body = (firstName: string) => + this.baseTemplate( + `Dispute resolved — ${outcome}`, + ` +

Hi ${firstName},

+

The dispute on shipment ${shipment.trackingNumber} has been reviewed and resolved by our admin team.

+

Outcome: ${outcome}

+ ${reasonNote} + ${this.shipmentSummary(shipment)} + `, + ); + + if (shipper) { + await this.sendSafe( + shipper.email, + `🔔 Dispute resolved for shipment ${shipment.trackingNumber}`, + body(shipper.firstName), + ); + } + if (carrier) { + await this.sendSafe( + carrier.email, + `🔔 Dispute resolved for shipment ${shipment.trackingNumber}`, + body(carrier.firstName), + ); + } + } +} From 0e97961d16dcc3ade69d56d0a7fa76d1ed808839 Mon Sep 17 00:00:00 2001 From: abdulmujibOladayo Date: Thu, 26 Mar 2026 15:32:02 +0100 Subject: [PATCH 2/2] implemented the gateway and the module for the notifications --- .../notifications/notifications.gateway.ts | 197 ++++++++++++++++++ .../src/notifications/notifications.module.ts | 23 ++ 2 files changed, 220 insertions(+) create mode 100644 backend/src/notifications/notifications.gateway.ts create mode 100644 backend/src/notifications/notifications.module.ts diff --git a/backend/src/notifications/notifications.gateway.ts b/backend/src/notifications/notifications.gateway.ts new file mode 100644 index 00000000..b8c12fee --- /dev/null +++ b/backend/src/notifications/notifications.gateway.ts @@ -0,0 +1,197 @@ +import { + WebSocketGateway, + WebSocketServer, + OnGatewayInit, + OnGatewayConnection, + OnGatewayDisconnect, +} from '@nestjs/websockets'; +import { Logger } from '@nestjs/common'; +import { OnEvent } from '@nestjs/event-emitter'; +import { JwtService } from '@nestjs/jwt'; +import { ConfigService } from '@nestjs/config'; +import { Server, Socket } from 'socket.io'; +import { + SHIPMENT_ACCEPTED, + SHIPMENT_IN_TRANSIT, + SHIPMENT_DELIVERED, + SHIPMENT_COMPLETED, + SHIPMENT_CANCELLED, + SHIPMENT_DISPUTED, + SHIPMENT_DISPUTE_RESOLVED, + SHIPMENT_CREATED, + ShipmentEvent, +} from '../shipments/events/shipment.events'; +import { JwtPayload } from '../auth/strategies/jwt.strategy'; + +/** Room prefix for per-user rooms */ +const userRoom = (userId: string) => `user:${userId}`; + +@WebSocketGateway({ + cors: { + origin: (origin: string, cb: (err: Error | null, allow: boolean) => void) => { + // Allow same-origin and configured frontend URL + cb(null, true); + }, + credentials: true, + }, +}) +export class NotificationsGateway + implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect +{ + @WebSocketServer() + private server: Server; + + private readonly logger = new Logger(NotificationsGateway.name); + + constructor( + private readonly jwtService: JwtService, + private readonly configService: ConfigService, + ) {} + + afterInit() { + this.logger.log('WebSocket gateway initialised'); + } + + // ── Connection lifecycle ──────────────────────────────────────────────────── + + async handleConnection(client: Socket) { + try { + const token = + (client.handshake.auth?.token as string | undefined) || + (client.handshake.headers?.authorization as string | undefined); + + if (!token) { + this.disconnect(client, 'No token provided'); + return; + } + + const raw = token.startsWith('Bearer ') ? token.slice(7) : token; + const secret = this.configService.get('JWT_SECRET')!; + + let payload: JwtPayload; + try { + payload = this.jwtService.verify(raw, { secret }); + } catch { + this.disconnect(client, 'Invalid or expired token'); + return; + } + + // Store userId on the socket for later use + (client.data as { userId: string }).userId = payload.sub; + + // Join the user's personal room + await client.join(userRoom(payload.sub)); + this.logger.debug(`Client ${client.id} joined room ${userRoom(payload.sub)}`); + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + this.disconnect(client, msg); + } + } + + handleDisconnect(client: Socket) { + const userId = (client.data as { userId?: string }).userId; + this.logger.debug(`Client ${client.id} disconnected${userId ? ` (user: ${userId})` : ''}`); + } + + private disconnect(client: Socket, reason: string) { + this.logger.warn(`Disconnecting client ${client.id}: ${reason}`); + client.emit('error', { message: reason }); + client.disconnect(true); + } + + // ── Emit helper ───────────────────────────────────────────────────────────── + + private emitToUser( + userId: string, + event: string, + payload: Record, + ) { + this.server.to(userRoom(userId)).emit(event, payload); + } + + private buildPayload( + eventName: string, + { shipment }: ShipmentEvent, + ): Record { + return { + event: eventName, + shipmentId: shipment.id, + trackingNumber: shipment.trackingNumber, + status: shipment.status, + origin: shipment.origin, + destination: shipment.destination, + updatedAt: new Date().toISOString(), + }; + } + + // ── Domain event → WebSocket emit ─────────────────────────────────────────── + + @OnEvent(SHIPMENT_CREATED) + onCreated(evt: ShipmentEvent) { + const payload = this.buildPayload('shipment:created', evt); + this.emitToUser(evt.shipment.shipperId, 'shipment:updated', payload); + } + + @OnEvent(SHIPMENT_ACCEPTED) + onAccepted(evt: ShipmentEvent) { + const payload = this.buildPayload('shipment:accepted', evt); + this.emitToUser(evt.shipment.shipperId, 'shipment:updated', payload); + if (evt.shipment.carrierId) { + this.emitToUser(evt.shipment.carrierId, 'shipment:updated', payload); + } + } + + @OnEvent(SHIPMENT_IN_TRANSIT) + onInTransit(evt: ShipmentEvent) { + const payload = this.buildPayload('shipment:in_transit', evt); + this.emitToUser(evt.shipment.shipperId, 'shipment:updated', payload); + if (evt.shipment.carrierId) { + this.emitToUser(evt.shipment.carrierId, 'shipment:updated', payload); + } + } + + @OnEvent(SHIPMENT_DELIVERED) + onDelivered(evt: ShipmentEvent) { + const payload = this.buildPayload('shipment:delivered', evt); + this.emitToUser(evt.shipment.shipperId, 'shipment:updated', payload); + if (evt.shipment.carrierId) { + this.emitToUser(evt.shipment.carrierId, 'shipment:updated', payload); + } + } + + @OnEvent(SHIPMENT_COMPLETED) + onCompleted(evt: ShipmentEvent) { + const payload = this.buildPayload('shipment:completed', evt); + this.emitToUser(evt.shipment.shipperId, 'shipment:updated', payload); + if (evt.shipment.carrierId) { + this.emitToUser(evt.shipment.carrierId, 'shipment:updated', payload); + } + } + + @OnEvent(SHIPMENT_CANCELLED) + onCancelled(evt: ShipmentEvent) { + const payload = this.buildPayload('shipment:cancelled', evt); + this.emitToUser(evt.shipment.shipperId, 'shipment:updated', payload); + if (evt.shipment.carrierId) { + this.emitToUser(evt.shipment.carrierId, 'shipment:updated', payload); + } + } + + @OnEvent(SHIPMENT_DISPUTED) + onDisputed(evt: ShipmentEvent) { + const payload = this.buildPayload('shipment:disputed', evt); + this.emitToUser(evt.shipment.shipperId, 'shipment:updated', payload); + if (evt.shipment.carrierId) { + this.emitToUser(evt.shipment.carrierId, 'shipment:updated', payload); + } + } + + @OnEvent(SHIPMENT_DISPUTE_RESOLVED) + onDisputeResolved(evt: ShipmentEvent) { + const payload = this.buildPayload('shipment:dispute_resolved', evt); + this.emitToUser(evt.shipment.shipperId, 'shipment:updated', payload); + if (evt.shipment.carrierId) { + this.emitToUser(evt.shipment.carrierId, 'shipment:updated', payload); + } + } +} diff --git a/backend/src/notifications/notifications.module.ts b/backend/src/notifications/notifications.module.ts new file mode 100644 index 00000000..edca0217 --- /dev/null +++ b/backend/src/notifications/notifications.module.ts @@ -0,0 +1,23 @@ +import { Module } from '@nestjs/common'; +import { JwtModule } from '@nestjs/jwt'; +import { ConfigModule, ConfigService } from '@nestjs/config'; +import type { StringValue } from 'ms'; +import { NotificationsService } from './notifications.service'; +import { NotificationsGateway } from './notifications.gateway'; + +@Module({ + imports: [ + JwtModule.registerAsync({ + imports: [ConfigModule], + inject: [ConfigService], + useFactory: (config: ConfigService) => ({ + secret: config.get('JWT_SECRET'), + signOptions: { + expiresIn: config.get('JWT_EXPIRES_IN', '15m') as StringValue, + }, + }), + }), + ], + providers: [NotificationsService, NotificationsGateway], +}) +export class NotificationsModule {}