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.
This commit is contained in:
barsa 2025-10-21 17:52:23 +09:00
parent 7cefee4c75
commit 82bb590023
3 changed files with 81 additions and 44 deletions

View File

@ -22,11 +22,9 @@ import {
subscriptionQuerySchema, subscriptionQuerySchema,
type SubscriptionQuery, type SubscriptionQuery,
} from "@customer-portal/domain/subscriptions"; } from "@customer-portal/domain/subscriptions";
import { import { InvoiceList } from "@customer-portal/domain/billing";
InvoiceList, import { createPaginationSchema } from "@customer-portal/domain/toolkit/validation/helpers";
invoiceListQuerySchema, import type { z } from "zod";
type InvoiceListQuery,
} from "@customer-portal/domain/billing";
import { import {
simTopupRequestSchema, simTopupRequestSchema,
simChangePlanRequestSchema, simChangePlanRequestSchema,
@ -43,6 +41,13 @@ import {
import { ZodValidationPipe } from "@bff/core/validation"; import { ZodValidationPipe } from "@bff/core/validation";
import type { RequestWithUser } from "@bff/modules/auth/auth.types"; import type { RequestWithUser } from "@bff/modules/auth/auth.types";
const subscriptionInvoiceQuerySchema = createPaginationSchema({
defaultLimit: 10,
maxLimit: 100,
minLimit: 1,
});
type SubscriptionInvoiceQuery = z.infer<typeof subscriptionInvoiceQuerySchema>;
@Controller("subscriptions") @Controller("subscriptions")
export class SubscriptionsController { export class SubscriptionsController {
constructor( constructor(
@ -75,24 +80,14 @@ export class SubscriptionsController {
@Request() req: RequestWithUser, @Request() req: RequestWithUser,
@Param("id", ParseIntPipe) subscriptionId: number @Param("id", ParseIntPipe) subscriptionId: number
): Promise<Subscription> { ): Promise<Subscription> {
if (subscriptionId <= 0) {
throw new BadRequestException("Subscription ID must be a positive number");
}
return this.subscriptionsService.getSubscriptionById(req.user.id, subscriptionId); return this.subscriptionsService.getSubscriptionById(req.user.id, subscriptionId);
} }
@Get(":id/invoices") @Get(":id/invoices")
@UsePipes(new ZodValidationPipe(invoiceListQuerySchema))
async getSubscriptionInvoices( async getSubscriptionInvoices(
@Request() req: RequestWithUser, @Request() req: RequestWithUser,
@Param("id", ParseIntPipe) subscriptionId: number, @Param("id", ParseIntPipe) subscriptionId: number,
@Query() query: InvoiceListQuery @Query(new ZodValidationPipe(subscriptionInvoiceQuerySchema)) query: SubscriptionInvoiceQuery
): Promise<InvoiceList> { ): Promise<InvoiceList> {
if (subscriptionId <= 0) {
throw new BadRequestException("Subscription ID must be a positive number");
}
return this.subscriptionsService.getSubscriptionInvoices(req.user.id, subscriptionId, query); return this.subscriptionsService.getSubscriptionInvoices(req.user.id, subscriptionId, query);
} }

View File

@ -1,5 +1,5 @@
import { getErrorMessage } from "@bff/core/utils/error.util"; import { getErrorMessage } from "@bff/core/utils/error.util";
import { Injectable, NotFoundException, Inject } from "@nestjs/common"; import { Injectable, NotFoundException, Inject, BadRequestException } from "@nestjs/common";
import { import {
Subscription, Subscription,
SubscriptionList, SubscriptionList,
@ -82,7 +82,7 @@ export class SubscriptionsService {
try { try {
// Validate subscription ID // Validate subscription ID
if (!subscriptionId || subscriptionId < 1) { 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 // Get WHMCS client ID from user mapping
@ -111,7 +111,7 @@ export class SubscriptionsService {
error: getErrorMessage(error), error: getErrorMessage(error),
}); });
if (error instanceof NotFoundException) { if (error instanceof NotFoundException || error instanceof BadRequestException) {
throw error; throw error;
} }

View File

@ -8,6 +8,42 @@
import { z } from "zod"; 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 // Request Parameter Types
// ============================================================================ // ============================================================================
@ -44,25 +80,25 @@ export const whmcsCustomFieldsContainerSchema = z.object({
// Raw WHMCS Product/Service (Subscription) // Raw WHMCS Product/Service (Subscription)
export const whmcsProductRawSchema = z.object({ export const whmcsProductRawSchema = z.object({
id: z.number(), id: normalizeRequiredNumber,
clientid: z.number(), clientid: normalizeRequiredNumber,
serviceid: z.number().optional(), serviceid: normalizeOptionalNumber,
pid: z.number().optional(), pid: normalizeOptionalNumber,
orderid: z.number().optional(), orderid: normalizeOptionalNumber,
ordernumber: z.string().optional(), ordernumber: optionalStringField(),
regdate: z.string(), regdate: z.string(),
name: z.string(), name: z.string(),
translated_name: z.string().optional(), translated_name: z.string().optional(),
groupname: z.string().optional(), groupname: z.string().optional(),
translated_groupname: z.string().optional(), translated_groupname: z.string().optional(),
domain: z.string().optional(), domain: optionalStringField(),
dedicatedip: z.string().optional(), dedicatedip: optionalStringField(),
serverid: z.number().optional(), serverid: normalizeOptionalNumber,
servername: z.string().optional(), servername: optionalStringField(),
serverip: z.string().optional(), serverip: optionalStringField(),
serverhostname: z.string().optional(), serverhostname: optionalStringField(),
suspensionreason: z.string().optional(), suspensionreason: z.string().optional(),
promoid: z.number().optional(), promoid: normalizeOptionalNumber,
subscriptionid: z.string().optional(), subscriptionid: z.string().optional(),
// Pricing // Pricing
@ -84,10 +120,10 @@ export const whmcsProductRawSchema = z.object({
// Notes // Notes
notes: z.string().optional(), notes: z.string().optional(),
diskusage: z.number().optional(), diskusage: normalizeOptionalNumber,
disklimit: z.number().optional(), disklimit: normalizeOptionalNumber,
bwusage: z.number().optional(), bwusage: normalizeOptionalNumber,
bwlimit: z.number().optional(), bwlimit: normalizeOptionalNumber,
lastupdate: z.string().optional(), lastupdate: z.string().optional(),
// Custom fields // Custom fields
@ -115,15 +151,21 @@ export type WhmcsCustomField = z.infer<typeof whmcsCustomFieldSchema>;
export const whmcsProductListResponseSchema = z.object({ export const whmcsProductListResponseSchema = z.object({
result: z.enum(["success", "error"]).optional(), result: z.enum(["success", "error"]).optional(),
message: z.string().optional(), message: z.string().optional(),
clientid: z.number().optional(), clientid: z.union([z.number(), z.string()]).optional(),
serviceid: z.number().optional(), serviceid: z.union([z.number(), z.string(), z.null()]).optional(),
pid: z.number().optional(), pid: z.union([z.number(), z.string(), z.null()]).optional(),
domain: z.string().optional(), domain: z.string().nullable().optional(),
totalresults: z.number().optional(), totalresults: z.union([z.number(), z.string()]).optional(),
startnumber: z.number().optional(), startnumber: normalizeOptionalNumber,
numreturned: z.number().optional(), numreturned: normalizeOptionalNumber,
products: z.object({ 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(), }).optional(),
}); });