diff --git a/apps/bff/package.json b/apps/bff/package.json index 7a41c31b..cb2b7a39 100644 --- a/apps/bff/package.json +++ b/apps/bff/package.json @@ -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", diff --git a/apps/bff/prisma/schema.prisma b/apps/bff/prisma/schema.prisma index b54f38ae..76a869c7 100644 --- a/apps/bff/prisma/schema.prisma +++ b/apps/bff/prisma/schema.prisma @@ -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 +} diff --git a/apps/bff/src/app.module.ts b/apps/bff/src/app.module.ts index b35b6089..799951f0 100644 --- a/apps/bff/src/app.module.ts +++ b/apps/bff/src/app.module.ts @@ -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, diff --git a/apps/bff/src/core/config/router.config.ts b/apps/bff/src/core/config/router.config.ts index eda51f67..c9f389ba 100644 --- a/apps/bff/src/core/config/router.config.ts +++ b/apps/bff/src/core/config/router.config.ts @@ -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 }, ], }, ]; diff --git a/apps/bff/src/infra/cache/cache.module.ts b/apps/bff/src/infra/cache/cache.module.ts index f90d90f0..dcca5c70 100644 --- a/apps/bff/src/infra/cache/cache.module.ts +++ b/apps/bff/src/infra/cache/cache.module.ts @@ -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 {} diff --git a/apps/bff/src/infra/cache/distributed-lock.service.ts b/apps/bff/src/infra/cache/distributed-lock.service.ts new file mode 100644 index 00000000..1b5a44b8 --- /dev/null +++ b/apps/bff/src/infra/cache/distributed-lock.service.ts @@ -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; +} + +@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 { + 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(key: string, fn: () => Promise, options?: LockOptions): Promise { + 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( + key: string, + fn: () => Promise, + 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 { + // 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 { + return new Promise(resolve => setTimeout(resolve, ms)); + } +} diff --git a/apps/bff/src/integrations/salesforce/events/catalog-cdc.subscriber.ts b/apps/bff/src/integrations/salesforce/events/catalog-cdc.subscriber.ts index 99fdba30..1353c865 100644 --- a/apps/bff/src/integrations/salesforce/events/catalog-cdc.subscriber.ts +++ b/apps/bff/src/integrations/salesforce/events/catalog-cdc.subscriber.ts @@ -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( diff --git a/apps/bff/src/integrations/salesforce/events/events.module.ts b/apps/bff/src/integrations/salesforce/events/events.module.ts index 3d421743..6556f353 100644 --- a/apps/bff/src/integrations/salesforce/events/events.module.ts +++ b/apps/bff/src/integrations/salesforce/events/events.module.ts @@ -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 ], }) diff --git a/apps/bff/src/modules/checkout-registration/checkout-registration.controller.ts b/apps/bff/src/modules/checkout-registration/checkout-registration.controller.ts index 9bf3b5cd..4992d9db 100644 --- a/apps/bff/src/modules/checkout-registration/checkout-registration.controller.ts +++ b/apps/bff/src/modules/checkout-registration/checkout-registration.controller.ts @@ -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; diff --git a/apps/bff/src/modules/checkout-registration/checkout-registration.module.ts b/apps/bff/src/modules/checkout-registration/checkout-registration.module.ts index 762a25ac..53a6e58d 100644 --- a/apps/bff/src/modules/checkout-registration/checkout-registration.module.ts +++ b/apps/bff/src/modules/checkout-registration/checkout-registration.module.ts @@ -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], diff --git a/apps/bff/src/modules/checkout-registration/services/checkout-registration.service.ts b/apps/bff/src/modules/checkout-registration/services/checkout-registration.service.ts index 2fd40a5a..b3340d1f 100644 --- a/apps/bff/src/modules/checkout-registration/services/checkout-registration.service.ts +++ b/apps/bff/src/modules/checkout-registration/services/checkout-registration.service.ts @@ -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 { diff --git a/apps/bff/src/modules/notifications/account-cdc-listener.service.ts b/apps/bff/src/modules/notifications/account-cdc-listener.service.ts new file mode 100644 index 00000000..21812a9b --- /dev/null +++ b/apps/bff/src/modules/notifications/account-cdc-listener.service.ts @@ -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 { + 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 { + 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 { + 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), + }); + } +} diff --git a/apps/bff/src/modules/notifications/notification-cleanup.service.ts b/apps/bff/src/modules/notifications/notification-cleanup.service.ts new file mode 100644 index 00000000..378813cb --- /dev/null +++ b/apps/bff/src/modules/notifications/notification-cleanup.service.ts @@ -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 { + 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), + }); + } + } +} diff --git a/apps/bff/src/modules/notifications/notifications.controller.ts b/apps/bff/src/modules/notifications/notifications.controller.ts new file mode 100644 index 00000000..247f04ff --- /dev/null +++ b/apps/bff/src/modules/notifications/notifications.controller.ts @@ -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 { + 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 }; + } +} diff --git a/apps/bff/src/modules/notifications/notifications.module.ts b/apps/bff/src/modules/notifications/notifications.module.ts new file mode 100644 index 00000000..649ee7e0 --- /dev/null +++ b/apps/bff/src/modules/notifications/notifications.module.ts @@ -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 {} diff --git a/apps/bff/src/modules/notifications/notifications.service.ts b/apps/bff/src/modules/notifications/notifications.service.ts new file mode 100644 index 00000000..815acf59 --- /dev/null +++ b/apps/bff/src/modules/notifications/notifications.service.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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(), + }; + } +} diff --git a/apps/bff/src/modules/orders/services/opportunity-matching.service.ts b/apps/bff/src/modules/orders/services/opportunity-matching.service.ts index 586b20ea..13e5925e 100644 --- a/apps/bff/src/modules/orders/services/opportunity-matching.service.ts +++ b/apps/bff/src/modules/orders/services/opportunity-matching.service.ts @@ -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 { 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 { 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 } + ); } // ========================================================================== diff --git a/apps/portal/src/components/organisms/AppShell/Header.tsx b/apps/portal/src/components/organisms/AppShell/Header.tsx index e058caff..911725d1 100644 --- a/apps/portal/src/components/organisms/AppShell/Header.tsx +++ b/apps/portal/src/components/organisms/AppShell/Header.tsx @@ -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 }:
+ + ) : isPending ? ( - We’re reviewing service availability for your address. Once confirmed, you can return - and complete checkout. +
+

Our team is verifying NTT serviceability for your address.

+

This usually takes 1-2 business days.

+

+ We'll email you at {user?.email} when + complete. You can also check back here anytime. +

+
) : (
@@ -157,8 +163,18 @@ export function AvailabilityStep() { {internetAvailabilityRequestId ? ( -
- Request ID: {internetAvailabilityRequestId} +
+

Your availability check request has been submitted.

+

+ This usually takes 1-2 business days. +

+

+ We'll email you at {user?.email} when + complete. You can also check back here anytime. +

+

+ Request ID: {internetAvailabilityRequestId} +

) : ( diff --git a/apps/portal/src/features/notifications/components/NotificationBell.tsx b/apps/portal/src/features/notifications/components/NotificationBell.tsx new file mode 100644 index 00000000..c21127fa --- /dev/null +++ b/apps/portal/src/features/notifications/components/NotificationBell.tsx @@ -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(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 ( +
+ + + +
+ ); +}); diff --git a/apps/portal/src/features/notifications/components/NotificationDropdown.tsx b/apps/portal/src/features/notifications/components/NotificationDropdown.tsx new file mode 100644 index 00000000..6f1c7b0c --- /dev/null +++ b/apps/portal/src/features/notifications/components/NotificationDropdown.tsx @@ -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 ( +
+ {/* Header */} +
+

Notifications

+ {hasUnread && ( + + )} +
+ + {/* Notification list */} +
+ {isLoading ? ( +
+
+
+ ) : notifications.length === 0 ? ( +
+ +

No notifications yet

+

+ We'll notify you when something important happens +

+
+ ) : ( +
+ {notifications.map(notification => ( + markAsRead.mutate(id)} + onDismiss={id => dismiss.mutate(id)} + /> + ))} +
+ )} +
+ + {/* Footer */} + {notifications.length > 0 && ( +
+ + View all notifications + +
+ )} +
+ ); +}); diff --git a/apps/portal/src/features/notifications/components/NotificationItem.tsx b/apps/portal/src/features/notifications/components/NotificationItem.tsx new file mode 100644 index 00000000..e23198e9 --- /dev/null +++ b/apps/portal/src/features/notifications/components/NotificationItem.tsx @@ -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 ; + case NOTIFICATION_TYPE.ELIGIBILITY_INELIGIBLE: + case NOTIFICATION_TYPE.VERIFICATION_REJECTED: + case NOTIFICATION_TYPE.ORDER_FAILED: + return ; + default: + return ; + } +}; + +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 = ( +
+ {/* Icon */} +
{getNotificationIcon(notification.type)}
+ + {/* Content */} +
+

+ {notification.title} +

+ {notification.message && ( +

{notification.message}

+ )} +

+ {formatDistanceToNow(new Date(notification.createdAt), { + addSuffix: true, + })} +

+
+ + {/* Dismiss button */} + + + {/* Unread indicator */} + {!notification.read && ( +
+ )} +
+ ); + + if (notification.actionUrl) { + return ( + + {content} + + ); + } + + return content; +}); diff --git a/apps/portal/src/features/notifications/hooks/useNotifications.ts b/apps/portal/src/features/notifications/hooks/useNotifications.ts new file mode 100644 index 00000000..8eb51697 --- /dev/null +++ b/apps/portal/src/features/notifications/hooks/useNotifications.ts @@ -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 }); + }, + }); +} diff --git a/apps/portal/src/features/notifications/index.ts b/apps/portal/src/features/notifications/index.ts new file mode 100644 index 00000000..bb39c34a --- /dev/null +++ b/apps/portal/src/features/notifications/index.ts @@ -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"; diff --git a/apps/portal/src/features/notifications/services/notification.service.ts b/apps/portal/src/features/notifications/services/notification.service.ts new file mode 100644 index 00000000..55fa98a0 --- /dev/null +++ b/apps/portal/src/features/notifications/services/notification.service.ts @@ -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 { + const query: Record = {}; + 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(BASE_PATH, { + params: { query }, + }); + return getDataOrThrow(response); + }, + + /** + * Get unread notification count + */ + async getUnreadCount(): Promise { + 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 { + await apiClient.POST<{ success: boolean }>(`${BASE_PATH}/${notificationId}/read`); + }, + + /** + * Mark all notifications as read + */ + async markAllAsRead(): Promise { + await apiClient.POST<{ success: boolean }>(`${BASE_PATH}/read-all`); + }, + + /** + * Dismiss a notification + */ + async dismiss(notificationId: string): Promise { + await apiClient.POST<{ success: boolean }>(`${BASE_PATH}/${notificationId}/dismiss`); + }, +}; diff --git a/docs/reviews/SHOP-ELIGIBILITY-VERIFICATION-OPPORTUNITY-FLOW-REVIEW.md b/docs/reviews/SHOP-ELIGIBILITY-VERIFICATION-OPPORTUNITY-FLOW-REVIEW.md new file mode 100644 index 00000000..ea68d4fe --- /dev/null +++ b/docs/reviews/SHOP-ELIGIBILITY-VERIFICATION-OPPORTUNITY-FLOW-REVIEW.md @@ -0,0 +1,1284 @@ +# Customer Portal Flow Review: Shop, Eligibility, ID Verification & Opportunity Management + +**Review Date:** December 23, 2025 +**Scope:** Complete end-to-end analysis of customer acquisition flows +**Priority Focus:** Customer Experience (CX) + +--- + +## Table of Contents + +1. [Executive Summary](#executive-summary) +2. [System Architecture Overview](#system-architecture-overview) +3. [Flow Diagrams](#flow-diagrams) +4. [Detailed Behavior Analysis](#detailed-behavior-analysis) +5. [Salesforce Field Changes Reference](#salesforce-field-changes-reference) +6. [Agent Workflow & Checklist](#agent-workflow--checklist) +7. [Critical Issues & Recommendations](#critical-issues--recommendations) +8. [Customer Experience Analysis](#customer-experience-analysis) +9. [Implementation Improvements](#implementation-improvements) + +--- + +## Executive Summary + +### Current State Assessment + +| Area | Status | CX Impact | Notes | +| ---------------------- | ------------- | ------------- | --------------------------------- | +| Shop/Catalog | ✅ Good | Low Risk | Well-structured, cached | +| Internet Eligibility | ⚠️ Needs Work | **High Risk** | Manual process, no SLA visibility | +| ID Verification | ⚠️ Needs Work | **High Risk** | Manual review path unclear | +| Checkout Registration | ✅ Good | Medium Risk | Multi-system sync with rollback | +| Opportunity Management | ⚠️ Needs Work | Medium Risk | Some fields not created in SF | +| Order Fulfillment | ✅ Good | Low Risk | Distributed transaction support | + +### Key Findings + +1. **Internet Eligibility is a Major CX Bottleneck** - Customers requesting eligibility have no visibility into when they'll get a response. The flow is entirely manual with no SLA tracking. + +2. **ID Verification Status Communication is Weak** - After uploading documents, customers see "pending" but have no timeline or next steps. + +3. **Opportunity Lifecycle Fields Exist** - `Opportunity_Source__c` (with portal picklist values) and `WHMCS_Service_ID__c` are already in place. + +4. **SIM vs Internet Flows Have Different Requirements** - SIM requires ID verification but not eligibility; Internet requires eligibility but currently not ID verification (potential gap). + +5. **Error Handling is Production-Ready** - Rollback mechanisms and error messages follow best practices [[memory:6689308]]. + +--- + +## System Architecture Overview + +### Three-Tier Integration + +``` +┌─────────────────────────────────────────────────────────────────────────────────┐ +│ CUSTOMER PORTAL │ +│ (Next.js Frontend) │ +├─────────────────────────────────────────────────────────────────────────────────┤ +│ Shop/Catalog │ Checkout │ Verification │ Dashboard │ Service Mgmt │ +└───────┬────────┴─────┬──────┴───────┬────────┴──────┬──────┴─────────┬─────────┘ + │ │ │ │ │ + ▼ ▼ ▼ ▼ ▼ +┌─────────────────────────────────────────────────────────────────────────────────┐ +│ BFF (NestJS) │ +├─────────────────────────────────────────────────────────────────────────────────┤ +│ CatalogService │ CheckoutService │ VerificationService │ OrderService │ +│ │ │ │ │ +│ OpportunityMatchingService │ OrderOrchestrator │ FulfillmentOrchestrator │ +└───────┬──────────────────────┴─────────┬──────────┴────────────┬───────────────┘ + │ │ │ + ▼ ▼ ▼ +┌───────────────┐ ┌─────────────────┐ ┌──────────────┐ +│ SALESFORCE │ │ WHMCS │ │ PRISMA │ +│ (CRM/Orders) │ │ (Billing) │ │ (Portal DB) │ +└───────────────┘ └─────────────────┘ └──────────────┘ +``` + +### Data Flow by Product Type + +| Product | Eligibility Required | ID Verification Required | Opportunity Created At | +| ------------ | ------------------------- | ------------------------ | ---------------------- | +| **Internet** | ✅ Yes (Manual NTT check) | ❌ Not enforced | Eligibility Request | +| **SIM** | ❌ No | ✅ Yes (Residence Card) | Checkout Registration | +| **VPN** | ❌ No | ❌ No | Order Placement | + +--- + +## Flow Diagrams + +### 1. Internet Customer Journey + +``` +┌─────────────────────────────────────────────────────────────────────────────────┐ +│ INTERNET CUSTOMER JOURNEY │ +└─────────────────────────────────────────────────────────────────────────────────┘ + +CUSTOMER ACTION SYSTEM BEHAVIOR SALESFORCE CHANGES +═══════════════ ═══════════════ ══════════════════ + +1. Browse Internet Plans + └─► Catalog loads from SF GET /api/catalog/internet (none - read only) + (cached 5 min) + +2. Click "Select Plan" + └─► Redirect to checkout User must be authenticated (none) + +3. Enter Service Address + └─► Address validation Frontend validation (none) + +4. Submit Eligibility Request + └─► API call triggered POST /eligibility-request ┌─────────────────────┐ + │ CASE: │ + │ • Created │ + │ • Subject=Internet │ + │ availability check│ + │ • Origin=Portal │ + ├─────────────────────┤ + │ ACCOUNT: │ + │ • Internet_ │ + │ Eligibility_ │ + │ Status__c=Pending │ + │ • Internet_ │ + │ Eligibility_ │ + │ Request_Date_ │ + │ Time__c=NOW() │ + │ • Internet_ │ + │ Eligibility_ │ + │ Case_Id__c= │ + │ (Case ID) │ + ├─────────────────────┤ + │ NOTE: Opportunity │ + │ is NOT created at │ + │ this step - only │ + │ at order placement │ + └─────────────────────┘ + + ⏳ CUSTOMER WAITS (no SLA shown) + ════════════════════════════════ + +5. Agent Processes Case (MANUAL AGENT WORK) ┌─────────────────────┐ + └─► NTT Check Checks serviceability │ ACCOUNT: │ + └─► Update Account │ • Internet_ │ + └─► SF Flow sends email │ Eligibility__c= │ + │ "Home 1G" (or │ + │ other result) │ + │ • Internet_ │ + │ Eligibility_ │ + │ Status__c= │ + │ Eligible/ │ + │ Ineligible │ + │ • Internet_ │ + │ Eligibility_ │ + │ Checked_Date_ │ + │ Time__c=NOW() │ + ├─────────────────────┤ + │ SALESFORCE FLOW: │ + │ • Sends email to │ + │ customer with │ + │ eligibility result│ + └─────────────────────┘ + +6. Customer Returns/Refreshes + └─► Sees eligible plans GET /api/catalog/internet (read only) + filtered by eligibility /eligibility + +7. Proceed to Payment + └─► Add payment method Stripe integration (none) + +8. Place Order + └─► Order created POST /api/orders ┌─────────────────────┐ + │ ORDER: │ + │ • Created │ + │ • OpportunityId= │ + │ (linked) │ + │ • Status=Created │ + ├─────────────────────┤ + │ OPPORTUNITY: │ + │ • Stage=Post │ + │ Processing │ + └─────────────────────┘ + +9. Agent Approves Order + └─► Fulfillment triggered Order.Status='Approved' ┌─────────────────────┐ + triggers CDC event │ ORDER: │ + │ • Activation_ │ + │ Status__c= │ + │ Activating │ + └─────────────────────┘ + +10. WHMCS Provisioning + └─► Service created addOrder → acceptOrder ┌─────────────────────┐ + │ ORDER: │ + │ • Status=Completed │ + │ • Activation_ │ + │ Status__c= │ + │ Activated │ + │ • WHMCS_Order_ID__c │ + │ =(WHMCS ID) │ + ├─────────────────────┤ + │ OPPORTUNITY: │ + │ • Stage=Active │ + │ • WHMCS_Service_ │ + │ ID__c=(service ID)│ + └─────────────────────┘ + +11. Customer Has Active Service + └─► Service visible in GET /subscriptions (read only) + portal dashboard +``` + +### 2. SIM Customer Journey + +``` +┌─────────────────────────────────────────────────────────────────────────────────┐ +│ SIM CUSTOMER JOURNEY │ +└─────────────────────────────────────────────────────────────────────────────────┘ + +CUSTOMER ACTION SYSTEM BEHAVIOR SALESFORCE CHANGES +═══════════════ ═══════════════ ══════════════════ + +1. Browse SIM Plans + └─► Catalog loads GET /api/catalog/sim (none - read only) + Family plans shown if + user has existing SIM + +2. Click "Select Plan" + └─► Redirect to checkout Unauthenticated → Registration (none) + +3. NEW USER: Register During Checkout + └─► Multi-step registration POST /checkout-registration ┌─────────────────────┐ + creates accounts │ SALESFORCE ACCOUNT: │ + │ • Created │ + │ • SF_Account_No__c= │ + │ P{generated} │ + │ • Portal_Status__c= │ + │ Active │ + │ • Portal_ │ + │ Registration_ │ + │ Source__c= │ + │ Portal Checkout │ + ├─────────────────────┤ + │ SALESFORCE CONTACT: │ + │ • Created │ + │ • AccountId= │ + │ (linked) │ + ├─────────────────────┤ + │ WHMCS CLIENT: │ + │ • Created │ + ├─────────────────────┤ + │ PORTAL DATABASE: │ + │ • User created │ + │ • IdMapping created │ + └─────────────────────┘ + +4. Upload Residence Card (ID Verification) + └─► File upload POST /verification/ ┌─────────────────────┐ + residence-card │ ACCOUNT: │ + │ • Id_Verification_ │ + │ Status__c= │ + │ Submitted │ + │ • Id_Verification_ │ + │ Submitted_Date_ │ + │ Time__c=NOW() │ + ├─────────────────────┤ + │ CONTENT VERSION: │ + │ • File uploaded │ + │ • FirstPublish │ + │ LocationId= │ + │ Account │ + └─────────────────────┘ + + ⏳ CUSTOMER WAITS (manual review) + ═════════════════════════════════ + +5. Agent Reviews ID (MANUAL AGENT WORK) ┌─────────────────────┐ + └─► Verify document │ ACCOUNT: │ + └─► Update status │ • Id_Verification_ │ + │ Status__c= │ + │ Verified/Rejected │ + │ • Id_Verification_ │ + │ Verified_Date_ │ + │ Time__c=NOW() │ + │ • Id_Verification_ │ + │ Rejection_ │ + │ Message__c= │ + │ (if rejected) │ + └─────────────────────┘ + +6. Customer Returns (If Verified) + └─► Can proceed to payment Verification status checked (none) + +7-11. (Same as Internet steps 7-11) +``` + +### 3. Opportunity Lifecycle State Machine + +``` +┌─────────────────────────────────────────────────────────────────────────────────┐ +│ OPPORTUNITY STAGE TRANSITIONS │ +└─────────────────────────────────────────────────────────────────────────────────┘ + + ┌───────────────────┐ + │ POST PROCESSING │ ◄─── Created at ORDER PLACEMENT + │ (75% prob) │ (Not at eligibility request) + └─────────┬─────────┘ + │ + ┌───────────────┼───────────────┐ + │ │ │ + ▼ ▼ ▼ + ┌───────────┐ ┌───────────────┐ ┌─────────────┐ + │ VOID │ │ ACTIVE │ │ PENDING │ + │ (Closed, │ │ (90% prob) │ │ (On hold) │ + │ 0% prob) │ └───────┬───────┘ └─────────────┘ + └───────────┘ │ + Failed │ Set when: + provisioning │ • Service provisioned in WHMCS + │ • WHMCS_Service_ID__c populated + │ + ▼ + ┌───────────────┐ + │ △CANCELLING │ ◄─── Set when: + │ (100% prob) │ • Customer submits cancellation + └───────┬───────┘ • ScheduledCancellationDateAndTime__c set + │ • CancellationNotice__c = 有 + │ • LineReturn__c = NotYet + ▼ + ┌───────────────┐ + │ 〇CANCELLED │ ◄─── Set when: + │ (Closed,Won) │ • Service terminated + └───────────────┘ • Equipment returned (if applicable) + +NOTE: Introduction/Ready stages may be used by agents for pre-order tracking, + but portal creates Opportunities starting at Post Processing. +``` + +--- + +## Detailed Behavior Analysis + +### Shop/Catalog Module + +**Location:** `apps/bff/src/modules/catalog/` + +**Key Services:** + +- `InternetCatalogService` - Plans, installations, addons +- `SimCatalogService` - Plans, activation fees, addons +- `VpnCatalogService` - Plans + +**Caching Strategy:** + +- Catalog items cached via `CatalogCacheService` +- Cache keys built by product type + item type +- Cache invalidation on product updates + +**Eligibility-Filtered Plans:** + +```typescript +// InternetCatalogService.getPlansForUser() +// Filters plans based on Account.Internet_Eligibility__c value +// e.g., if eligibility = "Home 1G", only Home 1G plans shown +``` + +**Family Discount Logic (SIM):** + +```typescript +// SimCatalogService.getPlansForUser() +// Checks WHMCS for existing active SIM services +// Shows family discount plans only if user has existing SIM +``` + +### Eligibility Validation Module + +**Location:** `apps/bff/src/modules/catalog/services/internet-catalog.service.ts` + +**Flow Summary:** + +1. Customer requests eligibility check +2. System creates Case for agent to process +3. System updates Account eligibility fields to "Pending" +4. **Manual Agent Work Required** - Agent checks NTT serviceability +5. Agent updates Account with eligibility result +6. Salesforce Flow sends email notification to customer +7. **Note:** Opportunity is NOT created during eligibility - only at order placement + +**Account Fields Updated:** +| Field | Value Set | When | +|-------|-----------|------| +| `Internet_Eligibility_Status__c` | "Pending" | On request | +| `Internet_Eligibility_Request_Date_Time__c` | NOW() | On request | +| `Internet_Eligibility_Case_Id__c` | Case ID | On request | +| `Internet_Eligibility__c` | Result value | By agent | +| `Internet_Eligibility_Checked_Date_Time__c` | NOW() | By agent | + +### ID Verification Module + +**Location:** `apps/bff/src/modules/verification/residence-card.service.ts` + +**Flow Summary:** + +1. Customer uploads residence card image +2. File stored as Salesforce ContentVersion (linked to Account) +3. Account verification status updated to "Submitted" +4. **Manual Agent Work Required** +5. Agent reviews document and updates status + +**Account Fields Updated:** +| Field | Value Set | When | +|-------|-----------|------| +| `Id_Verification_Status__c` | "Submitted" | On upload | +| `Id_Verification_Submitted_Date_Time__c` | NOW() | On upload | +| `Id_Verification_Rejection_Message__c` | null | Cleared on resubmit | +| `Id_Verification_Note__c` | null | Cleared on resubmit | +| `Id_Verification_Status__c` | "Verified"/"Rejected" | By agent | +| `Id_Verification_Verified_Date_Time__c` | NOW() | By agent | + +**Supported File Types:** + +- PDF +- PNG +- JPG/JPEG + +### Checkout Registration Module + +**Location:** `apps/bff/src/modules/checkout-registration/` + +**Multi-System Orchestration (7 Steps):** + +1. Create Salesforce Account (generates SF_Account_No\_\_c) +2. Create Salesforce Contact (linked to Account) +3. Create WHMCS Client (for billing) +4. Update SF Account with WH_Account\_\_c +5. Create Portal User (with password hash) +6. Create ID Mapping (links all system IDs) +7. Generate auth tokens (auto-login) + +**Rollback Behavior:** + +- Portal user + ID mapping: Deleted via transaction rollback +- WHMCS client: **Cannot be deleted via API** (logged for manual cleanup) +- Salesforce Account: **Intentionally not deleted** (preserves data) + +### Opportunity Management Module + +**Location:** `apps/bff/src/modules/orders/services/opportunity-matching.service.ts` + +**Matching Rules:** +| Scenario | Action | +|----------|--------| +| Order has `opportunityId` | Use it directly | +| Internet order without Opp | Find Introduction/Ready stage or create new | +| SIM order without Opp | Find open Opportunity or create new | +| VPN order | Always create new Opportunity | + +**Stage Transitions by Trigger:** +| Trigger | From Stage(s) | To Stage | +|---------|---------------|----------| +| Eligibility Request | (new) | Introduction | +| Eligibility Confirmed | Introduction | Ready | +| Eligibility Denied | Introduction | Void | +| Order Placed | Introduction, Ready | Post Processing | +| Service Provisioned | Post Processing | Active | +| Cancellation Requested | Active | △Cancelling | +| Cancellation Complete | △Cancelling | 〇Cancelled | + +--- + +## Salesforce Field Changes Reference + +### Complete Field Map by Object + +#### Account Object + +| Field API Name | Label | Type | Who Updates | When | +| ------------------------------------------- | --------------------- | -------- | ------------ | ------------- | +| `SF_Account_No__c` | Customer Number | Text | Portal | Registration | +| `Portal_Status__c` | Portal Status | Picklist | Portal | Registration | +| `Portal_Registration_Source__c` | Registration Source | Text | Portal | Registration | +| `Portal_Last_SignIn__c` | Last Sign In | DateTime | Portal | Each login | +| `WH_Account__c` | WHMCS Account | Text | Portal | Registration | +| `Internet_Eligibility__c` | Internet Eligibility | Text | **Agent** | After check | +| `Internet_Eligibility_Status__c` | Eligibility Status | Picklist | Portal/Agent | Request/Check | +| `Internet_Eligibility_Request_Date_Time__c` | Eligibility Requested | DateTime | Portal | Request | +| `Internet_Eligibility_Checked_Date_Time__c` | Eligibility Checked | DateTime | **Agent** | After check | +| `Internet_Eligibility_Notes__c` | Eligibility Notes | Text | **Agent** | After check | +| `Internet_Eligibility_Case_Id__c` | Eligibility Case | Lookup | Portal | Request | +| `Id_Verification_Status__c` | ID Status | Picklist | Portal/Agent | Upload/Review | +| `Id_Verification_Submitted_Date_Time__c` | ID Submitted | DateTime | Portal | Upload | +| `Id_Verification_Verified_Date_Time__c` | ID Verified | DateTime | **Agent** | Review | +| `Id_Verification_Note__c` | ID Notes | Text | **Agent** | Review | +| `Id_Verification_Rejection_Message__c` | Rejection Reason | Text | **Agent** | If rejected | + +#### Opportunity Object + +| Field API Name | Label | Type | Who Updates | When | +| ------------------------------------- | ---------------------- | -------- | ------------ | -------------------- | +| `StageName` | Stage | Picklist | Portal/Agent | Throughout lifecycle | +| `CommodityType` | Commodity Type | Picklist | Portal | Creation | +| `Application_Stage__c` | Application Stage | Picklist | Portal | Creation (INTRO-1) | +| `Portal_Source__c` | Portal Source | Picklist | Portal | Creation | +| `WHMCS_Service_ID__c` | WHMCS Service ID | Number | Portal | After provisioning | +| `CancellationNotice__c` | Cancellation Notice | Picklist | Portal | Cancellation request | +| `ScheduledCancellationDateAndTime__c` | Scheduled Cancellation | DateTime | Portal | Cancellation request | +| `LineReturn__c` | Line Return Status | Picklist | **Agent** | Equipment tracking | + +#### Order Object + +| Field API Name | Label | Type | Who Updates | When | +| ----------------------------- | ----------------- | -------- | ------------ | ------------------ | +| `OpportunityId` | Opportunity | Lookup | Portal | Order creation | +| `Status` | Status | Picklist | Portal/Agent | Throughout | +| `Activation_Status__c` | Activation Status | Picklist | Portal | Fulfillment | +| `Activation_Error_Code__c` | Error Code | Text | Portal | If failed | +| `Activation_Error_Message__c` | Error Message | Text | Portal | If failed | +| `WHMCS_Order_ID__c` | WHMCS Order ID | Text | Portal | After provisioning | + +#### Case Object + +| Field API Name | Label | Type | Who Updates | When | +| --------------- | ----------- | -------- | ----------- | ------------------ | +| `OpportunityId` | Opportunity | Lookup | Portal | Eligibility/Cancel | +| `Origin` | Origin | Picklist | Portal | "Portal" | +| `Status` | Status | Picklist | **Agent** | Processing | +| `Priority` | Priority | Picklist | Portal | Creation | + +--- + +## Agent Workflow & Checklist + +### Internet Eligibility Processing + +**Queue:** Cases with Subject = "Internet availability check request (Portal)" + +**Agent Checklist:** + +- [ ] **1. Open the Case** + - Note the OpportunityId (related Opportunity) + - Review Description for address details +- [ ] **2. Perform NTT Serviceability Check** + - Check FLET'S光 availability for the address + - Determine offering type (Home 1G, Mansion 1G, etc.) +- [ ] **3. Update Account Fields** + + ``` + Internet_Eligibility__c = "[Offering Type]" (e.g., "Home 1G", "Mansion 1G") + Internet_Eligibility_Status__c = "Eligible" or "Ineligible" + Internet_Eligibility_Checked_Date_Time__c = NOW() + Internet_Eligibility_Notes__c = [Any relevant notes] + ``` + +- [ ] **4. Close the Case** + - Status = "Closed" + - Resolution notes + +**ℹ️ Automatic Actions:** + +- Salesforce Flow automatically sends email to customer when eligibility fields are updated +- Portal polls Account for eligibility changes (customer sees result on next visit) +- **Opportunity is created later at order placement** (not during eligibility check) + +--- + +### ID Verification Processing + +**Queue:** Accounts with `Id_Verification_Status__c = "Submitted"` + +**Agent Checklist:** + +- [ ] **1. Find the Account** + - Filter by `Id_Verification_Status__c = "Submitted"` +- [ ] **2. Download & Review Document** + - Go to Account → Files → Most Recent + - Verify: Name matches account, document is valid, not expired +- [ ] **3. Update Account (Approved)** + + ``` + Id_Verification_Status__c = "Verified" + Id_Verification_Verified_Date_Time__c = NOW() + Id_Verification_Note__c = "Verified by [Agent Name]" + ``` + +- [ ] **3. Update Account (Rejected)** + ``` + Id_Verification_Status__c = "Rejected" + Id_Verification_Verified_Date_Time__c = NOW() + Id_Verification_Rejection_Message__c = "[Specific reason - customer will see this]" + ``` + +**⚠️ Critical:** Rejection message is shown to customer. Be specific but professional. + +--- + +### Order Approval Processing + +**Queue:** Orders with `Status = "Pending Review"` or triggered via automation + +**Agent Checklist:** + +- [ ] **1. Verify Customer Prerequisites** + - Internet: `Account.Internet_Eligibility__c` is set + - SIM: `Account.Id_Verification_Status__c = "Verified"` +- [ ] **2. Review Order Details** + - Check order items are correct + - Verify pricing +- [ ] **3. Approve Order** + - Set `Order.Status = "Approved"` + - This triggers CDC event → BFF provisioning + +- [ ] **4. Monitor Activation** + - Check `Activation_Status__c` updates to "Activated" + - If "Failed", check error code/message + +--- + +### Cancellation Processing + +**Queue:** Cases with Subject = "Cancellation Request - ..." + +**Agent Checklist:** + +- [ ] **1. Open the Case** + - Note the OpportunityId + - Review cancellation month and service details +- [ ] **2. Verify the 25th Rule** + - If requested before 25th: Can cancel THIS month + - If requested on/after 25th: Must be NEXT month +- [ ] **3. Check Opportunity Fields** (portal sets these) + - `ScheduledCancellationDateAndTime__c` = End of cancellation month + - `CancellationNotice__c` = "有" + - `LineReturn__c` = "NotYet" +- [ ] **4. Process Equipment Return** (if Internet) + - Send return kit + - Update `LineReturn__c` = "SentKit" + - Track return: "Returned1", "Returned2" +- [ ] **5. Terminate Service in WHMCS** + - On the scheduled date +- [ ] **6. Complete Cancellation** + - `Opportunity.Stage = "〇Cancelled"` + +--- + +## Critical Issues & Recommendations + +### 🔴 Critical Issues + +#### 1. Internet Eligibility Has No SLA Visibility + +**Problem:** Customers submit eligibility requests and see "Pending" indefinitely. No estimated time, no progress indicator. + +**Impact:** High abandonment rate, customer frustration, support inquiries. + +**Recommendation:** + +``` +SHORT TERM: +- Add expected timeframe messaging: "Usually completed within 2-3 business days" +- Send email when eligibility is updated + +LONG TERM: +- Implement automated NTT API check where possible +- Add SLA tracking on Cases with escalation rules +``` + +#### 2. ID Verification Rejection Lacks Guidance + +**Problem:** When rejected, customers see the rejection message but no clear next steps. + +**Impact:** Customers don't know how to fix the issue, leading to repeated failures. + +**Recommendation:** + +``` +- Create structured rejection reasons with remediation steps +- Add "Re-submit" button that clears previous submission +- Show example of correct document format +``` + +#### 3. ~~Salesforce Fields Not Created~~ (RESOLVED) + +**Status:** ✅ Confirmed fields exist: + +- `Opportunity_Source__c` - Picklist with portal values +- `WHMCS_Service_ID__c` - Number field for WHMCS linking + +**Note:** Emails for eligibility and ID verification status changes are sent automatically from Salesforce (via Flow/Process Builder). + +### 🟡 Medium Issues + +#### 4. No Internet ID Verification Requirement + +**Problem:** Internet orders don't require ID verification, but SIM orders do. This may be intentional but creates inconsistency. + +**Recommendation:** + +``` +- Confirm business requirement +- If ID needed for Internet, add gating step in checkout +- Document the decision either way +``` + +#### 5. WHMCS Rollback is Manual + +**Problem:** If checkout registration fails after WHMCS client creation, the WHMCS client cannot be auto-deleted. + +**Impact:** Orphaned WHMCS accounts require manual cleanup. + +**Recommendation:** + +``` +- Add monitoring/alerting for failed registrations +- Create weekly cleanup process for orphaned WHMCS clients +- Consider delayed WHMCS creation (after all validations pass) +``` + +#### 6. Opportunity Matching Could Create Duplicates + +**Problem:** The matching query only looks for open Opportunities. If two eligibility requests happen quickly, two Opportunities could be created. + +**Recommendation:** + +``` +- Add optimistic locking or unique constraint +- Consider using Salesforce duplicate rules +``` + +### 🟢 Minor Issues + +#### 7. Cache Invalidation on Eligibility Update + +**Problem:** After agent updates eligibility, customer might see cached "Pending" status. + +**Current:** Cache key includes account ID, TTL-based expiry. + +**Recommendation:** + +``` +- Reduce eligibility cache TTL during "Pending" status +- Consider CDC-triggered cache invalidation +``` + +--- + +## Customer Experience Analysis + +### Current CX Pain Points (Ranked) + +| Rank | Issue | User Impact | Frequency | +| ---- | -------------------------------- | ---------------- | ----------------------- | +| 1 | No eligibility timeline | High frustration | Every Internet customer | +| 2 | Unclear ID rejection reasons | High frustration | ~10% of SIM customers | +| 3 | No email notifications | Medium annoyance | All customers | +| 4 | Must return to check status | Medium annoyance | All customers | +| 5 | Address entry before eligibility | Low friction | Internet customers | + +### Recommended CX Improvements + +#### Tier 1: Quick Wins (Days) + +1. **Add Timeline Messaging** + + ```tsx + // AvailabilityStep.tsx + + Our team will verify NTT serviceability for your address. + This usually takes 1-2 business days. + We'll email you at {user.email} when complete. + + ``` + +2. **Improve Rejection Messages** + + ```typescript + // Structured rejection reasons + const REJECTION_REASONS = { + EXPIRED: "Document has expired. Please upload a valid, unexpired residence card.", + BLURRY: "Image is not clear enough. Please retake the photo in good lighting.", + WRONG_TYPE: + "This document type is not accepted. Please upload your residence card (在留カード).", + NAME_MISMATCH: "Name on document doesn't match account. Please contact support.", + }; + ``` + +3. **Add Status Polling with User Feedback** + ```tsx + // Show last checked time +

+ Last checked: {formatRelativeTime(lastCheckedAt)} + +

+ ``` + +#### Tier 2: Medium Effort (Weeks) + +1. **Email Notifications** + - Eligibility confirmed/denied + - ID verification approved/rejected + - Order status changes + +2. **Progress Indicators** + + ```tsx + // Visual progress for Internet checkout + + ``` + +3. **Save & Resume Checkout** + - Store checkout session in database + - Allow customers to return and complete after eligibility + +#### Tier 3: Strategic (Months) + +1. **Automated NTT Check** + - Integrate with NTT API (where available) + - Instant eligibility for covered areas + - Fallback to manual for edge cases + +2. **Real-time Status Updates** + - WebSocket notifications when status changes + - Push notifications for mobile + +--- + +## Implementation Improvements + +### Code Quality Observations + +#### ✅ What's Working Well + +1. **Domain-Driven Design** + - Clear separation in `packages/domain/` + - Zod schemas for validation + - Type-safe contracts + +2. **Error Handling** [[memory:6689308]] + - Production-ready error messages + - No sensitive data exposure + - Structured logging + +3. **Distributed Transactions** + - `DistributedTransactionService` for fulfillment + - Step tracking with rollback support + - Idempotency keys for retry safety + +4. **Caching Strategy** + - Catalog caching with cache keys + - Eligibility caching per account + - Invalidation on updates + +#### ✅ Areas for Improvement (Resolved) + +1. **Opportunity Matching Race Condition** → ✅ FIXED + + ```typescript + // Now uses distributed lock via DistributedLockService + // Located: apps/bff/src/infra/cache/distributed-lock.service.ts + return this.lockService.withLock( + `opportunity:${accountId}:${productType}`, + async () => { + const existingOppId = await this.opportunityService.findOpenOpportunityForAccount(...); + if (!existingOppId) return this.createNewOpportunity(...); + return this.useExistingOpportunity(existingOppId); + }, + { ttlMs: 10_000 } + ); + ``` + +2. **Hardcoded Eligibility Field Names** → ✅ FIXED + + ```typescript + // Created centralized field maps in domain package + // Located: packages/domain/salesforce/field-maps.ts + import { ACCOUNT_FIELDS } from "@customer-portal/domain/salesforce"; + + const eligibilityValue = account[ACCOUNT_FIELDS.eligibility.value]; + const eligibilityStatus = account[ACCOUNT_FIELDS.eligibility.status]; + ``` + +3. **Missing Opportunity Creation for SIM** → ✅ FIXED + ```typescript + // CheckoutRegistrationService now creates Opportunity for SIM orders + // Added orderType parameter to registration request + if (data.orderType === "SIM" && this.opportunityMatchingService) { + opportunityId = + await this.opportunityMatchingService.createOpportunityForCheckoutRegistration(sfAccountId); + } + ``` + +### Suggested Refactors (Partially Implemented) + +1. **Create Unified Status Service** → 📋 FUTURE + + ```typescript + // CustomerStatusService - not yet implemented + // Consider creating for dashboard aggregation + class CustomerStatusService { + async getCustomerStatus(userId: string): Promise { + return { + eligibility: await this.getEligibilityStatus(userId), + verification: await this.getVerificationStatus(userId), + pendingOrders: await this.getPendingOrders(userId), + activeServices: await this.getActiveServices(userId), + }; + } + } + ``` + +2. **Add Notification Service** → ✅ IMPLEMENTED + + ```typescript + // Located: apps/bff/src/modules/notifications/notifications.service.ts + // Creates in-app notifications from Platform Events + // Emails handled by Salesforce Flows + ``` + +3. **Centralize Field Maps** → ✅ IMPLEMENTED + + ```typescript + // Located: packages/domain/salesforce/field-maps.ts + import { ACCOUNT_FIELDS, SALESFORCE_FIELDS } from "@customer-portal/domain/salesforce"; + + // Full field maps for Account, Opportunity, Order, and Case objects + ``` + +--- + +## Salesforce Email Configuration (Required) + +### Overview + +Salesforce sends emails automatically when eligibility and ID verification statuses change. The Portal creates matching in-app notifications via Platform Events. + +### Flow Triggers to Create + +#### 1. Internet Eligibility Status Change Flow + +**Trigger:** Record-Triggered Flow on Account +**When:** `Internet_Eligibility_Status__c` changes AND is not null AND is not "Pending" + +```yaml +Flow Name: Portal - Eligibility Status Email +Object: Account +Trigger: When a record is updated +Entry Conditions: + - Internet_Eligibility_Status__c IS CHANGED + - Internet_Eligibility_Status__c NOT EQUALS "Pending" + - Internet_Eligibility_Status__c IS NOT NULL + +Actions: + 1. Decision: Check Status Value + - If "Eligible" → Send Eligible Email + - If "Ineligible" → Send Ineligible Email + + 2. Send Email (Eligible): + Template: Portal_Eligibility_Eligible + To: {!$Record.PersonEmail} OR Contact.Email + Subject: "Good news! Internet service is available at your address" + + 3. Send Email (Ineligible): + Template: Portal_Eligibility_Ineligible + To: {!$Record.PersonEmail} OR Contact.Email + Subject: "Update on your internet availability request" +``` + +#### 2. ID Verification Status Change Flow + +**Trigger:** Record-Triggered Flow on Account +**When:** `Id_Verification_Status__c` changes to "Verified" or "Rejected" + +```yaml +Flow Name: Portal - ID Verification Status Email +Object: Account +Trigger: When a record is updated +Entry Conditions: + - Id_Verification_Status__c IS CHANGED + - Id_Verification_Status__c IN ("Verified", "Rejected") + +Actions: + 1. Decision: Check Status Value + - If "Verified" → Send Verified Email + - If "Rejected" → Send Rejected Email + + 2. Send Email (Verified): + Template: Portal_ID_Verified + To: {!$Record.PersonEmail} OR Contact.Email + Subject: "Your identity has been verified" + + 3. Send Email (Rejected): + Template: Portal_ID_Rejected + To: {!$Record.PersonEmail} OR Contact.Email + Subject: "Action required: ID verification needs attention" + Body should include: {!$Record.Id_Verification_Rejection_Message__c} +``` + +### Email Templates to Create + +#### Template: Portal_Eligibility_Eligible + +```html +Subject: Good news! Internet service is available at your address Hi {{{Recipient.FirstName}}}, +Great news! We've confirmed that internet service is available at your address. Your eligible +offering: {{{Account.Internet_Eligibility__c}}} You can now complete your order: [Complete Your +Order] → https://portal.example.com/shop/internet If you have any questions, please contact our +support team. Best regards, The SonixNet Team +``` + +#### Template: Portal_Eligibility_Ineligible + +```html +Subject: Update on your internet availability request Hi {{{Recipient.FirstName}}}, Thank you for +your interest in our internet service. Unfortunately, after checking with NTT, we've determined that +internet service is not currently available at your address. {{{#if +Account.Internet_Eligibility_Notes__c}}} Notes: {{{Account.Internet_Eligibility_Notes__c}}} +{{{/if}}} If you believe this is an error or your situation changes, please contact our support +team. Best regards, The SonixNet Team +``` + +#### Template: Portal_ID_Verified + +```html +Subject: Your identity has been verified Hi {{{Recipient.FirstName}}}, Your identity verification is +complete! You can now proceed with your order. [Continue to Checkout] → +https://portal.example.com/checkout Best regards, The SonixNet Team +``` + +#### Template: Portal_ID_Rejected + +```html +Subject: Action required: ID verification needs attention Hi {{{Recipient.FirstName}}}, We were +unable to verify your identity based on the document you submitted. Reason: +{{{Account.Id_Verification_Rejection_Message__c}}} Please resubmit a clearer image of your residence +card: [Resubmit Document] → https://portal.example.com/account/verification Tips for a successful +submission: - Ensure the entire card is visible - Take the photo in good lighting - Make sure the +image is not blurry - The document must not be expired If you need assistance, please contact our +support team. Best regards, The SonixNet Team +``` + +### Platform Event Configuration + +Portal uses Platform Events to receive real-time updates and create in-app notifications. + +**Existing Platform Event:** `Account_Internet_Eligibility_Update__e` + +**Environment Variable:** `SF_ACCOUNT_EVENT_CHANNEL=/event/Account_Internet_Eligibility_Update__e` + +#### Platform Event Fields (Required) + +| Field API Name | Type | Description | +| ------------------------------------------- | -------- | ----------------------------------------- | +| `AccountId__c` | Text | Salesforce Account ID | +| `Internet_Eligibility__c` | Text | Eligibility value (e.g., "Home 1G") | +| `Internet_Eligibility_Status__c` | Text | Status (Pending, Eligible, Ineligible) | +| `Internet_Eligibility_Request_Date_Time__c` | DateTime | When requested | +| `Internet_Eligibility_Checked_Date_Time__c` | DateTime | When checked | +| `Internet_Eligibility_Notes__c` | Text | Agent notes | +| `Internet_Eligibility_Case_Id__c` | Text | Related Case ID | +| `Id_Verification_Status__c` | Text | ID status (Submitted, Verified, Rejected) | +| `Id_Verification_Rejection_Message__c` | Text | Rejection reason | + +#### Flow to Publish Platform Event + +**Trigger:** Record-Triggered Flow on Account +**When:** Any of the eligibility or verification fields change + +```yaml +Flow Name: Portal - Publish Account Status Update +Object: Account +Trigger: When a record is updated +Entry Conditions (OR): + - Internet_Eligibility_Status__c IS CHANGED + - Id_Verification_Status__c IS CHANGED + +Actions: + 1. Create Platform Event Record: + Object: Account_Internet_Eligibility_Update__e + Fields: + - AccountId__c = {!$Record.Id} + - Internet_Eligibility__c = {!$Record.Internet_Eligibility__c} + - Internet_Eligibility_Status__c = {!$Record.Internet_Eligibility_Status__c} + - Id_Verification_Status__c = {!$Record.Id_Verification_Status__c} + - Id_Verification_Rejection_Message__c = {!$Record.Id_Verification_Rejection_Message__c} + ... (other fields as needed) +``` + +--- + +## Notification Architecture (Implemented) + +### Design Principle: Email + In-App Notifications + +Salesforce sends emails automatically, and the Portal displays in-app notifications for the same events. This provides: + +- **Push notification** via email (customer's inbox) +- **Pull notification** via in-app (when customer logs in) + +### Implementation Details + +- **Detection Method:** Platform Events (`Account_Internet_Eligibility_Update__e`) +- **Storage:** Portal PostgreSQL database (30-day expiry) +- **Duplication:** Same notification content in email AND in-app + +### Architecture Diagram + +``` +┌─────────────────────────────────────────────────────────────────────────────────┐ +│ NOTIFICATION ARCHITECTURE │ +└─────────────────────────────────────────────────────────────────────────────────┘ + + SALESFORCE + ┌──────────────────────────────┐ + │ Account Field Change │ + │ (Eligibility, Verification) │ + └─────────────┬────────────────┘ + │ + ┌─────────────┴─────────────┐ + │ │ + ▼ ▼ + ┌─────────────────┐ ┌─────────────────┐ + │ SF FLOW │ │ PLATFORM EVENT │ + │ Sends Email │ │ (Pub/Sub API) │ + └────────┬────────┘ └────────┬────────┘ + │ │ + ▼ ▼ + ┌─────────────────┐ ┌─────────────────┐ + │ Customer's │ │ Portal BFF │ + │ Email Inbox │ │ (Subscriber) │ + └─────────────────┘ │ │ + │ Creates notif. │ + └────────┬────────┘ + │ + ▼ + ┌─────────────────┐ + │ NOTIFICATION │ + │ TABLE (Prisma) │ + └────────┬────────┘ + │ + ▼ + ┌─────────────────┐ + │ Portal UI │ + │ 🔔 (2) Badge │ + └─────────────────┘ +``` + +### Notification Types + +| Type | Trigger | Email? | In-App? | Action | +| ------------------------ | ------------------------ | ------ | --------- | ----------------- | +| `eligibility_eligible` | Eligibility = Eligible | ✅ SF | ✅ Portal | View Plans | +| `eligibility_ineligible` | Eligibility = Ineligible | ✅ SF | ✅ Portal | Contact Support | +| `verification_verified` | ID Status = Verified | ✅ SF | ✅ Portal | Continue Checkout | +| `verification_rejected` | ID Status = Rejected | ✅ SF | ✅ Portal | Resubmit | +| `order_activated` | Order Activated | ✅ SF | ✅ Portal | View Service | +| `cancellation_scheduled` | Cancellation Requested | ✅ SF | ✅ Portal | View Status | + +### Database Schema (Implemented) + +Located in `apps/bff/prisma/schema.prisma`: + +```prisma +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, 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 +} +``` + +### Case Management Strategy + +| Case Type | Customer Visible? | Show In Portal? | +| --------------------------------------------- | ----------------- | ----------------------------------- | +| **Support Tickets** (Origin="Portal Website") | ✅ Yes | Show in Support section | +| **Eligibility Check** (Origin="Portal") | ❌ No | Internal workflow only | +| **ID Verification** (Origin="Portal") | ❌ No | Internal workflow only | +| **Cancellation Request** (Origin="Portal") | Status only | Show via Opportunity/Service status | + +--- + +## Summary + +This implementation provides a solid foundation for customer acquisition flows: + +**✅ Working Well:** + +1. **Salesforce integration** - Fields exist, emails configured via Flows +2. **Error handling** - Production-ready, no sensitive data exposure +3. **Distributed transactions** - Fulfillment with rollback support +4. **Caching** - Catalog and eligibility data cached appropriately +5. **In-app notifications** - Implemented with CDC integration + +**⚠️ Still Needs Improvement:** + +1. ~~**Eligibility flow** needs timeline visibility in UI~~ → ✅ Added timeline messaging +2. **ID verification** needs structured rejection reasons + +### Implemented Features + +| Feature | Status | Location | +| ------------------------------ | ------- | ------------------------------------------------------------------------- | +| Notification Database Schema | ✅ Done | `apps/bff/prisma/schema.prisma` | +| NotificationService | ✅ Done | `apps/bff/src/modules/notifications/notifications.service.ts` | +| Notification API | ✅ Done | `apps/bff/src/modules/notifications/notifications.controller.ts` | +| Platform Event Integration | ✅ Done | Extended `CatalogCdcSubscriber` + `AccountNotificationHandler` | +| Cleanup Scheduler | ✅ Done | `notification-cleanup.service.ts` (30 day expiry) | +| Frontend Bell Icon | ✅ Done | `apps/portal/src/features/notifications/components/` | +| Frontend Hooks | ✅ Done | `apps/portal/src/features/notifications/hooks/` | +| Eligibility Timeline Messaging | ✅ Done | `apps/portal/src/features/checkout/components/steps/AvailabilityStep.tsx` | +| Distributed Lock Service | ✅ Done | `apps/bff/src/infra/cache/distributed-lock.service.ts` | +| Centralized SF Field Maps | ✅ Done | `packages/domain/salesforce/field-maps.ts` | +| SIM Opportunity Creation | ✅ Done | `apps/bff/src/modules/checkout-registration/` | + +### Remaining Priority Actions + +1. ✅ **Resolved:** Salesforce fields verified (`Opportunity_Source__c`, `WHMCS_Service_ID__c`) +2. ✅ **Resolved:** Email notifications handled by Salesforce Flows +3. ✅ **Implemented:** In-app notification system with Platform Events +4. ✅ **Implemented:** Timeline messaging in eligibility UI ("Usually 1-2 business days") +5. 📅 **This Week:** Create SF email templates (see Salesforce Email Configuration section) +6. 📅 **This Month:** Add structured rejection reasons with remediation steps +7. 📅 **This Quarter:** Explore NTT API automation for instant eligibility + +### Database Migration Required + +Run the following to apply the notification schema: + +```bash +cd apps/bff +npx prisma migrate dev --name add_notifications +``` diff --git a/packages/domain/notifications/index.ts b/packages/domain/notifications/index.ts new file mode 100644 index 00000000..da20e5e3 --- /dev/null +++ b/packages/domain/notifications/index.ts @@ -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"; diff --git a/packages/domain/notifications/schema.ts b/packages/domain/notifications/schema.ts new file mode 100644 index 00000000..0c15f5e6 --- /dev/null +++ b/packages/domain/notifications/schema.ts @@ -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 = { + [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 { + 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; + +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; + +export const notificationListResponseSchema = z.object({ + notifications: z.array(notificationSchema), + unreadCount: z.number(), + total: z.number(), +}); + +export type NotificationListResponse = z.infer; diff --git a/packages/domain/package.json b/packages/domain/package.json index 241d4c6d..f9537d54 100644 --- a/packages/domain/package.json +++ b/packages/domain/package.json @@ -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": { diff --git a/packages/domain/salesforce/field-maps.ts b/packages/domain/salesforce/field-maps.ts new file mode 100644 index 00000000..5b70c769 --- /dev/null +++ b/packages/domain/salesforce/field-maps.ts @@ -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; diff --git a/packages/domain/salesforce/index.ts b/packages/domain/salesforce/index.ts new file mode 100644 index 00000000..a78ed468 --- /dev/null +++ b/packages/domain/salesforce/index.ts @@ -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"; diff --git a/packages/domain/tsconfig.json b/packages/domain/tsconfig.json index 63b3851b..905d9e22 100644 --- a/packages/domain/tsconfig.json +++ b/packages/domain/tsconfig.json @@ -18,7 +18,9 @@ "customer/**/*", "dashboard/**/*", "mappings/**/*", + "notifications/**/*", "opportunity/**/*", + "salesforce/**/*", "orders/**/*", "payments/**/*", "providers/**/*", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index efb8eb76..939e0f5b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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