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..1d7fd27fc --- /dev/null +++ b/apps/edr-freight-api/src/modules/notification-inbox/notification-inbox.controller.ts @@ -0,0 +1,79 @@ +import { CurrentUser } from "@edr/api-common"; +import { + NotificationAudience, + NotificationPriority, + NotificationType, +} 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; + type?: NotificationType; + priority?: NotificationPriority; + 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..97ee0380e --- /dev/null +++ b/apps/edr-freight-api/src/modules/notification-inbox/notification-inbox.service.ts @@ -0,0 +1,239 @@ +import { + NotificationAudience, + NotificationChannels, + 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; + type?: NotificationType; + priority?: NotificationPriority; + title?: string; + message?: string; + }, + ): Promise { + const entity = await this.repo.create({ + recipientUserId: userId, + audience: body.audience ?? NotificationAudience.BACKOFFICE, + type: body.type ?? NotificationType.GENERIC, + title: body.title ?? "Test notification", + body: body.message ?? "This is a test in-app notification.", + priority: body.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); + + const channels = this.resolveChannels(input, priority); + if (channels.email || channels.sms) { + const channelsSent = await this.fanOut(userId, input, channels); + if (channelsSent) { + await this.repo.update(entity.id, { channelsSent }); + } + } + } + + /** + * Decide which outbound channels to use. An explicit `input.channels` + * selection wins; otherwise fall back to priority (HIGH ⇒ email + SMS). + */ + private resolveChannels( + input: NotifyInput, + priority: NotificationPriority, + ): Required { + if (input.channels) { + return { + email: input.channels.email === true, + sms: input.channels.sms === true, + }; + } + const high = priority === NotificationPriority.HIGH; + return { email: high, sms: high }; + } + + /** + * Best-effort email/SMS fan-out for the requested channels. Skips a channel + * the recipient has no address for. Never throws. + */ + private async fanOut( + userId: string, + input: NotifyInput, + channels: Required, + ): 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 (channels.email && user.email) { + const res = await this.emailClient.sendEmail({ + to: user.email, + subject: input.title, + text, + }); + sent.email = res.queued; + } + if (channels.sms && 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; + } + } +} diff --git a/apps/edr-freight-web/backoffice/package.json b/apps/edr-freight-web/backoffice/package.json index 72c782e11..4efc5c2d4 100644 --- a/apps/edr-freight-web/backoffice/package.json +++ b/apps/edr-freight-web/backoffice/package.json @@ -33,6 +33,7 @@ "react-hot-toast": "^2.6.0", "react-router-dom": "^6.27.0", "recharts": "^3.8.1", + "socket.io-client": "^4.8.3", "sonner": "^2.0.7", "stream-browserify": "^3.0.0", "tailwind-merge": "^3.6.0", diff --git a/apps/edr-freight-web/backoffice/src/components/layout/FreightDashboardHeader.tsx b/apps/edr-freight-web/backoffice/src/components/layout/FreightDashboardHeader.tsx index 8a45c7cf0..8748f589e 100644 --- a/apps/edr-freight-web/backoffice/src/components/layout/FreightDashboardHeader.tsx +++ b/apps/edr-freight-web/backoffice/src/components/layout/FreightDashboardHeader.tsx @@ -5,14 +5,12 @@ import { Burger, Divider, Group, - Indicator, Menu, Text, Tooltip, UnstyledButton, } from "@mantine/core"; import { - Bell, ChevronDown, FileSignature, Languages, @@ -25,6 +23,8 @@ import { import { type ReactNode } from "react"; import { useNavigate } from "react-router-dom"; +import NotificationBellContainer from "@/features/notifications/NotificationBellContainer"; + import type { PageMeta } from "./types"; export interface FreightDashboardHeaderProps { @@ -117,19 +117,7 @@ const FreightDashboardHeader = ({ - - - - - - - + {enableThemeToggle && ( p.items).map(toItem); +} + +/** + * Wires react-query (infinite unread/read lists) + the notification WebSocket + * into the shared bell + drawer. Lists are only fetched while the drawer is + * open; the badge is driven by the lightweight unread-count query + socket. + */ +export default function NotificationBellContainer({ + enabled = true, +}: { + enabled?: boolean; +}) { + const navigate = useNavigate(); + const [opened, setOpened] = useState(false); + + const unreadQ = useInfiniteNotifications(false, enabled && opened); + const readQ = useInfiniteNotifications(true, enabled && opened); + const unread = useUnreadCount(enabled); + const markRead = useMarkRead(); + const markAllRead = useMarkAllRead(); + + const unreadItems = toItems(unreadQ.data); + const readItems = toItems(readQ.data); + const unreadCount = unread.data ?? 0; + + const handleItemClick = (item: NotificationItemData) => { + if (!item.isRead) markRead.mutate(item.id); + const href = resolveNotificationHref(item); + setOpened(false); + if (href) navigate(href); + }; + + // Live push → rich toast that reuses the same registry + click action. + useNotificationSocket(enabled, (n) => { + const item = toItem(n); + toast.custom( + (t) => ( + { + toast.dismiss(t.id); + handleItemClick(item); + }} + onDismiss={() => toast.dismiss(t.id)} + /> + ), + { duration: 6000 }, + ); + }); + + return ( + <> + setOpened(true)} + /> + setOpened(false)} + unread={unreadItems} + read={readItems} + unreadCount={unreadCount} + loading={opened && (unreadQ.isLoading || readQ.isLoading)} + hasMoreUnread={unreadQ.hasNextPage} + hasMoreRead={readQ.hasNextPage} + loadingMoreUnread={unreadQ.isFetchingNextPage} + loadingMoreRead={readQ.isFetchingNextPage} + onLoadMoreUnread={() => { + if (unreadQ.hasNextPage && !unreadQ.isFetchingNextPage) { + void unreadQ.fetchNextPage(); + } + }} + onLoadMoreRead={() => { + if (readQ.hasNextPage && !readQ.isFetchingNextPage) { + void readQ.fetchNextPage(); + } + }} + onItemClick={handleItemClick} + onMarkAllRead={() => markAllRead.mutate()} + resolveVisual={resolveNotificationVisual} + /> + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/features/notifications/notificationConfig.tsx b/apps/edr-freight-web/backoffice/src/features/notifications/notificationConfig.tsx new file mode 100644 index 000000000..9baf4ebf2 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/features/notifications/notificationConfig.tsx @@ -0,0 +1,53 @@ +import { NotificationType } from "@edr/types"; +import type { NotificationItemData, NotificationVisual } from "@edr/ui-common"; +import { Bell, ClipboardCheck, Inbox, Wallet } from "lucide-react"; + +const ICON_SIZE = 17; + +/** + * Backoffice notification registry. Maps a notification `type` → icon + Mantine + * color, and `type`/`data` → an in-app deep link. This is the single place to + * customize how each staff-facing notification looks and where it goes. + */ +export function resolveNotificationVisual( + item: NotificationItemData, +): NotificationVisual { + switch (item.type) { + case NotificationType.REQUEST_SUBMITTED: + return { icon: , color: "blue" }; + case NotificationType.PAYMENT_RECEIVED: + return { icon: , color: "teal" }; + case NotificationType.CLEARANCE_REVIEW: + return { icon: , color: "orange" }; + default: + return { icon: , color: "edr-green" }; + } +} + +function asId(value: unknown): string | undefined { + return typeof value === "string" && value.length > 0 ? value : undefined; +} + +/** + * Resolve where clicking a notification navigates. Prefers an explicit + * server-provided `link`, else derives a `/dashboard/*` route from `type` + + * `data`. Returns `null` when there's nowhere sensible to go. + */ +export function resolveNotificationHref( + item: NotificationItemData, +): string | null { + if (item.link) return item.link; + const data = item.data ?? {}; + switch (item.type) { + case NotificationType.REQUEST_SUBMITTED: + return "/dashboard/booking-requests"; + case NotificationType.PAYMENT_RECEIVED: { + const id = asId(data.customerId); + return id ? `/dashboard/customers/${id}` : "/dashboard/customers"; + } + case NotificationType.CLEARANCE_REVIEW: + return "/dashboard/arrival-queue"; + default: + return null; + } +} diff --git a/apps/edr-freight-web/backoffice/src/features/notifications/notificationsApi.ts b/apps/edr-freight-web/backoffice/src/features/notifications/notificationsApi.ts new file mode 100644 index 000000000..1982aa238 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/features/notifications/notificationsApi.ts @@ -0,0 +1,37 @@ +import type { NotificationListResult } from "@edr/types"; + +import { api } from "@/auth/http"; + +export interface ListNotificationsParams { + page?: number; + limit?: number; + isRead?: boolean; +} + +/** + * Backoffice notification REST calls. The backoffice axios `api` response + * interceptor already unwraps the `{ success, data }` envelope, so `.data` here + * is the payload itself. + */ +export const notificationsApi = { + list: async ( + params: ListNotificationsParams = {}, + ): Promise => { + const { data } = await api.get("/notifications", { + params, + }); + return data; + }, + unreadCount: async (): Promise => { + const { data } = await api.get<{ unreadCount: number }>( + "/notifications/unread-count", + ); + return data.unreadCount; + }, + markRead: async (id: string): Promise => { + await api.patch(`/notifications/${id}/read`); + }, + markAllRead: async (): Promise => { + await api.post("/notifications/read-all"); + }, +}; diff --git a/apps/edr-freight-web/backoffice/src/features/notifications/useNotificationSocket.ts b/apps/edr-freight-web/backoffice/src/features/notifications/useNotificationSocket.ts new file mode 100644 index 000000000..080424d0a --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/features/notifications/useNotificationSocket.ts @@ -0,0 +1,58 @@ +import { + NOTIFICATION_WS_EVENTS, + NOTIFICATION_WS_NAMESPACE, + type NotificationDto, +} from "@edr/types"; +import { useQueryClient } from "@tanstack/react-query"; +import { useEffect, useRef } from "react"; +import { io } from "socket.io-client"; + +import { API_BASE_URL } from "@/constants/apiConfig"; +import { AUTH_TOKEN_COOKIE, getCookie } from "@/auth/cookies"; + +import { NOTIFICATIONS_KEY, UNREAD_KEY } from "./useNotifications"; + +// The socket namespace lives at the server root, not under the `/api` REST +// prefix — strip a trailing `/api` if the base URL carries one. +const SOCKET_ORIGIN = String(API_BASE_URL ?? "").replace(/\/api\/?$/, ""); + +/** + * Subscribes to live notification pushes for the signed-in staff user. New + * items invalidate the cached lists + fire `onNew` (the host shows a rich + * toast); unread-count pushes update the badge. + */ +export function useNotificationSocket( + enabled: boolean, + onNew?: (notification: NotificationDto) => void, +) { + const qc = useQueryClient(); + const onNewRef = useRef(onNew); + onNewRef.current = onNew; + + useEffect(() => { + if (!enabled) return; + const token = getCookie(AUTH_TOKEN_COOKIE); + if (!token) return; + + const socket = io(`${SOCKET_ORIGIN}/${NOTIFICATION_WS_NAMESPACE}`, { + auth: { token }, + transports: ["websocket"], + withCredentials: true, + }); + + socket.on(NOTIFICATION_WS_EVENTS.NEW, (n: NotificationDto) => { + qc.invalidateQueries({ queryKey: NOTIFICATIONS_KEY }); + qc.invalidateQueries({ queryKey: UNREAD_KEY }); + onNewRef.current?.(n); + }); + + socket.on(NOTIFICATION_WS_EVENTS.UNREAD_COUNT, (count: number) => { + if (typeof count === "number") qc.setQueryData(UNREAD_KEY, count); + }); + + return () => { + socket.off(); + socket.disconnect(); + }; + }, [enabled, qc]); +} diff --git a/apps/edr-freight-web/backoffice/src/features/notifications/useNotifications.ts b/apps/edr-freight-web/backoffice/src/features/notifications/useNotifications.ts new file mode 100644 index 000000000..9d488089d --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/features/notifications/useNotifications.ts @@ -0,0 +1,64 @@ +import { + useInfiniteQuery, + useMutation, + useQuery, + useQueryClient, +} from "@tanstack/react-query"; + +import { notificationsApi } from "./notificationsApi"; + +export const NOTIFICATIONS_KEY = ["notifications"] as const; +export const UNREAD_KEY = ["notifications", "unread"] as const; + +const PAGE_SIZE = 20; + +/** + * Paginated (infinite) notifications for one read-state. Drives a drawer + * section; call `fetchNextPage` as the user scrolls. Each page carries the + * server `count` so we know when to stop. + */ +export function useInfiniteNotifications(isRead: boolean, enabled = true) { + return useInfiniteQuery({ + queryKey: [...NOTIFICATIONS_KEY, "list", { isRead }], + queryFn: ({ pageParam }) => + notificationsApi.list({ page: pageParam, limit: PAGE_SIZE, isRead }), + initialPageParam: 1, + getNextPageParam: (lastPage, allPages) => { + const loaded = allPages.reduce((sum, p) => sum + p.items.length, 0); + return loaded < lastPage.count ? allPages.length + 1 : undefined; + }, + enabled, + }); +} + +export function useUnreadCount(enabled = true) { + return useQuery({ + queryKey: UNREAD_KEY, + queryFn: () => notificationsApi.unreadCount(), + enabled, + // WebSocket keeps this fresh; poll as a fallback if the socket drops. + refetchInterval: 60_000, + }); +} + +export function useMarkRead() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (id: string) => notificationsApi.markRead(id), + onSuccess: () => { + qc.invalidateQueries({ queryKey: NOTIFICATIONS_KEY }); + qc.invalidateQueries({ queryKey: UNREAD_KEY }); + }, + }); +} + +export function useMarkAllRead() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: () => notificationsApi.markAllRead(), + onSuccess: () => { + qc.invalidateQueries({ queryKey: NOTIFICATIONS_KEY }); + qc.invalidateQueries({ queryKey: UNREAD_KEY }); + }, + }); +} diff --git a/apps/edr-freight-web/portal/package.json b/apps/edr-freight-web/portal/package.json index a9bcf4ecc..433b83c1a 100644 --- a/apps/edr-freight-web/portal/package.json +++ b/apps/edr-freight-web/portal/package.json @@ -34,6 +34,7 @@ "react-phone-number-input": "^3.4.17", "react-router-dom": "^6.27.0", "recharts": "^3.8.1", + "socket.io-client": "^4.8.3", "tailwind-merge": "^3.6.0", "zod": "^4.4.3", "zustand": "^5.0.0" diff --git a/apps/edr-freight-web/portal/src/components/AppLayout.tsx b/apps/edr-freight-web/portal/src/components/AppLayout.tsx index 5f4de9f87..02df1f3e9 100644 --- a/apps/edr-freight-web/portal/src/components/AppLayout.tsx +++ b/apps/edr-freight-web/portal/src/components/AppLayout.tsx @@ -19,7 +19,6 @@ import { } from "@mantine/core"; import { useDisclosure } from "@mantine/hooks"; import { - Bell, ChevronDown, FileSignature, LogOut, @@ -40,6 +39,7 @@ import { useState, } from "react"; import { PROFILE_TYPE_LABELS } from "@/constants/profileMode"; +import NotificationBellContainer from "@/features/notifications/NotificationBellContainer"; export interface SidebarItem { label: string; @@ -353,25 +353,8 @@ export function AppLayout({ - {/* Bell */} - - - - - - + {/* Notifications */} + {enableThemeToggle && ( p.items).map(toItem); +} + +/** + * Wires react-query (infinite unread/read lists) + the notification WebSocket + * into the shared bell + drawer. Lists are only fetched while the drawer is + * open; the badge is driven by the lightweight unread-count query + socket. + */ +export default function NotificationBellContainer({ + enabled = true, +}: { + enabled?: boolean; +}) { + const navigate = useNavigate(); + const [opened, setOpened] = useState(false); + + const unreadQ = useInfiniteNotifications(false, enabled && opened); + const readQ = useInfiniteNotifications(true, enabled && opened); + const unread = useUnreadCount(enabled); + const markRead = useMarkRead(); + const markAllRead = useMarkAllRead(); + + const unreadItems = toItems(unreadQ.data); + const readItems = toItems(readQ.data); + const unreadCount = unread.data ?? 0; + + const handleItemClick = (item: NotificationItemData) => { + if (!item.isRead) markRead.mutate(item.id); + const href = resolveNotificationHref(item); + setOpened(false); + if (href) navigate(href); + }; + + // Live push → rich toast that reuses the same registry + click action. + useNotificationSocket(enabled, (n) => { + const item = toItem(n); + toast.custom( + (t) => ( + { + toast.dismiss(t.id); + handleItemClick(item); + }} + onDismiss={() => toast.dismiss(t.id)} + /> + ), + { duration: 6000 }, + ); + }); + + return ( + <> + setOpened(true)} + /> + setOpened(false)} + unread={unreadItems} + read={readItems} + unreadCount={unreadCount} + loading={opened && (unreadQ.isLoading || readQ.isLoading)} + hasMoreUnread={unreadQ.hasNextPage} + hasMoreRead={readQ.hasNextPage} + loadingMoreUnread={unreadQ.isFetchingNextPage} + loadingMoreRead={readQ.isFetchingNextPage} + onLoadMoreUnread={() => { + if (unreadQ.hasNextPage && !unreadQ.isFetchingNextPage) { + void unreadQ.fetchNextPage(); + } + }} + onLoadMoreRead={() => { + if (readQ.hasNextPage && !readQ.isFetchingNextPage) { + void readQ.fetchNextPage(); + } + }} + onItemClick={handleItemClick} + onMarkAllRead={() => markAllRead.mutate()} + resolveVisual={resolveNotificationVisual} + /> + + ); +} diff --git a/apps/edr-freight-web/portal/src/features/notifications/notificationConfig.tsx b/apps/edr-freight-web/portal/src/features/notifications/notificationConfig.tsx new file mode 100644 index 000000000..42f3294f7 --- /dev/null +++ b/apps/edr-freight-web/portal/src/features/notifications/notificationConfig.tsx @@ -0,0 +1,66 @@ +import { NotificationType } from "@edr/types"; +import type { NotificationItemData, NotificationVisual } from "@edr/ui-common"; +import { + BadgeCheck, + Bell, + FileWarning, + Package, + Receipt, +} from "lucide-react"; + +const ICON_SIZE = 17; + +/** + * Portal notification registry. Maps a notification `type` → icon + Mantine + * color, and `type`/`data` → an in-app deep link. This is the single place to + * customize how each notification looks and where clicking it goes. + */ +export function resolveNotificationVisual( + item: NotificationItemData, +): NotificationVisual { + switch (item.type) { + case NotificationType.CLEARANCE_DECISION: + return { icon: , color: "teal" }; + case NotificationType.DOCUMENT_ACTION: + return { icon: , color: "orange" }; + case NotificationType.BOOKING_STATUS: + return { icon: , color: "blue" }; + case NotificationType.INVOICE_ISSUED: + return { icon: , color: "violet" }; + default: + return { icon: , color: "edr-green" }; + } +} + +function asId(value: unknown): string | undefined { + return typeof value === "string" && value.length > 0 ? value : undefined; +} + +/** + * Resolve where clicking a notification navigates. Prefers an explicit + * server-provided `link`, else derives a route from `type` + `data`. + * Returns `null` when there's nowhere sensible to go (item just marks read). + */ +export function resolveNotificationHref( + item: NotificationItemData, +): string | null { + if (item.link) return item.link; + const data = item.data ?? {}; + switch (item.type) { + case NotificationType.INVOICE_ISSUED: { + const id = asId(data.invoiceId); + return id ? `/billing/${id}` : "/billing"; + } + case NotificationType.BOOKING_STATUS: { + const id = asId(data.bookingId); + return id ? `/bookings/${id}` : null; + } + case NotificationType.CLEARANCE_DECISION: + case NotificationType.DOCUMENT_ACTION: { + const id = asId(data.contractId); + return id ? `/contracts/${id}` : "/contracts"; + } + default: + return null; + } +} diff --git a/apps/edr-freight-web/portal/src/features/notifications/notificationsApi.ts b/apps/edr-freight-web/portal/src/features/notifications/notificationsApi.ts new file mode 100644 index 000000000..a93ba0db9 --- /dev/null +++ b/apps/edr-freight-web/portal/src/features/notifications/notificationsApi.ts @@ -0,0 +1,33 @@ +import type { NotificationListResult } from "@edr/types"; + +import { client } from "@/utils/api"; + +export interface ListNotificationsParams { + page?: number; + limit?: number; + isRead?: boolean; +} + +/** + * Portal notification REST calls. The portal axios `client` returns the raw + * response, and the API wraps payloads in a `{ success, data }` envelope — so we + * unwrap `.data.data` here (same convention as the other portal services). + */ +export const notificationsApi = { + list: async ( + params: ListNotificationsParams = {}, + ): Promise => { + const { data } = await client.get("/api/notifications", { params }); + return data.data; + }, + unreadCount: async (): Promise => { + const { data } = await client.get("/api/notifications/unread-count"); + return data.data.unreadCount; + }, + markRead: async (id: string): Promise => { + await client.patch(`/api/notifications/${id}/read`); + }, + markAllRead: async (): Promise => { + await client.post("/api/notifications/read-all"); + }, +}; diff --git a/apps/edr-freight-web/portal/src/features/notifications/useNotificationSocket.ts b/apps/edr-freight-web/portal/src/features/notifications/useNotificationSocket.ts new file mode 100644 index 000000000..510cc12d8 --- /dev/null +++ b/apps/edr-freight-web/portal/src/features/notifications/useNotificationSocket.ts @@ -0,0 +1,64 @@ +import { + NOTIFICATION_WS_EVENTS, + NOTIFICATION_WS_NAMESPACE, + type NotificationDto, +} from "@edr/types"; +import { useQueryClient } from "@tanstack/react-query"; +import { useEffect, useRef } from "react"; +import { io } from "socket.io-client"; + +import { API_BASE_URL } from "@/constants/apiConfig"; + +import { NOTIFICATIONS_KEY, UNREAD_KEY } from "./useNotifications"; + +function getAuthToken(): string | undefined { + return document.cookie + .split("; ") + .find((row) => row.startsWith("auth-token=")) + ?.split("=")[1]; +} + +// The socket namespace lives at the server root, not under the `/api` REST +// prefix — strip a trailing `/api` if the base URL carries one. +const SOCKET_ORIGIN = String(API_BASE_URL ?? "").replace(/\/api\/?$/, ""); + +/** + * Subscribes to live notification pushes for the signed-in user. New items + * invalidate the cached lists + fire `onNew` (the host shows a rich toast); + * unread-count pushes update the badge instantly. + */ +export function useNotificationSocket( + enabled: boolean, + onNew?: (notification: NotificationDto) => void, +) { + const qc = useQueryClient(); + const onNewRef = useRef(onNew); + onNewRef.current = onNew; + + useEffect(() => { + if (!enabled) return; + const token = getAuthToken(); + if (!token) return; + + const socket = io(`${SOCKET_ORIGIN}/${NOTIFICATION_WS_NAMESPACE}`, { + auth: { token }, + transports: ["websocket"], + withCredentials: true, + }); + + socket.on(NOTIFICATION_WS_EVENTS.NEW, (n: NotificationDto) => { + qc.invalidateQueries({ queryKey: NOTIFICATIONS_KEY }); + qc.invalidateQueries({ queryKey: UNREAD_KEY }); + onNewRef.current?.(n); + }); + + socket.on(NOTIFICATION_WS_EVENTS.UNREAD_COUNT, (count: number) => { + if (typeof count === "number") qc.setQueryData(UNREAD_KEY, count); + }); + + return () => { + socket.off(); + socket.disconnect(); + }; + }, [enabled, qc]); +} diff --git a/apps/edr-freight-web/portal/src/features/notifications/useNotifications.ts b/apps/edr-freight-web/portal/src/features/notifications/useNotifications.ts new file mode 100644 index 000000000..9d488089d --- /dev/null +++ b/apps/edr-freight-web/portal/src/features/notifications/useNotifications.ts @@ -0,0 +1,64 @@ +import { + useInfiniteQuery, + useMutation, + useQuery, + useQueryClient, +} from "@tanstack/react-query"; + +import { notificationsApi } from "./notificationsApi"; + +export const NOTIFICATIONS_KEY = ["notifications"] as const; +export const UNREAD_KEY = ["notifications", "unread"] as const; + +const PAGE_SIZE = 20; + +/** + * Paginated (infinite) notifications for one read-state. Drives a drawer + * section; call `fetchNextPage` as the user scrolls. Each page carries the + * server `count` so we know when to stop. + */ +export function useInfiniteNotifications(isRead: boolean, enabled = true) { + return useInfiniteQuery({ + queryKey: [...NOTIFICATIONS_KEY, "list", { isRead }], + queryFn: ({ pageParam }) => + notificationsApi.list({ page: pageParam, limit: PAGE_SIZE, isRead }), + initialPageParam: 1, + getNextPageParam: (lastPage, allPages) => { + const loaded = allPages.reduce((sum, p) => sum + p.items.length, 0); + return loaded < lastPage.count ? allPages.length + 1 : undefined; + }, + enabled, + }); +} + +export function useUnreadCount(enabled = true) { + return useQuery({ + queryKey: UNREAD_KEY, + queryFn: () => notificationsApi.unreadCount(), + enabled, + // WebSocket keeps this fresh; poll as a fallback if the socket drops. + refetchInterval: 60_000, + }); +} + +export function useMarkRead() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (id: string) => notificationsApi.markRead(id), + onSuccess: () => { + qc.invalidateQueries({ queryKey: NOTIFICATIONS_KEY }); + qc.invalidateQueries({ queryKey: UNREAD_KEY }); + }, + }); +} + +export function useMarkAllRead() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: () => notificationsApi.markAllRead(), + onSuccess: () => { + qc.invalidateQueries({ queryKey: NOTIFICATIONS_KEY }); + qc.invalidateQueries({ queryKey: UNREAD_KEY }); + }, + }); +} diff --git a/apps/edr-freight-web/portal/src/main.tsx b/apps/edr-freight-web/portal/src/main.tsx index d932bb0f3..44f2d3fb9 100644 --- a/apps/edr-freight-web/portal/src/main.tsx +++ b/apps/edr-freight-web/portal/src/main.tsx @@ -8,6 +8,7 @@ import "@mantine/dates/styles.css"; import "@edr/ui-common/styles.css"; import "../index.css"; import "@edr/ui-common/theme.css"; +import { Toaster } from "react-hot-toast"; import { mantineTheme } from "./theme/mantine"; import App from "./App"; @@ -39,6 +40,7 @@ createRoot(document.getElementById("root")!).render( + diff --git a/packages/types/src/freight/index.ts b/packages/types/src/freight/index.ts index ff7539e60..7c8a3c4cd 100644 --- a/packages/types/src/freight/index.ts +++ b/packages/types/src/freight/index.ts @@ -7,6 +7,7 @@ export * from "./overview"; export * from "./etrade"; export * from "./contracts"; export * from "./clearance-files.catalog"; +export * from "./notifications"; export enum TradeDirection { IMPORT = "IMPORT", diff --git a/packages/types/src/freight/notifications.ts b/packages/types/src/freight/notifications.ts new file mode 100644 index 000000000..bdf83a719 --- /dev/null +++ b/packages/types/src/freight/notifications.ts @@ -0,0 +1,124 @@ +/** + * Shared contracts for the freight in-app notification system. + * + * The notification *mechanism* (module, gateway, bell) ships first; individual + * domain triggers are wired later. The `NotificationType` values below seed the + * intended trigger set so the frontend can map icons/labels before any producer + * actually emits them. + */ + +/** Which app surface a notification is addressed to. */ +export enum NotificationAudience { + PORTAL = "PORTAL", + BACKOFFICE = "BACKOFFICE", +} + +/** + * Default channel fan-out when {@link NotifyInput.channels} is not given: + * HIGH also pushes email/SMS, NORMAL is in-app only. An explicit `channels` + * selection overrides this. + */ +export enum NotificationPriority { + NORMAL = "NORMAL", + HIGH = "HIGH", +} + +/** + * Semantic type of a notification. Used by the frontend to pick an icon/label + * and by producers to categorize. `GENERIC` is the catch-all for ad-hoc calls. + */ +export enum NotificationType { + GENERIC = "GENERIC", + // Portal-facing (customer) + CLEARANCE_DECISION = "CLEARANCE_DECISION", + DOCUMENT_ACTION = "DOCUMENT_ACTION", + BOOKING_STATUS = "BOOKING_STATUS", + INVOICE_ISSUED = "INVOICE_ISSUED", + // Backoffice-facing (staff) + REQUEST_SUBMITTED = "REQUEST_SUBMITTED", + PAYMENT_RECEIVED = "PAYMENT_RECEIVED", + CLEARANCE_REVIEW = "CLEARANCE_REVIEW", +} + +/** + * Explicit fan-out channel selection for a single `notify(...)` call. + * + * In-app delivery is always performed (this module is an inbox) and is not + * listed here. These flags control the *extra* outbound channels. When omitted + * on {@link NotifyInput}, channels fall back to priority: HIGH ⇒ email + SMS, + * NORMAL ⇒ none. + */ +export interface NotificationChannels { + email?: boolean; + sms?: boolean; +} + +/** Optional per-channel delivery outcome recorded on the notification row. */ +export interface NotificationChannelsSent { + email?: boolean; + sms?: boolean; +} + +/** A persisted in-app notification as returned to the client. */ +export interface NotificationDto { + id: string; + recipientUserId: string; + audience: NotificationAudience; + type: NotificationType; + title: string; + body: string; + /** Deep-link path within the app the item points to (e.g. `/contracts/:id`). */ + link?: string | null; + /** Arbitrary structured payload (bookingId, invoiceId, contractId, …). */ + data?: Record | null; + priority: NotificationPriority; + isRead: boolean; + readAt?: string | null; + createdAt: string; +} + +/** Target selector: any combination resolves to a set of recipient user ids. */ +export interface NotificationRecipients { + /** Explicit IAM user ids — always honored. */ + userIds?: string[]; + /** Portal: all users linked to this company (via external profiles). */ + companyId?: string; + /** Portal: resolved to the company, then to that company's users. */ + companyProfileId?: string; + /** Backoffice: all current employees of this organization. */ + organizationId?: string; +} + +/** Input any subsystem passes to `NotificationInboxService.notify(...)`. */ +export interface NotifyInput { + recipients: NotificationRecipients; + audience: NotificationAudience; + type: NotificationType; + title: string; + body: string; + link?: string | null; + data?: Record | null; + priority?: NotificationPriority; + /** + * Explicit email/SMS fan-out. Overrides the priority-based default when set; + * omit to let `priority` decide (HIGH ⇒ email + SMS, NORMAL ⇒ in-app only). + * In-app is always delivered regardless. + */ + channels?: NotificationChannels; +} + +/** Paginated list envelope for the notifications list endpoint. */ +export interface NotificationListResult { + items: NotificationDto[]; + count: number; + unreadCount: number; +} + +/** Socket.io event names pushed server → client on the `notifications` namespace. */ +export const NOTIFICATION_WS_EVENTS = { + NEW: "notification:new", + UNREAD_COUNT: "notification:unread-count", +} as const; + +/** Socket.io namespace the notifications gateway listens on. */ +export const NOTIFICATION_WS_NAMESPACE = "notifications"; diff --git a/packages/ui-common/src/components/NotificationBell/NotificationBell.tsx b/packages/ui-common/src/components/NotificationBell/NotificationBell.tsx new file mode 100644 index 000000000..55b1e6465 --- /dev/null +++ b/packages/ui-common/src/components/NotificationBell/NotificationBell.tsx @@ -0,0 +1,50 @@ +import { ActionIcon, Indicator } from "@mantine/core"; +import { Bell } from "lucide-react"; + +export interface NotificationBellProps { + unreadCount: number; + /** Opens the notification drawer. */ + onClick?: () => void; + ariaLabel?: string; + /** Extra className for the trigger button (e.g. the app's header "island"). */ + triggerClassName?: string; +} + +/** + * Header bell trigger: an icon button with an unread-count indicator. Purely + * presentational — clicking calls `onClick` (the app opens {@link NotificationDrawer}). + */ +export function NotificationBell({ + unreadCount, + onClick, + ariaLabel = "Notifications", + triggerClassName, +}: NotificationBellProps) { + const hasUnread = unreadCount > 0; + + return ( + 99 ? "99+" : unreadCount} + styles={{ + indicator: { fontSize: 10, fontWeight: 700, padding: "0 4px" }, + }} + > + + + + + ); +} + +export default NotificationBell; diff --git a/packages/ui-common/src/components/NotificationBell/NotificationDrawer.tsx b/packages/ui-common/src/components/NotificationBell/NotificationDrawer.tsx new file mode 100644 index 000000000..3be7c801b --- /dev/null +++ b/packages/ui-common/src/components/NotificationBell/NotificationDrawer.tsx @@ -0,0 +1,300 @@ +import { + ActionIcon, + Badge, + Box, + Button, + Drawer, + Group, + Loader, + ScrollArea, + Stack, + Text, + ThemeIcon, +} from "@mantine/core"; +import { Bell, CheckCheck, Inbox, X } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; + +import { NotificationItem } from "./NotificationItem"; +import { edr } from "./PriorityTag"; +import type { + NotificationItemData, + ResolveNotificationVisual, +} from "./types"; + +export interface NotificationDrawerProps { + opened: boolean; + onClose: () => void; + /** Unread items (newest first), already flattened across pages. */ + unread: NotificationItemData[]; + /** Read items (newest first), already flattened across pages. */ + read: NotificationItemData[]; + unreadCount?: number; + /** Initial load spinner (before any page resolves). */ + loading?: boolean; + hasMoreUnread?: boolean; + hasMoreRead?: boolean; + loadingMoreUnread?: boolean; + loadingMoreRead?: boolean; + onLoadMoreUnread?: () => void; + onLoadMoreRead?: () => void; + onItemClick?: (item: NotificationItemData) => void; + onMarkAllRead?: () => void; + /** App-owned registry: `item` → icon + color. */ + resolveVisual?: ResolveNotificationVisual; + emptyLabel?: string; + title?: string; + width?: number; +} + +/** Fires `onVisible` when it scrolls into view within `root`. Infinite-scroll trigger. */ +function Sentinel({ + root, + onVisible, +}: { + root: HTMLElement | null; + onVisible: () => void; +}) { + const ref = useRef(null); + const cb = useRef(onVisible); + cb.current = onVisible; + + useEffect(() => { + const el = ref.current; + if (!el) return; + const obs = new IntersectionObserver( + (entries) => { + if (entries.some((e) => e.isIntersecting)) cb.current(); + }, + { root, rootMargin: "160px" }, + ); + obs.observe(el); + return () => obs.disconnect(); + }, [root]); + + return
; +} + +function SectionLabel({ + label, + count, +}: { + label: string; + count?: number; +}) { + return ( + + + {label} + + {typeof count === "number" && count > 0 && ( + + {count} + + )} + + ); +} + +function LoadingRow() { + return ( + + + + ); +} + +/** + * Right slide-in notification center, styled to the EDR design system: a + * branded header, an "Unread" section over an "Earlier" (read) section, each + * with its own infinite-scroll trigger in one scroll surface. Presentational — + * the host wires data, paging, and the visual registry. + */ +export function NotificationDrawer({ + opened, + onClose, + unread, + read, + unreadCount, + loading = false, + hasMoreUnread = false, + hasMoreRead = false, + loadingMoreUnread = false, + loadingMoreRead = false, + onLoadMoreUnread, + onLoadMoreRead, + onItemClick, + onMarkAllRead, + resolveVisual, + emptyLabel = "You're all caught up", + title = "Notifications", + width = 424, +}: NotificationDrawerProps) { + const [viewport, setViewport] = useState(null); + const viewportRef = useRef(null); + + // Capture the scroll viewport so the sentinels observe within it, not the page. + useEffect(() => { + if (opened) setViewport(viewportRef.current); + }, [opened]); + + const totalUnread = unreadCount ?? unread.length; + const isEmpty = !loading && unread.length === 0 && read.length === 0; + + const renderItem = (item: NotificationItemData) => ( + + ); + + return ( + + + + + + + + + {title} + + + {totalUnread > 0 ? `${totalUnread} unread` : "All caught up"} + + + {totalUnread > 0 && ( + + {totalUnread > 99 ? "99+" : totalUnread} + + )} + + + {totalUnread > 0 && onMarkAllRead && ( + + )} + + + + + + + + {loading && unread.length === 0 && read.length === 0 ? ( + + ) : isEmpty ? ( + + + + + + {emptyLabel} + + + New notifications about your bookings, clearances and invoices will + show up here. + + + ) : ( + + {unread.length > 0 && ( + <> + + {unread.map(renderItem)} + {hasMoreUnread && ( + onLoadMoreUnread?.()} + /> + )} + {loadingMoreUnread && } + + )} + + {read.length > 0 && ( + <> + + {read.map(renderItem)} + {hasMoreRead && ( + onLoadMoreRead?.()} + /> + )} + {loadingMoreRead && } + + )} + + )} + + + ); +} + +export default NotificationDrawer; diff --git a/packages/ui-common/src/components/NotificationBell/NotificationItem.tsx b/packages/ui-common/src/components/NotificationBell/NotificationItem.tsx new file mode 100644 index 000000000..670f0991b --- /dev/null +++ b/packages/ui-common/src/components/NotificationBell/NotificationItem.tsx @@ -0,0 +1,133 @@ +import { Box, Group, Stack, Text, ThemeIcon } from "@mantine/core"; +import { Bell } from "lucide-react"; +import { useState } from "react"; + +import { edr, isHighPriority, PriorityTag } from "./PriorityTag"; +import { timeAgo } from "./timeAgo"; +import type { NotificationItemData, NotificationVisual } from "./types"; + +export interface NotificationItemProps { + item: NotificationItemData; + /** Resolved by the hosting app's registry from `item.type`/`item.data`. */ + visual?: NotificationVisual; + onClick?: (item: NotificationItemData) => void; +} + +/** + * One notification row, styled to the EDR design system. Unread rows get a + * colored left accent (per-type, or amber for HIGH priority) and a soft tint; + * HIGH-priority rows also show a "High" pill. Icon/color come from the app. + */ +export function NotificationItem({ item, visual, onClick }: NotificationItemProps) { + const [hovered, setHovered] = useState(false); + const color = visual?.color ?? "blue"; + const icon = visual?.icon ?? ; + const unread = !item.isRead; + const high = isHighPriority(item.priority); + const clickable = Boolean(onClick); + + // Accent + tint follow the type color, except HIGH priority which goes amber. + const accent = high ? edr.amber : `var(--mantine-color-${color}-6)`; + const tintBase = high + ? edr.amberSoft + : `var(--mantine-color-${color}-0)`; + const hoverBase = high + ? edr.amberSoft + : `var(--mantine-color-${color}-1)`; + + const background = unread + ? hovered + ? hoverBase + : tintBase + : hovered + ? "var(--mantine-color-edr-slate-soft-6, var(--mantine-color-gray-0))" + : "transparent"; + + return ( + onClick?.(item)} + onMouseEnter={() => setHovered(true)} + onMouseLeave={() => setHovered(false)} + onKeyDown={(e) => { + if (clickable && (e.key === "Enter" || e.key === " ")) { + e.preventDefault(); + onClick?.(item); + } + }} + px="md" + py="sm" + style={{ + position: "relative", + cursor: clickable ? "pointer" : "default", + borderLeft: "3px solid transparent", + borderLeftColor: unread ? accent : "transparent", + background, + transition: "background 120ms ease", + }} + > + + + {icon} + + + + + + + {item.title} + + {high && } + + + {timeAgo(item.createdAt)} + + + + {item.body} + + + + {unread && ( + + )} + + + ); +} + +export default NotificationItem; diff --git a/packages/ui-common/src/components/NotificationBell/NotificationToast.tsx b/packages/ui-common/src/components/NotificationBell/NotificationToast.tsx new file mode 100644 index 000000000..18e325f11 --- /dev/null +++ b/packages/ui-common/src/components/NotificationBell/NotificationToast.tsx @@ -0,0 +1,136 @@ +import { ActionIcon, Box, Group, Stack, Text, ThemeIcon } from "@mantine/core"; +import { Bell, ChevronRight, X } from "lucide-react"; + +import { edr, isHighPriority, PriorityTag } from "./PriorityTag"; +import { timeAgo } from "./timeAgo"; +import type { NotificationItemData, NotificationVisual } from "./types"; + +export interface NotificationToastProps { + item: NotificationItemData; + /** Resolved by the hosting app's registry from `item.type`/`item.data`. */ + visual?: NotificationVisual; + /** react-hot-toast enter/exit flag; drives the slide/fade animation. */ + visible?: boolean; + /** Whole-card click (usually: mark read + navigate). Shows a chevron affordance. */ + onClick?: () => void; + onDismiss?: () => void; + width?: number; +} + +/** + * Rich, ERP-grade notification toast styled to the EDR design system: a + * colored-accent card (per-type, amber for HIGH) with an icon tile, a bold + * title, a two-line body, a relative timestamp, and a dismiss button. + */ +export function NotificationToast({ + item, + visual, + visible = true, + onClick, + onDismiss, + width = 392, +}: NotificationToastProps) { + const color = visual?.color ?? "blue"; + const icon = visual?.icon ?? ; + const high = isHighPriority(item.priority); + const clickable = Boolean(onClick); + const accent = high ? edr.amber : `var(--mantine-color-${color}-6)`; + const linkColor = high ? edr.amberText : `var(--mantine-color-${color}-7)`; + + return ( + + {/* colored accent rail */} + + + { + if (clickable && (e.key === "Enter" || e.key === " ")) { + e.preventDefault(); + onClick?.(); + } + }} + p="sm" + style={{ flex: 1, minWidth: 0, cursor: clickable ? "pointer" : "default" }} + > + + + {icon} + + + + + + {item.title} + + {high && } + + + {item.body} + + + + {timeAgo(item.createdAt)} + + {clickable && ( + + + View details + + + + )} + + + + + + {onDismiss && ( + { + e.stopPropagation(); + onDismiss(); + }} + style={{ position: "absolute", top: 6, right: 6 }} + > + + + )} + + ); +} + +export default NotificationToast; diff --git a/packages/ui-common/src/components/NotificationBell/PriorityTag.tsx b/packages/ui-common/src/components/NotificationBell/PriorityTag.tsx new file mode 100644 index 000000000..0ada16ddf --- /dev/null +++ b/packages/ui-common/src/components/NotificationBell/PriorityTag.tsx @@ -0,0 +1,41 @@ +/** + * EDR design-system tokens used across the notification surfaces, with safe + * fallbacks so the shared components still render outside the freight apps. + */ +export const edr = { + text: "var(--mantine-color-edr-text-6, var(--mantine-color-text))", + muted: "var(--mantine-color-edr-muted-6, var(--mantine-color-dimmed))", + border: "var(--mantine-color-edr-border-6, var(--mantine-color-default-border))", + card: "var(--mantine-color-edr-card-6, var(--mantine-color-body))", + amber: "var(--mantine-color-edr-accent-6, #F2A516)", + amberSoft: "var(--mantine-color-edr-amber-soft-6, #FDF3E0)", + amberText: "var(--mantine-color-edr-amber-text-6, #9A5B00)", +} as const; + +/** True when a notification's priority string denotes HIGH urgency. */ +export const isHighPriority = (priority?: string | null): boolean => + typeof priority === "string" && priority.toUpperCase() === "HIGH"; + +/** Small amber "High" pill for urgent notifications. */ +export function PriorityTag() { + return ( + + High + + ); +} diff --git a/packages/ui-common/src/components/NotificationBell/index.ts b/packages/ui-common/src/components/NotificationBell/index.ts new file mode 100644 index 000000000..e7025c6e7 --- /dev/null +++ b/packages/ui-common/src/components/NotificationBell/index.ts @@ -0,0 +1,17 @@ +export { NotificationBell, default } from "./NotificationBell"; +export type { NotificationBellProps } from "./NotificationBell"; + +export { NotificationItem } from "./NotificationItem"; +export type { NotificationItemProps } from "./NotificationItem"; + +export { NotificationDrawer } from "./NotificationDrawer"; +export type { NotificationDrawerProps } from "./NotificationDrawer"; + +export { NotificationToast } from "./NotificationToast"; +export type { NotificationToastProps } from "./NotificationToast"; + +export type { + NotificationItemData, + NotificationVisual, + ResolveNotificationVisual, +} from "./types"; diff --git a/packages/ui-common/src/components/NotificationBell/timeAgo.ts b/packages/ui-common/src/components/NotificationBell/timeAgo.ts new file mode 100644 index 000000000..49f492296 --- /dev/null +++ b/packages/ui-common/src/components/NotificationBell/timeAgo.ts @@ -0,0 +1,14 @@ +/** Compact relative time: "just now", "5m ago", "3h ago", "2d ago", else date. */ +export const timeAgo = (iso: string): string => { + const then = new Date(iso).getTime(); + if (Number.isNaN(then)) return ""; + const secs = Math.max(0, Math.floor((Date.now() - then) / 1000)); + if (secs < 60) return "just now"; + const mins = Math.floor(secs / 60); + if (mins < 60) return `${mins}m ago`; + const hrs = Math.floor(mins / 60); + if (hrs < 24) return `${hrs}h ago`; + const days = Math.floor(hrs / 24); + if (days < 7) return `${days}d ago`; + return new Date(iso).toLocaleDateString(); +}; diff --git a/packages/ui-common/src/components/NotificationBell/types.ts b/packages/ui-common/src/components/NotificationBell/types.ts new file mode 100644 index 000000000..532a314db --- /dev/null +++ b/packages/ui-common/src/components/NotificationBell/types.ts @@ -0,0 +1,33 @@ +import type { ReactNode } from "react"; + +/** Minimal notification shape the presentational components render. */ +export interface NotificationItemData { + id: string; + /** Semantic type key (e.g. `INVOICE_ISSUED`). Drives the per-app visual. */ + type: string; + title: string; + body: string; + createdAt: string; + isRead: boolean; + /** Priority key (e.g. `HIGH`). `HIGH` gets an amber urgency cue. */ + priority?: string | null; + /** Deep-link path the item points to, if any. */ + link?: string | null; + /** Arbitrary structured payload (bookingId, invoiceId, …). */ + data?: Record | null; +} + +/** + * Per-item visual resolved by the hosting app's registry. `color` is a Mantine + * color key (e.g. `"blue"`, `"green"`); `icon` is any node (usually a lucide + * icon). The app maps `item.type`/`item.data` → this. + */ +export interface NotificationVisual { + icon?: ReactNode; + color?: string; +} + +/** Signature of the app-owned registry passed into the drawer. */ +export type ResolveNotificationVisual = ( + item: NotificationItemData, +) => NotificationVisual; diff --git a/packages/ui-common/src/index.ts b/packages/ui-common/src/index.ts index 136e9e93b..f7e609412 100644 --- a/packages/ui-common/src/index.ts +++ b/packages/ui-common/src/index.ts @@ -28,6 +28,22 @@ export type { OperationDatePickerProps } from "./components/OperationDatePicker" export { CountdownTimer } from "./components/CountdownTimer"; export type { CountdownTimerProps } from "./components/CountdownTimer"; +export { + NotificationBell, + NotificationItem, + NotificationDrawer, + NotificationToast, +} from "./components/NotificationBell"; +export type { + NotificationBellProps, + NotificationItemProps, + NotificationDrawerProps, + NotificationToastProps, + NotificationItemData, + NotificationVisual, + ResolveNotificationVisual, +} from "./components/NotificationBell"; + export { Badge } from "./components/badge"; // export type { BadgeProps } from "./components/badge"; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b8ec8273f..0e278995a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -62,7 +62,7 @@ importers: version: 4.0.4(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(rxjs@7.8.2) '@nestjs/core': specifier: ^11.0.0 - version: 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/microservices@11.1.24)(@nestjs/platform-express@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2) + version: 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/microservices@11.1.24)(@nestjs/platform-express@11.1.24)(@nestjs/websockets@11.1.27)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nestjs/event-emitter': specifier: ^2.0.4 version: 2.1.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24) @@ -71,10 +71,13 @@ importers: version: 2.1.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2) '@nestjs/microservices': specifier: ^11.0.0 - version: 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(amqp-connection-manager@5.0.0(amqplib@2.0.1))(amqplib@2.0.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) + version: 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(@nestjs/websockets@11.1.27)(amqp-connection-manager@5.0.0(amqplib@2.0.1))(amqplib@2.0.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nestjs/platform-express': specifier: ^11.0.0 version: 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24) + '@nestjs/platform-socket.io': + specifier: ^11.1.27 + version: 11.1.27(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/websockets@11.1.27)(rxjs@7.8.2) '@nestjs/schedule': specifier: ^6.1.3 version: 6.1.3(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24) @@ -84,6 +87,9 @@ importers: '@nestjs/typeorm': specifier: ^11.0.1 version: 11.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2)(typeorm@0.3.30(babel-plugin-macros@3.1.0)(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3))) + '@nestjs/websockets': + specifier: ^11.1.27 + version: 11.1.27(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(@nestjs/platform-socket.io@11.1.27)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@tria-plc/api-common': specifier: file:../../local-packages/tria-plc-api-common-1.4.3.tgz version: file:local-packages/tria-plc-api-common-1.4.3.tgz(bad2eb10df48448775040459098de142) @@ -138,6 +144,9 @@ importers: rxjs: specifier: ^7.8.1 version: 7.8.2 + socket.io: + specifier: ^4.8.3 + version: 4.8.3 typeorm: specifier: ^0.3.30 version: 0.3.30(babel-plugin-macros@3.1.0)(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3)) @@ -268,6 +277,9 @@ importers: recharts: specifier: ^3.8.1 version: 3.8.1(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6)(redux@5.0.1) + socket.io-client: + specifier: ^4.8.3 + version: 4.8.3 sonner: specifier: ^2.0.7 version: 2.0.7(react-dom@19.2.6(react@19.2.6))(react@19.2.6) @@ -392,6 +404,9 @@ importers: recharts: specifier: ^3.8.1 version: 3.8.1(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6)(redux@5.0.1) + socket.io-client: + specifier: ^4.8.3 + version: 4.8.3 tailwind-merge: specifier: ^3.6.0 version: 3.6.0 @@ -467,13 +482,13 @@ importers: version: 4.0.4(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(rxjs@7.8.2) '@nestjs/core': specifier: ^11.1.19 - version: 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/microservices@11.1.24)(@nestjs/platform-express@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2) + version: 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/microservices@11.1.24)(@nestjs/platform-express@11.1.24)(@nestjs/websockets@11.1.27)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nestjs/event-emitter': specifier: ^2.0.4 version: 2.1.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24) '@nestjs/microservices': specifier: ^11.1.24 - version: 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(amqp-connection-manager@5.0.0(amqplib@2.0.1))(amqplib@2.0.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) + version: 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(@nestjs/websockets@11.1.27)(amqp-connection-manager@5.0.0(amqplib@2.0.1))(amqplib@2.0.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nestjs/platform-express': specifier: ^11.1.19 version: 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24) @@ -794,7 +809,7 @@ importers: version: 4.0.4(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(rxjs@7.8.2) '@nestjs/core': specifier: ^11.0.0 - version: 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/microservices@11.1.24)(@nestjs/platform-express@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2) + version: 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/microservices@11.1.24)(@nestjs/platform-express@11.1.24)(@nestjs/websockets@11.1.27)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nestjs/platform-express': specifier: ^11.0.0 version: 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24) @@ -895,7 +910,7 @@ importers: version: 11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nestjs/core': specifier: ^11.0.0 - version: 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/microservices@11.1.24)(@nestjs/platform-express@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2) + version: 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/microservices@11.1.24)(@nestjs/platform-express@11.1.24)(@nestjs/websockets@11.1.27)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@types/node': specifier: ^20.14.0 version: 20.19.42 @@ -2504,6 +2519,13 @@ packages: '@nestjs/common': ^11.0.0 '@nestjs/core': ^11.0.0 + '@nestjs/platform-socket.io@11.1.27': + resolution: {integrity: sha512-xgpLzaIDGOCC6xOAtHnRAz8sqieFgGxxu3MN5ID026Jt6oeL3efp29N5QHhPr7UlqBfy/Jd02uj0POkZq6Au3Q==} + peerDependencies: + '@nestjs/common': ^11.0.0 + '@nestjs/websockets': ^11.0.0 + rxjs: ^7.1.0 + '@nestjs/schedule@6.1.3': resolution: {integrity: sha512-RflMFOpR16Dwd1jAUbeB4mfGTCh65fvEdL4mSjQPJChpkRGRjIXjb+6YQcK2faQrVT60c9DmLmoVR7/ONCtuYQ==} peerDependencies: @@ -2582,6 +2604,18 @@ packages: rxjs: ^7.2.0 typeorm: ^0.3.0 || ^1.0.0-dev + '@nestjs/websockets@11.1.27': + resolution: {integrity: sha512-X3OgJt9KgYTvt9D7sNz9SOj3A1daAHy7DZrYhM1pky8Fh+erlKQH5IQ/tKm+GaJKA5M0srBUr1CMqjak/qNxOw==} + peerDependencies: + '@nestjs/common': ^11.0.0 + '@nestjs/core': ^11.0.0 + '@nestjs/platform-socket.io': ^11.0.0 + reflect-metadata: ^0.1.12 || ^0.2.0 + rxjs: ^7.1.0 + peerDependenciesMeta: + '@nestjs/platform-socket.io': + optional: true + '@next/env@14.2.35': resolution: {integrity: sha512-DuhvCtj4t9Gwrx80dmz2F4t/zKQ4ktN8WrMwOuVzkJfBilwAwGr6v16M5eI8yCuZ63H9TTuEU09Iu2HqkzFPVQ==} @@ -4215,6 +4249,9 @@ packages: '@types/cookiejar@2.1.5': resolution: {integrity: sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==} + '@types/cors@2.8.19': + resolution: {integrity: sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==} + '@types/d3-array@3.2.2': resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==} @@ -4417,6 +4454,9 @@ packages: '@types/vorpal@1.12.8': resolution: {integrity: sha512-Qt+Yxa1q6QCaYMxZFXlyPOF3ktIscTelNr1AFYuKM7/Dhlki4gvc476uFyA/hYvskSA6V8W+55x9FjlbAPcYdQ==} + '@types/ws@8.18.1': + resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} + '@types/yargs-parser@21.0.3': resolution: {integrity: sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==} @@ -5264,6 +5304,10 @@ packages: base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + base64id@2.0.0: + resolution: {integrity: sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==} + engines: {node: ^4.5.0 || >= 5.9} + base@0.11.2: resolution: {integrity: sha512-5T6P4xPgpp0YDFvSWwEZ4NoE3aM4QBQXDzmVbraCkFj8zHM+mba8SyqB5DbZWyR7mYHo6Y7BdQo3MoA4m0TeQg==} engines: {node: '>=0.10.0'} @@ -6245,6 +6289,10 @@ packages: resolution: {integrity: sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==} engines: {node: '>=10.0.0'} + engine.io@6.6.9: + resolution: {integrity: sha512-clKkw4C7nJ22mGgoVcCg6V/W/TxdNyIOTr89k2ONZu81qqkddPFDF0LXcbAwhzPD8DjkiRCjzuiO6Y+fkpD4vg==} + engines: {node: '>=10.2.0'} + enhanced-resolve@5.23.0: resolution: {integrity: sha512-yJN/BOOLxcOW2aQgeif9mSnaUB8KtvmMMp56oA1kx1CRfBKbhZm2pJ+NBY+3eOboHxix8lfjWpHE0Ei5U8RbSA==} engines: {node: '>=10.13.0'} @@ -9987,6 +10035,9 @@ packages: resolution: {integrity: sha512-FtyOnWN/wCHTVXOMwvSv26d+ko5vWlIDD6zoUJ7LW8vh+ZBC8QdljveRP+crNrtBwioEUWy/4dMtbBjA4ioNlg==} engines: {node: '>=0.10.0'} + socket.io-adapter@2.5.8: + resolution: {integrity: sha512-6Oy52pbg+kvdCVvjcN+FnY7BvxZ7cIHNScbvztT/It5d0vbwoJoVZmF2gjJmnV0/4WlXRfG15zc45ySk9Ah8bw==} + socket.io-client@4.8.3: resolution: {integrity: sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g==} engines: {node: '>=10.0.0'} @@ -9995,6 +10046,10 @@ packages: resolution: {integrity: sha512-asJqbVBDsBCJx0pTqw3WfesSY0iRX+2xzWEWzrpcH7L6fLzrhyF8WPI8UaeM4YCuDfpwA/cgsdugMsmtz8EJeg==} engines: {node: '>=10.0.0'} + socket.io@4.8.3: + resolution: {integrity: sha512-2Dd78bqzzjE6KPkD5fHZmDAKRNe3J15q+YHDrIsy9WEkqttc7GY+kT9OBLSMaPbQaEd0x1BjcmtMtXkfpc+T5A==} + engines: {node: '>=10.2.0'} + socks-proxy-agent@8.0.5: resolution: {integrity: sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==} engines: {node: '>= 14'} @@ -12119,14 +12174,14 @@ snapshots: '@golevelup/nestjs-discovery@4.0.3(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)': dependencies: '@nestjs/common': 11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/core': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/microservices@11.1.24)(@nestjs/platform-express@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/microservices@11.1.24)(@nestjs/platform-express@11.1.24)(@nestjs/websockets@11.1.27)(reflect-metadata@0.2.2)(rxjs@7.8.2) lodash: 4.18.1 '@golevelup/nestjs-rabbitmq@5.7.0(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2)': dependencies: '@golevelup/nestjs-discovery': 4.0.3(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24) '@nestjs/common': 11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/core': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/microservices@11.1.24)(@nestjs/platform-express@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/microservices@11.1.24)(@nestjs/platform-express@11.1.24)(@nestjs/websockets@11.1.27)(reflect-metadata@0.2.2)(rxjs@7.8.2) amqp-connection-manager: 4.1.15(amqplib@0.10.9) amqplib: 0.10.9 lodash: 4.18.1 @@ -12979,7 +13034,7 @@ snapshots: lodash: 4.18.1 rxjs: 7.8.2 - '@nestjs/core@11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/microservices@11.1.24)(@nestjs/platform-express@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2)': + '@nestjs/core@11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/microservices@11.1.24)(@nestjs/platform-express@11.1.24)(@nestjs/websockets@11.1.27)(reflect-metadata@0.2.2)(rxjs@7.8.2)': dependencies: '@nestjs/common': 11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nuxt/opencollective': 0.4.1 @@ -12991,13 +13046,14 @@ snapshots: tslib: 2.8.1 uid: 2.0.2 optionalDependencies: - '@nestjs/microservices': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(amqp-connection-manager@5.0.0(amqplib@2.0.1))(amqplib@2.0.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/microservices': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(@nestjs/websockets@11.1.27)(amqp-connection-manager@5.0.0(amqplib@2.0.1))(amqplib@2.0.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nestjs/platform-express': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24) + '@nestjs/websockets': 11.1.27(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(@nestjs/platform-socket.io@11.1.27)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nestjs/event-emitter@2.1.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)': dependencies: '@nestjs/common': 11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/core': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/microservices@11.1.24)(@nestjs/platform-express@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/microservices@11.1.24)(@nestjs/platform-express@11.1.24)(@nestjs/websockets@11.1.27)(reflect-metadata@0.2.2)(rxjs@7.8.2) eventemitter2: 6.4.9 '@nestjs/jwt@10.2.0(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))': @@ -13022,15 +13078,16 @@ snapshots: class-transformer: 0.5.1 class-validator: 0.14.4 - '@nestjs/microservices@11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(amqp-connection-manager@5.0.0(amqplib@2.0.1))(amqplib@2.0.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)': + '@nestjs/microservices@11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(@nestjs/websockets@11.1.27)(amqp-connection-manager@5.0.0(amqplib@2.0.1))(amqplib@2.0.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)': dependencies: '@nestjs/common': 11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/core': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/microservices@11.1.24)(@nestjs/platform-express@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/microservices@11.1.24)(@nestjs/platform-express@11.1.24)(@nestjs/websockets@11.1.27)(reflect-metadata@0.2.2)(rxjs@7.8.2) iterare: 1.2.1 reflect-metadata: 0.2.2 rxjs: 7.8.2 tslib: 2.8.1 optionalDependencies: + '@nestjs/websockets': 11.1.27(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(@nestjs/platform-socket.io@11.1.27)(reflect-metadata@0.2.2)(rxjs@7.8.2) amqp-connection-manager: 5.0.0(amqplib@2.0.1) amqplib: 2.0.1 @@ -13042,7 +13099,7 @@ snapshots: '@nestjs/platform-express@11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)': dependencies: '@nestjs/common': 11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/core': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/microservices@11.1.24)(@nestjs/platform-express@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/microservices@11.1.24)(@nestjs/platform-express@11.1.24)(@nestjs/websockets@11.1.27)(reflect-metadata@0.2.2)(rxjs@7.8.2) cors: 2.8.6 express: 5.2.1 multer: 2.1.1 @@ -13051,10 +13108,22 @@ snapshots: transitivePeerDependencies: - supports-color + '@nestjs/platform-socket.io@11.1.27(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/websockets@11.1.27)(rxjs@7.8.2)': + dependencies: + '@nestjs/common': 11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/websockets': 11.1.27(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(@nestjs/platform-socket.io@11.1.27)(reflect-metadata@0.2.2)(rxjs@7.8.2) + rxjs: 7.8.2 + socket.io: 4.8.3 + tslib: 2.8.1 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + '@nestjs/schedule@6.1.3(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)': dependencies: '@nestjs/common': 11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/core': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/microservices@11.1.24)(@nestjs/platform-express@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/microservices@11.1.24)(@nestjs/platform-express@11.1.24)(@nestjs/websockets@11.1.27)(reflect-metadata@0.2.2)(rxjs@7.8.2) cron: 4.4.0 '@nestjs/schematics@11.1.0(chokidar@4.0.3)(prettier@3.8.3)(typescript@5.9.3)': @@ -13074,7 +13143,7 @@ snapshots: dependencies: '@microsoft/tsdoc': 0.16.0 '@nestjs/common': 11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/core': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/microservices@11.1.24)(@nestjs/platform-express@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/microservices@11.1.24)(@nestjs/platform-express@11.1.24)(@nestjs/websockets@11.1.27)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nestjs/mapped-types': 2.1.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2) js-yaml: 4.1.1 lodash: 4.18.1 @@ -13089,7 +13158,7 @@ snapshots: dependencies: '@microsoft/tsdoc': 0.15.1 '@nestjs/common': 11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/core': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/microservices@11.1.24)(@nestjs/platform-express@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/microservices@11.1.24)(@nestjs/platform-express@11.1.24)(@nestjs/websockets@11.1.27)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nestjs/mapped-types': 2.0.5(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2) js-yaml: 4.1.0 lodash: 4.17.21 @@ -13103,26 +13172,38 @@ snapshots: '@nestjs/testing@11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(@nestjs/microservices@11.1.24)(@nestjs/platform-express@11.1.24)': dependencies: '@nestjs/common': 11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/core': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/microservices@11.1.24)(@nestjs/platform-express@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/microservices@11.1.24)(@nestjs/platform-express@11.1.24)(@nestjs/websockets@11.1.27)(reflect-metadata@0.2.2)(rxjs@7.8.2) tslib: 2.8.1 optionalDependencies: - '@nestjs/microservices': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(amqp-connection-manager@5.0.0(amqplib@2.0.1))(amqplib@2.0.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/microservices': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(@nestjs/websockets@11.1.27)(amqp-connection-manager@5.0.0(amqplib@2.0.1))(amqplib@2.0.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nestjs/platform-express': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24) '@nestjs/throttler@6.5.0(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2)': dependencies: '@nestjs/common': 11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/core': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/microservices@11.1.24)(@nestjs/platform-express@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/microservices@11.1.24)(@nestjs/platform-express@11.1.24)(@nestjs/websockets@11.1.27)(reflect-metadata@0.2.2)(rxjs@7.8.2) reflect-metadata: 0.2.2 '@nestjs/typeorm@11.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2)(typeorm@0.3.30(babel-plugin-macros@3.1.0)(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3)))': dependencies: '@nestjs/common': 11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/core': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/microservices@11.1.24)(@nestjs/platform-express@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/microservices@11.1.24)(@nestjs/platform-express@11.1.24)(@nestjs/websockets@11.1.27)(reflect-metadata@0.2.2)(rxjs@7.8.2) reflect-metadata: 0.2.2 rxjs: 7.8.2 typeorm: 0.3.30(babel-plugin-macros@3.1.0)(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3)) + '@nestjs/websockets@11.1.27(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(@nestjs/platform-socket.io@11.1.27)(reflect-metadata@0.2.2)(rxjs@7.8.2)': + dependencies: + '@nestjs/common': 11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/microservices@11.1.24)(@nestjs/platform-express@11.1.24)(@nestjs/websockets@11.1.27)(reflect-metadata@0.2.2)(rxjs@7.8.2) + iterare: 1.2.1 + object-hash: 3.0.0 + reflect-metadata: 0.2.2 + rxjs: 7.8.2 + tslib: 2.8.1 + optionalDependencies: + '@nestjs/platform-socket.io': 11.1.27(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/websockets@11.1.27)(rxjs@7.8.2) + '@next/env@14.2.35': {} '@next/eslint-plugin-next@14.2.35': @@ -15424,9 +15505,9 @@ snapshots: dependencies: '@nestjs/axios': 4.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(axios@1.17.0)(rxjs@7.8.2) '@nestjs/common': 11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/core': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/microservices@11.1.24)(@nestjs/platform-express@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/microservices@11.1.24)(@nestjs/platform-express@11.1.24)(@nestjs/websockets@11.1.27)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nestjs/jwt': 10.2.0(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)) - '@nestjs/microservices': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(amqp-connection-manager@5.0.0(amqplib@2.0.1))(amqplib@2.0.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/microservices': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(@nestjs/websockets@11.1.27)(amqp-connection-manager@5.0.0(amqplib@2.0.1))(amqplib@2.0.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nestjs/passport': 10.0.3(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(passport@0.7.0) '@nestjs/swagger': 11.4.4(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2) '@nestjs/throttler': 6.5.0(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2) @@ -15468,9 +15549,9 @@ snapshots: dependencies: '@nestjs/axios': 4.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(axios@1.17.0)(rxjs@7.8.2) '@nestjs/common': 11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/core': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/microservices@11.1.24)(@nestjs/platform-express@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/microservices@11.1.24)(@nestjs/platform-express@11.1.24)(@nestjs/websockets@11.1.27)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nestjs/jwt': 10.2.0(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)) - '@nestjs/microservices': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(amqp-connection-manager@5.0.0(amqplib@2.0.1))(amqplib@2.0.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/microservices': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(@nestjs/websockets@11.1.27)(amqp-connection-manager@5.0.0(amqplib@2.0.1))(amqplib@2.0.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nestjs/passport': 10.0.3(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(passport@0.7.0) '@nestjs/swagger': 11.4.4(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2) '@nestjs/throttler': 6.5.0(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2) @@ -15739,6 +15820,10 @@ snapshots: '@types/cookiejar@2.1.5': {} + '@types/cors@2.8.19': + dependencies: + '@types/node': 20.19.42 + '@types/d3-array@3.2.2': {} '@types/d3-color@3.1.3': {} @@ -15961,6 +16046,10 @@ snapshots: '@types/vorpal@1.12.8': {} + '@types/ws@8.18.1': + dependencies: + '@types/node': 20.19.42 + '@types/yargs-parser@21.0.3': {} '@types/yargs@17.0.35': @@ -16906,6 +16995,8 @@ snapshots: base64-js@1.5.1: {} + base64id@2.0.0: {} + base@0.11.2: dependencies: cache-base: 1.0.1 @@ -17893,6 +17984,23 @@ snapshots: engine.io-parser@5.2.3: {} + engine.io@6.6.9: + dependencies: + '@types/cors': 2.8.19 + '@types/node': 20.19.42 + '@types/ws': 8.18.1 + accepts: 1.3.8 + base64id: 2.0.0 + cookie: 0.7.2 + cors: 2.8.6 + debug: 4.4.3(supports-color@5.5.0) + engine.io-parser: 5.2.3 + ws: 8.21.0 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + enhanced-resolve@5.23.0: dependencies: graceful-fs: 4.2.11 @@ -20704,7 +20812,7 @@ snapshots: nestjs-minio-client@2.2.0(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24): dependencies: '@nestjs/common': 11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/core': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/microservices@11.1.24)(@nestjs/platform-express@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/microservices@11.1.24)(@nestjs/platform-express@11.1.24)(@nestjs/websockets@11.1.27)(reflect-metadata@0.2.2)(rxjs@7.8.2) minio: 7.1.3 reflect-metadata: 0.1.14 rxjs: 7.8.2 @@ -22425,6 +22533,15 @@ snapshots: transitivePeerDependencies: - supports-color + socket.io-adapter@2.5.8: + dependencies: + debug: 4.4.3(supports-color@5.5.0) + ws: 8.21.0 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + socket.io-client@4.8.3: dependencies: '@socket.io/component-emitter': 3.1.2 @@ -22443,6 +22560,20 @@ snapshots: transitivePeerDependencies: - supports-color + socket.io@4.8.3: + dependencies: + accepts: 1.3.8 + base64id: 2.0.0 + cors: 2.8.6 + debug: 4.4.3(supports-color@5.5.0) + engine.io: 6.6.9 + socket.io-adapter: 2.5.8 + socket.io-parser: 4.2.6 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + socks-proxy-agent@8.0.5: dependencies: agent-base: 7.1.4