From 74d469ce223bcea1069b84ca26ca024269e674b7 Mon Sep 17 00:00:00 2001
From: barsa
Date: Mon, 29 Dec 2025 17:17:36 +0900
Subject: [PATCH] Refactor API Client Error Handling and Update Date Formatting
- Enhanced error handling in various API client services by integrating `redactForLogs` for sensitive data protection in logs.
- Updated date formatting across multiple components and services to utilize `formatIsoDate` and `formatIsoRelative`, improving consistency and readability.
- Removed unused imports and optimized code structure for better maintainability.
- Streamlined retry logic in the Freebit and WHMCS services to improve resilience and error management during API requests.
---
.github/actions/setup-node-pnpm/action.yml | 6 -
apps/bff/src/core/logging/redaction.util.ts | 109 ++++++
.../services/whmcs-request-queue.service.ts | 9 +-
.../services/freebit-client.service.ts | 344 ++++++++----------
.../services/whmcs-http-client.service.ts | 129 +------
.../src/features/auth/services/auth.store.ts | 15 +-
.../InvoiceDetail/InvoiceHeader.tsx | 11 +-
.../InvoiceDetail/InvoiceSummaryBar.tsx | 16 +-
.../components/InvoiceTable/InvoiceTable.tsx | 9 +-
.../dashboard/components/ActivityTimeline.tsx | 4 +-
.../marketing/views/PublicLandingView.tsx | 188 ----------
.../components/NotificationItem.tsx | 7 +-
.../features/orders/hooks/useOrderUpdates.ts | 19 +-
.../features/orders/utils/order-display.ts | 12 +-
.../features/orders/utils/order-presenters.ts | 14 +-
.../src/features/orders/utils/summary.ts | 17 +
.../src/features/orders/views/OrderDetail.tsx | 24 +-
.../components/sim/SimConfigureView.tsx | 3 +-
.../features/services/views/InternetPlans.tsx | 4 +-
.../sim/components/DataUsageChart.tsx | 6 +-
.../sim/components/SimDetailsCard.tsx | 13 +-
.../sim/components/SimManagementSection.tsx | 11 +-
.../components/SubscriptionCard.tsx | 10 +-
.../components/SubscriptionDetails.tsx | 10 +-
.../SubscriptionTable/SubscriptionTable.tsx | 10 +-
.../views/SubscriptionDetail.tsx | 11 +-
.../support/views/SupportCaseDetailView.tsx | 11 +-
.../support/views/SupportCasesView.tsx | 5 +-
.../support/views/SupportHomeView.tsx | 4 +-
apps/portal/src/lib/api/index.ts | 47 +--
apps/portal/src/lib/api/runtime/client.ts | 18 +-
.../src/lib/api/runtime/error-message.ts | 35 ++
apps/portal/src/lib/api/unauthorized.ts | 33 ++
apps/portal/src/lib/utils/date.ts | 72 ++++
apps/portal/src/lib/utils/index.ts | 9 +
35 files changed, 550 insertions(+), 695 deletions(-)
create mode 100644 apps/bff/src/core/logging/redaction.util.ts
delete mode 100644 apps/portal/src/features/marketing/views/PublicLandingView.tsx
create mode 100644 apps/portal/src/features/orders/utils/summary.ts
create mode 100644 apps/portal/src/lib/api/runtime/error-message.ts
create mode 100644 apps/portal/src/lib/api/unauthorized.ts
create mode 100644 apps/portal/src/lib/utils/date.ts
diff --git a/.github/actions/setup-node-pnpm/action.yml b/.github/actions/setup-node-pnpm/action.yml
index 317754ed..55453446 100644
--- a/.github/actions/setup-node-pnpm/action.yml
+++ b/.github/actions/setup-node-pnpm/action.yml
@@ -6,18 +6,12 @@ inputs:
description: Node.js version to use
required: false
default: "22"
- pnpm-version:
- description: pnpm version to use
- required: false
- default: "10.25.0"
runs:
using: composite
steps:
- name: Setup pnpm
uses: pnpm/action-setup@v4
- with:
- version: ${{ inputs.pnpm-version }}
- name: Setup Node.js
uses: actions/setup-node@v4
diff --git a/apps/bff/src/core/logging/redaction.util.ts b/apps/bff/src/core/logging/redaction.util.ts
new file mode 100644
index 00000000..108f0ab3
--- /dev/null
+++ b/apps/bff/src/core/logging/redaction.util.ts
@@ -0,0 +1,109 @@
+/**
+ * Log redaction utilities (BFF)
+ *
+ * Keep logs production-safe by redacting common sensitive fields and trimming large blobs.
+ * Prefer allowlists at call sites for best safety; this helper provides a safe baseline.
+ */
+
+export type RedactOptions = {
+ /**
+ * Extra keys to redact (case-insensitive, substring match).
+ * Example: ["email", "address"]
+ */
+ extraSensitiveKeys?: readonly string[];
+ /** Max string length to keep (defaults to 500). */
+ maxStringLength?: number;
+ /** Max depth to traverse (defaults to 6). */
+ maxDepth?: number;
+};
+
+const DEFAULT_SENSITIVE_KEY_PARTS = [
+ "password",
+ "passwd",
+ "secret",
+ "token",
+ "key",
+ "auth",
+ "authorization",
+ "cookie",
+ "set-cookie",
+ "session",
+ "sid",
+ "credit",
+ "card",
+ "cvv",
+ "cvc",
+ "ssn",
+ "social",
+ "email",
+ "phone",
+ "address",
+] as const;
+
+const REDACTED = "[REDACTED]";
+const TRUNCATED = "[TRUNCATED]";
+
+function shouldRedactKey(key: string, extra: readonly string[]): boolean {
+ const lower = key.toLowerCase();
+ for (const part of DEFAULT_SENSITIVE_KEY_PARTS) {
+ if (lower.includes(part)) return true;
+ }
+ for (const part of extra) {
+ if (lower.includes(part.toLowerCase())) return true;
+ }
+ return false;
+}
+
+function truncateString(value: string, max: number): string {
+ if (value.length <= max) return value;
+ return value.slice(0, max) + TRUNCATED;
+}
+
+export function redactForLogs(value: T, options: RedactOptions = {}): T {
+ const maxStringLength = options.maxStringLength ?? 500;
+ const maxDepth = options.maxDepth ?? 6;
+ const extraSensitiveKeys = options.extraSensitiveKeys ?? [];
+
+ const seen = new WeakSet
- {yenFormatter.format(totals.monthlyTotal)}
+ {Formatting.formatCurrency(totals.monthlyTotal)}
)}
@@ -345,7 +335,7 @@ export function OrderDetailContainer() {
One-Time
- {yenFormatter.format(totals.oneTimeTotal)}
+ {Formatting.formatCurrency(totals.oneTimeTotal)}
)}
@@ -415,7 +405,7 @@ export function OrderDetailContainer() {
className="whitespace-nowrap text-lg"
>
- {yenFormatter.format(charge.amount)}
+ {Formatting.formatCurrency(charge.amount)}
{descriptor}
diff --git a/apps/portal/src/features/services/components/sim/SimConfigureView.tsx b/apps/portal/src/features/services/components/sim/SimConfigureView.tsx
index 6753acdd..cfe637bc 100644
--- a/apps/portal/src/features/services/components/sim/SimConfigureView.tsx
+++ b/apps/portal/src/features/services/components/sim/SimConfigureView.tsx
@@ -20,6 +20,7 @@ import {
} from "@heroicons/react/24/outline";
import type { UseSimConfigureResult } from "@/features/services/hooks/useSimConfigure";
import type { SimActivationFeeCatalogItem } from "@customer-portal/domain/services";
+import { formatIsoMonthDay } from "@/lib/utils";
type Props = UseSimConfigureResult & {
onConfirm: () => void;
@@ -456,7 +457,7 @@ export function SimConfigureView({
Activation:
{activationType === "Scheduled" && scheduledActivationDate
- ? `${new Date(scheduledActivationDate).toLocaleDateString("en-US", { month: "short", day: "numeric" })}`
+ ? `${formatIsoMonthDay(scheduledActivationDate)}`
: activationType || "Not selected"}
diff --git a/apps/portal/src/features/services/views/InternetPlans.tsx b/apps/portal/src/features/services/views/InternetPlans.tsx
index 1a729b31..93dcc51f 100644
--- a/apps/portal/src/features/services/views/InternetPlans.tsx
+++ b/apps/portal/src/features/services/views/InternetPlans.tsx
@@ -27,7 +27,7 @@ import {
useRequestInternetEligibilityCheck,
} from "@/features/services/hooks";
import { useAuthSession } from "@/features/auth/services/auth.store";
-import { cn } from "@/lib/utils";
+import { cn, formatIsoDate } from "@/lib/utils";
type AutoRequestStatus = "idle" | "submitting" | "submitted" | "failed" | "missing_address";
@@ -705,7 +705,7 @@ export function InternetPlansContainer() {
{requestedAt && (
- Request submitted: {new Date(requestedAt).toLocaleDateString()}
+ Request submitted: {formatIsoDate(requestedAt)}
)}
diff --git a/apps/portal/src/features/sim/components/DataUsageChart.tsx b/apps/portal/src/features/sim/components/DataUsageChart.tsx
index 87dcb219..05387fb8 100644
--- a/apps/portal/src/features/sim/components/DataUsageChart.tsx
+++ b/apps/portal/src/features/sim/components/DataUsageChart.tsx
@@ -1,6 +1,7 @@
"use client";
import React from "react";
+import { formatIsoMonthDay } from "@/lib/utils";
import { ChartBarIcon, ExclamationTriangleIcon } from "@heroicons/react/24/outline";
export interface SimUsage {
@@ -197,10 +198,7 @@ export function DataUsageChart({
return (
- {new Date(day.date).toLocaleDateString("en-US", {
- month: "short",
- day: "numeric",
- })}
+ {formatIsoMonthDay(day.date)}
diff --git a/apps/portal/src/features/sim/components/SimDetailsCard.tsx b/apps/portal/src/features/sim/components/SimDetailsCard.tsx
index 2c9701ca..e8a776c5 100644
--- a/apps/portal/src/features/sim/components/SimDetailsCard.tsx
+++ b/apps/portal/src/features/sim/components/SimDetailsCard.tsx
@@ -11,6 +11,7 @@ import {
XCircleIcon,
} from "@heroicons/react/24/outline";
import type { SimDetails } from "@customer-portal/domain/sim";
+import { formatIsoDate } from "@/lib/utils";
// Re-export for backwards compatibility
export type { SimDetails };
@@ -81,16 +82,8 @@ export function SimDetailsCard({
};
const formatDate = (dateString: string) => {
- try {
- const date = new Date(dateString);
- return date.toLocaleDateString("en-US", {
- year: "numeric",
- month: "short",
- day: "numeric",
- });
- } catch {
- return dateString;
- }
+ const formatted = formatIsoDate(dateString, { fallback: dateString, dateStyle: "medium" });
+ return formatted === "Invalid date" ? dateString : formatted;
};
const formatQuota = (quotaMb: number) => {
diff --git a/apps/portal/src/features/sim/components/SimManagementSection.tsx b/apps/portal/src/features/sim/components/SimManagementSection.tsx
index ebb0b65f..56ac2fdb 100644
--- a/apps/portal/src/features/sim/components/SimManagementSection.tsx
+++ b/apps/portal/src/features/sim/components/SimManagementSection.tsx
@@ -13,9 +13,9 @@ import {
import { apiClient } from "@/lib/api";
import { useSubscription, useSubscriptionInvoices } from "@/features/subscriptions/hooks";
import { useCreateInvoiceSsoLink } from "@/features/billing/hooks/useBilling";
-import { format } from "date-fns";
import { Formatting } from "@customer-portal/domain/toolkit";
import { Button } from "@/components/atoms/button";
+import { formatIsoDate } from "@/lib/utils";
const { formatCurrency } = Formatting;
@@ -138,14 +138,7 @@ export function SimManagementSection({ subscriptionId }: SimManagementSectionPro
}
};
- const formatDate = (dateString: string | undefined) => {
- if (!dateString) return "N/A";
- try {
- return format(new Date(dateString), "MMM d yyyy");
- } catch {
- return "Invalid date";
- }
- };
+ const formatDate = (dateString: string | undefined) => formatIsoDate(dateString);
if (loading) {
return (
diff --git a/apps/portal/src/features/subscriptions/components/SubscriptionCard.tsx b/apps/portal/src/features/subscriptions/components/SubscriptionCard.tsx
index d5af14d8..ee77b043 100644
--- a/apps/portal/src/features/subscriptions/components/SubscriptionCard.tsx
+++ b/apps/portal/src/features/subscriptions/components/SubscriptionCard.tsx
@@ -1,7 +1,6 @@
"use client";
import { forwardRef } from "react";
-import { format } from "date-fns";
import { CalendarIcon, ArrowTopRightOnSquareIcon } from "@heroicons/react/24/outline";
import { StatusPill } from "@/components/atoms/status-pill";
import { Button } from "@/components/atoms/button";
@@ -9,7 +8,7 @@ import { SubCard } from "@/components/molecules/SubCard/SubCard";
import type { Subscription } from "@customer-portal/domain/subscriptions";
import { useFormatCurrency } from "@/lib/hooks/useFormatCurrency";
-import { cn } from "@/lib/utils";
+import { cn, formatIsoDate } from "@/lib/utils";
import {
getBillingCycleLabel,
getSubscriptionStatusIcon,
@@ -25,12 +24,7 @@ interface SubscriptionCardProps {
}
const formatDate = (dateString: string | undefined) => {
- if (!dateString) return "N/A";
- try {
- return format(new Date(dateString), "MMM d, yyyy");
- } catch {
- return "Invalid date";
- }
+ return formatIsoDate(dateString);
};
export const SubscriptionCard = forwardRef
(
diff --git a/apps/portal/src/features/subscriptions/components/SubscriptionDetails.tsx b/apps/portal/src/features/subscriptions/components/SubscriptionDetails.tsx
index a83a4d7c..a4b415b9 100644
--- a/apps/portal/src/features/subscriptions/components/SubscriptionDetails.tsx
+++ b/apps/portal/src/features/subscriptions/components/SubscriptionDetails.tsx
@@ -1,7 +1,6 @@
"use client";
import { forwardRef } from "react";
-import { format } from "date-fns";
import {
ServerIcon,
CalendarIcon,
@@ -14,7 +13,7 @@ import { SubCard } from "@/components/molecules/SubCard/SubCard";
import type { Subscription } from "@customer-portal/domain/subscriptions";
import { useFormatCurrency } from "@/lib/hooks/useFormatCurrency";
-import { cn } from "@/lib/utils";
+import { cn, formatIsoDate } from "@/lib/utils";
import {
getBillingCycleLabel,
getSubscriptionStatusIcon,
@@ -28,12 +27,7 @@ interface SubscriptionDetailsProps {
}
const formatDate = (dateString: string | undefined) => {
- if (!dateString) return "N/A";
- try {
- return format(new Date(dateString), "MMM d, yyyy");
- } catch {
- return "Invalid date";
- }
+ return formatIsoDate(dateString);
};
const isSimService = (productName: string) => {
diff --git a/apps/portal/src/features/subscriptions/components/SubscriptionTable/SubscriptionTable.tsx b/apps/portal/src/features/subscriptions/components/SubscriptionTable/SubscriptionTable.tsx
index 059f6ff9..0d65bd6d 100644
--- a/apps/portal/src/features/subscriptions/components/SubscriptionTable/SubscriptionTable.tsx
+++ b/apps/portal/src/features/subscriptions/components/SubscriptionTable/SubscriptionTable.tsx
@@ -2,7 +2,6 @@
import { useCallback, useMemo } from "react";
import { useRouter } from "next/navigation";
-import { format } from "date-fns";
import {
ServerIcon,
CheckCircleIcon,
@@ -18,7 +17,7 @@ import {
type Subscription,
} from "@customer-portal/domain/subscriptions";
import { Formatting } from "@customer-portal/domain/toolkit";
-import { cn } from "@/lib/utils";
+import { cn, formatIsoDate } from "@/lib/utils";
const { formatCurrency } = Formatting;
@@ -80,12 +79,7 @@ const getBillingPeriodText = (cycle: string): string => {
};
const formatDate = (dateString: string | undefined) => {
- if (!dateString) return "N/A";
- try {
- return format(new Date(dateString), "MMM d, yyyy");
- } catch {
- return "N/A";
- }
+ return formatIsoDate(dateString);
};
export function SubscriptionTable({
diff --git a/apps/portal/src/features/subscriptions/views/SubscriptionDetail.tsx b/apps/portal/src/features/subscriptions/views/SubscriptionDetail.tsx
index 0176ab52..d22e546c 100644
--- a/apps/portal/src/features/subscriptions/views/SubscriptionDetail.tsx
+++ b/apps/portal/src/features/subscriptions/views/SubscriptionDetail.tsx
@@ -10,12 +10,12 @@ import {
GlobeAltIcon,
XCircleIcon,
} from "@heroicons/react/24/outline";
-import { format } from "date-fns";
import { useSubscription } from "@/features/subscriptions/hooks";
import { InvoicesList } from "@/features/billing/components/InvoiceList/InvoiceList";
import { Formatting } from "@customer-portal/domain/toolkit";
import { PageLayout } from "@/components/templates/PageLayout";
import { StatusPill } from "@/components/atoms/status-pill";
+import { formatIsoDate } from "@/lib/utils";
const { formatCurrency: sharedFormatCurrency } = Formatting;
import { SimManagementSection } from "@/features/sim";
@@ -48,14 +48,7 @@ export function SubscriptionDetailContainer() {
return;
}, [searchParams]);
- const formatDate = (dateString: string | undefined) => {
- if (!dateString) return "N/A";
- try {
- return format(new Date(dateString), "MMM d, yyyy");
- } catch {
- return "Invalid date";
- }
- };
+ const formatDate = (dateString: string | undefined) => formatIsoDate(dateString);
const formatCurrency = (amount: number) => sharedFormatCurrency(amount || 0);
diff --git a/apps/portal/src/features/support/views/SupportCaseDetailView.tsx b/apps/portal/src/features/support/views/SupportCaseDetailView.tsx
index ffad545d..c8a897ee 100644
--- a/apps/portal/src/features/support/views/SupportCaseDetailView.tsx
+++ b/apps/portal/src/features/support/views/SupportCaseDetailView.tsx
@@ -2,7 +2,6 @@
import { CalendarIcon, ClockIcon, TagIcon, ArrowLeftIcon } from "@heroicons/react/24/outline";
import { TicketIcon as TicketIconSolid } from "@heroicons/react/24/solid";
-import { format, formatDistanceToNow } from "date-fns";
import { PageLayout } from "@/components/templates/PageLayout";
import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner";
import { Button } from "@/components/atoms";
@@ -12,6 +11,7 @@ import {
getCaseStatusClasses,
getCasePriorityClasses,
} from "@/features/support/utils";
+import { formatIsoDate, formatIsoRelative } from "@/lib/utils";
interface SupportCaseDetailViewProps {
caseId: string;
@@ -104,14 +104,11 @@ export function SupportCaseDetailView({ caseId }: SupportCaseDetailViewProps) {
- Created {format(new Date(supportCase.createdAt), "MMM d, yyyy")}
+ Created {formatIsoDate(supportCase.createdAt)}
-
- Updated{" "}
- {formatDistanceToNow(new Date(supportCase.updatedAt), { addSuffix: true })}
-
+ Updated {formatIsoRelative(supportCase.updatedAt)}
{supportCase.category && (
@@ -121,7 +118,7 @@ export function SupportCaseDetailView({ caseId }: SupportCaseDetailViewProps) {
)}
{supportCase.closedAt && (
- ✓ Closed {format(new Date(supportCase.closedAt), "MMM d, yyyy")}
+ ✓ Closed {formatIsoDate(supportCase.closedAt)}
)}
diff --git a/apps/portal/src/features/support/views/SupportCasesView.tsx b/apps/portal/src/features/support/views/SupportCasesView.tsx
index 9d37ba62..3f26643d 100644
--- a/apps/portal/src/features/support/views/SupportCasesView.tsx
+++ b/apps/portal/src/features/support/views/SupportCasesView.tsx
@@ -13,7 +13,6 @@ import {
XMarkIcon,
} from "@heroicons/react/24/outline";
import { ChatBubbleLeftRightIcon as ChatBubbleLeftRightIconSolid } from "@heroicons/react/24/solid";
-import { formatDistanceToNow } from "date-fns";
import { PageLayout } from "@/components/templates/PageLayout";
import { AnimatedCard } from "@/components/molecules";
import { Button } from "@/components/atoms";
@@ -29,6 +28,7 @@ import {
getCaseStatusClasses,
getCasePriorityClasses,
} from "@/features/support/utils";
+import { formatIsoRelative } from "@/lib/utils";
export function SupportCasesView() {
const router = useRouter();
@@ -217,8 +217,7 @@ export function SupportCasesView() {
{/* Timestamp */}
- Updated{" "}
- {formatDistanceToNow(new Date(supportCase.updatedAt), { addSuffix: true })}
+ Updated {formatIsoRelative(supportCase.updatedAt)}
diff --git a/apps/portal/src/features/support/views/SupportHomeView.tsx b/apps/portal/src/features/support/views/SupportHomeView.tsx
index 39389817..7eeaf264 100644
--- a/apps/portal/src/features/support/views/SupportHomeView.tsx
+++ b/apps/portal/src/features/support/views/SupportHomeView.tsx
@@ -11,13 +11,13 @@ import {
CheckCircleIcon,
} from "@heroicons/react/24/outline";
import { ChatBubbleLeftRightIcon as ChatBubbleLeftRightIconSolid } from "@heroicons/react/24/solid";
-import { formatDistanceToNow } from "date-fns";
import { PageLayout } from "@/components/templates/PageLayout";
import { AnimatedCard } from "@/components/molecules";
import { Button } from "@/components/atoms";
import { EmptyState } from "@/components/atoms/empty-state";
import { useSupportCases } from "@/features/support/hooks/useSupportCases";
import { getCaseStatusIcon, getCaseStatusClasses } from "@/features/support/utils";
+import { formatIsoRelative } from "@/lib/utils";
export function SupportHomeView() {
const router = useRouter();
@@ -125,7 +125,7 @@ export function SupportHomeView() {
{supportCase.subject}
- {formatDistanceToNow(new Date(supportCase.updatedAt), { addSuffix: true })}
+ {formatIsoRelative(supportCase.updatedAt)}
diff --git a/apps/portal/src/lib/api/index.ts b/apps/portal/src/lib/api/index.ts
index 85d7b2ee..8a6f5ccd 100644
--- a/apps/portal/src/lib/api/index.ts
+++ b/apps/portal/src/lib/api/index.ts
@@ -7,14 +7,16 @@ export type {
PathParams,
} from "./runtime/client";
export { ApiError, isApiError } from "./runtime/client";
+export { onUnauthorized } from "./unauthorized";
// Re-export API helpers
export * from "./response-helpers";
// Import createClient for internal use
import { createClient, ApiError } from "./runtime/client";
-import { parseDomainError } from "./response-helpers";
+import { getApiErrorMessage } from "./runtime/error-message";
import { logger } from "@/lib/logger";
+import { emitUnauthorized } from "./unauthorized";
/**
* Auth endpoints that should NOT trigger automatic logout on 401
@@ -41,38 +43,6 @@ function isAuthEndpoint(url: string): boolean {
}
}
-/**
- * Extract error message from API error body
- * Handles both `{ message }` and `{ error: { message } }` formats
- */
-function extractErrorMessage(body: unknown): string | null {
- const domainError = parseDomainError(body);
- if (domainError) {
- return domainError.error.message;
- }
-
- if (!body || typeof body !== "object") {
- return null;
- }
-
- // Check for nested error.message format (standard API error response)
- const bodyWithError = body as { error?: { message?: unknown } };
- if (bodyWithError.error && typeof bodyWithError.error === "object") {
- const errorMessage = bodyWithError.error.message;
- if (typeof errorMessage === "string") {
- return errorMessage;
- }
- }
-
- // Check for top-level message
- const bodyWithMessage = body as { message?: unknown };
- if (typeof bodyWithMessage.message === "string") {
- return bodyWithMessage.message;
- }
-
- return null;
-}
-
/**
* Global error handler for API client
* Handles authentication errors and triggers logout when needed
@@ -87,14 +57,7 @@ async function handleApiError(response: Response): Promise
{
if (response.status === 401 && !isAuthEndpoint(response.url)) {
logger.warn("Received 401 Unauthorized response - triggering logout");
- // Dispatch a custom event that the auth system will listen to
- if (typeof window !== "undefined") {
- window.dispatchEvent(
- new CustomEvent("auth:unauthorized", {
- detail: { url: response.url, status: response.status },
- })
- );
- }
+ emitUnauthorized({ url: response.url, status: response.status });
}
// Still throw the error so the calling code can handle it
@@ -106,7 +69,7 @@ async function handleApiError(response: Response): Promise {
const contentType = cloned.headers.get("content-type");
if (contentType?.includes("application/json")) {
body = await cloned.json();
- const extractedMessage = extractErrorMessage(body);
+ const extractedMessage = getApiErrorMessage(body);
if (extractedMessage) {
message = extractedMessage;
}
diff --git a/apps/portal/src/lib/api/runtime/client.ts b/apps/portal/src/lib/api/runtime/client.ts
index 3523d620..e01cc8fe 100644
--- a/apps/portal/src/lib/api/runtime/client.ts
+++ b/apps/portal/src/lib/api/runtime/client.ts
@@ -1,5 +1,6 @@
-import { parseDomainError, type ApiResponse } from "../response-helpers";
+import type { ApiResponse } from "../response-helpers";
import { logger } from "@/lib/logger";
+import { getApiErrorMessage } from "./error-message";
export class ApiError extends Error {
constructor(
@@ -131,20 +132,7 @@ const getBodyMessage = (body: unknown): string | null => {
if (typeof body === "string") {
return body;
}
-
- const domainError = parseDomainError(body);
- if (domainError) {
- return domainError.error.message;
- }
-
- if (body && typeof body === "object" && "message" in body) {
- const maybeMessage = (body as { message?: unknown }).message;
- if (typeof maybeMessage === "string") {
- return maybeMessage;
- }
- }
-
- return null;
+ return getApiErrorMessage(body);
};
async function defaultHandleError(response: Response) {
diff --git a/apps/portal/src/lib/api/runtime/error-message.ts b/apps/portal/src/lib/api/runtime/error-message.ts
new file mode 100644
index 00000000..1e9cf3aa
--- /dev/null
+++ b/apps/portal/src/lib/api/runtime/error-message.ts
@@ -0,0 +1,35 @@
+import { parseDomainError } from "../response-helpers";
+
+/**
+ * Extract a user-facing error message from an API error payload.
+ *
+ * Supports:
+ * - domain envelope: `{ success: false, error: { code, message, details? } }`
+ * - common nested error: `{ error: { message } }`
+ * - top-level message: `{ message }`
+ */
+export function getApiErrorMessage(payload: unknown): string | null {
+ const domainError = parseDomainError(payload);
+ if (domainError) {
+ return domainError.error.message;
+ }
+
+ if (!payload || typeof payload !== "object") {
+ return null;
+ }
+
+ const bodyWithError = payload as { error?: { message?: unknown } };
+ if (bodyWithError.error && typeof bodyWithError.error === "object") {
+ const errorMessage = bodyWithError.error.message;
+ if (typeof errorMessage === "string") {
+ return errorMessage;
+ }
+ }
+
+ const bodyWithMessage = payload as { message?: unknown };
+ if (typeof bodyWithMessage.message === "string") {
+ return bodyWithMessage.message;
+ }
+
+ return null;
+}
diff --git a/apps/portal/src/lib/api/unauthorized.ts b/apps/portal/src/lib/api/unauthorized.ts
new file mode 100644
index 00000000..208795d1
--- /dev/null
+++ b/apps/portal/src/lib/api/unauthorized.ts
@@ -0,0 +1,33 @@
+export type UnauthorizedDetail = {
+ url?: string;
+ status?: number;
+};
+
+export type UnauthorizedListener = (detail: UnauthorizedDetail) => void;
+
+const listeners = new Set();
+
+/**
+ * Subscribe to "unauthorized" events emitted by the API layer.
+ *
+ * Returns an unsubscribe function.
+ */
+export function onUnauthorized(listener: UnauthorizedListener): () => void {
+ listeners.add(listener);
+ return () => listeners.delete(listener);
+}
+
+/**
+ * Emit an "unauthorized" event to all listeners.
+ *
+ * Intended to be called by the API client when a non-auth endpoint returns 401.
+ */
+export function emitUnauthorized(detail: UnauthorizedDetail): void {
+ for (const listener of listeners) {
+ try {
+ listener(detail);
+ } catch {
+ // Never let one listener break other listeners or the caller.
+ }
+ }
+}
diff --git a/apps/portal/src/lib/utils/date.ts b/apps/portal/src/lib/utils/date.ts
new file mode 100644
index 00000000..7862e0d1
--- /dev/null
+++ b/apps/portal/src/lib/utils/date.ts
@@ -0,0 +1,72 @@
+import { Formatting } from "@customer-portal/domain/toolkit";
+
+export type FormatDateFallbackOptions = {
+ fallback?: string;
+ locale?: string;
+ dateStyle?: "short" | "medium" | "long" | "full";
+ timeStyle?: "short" | "medium" | "long" | "full";
+ includeTime?: boolean;
+ timezone?: string;
+};
+
+export function formatIsoDate(
+ iso: string | null | undefined,
+ options: FormatDateFallbackOptions = {}
+): string {
+ const {
+ fallback = "N/A",
+ locale,
+ dateStyle = "medium",
+ timeStyle = "short",
+ includeTime = false,
+ timezone,
+ } = options;
+
+ if (!iso) return fallback;
+ if (!Formatting.isValidDate(iso)) return "Invalid date";
+
+ return Formatting.formatDate(iso, { locale, dateStyle, timeStyle, includeTime, timezone });
+}
+
+export function formatIsoRelative(
+ iso: string | null | undefined,
+ options: { fallback?: string; locale?: string } = {}
+): string {
+ const { fallback = "N/A", locale } = options;
+ if (!iso) return fallback;
+ if (!Formatting.isValidDate(iso)) return "Invalid date";
+ return Formatting.formatRelativeDate(iso, { locale });
+}
+
+export function formatIsoMonthDay(
+ iso: string | null | undefined,
+ options: { fallback?: string; locale?: string } = {}
+): string {
+ const { fallback = "N/A", locale = "en-US" } = options;
+ if (!iso) return fallback;
+ if (!Formatting.isValidDate(iso)) return "Invalid date";
+ try {
+ const date = new Date(iso);
+ return date.toLocaleDateString(locale, { month: "short", day: "numeric" });
+ } catch {
+ return "Invalid date";
+ }
+}
+
+export function isSameDay(a: Date, b: Date): boolean {
+ return (
+ a.getFullYear() === b.getFullYear() &&
+ a.getMonth() === b.getMonth() &&
+ a.getDate() === b.getDate()
+ );
+}
+
+export function isToday(date: Date, now: Date = new Date()): boolean {
+ return isSameDay(date, now);
+}
+
+export function isYesterday(date: Date, now: Date = new Date()): boolean {
+ const yesterday = new Date(now);
+ yesterday.setDate(now.getDate() - 1);
+ return isSameDay(date, yesterday);
+}
diff --git a/apps/portal/src/lib/utils/index.ts b/apps/portal/src/lib/utils/index.ts
index 02d16a5d..e4c8f793 100644
--- a/apps/portal/src/lib/utils/index.ts
+++ b/apps/portal/src/lib/utils/index.ts
@@ -1,4 +1,13 @@
export { cn } from "./cn";
+export {
+ formatIsoDate,
+ formatIsoRelative,
+ formatIsoMonthDay,
+ isSameDay,
+ isToday,
+ isYesterday,
+ type FormatDateFallbackOptions,
+} from "./date";
export {
parseError,
getErrorMessage,