mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 17:45:42 +00:00
feat: setup the notification module to the api
This commit is contained in:
@@ -50,9 +50,11 @@
|
|||||||
"@nestjs/mapped-types": "^2.1.1",
|
"@nestjs/mapped-types": "^2.1.1",
|
||||||
"@nestjs/microservices": "^11.0.0",
|
"@nestjs/microservices": "^11.0.0",
|
||||||
"@nestjs/platform-express": "^11.0.0",
|
"@nestjs/platform-express": "^11.0.0",
|
||||||
|
"@nestjs/platform-socket.io": "^11.1.27",
|
||||||
"@nestjs/schedule": "^6.1.3",
|
"@nestjs/schedule": "^6.1.3",
|
||||||
"@nestjs/swagger": "^11.4.2",
|
"@nestjs/swagger": "^11.4.2",
|
||||||
"@nestjs/typeorm": "^11.0.1",
|
"@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/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",
|
"@tria-plc/iamapi-common": "file:../../local-packages/tria-plc-iamapi-common-0.7.7.tgz",
|
||||||
"amqp-connection-manager": "^5.0.0",
|
"amqp-connection-manager": "^5.0.0",
|
||||||
@@ -71,6 +73,7 @@
|
|||||||
"puppeteer": "^24.2.0",
|
"puppeteer": "^24.2.0",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
"rxjs": "^7.8.1",
|
"rxjs": "^7.8.1",
|
||||||
|
"socket.io": "^4.8.3",
|
||||||
"typeorm": "^0.3.30"
|
"typeorm": "^0.3.30"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ import { CompaniesModule } from "./modules/companies/companies.module";
|
|||||||
import { TrackingModule } from "./modules/tracking/tracking.module";
|
import { TrackingModule } from "./modules/tracking/tracking.module";
|
||||||
import { BillingModule } from "./modules/billing/billing.module";
|
import { BillingModule } from "./modules/billing/billing.module";
|
||||||
import { NotificationsModule } from "./modules/notifications/notifications.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 { FileUploadSettingsModule } from "./modules/file-upload-settings/file-upload-settings.module";
|
||||||
import { DropdownSettingsModule } from "./modules/dropdown-settings/dropdown-settings.module";
|
import { DropdownSettingsModule } from "./modules/dropdown-settings/dropdown-settings.module";
|
||||||
import { OtpModule } from "./modules/otp/otp.module";
|
import { OtpModule } from "./modules/otp/otp.module";
|
||||||
@@ -126,6 +127,7 @@ import { LoggerMiddleware } from "./logger.middleware";
|
|||||||
TrackingModule,
|
TrackingModule,
|
||||||
BillingModule,
|
BillingModule,
|
||||||
NotificationsModule,
|
NotificationsModule,
|
||||||
|
NotificationInboxModule,
|
||||||
FileUploadSettingsModule,
|
FileUploadSettingsModule,
|
||||||
DropdownSettingsModule,
|
DropdownSettingsModule,
|
||||||
OtpModule,
|
OtpModule,
|
||||||
|
|||||||
@@ -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<void> {
|
||||||
|
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<void> {
|
||||||
|
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`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -33,6 +33,11 @@ import { ETradeService } from "./services/etrade.service";
|
|||||||
CompanyDashboardRepository,
|
CompanyDashboardRepository,
|
||||||
ETradeService,
|
ETradeService,
|
||||||
],
|
],
|
||||||
exports: [CompaniesService],
|
exports: [
|
||||||
|
CompaniesService,
|
||||||
|
// Consumed by NotificationInboxModule for portal recipient targeting.
|
||||||
|
ExternalProfileRepository,
|
||||||
|
CompanyProfileRepository,
|
||||||
|
],
|
||||||
})
|
})
|
||||||
export class CompaniesModule { }
|
export class CompaniesModule { }
|
||||||
|
|||||||
@@ -334,9 +334,28 @@ export class CompaniesService {
|
|||||||
const company = await this.companiesRepo.findById(id);
|
const company = await this.companiesRepo.findById(id);
|
||||||
if (!company) throw new NotFoundException(`Company ${id} not found`);
|
if (!company) throw new NotFoundException(`Company ${id} not found`);
|
||||||
company.companyProfiles = await this.companyProfilesRepo.findByCompanyId(id);
|
company.companyProfiles = await this.companyProfilesRepo.findByCompanyId(id);
|
||||||
|
for (const profile of company.companyProfiles) {
|
||||||
|
profile.businessLicenseFiles = await this.signLicenseFiles(
|
||||||
|
profile.businessLicenseFiles,
|
||||||
|
);
|
||||||
|
}
|
||||||
return company;
|
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<BusinessLicenseFile[]> {
|
||||||
|
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
|
* 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
|
* to the booking's company and be Active. Used for government bookings (staff
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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<string, unknown> | 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;
|
||||||
|
}
|
||||||
@@ -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 ?? {});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {}
|
||||||
@@ -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<Notification> {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(Notification)
|
||||||
|
repo: Repository<Notification>,
|
||||||
|
) {
|
||||||
|
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<Notification> = { 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<number> {
|
||||||
|
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<boolean> {
|
||||||
|
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<number> {
|
||||||
|
const result = await this.repository.update(
|
||||||
|
{ recipientUserId: userId, isRead: false },
|
||||||
|
{ isRead: true, readAt: new Date() },
|
||||||
|
);
|
||||||
|
return result.affected ?? 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<User>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<void> {
|
||||||
|
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<NotificationListResult> {
|
||||||
|
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<NotificationDto> {
|
||||||
|
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<void> {
|
||||||
|
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<NotificationChannelsSent | null> {
|
||||||
|
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(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<string[]> {
|
||||||
|
const ids = new Set<string>();
|
||||||
|
|
||||||
|
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];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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:<id>` 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<void> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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: <sessionId> }`), 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<Session>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async resolveUserId(token?: string): Promise<string | null> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user