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,
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<typeof subscriptionInvoiceQuerySchema>;
@Controller("subscriptions")
export class SubscriptionsController {
constructor(
@ -75,24 +80,14 @@ export class SubscriptionsController {
@Request() req: RequestWithUser,
@Param("id", ParseIntPipe) subscriptionId: number
): Promise<Subscription> {
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<InvoiceList> {
if (subscriptionId <= 0) {
throw new BadRequestException("Subscription ID must be a positive number");
}
return this.subscriptionsService.getSubscriptionInvoices(req.user.id, subscriptionId, query);
}

View File

@ -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;
}

View File

@ -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<typeof whmcsCustomFieldSchema>;
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(),
});