Implement Notifications Feature and Enhance BFF Modules

- Introduced a new Notification model in the Prisma schema to manage in-app notifications for users.
- Integrated the NotificationsModule into the BFF application, allowing for the handling of notifications related to user actions and events.
- Updated the CatalogCdcSubscriber to create notifications for account eligibility and verification status changes, improving user engagement.
- Enhanced the CheckoutRegistrationService to create opportunities for SIM orders, integrating with the new notifications system.
- Refactored various modules to include the NotificationsModule, ensuring seamless interaction and notification handling across the application.
- Updated the frontend to display notification alerts in the AppShell header, enhancing user experience and accessibility.
This commit is contained in:
barsa 2025-12-23 11:36:44 +09:00
parent d9734b0c82
commit 2b183272cf
33 changed files with 3337 additions and 46 deletions

View File

@ -38,6 +38,7 @@
"@nestjs/config": "^4.0.2",
"@nestjs/core": "^11.1.9",
"@nestjs/platform-express": "^11.1.9",
"@nestjs/schedule": "^6.1.0",
"@prisma/adapter-pg": "^7.1.0",
"@prisma/client": "^7.1.0",
"@sendgrid/mail": "^8.1.6",

View File

@ -37,6 +37,7 @@ model User {
auditLogs AuditLog[]
idMapping IdMapping?
residenceCardSubmission ResidenceCardSubmission?
notifications Notification[]
@@map("users")
}
@ -216,3 +217,63 @@ model SimHistoryImport {
@@map("sim_history_imports")
}
// =============================================================================
// Notifications - In-app notifications synced with Salesforce email triggers
// =============================================================================
model Notification {
id String @id @default(uuid())
userId String @map("user_id")
// Notification content
type NotificationType
title String
message String?
// Action (optional CTA button)
actionUrl String? @map("action_url")
actionLabel String? @map("action_label")
// Source tracking for deduplication
source NotificationSource @default(SALESFORCE)
sourceId String? @map("source_id") // SF Account ID, Order ID, etc.
// Status
read Boolean @default(false)
readAt DateTime? @map("read_at")
dismissed Boolean @default(false)
// Timestamps
createdAt DateTime @default(now()) @map("created_at")
expiresAt DateTime @map("expires_at") // 30 days from creation
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId, read, dismissed])
@@index([userId, createdAt])
@@index([expiresAt])
@@map("notifications")
}
enum NotificationType {
ELIGIBILITY_ELIGIBLE
ELIGIBILITY_INELIGIBLE
VERIFICATION_VERIFIED
VERIFICATION_REJECTED
ORDER_APPROVED
ORDER_ACTIVATED
ORDER_FAILED
CANCELLATION_SCHEDULED
CANCELLATION_COMPLETE
PAYMENT_METHOD_EXPIRING
INVOICE_DUE
SYSTEM_ANNOUNCEMENT
}
enum NotificationSource {
SALESFORCE
WHMCS
PORTAL
SYSTEM
}

View File

@ -2,6 +2,7 @@ import { Module } from "@nestjs/common";
import { APP_PIPE } from "@nestjs/core";
import { RouterModule } from "@nestjs/core";
import { ConfigModule } from "@nestjs/config";
import { ScheduleModule } from "@nestjs/schedule";
import { ZodValidationPipe } from "nestjs-zod";
// Configuration
@ -37,6 +38,7 @@ import { CurrencyModule } from "@bff/modules/currency/currency.module.js";
import { SupportModule } from "@bff/modules/support/support.module.js";
import { RealtimeApiModule } from "@bff/modules/realtime/realtime.module.js";
import { VerificationModule } from "@bff/modules/verification/verification.module.js";
import { NotificationsModule } from "@bff/modules/notifications/notifications.module.js";
// System Modules
import { HealthModule } from "@bff/modules/health/health.module.js";
@ -57,6 +59,7 @@ import { HealthModule } from "@bff/modules/health/health.module.js";
imports: [
// === CONFIGURATION ===
ConfigModule.forRoot(appConfig),
ScheduleModule.forRoot(),
// === INFRASTRUCTURE ===
LoggingModule,
@ -89,6 +92,7 @@ import { HealthModule } from "@bff/modules/health/health.module.js";
SupportModule,
RealtimeApiModule,
VerificationModule,
NotificationsModule,
// === SYSTEM MODULES ===
HealthModule,

View File

@ -12,6 +12,7 @@ import { SupportModule } from "@bff/modules/support/support.module.js";
import { RealtimeApiModule } from "@bff/modules/realtime/realtime.module.js";
import { CheckoutRegistrationModule } from "@bff/modules/checkout-registration/checkout-registration.module.js";
import { VerificationModule } from "@bff/modules/verification/verification.module.js";
import { NotificationsModule } from "@bff/modules/notifications/notifications.module.js";
export const apiRoutes: Routes = [
{
@ -30,6 +31,7 @@ export const apiRoutes: Routes = [
{ path: "", module: RealtimeApiModule },
{ path: "", module: CheckoutRegistrationModule },
{ path: "", module: VerificationModule },
{ path: "", module: NotificationsModule },
],
},
];

View File

@ -1,16 +1,17 @@
import { Global, Module } from "@nestjs/common";
import { CacheService } from "./cache.service.js";
import { DistributedLockService } from "./distributed-lock.service.js";
/**
* Global cache module
*
* Provides Redis-backed caching infrastructure for the entire application.
* Exports CacheService for use in domain-specific cache services.
* Exports CacheService and DistributedLockService for use in domain services.
*/
@Global()
@Module({
providers: [CacheService],
exports: [CacheService],
providers: [CacheService, DistributedLockService],
exports: [CacheService, DistributedLockService],
})
export class CacheModule {}

View File

@ -0,0 +1,188 @@
/**
* Distributed Lock Service
*
* Redis-based distributed locking for preventing race conditions
* in operations that span multiple systems (e.g., Salesforce + Portal).
*
* Uses Redis SET NX PX pattern for atomic lock acquisition with TTL.
*/
import { Injectable, Inject } from "@nestjs/common";
import { Logger } from "nestjs-pino";
import type { Redis } from "ioredis";
const LOCK_PREFIX = "lock:";
const DEFAULT_TTL_MS = 30_000; // 30 seconds
const DEFAULT_RETRY_DELAY_MS = 100;
const DEFAULT_MAX_RETRIES = 50; // 5 seconds total with 100ms delay
export interface LockOptions {
/** Lock TTL in milliseconds (default: 30000) */
ttlMs?: number;
/** Delay between retry attempts in milliseconds (default: 100) */
retryDelayMs?: number;
/** Maximum number of retry attempts (default: 50) */
maxRetries?: number;
}
export interface Lock {
/** The lock key */
key: string;
/** Unique token for this lock instance */
token: string;
/** Release the lock */
release: () => Promise<void>;
}
@Injectable()
export class DistributedLockService {
constructor(
@Inject("REDIS_CLIENT") private readonly redis: Redis,
@Inject(Logger) private readonly logger: Logger
) {}
/**
* Acquire a distributed lock
*
* @param key - Unique key identifying the resource to lock
* @param options - Lock options
* @returns Lock object if acquired, null if unable to acquire
*/
async acquire(key: string, options?: LockOptions): Promise<Lock | null> {
const lockKey = LOCK_PREFIX + key;
const token = this.generateToken();
const ttlMs = options?.ttlMs ?? DEFAULT_TTL_MS;
const retryDelayMs = options?.retryDelayMs ?? DEFAULT_RETRY_DELAY_MS;
const maxRetries = options?.maxRetries ?? DEFAULT_MAX_RETRIES;
for (let attempt = 0; attempt <= maxRetries; attempt++) {
// SET key token NX PX ttl - atomic set if not exists with TTL
const result = await this.redis.set(lockKey, token, "PX", ttlMs, "NX");
if (result === "OK") {
this.logger.debug("Lock acquired", { key: lockKey, attempt });
return {
key: lockKey,
token,
release: () => this.release(lockKey, token),
};
}
// Lock is held by someone else, wait and retry
if (attempt < maxRetries) {
await this.delay(retryDelayMs);
}
}
this.logger.warn("Failed to acquire lock after max retries", {
key: lockKey,
maxRetries,
});
return null;
}
/**
* Execute a function with a lock
*
* Automatically acquires lock before execution and releases after.
* If lock cannot be acquired, throws an error.
*
* @param key - Unique key identifying the resource to lock
* @param fn - Function to execute while holding the lock
* @param options - Lock options
* @returns Result of the function
*/
async withLock<T>(key: string, fn: () => Promise<T>, options?: LockOptions): Promise<T> {
const lock = await this.acquire(key, options);
if (!lock) {
throw new Error(`Unable to acquire lock for key: ${key}`);
}
try {
return await fn();
} finally {
await lock.release();
}
}
/**
* Try to execute a function with a lock
*
* Unlike withLock, this returns null if lock cannot be acquired
* instead of throwing an error.
*
* @param key - Unique key identifying the resource to lock
* @param fn - Function to execute while holding the lock
* @param options - Lock options
* @returns Result of the function, or null if lock not acquired
*/
async tryWithLock<T>(
key: string,
fn: () => Promise<T>,
options?: LockOptions
): Promise<{ success: true; result: T } | { success: false; result: null }> {
const lock = await this.acquire(key, {
...options,
maxRetries: 0, // Don't retry for try semantics
});
if (!lock) {
return { success: false, result: null };
}
try {
const result = await fn();
return { success: true, result };
} finally {
await lock.release();
}
}
/**
* Release a lock
*
* Uses a Lua script to ensure we only release our own lock.
*/
private async release(lockKey: string, token: string): Promise<void> {
// Lua script: only delete if the token matches
const script = `
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
else
return 0
end
`;
try {
const result = await this.redis.eval(script, 1, lockKey, token);
if (result === 1) {
this.logger.debug("Lock released", { key: lockKey });
} else {
this.logger.warn("Lock release failed - token mismatch or expired", {
key: lockKey,
});
}
} catch (error) {
this.logger.error("Error releasing lock", {
key: lockKey,
error: error instanceof Error ? error.message : String(error),
});
}
}
/**
* Generate a unique token for lock ownership
*/
private generateToken(): string {
return `${Date.now()}-${Math.random().toString(36).substring(2, 15)}`;
}
/**
* Delay helper
*/
private delay(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
}

View File

@ -1,4 +1,4 @@
import { Injectable, Inject } from "@nestjs/common";
import { Injectable, Inject, Optional } from "@nestjs/common";
import type { OnModuleInit, OnModuleDestroy } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { Logger } from "nestjs-pino";
@ -6,6 +6,7 @@ import PubSubApiClientPkg from "salesforce-pubsub-api-client";
import { SalesforceConnection } from "../services/salesforce-connection.service.js";
import { CatalogCacheService } from "@bff/modules/catalog/services/catalog-cache.service.js";
import { RealtimeService } from "@bff/infra/realtime/realtime.service.js";
import { AccountNotificationHandler } from "@bff/modules/notifications/account-cdc-listener.service.js";
type PubSubCallback = (
subscription: { topicName?: string },
@ -40,7 +41,8 @@ export class CatalogCdcSubscriber implements OnModuleInit, OnModuleDestroy {
private readonly sfConnection: SalesforceConnection,
private readonly catalogCache: CatalogCacheService,
private readonly realtime: RealtimeService,
@Inject(Logger) private readonly logger: Logger
@Inject(Logger) private readonly logger: Logger,
@Optional() private readonly accountNotificationHandler?: AccountNotificationHandler
) {
this.numRequested = this.resolveNumRequested();
}
@ -280,6 +282,12 @@ export class CatalogCdcSubscriber implements OnModuleInit, OnModuleDestroy {
const notes = this.extractStringField(payload, ["Internet_Eligibility_Notes__c"]);
const requestId = this.extractStringField(payload, ["Internet_Eligibility_Case_Id__c"]);
// Also extract ID verification fields for notifications
const verificationStatus = this.extractStringField(payload, ["Id_Verification_Status__c"]);
const verificationRejection = this.extractStringField(payload, [
"Id_Verification_Rejection_Message__c",
]);
if (!accountId) {
this.logger.warn("Account eligibility event missing AccountId", {
channel,
@ -312,6 +320,17 @@ export class CatalogCdcSubscriber implements OnModuleInit, OnModuleDestroy {
this.realtime.publish(`account:sf:${accountId}`, "catalog.eligibility.changed", {
timestamp: new Date().toISOString(),
});
// Create in-app notifications for eligibility/verification status changes
if (this.accountNotificationHandler && (status || verificationStatus)) {
void this.accountNotificationHandler.processAccountEvent({
accountId,
eligibilityStatus: status,
eligibilityValue: eligibility,
verificationStatus,
verificationRejectionMessage: verificationRejection,
});
}
}
private mapEligibilityStatus(

View File

@ -3,6 +3,7 @@ import { ConfigModule } from "@nestjs/config";
import { IntegrationsModule } from "@bff/integrations/integrations.module.js";
import { OrdersModule } from "@bff/modules/orders/orders.module.js";
import { CatalogModule } from "@bff/modules/catalog/catalog.module.js";
import { NotificationsModule } from "@bff/modules/notifications/notifications.module.js";
import { CatalogCdcSubscriber } from "./catalog-cdc.subscriber.js";
import { OrderCdcSubscriber } from "./order-cdc.subscriber.js";
@ -12,9 +13,10 @@ import { OrderCdcSubscriber } from "./order-cdc.subscriber.js";
forwardRef(() => IntegrationsModule),
forwardRef(() => OrdersModule),
forwardRef(() => CatalogModule),
forwardRef(() => NotificationsModule),
],
providers: [
CatalogCdcSubscriber, // CDC for catalog cache invalidation
CatalogCdcSubscriber, // CDC for catalog cache invalidation + notifications
OrderCdcSubscriber, // CDC for order cache invalidation
],
})

View File

@ -36,6 +36,8 @@ const checkoutRegisterRequestSchema = z.object({
address: addressFormSchema,
acceptTerms: z.literal(true, { message: "You must accept the terms and conditions" }),
marketingConsent: z.boolean().optional(),
/** Order type for Opportunity creation (e.g., "SIM") */
orderType: z.enum(["Internet", "SIM", "VPN"]).optional(),
});
type CheckoutRegisterRequest = z.infer<typeof checkoutRegisterRequestSchema>;

View File

@ -1,4 +1,4 @@
import { Module } from "@nestjs/common";
import { Module, forwardRef } from "@nestjs/common";
import { CheckoutRegistrationController } from "./checkout-registration.controller.js";
import { CheckoutRegistrationService } from "./services/checkout-registration.service.js";
import { SalesforceModule } from "@bff/integrations/salesforce/salesforce.module.js";
@ -6,6 +6,7 @@ import { WhmcsModule } from "@bff/integrations/whmcs/whmcs.module.js";
import { AuthModule } from "@bff/modules/auth/auth.module.js";
import { UsersModule } from "@bff/modules/users/users.module.js";
import { MappingsModule } from "@bff/modules/id-mappings/mappings.module.js";
import { OrdersModule } from "@bff/modules/orders/orders.module.js";
/**
* Checkout Registration Module
@ -15,9 +16,17 @@ import { MappingsModule } from "@bff/modules/id-mappings/mappings.module.js";
* - Creates WHMCS Client
* - Creates Portal User
* - Links all systems via ID Mappings
* - Creates Opportunity for SIM orders
*/
@Module({
imports: [SalesforceModule, WhmcsModule, AuthModule, UsersModule, MappingsModule],
imports: [
SalesforceModule,
WhmcsModule,
AuthModule,
UsersModule,
MappingsModule,
forwardRef(() => OrdersModule),
],
controllers: [CheckoutRegistrationController],
providers: [CheckoutRegistrationService],
exports: [CheckoutRegistrationService],

View File

@ -1,4 +1,4 @@
import { BadRequestException, Inject, Injectable } from "@nestjs/common";
import { BadRequestException, Inject, Injectable, Optional } from "@nestjs/common";
import { Logger } from "nestjs-pino";
import * as argon2 from "argon2";
import { PrismaService } from "@bff/infra/database/prisma.service.js";
@ -8,6 +8,8 @@ import { WhmcsClientService } from "@bff/integrations/whmcs/services/whmcs-clien
import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js";
import { getErrorMessage } from "@bff/core/utils/error.util.js";
import { WhmcsPaymentService } from "@bff/integrations/whmcs/services/whmcs-payment.service.js";
import { OpportunityMatchingService } from "@bff/modules/orders/services/opportunity-matching.service.js";
import type { OrderTypeValue } from "@customer-portal/domain/orders";
/**
* Request type for checkout registration
@ -27,6 +29,8 @@ interface CheckoutRegisterData {
postcode: string;
country: string;
};
/** Optional order type for Opportunity creation */
orderType?: OrderTypeValue;
}
/**
@ -50,6 +54,7 @@ export class CheckoutRegistrationService {
private readonly whmcsClientService: WhmcsClientService,
private readonly whmcsPaymentService: WhmcsPaymentService,
private readonly mappingsService: MappingsService,
@Optional() private readonly opportunityMatchingService: OpportunityMatchingService | null,
@Inject(Logger) private readonly logger: Logger
) {}
@ -183,8 +188,30 @@ export class CheckoutRegistrationService {
});
portalUserId = user.id;
// Step 7: Generate auth tokens
this.logger.log("Step 7: Generating auth tokens");
// Step 7: Create Opportunity for SIM orders
// Note: Internet orders create Opportunity during eligibility request, not registration
let opportunityId: string | null = null;
if (data.orderType === "SIM" && this.opportunityMatchingService && sfAccountId) {
this.logger.log("Step 7: Creating Opportunity for SIM checkout registration");
try {
opportunityId =
await this.opportunityMatchingService.createOpportunityForCheckoutRegistration(
sfAccountId
);
} catch (error) {
// Log but don't fail registration - Opportunity can be created later during order
this.logger.warn(
"Failed to create Opportunity during registration, will create during order",
{
error: getErrorMessage(error),
sfAccountId,
}
);
}
}
// Step 8: Generate auth tokens
this.logger.log("Step 8: Generating auth tokens");
const tokens = await this.tokenService.generateTokenPair({
id: user.id,
email: user.email,
@ -196,6 +223,7 @@ export class CheckoutRegistrationService {
sfContactId,
sfAccountNumber,
whmcsClientId,
opportunityId,
});
return {

View File

@ -0,0 +1,167 @@
/**
* Account Notification Handler
*
* Processes Salesforce Account events and creates in-app notifications
* when eligibility or verification status changes.
*
* This is called by the existing CatalogCdcSubscriber when account
* events are received. Works alongside Salesforce's email notifications,
* providing both push (email) and pull (in-app) notification channels.
*/
import { Injectable, Inject } from "@nestjs/common";
import { Logger } from "nestjs-pino";
import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js";
import { NotificationService } from "./notifications.service.js";
import { getErrorMessage } from "@bff/core/utils/error.util.js";
import { NOTIFICATION_TYPE, NOTIFICATION_SOURCE } from "@customer-portal/domain/notifications";
export interface AccountEventPayload {
accountId: string;
eligibilityStatus?: string | null;
eligibilityValue?: string | null;
verificationStatus?: string | null;
verificationRejectionMessage?: string | null;
}
@Injectable()
export class AccountNotificationHandler {
constructor(
private readonly mappingsService: MappingsService,
private readonly notificationService: NotificationService,
@Inject(Logger) private readonly logger: Logger
) {}
/**
* Process an account event and create notifications if needed
*/
async processAccountEvent(payload: AccountEventPayload): Promise<void> {
try {
const {
accountId,
eligibilityStatus,
eligibilityValue,
verificationStatus,
verificationRejectionMessage,
} = payload;
// Find the portal user for this account
const mapping = await this.mappingsService.findBySfAccountId(accountId);
if (!mapping?.userId) {
this.logger.debug("No portal user for account, skipping notification", {
accountIdTail: accountId.slice(-4),
});
return;
}
// Process eligibility status change
if (eligibilityStatus) {
await this.processEligibilityChange(
mapping.userId,
accountId,
eligibilityStatus,
eligibilityValue ?? undefined
);
}
// Process verification status change
if (verificationStatus) {
await this.processVerificationChange(
mapping.userId,
accountId,
verificationStatus,
verificationRejectionMessage ?? undefined
);
}
} catch (error) {
this.logger.error("Error processing account event for notifications", {
error: getErrorMessage(error),
accountIdTail: payload.accountId.slice(-4),
});
}
}
/**
* Process eligibility status change
*/
private async processEligibilityChange(
userId: string,
accountId: string,
status: string,
eligibilityValue?: string
): Promise<void> {
const normalizedStatus = status.trim().toLowerCase();
// Only notify on final states, not "pending"
if (normalizedStatus === "pending" || normalizedStatus === "checking") {
return;
}
const isEligible = normalizedStatus === "eligible" || Boolean(eligibilityValue);
const notificationType = isEligible
? NOTIFICATION_TYPE.ELIGIBILITY_ELIGIBLE
: NOTIFICATION_TYPE.ELIGIBILITY_INELIGIBLE;
// Create customized message if we have the eligibility value
let message: string | undefined;
if (isEligible && eligibilityValue) {
message = `We've confirmed ${eligibilityValue} service is available at your address. You can now select a plan and complete your order.`;
}
await this.notificationService.createNotification({
userId,
type: notificationType,
message,
source: NOTIFICATION_SOURCE.SALESFORCE,
sourceId: accountId,
});
this.logger.log("Eligibility notification created", {
userId,
type: notificationType,
accountIdTail: accountId.slice(-4),
});
}
/**
* Process ID verification status change
*/
private async processVerificationChange(
userId: string,
accountId: string,
status: string,
rejectionMessage?: string
): Promise<void> {
const normalizedStatus = status.trim().toLowerCase();
// Only notify on final states
if (normalizedStatus !== "verified" && normalizedStatus !== "rejected") {
return;
}
const isVerified = normalizedStatus === "verified";
const notificationType = isVerified
? NOTIFICATION_TYPE.VERIFICATION_VERIFIED
: NOTIFICATION_TYPE.VERIFICATION_REJECTED;
// Include rejection reason in message
let message: string | undefined;
if (!isVerified && rejectionMessage) {
message = `We couldn't verify your ID: ${rejectionMessage}. Please resubmit a clearer image.`;
}
await this.notificationService.createNotification({
userId,
type: notificationType,
message,
source: NOTIFICATION_SOURCE.SALESFORCE,
sourceId: accountId,
});
this.logger.log("Verification notification created", {
userId,
type: notificationType,
accountIdTail: accountId.slice(-4),
});
}
}

View File

@ -0,0 +1,39 @@
/**
* Notification Cleanup Service
*
* Scheduled job to remove expired notifications from the database.
* Runs daily to clean up notifications older than 30 days.
*/
import { Injectable, Inject } from "@nestjs/common";
import { Cron, CronExpression } from "@nestjs/schedule";
import { Logger } from "nestjs-pino";
import { NotificationService } from "./notifications.service.js";
@Injectable()
export class NotificationCleanupService {
constructor(
private readonly notificationService: NotificationService,
@Inject(Logger) private readonly logger: Logger
) {}
/**
* Clean up expired notifications daily at 3 AM
*/
@Cron(CronExpression.EVERY_DAY_AT_3AM)
async handleCleanup(): Promise<void> {
this.logger.debug("Starting notification cleanup job");
try {
const count = await this.notificationService.cleanupExpired();
if (count > 0) {
this.logger.log("Notification cleanup completed", { deletedCount: count });
}
} catch (error) {
this.logger.error("Notification cleanup job failed", {
error: error instanceof Error ? error.message : String(error),
});
}
}
}

View File

@ -0,0 +1,93 @@
/**
* Notifications Controller
*
* API endpoints for managing in-app notifications.
*/
import {
Controller,
Get,
Post,
Param,
Query,
Req,
UseGuards,
ParseIntPipe,
DefaultValuePipe,
ParseBoolPipe,
} from "@nestjs/common";
import { RateLimit, RateLimitGuard } from "@bff/core/rate-limiting/index.js";
import type { RequestWithUser } from "@bff/modules/auth/auth.types.js";
import { NotificationService } from "./notifications.service.js";
import type { NotificationListResponse } from "@customer-portal/domain/notifications";
@Controller("notifications")
@UseGuards(RateLimitGuard)
export class NotificationsController {
constructor(private readonly notificationService: NotificationService) {}
/**
* Get notifications for the current user
*/
@Get()
@RateLimit({ limit: 60, ttl: 60 })
async getNotifications(
@Req() req: RequestWithUser,
@Query("limit", new DefaultValuePipe(20), ParseIntPipe) limit: number,
@Query("offset", new DefaultValuePipe(0), ParseIntPipe) offset: number,
@Query("includeRead", new DefaultValuePipe(true), ParseBoolPipe)
includeRead: boolean
): Promise<NotificationListResponse> {
return this.notificationService.getNotifications(req.user.id, {
limit: Math.min(limit, 50), // Cap at 50
offset,
includeRead,
});
}
/**
* Get unread notification count for the current user
*/
@Get("unread-count")
@RateLimit({ limit: 120, ttl: 60 })
async getUnreadCount(@Req() req: RequestWithUser): Promise<{ count: number }> {
const count = await this.notificationService.getUnreadCount(req.user.id);
return { count };
}
/**
* Mark a specific notification as read
*/
@Post(":id/read")
@RateLimit({ limit: 60, ttl: 60 })
async markAsRead(
@Req() req: RequestWithUser,
@Param("id") notificationId: string
): Promise<{ success: boolean }> {
await this.notificationService.markAsRead(notificationId, req.user.id);
return { success: true };
}
/**
* Mark all notifications as read
*/
@Post("read-all")
@RateLimit({ limit: 10, ttl: 60 })
async markAllAsRead(@Req() req: RequestWithUser): Promise<{ success: boolean }> {
await this.notificationService.markAllAsRead(req.user.id);
return { success: true };
}
/**
* Dismiss a notification (hide from UI)
*/
@Post(":id/dismiss")
@RateLimit({ limit: 60, ttl: 60 })
async dismiss(
@Req() req: RequestWithUser,
@Param("id") notificationId: string
): Promise<{ success: boolean }> {
await this.notificationService.dismiss(notificationId, req.user.id);
return { success: true };
}
}

View File

@ -0,0 +1,24 @@
/**
* Notifications Module
*
* Provides in-app notification functionality:
* - NotificationService: CRUD operations for notifications
* - NotificationsController: API endpoints
* - AccountNotificationHandler: Creates notifications from SF events
* - NotificationCleanupService: Removes expired notifications
*/
import { Module, forwardRef } from "@nestjs/common";
import { MappingsModule } from "@bff/modules/id-mappings/mappings.module.js";
import { NotificationService } from "./notifications.service.js";
import { NotificationsController } from "./notifications.controller.js";
import { AccountNotificationHandler } from "./account-cdc-listener.service.js";
import { NotificationCleanupService } from "./notification-cleanup.service.js";
@Module({
imports: [forwardRef(() => MappingsModule)],
controllers: [NotificationsController],
providers: [NotificationService, AccountNotificationHandler, NotificationCleanupService],
exports: [NotificationService, AccountNotificationHandler],
})
export class NotificationsModule {}

View File

@ -0,0 +1,325 @@
/**
* Notification Service
*
* Manages in-app notifications stored in the portal database.
* Notifications are created in response to Salesforce CDC events
* and displayed alongside email notifications.
*/
import { Injectable, Inject } from "@nestjs/common";
import { Logger } from "nestjs-pino";
import { PrismaService } from "@bff/infra/database/prisma.service.js";
import { getErrorMessage } from "@bff/core/utils/error.util.js";
import {
NOTIFICATION_SOURCE,
NOTIFICATION_TEMPLATES,
type NotificationTypeValue,
type NotificationSourceValue,
type Notification,
type NotificationListResponse,
} from "@customer-portal/domain/notifications";
// Notification expiry in days
const NOTIFICATION_EXPIRY_DAYS = 30;
export interface CreateNotificationParams {
userId: string;
type: NotificationTypeValue;
title?: string;
message?: string;
actionUrl?: string;
actionLabel?: string;
source?: NotificationSourceValue;
sourceId?: string;
}
@Injectable()
export class NotificationService {
constructor(
private readonly prisma: PrismaService,
@Inject(Logger) private readonly logger: Logger
) {}
/**
* Create a notification for a user
*/
async createNotification(params: CreateNotificationParams): Promise<Notification> {
const template = NOTIFICATION_TEMPLATES[params.type];
if (!template) {
throw new Error(`Unknown notification type: ${params.type}`);
}
// Calculate expiry date (30 days from now)
const expiresAt = new Date();
expiresAt.setDate(expiresAt.getDate() + NOTIFICATION_EXPIRY_DAYS);
try {
// Check for duplicate notification (same type + sourceId within last hour)
if (params.sourceId) {
const oneHourAgo = new Date();
oneHourAgo.setHours(oneHourAgo.getHours() - 1);
const existingNotification = await this.prisma.notification.findFirst({
where: {
userId: params.userId,
type: params.type,
sourceId: params.sourceId,
createdAt: { gte: oneHourAgo },
},
});
if (existingNotification) {
this.logger.debug("Duplicate notification detected, skipping", {
userId: params.userId,
type: params.type,
sourceId: params.sourceId,
});
return this.mapToNotification(existingNotification);
}
}
const notification = await this.prisma.notification.create({
data: {
userId: params.userId,
type: params.type,
title: params.title ?? template.title,
message: params.message ?? template.message,
actionUrl: params.actionUrl ?? template.actionUrl ?? null,
actionLabel: params.actionLabel ?? template.actionLabel ?? null,
source: params.source ?? NOTIFICATION_SOURCE.SALESFORCE,
sourceId: params.sourceId ?? null,
expiresAt,
},
});
this.logger.log("Notification created", {
notificationId: notification.id,
userId: params.userId,
type: params.type,
});
return this.mapToNotification(notification);
} catch (error) {
this.logger.error("Failed to create notification", {
error: getErrorMessage(error),
userId: params.userId,
type: params.type,
});
throw new Error("Failed to create notification");
}
}
/**
* Get notifications for a user
*/
async getNotifications(
userId: string,
options?: {
limit?: number;
offset?: number;
includeRead?: boolean;
includeDismissed?: boolean;
}
): Promise<NotificationListResponse> {
const limit = options?.limit ?? 20;
const offset = options?.offset ?? 0;
const now = new Date();
const where = {
userId,
expiresAt: { gt: now },
...(options?.includeDismissed ? {} : { dismissed: false }),
...(options?.includeRead ? {} : {}), // Include all by default for the list
};
try {
const [notifications, total, unreadCount] = await Promise.all([
this.prisma.notification.findMany({
where,
orderBy: { createdAt: "desc" },
take: limit,
skip: offset,
}),
this.prisma.notification.count({ where }),
this.prisma.notification.count({
where: {
userId,
read: false,
dismissed: false,
expiresAt: { gt: now },
},
}),
]);
return {
notifications: notifications.map(n => this.mapToNotification(n)),
unreadCount,
total,
};
} catch (error) {
this.logger.error("Failed to get notifications", {
error: getErrorMessage(error),
userId,
});
throw new Error("Failed to get notifications");
}
}
/**
* Get unread notification count for a user
*/
async getUnreadCount(userId: string): Promise<number> {
const now = new Date();
try {
return await this.prisma.notification.count({
where: {
userId,
read: false,
dismissed: false,
expiresAt: { gt: now },
},
});
} catch (error) {
this.logger.error("Failed to get unread count", {
error: getErrorMessage(error),
userId,
});
return 0;
}
}
/**
* Mark a notification as read
*/
async markAsRead(notificationId: string, userId: string): Promise<void> {
try {
await this.prisma.notification.updateMany({
where: { id: notificationId, userId },
data: { read: true, readAt: new Date() },
});
this.logger.debug("Notification marked as read", {
notificationId,
userId,
});
} catch (error) {
this.logger.error("Failed to mark notification as read", {
error: getErrorMessage(error),
notificationId,
userId,
});
throw new Error("Failed to update notification");
}
}
/**
* Mark all notifications as read for a user
*/
async markAllAsRead(userId: string): Promise<void> {
try {
const result = await this.prisma.notification.updateMany({
where: { userId, read: false },
data: { read: true, readAt: new Date() },
});
this.logger.debug("All notifications marked as read", {
userId,
count: result.count,
});
} catch (error) {
this.logger.error("Failed to mark all notifications as read", {
error: getErrorMessage(error),
userId,
});
throw new Error("Failed to update notifications");
}
}
/**
* Dismiss a notification (hide from UI)
*/
async dismiss(notificationId: string, userId: string): Promise<void> {
try {
await this.prisma.notification.updateMany({
where: { id: notificationId, userId },
data: { dismissed: true, read: true, readAt: new Date() },
});
this.logger.debug("Notification dismissed", {
notificationId,
userId,
});
} catch (error) {
this.logger.error("Failed to dismiss notification", {
error: getErrorMessage(error),
notificationId,
userId,
});
throw new Error("Failed to dismiss notification");
}
}
/**
* Clean up expired notifications (called by scheduled job)
*/
async cleanupExpired(): Promise<number> {
try {
const result = await this.prisma.notification.deleteMany({
where: {
expiresAt: { lt: new Date() },
},
});
if (result.count > 0) {
this.logger.log("Cleaned up expired notifications", {
count: result.count,
});
}
return result.count;
} catch (error) {
this.logger.error("Failed to cleanup expired notifications", {
error: getErrorMessage(error),
});
return 0;
}
}
/**
* Map Prisma model to domain type
*/
private mapToNotification(record: {
id: string;
userId: string;
type: string;
title: string;
message: string | null;
actionUrl: string | null;
actionLabel: string | null;
source: string;
sourceId: string | null;
read: boolean;
readAt: Date | null;
dismissed: boolean;
createdAt: Date;
expiresAt: Date;
}): Notification {
return {
id: record.id,
userId: record.userId,
type: record.type as NotificationTypeValue,
title: record.title,
message: record.message,
actionUrl: record.actionUrl,
actionLabel: record.actionLabel,
source: record.source as NotificationSourceValue,
sourceId: record.sourceId,
read: record.read,
readAt: record.readAt?.toISOString() ?? null,
dismissed: record.dismissed,
createdAt: record.createdAt.toISOString(),
expiresAt: record.expiresAt.toISOString(),
};
}
}

View File

@ -19,6 +19,7 @@
import { Injectable, Inject } from "@nestjs/common";
import { Logger } from "nestjs-pino";
import { SalesforceOpportunityService } from "@bff/integrations/salesforce/services/salesforce-opportunity.service.js";
import { DistributedLockService } from "@bff/infra/cache/distributed-lock.service.js";
import {
type OpportunityProductTypeValue,
type OpportunityStageValue,
@ -78,6 +79,7 @@ export interface ResolvedOpportunity {
export class OpportunityMatchingService {
constructor(
private readonly opportunityService: SalesforceOpportunityService,
private readonly lockService: DistributedLockService,
@Inject(Logger) private readonly logger: Logger
) {}
@ -91,6 +93,9 @@ export class OpportunityMatchingService {
* This is the main entry point for Opportunity matching.
* It handles finding existing Opportunities or creating new ones.
*
* Uses a distributed lock to prevent race conditions when multiple
* concurrent requests try to create Opportunities for the same account.
*
* @param context - Resolution context with account and order details
* @returns Resolved Opportunity with ID and metadata
*/
@ -118,18 +123,32 @@ export class OpportunityMatchingService {
return this.createNewOpportunity(context, "Internet");
}
// Try to find existing open Opportunity
const existingOppId = await this.opportunityService.findOpenOpportunityForAccount(
context.accountId,
productType
// Use distributed lock to prevent race conditions
// Lock key is specific to account + product type
const lockKey = `opportunity:${context.accountId}:${productType}`;
return this.lockService.withLock(
lockKey,
async () => {
// Re-check for existing Opportunity after acquiring lock
// Another request may have created one while we were waiting
const existingOppId = await this.opportunityService.findOpenOpportunityForAccount(
context.accountId,
productType
);
if (existingOppId) {
this.logger.debug("Found existing Opportunity after acquiring lock", {
opportunityId: existingOppId,
});
return this.useExistingOpportunity(existingOppId);
}
// No existing Opportunity found - create new one
return this.createNewOpportunity(context, productType);
},
{ ttlMs: 10_000 } // 10 second lock TTL
);
if (existingOppId) {
return this.useExistingOpportunity(existingOppId);
}
// No existing Opportunity found - create new one
return this.createNewOpportunity(context, productType);
}
// ==========================================================================
@ -140,55 +159,102 @@ export class OpportunityMatchingService {
* Create Opportunity at eligibility request (Internet only)
*
* Called when customer requests Internet eligibility check.
* Creates an Opportunity in "Introduction" stage.
* Uses distributed lock to prevent duplicate Opportunities.
* First checks for existing open Opportunity before creating.
*
* NOTE: The Case is linked TO the Opportunity via Case.OpportunityId,
* not the other way around. So we don't need to store Case ID on Opportunity.
* NOTE: Opportunity Name is auto-generated by Salesforce workflow.
*
* @param accountId - Salesforce Account ID
* @returns Created Opportunity ID
* @returns Opportunity ID (existing or newly created)
*/
async createOpportunityForEligibility(accountId: string): Promise<string> {
this.logger.log("Creating Opportunity for Internet eligibility request", {
accountId,
});
const opportunityId = await this.opportunityService.createOpportunity({
accountId,
productType: OPPORTUNITY_PRODUCT_TYPE.INTERNET,
stage: OPPORTUNITY_STAGE.INTRODUCTION,
source: OPPORTUNITY_SOURCE.INTERNET_ELIGIBILITY,
applicationStage: APPLICATION_STAGE.INTRO_1,
});
const lockKey = `opportunity:${accountId}:${OPPORTUNITY_PRODUCT_TYPE.INTERNET}`;
return opportunityId;
return this.lockService.withLock(
lockKey,
async () => {
// Check for existing open Opportunity first
const existingOppId = await this.opportunityService.findOpenOpportunityForAccount(
accountId,
OPPORTUNITY_PRODUCT_TYPE.INTERNET
);
if (existingOppId) {
this.logger.debug("Found existing Internet Opportunity, reusing", {
opportunityId: existingOppId,
});
return existingOppId;
}
// Create new Opportunity
const opportunityId = await this.opportunityService.createOpportunity({
accountId,
productType: OPPORTUNITY_PRODUCT_TYPE.INTERNET,
stage: OPPORTUNITY_STAGE.INTRODUCTION,
source: OPPORTUNITY_SOURCE.INTERNET_ELIGIBILITY,
applicationStage: APPLICATION_STAGE.INTRO_1,
});
return opportunityId;
},
{ ttlMs: 10_000 }
);
}
/**
* Create Opportunity at checkout registration (SIM only)
*
* Called when customer creates account during SIM checkout.
* Creates an Opportunity in "Introduction" stage.
* Uses distributed lock to prevent duplicate Opportunities.
* First checks for existing open Opportunity before creating.
*
* NOTE: Opportunity Name is auto-generated by Salesforce workflow.
*
* @param accountId - Salesforce Account ID
* @returns Created Opportunity ID
* @returns Opportunity ID (existing or newly created)
*/
async createOpportunityForCheckoutRegistration(accountId: string): Promise<string> {
this.logger.log("Creating Opportunity for SIM checkout registration", {
accountId,
});
const opportunityId = await this.opportunityService.createOpportunity({
accountId,
productType: OPPORTUNITY_PRODUCT_TYPE.SIM,
stage: OPPORTUNITY_STAGE.INTRODUCTION,
source: OPPORTUNITY_SOURCE.SIM_CHECKOUT_REGISTRATION,
applicationStage: APPLICATION_STAGE.INTRO_1,
});
const lockKey = `opportunity:${accountId}:${OPPORTUNITY_PRODUCT_TYPE.SIM}`;
return opportunityId;
return this.lockService.withLock(
lockKey,
async () => {
// Check for existing open Opportunity first
const existingOppId = await this.opportunityService.findOpenOpportunityForAccount(
accountId,
OPPORTUNITY_PRODUCT_TYPE.SIM
);
if (existingOppId) {
this.logger.debug("Found existing SIM Opportunity, reusing", {
opportunityId: existingOppId,
});
return existingOppId;
}
// Create new Opportunity
const opportunityId = await this.opportunityService.createOpportunity({
accountId,
productType: OPPORTUNITY_PRODUCT_TYPE.SIM,
stage: OPPORTUNITY_STAGE.INTRODUCTION,
source: OPPORTUNITY_SOURCE.SIM_CHECKOUT_REGISTRATION,
applicationStage: APPLICATION_STAGE.INTRO_1,
});
return opportunityId;
},
{ ttlMs: 10_000 }
);
}
// ==========================================================================

View File

@ -3,6 +3,7 @@
import Link from "next/link";
import { memo } from "react";
import { Bars3Icon, QuestionMarkCircleIcon } from "@heroicons/react/24/outline";
import { NotificationBell } from "@/features/notifications";
interface HeaderProps {
onMenuClick: () => void;
@ -38,6 +39,8 @@ export const Header = memo(function Header({ onMenuClick, user, profileReady }:
<div className="flex-1" />
<div className="flex items-center gap-1">
<NotificationBell />
<Link
href="/account/support"
prefetch

View File

@ -128,8 +128,14 @@ export function AvailabilityStep() {
</AlertBanner>
) : isPending ? (
<AlertBanner variant="info" title="Review in progress" elevated>
Were reviewing service availability for your address. Once confirmed, you can return
and complete checkout.
<div className="space-y-2 text-sm text-foreground/80">
<p>Our team is verifying NTT serviceability for your address.</p>
<p className="font-medium text-foreground">This usually takes 1-2 business days.</p>
<p className="text-muted-foreground">
We&apos;ll email you at <span className="font-medium">{user?.email}</span> when
complete. You can also check back here anytime.
</p>
</div>
</AlertBanner>
) : (
<div className="space-y-4">
@ -157,8 +163,18 @@ export function AvailabilityStep() {
{internetAvailabilityRequestId ? (
<AlertBanner variant="success" title="Request submitted" elevated>
<div className="text-sm text-foreground/80">
Request ID: <span className="font-mono">{internetAvailabilityRequestId}</span>
<div className="space-y-2 text-sm text-foreground/80">
<p>Your availability check request has been submitted.</p>
<p className="font-medium text-foreground">
This usually takes 1-2 business days.
</p>
<p className="text-muted-foreground">
We&apos;ll email you at <span className="font-medium">{user?.email}</span> when
complete. You can also check back here anytime.
</p>
<p className="text-xs text-muted-foreground/70">
Request ID: <span className="font-mono">{internetAvailabilityRequestId}</span>
</p>
</div>
</AlertBanner>
) : (

View File

@ -0,0 +1,87 @@
"use client";
import { memo, useState, useRef, useCallback, useEffect } from "react";
import { BellIcon } from "@heroicons/react/24/outline";
import { useUnreadNotificationCount } from "../hooks/useNotifications";
import { NotificationDropdown } from "./NotificationDropdown";
import { cn } from "@/lib/utils";
interface NotificationBellProps {
className?: string;
}
export const NotificationBell = memo(function NotificationBell({
className,
}: NotificationBellProps) {
const [isOpen, setIsOpen] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const { data: unreadCount = 0 } = useUnreadNotificationCount();
const toggleDropdown = useCallback(() => {
setIsOpen(prev => !prev);
}, []);
const closeDropdown = useCallback(() => {
setIsOpen(false);
}, []);
// Close dropdown when clicking outside
useEffect(() => {
if (!isOpen) return;
const handleClickOutside = (event: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
}, [isOpen]);
// Close on escape
useEffect(() => {
if (!isOpen) return;
const handleEscape = (event: KeyboardEvent) => {
if (event.key === "Escape") {
setIsOpen(false);
}
};
document.addEventListener("keydown", handleEscape);
return () => {
document.removeEventListener("keydown", handleEscape);
};
}, [isOpen]);
return (
<div ref={containerRef} className={cn("relative", className)}>
<button
type="button"
className={cn(
"relative p-2.5 rounded-xl transition-all duration-200",
"text-muted-foreground hover:text-foreground hover:bg-muted/60",
isOpen && "bg-muted/60 text-foreground"
)}
onClick={toggleDropdown}
aria-label={`Notifications${unreadCount > 0 ? ` (${unreadCount} unread)` : ""}`}
aria-expanded={isOpen}
aria-haspopup="true"
>
<BellIcon className="h-5 w-5" />
{/* Badge */}
{unreadCount > 0 && (
<span className="absolute top-1.5 right-1.5 flex h-4 min-w-4 items-center justify-center rounded-full bg-primary px-1 text-[10px] font-bold text-primary-foreground">
{unreadCount > 9 ? "9+" : unreadCount}
</span>
)}
</button>
<NotificationDropdown isOpen={isOpen} onClose={closeDropdown} />
</div>
);
});

View File

@ -0,0 +1,107 @@
"use client";
import { memo } from "react";
import Link from "next/link";
import { CheckIcon } from "@heroicons/react/24/outline";
import { BellSlashIcon } from "@heroicons/react/24/solid";
import {
useNotifications,
useMarkNotificationAsRead,
useMarkAllNotificationsAsRead,
useDismissNotification,
} from "../hooks/useNotifications";
import { NotificationItem } from "./NotificationItem";
import { cn } from "@/lib/utils";
interface NotificationDropdownProps {
isOpen: boolean;
onClose: () => void;
}
export const NotificationDropdown = memo(function NotificationDropdown({
isOpen,
onClose,
}: NotificationDropdownProps) {
const { data, isLoading } = useNotifications({
limit: 10,
includeRead: true,
enabled: isOpen,
});
const markAsRead = useMarkNotificationAsRead();
const markAllAsRead = useMarkAllNotificationsAsRead();
const dismiss = useDismissNotification();
const notifications = data?.notifications ?? [];
const hasUnread = (data?.unreadCount ?? 0) > 0;
if (!isOpen) return null;
return (
<div
className={cn(
"absolute right-0 top-full mt-2 w-80 sm:w-96",
"bg-popover border border-border rounded-xl shadow-lg z-50 overflow-hidden",
"animate-in fade-in-0 zoom-in-95 duration-100"
)}
>
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
<h3 className="text-sm font-semibold text-foreground">Notifications</h3>
{hasUnread && (
<button
type="button"
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
onClick={() => markAllAsRead.mutate()}
disabled={markAllAsRead.isPending}
>
<CheckIcon className="h-3.5 w-3.5" />
Mark all read
</button>
)}
</div>
{/* Notification list */}
<div className="max-h-96 overflow-y-auto">
{isLoading ? (
<div className="flex items-center justify-center py-8">
<div className="h-5 w-5 animate-spin rounded-full border-2 border-primary border-t-transparent" />
</div>
) : notifications.length === 0 ? (
<div className="flex flex-col items-center justify-center py-10 px-4 text-center">
<BellSlashIcon className="h-10 w-10 text-muted-foreground/40 mb-3" />
<p className="text-sm text-muted-foreground">No notifications yet</p>
<p className="text-xs text-muted-foreground/70 mt-1">
We&apos;ll notify you when something important happens
</p>
</div>
) : (
<div className="divide-y divide-border/50">
{notifications.map(notification => (
<NotificationItem
key={notification.id}
notification={notification}
onMarkAsRead={id => markAsRead.mutate(id)}
onDismiss={id => dismiss.mutate(id)}
/>
))}
</div>
)}
</div>
{/* Footer */}
{notifications.length > 0 && (
<div className="px-4 py-3 border-t border-border">
<Link
href="/account/notifications"
className="block text-center text-xs text-muted-foreground hover:text-foreground transition-colors"
onClick={onClose}
prefetch={false}
>
View all notifications
</Link>
</div>
)}
</div>
);
});

View File

@ -0,0 +1,115 @@
"use client";
import { memo, useCallback } from "react";
import Link from "next/link";
import { formatDistanceToNow } from "date-fns";
import { XMarkIcon } from "@heroicons/react/24/outline";
import {
CheckCircleIcon,
ExclamationCircleIcon,
InformationCircleIcon,
} from "@heroicons/react/24/solid";
import type { Notification } from "@customer-portal/domain/notifications";
import { NOTIFICATION_TYPE } from "@customer-portal/domain/notifications";
import { cn } from "@/lib/utils";
interface NotificationItemProps {
notification: Notification;
onMarkAsRead?: (id: string) => void;
onDismiss?: (id: string) => void;
}
const getNotificationIcon = (type: Notification["type"]) => {
switch (type) {
case NOTIFICATION_TYPE.ELIGIBILITY_ELIGIBLE:
case NOTIFICATION_TYPE.VERIFICATION_VERIFIED:
case NOTIFICATION_TYPE.ORDER_ACTIVATED:
return <CheckCircleIcon className="h-5 w-5 text-emerald-500 flex-shrink-0" />;
case NOTIFICATION_TYPE.ELIGIBILITY_INELIGIBLE:
case NOTIFICATION_TYPE.VERIFICATION_REJECTED:
case NOTIFICATION_TYPE.ORDER_FAILED:
return <ExclamationCircleIcon className="h-5 w-5 text-amber-500 flex-shrink-0" />;
default:
return <InformationCircleIcon className="h-5 w-5 text-blue-500 flex-shrink-0" />;
}
};
export const NotificationItem = memo(function NotificationItem({
notification,
onMarkAsRead,
onDismiss,
}: NotificationItemProps) {
const handleClick = useCallback(() => {
if (!notification.read && onMarkAsRead) {
onMarkAsRead(notification.id);
}
}, [notification.id, notification.read, onMarkAsRead]);
const handleDismiss = useCallback(
(e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
onDismiss?.(notification.id);
},
[notification.id, onDismiss]
);
const content = (
<div
className={cn(
"group relative flex gap-3 p-3 rounded-lg transition-colors",
!notification.read && "bg-primary/5",
"hover:bg-muted/60"
)}
onClick={handleClick}
>
{/* Icon */}
<div className="pt-0.5">{getNotificationIcon(notification.type)}</div>
{/* Content */}
<div className="flex-1 min-w-0 space-y-1">
<p
className={cn(
"text-sm leading-snug",
!notification.read ? "font-medium text-foreground" : "text-muted-foreground"
)}
>
{notification.title}
</p>
{notification.message && (
<p className="text-xs text-muted-foreground line-clamp-2">{notification.message}</p>
)}
<p className="text-xs text-muted-foreground/70">
{formatDistanceToNow(new Date(notification.createdAt), {
addSuffix: true,
})}
</p>
</div>
{/* Dismiss button */}
<button
type="button"
className="absolute top-2 right-2 p-1 rounded opacity-0 group-hover:opacity-100 hover:bg-muted transition-opacity"
onClick={handleDismiss}
aria-label="Dismiss notification"
>
<XMarkIcon className="h-4 w-4 text-muted-foreground" />
</button>
{/* Unread indicator */}
{!notification.read && (
<div className="absolute top-3 right-3 h-2 w-2 rounded-full bg-primary group-hover:hidden" />
)}
</div>
);
if (notification.actionUrl) {
return (
<Link href={notification.actionUrl} className="block" prefetch={false}>
{content}
</Link>
);
}
return content;
});

View File

@ -0,0 +1,90 @@
/**
* Notification Hooks
*
* React Query hooks for managing notifications.
*/
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { notificationService } from "../services/notification.service";
const NOTIFICATION_QUERY_KEY = ["notifications"];
const UNREAD_COUNT_QUERY_KEY = ["notifications", "unread-count"];
/**
* Hook to fetch notifications
*/
export function useNotifications(options?: {
limit?: number;
includeRead?: boolean;
enabled?: boolean;
}) {
return useQuery({
queryKey: [...NOTIFICATION_QUERY_KEY, "list", options?.limit, options?.includeRead],
queryFn: () =>
notificationService.getNotifications({
limit: options?.limit ?? 10,
includeRead: options?.includeRead ?? true,
}),
staleTime: 30 * 1000, // 30 seconds
enabled: options?.enabled ?? true,
});
}
/**
* Hook to get unread notification count
*/
export function useUnreadNotificationCount(enabled = true) {
return useQuery({
queryKey: UNREAD_COUNT_QUERY_KEY,
queryFn: () => notificationService.getUnreadCount(),
staleTime: 30 * 1000, // 30 seconds
refetchInterval: 60 * 1000, // Refetch every minute
enabled,
});
}
/**
* Hook to mark a notification as read
*/
export function useMarkNotificationAsRead() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (notificationId: string) => notificationService.markAsRead(notificationId),
onSuccess: () => {
// Invalidate both queries
void queryClient.invalidateQueries({ queryKey: NOTIFICATION_QUERY_KEY });
void queryClient.invalidateQueries({ queryKey: UNREAD_COUNT_QUERY_KEY });
},
});
}
/**
* Hook to mark all notifications as read
*/
export function useMarkAllNotificationsAsRead() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: () => notificationService.markAllAsRead(),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: NOTIFICATION_QUERY_KEY });
void queryClient.invalidateQueries({ queryKey: UNREAD_COUNT_QUERY_KEY });
},
});
}
/**
* Hook to dismiss a notification
*/
export function useDismissNotification() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (notificationId: string) => notificationService.dismiss(notificationId),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: NOTIFICATION_QUERY_KEY });
void queryClient.invalidateQueries({ queryKey: UNREAD_COUNT_QUERY_KEY });
},
});
}

View File

@ -0,0 +1,22 @@
/**
* Notifications Feature
*
* In-app notification components, hooks, and services.
*/
// Services
export { notificationService } from "./services/notification.service";
// Hooks
export {
useNotifications,
useUnreadNotificationCount,
useMarkNotificationAsRead,
useMarkAllNotificationsAsRead,
useDismissNotification,
} from "./hooks/useNotifications";
// Components
export { NotificationBell } from "./components/NotificationBell";
export { NotificationDropdown } from "./components/NotificationDropdown";
export { NotificationItem } from "./components/NotificationItem";

View File

@ -0,0 +1,61 @@
/**
* Notification Service
*
* Handles API calls for in-app notifications.
*/
import { apiClient, getDataOrThrow } from "@/lib/api";
import type { NotificationListResponse } from "@customer-portal/domain/notifications";
const BASE_PATH = "/api/notifications";
export const notificationService = {
/**
* Get notifications for the current user
*/
async getNotifications(params?: {
limit?: number;
offset?: number;
includeRead?: boolean;
}): Promise<NotificationListResponse> {
const query: Record<string, string> = {};
if (params?.limit) query.limit = String(params.limit);
if (params?.offset) query.offset = String(params.offset);
if (params?.includeRead !== undefined) query.includeRead = String(params.includeRead);
const response = await apiClient.GET<NotificationListResponse>(BASE_PATH, {
params: { query },
});
return getDataOrThrow(response);
},
/**
* Get unread notification count
*/
async getUnreadCount(): Promise<number> {
const response = await apiClient.GET<{ count: number }>(`${BASE_PATH}/unread-count`);
const data = getDataOrThrow(response);
return data.count;
},
/**
* Mark a notification as read
*/
async markAsRead(notificationId: string): Promise<void> {
await apiClient.POST<{ success: boolean }>(`${BASE_PATH}/${notificationId}/read`);
},
/**
* Mark all notifications as read
*/
async markAllAsRead(): Promise<void> {
await apiClient.POST<{ success: boolean }>(`${BASE_PATH}/read-all`);
},
/**
* Dismiss a notification
*/
async dismiss(notificationId: string): Promise<void> {
await apiClient.POST<{ success: boolean }>(`${BASE_PATH}/${notificationId}/dismiss`);
},
};

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,26 @@
/**
* Notifications Domain
*
* Exports all notification-related contracts, schemas, and types.
* Used for in-app notifications synced with Salesforce email triggers.
*/
export {
// Enums
NOTIFICATION_TYPE,
NOTIFICATION_SOURCE,
type NotificationTypeValue,
type NotificationSourceValue,
// Templates
NOTIFICATION_TEMPLATES,
getNotificationTemplate,
// Schemas
notificationSchema,
createNotificationRequestSchema,
notificationListResponseSchema,
// Types
type Notification,
type CreateNotificationRequest,
type NotificationTemplate,
type NotificationListResponse,
} from "./schema.js";

View File

@ -0,0 +1,209 @@
/**
* Notifications Schema
*
* Zod schemas and types for in-app notifications.
*/
import { z } from "zod";
// =============================================================================
// Enums
// =============================================================================
export const NOTIFICATION_TYPE = {
ELIGIBILITY_ELIGIBLE: "ELIGIBILITY_ELIGIBLE",
ELIGIBILITY_INELIGIBLE: "ELIGIBILITY_INELIGIBLE",
VERIFICATION_VERIFIED: "VERIFICATION_VERIFIED",
VERIFICATION_REJECTED: "VERIFICATION_REJECTED",
ORDER_APPROVED: "ORDER_APPROVED",
ORDER_ACTIVATED: "ORDER_ACTIVATED",
ORDER_FAILED: "ORDER_FAILED",
CANCELLATION_SCHEDULED: "CANCELLATION_SCHEDULED",
CANCELLATION_COMPLETE: "CANCELLATION_COMPLETE",
PAYMENT_METHOD_EXPIRING: "PAYMENT_METHOD_EXPIRING",
INVOICE_DUE: "INVOICE_DUE",
SYSTEM_ANNOUNCEMENT: "SYSTEM_ANNOUNCEMENT",
} as const;
export type NotificationTypeValue = (typeof NOTIFICATION_TYPE)[keyof typeof NOTIFICATION_TYPE];
export const NOTIFICATION_SOURCE = {
SALESFORCE: "SALESFORCE",
WHMCS: "WHMCS",
PORTAL: "PORTAL",
SYSTEM: "SYSTEM",
} as const;
export type NotificationSourceValue =
(typeof NOTIFICATION_SOURCE)[keyof typeof NOTIFICATION_SOURCE];
// =============================================================================
// Notification Templates
// =============================================================================
export interface NotificationTemplate {
type: NotificationTypeValue;
title: string;
message: string;
actionUrl?: string;
actionLabel?: string;
priority: "low" | "medium" | "high";
}
export const NOTIFICATION_TEMPLATES: Record<NotificationTypeValue, NotificationTemplate> = {
[NOTIFICATION_TYPE.ELIGIBILITY_ELIGIBLE]: {
type: NOTIFICATION_TYPE.ELIGIBILITY_ELIGIBLE,
title: "Good news! Internet service is available",
message:
"We've confirmed internet service is available at your address. You can now select a plan and complete your order.",
actionUrl: "/shop/internet",
actionLabel: "View Plans",
priority: "high",
},
[NOTIFICATION_TYPE.ELIGIBILITY_INELIGIBLE]: {
type: NOTIFICATION_TYPE.ELIGIBILITY_INELIGIBLE,
title: "Internet service not available",
message:
"Unfortunately, internet service is not currently available at your address. We'll notify you if this changes.",
actionUrl: "/support",
actionLabel: "Contact Support",
priority: "high",
},
[NOTIFICATION_TYPE.VERIFICATION_VERIFIED]: {
type: NOTIFICATION_TYPE.VERIFICATION_VERIFIED,
title: "ID verification complete",
message: "Your identity has been verified. You can now complete your order.",
actionUrl: "/checkout",
actionLabel: "Continue Checkout",
priority: "high",
},
[NOTIFICATION_TYPE.VERIFICATION_REJECTED]: {
type: NOTIFICATION_TYPE.VERIFICATION_REJECTED,
title: "ID verification requires attention",
message: "We couldn't verify your ID. Please review the feedback and resubmit.",
actionUrl: "/account/verification",
actionLabel: "Resubmit",
priority: "high",
},
[NOTIFICATION_TYPE.ORDER_APPROVED]: {
type: NOTIFICATION_TYPE.ORDER_APPROVED,
title: "Order approved",
message: "Your order has been approved and is being processed.",
actionUrl: "/orders",
actionLabel: "View Order",
priority: "medium",
},
[NOTIFICATION_TYPE.ORDER_ACTIVATED]: {
type: NOTIFICATION_TYPE.ORDER_ACTIVATED,
title: "Service activated",
message: "Your service is now active and ready to use.",
actionUrl: "/subscriptions",
actionLabel: "View Service",
priority: "high",
},
[NOTIFICATION_TYPE.ORDER_FAILED]: {
type: NOTIFICATION_TYPE.ORDER_FAILED,
title: "Order requires attention",
message: "There was an issue processing your order. Please contact support.",
actionUrl: "/support",
actionLabel: "Contact Support",
priority: "high",
},
[NOTIFICATION_TYPE.CANCELLATION_SCHEDULED]: {
type: NOTIFICATION_TYPE.CANCELLATION_SCHEDULED,
title: "Cancellation scheduled",
message: "Your cancellation request has been received and scheduled.",
actionUrl: "/subscriptions",
actionLabel: "View Details",
priority: "medium",
},
[NOTIFICATION_TYPE.CANCELLATION_COMPLETE]: {
type: NOTIFICATION_TYPE.CANCELLATION_COMPLETE,
title: "Service cancelled",
message: "Your service has been successfully cancelled.",
actionUrl: "/subscriptions",
actionLabel: "View Details",
priority: "medium",
},
[NOTIFICATION_TYPE.PAYMENT_METHOD_EXPIRING]: {
type: NOTIFICATION_TYPE.PAYMENT_METHOD_EXPIRING,
title: "Payment method expiring soon",
message:
"Your payment method is expiring soon. Please update it to avoid service interruption.",
actionUrl: "/billing/payment-methods",
actionLabel: "Update Payment",
priority: "high",
},
[NOTIFICATION_TYPE.INVOICE_DUE]: {
type: NOTIFICATION_TYPE.INVOICE_DUE,
title: "Invoice due",
message: "You have an invoice due. Please make a payment to keep your service active.",
actionUrl: "/billing/invoices",
actionLabel: "Pay Now",
priority: "high",
},
[NOTIFICATION_TYPE.SYSTEM_ANNOUNCEMENT]: {
type: NOTIFICATION_TYPE.SYSTEM_ANNOUNCEMENT,
title: "System announcement",
message: "Important information about your service.",
priority: "low",
},
};
/**
* Get notification template by type with optional overrides
*/
export function getNotificationTemplate(
type: NotificationTypeValue,
overrides?: Partial<NotificationTemplate>
): NotificationTemplate {
const template = NOTIFICATION_TEMPLATES[type];
if (!template) {
throw new Error(`Unknown notification type: ${type}`);
}
return { ...template, ...overrides };
}
// =============================================================================
// Schemas
// =============================================================================
export const notificationSchema = z.object({
id: z.string().uuid(),
userId: z.string().uuid(),
type: z.nativeEnum(NOTIFICATION_TYPE),
title: z.string(),
message: z.string().nullable(),
actionUrl: z.string().nullable(),
actionLabel: z.string().nullable(),
source: z.nativeEnum(NOTIFICATION_SOURCE),
sourceId: z.string().nullable(),
read: z.boolean(),
readAt: z.string().datetime().nullable(),
dismissed: z.boolean(),
createdAt: z.string().datetime(),
expiresAt: z.string().datetime(),
});
export type Notification = z.infer<typeof notificationSchema>;
export const createNotificationRequestSchema = z.object({
userId: z.string().uuid(),
type: z.nativeEnum(NOTIFICATION_TYPE),
title: z.string().optional(),
message: z.string().optional(),
actionUrl: z.string().optional(),
actionLabel: z.string().optional(),
source: z.nativeEnum(NOTIFICATION_SOURCE).optional(),
sourceId: z.string().optional(),
});
export type CreateNotificationRequest = z.infer<typeof createNotificationRequestSchema>;
export const notificationListResponseSchema = z.object({
notifications: z.array(notificationSchema),
unreadCount: z.number(),
total: z.number(),
});
export type NotificationListResponse = z.infer<typeof notificationListResponseSchema>;

View File

@ -146,6 +146,22 @@
"./toolkit/*": {
"import": "./dist/toolkit/*.js",
"types": "./dist/toolkit/*.d.ts"
},
"./notifications": {
"import": "./dist/notifications/index.js",
"types": "./dist/notifications/index.d.ts"
},
"./notifications/*": {
"import": "./dist/notifications/*.js",
"types": "./dist/notifications/*.d.ts"
},
"./salesforce": {
"import": "./dist/salesforce/index.js",
"types": "./dist/salesforce/index.d.ts"
},
"./salesforce/*": {
"import": "./dist/salesforce/*.js",
"types": "./dist/salesforce/*.d.ts"
}
},
"scripts": {

View File

@ -0,0 +1,165 @@
/**
* Salesforce Field Maps
*
* Centralized mapping of logical field names to Salesforce API field names.
* This provides a single source of truth for all Salesforce custom fields
* used by the Customer Portal.
*
* Usage:
* ```typescript
* import { ACCOUNT_FIELDS } from "@customer-portal/domain/salesforce";
*
* const eligibilityValue = account[ACCOUNT_FIELDS.eligibility.value];
* ```
*/
// =============================================================================
// Account Fields
// =============================================================================
export const ACCOUNT_FIELDS = {
// Standard fields
id: "Id",
name: "Name",
personEmail: "PersonEmail",
phone: "Phone",
// Portal identification
customerNumber: "SF_Account_No__c",
portalStatus: "Portal_Status__c",
registrationSource: "Portal_Registration_Source__c",
lastSignIn: "Portal_Last_SignIn__c",
// WHMCS integration
whmcsAccountId: "WH_Account__c",
// Internet eligibility
eligibility: {
value: "Internet_Eligibility__c",
status: "Internet_Eligibility_Status__c",
requestedAt: "Internet_Eligibility_Request_Date_Time__c",
checkedAt: "Internet_Eligibility_Checked_Date_Time__c",
notes: "Internet_Eligibility_Notes__c",
caseId: "Internet_Eligibility_Case_Id__c",
},
// ID verification
verification: {
status: "Id_Verification_Status__c",
submittedAt: "Id_Verification_Submitted_Date_Time__c",
verifiedAt: "Id_Verification_Verified_Date_Time__c",
note: "Id_Verification_Note__c",
rejectionMessage: "Id_Verification_Rejection_Message__c",
},
// Address fields
address: {
street: "BillingStreet",
city: "BillingCity",
state: "BillingState",
postalCode: "BillingPostalCode",
country: "BillingCountry",
},
} as const;
export type AccountFieldKey = keyof typeof ACCOUNT_FIELDS;
// =============================================================================
// Opportunity Fields
// =============================================================================
export const OPPORTUNITY_FIELDS = {
// Standard fields
id: "Id",
name: "Name",
accountId: "AccountId",
stage: "StageName",
closeDate: "CloseDate",
probability: "Probability",
// Product classification
commodityType: "CommodityType",
applicationStage: "Application_Stage__c",
// Portal integration
portalSource: "Portal_Source__c",
whmcsServiceId: "WHMCS_Service_ID__c",
// Cancellation
cancellationNotice: "CancellationNotice__c",
scheduledCancellationDate: "ScheduledCancellationDateAndTime__c",
lineReturn: "LineReturn__c",
} as const;
export type OpportunityFieldKey = keyof typeof OPPORTUNITY_FIELDS;
// =============================================================================
// Order Fields
// =============================================================================
export const ORDER_FIELDS = {
// Standard fields
id: "Id",
orderNumber: "OrderNumber",
accountId: "AccountId",
opportunityId: "OpportunityId",
status: "Status",
effectiveDate: "EffectiveDate",
totalAmount: "TotalAmount",
// Activation tracking
activationStatus: "Activation_Status__c",
activationErrorCode: "Activation_Error_Code__c",
activationErrorMessage: "Activation_Error_Message__c",
activationLastAttemptAt: "Activation_Last_Attempt_At__c",
// WHMCS integration
whmcsOrderId: "WHMCS_Order_ID__c",
// Address fields
billing: {
street: "BillingStreet",
city: "BillingCity",
state: "BillingState",
postalCode: "BillingPostalCode",
country: "BillingCountry",
},
} as const;
export type OrderFieldKey = keyof typeof ORDER_FIELDS;
// =============================================================================
// Case Fields
// =============================================================================
export const CASE_FIELDS = {
// Standard fields
id: "Id",
caseNumber: "CaseNumber",
accountId: "AccountId",
contactId: "ContactId",
opportunityId: "OpportunityId",
subject: "Subject",
description: "Description",
status: "Status",
priority: "Priority",
origin: "Origin",
type: "Type",
// Portal fields
createdAt: "CreatedDate",
closedAt: "ClosedDate",
} as const;
export type CaseFieldKey = keyof typeof CASE_FIELDS;
// =============================================================================
// Combined Export
// =============================================================================
export const SALESFORCE_FIELDS = {
account: ACCOUNT_FIELDS,
opportunity: OPPORTUNITY_FIELDS,
order: ORDER_FIELDS,
case: CASE_FIELDS,
} as const;

View File

@ -0,0 +1,19 @@
/**
* Salesforce Domain
*
* Centralized Salesforce field maps and constants.
* Provides a single source of truth for field names used across
* the BFF integration layer.
*/
export {
SALESFORCE_FIELDS,
ACCOUNT_FIELDS,
OPPORTUNITY_FIELDS,
ORDER_FIELDS,
CASE_FIELDS,
type AccountFieldKey,
type OpportunityFieldKey,
type OrderFieldKey,
type CaseFieldKey,
} from "./field-maps.js";

View File

@ -18,7 +18,9 @@
"customer/**/*",
"dashboard/**/*",
"mappings/**/*",
"notifications/**/*",
"opportunity/**/*",
"salesforce/**/*",
"orders/**/*",
"payments/**/*",
"providers/**/*",

38
pnpm-lock.yaml generated
View File

@ -80,6 +80,9 @@ importers:
"@nestjs/platform-express":
specifier: ^11.1.9
version: 11.1.9(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.9)
"@nestjs/schedule":
specifier: ^6.1.0
version: 6.1.0(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.9)
"@prisma/adapter-pg":
specifier: ^7.1.0
version: 7.1.0
@ -1697,6 +1700,15 @@ packages:
"@nestjs/common": ^11.0.0
"@nestjs/core": ^11.0.0
"@nestjs/schedule@6.1.0":
resolution:
{
integrity: sha512-W25Ydc933Gzb1/oo7+bWzzDiOissE+h/dhIAPugA39b9MuIzBbLybuXpc1AjoQLczO3v0ldmxaffVl87W0uqoQ==,
}
peerDependencies:
"@nestjs/common": ^10.0.0 || ^11.0.0
"@nestjs/core": ^10.0.0 || ^11.0.0
"@nestjs/schematics@11.0.9":
resolution:
{
@ -2480,6 +2492,12 @@ packages:
integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==,
}
"@types/luxon@3.7.1":
resolution:
{
integrity: sha512-H3iskjFIAn5SlJU7OuxUmTEpebK6TKB8rxZShDslBMZJ5u9S//KM1sbdAisiSrqwLQncVjnpi2OK2J51h+4lsg==,
}
"@types/node@18.19.130":
resolution:
{
@ -3627,6 +3645,13 @@ packages:
}
engines: { node: ">=12.0.0" }
cron@4.3.5:
resolution:
{
integrity: sha512-hKPP7fq1+OfyCqoePkKfVq7tNAdFwiQORr4lZUHwrf0tebC65fYEeWgOrXOL6prn1/fegGOdTfrM6e34PJfksg==,
}
engines: { node: ">=18.x" }
cross-env@10.1.0:
resolution:
{
@ -8289,6 +8314,12 @@ snapshots:
transitivePeerDependencies:
- supports-color
"@nestjs/schedule@6.1.0(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.9)":
dependencies:
"@nestjs/common": 11.1.9(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2)
"@nestjs/core": 11.1.9(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.9)(reflect-metadata@0.2.2)(rxjs@7.8.2)
cron: 4.3.5
"@nestjs/schematics@11.0.9(chokidar@4.0.3)(typescript@5.9.3)":
dependencies:
"@angular-devkit/core": 19.2.17(chokidar@4.0.3)
@ -8753,6 +8784,8 @@ snapshots:
"@types/json-schema@7.0.15": {}
"@types/luxon@3.7.1": {}
"@types/node@18.19.130":
dependencies:
undici-types: 5.26.5
@ -9538,6 +9571,11 @@ snapshots:
dependencies:
luxon: 3.7.2
cron@4.3.5:
dependencies:
"@types/luxon": 3.7.1
luxon: 3.7.2
cross-env@10.1.0:
dependencies:
"@epic-web/invariant": 1.0.0