Refactor WHMCS HTTP Client Error Handling and Update API Response Types

- Modified error handling in WhmcsHttpClientService to avoid exposing sensitive response bodies in error messages, logging snippets only in development mode.
- Updated NotificationsController and SubscriptionsController to use ApiSuccessAckResponse for success responses, enhancing type safety and consistency.
- Refactored various service methods to return more specific response types, including ApiSuccessResponse for subscription-related actions and improved SIM management services.
- Cleaned up unused interfaces and types in the SIM management services, streamlining the codebase and improving maintainability.
This commit is contained in:
barsa 2025-12-26 10:30:09 +09:00
parent b1ff1e8fd3
commit 851207b401
21 changed files with 337 additions and 286 deletions

View File

@ -151,10 +151,21 @@ export class WhmcsHttpClientService {
const responseText = await response.text();
if (!response.ok) {
const snippet = responseText?.slice(0, 300);
throw new Error(
`HTTP ${response.status}: ${response.statusText}${snippet ? ` | Body: ${snippet}` : ""}`
);
// Do NOT include response body in thrown error messages (could contain sensitive/PII and
// would propagate into unified exception logs). If needed, emit a short snippet only in dev.
if (process.env.NODE_ENV !== "production") {
const snippet = responseText?.slice(0, 300);
if (snippet) {
this.logger.debug(`WHMCS non-OK response body snippet [${action}]`, {
action,
status: response.status,
statusText: response.statusText,
snippet,
});
}
}
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
return this.parseResponse<T>(responseText, action, params);

View File

@ -20,6 +20,7 @@ import { RateLimit, RateLimitGuard } from "@bff/core/rate-limiting/index.js";
import type { RequestWithUser } from "@bff/modules/auth/auth.types.js";
import { NotificationService } from "./notifications.service.js";
import type { NotificationListResponse } from "@customer-portal/domain/notifications";
import type { ApiSuccessAckResponse } from "@customer-portal/domain/common";
@Controller("notifications")
@UseGuards(RateLimitGuard)
@ -63,7 +64,7 @@ export class NotificationsController {
async markAsRead(
@Req() req: RequestWithUser,
@Param("id") notificationId: string
): Promise<{ success: boolean }> {
): Promise<ApiSuccessAckResponse> {
await this.notificationService.markAsRead(notificationId, req.user.id);
return { success: true };
}
@ -73,7 +74,7 @@ export class NotificationsController {
*/
@Post("read-all")
@RateLimit({ limit: 10, ttl: 60 })
async markAllAsRead(@Req() req: RequestWithUser): Promise<{ success: boolean }> {
async markAllAsRead(@Req() req: RequestWithUser): Promise<ApiSuccessAckResponse> {
await this.notificationService.markAllAsRead(req.user.id);
return { success: true };
}
@ -86,7 +87,7 @@ export class NotificationsController {
async dismiss(
@Req() req: RequestWithUser,
@Param("id") notificationId: string
): Promise<{ success: boolean }> {
): Promise<ApiSuccessAckResponse> {
await this.notificationService.dismiss(notificationId, req.user.id);
return { success: true };
}

View File

@ -8,12 +8,7 @@ import { SimValidationService } from "./sim-validation.service.js";
import { SimNotificationService } from "./sim-notification.service.js";
import { SimApiNotificationService } from "./sim-api-notification.service.js";
import { getErrorMessage } from "@bff/core/utils/error.util.js";
import type { SimReissueRequest } from "@customer-portal/domain/sim";
export interface ReissueSimRequest {
simType: "physical" | "esim";
newEid?: string;
}
import type { SimReissueRequest, SimReissueFullRequest } from "@customer-portal/domain/sim";
@Injectable()
export class EsimManagementService {
@ -99,7 +94,7 @@ export class EsimManagementService {
async reissueSim(
userId: string,
subscriptionId: number,
request: ReissueSimRequest
request: SimReissueFullRequest
): Promise<void> {
const { account } = await this.simValidation.validateSimSubscription(userId, subscriptionId);
const simDetails = await this.freebitService.getSimDetails(account);

View File

@ -3,6 +3,11 @@ import { Logger } from "nestjs-pino";
import { PrismaService } from "@bff/infra/database/prisma.service.js";
import { SftpClientService } from "@bff/integrations/sftp/sftp-client.service.js";
import { SimValidationService } from "./sim-validation.service.js";
import type {
SimDomesticCallHistoryResponse,
SimInternationalCallHistoryResponse,
SimSmsHistoryResponse,
} from "@customer-portal/domain/sim";
// SmsType enum to match Prisma schema
type SmsType = "DOMESTIC" | "INTERNATIONAL";
@ -46,45 +51,6 @@ export interface CallHistoryPagination {
totalPages: number;
}
export interface DomesticCallHistoryResponse {
calls: Array<{
id: string;
date: string;
time: string;
calledTo: string;
callLength: string; // Formatted as "Xh Xm Xs"
callCharge: number;
}>;
pagination: CallHistoryPagination;
month: string;
}
export interface InternationalCallHistoryResponse {
calls: Array<{
id: string;
date: string;
startTime: string;
stopTime: string | null;
country: string | null;
calledTo: string;
callCharge: number;
}>;
pagination: CallHistoryPagination;
month: string;
}
export interface SmsHistoryResponse {
messages: Array<{
id: string;
date: string;
time: string;
sentTo: string;
type: string;
}>;
pagination: CallHistoryPagination;
month: string;
}
@Injectable()
export class SimCallHistoryService {
constructor(
@ -410,7 +376,7 @@ export class SimCallHistoryService {
month?: string,
page: number = 1,
limit: number = 50
): Promise<DomesticCallHistoryResponse> {
): Promise<SimDomesticCallHistoryResponse> {
// Validate subscription ownership
await this.simValidation.validateSimSubscription(userId, subscriptionId);
// Dev/testing mode: call history data is currently sourced from a fixed account.
@ -475,7 +441,7 @@ export class SimCallHistoryService {
month?: string,
page: number = 1,
limit: number = 50
): Promise<InternationalCallHistoryResponse> {
): Promise<SimInternationalCallHistoryResponse> {
// Validate subscription ownership
await this.simValidation.validateSimSubscription(userId, subscriptionId);
// Dev/testing mode: call history data is currently sourced from a fixed account.
@ -542,7 +508,7 @@ export class SimCallHistoryService {
month?: string,
page: number = 1,
limit: number = 50
): Promise<SmsHistoryResponse> {
): Promise<SimSmsHistoryResponse> {
// Validate subscription ownership
await this.simValidation.validateSimSubscription(userId, subscriptionId);
// Dev/testing mode: call history data is currently sourced from a fixed account.

View File

@ -5,31 +5,18 @@ import { FreebitOrchestratorService } from "@bff/integrations/freebit/services/f
import { WhmcsService } from "@bff/integrations/whmcs/whmcs.service.js";
import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js";
import { SimValidationService } from "./sim-validation.service.js";
import type { SimCancelRequest, SimCancelFullRequest } from "@customer-portal/domain/sim";
import type {
SimCancelRequest,
SimCancelFullRequest,
SimCancellationMonth,
SimCancellationPreview,
} from "@customer-portal/domain/sim";
import { SimScheduleService } from "./sim-schedule.service.js";
import { SimActionRunnerService } from "./sim-action-runner.service.js";
import { SimApiNotificationService } from "./sim-api-notification.service.js";
import { NotificationService } from "@bff/modules/notifications/notifications.service.js";
import { NOTIFICATION_SOURCE, NOTIFICATION_TYPE } from "@customer-portal/domain/notifications";
export interface CancellationMonth {
value: string; // YYYY-MM format
label: string; // Display label like "November 2025"
runDate: string; // YYYYMMDD format for API (1st of next month)
}
export interface CancellationPreview {
simNumber: string;
serialNumber?: string;
planCode: string;
startDate?: string;
minimumContractEndDate?: string;
isWithinMinimumTerm: boolean;
availableMonths: CancellationMonth[];
customerEmail: string;
customerName: string;
}
@Injectable()
export class SimCancellationService {
constructor(
@ -52,8 +39,8 @@ export class SimCancellationService {
/**
* Generate available cancellation months (next 12 months)
*/
private generateCancellationMonths(): CancellationMonth[] {
const months: CancellationMonth[] = [];
private generateCancellationMonths(): SimCancellationMonth[] {
const months: SimCancellationMonth[] = [];
const today = new Date();
const dayOfMonth = today.getDate();
@ -108,7 +95,7 @@ export class SimCancellationService {
async getCancellationPreview(
userId: string,
subscriptionId: number
): Promise<CancellationPreview> {
): Promise<SimCancellationPreview> {
const validation = await this.simValidation.validateSimSubscription(userId, subscriptionId);
const simDetails = await this.freebitService.getSimDetails(validation.account);

View File

@ -7,13 +7,13 @@ import type {
SimPlanChangeRequest,
SimFeaturesUpdateRequest,
SimChangePlanFullRequest,
SimAvailablePlan,
} from "@customer-portal/domain/sim";
import { SimScheduleService } from "./sim-schedule.service.js";
import { SimActionRunnerService } from "./sim-action-runner.service.js";
import { SimManagementQueueService } from "../queue/sim-management.queue.js";
import { SimApiNotificationService } from "./sim-api-notification.service.js";
import { SimServicesService } from "@bff/modules/services/services/sim-services.service.js";
import type { SimCatalogProduct } from "@customer-portal/domain/services";
// Mapping from Salesforce SKU to Freebit plan code
const SKU_TO_FREEBIT_PLAN_CODE: Record<string, string> = {
@ -33,11 +33,6 @@ const FREEBIT_PLAN_CODE_TO_SKU: Record<string, string> = Object.fromEntries(
Object.entries(SKU_TO_FREEBIT_PLAN_CODE).map(([sku, code]) => [code, sku])
);
export interface AvailablePlan extends SimCatalogProduct {
freebitPlanCode: string;
isCurrentPlan: boolean;
}
@Injectable()
export class SimPlanService {
constructor(
@ -60,7 +55,7 @@ export class SimPlanService {
* Get available plans for plan change
* Filters by current plan type (e.g., only show DataSmsVoice plans if current is DataSmsVoice)
*/
async getAvailablePlans(userId: string, subscriptionId: number): Promise<AvailablePlan[]> {
async getAvailablePlans(userId: string, subscriptionId: number): Promise<SimAvailablePlan[]> {
const validation = await this.simValidation.validateSimSubscription(userId, subscriptionId);
const simDetails = await this.freebitService.getSimDetails(validation.account);
const currentPlanCode = simDetails.planCode;
@ -81,7 +76,7 @@ export class SimPlanService {
? allPlans.filter(p => p.simPlanType === currentPlanType)
: allPlans.filter(p => !p.simHasFamilyDiscount); // Default: non-family plans
// Map to AvailablePlan with Freebit codes
// Map to SimAvailablePlan with Freebit codes
return filteredPlans.map(plan => {
const freebitPlanCode = SKU_TO_FREEBIT_PLAN_CODE[plan.sku] || plan.sku;
return {

View File

@ -27,6 +27,7 @@ import type {
SubscriptionQuery,
} from "@customer-portal/domain/subscriptions";
import type { InvoiceList } from "@customer-portal/domain/billing";
import type { ApiSuccessResponse } from "@customer-portal/domain/common";
import { createPaginationSchema } from "@customer-portal/domain/toolkit/validation/helpers";
import type { z } from "zod";
import {
@ -36,22 +37,26 @@ import {
simFeaturesRequestSchema,
simCancelFullRequestSchema,
simChangePlanFullRequestSchema,
simReissueFullRequestSchema,
type SimTopupRequest,
type SimChangePlanRequest,
type SimCancelRequest,
type SimFeaturesRequest,
type SimCancelFullRequest,
type SimChangePlanFullRequest,
type SimAvailablePlan,
type SimCancellationPreview,
type SimDomesticCallHistoryResponse,
type SimInternationalCallHistoryResponse,
type SimSmsHistoryResponse,
type SimReissueFullRequest,
} from "@customer-portal/domain/sim";
import { ZodValidationPipe } from "nestjs-zod";
import type { RequestWithUser } from "@bff/modules/auth/auth.types.js";
import { SimPlanService } from "./sim-management/services/sim-plan.service.js";
import { SimCancellationService } from "./sim-management/services/sim-cancellation.service.js";
import { AdminGuard } from "@bff/core/security/guards/admin.guard.js";
import {
EsimManagementService,
type ReissueSimRequest,
} from "./sim-management/services/esim-management.service.js";
import { EsimManagementService } from "./sim-management/services/esim-management.service.js";
import { SimCallHistoryService } from "./sim-management/services/sim-call-history.service.js";
import { InternetCancellationService } from "./internet-management/services/internet-cancellation.service.js";
import {
@ -313,7 +318,7 @@ export class SubscriptionsController {
async getAvailablePlans(
@Request() req: RequestWithUser,
@Param("id", ParseIntPipe) subscriptionId: number
) {
): Promise<ApiSuccessResponse<SimAvailablePlan[]>> {
const plans = await this.simPlanService.getAvailablePlans(req.user.id, subscriptionId);
return { success: true, data: plans };
}
@ -344,7 +349,7 @@ export class SubscriptionsController {
async getCancellationPreview(
@Request() req: RequestWithUser,
@Param("id", ParseIntPipe) subscriptionId: number
) {
): Promise<ApiSuccessResponse<SimCancellationPreview>> {
const preview = await this.simCancellationService.getCancellationPreview(
req.user.id,
subscriptionId
@ -373,10 +378,11 @@ export class SubscriptionsController {
* Reissue SIM (both eSIM and physical SIM)
*/
@Post(":id/sim/reissue")
@UsePipes(new ZodValidationPipe(simReissueFullRequestSchema))
async reissueSim(
@Request() req: RequestWithUser,
@Param("id", ParseIntPipe) subscriptionId: number,
@Body() body: ReissueSimRequest
@Body() body: SimReissueFullRequest
): Promise<SimActionResponse> {
await this.esimManagementService.reissueSim(req.user.id, subscriptionId, body);
@ -438,7 +444,7 @@ export class SubscriptionsController {
@Query("month") month?: string,
@Query("page") page?: string,
@Query("limit") limit?: string
) {
): Promise<ApiSuccessResponse<SimDomesticCallHistoryResponse>> {
const pageNum = parseInt(page || "1", 10);
const limitNum = parseInt(limit || "50", 10);
@ -470,7 +476,7 @@ export class SubscriptionsController {
@Query("month") month?: string,
@Query("page") page?: string,
@Query("limit") limit?: string
) {
): Promise<ApiSuccessResponse<SimInternationalCallHistoryResponse>> {
const pageNum = parseInt(page || "1", 10);
const limitNum = parseInt(limit || "50", 10);
@ -502,7 +508,7 @@ export class SubscriptionsController {
@Query("month") month?: string,
@Query("page") page?: string,
@Query("limit") limit?: string
) {
): Promise<ApiSuccessResponse<SimSmsHistoryResponse>> {
const pageNum = parseInt(page || "1", 10);
const limitNum = parseInt(limit || "50", 10);

View File

@ -27,6 +27,7 @@ import {
} from "@customer-portal/domain/support";
import type { RequestWithUser } from "@bff/modules/auth/auth.types.js";
import { hashEmailForLogs } from "./support.logging.js";
import type { ApiSuccessMessageResponse } from "@customer-portal/domain/common";
@Controller("support")
export class SupportController {
@ -74,7 +75,7 @@ export class SupportController {
async publicContact(
@Body(new ZodValidationPipe(publicContactRequestSchema))
body: PublicContactRequest
): Promise<{ success: boolean; message: string }> {
): Promise<ApiSuccessMessageResponse> {
this.logger.log("Public contact form submission", { emailHash: hashEmailForLogs(body.email) });
try {

View File

@ -5,6 +5,7 @@
*/
import { apiClient, getDataOrThrow } from "@/lib/api";
import type { ApiSuccessAckResponse } from "@customer-portal/domain/common";
import type { NotificationListResponse } from "@customer-portal/domain/notifications";
const BASE_PATH = "/api/notifications";
@ -42,20 +43,20 @@ export const notificationService = {
* Mark a notification as read
*/
async markAsRead(notificationId: string): Promise<void> {
await apiClient.POST<{ success: boolean }>(`${BASE_PATH}/${notificationId}/read`);
await apiClient.POST<ApiSuccessAckResponse>(`${BASE_PATH}/${notificationId}/read`);
},
/**
* Mark all notifications as read
*/
async markAllAsRead(): Promise<void> {
await apiClient.POST<{ success: boolean }>(`${BASE_PATH}/read-all`);
await apiClient.POST<ApiSuccessAckResponse>(`${BASE_PATH}/read-all`);
},
/**
* Dismiss a notification
*/
async dismiss(notificationId: string): Promise<void> {
await apiClient.POST<{ success: boolean }>(`${BASE_PATH}/${notificationId}/dismiss`);
await apiClient.POST<ApiSuccessAckResponse>(`${BASE_PATH}/${notificationId}/dismiss`);
},
};

View File

@ -1,43 +1,28 @@
import { apiClient } from "@/lib/api";
import { apiClient, getDataOrThrow } from "@/lib/api";
import type {
InternetCancellationPreview,
InternetCancelRequest,
InternetCancellationPreview,
} from "@customer-portal/domain/subscriptions";
export interface InternetCancellationMonth {
value: string;
label: string;
}
export interface InternetCancellationPreviewResponse {
productName: string;
billingAmount: number;
nextDueDate?: string;
registrationDate?: string;
availableMonths: InternetCancellationMonth[];
customerEmail: string;
customerName: string;
}
import type { ApiSuccessResponse } from "@customer-portal/domain/common";
export const internetActionsService = {
/**
* Get cancellation preview (available months, service details)
*/
async getCancellationPreview(
subscriptionId: string
): Promise<InternetCancellationPreviewResponse> {
const response = await apiClient.GET<{
success: boolean;
data: InternetCancellationPreview;
}>("/api/subscriptions/{subscriptionId}/internet/cancellation-preview", {
params: { path: { subscriptionId } },
});
async getCancellationPreview(subscriptionId: string): Promise<InternetCancellationPreview> {
const response = await apiClient.GET<ApiSuccessResponse<InternetCancellationPreview>>(
"/api/subscriptions/{subscriptionId}/internet/cancellation-preview",
{
params: { path: { subscriptionId } },
}
);
if (!response.data?.data) {
const payload = getDataOrThrow(response, "Failed to load cancellation information");
if (!payload.data) {
throw new Error("Failed to load cancellation information");
}
return response.data.data;
return payload.data;
},
/**

View File

@ -1,15 +1,23 @@
import { apiClient } from "@/lib/api";
import type { ApiSuccessResponse } from "@customer-portal/domain/common";
import {
simInfoSchema,
type SimAvailablePlan,
type SimInfo,
type SimCancelFullRequest,
type SimChangePlanFullRequest,
type SimCancellationPreview,
type SimDomesticCallHistoryResponse,
type SimInternationalCallHistoryResponse,
type SimSmsHistoryResponse,
type SimReissueFullRequest,
} from "@customer-portal/domain/sim";
import type {
SimTopUpRequest,
SimPlanChangeRequest,
SimCancelRequest,
} from "@customer-portal/domain/sim";
import type { SimPlanChangeResult } from "@customer-portal/domain/subscriptions";
// Types imported from domain - no duplication
// Domain schemas provide validation rules:
@ -17,41 +25,6 @@ import type {
// - SimPlanChangeRequest: newPlanCode, assignGlobalIp, scheduledAt (YYYYMMDD format)
// - SimCancelRequest: scheduledAt (YYYYMMDD format)
export interface AvailablePlan {
id: string;
name: string;
sku: string;
description?: string;
monthlyPrice?: number;
simDataSize?: string;
simPlanType?: string;
freebitPlanCode: string;
isCurrentPlan: boolean;
}
export interface CancellationMonth {
value: string;
label: string;
runDate: string;
}
export interface CancellationPreview {
simNumber: string;
serialNumber?: string;
planCode: string;
startDate?: string;
minimumContractEndDate?: string;
isWithinMinimumTerm: boolean;
availableMonths: CancellationMonth[];
customerEmail: string;
customerName: string;
}
export interface ReissueSimRequest {
simType: "physical" | "esim";
newEid?: string;
}
export const simActionsService = {
async topUp(subscriptionId: string, request: SimTopUpRequest): Promise<void> {
await apiClient.POST("/api/subscriptions/{subscriptionId}/sim/top-up", {
@ -71,14 +44,13 @@ export const simActionsService = {
subscriptionId: string,
request: SimChangePlanFullRequest
): Promise<{ scheduledAt?: string }> {
const response = await apiClient.POST<{
success: boolean;
message: string;
scheduledAt?: string;
}>("/api/subscriptions/{subscriptionId}/sim/change-plan-full", {
params: { path: { subscriptionId } },
body: request,
});
const response = await apiClient.POST<SimPlanChangeResult>(
"/api/subscriptions/{subscriptionId}/sim/change-plan-full",
{
params: { path: { subscriptionId } },
body: request,
}
);
return { scheduledAt: response.data?.scheduledAt };
},
@ -108,27 +80,27 @@ export const simActionsService = {
return simInfoSchema.parse(response.data);
},
async getAvailablePlans(subscriptionId: string): Promise<AvailablePlan[]> {
const response = await apiClient.GET<{ success: boolean; data: AvailablePlan[] }>(
async getAvailablePlans(subscriptionId: string): Promise<SimAvailablePlan[]> {
const response = await apiClient.GET<ApiSuccessResponse<SimAvailablePlan[]>>(
"/api/subscriptions/{subscriptionId}/sim/available-plans",
{
params: { path: { subscriptionId } },
}
);
return response.data?.data || [];
return response.data?.data ?? [];
},
async getCancellationPreview(subscriptionId: string): Promise<CancellationPreview | null> {
const response = await apiClient.GET<{ success: boolean; data: CancellationPreview }>(
async getCancellationPreview(subscriptionId: string): Promise<SimCancellationPreview | null> {
const response = await apiClient.GET<ApiSuccessResponse<SimCancellationPreview>>(
"/api/subscriptions/{subscriptionId}/sim/cancellation-preview",
{
params: { path: { subscriptionId } },
}
);
return response.data?.data || null;
return response.data?.data ?? null;
},
async reissueSim(subscriptionId: string, request: ReissueSimRequest): Promise<void> {
async reissueSim(subscriptionId: string, request: SimReissueFullRequest): Promise<void> {
await apiClient.POST("/api/subscriptions/{subscriptionId}/sim/reissue", {
params: { path: { subscriptionId } },
body: request,
@ -142,19 +114,19 @@ export const simActionsService = {
month?: string,
page: number = 1,
limit: number = 50
): Promise<DomesticCallHistoryResponse | null> {
): Promise<SimDomesticCallHistoryResponse | null> {
const params: Record<string, string> = {};
if (month) params.month = month;
params.page = String(page);
params.limit = String(limit);
const response = await apiClient.GET<{ success: boolean; data: DomesticCallHistoryResponse }>(
const response = await apiClient.GET<ApiSuccessResponse<SimDomesticCallHistoryResponse>>(
"/api/subscriptions/{subscriptionId}/sim/call-history/domestic",
{
params: { path: { subscriptionId }, query: params },
}
);
return response.data?.data || null;
return response.data?.data ?? null;
},
async getInternationalCallHistory(
@ -162,19 +134,19 @@ export const simActionsService = {
month?: string,
page: number = 1,
limit: number = 50
): Promise<InternationalCallHistoryResponse | null> {
): Promise<SimInternationalCallHistoryResponse | null> {
const params: Record<string, string> = {};
if (month) params.month = month;
params.page = String(page);
params.limit = String(limit);
const response = await apiClient.GET<{
success: boolean;
data: InternationalCallHistoryResponse;
}>("/api/subscriptions/{subscriptionId}/sim/call-history/international", {
params: { path: { subscriptionId }, query: params },
});
return response.data?.data || null;
const response = await apiClient.GET<ApiSuccessResponse<SimInternationalCallHistoryResponse>>(
"/api/subscriptions/{subscriptionId}/sim/call-history/international",
{
params: { path: { subscriptionId }, query: params },
}
);
return response.data?.data ?? null;
},
async getSmsHistory(
@ -182,79 +154,26 @@ export const simActionsService = {
month?: string,
page: number = 1,
limit: number = 50
): Promise<SmsHistoryResponse | null> {
): Promise<SimSmsHistoryResponse | null> {
const params: Record<string, string> = {};
if (month) params.month = month;
params.page = String(page);
params.limit = String(limit);
const response = await apiClient.GET<{ success: boolean; data: SmsHistoryResponse }>(
const response = await apiClient.GET<ApiSuccessResponse<SimSmsHistoryResponse>>(
"/api/subscriptions/{subscriptionId}/sim/sms-history",
{
params: { path: { subscriptionId }, query: params },
}
);
return response.data?.data || null;
return response.data?.data ?? null;
},
async getAvailableHistoryMonths(): Promise<string[]> {
const response = await apiClient.GET<{ success: boolean; data: string[] }>(
const response = await apiClient.GET<ApiSuccessResponse<string[]>>(
"/api/subscriptions/sim/call-history/available-months",
{}
);
return response.data?.data || [];
return response.data?.data ?? [];
},
};
// Additional types for call/SMS history
export interface CallHistoryPagination {
page: number;
limit: number;
total: number;
totalPages: number;
}
export interface DomesticCallRecord {
id: string;
date: string;
time: string;
calledTo: string;
callLength: string;
callCharge: number;
}
export interface DomesticCallHistoryResponse {
calls: DomesticCallRecord[];
pagination: CallHistoryPagination;
month: string;
}
export interface InternationalCallRecord {
id: string;
date: string;
startTime: string;
stopTime: string | null;
country: string | null;
calledTo: string;
callCharge: number;
}
export interface InternationalCallHistoryResponse {
calls: InternationalCallRecord[];
pagination: CallHistoryPagination;
month: string;
}
export interface SmsRecord {
id: string;
date: string;
time: string;
sentTo: string;
type: string;
}
export interface SmsHistoryResponse {
messages: SmsRecord[];
pagination: CallHistoryPagination;
month: string;
}

View File

@ -3,10 +3,8 @@
import Link from "next/link";
import { useParams, useRouter } from "next/navigation";
import { useEffect, useState, type ReactNode } from "react";
import {
internetActionsService,
type InternetCancellationPreviewResponse,
} from "@/features/subscriptions/services/internet-actions.service";
import { internetActionsService } from "@/features/subscriptions/services/internet-actions.service";
import type { InternetCancellationPreview } from "@customer-portal/domain/subscriptions";
import { PageLayout } from "@/components/templates/PageLayout";
import { SubCard } from "@/components/molecules/SubCard/SubCard";
import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner";
@ -40,7 +38,7 @@ export function InternetCancelContainer() {
const [step, setStep] = useState<Step>(1);
const [loading, setLoading] = useState(false);
const [preview, setPreview] = useState<InternetCancellationPreviewResponse | null>(null);
const [preview, setPreview] = useState<InternetCancellationPreview | null>(null);
const [error, setError] = useState<string | null>(null);
const [message, setMessage] = useState<string | null>(null);
const [acceptTerms, setAcceptTerms] = useState(false);

View File

@ -6,12 +6,12 @@ import { useParams } from "next/navigation";
import { PageLayout } from "@/components/templates/PageLayout";
import { SubCard } from "@/components/molecules/SubCard/SubCard";
import { PhoneIcon, GlobeAltIcon, ChatBubbleLeftIcon } from "@heroicons/react/24/outline";
import {
simActionsService,
type DomesticCallHistoryResponse,
type InternationalCallHistoryResponse,
type SmsHistoryResponse,
} from "@/features/subscriptions/services/sim-actions.service";
import { simActionsService } from "@/features/subscriptions/services/sim-actions.service";
import type {
SimDomesticCallHistoryResponse,
SimInternationalCallHistoryResponse,
SimSmsHistoryResponse,
} from "@customer-portal/domain/sim";
import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner";
import { Formatting } from "@customer-portal/domain/toolkit";
@ -64,10 +64,10 @@ export function SimCallHistoryContainer() {
const [error, setError] = useState<string | null>(null);
// Data states
const [domesticData, setDomesticData] = useState<DomesticCallHistoryResponse | null>(null);
const [domesticData, setDomesticData] = useState<SimDomesticCallHistoryResponse | null>(null);
const [internationalData, setInternationalData] =
useState<InternationalCallHistoryResponse | null>(null);
const [smsData, setSmsData] = useState<SmsHistoryResponse | null>(null);
useState<SimInternationalCallHistoryResponse | null>(null);
const [smsData, setSmsData] = useState<SimSmsHistoryResponse | null>(null);
// Pagination states
const [domesticPage, setDomesticPage] = useState(1);

View File

@ -3,10 +3,8 @@
import Link from "next/link";
import { useParams, useRouter } from "next/navigation";
import { useEffect, useState, type ReactNode } from "react";
import {
simActionsService,
type CancellationPreview,
} from "@/features/subscriptions/services/sim-actions.service";
import { simActionsService } from "@/features/subscriptions/services/sim-actions.service";
import type { SimCancellationPreview } from "@customer-portal/domain/sim";
import { PageLayout } from "@/components/templates/PageLayout";
import { SubCard } from "@/components/molecules/SubCard/SubCard";
import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner";
@ -40,7 +38,7 @@ export function SimCancelContainer() {
const [step, setStep] = useState<Step>(1);
const [loading, setLoading] = useState(false);
const [preview, setPreview] = useState<CancellationPreview | null>(null);
const [preview, setPreview] = useState<SimCancellationPreview | null>(null);
const [error, setError] = useState<string | null>(null);
const [message, setMessage] = useState<string | null>(null);
const [acceptTerms, setAcceptTerms] = useState(false);

View File

@ -6,10 +6,8 @@ import { useParams } from "next/navigation";
import { PageLayout } from "@/components/templates/PageLayout";
import { SubCard } from "@/components/molecules/SubCard/SubCard";
import { DevicePhoneMobileIcon, CheckCircleIcon } from "@heroicons/react/24/outline";
import {
simActionsService,
type AvailablePlan,
} from "@/features/subscriptions/services/sim-actions.service";
import { simActionsService } from "@/features/subscriptions/services/sim-actions.service";
import type { SimAvailablePlan } from "@customer-portal/domain/sim";
import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner";
import { Formatting } from "@customer-portal/domain/toolkit";
import { Button } from "@/components/atoms";
@ -19,8 +17,8 @@ const { formatCurrency } = Formatting;
export function SimChangePlanContainer() {
const params = useParams();
const subscriptionId = params.id as string;
const [plans, setPlans] = useState<AvailablePlan[]>([]);
const [selectedPlan, setSelectedPlan] = useState<AvailablePlan | null>(null);
const [plans, setPlans] = useState<SimAvailablePlan[]>([]);
const [selectedPlan, setSelectedPlan] = useState<SimAvailablePlan | null>(null);
const [assignGlobalIp, setAssignGlobalIp] = useState(false);
const [message, setMessage] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);

View File

@ -6,10 +6,8 @@ import { useParams } from "next/navigation";
import { PageLayout } from "@/components/templates/PageLayout";
import { SubCard } from "@/components/molecules/SubCard/SubCard";
import { DevicePhoneMobileIcon, DeviceTabletIcon, CpuChipIcon } from "@heroicons/react/24/outline";
import {
simActionsService,
type ReissueSimRequest,
} from "@/features/subscriptions/services/sim-actions.service";
import { simActionsService } from "@/features/subscriptions/services/sim-actions.service";
import type { SimReissueFullRequest } from "@customer-portal/domain/sim";
import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner";
import type { SimDetails } from "@/features/sim-management/components/SimDetailsCard";
import { Button } from "@/components/atoms";
@ -71,10 +69,8 @@ export function SimReissueContainer() {
setError(null);
try {
const request: ReissueSimRequest = {
simType,
...(simType === "esim" && { newEid: newEid.trim() }),
};
const request: SimReissueFullRequest =
simType === "esim" ? { simType, newEid: newEid.trim() } : { simType };
await simActionsService.reissueSim(subscriptionId, request);
if (simType === "esim") {

View File

@ -101,6 +101,21 @@ export const apiSuccessResponseSchema = <T extends z.ZodTypeAny>(dataSchema: T)
data: dataSchema,
});
/**
* Schema for successful API acknowledgements (no payload)
*/
export const apiSuccessAckResponseSchema = z.object({
success: z.literal(true),
});
/**
* Schema for successful API responses with a human-readable message (no data payload)
*/
export const apiSuccessMessageResponseSchema = z.object({
success: z.literal(true),
message: z.string(),
});
/**
* Schema for error API responses
*/

View File

@ -48,6 +48,23 @@ export interface ApiSuccessResponse<T> {
data: T;
}
/**
* Success acknowledgement response (no payload)
* Used for endpoints that simply confirm the operation succeeded.
*/
export interface ApiSuccessAckResponse {
success: true;
}
/**
* Success message response (no data payload)
* Used for endpoints that return a human-readable message (e.g., actions).
*/
export interface ApiSuccessMessageResponse {
success: true;
message: string;
}
export interface ApiErrorResponse {
success: false;
error: {

View File

@ -36,6 +36,18 @@ export type {
SimTopUpHistoryEntry,
SimTopUpHistory,
SimInfo,
// Portal-facing DTOs
SimAvailablePlan,
SimCancellationMonth,
SimCancellationPreview,
SimReissueFullRequest,
SimCallHistoryPagination,
SimDomesticCallRecord,
SimDomesticCallHistoryResponse,
SimInternationalCallRecord,
SimInternationalCallHistoryResponse,
SimSmsRecord,
SimSmsHistoryResponse,
// Request types
SimTopUpRequest,
SimPlanChangeRequest,

View File

@ -3,6 +3,7 @@
*/
import { z } from "zod";
import { simCatalogProductSchema } from "../services/schema.js";
export const simStatusSchema = z.enum(["active", "suspended", "cancelled", "pending"]);
@ -157,6 +158,154 @@ export const simReissueRequestSchema = z.object({
simType: z.enum(["physical", "esim"]).optional(),
});
// ============================================================================
// Portal-facing SIM DTOs (responses + request contracts)
// ============================================================================
/**
* Available plan for SIM plan change (portal-facing)
*
* This extends the catalog's SIM product shape with:
* - freebitPlanCode: the actual plan code used by Freebit
* - isCurrentPlan: whether this is the customer's current plan
*/
export const simAvailablePlanSchema = simCatalogProductSchema.extend({
freebitPlanCode: z.string(),
isCurrentPlan: z.boolean(),
});
export type SimAvailablePlan = z.infer<typeof simAvailablePlanSchema>;
/**
* Cancellation month option for SIM cancellation preview
*/
export const simCancellationMonthSchema = z.object({
value: z.string().regex(/^\d{4}-\d{2}$/, "Month must be in YYYY-MM format"),
label: z.string(),
runDate: z.string().regex(/^\d{8}$/, "runDate must be in YYYYMMDD format"),
});
export type SimCancellationMonth = z.infer<typeof simCancellationMonthSchema>;
/**
* SIM cancellation preview payload (portal-facing)
*/
export const simCancellationPreviewSchema = z.object({
simNumber: z.string(),
serialNumber: z.string().optional(),
planCode: z.string(),
startDate: z.string().optional(),
minimumContractEndDate: z.string().optional(),
isWithinMinimumTerm: z.boolean(),
availableMonths: z.array(simCancellationMonthSchema),
customerEmail: z.string(),
customerName: z.string(),
});
export type SimCancellationPreview = z.infer<typeof simCancellationPreviewSchema>;
/**
* Reissue SIM request (full flow) for `/api/subscriptions/:id/sim/reissue`
*
* - Physical SIM: simType="physical" (newEid optional/ignored)
* - eSIM: simType="esim" and newEid is required
*/
export const simReissueFullRequestSchema = z
.object({
simType: z.enum(["physical", "esim"]),
newEid: z
.string()
.regex(/^\d{32}$/, "EID must be exactly 32 digits")
.optional(),
})
.superRefine((data, ctx) => {
if (data.simType === "esim" && !data.newEid) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "newEid is required for eSIM reissue",
path: ["newEid"],
});
}
});
export type SimReissueFullRequest = z.infer<typeof simReissueFullRequestSchema>;
// ============================================================================
// SIM Call/SMS History (portal-facing)
// ============================================================================
const simHistoryMonthSchema = z.string().regex(/^\d{4}-\d{2}$/, "Month must be in YYYY-MM format");
const isoDateSchema = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Date must be in YYYY-MM-DD format");
const timeHmsSchema = z.string().regex(/^\d{2}:\d{2}:\d{2}$/, "Time must be in HH:MM:SS format");
export const simCallHistoryPaginationSchema = z.object({
page: z.number().int().min(1),
limit: z.number().int().min(1),
total: z.number().int().min(0),
totalPages: z.number().int().min(0),
});
export type SimCallHistoryPagination = z.infer<typeof simCallHistoryPaginationSchema>;
export const simDomesticCallRecordSchema = z.object({
id: z.string(),
date: isoDateSchema,
time: timeHmsSchema,
calledTo: z.string(),
callLength: z.string(),
callCharge: z.number(),
});
export type SimDomesticCallRecord = z.infer<typeof simDomesticCallRecordSchema>;
export const simDomesticCallHistoryResponseSchema = z.object({
calls: z.array(simDomesticCallRecordSchema),
pagination: simCallHistoryPaginationSchema,
month: simHistoryMonthSchema,
});
export type SimDomesticCallHistoryResponse = z.infer<typeof simDomesticCallHistoryResponseSchema>;
export const simInternationalCallRecordSchema = z.object({
id: z.string(),
date: isoDateSchema,
startTime: timeHmsSchema,
stopTime: z.string().nullable(),
country: z.string().nullable(),
calledTo: z.string(),
callCharge: z.number(),
});
export type SimInternationalCallRecord = z.infer<typeof simInternationalCallRecordSchema>;
export const simInternationalCallHistoryResponseSchema = z.object({
calls: z.array(simInternationalCallRecordSchema),
pagination: simCallHistoryPaginationSchema,
month: simHistoryMonthSchema,
});
export type SimInternationalCallHistoryResponse = z.infer<
typeof simInternationalCallHistoryResponseSchema
>;
export const simSmsRecordSchema = z.object({
id: z.string(),
date: isoDateSchema,
time: timeHmsSchema,
sentTo: z.string(),
type: z.string(),
});
export type SimSmsRecord = z.infer<typeof simSmsRecordSchema>;
export const simSmsHistoryResponseSchema = z.object({
messages: z.array(simSmsRecordSchema),
pagination: simCallHistoryPaginationSchema,
month: simHistoryMonthSchema,
});
export type SimSmsHistoryResponse = z.infer<typeof simSmsHistoryResponseSchema>;
// Enhanced cancellation request with more details
export const simCancelFullRequestSchema = z
.object({

View File

@ -5,6 +5,7 @@
*/
import { z } from "zod";
import { apiSuccessMessageResponseSchema } from "../common/schema.js";
// Subscription Status Schema
export const subscriptionStatusSchema = z.enum([
@ -92,20 +93,20 @@ export const subscriptionStatsSchema = z.object({
/**
* Schema for SIM action responses (top-up, cancellation, feature updates)
*/
export const simActionResponseSchema = z.object({
success: z.boolean(),
message: z.string(),
export const simActionResponseSchema = apiSuccessMessageResponseSchema.extend({
data: z.unknown().optional(),
});
/**
* Schema for SIM plan change result with IP addresses
*/
export const simPlanChangeResultSchema = z.object({
success: z.boolean(),
message: z.string(),
export const simPlanChangeResultSchema = apiSuccessMessageResponseSchema.extend({
ipv4: z.string().optional(),
ipv6: z.string().optional(),
scheduledAt: z
.string()
.regex(/^\d{8}$/, "Scheduled date must be in YYYYMMDD format")
.optional(),
});
// ============================================================================