mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 17:45:42 +00:00
Merge pull request #482 from Tria-plc/freight/feat/fixes-v1
Central notification system with inapp notfication using socket.io
This commit is contained in:
@@ -50,9 +50,11 @@
|
|||||||
"@nestjs/mapped-types": "^2.1.1",
|
"@nestjs/mapped-types": "^2.1.1",
|
||||||
"@nestjs/microservices": "^11.0.0",
|
"@nestjs/microservices": "^11.0.0",
|
||||||
"@nestjs/platform-express": "^11.0.0",
|
"@nestjs/platform-express": "^11.0.0",
|
||||||
|
"@nestjs/platform-socket.io": "^11.1.27",
|
||||||
"@nestjs/schedule": "^6.1.3",
|
"@nestjs/schedule": "^6.1.3",
|
||||||
"@nestjs/swagger": "^11.4.2",
|
"@nestjs/swagger": "^11.4.2",
|
||||||
"@nestjs/typeorm": "^11.0.1",
|
"@nestjs/typeorm": "^11.0.1",
|
||||||
|
"@nestjs/websockets": "^11.1.27",
|
||||||
"@tria-plc/api-common": "file:../../local-packages/tria-plc-api-common-1.4.3.tgz",
|
"@tria-plc/api-common": "file:../../local-packages/tria-plc-api-common-1.4.3.tgz",
|
||||||
"@tria-plc/iamapi-common": "file:../../local-packages/tria-plc-iamapi-common-0.7.7.tgz",
|
"@tria-plc/iamapi-common": "file:../../local-packages/tria-plc-iamapi-common-0.7.7.tgz",
|
||||||
"amqp-connection-manager": "^5.0.0",
|
"amqp-connection-manager": "^5.0.0",
|
||||||
@@ -71,6 +73,7 @@
|
|||||||
"puppeteer": "^24.2.0",
|
"puppeteer": "^24.2.0",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
"rxjs": "^7.8.1",
|
"rxjs": "^7.8.1",
|
||||||
|
"socket.io": "^4.8.3",
|
||||||
"typeorm": "^0.3.30"
|
"typeorm": "^0.3.30"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ import { CompaniesModule } from "./modules/companies/companies.module";
|
|||||||
import { TrackingModule } from "./modules/tracking/tracking.module";
|
import { TrackingModule } from "./modules/tracking/tracking.module";
|
||||||
import { BillingModule } from "./modules/billing/billing.module";
|
import { BillingModule } from "./modules/billing/billing.module";
|
||||||
import { NotificationsModule } from "./modules/notifications/notifications.module";
|
import { NotificationsModule } from "./modules/notifications/notifications.module";
|
||||||
|
import { NotificationInboxModule } from "./modules/notification-inbox/notification-inbox.module";
|
||||||
import { FileUploadSettingsModule } from "./modules/file-upload-settings/file-upload-settings.module";
|
import { FileUploadSettingsModule } from "./modules/file-upload-settings/file-upload-settings.module";
|
||||||
import { DropdownSettingsModule } from "./modules/dropdown-settings/dropdown-settings.module";
|
import { DropdownSettingsModule } from "./modules/dropdown-settings/dropdown-settings.module";
|
||||||
import { OtpModule } from "./modules/otp/otp.module";
|
import { OtpModule } from "./modules/otp/otp.module";
|
||||||
@@ -126,6 +127,7 @@ import { LoggerMiddleware } from "./logger.middleware";
|
|||||||
TrackingModule,
|
TrackingModule,
|
||||||
BillingModule,
|
BillingModule,
|
||||||
NotificationsModule,
|
NotificationsModule,
|
||||||
|
NotificationInboxModule,
|
||||||
FileUploadSettingsModule,
|
FileUploadSettingsModule,
|
||||||
DropdownSettingsModule,
|
DropdownSettingsModule,
|
||||||
OtpModule,
|
OtpModule,
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* In-app notification inbox. One row per recipient per logical notification;
|
||||||
|
* producers fan out by inserting many rows. Indexed for the two hot queries:
|
||||||
|
* unread-count (recipient + is_read) and the newest-first list (recipient +
|
||||||
|
* created_at). Enum-like columns are stored as varchar to avoid PG enum churn.
|
||||||
|
*/
|
||||||
|
export class CreateNotifications1950000000000 implements MigrationInterface {
|
||||||
|
name = "CreateNotifications1950000000000";
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS freight.notifications (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
recipient_user_id uuid NOT NULL,
|
||||||
|
audience varchar(20) NOT NULL,
|
||||||
|
type varchar(48) NOT NULL DEFAULT 'GENERIC',
|
||||||
|
title varchar(200) NOT NULL,
|
||||||
|
body text NOT NULL,
|
||||||
|
link varchar,
|
||||||
|
data jsonb,
|
||||||
|
priority varchar(12) NOT NULL DEFAULT 'NORMAL',
|
||||||
|
is_read boolean NOT NULL DEFAULT false,
|
||||||
|
read_at timestamptz,
|
||||||
|
channels_sent jsonb,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
deleted_at timestamptz
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE INDEX IF NOT EXISTS "IDX_NOTIFICATIONS_RECIPIENT_UNREAD"
|
||||||
|
ON freight.notifications (recipient_user_id, is_read)
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE INDEX IF NOT EXISTS "IDX_NOTIFICATIONS_RECIPIENT_CREATED"
|
||||||
|
ON freight.notifications (recipient_user_id, created_at)
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(
|
||||||
|
`DROP INDEX IF EXISTS freight."IDX_NOTIFICATIONS_RECIPIENT_CREATED"`,
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`DROP INDEX IF EXISTS freight."IDX_NOTIFICATIONS_RECIPIENT_UNREAD"`,
|
||||||
|
);
|
||||||
|
await queryRunner.query(`DROP TABLE IF EXISTS freight.notifications`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -33,6 +33,11 @@ import { ETradeService } from "./services/etrade.service";
|
|||||||
CompanyDashboardRepository,
|
CompanyDashboardRepository,
|
||||||
ETradeService,
|
ETradeService,
|
||||||
],
|
],
|
||||||
exports: [CompaniesService],
|
exports: [
|
||||||
|
CompaniesService,
|
||||||
|
// Consumed by NotificationInboxModule for portal recipient targeting.
|
||||||
|
ExternalProfileRepository,
|
||||||
|
CompanyProfileRepository,
|
||||||
|
],
|
||||||
})
|
})
|
||||||
export class CompaniesModule { }
|
export class CompaniesModule { }
|
||||||
|
|||||||
@@ -334,9 +334,28 @@ export class CompaniesService {
|
|||||||
const company = await this.companiesRepo.findById(id);
|
const company = await this.companiesRepo.findById(id);
|
||||||
if (!company) throw new NotFoundException(`Company ${id} not found`);
|
if (!company) throw new NotFoundException(`Company ${id} not found`);
|
||||||
company.companyProfiles = await this.companyProfilesRepo.findByCompanyId(id);
|
company.companyProfiles = await this.companyProfilesRepo.findByCompanyId(id);
|
||||||
|
for (const profile of company.companyProfiles) {
|
||||||
|
profile.businessLicenseFiles = await this.signLicenseFiles(
|
||||||
|
profile.businessLicenseFiles,
|
||||||
|
);
|
||||||
|
}
|
||||||
return company;
|
return company;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Business-license files are stored as raw, unsigned MinIO URLs (see
|
||||||
|
* `BusinessLicenseFile` on `CompanyProfile`) — a browser can't fetch them
|
||||||
|
* directly. Sign each one with a short-lived URL before it reaches a response.
|
||||||
|
*/
|
||||||
|
private async signLicenseFiles(
|
||||||
|
files?: BusinessLicenseFile[] | null,
|
||||||
|
): Promise<BusinessLicenseFile[]> {
|
||||||
|
if (!files?.length) return [];
|
||||||
|
return Promise.all(
|
||||||
|
files.map(async (f) => ({ ...f, url: await this.filesService.signUrl(f.url) })),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validate an explicitly-chosen company profile for a booking: it must belong
|
* Validate an explicitly-chosen company profile for a booking: it must belong
|
||||||
* to the booking's company and be Active. Used for government bookings (staff
|
* to the booking's company and be Active. Used for government bookings (staff
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { ApiPropertyOptional } from "@nestjs/swagger";
|
||||||
|
import { Transform, Type } from "class-transformer";
|
||||||
|
import { IsBoolean, IsInt, IsOptional, Max, Min } from "class-validator";
|
||||||
|
|
||||||
|
export class ListNotificationsQueryDto {
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: "Filter by read state. Omit to return all.",
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@Transform(({ value }) =>
|
||||||
|
value === "true" ? true : value === "false" ? false : value,
|
||||||
|
)
|
||||||
|
@IsBoolean()
|
||||||
|
isRead?: boolean;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ minimum: 1, default: 1 })
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
page?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ minimum: 1, maximum: 100, default: 20 })
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
@Max(100)
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { BaseEntity } from "@edr/api-common";
|
||||||
|
import {
|
||||||
|
NotificationAudience,
|
||||||
|
NotificationChannelsSent,
|
||||||
|
NotificationPriority,
|
||||||
|
NotificationType,
|
||||||
|
} from "@edr/types";
|
||||||
|
import { Column, Entity, Index } from "typeorm";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A single persisted in-app notification addressed to one IAM user. Producers
|
||||||
|
* fan a logical notification out to N recipients by inserting one row per
|
||||||
|
* resolved user id (see NotificationInboxService.notify).
|
||||||
|
*/
|
||||||
|
@Entity({ schema: "freight", name: "notifications" })
|
||||||
|
@Index("IDX_NOTIFICATIONS_RECIPIENT_UNREAD", ["recipientUserId", "isRead"])
|
||||||
|
@Index("IDX_NOTIFICATIONS_RECIPIENT_CREATED", ["recipientUserId", "createdAt"])
|
||||||
|
export class Notification extends BaseEntity {
|
||||||
|
@Column({ name: "recipient_user_id", type: "uuid" })
|
||||||
|
recipientUserId!: string;
|
||||||
|
|
||||||
|
@Column({ name: "audience", type: "varchar", length: 20 })
|
||||||
|
audience!: NotificationAudience;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
name: "type",
|
||||||
|
type: "varchar",
|
||||||
|
length: 48,
|
||||||
|
default: NotificationType.GENERIC,
|
||||||
|
})
|
||||||
|
type!: NotificationType;
|
||||||
|
|
||||||
|
@Column({ name: "title", type: "varchar", length: 200 })
|
||||||
|
title!: string;
|
||||||
|
|
||||||
|
@Column({ name: "body", type: "text" })
|
||||||
|
body!: string;
|
||||||
|
|
||||||
|
/** Deep-link path within the app the item points to (e.g. `/contracts/:id`). */
|
||||||
|
@Column({ name: "link", type: "varchar", nullable: true })
|
||||||
|
link?: string | null;
|
||||||
|
|
||||||
|
/** Arbitrary structured payload (bookingId, invoiceId, contractId, …). */
|
||||||
|
@Column({ name: "data", type: "jsonb", nullable: true })
|
||||||
|
data?: Record<string, unknown> | null;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
name: "priority",
|
||||||
|
type: "varchar",
|
||||||
|
length: 12,
|
||||||
|
default: NotificationPriority.NORMAL,
|
||||||
|
})
|
||||||
|
priority!: NotificationPriority;
|
||||||
|
|
||||||
|
@Column({ name: "is_read", type: "boolean", default: false })
|
||||||
|
isRead!: boolean;
|
||||||
|
|
||||||
|
@Column({ name: "read_at", type: "timestamptz", nullable: true })
|
||||||
|
readAt?: Date | null;
|
||||||
|
|
||||||
|
/** Per-channel fan-out outcome for HIGH-priority items (email/SMS). */
|
||||||
|
@Column({ name: "channels_sent", type: "jsonb", nullable: true })
|
||||||
|
channelsSent?: NotificationChannelsSent | null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,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 ?? {});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { Module } from "@nestjs/common";
|
||||||
|
import { TypeOrmModule } from "@nestjs/typeorm";
|
||||||
|
import { Session } from "@tria-plc/iamapi-common/entities/iam/user/session.entity";
|
||||||
|
import { User } from "@tria-plc/iamapi-common/entities/iam/user/user.entity";
|
||||||
|
|
||||||
|
import { BackofficeModule } from "../backoffice/backoffice.module";
|
||||||
|
import { CompaniesModule } from "../companies/companies.module";
|
||||||
|
import { NotificationsModule } from "../notifications/notifications.module";
|
||||||
|
import { Notification } from "./entities/notification.entity";
|
||||||
|
import { NotificationInboxController } from "./notification-inbox.controller";
|
||||||
|
import { NotificationInboxRepository } from "./notification-inbox.repository";
|
||||||
|
import { NotificationInboxService } from "./notification-inbox.service";
|
||||||
|
import { NotificationRecipientsService } from "./notification-recipients.service";
|
||||||
|
import { NotificationsGateway } from "./notifications.gateway";
|
||||||
|
import { WsAuthService } from "./ws-auth.service";
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
TypeOrmModule.forFeature([Notification, User, Session]),
|
||||||
|
// ExternalProfileRepository + CompanyProfileRepository (portal targeting)
|
||||||
|
CompaniesModule,
|
||||||
|
// BackofficeService.getOrganizationEmployees (staff targeting)
|
||||||
|
BackofficeModule,
|
||||||
|
// EmailClientService + SmsClientService (HIGH-priority fan-out)
|
||||||
|
NotificationsModule,
|
||||||
|
],
|
||||||
|
controllers: [NotificationInboxController],
|
||||||
|
providers: [
|
||||||
|
NotificationInboxRepository,
|
||||||
|
NotificationRecipientsService,
|
||||||
|
NotificationsGateway,
|
||||||
|
WsAuthService,
|
||||||
|
NotificationInboxService,
|
||||||
|
],
|
||||||
|
exports: [NotificationInboxService],
|
||||||
|
})
|
||||||
|
export class NotificationInboxModule {}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { BaseRepository } from "@edr/api-common";
|
||||||
|
import { Injectable } from "@nestjs/common";
|
||||||
|
import { InjectRepository } from "@nestjs/typeorm";
|
||||||
|
import { FindOptionsWhere, Repository } from "typeorm";
|
||||||
|
|
||||||
|
import { Notification } from "./entities/notification.entity";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class NotificationInboxRepository extends BaseRepository<Notification> {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(Notification)
|
||||||
|
repo: Repository<Notification>,
|
||||||
|
) {
|
||||||
|
super(repo);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Newest-first page of a recipient's notifications, optionally read-filtered. */
|
||||||
|
async findForRecipient(
|
||||||
|
userId: string,
|
||||||
|
opts: { page?: number; limit?: number; isRead?: boolean } = {},
|
||||||
|
): Promise<[Notification[], number]> {
|
||||||
|
const page = opts.page && opts.page > 0 ? opts.page : 1;
|
||||||
|
const limit = opts.limit && opts.limit > 0 ? opts.limit : 20;
|
||||||
|
const where: FindOptionsWhere<Notification> = { recipientUserId: userId };
|
||||||
|
if (typeof opts.isRead === "boolean") {
|
||||||
|
where.isRead = opts.isRead;
|
||||||
|
}
|
||||||
|
return this.repository.findAndCount({
|
||||||
|
where,
|
||||||
|
order: { createdAt: "DESC" },
|
||||||
|
skip: (page - 1) * limit,
|
||||||
|
take: limit,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async countUnread(userId: string): Promise<number> {
|
||||||
|
return this.repository.count({
|
||||||
|
where: { recipientUserId: userId, isRead: false },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mark a single notification read (scoped to its recipient). Returns true if it changed. */
|
||||||
|
async markRead(id: string, userId: string): Promise<boolean> {
|
||||||
|
const result = await this.repository.update(
|
||||||
|
{ id, recipientUserId: userId, isRead: false },
|
||||||
|
{ isRead: true, readAt: new Date() },
|
||||||
|
);
|
||||||
|
return (result.affected ?? 0) > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mark all of a recipient's unread notifications read. Returns the count updated. */
|
||||||
|
async markAllRead(userId: string): Promise<number> {
|
||||||
|
const result = await this.repository.update(
|
||||||
|
{ recipientUserId: userId, isRead: false },
|
||||||
|
{ isRead: true, readAt: new Date() },
|
||||||
|
);
|
||||||
|
return result.affected ?? 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,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<User>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fan a logical notification out to every resolved recipient: persist one row
|
||||||
|
* each, push it live over WebSocket, and (for HIGH priority) also queue
|
||||||
|
* email/SMS via the existing clients.
|
||||||
|
*/
|
||||||
|
async notify(input: NotifyInput): Promise<void> {
|
||||||
|
try {
|
||||||
|
const userIds = await this.recipients.resolve(input.recipients);
|
||||||
|
if (userIds.length === 0) {
|
||||||
|
this.logger.debug(
|
||||||
|
`notify(${input.type}) resolved 0 recipients — skipped`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const priority = input.priority ?? NotificationPriority.NORMAL;
|
||||||
|
|
||||||
|
for (const userId of userIds) {
|
||||||
|
await this.deliverToUser(userId, input, priority);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.error(
|
||||||
|
`notify failed: ${(err as Error).message}`,
|
||||||
|
(err as Error).stack,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async list(
|
||||||
|
userId: string,
|
||||||
|
query: ListNotificationsQueryDto,
|
||||||
|
): Promise<NotificationListResult> {
|
||||||
|
const [items, count] = await this.repo.findForRecipient(userId, {
|
||||||
|
page: query.page,
|
||||||
|
limit: query.limit,
|
||||||
|
isRead: query.isRead,
|
||||||
|
});
|
||||||
|
const unreadCount = await this.repo.countUnread(userId);
|
||||||
|
return { items: items.map((n) => this.toDto(n)), count, unreadCount };
|
||||||
|
}
|
||||||
|
|
||||||
|
async unreadCount(userId: string): Promise<{ unreadCount: number }> {
|
||||||
|
return { unreadCount: await this.repo.countUnread(userId) };
|
||||||
|
}
|
||||||
|
|
||||||
|
async markRead(
|
||||||
|
id: string,
|
||||||
|
userId: string,
|
||||||
|
): Promise<{ success: boolean; unreadCount: number }> {
|
||||||
|
const success = await this.repo.markRead(id, userId);
|
||||||
|
const unreadCount = await this.repo.countUnread(userId);
|
||||||
|
this.gateway.emitUnreadCount(userId, unreadCount);
|
||||||
|
return { success, unreadCount };
|
||||||
|
}
|
||||||
|
|
||||||
|
async markAllRead(
|
||||||
|
userId: string,
|
||||||
|
): Promise<{ updated: number; unreadCount: number }> {
|
||||||
|
const updated = await this.repo.markAllRead(userId);
|
||||||
|
const unreadCount = await this.repo.countUnread(userId);
|
||||||
|
this.gateway.emitUnreadCount(userId, unreadCount);
|
||||||
|
return { updated, unreadCount };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** [dev/verification only] Send a canned notification straight to one user. */
|
||||||
|
async sendTestToUser(
|
||||||
|
userId: string,
|
||||||
|
body: {
|
||||||
|
audience?: NotificationAudience;
|
||||||
|
type?: NotificationType;
|
||||||
|
priority?: NotificationPriority;
|
||||||
|
title?: string;
|
||||||
|
message?: string;
|
||||||
|
},
|
||||||
|
): Promise<NotificationDto> {
|
||||||
|
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<void> {
|
||||||
|
const entity = await this.repo.create({
|
||||||
|
recipientUserId: userId,
|
||||||
|
audience: input.audience,
|
||||||
|
type: input.type,
|
||||||
|
title: input.title,
|
||||||
|
body: input.body,
|
||||||
|
link: input.link ?? null,
|
||||||
|
data: input.data ?? null,
|
||||||
|
priority,
|
||||||
|
isRead: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const unreadCount = await this.repo.countUnread(userId);
|
||||||
|
this.gateway.emitNew(userId, this.toDto(entity), unreadCount);
|
||||||
|
|
||||||
|
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<NotificationChannels> {
|
||||||
|
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<NotificationChannels>,
|
||||||
|
): Promise<NotificationChannelsSent | null> {
|
||||||
|
try {
|
||||||
|
const user = await this.users.findOne({
|
||||||
|
where: { id: userId } as never,
|
||||||
|
});
|
||||||
|
if (!user) return null;
|
||||||
|
|
||||||
|
const sent: NotificationChannelsSent = {};
|
||||||
|
const text = `${input.title}\n\n${input.body}`;
|
||||||
|
|
||||||
|
if (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(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { NotificationRecipients } from "@edr/types";
|
||||||
|
import { Injectable, Logger } from "@nestjs/common";
|
||||||
|
|
||||||
|
import { BackofficeService } from "../backoffice/backoffice.service";
|
||||||
|
import { CompanyProfileRepository } from "../companies/company-profile.repository";
|
||||||
|
import { ExternalProfileRepository } from "../companies/external-profile.repository";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Turns a {@link NotificationRecipients} selector into a de-duplicated set of
|
||||||
|
* IAM user ids.
|
||||||
|
*
|
||||||
|
* - `userIds` → honored as-is.
|
||||||
|
* - `companyId` → all portal users linked to the company (external_profiles).
|
||||||
|
* - `companyProfileId` → resolved to its company, then to that company's users.
|
||||||
|
* - `organizationId` → all current employees of the org (backoffice staff).
|
||||||
|
*
|
||||||
|
* NOTE: permission-scoped staff targeting is intentionally unsupported — freight
|
||||||
|
* has no "users-by-permission" lookup. Target explicit userIds or an org instead.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class NotificationRecipientsService {
|
||||||
|
private readonly logger = new Logger(NotificationRecipientsService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly externalProfiles: ExternalProfileRepository,
|
||||||
|
private readonly companyProfiles: CompanyProfileRepository,
|
||||||
|
private readonly backoffice: BackofficeService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async resolve(recipients: NotificationRecipients): Promise<string[]> {
|
||||||
|
const ids = new Set<string>();
|
||||||
|
|
||||||
|
for (const id of recipients.userIds ?? []) {
|
||||||
|
if (id) ids.add(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
let companyId = recipients.companyId;
|
||||||
|
if (!companyId && recipients.companyProfileId) {
|
||||||
|
const profile = await this.companyProfiles.findById(
|
||||||
|
recipients.companyProfileId,
|
||||||
|
);
|
||||||
|
companyId = profile?.companyId ?? undefined;
|
||||||
|
}
|
||||||
|
if (companyId) {
|
||||||
|
const profiles = await this.externalProfiles.findByCompanyId(companyId);
|
||||||
|
for (const p of profiles) {
|
||||||
|
if (p.userId) ids.add(p.userId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (recipients.organizationId) {
|
||||||
|
try {
|
||||||
|
const { items } = await this.backoffice.getOrganizationEmployees(
|
||||||
|
recipients.organizationId,
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
for (const employee of items as Array<{
|
||||||
|
user?: { id?: string };
|
||||||
|
userId?: string;
|
||||||
|
}>) {
|
||||||
|
const uid = employee?.user?.id ?? employee?.userId;
|
||||||
|
if (uid) ids.add(uid);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(
|
||||||
|
`Failed to resolve org recipients for ${recipients.organizationId}: ${
|
||||||
|
(err as Error).message
|
||||||
|
}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...ids];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import {
|
||||||
|
NOTIFICATION_WS_EVENTS,
|
||||||
|
NOTIFICATION_WS_NAMESPACE,
|
||||||
|
NotificationDto,
|
||||||
|
} from "@edr/types";
|
||||||
|
import { Logger } from "@nestjs/common";
|
||||||
|
import {
|
||||||
|
OnGatewayConnection,
|
||||||
|
WebSocketGateway,
|
||||||
|
WebSocketServer,
|
||||||
|
} from "@nestjs/websockets";
|
||||||
|
import { Server, Socket } from "socket.io";
|
||||||
|
|
||||||
|
import { WsAuthService } from "./ws-auth.service";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Server → client push for in-app notifications. Clients only *listen* (no
|
||||||
|
* `@SubscribeMessage` handlers), so the global HTTP JwtGuard never applies here;
|
||||||
|
* the handshake is authenticated in `handleConnection` and each socket joins a
|
||||||
|
* private `user:<id>` room the service targets.
|
||||||
|
*/
|
||||||
|
@WebSocketGateway({
|
||||||
|
namespace: NOTIFICATION_WS_NAMESPACE,
|
||||||
|
cors: { origin: true, credentials: true },
|
||||||
|
})
|
||||||
|
export class NotificationsGateway implements OnGatewayConnection {
|
||||||
|
private readonly logger = new Logger(NotificationsGateway.name);
|
||||||
|
|
||||||
|
@WebSocketServer()
|
||||||
|
private readonly server!: Server;
|
||||||
|
|
||||||
|
constructor(private readonly wsAuth: WsAuthService) {}
|
||||||
|
|
||||||
|
async handleConnection(socket: Socket): Promise<void> {
|
||||||
|
const userId = await this.wsAuth.resolveUserId(this.extractToken(socket));
|
||||||
|
if (!userId) {
|
||||||
|
this.logger.debug(`Rejected notifications handshake ${socket.id}`);
|
||||||
|
socket.disconnect(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
socket.data.userId = userId;
|
||||||
|
await socket.join(this.room(userId));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Push a freshly-created notification + the new unread count to a user. */
|
||||||
|
emitNew(userId: string, notification: NotificationDto, unreadCount: number): void {
|
||||||
|
const room = this.server.to(this.room(userId));
|
||||||
|
room.emit(NOTIFICATION_WS_EVENTS.NEW, notification);
|
||||||
|
room.emit(NOTIFICATION_WS_EVENTS.UNREAD_COUNT, unreadCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Push only an updated unread count (e.g. after a read on another tab). */
|
||||||
|
emitUnreadCount(userId: string, unreadCount: number): void {
|
||||||
|
this.server
|
||||||
|
.to(this.room(userId))
|
||||||
|
.emit(NOTIFICATION_WS_EVENTS.UNREAD_COUNT, unreadCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
private room(userId: string): string {
|
||||||
|
return `user:${userId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private extractToken(socket: Socket): string | undefined {
|
||||||
|
const authToken = socket.handshake.auth?.token as string | undefined;
|
||||||
|
if (authToken) return authToken;
|
||||||
|
|
||||||
|
const queryToken = socket.handshake.query?.token;
|
||||||
|
if (typeof queryToken === "string") return queryToken;
|
||||||
|
|
||||||
|
const header = socket.handshake.headers?.authorization;
|
||||||
|
if (header?.startsWith("Bearer ")) return header.slice(7);
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { Injectable, Logger } from "@nestjs/common";
|
||||||
|
import { InjectRepository } from "@nestjs/typeorm";
|
||||||
|
import { Repository } from "typeorm";
|
||||||
|
|
||||||
|
import { verifyToken } from "@tria-plc/api-common/utils/token";
|
||||||
|
import { ESessionStatus } from "@tria-plc/api-common/utils/enums/user.enum";
|
||||||
|
import { Session } from "@tria-plc/iamapi-common/entities/iam/user/session.entity";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Authenticates a WebSocket handshake by mirroring the HTTP JwtGuard: the access
|
||||||
|
* token payload is only a *session* pointer (`{ id: <sessionId> }`), not the
|
||||||
|
* user — so we verify the signature (`verifyToken`), then load the IAM session
|
||||||
|
* and require it to be ACTIVE and unexpired, and read the real user id out of
|
||||||
|
* `session.userInfo`. There is no context-free verifier in the auth package, so
|
||||||
|
* this lookup is unavoidable; using the typed `Session` entity (rather than raw
|
||||||
|
* SQL) keeps it column-rename-safe and consistent with the package's own model.
|
||||||
|
*
|
||||||
|
* Returns the IAM user id, or null for any invalid/expired/revoked/malformed token.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class WsAuthService {
|
||||||
|
private readonly logger = new Logger(WsAuthService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(Session)
|
||||||
|
private readonly sessions: Repository<Session>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async resolveUserId(token?: string): Promise<string | null> {
|
||||||
|
if (!token) return null;
|
||||||
|
try {
|
||||||
|
const payload = verifyToken(token) as { id?: string };
|
||||||
|
const sessionId = payload?.id;
|
||||||
|
if (!sessionId) return null;
|
||||||
|
|
||||||
|
const session = await this.sessions.findOne({
|
||||||
|
where: { id: sessionId },
|
||||||
|
});
|
||||||
|
if (!session) return null;
|
||||||
|
if (session.status !== ESessionStatus.ACTIVE) return null;
|
||||||
|
if (!session.expiryTime || new Date(session.expiryTime) <= new Date()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return session.userInfo?.id ?? null;
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.debug(`WS auth rejected: ${(err as Error).message}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -33,6 +33,7 @@
|
|||||||
"react-hot-toast": "^2.6.0",
|
"react-hot-toast": "^2.6.0",
|
||||||
"react-router-dom": "^6.27.0",
|
"react-router-dom": "^6.27.0",
|
||||||
"recharts": "^3.8.1",
|
"recharts": "^3.8.1",
|
||||||
|
"socket.io-client": "^4.8.3",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"stream-browserify": "^3.0.0",
|
"stream-browserify": "^3.0.0",
|
||||||
"tailwind-merge": "^3.6.0",
|
"tailwind-merge": "^3.6.0",
|
||||||
|
|||||||
@@ -5,14 +5,12 @@ import {
|
|||||||
Burger,
|
Burger,
|
||||||
Divider,
|
Divider,
|
||||||
Group,
|
Group,
|
||||||
Indicator,
|
|
||||||
Menu,
|
Menu,
|
||||||
Text,
|
Text,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
UnstyledButton,
|
UnstyledButton,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import {
|
import {
|
||||||
Bell,
|
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
FileSignature,
|
FileSignature,
|
||||||
Languages,
|
Languages,
|
||||||
@@ -25,6 +23,8 @@ import {
|
|||||||
import { type ReactNode } from "react";
|
import { type ReactNode } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
|
import NotificationBellContainer from "@/features/notifications/NotificationBellContainer";
|
||||||
|
|
||||||
import type { PageMeta } from "./types";
|
import type { PageMeta } from "./types";
|
||||||
|
|
||||||
export interface FreightDashboardHeaderProps {
|
export interface FreightDashboardHeaderProps {
|
||||||
@@ -117,19 +117,7 @@ const FreightDashboardHeader = ({
|
|||||||
</UnstyledButton>
|
</UnstyledButton>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
<Tooltip label="Notifications" withArrow openDelay={300}>
|
<NotificationBellContainer />
|
||||||
<Indicator
|
|
||||||
color="edr-accent"
|
|
||||||
size={8}
|
|
||||||
offset={6}
|
|
||||||
withBorder
|
|
||||||
aria-label="Unread notifications"
|
|
||||||
>
|
|
||||||
<UnstyledButton className={ISLAND} aria-label="Notifications">
|
|
||||||
<Bell size={17} strokeWidth={1.8} />
|
|
||||||
</UnstyledButton>
|
|
||||||
</Indicator>
|
|
||||||
</Tooltip>
|
|
||||||
|
|
||||||
{enableThemeToggle && (
|
{enableThemeToggle && (
|
||||||
<Tooltip
|
<Tooltip
|
||||||
|
|||||||
@@ -0,0 +1,129 @@
|
|||||||
|
import type { NotificationDto, NotificationListResult } from "@edr/types";
|
||||||
|
import {
|
||||||
|
NotificationBell,
|
||||||
|
NotificationDrawer,
|
||||||
|
NotificationToast,
|
||||||
|
type NotificationItemData,
|
||||||
|
} from "@edr/ui-common";
|
||||||
|
import { useState } from "react";
|
||||||
|
import toast from "react-hot-toast";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
|
import {
|
||||||
|
resolveNotificationHref,
|
||||||
|
resolveNotificationVisual,
|
||||||
|
} from "./notificationConfig";
|
||||||
|
import {
|
||||||
|
useInfiniteNotifications,
|
||||||
|
useMarkAllRead,
|
||||||
|
useMarkRead,
|
||||||
|
useUnreadCount,
|
||||||
|
} from "./useNotifications";
|
||||||
|
import { useNotificationSocket } from "./useNotificationSocket";
|
||||||
|
|
||||||
|
/** Map a server notification into the shared presentational item shape. */
|
||||||
|
function toItem(n: NotificationDto): NotificationItemData {
|
||||||
|
return {
|
||||||
|
id: n.id,
|
||||||
|
type: n.type,
|
||||||
|
title: n.title,
|
||||||
|
body: n.body,
|
||||||
|
createdAt: n.createdAt,
|
||||||
|
isRead: n.isRead,
|
||||||
|
priority: n.priority,
|
||||||
|
link: n.link,
|
||||||
|
data: n.data,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Flatten an infinite query's pages into the drawer's item shape. */
|
||||||
|
function toItems(
|
||||||
|
data: { pages: NotificationListResult[] } | undefined,
|
||||||
|
): NotificationItemData[] {
|
||||||
|
return (data?.pages ?? []).flatMap((p) => 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) => (
|
||||||
|
<NotificationToast
|
||||||
|
item={item}
|
||||||
|
visual={resolveNotificationVisual(item)}
|
||||||
|
visible={t.visible}
|
||||||
|
onClick={() => {
|
||||||
|
toast.dismiss(t.id);
|
||||||
|
handleItemClick(item);
|
||||||
|
}}
|
||||||
|
onDismiss={() => toast.dismiss(t.id)}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
{ duration: 6000 },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<NotificationBell
|
||||||
|
unreadCount={unreadCount}
|
||||||
|
onClick={() => setOpened(true)}
|
||||||
|
/>
|
||||||
|
<NotificationDrawer
|
||||||
|
opened={opened}
|
||||||
|
onClose={() => 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}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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: <Inbox size={ICON_SIZE} />, color: "blue" };
|
||||||
|
case NotificationType.PAYMENT_RECEIVED:
|
||||||
|
return { icon: <Wallet size={ICON_SIZE} />, color: "teal" };
|
||||||
|
case NotificationType.CLEARANCE_REVIEW:
|
||||||
|
return { icon: <ClipboardCheck size={ICON_SIZE} />, color: "orange" };
|
||||||
|
default:
|
||||||
|
return { icon: <Bell size={ICON_SIZE} />, 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<NotificationListResult> => {
|
||||||
|
const { data } = await api.get<NotificationListResult>("/notifications", {
|
||||||
|
params,
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
unreadCount: async (): Promise<number> => {
|
||||||
|
const { data } = await api.get<{ unreadCount: number }>(
|
||||||
|
"/notifications/unread-count",
|
||||||
|
);
|
||||||
|
return data.unreadCount;
|
||||||
|
},
|
||||||
|
markRead: async (id: string): Promise<void> => {
|
||||||
|
await api.patch(`/notifications/${id}/read`);
|
||||||
|
},
|
||||||
|
markAllRead: async (): Promise<void> => {
|
||||||
|
await api.post("/notifications/read-all");
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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]);
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -34,6 +34,7 @@
|
|||||||
"react-phone-number-input": "^3.4.17",
|
"react-phone-number-input": "^3.4.17",
|
||||||
"react-router-dom": "^6.27.0",
|
"react-router-dom": "^6.27.0",
|
||||||
"recharts": "^3.8.1",
|
"recharts": "^3.8.1",
|
||||||
|
"socket.io-client": "^4.8.3",
|
||||||
"tailwind-merge": "^3.6.0",
|
"tailwind-merge": "^3.6.0",
|
||||||
"zod": "^4.4.3",
|
"zod": "^4.4.3",
|
||||||
"zustand": "^5.0.0"
|
"zustand": "^5.0.0"
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ import {
|
|||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { useDisclosure } from "@mantine/hooks";
|
import { useDisclosure } from "@mantine/hooks";
|
||||||
import {
|
import {
|
||||||
Bell,
|
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
FileSignature,
|
FileSignature,
|
||||||
LogOut,
|
LogOut,
|
||||||
@@ -40,6 +39,7 @@ import {
|
|||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { PROFILE_TYPE_LABELS } from "@/constants/profileMode";
|
import { PROFILE_TYPE_LABELS } from "@/constants/profileMode";
|
||||||
|
import NotificationBellContainer from "@/features/notifications/NotificationBellContainer";
|
||||||
|
|
||||||
export interface SidebarItem {
|
export interface SidebarItem {
|
||||||
label: string;
|
label: string;
|
||||||
@@ -353,25 +353,8 @@ export function AppLayout({
|
|||||||
</Text>
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
{/* Bell */}
|
{/* Notifications */}
|
||||||
<Box style={{ position: "relative" }}>
|
<NotificationBellContainer />
|
||||||
<UnstyledButton style={islandStyle} aria-label="Notifications">
|
|
||||||
<Bell size={17} color={textColor} strokeWidth={1.8} />
|
|
||||||
</UnstyledButton>
|
|
||||||
<Box
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
top: 7,
|
|
||||||
right: 7,
|
|
||||||
width: 7,
|
|
||||||
height: 7,
|
|
||||||
borderRadius: "50%",
|
|
||||||
backgroundColor: accentColor,
|
|
||||||
border: "1.5px solid #fff",
|
|
||||||
pointerEvents: "none",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{enableThemeToggle && (
|
{enableThemeToggle && (
|
||||||
<UnstyledButton
|
<UnstyledButton
|
||||||
|
|||||||
@@ -0,0 +1,129 @@
|
|||||||
|
import type { NotificationDto, NotificationListResult } from "@edr/types";
|
||||||
|
import {
|
||||||
|
NotificationBell,
|
||||||
|
NotificationDrawer,
|
||||||
|
NotificationToast,
|
||||||
|
type NotificationItemData,
|
||||||
|
} from "@edr/ui-common";
|
||||||
|
import { useState } from "react";
|
||||||
|
import toast from "react-hot-toast";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
|
import {
|
||||||
|
resolveNotificationHref,
|
||||||
|
resolveNotificationVisual,
|
||||||
|
} from "./notificationConfig";
|
||||||
|
import {
|
||||||
|
useInfiniteNotifications,
|
||||||
|
useMarkAllRead,
|
||||||
|
useMarkRead,
|
||||||
|
useUnreadCount,
|
||||||
|
} from "./useNotifications";
|
||||||
|
import { useNotificationSocket } from "./useNotificationSocket";
|
||||||
|
|
||||||
|
/** Map a server notification into the shared presentational item shape. */
|
||||||
|
function toItem(n: NotificationDto): NotificationItemData {
|
||||||
|
return {
|
||||||
|
id: n.id,
|
||||||
|
type: n.type,
|
||||||
|
title: n.title,
|
||||||
|
body: n.body,
|
||||||
|
createdAt: n.createdAt,
|
||||||
|
isRead: n.isRead,
|
||||||
|
priority: n.priority,
|
||||||
|
link: n.link,
|
||||||
|
data: n.data,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Flatten an infinite query's pages into the drawer's item shape. */
|
||||||
|
function toItems(
|
||||||
|
data: { pages: NotificationListResult[] } | undefined,
|
||||||
|
): NotificationItemData[] {
|
||||||
|
return (data?.pages ?? []).flatMap((p) => 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) => (
|
||||||
|
<NotificationToast
|
||||||
|
item={item}
|
||||||
|
visual={resolveNotificationVisual(item)}
|
||||||
|
visible={t.visible}
|
||||||
|
onClick={() => {
|
||||||
|
toast.dismiss(t.id);
|
||||||
|
handleItemClick(item);
|
||||||
|
}}
|
||||||
|
onDismiss={() => toast.dismiss(t.id)}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
{ duration: 6000 },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<NotificationBell
|
||||||
|
unreadCount={unreadCount}
|
||||||
|
onClick={() => setOpened(true)}
|
||||||
|
/>
|
||||||
|
<NotificationDrawer
|
||||||
|
opened={opened}
|
||||||
|
onClose={() => 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}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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: <BadgeCheck size={ICON_SIZE} />, color: "teal" };
|
||||||
|
case NotificationType.DOCUMENT_ACTION:
|
||||||
|
return { icon: <FileWarning size={ICON_SIZE} />, color: "orange" };
|
||||||
|
case NotificationType.BOOKING_STATUS:
|
||||||
|
return { icon: <Package size={ICON_SIZE} />, color: "blue" };
|
||||||
|
case NotificationType.INVOICE_ISSUED:
|
||||||
|
return { icon: <Receipt size={ICON_SIZE} />, color: "violet" };
|
||||||
|
default:
|
||||||
|
return { icon: <Bell size={ICON_SIZE} />, 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<NotificationListResult> => {
|
||||||
|
const { data } = await client.get("/api/notifications", { params });
|
||||||
|
return data.data;
|
||||||
|
},
|
||||||
|
unreadCount: async (): Promise<number> => {
|
||||||
|
const { data } = await client.get("/api/notifications/unread-count");
|
||||||
|
return data.data.unreadCount;
|
||||||
|
},
|
||||||
|
markRead: async (id: string): Promise<void> => {
|
||||||
|
await client.patch(`/api/notifications/${id}/read`);
|
||||||
|
},
|
||||||
|
markAllRead: async (): Promise<void> => {
|
||||||
|
await client.post("/api/notifications/read-all");
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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]);
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import "@mantine/dates/styles.css";
|
|||||||
import "@edr/ui-common/styles.css";
|
import "@edr/ui-common/styles.css";
|
||||||
import "../index.css";
|
import "../index.css";
|
||||||
import "@edr/ui-common/theme.css";
|
import "@edr/ui-common/theme.css";
|
||||||
|
import { Toaster } from "react-hot-toast";
|
||||||
import { mantineTheme } from "./theme/mantine";
|
import { mantineTheme } from "./theme/mantine";
|
||||||
|
|
||||||
import App from "./App";
|
import App from "./App";
|
||||||
@@ -39,6 +40,7 @@ createRoot(document.getElementById("root")!).render(
|
|||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<App />
|
<App />
|
||||||
|
<Toaster position="top-right" />
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
</MantineProvider>
|
</MantineProvider>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ export * from "./overview";
|
|||||||
export * from "./etrade";
|
export * from "./etrade";
|
||||||
export * from "./contracts";
|
export * from "./contracts";
|
||||||
export * from "./clearance-files.catalog";
|
export * from "./clearance-files.catalog";
|
||||||
|
export * from "./notifications";
|
||||||
|
|
||||||
export enum TradeDirection {
|
export enum TradeDirection {
|
||||||
IMPORT = "IMPORT",
|
IMPORT = "IMPORT",
|
||||||
|
|||||||
124
packages/types/src/freight/notifications.ts
Normal file
124
packages/types/src/freight/notifications.ts
Normal file
@@ -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<string, unknown> | 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<string, unknown> | 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";
|
||||||
@@ -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 (
|
||||||
|
<Indicator
|
||||||
|
color="red"
|
||||||
|
size={16}
|
||||||
|
offset={4}
|
||||||
|
disabled={!hasUnread}
|
||||||
|
label={unreadCount > 99 ? "99+" : unreadCount}
|
||||||
|
styles={{
|
||||||
|
indicator: { fontSize: 10, fontWeight: 700, padding: "0 4px" },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ActionIcon
|
||||||
|
variant="subtle"
|
||||||
|
radius="xl"
|
||||||
|
size={36}
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
className={triggerClassName}
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
<Bell size={17} strokeWidth={1.8} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Indicator>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default NotificationBell;
|
||||||
@@ -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<HTMLDivElement>(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 <div ref={ref} style={{ height: 1 }} aria-hidden />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SectionLabel({
|
||||||
|
label,
|
||||||
|
count,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
count?: number;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Group
|
||||||
|
gap={8}
|
||||||
|
px="md"
|
||||||
|
py={8}
|
||||||
|
wrap="nowrap"
|
||||||
|
style={{
|
||||||
|
position: "sticky",
|
||||||
|
top: 0,
|
||||||
|
zIndex: 1,
|
||||||
|
background: edr.card,
|
||||||
|
borderBottom: `1px solid ${edr.border}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
size="xs"
|
||||||
|
fw={700}
|
||||||
|
tt="uppercase"
|
||||||
|
style={{ color: edr.muted, letterSpacing: 0.6 }}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
{typeof count === "number" && count > 0 && (
|
||||||
|
<Text size="xs" fw={600} style={{ color: edr.muted }}>
|
||||||
|
{count}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LoadingRow() {
|
||||||
|
return (
|
||||||
|
<Group justify="center" py="md">
|
||||||
|
<Loader size="xs" color="edr-green" />
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<HTMLElement | null>(null);
|
||||||
|
const viewportRef = useRef<HTMLDivElement>(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) => (
|
||||||
|
<NotificationItem
|
||||||
|
key={item.id}
|
||||||
|
item={item}
|
||||||
|
visual={resolveVisual?.(item)}
|
||||||
|
onClick={onItemClick}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Drawer
|
||||||
|
opened={opened}
|
||||||
|
onClose={onClose}
|
||||||
|
position="right"
|
||||||
|
size={width}
|
||||||
|
padding={0}
|
||||||
|
withCloseButton={false}
|
||||||
|
overlayProps={{ backgroundOpacity: 0.45, blur: 3 }}
|
||||||
|
transitionProps={{ transition: "slide-left", duration: 220 }}
|
||||||
|
styles={{
|
||||||
|
content: {
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
background: edr.card,
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
flex: 1,
|
||||||
|
minHeight: 0,
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
padding: 0,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Group
|
||||||
|
justify="space-between"
|
||||||
|
px="md"
|
||||||
|
py="md"
|
||||||
|
wrap="nowrap"
|
||||||
|
style={{ borderBottom: `1px solid ${edr.border}` }}
|
||||||
|
>
|
||||||
|
<Group gap="sm" wrap="nowrap">
|
||||||
|
<ThemeIcon variant="light" color="edr-green" radius="md" size={36}>
|
||||||
|
<Bell size={18} strokeWidth={2} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<Box>
|
||||||
|
<Text fw={700} size="md" style={{ color: edr.text, lineHeight: 1.2 }}>
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" style={{ color: edr.muted }}>
|
||||||
|
{totalUnread > 0 ? `${totalUnread} unread` : "All caught up"}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
{totalUnread > 0 && (
|
||||||
|
<Badge color="red" variant="filled" size="sm" radius="xl">
|
||||||
|
{totalUnread > 99 ? "99+" : totalUnread}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
<Group gap={4} wrap="nowrap">
|
||||||
|
{totalUnread > 0 && onMarkAllRead && (
|
||||||
|
<Button
|
||||||
|
variant="subtle"
|
||||||
|
size="compact-xs"
|
||||||
|
color="edr-green"
|
||||||
|
leftSection={<CheckCheck size={13} />}
|
||||||
|
onClick={onMarkAllRead}
|
||||||
|
>
|
||||||
|
Mark all read
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<ActionIcon
|
||||||
|
variant="subtle"
|
||||||
|
color="gray"
|
||||||
|
radius="xl"
|
||||||
|
size={32}
|
||||||
|
aria-label="Close notifications"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<X size={18} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<ScrollArea
|
||||||
|
type="hover"
|
||||||
|
viewportRef={viewportRef}
|
||||||
|
style={{ flex: 1, minHeight: 0 }}
|
||||||
|
>
|
||||||
|
{loading && unread.length === 0 && read.length === 0 ? (
|
||||||
|
<LoadingRow />
|
||||||
|
) : isEmpty ? (
|
||||||
|
<Stack align="center" gap="sm" py={72} px="lg">
|
||||||
|
<ThemeIcon variant="light" color="edr-green" radius="xl" size={56}>
|
||||||
|
<Inbox size={26} strokeWidth={1.6} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<Text size="sm" fw={600} style={{ color: edr.text }}>
|
||||||
|
{emptyLabel}
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" ta="center" style={{ color: edr.muted, maxWidth: 240 }}>
|
||||||
|
New notifications about your bookings, clearances and invoices will
|
||||||
|
show up here.
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
) : (
|
||||||
|
<Box pb="md">
|
||||||
|
{unread.length > 0 && (
|
||||||
|
<>
|
||||||
|
<SectionLabel label="Unread" count={totalUnread} />
|
||||||
|
<Stack gap={0}>{unread.map(renderItem)}</Stack>
|
||||||
|
{hasMoreUnread && (
|
||||||
|
<Sentinel
|
||||||
|
root={viewport}
|
||||||
|
onVisible={() => onLoadMoreUnread?.()}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{loadingMoreUnread && <LoadingRow />}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{read.length > 0 && (
|
||||||
|
<>
|
||||||
|
<SectionLabel label="Earlier" />
|
||||||
|
<Stack gap={0}>{read.map(renderItem)}</Stack>
|
||||||
|
{hasMoreRead && (
|
||||||
|
<Sentinel
|
||||||
|
root={viewport}
|
||||||
|
onVisible={() => onLoadMoreRead?.()}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{loadingMoreRead && <LoadingRow />}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</ScrollArea>
|
||||||
|
</Drawer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default NotificationDrawer;
|
||||||
@@ -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 ?? <Bell size={17} strokeWidth={2} />;
|
||||||
|
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 (
|
||||||
|
<Box
|
||||||
|
role={clickable ? "button" : undefined}
|
||||||
|
tabIndex={clickable ? 0 : undefined}
|
||||||
|
onClick={() => 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",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Group align="flex-start" wrap="nowrap" gap="sm">
|
||||||
|
<ThemeIcon
|
||||||
|
variant="light"
|
||||||
|
color={high ? "edr-accent" : color}
|
||||||
|
radius="md"
|
||||||
|
size={40}
|
||||||
|
style={{ flexShrink: 0 }}
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
</ThemeIcon>
|
||||||
|
|
||||||
|
<Stack gap={3} style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<Group justify="space-between" wrap="nowrap" gap={8} align="flex-start">
|
||||||
|
<Group gap={6} wrap="nowrap" style={{ minWidth: 0 }}>
|
||||||
|
<Text
|
||||||
|
fw={unread ? 600 : 500}
|
||||||
|
size="sm"
|
||||||
|
lineClamp={1}
|
||||||
|
style={{ color: unread ? edr.text : edr.muted }}
|
||||||
|
>
|
||||||
|
{item.title}
|
||||||
|
</Text>
|
||||||
|
{high && <PriorityTag />}
|
||||||
|
</Group>
|
||||||
|
<Text
|
||||||
|
size="xs"
|
||||||
|
style={{
|
||||||
|
color: edr.muted,
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
flexShrink: 0,
|
||||||
|
marginTop: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{timeAgo(item.createdAt)}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
<Text
|
||||||
|
size="xs"
|
||||||
|
lineClamp={2}
|
||||||
|
style={{ color: edr.muted, lineHeight: 1.45 }}
|
||||||
|
>
|
||||||
|
{item.body}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{unread && (
|
||||||
|
<Box
|
||||||
|
aria-hidden
|
||||||
|
style={{
|
||||||
|
flexShrink: 0,
|
||||||
|
width: 8,
|
||||||
|
height: 8,
|
||||||
|
borderRadius: "50%",
|
||||||
|
marginTop: 6,
|
||||||
|
background: accent,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default NotificationItem;
|
||||||
@@ -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 ?? <Bell size={18} strokeWidth={2} />;
|
||||||
|
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 (
|
||||||
|
<Box
|
||||||
|
style={{
|
||||||
|
position: "relative",
|
||||||
|
width,
|
||||||
|
maxWidth: "calc(100vw - 32px)",
|
||||||
|
display: "flex",
|
||||||
|
overflow: "hidden",
|
||||||
|
borderRadius: "var(--mantine-radius-lg)",
|
||||||
|
background: edr.card,
|
||||||
|
border: `1px solid ${edr.border}`,
|
||||||
|
boxShadow: "0 10px 30px rgba(16, 24, 40, 0.12)",
|
||||||
|
pointerEvents: "auto",
|
||||||
|
transform: visible ? "translateX(0)" : "translateX(16px)",
|
||||||
|
opacity: visible ? 1 : 0,
|
||||||
|
transition: "transform 200ms ease, opacity 200ms ease",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* colored accent rail */}
|
||||||
|
<Box aria-hidden style={{ width: 4, flexShrink: 0, background: accent }} />
|
||||||
|
|
||||||
|
<Box
|
||||||
|
role={clickable ? "button" : undefined}
|
||||||
|
tabIndex={clickable ? 0 : undefined}
|
||||||
|
onClick={onClick}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (clickable && (e.key === "Enter" || e.key === " ")) {
|
||||||
|
e.preventDefault();
|
||||||
|
onClick?.();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
p="sm"
|
||||||
|
style={{ flex: 1, minWidth: 0, cursor: clickable ? "pointer" : "default" }}
|
||||||
|
>
|
||||||
|
<Group align="flex-start" wrap="nowrap" gap="sm">
|
||||||
|
<ThemeIcon
|
||||||
|
variant="light"
|
||||||
|
color={high ? "edr-accent" : color}
|
||||||
|
radius="md"
|
||||||
|
size={40}
|
||||||
|
style={{ flexShrink: 0 }}
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
</ThemeIcon>
|
||||||
|
|
||||||
|
<Stack gap={3} style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<Group gap={6} wrap="nowrap" style={{ minWidth: 0 }} pr={20}>
|
||||||
|
<Text fw={700} size="sm" lineClamp={1} style={{ color: edr.text }}>
|
||||||
|
{item.title}
|
||||||
|
</Text>
|
||||||
|
{high && <PriorityTag />}
|
||||||
|
</Group>
|
||||||
|
<Text
|
||||||
|
size="xs"
|
||||||
|
lineClamp={2}
|
||||||
|
style={{ color: edr.muted, lineHeight: 1.45 }}
|
||||||
|
>
|
||||||
|
{item.body}
|
||||||
|
</Text>
|
||||||
|
<Group justify="space-between" wrap="nowrap" gap={6} mt={4}>
|
||||||
|
<Text size="xs" style={{ color: edr.muted, whiteSpace: "nowrap" }}>
|
||||||
|
{timeAgo(item.createdAt)}
|
||||||
|
</Text>
|
||||||
|
{clickable && (
|
||||||
|
<Group gap={2} wrap="nowrap">
|
||||||
|
<Text size="xs" fw={600} style={{ color: linkColor }}>
|
||||||
|
View details
|
||||||
|
</Text>
|
||||||
|
<ChevronRight size={13} color={linkColor} />
|
||||||
|
</Group>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Group>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{onDismiss && (
|
||||||
|
<ActionIcon
|
||||||
|
variant="subtle"
|
||||||
|
color="gray"
|
||||||
|
size="sm"
|
||||||
|
radius="xl"
|
||||||
|
aria-label="Dismiss"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onDismiss();
|
||||||
|
}}
|
||||||
|
style={{ position: "absolute", top: 6, right: 6 }}
|
||||||
|
>
|
||||||
|
<X size={14} />
|
||||||
|
</ActionIcon>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default NotificationToast;
|
||||||
@@ -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 (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: 700,
|
||||||
|
letterSpacing: 0.4,
|
||||||
|
lineHeight: 1.4,
|
||||||
|
textTransform: "uppercase",
|
||||||
|
padding: "1px 6px",
|
||||||
|
borderRadius: 999,
|
||||||
|
color: edr.amberText,
|
||||||
|
background: edr.amberSoft,
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
High
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
17
packages/ui-common/src/components/NotificationBell/index.ts
Normal file
17
packages/ui-common/src/components/NotificationBell/index.ts
Normal file
@@ -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";
|
||||||
@@ -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();
|
||||||
|
};
|
||||||
33
packages/ui-common/src/components/NotificationBell/types.ts
Normal file
33
packages/ui-common/src/components/NotificationBell/types.ts
Normal file
@@ -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<string, unknown> | 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;
|
||||||
@@ -28,6 +28,22 @@ export type { OperationDatePickerProps } from "./components/OperationDatePicker"
|
|||||||
export { CountdownTimer } from "./components/CountdownTimer";
|
export { CountdownTimer } from "./components/CountdownTimer";
|
||||||
export type { CountdownTimerProps } 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 { Badge } from "./components/badge";
|
||||||
// export type { BadgeProps } from "./components/badge";
|
// export type { BadgeProps } from "./components/badge";
|
||||||
|
|
||||||
|
|||||||
183
pnpm-lock.yaml
generated
183
pnpm-lock.yaml
generated
@@ -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)
|
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':
|
'@nestjs/core':
|
||||||
specifier: ^11.0.0
|
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':
|
'@nestjs/event-emitter':
|
||||||
specifier: ^2.0.4
|
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)
|
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)
|
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':
|
'@nestjs/microservices':
|
||||||
specifier: ^11.0.0
|
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':
|
'@nestjs/platform-express':
|
||||||
specifier: ^11.0.0
|
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)
|
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':
|
'@nestjs/schedule':
|
||||||
specifier: ^6.1.3
|
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)
|
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':
|
'@nestjs/typeorm':
|
||||||
specifier: ^11.0.1
|
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)))
|
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':
|
'@tria-plc/api-common':
|
||||||
specifier: file:../../local-packages/tria-plc-api-common-1.4.3.tgz
|
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)
|
version: file:local-packages/tria-plc-api-common-1.4.3.tgz(bad2eb10df48448775040459098de142)
|
||||||
@@ -138,6 +144,9 @@ importers:
|
|||||||
rxjs:
|
rxjs:
|
||||||
specifier: ^7.8.1
|
specifier: ^7.8.1
|
||||||
version: 7.8.2
|
version: 7.8.2
|
||||||
|
socket.io:
|
||||||
|
specifier: ^4.8.3
|
||||||
|
version: 4.8.3
|
||||||
typeorm:
|
typeorm:
|
||||||
specifier: ^0.3.30
|
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))
|
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:
|
recharts:
|
||||||
specifier: ^3.8.1
|
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)
|
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:
|
sonner:
|
||||||
specifier: ^2.0.7
|
specifier: ^2.0.7
|
||||||
version: 2.0.7(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
version: 2.0.7(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||||
@@ -392,6 +404,9 @@ importers:
|
|||||||
recharts:
|
recharts:
|
||||||
specifier: ^3.8.1
|
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)
|
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:
|
tailwind-merge:
|
||||||
specifier: ^3.6.0
|
specifier: ^3.6.0
|
||||||
version: 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)
|
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':
|
'@nestjs/core':
|
||||||
specifier: ^11.1.19
|
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':
|
'@nestjs/event-emitter':
|
||||||
specifier: ^2.0.4
|
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)
|
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':
|
'@nestjs/microservices':
|
||||||
specifier: ^11.1.24
|
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':
|
'@nestjs/platform-express':
|
||||||
specifier: ^11.1.19
|
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)
|
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)
|
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':
|
'@nestjs/core':
|
||||||
specifier: ^11.0.0
|
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':
|
'@nestjs/platform-express':
|
||||||
specifier: ^11.0.0
|
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)
|
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)
|
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':
|
'@nestjs/core':
|
||||||
specifier: ^11.0.0
|
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':
|
'@types/node':
|
||||||
specifier: ^20.14.0
|
specifier: ^20.14.0
|
||||||
version: 20.19.42
|
version: 20.19.42
|
||||||
@@ -2504,6 +2519,13 @@ packages:
|
|||||||
'@nestjs/common': ^11.0.0
|
'@nestjs/common': ^11.0.0
|
||||||
'@nestjs/core': ^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':
|
'@nestjs/schedule@6.1.3':
|
||||||
resolution: {integrity: sha512-RflMFOpR16Dwd1jAUbeB4mfGTCh65fvEdL4mSjQPJChpkRGRjIXjb+6YQcK2faQrVT60c9DmLmoVR7/ONCtuYQ==}
|
resolution: {integrity: sha512-RflMFOpR16Dwd1jAUbeB4mfGTCh65fvEdL4mSjQPJChpkRGRjIXjb+6YQcK2faQrVT60c9DmLmoVR7/ONCtuYQ==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -2582,6 +2604,18 @@ packages:
|
|||||||
rxjs: ^7.2.0
|
rxjs: ^7.2.0
|
||||||
typeorm: ^0.3.0 || ^1.0.0-dev
|
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':
|
'@next/env@14.2.35':
|
||||||
resolution: {integrity: sha512-DuhvCtj4t9Gwrx80dmz2F4t/zKQ4ktN8WrMwOuVzkJfBilwAwGr6v16M5eI8yCuZ63H9TTuEU09Iu2HqkzFPVQ==}
|
resolution: {integrity: sha512-DuhvCtj4t9Gwrx80dmz2F4t/zKQ4ktN8WrMwOuVzkJfBilwAwGr6v16M5eI8yCuZ63H9TTuEU09Iu2HqkzFPVQ==}
|
||||||
|
|
||||||
@@ -4215,6 +4249,9 @@ packages:
|
|||||||
'@types/cookiejar@2.1.5':
|
'@types/cookiejar@2.1.5':
|
||||||
resolution: {integrity: sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==}
|
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':
|
'@types/d3-array@3.2.2':
|
||||||
resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==}
|
resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==}
|
||||||
|
|
||||||
@@ -4417,6 +4454,9 @@ packages:
|
|||||||
'@types/vorpal@1.12.8':
|
'@types/vorpal@1.12.8':
|
||||||
resolution: {integrity: sha512-Qt+Yxa1q6QCaYMxZFXlyPOF3ktIscTelNr1AFYuKM7/Dhlki4gvc476uFyA/hYvskSA6V8W+55x9FjlbAPcYdQ==}
|
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':
|
'@types/yargs-parser@21.0.3':
|
||||||
resolution: {integrity: sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==}
|
resolution: {integrity: sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==}
|
||||||
|
|
||||||
@@ -5264,6 +5304,10 @@ packages:
|
|||||||
base64-js@1.5.1:
|
base64-js@1.5.1:
|
||||||
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
|
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:
|
base@0.11.2:
|
||||||
resolution: {integrity: sha512-5T6P4xPgpp0YDFvSWwEZ4NoE3aM4QBQXDzmVbraCkFj8zHM+mba8SyqB5DbZWyR7mYHo6Y7BdQo3MoA4m0TeQg==}
|
resolution: {integrity: sha512-5T6P4xPgpp0YDFvSWwEZ4NoE3aM4QBQXDzmVbraCkFj8zHM+mba8SyqB5DbZWyR7mYHo6Y7BdQo3MoA4m0TeQg==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
@@ -6245,6 +6289,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==}
|
resolution: {integrity: sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==}
|
||||||
engines: {node: '>=10.0.0'}
|
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:
|
enhanced-resolve@5.23.0:
|
||||||
resolution: {integrity: sha512-yJN/BOOLxcOW2aQgeif9mSnaUB8KtvmMMp56oA1kx1CRfBKbhZm2pJ+NBY+3eOboHxix8lfjWpHE0Ei5U8RbSA==}
|
resolution: {integrity: sha512-yJN/BOOLxcOW2aQgeif9mSnaUB8KtvmMMp56oA1kx1CRfBKbhZm2pJ+NBY+3eOboHxix8lfjWpHE0Ei5U8RbSA==}
|
||||||
engines: {node: '>=10.13.0'}
|
engines: {node: '>=10.13.0'}
|
||||||
@@ -9987,6 +10035,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-FtyOnWN/wCHTVXOMwvSv26d+ko5vWlIDD6zoUJ7LW8vh+ZBC8QdljveRP+crNrtBwioEUWy/4dMtbBjA4ioNlg==}
|
resolution: {integrity: sha512-FtyOnWN/wCHTVXOMwvSv26d+ko5vWlIDD6zoUJ7LW8vh+ZBC8QdljveRP+crNrtBwioEUWy/4dMtbBjA4ioNlg==}
|
||||||
engines: {node: '>=0.10.0'}
|
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:
|
socket.io-client@4.8.3:
|
||||||
resolution: {integrity: sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g==}
|
resolution: {integrity: sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g==}
|
||||||
engines: {node: '>=10.0.0'}
|
engines: {node: '>=10.0.0'}
|
||||||
@@ -9995,6 +10046,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-asJqbVBDsBCJx0pTqw3WfesSY0iRX+2xzWEWzrpcH7L6fLzrhyF8WPI8UaeM4YCuDfpwA/cgsdugMsmtz8EJeg==}
|
resolution: {integrity: sha512-asJqbVBDsBCJx0pTqw3WfesSY0iRX+2xzWEWzrpcH7L6fLzrhyF8WPI8UaeM4YCuDfpwA/cgsdugMsmtz8EJeg==}
|
||||||
engines: {node: '>=10.0.0'}
|
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:
|
socks-proxy-agent@8.0.5:
|
||||||
resolution: {integrity: sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==}
|
resolution: {integrity: sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==}
|
||||||
engines: {node: '>= 14'}
|
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)':
|
'@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:
|
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/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
|
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)':
|
'@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:
|
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)
|
'@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/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)
|
amqp-connection-manager: 4.1.15(amqplib@0.10.9)
|
||||||
amqplib: 0.10.9
|
amqplib: 0.10.9
|
||||||
lodash: 4.18.1
|
lodash: 4.18.1
|
||||||
@@ -12979,7 +13034,7 @@ snapshots:
|
|||||||
lodash: 4.18.1
|
lodash: 4.18.1
|
||||||
rxjs: 7.8.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)':
|
||||||
dependencies:
|
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/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
|
'@nuxt/opencollective': 0.4.1
|
||||||
@@ -12991,13 +13046,14 @@ snapshots:
|
|||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
uid: 2.0.2
|
uid: 2.0.2
|
||||||
optionalDependencies:
|
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/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)':
|
'@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:
|
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/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
|
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))':
|
'@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-transformer: 0.5.1
|
||||||
class-validator: 0.14.4
|
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:
|
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/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
|
iterare: 1.2.1
|
||||||
reflect-metadata: 0.2.2
|
reflect-metadata: 0.2.2
|
||||||
rxjs: 7.8.2
|
rxjs: 7.8.2
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
optionalDependencies:
|
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)
|
amqp-connection-manager: 5.0.0(amqplib@2.0.1)
|
||||||
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)':
|
'@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:
|
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/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
|
cors: 2.8.6
|
||||||
express: 5.2.1
|
express: 5.2.1
|
||||||
multer: 2.1.1
|
multer: 2.1.1
|
||||||
@@ -13051,10 +13108,22 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- 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)':
|
'@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:
|
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/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
|
cron: 4.4.0
|
||||||
|
|
||||||
'@nestjs/schematics@11.1.0(chokidar@4.0.3)(prettier@3.8.3)(typescript@5.9.3)':
|
'@nestjs/schematics@11.1.0(chokidar@4.0.3)(prettier@3.8.3)(typescript@5.9.3)':
|
||||||
@@ -13074,7 +13143,7 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@microsoft/tsdoc': 0.16.0
|
'@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/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)
|
'@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
|
js-yaml: 4.1.1
|
||||||
lodash: 4.18.1
|
lodash: 4.18.1
|
||||||
@@ -13089,7 +13158,7 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@microsoft/tsdoc': 0.15.1
|
'@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/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)
|
'@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
|
js-yaml: 4.1.0
|
||||||
lodash: 4.17.21
|
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)':
|
'@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:
|
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/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
|
tslib: 2.8.1
|
||||||
optionalDependencies:
|
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/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)':
|
'@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:
|
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/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
|
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)))':
|
'@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:
|
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/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
|
reflect-metadata: 0.2.2
|
||||||
rxjs: 7.8.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))
|
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/env@14.2.35': {}
|
||||||
|
|
||||||
'@next/eslint-plugin-next@14.2.35':
|
'@next/eslint-plugin-next@14.2.35':
|
||||||
@@ -15424,9 +15505,9 @@ snapshots:
|
|||||||
dependencies:
|
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/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/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/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/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/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)
|
'@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:
|
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/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/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/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/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/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)
|
'@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/cookiejar@2.1.5': {}
|
||||||
|
|
||||||
|
'@types/cors@2.8.19':
|
||||||
|
dependencies:
|
||||||
|
'@types/node': 20.19.42
|
||||||
|
|
||||||
'@types/d3-array@3.2.2': {}
|
'@types/d3-array@3.2.2': {}
|
||||||
|
|
||||||
'@types/d3-color@3.1.3': {}
|
'@types/d3-color@3.1.3': {}
|
||||||
@@ -15961,6 +16046,10 @@ snapshots:
|
|||||||
|
|
||||||
'@types/vorpal@1.12.8': {}
|
'@types/vorpal@1.12.8': {}
|
||||||
|
|
||||||
|
'@types/ws@8.18.1':
|
||||||
|
dependencies:
|
||||||
|
'@types/node': 20.19.42
|
||||||
|
|
||||||
'@types/yargs-parser@21.0.3': {}
|
'@types/yargs-parser@21.0.3': {}
|
||||||
|
|
||||||
'@types/yargs@17.0.35':
|
'@types/yargs@17.0.35':
|
||||||
@@ -16906,6 +16995,8 @@ snapshots:
|
|||||||
|
|
||||||
base64-js@1.5.1: {}
|
base64-js@1.5.1: {}
|
||||||
|
|
||||||
|
base64id@2.0.0: {}
|
||||||
|
|
||||||
base@0.11.2:
|
base@0.11.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
cache-base: 1.0.1
|
cache-base: 1.0.1
|
||||||
@@ -17893,6 +17984,23 @@ snapshots:
|
|||||||
|
|
||||||
engine.io-parser@5.2.3: {}
|
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:
|
enhanced-resolve@5.23.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
graceful-fs: 4.2.11
|
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):
|
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:
|
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/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
|
minio: 7.1.3
|
||||||
reflect-metadata: 0.1.14
|
reflect-metadata: 0.1.14
|
||||||
rxjs: 7.8.2
|
rxjs: 7.8.2
|
||||||
@@ -22425,6 +22533,15 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- 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:
|
socket.io-client@4.8.3:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@socket.io/component-emitter': 3.1.2
|
'@socket.io/component-emitter': 3.1.2
|
||||||
@@ -22443,6 +22560,20 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- 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:
|
socks-proxy-agent@8.0.5:
|
||||||
dependencies:
|
dependencies:
|
||||||
agent-base: 7.1.4
|
agent-base: 7.1.4
|
||||||
|
|||||||
Reference in New Issue
Block a user