Update worktree setup and enhance BFF with SupportModule integration

- Changed worktree setup command from npm to pnpm for improved package management.
- Added SupportModule to app.module.ts and router.config.ts for better support case handling.
- Refactored OrderEventsService to utilize OrderUpdateEventPayload for improved type safety.
- Updated InvoicesList component to use INVOICE_STATUS for status filtering and improved type definitions.
- Enhanced SimActions and SimDetailsCard components to utilize SimStatus for better state management.
- Refactored Subscription components to leverage new utility functions for status handling and billing cycle labels.
- Improved SupportCasesView with better state management and error handling.
- Updated API query keys to include support cases for better data retrieval.
This commit is contained in:
barsa 2025-11-18 14:06:27 +09:00
parent 1220f219e4
commit 8c89109213
31 changed files with 620 additions and 379 deletions

View File

@ -1,5 +1,5 @@
{
"setup-worktree": [
"npm install"
"pnpm install"
]
}

View File

@ -34,6 +34,7 @@ import { OrdersModule } from "@bff/modules/orders/orders.module";
import { InvoicesModule } from "@bff/modules/invoices/invoices.module";
import { SubscriptionsModule } from "@bff/modules/subscriptions/subscriptions.module";
import { CurrencyModule } from "@bff/modules/currency/currency.module";
import { SupportModule } from "@bff/modules/support/support.module";
// System Modules
import { HealthModule } from "@bff/modules/health/health.module";
@ -85,6 +86,7 @@ import { HealthModule } from "@bff/modules/health/health.module";
InvoicesModule,
SubscriptionsModule,
CurrencyModule,
SupportModule,
// === SYSTEM MODULES ===
HealthModule,

View File

@ -8,6 +8,7 @@ import { InvoicesModule } from "@bff/modules/invoices/invoices.module";
import { SubscriptionsModule } from "@bff/modules/subscriptions/subscriptions.module";
import { CurrencyModule } from "@bff/modules/currency/currency.module";
import { SecurityModule } from "@bff/core/security/security.module";
import { SupportModule } from "@bff/modules/support/support.module";
export const apiRoutes: Routes = [
{
@ -21,6 +22,7 @@ export const apiRoutes: Routes = [
{ path: "", module: InvoicesModule },
{ path: "", module: SubscriptionsModule },
{ path: "", module: CurrencyModule },
{ path: "", module: SupportModule },
{ path: "", module: SecurityModule },
],
},

View File

@ -1,18 +1,7 @@
import { Injectable, Logger } from "@nestjs/common";
import type { MessageEvent } from "@nestjs/common";
import { Observable } from "rxjs";
export interface OrderUpdateEvent {
orderId: string;
status?: string;
activationStatus?: string | null;
message?: string;
reason?: string;
stage?: "started" | "in_progress" | "completed" | "failed";
source?: string;
timestamp: string;
payload?: Record<string, unknown>;
}
import type { OrderUpdateEventPayload } from "@customer-portal/domain/orders";
interface InternalObserver {
next: (event: MessageEvent) => void;
@ -74,7 +63,7 @@ export class OrderEventsService {
});
}
publish(orderId: string, update: OrderUpdateEvent): void {
publish(orderId: string, update: OrderUpdateEventPayload): void {
const currentObservers = this.observers.get(orderId);
if (!currentObservers || currentObservers.size === 0) {
this.logger.debug("No active listeners for order update", { orderId });

View File

@ -0,0 +1,3 @@
export * from "./support.module";
export * from "./support.controller";
export * from "./support.service";

View File

@ -0,0 +1,24 @@
import { Controller, Get, Query, Request } from "@nestjs/common";
import { SupportService } from "./support.service";
import { ZodValidationPipe } from "@customer-portal/validation/nestjs";
import {
supportCaseFilterSchema,
type SupportCaseFilter,
type SupportCaseList,
} from "@customer-portal/domain/support";
import type { RequestWithUser } from "@bff/modules/auth/auth.types";
@Controller("support")
export class SupportController {
constructor(private readonly supportService: SupportService) {}
@Get("cases")
async listCases(
@Request() _req: RequestWithUser,
@Query(new ZodValidationPipe(supportCaseFilterSchema))
filters: SupportCaseFilter
): Promise<SupportCaseList> {
void _req;
return this.supportService.listCases(filters);
}
}

View File

@ -0,0 +1,10 @@
import { Module } from "@nestjs/common";
import { SupportController } from "./support.controller";
import { SupportService } from "./support.service";
@Module({
controllers: [SupportController],
providers: [SupportService],
exports: [SupportService],
})
export class SupportModule {}

View File

@ -0,0 +1,140 @@
import { Injectable } from "@nestjs/common";
import {
SUPPORT_CASE_CATEGORY,
SUPPORT_CASE_PRIORITY,
SUPPORT_CASE_STATUS,
supportCaseFilterSchema,
supportCaseListSchema,
type SupportCase,
type SupportCaseFilter,
type SupportCaseList,
type SupportCasePriority,
type SupportCaseStatus,
} from "@customer-portal/domain/support";
const OPEN_STATUSES: SupportCaseStatus[] = [
SUPPORT_CASE_STATUS.OPEN,
SUPPORT_CASE_STATUS.IN_PROGRESS,
SUPPORT_CASE_STATUS.WAITING_ON_CUSTOMER,
];
const RESOLVED_STATUSES: SupportCaseStatus[] = [
SUPPORT_CASE_STATUS.RESOLVED,
SUPPORT_CASE_STATUS.CLOSED,
];
const HIGH_PRIORITIES: SupportCasePriority[] = [
SUPPORT_CASE_PRIORITY.HIGH,
SUPPORT_CASE_PRIORITY.CRITICAL,
];
@Injectable()
export class SupportService {
// Placeholder dataset until Salesforce integration is ready
private readonly cases: SupportCase[] = [
{
id: 12001,
subject: "VPS Performance Issues",
status: SUPPORT_CASE_STATUS.IN_PROGRESS,
priority: SUPPORT_CASE_PRIORITY.HIGH,
category: SUPPORT_CASE_CATEGORY.TECHNICAL,
createdAt: "2025-08-14T10:30:00Z",
updatedAt: "2025-08-15T14:20:00Z",
lastReply: "2025-08-15T14:20:00Z",
description: "Experiencing slow response times on VPS server, CPU usage appears high.",
assignedTo: "Technical Support Team",
},
{
id: 12002,
subject: "Billing Question - Invoice #12345",
status: SUPPORT_CASE_STATUS.WAITING_ON_CUSTOMER,
priority: SUPPORT_CASE_PRIORITY.MEDIUM,
category: SUPPORT_CASE_CATEGORY.BILLING,
createdAt: "2025-08-13T16:45:00Z",
updatedAt: "2025-08-14T09:30:00Z",
lastReply: "2025-08-14T09:30:00Z",
description: "Need clarification on charges in recent invoice.",
assignedTo: "Billing Department",
},
{
id: 12003,
subject: "SSL Certificate Installation",
status: SUPPORT_CASE_STATUS.RESOLVED,
priority: SUPPORT_CASE_PRIORITY.LOW,
category: SUPPORT_CASE_CATEGORY.TECHNICAL,
createdAt: "2025-08-12T08:15:00Z",
updatedAt: "2025-08-12T15:45:00Z",
lastReply: "2025-08-12T15:45:00Z",
description: "Request assistance with SSL certificate installation on shared hosting.",
assignedTo: "Technical Support Team",
},
{
id: 12004,
subject: "Feature Request: Control Panel Enhancement",
status: SUPPORT_CASE_STATUS.OPEN,
priority: SUPPORT_CASE_PRIORITY.LOW,
category: SUPPORT_CASE_CATEGORY.FEATURE_REQUEST,
createdAt: "2025-08-11T13:20:00Z",
updatedAt: "2025-08-11T13:20:00Z",
description: "Would like to see improved backup management in the control panel.",
assignedTo: "Development Team",
},
{
id: 12005,
subject: "Server Migration Assistance",
status: SUPPORT_CASE_STATUS.CLOSED,
priority: SUPPORT_CASE_PRIORITY.MEDIUM,
category: SUPPORT_CASE_CATEGORY.TECHNICAL,
createdAt: "2025-08-10T11:00:00Z",
updatedAt: "2025-08-11T17:30:00Z",
lastReply: "2025-08-11T17:30:00Z",
description: "Need help migrating website from old server to new VPS.",
assignedTo: "Migration Team",
},
];
async listCases(rawFilters?: SupportCaseFilter): Promise<SupportCaseList> {
const filters = supportCaseFilterSchema.parse(rawFilters ?? {});
const filteredCases = this.applyFilters(this.cases, filters);
const result = {
cases: filteredCases,
summary: this.buildSummary(filteredCases),
};
return supportCaseListSchema.parse(result);
}
private applyFilters(cases: SupportCase[], filters: SupportCaseFilter): SupportCase[] {
const search = filters.search?.toLowerCase().trim();
return cases.filter(supportCase => {
if (filters.status && supportCase.status !== filters.status) {
return false;
}
if (filters.priority && supportCase.priority !== filters.priority) {
return false;
}
if (filters.category && supportCase.category !== filters.category) {
return false;
}
if (search) {
const haystack = `${supportCase.subject} ${supportCase.description} ${supportCase.id}`.toLowerCase();
if (!haystack.includes(search)) {
return false;
}
}
return true;
});
}
private buildSummary(cases: SupportCase[]): SupportCaseList["summary"] {
const open = cases.filter(c => OPEN_STATUSES.includes(c.status)).length;
const highPriority = cases.filter(c => HIGH_PRIORITIES.includes(c.priority)).length;
const resolved = cases.filter(c => RESOLVED_STATUSES.includes(c.status)).length;
return {
total: cases.length,
open,
highPriority,
resolved,
};
}
}

View File

@ -9,7 +9,7 @@ import { PaginationBar } from "@/components/molecules/PaginationBar/PaginationBa
import { InvoiceTable } from "@/features/billing/components/InvoiceTable/InvoiceTable";
import { useInvoices } from "@/features/billing/hooks/useBilling";
import { useSubscriptionInvoices } from "@/features/subscriptions/hooks/useSubscriptions";
import type { Invoice, InvoiceStatus } from "@customer-portal/domain/billing";
import { INVOICE_STATUS, type Invoice, type InvoiceStatus } from "@customer-portal/domain/billing";
import { cn } from "@/lib/utils";
interface InvoicesListProps {
@ -20,6 +20,8 @@ interface InvoicesListProps {
className?: string;
}
const INVOICE_STATUS_OPTIONS = Object.values(INVOICE_STATUS) as InvoiceStatus[];
export function InvoicesList({
subscriptionId,
pageSize = 10,
@ -28,7 +30,7 @@ export function InvoicesList({
className,
}: InvoicesListProps) {
const [searchTerm, setSearchTerm] = useState("");
const [statusFilter, setStatusFilter] = useState("all");
const [statusFilter, setStatusFilter] = useState<InvoiceStatus | "all">("all");
const [currentPage, setCurrentPage] = useState(1);
const isSubscriptionMode = typeof subscriptionId === "number" && !Number.isNaN(subscriptionId);
@ -70,13 +72,16 @@ export function InvoicesList({
});
}, [invoices, searchTerm]);
const statusFilterOptions = [
{ value: "all", label: "All Status" },
{ value: "Unpaid", label: "Unpaid" },
{ value: "Paid", label: "Paid" },
{ value: "Overdue", label: "Overdue" },
{ value: "Cancelled", label: "Cancelled" },
];
const statusFilterOptions = useMemo(
() => [
{ value: "all" as const, label: "All Status" },
...INVOICE_STATUS_OPTIONS.map(status => ({
value: status,
label: status,
})),
],
[]
);
if (isLoading || error) {
return (
@ -126,7 +131,8 @@ export function InvoicesList({
<select
value={statusFilter}
onChange={e => {
setStatusFilter(e.target.value);
const nextValue = e.target.value as InvoiceStatus | "all";
setStatusFilter(nextValue);
setCurrentPage(1);
}}
className="block w-36 pl-3 pr-8 py-2.5 text-sm border border-gray-200 focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-400 rounded-lg appearance-none bg-white/50 cursor-pointer transition-all duration-200"

View File

@ -3,23 +3,10 @@
import { useEffect, useRef } from "react";
import { resolveBaseUrl } from "@/lib/api";
import { logger } from "@/lib/logger";
export interface OrderStreamEvent<T extends string = string, P = unknown> {
event: T;
data: P;
}
export interface OrderUpdateEventPayload {
orderId: string;
status?: string;
activationStatus?: string | null;
message?: string;
reason?: string;
stage?: "started" | "in_progress" | "completed" | "failed";
source?: string;
timestamp: string;
payload?: Record<string, unknown> | null;
}
import {
type OrderStreamEvent,
type OrderUpdateEventPayload,
} from "@customer-portal/domain/orders";
type OrderUpdateHandler = (event: OrderUpdateEventPayload) => void;

View File

@ -22,10 +22,7 @@ import {
import { StatusPill } from "@/components/atoms/status-pill";
import { Button } from "@/components/atoms/button";
import { ordersService } from "@/features/orders/services/orders.service";
import {
useOrderUpdates,
type OrderUpdateEventPayload,
} from "@/features/orders/hooks/useOrderUpdates";
import { useOrderUpdates } from "@/features/orders/hooks/useOrderUpdates";
import {
calculateOrderTotals,
deriveOrderStatusDescriptor,
@ -37,7 +34,7 @@ import {
type OrderDisplayItemCategory,
type OrderDisplayItemCharge,
} from "@/features/orders/utils/order-display";
import type { OrderDetails } from "@customer-portal/domain/orders";
import type { OrderDetails, OrderUpdateEventPayload } from "@customer-portal/domain/orders";
import { cn } from "@/lib/utils/cn";
const STATUS_PILL_VARIANT: Record<

View File

@ -12,11 +12,17 @@ import {
import { TopUpModal } from "./TopUpModal";
import { ChangePlanModal } from "./ChangePlanModal";
import { apiClient } from "@/lib/api";
import {
canTopUpSim,
canReissueEsim,
canCancelSim,
type SimStatus,
} from "@customer-portal/domain/sim";
interface SimActionsProps {
subscriptionId: number;
simType: "physical" | "esim";
status: string;
status: SimStatus;
onTopUpSuccess?: () => void;
onPlanChangeSuccess?: () => void;
onCancelSuccess?: () => void;
@ -48,10 +54,10 @@ export function SimActions({
"topup" | "reissue" | "cancel" | "changePlan" | null
>(null);
const isActive = status === "active";
const canTopUp = isActive;
const canReissue = isActive && simType === "esim";
const canCancel = isActive;
const isActiveStatus = canTopUpSim(status);
const canTopUp = isActiveStatus;
const canReissue = simType === "esim" && canReissueEsim(status);
const canCancel = canCancelSim(status);
const handleReissueEsim = async () => {
setLoading("reissue");
@ -154,7 +160,7 @@ export function SimActions({
</div>
)}
{!isActive && (
{!isActiveStatus && (
<div className="mb-4 bg-yellow-50 border border-yellow-200 rounded-lg p-4">
<div className="flex items-center">
<ExclamationTriangleIcon className="h-5 w-5 text-yellow-500 mr-2" />

View File

@ -9,7 +9,7 @@ import {
XCircleIcon,
ClockIcon,
} from "@heroicons/react/24/outline";
import type { SimDetails } from "@customer-portal/domain/sim";
import type { SimDetails, SimStatus } from "@customer-portal/domain/sim";
interface SimDetailsCardProps {
simDetails: SimDetails;
@ -19,14 +19,14 @@ interface SimDetailsCardProps {
showFeaturesSummary?: boolean;
}
const statusIconMap: Record<string, React.ReactNode> = {
const STATUS_ICON_MAP: Record<SimStatus, React.ReactNode> = {
active: <CheckCircleIcon className="h-5 w-5 text-green-500" />,
suspended: <ExclamationTriangleIcon className="h-5 w-5 text-yellow-500" />,
cancelled: <XCircleIcon className="h-5 w-5 text-red-500" />,
pending: <ClockIcon className="h-5 w-5 text-blue-500" />,
};
const statusBadgeClass: Record<string, string> = {
const STATUS_BADGE_CLASS_MAP: Record<SimStatus, string> = {
active: "bg-green-100 text-green-800",
suspended: "bg-yellow-100 text-yellow-800",
cancelled: "bg-red-100 text-red-800",
@ -107,11 +107,10 @@ export function SimDetailsCard({
}
const planName = simDetails.planName || formatPlanShort(simDetails.planCode) || "SIM Plan";
const normalizedStatus = simDetails.status?.toLowerCase() ?? "unknown";
const statusIcon = statusIconMap[normalizedStatus] ?? (
const statusIcon = STATUS_ICON_MAP[simDetails.status] ?? (
<DevicePhoneMobileIcon className="h-5 w-5 text-gray-500" />
);
const statusClass = statusBadgeClass[normalizedStatus] ?? "bg-gray-100 text-gray-800";
const statusClass = STATUS_BADGE_CLASS_MAP[simDetails.status] ?? "bg-gray-100 text-gray-800";
const containerClasses = embedded ? "" : "bg-white shadow-lg rounded-xl border border-gray-100";
return (

View File

@ -2,14 +2,7 @@
import { forwardRef } from "react";
import { format } from "date-fns";
import {
CheckCircleIcon,
ExclamationTriangleIcon,
ClockIcon,
XCircleIcon,
CalendarIcon,
ArrowTopRightOnSquareIcon,
} from "@heroicons/react/24/outline";
import { CalendarIcon, ArrowTopRightOnSquareIcon } from "@heroicons/react/24/outline";
import { StatusPill } from "@/components/atoms/status-pill";
import { Button } from "@/components/atoms/button";
import { SubCard } from "@/components/molecules/SubCard/SubCard";
@ -17,6 +10,11 @@ import type { Subscription } from "@customer-portal/domain/subscriptions";
import { useFormatCurrency } from "@/lib/hooks/useFormatCurrency";
import { cn } from "@/lib/utils";
import {
getBillingCycleLabel,
getSubscriptionStatusIcon,
getSubscriptionStatusVariant,
} from "@/features/subscriptions/utils/status-presenters";
interface SubscriptionCardProps {
subscription: Subscription;
@ -26,38 +24,6 @@ interface SubscriptionCardProps {
className?: string;
}
const getStatusIcon = (status: string) => {
switch (status) {
case "Active":
return <CheckCircleIcon className="h-5 w-5 text-green-500" />;
case "Suspended":
return <ExclamationTriangleIcon className="h-5 w-5 text-yellow-500" />;
case "Pending":
return <ClockIcon className="h-5 w-5 text-blue-500" />;
case "Cancelled":
case "Terminated":
return <XCircleIcon className="h-5 w-5 text-gray-500" />;
default:
return <ClockIcon className="h-5 w-5 text-gray-500" />;
}
};
const getStatusVariant = (status: string) => {
switch (status) {
case "Active":
return "success" as const;
case "Suspended":
return "warning" as const;
case "Pending":
return "info" as const;
case "Cancelled":
case "Terminated":
return "neutral" as const;
default:
return "neutral" as const;
}
};
const formatDate = (dateString: string | undefined) => {
if (!dateString) return "N/A";
try {
@ -67,12 +33,6 @@ const formatDate = (dateString: string | undefined) => {
}
};
const getBillingCycleLabel = (cycle: string) => {
const name = cycle.toLowerCase();
const looksLikeActivation = name.includes("activation") || name.includes("setup");
return looksLikeActivation ? "One-time" : cycle;
};
export const SubscriptionCard = forwardRef<HTMLDivElement, SubscriptionCardProps>(
({ subscription, variant = "list", showActions = true, onViewClick, className }, ref) => {
const { formatCurrency } = useFormatCurrency();
@ -90,7 +50,7 @@ export const SubscriptionCard = forwardRef<HTMLDivElement, SubscriptionCardProps
{/* Header */}
<div className="flex items-start justify-between">
<div className="flex items-center space-x-3">
{getStatusIcon(subscription.status)}
{getSubscriptionStatusIcon(subscription.status)}
<div className="min-w-0 flex-1">
<h3 className="text-lg font-semibold text-gray-900 truncate">
{subscription.productName}
@ -100,7 +60,7 @@ export const SubscriptionCard = forwardRef<HTMLDivElement, SubscriptionCardProps
</div>
<StatusPill
label={subscription.status}
variant={getStatusVariant(subscription.status)}
variant={getSubscriptionStatusVariant(subscription.status)}
size="sm"
/>
</div>
@ -149,7 +109,7 @@ export const SubscriptionCard = forwardRef<HTMLDivElement, SubscriptionCardProps
<SubCard ref={ref} className={cn("hover:shadow-md transition-all duration-200", className)}>
<div className="flex items-center justify-between">
<div className="flex items-center space-x-4 min-w-0 flex-1">
{getStatusIcon(subscription.status)}
{getSubscriptionStatusIcon(subscription.status)}
<div className="min-w-0 flex-1">
<div className="flex items-center space-x-3">
<h3 className="text-base font-semibold text-gray-900 truncate">
@ -157,7 +117,7 @@ export const SubscriptionCard = forwardRef<HTMLDivElement, SubscriptionCardProps
</h3>
<StatusPill
label={subscription.status}
variant={getStatusVariant(subscription.status)}
variant={getSubscriptionStatusVariant(subscription.status)}
size="sm"
/>
</div>

View File

@ -4,10 +4,6 @@ import { forwardRef } from "react";
import { format } from "date-fns";
import {
ServerIcon,
CheckCircleIcon,
ExclamationTriangleIcon,
ClockIcon,
XCircleIcon,
CalendarIcon,
CurrencyYenIcon,
IdentificationIcon,
@ -19,6 +15,11 @@ import type { Subscription } from "@customer-portal/domain/subscriptions";
import { useFormatCurrency } from "@/lib/hooks/useFormatCurrency";
import { cn } from "@/lib/utils";
import {
getBillingCycleLabel,
getSubscriptionStatusIcon,
getSubscriptionStatusVariant,
} from "@/features/subscriptions/utils/status-presenters";
interface SubscriptionDetailsProps {
subscription: Subscription;
@ -26,40 +27,6 @@ interface SubscriptionDetailsProps {
className?: string;
}
const getStatusIcon = (status: string) => {
switch (status) {
case "Active":
return <CheckCircleIcon className="h-6 w-6 text-green-500" />;
case "Suspended":
return <ExclamationTriangleIcon className="h-6 w-6 text-yellow-500" />;
case "Terminated":
return <XCircleIcon className="h-6 w-6 text-red-500" />;
case "Cancelled":
return <XCircleIcon className="h-6 w-6 text-gray-500" />;
case "Pending":
return <ClockIcon className="h-6 w-6 text-blue-500" />;
default:
return <ServerIcon className="h-6 w-6 text-gray-500" />;
}
};
const getStatusVariant = (status: string) => {
switch (status) {
case "Active":
return "success" as const;
case "Suspended":
return "warning" as const;
case "Terminated":
return "error" as const;
case "Cancelled":
return "neutral" as const;
case "Pending":
return "info" as const;
default:
return "neutral" as const;
}
};
const formatDate = (dateString: string | undefined) => {
if (!dateString) return "N/A";
try {
@ -69,25 +36,6 @@ const formatDate = (dateString: string | undefined) => {
}
};
const formatBillingLabel = (cycle: string) => {
switch (cycle) {
case "Monthly":
return "Monthly Billing";
case "Annually":
return "Annual Billing";
case "Quarterly":
return "Quarterly Billing";
case "Semi-Annually":
return "Semi-Annual Billing";
case "Biennially":
return "Biennial Billing";
case "Triennially":
return "Triennial Billing";
default:
return "One-time Payment";
}
};
const isSimService = (productName: string) => {
return productName.toLowerCase().includes("sim");
};
@ -112,7 +60,7 @@ export const SubscriptionDetails = forwardRef<HTMLDivElement, SubscriptionDetail
header={
<div className="flex items-center justify-between">
<div className="flex items-center space-x-3">
{getStatusIcon(subscription.status)}
{getSubscriptionStatusIcon(subscription.status)}
<div>
<h3 className="text-lg font-semibold text-gray-900">Subscription Details</h3>
<p className="text-sm text-gray-500">Service subscription information</p>
@ -120,7 +68,7 @@ export const SubscriptionDetails = forwardRef<HTMLDivElement, SubscriptionDetail
</div>
<StatusPill
label={subscription.status}
variant={getStatusVariant(subscription.status)}
variant={getSubscriptionStatusVariant(subscription.status)}
size="lg"
/>
</div>
@ -138,7 +86,7 @@ export const SubscriptionDetails = forwardRef<HTMLDivElement, SubscriptionDetail
<p className="text-2xl font-bold text-gray-900">
{formatCurrency(subscription.amount)}
</p>
<p className="text-sm text-gray-500">{formatBillingLabel(subscription.cycle)}</p>
<p className="text-sm text-gray-500">{getBillingCycleLabel(subscription.cycle)}</p>
</div>
{/* Next Due Date */}

View File

@ -1,21 +1,22 @@
"use client";
export function SubscriptionStatusBadge({ status }: { status: string }) {
const color = (() => {
switch (status) {
case "Active":
return "bg-green-100 text-green-800";
case "Suspended":
return "bg-yellow-100 text-yellow-800";
case "Pending":
return "bg-blue-100 text-blue-800";
case "Cancelled":
case "Terminated":
return "bg-gray-100 text-gray-800";
default:
return "bg-gray-100 text-gray-800";
}
})();
import {
SUBSCRIPTION_STATUS,
type SubscriptionStatus,
} from "@customer-portal/domain/subscriptions";
const STATUS_COLOR_MAP: Record<SubscriptionStatus, string> = {
[SUBSCRIPTION_STATUS.ACTIVE]: "bg-green-100 text-green-800",
[SUBSCRIPTION_STATUS.INACTIVE]: "bg-gray-100 text-gray-800",
[SUBSCRIPTION_STATUS.PENDING]: "bg-blue-100 text-blue-800",
[SUBSCRIPTION_STATUS.SUSPENDED]: "bg-yellow-100 text-yellow-800",
[SUBSCRIPTION_STATUS.CANCELLED]: "bg-gray-100 text-gray-800",
[SUBSCRIPTION_STATUS.TERMINATED]: "bg-red-100 text-red-800",
[SUBSCRIPTION_STATUS.COMPLETED]: "bg-green-100 text-green-800",
};
export function SubscriptionStatusBadge({ status }: { status: SubscriptionStatus }) {
const color = STATUS_COLOR_MAP[status] ?? "bg-gray-100 text-gray-800";
return (
<span className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${color}`}>

View File

@ -0,0 +1,59 @@
import type { ReactNode } from "react";
import {
CheckCircleIcon,
ExclamationTriangleIcon,
ClockIcon,
XCircleIcon,
ServerIcon,
} from "@heroicons/react/24/outline";
import {
SUBSCRIPTION_STATUS,
SUBSCRIPTION_CYCLE,
type SubscriptionStatus,
type SubscriptionCycle,
} from "@customer-portal/domain/subscriptions";
export type SubscriptionStatusVariant = "success" | "info" | "warning" | "neutral" | "error";
const STATUS_ICON_MAP: Record<SubscriptionStatus, ReactNode> = {
[SUBSCRIPTION_STATUS.ACTIVE]: <CheckCircleIcon className="h-6 w-6 text-green-500" />,
[SUBSCRIPTION_STATUS.INACTIVE]: <ServerIcon className="h-6 w-6 text-gray-500" />,
[SUBSCRIPTION_STATUS.PENDING]: <ClockIcon className="h-6 w-6 text-blue-500" />,
[SUBSCRIPTION_STATUS.SUSPENDED]: <ExclamationTriangleIcon className="h-6 w-6 text-yellow-500" />,
[SUBSCRIPTION_STATUS.TERMINATED]: <XCircleIcon className="h-6 w-6 text-red-500" />,
[SUBSCRIPTION_STATUS.CANCELLED]: <XCircleIcon className="h-6 w-6 text-gray-500" />,
[SUBSCRIPTION_STATUS.COMPLETED]: <CheckCircleIcon className="h-6 w-6 text-green-500" />,
};
const STATUS_VARIANT_MAP: Record<SubscriptionStatus, SubscriptionStatusVariant> = {
[SUBSCRIPTION_STATUS.ACTIVE]: "success",
[SUBSCRIPTION_STATUS.INACTIVE]: "neutral",
[SUBSCRIPTION_STATUS.PENDING]: "info",
[SUBSCRIPTION_STATUS.SUSPENDED]: "warning",
[SUBSCRIPTION_STATUS.TERMINATED]: "error",
[SUBSCRIPTION_STATUS.CANCELLED]: "neutral",
[SUBSCRIPTION_STATUS.COMPLETED]: "success",
};
export function getSubscriptionStatusIcon(status: SubscriptionStatus): ReactNode {
return STATUS_ICON_MAP[status] ?? <ServerIcon className="h-6 w-6 text-gray-500" />;
}
export function getSubscriptionStatusVariant(status: SubscriptionStatus): SubscriptionStatusVariant {
return STATUS_VARIANT_MAP[status] ?? "neutral";
}
const BILLING_LABEL_MAP: Partial<Record<SubscriptionCycle, string>> = {
[SUBSCRIPTION_CYCLE.MONTHLY]: "Monthly Billing",
[SUBSCRIPTION_CYCLE.QUARTERLY]: "Quarterly Billing",
[SUBSCRIPTION_CYCLE.SEMI_ANNUALLY]: "Semi-Annual Billing",
[SUBSCRIPTION_CYCLE.ANNUALLY]: "Annual Billing",
[SUBSCRIPTION_CYCLE.BIENNIALLY]: "Biennial Billing",
[SUBSCRIPTION_CYCLE.TRIENNIALLY]: "Triennial Billing",
[SUBSCRIPTION_CYCLE.ONE_TIME]: "One-time Payment",
[SUBSCRIPTION_CYCLE.FREE]: "Free Plan",
};
export function getBillingCycleLabel(cycle: SubscriptionCycle): string {
return BILLING_LABEL_MAP[cycle] ?? cycle;
}

View File

@ -9,10 +9,7 @@ import Link from "next/link";
import {
ArrowLeftIcon,
ServerIcon,
CheckCircleIcon,
ExclamationTriangleIcon,
ClockIcon,
XCircleIcon,
CalendarIcon,
DocumentTextIcon,
} from "@heroicons/react/24/outline";
@ -23,6 +20,11 @@ import { Formatting } from "@customer-portal/domain/toolkit";
const { formatCurrency: sharedFormatCurrency } = Formatting;
import { SimManagementSection } from "@/features/sim-management";
import {
getBillingCycleLabel,
getSubscriptionStatusIcon,
getSubscriptionStatusVariant,
} from "@/features/subscriptions/utils/status-presenters";
export function SubscriptionDetailContainer() {
const params = useParams();
@ -54,23 +56,6 @@ export function SubscriptionDetailContainer() {
return;
}, [searchParams]);
const getStatusIcon = (status: string) => {
switch (status) {
case "Active":
return <CheckCircleIcon className="h-6 w-6 text-green-500" />;
case "Suspended":
return <ExclamationTriangleIcon className="h-6 w-6 text-yellow-500" />;
case "Terminated":
return <XCircleIcon className="h-6 w-6 text-red-500" />;
case "Cancelled":
return <XCircleIcon className="h-6 w-6 text-gray-500" />;
case "Pending":
return <ClockIcon className="h-6 w-6 text-blue-500" />;
default:
return <ServerIcon className="h-6 w-6 text-gray-500" />;
}
};
const formatDate = (dateString: string | undefined) => {
if (!dateString) return "N/A";
try {
@ -153,17 +138,10 @@ export function SubscriptionDetailContainer() {
<DetailHeader
title="Subscription Details"
subtitle="Service subscription information"
leftIcon={getStatusIcon(subscription.status)}
leftIcon={getSubscriptionStatusIcon(subscription.status)}
status={{
label: subscription.status,
variant:
subscription.status === "Active"
? "success"
: subscription.status === "Suspended"
? "warning"
: ["Cancelled", "Terminated"].includes(subscription.status)
? "neutral"
: "info",
variant: getSubscriptionStatusVariant(subscription.status),
}}
/>
<div className="pt-4">
@ -176,7 +154,9 @@ export function SubscriptionDetailContainer() {
<p className="text-2xl font-bold text-gray-900">
{formatCurrency(subscription.amount)}
</p>
<span className="text-sm text-gray-500">{subscription.cycle}</span>
<span className="text-sm text-gray-500">
{getBillingCycleLabel(subscription.cycle)}
</span>
</div>
</div>
<div>

View File

@ -11,11 +11,17 @@ import { AsyncBlock } from "@/components/molecules/AsyncBlock/AsyncBlock";
import { SubscriptionTable } from "@/features/subscriptions/components/SubscriptionTable";
import { ServerIcon, CheckCircleIcon, XCircleIcon } from "@heroicons/react/24/outline";
import { useSubscriptions, useSubscriptionStats } from "@/features/subscriptions/hooks";
import type { Subscription } from "@customer-portal/domain/subscriptions";
import {
SUBSCRIPTION_STATUS,
type Subscription,
type SubscriptionStatus,
} from "@customer-portal/domain/subscriptions";
const SUBSCRIPTION_STATUS_OPTIONS = Object.values(SUBSCRIPTION_STATUS) as SubscriptionStatus[];
export function SubscriptionsListContainer() {
const [searchTerm, setSearchTerm] = useState("");
const [statusFilter, setStatusFilter] = useState("all");
const [statusFilter, setStatusFilter] = useState<SubscriptionStatus | "all">("all");
const {
data: subscriptionData,
@ -40,12 +46,16 @@ export function SubscriptionsListContainer() {
});
}, [subscriptions, searchTerm]);
const statusFilterOptions = [
{ value: "all", label: "All Status" },
{ value: "Active", label: "Active" },
{ value: "Completed", label: "Completed" },
{ value: "Cancelled", label: "Cancelled" },
];
const statusFilterOptions = useMemo(
() => [
{ value: "all" as const, label: "All Status" },
...SUBSCRIPTION_STATUS_OPTIONS.map(status => ({
value: status,
label: status,
})),
],
[]
);
if (isLoading || error) {
return (
@ -142,7 +152,7 @@ export function SubscriptionsListContainer() {
onSearchChange={setSearchTerm}
searchPlaceholder="Search subscriptions..."
filterValue={statusFilter}
onFilterChange={setStatusFilter}
onFilterChange={value => setStatusFilter(value as SubscriptionStatus | "all")}
filterOptions={statusFilterOptions}
filterLabel="Filter by status"
/>

View File

@ -0,0 +1,24 @@
"use client";
import { useMemo } from "react";
import { useQuery } from "@tanstack/react-query";
import { useAuthSession } from "@/features/auth/services/auth.store";
import { apiClient, getDataOrThrow, queryKeys } from "@/lib/api";
import type { SupportCaseFilter, SupportCaseList } from "@customer-portal/domain/support";
export function useSupportCases(filters?: SupportCaseFilter) {
const { isAuthenticated } = useAuthSession();
const queryFilters = useMemo(() => filters ?? {}, [filters]);
return useQuery<SupportCaseList>({
queryKey: queryKeys.support.cases(queryFilters),
queryFn: async () => {
const response = await apiClient.GET<SupportCaseList>("/api/support/cases", {
params: Object.keys(queryFilters).length > 0 ? { query: queryFilters } : undefined,
});
return getDataOrThrow(response, "Failed to load support cases");
},
enabled: isAuthenticated,
staleTime: 60 * 1000,
});
}

View File

@ -1,6 +1,6 @@
"use client";
import { useEffect, useState } from "react";
import { useDeferredValue, useMemo, useState } from "react";
import Link from "next/link";
import {
ChatBubbleLeftRightIcon,
@ -14,162 +14,106 @@ import {
UserIcon,
} from "@heroicons/react/24/outline";
import { format } from "date-fns";
interface SupportCase {
id: number;
subject: string;
status: "Open" | "In Progress" | "Waiting on Customer" | "Resolved" | "Closed";
priority: "Low" | "Medium" | "High" | "Critical";
category: "Technical" | "Billing" | "General" | "Feature Request";
createdAt: string;
updatedAt: string;
lastReply?: string;
description: string;
assignedTo?: string;
}
import { useSupportCases } from "@/features/support/hooks/useSupportCases";
import {
SUPPORT_CASE_PRIORITY,
SUPPORT_CASE_STATUS,
type SupportCaseFilter,
type SupportCasePriority,
type SupportCaseStatus,
} from "@customer-portal/domain/support";
export function SupportCasesView() {
const [cases, setCases] = useState<SupportCase[]>([]);
const [loading, setLoading] = useState(true);
const [searchTerm, setSearchTerm] = useState("");
const [statusFilter, setStatusFilter] = useState("all");
const [priorityFilter, setPriorityFilter] = useState("all");
const [statusFilter, setStatusFilter] = useState<SupportCaseStatus | "all">("all");
const [priorityFilter, setPriorityFilter] = useState<SupportCasePriority | "all">("all");
const deferredSearchTerm = useDeferredValue(searchTerm);
// Mock data - would normally come from API
useEffect(() => {
const mockCases: SupportCase[] = [
{
id: 12001,
subject: "VPS Performance Issues",
status: "In Progress",
priority: "High",
category: "Technical",
createdAt: "2025-08-14T10:30:00Z",
updatedAt: "2025-08-15T14:20:00Z",
lastReply: "2025-08-15T14:20:00Z",
description: "Experiencing slow response times on VPS server, CPU usage appears high.",
assignedTo: "Technical Support Team",
},
{
id: 12002,
subject: "Billing Question - Invoice #12345",
status: "Waiting on Customer",
priority: "Medium",
category: "Billing",
createdAt: "2025-08-13T16:45:00Z",
updatedAt: "2025-08-14T09:30:00Z",
lastReply: "2025-08-14T09:30:00Z",
description: "Need clarification on charges in recent invoice.",
assignedTo: "Billing Department",
},
{
id: 12003,
subject: "SSL Certificate Installation",
status: "Resolved",
priority: "Low",
category: "Technical",
createdAt: "2025-08-12T08:15:00Z",
updatedAt: "2025-08-12T15:45:00Z",
lastReply: "2025-08-12T15:45:00Z",
description: "Request assistance with SSL certificate installation on shared hosting.",
assignedTo: "Technical Support Team",
},
{
id: 12004,
subject: "Feature Request: Control Panel Enhancement",
status: "Open",
priority: "Low",
category: "Feature Request",
createdAt: "2025-08-11T13:20:00Z",
updatedAt: "2025-08-11T13:20:00Z",
description: "Would like to see improved backup management in the control panel.",
assignedTo: "Development Team",
},
{
id: 12005,
subject: "Server Migration Assistance",
status: "Closed",
priority: "Medium",
category: "Technical",
createdAt: "2025-08-10T11:00:00Z",
updatedAt: "2025-08-11T17:30:00Z",
lastReply: "2025-08-11T17:30:00Z",
description: "Need help migrating website from old server to new VPS.",
assignedTo: "Migration Team",
},
];
const queryFilters = useMemo(() => {
const nextFilters: SupportCaseFilter = {};
if (statusFilter !== "all") {
nextFilters.status = statusFilter;
}
if (priorityFilter !== "all") {
nextFilters.priority = priorityFilter;
}
if (deferredSearchTerm.trim()) {
nextFilters.search = deferredSearchTerm.trim();
}
return nextFilters;
}, [statusFilter, priorityFilter, deferredSearchTerm]);
const timeout = setTimeout(() => {
setCases(mockCases);
setLoading(false);
}, 500);
const { data, isLoading, error } = useSupportCases(queryFilters);
const cases = data?.cases ?? [];
const summary = data?.summary ?? { total: 0, open: 0, highPriority: 0, resolved: 0 };
return () => clearTimeout(timeout);
}, []);
const statusFilterOptions = useMemo(
() => [
{ value: "all" as const, label: "All Statuses" },
...Object.values(SUPPORT_CASE_STATUS).map(status => ({ value: status, label: status })),
],
[]
);
// Filter cases based on search, status, and priority
const filteredCases = cases.filter(supportCase => {
const matchesSearch =
supportCase.subject.toLowerCase().includes(searchTerm.toLowerCase()) ||
supportCase.description.toLowerCase().includes(searchTerm.toLowerCase()) ||
supportCase.id.toString().includes(searchTerm);
const matchesStatus =
statusFilter === "all" ||
supportCase.status.toLowerCase().replace(/\s+/g, "").includes(statusFilter.toLowerCase());
const matchesPriority =
priorityFilter === "all" ||
supportCase.priority.toLowerCase() === priorityFilter.toLowerCase();
return matchesSearch && matchesStatus && matchesPriority;
});
const priorityFilterOptions = useMemo(
() => [
{ value: "all" as const, label: "All Priorities" },
...Object.values(SUPPORT_CASE_PRIORITY).map(priority => ({
value: priority,
label: priority,
})),
],
[]
);
const getStatusIcon = (status: string) => {
const getStatusIcon = (status: SupportCaseStatus) => {
switch (status) {
case "Resolved":
case "Closed":
case SUPPORT_CASE_STATUS.RESOLVED:
case SUPPORT_CASE_STATUS.CLOSED:
return <CheckCircleIcon className="h-5 w-5 text-green-500" />;
case "In Progress":
case SUPPORT_CASE_STATUS.IN_PROGRESS:
return <ClockIcon className="h-5 w-5 text-blue-500" />;
case "Waiting on Customer":
case SUPPORT_CASE_STATUS.WAITING_ON_CUSTOMER:
return <ExclamationTriangleIcon className="h-5 w-5 text-yellow-500" />;
case "Open":
case SUPPORT_CASE_STATUS.OPEN:
return <ChatBubbleLeftRightIcon className="h-5 w-5 text-gray-500" />;
default:
return <ChatBubbleLeftRightIcon className="h-5 w-5 text-gray-500" />;
}
};
const getStatusColor = (status: string) => {
const getStatusColor = (status: SupportCaseStatus) => {
switch (status) {
case "Resolved":
case "Closed":
case SUPPORT_CASE_STATUS.RESOLVED:
case SUPPORT_CASE_STATUS.CLOSED:
return "bg-green-100 text-green-800";
case "In Progress":
case SUPPORT_CASE_STATUS.IN_PROGRESS:
return "bg-blue-100 text-blue-800";
case "Waiting on Customer":
case SUPPORT_CASE_STATUS.WAITING_ON_CUSTOMER:
return "bg-yellow-100 text-yellow-800";
case "Open":
case SUPPORT_CASE_STATUS.OPEN:
return "bg-gray-100 text-gray-800";
default:
return "bg-gray-100 text-gray-800";
}
};
const getPriorityColor = (priority: string) => {
const getPriorityColor = (priority: SupportCasePriority) => {
switch (priority) {
case "Critical":
case SUPPORT_CASE_PRIORITY.CRITICAL:
return "bg-red-100 text-red-800";
case "High":
case SUPPORT_CASE_PRIORITY.HIGH:
return "bg-orange-100 text-orange-800";
case "Medium":
case SUPPORT_CASE_PRIORITY.MEDIUM:
return "bg-yellow-100 text-yellow-800";
case "Low":
case SUPPORT_CASE_PRIORITY.LOW:
return "bg-green-100 text-green-800";
default:
return "bg-gray-100 text-gray-800";
}
};
if (loading) {
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
<div className="text-center">
@ -201,6 +145,12 @@ export function SupportCasesView() {
</div>
</div>
{error && (
<div className="mb-6 bg-red-50 border border-red-200 rounded-lg p-4 text-sm text-red-800">
{error instanceof Error ? error.message : "Failed to load support cases"}
</div>
)}
{/* Stats Cards */}
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-4 mb-8">
<div className="bg-white overflow-hidden shadow rounded-lg">
@ -212,7 +162,7 @@ export function SupportCasesView() {
<div className="ml-5 w-0 flex-1">
<dl>
<dt className="text-sm font-medium text-gray-500 truncate">Total Cases</dt>
<dd className="text-lg font-medium text-gray-900">{cases.length}</dd>
<dd className="text-lg font-medium text-gray-900">{summary.total}</dd>
</dl>
</div>
</div>
@ -228,13 +178,7 @@ export function SupportCasesView() {
<div className="ml-5 w-0 flex-1">
<dl>
<dt className="text-sm font-medium text-gray-500 truncate">Open Cases</dt>
<dd className="text-lg font-medium text-gray-900">
{
cases.filter(caseItem =>
["Open", "In Progress", "Waiting on Customer"].includes(caseItem.status)
).length
}
</dd>
<dd className="text-lg font-medium text-gray-900">{summary.open}</dd>
</dl>
</div>
</div>
@ -250,12 +194,7 @@ export function SupportCasesView() {
<div className="ml-5 w-0 flex-1">
<dl>
<dt className="text-sm font-medium text-gray-500 truncate">High Priority</dt>
<dd className="text-lg font-medium text-gray-900">
{
cases.filter(caseItem => ["High", "Critical"].includes(caseItem.priority))
.length
}
</dd>
<dd className="text-lg font-medium text-gray-900">{summary.highPriority}</dd>
</dl>
</div>
</div>
@ -271,12 +210,7 @@ export function SupportCasesView() {
<div className="ml-5 w-0 flex-1">
<dl>
<dt className="text-sm font-medium text-gray-500 truncate">Resolved</dt>
<dd className="text-lg font-medium text-gray-900">
{
cases.filter(caseItem => ["Resolved", "Closed"].includes(caseItem.status))
.length
}
</dd>
<dd className="text-lg font-medium text-gray-900">{summary.resolved}</dd>
</dl>
</div>
</div>
@ -306,15 +240,16 @@ export function SupportCasesView() {
<div className="relative">
<select
value={statusFilter}
onChange={event => setStatusFilter(event.target.value)}
onChange={event =>
setStatusFilter(event.target.value as SupportCaseStatus | "all")
}
className="block w-full px-3 py-2 border border-gray-300 rounded-md leading-5 bg-white focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-blue-500"
>
<option value="all">All Statuses</option>
<option value="open">Open</option>
<option value="inprogress">In Progress</option>
<option value="waiting">Waiting on Customer</option>
<option value="resolved">Resolved</option>
<option value="closed">Closed</option>
{statusFilterOptions.map(option => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
@ -322,14 +257,16 @@ export function SupportCasesView() {
<div className="relative">
<select
value={priorityFilter}
onChange={event => setPriorityFilter(event.target.value)}
onChange={event =>
setPriorityFilter(event.target.value as SupportCasePriority | "all")
}
className="block w-full px-3 py-2 border border-gray-300 rounded-md leading-5 bg-white focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-blue-500"
>
<option value="all">All Priorities</option>
<option value="critical">Critical</option>
<option value="high">High</option>
<option value="medium">Medium</option>
<option value="low">Low</option>
{priorityFilterOptions.map(option => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
</div>
@ -339,7 +276,7 @@ export function SupportCasesView() {
{/* Cases List */}
<div className="bg-white shadow overflow-hidden sm:rounded-md">
<ul className="divide-y divide-gray-200">
{filteredCases.map(supportCase => (
{cases.map(supportCase => (
<li key={supportCase.id}>
<Link
href={`/support/cases/${supportCase.id}`}
@ -400,7 +337,7 @@ export function SupportCasesView() {
</ul>
{/* Empty State */}
{filteredCases.length === 0 && (
{cases.length === 0 && (
<div className="text-center py-12">
<ChatBubbleLeftRightIcon className="mx-auto h-12 w-12 text-gray-400" />
<h3 className="mt-2 text-sm font-medium text-gray-900">No support cases found</h3>

View File

@ -102,6 +102,9 @@ export const queryKeys = {
list: () => ["orders", "list"] as const,
detail: (id: string | number) => ["orders", "detail", String(id)] as const,
},
support: {
cases: (params?: Record<string, unknown>) => ["support", "cases", params] as const,
},
currency: {
default: () => ["currency", "default"] as const,
},

View File

@ -59,7 +59,7 @@ export class OrderEventsService {
});
}
publish(orderId: string, update: OrderUpdateEvent): void {
publish(orderId: string, update: OrderUpdateEventPayload): void {
const event = this.buildEvent("order.update", update);
currentObservers.forEach(observer => observer.next(event));
}
@ -155,4 +155,3 @@ WebSockets provide bidirectional communication but are more complex to implement
**Status**: Phase 1 & 2 Complete, Phase 3 (monitoring) in progress
**Last Updated**: November 2025
**Owner**: Engineering Team

View File

@ -8,6 +8,7 @@ export * as Billing from "./billing/index";
export * as Subscriptions from "./subscriptions/index";
export * as Payments from "./payments/index";
export * as Sim from "./sim/index";
export * as Support from "./support/index";
export * as Orders from "./orders/index";
export * as Catalog from "./catalog/index";
export * as Common from "./common/index";

View File

@ -0,0 +1,26 @@
/**
* Order Events - Shared Contracts
*
* Shared event payloads for Server-Sent Events used by both the BFF
* and the frontend. Keeping these definitions in the domain package
* guarantees both layers stay in sync when the payload evolves.
*/
export type OrderUpdateStage = "started" | "in_progress" | "completed" | "failed";
export interface OrderStreamEvent<T extends string = string, P = unknown> {
event: T;
data: P;
}
export interface OrderUpdateEventPayload {
orderId: string;
status?: string;
activationStatus?: string | null;
message?: string;
reason?: string;
stage?: OrderUpdateStage;
source?: string;
timestamp: string;
payload?: Record<string, unknown> | null;
}

View File

@ -40,6 +40,7 @@ export * from "./validation";
// Utilities
export * from "./utils";
export * from "./events";
export {
buildSimOrderConfigurations,
normalizeBillingCycle,

View File

@ -0,0 +1,18 @@
import { SIM_STATUS } from "./contract";
import type { SimStatus } from "./schema";
export function canManageActiveSim(status: SimStatus): boolean {
return status === SIM_STATUS.ACTIVE;
}
export function canReissueEsim(status: SimStatus): boolean {
return canManageActiveSim(status);
}
export function canCancelSim(status: SimStatus): boolean {
return canManageActiveSim(status);
}
export function canTopUpSim(status: SimStatus): boolean {
return canManageActiveSim(status);
}

View File

@ -15,6 +15,7 @@ export * from "./lifecycle";
// Validation functions
export * from "./validation";
export * from "./helpers";
// Re-export types for convenience
export type {

View File

@ -0,0 +1,27 @@
/**
* Support Domain - Contract
*
* Constants for support case statuses, priorities, and categories.
*/
export const SUPPORT_CASE_STATUS = {
OPEN: "Open",
IN_PROGRESS: "In Progress",
WAITING_ON_CUSTOMER: "Waiting on Customer",
RESOLVED: "Resolved",
CLOSED: "Closed",
} as const;
export const SUPPORT_CASE_PRIORITY = {
LOW: "Low",
MEDIUM: "Medium",
HIGH: "High",
CRITICAL: "Critical",
} as const;
export const SUPPORT_CASE_CATEGORY = {
TECHNICAL: "Technical",
BILLING: "Billing",
GENERAL: "General",
FEATURE_REQUEST: "Feature Request",
} as const;

View File

@ -0,0 +1,7 @@
export {
SUPPORT_CASE_STATUS,
SUPPORT_CASE_PRIORITY,
SUPPORT_CASE_CATEGORY,
} from "./contract";
export * from "./schema";

View File

@ -0,0 +1,74 @@
import { z } from "zod";
import {
SUPPORT_CASE_STATUS,
SUPPORT_CASE_PRIORITY,
SUPPORT_CASE_CATEGORY,
} from "./contract";
const supportCaseStatusValues = [
SUPPORT_CASE_STATUS.OPEN,
SUPPORT_CASE_STATUS.IN_PROGRESS,
SUPPORT_CASE_STATUS.WAITING_ON_CUSTOMER,
SUPPORT_CASE_STATUS.RESOLVED,
SUPPORT_CASE_STATUS.CLOSED,
] as const;
const supportCasePriorityValues = [
SUPPORT_CASE_PRIORITY.LOW,
SUPPORT_CASE_PRIORITY.MEDIUM,
SUPPORT_CASE_PRIORITY.HIGH,
SUPPORT_CASE_PRIORITY.CRITICAL,
] as const;
const supportCaseCategoryValues = [
SUPPORT_CASE_CATEGORY.TECHNICAL,
SUPPORT_CASE_CATEGORY.BILLING,
SUPPORT_CASE_CATEGORY.GENERAL,
SUPPORT_CASE_CATEGORY.FEATURE_REQUEST,
] as const;
export const supportCaseStatusSchema = z.enum(supportCaseStatusValues);
export const supportCasePrioritySchema = z.enum(supportCasePriorityValues);
export const supportCaseCategorySchema = z.enum(supportCaseCategoryValues);
export const supportCaseSchema = z.object({
id: z.number().int().positive(),
subject: z.string().min(1),
status: supportCaseStatusSchema,
priority: supportCasePrioritySchema,
category: supportCaseCategorySchema,
createdAt: z.string(),
updatedAt: z.string(),
lastReply: z.string().optional(),
description: z.string(),
assignedTo: z.string().optional(),
});
export const supportCaseSummarySchema = z.object({
total: z.number().int().nonnegative(),
open: z.number().int().nonnegative(),
highPriority: z.number().int().nonnegative(),
resolved: z.number().int().nonnegative(),
});
export const supportCaseListSchema = z.object({
cases: z.array(supportCaseSchema),
summary: supportCaseSummarySchema,
});
export const supportCaseFilterSchema = z
.object({
status: supportCaseStatusSchema.optional(),
priority: supportCasePrioritySchema.optional(),
category: supportCaseCategorySchema.optional(),
search: z.string().trim().min(1).optional(),
})
.default({});
export type SupportCaseStatus = z.infer<typeof supportCaseStatusSchema>;
export type SupportCasePriority = z.infer<typeof supportCasePrioritySchema>;
export type SupportCaseCategory = z.infer<typeof supportCaseCategorySchema>;
export type SupportCase = z.infer<typeof supportCaseSchema>;
export type SupportCaseSummary = z.infer<typeof supportCaseSummarySchema>;
export type SupportCaseList = z.infer<typeof supportCaseListSchema>;
export type SupportCaseFilter = z.infer<typeof supportCaseFilterSchema>;