feat: setup the notification module to the api

This commit is contained in:
Nathnael
2026-07-06 06:51:38 +00:00
parent e910e6c5bd
commit 5a10c14ceb
14 changed files with 750 additions and 1 deletions

View File

@@ -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": {

View File

@@ -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,

View File

@@ -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`);
}
}

View File

@@ -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 { }

View File

@@ -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<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
* to the booking's company and be Active. Used for government bookings (staff

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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 ?? {});
}
}

View File

@@ -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 {}

View File

@@ -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;
}
}

View File

@@ -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(),
};
}
}

View File

@@ -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];
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}
}