Assist_Design/apps/bff/src/modules/orders/services/fulfillment-side-effects.service.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

223 lines
6.7 KiB
TypeScript

import { Injectable, Inject } from "@nestjs/common";
import { Logger } from "nestjs-pino";
import { OrderEventsService } from "./order-events.service.js";
import { OrdersCacheService } from "./orders-cache.service.js";
import type { OrderUpdateEventPayload } from "@customer-portal/domain/orders";
import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js";
import { NotificationService } from "@bff/modules/notifications/notifications.service.js";
import { NOTIFICATION_SOURCE, NOTIFICATION_TYPE } from "@customer-portal/domain/notifications";
import { salesforceAccountIdSchema } from "@customer-portal/domain/common";
import { extractErrorMessage } from "@bff/core/utils/error.util.js";
/**
* Fulfillment Side Effects Service
*
* Handles all non-critical side effects during order fulfillment:
* - Event publishing (SSE/WebSocket notifications)
* - In-app notification creation
* - Cache invalidation
*
* All methods are safe and do not throw - they log warnings on failure.
* This keeps the main orchestration flow clean and focused on critical operations.
*/
@Injectable()
export class FulfillmentSideEffectsService {
constructor(
@Inject(Logger) private readonly logger: Logger,
private readonly orderEvents: OrderEventsService,
private readonly ordersCache: OrdersCacheService,
private readonly mappingsService: MappingsService,
private readonly notifications: NotificationService
) {}
/**
* Publish a status update event for real-time UI updates
*/
publishStatusUpdate(sfOrderId: string, event: Omit<OrderUpdateEventPayload, "orderId">): void {
this.orderEvents.publish(sfOrderId, {
orderId: sfOrderId,
...event,
});
}
/**
* Publish event for order starting activation
*/
publishActivating(sfOrderId: string): void {
this.publishStatusUpdate(sfOrderId, {
status: "Processing",
activationStatus: "Activating",
stage: "in_progress",
source: "fulfillment",
timestamp: new Date().toISOString(),
});
}
/**
* Publish event for order completed successfully
*/
publishCompleted(sfOrderId: string, whmcsOrderId?: number, whmcsServiceIds?: number[]): void {
this.publishStatusUpdate(sfOrderId, {
status: "Completed",
activationStatus: "Activated",
stage: "completed",
source: "fulfillment",
timestamp: new Date().toISOString(),
payload: {
...(whmcsOrderId !== undefined && { whmcsOrderId }),
...(whmcsServiceIds !== undefined && { whmcsServiceIds }),
},
});
}
/**
* Publish event for order already provisioned (idempotent case)
*/
publishAlreadyProvisioned(sfOrderId: string, whmcsOrderId?: string): void {
this.publishStatusUpdate(sfOrderId, {
status: "Completed",
activationStatus: "Activated",
stage: "completed",
source: "fulfillment",
message: "Order already provisioned",
timestamp: new Date().toISOString(),
payload: {
...(whmcsOrderId !== undefined && { whmcsOrderId }),
},
});
}
/**
* Publish event for order fulfillment failed
*/
publishFailed(sfOrderId: string, reason: string): void {
this.publishStatusUpdate(sfOrderId, {
status: "Pending Review",
activationStatus: "Failed",
stage: "failed",
source: "fulfillment",
timestamp: new Date().toISOString(),
reason,
});
}
/**
* Create in-app notification for order approved (started processing)
*/
async notifyOrderApproved(sfOrderId: string, accountId?: unknown): Promise<void> {
await this.safeCreateNotification({
type: NOTIFICATION_TYPE.ORDER_APPROVED,
sfOrderId,
accountId,
actionUrl: `/account/orders/${sfOrderId}`,
});
}
/**
* Create in-app notification for order activated (completed)
*/
async notifyOrderActivated(sfOrderId: string, accountId?: unknown): Promise<void> {
await this.safeCreateNotification({
type: NOTIFICATION_TYPE.ORDER_ACTIVATED,
sfOrderId,
accountId,
actionUrl: "/account/services",
});
}
/**
* Create in-app notification for order failed
*/
async notifyOrderFailed(sfOrderId: string, accountId?: unknown): Promise<void> {
await this.safeCreateNotification({
type: NOTIFICATION_TYPE.ORDER_FAILED,
sfOrderId,
accountId,
actionUrl: `/account/orders/${sfOrderId}`,
});
}
/**
* Invalidate caches for order data
*/
async invalidateCaches(sfOrderId: string, accountId?: string | null): Promise<void> {
const tasks: Array<Promise<unknown>> = [this.ordersCache.invalidateOrder(sfOrderId)];
if (accountId) {
tasks.push(this.ordersCache.invalidateAccountOrders(accountId));
}
try {
await Promise.all(tasks);
} catch (error) {
this.logger.warn("Failed to invalidate order caches", {
sfOrderId,
accountId: accountId ?? undefined,
error: extractErrorMessage(error),
});
}
}
/**
* Execute all completion side effects
*/
async onFulfillmentComplete(
sfOrderId: string,
accountId?: string | null,
whmcsOrderId?: number,
whmcsServiceIds?: number[]
): Promise<void> {
this.publishCompleted(sfOrderId, whmcsOrderId, whmcsServiceIds);
await this.notifyOrderActivated(sfOrderId, accountId);
await this.invalidateCaches(sfOrderId, accountId);
}
/**
* Execute all failure side effects
*/
async onFulfillmentFailed(
sfOrderId: string,
accountId?: string | null,
reason: string = "Fulfillment failed"
): Promise<void> {
this.publishFailed(sfOrderId, reason);
await this.notifyOrderFailed(sfOrderId, accountId);
await this.invalidateCaches(sfOrderId, accountId);
}
/**
* Safe notification creation - never throws
*/
private async safeCreateNotification(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"
);
}
}
}