diff --git a/apps/bff/src/app.module.ts b/apps/bff/src/app.module.ts index 36104955..169c7a7f 100644 --- a/apps/bff/src/app.module.ts +++ b/apps/bff/src/app.module.ts @@ -28,6 +28,7 @@ import { SalesforceEventsModule } from "@bff/integrations/salesforce/events/even // Feature Modules import { AuthModule } from "@bff/modules/auth/auth.module.js"; import { UsersModule } from "@bff/modules/users/users.module.js"; +import { MeStatusModule } from "@bff/modules/me-status/me-status.module.js"; import { MappingsModule } from "@bff/modules/id-mappings/mappings.module.js"; import { CatalogModule } from "@bff/modules/catalog/catalog.module.js"; import { OrdersModule } from "@bff/modules/orders/orders.module.js"; @@ -81,6 +82,7 @@ import { HealthModule } from "@bff/modules/health/health.module.js"; // === FEATURE MODULES === AuthModule, UsersModule, + MeStatusModule, MappingsModule, CatalogModule, OrdersModule, diff --git a/apps/bff/src/core/config/router.config.ts b/apps/bff/src/core/config/router.config.ts index 889fee52..ed10783f 100644 --- a/apps/bff/src/core/config/router.config.ts +++ b/apps/bff/src/core/config/router.config.ts @@ -1,6 +1,7 @@ import type { Routes } from "@nestjs/core"; import { AuthModule } from "@bff/modules/auth/auth.module.js"; import { UsersModule } from "@bff/modules/users/users.module.js"; +import { MeStatusModule } from "@bff/modules/me-status/me-status.module.js"; import { MappingsModule } from "@bff/modules/id-mappings/mappings.module.js"; import { CatalogModule } from "@bff/modules/catalog/catalog.module.js"; import { OrdersModule } from "@bff/modules/orders/orders.module.js"; @@ -19,6 +20,7 @@ export const apiRoutes: Routes = [ children: [ { path: "", module: AuthModule }, { path: "", module: UsersModule }, + { path: "", module: MeStatusModule }, { path: "", module: MappingsModule }, { path: "", module: CatalogModule }, { path: "", module: OrdersModule }, diff --git a/apps/bff/src/integrations/salesforce/salesforce.module.ts b/apps/bff/src/integrations/salesforce/salesforce.module.ts index 3bc061be..5398b5c7 100644 --- a/apps/bff/src/integrations/salesforce/salesforce.module.ts +++ b/apps/bff/src/integrations/salesforce/salesforce.module.ts @@ -7,6 +7,7 @@ import { SalesforceAccountService } from "./services/salesforce-account.service. import { SalesforceOrderService } from "./services/salesforce-order.service.js"; import { SalesforceCaseService } from "./services/salesforce-case.service.js"; import { SalesforceOpportunityService } from "./services/salesforce-opportunity.service.js"; +import { OpportunityResolutionService } from "./services/opportunity-resolution.service.js"; import { OrderFieldConfigModule } from "@bff/modules/orders/config/order-field-config.module.js"; import { SalesforceReadThrottleGuard } from "./guards/salesforce-read-throttle.guard.js"; import { SalesforceWriteThrottleGuard } from "./guards/salesforce-write-throttle.guard.js"; @@ -19,6 +20,7 @@ import { SalesforceWriteThrottleGuard } from "./guards/salesforce-write-throttle SalesforceOrderService, SalesforceCaseService, SalesforceOpportunityService, + OpportunityResolutionService, SalesforceService, SalesforceReadThrottleGuard, SalesforceWriteThrottleGuard, @@ -31,6 +33,7 @@ import { SalesforceWriteThrottleGuard } from "./guards/salesforce-write-throttle SalesforceOrderService, SalesforceCaseService, SalesforceOpportunityService, + OpportunityResolutionService, SalesforceReadThrottleGuard, SalesforceWriteThrottleGuard, ], diff --git a/apps/bff/src/integrations/salesforce/services/opportunity-resolution.service.ts b/apps/bff/src/integrations/salesforce/services/opportunity-resolution.service.ts new file mode 100644 index 00000000..defc2ac3 --- /dev/null +++ b/apps/bff/src/integrations/salesforce/services/opportunity-resolution.service.ts @@ -0,0 +1,137 @@ +import { Injectable, Inject } from "@nestjs/common"; +import { Logger } from "nestjs-pino"; +import { DistributedLockService } from "@bff/infra/cache/distributed-lock.service.js"; +import { SalesforceOpportunityService } from "./salesforce-opportunity.service.js"; +import { assertSalesforceId } from "../utils/soql.util.js"; +import type { OrderTypeValue } from "@customer-portal/domain/orders"; +import { + APPLICATION_STAGE, + OPPORTUNITY_PRODUCT_TYPE, + OPPORTUNITY_SOURCE, + OPPORTUNITY_STAGE, + OPPORTUNITY_MATCH_STAGES_INTERNET_ELIGIBILITY, + OPPORTUNITY_MATCH_STAGES_ORDER_PLACEMENT, + type OpportunityProductTypeValue, +} from "@customer-portal/domain/opportunity"; + +/** + * Opportunity Resolution Service + * + * Centralizes the "find or create" rules for Opportunities so eligibility, checkout, + * and other flows cannot drift over time. + * + * Key principle: + * - Eligibility can only match the initial Introduction opportunity. + * - Order placement can match Introduction/Ready. It must never match Active. + */ +@Injectable() +export class OpportunityResolutionService { + constructor( + private readonly opportunities: SalesforceOpportunityService, + private readonly lockService: DistributedLockService, + @Inject(Logger) private readonly logger: Logger + ) {} + + /** + * Resolve (find or create) an Internet Opportunity for eligibility request. + * + * NOTE: The eligibility flow itself should ensure idempotency for Case creation. + * This method only resolves the Opportunity link. + */ + async findOrCreateForInternetEligibility(accountId: string): Promise<{ + opportunityId: string; + wasCreated: boolean; + }> { + const safeAccountId = assertSalesforceId(accountId, "accountId"); + + const existing = await this.opportunities.findOpenOpportunityForAccount( + safeAccountId, + OPPORTUNITY_PRODUCT_TYPE.INTERNET, + { stages: OPPORTUNITY_MATCH_STAGES_INTERNET_ELIGIBILITY } + ); + + if (existing) { + return { opportunityId: existing, wasCreated: false }; + } + + const created = await this.opportunities.createOpportunity({ + accountId: safeAccountId, + productType: OPPORTUNITY_PRODUCT_TYPE.INTERNET, + stage: OPPORTUNITY_STAGE.INTRODUCTION, + source: OPPORTUNITY_SOURCE.INTERNET_ELIGIBILITY, + applicationStage: APPLICATION_STAGE.INTRO_1, + }); + + return { opportunityId: created, wasCreated: true }; + } + + /** + * Resolve (find or create) an Opportunity for order placement. + * + * - If an OpportunityId is already provided, use it as-is. + * - Otherwise, match only Introduction/Ready to avoid corrupting lifecycle tracking. + * - If none found, create a new Opportunity in Post Processing stage. + */ + async resolveForOrderPlacement(params: { + accountId: string | null; + orderType: OrderTypeValue; + existingOpportunityId?: string; + }): Promise { + if (!params.accountId) return null; + + const safeAccountId = assertSalesforceId(params.accountId, "accountId"); + + if (params.existingOpportunityId) { + return assertSalesforceId(params.existingOpportunityId, "existingOpportunityId"); + } + + const productType = this.mapOrderTypeToProductType(params.orderType); + const lockKey = `opportunity:order:${safeAccountId}:${productType}`; + + return this.lockService.withLock( + lockKey, + async () => { + const existing = await this.opportunities.findOpenOpportunityForAccount( + safeAccountId, + productType, + { stages: OPPORTUNITY_MATCH_STAGES_ORDER_PLACEMENT } + ); + + if (existing) { + return existing; + } + + const created = await this.opportunities.createOpportunity({ + accountId: safeAccountId, + productType, + stage: OPPORTUNITY_STAGE.POST_PROCESSING, + source: OPPORTUNITY_SOURCE.ORDER_PLACEMENT, + applicationStage: APPLICATION_STAGE.INTRO_1, + }); + + this.logger.log("Created new Opportunity for order placement", { + accountIdTail: safeAccountId.slice(-4), + opportunityIdTail: created.slice(-4), + productType, + orderType: params.orderType, + }); + + return created; + }, + { ttlMs: 10_000 } + ); + } + + private mapOrderTypeToProductType(orderType: OrderTypeValue): OpportunityProductTypeValue { + switch (orderType) { + case "Internet": + return OPPORTUNITY_PRODUCT_TYPE.INTERNET; + case "SIM": + return OPPORTUNITY_PRODUCT_TYPE.SIM; + case "VPN": + return OPPORTUNITY_PRODUCT_TYPE.VPN; + default: + return OPPORTUNITY_PRODUCT_TYPE.SIM; + } + } +} diff --git a/apps/bff/src/modules/catalog/internet-eligibility.controller.ts b/apps/bff/src/modules/catalog/internet-eligibility.controller.ts index d20f4765..7ca825ff 100644 --- a/apps/bff/src/modules/catalog/internet-eligibility.controller.ts +++ b/apps/bff/src/modules/catalog/internet-eligibility.controller.ts @@ -3,11 +3,9 @@ import { ZodValidationPipe } from "nestjs-zod"; import { z } from "zod"; import type { RequestWithUser } from "@bff/modules/auth/auth.types.js"; import { RateLimit, RateLimitGuard } from "@bff/core/rate-limiting/index.js"; -import { - InternetCatalogService, - type InternetEligibilityDto, -} from "./services/internet-catalog.service.js"; +import { InternetCatalogService } from "./services/internet-catalog.service.js"; import { addressSchema } from "@customer-portal/domain/customer"; +import type { InternetEligibilityDetails } from "@customer-portal/domain/catalog"; const eligibilityRequestSchema = z.object({ notes: z.string().trim().max(2000).optional(), @@ -33,7 +31,7 @@ export class InternetEligibilityController { @Get("eligibility") @RateLimit({ limit: 60, ttl: 60 }) // 60/min per IP (cheap) - async getEligibility(@Req() req: RequestWithUser): Promise { + async getEligibility(@Req() req: RequestWithUser): Promise { return this.internetCatalog.getEligibilityDetailsForUser(req.user.id); } diff --git a/apps/bff/src/modules/catalog/services/internet-catalog.service.ts b/apps/bff/src/modules/catalog/services/internet-catalog.service.ts index 781641d7..6cb1f60d 100644 --- a/apps/bff/src/modules/catalog/services/internet-catalog.service.ts +++ b/apps/bff/src/modules/catalog/services/internet-catalog.service.ts @@ -7,16 +7,19 @@ import type { InternetPlanCatalogItem, InternetInstallationCatalogItem, InternetAddonCatalogItem, + InternetEligibilityDetails, + InternetEligibilityStatus, } from "@customer-portal/domain/catalog"; import { Providers as CatalogProviders, enrichInternetPlanMetadata, inferAddonTypeFromSku, inferInstallationTermFromSku, + internetEligibilityDetailsSchema, } from "@customer-portal/domain/catalog"; import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js"; import { SalesforceConnection } from "@bff/integrations/salesforce/services/salesforce-connection.service.js"; -import { SalesforceOpportunityService } from "@bff/integrations/salesforce/services/salesforce-opportunity.service.js"; +import { OpportunityResolutionService } from "@bff/integrations/salesforce/services/opportunity-resolution.service.js"; import { SalesforceCaseService } from "@bff/integrations/salesforce/services/salesforce-case.service.js"; import { DistributedLockService } from "@bff/infra/cache/distributed-lock.service.js"; import { Logger } from "nestjs-pino"; @@ -29,20 +32,8 @@ import { OPPORTUNITY_STAGE, OPPORTUNITY_SOURCE, OPPORTUNITY_PRODUCT_TYPE, - OPPORTUNITY_MATCH_STAGES_INTERNET_ELIGIBILITY, } from "@customer-portal/domain/opportunity"; -export type InternetEligibilityStatusDto = "not_requested" | "pending" | "eligible" | "ineligible"; - -export interface InternetEligibilityDto { - status: InternetEligibilityStatusDto; - eligibility: string | null; - requestId: string | null; - requestedAt: string | null; - checkedAt: string | null; - notes: string | null; -} - @Injectable() export class InternetCatalogService extends BaseCatalogService { constructor( @@ -52,7 +43,7 @@ export class InternetCatalogService extends BaseCatalogService { private mappingsService: MappingsService, private catalogCache: CatalogCacheService, private lockService: DistributedLockService, - private opportunityService: SalesforceOpportunityService, + private opportunityResolution: OpportunityResolutionService, private caseService: SalesforceCaseService ) { super(sf, config, logger); @@ -192,7 +183,7 @@ export class InternetCatalogService extends BaseCatalogService { // Get customer's eligibility from Salesforce const sfAccountId = assertSalesforceId(mapping.sfAccountId, "sfAccountId"); const eligibilityKey = this.catalogCache.buildEligibilityKey("internet", sfAccountId); - const details = await this.catalogCache.getCachedEligibility( + const details = await this.catalogCache.getCachedEligibility( eligibilityKey, async () => this.queryEligibilityDetails(sfAccountId) ); @@ -239,22 +230,22 @@ export class InternetCatalogService extends BaseCatalogService { return details.eligibility; } - async getEligibilityDetailsForUser(userId: string): Promise { + async getEligibilityDetailsForUser(userId: string): Promise { const mapping = await this.mappingsService.findByUserId(userId); if (!mapping?.sfAccountId) { - return { + return internetEligibilityDetailsSchema.parse({ status: "not_requested", eligibility: null, requestId: null, requestedAt: null, checkedAt: null, notes: null, - }; + }); } const sfAccountId = assertSalesforceId(mapping.sfAccountId, "sfAccountId"); const eligibilityKey = this.catalogCache.buildEligibilityKey("internet", sfAccountId); - return this.catalogCache.getCachedEligibility( + return this.catalogCache.getCachedEligibility( eligibilityKey, async () => this.queryEligibilityDetails(sfAccountId) ); @@ -298,29 +289,8 @@ export class InternetCatalogService extends BaseCatalogService { } // 1) Find or create Opportunity for Internet eligibility - // Only match Introduction stage. "Ready"/"Post Processing"/"Active" indicate the journey progressed. - let opportunityId = await this.opportunityService.findOpenOpportunityForAccount( - sfAccountId, - OPPORTUNITY_PRODUCT_TYPE.INTERNET, - { stages: OPPORTUNITY_MATCH_STAGES_INTERNET_ELIGIBILITY } - ); - - let opportunityCreated = false; - if (!opportunityId) { - // Create Opportunity - Salesforce workflow auto-generates the name - opportunityId = await this.opportunityService.createOpportunity({ - accountId: sfAccountId, - productType: OPPORTUNITY_PRODUCT_TYPE.INTERNET, - stage: OPPORTUNITY_STAGE.INTRODUCTION, - source: OPPORTUNITY_SOURCE.INTERNET_ELIGIBILITY, - }); - opportunityCreated = true; - - this.logger.log("Created Opportunity for eligibility request", { - opportunityIdTail: opportunityId.slice(-4), - sfAccountIdTail: sfAccountId.slice(-4), - }); - } + const { opportunityId, wasCreated: opportunityCreated } = + await this.opportunityResolution.findOrCreateForInternetEligibility(sfAccountId); // 2) Build case description const subject = "Internet availability check request (Portal)"; @@ -380,7 +350,7 @@ export class InternetCatalogService extends BaseCatalogService { return plan.internetOfferingType === eligibility; } - private async queryEligibilityDetails(sfAccountId: string): Promise { + private async queryEligibilityDetails(sfAccountId: string): Promise { const eligibilityField = assertSoqlFieldName( this.config.get("ACCOUNT_INTERNET_ELIGIBILITY_FIELD") ?? "Internet_Eligibility__c", "ACCOUNT_INTERNET_ELIGIBILITY_FIELD" @@ -423,14 +393,14 @@ export class InternetCatalogService extends BaseCatalogService { })) as SalesforceResponse>; const record = (res.records?.[0] as Record | undefined) ?? undefined; if (!record) { - return { + return internetEligibilityDetailsSchema.parse({ status: "not_requested", eligibility: null, requestId: null, requestedAt: null, checkedAt: null, notes: null, - }; + }); } const eligibilityRaw = record[eligibilityField]; @@ -442,7 +412,7 @@ export class InternetCatalogService extends BaseCatalogService { const statusRaw = record[statusField]; const normalizedStatus = typeof statusRaw === "string" ? statusRaw.trim().toLowerCase() : ""; - const status: InternetEligibilityStatusDto = + const status: InternetEligibilityStatus = normalizedStatus === "pending" || normalizedStatus === "checking" ? "pending" : normalizedStatus === "eligible" @@ -475,7 +445,14 @@ export class InternetCatalogService extends BaseCatalogService { : null; const notes = typeof notesRaw === "string" && notesRaw.trim() ? notesRaw.trim() : null; - return { status, eligibility, requestId, requestedAt, checkedAt, notes }; + return internetEligibilityDetailsSchema.parse({ + status, + eligibility, + requestId, + requestedAt, + checkedAt, + notes, + }); } // Note: createEligibilityCaseOrTask was removed - now using this.caseService.createEligibilityCase() diff --git a/apps/bff/src/modules/me-status/me-status.controller.ts b/apps/bff/src/modules/me-status/me-status.controller.ts new file mode 100644 index 00000000..99413bb2 --- /dev/null +++ b/apps/bff/src/modules/me-status/me-status.controller.ts @@ -0,0 +1,16 @@ +import { Controller, Get, Req, UseGuards } from "@nestjs/common"; +import type { RequestWithUser } from "@bff/modules/auth/auth.types.js"; +import { SalesforceReadThrottleGuard } from "@bff/integrations/salesforce/guards/salesforce-read-throttle.guard.js"; +import { MeStatusService } from "./me-status.service.js"; +import type { MeStatus } from "@customer-portal/domain/dashboard"; + +@Controller("me") +export class MeStatusController { + constructor(private readonly meStatus: MeStatusService) {} + + @UseGuards(SalesforceReadThrottleGuard) + @Get("status") + async getStatus(@Req() req: RequestWithUser): Promise { + return this.meStatus.getStatusForUser(req.user.id); + } +} diff --git a/apps/bff/src/modules/me-status/me-status.module.ts b/apps/bff/src/modules/me-status/me-status.module.ts new file mode 100644 index 00000000..fbebbf30 --- /dev/null +++ b/apps/bff/src/modules/me-status/me-status.module.ts @@ -0,0 +1,25 @@ +import { Module } from "@nestjs/common"; +import { MeStatusController } from "./me-status.controller.js"; +import { MeStatusService } from "./me-status.service.js"; +import { UsersModule } from "@bff/modules/users/users.module.js"; +import { OrdersModule } from "@bff/modules/orders/orders.module.js"; +import { CatalogModule } from "@bff/modules/catalog/catalog.module.js"; +import { VerificationModule } from "@bff/modules/verification/verification.module.js"; +import { WhmcsModule } from "@bff/integrations/whmcs/whmcs.module.js"; +import { MappingsModule } from "@bff/modules/id-mappings/mappings.module.js"; +import { NotificationsModule } from "@bff/modules/notifications/notifications.module.js"; + +@Module({ + imports: [ + UsersModule, + OrdersModule, + CatalogModule, + VerificationModule, + WhmcsModule, + MappingsModule, + NotificationsModule, + ], + controllers: [MeStatusController], + providers: [MeStatusService], +}) +export class MeStatusModule {} diff --git a/apps/bff/src/modules/me-status/me-status.service.ts b/apps/bff/src/modules/me-status/me-status.service.ts new file mode 100644 index 00000000..e1fa8dec --- /dev/null +++ b/apps/bff/src/modules/me-status/me-status.service.ts @@ -0,0 +1,264 @@ +import { Injectable, Inject } from "@nestjs/common"; +import { Logger } from "nestjs-pino"; +import { UsersFacade } from "@bff/modules/users/application/users.facade.js"; +import { OrderOrchestrator } from "@bff/modules/orders/services/order-orchestrator.service.js"; +import { InternetCatalogService } from "@bff/modules/catalog/services/internet-catalog.service.js"; +import { ResidenceCardService } from "@bff/modules/verification/residence-card.service.js"; +import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js"; +import { WhmcsPaymentService } from "@bff/integrations/whmcs/services/whmcs-payment.service.js"; +import { NotificationService } from "@bff/modules/notifications/notifications.service.js"; +import { + meStatusSchema, + type DashboardSummary, + type DashboardTask, + type MeStatus, + type PaymentMethodsStatus, +} from "@customer-portal/domain/dashboard"; +import { NOTIFICATION_SOURCE, NOTIFICATION_TYPE } from "@customer-portal/domain/notifications"; +import type { InternetEligibilityDetails } from "@customer-portal/domain/catalog"; +import type { ResidenceCardVerification } from "@customer-portal/domain/customer"; +import type { OrderSummary } from "@customer-portal/domain/orders"; + +@Injectable() +export class MeStatusService { + constructor( + private readonly users: UsersFacade, + private readonly orders: OrderOrchestrator, + private readonly internetCatalog: InternetCatalogService, + private readonly residenceCards: ResidenceCardService, + private readonly mappings: MappingsService, + private readonly whmcsPayments: WhmcsPaymentService, + private readonly notifications: NotificationService, + @Inject(Logger) private readonly logger: Logger + ) {} + + async getStatusForUser(userId: string): Promise { + const [summary, internetEligibility, residenceCardVerification, orders] = await Promise.all([ + this.users.getUserSummary(userId), + this.internetCatalog.getEligibilityDetailsForUser(userId), + this.residenceCards.getStatusForUser(userId), + this.safeGetOrders(userId), + ]); + + const paymentMethods = await this.safeGetPaymentMethodsStatus(userId); + + const tasks = this.computeTasks({ + summary, + paymentMethods, + internetEligibility, + residenceCardVerification, + orders, + }); + + await this.maybeCreateInvoiceDueNotification(userId, summary); + + return meStatusSchema.parse({ + summary, + paymentMethods, + internetEligibility, + residenceCardVerification, + tasks, + }); + } + + private async safeGetOrders(userId: string): Promise { + try { + const result = await this.orders.getOrdersForUser(userId); + return Array.isArray(result) ? result : []; + } catch (error) { + this.logger.warn( + { userId, err: error instanceof Error ? error.message : String(error) }, + "Failed to load orders for status payload" + ); + return null; + } + } + + private async safeGetPaymentMethodsStatus(userId: string): Promise { + try { + const mapping = await this.mappings.findByUserId(userId); + if (!mapping?.whmcsClientId) { + return { totalCount: null }; + } + + const list = await this.whmcsPayments.getPaymentMethods(mapping.whmcsClientId, userId); + return { totalCount: typeof list?.totalCount === "number" ? list.totalCount : 0 }; + } catch (error) { + this.logger.warn( + { userId, err: error instanceof Error ? error.message : String(error) }, + "Failed to load payment methods for status payload" + ); + return { totalCount: null }; + } + } + + private computeTasks(params: { + summary: DashboardSummary; + paymentMethods: PaymentMethodsStatus; + internetEligibility: InternetEligibilityDetails; + residenceCardVerification: ResidenceCardVerification; + orders: OrderSummary[] | null; + }): DashboardTask[] { + const tasks: DashboardTask[] = []; + + const { summary, paymentMethods, internetEligibility, residenceCardVerification, orders } = + params; + + // Priority 1: next unpaid invoice + if (summary.nextInvoice) { + const dueDate = new Date(summary.nextInvoice.dueDate); + const isValid = !Number.isNaN(dueDate.getTime()); + const isOverdue = isValid ? dueDate.getTime() < Date.now() : false; + + const formattedAmount = new Intl.NumberFormat("ja-JP", { + style: "currency", + currency: summary.nextInvoice.currency, + maximumFractionDigits: 0, + }).format(summary.nextInvoice.amount); + + const dueText = isValid + ? dueDate.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" }) + : "soon"; + + tasks.push({ + id: `invoice-${summary.nextInvoice.id}`, + priority: 1, + type: "invoice", + title: isOverdue ? "Pay overdue invoice" : "Pay upcoming invoice", + description: `Invoice #${summary.nextInvoice.id} · ${formattedAmount} · Due ${dueText}`, + actionLabel: "Pay now", + detailHref: `/account/billing/invoices/${summary.nextInvoice.id}`, + requiresSsoAction: true, + tone: "critical", + metadata: { + invoiceId: summary.nextInvoice.id, + amount: summary.nextInvoice.amount, + currency: summary.nextInvoice.currency, + ...(isValid ? { dueDate: dueDate.toISOString() } : {}), + }, + }); + } + + // Priority 2: no payment method (only when we could verify) + if (paymentMethods.totalCount === 0) { + tasks.push({ + id: "add-payment-method", + priority: 2, + type: "payment_method", + title: "Add a payment method", + description: "Required to place orders and process invoices", + actionLabel: "Add method", + detailHref: "/account/billing/payments", + requiresSsoAction: true, + tone: "warning", + }); + } + + // Priority 3: pending orders + if (orders && orders.length > 0) { + const pendingOrders = orders.filter( + o => + o.status === "Draft" || + o.status === "Pending" || + (o.status === "Activated" && o.activationStatus !== "Completed") + ); + + if (pendingOrders.length > 0) { + const order = pendingOrders[0]; + const statusText = + order.status === "Pending" + ? "awaiting review" + : order.status === "Draft" + ? "in draft" + : "being activated"; + + tasks.push({ + id: `order-${order.id}`, + priority: 3, + type: "order", + title: "Order in progress", + description: `${order.orderType || "Your"} order is ${statusText}`, + actionLabel: "View details", + detailHref: `/account/orders/${order.id}`, + tone: "info", + metadata: { orderId: order.id }, + }); + } + } + + // Priority 4: Internet eligibility review (only when explicitly pending) + if (internetEligibility.status === "pending") { + tasks.push({ + id: "internet-eligibility-review", + priority: 4, + type: "internet_eligibility", + title: "Internet availability review", + description: + "We’re verifying if our service is available at your residence. We’ll notify you when review is complete.", + actionLabel: "View status", + detailHref: "/account/shop/internet", + tone: "info", + }); + } + + // Priority 4: ID verification rejected + if (residenceCardVerification.status === "rejected") { + tasks.push({ + id: "id-verification-rejected", + priority: 4, + type: "id_verification", + title: "ID verification requires attention", + description: "We couldn’t verify your ID. Please review the feedback and resubmit.", + actionLabel: "Resubmit", + detailHref: "/account/settings/verification", + tone: "warning", + }); + } + + // Priority 4: onboarding (only when no other tasks) + if (summary.stats.activeSubscriptions === 0 && tasks.length === 0) { + tasks.push({ + id: "start-subscription", + priority: 4, + type: "onboarding", + title: "Start your first service", + description: "Browse our catalog and subscribe to internet, SIM, or VPN", + actionLabel: "Browse services", + detailHref: "/shop", + tone: "neutral", + }); + } + + return tasks.sort((a, b) => a.priority - b.priority); + } + + private async maybeCreateInvoiceDueNotification( + userId: string, + summary: DashboardSummary + ): Promise { + const invoice = summary.nextInvoice; + if (!invoice) return; + + try { + const dueDate = new Date(invoice.dueDate); + if (Number.isNaN(dueDate.getTime())) return; + + const daysUntilDue = (dueDate.getTime() - Date.now()) / (1000 * 60 * 60 * 24); + // Notify when due within a week (or overdue). + if (daysUntilDue > 7) return; + + await this.notifications.createNotification({ + userId, + type: NOTIFICATION_TYPE.INVOICE_DUE, + source: NOTIFICATION_SOURCE.SYSTEM, + sourceId: `invoice:${invoice.id}`, + actionUrl: `/account/billing/invoices/${invoice.id}`, + }); + } catch (error) { + this.logger.warn( + { userId, err: error instanceof Error ? error.message : String(error) }, + "Failed to create invoice due notification" + ); + } + } +} diff --git a/apps/bff/src/modules/notifications/notifications.service.ts b/apps/bff/src/modules/notifications/notifications.service.ts index a1b42076..0b08a0d2 100644 --- a/apps/bff/src/modules/notifications/notifications.service.ts +++ b/apps/bff/src/modules/notifications/notifications.service.ts @@ -22,6 +22,16 @@ import { // Notification expiry in days const NOTIFICATION_EXPIRY_DAYS = 30; +// Dedupe window (in hours) per notification type. +// Defaults to 1 hour when not specified. +const NOTIFICATION_DEDUPE_WINDOW_HOURS: Partial> = { + // These are often evaluated opportunistically (e.g., on dashboard load), + // so keep the dedupe window larger to avoid spam. + INVOICE_DUE: 24, + PAYMENT_METHOD_EXPIRING: 24, + SYSTEM_ANNOUNCEMENT: 24, +}; + export interface CreateNotificationParams { userId: string; type: NotificationTypeValue; @@ -54,17 +64,17 @@ export class NotificationService { expiresAt.setDate(expiresAt.getDate() + NOTIFICATION_EXPIRY_DAYS); try { - // Check for duplicate notification (same type + sourceId within last hour) + // Check for duplicate notification (same type + sourceId within a short window) if (params.sourceId) { - const oneHourAgo = new Date(); - oneHourAgo.setHours(oneHourAgo.getHours() - 1); + const dedupeHours = NOTIFICATION_DEDUPE_WINDOW_HOURS[params.type] ?? 1; + const since = new Date(Date.now() - dedupeHours * 60 * 60 * 1000); const existingNotification = await this.prisma.notification.findFirst({ where: { userId: params.userId, type: params.type, sourceId: params.sourceId, - createdAt: { gte: oneHourAgo }, + createdAt: { gte: since }, }, }); diff --git a/apps/bff/src/modules/orders/orders.module.ts b/apps/bff/src/modules/orders/orders.module.ts index d9be314f..5156e324 100644 --- a/apps/bff/src/modules/orders/orders.module.ts +++ b/apps/bff/src/modules/orders/orders.module.ts @@ -9,6 +9,7 @@ import { DatabaseModule } from "@bff/core/database/database.module.js"; import { CatalogModule } from "@bff/modules/catalog/catalog.module.js"; import { CacheModule } from "@bff/infra/cache/cache.module.js"; import { VerificationModule } from "@bff/modules/verification/verification.module.js"; +import { NotificationsModule } from "@bff/modules/notifications/notifications.module.js"; // Clean modular order services import { OrderValidator } from "./services/order-validator.service.js"; @@ -41,6 +42,7 @@ import { OrderFieldConfigModule } from "./config/order-field-config.module.js"; CatalogModule, CacheModule, VerificationModule, + NotificationsModule, OrderFieldConfigModule, ], controllers: [OrdersController, CheckoutController], diff --git a/apps/bff/src/modules/orders/services/order-fulfillment-orchestrator.service.ts b/apps/bff/src/modules/orders/services/order-fulfillment-orchestrator.service.ts index 110063fd..eb6c187f 100644 --- a/apps/bff/src/modules/orders/services/order-fulfillment-orchestrator.service.ts +++ b/apps/bff/src/modules/orders/services/order-fulfillment-orchestrator.service.ts @@ -12,12 +12,16 @@ import { DistributedTransactionService } from "@bff/core/database/services/distr import { getErrorMessage } from "@bff/core/utils/error.util.js"; import { OrderEventsService } from "./order-events.service.js"; import { OrdersCacheService } from "./orders-cache.service.js"; +import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js"; +import { NotificationService } from "@bff/modules/notifications/notifications.service.js"; import { type OrderDetails, type OrderFulfillmentValidationResult, Providers as OrderProviders, } from "@customer-portal/domain/orders"; import { OPPORTUNITY_STAGE } from "@customer-portal/domain/opportunity"; +import { NOTIFICATION_SOURCE, NOTIFICATION_TYPE } from "@customer-portal/domain/notifications"; +import { salesforceAccountIdSchema } from "@customer-portal/domain/common"; import { OrderValidationException, FulfillmentException, @@ -61,7 +65,9 @@ export class OrderFulfillmentOrchestrator { private readonly simFulfillmentService: SimFulfillmentService, private readonly distributedTransactionService: DistributedTransactionService, private readonly orderEvents: OrderEventsService, - private readonly ordersCache: OrdersCacheService + private readonly ordersCache: OrdersCacheService, + private readonly mappingsService: MappingsService, + private readonly notifications: NotificationService ) {} /** @@ -174,6 +180,12 @@ export class OrderFulfillmentOrchestrator { source: "fulfillment", timestamp: new Date().toISOString(), }); + await this.safeNotifyOrder({ + type: NOTIFICATION_TYPE.ORDER_APPROVED, + sfOrderId, + accountId: context.validation?.sfOrder?.AccountId, + actionUrl: `/account/orders/${sfOrderId}`, + }); return result; }), rollback: async () => { @@ -343,6 +355,12 @@ export class OrderFulfillmentOrchestrator { whmcsServiceIds: whmcsCreateResult?.serviceIds, }, }); + await this.safeNotifyOrder({ + type: NOTIFICATION_TYPE.ORDER_ACTIVATED, + sfOrderId, + accountId: context.validation?.sfOrder?.AccountId, + actionUrl: "/account/services", + }); return result; }), rollback: async () => { @@ -442,6 +460,12 @@ export class OrderFulfillmentOrchestrator { } catch (error) { await this.invalidateOrderCaches(sfOrderId, context.validation?.sfOrder?.AccountId); await this.handleFulfillmentError(context, error as Error); + await this.safeNotifyOrder({ + type: NOTIFICATION_TYPE.ORDER_FAILED, + sfOrderId, + accountId: context.validation?.sfOrder?.AccountId, + actionUrl: `/account/orders/${sfOrderId}`, + }); this.orderEvents.publish(sfOrderId, { orderId: sfOrderId, status: "Pending Review", @@ -501,6 +525,38 @@ export class OrderFulfillmentOrchestrator { } } + private async safeNotifyOrder(params: { + type: (typeof NOTIFICATION_TYPE)[keyof typeof NOTIFICATION_TYPE]; + sfOrderId: string; + accountId?: unknown; + actionUrl: string; + }): Promise { + try { + const sfAccountId = salesforceAccountIdSchema.safeParse(params.accountId); + if (!sfAccountId.success) return; + + const mapping = await this.mappingsService.findBySfAccountId(sfAccountId.data); + if (!mapping?.userId) return; + + await this.notifications.createNotification({ + userId: mapping.userId, + type: params.type, + source: NOTIFICATION_SOURCE.SYSTEM, + sourceId: params.sfOrderId, + actionUrl: params.actionUrl, + }); + } catch (error) { + this.logger.warn( + { + sfOrderId: params.sfOrderId, + type: params.type, + err: error instanceof Error ? error.message : String(error), + }, + "Failed to create in-app order notification" + ); + } + } + /** * Handle fulfillment errors and update Salesforce */ diff --git a/apps/bff/src/modules/orders/services/order-orchestrator.service.ts b/apps/bff/src/modules/orders/services/order-orchestrator.service.ts index 80c72a0e..477e5007 100644 --- a/apps/bff/src/modules/orders/services/order-orchestrator.service.ts +++ b/apps/bff/src/modules/orders/services/order-orchestrator.service.ts @@ -2,7 +2,7 @@ import { Injectable, Inject, NotFoundException } from "@nestjs/common"; import { Logger } from "nestjs-pino"; import { SalesforceOrderService } from "@bff/integrations/salesforce/services/salesforce-order.service.js"; import { SalesforceOpportunityService } from "@bff/integrations/salesforce/services/salesforce-opportunity.service.js"; -import { DistributedLockService } from "@bff/infra/cache/distributed-lock.service.js"; +import { OpportunityResolutionService } from "@bff/integrations/salesforce/services/opportunity-resolution.service.js"; import { OrderValidator } from "./order-validator.service.js"; import { OrderBuilder } from "./order-builder.service.js"; import { OrderItemBuilder } from "./order-item-builder.service.js"; @@ -10,13 +10,7 @@ import type { OrderItemCompositePayload } from "./order-item-builder.service.js" import { OrdersCacheService } from "./orders-cache.service.js"; import type { OrderDetails, OrderSummary, OrderTypeValue } from "@customer-portal/domain/orders"; import { assertSalesforceId } from "@bff/integrations/salesforce/utils/soql.util.js"; -import { - OPPORTUNITY_STAGE, - OPPORTUNITY_SOURCE, - OPPORTUNITY_PRODUCT_TYPE, - type OpportunityProductTypeValue, - OPPORTUNITY_MATCH_STAGES_ORDER_PLACEMENT, -} from "@customer-portal/domain/opportunity"; +import { OPPORTUNITY_STAGE } from "@customer-portal/domain/opportunity"; type OrderDetailsResponse = OrderDetails; type OrderSummaryResponse = OrderSummary; @@ -31,7 +25,7 @@ export class OrderOrchestrator { @Inject(Logger) private readonly logger: Logger, private readonly salesforceOrderService: SalesforceOrderService, private readonly opportunityService: SalesforceOpportunityService, - private readonly lockService: DistributedLockService, + private readonly opportunityResolution: OpportunityResolutionService, private readonly orderValidator: OrderValidator, private readonly orderBuilder: OrderBuilder, private readonly orderItemBuilder: OrderItemBuilder, @@ -148,87 +142,28 @@ export class OrderOrchestrator { sfAccountId: string | null, existingOpportunityId?: string ): Promise { - // If account ID is missing, can't create Opportunity - if (!sfAccountId) { - this.logger.warn("Cannot resolve Opportunity: no Salesforce Account ID"); - return null; - } - - const safeAccountId = assertSalesforceId(sfAccountId, "sfAccountId"); - const productType = this.mapOrderTypeToProductType(orderType); - - // If order already has Opportunity ID, use it - if (existingOpportunityId) { - this.logger.debug("Using existing Opportunity from order", { - opportunityId: existingOpportunityId, - }); - return existingOpportunityId; - } - try { - const lockKey = `opportunity:order:${safeAccountId}:${productType}`; - - return await this.lockService.withLock( - lockKey, - async () => { - // Try to find existing matchable Opportunity (Introduction or Ready stage) - const existingOppId = await this.opportunityService.findOpenOpportunityForAccount( - safeAccountId, - productType, - { stages: OPPORTUNITY_MATCH_STAGES_ORDER_PLACEMENT } - ); - - if (existingOppId) { - this.logger.log("Found existing Opportunity for order", { - opportunityIdTail: existingOppId.slice(-4), - productType, - }); - return existingOppId; - } - - // Create new Opportunity - Salesforce workflow auto-generates the name - const newOppId = await this.opportunityService.createOpportunity({ - accountId: safeAccountId, - productType, - stage: OPPORTUNITY_STAGE.POST_PROCESSING, - source: OPPORTUNITY_SOURCE.ORDER_PLACEMENT, - }); - - this.logger.log("Created new Opportunity for order", { - opportunityIdTail: newOppId.slice(-4), - productType, - }); - - return newOppId; - }, - { ttlMs: 10_000 } - ); - } catch { - this.logger.warn("Failed to resolve Opportunity for order", { + const resolved = await this.opportunityResolution.resolveForOrderPlacement({ + accountId: sfAccountId, orderType, - accountIdTail: safeAccountId.slice(-4), + existingOpportunityId, }); + if (resolved) { + this.logger.debug("Resolved Opportunity for order", { + opportunityIdTail: resolved.slice(-4), + orderType, + }); + } + return resolved; + } catch { + const accountIdTail = + typeof sfAccountId === "string" && sfAccountId.length >= 4 ? sfAccountId.slice(-4) : "none"; + this.logger.warn("Failed to resolve Opportunity for order", { orderType, accountIdTail }); // Don't fail the order if Opportunity resolution fails return null; } } - /** - * Map order type to Opportunity product type - */ - private mapOrderTypeToProductType(orderType: OrderTypeValue): OpportunityProductTypeValue { - switch (orderType) { - case "Internet": - return OPPORTUNITY_PRODUCT_TYPE.INTERNET; - case "SIM": - return OPPORTUNITY_PRODUCT_TYPE.SIM; - case "VPN": - return OPPORTUNITY_PRODUCT_TYPE.VPN; - default: - return OPPORTUNITY_PRODUCT_TYPE.SIM; // Default fallback - } - } - /** * Get order by ID with order items */ diff --git a/apps/bff/src/modules/subscriptions/internet-management/internet-management.module.ts b/apps/bff/src/modules/subscriptions/internet-management/internet-management.module.ts index ecb4548b..67b7306e 100644 --- a/apps/bff/src/modules/subscriptions/internet-management/internet-management.module.ts +++ b/apps/bff/src/modules/subscriptions/internet-management/internet-management.module.ts @@ -4,9 +4,10 @@ import { WhmcsModule } from "@bff/integrations/whmcs/whmcs.module.js"; import { MappingsModule } from "@bff/modules/id-mappings/mappings.module.js"; import { SalesforceModule } from "@bff/integrations/salesforce/salesforce.module.js"; import { EmailModule } from "@bff/infra/email/email.module.js"; +import { NotificationsModule } from "@bff/modules/notifications/notifications.module.js"; @Module({ - imports: [WhmcsModule, MappingsModule, SalesforceModule, EmailModule], + imports: [WhmcsModule, MappingsModule, SalesforceModule, EmailModule, NotificationsModule], providers: [InternetCancellationService], exports: [InternetCancellationService], }) diff --git a/apps/bff/src/modules/subscriptions/internet-management/services/internet-cancellation.service.ts b/apps/bff/src/modules/subscriptions/internet-management/services/internet-cancellation.service.ts index 5bf351bf..abed5d52 100644 --- a/apps/bff/src/modules/subscriptions/internet-management/services/internet-cancellation.service.ts +++ b/apps/bff/src/modules/subscriptions/internet-management/services/internet-cancellation.service.ts @@ -18,6 +18,7 @@ import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js"; import { SalesforceCaseService } from "@bff/integrations/salesforce/services/salesforce-case.service.js"; import { SalesforceOpportunityService } from "@bff/integrations/salesforce/services/salesforce-opportunity.service.js"; import { EmailService } from "@bff/infra/email/email.service.js"; +import { NotificationService } from "@bff/modules/notifications/notifications.service.js"; import type { InternetCancellationPreview, InternetCancellationMonth, @@ -28,6 +29,7 @@ import { CANCELLATION_NOTICE, LINE_RETURN_STATUS, } from "@customer-portal/domain/opportunity"; +import { NOTIFICATION_SOURCE, NOTIFICATION_TYPE } from "@customer-portal/domain/notifications"; @Injectable() export class InternetCancellationService { @@ -37,6 +39,7 @@ export class InternetCancellationService { private readonly caseService: SalesforceCaseService, private readonly opportunityService: SalesforceOpportunityService, private readonly emailService: EmailService, + private readonly notifications: NotificationService, @Inject(Logger) private readonly logger: Logger ) {} @@ -232,6 +235,23 @@ export class InternetCancellationService { opportunityId, }); + try { + await this.notifications.createNotification({ + userId, + type: NOTIFICATION_TYPE.CANCELLATION_SCHEDULED, + source: NOTIFICATION_SOURCE.SYSTEM, + sourceId: caseId, + actionUrl: `/account/services/${subscriptionId}`, + }); + } catch (error) { + this.logger.warn("Failed to create cancellation notification", { + userId, + subscriptionId, + caseId, + error: error instanceof Error ? error.message : String(error), + }); + } + // Update Opportunity if found if (opportunityId) { try { diff --git a/apps/bff/src/modules/subscriptions/sim-management/services/sim-cancellation.service.ts b/apps/bff/src/modules/subscriptions/sim-management/services/sim-cancellation.service.ts index 175a4a2c..c7caadb9 100644 --- a/apps/bff/src/modules/subscriptions/sim-management/services/sim-cancellation.service.ts +++ b/apps/bff/src/modules/subscriptions/sim-management/services/sim-cancellation.service.ts @@ -9,6 +9,8 @@ import type { SimCancelRequest, SimCancelFullRequest } from "@customer-portal/do import { SimScheduleService } from "./sim-schedule.service.js"; import { SimActionRunnerService } from "./sim-action-runner.service.js"; import { SimApiNotificationService } from "./sim-api-notification.service.js"; +import { NotificationService } from "@bff/modules/notifications/notifications.service.js"; +import { NOTIFICATION_SOURCE, NOTIFICATION_TYPE } from "@customer-portal/domain/notifications"; export interface CancellationMonth { value: string; // YYYY-MM format @@ -38,6 +40,7 @@ export class SimCancellationService { private readonly simSchedule: SimScheduleService, private readonly simActionRunner: SimActionRunnerService, private readonly apiNotification: SimApiNotificationService, + private readonly notifications: NotificationService, private readonly configService: ConfigService, @Inject(Logger) private readonly logger: Logger ) {} @@ -254,6 +257,24 @@ export class SimCancellationService { runDate, }); + try { + await this.notifications.createNotification({ + userId, + type: NOTIFICATION_TYPE.CANCELLATION_SCHEDULED, + source: NOTIFICATION_SOURCE.SYSTEM, + sourceId: `sim:${subscriptionId}:${runDate}`, + actionUrl: `/account/services/${subscriptionId}`, + }); + } catch (error) { + this.logger.warn("Failed to create SIM cancellation notification", { + userId, + subscriptionId, + account, + runDate, + error: error instanceof Error ? error.message : String(error), + }); + } + // Send admin notification email const adminEmailBody = this.apiNotification.buildCancellationAdminEmail({ customerName, diff --git a/apps/bff/src/modules/subscriptions/sim-management/sim-management.module.ts b/apps/bff/src/modules/subscriptions/sim-management/sim-management.module.ts index bf615f04..6bedf729 100644 --- a/apps/bff/src/modules/subscriptions/sim-management/sim-management.module.ts +++ b/apps/bff/src/modules/subscriptions/sim-management/sim-management.module.ts @@ -28,6 +28,7 @@ import { SimManagementProcessor } from "./queue/sim-management.processor.js"; import { SimVoiceOptionsService } from "./services/sim-voice-options.service.js"; import { SimCallHistoryService } from "./services/sim-call-history.service.js"; import { CatalogModule } from "@bff/modules/catalog/catalog.module.js"; +import { NotificationsModule } from "@bff/modules/notifications/notifications.module.js"; @Module({ imports: [ @@ -38,6 +39,7 @@ import { CatalogModule } from "@bff/modules/catalog/catalog.module.js"; EmailModule, CatalogModule, SftpModule, + NotificationsModule, ], providers: [ // Core services that the SIM services depend on diff --git a/apps/bff/src/modules/verification/residence-card.controller.ts b/apps/bff/src/modules/verification/residence-card.controller.ts index dd8c8e84..df0db842 100644 --- a/apps/bff/src/modules/verification/residence-card.controller.ts +++ b/apps/bff/src/modules/verification/residence-card.controller.ts @@ -11,10 +11,8 @@ import { import { FileInterceptor } from "@nestjs/platform-express"; import type { RequestWithUser } from "@bff/modules/auth/auth.types.js"; import { RateLimit, RateLimitGuard } from "@bff/core/rate-limiting/index.js"; -import { - ResidenceCardService, - type ResidenceCardVerificationDto, -} from "./residence-card.service.js"; +import { ResidenceCardService } from "./residence-card.service.js"; +import type { ResidenceCardVerification } from "@customer-portal/domain/customer"; const MAX_FILE_BYTES = 5 * 1024 * 1024; // 5MB const ALLOWED_MIME_TYPES = new Set(["image/jpeg", "image/png", "application/pdf"]); @@ -33,7 +31,7 @@ export class ResidenceCardController { @Get() @RateLimit({ limit: 60, ttl: 60 }) - async getStatus(@Req() req: RequestWithUser): Promise { + async getStatus(@Req() req: RequestWithUser): Promise { return this.residenceCards.getStatusForUser(req.user.id); } @@ -57,7 +55,7 @@ export class ResidenceCardController { async submit( @Req() req: RequestWithUser, @UploadedFile() file?: UploadedResidenceCard - ): Promise { + ): Promise { if (!file) { throw new BadRequestException("Missing file upload."); } diff --git a/apps/bff/src/modules/verification/residence-card.service.ts b/apps/bff/src/modules/verification/residence-card.service.ts index 3a826de7..11b79001 100644 --- a/apps/bff/src/modules/verification/residence-card.service.ts +++ b/apps/bff/src/modules/verification/residence-card.service.ts @@ -8,21 +8,14 @@ import { assertSoqlFieldName, } from "@bff/integrations/salesforce/utils/soql.util.js"; import type { SalesforceResponse } from "@customer-portal/domain/common"; +import { + residenceCardVerificationSchema, + type ResidenceCardVerification, + type ResidenceCardVerificationStatus, +} from "@customer-portal/domain/customer"; import { getErrorMessage } from "@bff/core/utils/error.util.js"; import { basename, extname } from "node:path"; -type ResidenceCardStatusDto = "not_submitted" | "pending" | "verified" | "rejected"; - -export interface ResidenceCardVerificationDto { - status: ResidenceCardStatusDto; - filename: string | null; - mimeType: string | null; - sizeBytes: number | null; - submittedAt: string | null; - reviewedAt: string | null; - reviewerNotes: string | null; -} - function mapFileTypeToMime(fileType?: string | null): string | null { const normalized = String(fileType || "") .trim() @@ -42,13 +35,13 @@ export class ResidenceCardService { @Inject(Logger) private readonly logger: Logger ) {} - async getStatusForUser(userId: string): Promise { + async getStatusForUser(userId: string): Promise { const mapping = await this.mappings.findByUserId(userId); const sfAccountId = mapping?.sfAccountId ? assertSalesforceId(mapping.sfAccountId, "sfAccountId") : null; if (!sfAccountId) { - return { + return residenceCardVerificationSchema.parse({ status: "not_submitted", filename: null, mimeType: null, @@ -56,7 +49,7 @@ export class ResidenceCardService { submittedAt: null, reviewedAt: null, reviewerNotes: null, - }; + }); } const fields = this.getAccountFieldNames(); @@ -75,7 +68,7 @@ export class ResidenceCardService { const statusRaw = account ? account[fields.status] : undefined; const statusText = typeof statusRaw === "string" ? statusRaw.trim().toLowerCase() : ""; - const status: ResidenceCardStatusDto = + const status: ResidenceCardVerificationStatus = statusText === "verified" ? "verified" : statusText === "rejected" @@ -114,7 +107,7 @@ export class ResidenceCardService { const fileMeta = status === "not_submitted" ? null : await this.getLatestAccountFileMetadata(sfAccountId); - return { + return residenceCardVerificationSchema.parse({ status, filename: fileMeta?.filename ?? null, mimeType: fileMeta?.mimeType ?? null, @@ -122,7 +115,7 @@ export class ResidenceCardService { submittedAt: submittedAt ?? fileMeta?.submittedAt ?? null, reviewedAt, reviewerNotes, - }; + }); } async submitForUser(params: { @@ -131,7 +124,7 @@ export class ResidenceCardService { mimeType: string; sizeBytes: number; content: Uint8Array; - }): Promise { + }): Promise { const mapping = await this.mappings.findByUserId(params.userId); if (!mapping?.sfAccountId) { throw new Error("No Salesforce mapping found for current user"); diff --git a/apps/portal/src/features/account/views/ProfileContainer.tsx b/apps/portal/src/features/account/views/ProfileContainer.tsx index d5e133f0..0048b3df 100644 --- a/apps/portal/src/features/account/views/ProfileContainer.tsx +++ b/apps/portal/src/features/account/views/ProfileContainer.tsx @@ -567,7 +567,12 @@ export default function ProfileContainer() { {verificationQuery.data?.reviewerNotes && (

{verificationQuery.data.reviewerNotes}

)} -

Please upload a new, clear photo of your residence card.

+

Please upload a new, clear photo or scan of your residence card.

+
    +
  • Make sure all text is readable and the full card is visible.
  • +
  • Avoid glare/reflections and blurry photos.
  • +
  • Maximum file size: 5MB.
  • +
) : ( @@ -577,6 +582,37 @@ export default function ProfileContainer() {

)} + {(verificationQuery.data?.filename || + verificationQuery.data?.submittedAt || + verificationQuery.data?.reviewedAt) && ( +
+
+ Latest submission +
+ {verificationQuery.data?.filename && ( +
+ {verificationQuery.data.filename} +
+ )} + {verificationQuery.data?.submittedAt && ( +
+ Submitted on{" "} + {new Date(verificationQuery.data.submittedAt).toLocaleDateString(undefined, { + dateStyle: "medium", + })} +
+ )} + {verificationQuery.data?.reviewedAt && ( +
+ Reviewed on{" "} + {new Date(verificationQuery.data.reviewedAt).toLocaleDateString(undefined, { + dateStyle: "medium", + })} +
+ )} +
+ )} + {canUploadVerification && (
- Accepted formats: JPG, PNG, or PDF. Make sure all text is readable. + Accepted formats: JPG, PNG, or PDF (max 5MB). Make sure all text is readable.

)} diff --git a/apps/portal/src/features/catalog/services/catalog.service.ts b/apps/portal/src/features/catalog/services/catalog.service.ts index 1c662894..58ed4caf 100644 --- a/apps/portal/src/features/catalog/services/catalog.service.ts +++ b/apps/portal/src/features/catalog/services/catalog.service.ts @@ -4,12 +4,14 @@ import { EMPTY_VPN_CATALOG, internetInstallationCatalogItemSchema, internetAddonCatalogItemSchema, + internetEligibilityDetailsSchema, simActivationFeeCatalogItemSchema, simCatalogProductSchema, vpnCatalogProductSchema, type InternetCatalogCollection, type InternetAddonCatalogItem, type InternetInstallationCatalogItem, + type InternetEligibilityDetails, type SimActivationFeeCatalogItem, type SimCatalogCollection, type SimCatalogProduct, @@ -18,17 +20,6 @@ import { } from "@customer-portal/domain/catalog"; import type { Address } from "@customer-portal/domain/customer"; -export type InternetEligibilityStatus = "not_requested" | "pending" | "eligible" | "ineligible"; - -export interface InternetEligibilityDetails { - status: InternetEligibilityStatus; - eligibility: string | null; - requestId: string | null; - requestedAt: string | null; - checkedAt: string | null; - notes: string | null; -} - export const catalogService = { async getInternetCatalog(): Promise { const response = await apiClient.GET("/api/catalog/internet/plans"); @@ -91,7 +82,8 @@ export const catalogService = { const response = await apiClient.GET( "/api/catalog/internet/eligibility" ); - return getDataOrThrow(response, "Failed to load internet eligibility"); + const data = getDataOrThrow(response, "Failed to load internet eligibility"); + return internetEligibilityDetailsSchema.parse(data); }, async requestInternetEligibilityCheck(body?: { diff --git a/apps/portal/src/features/catalog/views/PublicCatalogHome.tsx b/apps/portal/src/features/catalog/views/PublicCatalogHome.tsx index 7e0d2244..9b3a751a 100644 --- a/apps/portal/src/features/catalog/views/PublicCatalogHome.tsx +++ b/apps/portal/src/features/catalog/views/PublicCatalogHome.tsx @@ -8,7 +8,8 @@ import { ShieldCheckIcon, WifiIcon, GlobeAltIcon, - CheckCircleIcon, + ClockIcon, + BoltIcon, } from "@heroicons/react/24/outline"; import { ServiceHeroCard } from "@/features/catalog/components/common/ServiceHeroCard"; import { FeatureCard } from "@/features/catalog/components/common/FeatureCard"; @@ -34,38 +35,49 @@ export function PublicCatalogHomeView() { Choose your connectivity solution

- Discover high-speed internet, mobile data/voice options, and secure VPN services. Browse - our catalog and see starting prices. Create an account to unlock personalized plans and - check internet availability for your address. + Explore our internet, mobile, and VPN services. Browse plans and pricing, then create an + account when you're ready to order.

+ {/* Service-specific ordering info */}
-
- {[ - { - title: "Pick a service", - description: "Internet, SIM, or VPN based on your needs.", - }, - { - title: "Create an account", - description: "Confirm your address and unlock eligibility checks.", - }, - { - title: "Configure and order", - description: "Choose your plan and complete checkout.", - }, - ].map(step => ( -
-
- -
-
-
{step.title}
-
{step.description}
+

What to expect when ordering

+
+
+
+ +
+
+
Internet
+
+ Requires address verification (1-2 business days). We'll email you when plans + are ready.
- ))} +
+
+
+ +
+
+
SIM & eSIM
+
+ Order immediately after signup. Physical SIM ships next business day. +
+
+
+
+
+ +
+
+
VPN
+
+ Order immediately after signup. Router shipped upon order confirmation. +
+
+
@@ -117,20 +129,19 @@ export function PublicCatalogHomeView() { Why choose our services?

- High-quality connectivity solutions with personalized recommendations and seamless - ordering. + Reliable connectivity with transparent pricing and dedicated support.

} - title="Personalized Plans" - description="Sign up to see eligibility-based internet offerings and plan options" + title="Quality Networks" + description="NTT fiber for internet, 5G coverage for mobile, secure VPN infrastructure" /> } - title="Account-First Ordering" - description="Create an account to verify eligibility and complete your order" + title="Simple Management" + description="Manage all your services, billing, and support from one account portal" />
diff --git a/apps/portal/src/features/catalog/views/PublicInternetConfigure.tsx b/apps/portal/src/features/catalog/views/PublicInternetConfigure.tsx index e6559941..e7c33198 100644 --- a/apps/portal/src/features/catalog/views/PublicInternetConfigure.tsx +++ b/apps/portal/src/features/catalog/views/PublicInternetConfigure.tsx @@ -1,63 +1,186 @@ "use client"; import { useSearchParams } from "next/navigation"; -import { WifiIcon, CheckCircleIcon, ClockIcon, BellIcon } from "@heroicons/react/24/outline"; +import { WifiIcon, CheckIcon, ClockIcon, EnvelopeIcon } from "@heroicons/react/24/outline"; import { CatalogBackLink } from "@/features/catalog/components/base/CatalogBackLink"; import { InlineAuthSection } from "@/features/auth/components/InlineAuthSection/InlineAuthSection"; import { useShopBasePath } from "@/features/catalog/hooks/useShopBasePath"; +import { useInternetPlan } from "@/features/catalog/hooks"; +import { CardPricing } from "@/features/catalog/components/base/CardPricing"; +import { Skeleton } from "@/components/atoms/loading-skeleton"; /** * Public Internet Configure View * - * Generic signup flow for internet availability check. - * Focuses on account creation, not plan details. + * Signup flow for internet ordering with honest expectations about + * the verification timeline (1-2 business days, not instant). */ export function PublicInternetConfigureView() { const shopBasePath = useShopBasePath(); const searchParams = useSearchParams(); const planSku = searchParams?.get("planSku"); + const { plan, isLoading } = useInternetPlan(planSku || undefined); const redirectTo = planSku ? `/account/shop/internet?autoEligibilityRequest=1&planSku=${encodeURIComponent(planSku)}` : "/account/shop/internet?autoEligibilityRequest=1"; + if (isLoading) { + return ( +
+ +
+ + +
+
+ ); + } + return ( -
+
{/* Header */}
-
- +
+
-

Check Internet Availability

-

- Create an account to verify service availability at your address. +

+ Request Internet Service +

+

+ Create an account to request an availability check for your address.

- {/* Process Steps - Compact */} -
-
- - Create account + {/* Plan Summary Card - only if plan is selected */} + {plan && ( +
+
+ Selected Plan +
+
+
+
+ +
+
+
+
+
+

{plan.name}

+ {plan.description && ( +

{plan.description}

+ )} + {(plan.catalogMetadata?.tierDescription || + plan.internetPlanTier || + plan.internetOfferingType) && ( +
+ {(plan.catalogMetadata?.tierDescription || plan.internetPlanTier) && ( + + {plan.catalogMetadata?.tierDescription || plan.internetPlanTier} + + )} + {plan.internetOfferingType && ( + + {plan.internetOfferingType} + + )} +
+ )} +
+
+ +
+
+
+
-
- - We verify availability + )} + + {/* What happens after signup - honest timeline */} +
+

What happens next

+
+
+
+ 1 +
+
+

Create your account

+

+ Sign up with your service address to start the process. +

+
+
+
+
+ 2 +
+
+
+

We verify availability

+ + + 1-2 business days + +
+

+ Our team checks service availability with NTT for your specific address. +

+
+
+
+
+ 3 +
+
+
+

+ You receive email notification +

+ +
+

+ We'll email you when your personalized plans are ready to view. +

+
+
+
+
+ 4 +
+
+

Complete your order

+

+ Choose your plan options, add payment, and schedule installation. +

+
+
-
- - Get notified +
+ + {/* Important note */} +
+
+ +
+

Your account is ready immediately

+

+ While we verify your address, you can explore your account, add payment methods, and + browse our other services like SIM and VPN. +

+
{/* Auth Section */}
diff --git a/apps/portal/src/features/catalog/views/PublicInternetPlans.tsx b/apps/portal/src/features/catalog/views/PublicInternetPlans.tsx index 933a6d04..a32438ab 100644 --- a/apps/portal/src/features/catalog/views/PublicInternetPlans.tsx +++ b/apps/portal/src/features/catalog/views/PublicInternetPlans.tsx @@ -106,25 +106,33 @@ export function PublicInternetPlansView() { -
-

- Compare starting prices for each internet type. Create an account to check availability - for your residence and unlock personalized plan options. -

-
- {offeringTypes.map(type => ( -
- {getEligibilityIcon(type)} - {type} -
- ))} +
+ {/* Availability notice */} +
+

+ Availability check required:{" "} + + After signup, we verify your address with NTT (1-2 business days). You'll + receive an email when your personalized plans are ready. + +

+ {offeringTypes.length > 0 && ( +
+ {offeringTypes.map(type => ( +
+ {getEligibilityIcon(type)} + {type} +
+ ))} +
+ )}
diff --git a/apps/portal/src/features/catalog/views/PublicSimConfigure.tsx b/apps/portal/src/features/catalog/views/PublicSimConfigure.tsx index 0b2b9c7b..ecb64781 100644 --- a/apps/portal/src/features/catalog/views/PublicSimConfigure.tsx +++ b/apps/portal/src/features/catalog/views/PublicSimConfigure.tsx @@ -1,10 +1,9 @@ "use client"; import { useSearchParams } from "next/navigation"; -import { DevicePhoneMobileIcon, CheckIcon } from "@heroicons/react/24/outline"; +import { DevicePhoneMobileIcon, CheckIcon, BoltIcon } from "@heroicons/react/24/outline"; import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner"; import { CatalogBackLink } from "@/features/catalog/components/base/CatalogBackLink"; -import { CatalogHero } from "@/features/catalog/components/base/CatalogHero"; import { useShopBasePath } from "@/features/catalog/hooks/useShopBasePath"; import { useSimPlan } from "@/features/catalog/hooks"; import { InlineAuthSection } from "@/features/auth/components/InlineAuthSection/InlineAuthSection"; @@ -14,8 +13,8 @@ import { Skeleton } from "@/components/atoms/loading-skeleton"; /** * Public SIM Configure View * - * Shows selected plan information and prompts for authentication via modal. - * Much better UX than redirecting to a full signup page. + * Shows selected plan information and prompts for authentication. + * Simplified design focused on quick signup-to-order flow. */ export function PublicSimConfigureView() { const shopBasePath = useShopBasePath(); @@ -32,7 +31,7 @@ export function PublicSimConfigureView() {
- +
@@ -51,109 +50,121 @@ export function PublicSimConfigureView() { } return ( - <> -
- +
+ - + {/* Header */} +
+
+
+ +
+
+

Order Your SIM

+

+ Create an account to complete your order. Physical SIMs ship next business day. +

+
-
- {/* Plan Summary Card */} -
-
-
-
- -
-
-
-

{plan.name}

+ {/* Plan Summary Card */} +
+
+ Selected Plan +
+
+
+
+ +
+
+
+
+
+

{plan.name}

{plan.description && ( -

{plan.description}

+

{plan.description}

)} -
+
{plan.simPlanType && ( - + {plan.simPlanType} )} + {plan.simDataSize && ( + + {plan.simDataSize} + + )}
-
- -
+
+
+
- - {/* Plan Details */} - {(plan.simDataSize || plan.description) && ( -
-

- Plan Details: -

-
    - {plan.simDataSize && ( -
  • - - - Data:{" "} - {plan.simDataSize} - -
  • - )} - {plan.simPlanType && ( -
  • - - - Type:{" "} - {plan.simPlanType} - -
  • - )} - {plan.simHasFamilyDiscount && ( -
  • - - - - Family Discount Available - - -
  • - )} - {plan.billingCycle && ( -
  • - - - Billing:{" "} - {plan.billingCycle} - -
  • - )} -
-
- )}
+
- + {/* Plan Details */} + {(plan.simDataSize || plan.simHasFamilyDiscount || plan.billingCycle) && ( +
+
    + {plan.simDataSize && ( +
  • + + + {plan.simDataSize} data + +
  • + )} + {plan.simPlanType && ( +
  • + + {plan.simPlanType} +
  • + )} + {plan.simHasFamilyDiscount && ( +
  • + + Family discount +
  • + )} + {plan.billingCycle && ( +
  • + + {plan.billingCycle} billing +
  • + )} +
+
+ )} +
+ + {/* Quick order info */} +
+
+ +
+

Order today, get started fast

+

+ After signup, add a payment method and configure your SIM options. Choose eSIM for + instant activation or physical SIM (ships next business day). +

+
- + + {/* Auth Section */} + +
); } diff --git a/apps/portal/src/features/catalog/views/PublicSimPlans.tsx b/apps/portal/src/features/catalog/views/PublicSimPlans.tsx index af374111..c8203629 100644 --- a/apps/portal/src/features/catalog/views/PublicSimPlans.tsx +++ b/apps/portal/src/features/catalog/views/PublicSimPlans.tsx @@ -7,6 +7,7 @@ import { PhoneIcon, GlobeAltIcon, ArrowLeftIcon, + BoltIcon, } from "@heroicons/react/24/outline"; import { Skeleton } from "@/components/atoms/loading-skeleton"; import { Button } from "@/components/atoms/button"; @@ -104,9 +105,23 @@ export function PublicSimPlansView() { + title="SIM & eSIM Plans" + description="Data, voice, and SMS plans with 5G network coverage." + > + {/* Order info banner */} +
+
+ +

+ Order today + + {" "} + — eSIM activates instantly, physical SIM ships next business day. + +

+
+
+
diff --git a/apps/portal/src/features/catalog/views/PublicVpnPlans.tsx b/apps/portal/src/features/catalog/views/PublicVpnPlans.tsx index f0b33004..67962259 100644 --- a/apps/portal/src/features/catalog/views/PublicVpnPlans.tsx +++ b/apps/portal/src/features/catalog/views/PublicVpnPlans.tsx @@ -1,6 +1,6 @@ "use client"; -import { ShieldCheckIcon } from "@heroicons/react/24/outline"; +import { ShieldCheckIcon, BoltIcon } from "@heroicons/react/24/outline"; import { useVpnCatalog } from "@/features/catalog/hooks"; import { LoadingCard } from "@/components/atoms"; import { AsyncBlock } from "@/components/molecules/AsyncBlock/AsyncBlock"; @@ -47,14 +47,30 @@ export function PublicVpnPlansView() { + title="VPN Router Service" + description="Secure VPN connections to San Francisco or London using a pre-configured router." + > + {/* Order info banner */} +
+
+ +

+ Order today + + {" "} + — create account, add payment, and your router ships upon confirmation. + +

+
+
+
{vpnPlans.length > 0 ? (
-

Available Plans

-

(One region per router)

+

Choose Your Region

+

+ Select one region per router rental +

{vpnPlans.map(plan => ( @@ -64,8 +80,7 @@ export function PublicVpnPlansView() { {activationFees.length > 0 && ( - A one-time activation fee of 3000 JPY is incurred separately for each rental unit. Tax - (10%) not included. + A one-time activation fee of ¥3,000 applies per router rental. Tax (10%) not included. )}
@@ -86,34 +101,30 @@ export function PublicVpnPlansView() { )}
-

How It Works

-
+

How It Works

+

SonixNet VPN is the easiest way to access video streaming services from overseas on your network media players such as an Apple TV, Roku, or Amazon Fire.

A configured Wi-Fi router is provided for rental (no purchase required, no hidden fees). - All you will need to do is to plug the VPN router into your existing internet - connection. + All you need to do is plug the VPN router into your existing internet connection.

- Then you can connect your network media players to the VPN Wi-Fi network, to connect to - the VPN server. -

-

- For daily Internet usage that does not require a VPN, we recommend connecting to your - regular home Wi-Fi. + Connect your network media players to the VPN Wi-Fi network to access content from the + selected region. For regular internet usage, use your normal home Wi-Fi.

- *1: Content subscriptions are NOT included in the SonixNet VPN package. Our VPN service will - establish a network connection that virtually locates you in the designated server location, - then you will sign up for the streaming services of your choice. Not all services/websites - can be unblocked. Assist Solutions does not guarantee or bear any responsibility over the - unblocking of any websites or the quality of the streaming/browsing. +

+ Content subscriptions are NOT included in the VPN package. Our VPN service establishes a + network connection that virtually locates you in the designated server location. Not all + services can be unblocked. We do not guarantee access to any specific website or streaming + service quality. +

); diff --git a/apps/portal/src/features/dashboard/hooks/index.ts b/apps/portal/src/features/dashboard/hooks/index.ts index 1c598b0c..5dceb40a 100644 --- a/apps/portal/src/features/dashboard/hooks/index.ts +++ b/apps/portal/src/features/dashboard/hooks/index.ts @@ -1,2 +1,3 @@ export * from "./useDashboardSummary"; export * from "./useDashboardTasks"; +export * from "./useMeStatus"; diff --git a/apps/portal/src/features/dashboard/hooks/useDashboardSummary.ts b/apps/portal/src/features/dashboard/hooks/useDashboardSummary.ts index 98f4d778..05aa3b65 100644 --- a/apps/portal/src/features/dashboard/hooks/useDashboardSummary.ts +++ b/apps/portal/src/features/dashboard/hooks/useDashboardSummary.ts @@ -3,80 +3,20 @@ * Provides dashboard data with proper error handling, caching, and loading states */ -import { useQuery } from "@tanstack/react-query"; -import { useAuthSession } from "@/features/auth/services/auth.store"; -import { apiClient, queryKeys } from "@/lib/api"; -import { - dashboardSummarySchema, - type DashboardSummary, - type DashboardError, -} from "@customer-portal/domain/dashboard"; - -class DashboardDataError extends Error { - constructor( - public code: DashboardError["code"], - message: string, - public details?: Record - ) { - super(message); - this.name = "DashboardDataError"; - } -} +import type { DashboardSummary } from "@customer-portal/domain/dashboard"; +import { useMeStatus } from "./useMeStatus"; /** * Hook for fetching dashboard summary data */ export function useDashboardSummary() { - const { isAuthenticated } = useAuthSession(); + const status = useMeStatus(); - return useQuery({ - queryKey: queryKeys.dashboard.summary(), - queryFn: async () => { - if (!isAuthenticated) { - throw new DashboardDataError( - "AUTHENTICATION_REQUIRED", - "Authentication required to fetch dashboard data" - ); - } - - try { - const response = await apiClient.GET("/api/me/summary"); - if (!response.data) { - throw new DashboardDataError("FETCH_ERROR", "Dashboard summary response was empty"); - } - const parsed = dashboardSummarySchema.safeParse(response.data); - if (!parsed.success) { - throw new DashboardDataError( - "FETCH_ERROR", - "Dashboard summary response failed validation", - { issues: parsed.error.issues } - ); - } - return parsed.data; - } catch (error) { - // Transform API errors to DashboardError format - if (error instanceof Error) { - throw new DashboardDataError("FETCH_ERROR", error.message, { - originalError: error, - }); - } - - throw new DashboardDataError( - "UNKNOWN_ERROR", - "An unexpected error occurred while fetching dashboard data", - { originalError: error as Record } - ); - } - }, - enabled: isAuthenticated, - retry: (failureCount, error) => { - // Don't retry authentication errors - if (error?.code === "AUTHENTICATION_REQUIRED") { - return false; - } - // Retry up to 3 times for other errors - return failureCount < 3; - }, - retryDelay: attemptIndex => Math.min(1000 * 2 ** attemptIndex, 30000), // Exponential backoff - }); + return { + data: (status.data?.summary ?? undefined) as DashboardSummary | undefined, + isLoading: status.isLoading, + isError: status.isError, + error: status.error, + refetch: status.refetch, + }; } diff --git a/apps/portal/src/features/dashboard/hooks/useDashboardTasks.ts b/apps/portal/src/features/dashboard/hooks/useDashboardTasks.ts index e079a781..e94cacfd 100644 --- a/apps/portal/src/features/dashboard/hooks/useDashboardTasks.ts +++ b/apps/portal/src/features/dashboard/hooks/useDashboardTasks.ts @@ -1,190 +1,38 @@ "use client"; import { useMemo } from "react"; -import { formatDistanceToNow, format } from "date-fns"; import { ExclamationCircleIcon, CreditCardIcon, ClockIcon, SparklesIcon, + IdentificationIcon, } from "@heroicons/react/24/outline"; -import type { DashboardSummary } from "@customer-portal/domain/dashboard"; -import type { PaymentMethodList } from "@customer-portal/domain/payments"; -import type { OrderSummary } from "@customer-portal/domain/orders"; import type { TaskTone } from "../components/TaskCard"; -import { useDashboardSummary } from "./useDashboardSummary"; -import { usePaymentMethods } from "@/features/billing/hooks/useBilling"; -import { useOrdersList } from "@/features/orders/hooks/useOrdersList"; -import { useFormatCurrency } from "@/lib/hooks/useFormatCurrency"; -import { useInternetEligibility } from "@/features/catalog/hooks"; -import { useAuthSession } from "@/features/auth/services/auth.store"; +import type { + DashboardTask as DomainDashboardTask, + DashboardTaskType, +} from "@customer-portal/domain/dashboard"; +import { useMeStatus } from "./useMeStatus"; /** * Task type for dashboard actions */ -export type DashboardTaskType = - | "invoice" - | "payment_method" - | "order" - | "internet_eligibility" - | "onboarding"; +export type { DashboardTaskType }; -/** - * Dashboard task structure - */ -export interface DashboardTask { - id: string; - priority: 1 | 2 | 3 | 4; - type: DashboardTaskType; - title: string; - description: string; - /** Label for the action button */ - actionLabel: string; - /** Link for card click (navigates to detail page) */ - detailHref?: string; - /** Whether the action opens an external SSO link */ - requiresSsoAction?: boolean; +export interface DashboardTask extends DomainDashboardTask { tone: TaskTone; icon: React.ComponentType>; - metadata?: { - invoiceId?: number; - orderId?: string; - amount?: number; - currency?: string; - }; } -interface ComputeTasksParams { - summary: DashboardSummary | undefined; - paymentMethods: PaymentMethodList | undefined; - orders: OrderSummary[] | undefined; - internetEligibilityStatus: "not_requested" | "pending" | "eligible" | "ineligible" | undefined; - formatCurrency: (amount: number, options?: { currency?: string }) => string; -} - -/** - * Compute dashboard tasks based on user's account state - */ -function computeTasks({ - summary, - paymentMethods, - orders, - internetEligibilityStatus, - formatCurrency, -}: ComputeTasksParams): DashboardTask[] { - const tasks: DashboardTask[] = []; - - if (!summary) return tasks; - - // Priority 1: Unpaid invoices - if (summary.nextInvoice) { - const dueDate = new Date(summary.nextInvoice.dueDate); - const isOverdue = dueDate < new Date(); - const dueText = isOverdue - ? `Overdue since ${format(dueDate, "MMM d")}` - : `Due ${formatDistanceToNow(dueDate, { addSuffix: true })}`; - - tasks.push({ - id: `invoice-${summary.nextInvoice.id}`, - priority: 1, - type: "invoice", - title: isOverdue ? "Pay overdue invoice" : "Pay upcoming invoice", - description: `Invoice #${summary.nextInvoice.id} · ${formatCurrency(summary.nextInvoice.amount, { currency: summary.nextInvoice.currency })} · ${dueText}`, - actionLabel: "Pay now", - detailHref: `/account/billing/invoices/${summary.nextInvoice.id}`, - requiresSsoAction: true, - tone: "critical", - icon: ExclamationCircleIcon, - metadata: { - invoiceId: summary.nextInvoice.id, - amount: summary.nextInvoice.amount, - currency: summary.nextInvoice.currency, - }, - }); - } - - // Priority 2: No payment method - if (paymentMethods && paymentMethods.totalCount === 0) { - tasks.push({ - id: "add-payment-method", - priority: 2, - type: "payment_method", - title: "Add a payment method", - description: "Required to place orders and process invoices", - actionLabel: "Add method", - detailHref: "/account/billing/payments", - requiresSsoAction: true, - tone: "warning", - icon: CreditCardIcon, - }); - } - - // Priority 3: Pending orders (Draft, Pending, or Activated but not yet complete) - if (orders && orders.length > 0) { - const pendingOrders = orders.filter( - o => - o.status === "Draft" || - o.status === "Pending" || - (o.status === "Activated" && o.activationStatus !== "Completed") - ); - - if (pendingOrders.length > 0) { - const order = pendingOrders[0]; - const statusText = - order.status === "Pending" - ? "awaiting review" - : order.status === "Draft" - ? "in draft" - : "being activated"; - - tasks.push({ - id: `order-${order.id}`, - priority: 3, - type: "order", - title: "Order in progress", - description: `${order.orderType || "Your"} order is ${statusText}`, - actionLabel: "View details", - detailHref: `/account/orders/${order.id}`, - tone: "info", - icon: ClockIcon, - metadata: { orderId: order.id }, - }); - } - } - - // Priority 4: Internet eligibility review (only when explicitly pending) - if (internetEligibilityStatus === "pending") { - tasks.push({ - id: "internet-eligibility-review", - priority: 4, - type: "internet_eligibility", - title: "Internet availability review", - description: - "We’re verifying if our service is available at your residence. We’ll notify you when review is complete.", - actionLabel: "View status", - detailHref: "/account/shop/internet", - tone: "info", - icon: ClockIcon, - }); - } - - // Priority 4: No subscriptions (onboarding) - only show if no other tasks - if (summary.stats.activeSubscriptions === 0 && tasks.length === 0) { - tasks.push({ - id: "start-subscription", - priority: 4, - type: "onboarding", - title: "Start your first service", - description: "Browse our catalog and subscribe to internet, SIM, or VPN", - actionLabel: "Browse services", - detailHref: "/shop", - tone: "neutral", - icon: SparklesIcon, - }); - } - - return tasks.sort((a, b) => a.priority - b.priority); -} +const TASK_ICONS: Record = { + invoice: ExclamationCircleIcon, + payment_method: CreditCardIcon, + order: ClockIcon, + internet_eligibility: ClockIcon, + id_verification: IdentificationIcon, + onboarding: SparklesIcon, +}; export interface UseDashboardTasksResult { tasks: DashboardTask[]; @@ -194,47 +42,25 @@ export interface UseDashboardTasksResult { } /** - * Hook to compute and return prioritized dashboard tasks + * Hook to return prioritized dashboard tasks computed by the BFF. */ export function useDashboardTasks(): UseDashboardTasksResult { - const { formatCurrency } = useFormatCurrency(); - const { isAuthenticated } = useAuthSession(); + const { data, isLoading, error } = useMeStatus(); - const { data: summary, isLoading: summaryLoading, error: summaryError } = useDashboardSummary(); - - const { - data: paymentMethods, - isLoading: paymentMethodsLoading, - error: paymentMethodsError, - } = usePaymentMethods(); - - const { data: orders, isLoading: ordersLoading, error: ordersError } = useOrdersList(); - - const { - data: eligibility, - isLoading: eligibilityLoading, - error: eligibilityError, - } = useInternetEligibility({ enabled: isAuthenticated }); - - const isLoading = summaryLoading || paymentMethodsLoading || ordersLoading; - const hasError = Boolean(summaryError || paymentMethodsError || ordersError || eligibilityError); - - const tasks = useMemo( - () => - computeTasks({ - summary, - paymentMethods, - orders, - internetEligibilityStatus: eligibility?.status, - formatCurrency, - }), - [summary, paymentMethods, orders, eligibility?.status, formatCurrency] - ); + const tasks = useMemo(() => { + const raw = data?.tasks ?? []; + return raw.map(task => ({ + ...task, + // Default to neutral when undefined (shouldn't happen due to domain validation) + tone: (task.tone ?? "neutral") as TaskTone, + icon: TASK_ICONS[task.type] ?? SparklesIcon, + })); + }, [data?.tasks]); return { tasks, - isLoading: isLoading || eligibilityLoading, - hasError, + isLoading, + hasError: Boolean(error), taskCount: tasks.length, }; } diff --git a/apps/portal/src/features/dashboard/hooks/useMeStatus.ts b/apps/portal/src/features/dashboard/hooks/useMeStatus.ts new file mode 100644 index 00000000..10828ff8 --- /dev/null +++ b/apps/portal/src/features/dashboard/hooks/useMeStatus.ts @@ -0,0 +1,24 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; +import { useAuthSession } from "@/features/auth/services/auth.store"; +import { queryKeys } from "@/lib/api"; +import { getMeStatus } from "../services/meStatus.service"; +import type { MeStatus } from "@customer-portal/domain/dashboard"; + +/** + * Fetches aggregated customer status used by the dashboard (tasks, summary, gating signals). + */ +export function useMeStatus() { + const { isAuthenticated } = useAuthSession(); + + return useQuery({ + queryKey: queryKeys.me.status(), + queryFn: () => getMeStatus(), + enabled: isAuthenticated, + refetchOnWindowFocus: false, + refetchOnReconnect: false, + }); +} + +export type UseMeStatusResult = ReturnType; diff --git a/apps/portal/src/features/dashboard/services/meStatus.service.ts b/apps/portal/src/features/dashboard/services/meStatus.service.ts new file mode 100644 index 00000000..410d6c45 --- /dev/null +++ b/apps/portal/src/features/dashboard/services/meStatus.service.ts @@ -0,0 +1,14 @@ +import { apiClient } from "@/lib/api"; +import { meStatusSchema, type MeStatus } from "@customer-portal/domain/dashboard"; + +export async function getMeStatus(): Promise { + const response = await apiClient.GET("/api/me/status"); + if (!response.data) { + throw new Error("Status response was empty"); + } + const parsed = meStatusSchema.safeParse(response.data); + if (!parsed.success) { + throw new Error("Status response failed validation"); + } + return parsed.data; +} diff --git a/apps/portal/src/features/dashboard/utils/dashboard.utils.ts b/apps/portal/src/features/dashboard/utils/dashboard.utils.ts index d27c499d..355d6be5 100644 --- a/apps/portal/src/features/dashboard/utils/dashboard.utils.ts +++ b/apps/portal/src/features/dashboard/utils/dashboard.utils.ts @@ -11,8 +11,8 @@ import { ACTIVITY_FILTERS, filterActivities, isActivityClickable, - generateDashboardTasks, - type DashboardTask, + generateQuickActions, + type QuickActionTask, type DashboardTaskSummary, } from "@customer-portal/domain/dashboard"; import { formatCurrency as formatCurrencyUtil } from "@customer-portal/domain/toolkit"; @@ -22,8 +22,8 @@ export { ACTIVITY_FILTERS, filterActivities, isActivityClickable, - generateDashboardTasks, - type DashboardTask, + generateQuickActions, + type QuickActionTask, type DashboardTaskSummary, }; diff --git a/apps/portal/src/features/realtime/components/AccountEventsListener.tsx b/apps/portal/src/features/realtime/components/AccountEventsListener.tsx index 570f2ea6..b563c789 100644 --- a/apps/portal/src/features/realtime/components/AccountEventsListener.tsx +++ b/apps/portal/src/features/realtime/components/AccountEventsListener.tsx @@ -59,6 +59,7 @@ export function AccountEventsListener() { void queryClient.invalidateQueries({ queryKey: queryKeys.orders.list() }); // Dashboard summary often depends on orders/subscriptions; cheap to keep in sync. void queryClient.invalidateQueries({ queryKey: queryKeys.dashboard.summary() }); + void queryClient.invalidateQueries({ queryKey: queryKeys.me.status() }); return; } } catch (error) { diff --git a/apps/portal/src/features/verification/services/verification.service.ts b/apps/portal/src/features/verification/services/verification.service.ts index 9f4ca292..d897f4e2 100644 --- a/apps/portal/src/features/verification/services/verification.service.ts +++ b/apps/portal/src/features/verification/services/verification.service.ts @@ -1,25 +1,18 @@ "use client"; import { apiClient, getDataOrThrow } from "@/lib/api"; - -export type ResidenceCardVerificationStatus = "not_submitted" | "pending" | "verified" | "rejected"; - -export interface ResidenceCardVerification { - status: ResidenceCardVerificationStatus; - filename: string | null; - mimeType: string | null; - sizeBytes: number | null; - submittedAt: string | null; - reviewedAt: string | null; - reviewerNotes: string | null; -} +import { + residenceCardVerificationSchema, + type ResidenceCardVerification, +} from "@customer-portal/domain/customer"; export const verificationService = { async getResidenceCardVerification(): Promise { const response = await apiClient.GET( "/api/verification/residence-card" ); - return getDataOrThrow(response, "Failed to load residence card verification status"); + const data = getDataOrThrow(response, "Failed to load residence card verification status"); + return residenceCardVerificationSchema.parse(data); }, async submitResidenceCard(file: File): Promise { @@ -32,6 +25,7 @@ export const verificationService = { body: form, } ); - return getDataOrThrow(response, "Failed to submit residence card"); + const data = getDataOrThrow(response, "Failed to submit residence card"); + return residenceCardVerificationSchema.parse(data); }, }; diff --git a/apps/portal/src/features/verification/views/ResidenceCardVerificationSettingsView.tsx b/apps/portal/src/features/verification/views/ResidenceCardVerificationSettingsView.tsx index f60c8e2a..70c3fc64 100644 --- a/apps/portal/src/features/verification/views/ResidenceCardVerificationSettingsView.tsx +++ b/apps/portal/src/features/verification/views/ResidenceCardVerificationSettingsView.tsx @@ -132,9 +132,39 @@ export function ResidenceCardVerificationSettingsView() {

{residenceCardQuery.data.reviewerNotes}

)}

Upload a clear photo or scan of your residence card (JPG, PNG, or PDF).

+
    +
  • Make sure all text is readable and the full card is visible.
  • +
  • Avoid glare/reflections and blurry photos.
  • +
  • Maximum file size: 5MB.
  • +
+ {(residenceCardQuery.data?.filename || + residenceCardQuery.data?.submittedAt || + residenceCardQuery.data?.reviewedAt) && ( +
+
+ Latest submission +
+ {residenceCardQuery.data?.filename && ( +
+ {residenceCardQuery.data.filename} +
+ )} + {formatDateTime(residenceCardQuery.data?.submittedAt) && ( +
+ Submitted: {formatDateTime(residenceCardQuery.data?.submittedAt)} +
+ )} + {formatDateTime(residenceCardQuery.data?.reviewedAt) && ( +
+ Reviewed: {formatDateTime(residenceCardQuery.data?.reviewedAt)} +
+ )} +
+ )} + {canUpload && (
- Accepted formats: JPG, PNG, or PDF. Make sure all text is readable. + Accepted formats: JPG, PNG, or PDF (max 5MB). Tip: higher resolution photos make + review faster.

)} diff --git a/apps/portal/src/lib/api/index.ts b/apps/portal/src/lib/api/index.ts index c64ff6b2..60c92ef3 100644 --- a/apps/portal/src/lib/api/index.ts +++ b/apps/portal/src/lib/api/index.ts @@ -122,6 +122,9 @@ export const queryKeys = { me: () => ["auth", "me"] as const, session: () => ["auth", "session"] as const, }, + me: { + status: () => ["me", "status"] as const, + }, billing: { invoices: (params?: Record) => ["billing", "invoices", params] as const, invoice: (id: string) => ["billing", "invoice", id] as const, diff --git a/docs/architecture/system-overview.md b/docs/architecture/system-overview.md index 33979c37..918b383a 100644 --- a/docs/architecture/system-overview.md +++ b/docs/architecture/system-overview.md @@ -67,6 +67,7 @@ src/ modules/ # Feature-aligned modules auth/ # Authentication and authorization users/ # User management + me-status/ # Aggregated customer status (dashboard + gating signals) id-mappings/ # Portal-WHMCS-Salesforce ID mappings catalog/ # Product catalog orders/ # Order creation and fulfillment @@ -208,6 +209,13 @@ Centralized logging is implemented in the BFF using `nestjs-pino`: - ESLint and Prettier for consistent formatting - Pre-commit hooks for quality gates +### **Domain Build Hygiene** + +The domain package (`packages/domain`) is consumed via committed `dist/` outputs. + +- **Build**: `pnpm domain:build` +- **Verify dist drift** (CI-friendly): `pnpm domain:check-dist` + ## 📈 **Performance & Scalability** ### **Caching Strategy** diff --git a/docs/how-it-works/README.md b/docs/how-it-works/README.md index 1282450d..ba616492 100644 --- a/docs/how-it-works/README.md +++ b/docs/how-it-works/README.md @@ -17,6 +17,7 @@ Start with `system-overview.md`, then jump into the feature you care about. | [Billing & Payments](./billing-and-payments.md) | Invoices, payment methods, billing links | | [Subscriptions](./subscriptions.md) | How active services are read and refreshed | | [Support Cases](./support-cases.md) | Case creation/reading in Salesforce | +| [Dashboard & Notifications](./dashboard-and-notifications.md) | Dashboard status model + in-app notification triggers | | [UI Design System](./ui-design-system.md) | UI tokens, page shells, component patterns | ## Related Documentation diff --git a/docs/how-it-works/dashboard-and-notifications.md b/docs/how-it-works/dashboard-and-notifications.md new file mode 100644 index 00000000..ebbd904b --- /dev/null +++ b/docs/how-it-works/dashboard-and-notifications.md @@ -0,0 +1,61 @@ +# Dashboard & Notifications + +This guide explains how the **customer dashboard** stays consistent and how **in-app notifications** are generated. + +## Dashboard “single read model” (`/api/me/status`) + +To keep business logic out of the frontend, the Portal uses a single BFF endpoint: + +- **Endpoint**: `GET /api/me/status` +- **Purpose**: Return a consistent snapshot of the customer’s current state (summary + tasks + gating signals). +- **Domain type**: `@customer-portal/domain/dashboard` → `meStatusSchema` + +The response includes: + +- **`summary`**: Same shape as `GET /api/me/summary` (stats, next invoice, activity). +- **`internetEligibility`**: Internet eligibility status/details for the logged-in customer. +- **`residenceCardVerification`**: Residence card verification status/details. +- **`paymentMethods.totalCount`**: Count of stored payment methods (or `null` if unavailable). +- **`tasks[]`**: A prioritized list of dashboard tasks (invoice due, add payment method, order in progress, eligibility pending, IDV rejected, onboarding). + +Portal UI maps task `type` → icon locally; everything else (priority, copy, links) is computed server-side. + +## In-app notifications + +In-app notifications are stored in Postgres and fetched via the Notifications API. Notifications use domain templates in: + +- `packages/domain/notifications/schema.ts` + +### Where notifications are created + +- **Eligibility / Verification**: + - Triggered from Salesforce events (Account fields change). + - Created by the Salesforce events handlers. + +- **Orders**: + - **Approved / Activated / Failed** notifications are created during the fulfillment workflow: + - `apps/bff/src/modules/orders/services/order-fulfillment-orchestrator.service.ts` + - The notification `sourceId` uses the Salesforce Order Id to prevent duplicates during retries. + +- **Cancellations**: + - A “Cancellation scheduled” notification is created when the cancellation request is submitted: + - Internet: `apps/bff/src/modules/subscriptions/internet-management/services/internet-cancellation.service.ts` + - SIM: `apps/bff/src/modules/subscriptions/sim-management/services/sim-cancellation.service.ts` + +- **Invoice due**: + - Created opportunistically when the dashboard status is requested (`GET /api/me/status`) if an invoice is due within 7 days (or overdue). + +### Dedupe behavior + +Notifications dedupe is enforced in: + +- `apps/bff/src/modules/notifications/notifications.service.ts` + +Rules: + +- For most types: dedupe is **type + sourceId within 1 hour**. +- For “reminder-style” types (invoice due, payment method expiring, system announcement): dedupe is **type + sourceId within 24 hours**. + +### Action URLs + +Notification templates use **authenticated Portal routes** (e.g. `/account/orders`, `/account/services`, `/account/billing/*`) so clicks always land in the correct shell. diff --git a/docs/integrations/salesforce/opportunity-lifecycle.md b/docs/integrations/salesforce/opportunity-lifecycle.md index 57615b11..61df6a01 100644 --- a/docs/integrations/salesforce/opportunity-lifecycle.md +++ b/docs/integrations/salesforce/opportunity-lifecycle.md @@ -260,28 +260,20 @@ This guide documents the Salesforce Opportunity integration for service lifecycl │ └─ If "Verified" → Skip verification, proceed to checkout │ │ │ │ 2. CUSTOMER UPLOADS ID DOCUMENTS │ -│ └─ Portal: POST /api/verification/submit │ -│ └─ eKYC service processes documents │ +│ └─ Portal: POST /api/verification/residence-card │ +│ └─ BFF uploads file to Salesforce Files (ContentVersion) │ │ │ │ 3. UPDATE ACCOUNT │ -│ └─ Id_Verification_Status__c = "Pending" │ +│ └─ Id_Verification_Status__c = "Submitted" (portal maps to pending) │ │ └─ Id_Verification_Submitted_Date_Time__c = now() │ │ │ -│ 4. IF eKYC AUTO-APPROVED │ -│ └─ Id_Verification_Status__c = "Verified" │ -│ └─ Id_Verification_Verified_Date_Time__c = now() │ -│ └─ Customer can proceed to order immediately │ +│ 4. CS REVIEWS IN SALESFORCE │ +│ └─ Id_Verification_Status__c = "Verified" or "Rejected" │ +│ └─ Id_Verification_Verified_Date_Time__c = now() (on verify) │ +│ └─ Id_Verification_Rejection_Message__c = reason (on reject) │ │ │ -│ 5. IF MANUAL REVIEW NEEDED │ -│ └─ Create Case for CS review │ -│ └─ Case.Type = "ID Verification" │ -│ └─ Case.OpportunityId = linked Opportunity (if exists) │ -│ └─ CS reviews and updates Account │ -│ │ -│ 6. IF REJECTED │ -│ └─ Id_Verification_Status__c = "Rejected" │ -│ └─ Id_Verification_Rejection_Message__c = reason │ -│ └─ Customer must resubmit │ +│ 5. CUSTOMER RESUBMITS IF NEEDED │ +│ └─ Portal shows feedback + upload UI │ │ │ └─────────────────────────────────────────────────────────────────────────┘ ``` diff --git a/package.json b/package.json index 9f175338..1422bf79 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,8 @@ "dev": "./scripts/dev/manage.sh apps", "dev:all": "pnpm --filter @customer-portal/domain build && pnpm --parallel --filter @customer-portal/portal --filter @customer-portal/bff run dev", "dev:apps": "pnpm --parallel --filter @customer-portal/portal --filter @customer-portal/bff run dev", + "domain:build": "pnpm --filter @customer-portal/domain build", + "domain:check-dist": "bash ./scripts/domain/check-dist.sh", "build": "pnpm --filter @customer-portal/domain build && pnpm --recursive --filter=!@customer-portal/domain run build", "start": "pnpm --parallel --filter @customer-portal/portal --filter @customer-portal/bff run start", "test": "pnpm --recursive run test", diff --git a/packages/domain/catalog/contract.ts b/packages/domain/catalog/contract.ts index 0c6e1d2a..2fca6f4f 100644 --- a/packages/domain/catalog/contract.ts +++ b/packages/domain/catalog/contract.ts @@ -1,6 +1,6 @@ /** * Catalog Domain - Contract - * + * * Constants and types for the catalog domain. * Most types are derived from schemas (see schema.ts). */ @@ -43,7 +43,7 @@ export interface SalesforceProductFieldMap { export interface PricingTier { name: string; price: number; - billingCycle: 'Monthly' | 'Onetime' | 'Annual'; + billingCycle: "Monthly" | "Onetime" | "Annual"; description?: string; features?: string[]; isRecommended?: boolean; @@ -84,6 +84,8 @@ export type { InternetPlanCatalogItem, InternetInstallationCatalogItem, InternetAddonCatalogItem, + InternetEligibilityStatus, + InternetEligibilityDetails, // SIM products SimCatalogProduct, SimActivationFeeCatalogItem, @@ -91,4 +93,4 @@ export type { VpnCatalogProduct, // Union type CatalogProduct, -} from './schema.js'; +} from "./schema.js"; diff --git a/packages/domain/catalog/index.ts b/packages/domain/catalog/index.ts index 1bfe33d9..43c8b261 100644 --- a/packages/domain/catalog/index.ts +++ b/packages/domain/catalog/index.ts @@ -1,13 +1,13 @@ /** * Catalog Domain - * + * * Exports all catalog-related contracts, schemas, and provider mappers. - * + * * Types are derived from Zod schemas (Schema-First Approach) */ // Provider-specific types -export { +export { type SalesforceProductFieldMap, type PricingTier, type CatalogFilter, @@ -27,6 +27,8 @@ export type { InternetPlanCatalogItem, InternetInstallationCatalogItem, InternetAddonCatalogItem, + InternetEligibilityStatus, + InternetEligibilityDetails, // SIM products SimCatalogProduct, SimActivationFeeCatalogItem, @@ -34,7 +36,7 @@ export type { VpnCatalogProduct, // Union type CatalogProduct, -} from './schema.js'; +} from "./schema.js"; // Provider adapters export * as Providers from "./providers/index.js"; diff --git a/packages/domain/catalog/schema.ts b/packages/domain/catalog/schema.ts index 10277959..13750c50 100644 --- a/packages/domain/catalog/schema.ts +++ b/packages/domain/catalog/schema.ts @@ -1,6 +1,6 @@ /** * Catalog Domain - Schemas - * + * * Zod schemas for runtime validation of catalog product data. */ @@ -53,17 +53,21 @@ export const internetPlanTemplateSchema = z.object({ }); export const internetPlanCatalogItemSchema = internetCatalogProductSchema.extend({ - catalogMetadata: z.object({ - tierDescription: z.string().optional(), - features: z.array(z.string()).optional(), - isRecommended: z.boolean().optional(), - }).optional(), + catalogMetadata: z + .object({ + tierDescription: z.string().optional(), + features: z.array(z.string()).optional(), + isRecommended: z.boolean().optional(), + }) + .optional(), }); export const internetInstallationCatalogItemSchema = internetCatalogProductSchema.extend({ - catalogMetadata: z.object({ - installationTerm: z.enum(["One-time", "12-Month", "24-Month"]), - }).optional(), + catalogMetadata: z + .object({ + installationTerm: z.enum(["One-time", "12-Month", "24-Month"]), + }) + .optional(), }); export const internetAddonCatalogItemSchema = internetCatalogProductSchema.extend({ @@ -79,6 +83,34 @@ export const internetCatalogCollectionSchema = z.object({ export const internetCatalogResponseSchema = internetCatalogCollectionSchema; +// ============================================================================ +// Internet Eligibility Schemas +// ============================================================================ + +/** + * Portal-facing internet eligibility status. + * + * NOTE: This is intentionally a small, stable enum used across BFF + Portal. + * The raw Salesforce field value is returned separately as `eligibility`. + */ +export const internetEligibilityStatusSchema = z.enum([ + "not_requested", + "pending", + "eligible", + "ineligible", +]); + +export const internetEligibilityDetailsSchema = z.object({ + status: internetEligibilityStatusSchema, + /** Raw Salesforce value from Account.Internet_Eligibility__c (if present) */ + eligibility: z.string().nullable(), + /** Salesforce Case Id (eligibility request) */ + requestId: z.string().nullable(), + requestedAt: z.string().datetime().nullable(), + checkedAt: z.string().datetime().nullable(), + notes: z.string().nullable(), +}); + // ============================================================================ // SIM Product Schemas // ============================================================================ @@ -151,6 +183,8 @@ export type InternetPlanCatalogItem = z.infer; export type InternetAddonCatalogItem = z.infer; export type InternetCatalogCollection = z.infer; +export type InternetEligibilityStatus = z.infer; +export type InternetEligibilityDetails = z.infer; // SIM products export type SimCatalogProduct = z.infer; @@ -170,4 +204,3 @@ export type CatalogProduct = | SimActivationFeeCatalogItem | VpnCatalogProduct | CatalogProductBase; - diff --git a/packages/domain/customer/contract.ts b/packages/domain/customer/contract.ts index b85b4826..a3503601 100644 --- a/packages/domain/customer/contract.ts +++ b/packages/domain/customer/contract.ts @@ -1,9 +1,9 @@ /** * Customer Domain - Contract - * + * * Constants and provider-specific types. * Main domain types exported from schema.ts - * + * * Pattern matches billing and subscriptions domains. */ @@ -52,4 +52,6 @@ export type { UserRole, Address, AddressFormData, -} from './schema.js'; + ResidenceCardVerificationStatus, + ResidenceCardVerification, +} from "./schema.js"; diff --git a/packages/domain/customer/index.ts b/packages/domain/customer/index.ts index 3fabd9aa..20bcd236 100644 --- a/packages/domain/customer/index.ts +++ b/packages/domain/customer/index.ts @@ -1,13 +1,13 @@ /** * Customer Domain - * + * * Main exports: * - User: API response type * - UserAuth: Portal DB auth state * - Address: Address structure (follows billing/subscriptions pattern) - * + * * Pattern matches billing and subscriptions domains. - * + * * Types are derived from Zod schemas (Schema-First Approach) */ @@ -22,16 +22,18 @@ export { USER_ROLE, type UserRoleValue } from "./contract.js"; // ============================================================================ export type { - User, // API response type (normalized camelCase) - UserAuth, // Portal DB auth state - UserRole, // "USER" | "ADMIN" - Address, // Address structure (not "CustomerAddress") + User, // API response type (normalized camelCase) + UserAuth, // Portal DB auth state + UserRole, // "USER" | "ADMIN" + Address, // Address structure (not "CustomerAddress") AddressFormData, // Address form validation ProfileEditFormData, // Profile edit form data - ProfileDisplayData, // Profile display data (alias) - UserProfile, // Alias for User + ProfileDisplayData, // Profile display data (alias) + ResidenceCardVerificationStatus, + ResidenceCardVerification, + UserProfile, // Alias for User AuthenticatedUser, // Alias for authenticated user - WhmcsClient, // Provider-normalized WHMCS client shape + WhmcsClient, // Provider-normalized WHMCS client shape } from "./schema.js"; // ============================================================================ @@ -45,9 +47,11 @@ export { addressFormSchema, profileEditFormSchema, profileDisplayDataSchema, - + residenceCardVerificationStatusSchema, + residenceCardVerificationSchema, + // Helper functions - combineToUser, // Domain helper: UserAuth + WhmcsClient → User + combineToUser, // Domain helper: UserAuth + WhmcsClient → User addressFormToRequest, profileFormToRequest, } from "./schema.js"; @@ -58,7 +62,7 @@ export { /** * Providers namespace contains provider-specific implementations - * + * * Access as: * - Providers.Whmcs.Client (full WHMCS type) * - Providers.Whmcs.transformWhmcsClientResponse() @@ -94,7 +98,4 @@ export type { * Salesforce integration types * Provider-specific, not validated at runtime */ -export type { - SalesforceAccountFieldMap, - SalesforceAccountRecord, -} from "./contract.js"; +export type { SalesforceAccountFieldMap, SalesforceAccountRecord } from "./contract.js"; diff --git a/packages/domain/customer/schema.ts b/packages/domain/customer/schema.ts index 78541241..3eb4ed47 100644 --- a/packages/domain/customer/schema.ts +++ b/packages/domain/customer/schema.ts @@ -390,6 +390,32 @@ export function combineToUser(userAuth: UserAuth, whmcsClient: WhmcsClient): Use }); } +// ============================================================================ +// Verification (Customer-facing) +// ============================================================================ + +/** + * Residence card verification status shown in the portal. + * + * Stored in Salesforce on the Account record. + */ +export const residenceCardVerificationStatusSchema = z.enum([ + "not_submitted", + "pending", + "verified", + "rejected", +]); + +export const residenceCardVerificationSchema = z.object({ + status: residenceCardVerificationStatusSchema, + filename: z.string().nullable(), + mimeType: z.string().nullable(), + sizeBytes: z.number().int().nonnegative().nullable(), + submittedAt: z.string().datetime().nullable(), + reviewedAt: z.string().datetime().nullable(), + reviewerNotes: z.string().nullable(), +}); + // ============================================================================ // Exported Types (Public API) // ============================================================================ @@ -401,6 +427,8 @@ export type Address = z.infer; export type AddressFormData = z.infer; export type ProfileEditFormData = z.infer; export type ProfileDisplayData = z.infer; +export type ResidenceCardVerificationStatus = z.infer; +export type ResidenceCardVerification = z.infer; // Convenience aliases export type UserProfile = User; // Alias for user profile diff --git a/packages/domain/dashboard/contract.ts b/packages/domain/dashboard/contract.ts index efd1deab..94e3432b 100644 --- a/packages/domain/dashboard/contract.ts +++ b/packages/domain/dashboard/contract.ts @@ -9,6 +9,11 @@ import type { activityFilterSchema, activityFilterConfigSchema, dashboardSummaryResponseSchema, + dashboardTaskTypeSchema, + dashboardTaskToneSchema, + dashboardTaskSchema, + paymentMethodsStatusSchema, + meStatusSchema, } from "./schema.js"; export type ActivityType = z.infer; @@ -20,3 +25,8 @@ export type DashboardError = z.infer; export type ActivityFilter = z.infer; export type ActivityFilterConfig = z.infer; export type DashboardSummaryResponse = z.infer; +export type DashboardTaskType = z.infer; +export type DashboardTaskTone = z.infer; +export type DashboardTask = z.infer; +export type PaymentMethodsStatus = z.infer; +export type MeStatus = z.infer; diff --git a/packages/domain/dashboard/schema.ts b/packages/domain/dashboard/schema.ts index 790993d5..e7fb3f2f 100644 --- a/packages/domain/dashboard/schema.ts +++ b/packages/domain/dashboard/schema.ts @@ -1,5 +1,7 @@ import { z } from "zod"; import { invoiceSchema } from "../billing/schema.js"; +import { internetEligibilityDetailsSchema } from "../catalog/schema.js"; +import { residenceCardVerificationSchema } from "../customer/schema.js"; export const activityTypeSchema = z.enum([ "invoice_created", @@ -81,5 +83,57 @@ export const dashboardSummaryResponseSchema = dashboardSummarySchema.extend({ invoices: z.array(invoiceSchema).optional(), }); +// ============================================================================ +// Dashboard Tasks (Customer-facing) +// ============================================================================ + +export const dashboardTaskTypeSchema = z.enum([ + "invoice", + "payment_method", + "order", + "internet_eligibility", + "id_verification", + "onboarding", +]); + +export const dashboardTaskToneSchema = z.enum(["critical", "warning", "info", "neutral"]); + +export const dashboardTaskSchema = z.object({ + id: z.string(), + priority: z.union([z.literal(1), z.literal(2), z.literal(3), z.literal(4)]), + type: dashboardTaskTypeSchema, + title: z.string(), + description: z.string(), + actionLabel: z.string(), + detailHref: z.string().optional(), + requiresSsoAction: z.boolean().optional(), + tone: dashboardTaskToneSchema, + metadata: z + .object({ + invoiceId: z.number().int().positive().optional(), + orderId: z.string().optional(), + amount: z.number().optional(), + currency: z.string().optional(), + dueDate: z.string().datetime().optional(), + }) + .optional(), +}); + +export const paymentMethodsStatusSchema = z.object({ + /** null indicates the value could not be loaded (avoid incorrect UI gating). */ + totalCount: z.number().int().nonnegative().nullable(), +}); + +/** + * Aggregated customer status payload intended to power dashboard + gating UX. + */ +export const meStatusSchema = z.object({ + summary: dashboardSummarySchema, + paymentMethods: paymentMethodsStatusSchema, + internetEligibility: internetEligibilityDetailsSchema, + residenceCardVerification: residenceCardVerificationSchema, + tasks: z.array(dashboardTaskSchema), +}); + export type InvoiceActivityMetadata = z.infer; export type ServiceActivityMetadata = z.infer; diff --git a/packages/domain/dashboard/utils.ts b/packages/domain/dashboard/utils.ts index 93c2c925..8cd4976f 100644 --- a/packages/domain/dashboard/utils.ts +++ b/packages/domain/dashboard/utils.ts @@ -57,9 +57,9 @@ export function isActivityClickable(activity: Activity): boolean { } /** - * Dashboard task definition + * Quick action task definition for dashboard */ -export interface DashboardTask { +export interface QuickActionTask { label: string; href: string; } @@ -73,10 +73,10 @@ export interface DashboardTaskSummary { } /** - * Generate dashboard task suggestions based on summary data + * Generate dashboard quick action suggestions based on summary data */ -export function generateDashboardTasks(summary: DashboardTaskSummary): DashboardTask[] { - const tasks: DashboardTask[] = []; +export function generateQuickActions(summary: DashboardTaskSummary): QuickActionTask[] { + const tasks: QuickActionTask[] = []; if (summary.nextInvoice) { tasks.push({ @@ -101,4 +101,3 @@ export function generateDashboardTasks(summary: DashboardTaskSummary): Dashboard return tasks; } - diff --git a/packages/domain/notifications/schema.ts b/packages/domain/notifications/schema.ts index 0c15f5e6..ab2575cf 100644 --- a/packages/domain/notifications/schema.ts +++ b/packages/domain/notifications/schema.ts @@ -56,7 +56,7 @@ export const NOTIFICATION_TEMPLATES: Record/dev/null + +if command -v git >/dev/null 2>&1; then + if git diff --quiet -- packages/domain/dist; then + echo "[domain] OK: packages/domain/dist is up to date." + exit 0 + fi + + echo "[domain] ERROR: packages/domain/dist is out of sync with source." + echo "[domain] Run: pnpm --filter @customer-portal/domain build" + echo "[domain] Then commit the updated dist outputs." + git --no-pager diff -- packages/domain/dist | head -200 + exit 1 +fi + +echo "[domain] WARNING: git not found; cannot verify dist drift. Build completed." +