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 { Injectable } from "@nestjs/common";
import { SimOrchestratorService } from "./sim-management/services/sim-orchestrator.service"; import { SimOrchestratorService } from "./sim-management/services/sim-orchestrator.service";
import { SimNotificationService } from "./sim-management/services/sim-notification.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 { import type {
SimTopUpRequest, SimTopUpRequest,
SimPlanChangeRequest, SimPlanChangeRequest,
@ -123,10 +123,7 @@ export class SimManagementService {
async getSimInfo( async getSimInfo(
userId: string, userId: string,
subscriptionId: number subscriptionId: number
): Promise<{ ): Promise<SimInfo> {
details: SimDetails;
usage: SimUsage;
}> {
return this.simOrchestrator.getSimInfo(userId, subscriptionId); 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 { EsimManagementService } from "./esim-management.service";
import { SimValidationService } from "./sim-validation.service"; import { SimValidationService } from "./sim-validation.service";
import { getErrorMessage } from "@bff/core/utils/error.util"; 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 { import type {
SimTopUpRequest, SimTopUpRequest,
SimPlanChangeRequest, SimPlanChangeRequest,
@ -113,10 +114,7 @@ export class SimOrchestratorService {
async getSimInfo( async getSimInfo(
userId: string, userId: string,
subscriptionId: number subscriptionId: number
): Promise<{ ): Promise<SimInfo> {
details: SimDetails;
usage: SimUsage;
}> {
try { try {
const [details, usage] = await Promise.all([ const [details, usage] = await Promise.all([
this.getSimDetails(userId, subscriptionId), this.getSimDetails(userId, subscriptionId),
@ -141,7 +139,7 @@ export class SimOrchestratorService {
} }
} }
return { details, usage }; return simInfoSchema.parse({ details, usage });
} catch (error) { } catch (error) {
const sanitizedError = getErrorMessage(error); const sanitizedError = getErrorMessage(error);
this.logger.error(`Failed to get comprehensive SIM info for subscription ${subscriptionId}`, { this.logger.error(`Failed to get comprehensive SIM info for subscription ${subscriptionId}`, {

View File

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

View File

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

View File

@ -17,6 +17,7 @@ import {
import type { Subscription } from "@customer-portal/domain/subscriptions"; import type { Subscription } from "@customer-portal/domain/subscriptions";
import type { Invoice } from "@customer-portal/domain/billing"; import type { Invoice } from "@customer-portal/domain/billing";
import type { Activity, DashboardSummary, NextInvoice } from "@customer-portal/domain/dashboard"; 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 { WhmcsService } from "@bff/integrations/whmcs/whmcs.service";
import { SalesforceService } from "@bff/integrations/salesforce/salesforce.service"; import { SalesforceService } from "@bff/integrations/salesforce/salesforce.service";
import { MappingsService } from "@bff/modules/id-mappings/mappings.service"; import { MappingsService } from "@bff/modules/id-mappings/mappings.service";
@ -525,7 +526,7 @@ export class UsersService {
nextInvoice, nextInvoice,
recentActivity, recentActivity,
}; };
return summary; return dashboardSummarySchema.parse(summary);
} catch (error) { } catch (error) {
this.logger.error(`Failed to get user summary for ${userId}`, { this.logger.error(`Failed to get user summary for ${userId}`, {
error: getErrorMessage(error), 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 { MapPinIcon, PencilIcon, CheckIcon, XMarkIcon } from "@heroicons/react/24/outline";
import { AddressForm, type AddressFormProps } from "@/features/catalog/components"; import { AddressForm, type AddressFormProps } from "@/features/catalog/components";
import type { Address } from "@customer-portal/domain/customer"; import type { Address } from "@customer-portal/domain/customer";
import { getCountryName } from "@/lib/constants/countries";
interface AddressCardProps { interface AddressCardProps {
address: Address; address: Address;
@ -26,6 +27,9 @@ export function AddressCard({
onSave, onSave,
onAddressChange, onAddressChange,
}: AddressCardProps) { }: AddressCardProps) {
const countryLabel =
address.country ? getCountryName(address.country) ?? address.country : null;
return ( return (
<SubCard> <SubCard>
<div className="pb-5 border-b border-gray-200"> <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(", ")} {[address.city, address.state, address.postcode].filter(Boolean).join(", ")}
</p> </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>
)} )}
</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 { FormErrors, FormTouched, UseZodFormReturn } from "@customer-portal/validation";
import type { SignupFormValues } from "./SignupForm"; import type { SignupFormValues } from "./SignupForm";
import type { Address } from "@customer-portal/domain/customer"; import type { Address } from "@customer-portal/domain/customer";
import { COUNTRY_OPTIONS } from "@/lib/constants/countries";
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" },
];
interface AddressStepProps { interface AddressStepProps {
address: SignupFormValues["address"]; address: SignupFormValues["address"];
@ -53,7 +31,19 @@ export function AddressStep({
// Use domain Address type directly - no type helpers needed // Use domain Address type directly - no type helpers needed
const updateAddressField = useCallback( const updateAddressField = useCallback(
(field: keyof Address, value: string) => { (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] [address, onAddressChange]
); );
@ -139,13 +129,13 @@ export function AddressStep({
<FormField label="Country" error={getFieldError("country")} required> <FormField label="Country" error={getFieldError("country")} required>
<select <select
value={address?.country || ""} value={address?.country || ""}
onChange={e => updateAddressField("country", e.target.value)} onChange={e => handleCountryChange(e.target.value)}
onBlur={markTouched} 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" 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> <option value="">Select a country</option>
{COUNTRIES.map(country => ( {COUNTRY_OPTIONS.map(country => (
<option key={country.code} value={country.name}> <option key={country.code} value={country.code}>
{country.name} {country.name}
</option> </option>
))} ))}

View File

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

View File

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

View File

@ -5,8 +5,12 @@
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { useAuthSession } from "@/features/auth/services/auth.store"; import { useAuthSession } from "@/features/auth/services/auth.store";
import { apiClient, queryKeys, getDataOrThrow } from "@/lib/api"; import { apiClient, queryKeys } from "@/lib/api";
import type { DashboardSummary, DashboardError } from "@customer-portal/domain/dashboard"; import {
dashboardSummarySchema,
type DashboardSummary,
type DashboardError,
} from "@customer-portal/domain/dashboard";
class DashboardDataError extends Error { class DashboardDataError extends Error {
constructor( constructor(
@ -37,7 +41,21 @@ export function useDashboardSummary() {
try { try {
const response = await apiClient.GET<DashboardSummary>("/api/me/summary"); 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) { } catch (error) {
// Transform API errors to DashboardError format // Transform API errors to DashboardError format
if (error instanceof Error) { if (error instanceof Error) {

View File

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

View File

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

View File

@ -48,7 +48,7 @@ export function SimCancelContainer() {
useEffect(() => { useEffect(() => {
const fetchDetails = async () => { const fetchDetails = async () => {
try { try {
const info = await simActionsService.getSimInfo<SimDetails, unknown>(subscriptionId); const info = await simActionsService.getSimInfo(subscriptionId);
setDetails(info?.details || null); setDetails(info?.details || null);
} catch (e: unknown) { } catch (e: unknown) {
setError(e instanceof Error ? e.message : "Failed to load SIM details"); 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 { getNullableData } from "@/lib/api/response-helpers";
import { useAuthSession } from "@/features/auth/services"; import { useAuthSession } from "@/features/auth/services";
import type { InvoiceList } from "@customer-portal/domain/billing"; 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 { interface UseSubscriptionsOptions {
status?: string; status?: string;
} }
const emptyStats = {
total: 0,
active: 0,
completed: 0,
cancelled: 0,
};
const emptyInvoiceList: InvoiceList = { const emptyInvoiceList: InvoiceList = {
invoices: [], invoices: [],
pagination: { pagination: {
@ -76,11 +74,14 @@ export function useActiveSubscriptions() {
export function useSubscriptionStats() { export function useSubscriptionStats() {
const { isAuthenticated } = useAuthSession(); const { isAuthenticated } = useAuthSession();
return useQuery({ return useQuery<SubscriptionStats>({
queryKey: queryKeys.subscriptions.stats(), queryKey: queryKeys.subscriptions.stats(),
queryFn: async () => { queryFn: async () => {
const response = await apiClient.GET<typeof emptyStats>("/api/subscriptions/stats"); const response = await apiClient.GET<SubscriptionStats>("/api/subscriptions/stats");
return getDataOrThrow<typeof emptyStats>(response, "Failed to load subscription statistics"); if (!response.data) {
throw new Error("Subscription statistics response was empty");
}
return subscriptionStatsSchema.parse(response.data);
}, },
staleTime: 5 * 60 * 1000, staleTime: 5 * 60 * 1000,
gcTime: 10 * 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 { import type {
SimTopUpRequest, SimTopUpRequest,
SimPlanChangeRequest, SimPlanChangeRequest,
@ -11,11 +12,6 @@ import type {
// - SimPlanChangeRequest: newPlanCode, assignGlobalIp, scheduledAt (YYYYMMDD format) // - SimPlanChangeRequest: newPlanCode, assignGlobalIp, scheduledAt (YYYYMMDD format)
// - SimCancelRequest: scheduledAt (YYYYMMDD format) // - SimCancelRequest: scheduledAt (YYYYMMDD format)
export interface SimInfo<T, E = unknown> {
details: T;
error?: E;
}
export const simActionsService = { export const simActionsService = {
async topUp(subscriptionId: string, request: SimTopUpRequest): Promise<void> { async topUp(subscriptionId: string, request: SimTopUpRequest): Promise<void> {
await apiClient.POST("/api/subscriptions/{subscriptionId}/sim/top-up", { 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> { async getSimInfo(subscriptionId: string): Promise<SimInfo | null> {
const response = await apiClient.GET<SimInfo<T, E> | null>( const response = await apiClient.GET<SimInfo>("/api/subscriptions/{subscriptionId}/sim", {
"/api/subscriptions/{subscriptionId}/sim/info", params: { path: { subscriptionId } },
{ });
params: { path: { subscriptionId } },
} if (!response.data) {
); return null;
return getDataOrDefault<SimInfo<T, E> | null>(response, null); }
return simInfoSchema.parse(response.data);
}, },
}; };

View File

@ -48,7 +48,7 @@ export function SimCancelContainer() {
useEffect(() => { useEffect(() => {
const fetchDetails = async () => { const fetchDetails = async () => {
try { try {
const info = await simActionsService.getSimInfo<SimDetails, unknown>(subscriptionId); const info = await simActionsService.getSimInfo(subscriptionId);
setDetails(info?.details || null); setDetails(info?.details || null);
} catch (e: unknown) { } catch (e: unknown) {
setError(e instanceof Error ? e.message : "Failed to load SIM details"); 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 @@
/** import { z } from "zod";
* Dashboard Domain - Contract import {
* activityTypeSchema,
* Shared types for dashboard summaries, activity feeds, and related data. activitySchema,
*/ dashboardStatsSchema,
nextInvoiceSchema,
dashboardSummarySchema,
dashboardErrorSchema,
activityFilterSchema,
activityFilterConfigSchema,
dashboardSummaryResponseSchema,
} from "./schema";
import type { IsoDateTimeString } from "../common/types"; export type ActivityType = z.infer<typeof activityTypeSchema>;
import type { Invoice } from "../billing/contract"; export type Activity = z.infer<typeof activitySchema>;
export type DashboardStats = z.infer<typeof dashboardStatsSchema>;
export type ActivityType = export type NextInvoice = z.infer<typeof nextInvoiceSchema>;
| "invoice_created" export type DashboardSummary = z.infer<typeof dashboardSummarySchema>;
| "invoice_paid" export type DashboardError = z.infer<typeof dashboardErrorSchema>;
| "service_activated" export type ActivityFilter = z.infer<typeof activityFilterSchema>;
| "case_created" export type ActivityFilterConfig = z.infer<typeof activityFilterConfigSchema>;
| "case_closed"; export type DashboardSummaryResponse = z.infer<typeof dashboardSummaryResponseSchema>;
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[];
}

View File

@ -3,3 +3,4 @@
*/ */
export * from "./contract"; 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, SimUsage,
SimTopUpHistoryEntry, SimTopUpHistoryEntry,
SimTopUpHistory, SimTopUpHistory,
SimInfo,
// Request types // Request types
SimTopUpRequest, SimTopUpRequest,
SimPlanChangeRequest, SimPlanChangeRequest,

View File

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