diff --git a/apps/bff/src/integrations/salesforce/utils/datetime.util.ts b/apps/bff/src/integrations/salesforce/utils/datetime.util.ts new file mode 100644 index 00000000..e4eacb56 --- /dev/null +++ b/apps/bff/src/integrations/salesforce/utils/datetime.util.ts @@ -0,0 +1,30 @@ +/** + * Salesforce DateTime normalization + * + * Salesforce APIs often return DateTime strings with timezone offsets like `+0000` + * (instead of the `Z`-suffixed ISO form expected by our public domain schemas). + * + * This utility normalizes supported timezone-bearing inputs to a strict UTC ISO string. + */ +export function normalizeSalesforceDateTimeToIsoUtc(value: unknown): string | null { + if (value == null) return null; + + if (value instanceof Date) { + const t = value.getTime(); + return Number.isNaN(t) ? null : value.toISOString(); + } + + if (typeof value !== "string") return null; + + const raw = value.trim(); + if (raw.length === 0) return null; + + // Only normalize strings that explicitly include a timezone (Z or numeric offset). + // This avoids accidentally interpreting timezone-less strings as local time. + const hasTimezone = raw.endsWith("Z") || /[+-]\d{2}:\d{2}$/.test(raw) || /[+-]\d{4}$/.test(raw); + if (!hasTimezone) return null; + + const date = new Date(raw); + const t = date.getTime(); + return Number.isNaN(t) ? null : date.toISOString(); +} diff --git a/apps/bff/src/modules/me-status/me-status.service.ts b/apps/bff/src/modules/me-status/me-status.service.ts index 876ce0d3..96e83c95 100644 --- a/apps/bff/src/modules/me-status/me-status.service.ts +++ b/apps/bff/src/modules/me-status/me-status.service.ts @@ -16,7 +16,10 @@ import { } from "@customer-portal/domain/dashboard"; import { NOTIFICATION_SOURCE, NOTIFICATION_TYPE } from "@customer-portal/domain/notifications"; import type { InternetEligibilityDetails } from "@customer-portal/domain/services"; -import type { ResidenceCardVerification } from "@customer-portal/domain/customer"; +import { + residenceCardVerificationSchema, + type ResidenceCardVerification, +} from "@customer-portal/domain/customer"; import type { OrderSummary } from "@customer-portal/domain/orders"; @Injectable() @@ -37,7 +40,7 @@ export class MeStatusService { const [summary, internetEligibility, residenceCardVerification, orders] = await Promise.all([ this.users.getUserSummary(userId), this.internetCatalog.getEligibilityDetailsForUser(userId), - this.residenceCards.getStatusForUser(userId), + this.safeGetResidenceCardVerification(userId), this.safeGetOrders(userId), ]); @@ -66,6 +69,28 @@ export class MeStatusService { } } + private async safeGetResidenceCardVerification( + userId: string + ): Promise { + try { + return await this.residenceCards.getStatusForUser(userId); + } catch (error) { + this.logger.warn( + { userId, err: error instanceof Error ? error.message : String(error) }, + "Failed to load residence card verification for status payload" + ); + return residenceCardVerificationSchema.parse({ + status: "not_submitted", + filename: null, + mimeType: null, + sizeBytes: null, + submittedAt: null, + reviewedAt: null, + reviewerNotes: null, + }); + } + } + private async safeGetOrders(userId: string): Promise { try { const result = await this.orders.getOrdersForUser(userId); diff --git a/apps/bff/src/modules/verification/residence-card.service.ts b/apps/bff/src/modules/verification/residence-card.service.ts index 6b3fad04..8f31f077 100644 --- a/apps/bff/src/modules/verification/residence-card.service.ts +++ b/apps/bff/src/modules/verification/residence-card.service.ts @@ -7,6 +7,7 @@ import { assertSalesforceId, assertSoqlFieldName, } from "@bff/integrations/salesforce/utils/soql.util.js"; +import { normalizeSalesforceDateTimeToIsoUtc } from "@bff/integrations/salesforce/utils/datetime.util.js"; import type { SalesforceResponse } from "@customer-portal/domain/common/providers"; import { residenceCardVerificationSchema, @@ -84,18 +85,8 @@ export class ResidenceCardService { const noteRaw = account ? account[fields.note] : undefined; const rejectionRaw = account ? account[fields.rejectionMessage] : undefined; - const submittedAt = - typeof submittedAtRaw === "string" - ? submittedAtRaw - : submittedAtRaw instanceof Date - ? submittedAtRaw.toISOString() - : null; - const reviewedAt = - typeof verifiedAtRaw === "string" - ? verifiedAtRaw - : verifiedAtRaw instanceof Date - ? verifiedAtRaw.toISOString() - : null; + const submittedAt = normalizeSalesforceDateTimeToIsoUtc(submittedAtRaw); + const reviewedAt = normalizeSalesforceDateTimeToIsoUtc(verifiedAtRaw); const reviewerNotes = typeof rejectionRaw === "string" && rejectionRaw.trim().length > 0 @@ -107,7 +98,7 @@ export class ResidenceCardService { const fileMeta = status === "not_submitted" ? null : await this.getLatestAccountFileMetadata(sfAccountId); - return residenceCardVerificationSchema.parse({ + const payload = { status, filename: fileMeta?.filename ?? null, mimeType: fileMeta?.mimeType ?? null, @@ -115,6 +106,31 @@ export class ResidenceCardService { submittedAt: submittedAt ?? fileMeta?.submittedAt ?? null, reviewedAt, reviewerNotes, + }; + + const parsed = residenceCardVerificationSchema.safeParse(payload); + if (parsed.success) return parsed.data; + + this.logger.warn( + { userId, err: parsed.error.message }, + "Invalid residence card verification payload from Salesforce; returning safe fallback" + ); + + const fallback = residenceCardVerificationSchema.safeParse({ + ...payload, + submittedAt: null, + reviewedAt: null, + }); + if (fallback.success) return fallback.data; + + return residenceCardVerificationSchema.parse({ + status: "not_submitted", + filename: null, + mimeType: null, + sizeBytes: null, + submittedAt: null, + reviewedAt: null, + reviewerNotes: null, }); } @@ -251,12 +267,7 @@ export class ResidenceCardService { const fileType = typeof version.FileType === "string" ? version.FileType.trim() : ""; const sizeBytes = typeof version.ContentSize === "number" ? version.ContentSize : null; const createdDateRaw = version.CreatedDate; - const submittedAt = - typeof createdDateRaw === "string" - ? createdDateRaw - : createdDateRaw instanceof Date - ? createdDateRaw.toISOString() - : null; + const submittedAt = normalizeSalesforceDateTimeToIsoUtc(createdDateRaw); const filename = title ? ext && !title.toLowerCase().endsWith(`.${ext.toLowerCase()}`) diff --git a/apps/portal/next.config.mjs b/apps/portal/next.config.mjs index 69edd097..0b6f98b1 100644 --- a/apps/portal/next.config.mjs +++ b/apps/portal/next.config.mjs @@ -7,6 +7,8 @@ const workspaceRoot = path.resolve(__dirname, "..", ".."); // BFF URL for development API proxying const BFF_URL = process.env.BFF_URL || "http://localhost:4000"; const isDev = process.env.NODE_ENV === "development"; +const directApiBase = process.env.NEXT_PUBLIC_API_BASE; +const useDirectApiBase = Boolean(directApiBase && directApiBase.trim().startsWith("http")); /** @type {import('next').NextConfig} */ const nextConfig = { @@ -27,9 +29,9 @@ const nextConfig = { }, env: { - // In development, we use rewrites to proxy API calls, so API_BASE is same-origin - // In production, API_BASE should be set via environment (nginx proxy or direct BFF URL) - NEXT_PUBLIC_API_BASE: isDev ? "" : process.env.NEXT_PUBLIC_API_BASE, + // Default: same-origin (dev rewrites or prod nginx proxy). + // Optional: set NEXT_PUBLIC_API_BASE (e.g. http://localhost:4000) to bypass Next rewrites and call the BFF directly via CORS. + NEXT_PUBLIC_API_BASE: process.env.NEXT_PUBLIC_API_BASE ?? "", NEXT_PUBLIC_APP_NAME: process.env.NEXT_PUBLIC_APP_NAME, NEXT_PUBLIC_APP_VERSION: process.env.NEXT_PUBLIC_APP_VERSION, }, @@ -37,6 +39,9 @@ const nextConfig = { // Proxy API requests to BFF in development async rewrites() { if (!isDev) return []; + // If NEXT_PUBLIC_API_BASE is set, the app will call the BFF directly via CORS. + // Avoid Next's internal dev proxy path (which can emit Node deprecation warnings). + if (useDirectApiBase) return []; return [ { source: "/api/:path*", diff --git a/docs/getting-started/running.md b/docs/getting-started/running.md index 50a399fe..9f4506cc 100644 --- a/docs/getting-started/running.md +++ b/docs/getting-started/running.md @@ -37,6 +37,17 @@ pnpm format # Format code with Prettier - **Adminer**: http://localhost:8080 (when using `pnpm dev:tools`) - **Redis Commander**: http://localhost:8081 (when using `pnpm dev:tools`) +## Hiding Next dev proxy deprecation warnings (Portal ➝ BFF) + +If you see Node warnings like `(node:...) [DEP0060] DeprecationWarning: The util._extend API is deprecated`, they typically come from Next's internal dev proxy used for `rewrites()` (see `apps/portal/next.config.mjs`). + +The preferred fix is to **bypass the Next rewrite-proxy** and call the BFF directly via CORS: + +- **Portal**: set `NEXT_PUBLIC_API_BASE=http://localhost:4000` +- **BFF**: set `CORS_ORIGIN=http://localhost:3000` + +Then restart `pnpm dev`. + ## Production Commands _For detailed deployment guide, see `DEPLOY.md`_