Refactor SIM management services to unify SIM info retrieval and enhance type safety. Updated getSimInfo methods across services and controllers to return a structured SimInfo type, improving data consistency. Integrated validation schemas for better error handling and streamlined response parsing in API calls.

This commit is contained in:
barsa 2025-10-21 13:21:03 +09:00
parent 939922a40e
commit 2de2e8ec8a
22 changed files with 270 additions and 170 deletions

View File

@ -1,7 +1,7 @@
import { Injectable } from "@nestjs/common";
import { SimOrchestratorService } from "./sim-management/services/sim-orchestrator.service";
import { SimNotificationService } from "./sim-management/services/sim-notification.service";
import type { SimDetails, SimTopUpHistory, SimUsage } from "@customer-portal/domain/sim";
import type { SimDetails, SimInfo, SimTopUpHistory, SimUsage } from "@customer-portal/domain/sim";
import type {
SimTopUpRequest,
SimPlanChangeRequest,
@ -123,10 +123,7 @@ export class SimManagementService {
async getSimInfo(
userId: string,
subscriptionId: number
): Promise<{
details: SimDetails;
usage: SimUsage;
}> {
): Promise<SimInfo> {
return this.simOrchestrator.getSimInfo(userId, subscriptionId);
}

View File

@ -8,7 +8,8 @@ import { SimCancellationService } from "./sim-cancellation.service";
import { EsimManagementService } from "./esim-management.service";
import { SimValidationService } from "./sim-validation.service";
import { getErrorMessage } from "@bff/core/utils/error.util";
import type { SimDetails, SimTopUpHistory, SimUsage } from "@customer-portal/domain/sim";
import { simInfoSchema } from "@customer-portal/domain/sim";
import type { SimInfo, SimDetails, SimTopUpHistory, SimUsage } from "@customer-portal/domain/sim";
import type {
SimTopUpRequest,
SimPlanChangeRequest,
@ -113,10 +114,7 @@ export class SimOrchestratorService {
async getSimInfo(
userId: string,
subscriptionId: number
): Promise<{
details: SimDetails;
usage: SimUsage;
}> {
): Promise<SimInfo> {
try {
const [details, usage] = await Promise.all([
this.getSimDetails(userId, subscriptionId),
@ -141,7 +139,7 @@ export class SimOrchestratorService {
}
}
return { details, usage };
return simInfoSchema.parse({ details, usage });
} catch (error) {
const sanitizedError = getErrorMessage(error);
this.logger.error(`Failed to get comprehensive SIM info for subscription ${subscriptionId}`, {

View File

@ -33,6 +33,7 @@ import {
simCancelRequestSchema,
simFeaturesRequestSchema,
simReissueRequestSchema,
type SimInfo,
type SimTopupRequest,
type SimChangePlanRequest,
type SimCancelRequest,
@ -109,7 +110,7 @@ export class SubscriptionsController {
async getSimInfo(
@Request() req: RequestWithUser,
@Param("id", ParseIntPipe) subscriptionId: number
) {
): Promise<SimInfo> {
return this.simManagementService.getSimInfo(req.user.id, subscriptionId);
}

View File

@ -5,6 +5,7 @@ import {
SubscriptionList,
subscriptionListSchema,
subscriptionStatusSchema,
subscriptionStatsSchema,
type SubscriptionStatus,
} from "@customer-portal/domain/subscriptions";
import type { Invoice, InvoiceItem, InvoiceList } from "@customer-portal/domain/billing";
@ -201,7 +202,7 @@ export class SubscriptionsService {
this.logger.log(`Generated subscription stats for user ${userId}`, stats);
return stats;
return subscriptionStatsSchema.parse(stats);
} catch (error) {
this.logger.error(`Failed to generate subscription stats for user ${userId}`, {
error: getErrorMessage(error),

View File

@ -17,6 +17,7 @@ import {
import type { Subscription } from "@customer-portal/domain/subscriptions";
import type { Invoice } from "@customer-portal/domain/billing";
import type { Activity, DashboardSummary, NextInvoice } from "@customer-portal/domain/dashboard";
import { dashboardSummarySchema } from "@customer-portal/domain/dashboard";
import { WhmcsService } from "@bff/integrations/whmcs/whmcs.service";
import { SalesforceService } from "@bff/integrations/salesforce/salesforce.service";
import { MappingsService } from "@bff/modules/id-mappings/mappings.service";
@ -525,7 +526,7 @@ export class UsersService {
nextInvoice,
recentActivity,
};
return summary;
return dashboardSummarySchema.parse(summary);
} catch (error) {
this.logger.error(`Failed to get user summary for ${userId}`, {
error: getErrorMessage(error),

View File

@ -4,6 +4,7 @@ import { SubCard } from "@/components/molecules/SubCard/SubCard";
import { MapPinIcon, PencilIcon, CheckIcon, XMarkIcon } from "@heroicons/react/24/outline";
import { AddressForm, type AddressFormProps } from "@/features/catalog/components";
import type { Address } from "@customer-portal/domain/customer";
import { getCountryName } from "@/lib/constants/countries";
interface AddressCardProps {
address: Address;
@ -26,6 +27,9 @@ export function AddressCard({
onSave,
onAddressChange,
}: AddressCardProps) {
const countryLabel =
address.country ? getCountryName(address.country) ?? address.country : null;
return (
<SubCard>
<div className="pb-5 border-b border-gray-200">
@ -88,7 +92,7 @@ export function AddressCard({
{[address.city, address.state, address.postcode].filter(Boolean).join(", ")}
</p>
)}
{address.country && <p className="text-gray-600 font-medium">{address.country}</p>}
{countryLabel && <p className="text-gray-600 font-medium">{countryLabel}</p>}
</div>
)}
</div>

View File

@ -11,29 +11,7 @@ import { FormField } from "@/components/molecules/FormField/FormField";
import type { FormErrors, FormTouched, UseZodFormReturn } from "@customer-portal/validation";
import type { SignupFormValues } from "./SignupForm";
import type { Address } from "@customer-portal/domain/customer";
const COUNTRIES = [
{ code: "US", name: "United States" },
{ code: "CA", name: "Canada" },
{ code: "GB", name: "United Kingdom" },
{ code: "AU", name: "Australia" },
{ code: "DE", name: "Germany" },
{ code: "FR", name: "France" },
{ code: "IT", name: "Italy" },
{ code: "ES", name: "Spain" },
{ code: "NL", name: "Netherlands" },
{ code: "SE", name: "Sweden" },
{ code: "NO", name: "Norway" },
{ code: "DK", name: "Denmark" },
{ code: "FI", name: "Finland" },
{ code: "CH", name: "Switzerland" },
{ code: "AT", name: "Austria" },
{ code: "BE", name: "Belgium" },
{ code: "IE", name: "Ireland" },
{ code: "PT", name: "Portugal" },
{ code: "GR", name: "Greece" },
{ code: "JP", name: "Japan" },
];
import { COUNTRY_OPTIONS } from "@/lib/constants/countries";
interface AddressStepProps {
address: SignupFormValues["address"];
@ -53,7 +31,19 @@ export function AddressStep({
// Use domain Address type directly - no type helpers needed
const updateAddressField = useCallback(
(field: keyof Address, value: string) => {
onAddressChange({ ...address, [field]: value });
onAddressChange({ ...(address ?? {}), [field]: value });
},
[address, onAddressChange]
);
const handleCountryChange = useCallback(
(code: string) => {
const normalized = code || "";
onAddressChange({
...(address ?? {}),
country: normalized,
countryCode: normalized,
});
},
[address, onAddressChange]
);
@ -139,13 +129,13 @@ export function AddressStep({
<FormField label="Country" error={getFieldError("country")} required>
<select
value={address?.country || ""}
onChange={e => updateAddressField("country", e.target.value)}
onChange={e => handleCountryChange(e.target.value)}
onBlur={markTouched}
className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
>
<option value="">Select a country</option>
{COUNTRIES.map(country => (
<option key={country.code} value={country.name}>
{COUNTRY_OPTIONS.map(country => (
<option key={country.code} value={country.code}>
{country.name}
</option>
))}

View File

@ -17,6 +17,7 @@ import { MultiStepForm, type FormStep } from "./MultiStepForm";
import { AddressStep } from "./AddressStep";
import { PasswordStep } from "./PasswordStep";
import { PersonalStep } from "./PersonalStep";
import { getCountryCodeByName } from "@/lib/constants/countries";
interface SignupFormProps {
onSuccess?: () => void;
@ -64,10 +65,30 @@ export function SignupForm({
async ({ confirmPassword: _confirm, ...formData }: SignupFormValues) => {
clearError();
try {
const normalizeCountryCode = (value?: string) => {
if (!value) return "";
if (value.length === 2) return value.toUpperCase();
return getCountryCodeByName(value) ?? value;
};
const normalizedAddress = formData.address
? (() => {
const countryValue =
formData.address.country || formData.address.countryCode || "";
const normalizedCountry = normalizeCountryCode(countryValue);
return {
...formData.address,
country: normalizedCountry,
countryCode: normalizedCountry,
};
})()
: undefined;
// Remove confirmPassword and create SignupRequest
// The signup hook will handle API format transformation
const request: SignupRequest = {
...formData,
...(normalizedAddress ? { address: normalizedAddress } : {}),
// API expects both camelCase and snake_case (for WHMCS compatibility)
firstname: formData.firstName,
lastname: formData.lastName,
@ -112,6 +133,7 @@ export function SignupForm({
state: "",
postcode: "",
country: "",
countryCode: "",
},
nationality: "",
dateOfBirth: "",

View File

@ -16,6 +16,7 @@ import {
XMarkIcon,
ExclamationTriangleIcon,
} from "@heroicons/react/24/outline";
import { getCountryName } from "@/lib/constants/countries";
// Use canonical Address type from domain
import type { Address } from "@customer-portal/domain/customer";
@ -55,28 +56,35 @@ export function AddressConfirmation({
try {
setLoading(true);
const data = await accountService.getAddress();
const normalizedAddress = data
? {
...data,
country: data.country ?? "",
countryCode: data.countryCode ?? data.country ?? "",
}
: null;
const isComplete = !!(
data &&
data.address1 &&
data.city &&
data.state &&
data.postcode &&
data.country
normalizedAddress &&
normalizedAddress.address1 &&
normalizedAddress.city &&
normalizedAddress.state &&
normalizedAddress.postcode &&
normalizedAddress.country
);
setBillingInfo({
company: null,
email: "",
phone: null,
address: data,
address: normalizedAddress,
isComplete,
});
if (requiresAddressVerification) {
setAddressConfirmed(false);
onAddressIncomplete();
} else if (isComplete && data) {
onAddressConfirmed(data);
} else if (isComplete && normalizedAddress) {
onAddressConfirmed(normalizedAddress);
setAddressConfirmed(true);
} else {
onAddressIncomplete();
@ -107,6 +115,7 @@ export function AddressConfirmation({
state: "",
postcode: "",
country: "",
countryCode: "",
}
);
};
@ -142,6 +151,7 @@ export function AddressConfirmation({
state: editedAddress.state?.trim() || null,
postcode: editedAddress.postcode?.trim() || null,
country: editedAddress.country?.trim() || null,
countryCode: editedAddress.country?.trim() || null,
};
// Persist to server (WHMCS via BFF)
@ -231,6 +241,8 @@ export function AddressConfirmation({
if (!billingInfo) return null;
const address = billingInfo.address;
const countryLabel =
address?.country ? getCountryName(address.country) ?? address.country : null;
return wrap(
<>
@ -343,7 +355,10 @@ export function AddressConfirmation({
value={editedAddress?.country || ""}
onChange={e => {
setError(null);
setEditedAddress(prev => (prev ? { ...prev, country: e.target.value } : null));
const next = e.target.value;
setEditedAddress(prev =>
prev ? { ...prev, country: next, countryCode: next } : null
);
}}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
>
@ -388,7 +403,7 @@ export function AddressConfirmation({
{[address.city, address.state].filter(Boolean).join(", ")}
{address.postcode ? ` ${address.postcode}` : ""}
</p>
<p className="text-gray-600">{address.country}</p>
{countryLabel && <p className="text-gray-600">{countryLabel}</p>}
</div>
{/* Status message for Internet orders when pending */}

View File

@ -5,8 +5,12 @@
import { useQuery } from "@tanstack/react-query";
import { useAuthSession } from "@/features/auth/services/auth.store";
import { apiClient, queryKeys, getDataOrThrow } from "@/lib/api";
import type { DashboardSummary, DashboardError } from "@customer-portal/domain/dashboard";
import { apiClient, queryKeys } from "@/lib/api";
import {
dashboardSummarySchema,
type DashboardSummary,
type DashboardError,
} from "@customer-portal/domain/dashboard";
class DashboardDataError extends Error {
constructor(
@ -37,7 +41,21 @@ export function useDashboardSummary() {
try {
const response = await apiClient.GET<DashboardSummary>("/api/me/summary");
return getDataOrThrow<DashboardSummary>(response, "Dashboard summary response was empty");
if (!response.data) {
throw new DashboardDataError(
"FETCH_ERROR",
"Dashboard summary response was empty"
);
}
const parsed = dashboardSummarySchema.safeParse(response.data);
if (!parsed.success) {
throw new DashboardDataError(
"FETCH_ERROR",
"Dashboard summary response failed validation",
{ issues: parsed.error.issues }
);
}
return parsed.data;
} catch (error) {
// Transform API errors to DashboardError format
if (error instanceof Error) {

View File

@ -2,18 +2,7 @@
import React from "react";
import { ChartBarIcon, ExclamationTriangleIcon } from "@heroicons/react/24/outline";
export interface SimUsage {
account: string;
todayUsageKb: number;
todayUsageMb: number;
recentDaysUsage: Array<{
date: string;
usageKb: number;
usageMb: number;
}>;
isBlacklisted: boolean;
}
import type { SimUsage } from "@customer-portal/domain/sim";
interface DataUsageChartProps {
usage: SimUsage;

View File

@ -7,21 +7,16 @@ import {
ArrowPathIcon,
} from "@heroicons/react/24/outline";
import { SimDetailsCard } from "./SimDetailsCard";
import { DataUsageChart, type SimUsage } from "./DataUsageChart";
import { DataUsageChart } from "./DataUsageChart";
import { SimActions } from "./SimActions";
import { apiClient } from "@/lib/api";
import { SimFeatureToggles } from "./SimFeatureToggles";
import type { SimDetails } from "@customer-portal/domain/sim";
import { simInfoSchema, type SimInfo } from "@customer-portal/domain/sim";
interface SimManagementSectionProps {
subscriptionId: number;
}
interface SimInfo {
details: SimDetails;
usage: SimUsage;
}
export function SimManagementSection({ subscriptionId }: SimManagementSectionProps) {
const [simInfo, setSimInfo] = useState<SimInfo | null>(null);
const [loading, setLoading] = useState(true);
@ -31,16 +26,16 @@ export function SimManagementSection({ subscriptionId }: SimManagementSectionPro
try {
setError(null);
const response = await apiClient.GET("/api/subscriptions/{id}/sim", {
const response = await apiClient.GET<SimInfo>("/api/subscriptions/{id}/sim", {
params: { path: { id: subscriptionId } },
});
const payload = response.data as SimInfo | undefined;
if (!payload) {
if (!response.data) {
throw new Error("Failed to load SIM information");
}
const payload = simInfoSchema.parse(response.data);
setSimInfo(payload);
} catch (err: unknown) {
const hasStatus = (value: unknown): value is { status: number } =>

View File

@ -48,7 +48,7 @@ export function SimCancelContainer() {
useEffect(() => {
const fetchDetails = async () => {
try {
const info = await simActionsService.getSimInfo<SimDetails, unknown>(subscriptionId);
const info = await simActionsService.getSimInfo(subscriptionId);
setDetails(info?.details || null);
} catch (e: unknown) {
setError(e instanceof Error ? e.message : "Failed to load SIM details");

View File

@ -8,19 +8,17 @@ import { apiClient, queryKeys, getDataOrDefault, getDataOrThrow } from "@/lib/ap
import { getNullableData } from "@/lib/api/response-helpers";
import { useAuthSession } from "@/features/auth/services";
import type { InvoiceList } from "@customer-portal/domain/billing";
import type { Subscription, SubscriptionList } from "@customer-portal/domain/subscriptions";
import {
subscriptionStatsSchema,
type Subscription,
type SubscriptionList,
type SubscriptionStats,
} from "@customer-portal/domain/subscriptions";
interface UseSubscriptionsOptions {
status?: string;
}
const emptyStats = {
total: 0,
active: 0,
completed: 0,
cancelled: 0,
};
const emptyInvoiceList: InvoiceList = {
invoices: [],
pagination: {
@ -76,11 +74,14 @@ export function useActiveSubscriptions() {
export function useSubscriptionStats() {
const { isAuthenticated } = useAuthSession();
return useQuery({
return useQuery<SubscriptionStats>({
queryKey: queryKeys.subscriptions.stats(),
queryFn: async () => {
const response = await apiClient.GET<typeof emptyStats>("/api/subscriptions/stats");
return getDataOrThrow<typeof emptyStats>(response, "Failed to load subscription statistics");
const response = await apiClient.GET<SubscriptionStats>("/api/subscriptions/stats");
if (!response.data) {
throw new Error("Subscription statistics response was empty");
}
return subscriptionStatsSchema.parse(response.data);
},
staleTime: 5 * 60 * 1000,
gcTime: 10 * 60 * 1000,

View File

@ -1,4 +1,5 @@
import { apiClient, getDataOrDefault } from "@/lib/api";
import { apiClient } from "@/lib/api";
import { simInfoSchema, type SimInfo } from "@customer-portal/domain/sim";
import type {
SimTopUpRequest,
SimPlanChangeRequest,
@ -11,11 +12,6 @@ import type {
// - SimPlanChangeRequest: newPlanCode, assignGlobalIp, scheduledAt (YYYYMMDD format)
// - SimCancelRequest: scheduledAt (YYYYMMDD format)
export interface SimInfo<T, E = unknown> {
details: T;
error?: E;
}
export const simActionsService = {
async topUp(subscriptionId: string, request: SimTopUpRequest): Promise<void> {
await apiClient.POST("/api/subscriptions/{subscriptionId}/sim/top-up", {
@ -38,13 +34,15 @@ export const simActionsService = {
});
},
async getSimInfo<T, E = unknown>(subscriptionId: string): Promise<SimInfo<T, E> | null> {
const response = await apiClient.GET<SimInfo<T, E> | null>(
"/api/subscriptions/{subscriptionId}/sim/info",
{
params: { path: { subscriptionId } },
}
);
return getDataOrDefault<SimInfo<T, E> | null>(response, null);
async getSimInfo(subscriptionId: string): Promise<SimInfo | null> {
const response = await apiClient.GET<SimInfo>("/api/subscriptions/{subscriptionId}/sim", {
params: { path: { subscriptionId } },
});
if (!response.data) {
return null;
}
return simInfoSchema.parse(response.data);
},
};

View File

@ -48,7 +48,7 @@ export function SimCancelContainer() {
useEffect(() => {
const fetchDetails = async () => {
try {
const info = await simActionsService.getSimInfo<SimDetails, unknown>(subscriptionId);
const info = await simActionsService.getSimInfo(subscriptionId);
setDetails(info?.details || null);
} catch (e: unknown) {
setError(e instanceof Error ? e.message : "Failed to load SIM details");

View File

@ -0,0 +1,42 @@
export interface CountryOption {
code: string;
name: string;
}
export const COUNTRY_OPTIONS: CountryOption[] = [
{ code: "US", name: "United States" },
{ code: "CA", name: "Canada" },
{ code: "GB", name: "United Kingdom" },
{ code: "AU", name: "Australia" },
{ code: "DE", name: "Germany" },
{ code: "FR", name: "France" },
{ code: "IT", name: "Italy" },
{ code: "ES", name: "Spain" },
{ code: "NL", name: "Netherlands" },
{ code: "SE", name: "Sweden" },
{ code: "NO", name: "Norway" },
{ code: "DK", name: "Denmark" },
{ code: "FI", name: "Finland" },
{ code: "CH", name: "Switzerland" },
{ code: "AT", name: "Austria" },
{ code: "BE", name: "Belgium" },
{ code: "IE", name: "Ireland" },
{ code: "PT", name: "Portugal" },
{ code: "GR", name: "Greece" },
{ code: "JP", name: "Japan" },
];
const COUNTRY_NAME_BY_CODE = new Map(COUNTRY_OPTIONS.map(option => [option.code, option.name]));
const COUNTRY_CODE_BY_NAME = new Map(
COUNTRY_OPTIONS.map(option => [option.name.toLowerCase(), option.code])
);
export function getCountryName(code?: string | null): string | undefined {
if (!code) return undefined;
return COUNTRY_NAME_BY_CODE.get(code) ?? undefined;
}
export function getCountryCodeByName(name?: string | null): string | undefined {
if (!name) return undefined;
return COUNTRY_CODE_BY_NAME.get(name.toLowerCase()) ?? undefined;
}

View File

@ -1,65 +1,22 @@
/**
* Dashboard Domain - Contract
*
* Shared types for dashboard summaries, activity feeds, and related data.
*/
import { z } from "zod";
import {
activityTypeSchema,
activitySchema,
dashboardStatsSchema,
nextInvoiceSchema,
dashboardSummarySchema,
dashboardErrorSchema,
activityFilterSchema,
activityFilterConfigSchema,
dashboardSummaryResponseSchema,
} from "./schema";
import type { IsoDateTimeString } from "../common/types";
import type { Invoice } from "../billing/contract";
export type ActivityType =
| "invoice_created"
| "invoice_paid"
| "service_activated"
| "case_created"
| "case_closed";
export interface Activity {
id: string;
type: ActivityType;
title: string;
description?: string;
date: IsoDateTimeString;
relatedId?: number;
metadata?: Record<string, unknown>;
}
export interface DashboardStats {
activeSubscriptions: number;
unpaidInvoices: number;
openCases: number;
recentOrders?: number;
totalSpent?: number;
currency: string;
}
export interface NextInvoice {
id: number;
dueDate: IsoDateTimeString;
amount: number;
currency: string;
}
export interface DashboardSummary {
stats: DashboardStats;
nextInvoice: NextInvoice | null;
recentActivity: Activity[];
}
export interface DashboardError {
code: string;
message: string;
details?: Record<string, unknown>;
}
export type ActivityFilter = "all" | "billing" | "orders" | "support";
export interface ActivityFilterConfig {
key: ActivityFilter;
label: string;
types?: ActivityType[];
}
export interface DashboardSummaryResponse extends DashboardSummary {
invoices?: Invoice[];
}
export type ActivityType = z.infer<typeof activityTypeSchema>;
export type Activity = z.infer<typeof activitySchema>;
export type DashboardStats = z.infer<typeof dashboardStatsSchema>;
export type NextInvoice = z.infer<typeof nextInvoiceSchema>;
export type DashboardSummary = z.infer<typeof dashboardSummarySchema>;
export type DashboardError = z.infer<typeof dashboardErrorSchema>;
export type ActivityFilter = z.infer<typeof activityFilterSchema>;
export type ActivityFilterConfig = z.infer<typeof activityFilterConfigSchema>;
export type DashboardSummaryResponse = z.infer<typeof dashboardSummaryResponseSchema>;

View File

@ -3,3 +3,4 @@
*/
export * from "./contract";
export * from "./schema";

View File

@ -0,0 +1,60 @@
import { z } from "zod";
import { invoiceSchema } from "../billing/schema";
export const activityTypeSchema = z.enum([
"invoice_created",
"invoice_paid",
"service_activated",
"case_created",
"case_closed",
]);
export const activitySchema = z.object({
id: z.string(),
type: activityTypeSchema,
title: z.string(),
description: z.string().optional(),
date: z.string(),
relatedId: z.number().optional(),
metadata: z.record(z.string(), z.unknown()).optional(),
});
export const dashboardStatsSchema = z.object({
activeSubscriptions: z.number().int().nonnegative(),
unpaidInvoices: z.number().int().nonnegative(),
openCases: z.number().int().nonnegative(),
recentOrders: z.number().int().nonnegative().optional(),
totalSpent: z.number().nonnegative().optional(),
currency: z.string(),
});
export const nextInvoiceSchema = z.object({
id: z.number().int().positive(),
dueDate: z.string(),
amount: z.number(),
currency: z.string(),
});
export const dashboardSummarySchema = z.object({
stats: dashboardStatsSchema,
nextInvoice: nextInvoiceSchema.nullable(),
recentActivity: z.array(activitySchema),
});
export const dashboardErrorSchema = z.object({
code: z.string(),
message: z.string(),
details: z.record(z.string(), z.unknown()).optional(),
});
export const activityFilterSchema = z.enum(["all", "billing", "orders", "support"]);
export const activityFilterConfigSchema = z.object({
key: activityFilterSchema,
label: z.string(),
types: z.array(activityTypeSchema).optional(),
});
export const dashboardSummaryResponseSchema = dashboardSummarySchema.extend({
invoices: z.array(invoiceSchema).optional(),
});

View File

@ -24,6 +24,7 @@ export type {
SimUsage,
SimTopUpHistoryEntry,
SimTopUpHistory,
SimInfo,
// Request types
SimTopUpRequest,
SimPlanChangeRequest,

View File

@ -60,6 +60,14 @@ export const simTopUpHistorySchema = z.object({
history: z.array(simTopUpHistoryEntrySchema),
});
/**
* Combined SIM info payload (details + usage)
*/
export const simInfoSchema = z.object({
details: simDetailsSchema,
usage: simUsageSchema,
});
// ============================================================================
// SIM Management Request Schemas
// ============================================================================
@ -283,6 +291,7 @@ export type RecentDayUsage = z.infer<typeof recentDayUsageSchema>;
export type SimUsage = z.infer<typeof simUsageSchema>;
export type SimTopUpHistoryEntry = z.infer<typeof simTopUpHistoryEntrySchema>;
export type SimTopUpHistory = z.infer<typeof simTopUpHistorySchema>;
export type SimInfo = z.infer<typeof simInfoSchema>;
// Request types (derived from request schemas)
export type SimTopUpRequest = z.infer<typeof simTopUpRequestSchema>;