Enhance Dashboard and Notification Features

- Introduced MeStatus module to aggregate customer status, integrating dashboard summary, payment methods, internet eligibility, and residence card verification.
- Updated dashboard hooks to utilize MeStatus for improved data fetching and error handling.
- Enhanced notification handling across various modules, including cancellation notifications for internet and SIM services, ensuring timely user alerts.
- Refactored related schemas and services to support new dashboard tasks and notification types, improving overall user engagement and experience.
This commit is contained in:
barsa 2025-12-23 17:53:08 +09:00
parent a61c2dd68b
commit a6bc9666e1
53 changed files with 1457 additions and 732 deletions

View File

@ -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,

View File

@ -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 },

View File

@ -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,
],

View File

@ -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<string | null> {
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;
}
}
}

View File

@ -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<InternetEligibilityDto> {
async getEligibility(@Req() req: RequestWithUser): Promise<InternetEligibilityDetails> {
return this.internetCatalog.getEligibilityDetailsForUser(req.user.id);
}

View File

@ -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<InternetEligibilityDto>(
const details = await this.catalogCache.getCachedEligibility<InternetEligibilityDetails>(
eligibilityKey,
async () => this.queryEligibilityDetails(sfAccountId)
);
@ -239,22 +230,22 @@ export class InternetCatalogService extends BaseCatalogService {
return details.eligibility;
}
async getEligibilityDetailsForUser(userId: string): Promise<InternetEligibilityDto> {
async getEligibilityDetailsForUser(userId: string): Promise<InternetEligibilityDetails> {
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<InternetEligibilityDto>(
return this.catalogCache.getCachedEligibility<InternetEligibilityDetails>(
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<InternetEligibilityDto> {
private async queryEligibilityDetails(sfAccountId: string): Promise<InternetEligibilityDetails> {
const eligibilityField = assertSoqlFieldName(
this.config.get<string>("ACCOUNT_INTERNET_ELIGIBILITY_FIELD") ?? "Internet_Eligibility__c",
"ACCOUNT_INTERNET_ELIGIBILITY_FIELD"
@ -423,14 +393,14 @@ export class InternetCatalogService extends BaseCatalogService {
})) as SalesforceResponse<Record<string, unknown>>;
const record = (res.records?.[0] as Record<string, unknown> | 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()

View File

@ -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<MeStatus> {
return this.meStatus.getStatusForUser(req.user.id);
}
}

View File

@ -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 {}

View File

@ -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<MeStatus> {
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<OrderSummary[] | null> {
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<PaymentMethodsStatus> {
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:
"Were verifying if our service is available at your residence. Well 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 couldnt 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<void> {
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"
);
}
}
}

View File

@ -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<Record<NotificationTypeValue, number>> = {
// 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 },
},
});

View File

@ -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],

View File

@ -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<void> {
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
*/

View File

@ -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<string | null> {
// 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
*/

View File

@ -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],
})

View File

@ -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 {

View File

@ -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,

View File

@ -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

View File

@ -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<ResidenceCardVerificationDto> {
async getStatus(@Req() req: RequestWithUser): Promise<ResidenceCardVerification> {
return this.residenceCards.getStatusForUser(req.user.id);
}
@ -57,7 +55,7 @@ export class ResidenceCardController {
async submit(
@Req() req: RequestWithUser,
@UploadedFile() file?: UploadedResidenceCard
): Promise<ResidenceCardVerificationDto> {
): Promise<ResidenceCardVerification> {
if (!file) {
throw new BadRequestException("Missing file upload.");
}

View File

@ -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<ResidenceCardVerificationDto> {
async getStatusForUser(userId: string): Promise<ResidenceCardVerification> {
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<ArrayBuffer>;
}): Promise<ResidenceCardVerificationDto> {
}): Promise<ResidenceCardVerification> {
const mapping = await this.mappings.findByUserId(params.userId);
if (!mapping?.sfAccountId) {
throw new Error("No Salesforce mapping found for current user");

View File

@ -567,7 +567,12 @@ export default function ProfileContainer() {
{verificationQuery.data?.reviewerNotes && (
<p>{verificationQuery.data.reviewerNotes}</p>
)}
<p>Please upload a new, clear photo of your residence card.</p>
<p>Please upload a new, clear photo or scan of your residence card.</p>
<ul className="list-disc space-y-1 pl-5 text-sm text-muted-foreground">
<li>Make sure all text is readable and the full card is visible.</li>
<li>Avoid glare/reflections and blurry photos.</li>
<li>Maximum file size: 5MB.</li>
</ul>
</div>
</AlertBanner>
) : (
@ -577,6 +582,37 @@ export default function ProfileContainer() {
</p>
)}
{(verificationQuery.data?.filename ||
verificationQuery.data?.submittedAt ||
verificationQuery.data?.reviewedAt) && (
<div className="rounded-lg border border-border bg-muted/30 px-4 py-3">
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
Latest submission
</div>
{verificationQuery.data?.filename && (
<div className="mt-1 text-sm font-medium text-foreground">
{verificationQuery.data.filename}
</div>
)}
{verificationQuery.data?.submittedAt && (
<div className="mt-1 text-xs text-muted-foreground">
Submitted on{" "}
{new Date(verificationQuery.data.submittedAt).toLocaleDateString(undefined, {
dateStyle: "medium",
})}
</div>
)}
{verificationQuery.data?.reviewedAt && (
<div className="mt-1 text-xs text-muted-foreground">
Reviewed on{" "}
{new Date(verificationQuery.data.reviewedAt).toLocaleDateString(undefined, {
dateStyle: "medium",
})}
</div>
)}
</div>
)}
{canUploadVerification && (
<div className="space-y-3">
<input
@ -644,7 +680,7 @@ export default function ProfileContainer() {
)}
<p className="text-xs text-muted-foreground">
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.
</p>
</div>
)}

View File

@ -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<InternetCatalogCollection> {
const response = await apiClient.GET<InternetCatalogCollection>("/api/catalog/internet/plans");
@ -91,7 +82,8 @@ export const catalogService = {
const response = await apiClient.GET<InternetEligibilityDetails>(
"/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?: {

View File

@ -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
</h1>
<p className="text-sm sm:text-base text-muted-foreground mt-2 max-w-3xl leading-relaxed">
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&apos;re ready to order.
</p>
</div>
{/* Service-specific ordering info */}
<div className="bg-muted/40 border border-border rounded-2xl p-6 mb-10">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{[
{
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 => (
<div key={step.title} className="flex items-start gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-primary/10 text-primary">
<CheckCircleIcon className="h-5 w-5" />
</div>
<div>
<div className="text-sm font-semibold text-foreground">{step.title}</div>
<div className="text-xs text-muted-foreground">{step.description}</div>
<h2 className="text-sm font-semibold text-foreground mb-4">What to expect when ordering</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div className="flex items-start gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-blue-500/10 text-blue-500 border border-blue-500/20 flex-shrink-0">
<ClockIcon className="h-5 w-5" />
</div>
<div>
<div className="text-sm font-semibold text-foreground">Internet</div>
<div className="text-xs text-muted-foreground">
Requires address verification (1-2 business days). We&apos;ll email you when plans
are ready.
</div>
</div>
))}
</div>
<div className="flex items-start gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-green-500/10 text-green-500 border border-green-500/20 flex-shrink-0">
<BoltIcon className="h-5 w-5" />
</div>
<div>
<div className="text-sm font-semibold text-foreground">SIM & eSIM</div>
<div className="text-xs text-muted-foreground">
Order immediately after signup. Physical SIM ships next business day.
</div>
</div>
</div>
<div className="flex items-start gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-purple-500/10 text-purple-500 border border-purple-500/20 flex-shrink-0">
<BoltIcon className="h-5 w-5" />
</div>
<div>
<div className="text-sm font-semibold text-foreground">VPN</div>
<div className="text-xs text-muted-foreground">
Order immediately after signup. Router shipped upon order confirmation.
</div>
</div>
</div>
</div>
</div>
@ -117,20 +129,19 @@ export function PublicCatalogHomeView() {
Why choose our services?
</h3>
<p className="text-sm text-muted-foreground max-w-2xl leading-relaxed">
High-quality connectivity solutions with personalized recommendations and seamless
ordering.
Reliable connectivity with transparent pricing and dedicated support.
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<FeatureCard
icon={<WifiIcon className="h-8 w-8 text-primary" />}
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"
/>
<FeatureCard
icon={<GlobeAltIcon className="h-8 w-8 text-primary" />}
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"
/>
</div>
</div>

View File

@ -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 (
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 pb-20">
<CatalogBackLink href={`${shopBasePath}/internet`} label="Back to plans" />
<div className="mt-8 space-y-6">
<Skeleton className="h-10 w-96 mx-auto" />
<Skeleton className="h-32 w-full" />
</div>
</div>
);
}
return (
<div className="max-w-2xl mx-auto px-4 sm:px-6 lg:px-8 pb-20">
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 pb-20">
<CatalogBackLink href={`${shopBasePath}/internet`} label="Back to plans" />
{/* Header */}
<div className="mt-6 mb-8 text-center">
<div className="flex justify-center mb-4">
<div className="flex h-14 w-14 items-center justify-center rounded-full bg-primary/10 border border-primary/20">
<WifiIcon className="h-7 w-7 text-primary" />
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-gradient-to-br from-blue-500/20 to-blue-500/5 border border-blue-500/20 shadow-lg shadow-blue-500/10">
<WifiIcon className="h-8 w-8 text-blue-500" />
</div>
</div>
<h1 className="text-2xl font-bold text-foreground mb-2">Check Internet Availability</h1>
<p className="text-muted-foreground">
Create an account to verify service availability at your address.
<h1 className="text-2xl md:text-3xl font-bold text-foreground mb-3">
Request Internet Service
</h1>
<p className="text-muted-foreground max-w-lg mx-auto">
Create an account to request an availability check for your address.
</p>
</div>
{/* Process Steps - Compact */}
<div className="mb-8 grid grid-cols-3 gap-3 text-center">
<div className="flex flex-col items-center gap-2 p-3 rounded-lg bg-muted/50">
<CheckCircleIcon className="h-5 w-5 text-success" />
<span className="text-xs font-medium text-foreground">Create account</span>
{/* Plan Summary Card - only if plan is selected */}
{plan && (
<div className="mb-8 bg-card border border-border rounded-2xl p-6 shadow-[var(--cp-shadow-1)]">
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-3">
Selected Plan
</div>
<div className="flex items-start gap-4">
<div className="flex-shrink-0">
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-blue-500/10 border border-blue-500/20">
<WifiIcon className="h-6 w-6 text-blue-500" />
</div>
</div>
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-4 flex-wrap">
<div>
<h3 className="text-lg font-semibold text-foreground">{plan.name}</h3>
{plan.description && (
<p className="text-sm text-muted-foreground mt-1">{plan.description}</p>
)}
{(plan.catalogMetadata?.tierDescription ||
plan.internetPlanTier ||
plan.internetOfferingType) && (
<div className="flex flex-wrap gap-2 mt-3">
{(plan.catalogMetadata?.tierDescription || plan.internetPlanTier) && (
<span className="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium bg-info/10 text-info border border-info/20">
{plan.catalogMetadata?.tierDescription || plan.internetPlanTier}
</span>
)}
{plan.internetOfferingType && (
<span className="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium bg-muted text-muted-foreground">
{plan.internetOfferingType}
</span>
)}
</div>
)}
</div>
<div className="text-right">
<CardPricing monthlyPrice={plan.monthlyPrice} size="md" alignment="right" />
</div>
</div>
</div>
</div>
</div>
<div className="flex flex-col items-center gap-2 p-3 rounded-lg bg-muted/50">
<ClockIcon className="h-5 w-5 text-info" />
<span className="text-xs font-medium text-foreground">We verify availability</span>
)}
{/* What happens after signup - honest timeline */}
<div className="mb-8 bg-card border border-border rounded-2xl p-6 shadow-[var(--cp-shadow-1)]">
<h2 className="text-base font-semibold text-foreground mb-4">What happens next</h2>
<div className="space-y-4">
<div className="flex items-start gap-4">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-primary/10 text-primary text-sm font-bold flex-shrink-0">
1
</div>
<div>
<p className="text-sm font-medium text-foreground">Create your account</p>
<p className="text-sm text-muted-foreground">
Sign up with your service address to start the process.
</p>
</div>
</div>
<div className="flex items-start gap-4">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-muted text-muted-foreground text-sm font-bold flex-shrink-0">
2
</div>
<div className="flex-1">
<div className="flex items-center gap-2">
<p className="text-sm font-medium text-foreground">We verify availability</p>
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-amber-500/10 text-amber-600 border border-amber-500/20">
<ClockIcon className="h-3 w-3" />
1-2 business days
</span>
</div>
<p className="text-sm text-muted-foreground">
Our team checks service availability with NTT for your specific address.
</p>
</div>
</div>
<div className="flex items-start gap-4">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-muted text-muted-foreground text-sm font-bold flex-shrink-0">
3
</div>
<div className="flex-1">
<div className="flex items-center gap-2">
<p className="text-sm font-medium text-foreground">
You receive email notification
</p>
<EnvelopeIcon className="h-4 w-4 text-muted-foreground" />
</div>
<p className="text-sm text-muted-foreground">
We&apos;ll email you when your personalized plans are ready to view.
</p>
</div>
</div>
<div className="flex items-start gap-4">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-muted text-muted-foreground text-sm font-bold flex-shrink-0">
4
</div>
<div>
<p className="text-sm font-medium text-foreground">Complete your order</p>
<p className="text-sm text-muted-foreground">
Choose your plan options, add payment, and schedule installation.
</p>
</div>
</div>
</div>
<div className="flex flex-col items-center gap-2 p-3 rounded-lg bg-muted/50">
<BellIcon className="h-5 w-5 text-primary" />
<span className="text-xs font-medium text-foreground">Get notified</span>
</div>
{/* Important note */}
<div className="mb-8 bg-info-soft border border-info/25 rounded-xl p-4">
<div className="flex items-start gap-3">
<CheckIcon className="h-5 w-5 text-info mt-0.5 flex-shrink-0" />
<div>
<p className="text-sm font-medium text-foreground">Your account is ready immediately</p>
<p className="text-sm text-muted-foreground mt-1">
While we verify your address, you can explore your account, add payment methods, and
browse our other services like SIM and VPN.
</p>
</div>
</div>
</div>
{/* Auth Section */}
<InlineAuthSection
title="Create your account"
description="We'll verify internet availability at your address and notify you when ready."
description="Enter your details including service address to get started."
redirectTo={redirectTo}
/>
</div>

View File

@ -106,25 +106,33 @@ export function PublicInternetPlansView() {
<CatalogBackLink href={shopBasePath} label="Back to Services" />
<CatalogHero
title="Choose Your Internet Type"
description="Compare apartment vs home options and pick the speed that fits your address."
title="Internet Service Plans"
description="High-speed fiber internet for homes and apartments."
>
<div className="flex flex-col items-center gap-3">
<p className="text-sm text-muted-foreground text-center max-w-xl">
Compare starting prices for each internet type. Create an account to check availability
for your residence and unlock personalized plan options.
</p>
<div className="flex flex-wrap justify-center gap-2">
{offeringTypes.map(type => (
<div
key={type}
className={`inline-flex items-center gap-2 px-3 py-1.5 rounded-full border ${getEligibilityColor(type)} shadow-[var(--cp-shadow-1)]`}
>
{getEligibilityIcon(type)}
<span className="text-sm font-semibold">{type}</span>
</div>
))}
<div className="flex flex-col items-center gap-4">
{/* Availability notice */}
<div className="bg-info-soft border border-info/25 rounded-xl px-4 py-3 max-w-xl">
<p className="text-sm text-foreground text-center">
<span className="font-medium">Availability check required:</span>{" "}
<span className="text-muted-foreground">
After signup, we verify your address with NTT (1-2 business days). You&apos;ll
receive an email when your personalized plans are ready.
</span>
</p>
</div>
{offeringTypes.length > 0 && (
<div className="flex flex-wrap justify-center gap-2">
{offeringTypes.map(type => (
<div
key={type}
className={`inline-flex items-center gap-2 px-3 py-1.5 rounded-full border ${getEligibilityColor(type)} shadow-[var(--cp-shadow-1)]`}
>
{getEligibilityIcon(type)}
<span className="text-sm font-semibold">{type}</span>
</div>
))}
</div>
)}
</div>
</CatalogHero>

View File

@ -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() {
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 pb-20">
<CatalogBackLink href={`${shopBasePath}/sim`} label="Back to SIM plans" />
<div className="mt-8 space-y-6">
<Skeleton className="h-10 w-96" />
<Skeleton className="h-10 w-96 mx-auto" />
<Skeleton className="h-32 w-full" />
</div>
</div>
@ -51,109 +50,121 @@ export function PublicSimConfigureView() {
}
return (
<>
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 pb-20">
<CatalogBackLink href={`${shopBasePath}/sim`} label="Back to SIM plans" />
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 pb-20">
<CatalogBackLink href={`${shopBasePath}/sim`} label="Back to SIM plans" />
<CatalogHero
title="Get started with your SIM plan"
description="Create an account to complete your order, add a payment method, and complete identity verification."
/>
{/* Header */}
<div className="mt-6 mb-8 text-center">
<div className="flex justify-center mb-4">
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-gradient-to-br from-green-500/20 to-green-500/5 border border-green-500/20 shadow-lg shadow-green-500/10">
<DevicePhoneMobileIcon className="h-8 w-8 text-green-500" />
</div>
</div>
<h1 className="text-2xl md:text-3xl font-bold text-foreground mb-3">Order Your SIM</h1>
<p className="text-muted-foreground max-w-lg mx-auto">
Create an account to complete your order. Physical SIMs ship next business day.
</p>
</div>
<div className="mt-8 space-y-8">
{/* Plan Summary Card */}
<div className="bg-card border border-border rounded-2xl p-6 md:p-8 shadow-[var(--cp-shadow-1)]">
<div className="flex items-start gap-4 mb-6">
<div className="flex-shrink-0">
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-primary/10 border border-primary/20">
<DevicePhoneMobileIcon className="h-6 w-6 text-primary" />
</div>
</div>
<div className="flex-1 min-w-0">
<h3 className="text-xl font-semibold text-foreground mb-2">{plan.name}</h3>
{/* Plan Summary Card */}
<div className="mb-8 bg-card border border-border rounded-2xl p-6 shadow-[var(--cp-shadow-1)]">
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-3">
Selected Plan
</div>
<div className="flex items-start gap-4">
<div className="flex-shrink-0">
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-green-500/10 border border-green-500/20">
<DevicePhoneMobileIcon className="h-6 w-6 text-green-500" />
</div>
</div>
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-4 flex-wrap">
<div>
<h3 className="text-lg font-semibold text-foreground">{plan.name}</h3>
{plan.description && (
<p className="text-sm text-muted-foreground mb-4">{plan.description}</p>
<p className="text-sm text-muted-foreground mt-1">{plan.description}</p>
)}
<div className="flex flex-wrap gap-2 mb-4">
<div className="flex flex-wrap gap-2 mt-3">
{plan.simPlanType && (
<span className="inline-flex items-center px-3 py-1 rounded-full text-xs font-medium bg-primary/10 text-primary border border-primary/20">
<span className="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium bg-green-500/10 text-green-600 border border-green-500/20">
{plan.simPlanType}
</span>
)}
{plan.simDataSize && (
<span className="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium bg-info/10 text-info border border-info/20">
{plan.simDataSize}
</span>
)}
</div>
<div className="mt-4">
<CardPricing
monthlyPrice={plan.monthlyPrice}
oneTimePrice={plan.oneTimePrice}
size="lg"
alignment="left"
/>
</div>
</div>
<div className="text-right">
<CardPricing
monthlyPrice={plan.monthlyPrice}
oneTimePrice={plan.oneTimePrice}
size="md"
alignment="right"
/>
</div>
</div>
{/* Plan Details */}
{(plan.simDataSize || plan.description) && (
<div className="border-t border-border pt-6 mt-6">
<h4 className="text-sm font-semibold text-foreground mb-4 uppercase tracking-wide">
Plan Details:
</h4>
<ul className="grid grid-cols-1 md:grid-cols-2 gap-3">
{plan.simDataSize && (
<li className="flex items-start gap-2">
<CheckIcon className="h-4 w-4 text-success mt-0.5 flex-shrink-0" />
<span className="text-sm text-muted-foreground">
Data:{" "}
<span className="text-foreground font-medium">{plan.simDataSize}</span>
</span>
</li>
)}
{plan.simPlanType && (
<li className="flex items-start gap-2">
<CheckIcon className="h-4 w-4 text-success mt-0.5 flex-shrink-0" />
<span className="text-sm text-muted-foreground">
Type:{" "}
<span className="text-foreground font-medium">{plan.simPlanType}</span>
</span>
</li>
)}
{plan.simHasFamilyDiscount && (
<li className="flex items-start gap-2">
<CheckIcon className="h-4 w-4 text-success mt-0.5 flex-shrink-0" />
<span className="text-sm text-muted-foreground">
<span className="text-foreground font-medium">
Family Discount Available
</span>
</span>
</li>
)}
{plan.billingCycle && (
<li className="flex items-start gap-2">
<CheckIcon className="h-4 w-4 text-success mt-0.5 flex-shrink-0" />
<span className="text-sm text-muted-foreground">
Billing:{" "}
<span className="text-foreground font-medium">{plan.billingCycle}</span>
</span>
</li>
)}
</ul>
</div>
)}
</div>
</div>
<InlineAuthSection
title="Ready to order?"
description="Create an account to complete your SIM order. You'll need to add a payment method and complete identity verification."
redirectTo={redirectTarget}
highlights={[
{ title: "Secure Payment", description: "Add payment method safely" },
{ title: "Identity Verification", description: "Complete verification process" },
{ title: "Order Management", description: "Track your order status" },
]}
/>
{/* Plan Details */}
{(plan.simDataSize || plan.simHasFamilyDiscount || plan.billingCycle) && (
<div className="border-t border-border pt-4 mt-4">
<ul className="grid grid-cols-2 md:grid-cols-4 gap-3">
{plan.simDataSize && (
<li className="flex items-center gap-2">
<CheckIcon className="h-4 w-4 text-success flex-shrink-0" />
<span className="text-sm text-muted-foreground">
<span className="text-foreground font-medium">{plan.simDataSize}</span> data
</span>
</li>
)}
{plan.simPlanType && (
<li className="flex items-center gap-2">
<CheckIcon className="h-4 w-4 text-success flex-shrink-0" />
<span className="text-sm text-muted-foreground">{plan.simPlanType}</span>
</li>
)}
{plan.simHasFamilyDiscount && (
<li className="flex items-center gap-2">
<CheckIcon className="h-4 w-4 text-success flex-shrink-0" />
<span className="text-sm text-muted-foreground">Family discount</span>
</li>
)}
{plan.billingCycle && (
<li className="flex items-center gap-2">
<CheckIcon className="h-4 w-4 text-success flex-shrink-0" />
<span className="text-sm text-muted-foreground">{plan.billingCycle} billing</span>
</li>
)}
</ul>
</div>
)}
</div>
{/* Quick order info */}
<div className="mb-8 bg-success-soft border border-success/25 rounded-xl p-4">
<div className="flex items-start gap-3">
<BoltIcon className="h-5 w-5 text-success mt-0.5 flex-shrink-0" />
<div>
<p className="text-sm font-medium text-foreground">Order today, get started fast</p>
<p className="text-sm text-muted-foreground mt-1">
After signup, add a payment method and configure your SIM options. Choose eSIM for
instant activation or physical SIM (ships next business day).
</p>
</div>
</div>
</div>
</>
{/* Auth Section */}
<InlineAuthSection
title="Create your account to order"
description="Quick signup to configure your SIM and complete checkout."
redirectTo={redirectTarget}
/>
</div>
);
}

View File

@ -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() {
<CatalogBackLink href={shopBasePath} label="Back to Services" />
<CatalogHero
title="Choose Your SIM Plan"
description="Browse plan options now. Create an account to order, manage billing, and complete verification."
/>
title="SIM & eSIM Plans"
description="Data, voice, and SMS plans with 5G network coverage."
>
{/* Order info banner */}
<div className="bg-success-soft border border-success/25 rounded-xl px-4 py-3 max-w-xl mt-4">
<div className="flex items-center gap-2 justify-center">
<BoltIcon className="h-4 w-4 text-success flex-shrink-0" />
<p className="text-sm text-foreground">
<span className="font-medium">Order today</span>
<span className="text-muted-foreground">
{" "}
eSIM activates instantly, physical SIM ships next business day.
</span>
</p>
</div>
</div>
</CatalogHero>
<div className="mb-8 flex justify-center">
<div className="border-b border-border">

View File

@ -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() {
<CatalogBackLink href={shopBasePath} label="Back to Services" />
<CatalogHero
title="SonixNet VPN Router Service"
description="Fast and secure VPN connections to San Francisco or London using a pre-configured router."
/>
title="VPN Router Service"
description="Secure VPN connections to San Francisco or London using a pre-configured router."
>
{/* Order info banner */}
<div className="bg-success-soft border border-success/25 rounded-xl px-4 py-3 max-w-xl mt-4">
<div className="flex items-center gap-2 justify-center">
<BoltIcon className="h-4 w-4 text-success flex-shrink-0" />
<p className="text-sm text-foreground">
<span className="font-medium">Order today</span>
<span className="text-muted-foreground">
{" "}
create account, add payment, and your router ships upon confirmation.
</span>
</p>
</div>
</div>
</CatalogHero>
{vpnPlans.length > 0 ? (
<div className="mb-8">
<h2 className="text-2xl font-bold text-foreground mb-2 text-center">Available Plans</h2>
<p className="text-muted-foreground text-center mb-6">(One region per router)</p>
<h2 className="text-xl font-bold text-foreground mb-2 text-center">Choose Your Region</h2>
<p className="text-sm text-muted-foreground text-center mb-6">
Select one region per router rental
</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 max-w-4xl mx-auto">
{vpnPlans.map(plan => (
@ -64,8 +80,7 @@ export function PublicVpnPlansView() {
{activationFees.length > 0 && (
<AlertBanner variant="info" className="mt-6 max-w-4xl mx-auto" title="Activation Fee">
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.
</AlertBanner>
)}
</div>
@ -86,34 +101,30 @@ export function PublicVpnPlansView() {
)}
<div className="bg-card rounded-xl border border-border p-8 mb-8">
<h2 className="text-2xl font-bold text-foreground mb-6">How It Works</h2>
<div className="space-y-4 text-muted-foreground">
<h2 className="text-xl font-bold text-foreground mb-6">How It Works</h2>
<div className="space-y-4 text-sm text-muted-foreground">
<p>
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.
</p>
<p>
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.
</p>
<p>
Then you can connect your network media players to the VPN Wi-Fi network, to connect to
the VPN server.
</p>
<p>
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.
</p>
</div>
</div>
<AlertBanner variant="warning" title="Important Disclaimer" className="mb-8">
*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.
<p className="text-sm">
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.
</p>
</AlertBanner>
</div>
);

View File

@ -1,2 +1,3 @@
export * from "./useDashboardSummary";
export * from "./useDashboardTasks";
export * from "./useMeStatus";

View File

@ -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<string, unknown>
) {
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<DashboardSummary, DashboardError>({
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<DashboardSummary>("/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<string, unknown> }
);
}
},
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,
};
}

View File

@ -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<React.SVGProps<SVGSVGElement>>;
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:
"Were verifying if our service is available at your residence. Well 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<DashboardTaskType, DashboardTask["icon"]> = {
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<DashboardTask[]>(() => {
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,
};
}

View File

@ -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<MeStatus, Error>({
queryKey: queryKeys.me.status(),
queryFn: () => getMeStatus(),
enabled: isAuthenticated,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
});
}
export type UseMeStatusResult = ReturnType<typeof useMeStatus>;

View File

@ -0,0 +1,14 @@
import { apiClient } from "@/lib/api";
import { meStatusSchema, type MeStatus } from "@customer-portal/domain/dashboard";
export async function getMeStatus(): Promise<MeStatus> {
const response = await apiClient.GET<MeStatus>("/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;
}

View File

@ -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,
};

View File

@ -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) {

View File

@ -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<ResidenceCardVerification> {
const response = await apiClient.GET<ResidenceCardVerification>(
"/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<ResidenceCardVerification> {
@ -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);
},
};

View File

@ -132,9 +132,39 @@ export function ResidenceCardVerificationSettingsView() {
<p>{residenceCardQuery.data.reviewerNotes}</p>
)}
<p>Upload a clear photo or scan of your residence card (JPG, PNG, or PDF).</p>
<ul className="list-disc space-y-1 pl-5 text-sm text-muted-foreground">
<li>Make sure all text is readable and the full card is visible.</li>
<li>Avoid glare/reflections and blurry photos.</li>
<li>Maximum file size: 5MB.</li>
</ul>
</div>
</AlertBanner>
{(residenceCardQuery.data?.filename ||
residenceCardQuery.data?.submittedAt ||
residenceCardQuery.data?.reviewedAt) && (
<div className="rounded-lg border border-border bg-muted/30 px-4 py-3">
<div className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Latest submission
</div>
{residenceCardQuery.data?.filename && (
<div className="mt-1 text-sm font-medium text-foreground">
{residenceCardQuery.data.filename}
</div>
)}
{formatDateTime(residenceCardQuery.data?.submittedAt) && (
<div className="mt-1 text-xs text-muted-foreground">
Submitted: {formatDateTime(residenceCardQuery.data?.submittedAt)}
</div>
)}
{formatDateTime(residenceCardQuery.data?.reviewedAt) && (
<div className="mt-1 text-xs text-muted-foreground">
Reviewed: {formatDateTime(residenceCardQuery.data?.reviewedAt)}
</div>
)}
</div>
)}
{canUpload && (
<div className="space-y-3">
<input
@ -211,7 +241,8 @@ export function ResidenceCardVerificationSettingsView() {
)}
<p className="text-xs text-muted-foreground">
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.
</p>
</div>
)}

View File

@ -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<string, unknown>) => ["billing", "invoices", params] as const,
invoice: (id: string) => ["billing", "invoice", id] as const,

View File

@ -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**

View File

@ -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

View File

@ -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 customers 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.

View File

@ -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 │
│ │
└─────────────────────────────────────────────────────────────────────────┘
```

View File

@ -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",

View File

@ -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";

View File

@ -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";

View File

@ -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<typeof internetPlanCatalogItemSche
export type InternetInstallationCatalogItem = z.infer<typeof internetInstallationCatalogItemSchema>;
export type InternetAddonCatalogItem = z.infer<typeof internetAddonCatalogItemSchema>;
export type InternetCatalogCollection = z.infer<typeof internetCatalogCollectionSchema>;
export type InternetEligibilityStatus = z.infer<typeof internetEligibilityStatusSchema>;
export type InternetEligibilityDetails = z.infer<typeof internetEligibilityDetailsSchema>;
// SIM products
export type SimCatalogProduct = z.infer<typeof simCatalogProductSchema>;
@ -170,4 +204,3 @@ export type CatalogProduct =
| SimActivationFeeCatalogItem
| VpnCatalogProduct
| CatalogProductBase;

View File

@ -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";

View File

@ -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";

View File

@ -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<typeof addressSchema>;
export type AddressFormData = z.infer<typeof addressFormSchema>;
export type ProfileEditFormData = z.infer<typeof profileEditFormSchema>;
export type ProfileDisplayData = z.infer<typeof profileDisplayDataSchema>;
export type ResidenceCardVerificationStatus = z.infer<typeof residenceCardVerificationStatusSchema>;
export type ResidenceCardVerification = z.infer<typeof residenceCardVerificationSchema>;
// Convenience aliases
export type UserProfile = User; // Alias for user profile

View File

@ -9,6 +9,11 @@ import type {
activityFilterSchema,
activityFilterConfigSchema,
dashboardSummaryResponseSchema,
dashboardTaskTypeSchema,
dashboardTaskToneSchema,
dashboardTaskSchema,
paymentMethodsStatusSchema,
meStatusSchema,
} from "./schema.js";
export type ActivityType = z.infer<typeof activityTypeSchema>;
@ -20,3 +25,8 @@ export type DashboardError = z.infer<typeof dashboardErrorSchema>;
export type ActivityFilter = z.infer<typeof activityFilterSchema>;
export type ActivityFilterConfig = z.infer<typeof activityFilterConfigSchema>;
export type DashboardSummaryResponse = z.infer<typeof dashboardSummaryResponseSchema>;
export type DashboardTaskType = z.infer<typeof dashboardTaskTypeSchema>;
export type DashboardTaskTone = z.infer<typeof dashboardTaskToneSchema>;
export type DashboardTask = z.infer<typeof dashboardTaskSchema>;
export type PaymentMethodsStatus = z.infer<typeof paymentMethodsStatusSchema>;
export type MeStatus = z.infer<typeof meStatusSchema>;

View File

@ -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<typeof invoiceActivityMetadataSchema>;
export type ServiceActivityMetadata = z.infer<typeof serviceActivityMetadataSchema>;

View File

@ -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;
}

View File

@ -56,7 +56,7 @@ export const NOTIFICATION_TEMPLATES: Record<NotificationTypeValue, NotificationT
title: "Good news! Internet service is available",
message:
"We've confirmed internet service is available at your address. You can now select a plan and complete your order.",
actionUrl: "/shop/internet",
actionUrl: "/account/shop/internet",
actionLabel: "View Plans",
priority: "high",
},
@ -65,7 +65,7 @@ export const NOTIFICATION_TEMPLATES: Record<NotificationTypeValue, NotificationT
title: "Internet service not available",
message:
"Unfortunately, internet service is not currently available at your address. We'll notify you if this changes.",
actionUrl: "/support",
actionUrl: "/account/support",
actionLabel: "Contact Support",
priority: "high",
},
@ -73,7 +73,7 @@ export const NOTIFICATION_TEMPLATES: Record<NotificationTypeValue, NotificationT
type: NOTIFICATION_TYPE.VERIFICATION_VERIFIED,
title: "ID verification complete",
message: "Your identity has been verified. You can now complete your order.",
actionUrl: "/checkout",
actionUrl: "/account/order",
actionLabel: "Continue Checkout",
priority: "high",
},
@ -81,7 +81,7 @@ export const NOTIFICATION_TEMPLATES: Record<NotificationTypeValue, NotificationT
type: NOTIFICATION_TYPE.VERIFICATION_REJECTED,
title: "ID verification requires attention",
message: "We couldn't verify your ID. Please review the feedback and resubmit.",
actionUrl: "/account/verification",
actionUrl: "/account/settings/verification",
actionLabel: "Resubmit",
priority: "high",
},
@ -89,7 +89,7 @@ export const NOTIFICATION_TEMPLATES: Record<NotificationTypeValue, NotificationT
type: NOTIFICATION_TYPE.ORDER_APPROVED,
title: "Order approved",
message: "Your order has been approved and is being processed.",
actionUrl: "/orders",
actionUrl: "/account/orders",
actionLabel: "View Order",
priority: "medium",
},
@ -97,7 +97,7 @@ export const NOTIFICATION_TEMPLATES: Record<NotificationTypeValue, NotificationT
type: NOTIFICATION_TYPE.ORDER_ACTIVATED,
title: "Service activated",
message: "Your service is now active and ready to use.",
actionUrl: "/subscriptions",
actionUrl: "/account/services",
actionLabel: "View Service",
priority: "high",
},
@ -105,7 +105,7 @@ export const NOTIFICATION_TEMPLATES: Record<NotificationTypeValue, NotificationT
type: NOTIFICATION_TYPE.ORDER_FAILED,
title: "Order requires attention",
message: "There was an issue processing your order. Please contact support.",
actionUrl: "/support",
actionUrl: "/account/support",
actionLabel: "Contact Support",
priority: "high",
},
@ -113,7 +113,7 @@ export const NOTIFICATION_TEMPLATES: Record<NotificationTypeValue, NotificationT
type: NOTIFICATION_TYPE.CANCELLATION_SCHEDULED,
title: "Cancellation scheduled",
message: "Your cancellation request has been received and scheduled.",
actionUrl: "/subscriptions",
actionUrl: "/account/services",
actionLabel: "View Details",
priority: "medium",
},
@ -121,7 +121,7 @@ export const NOTIFICATION_TEMPLATES: Record<NotificationTypeValue, NotificationT
type: NOTIFICATION_TYPE.CANCELLATION_COMPLETE,
title: "Service cancelled",
message: "Your service has been successfully cancelled.",
actionUrl: "/subscriptions",
actionUrl: "/account/services",
actionLabel: "View Details",
priority: "medium",
},
@ -130,7 +130,7 @@ export const NOTIFICATION_TEMPLATES: Record<NotificationTypeValue, NotificationT
title: "Payment method expiring soon",
message:
"Your payment method is expiring soon. Please update it to avoid service interruption.",
actionUrl: "/billing/payment-methods",
actionUrl: "/account/billing/payments",
actionLabel: "Update Payment",
priority: "high",
},
@ -138,7 +138,7 @@ export const NOTIFICATION_TEMPLATES: Record<NotificationTypeValue, NotificationT
type: NOTIFICATION_TYPE.INVOICE_DUE,
title: "Invoice due",
message: "You have an invoice due. Please make a payment to keep your service active.",
actionUrl: "/billing/invoices",
actionUrl: "/account/billing/invoices",
actionLabel: "Pay Now",
priority: "high",
},

View File

@ -0,0 +1,27 @@
#!/usr/bin/env bash
set -euo pipefail
# Ensures packages/domain/dist stays in sync with source.
# Intended for CI or local verification before pushing.
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
cd "$ROOT_DIR"
echo "[domain] Building @customer-portal/domain…"
pnpm --filter @customer-portal/domain build >/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."