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:
parent
d9734b0c82
commit
2b183272cf
@ -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",
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
7
apps/bff/src/infra/cache/cache.module.ts
vendored
7
apps/bff/src/infra/cache/cache.module.ts
vendored
@ -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 {}
|
||||
|
||||
|
||||
188
apps/bff/src/infra/cache/distributed-lock.service.ts
vendored
Normal file
188
apps/bff/src/infra/cache/distributed-lock.service.ts
vendored
Normal 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));
|
||||
}
|
||||
}
|
||||
@ -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(
|
||||
|
||||
@ -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
|
||||
],
|
||||
})
|
||||
|
||||
@ -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>;
|
||||
|
||||
@ -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],
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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),
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -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),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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 };
|
||||
}
|
||||
}
|
||||
24
apps/bff/src/modules/notifications/notifications.module.ts
Normal file
24
apps/bff/src/modules/notifications/notifications.module.ts
Normal 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 {}
|
||||
325
apps/bff/src/modules/notifications/notifications.service.ts
Normal file
325
apps/bff/src/modules/notifications/notifications.service.ts
Normal 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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -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 }
|
||||
);
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
|
||||
@ -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
|
||||
|
||||
@ -128,8 +128,14 @@ export function AvailabilityStep() {
|
||||
</AlertBanner>
|
||||
) : isPending ? (
|
||||
<AlertBanner variant="info" title="Review in progress" elevated>
|
||||
We’re 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'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'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>
|
||||
) : (
|
||||
|
||||
@ -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>
|
||||
);
|
||||
});
|
||||
@ -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'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>
|
||||
);
|
||||
});
|
||||
@ -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;
|
||||
});
|
||||
@ -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 });
|
||||
},
|
||||
});
|
||||
}
|
||||
22
apps/portal/src/features/notifications/index.ts
Normal file
22
apps/portal/src/features/notifications/index.ts
Normal 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";
|
||||
@ -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
26
packages/domain/notifications/index.ts
Normal file
26
packages/domain/notifications/index.ts
Normal 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";
|
||||
209
packages/domain/notifications/schema.ts
Normal file
209
packages/domain/notifications/schema.ts
Normal 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>;
|
||||
@ -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": {
|
||||
|
||||
165
packages/domain/salesforce/field-maps.ts
Normal file
165
packages/domain/salesforce/field-maps.ts
Normal 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;
|
||||
19
packages/domain/salesforce/index.ts
Normal file
19
packages/domain/salesforce/index.ts
Normal 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";
|
||||
@ -18,7 +18,9 @@
|
||||
"customer/**/*",
|
||||
"dashboard/**/*",
|
||||
"mappings/**/*",
|
||||
"notifications/**/*",
|
||||
"opportunity/**/*",
|
||||
"salesforce/**/*",
|
||||
"orders/**/*",
|
||||
"payments/**/*",
|
||||
"providers/**/*",
|
||||
|
||||
38
pnpm-lock.yaml
generated
38
pnpm-lock.yaml
generated
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user