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
223 lines
6.7 KiB
TypeScript
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"
|
|
);
|
|
}
|
|
}
|
|
}
|