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:
parent
939922a40e
commit
2de2e8ec8a
@ -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);
|
||||
}
|
||||
|
||||
|
||||
@ -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}`, {
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
))}
|
||||
|
||||
@ -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: "",
|
||||
|
||||
@ -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 */}
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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 } =>
|
||||
|
||||
@ -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");
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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);
|
||||
},
|
||||
};
|
||||
|
||||
@ -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");
|
||||
|
||||
42
apps/portal/src/lib/constants/countries.ts
Normal file
42
apps/portal/src/lib/constants/countries.ts
Normal 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;
|
||||
}
|
||||
@ -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>;
|
||||
|
||||
@ -3,3 +3,4 @@
|
||||
*/
|
||||
|
||||
export * from "./contract";
|
||||
export * from "./schema";
|
||||
|
||||
60
packages/domain/dashboard/schema.ts
Normal file
60
packages/domain/dashboard/schema.ts
Normal 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(),
|
||||
});
|
||||
@ -24,6 +24,7 @@ export type {
|
||||
SimUsage,
|
||||
SimTopUpHistoryEntry,
|
||||
SimTopUpHistory,
|
||||
SimInfo,
|
||||
// Request types
|
||||
SimTopUpRequest,
|
||||
SimPlanChangeRequest,
|
||||
|
||||
@ -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>;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user