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 { 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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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}`, {
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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),
|
||||||
|
|||||||
@ -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),
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@ -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: "",
|
||||||
|
|||||||
@ -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 */}
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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 } =>
|
||||||
|
|||||||
@ -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");
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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");
|
||||||
|
|||||||
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 @@
|
|||||||
/**
|
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[];
|
|
||||||
}
|
|
||||||
|
|||||||
@ -3,3 +3,4 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
export * from "./contract";
|
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,
|
SimUsage,
|
||||||
SimTopUpHistoryEntry,
|
SimTopUpHistoryEntry,
|
||||||
SimTopUpHistory,
|
SimTopUpHistory,
|
||||||
|
SimInfo,
|
||||||
// Request types
|
// Request types
|
||||||
SimTopUpRequest,
|
SimTopUpRequest,
|
||||||
SimPlanChangeRequest,
|
SimPlanChangeRequest,
|
||||||
|
|||||||
@ -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>;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user