From 82bb590023ce3aa5bce987a8143dafab8bea2916 Mon Sep 17 00:00:00 2001 From: barsa Date: Tue, 21 Oct 2025 17:52:23 +0900 Subject: [PATCH] Refactor subscriptions controller and service to enhance validation and error handling - Introduced a new pagination schema for subscription invoices to improve query validation. - Updated the subscriptions controller to utilize the new schema for invoice queries, ensuring better data integrity. - Enhanced error handling in the subscriptions service by replacing generic error messages with specific BadRequestException for invalid subscription IDs. - Refactored WHMCS raw types to implement normalization functions for better data validation and consistency. --- .../subscriptions/subscriptions.controller.ts | 27 +++--- .../subscriptions/subscriptions.service.ts | 6 +- .../providers/whmcs/raw.types.ts | 92 ++++++++++++++----- 3 files changed, 81 insertions(+), 44 deletions(-) diff --git a/apps/bff/src/modules/subscriptions/subscriptions.controller.ts b/apps/bff/src/modules/subscriptions/subscriptions.controller.ts index a6fe8d4b..1742a77a 100644 --- a/apps/bff/src/modules/subscriptions/subscriptions.controller.ts +++ b/apps/bff/src/modules/subscriptions/subscriptions.controller.ts @@ -22,11 +22,9 @@ import { subscriptionQuerySchema, type SubscriptionQuery, } from "@customer-portal/domain/subscriptions"; -import { - InvoiceList, - invoiceListQuerySchema, - type InvoiceListQuery, -} from "@customer-portal/domain/billing"; +import { InvoiceList } from "@customer-portal/domain/billing"; +import { createPaginationSchema } from "@customer-portal/domain/toolkit/validation/helpers"; +import type { z } from "zod"; import { simTopupRequestSchema, simChangePlanRequestSchema, @@ -43,6 +41,13 @@ import { import { ZodValidationPipe } from "@bff/core/validation"; import type { RequestWithUser } from "@bff/modules/auth/auth.types"; +const subscriptionInvoiceQuerySchema = createPaginationSchema({ + defaultLimit: 10, + maxLimit: 100, + minLimit: 1, +}); +type SubscriptionInvoiceQuery = z.infer; + @Controller("subscriptions") export class SubscriptionsController { constructor( @@ -75,24 +80,14 @@ export class SubscriptionsController { @Request() req: RequestWithUser, @Param("id", ParseIntPipe) subscriptionId: number ): Promise { - if (subscriptionId <= 0) { - throw new BadRequestException("Subscription ID must be a positive number"); - } - return this.subscriptionsService.getSubscriptionById(req.user.id, subscriptionId); } - @Get(":id/invoices") - @UsePipes(new ZodValidationPipe(invoiceListQuerySchema)) async getSubscriptionInvoices( @Request() req: RequestWithUser, @Param("id", ParseIntPipe) subscriptionId: number, - @Query() query: InvoiceListQuery + @Query(new ZodValidationPipe(subscriptionInvoiceQuerySchema)) query: SubscriptionInvoiceQuery ): Promise { - if (subscriptionId <= 0) { - throw new BadRequestException("Subscription ID must be a positive number"); - } - return this.subscriptionsService.getSubscriptionInvoices(req.user.id, subscriptionId, query); } diff --git a/apps/bff/src/modules/subscriptions/subscriptions.service.ts b/apps/bff/src/modules/subscriptions/subscriptions.service.ts index 77a42487..4b828823 100644 --- a/apps/bff/src/modules/subscriptions/subscriptions.service.ts +++ b/apps/bff/src/modules/subscriptions/subscriptions.service.ts @@ -1,5 +1,5 @@ import { getErrorMessage } from "@bff/core/utils/error.util"; -import { Injectable, NotFoundException, Inject } from "@nestjs/common"; +import { Injectable, NotFoundException, Inject, BadRequestException } from "@nestjs/common"; import { Subscription, SubscriptionList, @@ -82,7 +82,7 @@ export class SubscriptionsService { try { // Validate subscription ID if (!subscriptionId || subscriptionId < 1) { - throw new Error("Invalid subscription ID"); + throw new BadRequestException("Subscription ID must be a positive number"); } // Get WHMCS client ID from user mapping @@ -111,7 +111,7 @@ export class SubscriptionsService { error: getErrorMessage(error), }); - if (error instanceof NotFoundException) { + if (error instanceof NotFoundException || error instanceof BadRequestException) { throw error; } diff --git a/packages/domain/subscriptions/providers/whmcs/raw.types.ts b/packages/domain/subscriptions/providers/whmcs/raw.types.ts index a3218453..7ccf7cce 100644 --- a/packages/domain/subscriptions/providers/whmcs/raw.types.ts +++ b/packages/domain/subscriptions/providers/whmcs/raw.types.ts @@ -8,6 +8,42 @@ import { z } from "zod"; +const normalizeRequiredNumber = z.preprocess( + value => { + if (typeof value === "number") return value; + if (typeof value === "string" && value.trim().length > 0) { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : value; + } + return value; + }, + z.number() +); + +const normalizeOptionalNumber = z.preprocess( + value => { + if (value === undefined || value === null || value === "") return undefined; + if (typeof value === "number") return value; + if (typeof value === "string") { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : undefined; + } + return undefined; + }, + z.number().optional() +); + +const optionalStringField = () => + z + .union([z.string(), z.number()]) + .optional() + .nullable() + .transform(value => { + if (value === undefined || value === null) return undefined; + const text = String(value).trim(); + return text.length > 0 ? text : undefined; + }); + // ============================================================================ // Request Parameter Types // ============================================================================ @@ -44,25 +80,25 @@ export const whmcsCustomFieldsContainerSchema = z.object({ // Raw WHMCS Product/Service (Subscription) export const whmcsProductRawSchema = z.object({ - id: z.number(), - clientid: z.number(), - serviceid: z.number().optional(), - pid: z.number().optional(), - orderid: z.number().optional(), - ordernumber: z.string().optional(), + id: normalizeRequiredNumber, + clientid: normalizeRequiredNumber, + serviceid: normalizeOptionalNumber, + pid: normalizeOptionalNumber, + orderid: normalizeOptionalNumber, + ordernumber: optionalStringField(), regdate: z.string(), name: z.string(), translated_name: z.string().optional(), groupname: z.string().optional(), translated_groupname: z.string().optional(), - domain: z.string().optional(), - dedicatedip: z.string().optional(), - serverid: z.number().optional(), - servername: z.string().optional(), - serverip: z.string().optional(), - serverhostname: z.string().optional(), + domain: optionalStringField(), + dedicatedip: optionalStringField(), + serverid: normalizeOptionalNumber, + servername: optionalStringField(), + serverip: optionalStringField(), + serverhostname: optionalStringField(), suspensionreason: z.string().optional(), - promoid: z.number().optional(), + promoid: normalizeOptionalNumber, subscriptionid: z.string().optional(), // Pricing @@ -84,10 +120,10 @@ export const whmcsProductRawSchema = z.object({ // Notes notes: z.string().optional(), - diskusage: z.number().optional(), - disklimit: z.number().optional(), - bwusage: z.number().optional(), - bwlimit: z.number().optional(), + diskusage: normalizeOptionalNumber, + disklimit: normalizeOptionalNumber, + bwusage: normalizeOptionalNumber, + bwlimit: normalizeOptionalNumber, lastupdate: z.string().optional(), // Custom fields @@ -115,15 +151,21 @@ export type WhmcsCustomField = z.infer; export const whmcsProductListResponseSchema = z.object({ result: z.enum(["success", "error"]).optional(), message: z.string().optional(), - clientid: z.number().optional(), - serviceid: z.number().optional(), - pid: z.number().optional(), - domain: z.string().optional(), - totalresults: z.number().optional(), - startnumber: z.number().optional(), - numreturned: z.number().optional(), + clientid: z.union([z.number(), z.string()]).optional(), + serviceid: z.union([z.number(), z.string(), z.null()]).optional(), + pid: z.union([z.number(), z.string(), z.null()]).optional(), + domain: z.string().nullable().optional(), + totalresults: z.union([z.number(), z.string()]).optional(), + startnumber: normalizeOptionalNumber, + numreturned: normalizeOptionalNumber, products: z.object({ - product: z.union([whmcsProductRawSchema, z.array(whmcsProductRawSchema)]).optional(), + product: z + .preprocess(value => { + if (value === null || value === undefined || value === "") { + return undefined; + } + return value; + }, z.union([whmcsProductRawSchema, z.array(whmcsProductRawSchema)]).optional()) }).optional(), });