Enhance Salesforce Case Integration and Update Eligibility Logic

- Added new fields for notes and case ID in the ServicesCdcSubscriber to improve eligibility detail tracking.
- Refactored the SalesforceCaseService to unify case creation for various case types, including customer support and internal workflow cases.
- Updated InternetServicesService to create internal workflow cases for eligibility checks, enhancing tracking and management.
- Improved error handling and logging consistency across services when creating cases.
- Cleaned up unused imports and optimized code structure for better maintainability.
This commit is contained in:
barsa 2025-12-29 16:53:32 +09:00
parent a55967a31f
commit a938c605c7
14 changed files with 330 additions and 333 deletions

View File

@ -273,6 +273,8 @@ export class ServicesCdcSubscriber implements OnModuleInit, OnModuleDestroy {
const accountId = this.extractStringField(payload, ["AccountId__c", "AccountId", "Id"]); const accountId = this.extractStringField(payload, ["AccountId__c", "AccountId", "Id"]);
const eligibility = this.extractStringField(payload, ["Internet_Eligibility__c"]); const eligibility = this.extractStringField(payload, ["Internet_Eligibility__c"]);
const status = this.extractStringField(payload, ["Internet_Eligibility_Status__c"]); const status = this.extractStringField(payload, ["Internet_Eligibility_Status__c"]);
const notes = this.extractStringField(payload, ["Internet_Eligibility_Notes__c"]);
const caseId = this.extractStringField(payload, ["Internet_Eligibility_Case_Id__c"]);
const requestedAt = this.extractStringField(payload, [ const requestedAt = this.extractStringField(payload, [
"Internet_Eligibility_Request_Date_Time__c", "Internet_Eligibility_Request_Date_Time__c",
]); ]);
@ -280,8 +282,7 @@ export class ServicesCdcSubscriber implements OnModuleInit, OnModuleDestroy {
"Internet_Eligibility_Checked_Date_Time__c", "Internet_Eligibility_Checked_Date_Time__c",
]); ]);
// Note: Request ID field is not used in this environment const requestId = caseId;
const requestId = undefined;
// Also extract ID verification fields for notifications // Also extract ID verification fields for notifications
const verificationStatus = this.extractStringField(payload, ["Id_Verification_Status__c"]); const verificationStatus = this.extractStringField(payload, ["Id_Verification_Status__c"]);
@ -303,7 +304,9 @@ export class ServicesCdcSubscriber implements OnModuleInit, OnModuleDestroy {
}); });
await this.catalogCache.invalidateEligibility(accountId); await this.catalogCache.invalidateEligibility(accountId);
const hasDetails = Boolean(status || eligibility || requestedAt || checkedAt || requestId); const hasDetails = Boolean(
status || eligibility || requestedAt || checkedAt || requestId || notes
);
if (hasDetails) { if (hasDetails) {
await this.catalogCache.setEligibilityDetails(accountId, { await this.catalogCache.setEligibilityDetails(accountId, {
status: this.mapEligibilityStatus(status, eligibility), status: this.mapEligibilityStatus(status, eligibility),
@ -311,7 +314,7 @@ export class ServicesCdcSubscriber implements OnModuleInit, OnModuleDestroy {
requestId: requestId ?? null, requestId: requestId ?? null,
requestedAt: requestedAt ?? null, requestedAt: requestedAt ?? null,
checkedAt: checkedAt ?? null, checkedAt: checkedAt ?? null,
notes: null, // Field not used notes: notes ?? null,
}); });
} }

View File

@ -1,10 +1,12 @@
/** /**
* Salesforce Case Integration Service * Salesforce Case Integration Service
* *
* Encapsulates all Salesforce Case operations for the portal. * Unified service for all Salesforce Case operations.
* - Queries cases filtered by Origin = 'Portal Website' *
* - Creates cases with portal-specific defaults * Case Types:
* - Validates account ownership for security * - Customer Support Cases (Origin: Portal Support) - visible to customers
* - Internal Workflow Cases (Origin: Portal Notification) - for CS team only
* - Web-to-Case (Origin: Web) - public contact form submissions
* *
* Uses domain types and mappers from @customer-portal/domain/support * Uses domain types and mappers from @customer-portal/domain/support
*/ */
@ -15,31 +17,62 @@ import { SalesforceConnection } from "./salesforce-connection.service.js";
import { assertSalesforceId } from "../utils/soql.util.js"; import { assertSalesforceId } from "../utils/soql.util.js";
import { extractErrorMessage } from "@bff/core/utils/error.util.js"; import { extractErrorMessage } from "@bff/core/utils/error.util.js";
import type { SalesforceResponse } from "@customer-portal/domain/common/providers"; import type { SalesforceResponse } from "@customer-portal/domain/common/providers";
import type { SupportCase, CreateCaseRequest } from "@customer-portal/domain/support"; import type { SupportCase } from "@customer-portal/domain/support";
import type { SalesforceCaseRecord } from "@customer-portal/domain/support/providers"; import type { SalesforceCaseRecord } from "@customer-portal/domain/support/providers";
import { import {
SALESFORCE_CASE_ORIGIN, SALESFORCE_CASE_ORIGIN,
SALESFORCE_CASE_STATUS, SALESFORCE_CASE_STATUS,
SALESFORCE_CASE_PRIORITY, SALESFORCE_CASE_PRIORITY,
toSalesforcePriority,
type SalesforceCaseOrigin,
} from "@customer-portal/domain/support/providers"; } from "@customer-portal/domain/support/providers";
import { import {
buildCaseByIdQuery, buildCaseByIdQuery,
buildCaseSelectFields, buildCaseSelectFields,
buildCasesForAccountQuery, buildCasesForAccountQuery,
toSalesforcePriority,
transformSalesforceCaseToSupportCase, transformSalesforceCaseToSupportCase,
transformSalesforceCasesToSupportCases, transformSalesforceCasesToSupportCases,
} from "@customer-portal/domain/support/providers"; } from "@customer-portal/domain/support/providers";
// ============================================================================
// Types
// ============================================================================
/** /**
* Parameters for creating a case in Salesforce * Parameters for creating any case in Salesforce.
* Extends domain CreateCaseRequest with infrastructure-specific fields *
* The `origin` field determines case visibility:
* - PORTAL_SUPPORT: Customer-visible support case
* - PORTAL_NOTIFICATION: Internal CS workflow case (eligibility, verification, cancellation)
*/ */
export interface CreateCaseParams extends CreateCaseRequest { export interface CreateCaseParams {
/** Salesforce Account ID */ /** Salesforce Account ID */
accountId: string; accountId: string;
/** Case subject line */
subject: string;
/** Case description/body */
description: string;
/** Case origin - determines visibility and routing */
origin: SalesforceCaseOrigin;
/** Priority (defaults to Medium) */
priority?: string;
/** Optional Salesforce Contact ID */ /** Optional Salesforce Contact ID */
contactId?: string; contactId?: string;
/** Optional Opportunity ID for workflow cases */
opportunityId?: string;
}
/**
* Parameters for Web-to-Case (public contact form, no account)
*/
export interface CreateWebCaseParams {
subject: string;
description: string;
suppliedEmail: string;
suppliedName: string;
suppliedPhone?: string;
origin?: string;
priority?: string;
} }
@Injectable() @Injectable()
@ -50,13 +83,13 @@ export class SalesforceCaseService {
) {} ) {}
/** /**
* Get all cases for an account filtered by Portal Website origin * Get all customer-visible support cases for an account
*/ */
async getCasesForAccount(accountId: string): Promise<SupportCase[]> { async getCasesForAccount(accountId: string): Promise<SupportCase[]> {
const safeAccountId = assertSalesforceId(accountId, "accountId"); const safeAccountId = assertSalesforceId(accountId, "accountId");
this.logger.debug({ accountId: safeAccountId }, "Fetching portal cases for account"); this.logger.debug({ accountId: safeAccountId }, "Fetching portal cases for account");
const soql = buildCasesForAccountQuery(safeAccountId, SALESFORCE_CASE_ORIGIN.PORTAL_WEBSITE); const soql = buildCasesForAccountQuery(safeAccountId, SALESFORCE_CASE_ORIGIN.PORTAL_SUPPORT);
try { try {
const result = (await this.sf.query(soql, { const result = (await this.sf.query(soql, {
@ -92,7 +125,7 @@ export class SalesforceCaseService {
const soql = buildCaseByIdQuery( const soql = buildCaseByIdQuery(
safeCaseId, safeCaseId,
safeAccountId, safeAccountId,
SALESFORCE_CASE_ORIGIN.PORTAL_WEBSITE SALESFORCE_CASE_ORIGIN.PORTAL_SUPPORT
); );
try { try {
@ -118,27 +151,40 @@ export class SalesforceCaseService {
} }
/** /**
* Create a new case with Portal Website origin * Create a case in Salesforce.
*
* This is the unified method for creating all case types:
* - Customer support cases (origin: PORTAL_SUPPORT)
* - Internal workflow cases (origin: PORTAL_NOTIFICATION)
*
* @param params - Case creation parameters
* @returns Created case ID and case number
*/ */
async createCase(params: CreateCaseParams): Promise<{ id: string; caseNumber: string }> { async createCase(params: CreateCaseParams): Promise<{ id: string; caseNumber: string }> {
const safeAccountId = assertSalesforceId(params.accountId, "accountId"); const safeAccountId = assertSalesforceId(params.accountId, "accountId");
const safeContactId = params.contactId const safeContactId = params.contactId
? assertSalesforceId(params.contactId, "contactId") ? assertSalesforceId(params.contactId, "contactId")
: undefined; : undefined;
const safeOpportunityId = params.opportunityId
? assertSalesforceId(params.opportunityId, "opportunityId")
: undefined;
this.logger.log( const isInternalCase = params.origin === SALESFORCE_CASE_ORIGIN.PORTAL_NOTIFICATION;
{ accountId: safeAccountId, subject: params.subject },
"Creating portal support case"
);
// Build case payload with portal defaults this.logger.log("Creating Salesforce case", {
// Convert portal display values to Salesforce API values accountIdTail: safeAccountId.slice(-4),
origin: params.origin,
isInternal: isInternalCase,
hasOpportunity: !!safeOpportunityId,
});
// Convert portal display priority to Salesforce API value
const sfPriority = params.priority const sfPriority = params.priority
? toSalesforcePriority(params.priority) ? toSalesforcePriority(params.priority)
: SALESFORCE_CASE_PRIORITY.MEDIUM; : SALESFORCE_CASE_PRIORITY.MEDIUM;
const casePayload: Record<string, unknown> = { const casePayload: Record<string, unknown> = {
Origin: SALESFORCE_CASE_ORIGIN.PORTAL_WEBSITE, Origin: params.origin,
Status: SALESFORCE_CASE_STATUS.NEW, Status: SALESFORCE_CASE_STATUS.NEW,
Priority: sfPriority, Priority: sfPriority,
Subject: params.subject.trim(), Subject: params.subject.trim(),
@ -146,18 +192,17 @@ export class SalesforceCaseService {
}; };
// Set ContactId if available - Salesforce will auto-populate AccountId from Contact // Set ContactId if available - Salesforce will auto-populate AccountId from Contact
// If no ContactId, we must set AccountId directly (requires FLS write permission) // If no ContactId, we must set AccountId directly
if (safeContactId) { if (safeContactId) {
casePayload.ContactId = safeContactId; casePayload.ContactId = safeContactId;
} else { } else {
// Only set AccountId when no ContactId is available
// Note: This requires AccountId field-level security write permission
casePayload.AccountId = safeAccountId; casePayload.AccountId = safeAccountId;
} }
// Note: Category maps to Salesforce Type field // Link to Opportunity if provided (used for workflow cases)
// Only set if Type picklist is configured in Salesforce if (safeOpportunityId) {
// Currently skipped as Type picklist values are unknown casePayload.OpportunityId = safeOpportunityId;
}
try { try {
const created = (await this.sf.sobject("Case").create(casePayload)) as { id?: string }; const created = (await this.sf.sobject("Case").create(casePayload)) as { id?: string };
@ -170,34 +215,30 @@ export class SalesforceCaseService {
const createdCase = await this.getCaseByIdInternal(created.id); const createdCase = await this.getCaseByIdInternal(created.id);
const caseNumber = createdCase?.CaseNumber ?? created.id; const caseNumber = createdCase?.CaseNumber ?? created.id;
this.logger.log( this.logger.log("Salesforce case created successfully", {
{ caseId: created.id, caseNumber }, caseId: created.id,
"Portal support case created successfully" caseNumber,
); origin: params.origin,
});
return { id: created.id, caseNumber }; return { id: created.id, caseNumber };
} catch (error: unknown) { } catch (error: unknown) {
this.logger.error("Failed to create support case", { this.logger.error("Failed to create Salesforce case", {
error: extractErrorMessage(error), error: extractErrorMessage(error),
accountId: safeAccountId, accountIdTail: safeAccountId.slice(-4),
origin: params.origin,
}); });
throw new Error("Failed to create support case"); throw new Error("Failed to create case");
} }
} }
/** /**
* Create a Web-to-Case for public contact form submissions * Create a Web-to-Case for public contact form submissions.
* Does not require an Account - uses supplied contact info *
* Does not require an Account - uses supplied contact info.
* Separate from createCase() because it has a different payload structure.
*/ */
async createWebCase(params: { async createWebCase(params: CreateWebCaseParams): Promise<{ id: string; caseNumber: string }> {
subject: string;
description: string;
suppliedEmail: string;
suppliedName: string;
suppliedPhone?: string;
origin?: string;
priority?: string;
}): Promise<{ id: string; caseNumber: string }> {
this.logger.log("Creating Web-to-Case", { email: params.suppliedEmail }); this.logger.log("Creating Web-to-Case", { email: params.suppliedEmail });
const casePayload: Record<string, unknown> = { const casePayload: Record<string, unknown> = {
@ -258,148 +299,4 @@ export class SalesforceCaseService {
return result.records?.[0] ?? null; return result.records?.[0] ?? null;
} }
// ==========================================================================
// Opportunity-Linked Cases
// ==========================================================================
/**
* Create an eligibility check case linked to an Opportunity
*
* @param params - Case parameters including Opportunity link
* @returns Created case ID
*/
async createEligibilityCase(params: {
accountId: string;
opportunityId: string;
subject: string;
description: string;
}): Promise<string> {
const safeAccountId = assertSalesforceId(params.accountId, "accountId");
const safeOpportunityId = assertSalesforceId(params.opportunityId, "opportunityId");
this.logger.log("Creating eligibility check case linked to Opportunity", {
accountIdTail: safeAccountId.slice(-4),
opportunityIdTail: safeOpportunityId.slice(-4),
});
const casePayload: Record<string, unknown> = {
Origin: SALESFORCE_CASE_ORIGIN.PORTAL_WEBSITE,
Status: SALESFORCE_CASE_STATUS.NEW,
Priority: SALESFORCE_CASE_PRIORITY.MEDIUM,
Subject: params.subject,
Description: params.description,
AccountId: safeAccountId,
// Link Case to Opportunity - this is a standard lookup field
OpportunityId: safeOpportunityId,
};
try {
const created = (await this.sf.sobject("Case").create(casePayload)) as { id?: string };
if (!created.id) {
throw new Error("Salesforce did not return a case ID");
}
this.logger.log("Eligibility case created and linked to Opportunity", {
caseId: created.id,
opportunityIdTail: safeOpportunityId.slice(-4),
});
return created.id;
} catch (error: unknown) {
this.logger.error("Failed to create eligibility case", {
error: extractErrorMessage(error),
accountIdTail: safeAccountId.slice(-4),
});
throw new Error("Failed to create eligibility check case");
}
}
/**
* Create a cancellation request case linked to an Opportunity
*
* All customer-provided details (comments, alternative email) go here.
* The Opportunity only gets the core lifecycle fields (dates, status).
*
* @param params - Cancellation case parameters
* @returns Created case ID
*/
async createCancellationCase(params: {
accountId: string;
opportunityId?: string;
whmcsServiceId: number;
productType: string;
cancellationMonth: string;
cancellationDate: string;
alternativeEmail?: string;
comments?: string;
}): Promise<string> {
const safeAccountId = assertSalesforceId(params.accountId, "accountId");
const safeOpportunityId = params.opportunityId
? assertSalesforceId(params.opportunityId, "opportunityId")
: null;
this.logger.log("Creating cancellation request case", {
accountIdTail: safeAccountId.slice(-4),
opportunityId: safeOpportunityId ? safeOpportunityId.slice(-4) : "none",
whmcsServiceId: params.whmcsServiceId,
});
// Build description with all form data
const descriptionLines = [
`Cancellation Request from Portal`,
``,
`Product Type: ${params.productType}`,
`WHMCS Service ID: ${params.whmcsServiceId}`,
`Cancellation Month: ${params.cancellationMonth}`,
`Service End Date: ${params.cancellationDate}`,
``,
];
if (params.alternativeEmail) {
descriptionLines.push(`Alternative Contact Email: ${params.alternativeEmail}`);
}
if (params.comments) {
descriptionLines.push(``, `Customer Comments:`, params.comments);
}
descriptionLines.push(``, `Submitted: ${new Date().toISOString()}`);
const casePayload: Record<string, unknown> = {
Origin: "Portal",
Status: SALESFORCE_CASE_STATUS.NEW,
Priority: SALESFORCE_CASE_PRIORITY.HIGH,
Subject: `Cancellation Request - ${params.productType} (${params.cancellationMonth})`,
Description: descriptionLines.join("\n"),
AccountId: safeAccountId,
};
// Link to Opportunity if we have one
if (safeOpportunityId) {
casePayload.OpportunityId = safeOpportunityId;
}
try {
const created = (await this.sf.sobject("Case").create(casePayload)) as { id?: string };
if (!created.id) {
throw new Error("Salesforce did not return a case ID");
}
this.logger.log("Cancellation case created", {
caseId: created.id,
hasOpportunityLink: !!safeOpportunityId,
});
return created.id;
} catch (error: unknown) {
this.logger.error("Failed to create cancellation case", {
error: extractErrorMessage(error),
accountIdTail: safeAccountId.slice(-4),
});
throw new Error("Failed to create cancellation request case");
}
}
} }

View File

@ -29,7 +29,7 @@ export class InvoiceRetrievalService {
/** /**
* Get paginated invoices for a user * Get paginated invoices for a user
*/ */
async getInvoices(userId: string, options: InvoiceListQuery = {}): Promise<InvoiceList> { async getInvoices(userId: string, options: Partial<InvoiceListQuery> = {}): Promise<InvoiceList> {
const { const {
page = INVOICE_PAGINATION.DEFAULT_PAGE, page = INVOICE_PAGINATION.DEFAULT_PAGE,
limit = INVOICE_PAGINATION.DEFAULT_LIMIT, limit = INVOICE_PAGINATION.DEFAULT_LIMIT,

View File

@ -25,7 +25,7 @@ import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js";
import { SalesforceConnection } from "@bff/integrations/salesforce/services/salesforce-connection.service.js"; import { SalesforceConnection } from "@bff/integrations/salesforce/services/salesforce-connection.service.js";
import { OpportunityResolutionService } from "@bff/integrations/salesforce/services/opportunity-resolution.service.js"; import { OpportunityResolutionService } from "@bff/integrations/salesforce/services/opportunity-resolution.service.js";
import { SalesforceCaseService } from "@bff/integrations/salesforce/services/salesforce-case.service.js"; import { SalesforceCaseService } from "@bff/integrations/salesforce/services/salesforce-case.service.js";
import { DistributedLockService } from "@bff/infra/cache/distributed-lock.service.js"; import { SALESFORCE_CASE_ORIGIN } from "@customer-portal/domain/support/providers";
import { Logger } from "nestjs-pino"; import { Logger } from "nestjs-pino";
import { extractErrorMessage } from "@bff/core/utils/error.util.js"; import { extractErrorMessage } from "@bff/core/utils/error.util.js";
import { assertSalesforceId } from "@bff/integrations/salesforce/utils/soql.util.js"; import { assertSalesforceId } from "@bff/integrations/salesforce/utils/soql.util.js";
@ -42,7 +42,6 @@ export class InternetServicesService extends BaseServicesService {
@Inject(Logger) logger: Logger, @Inject(Logger) logger: Logger,
private mappingsService: MappingsService, private mappingsService: MappingsService,
private catalogCache: ServicesCacheService, private catalogCache: ServicesCacheService,
private lockService: DistributedLockService,
private opportunityResolution: OpportunityResolutionService, private opportunityResolution: OpportunityResolutionService,
private caseService: SalesforceCaseService private caseService: SalesforceCaseService
) { ) {
@ -300,85 +299,52 @@ export class InternetServicesService extends BaseServicesService {
} }
try { try {
const lockKey = `internet:eligibility:${sfAccountId}`; const subject = "Internet availability check request (Portal)";
const caseId = await this.lockService.withLock( // 1) Find or create Opportunity for Internet eligibility (this service remains locked internally)
lockKey, const { opportunityId, wasCreated: opportunityCreated } =
async () => { await this.opportunityResolution.findOrCreateForInternetEligibility(sfAccountId);
// Idempotency: if we already have a pending request, do not create a new Case.
// The Case creation is a signal of interest; if status is pending, interest is already signaled/active.
const existing = await this.queryEligibilityDetails(sfAccountId);
if (existing.status === "pending") { // 2) Build case description
this.logger.log("Eligibility request already pending; skipping new case creation", { const descriptionLines: string[] = [
userId, "Portal internet availability check requested.",
sfAccountIdTail: sfAccountId.slice(-4), "",
}); `UserId: ${userId}`,
`Email: ${request.email}`,
`SalesforceAccountId: ${sfAccountId}`,
`OpportunityId: ${opportunityId}`,
"",
request.notes ? `Notes: ${request.notes}` : "",
request.address ? `Address: ${formatAddressForLog(request.address)}` : "",
"",
`RequestedAt: ${new Date().toISOString()}`,
].filter(Boolean);
// Try to find the existing open case to return its ID (best effort) // 3) Create Case linked to Opportunity (internal workflow case)
try { const { id: createdCaseId } = await this.caseService.createCase({
const cases = await this.caseService.getCasesForAccount(sfAccountId); accountId: sfAccountId,
const openCase = cases.find( opportunityId,
c => c.status !== "Closed" && c.subject.includes("Internet availability check") subject,
); description: descriptionLines.join("\n"),
if (openCase) { origin: SALESFORCE_CASE_ORIGIN.PORTAL_NOTIFICATION,
return openCase.id; });
}
} catch (error) {
this.logger.warn("Failed to lookup existing case for pending request", { error });
}
// If we can't find the case ID but status is pending, we return a placeholder or empty string. // 4) Update Account eligibility status (best-effort for optional fields)
// The frontend primarily relies on the status change. await this.updateAccountEligibilityRequestState(sfAccountId, {
return ""; caseId: createdCaseId,
} notes: request.notes,
});
await this.catalogCache.invalidateEligibility(sfAccountId);
// 1) Find or create Opportunity for Internet eligibility this.logger.log("Created eligibility Case linked to Opportunity", {
const { opportunityId, wasCreated: opportunityCreated } = userId,
await this.opportunityResolution.findOrCreateForInternetEligibility(sfAccountId); sfAccountIdTail: sfAccountId.slice(-4),
caseIdTail: createdCaseId.slice(-4),
opportunityIdTail: opportunityId.slice(-4),
opportunityCreated,
});
// 2) Build case description return createdCaseId;
const subject = "Internet availability check request (Portal)";
const descriptionLines: string[] = [
"Portal internet availability check requested.",
"",
`UserId: ${userId}`,
`Email: ${request.email}`,
`SalesforceAccountId: ${sfAccountId}`,
`OpportunityId: ${opportunityId}`,
"",
request.notes ? `Notes: ${request.notes}` : "",
request.address ? `Address: ${formatAddressForLog(request.address)}` : "",
"",
`RequestedAt: ${new Date().toISOString()}`,
].filter(Boolean);
// 3) Create Case linked to Opportunity
const createdCaseId = await this.caseService.createEligibilityCase({
accountId: sfAccountId,
opportunityId,
subject,
description: descriptionLines.join("\n"),
});
// 4) Update Account eligibility status
await this.updateAccountEligibilityRequestState(sfAccountId);
await this.catalogCache.invalidateEligibility(sfAccountId);
this.logger.log("Created eligibility Case linked to Opportunity", {
userId,
sfAccountIdTail: sfAccountId.slice(-4),
caseIdTail: createdCaseId.slice(-4),
opportunityIdTail: opportunityId.slice(-4),
opportunityCreated,
});
return createdCaseId;
},
{ ttlMs: 10_000 }
);
return caseId;
} catch (error) { } catch (error) {
this.logger.error("Failed to create eligibility request", { this.logger.error("Failed to create eligibility request", {
userId, userId,
@ -415,18 +381,44 @@ export class InternetServicesService extends BaseServicesService {
"Internet_Eligibility_Checked_Date_Time__c", "Internet_Eligibility_Checked_Date_Time__c",
"ACCOUNT_INTERNET_ELIGIBILITY_CHECKED_AT_FIELD" "ACCOUNT_INTERNET_ELIGIBILITY_CHECKED_AT_FIELD"
); );
// Note: Notes and Case ID fields removed as they are not present/needed in the Salesforce schema const notesField = assertSoqlFieldName(
this.config.get<string>("ACCOUNT_INTERNET_ELIGIBILITY_NOTES_FIELD") ??
"Internet_Eligibility_Notes__c",
"ACCOUNT_INTERNET_ELIGIBILITY_NOTES_FIELD"
);
const caseIdField = assertSoqlFieldName(
this.config.get<string>("ACCOUNT_INTERNET_ELIGIBILITY_CASE_ID_FIELD") ??
"Internet_Eligibility_Case_Id__c",
"ACCOUNT_INTERNET_ELIGIBILITY_CASE_ID_FIELD"
);
const soql = ` const selectBase = `Id, ${eligibilityField}, ${statusField}, ${requestedAtField}, ${checkedAtField}`;
SELECT Id, ${eligibilityField}, ${statusField}, ${requestedAtField}, ${checkedAtField} const selectExtended = `${selectBase}, ${notesField}, ${caseIdField}`;
FROM Account
WHERE Id = '${sfAccountId}'
LIMIT 1
`;
const res = (await this.sf.query(soql, { const queryAccount = async (selectFields: string, labelSuffix: string) => {
label: "services:internet:eligibility_details", const soql = `
})) as SalesforceResponse<Record<string, unknown>>; SELECT ${selectFields}
FROM Account
WHERE Id = '${sfAccountId}'
LIMIT 1
`;
return (await this.sf.query(soql, {
label: `services:internet:eligibility_details:${labelSuffix}`,
})) as SalesforceResponse<Record<string, unknown>>;
};
let res: SalesforceResponse<Record<string, unknown>>;
try {
res = await queryAccount(selectExtended, "extended");
} catch (error) {
// Hardening: if optional fields aren't available (FLS / schema drift),
// fall back to the minimal field set rather than failing the whole endpoint.
this.logger.warn("Eligibility details query failed (extended); falling back to base fields", {
sfAccountIdTail: sfAccountId.slice(-4),
error: extractErrorMessage(error),
});
res = await queryAccount(selectBase, "base");
}
const record = (res.records?.[0] as Record<string, unknown> | undefined) ?? undefined; const record = (res.records?.[0] as Record<string, unknown> | undefined) ?? undefined;
if (!record) { if (!record) {
return internetEligibilityDetailsSchema.parse({ return internetEligibilityDetailsSchema.parse({
@ -461,6 +453,8 @@ export class InternetServicesService extends BaseServicesService {
const requestedAtRaw = record[requestedAtField]; const requestedAtRaw = record[requestedAtField];
const checkedAtRaw = record[checkedAtField]; const checkedAtRaw = record[checkedAtField];
const notesRaw = record[notesField];
const requestIdRaw = record[caseIdField];
const requestedAt = const requestedAt =
typeof requestedAtRaw === "string" typeof requestedAtRaw === "string"
@ -478,17 +472,23 @@ export class InternetServicesService extends BaseServicesService {
return internetEligibilityDetailsSchema.parse({ return internetEligibilityDetailsSchema.parse({
status, status,
eligibility, eligibility,
requestId: null, // Always null as field is not used requestId:
typeof requestIdRaw === "string" && requestIdRaw.trim().length > 0
? requestIdRaw.trim()
: null,
requestedAt, requestedAt,
checkedAt, checkedAt,
notes: null, // Always null as field is not used notes: typeof notesRaw === "string" && notesRaw.trim().length > 0 ? notesRaw.trim() : null,
}); });
} }
// Note: createEligibilityCaseOrTask was removed - now using this.caseService.createEligibilityCase() // Note: createEligibilityCaseOrTask was removed - now using this.caseService.createEligibilityCase()
// which links the Case to the Opportunity // which links the Case to the Opportunity
private async updateAccountEligibilityRequestState(sfAccountId: string): Promise<void> { private async updateAccountEligibilityRequestState(
sfAccountId: string,
params?: { caseId?: string; notes?: string }
): Promise<void> {
const statusField = assertSoqlFieldName( const statusField = assertSoqlFieldName(
this.config.get<string>("ACCOUNT_INTERNET_ELIGIBILITY_STATUS_FIELD") ?? this.config.get<string>("ACCOUNT_INTERNET_ELIGIBILITY_STATUS_FIELD") ??
"Internet_Eligibility_Status__c", "Internet_Eligibility_Status__c",
@ -499,17 +499,45 @@ export class InternetServicesService extends BaseServicesService {
"Internet_Eligibility_Request_Date_Time__c", "Internet_Eligibility_Request_Date_Time__c",
"ACCOUNT_INTERNET_ELIGIBILITY_REQUESTED_AT_FIELD" "ACCOUNT_INTERNET_ELIGIBILITY_REQUESTED_AT_FIELD"
); );
const caseIdField = assertSoqlFieldName(
this.config.get<string>("ACCOUNT_INTERNET_ELIGIBILITY_CASE_ID_FIELD") ??
"Internet_Eligibility_Case_Id__c",
"ACCOUNT_INTERNET_ELIGIBILITY_CASE_ID_FIELD"
);
const notesField = assertSoqlFieldName(
this.config.get<string>("ACCOUNT_INTERNET_ELIGIBILITY_NOTES_FIELD") ??
"Internet_Eligibility_Notes__c",
"ACCOUNT_INTERNET_ELIGIBILITY_NOTES_FIELD"
);
const update = this.sf.sobject("Account")?.update; const update = this.sf.sobject("Account")?.update;
if (!update) { if (!update) {
throw new Error("Salesforce Account update method not available"); throw new Error("Salesforce Account update method not available");
} }
await update({ const basePayload: { Id: string } & Record<string, unknown> = {
Id: sfAccountId, Id: sfAccountId,
[statusField]: "Pending", [statusField]: "Pending",
[requestedAtField]: new Date().toISOString(), [requestedAtField]: new Date().toISOString(),
}); };
const extendedPayload: { Id: string } & Record<string, unknown> = { ...basePayload };
if (params?.caseId) {
extendedPayload[caseIdField] = params.caseId;
}
if (typeof params?.notes === "string") {
extendedPayload[notesField] = params.notes.trim().length > 0 ? params.notes.trim() : null;
}
try {
await update(extendedPayload);
} catch (error) {
// Hardening: If optional fields fail (schema/FLS), still set core Pending fields.
this.logger.warn("Eligibility Account update failed (extended); retrying base update", {
sfAccountIdTail: sfAccountId.slice(-4),
error: extractErrorMessage(error),
});
await update(basePayload);
}
} }
} }

View File

@ -18,6 +18,7 @@ import { WhmcsClientService } from "@bff/integrations/whmcs/services/whmcs-clien
import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js"; import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js";
import { SalesforceCaseService } from "@bff/integrations/salesforce/services/salesforce-case.service.js"; import { SalesforceCaseService } from "@bff/integrations/salesforce/services/salesforce-case.service.js";
import { SalesforceOpportunityService } from "@bff/integrations/salesforce/services/salesforce-opportunity.service.js"; import { SalesforceOpportunityService } from "@bff/integrations/salesforce/services/salesforce-opportunity.service.js";
import { SALESFORCE_CASE_ORIGIN } from "@customer-portal/domain/support/providers";
import { EmailService } from "@bff/infra/email/email.service.js"; import { EmailService } from "@bff/infra/email/email.service.js";
import { NotificationService } from "@bff/modules/notifications/notifications.service.js"; import { NotificationService } from "@bff/modules/notifications/notifications.service.js";
import type { import type {
@ -225,16 +226,35 @@ export class InternetCancellationService {
this.logger.warn("Could not find Opportunity for subscription", { subscriptionId }); this.logger.warn("Could not find Opportunity for subscription", { subscriptionId });
} }
// Create Salesforce Case for cancellation // Build description with all form data
const caseId = await this.caseService.createCancellationCase({ const descriptionLines = [
`Cancellation Request from Portal`,
``,
`Product Type: Internet`,
`WHMCS Service ID: ${subscriptionId}`,
`Cancellation Month: ${request.cancellationMonth}`,
`Service End Date: ${cancellationDate}`,
``,
];
if (request.alternativeEmail) {
descriptionLines.push(`Alternative Contact Email: ${request.alternativeEmail}`);
}
if (request.comments) {
descriptionLines.push(``, `Customer Comments:`, request.comments);
}
descriptionLines.push(``, `Submitted: ${new Date().toISOString()}`);
// Create Salesforce Case for cancellation (internal workflow case)
const { id: caseId } = await this.caseService.createCase({
accountId: sfAccountId, accountId: sfAccountId,
opportunityId: opportunityId || undefined, opportunityId: opportunityId || undefined,
whmcsServiceId: subscriptionId, subject: `Cancellation Request - Internet (${request.cancellationMonth})`,
productType: "Internet", description: descriptionLines.join("\n"),
cancellationMonth: request.cancellationMonth, origin: SALESFORCE_CASE_ORIGIN.PORTAL_NOTIFICATION,
cancellationDate, priority: "High",
alternativeEmail: request.alternativeEmail || undefined,
comments: request.comments,
}); });
this.logger.log("Cancellation case created", { this.logger.log("Cancellation case created", {

View File

@ -10,6 +10,7 @@ import {
type CreateCaseResponse, type CreateCaseResponse,
type PublicContactRequest, type PublicContactRequest,
} from "@customer-portal/domain/support"; } from "@customer-portal/domain/support";
import { SALESFORCE_CASE_ORIGIN } from "@customer-portal/domain/support/providers";
import { SalesforceCaseService } from "@bff/integrations/salesforce/services/salesforce-case.service.js"; import { SalesforceCaseService } from "@bff/integrations/salesforce/services/salesforce-case.service.js";
import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js"; import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js";
import { extractErrorMessage } from "@bff/core/utils/error.util.js"; import { extractErrorMessage } from "@bff/core/utils/error.util.js";
@ -110,9 +111,9 @@ export class SupportService {
const result = await this.caseService.createCase({ const result = await this.caseService.createCase({
subject: request.subject, subject: request.subject,
description: request.description, description: request.description,
category: request.category,
priority: request.priority, priority: request.priority,
accountId, accountId,
origin: SALESFORCE_CASE_ORIGIN.PORTAL_SUPPORT,
}); });
this.logger.log("Support case created", { this.logger.log("Support case created", {

View File

@ -3,6 +3,8 @@ import { ConfigService } from "@nestjs/config";
import { Logger } from "nestjs-pino"; import { Logger } from "nestjs-pino";
import { SalesforceConnection } from "@bff/integrations/salesforce/services/salesforce-connection.service.js"; import { SalesforceConnection } from "@bff/integrations/salesforce/services/salesforce-connection.service.js";
import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js"; import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js";
import { SalesforceCaseService } from "@bff/integrations/salesforce/services/salesforce-case.service.js";
import { SALESFORCE_CASE_ORIGIN } from "@customer-portal/domain/support/providers";
import { import {
assertSalesforceId, assertSalesforceId,
assertSoqlFieldName, assertSoqlFieldName,
@ -34,6 +36,7 @@ export class ResidenceCardService {
constructor( constructor(
private readonly sf: SalesforceConnection, private readonly sf: SalesforceConnection,
private readonly mappings: MappingsService, private readonly mappings: MappingsService,
private readonly caseService: SalesforceCaseService,
private readonly config: ConfigService, private readonly config: ConfigService,
@Inject(Logger) private readonly logger: Logger @Inject(Logger) private readonly logger: Logger
) {} ) {}
@ -98,7 +101,9 @@ export class ResidenceCardService {
: null; : null;
const fileMeta = const fileMeta =
status === "not_submitted" ? null : await this.getLatestAccountFileMetadata(sfAccountId); status === "not_submitted"
? null
: await this.getLatestIdVerificationFileMetadata(sfAccountId);
const payload = { const payload = {
status, status,
@ -149,6 +154,33 @@ export class ResidenceCardService {
} }
const sfAccountId = assertSalesforceId(mapping.sfAccountId, "sfAccountId"); const sfAccountId = assertSalesforceId(mapping.sfAccountId, "sfAccountId");
// Create an internal workflow Case for CS to track this submission.
// (No lock/dedupe: multiple submissions may create multiple cases by design.)
const subject = "ID verification review (Portal)";
const descriptionLines: string[] = [
"Portal ID verification submitted (residence card).",
"",
`UserId: ${params.userId}`,
// Email is not included here to reduce sensitive data in case bodies.
`SalesforceAccountId: ${sfAccountId}`,
"",
`Filename: ${params.filename || "residence-card"}`,
`MimeType: ${params.mimeType}`,
`SizeBytes: ${params.sizeBytes}`,
"",
"The ID document is attached to this Case (see Files related list).",
"",
`SubmittedAt: ${new Date().toISOString()}`,
];
const { id: caseId } = await this.caseService.createCase({
accountId: sfAccountId,
subject,
description: descriptionLines.join("\n"),
origin: SALESFORCE_CASE_ORIGIN.PORTAL_NOTIFICATION,
});
// Upload file to Salesforce Files and attach to the Case
const versionData = params.content.toString("base64"); const versionData = params.content.toString("base64");
const extension = extname(params.filename || "").replace(/^\./, ""); const extension = extname(params.filename || "").replace(/^\./, "");
const title = basename(params.filename || "residence-card", extension ? `.${extension}` : ""); const title = basename(params.filename || "residence-card", extension ? `.${extension}` : "");
@ -159,26 +191,33 @@ export class ResidenceCardService {
} }
try { try {
// Attach file directly to the Case (not Account) so CS can see it in the Case's Files
const result = await create({ const result = await create({
Title: title || "residence-card", Title: title || "residence-card",
PathOnClient: params.filename || "residence-card", PathOnClient: params.filename || "residence-card",
VersionData: versionData, VersionData: versionData,
FirstPublishLocationId: sfAccountId, FirstPublishLocationId: caseId, // Attach to Case, not Account
}); });
const id = (result as { id?: unknown })?.id; const contentVersionId = (result as { id?: unknown })?.id;
if (typeof id !== "string" || id.trim().length === 0) { if (typeof contentVersionId !== "string" || contentVersionId.trim().length === 0) {
throw new DomainHttpException( throw new DomainHttpException(
ErrorCode.EXTERNAL_SERVICE_ERROR, ErrorCode.EXTERNAL_SERVICE_ERROR,
HttpStatus.SERVICE_UNAVAILABLE HttpStatus.SERVICE_UNAVAILABLE
); );
} }
this.logger.log("ID verification file attached to Case", {
caseIdTail: caseId.slice(-4),
contentVersionId,
filename: params.filename,
});
} catch (error) { } catch (error) {
if (error instanceof DomainHttpException) { if (error instanceof DomainHttpException) {
throw error; throw error;
} }
this.logger.error("Failed to upload residence card to Salesforce Files", { this.logger.error("Failed to upload residence card to Salesforce Files", {
userId: params.userId, userId: params.userId,
sfAccountIdTail: sfAccountId.slice(-4), caseIdTail: caseId.slice(-4),
error: extractErrorMessage(error), error: extractErrorMessage(error),
}); });
throw new DomainHttpException( throw new DomainHttpException(
@ -239,17 +278,40 @@ export class ResidenceCardService {
}; };
} }
private async getLatestAccountFileMetadata(accountId: string): Promise<{ /**
* Get the latest ID verification file metadata from Cases linked to the Account.
*
* Files are attached to ID verification Cases (not the Account directly).
* We find the most recent Case with the ID verification subject and get its file.
*/
private async getLatestIdVerificationFileMetadata(accountId: string): Promise<{
filename: string | null; filename: string | null;
mimeType: string | null; mimeType: string | null;
sizeBytes: number | null; sizeBytes: number | null;
submittedAt: string | null; submittedAt: string | null;
} | null> { } | null> {
try { try {
// Find the most recent ID verification case for this account
const caseSoql = `
SELECT Id
FROM Case
WHERE AccountId = '${accountId}'
AND Origin = '${SALESFORCE_CASE_ORIGIN.PORTAL_NOTIFICATION}'
AND Subject LIKE '%ID verification%'
ORDER BY CreatedDate DESC
LIMIT 1
`;
const caseRes = (await this.sf.query(caseSoql, {
label: "verification:residence_card:latest_case",
})) as SalesforceResponse<{ Id?: string }>;
const caseId = caseRes.records?.[0]?.Id;
if (!caseId) return null;
// Get files linked to that case
const linkSoql = ` const linkSoql = `
SELECT ContentDocumentId SELECT ContentDocumentId
FROM ContentDocumentLink FROM ContentDocumentLink
WHERE LinkedEntityId = '${accountId}' WHERE LinkedEntityId = '${caseId}'
ORDER BY SystemModstamp DESC ORDER BY SystemModstamp DESC
LIMIT 1 LIMIT 1
`; `;
@ -292,7 +354,7 @@ export class ResidenceCardService {
submittedAt, submittedAt,
}; };
} catch (error) { } catch (error) {
this.logger.warn("Failed to load residence card file metadata from Salesforce", { this.logger.warn("Failed to load ID verification file metadata from Salesforce", {
accountIdTail: accountId.slice(-4), accountIdTail: accountId.slice(-4),
error: extractErrorMessage(error), error: extractErrorMessage(error),
}); });

View File

@ -1,6 +1,6 @@
/// <reference types="next" /> /// <reference types="next" />
/// <reference types="next/image-types/global" /> /// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts"; import "./.next/dev/types/routes.d.ts";
// NOTE: This file should not be edited // NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. // see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

View File

@ -128,6 +128,6 @@
}, },
"devDependencies": { "devDependencies": {
"typescript": "catalog:", "typescript": "catalog:",
"zod": "^4.2.1" "zod": "catalog:"
} }
} }

View File

@ -69,6 +69,6 @@ export const SUPPORT_CASE_CATEGORY = {
} as const; } as const;
/** /**
* Portal Website origin - used to filter and create portal cases * Portal support origin - used to filter and create customer-visible portal support cases
*/ */
export const PORTAL_CASE_ORIGIN = "Portal Website" as const; export const PORTAL_CASE_ORIGIN = "Portal Support" as const;

View File

@ -123,7 +123,7 @@ export function buildCaseSelectFields(additionalFields: string[] = []): string[]
* Build a SOQL query for fetching cases for an account. * Build a SOQL query for fetching cases for an account.
* *
* @param accountId - Salesforce Account ID * @param accountId - Salesforce Account ID
* @param origin - Case origin to filter by (e.g., "Portal Website") * @param origin - Case origin to filter by (e.g., "Portal Support")
* @param additionalFields - Optional additional fields to include * @param additionalFields - Optional additional fields to include
* @returns SOQL query string * @returns SOQL query string
*/ */

View File

@ -168,8 +168,11 @@ export type SalesforceCaseCreatePayload = z.infer<typeof salesforceCaseCreatePay
* API Names from Salesforce Setup > Object Manager > Case > Fields > Case Origin * API Names from Salesforce Setup > Object Manager > Case > Fields > Case Origin
*/ */
export const SALESFORCE_CASE_ORIGIN = { export const SALESFORCE_CASE_ORIGIN = {
// Portal origin (used for portal-created cases) // Customer-visible support cases created from the portal support UI
PORTAL_WEBSITE: "Portal Website", PORTAL_SUPPORT: "Portal Support",
// Internal portal workflow cases (eligibility, verification, cancellations)
PORTAL_NOTIFICATION: "Portal Notification",
// Phone/Email origins // Phone/Email origins
PHONE: "電話", // Japanese: Phone PHONE: "電話", // Japanese: Phone

33
pnpm-lock.yaml generated
View File

@ -14,8 +14,8 @@ catalogs:
specifier: 5.9.3 specifier: 5.9.3
version: 5.9.3 version: 5.9.3
zod: zod:
specifier: 4.1.13 specifier: 4.2.1
version: 4.1.13 version: 4.2.1
overrides: overrides:
js-yaml: ">=4.1.1" js-yaml: ">=4.1.1"
@ -64,7 +64,7 @@ importers:
dependencies: dependencies:
"@customer-portal/domain": "@customer-portal/domain":
specifier: workspace:* specifier: workspace:*
version: file:packages/domain(zod@4.1.13) version: link:../../packages/domain
"@nestjs/bullmq": "@nestjs/bullmq":
specifier: ^11.0.4 specifier: ^11.0.4
version: 11.0.4(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.9)(bullmq@5.65.1) version: 11.0.4(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.9)(bullmq@5.65.1)
@ -121,7 +121,7 @@ importers:
version: 4.5.0(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(pino-http@11.0.0)(pino@10.1.0)(rxjs@7.8.2) version: 4.5.0(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(pino-http@11.0.0)(pino@10.1.0)(rxjs@7.8.2)
nestjs-zod: nestjs-zod:
specifier: ^5.0.1 specifier: ^5.0.1
version: 5.0.1(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/swagger@11.2.3(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.9)(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2))(rxjs@7.8.2)(zod@4.1.13) version: 5.0.1(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/swagger@11.2.3(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.9)(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2))(rxjs@7.8.2)(zod@4.2.1)
p-queue: p-queue:
specifier: ^9.0.1 specifier: ^9.0.1
version: 9.0.1 version: 9.0.1
@ -151,7 +151,7 @@ importers:
version: 5.0.1(express@5.1.0) version: 5.0.1(express@5.1.0)
zod: zod:
specifier: "catalog:" specifier: "catalog:"
version: 4.1.13 version: 4.2.1
devDependencies: devDependencies:
"@nestjs/cli": "@nestjs/cli":
specifier: ^11.0.14 specifier: ^11.0.14
@ -260,7 +260,7 @@ importers:
specifier: "catalog:" specifier: "catalog:"
version: 5.9.3 version: 5.9.3
zod: zod:
specifier: ^4.2.1 specifier: "catalog:"
version: 4.2.1 version: 4.2.1
packages: packages:
@ -508,11 +508,6 @@ packages:
} }
engines: { node: ">=0.1.90" } engines: { node: ">=0.1.90" }
"@customer-portal/domain@file:packages/domain":
resolution: { directory: packages/domain, type: directory }
peerDependencies:
zod: 4.1.13
"@discoveryjs/json-ext@0.5.7": "@discoveryjs/json-ext@0.5.7":
resolution: resolution:
{ {
@ -7513,12 +7508,6 @@ packages:
peerDependencies: peerDependencies:
zod: ^3.25.0 || ^4.0.0 zod: ^3.25.0 || ^4.0.0
zod@4.1.13:
resolution:
{
integrity: sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig==,
}
zod@4.2.1: zod@4.2.1:
resolution: resolution:
{ {
@ -7729,10 +7718,6 @@ snapshots:
"@colors/colors@1.5.0": "@colors/colors@1.5.0":
optional: true optional: true
"@customer-portal/domain@file:packages/domain(zod@4.1.13)":
dependencies:
zod: 4.1.13
"@discoveryjs/json-ext@0.5.7": {} "@discoveryjs/json-ext@0.5.7": {}
"@electric-sql/pglite-socket@0.0.6(@electric-sql/pglite@0.3.2)": "@electric-sql/pglite-socket@0.0.6(@electric-sql/pglite@0.3.2)":
@ -10757,12 +10742,12 @@ snapshots:
pino-http: 11.0.0 pino-http: 11.0.0
rxjs: 7.8.2 rxjs: 7.8.2
nestjs-zod@5.0.1(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/swagger@11.2.3(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.9)(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2))(rxjs@7.8.2)(zod@4.1.13): nestjs-zod@5.0.1(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/swagger@11.2.3(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.9)(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2))(rxjs@7.8.2)(zod@4.2.1):
dependencies: dependencies:
"@nestjs/common": 11.1.9(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2) "@nestjs/common": 11.1.9(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2)
deepmerge: 4.3.1 deepmerge: 4.3.1
rxjs: 7.8.2 rxjs: 7.8.2
zod: 4.1.13 zod: 4.2.1
optionalDependencies: optionalDependencies:
"@nestjs/swagger": 11.2.3(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.9)(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2) "@nestjs/swagger": 11.2.3(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.9)(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)
@ -11928,8 +11913,6 @@ snapshots:
dependencies: dependencies:
zod: 4.2.1 zod: 4.2.1
zod@4.1.13: {}
zod@4.2.1: {} zod@4.2.1: {}
zustand@5.0.9(@types/react@19.2.7)(react@19.2.3): zustand@5.0.9(@types/react@19.2.7)(react@19.2.3):

View File

@ -5,4 +5,4 @@ packages:
catalog: catalog:
"@types/node": 24.10.3 "@types/node": 24.10.3
typescript: 5.9.3 typescript: 5.9.3
zod: 4.1.13 zod: 4.2.1