Refactor Residence Card Verification Logic and Enhance Error Handling
- Updated the MeStatusService to include a safe method for fetching residence card verification, improving error handling and providing a fallback response in case of failures. - Refactored the ResidenceCardService to utilize a normalization utility for date handling and improved payload validation, ensuring consistent and safe responses from Salesforce. - Enhanced logging for error scenarios to aid in debugging and maintainability. - Updated the Next.js configuration to allow direct API calls, providing a solution to bypass Next's internal dev proxy and avoid deprecation warnings. - Expanded documentation to guide users on configuring the environment for development.
This commit is contained in:
parent
88aebdc75c
commit
ea188f098b
30
apps/bff/src/integrations/salesforce/utils/datetime.util.ts
Normal file
30
apps/bff/src/integrations/salesforce/utils/datetime.util.ts
Normal file
@ -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();
|
||||||
|
}
|
||||||
@ -16,7 +16,10 @@ import {
|
|||||||
} from "@customer-portal/domain/dashboard";
|
} from "@customer-portal/domain/dashboard";
|
||||||
import { NOTIFICATION_SOURCE, NOTIFICATION_TYPE } from "@customer-portal/domain/notifications";
|
import { NOTIFICATION_SOURCE, NOTIFICATION_TYPE } from "@customer-portal/domain/notifications";
|
||||||
import type { InternetEligibilityDetails } from "@customer-portal/domain/services";
|
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";
|
import type { OrderSummary } from "@customer-portal/domain/orders";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@ -37,7 +40,7 @@ export class MeStatusService {
|
|||||||
const [summary, internetEligibility, residenceCardVerification, orders] = await Promise.all([
|
const [summary, internetEligibility, residenceCardVerification, orders] = await Promise.all([
|
||||||
this.users.getUserSummary(userId),
|
this.users.getUserSummary(userId),
|
||||||
this.internetCatalog.getEligibilityDetailsForUser(userId),
|
this.internetCatalog.getEligibilityDetailsForUser(userId),
|
||||||
this.residenceCards.getStatusForUser(userId),
|
this.safeGetResidenceCardVerification(userId),
|
||||||
this.safeGetOrders(userId),
|
this.safeGetOrders(userId),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@ -66,6 +69,28 @@ export class MeStatusService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async safeGetResidenceCardVerification(
|
||||||
|
userId: string
|
||||||
|
): Promise<ResidenceCardVerification> {
|
||||||
|
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<OrderSummary[] | null> {
|
private async safeGetOrders(userId: string): Promise<OrderSummary[] | null> {
|
||||||
try {
|
try {
|
||||||
const result = await this.orders.getOrdersForUser(userId);
|
const result = await this.orders.getOrdersForUser(userId);
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import {
|
|||||||
assertSalesforceId,
|
assertSalesforceId,
|
||||||
assertSoqlFieldName,
|
assertSoqlFieldName,
|
||||||
} from "@bff/integrations/salesforce/utils/soql.util.js";
|
} 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 type { SalesforceResponse } from "@customer-portal/domain/common/providers";
|
||||||
import {
|
import {
|
||||||
residenceCardVerificationSchema,
|
residenceCardVerificationSchema,
|
||||||
@ -84,18 +85,8 @@ export class ResidenceCardService {
|
|||||||
const noteRaw = account ? account[fields.note] : undefined;
|
const noteRaw = account ? account[fields.note] : undefined;
|
||||||
const rejectionRaw = account ? account[fields.rejectionMessage] : undefined;
|
const rejectionRaw = account ? account[fields.rejectionMessage] : undefined;
|
||||||
|
|
||||||
const submittedAt =
|
const submittedAt = normalizeSalesforceDateTimeToIsoUtc(submittedAtRaw);
|
||||||
typeof submittedAtRaw === "string"
|
const reviewedAt = normalizeSalesforceDateTimeToIsoUtc(verifiedAtRaw);
|
||||||
? submittedAtRaw
|
|
||||||
: submittedAtRaw instanceof Date
|
|
||||||
? submittedAtRaw.toISOString()
|
|
||||||
: null;
|
|
||||||
const reviewedAt =
|
|
||||||
typeof verifiedAtRaw === "string"
|
|
||||||
? verifiedAtRaw
|
|
||||||
: verifiedAtRaw instanceof Date
|
|
||||||
? verifiedAtRaw.toISOString()
|
|
||||||
: null;
|
|
||||||
|
|
||||||
const reviewerNotes =
|
const reviewerNotes =
|
||||||
typeof rejectionRaw === "string" && rejectionRaw.trim().length > 0
|
typeof rejectionRaw === "string" && rejectionRaw.trim().length > 0
|
||||||
@ -107,7 +98,7 @@ export class ResidenceCardService {
|
|||||||
const fileMeta =
|
const fileMeta =
|
||||||
status === "not_submitted" ? null : await this.getLatestAccountFileMetadata(sfAccountId);
|
status === "not_submitted" ? null : await this.getLatestAccountFileMetadata(sfAccountId);
|
||||||
|
|
||||||
return residenceCardVerificationSchema.parse({
|
const payload = {
|
||||||
status,
|
status,
|
||||||
filename: fileMeta?.filename ?? null,
|
filename: fileMeta?.filename ?? null,
|
||||||
mimeType: fileMeta?.mimeType ?? null,
|
mimeType: fileMeta?.mimeType ?? null,
|
||||||
@ -115,6 +106,31 @@ export class ResidenceCardService {
|
|||||||
submittedAt: submittedAt ?? fileMeta?.submittedAt ?? null,
|
submittedAt: submittedAt ?? fileMeta?.submittedAt ?? null,
|
||||||
reviewedAt,
|
reviewedAt,
|
||||||
reviewerNotes,
|
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 fileType = typeof version.FileType === "string" ? version.FileType.trim() : "";
|
||||||
const sizeBytes = typeof version.ContentSize === "number" ? version.ContentSize : null;
|
const sizeBytes = typeof version.ContentSize === "number" ? version.ContentSize : null;
|
||||||
const createdDateRaw = version.CreatedDate;
|
const createdDateRaw = version.CreatedDate;
|
||||||
const submittedAt =
|
const submittedAt = normalizeSalesforceDateTimeToIsoUtc(createdDateRaw);
|
||||||
typeof createdDateRaw === "string"
|
|
||||||
? createdDateRaw
|
|
||||||
: createdDateRaw instanceof Date
|
|
||||||
? createdDateRaw.toISOString()
|
|
||||||
: null;
|
|
||||||
|
|
||||||
const filename = title
|
const filename = title
|
||||||
? ext && !title.toLowerCase().endsWith(`.${ext.toLowerCase()}`)
|
? ext && !title.toLowerCase().endsWith(`.${ext.toLowerCase()}`)
|
||||||
|
|||||||
@ -7,6 +7,8 @@ const workspaceRoot = path.resolve(__dirname, "..", "..");
|
|||||||
// BFF URL for development API proxying
|
// BFF URL for development API proxying
|
||||||
const BFF_URL = process.env.BFF_URL || "http://localhost:4000";
|
const BFF_URL = process.env.BFF_URL || "http://localhost:4000";
|
||||||
const isDev = process.env.NODE_ENV === "development";
|
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} */
|
/** @type {import('next').NextConfig} */
|
||||||
const nextConfig = {
|
const nextConfig = {
|
||||||
@ -27,9 +29,9 @@ const nextConfig = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
env: {
|
env: {
|
||||||
// In development, we use rewrites to proxy API calls, so API_BASE is same-origin
|
// Default: same-origin (dev rewrites or prod nginx proxy).
|
||||||
// In production, API_BASE should be set via environment (nginx proxy or direct BFF URL)
|
// 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: isDev ? "" : process.env.NEXT_PUBLIC_API_BASE,
|
NEXT_PUBLIC_API_BASE: process.env.NEXT_PUBLIC_API_BASE ?? "",
|
||||||
NEXT_PUBLIC_APP_NAME: process.env.NEXT_PUBLIC_APP_NAME,
|
NEXT_PUBLIC_APP_NAME: process.env.NEXT_PUBLIC_APP_NAME,
|
||||||
NEXT_PUBLIC_APP_VERSION: process.env.NEXT_PUBLIC_APP_VERSION,
|
NEXT_PUBLIC_APP_VERSION: process.env.NEXT_PUBLIC_APP_VERSION,
|
||||||
},
|
},
|
||||||
@ -37,6 +39,9 @@ const nextConfig = {
|
|||||||
// Proxy API requests to BFF in development
|
// Proxy API requests to BFF in development
|
||||||
async rewrites() {
|
async rewrites() {
|
||||||
if (!isDev) return [];
|
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 [
|
return [
|
||||||
{
|
{
|
||||||
source: "/api/:path*",
|
source: "/api/:path*",
|
||||||
|
|||||||
@ -37,6 +37,17 @@ pnpm format # Format code with Prettier
|
|||||||
- **Adminer**: http://localhost:8080 (when using `pnpm dev:tools`)
|
- **Adminer**: http://localhost:8080 (when using `pnpm dev:tools`)
|
||||||
- **Redis Commander**: http://localhost:8081 (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
|
## Production Commands
|
||||||
|
|
||||||
_For detailed deployment guide, see `DEPLOY.md`_
|
_For detailed deployment guide, see `DEPLOY.md`_
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user