From 5a10c14ceb98fe73d3cf4cf6aa89bffa9405d092 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Mon, 6 Jul 2026 06:51:38 +0000 Subject: [PATCH] feat: setup the notification module to the api --- apps/edr-freight-api/package.json | 3 + apps/edr-freight-api/src/app.module.ts | 2 + .../1950000000000-CreateNotifications.ts | 51 +++++ .../src/modules/companies/companies.module.ts | 7 +- .../modules/companies/companies.service.ts | 19 ++ .../dto/list-notifications-query.dto.ts | 30 +++ .../entities/notification.entity.ts | 64 ++++++ .../notification-inbox.controller.ts | 69 ++++++ .../notification-inbox.module.ts | 37 ++++ .../notification-inbox.repository.ts | 59 +++++ .../notification-inbox.service.ts | 209 ++++++++++++++++++ .../notification-recipients.service.ts | 75 +++++++ .../notifications.gateway.ts | 75 +++++++ .../notification-inbox/ws-auth.service.ts | 51 +++++ 14 files changed, 750 insertions(+), 1 deletion(-) create mode 100644 apps/edr-freight-api/src/migrations/1950000000000-CreateNotifications.ts create mode 100644 apps/edr-freight-api/src/modules/notification-inbox/dto/list-notifications-query.dto.ts create mode 100644 apps/edr-freight-api/src/modules/notification-inbox/entities/notification.entity.ts create mode 100644 apps/edr-freight-api/src/modules/notification-inbox/notification-inbox.controller.ts create mode 100644 apps/edr-freight-api/src/modules/notification-inbox/notification-inbox.module.ts create mode 100644 apps/edr-freight-api/src/modules/notification-inbox/notification-inbox.repository.ts create mode 100644 apps/edr-freight-api/src/modules/notification-inbox/notification-inbox.service.ts create mode 100644 apps/edr-freight-api/src/modules/notification-inbox/notification-recipients.service.ts create mode 100644 apps/edr-freight-api/src/modules/notification-inbox/notifications.gateway.ts create mode 100644 apps/edr-freight-api/src/modules/notification-inbox/ws-auth.service.ts diff --git a/apps/edr-freight-api/package.json b/apps/edr-freight-api/package.json index 39eed76ea..457786cbe 100644 --- a/apps/edr-freight-api/package.json +++ b/apps/edr-freight-api/package.json @@ -50,9 +50,11 @@ "@nestjs/mapped-types": "^2.1.1", "@nestjs/microservices": "^11.0.0", "@nestjs/platform-express": "^11.0.0", + "@nestjs/platform-socket.io": "^11.1.27", "@nestjs/schedule": "^6.1.3", "@nestjs/swagger": "^11.4.2", "@nestjs/typeorm": "^11.0.1", + "@nestjs/websockets": "^11.1.27", "@tria-plc/api-common": "file:../../local-packages/tria-plc-api-common-1.4.3.tgz", "@tria-plc/iamapi-common": "file:../../local-packages/tria-plc-iamapi-common-0.7.7.tgz", "amqp-connection-manager": "^5.0.0", @@ -71,6 +73,7 @@ "puppeteer": "^24.2.0", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1", + "socket.io": "^4.8.3", "typeorm": "^0.3.30" }, "devDependencies": { diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts index c2a3a5abb..d23d5bd2a 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -35,6 +35,7 @@ import { CompaniesModule } from "./modules/companies/companies.module"; import { TrackingModule } from "./modules/tracking/tracking.module"; import { BillingModule } from "./modules/billing/billing.module"; import { NotificationsModule } from "./modules/notifications/notifications.module"; +import { NotificationInboxModule } from "./modules/notification-inbox/notification-inbox.module"; import { FileUploadSettingsModule } from "./modules/file-upload-settings/file-upload-settings.module"; import { DropdownSettingsModule } from "./modules/dropdown-settings/dropdown-settings.module"; import { OtpModule } from "./modules/otp/otp.module"; @@ -126,6 +127,7 @@ import { LoggerMiddleware } from "./logger.middleware"; TrackingModule, BillingModule, NotificationsModule, + NotificationInboxModule, FileUploadSettingsModule, DropdownSettingsModule, OtpModule, diff --git a/apps/edr-freight-api/src/migrations/1950000000000-CreateNotifications.ts b/apps/edr-freight-api/src/migrations/1950000000000-CreateNotifications.ts new file mode 100644 index 000000000..c5c608a95 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1950000000000-CreateNotifications.ts @@ -0,0 +1,51 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * In-app notification inbox. One row per recipient per logical notification; + * producers fan out by inserting many rows. Indexed for the two hot queries: + * unread-count (recipient + is_read) and the newest-first list (recipient + + * created_at). Enum-like columns are stored as varchar to avoid PG enum churn. + */ +export class CreateNotifications1950000000000 implements MigrationInterface { + name = "CreateNotifications1950000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.notifications ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + recipient_user_id uuid NOT NULL, + audience varchar(20) NOT NULL, + type varchar(48) NOT NULL DEFAULT 'GENERIC', + title varchar(200) NOT NULL, + body text NOT NULL, + link varchar, + data jsonb, + priority varchar(12) NOT NULL DEFAULT 'NORMAL', + is_read boolean NOT NULL DEFAULT false, + read_at timestamptz, + channels_sent jsonb, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz + ) + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS "IDX_NOTIFICATIONS_RECIPIENT_UNREAD" + ON freight.notifications (recipient_user_id, is_read) + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS "IDX_NOTIFICATIONS_RECIPIENT_CREATED" + ON freight.notifications (recipient_user_id, created_at) + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `DROP INDEX IF EXISTS freight."IDX_NOTIFICATIONS_RECIPIENT_CREATED"`, + ); + await queryRunner.query( + `DROP INDEX IF EXISTS freight."IDX_NOTIFICATIONS_RECIPIENT_UNREAD"`, + ); + await queryRunner.query(`DROP TABLE IF EXISTS freight.notifications`); + } +} diff --git a/apps/edr-freight-api/src/modules/companies/companies.module.ts b/apps/edr-freight-api/src/modules/companies/companies.module.ts index 88871f8ad..42186dd8e 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.module.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.module.ts @@ -33,6 +33,11 @@ import { ETradeService } from "./services/etrade.service"; CompanyDashboardRepository, ETradeService, ], - exports: [CompaniesService], + exports: [ + CompaniesService, + // Consumed by NotificationInboxModule for portal recipient targeting. + ExternalProfileRepository, + CompanyProfileRepository, + ], }) export class CompaniesModule { } diff --git a/apps/edr-freight-api/src/modules/companies/companies.service.ts b/apps/edr-freight-api/src/modules/companies/companies.service.ts index 62be578bb..fe679627b 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.service.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.service.ts @@ -334,9 +334,28 @@ export class CompaniesService { const company = await this.companiesRepo.findById(id); if (!company) throw new NotFoundException(`Company ${id} not found`); company.companyProfiles = await this.companyProfilesRepo.findByCompanyId(id); + for (const profile of company.companyProfiles) { + profile.businessLicenseFiles = await this.signLicenseFiles( + profile.businessLicenseFiles, + ); + } return company; } + /** + * Business-license files are stored as raw, unsigned MinIO URLs (see + * `BusinessLicenseFile` on `CompanyProfile`) — a browser can't fetch them + * directly. Sign each one with a short-lived URL before it reaches a response. + */ + private async signLicenseFiles( + files?: BusinessLicenseFile[] | null, + ): Promise { + if (!files?.length) return []; + return Promise.all( + files.map(async (f) => ({ ...f, url: await this.filesService.signUrl(f.url) })), + ); + } + /** * Validate an explicitly-chosen company profile for a booking: it must belong * to the booking's company and be Active. Used for government bookings (staff diff --git a/apps/edr-freight-api/src/modules/notification-inbox/dto/list-notifications-query.dto.ts b/apps/edr-freight-api/src/modules/notification-inbox/dto/list-notifications-query.dto.ts new file mode 100644 index 000000000..a92dcef16 --- /dev/null +++ b/apps/edr-freight-api/src/modules/notification-inbox/dto/list-notifications-query.dto.ts @@ -0,0 +1,30 @@ +import { ApiPropertyOptional } from "@nestjs/swagger"; +import { Transform, Type } from "class-transformer"; +import { IsBoolean, IsInt, IsOptional, Max, Min } from "class-validator"; + +export class ListNotificationsQueryDto { + @ApiPropertyOptional({ + description: "Filter by read state. Omit to return all.", + }) + @IsOptional() + @Transform(({ value }) => + value === "true" ? true : value === "false" ? false : value, + ) + @IsBoolean() + isRead?: boolean; + + @ApiPropertyOptional({ minimum: 1, default: 1 }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + page?: number; + + @ApiPropertyOptional({ minimum: 1, maximum: 100, default: 20 }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + @Max(100) + limit?: number; +} diff --git a/apps/edr-freight-api/src/modules/notification-inbox/entities/notification.entity.ts b/apps/edr-freight-api/src/modules/notification-inbox/entities/notification.entity.ts new file mode 100644 index 000000000..eddb7940b --- /dev/null +++ b/apps/edr-freight-api/src/modules/notification-inbox/entities/notification.entity.ts @@ -0,0 +1,64 @@ +import { BaseEntity } from "@edr/api-common"; +import { + NotificationAudience, + NotificationChannelsSent, + NotificationPriority, + NotificationType, +} from "@edr/types"; +import { Column, Entity, Index } from "typeorm"; + +/** + * A single persisted in-app notification addressed to one IAM user. Producers + * fan a logical notification out to N recipients by inserting one row per + * resolved user id (see NotificationInboxService.notify). + */ +@Entity({ schema: "freight", name: "notifications" }) +@Index("IDX_NOTIFICATIONS_RECIPIENT_UNREAD", ["recipientUserId", "isRead"]) +@Index("IDX_NOTIFICATIONS_RECIPIENT_CREATED", ["recipientUserId", "createdAt"]) +export class Notification extends BaseEntity { + @Column({ name: "recipient_user_id", type: "uuid" }) + recipientUserId!: string; + + @Column({ name: "audience", type: "varchar", length: 20 }) + audience!: NotificationAudience; + + @Column({ + name: "type", + type: "varchar", + length: 48, + default: NotificationType.GENERIC, + }) + type!: NotificationType; + + @Column({ name: "title", type: "varchar", length: 200 }) + title!: string; + + @Column({ name: "body", type: "text" }) + body!: string; + + /** Deep-link path within the app the item points to (e.g. `/contracts/:id`). */ + @Column({ name: "link", type: "varchar", nullable: true }) + link?: string | null; + + /** Arbitrary structured payload (bookingId, invoiceId, contractId, …). */ + @Column({ name: "data", type: "jsonb", nullable: true }) + data?: Record | null; + + @Column({ + name: "priority", + type: "varchar", + length: 12, + default: NotificationPriority.NORMAL, + }) + priority!: NotificationPriority; + + @Column({ name: "is_read", type: "boolean", default: false }) + isRead!: boolean; + + @Column({ name: "read_at", type: "timestamptz", nullable: true }) + readAt?: Date | null; + + /** Per-channel fan-out outcome for HIGH-priority items (email/SMS). */ + @Column({ name: "channels_sent", type: "jsonb", nullable: true }) + channelsSent?: NotificationChannelsSent | null; +} diff --git a/apps/edr-freight-api/src/modules/notification-inbox/notification-inbox.controller.ts b/apps/edr-freight-api/src/modules/notification-inbox/notification-inbox.controller.ts new file mode 100644 index 000000000..968dd8adb --- /dev/null +++ b/apps/edr-freight-api/src/modules/notification-inbox/notification-inbox.controller.ts @@ -0,0 +1,69 @@ +import { CurrentUser } from "@edr/api-common"; +import { NotificationAudience } from "@edr/types"; +import { + Body, + Controller, + Get, + Param, + ParseUUIDPipe, + Patch, + Post, + Query, +} from "@nestjs/common"; +import { ApiOperation, ApiTags } from "@nestjs/swagger"; + +import { + AuthUserPayload, + resolveAuthUserId, +} from "../../common/resolve-auth-user-id"; +import { ListNotificationsQueryDto } from "./dto/list-notifications-query.dto"; +import { NotificationInboxService } from "./notification-inbox.service"; + +@ApiTags("notifications") +@Controller("notifications") +export class NotificationInboxController { + constructor(private readonly service: NotificationInboxService) {} + + @Get() + @ApiOperation({ summary: "List my notifications (paginated, newest first)" }) + list( + @CurrentUser() user: AuthUserPayload, + @Query() query: ListNotificationsQueryDto, + ) { + return this.service.list(resolveAuthUserId(user), query); + } + + @Get("unread-count") + @ApiOperation({ summary: "Count my unread notifications" }) + unreadCount(@CurrentUser() user: AuthUserPayload) { + return this.service.unreadCount(resolveAuthUserId(user)); + } + + @Patch(":id/read") + @ApiOperation({ summary: "Mark one of my notifications as read" }) + markRead( + @CurrentUser() user: AuthUserPayload, + @Param("id", ParseUUIDPipe) id: string, + ) { + return this.service.markRead(id, resolveAuthUserId(user)); + } + + @Post("read-all") + @ApiOperation({ summary: "Mark all my notifications as read" }) + markAllRead(@CurrentUser() user: AuthUserPayload) { + return this.service.markAllRead(resolveAuthUserId(user)); + } + + // TODO: remove before merge — dev/verification helper only. + @Post("test") + @ApiOperation({ + summary: "[dev] Send a test notification to the current user", + }) + sendTest( + @CurrentUser() user: AuthUserPayload, + @Body() + body: { audience?: NotificationAudience; title?: string; message?: string }, + ) { + return this.service.sendTestToUser(resolveAuthUserId(user), body ?? {}); + } +} diff --git a/apps/edr-freight-api/src/modules/notification-inbox/notification-inbox.module.ts b/apps/edr-freight-api/src/modules/notification-inbox/notification-inbox.module.ts new file mode 100644 index 000000000..4981a9486 --- /dev/null +++ b/apps/edr-freight-api/src/modules/notification-inbox/notification-inbox.module.ts @@ -0,0 +1,37 @@ +import { Module } from "@nestjs/common"; +import { TypeOrmModule } from "@nestjs/typeorm"; +import { Session } from "@tria-plc/iamapi-common/entities/iam/user/session.entity"; +import { User } from "@tria-plc/iamapi-common/entities/iam/user/user.entity"; + +import { BackofficeModule } from "../backoffice/backoffice.module"; +import { CompaniesModule } from "../companies/companies.module"; +import { NotificationsModule } from "../notifications/notifications.module"; +import { Notification } from "./entities/notification.entity"; +import { NotificationInboxController } from "./notification-inbox.controller"; +import { NotificationInboxRepository } from "./notification-inbox.repository"; +import { NotificationInboxService } from "./notification-inbox.service"; +import { NotificationRecipientsService } from "./notification-recipients.service"; +import { NotificationsGateway } from "./notifications.gateway"; +import { WsAuthService } from "./ws-auth.service"; + +@Module({ + imports: [ + TypeOrmModule.forFeature([Notification, User, Session]), + // ExternalProfileRepository + CompanyProfileRepository (portal targeting) + CompaniesModule, + // BackofficeService.getOrganizationEmployees (staff targeting) + BackofficeModule, + // EmailClientService + SmsClientService (HIGH-priority fan-out) + NotificationsModule, + ], + controllers: [NotificationInboxController], + providers: [ + NotificationInboxRepository, + NotificationRecipientsService, + NotificationsGateway, + WsAuthService, + NotificationInboxService, + ], + exports: [NotificationInboxService], +}) +export class NotificationInboxModule {} diff --git a/apps/edr-freight-api/src/modules/notification-inbox/notification-inbox.repository.ts b/apps/edr-freight-api/src/modules/notification-inbox/notification-inbox.repository.ts new file mode 100644 index 000000000..a3842c9e3 --- /dev/null +++ b/apps/edr-freight-api/src/modules/notification-inbox/notification-inbox.repository.ts @@ -0,0 +1,59 @@ +import { BaseRepository } from "@edr/api-common"; +import { Injectable } from "@nestjs/common"; +import { InjectRepository } from "@nestjs/typeorm"; +import { FindOptionsWhere, Repository } from "typeorm"; + +import { Notification } from "./entities/notification.entity"; + +@Injectable() +export class NotificationInboxRepository extends BaseRepository { + constructor( + @InjectRepository(Notification) + repo: Repository, + ) { + super(repo); + } + + /** Newest-first page of a recipient's notifications, optionally read-filtered. */ + async findForRecipient( + userId: string, + opts: { page?: number; limit?: number; isRead?: boolean } = {}, + ): Promise<[Notification[], number]> { + const page = opts.page && opts.page > 0 ? opts.page : 1; + const limit = opts.limit && opts.limit > 0 ? opts.limit : 20; + const where: FindOptionsWhere = { recipientUserId: userId }; + if (typeof opts.isRead === "boolean") { + where.isRead = opts.isRead; + } + return this.repository.findAndCount({ + where, + order: { createdAt: "DESC" }, + skip: (page - 1) * limit, + take: limit, + }); + } + + async countUnread(userId: string): Promise { + return this.repository.count({ + where: { recipientUserId: userId, isRead: false }, + }); + } + + /** Mark a single notification read (scoped to its recipient). Returns true if it changed. */ + async markRead(id: string, userId: string): Promise { + const result = await this.repository.update( + { id, recipientUserId: userId, isRead: false }, + { isRead: true, readAt: new Date() }, + ); + return (result.affected ?? 0) > 0; + } + + /** Mark all of a recipient's unread notifications read. Returns the count updated. */ + async markAllRead(userId: string): Promise { + const result = await this.repository.update( + { recipientUserId: userId, isRead: false }, + { isRead: true, readAt: new Date() }, + ); + return result.affected ?? 0; + } +} diff --git a/apps/edr-freight-api/src/modules/notification-inbox/notification-inbox.service.ts b/apps/edr-freight-api/src/modules/notification-inbox/notification-inbox.service.ts new file mode 100644 index 000000000..d6c1fbc92 --- /dev/null +++ b/apps/edr-freight-api/src/modules/notification-inbox/notification-inbox.service.ts @@ -0,0 +1,209 @@ +import { + NotificationAudience, + NotificationChannelsSent, + NotificationDto, + NotificationListResult, + NotificationPriority, + NotificationType, + NotifyInput, +} from "@edr/types"; +import { Injectable, Logger } from "@nestjs/common"; +import { InjectRepository } from "@nestjs/typeorm"; +import { User } from "@tria-plc/iamapi-common/entities/iam/user/user.entity"; +import { Repository } from "typeorm"; + +import { EmailClientService } from "../notifications/email-client.service"; +import { SmsClientService } from "../notifications/sms-client.service"; +import { ListNotificationsQueryDto } from "./dto/list-notifications-query.dto"; +import { Notification } from "./entities/notification.entity"; +import { NotificationInboxRepository } from "./notification-inbox.repository"; +import { NotificationRecipientsService } from "./notification-recipients.service"; +import { NotificationsGateway } from "./notifications.gateway"; + +/** + * The single entry point subsystems use for in-app notifications. Call + * {@link notify}; everything else (reads, mark-read) backs the REST controller. + * + * `notify` is deliberately fault-tolerant: it never throws into the caller so a + * notification failure can't roll back or break the business transaction that + * triggered it. Failures are logged. + */ +@Injectable() +export class NotificationInboxService { + private readonly logger = new Logger(NotificationInboxService.name); + + constructor( + private readonly repo: NotificationInboxRepository, + private readonly recipients: NotificationRecipientsService, + private readonly gateway: NotificationsGateway, + private readonly emailClient: EmailClientService, + private readonly smsClient: SmsClientService, + @InjectRepository(User) + private readonly users: Repository, + ) {} + + /** + * Fan a logical notification out to every resolved recipient: persist one row + * each, push it live over WebSocket, and (for HIGH priority) also queue + * email/SMS via the existing clients. + */ + async notify(input: NotifyInput): Promise { + try { + const userIds = await this.recipients.resolve(input.recipients); + if (userIds.length === 0) { + this.logger.debug( + `notify(${input.type}) resolved 0 recipients — skipped`, + ); + return; + } + const priority = input.priority ?? NotificationPriority.NORMAL; + + for (const userId of userIds) { + await this.deliverToUser(userId, input, priority); + } + } catch (err) { + this.logger.error( + `notify failed: ${(err as Error).message}`, + (err as Error).stack, + ); + } + } + + async list( + userId: string, + query: ListNotificationsQueryDto, + ): Promise { + const [items, count] = await this.repo.findForRecipient(userId, { + page: query.page, + limit: query.limit, + isRead: query.isRead, + }); + const unreadCount = await this.repo.countUnread(userId); + return { items: items.map((n) => this.toDto(n)), count, unreadCount }; + } + + async unreadCount(userId: string): Promise<{ unreadCount: number }> { + return { unreadCount: await this.repo.countUnread(userId) }; + } + + async markRead( + id: string, + userId: string, + ): Promise<{ success: boolean; unreadCount: number }> { + const success = await this.repo.markRead(id, userId); + const unreadCount = await this.repo.countUnread(userId); + this.gateway.emitUnreadCount(userId, unreadCount); + return { success, unreadCount }; + } + + async markAllRead( + userId: string, + ): Promise<{ updated: number; unreadCount: number }> { + const updated = await this.repo.markAllRead(userId); + const unreadCount = await this.repo.countUnread(userId); + this.gateway.emitUnreadCount(userId, unreadCount); + return { updated, unreadCount }; + } + + /** [dev/verification only] Send a canned notification straight to one user. */ + async sendTestToUser( + userId: string, + body: { audience?: NotificationAudience; title?: string; message?: string }, + ): Promise { + const entity = await this.repo.create({ + recipientUserId: userId, + audience: body.audience ?? NotificationAudience.BACKOFFICE, + type: NotificationType.GENERIC, + title: body.title ?? "Test notification", + body: body.message ?? "This is a test in-app notification.", + priority: NotificationPriority.NORMAL, + isRead: false, + }); + const dto = this.toDto(entity); + this.gateway.emitNew(userId, dto, await this.repo.countUnread(userId)); + return dto; + } + + private async deliverToUser( + userId: string, + input: NotifyInput, + priority: NotificationPriority, + ): Promise { + const entity = await this.repo.create({ + recipientUserId: userId, + audience: input.audience, + type: input.type, + title: input.title, + body: input.body, + link: input.link ?? null, + data: input.data ?? null, + priority, + isRead: false, + }); + + const unreadCount = await this.repo.countUnread(userId); + this.gateway.emitNew(userId, this.toDto(entity), unreadCount); + + if (priority === NotificationPriority.HIGH) { + const channelsSent = await this.fanOut(userId, input); + if (channelsSent) { + await this.repo.update(entity.id, { channelsSent }); + } + } + } + + /** Best-effort email/SMS fan-out for HIGH-priority items. Never throws. */ + private async fanOut( + userId: string, + input: NotifyInput, + ): Promise { + try { + const user = await this.users.findOne({ + where: { id: userId } as never, + }); + if (!user) return null; + + const sent: NotificationChannelsSent = {}; + const text = `${input.title}\n\n${input.body}`; + + if (user.email) { + const res = await this.emailClient.sendEmail({ + to: user.email, + subject: input.title, + text, + }); + sent.email = res.queued; + } + if (user.phoneNumber) { + const res = await this.smsClient.sendSms({ + to: user.phoneNumber, + message: text, + }); + sent.sms = res.queued; + } + return Object.keys(sent).length ? sent : null; + } catch (err) { + this.logger.warn( + `fan-out failed for user ${userId}: ${(err as Error).message}`, + ); + return null; + } + } + + private toDto(n: Notification): NotificationDto { + return { + id: n.id, + recipientUserId: n.recipientUserId, + audience: n.audience, + type: n.type, + title: n.title, + body: n.body, + link: n.link ?? null, + data: n.data ?? null, + priority: n.priority, + isRead: n.isRead, + readAt: n.readAt ? new Date(n.readAt).toISOString() : null, + createdAt: new Date(n.createdAt).toISOString(), + }; + } +} diff --git a/apps/edr-freight-api/src/modules/notification-inbox/notification-recipients.service.ts b/apps/edr-freight-api/src/modules/notification-inbox/notification-recipients.service.ts new file mode 100644 index 000000000..265e19303 --- /dev/null +++ b/apps/edr-freight-api/src/modules/notification-inbox/notification-recipients.service.ts @@ -0,0 +1,75 @@ +import { NotificationRecipients } from "@edr/types"; +import { Injectable, Logger } from "@nestjs/common"; + +import { BackofficeService } from "../backoffice/backoffice.service"; +import { CompanyProfileRepository } from "../companies/company-profile.repository"; +import { ExternalProfileRepository } from "../companies/external-profile.repository"; + +/** + * Turns a {@link NotificationRecipients} selector into a de-duplicated set of + * IAM user ids. + * + * - `userIds` → honored as-is. + * - `companyId` → all portal users linked to the company (external_profiles). + * - `companyProfileId` → resolved to its company, then to that company's users. + * - `organizationId` → all current employees of the org (backoffice staff). + * + * NOTE: permission-scoped staff targeting is intentionally unsupported — freight + * has no "users-by-permission" lookup. Target explicit userIds or an org instead. + */ +@Injectable() +export class NotificationRecipientsService { + private readonly logger = new Logger(NotificationRecipientsService.name); + + constructor( + private readonly externalProfiles: ExternalProfileRepository, + private readonly companyProfiles: CompanyProfileRepository, + private readonly backoffice: BackofficeService, + ) {} + + async resolve(recipients: NotificationRecipients): Promise { + const ids = new Set(); + + for (const id of recipients.userIds ?? []) { + if (id) ids.add(id); + } + + let companyId = recipients.companyId; + if (!companyId && recipients.companyProfileId) { + const profile = await this.companyProfiles.findById( + recipients.companyProfileId, + ); + companyId = profile?.companyId ?? undefined; + } + if (companyId) { + const profiles = await this.externalProfiles.findByCompanyId(companyId); + for (const p of profiles) { + if (p.userId) ids.add(p.userId); + } + } + + if (recipients.organizationId) { + try { + const { items } = await this.backoffice.getOrganizationEmployees( + recipients.organizationId, + {}, + ); + for (const employee of items as Array<{ + user?: { id?: string }; + userId?: string; + }>) { + const uid = employee?.user?.id ?? employee?.userId; + if (uid) ids.add(uid); + } + } catch (err) { + this.logger.warn( + `Failed to resolve org recipients for ${recipients.organizationId}: ${ + (err as Error).message + }`, + ); + } + } + + return [...ids]; + } +} diff --git a/apps/edr-freight-api/src/modules/notification-inbox/notifications.gateway.ts b/apps/edr-freight-api/src/modules/notification-inbox/notifications.gateway.ts new file mode 100644 index 000000000..c14dcbaa5 --- /dev/null +++ b/apps/edr-freight-api/src/modules/notification-inbox/notifications.gateway.ts @@ -0,0 +1,75 @@ +import { + NOTIFICATION_WS_EVENTS, + NOTIFICATION_WS_NAMESPACE, + NotificationDto, +} from "@edr/types"; +import { Logger } from "@nestjs/common"; +import { + OnGatewayConnection, + WebSocketGateway, + WebSocketServer, +} from "@nestjs/websockets"; +import { Server, Socket } from "socket.io"; + +import { WsAuthService } from "./ws-auth.service"; + +/** + * Server → client push for in-app notifications. Clients only *listen* (no + * `@SubscribeMessage` handlers), so the global HTTP JwtGuard never applies here; + * the handshake is authenticated in `handleConnection` and each socket joins a + * private `user:` room the service targets. + */ +@WebSocketGateway({ + namespace: NOTIFICATION_WS_NAMESPACE, + cors: { origin: true, credentials: true }, +}) +export class NotificationsGateway implements OnGatewayConnection { + private readonly logger = new Logger(NotificationsGateway.name); + + @WebSocketServer() + private readonly server!: Server; + + constructor(private readonly wsAuth: WsAuthService) {} + + async handleConnection(socket: Socket): Promise { + const userId = await this.wsAuth.resolveUserId(this.extractToken(socket)); + if (!userId) { + this.logger.debug(`Rejected notifications handshake ${socket.id}`); + socket.disconnect(true); + return; + } + socket.data.userId = userId; + await socket.join(this.room(userId)); + } + + /** Push a freshly-created notification + the new unread count to a user. */ + emitNew(userId: string, notification: NotificationDto, unreadCount: number): void { + const room = this.server.to(this.room(userId)); + room.emit(NOTIFICATION_WS_EVENTS.NEW, notification); + room.emit(NOTIFICATION_WS_EVENTS.UNREAD_COUNT, unreadCount); + } + + /** Push only an updated unread count (e.g. after a read on another tab). */ + emitUnreadCount(userId: string, unreadCount: number): void { + this.server + .to(this.room(userId)) + .emit(NOTIFICATION_WS_EVENTS.UNREAD_COUNT, unreadCount); + } + + private room(userId: string): string { + return `user:${userId}`; + } + + private extractToken(socket: Socket): string | undefined { + const authToken = socket.handshake.auth?.token as string | undefined; + if (authToken) return authToken; + + const queryToken = socket.handshake.query?.token; + if (typeof queryToken === "string") return queryToken; + + const header = socket.handshake.headers?.authorization; + if (header?.startsWith("Bearer ")) return header.slice(7); + + return undefined; + } +} diff --git a/apps/edr-freight-api/src/modules/notification-inbox/ws-auth.service.ts b/apps/edr-freight-api/src/modules/notification-inbox/ws-auth.service.ts new file mode 100644 index 000000000..11c178e31 --- /dev/null +++ b/apps/edr-freight-api/src/modules/notification-inbox/ws-auth.service.ts @@ -0,0 +1,51 @@ +import { Injectable, Logger } from "@nestjs/common"; +import { InjectRepository } from "@nestjs/typeorm"; +import { Repository } from "typeorm"; + +import { verifyToken } from "@tria-plc/api-common/utils/token"; +import { ESessionStatus } from "@tria-plc/api-common/utils/enums/user.enum"; +import { Session } from "@tria-plc/iamapi-common/entities/iam/user/session.entity"; + +/** + * Authenticates a WebSocket handshake by mirroring the HTTP JwtGuard: the access + * token payload is only a *session* pointer (`{ id: }`), not the + * user — so we verify the signature (`verifyToken`), then load the IAM session + * and require it to be ACTIVE and unexpired, and read the real user id out of + * `session.userInfo`. There is no context-free verifier in the auth package, so + * this lookup is unavoidable; using the typed `Session` entity (rather than raw + * SQL) keeps it column-rename-safe and consistent with the package's own model. + * + * Returns the IAM user id, or null for any invalid/expired/revoked/malformed token. + */ +@Injectable() +export class WsAuthService { + private readonly logger = new Logger(WsAuthService.name); + + constructor( + @InjectRepository(Session) + private readonly sessions: Repository, + ) {} + + async resolveUserId(token?: string): Promise { + if (!token) return null; + try { + const payload = verifyToken(token) as { id?: string }; + const sessionId = payload?.id; + if (!sessionId) return null; + + const session = await this.sessions.findOne({ + where: { id: sessionId }, + }); + if (!session) return null; + if (session.status !== ESessionStatus.ACTIVE) return null; + if (!session.expiryTime || new Date(session.expiryTime) <= new Date()) { + return null; + } + + return session.userInfo?.id ?? null; + } catch (err) { + this.logger.debug(`WS auth rejected: ${(err as Error).message}`); + return null; + } + } +}