diff --git a/apps/bff/src/integrations/whmcs/connection/services/whmcs-http-client.service.ts b/apps/bff/src/integrations/whmcs/connection/services/whmcs-http-client.service.ts index 7bcecada..b8673fc5 100644 --- a/apps/bff/src/integrations/whmcs/connection/services/whmcs-http-client.service.ts +++ b/apps/bff/src/integrations/whmcs/connection/services/whmcs-http-client.service.ts @@ -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(responseText, action, params); diff --git a/apps/bff/src/modules/notifications/notifications.controller.ts b/apps/bff/src/modules/notifications/notifications.controller.ts index 247f04ff..49c7fe45 100644 --- a/apps/bff/src/modules/notifications/notifications.controller.ts +++ b/apps/bff/src/modules/notifications/notifications.controller.ts @@ -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 { 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 { 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 { await this.notificationService.dismiss(notificationId, req.user.id); return { success: true }; } diff --git a/apps/bff/src/modules/subscriptions/sim-management/services/esim-management.service.ts b/apps/bff/src/modules/subscriptions/sim-management/services/esim-management.service.ts index 26a94908..4a2fc125 100644 --- a/apps/bff/src/modules/subscriptions/sim-management/services/esim-management.service.ts +++ b/apps/bff/src/modules/subscriptions/sim-management/services/esim-management.service.ts @@ -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 { const { account } = await this.simValidation.validateSimSubscription(userId, subscriptionId); const simDetails = await this.freebitService.getSimDetails(account); diff --git a/apps/bff/src/modules/subscriptions/sim-management/services/sim-call-history.service.ts b/apps/bff/src/modules/subscriptions/sim-management/services/sim-call-history.service.ts index a4877ef0..de3df57b 100644 --- a/apps/bff/src/modules/subscriptions/sim-management/services/sim-call-history.service.ts +++ b/apps/bff/src/modules/subscriptions/sim-management/services/sim-call-history.service.ts @@ -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 { + ): Promise { // 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 { + ): Promise { // 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 { + ): Promise { // Validate subscription ownership await this.simValidation.validateSimSubscription(userId, subscriptionId); // Dev/testing mode: call history data is currently sourced from a fixed account. diff --git a/apps/bff/src/modules/subscriptions/sim-management/services/sim-cancellation.service.ts b/apps/bff/src/modules/subscriptions/sim-management/services/sim-cancellation.service.ts index c7caadb9..85c013fa 100644 --- a/apps/bff/src/modules/subscriptions/sim-management/services/sim-cancellation.service.ts +++ b/apps/bff/src/modules/subscriptions/sim-management/services/sim-cancellation.service.ts @@ -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 { + ): Promise { const validation = await this.simValidation.validateSimSubscription(userId, subscriptionId); const simDetails = await this.freebitService.getSimDetails(validation.account); diff --git a/apps/bff/src/modules/subscriptions/sim-management/services/sim-plan.service.ts b/apps/bff/src/modules/subscriptions/sim-management/services/sim-plan.service.ts index b2395014..d95d763b 100644 --- a/apps/bff/src/modules/subscriptions/sim-management/services/sim-plan.service.ts +++ b/apps/bff/src/modules/subscriptions/sim-management/services/sim-plan.service.ts @@ -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 = { @@ -33,11 +33,6 @@ const FREEBIT_PLAN_CODE_TO_SKU: Record = 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 { + async getAvailablePlans(userId: string, subscriptionId: number): Promise { 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 { diff --git a/apps/bff/src/modules/subscriptions/subscriptions.controller.ts b/apps/bff/src/modules/subscriptions/subscriptions.controller.ts index edff7d72..dda9dbe3 100644 --- a/apps/bff/src/modules/subscriptions/subscriptions.controller.ts +++ b/apps/bff/src/modules/subscriptions/subscriptions.controller.ts @@ -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> { 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> { 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 { 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> { 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> { 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> { const pageNum = parseInt(page || "1", 10); const limitNum = parseInt(limit || "50", 10); diff --git a/apps/bff/src/modules/support/support.controller.ts b/apps/bff/src/modules/support/support.controller.ts index ecbe8cf2..e2fc41c8 100644 --- a/apps/bff/src/modules/support/support.controller.ts +++ b/apps/bff/src/modules/support/support.controller.ts @@ -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 { this.logger.log("Public contact form submission", { emailHash: hashEmailForLogs(body.email) }); try { diff --git a/apps/portal/src/features/notifications/services/notification.service.ts b/apps/portal/src/features/notifications/services/notification.service.ts index 55fa98a0..a36e935c 100644 --- a/apps/portal/src/features/notifications/services/notification.service.ts +++ b/apps/portal/src/features/notifications/services/notification.service.ts @@ -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 { - await apiClient.POST<{ success: boolean }>(`${BASE_PATH}/${notificationId}/read`); + await apiClient.POST(`${BASE_PATH}/${notificationId}/read`); }, /** * Mark all notifications as read */ async markAllAsRead(): Promise { - await apiClient.POST<{ success: boolean }>(`${BASE_PATH}/read-all`); + await apiClient.POST(`${BASE_PATH}/read-all`); }, /** * Dismiss a notification */ async dismiss(notificationId: string): Promise { - await apiClient.POST<{ success: boolean }>(`${BASE_PATH}/${notificationId}/dismiss`); + await apiClient.POST(`${BASE_PATH}/${notificationId}/dismiss`); }, }; diff --git a/apps/portal/src/features/subscriptions/services/internet-actions.service.ts b/apps/portal/src/features/subscriptions/services/internet-actions.service.ts index 324e6a90..8d61acb4 100644 --- a/apps/portal/src/features/subscriptions/services/internet-actions.service.ts +++ b/apps/portal/src/features/subscriptions/services/internet-actions.service.ts @@ -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 { - const response = await apiClient.GET<{ - success: boolean; - data: InternetCancellationPreview; - }>("/api/subscriptions/{subscriptionId}/internet/cancellation-preview", { - params: { path: { subscriptionId } }, - }); + async getCancellationPreview(subscriptionId: string): Promise { + const response = await apiClient.GET>( + "/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; }, /** diff --git a/apps/portal/src/features/subscriptions/services/sim-actions.service.ts b/apps/portal/src/features/subscriptions/services/sim-actions.service.ts index c43eb8f1..00baca40 100644 --- a/apps/portal/src/features/subscriptions/services/sim-actions.service.ts +++ b/apps/portal/src/features/subscriptions/services/sim-actions.service.ts @@ -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 { 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( + "/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 { - const response = await apiClient.GET<{ success: boolean; data: AvailablePlan[] }>( + async getAvailablePlans(subscriptionId: string): Promise { + const response = await apiClient.GET>( "/api/subscriptions/{subscriptionId}/sim/available-plans", { params: { path: { subscriptionId } }, } ); - return response.data?.data || []; + return response.data?.data ?? []; }, - async getCancellationPreview(subscriptionId: string): Promise { - const response = await apiClient.GET<{ success: boolean; data: CancellationPreview }>( + async getCancellationPreview(subscriptionId: string): Promise { + const response = await apiClient.GET>( "/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 { + async reissueSim(subscriptionId: string, request: SimReissueFullRequest): Promise { 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 { + ): Promise { const params: Record = {}; 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>( "/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 { + ): Promise { const params: Record = {}; 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>( + "/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 { + ): Promise { const params: Record = {}; 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>( "/api/subscriptions/{subscriptionId}/sim/sms-history", { params: { path: { subscriptionId }, query: params }, } ); - return response.data?.data || null; + return response.data?.data ?? null; }, async getAvailableHistoryMonths(): Promise { - const response = await apiClient.GET<{ success: boolean; data: string[] }>( + const response = await apiClient.GET>( "/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; -} diff --git a/apps/portal/src/features/subscriptions/views/InternetCancel.tsx b/apps/portal/src/features/subscriptions/views/InternetCancel.tsx index d478c104..d3d36ff3 100644 --- a/apps/portal/src/features/subscriptions/views/InternetCancel.tsx +++ b/apps/portal/src/features/subscriptions/views/InternetCancel.tsx @@ -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(1); const [loading, setLoading] = useState(false); - const [preview, setPreview] = useState(null); + const [preview, setPreview] = useState(null); const [error, setError] = useState(null); const [message, setMessage] = useState(null); const [acceptTerms, setAcceptTerms] = useState(false); diff --git a/apps/portal/src/features/subscriptions/views/SimCallHistory.tsx b/apps/portal/src/features/subscriptions/views/SimCallHistory.tsx index b4f38d6f..9a736979 100644 --- a/apps/portal/src/features/subscriptions/views/SimCallHistory.tsx +++ b/apps/portal/src/features/subscriptions/views/SimCallHistory.tsx @@ -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(null); // Data states - const [domesticData, setDomesticData] = useState(null); + const [domesticData, setDomesticData] = useState(null); const [internationalData, setInternationalData] = - useState(null); - const [smsData, setSmsData] = useState(null); + useState(null); + const [smsData, setSmsData] = useState(null); // Pagination states const [domesticPage, setDomesticPage] = useState(1); diff --git a/apps/portal/src/features/subscriptions/views/SimCancel.tsx b/apps/portal/src/features/subscriptions/views/SimCancel.tsx index 35cace87..90ebb658 100644 --- a/apps/portal/src/features/subscriptions/views/SimCancel.tsx +++ b/apps/portal/src/features/subscriptions/views/SimCancel.tsx @@ -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(1); const [loading, setLoading] = useState(false); - const [preview, setPreview] = useState(null); + const [preview, setPreview] = useState(null); const [error, setError] = useState(null); const [message, setMessage] = useState(null); const [acceptTerms, setAcceptTerms] = useState(false); diff --git a/apps/portal/src/features/subscriptions/views/SimChangePlan.tsx b/apps/portal/src/features/subscriptions/views/SimChangePlan.tsx index 42c948dd..a29ddefd 100644 --- a/apps/portal/src/features/subscriptions/views/SimChangePlan.tsx +++ b/apps/portal/src/features/subscriptions/views/SimChangePlan.tsx @@ -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([]); - const [selectedPlan, setSelectedPlan] = useState(null); + const [plans, setPlans] = useState([]); + const [selectedPlan, setSelectedPlan] = useState(null); const [assignGlobalIp, setAssignGlobalIp] = useState(false); const [message, setMessage] = useState(null); const [error, setError] = useState(null); diff --git a/apps/portal/src/features/subscriptions/views/SimReissue.tsx b/apps/portal/src/features/subscriptions/views/SimReissue.tsx index 3cf6e5c0..d020b79a 100644 --- a/apps/portal/src/features/subscriptions/views/SimReissue.tsx +++ b/apps/portal/src/features/subscriptions/views/SimReissue.tsx @@ -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") { diff --git a/packages/domain/common/schema.ts b/packages/domain/common/schema.ts index 82da0f41..a3a26ada 100644 --- a/packages/domain/common/schema.ts +++ b/packages/domain/common/schema.ts @@ -101,6 +101,21 @@ export const apiSuccessResponseSchema = (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 */ diff --git a/packages/domain/common/types.ts b/packages/domain/common/types.ts index a6720d7e..1fe21647 100644 --- a/packages/domain/common/types.ts +++ b/packages/domain/common/types.ts @@ -48,6 +48,23 @@ export interface ApiSuccessResponse { 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: { diff --git a/packages/domain/sim/index.ts b/packages/domain/sim/index.ts index 36a0385f..dd52c154 100644 --- a/packages/domain/sim/index.ts +++ b/packages/domain/sim/index.ts @@ -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, diff --git a/packages/domain/sim/schema.ts b/packages/domain/sim/schema.ts index 164d42fc..7be63574 100644 --- a/packages/domain/sim/schema.ts +++ b/packages/domain/sim/schema.ts @@ -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; + +/** + * 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; + +/** + * 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; + +/** + * 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; + +// ============================================================================ +// 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; + +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; + +export const simDomesticCallHistoryResponseSchema = z.object({ + calls: z.array(simDomesticCallRecordSchema), + pagination: simCallHistoryPaginationSchema, + month: simHistoryMonthSchema, +}); + +export type SimDomesticCallHistoryResponse = z.infer; + +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; + +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; + +export const simSmsHistoryResponseSchema = z.object({ + messages: z.array(simSmsRecordSchema), + pagination: simCallHistoryPaginationSchema, + month: simHistoryMonthSchema, +}); + +export type SimSmsHistoryResponse = z.infer; + // Enhanced cancellation request with more details export const simCancelFullRequestSchema = z .object({ diff --git a/packages/domain/subscriptions/schema.ts b/packages/domain/subscriptions/schema.ts index f2aae916..4ec38b8d 100644 --- a/packages/domain/subscriptions/schema.ts +++ b/packages/domain/subscriptions/schema.ts @@ -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(), }); // ============================================================================