diff --git a/apps/bff/src/health/health.controller.ts b/apps/bff/src/health/health.controller.ts index 22445469..3d7dcea4 100644 --- a/apps/bff/src/health/health.controller.ts +++ b/apps/bff/src/health/health.controller.ts @@ -21,6 +21,7 @@ export class HealthController { private readonly prisma: PrismaService, private readonly config: ConfigService, @InjectQueue(QUEUE_NAMES.EMAIL) private readonly emailQueue: Queue, + @InjectQueue(QUEUE_NAMES.PROVISIONING) private readonly provisioningQueue: Queue, private readonly cache: CacheService ) {} @@ -51,6 +52,12 @@ export class HealthController { "failed", "delayed" ); + const provisioningQueueInfo = await this.provisioningQueue.getJobCounts( + "waiting", + "active", + "failed", + "delayed" + ); // Check Redis availability by a simple set/get on a volatile key const nonceProbeKey = "health:nonce:probe"; @@ -70,6 +77,7 @@ export class HealthController { version: "1.0.0", queues: { email: emailQueueInfo, + provisioning: provisioningQueueInfo, }, integrations: { redis: redisStatus, diff --git a/apps/bff/src/orders/queue/provisioning.processor.ts b/apps/bff/src/orders/queue/provisioning.processor.ts index 05691214..5c7acde5 100644 --- a/apps/bff/src/orders/queue/provisioning.processor.ts +++ b/apps/bff/src/orders/queue/provisioning.processor.ts @@ -36,19 +36,43 @@ export class ProvisioningProcessor extends WorkerHost { const fields = getSalesforceFieldMap(); const order = await this.salesforceService.getOrder(sfOrderId); const status = (order?.[fields.order.activationStatus] as string) || ""; + const lastErrorCodeField = fields.order.lastErrorCode; + const lastErrorCode = lastErrorCodeField + ? ((order?.[lastErrorCodeField] as string) || "") + : ""; if (status !== "Activating") { this.logger.log("Skipping provisioning job: Order not in Activating state", { sfOrderId, currentStatus: status, }); + await this.commitReplay(job); return; // Ack + no-op to safely handle duplicate/old events } - // Execute the same orchestration used by the webhook path, but without payload validation - await this.orchestrator.executeFulfillment(sfOrderId, {}, idempotencyKey); + // Guard: Avoid thrashing on known user-actionable failures + if (lastErrorCode === "PAYMENT_METHOD_MISSING") { + this.logger.log("Skipping provisioning job: Awaiting payment method addition", { + sfOrderId, + currentStatus: status, + lastErrorCode, + }); + await this.commitReplay(job); + return; + } - // Commit processed replay id for Pub/Sub resume (only after success) - if (typeof job.data.pubsubReplayId === "number") { + try { + // Execute the same orchestration used by the webhook path, but without payload validation + await this.orchestrator.executeFulfillment(sfOrderId, {}, idempotencyKey); + this.logger.log("Provisioning job completed", { sfOrderId }); + } finally { + // Commit processed replay id for Pub/Sub resume (commit regardless of success to avoid replay storms) + await this.commitReplay(job); + } + } + + private async commitReplay(job: { data: ProvisioningJobData }): Promise { + if (typeof job.data.pubsubReplayId !== "number") return; + try { const channel = this.config.get( "SF_PROVISION_EVENT_CHANNEL", "/event/Order_Fulfilment_Requested__e" @@ -58,8 +82,10 @@ export class ProvisioningProcessor extends WorkerHost { if (job.data.pubsubReplayId > prev) { await this.cache.set(replayKey, String(job.data.pubsubReplayId)); } + } catch (e) { + this.logger.warn("Failed to commit Pub/Sub replay id", { + error: e instanceof Error ? e.message : String(e), + }); } - - this.logger.log("Provisioning job completed", { sfOrderId }); } } diff --git a/apps/bff/src/orders/queue/provisioning.queue.ts b/apps/bff/src/orders/queue/provisioning.queue.ts index eceffe61..5095ddbf 100644 --- a/apps/bff/src/orders/queue/provisioning.queue.ts +++ b/apps/bff/src/orders/queue/provisioning.queue.ts @@ -19,17 +19,32 @@ export class ProvisioningQueueService { ) {} async enqueue(job: ProvisioningJobData): Promise { - await this.queue.add("provision", job, { - removeOnComplete: 100, - removeOnFail: 100, - attempts: 1, // No automatic retries; Salesforce is source of truth for retry - }); - this.logger.debug("Queued provisioning job", { - sfOrderId: job.sfOrderId, - idempotencyKey: job.idempotencyKey, - correlationId: job.correlationId, - pubsubReplayId: job.pubsubReplayId, - }); + const jobId = + typeof job.pubsubReplayId === "number" + ? `sf:${job.sfOrderId}:replay:${job.pubsubReplayId}` + : `sf:${job.sfOrderId}`; + try { + await this.queue.add("provision", job, { + jobId, + removeOnComplete: 100, + removeOnFail: 100, + attempts: 1, // No automatic retries; Salesforce is source of truth for retry + }); + // Use info level so it's visible in default logs + this.logger.log("Queued provisioning job", { + sfOrderId: job.sfOrderId, + idempotencyKey: job.idempotencyKey, + correlationId: job.correlationId, + pubsubReplayId: job.pubsubReplayId, + }); + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + if (/already exists/i.test(msg)) { + this.logger.debug("Duplicate provisioning job ignored", { sfOrderId: job.sfOrderId, jobId }); + return; + } + throw err; + } } async depth(): Promise { diff --git a/apps/bff/src/orders/services/order-fulfillment-validator.service.ts b/apps/bff/src/orders/services/order-fulfillment-validator.service.ts index 8baaa4d7..66c07e2e 100644 --- a/apps/bff/src/orders/services/order-fulfillment-validator.service.ts +++ b/apps/bff/src/orders/services/order-fulfillment-validator.service.ts @@ -1,7 +1,7 @@ import { Injectable, BadRequestException, ConflictException, Inject } from "@nestjs/common"; import { Logger } from "nestjs-pino"; import { SalesforceService } from "../../vendors/salesforce/salesforce.service"; -import { WhmcsOrderService } from "../../vendors/whmcs/services/whmcs-order.service"; +import { WhmcsPaymentService } from "../../vendors/whmcs/services/whmcs-payment.service"; import { MappingsService } from "../../mappings/mappings.service"; import { getErrorMessage } from "../../common/utils/error.util"; import { SalesforceOrder } from "../types/salesforce-order.types"; @@ -23,7 +23,7 @@ export class OrderFulfillmentValidator { constructor( @Inject(Logger) private readonly logger: Logger, private readonly salesforceService: SalesforceService, - private readonly whmcsOrderService: WhmcsOrderService, + private readonly whmcsPaymentService: WhmcsPaymentService, private readonly mappingsService: MappingsService ) {} @@ -62,16 +62,24 @@ export class OrderFulfillmentValidator { }; } - // 3. Get WHMCS client ID from account mapping - const clientId = await this.getWhmcsClientId(sfOrder.Account.Id); + // 3. Get WHMCS client mapping + const accountId = (sfOrder as unknown as { AccountId?: unknown })?.AccountId; + if (typeof accountId !== "string" || !accountId) { + throw new BadRequestException("Salesforce order is missing AccountId"); + } + const mapping = await this.mappingsService.findBySfAccountId(accountId); + if (!mapping?.whmcsClientId) { + throw new BadRequestException(`No WHMCS client mapping found for account ${accountId}`); + } + const clientId = mapping.whmcsClientId; // 4. Validate payment method exists - await this.validatePaymentMethod(clientId); + await this.validatePaymentMethod(clientId, mapping.userId); this.logger.log("Fulfillment validation completed successfully", { sfOrderId, clientId, - accountId: sfOrder.Account.Id, + accountId, }); return { @@ -117,7 +125,7 @@ export class OrderFulfillmentValidator { ]; return typeof v === "string" ? v : undefined; })(), - accountId: salesforceOrder.Account.Id, + accountId: (salesforceOrder as unknown as { AccountId?: unknown })?.AccountId, }); return salesforceOrder; @@ -127,35 +135,25 @@ export class OrderFulfillmentValidator { * Get WHMCS client ID from Salesforce account ID using mappings */ private async getWhmcsClientId(sfAccountId: string): Promise { - try { - // Use existing mappings service to get client ID - const mapping = await this.mappingsService.findBySfAccountId(sfAccountId); - - if (!mapping?.whmcsClientId) { - throw new BadRequestException(`No WHMCS client mapping found for account ${sfAccountId}`); - } - - this.logger.log("WHMCS client mapping found", { - sfAccountId, - whmcsClientId: mapping.whmcsClientId, - }); - - return mapping.whmcsClientId; - } catch (error) { - this.logger.error("Failed to get WHMCS client mapping", { - error: getErrorMessage(error), - sfAccountId, - }); - throw new BadRequestException(`Failed to find WHMCS client for account ${sfAccountId}`); + // Deprecated: retained for compatibility if referenced elsewhere + const mapping = await this.mappingsService.findBySfAccountId(sfAccountId); + if (!mapping?.whmcsClientId) { + throw new BadRequestException(`No WHMCS client mapping found for account ${sfAccountId}`); } + return mapping.whmcsClientId; } /** * Validate client has payment method in WHMCS */ - private async validatePaymentMethod(clientId: number): Promise { + private async validatePaymentMethod(clientId: number, userId: string): Promise { try { - const hasPaymentMethod = await this.whmcsOrderService.hasPaymentMethod(clientId); + // Centralized helper; aligns with frontend detection and allows fresh reads + const hasPaymentMethod = await this.whmcsPaymentService.hasPaymentMethod( + clientId, + userId, + { fresh: true } + ); if (!hasPaymentMethod) { throw new ConflictException( @@ -172,6 +170,7 @@ export class OrderFulfillmentValidator { this.logger.error("Payment method validation failed", { error: getErrorMessage(error), clientId, + userId, }); throw new ConflictException("Unable to validate payment method - fulfillment cannot proceed"); diff --git a/apps/bff/src/orders/services/order-orchestrator.service.ts b/apps/bff/src/orders/services/order-orchestrator.service.ts index d2859743..2914dd5c 100644 --- a/apps/bff/src/orders/services/order-orchestrator.service.ts +++ b/apps/bff/src/orders/services/order-orchestrator.service.ts @@ -166,6 +166,7 @@ export class OrderOrchestrator { id: order.Id, orderNumber: order.OrderNumber, status: order.Status, + accountId: (order as unknown as { AccountId?: unknown })?.AccountId as string | undefined, orderType: typeof (order as unknown as Record)[fields.order.orderType] === "string" ? ((order as unknown as Record)[fields.order.orderType] as string) @@ -263,7 +264,7 @@ export class OrderOrchestrator { quantity: item.Quantity, unitPrice: item.UnitPrice, totalPrice: item.TotalPrice, - billingCycle: String(item.PricebookEntry?.Product2?.Billing_Cycle__c || ""), + billingCycle: String((p2?.[fields.product.billingCycle] as string | undefined) || ""), }); return acc; }, diff --git a/apps/bff/src/orders/types/order-details.dto.ts b/apps/bff/src/orders/types/order-details.dto.ts index 0294cb5d..bc14d076 100644 --- a/apps/bff/src/orders/types/order-details.dto.ts +++ b/apps/bff/src/orders/types/order-details.dto.ts @@ -22,6 +22,7 @@ export interface OrderDetailsDto { orderType?: string; effectiveDate: string; totalAmount: number; + accountId?: string; accountName?: string; createdDate: string; lastModifiedDate: string; diff --git a/apps/bff/src/vendors/salesforce/events/pubsub.subscriber.ts b/apps/bff/src/vendors/salesforce/events/pubsub.subscriber.ts index f2ad1450..d62dbe8e 100644 --- a/apps/bff/src/vendors/salesforce/events/pubsub.subscriber.ts +++ b/apps/bff/src/vendors/salesforce/events/pubsub.subscriber.ts @@ -19,17 +19,17 @@ type SubscribeCallback = ( interface PubSubClient { connect(): Promise; - subscribe(topic: string, cb: SubscribeCallback, numRequested: number): Promise; + subscribe(topic: string, cb: SubscribeCallback, numRequested?: number): Promise; subscribeFromReplayId( topic: string, cb: SubscribeCallback, - numRequested: number, + numRequested: number | null, replayId: number ): Promise; subscribeFromEarliestEvent( topic: string, cb: SubscribeCallback, - numRequested: number + numRequested?: number ): Promise; requestAdditionalEvents(topic: string, numRequested: number): Promise; close(): Promise; @@ -106,36 +106,36 @@ export class SalesforcePubSubSubscriber implements OnModuleInit, OnModuleDestroy const subscribeCallback: SubscribeCallback = async (subscription, callbackType, data) => { try { - // Normalize library callback signatures const argTypes = [typeof subscription, typeof callbackType, typeof data]; - let type: string | undefined; - let payloadData: unknown; - let topic = (subscription as { topicName?: string })?.topicName || this.channel; + const type = callbackType; + const typeNorm = String(type || "").toLowerCase(); + const topic = (subscription as { topicName?: string })?.topicName || this.channel; - if (typeof callbackType === "string") { - type = callbackType; - payloadData = data; - } else if (typeof subscription === "string") { - type = subscription; - payloadData = callbackType; - topic = this.channel; - } else { - type = "data"; - payloadData = data ?? callbackType ?? subscription; - } - - if (type === "data") { - const event = payloadData as Record; + if (typeNorm === "data" || typeNorm === "event") { + const event = data as Record; + // Basic breadcrumb to confirm we are handling data callbacks + this.logger.debug("SF Pub/Sub data callback received", { + topic, + argTypes, + hasPayload: ((): boolean => { + if (!event || typeof event !== "object") return false; + const maybePayload = event["payload"]; + return typeof maybePayload === "object" && maybePayload !== null; + })(), + }); const payload = ((): Record | undefined => { - const p = event?.["payload"]; - return typeof p === "object" && p != null ? (p as Record) : undefined; + const p = event["payload"]; + if (typeof p === "object" && p !== null) { + return p as Record; + } + return undefined; })(); // Only check parsed payload const orderIdVal = payload?.["OrderId__c"] ?? payload?.["OrderId"]; const orderId = typeof orderIdVal === "string" ? orderIdVal : undefined; if (!orderId) { - this.logger.warn("Pub/Sub event missing OrderId__c; skipping", { argTypes, topic }); + this.logger.warn("Pub/Sub event missing OrderId__c; skipping", { argTypes, topic, payloadKeys: payload ? Object.keys(payload) : [] }); const depth = await this.provisioningQueue.depth(); if (depth < maxQueue) { await client.requestAdditionalEvents(topic, 1); @@ -161,20 +161,16 @@ export class SalesforcePubSubSubscriber implements OnModuleInit, OnModuleDestroy topic, }); - // Keep sliding window full when queue has room - const depth = await this.provisioningQueue.depth(); - if (depth < maxQueue) { - await client.requestAdditionalEvents(topic, 1); - } - } else if (type === "lastEvent") { + // Do not request more here; rely on 'lastevent' to top-up + } else if (typeNorm === "lastevent") { const depth = await this.provisioningQueue.depth(); const available = Math.max(0, maxQueue - depth); const desired = Math.max(0, Math.min(numRequested, available)); if (desired > 0) { await client.requestAdditionalEvents(topic, desired); } - } else if (type === "grpcKeepalive") { - const latestVal = (payloadData as { latestReplayId?: unknown })?.latestReplayId; + } else if (typeNorm === "grpckeepalive") { + const latestVal = (data as { latestReplayId?: unknown })?.latestReplayId; const latest = typeof latestVal === "number" ? latestVal : undefined; if (typeof latest === "number") { await this.cache.set(sfLatestSeenKey(this.channel), { @@ -182,6 +178,24 @@ export class SalesforcePubSubSubscriber implements OnModuleInit, OnModuleDestroy at: Date.now(), }); } + } else if (typeNorm === "grpcstatus" || typeNorm === "end") { + // No-op; informational + } else if (typeNorm === "error") { + this.logger.warn("SF Pub/Sub stream error", { topic, data }); + } else { + // Unknown callback type: log once with minimal context + const maybeEvent = data as Record | undefined; + const hasPayload = ((): boolean => { + if (!maybeEvent || typeof maybeEvent !== "object") return false; + const p = maybeEvent["payload"]; + return typeof p === "object" && p !== null; + })(); + this.logger.debug("SF Pub/Sub callback ignored (unknown type)", { + type, + topic, + argTypes, + hasPayload, + }); } } catch (err) { this.logger.error("Pub/Sub subscribe callback failed", { diff --git a/apps/bff/src/vendors/salesforce/salesforce.service.ts b/apps/bff/src/vendors/salesforce/salesforce.service.ts index e3e48f59..cd024708 100644 --- a/apps/bff/src/vendors/salesforce/salesforce.service.ts +++ b/apps/bff/src/vendors/salesforce/salesforce.service.ts @@ -145,7 +145,9 @@ export class SalesforceService implements OnModuleInit { const fields = getSalesforceFieldMap(); const result = (await this.connection.query( - `SELECT Id, Status, ${fields.order.activationStatus}, ${fields.order.whmcsOrderId}, AccountId + `SELECT Id, Status, ${fields.order.activationStatus}, ${fields.order.whmcsOrderId}, + ${fields.order.lastErrorCode}, ${fields.order.lastErrorMessage}, + AccountId, Account.Name FROM Order WHERE Id = '${orderId}' LIMIT 1` diff --git a/apps/bff/src/vendors/whmcs/services/whmcs-connection.service.ts b/apps/bff/src/vendors/whmcs/services/whmcs-connection.service.ts index 0a0132ea..ac9959f0 100644 --- a/apps/bff/src/vendors/whmcs/services/whmcs-connection.service.ts +++ b/apps/bff/src/vendors/whmcs/services/whmcs-connection.service.ts @@ -32,6 +32,9 @@ export interface WhmcsApiConfig { timeout?: number; retryAttempts?: number; retryDelay?: number; + // Optional elevated admin credentials for privileged actions (eg. AcceptOrder) + adminUsername?: string; + adminPasswordHash?: string; // MD5 hash of admin password } @Injectable() @@ -50,6 +53,10 @@ export class WhmcsConnectionService { timeout: this.configService.get("WHMCS_API_TIMEOUT", 30000), retryAttempts: this.configService.get("WHMCS_API_RETRY_ATTEMPTS", 1), retryDelay: this.configService.get("WHMCS_API_RETRY_DELAY", 1000), + adminUsername: this.configService.get("WHMCS_ADMIN_USERNAME"), + adminPasswordHash: + this.configService.get("WHMCS_ADMIN_PASSWORD_MD5") || + this.configService.get("WHMCS_ADMIN_PASSWORD_HASH"), }; // Optional API Access Key (used by some WHMCS deployments alongside API Credentials) this.accessKey = this.configService.get("WHMCS_API_ACCESS_KEY"); @@ -82,14 +89,26 @@ export class WhmcsConnectionService { ): Promise { const url = `${this.config.baseUrl}/includes/api.php`; - // Use WHMCS API Credential fields (identifier/secret). Do not send as username/password. - // WHMCS expects `identifier` and `secret` when authenticating with API Credentials. - const baseParams: Record = { - action, - identifier: this.config.identifier, - secret: this.config.secret, - responsetype: "json", - }; + // Choose authentication strategy. + // Prefer elevated admin credentials for privileged actions (AcceptOrder), if provided. + const useAdminAuth = + action.toLowerCase() === "acceptorder" && + !!this.config.adminUsername && + !!this.config.adminPasswordHash; + + const baseParams: Record = useAdminAuth + ? { + action, + username: this.config.adminUsername!, + password: this.config.adminPasswordHash!, + responsetype: "json", + } + : { + action, + identifier: this.config.identifier, + secret: this.config.secret, + responsetype: "json", + }; if (this.accessKey) { baseParams.accesskey = this.accessKey; } @@ -105,6 +124,7 @@ export class WhmcsConnectionService { this.logger.debug(`WHMCS API Request [${action}] attempt ${attempt}`, { action, params: this.sanitizeLogParams(params), + authMode: useAdminAuth ? "admin" : "api_credentials", }); const formData = new URLSearchParams(requestParams); @@ -149,6 +169,7 @@ export class WhmcsConnectionService { message: errorResponse.message, errorcode: errorResponse.errorcode, params: this.sanitizeLogParams(params), + authModeTried: useAdminAuth ? "admin" : "api_credentials", }); throw new Error(`WHMCS API Error: ${errorResponse.message}`); } @@ -214,6 +235,27 @@ export class WhmcsConnectionService { for (const [key, value] of Object.entries(params)) { if (value === undefined || value === null) continue; + // Handle arrays using PHP-style indexed parameters: key[0]=..., key[1]=... + if (Array.isArray(value)) { + value.forEach((v, i) => { + const idxKey = `${key}[${i}]`; + if (v === undefined || v === null) return; + const t = typeof v; + if (t === "string") { + sanitized[idxKey] = v as string; + } else if (t === "number" || t === "boolean" || t === "bigint") { + sanitized[idxKey] = (v as number | boolean | bigint).toString(); + } else if (t === "object") { + try { + sanitized[idxKey] = JSON.stringify(v); + } catch { + sanitized[idxKey] = ""; + } + } + }); + continue; + } + const typeOfValue = typeof value; if (typeOfValue === "string") { sanitized[key] = value as string; @@ -224,6 +266,7 @@ export class WhmcsConnectionService { ) { sanitized[key] = (value as number | boolean | bigint).toString(); } else if (typeOfValue === "object") { + // For plain objects, fall back to JSON string (only used for non-array fields) try { sanitized[key] = JSON.stringify(value); } catch { diff --git a/apps/bff/src/vendors/whmcs/services/whmcs-order.service.ts b/apps/bff/src/vendors/whmcs/services/whmcs-order.service.ts index ecc9a1e7..c35c0175 100644 --- a/apps/bff/src/vendors/whmcs/services/whmcs-order.service.ts +++ b/apps/bff/src/vendors/whmcs/services/whmcs-order.service.ts @@ -97,6 +97,10 @@ export class WhmcsOrderService { // Call WHMCS AcceptOrder API const response = (await this.connection.acceptOrder({ orderid: orderId.toString(), + // Ensure module provisioning is executed even if product config is different + autosetup: true, + // Suppress customer emails to remain consistent with earlier noemail flag + sendemail: false, })) as Record; if (response.result !== "success") { @@ -180,15 +184,20 @@ export class WhmcsOrderService { } // Check if client has any payment methods - const paymentMethods = (response.paymethods as { paymethod?: unknown[] })?.paymethod || []; - const hasValidMethod = Array.isArray(paymentMethods) - ? paymentMethods.length > 0 - : Boolean(paymentMethods); + const paymethodsNode = (response.paymethods as { paymethod?: unknown } | undefined)?.paymethod; + const totalResults = Number((response as { totalresults?: unknown })?.totalresults ?? 0) || 0; + const methodCount = Array.isArray(paymethodsNode) + ? paymethodsNode.length + : paymethodsNode && typeof paymethodsNode === "object" + ? 1 + : 0; + const hasValidMethod = methodCount > 0 || totalResults > 0; this.logger.log("Payment method check completed", { clientId, hasPaymentMethod: hasValidMethod, - methodCount: Array.isArray(paymentMethods) ? paymentMethods.length : hasValidMethod ? 1 : 0, + methodCount, + totalResults, }); return hasValidMethod; diff --git a/apps/bff/src/vendors/whmcs/services/whmcs-payment.service.ts b/apps/bff/src/vendors/whmcs/services/whmcs-payment.service.ts index 8122ae6b..d3d0ecbc 100644 --- a/apps/bff/src/vendors/whmcs/services/whmcs-payment.service.ts +++ b/apps/bff/src/vendors/whmcs/services/whmcs-payment.service.ts @@ -24,13 +24,19 @@ export class WhmcsPaymentService { /** * Get payment methods for a client */ - async getPaymentMethods(clientId: number, userId: string): Promise { + async getPaymentMethods( + clientId: number, + userId: string, + options?: { fresh?: boolean } + ): Promise { try { - // Try cache first - const cached = await this.cacheService.getPaymentMethods(userId); - if (cached) { - this.logger.debug(`Cache hit for payment methods: user ${userId}`); - return cached; + // Try cache first unless fresh requested + if (!options?.fresh) { + const cached = await this.cacheService.getPaymentMethods(userId); + if (cached) { + this.logger.debug(`Cache hit for payment methods: user ${userId}`); + return cached; + } } const response = await this.connectionService.getPayMethods({ clientid: clientId }); @@ -68,7 +74,9 @@ export class WhmcsPaymentService { .filter((method): method is PaymentMethod => method !== null); const result: PaymentMethodList = { paymentMethods: methods, totalCount: methods.length }; - await this.cacheService.setPaymentMethods(userId, result); + if (!options?.fresh) { + await this.cacheService.setPaymentMethods(userId, result); + } return result; } catch (error) { this.logger.error(`Failed to fetch payment methods for client ${clientId}`, { @@ -79,6 +87,22 @@ export class WhmcsPaymentService { } } + /** + * Centralized check used by both UI-aligned code paths and worker validation. + * Returns true when the transformed list has at least one saved payment method. + * Pass { fresh: true } to bypass cache for provisioning-time checks. + */ + async hasPaymentMethod( + clientId: number, + userId: string, + options?: { fresh?: boolean } + ): Promise { + const list = await this.getPaymentMethods(clientId, userId, options); + const count = list?.totalCount || 0; + this.logger.debug("hasPaymentMethod check", { clientId, userId, count }); + return count > 0; + } + /** * Get available payment gateways */ diff --git a/apps/bff/src/vendors/whmcs/whmcs.module.ts b/apps/bff/src/vendors/whmcs/whmcs.module.ts index 2746aad2..4d4161e5 100644 --- a/apps/bff/src/vendors/whmcs/whmcs.module.ts +++ b/apps/bff/src/vendors/whmcs/whmcs.module.ts @@ -31,6 +31,7 @@ import { WhmcsOrderService } from "./services/whmcs-order.service"; WhmcsDataTransformer, WhmcsCacheService, WhmcsOrderService, + WhmcsPaymentService, ], }) export class WhmcsModule {} diff --git a/docs/PORTAL-INTEGRATION-OVERVIEW.md b/docs/PORTAL-INTEGRATION-OVERVIEW.md new file mode 100644 index 00000000..9435fd6d --- /dev/null +++ b/docs/PORTAL-INTEGRATION-OVERVIEW.md @@ -0,0 +1,150 @@ +# Portal Integration Overview — Salesforce + WHMCS + +This document explains how the portal integrates Salesforce (catalog, orders, provisioning control) and WHMCS (billing, invoices, subscriptions, payments). It covers the main flows end‑to‑end, what checks occur, and where the logic lives in code. + +## System Overview + +- Components + - Frontend: Next.js portal (`apps/portal`) + - Backend (BFF): NestJS (`apps/bff`), orchestrates Salesforce + WHMCS + - Billing: WHMCS (invoices, payment methods, subscriptions) + - CRM/Control Plane: Salesforce (catalog via Product2, Orders, provisioning trigger) + - Infra: PostgreSQL (mappings), Redis (cache), BullMQ (queues) + +- Sources of truth + - Salesforce: Product catalog, Pricebook pricing, Orders, provisioning status and tracking + - WHMCS: Customer profile, payment methods, subscriptions, invoices + - BFF: Orchestration + ID mappings only; no customer data authority + +- Key environment flags (validation schema) + - `SF_EVENTS_ENABLED`, `SF_PROVISION_EVENT_CHANNEL`, `SF_*` for Salesforce; `WHMCS_*` for WHMCS; `PORTAL_PRICEBOOK_ID/PORTAL_PRICEBOOK_NAME` for catalog/pricing. See env sample for full list (env/portal-backend.env.sample:1). + +## Identity & Mapping + +- Purpose: link a portal user to a WHMCS client and Salesforce Account. +- Persistence: `idMapping` table via `MappingsService` (apps/bff/src/mappings/mappings.service.ts:1). +- Lookups + - By user → WHMCS client ID (apps/bff/src/mappings/mappings.service.ts:148) + - By Salesforce Account → WHMCS client ID (apps/bff/src/mappings/mappings.service.ts:63) +- Usage: Most flows start by resolving mapping to enforce access and route calls correctly (e.g., invoices, orders). + +## Catalog (Shown in Portal) + +- Source of truth: Salesforce Product2 + PricebookEntry. +- Field mapping is configurable via env (apps/bff/src/common/config/field-map.ts:1). Important fields include: + - `Product2.StockKeepingUnit` (SKU), portal visibility flags, `Item_Class__c`, billing cycle, and WHMCS product id/name. +- Base query helper uses a “Portal” pricebook (ID or name) and builds SOQL with visible/accessible filters (apps/bff/src/catalog/services/base-catalog.service.ts:1). +- SIM catalog example: returns plans, activation fees, add‑ons; optionally personalizes based on existing WHMCS services (apps/bff/src/catalog/services/sim-catalog.service.ts:1). +- Principle: Frontend selects explicit SKUs; backend validates SKUs exist in the portal pricebook and creates Salesforce OrderItems accordingly. See docs/PRODUCT-CATALOG-ARCHITECTURE.md:1. + +## Invoices & Payments + +- Endpoints: `GET /invoices`, `GET /invoices/:id`, `GET /invoices/:id/subscriptions`, `POST /invoices/:id/sso-link`, `POST /invoices/:id/payment-link` (apps/bff/src/invoices/invoices.controller.ts:1). +- Service flow: resolve mapping → fetch from WHMCS via `WhmcsService` → transform/cache → return (apps/bff/src/invoices/invoices.service.ts:24). + - List/paginate via WHMCS GetInvoices; details enriched with line items and `serviceId` links (apps/bff/src/vendors/whmcs/services/whmcs-invoice.service.ts:1). + - Subscriptions listed via WHMCS GetClientsProducts; transformed and cached (apps/bff/src/vendors/whmcs/services/whmcs-subscription.service.ts:1). + - Payment methods/gateways via WHMCS; cached in Redis; also used for gating order creation/provisioning (apps/bff/src/vendors/whmcs/services/whmcs-payment.service.ts:1). +- SSO links: invoice view/download/pay and payment-page with preselected method/gateway (apps/bff/src/vendors/whmcs/services/whmcs-payment.service.ts:168). + +## Orders — Creation (Portal ➝ Salesforce) + +- Entry point: `OrderOrchestrator.createOrder()` (apps/bff/src/orders/services/order-orchestrator.service.ts:36). +- Steps + 1) Validate request, user mapping, and business rules via `OrderValidator.validateCompleteOrder()` (apps/bff/src/orders/services/order-validator.service.ts:296): + - Format: `orderType`, non‑empty `skus[]` (apps/bff/src/orders/services/order-validator.service.ts:24) + - Mapping present: user ↔ WHMCS client ↔ SF Account (apps/bff/src/orders/services/order-validator.service.ts:79) + - Payment method exists in WHMCS (gating) (apps/bff/src/orders/services/order-validator.service.ts:96) + - SKU existence in portal pricebook (apps/bff/src/orders/services/order-validator.service.ts:197) + - Business rules by type (SIM, VPN, Internet) e.g. SIM requires activation fee (apps/bff/src/orders/services/order-validator.service.ts:225) + - Internet duplication guard: one active Internet service per account (apps/bff/src/orders/services/order-validator.service.ts:166) + 2) Build Order header fields including activation fields and address snapshot (apps/bff/src/orders/services/order-builder.service.ts:22) + - Address snapshot always sets BillTo* fields; sets `Address_Changed__c` if user supplied a different address at checkout (apps/bff/src/orders/services/order-builder.service.ts:88) + 3) Create Salesforce Order and then OrderItems by SKU using the pricebook entry and unit price (apps/bff/src/orders/services/order-item-builder.service.ts:20) +- Result: Returns `sfOrderId` with status Created for operator review/approval in Salesforce. + +## Orders — Provisioning (Salesforce ➝ WHMCS) + +- Trigger: Salesforce publishes a Platform Event (record‑triggered flow) on approval. The BFF subscriber listens when `SF_EVENTS_ENABLED=true` and enqueues provisioning (apps/bff/src/vendors/salesforce/events/pubsub.subscriber.ts:58). +- Queue: BullMQ `provisioning` queue with idempotent job IDs (apps/bff/src/orders/queue/provisioning.queue.ts:1). +- Processor guardrails (apps/bff/src/orders/queue/provisioning.processor.ts:26): + - Only process when Order `Activation_Status__c` is `Activating` (apps/bff/src/orders/queue/provisioning.processor.ts:35) + - Skip if last error is `PAYMENT_METHOD_MISSING` to avoid thrash (apps/bff/src/orders/queue/provisioning.processor.ts:52) + - Commit Salesforce Pub/Sub replay IDs for exactly‑once handling (apps/bff/src/orders/queue/provisioning.processor.ts:73) +- Orchestration steps (apps/bff/src/orders/services/order-fulfillment-orchestrator.service.ts:57): + - Validate request: not already provisioned (checks `WHMCS_Order_ID__c`), ensure client has payment method; resolve mapping (apps/bff/src/orders/services/order-fulfillment-validator.service.ts:23) + - Set SF activation status to `Activating` (apps/bff/src/orders/services/order-fulfillment-orchestrator.service.ts:98) + - Load SF Order details + OrderItems, map each to WHMCS items using the Product2 mapping (`WH_Product_ID__c`) and billing cycle (apps/bff/src/orders/services/order-whmcs-mapper.service.ts:1) + - Create WHMCS order (AddOrder) with Stripe as payment method; optional promo code and tracking notes (apps/bff/src/vendors/whmcs/services/whmcs-order.service.ts:20) + - Accept/provision order (AcceptOrder), capture service IDs and invoice ID returned (apps/bff/src/vendors/whmcs/services/whmcs-order.service.ts:60) + - Update SF: `Status=Completed`, `Activation_Status__c=Activated`, and write back `WHMCS_Order_ID__c` (apps/bff/src/orders/services/order-fulfillment-orchestrator.service.ts:117) +- Error handling: On failure, set `Status=Pending Review`, `Activation_Status__c=Failed`, and write concise error code/message for operator triage (apps/bff/src/orders/services/order-fulfillment-orchestrator.service.ts:146). + +## Subscriptions (Shown in Portal) + +- Data comes from WHMCS products/services via `GetClientsProducts` and is transformed into a standard Subscription list (apps/bff/src/vendors/whmcs/services/whmcs-subscription.service.ts:1). +- Cached per user; supports status filtering; invoice items link to `serviceId` to show related subscriptions (apps/bff/src/vendors/whmcs/transformers/whmcs-data.transformer.ts:35). + +## Payments & SSO + +- Payment methods summary drives UI gating and provisioning validation (apps/bff/src/vendors/whmcs/services/whmcs-payment.service.ts:44). +- SSO flows + - General WHMCS SSO (dashboard/settings) via `CreateSsoToken` + - Invoice view/download/pay SSO (apps/bff/src/vendors/whmcs/services/whmcs-payment.service.ts:168) + - Payment link with pre‑selected saved method or gateway (apps/bff/src/vendors/whmcs/services/whmcs-payment.service.ts:168) + +## Caching & Performance + +- Redis cache is used for: invoices (lists and by ID), subscriptions, payment methods, payment gateways, user mappings (apps/bff/src/common/cache/cache.service.ts:1). +- Cache invalidation helpers exist per domain (e.g., `invalidatePaymentMethodsCache`, `invalidateInvoiceCache`). +- HTTP pagination/limits enforced in controller input validation for invoices (apps/bff/src/invoices/invoices.controller.ts:1). + +## Field Map (Configurable) + +- All SF field API names are env‑driven and wrapped by `getSalesforceFieldMap()` (apps/bff/src/common/config/field-map.ts:1). +- Key paths used in flows + - Order activation: type/scheduledAt/status + - WHMCS IDs: `Order.WHMCS_Order_ID__c`, `OrderItem.WHMCS_Service_ID__c` + - Address snapshot: BillingStreet/City/State/Postal/Country + `Address_Changed__c` + +## Environment & Health + +- Env validation schema lists required/optional vars and sensible defaults (apps/bff/src/common/config/env.validation.ts:1). Sample file provides production-ready defaults (env/portal-backend.env.sample:1). +- Health endpoints expose DB/queue/feature flags and Salesforce events status (apps/bff/src/health/health.controller.ts:1). + +## End‑to‑End Flow Summary + +1) User links account at signup; mapping stored. Adds payment method in WHMCS via SSO. +2) Portal shows catalog from Salesforce Product2 + Pricebook. User selects options; frontend builds explicit SKUs. +3) `POST /orders` triggers validation (mapping, payment method exists, SKUs exist, business rules) → creates SF Order + OrderItems with address snapshot. +4) Operator approves in Salesforce; Platform Event published. +5) BFF subscriber enqueues provisioning; worker validates, maps items to WHMCS, creates/accepts WHMCS order, and updates Salesforce with status/IDs. +6) Portal pages show subscriptions and invoices from WHMCS with SSO links for payment. + +## Key Checks & Guards + +- Payment method required before order creation and before provisioning. +- SKU validity in pricebook; missing UnitPrice is a hard error. +- Business rules per order type (SIM requires activation fee, etc.). +- Internet duplication guard to prevent multiple active Internet services. +- Idempotency: If `WHMCS_Order_ID__c` is set, fulfillment short‑circuits as already provisioned. +- Queue processor only acts in `Activating` state; skips when awaiting user‑actionable errors. + +## Where to Look in Code + +- Order creation orchestration: apps/bff/src/orders/services/order-orchestrator.service.ts:36 +- Validation (format/mapping/PM/SKUs/rules): apps/bff/src/orders/services/order-validator.service.ts:24 +- Order header + address snapshot: apps/bff/src/orders/services/order-builder.service.ts:22 +- OrderItems from SKUs: apps/bff/src/orders/services/order-item-builder.service.ts:20 +- Fulfillment orchestration: apps/bff/src/orders/services/order-fulfillment-orchestrator.service.ts:57 +- Salesforce events subscriber: apps/bff/src/vendors/salesforce/events/pubsub.subscriber.ts:58 +- Provisioning queue processor: apps/bff/src/orders/queue/provisioning.processor.ts:26 +- Invoices service: apps/bff/src/invoices/invoices.service.ts:24 +- Subscriptions service: apps/bff/src/vendors/whmcs/services/whmcs-subscription.service.ts:1 +- Payment/SSO service: apps/bff/src/vendors/whmcs/services/whmcs-payment.service.ts:1 + +--- +For deeper details, see: +- docs/PORTAL-ORDERING-PROVISIONING.md +- docs/ORDER-FULFILLMENT-COMPLETE-GUIDE.md +- docs/PRODUCT-CATALOG-ARCHITECTURE.md +- docs/ADDRESS_SYSTEM.md diff --git a/docs/PORTAL-NONTECH-PRESENTATION.md b/docs/PORTAL-NONTECH-PRESENTATION.md new file mode 100644 index 00000000..d11f1c6a --- /dev/null +++ b/docs/PORTAL-NONTECH-PRESENTATION.md @@ -0,0 +1,167 @@ +# Customer Portal — Simple Overview (Non‑Technical) + +Use these slide-ready bullets to explain, in plain language, how our portal works with Salesforce and WHMCS. + +## 1) What The Portal Does +- Purpose: One place for customers to browse plans, place orders, and view/pay bills. +- Connects two company systems: Salesforce (service control) and WHMCS (billing). +- Goal: Smooth experience for customers, clear control and visibility for our team. + +## 2) The Two Systems We Connect +- Salesforce: Our “control center” for products and orders. Staff review and approve here. +- WHMCS: Our “billing system” for invoices, payment methods, and subscriptions. +- The Portal: The “bridge” customers use. It talks to both safely in the background. + +## 3) How It Fits Together (Big Picture) +- Customer uses the Portal → chooses products → places an order. +- Portal creates the order in Salesforce for review/approval. +- After approval, the Portal provisions the order in WHMCS. +- WHMCS then holds the subscription and generates the invoices customers can pay. + +## 4) Catalog (What Customers Can Buy) +- We show the catalog from Salesforce so prices and products are consistent. +- Products are organized (e.g., Internet, SIM, VPN) with clear names and monthly/one‑time fees. +- The Portal only shows what’s approved for online sale. + +## 5) Customer Journey (At a Glance) +1. Sign up and link your account. +2. Add a payment method (credit card or gateway) — one‑time step. +3. Browse the catalog and select a plan. +4. Place an order (Portal sends it to Salesforce). +5. Staff reviews/approves in Salesforce, and the Portal activates it in WHMCS. +6. Customer sees subscriptions and invoices in the Portal and pays securely. + +## 5a) Sign Up & Account Linking (Plain English) +- What we ask for: email, password, name, and your Customer Number (Salesforce number) — this lets us find your existing account. +- What happens: we create a billing profile in WHMCS and securely link three IDs together behind the scenes: + - Portal user ↔ WHMCS client ↔ Salesforce account. +- Why this matters: from then on, the Portal always shows the right invoices, payment methods, and subscriptions for that customer. + +## 5b) Address — Where It Lives and How We Use It +- Required at signup: we capture a full billing address and store it in WHMCS (our billing source of truth). +- Always available: the Portal pulls your current address from WHMCS so checkout is smooth. +- Snapshotted on every order: we copy the current address into the Salesforce order so staff can review what was used at the time. +- Internet orders: we ask you to explicitly confirm the installation address (technician visit). If you change it, we mark the order “address changed” for staff visibility. +- Updates later: when customers update address in the Portal, we sync it to WHMCS so billing and future orders are correct. + +## 5c) Payment Methods — Why We Require One First +- Fewer failures: we only allow ordering after a payment method is on file. +- How customers add it: the Portal opens WHMCS’s secure payment page (single sign‑on); cards are stored by WHMCS, not by the Portal. +- Provisioning also checks: even after approval, we re‑check payment to avoid failed activations. + +## 6) Ordering Flow (Simple) +- Before ordering, the Portal checks: “Do you have a saved payment method?” +- The Portal sends your chosen items to Salesforce as an order for review. +- After approval, the Portal creates the service in WHMCS and finishes activation. + +## 6a) Business Rules We Enforce (Simple) +- Internet: must include a service plan; installation options are clearly shown; we prevent duplicate active Internet services for the same account. +- SIM: must include a SIM plan and a one‑time activation fee; optional add‑ons (e.g., voicemail) can be added; for number transfer (MNP), we collect the reservation details. +- VPN: must include the VPN activation fee; regions/options are chosen up front. +- Product validity: only products that are approved and priced are allowed to be ordered. + +## 7) Billing & Payments (Simple) +- Invoices: Created by WHMCS and shown in the Portal. +- Payment: The Portal opens a secure payment page (SSO) directly in WHMCS. +- Subscriptions: Ongoing services (e.g., monthly plans) displayed in the Portal. + +## 8) Built‑In Safeguards (What We Check Automatically) +- Payment method required: prevents failed activations and billing issues. +- Product validity: only approved, priced items can be ordered. +- Duplicate protection: e.g., avoids ordering a second Internet line by mistake. +- Status tracking: Salesforce and WHMCS stay in sync (Created → Activating → Activated). + +## 8a) Behind the Scenes (Safe & Repeatable) +- Approvals: staff review orders in Salesforce; on approval, the Portal activates the order in WHMCS. +- Single sign‑on (SSO): the Portal uses expiring links to WHMCS for invoices and payments; we don’t handle card numbers directly. +- Clear errors: if something blocks activation (e.g., missing payment method), we pause and show a short, human‑readable note to staff and a clear status to the customer. + +## 9) If Something Goes Wrong +- The order is paused with a clear status (e.g., “Awaiting payment method”). +- Staff sees a short error code and message in Salesforce to resolve quickly. +- Customers keep seeing clear status updates in the Portal. + +## 10) Why This Is Better +- For customers: Clear catalog, simple checkout, easy invoice payments. +- For staff: Single review step in Salesforce, automated activation, fewer manual tasks. +- For the business: Consistent pricing, faster time‑to‑activate, fewer errors. + +--- + +# Suggested Slide Deck (Titles + Bullets + Notes) + +1. Title — “Customer Portal: How It Works” + - Bullets: One place to buy, manage, and pay. + - Notes: We connect Salesforce (control) and WHMCS (billing). + +2. The Systems + - Bullets: Salesforce = control; WHMCS = billing; Portal = bridge. + - Notes: Keep the mental model: control center vs. billing system. + +3. Big Picture Flow + - Bullets: Choose → Order in Salesforce → Approve → Activate in WHMCS → Pay. + - Notes: Emphasize approvals happen in Salesforce; invoices in WHMCS. + +4. The Catalog + - Bullets: Products come from Salesforce; only approved offers appear. + - Notes: Ensures one source of truth for products and prices. + +5. Customer Journey + - Bullets: Sign up → Add payment → Choose plan → Order → Approve → Activate → Pay. + - Notes: This is what a typical customer sees end‑to‑end. + +6. Ordering Checks + - Bullets: Payment method required; valid items only; no duplicates. + - Notes: Prevents surprises and support tickets later. + +7. Billing & Payments + - Bullets: Invoices from WHMCS; pay via secure SSO; subscriptions visible. + - Notes: We never store card numbers in the Portal. + +8. Status & Errors + - Bullets: Clear statuses; short error messages for staff. + - Notes: Faster turnaround and fewer escalations. + +9. Benefits + - Bullets: Better customer experience; less manual work; consistent pricing. + - Notes: Close with impact on activation time and error reduction. + +10. Q&A + - Bullets: — + - Notes: Keep backup slides with examples/screenshots. + +--- + +Tip: Pair these slides with a simple swimlane diagram (Customer, Portal, Salesforce, WHMCS) showing the hand‑offs at order and activation. + +References for deeper reading (optional for presenters) +- Address flow details: docs/ADDRESS_SYSTEM.md +- Technical overview: docs/PORTAL-INTEGRATION-OVERVIEW.md + +Diagram (PNG/SVG for slides) +- Swimlane visual: docs/assets/portal-swimlane.svg + +--- + +## 11) Migration (Moving Existing WHMCS Users Into the Portal) + +Goal: Let existing customers with WHMCS billing accounts start using the new Portal without losing history. + +How it works (plain English): +- Check your email: if you already have a WHMCS billing account, choose “Link account”. +- One-time check: enter your current WHMCS password once. We only verify it directly with WHMCS to prove ownership — we don’t copy it. +- Auto‑linking: we read your Customer Number from WHMCS and find the matching Salesforce account. Then we create your Portal account and link all three IDs. +- Set a new Portal password: we ask you to create a new password for the Portal (your WHMCS password stays in WHMCS). +- After that: log in with your new Portal password; continue to manage payment methods and invoices via secure SSO into WHMCS. + +Why setting a NEW Portal password is better than reusing the old one: +- Separation of risk: WHMCS and the Portal are different systems. Separate passwords reduce the blast radius if one is compromised. +- Stronger policy & protections: the Portal enforces modern hashing, lockouts, and audit logs tailored to our app. We don’t control WHMCS’s password rules. +- Least privilege: the Portal never stores or proxies your WHMCS password. We only validate it once during linking, then discard it. +- Future flexibility: lets us improve Portal security (e.g., MFA, rotation rules) without affecting WHMCS. +- Clear SSO flow: customers use the Portal to reach WHMCS billing pages securely without sharing credentials. + +Simple talking points for the migration slide: +- “Link your existing billing account by confirming your current password once.” +- “Create a new Portal password — safer, independent, and future‑proof.” +- “Nothing is lost — subscriptions and invoices automatically appear once linked.” diff --git a/docs/assets/portal-swimlane-1600x900.png b/docs/assets/portal-swimlane-1600x900.png new file mode 100644 index 00000000..b7739d98 Binary files /dev/null and b/docs/assets/portal-swimlane-1600x900.png differ diff --git a/docs/assets/portal-swimlane-1920x1080.png b/docs/assets/portal-swimlane-1920x1080.png new file mode 100644 index 00000000..34100417 Binary files /dev/null and b/docs/assets/portal-swimlane-1920x1080.png differ diff --git a/docs/assets/portal-swimlane.svg b/docs/assets/portal-swimlane.svg new file mode 100644 index 00000000..6562ba8f --- /dev/null +++ b/docs/assets/portal-swimlane.svg @@ -0,0 +1,122 @@ + + + + + + + + + + + + + + + + + + + + + Customer + Portal (BFF) + Salesforce + WHMCS + + + + 1) Sign Up & Link Account + Provide Customer Number + + + 2) Add Payment Method + Secure SSO to WHMCS + + + 3) Browse Catalog + Select Plan & Add‑ons + + + 7) View Invoices + Pay via SSO + + + + Validate mapping & address + Create WHMCS client + + + Open WHMCS payment page + (SSO) + + + Create Order in SF + (snapshot address) + + + Provision in WHMCS + after approval + + + Show subs & invoices + + + + Review & Approve + Order + + + + Store Payment Methods + + + Create Services & Invoice + + + + + Customer Number + + + + + SSO Payment Page + + + + Selected items + + + + Order + address + + + + Approved + + + + AddOrder + Accept + + + + Subscriptions + Invoices + + + + + + + Pay Invoice (SSO) + + + Legend: SSO = secure single sign-on; SF = Salesforce + diff --git a/package.json b/package.json index 50f89b7f..fd5ca72b 100644 --- a/package.json +++ b/package.json @@ -59,6 +59,7 @@ "globals": "^16.3.0", "husky": "^9.1.7", "prettier": "^3.6.2", + "sharp": "^0.34.3", "typescript": "^5.9.2", "typescript-eslint": "^8.40.0" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6198365a..167a99f5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -39,6 +39,9 @@ importers: prettier: specifier: ^3.6.2 version: 3.6.2 + sharp: + specifier: ^0.34.3 + version: 0.34.3 typescript: specifier: ^5.9.2 version: 5.9.2 @@ -7298,13 +7301,11 @@ snapshots: dependencies: color-name: 1.1.4 simple-swizzle: 0.2.2 - optional: true color@4.2.3: dependencies: color-convert: 2.0.1 color-string: 1.9.1 - optional: true colorette@2.0.20: {} @@ -8418,8 +8419,7 @@ snapshots: is-arrayish@0.2.1: {} - is-arrayish@0.3.2: - optional: true + is-arrayish@0.3.2: {} is-async-function@2.1.1: dependencies: @@ -9977,7 +9977,6 @@ snapshots: '@img/sharp-win32-arm64': 0.34.3 '@img/sharp-win32-ia32': 0.34.3 '@img/sharp-win32-x64': 0.34.3 - optional: true shebang-command@2.0.0: dependencies: @@ -10020,7 +10019,6 @@ snapshots: simple-swizzle@0.2.2: dependencies: is-arrayish: 0.3.2 - optional: true slash@3.0.0: {} diff --git a/scripts/tools/svg2png.mjs b/scripts/tools/svg2png.mjs new file mode 100644 index 00000000..3cd433d7 --- /dev/null +++ b/scripts/tools/svg2png.mjs @@ -0,0 +1,42 @@ +#!/usr/bin/env node +/** + * Simple SVG -> PNG converter using sharp. + * Usage: node svg2png.mjs + */ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import sharp from 'sharp'; + +async function main() { + const [,, inPath, outPath, wArg, hArg] = process.argv; + if (!inPath || !outPath || !wArg || !hArg) { + console.error('Usage: node svg2png.mjs '); + process.exit(1); + } + const width = Number(wArg); + const height = Number(hArg); + if (!width || !height) { + console.error('Width and height must be numbers'); + process.exit(1); + } + + const absIn = path.resolve(inPath); + const absOut = path.resolve(outPath); + const svg = await fs.readFile(absIn); + + // Render with background white to avoid transparency issues in slides + const png = await sharp(svg, { density: 300 }) + .resize(width, height, { fit: 'contain', background: { r: 255, g: 255, b: 255, alpha: 1 } }) + .png({ compressionLevel: 9 }) + .toBuffer(); + + await fs.mkdir(path.dirname(absOut), { recursive: true }); + await fs.writeFile(absOut, png); + console.log(`Wrote ${absOut} (${width}x${height})`); +} + +main().catch((err) => { + console.error('svg2png failed:', err?.message || err); + process.exit(1); +}); +