Assist_Design/apps/bff/src/modules/me-status/me-status.aggregator.ts
barsa be164cf287 feat: Implement Me Status Aggregator to consolidate user status data
feat: Add Fulfillment Side Effects Service for order processing notifications and cache management

feat: Create base validation interfaces and implement various order validators

feat: Develop Internet Order Validator to check eligibility and prevent duplicate services

feat: Implement SIM Order Validator to ensure residence card verification and activation fee presence

feat: Create SKU Validator to validate product SKUs against the Salesforce pricebook

feat: Implement User Mapping Validator to ensure necessary account mappings exist before ordering

feat: Enhance Users Service with methods for user profile management and summary retrieval
2026-01-19 11:25:30 +09:00

302 lines
10 KiB
TypeScript

import { Injectable, Inject } from "@nestjs/common";
import { Logger } from "nestjs-pino";
import { UsersService } from "@bff/modules/users/application/users.service.js";
import { OrderOrchestrator } from "@bff/modules/orders/services/order-orchestrator.service.js";
import { InternetEligibilityService } from "@bff/modules/services/application/internet-eligibility.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/services";
import {
residenceCardVerificationSchema,
type ResidenceCardVerification,
} from "@customer-portal/domain/customer";
import type { OrderSummary } from "@customer-portal/domain/orders";
/**
* Me Status Aggregator
*
* Read-only aggregator that combines data from multiple sources
* (users, orders, eligibility, verification, payments, notifications)
* to build the dashboard status response.
*/
@Injectable()
export class MeStatusAggregator {
constructor(
private readonly users: UsersService,
private readonly orders: OrderOrchestrator,
private readonly internetEligibility: InternetEligibilityService,
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> {
try {
const [summary, internetEligibility, residenceCardVerification, orders] = await Promise.all([
this.users.getUserSummary(userId),
this.internetEligibility.getEligibilityDetailsForUser(userId),
this.safeGetResidenceCardVerification(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,
});
} catch (error) {
this.logger.error({ userId, err: error }, "Failed to get status for user");
throw error;
}
}
private async safeGetResidenceCardVerification(
userId: string
): Promise<ResidenceCardVerification> {
try {
return await this.residenceCards.getStatusForUser(userId);
} catch (error) {
this.logger.warn(
{ userId, err: error instanceof Error ? error.message : String(error) },
"Failed to load residence card verification for status payload"
);
return residenceCardVerificationSchema.parse({
status: "not_submitted",
filename: null,
mimeType: null,
sizeBytes: null,
submittedAt: null,
reviewedAt: null,
reviewerNotes: null,
});
}
}
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("en-US", {
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 pendingOrder = orders.find(
o =>
o.status === "Draft" ||
o.status === "Pending" ||
(o.status === "Activated" && o.activationStatus !== "Completed")
);
const firstPendingOrder = pendingOrder;
if (firstPendingOrder) {
const statusText =
firstPendingOrder.status === "Pending"
? "awaiting review"
: firstPendingOrder.status === "Draft"
? "in draft"
: "being activated";
tasks.push({
id: `order-${firstPendingOrder.id}`,
priority: 3,
type: "order",
title: "Order in progress",
description: `${firstPendingOrder.orderType || "Your"} order is ${statusText}`,
actionLabel: "View details",
detailHref: `/account/orders/${firstPendingOrder.id}`,
tone: "info",
metadata: { orderId: firstPendingOrder.id },
});
}
}
// Priority 4: Internet eligibility review (only when explicitly pending)
if (internetEligibility.status === "pending") {
tasks.push({
id: "internet-eligibility-review",
priority: 4,
type: "internet_eligibility",
title: "Internet availability review",
description:
"We're verifying if our service is available at your residence. We'll notify you when review is complete.",
actionLabel: "View status",
detailHref: "/account/services/internet",
tone: "info",
});
}
// Priority 4: ID verification rejected
if (residenceCardVerification.status === "rejected") {
tasks.push({
id: "id-verification-rejected",
priority: 4,
type: "id_verification",
title: "ID verification requires attention",
description: "We couldn't verify your ID. Please review the feedback and resubmit.",
actionLabel: "Resubmit",
detailHref: "/account/settings/verification",
tone: "warning",
});
}
// Priority 4: onboarding (only when no other tasks)
if (summary.stats.activeSubscriptions === 0 && tasks.length === 0) {
tasks.push({
id: "start-subscription",
priority: 4,
type: "onboarding",
title: "Start your first service",
description: "Browse our catalog and subscribe to internet, SIM, or VPN",
actionLabel: "Browse services",
detailHref: "/services",
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"
);
}
}
}