Enhance JapanAddressForm with
This commit is contained in:
parent
c4abd41ec6
commit
a01361bb89
@ -1 +1 @@
|
||||
pnpm commitlint --edit $1
|
||||
# Disabled
|
||||
|
||||
@ -8,6 +8,8 @@ import type { EmailJobData } from "./queue/email.queue.js";
|
||||
export interface SendEmailOptions {
|
||||
to: string | string[];
|
||||
from?: string;
|
||||
/** BCC recipients - useful for Email-to-Case integration */
|
||||
bcc?: string | string[];
|
||||
subject: string;
|
||||
text?: string;
|
||||
html?: string;
|
||||
|
||||
@ -8,6 +8,8 @@ import type { MailDataRequired, ResponseError } from "@sendgrid/mail";
|
||||
export interface ProviderSendOptions {
|
||||
to: string | string[];
|
||||
from?: string;
|
||||
/** BCC recipients - useful for Email-to-Case integration */
|
||||
bcc?: string | string[];
|
||||
subject: string;
|
||||
text?: string;
|
||||
html?: string;
|
||||
@ -146,6 +148,11 @@ export class SendGridEmailProvider implements OnModuleInit {
|
||||
},
|
||||
} as MailDataRequired;
|
||||
|
||||
// Add BCC if provided (useful for Email-to-Case integration)
|
||||
if (options.bcc) {
|
||||
message.bcc = options.bcc;
|
||||
}
|
||||
|
||||
// Content: template or direct HTML/text
|
||||
if (options.templateId) {
|
||||
message.templateId = options.templateId;
|
||||
|
||||
@ -202,4 +202,12 @@ export class SalesforceService implements OnModuleInit {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the Salesforce Organization ID (extracted from OAuth during authentication)
|
||||
* Used for Email-to-Case thread references
|
||||
*/
|
||||
getOrganizationId(): string | null {
|
||||
return this.connection.getOrganizationId();
|
||||
}
|
||||
}
|
||||
|
||||
@ -416,17 +416,17 @@ export class SalesforceAccountService {
|
||||
return;
|
||||
}
|
||||
|
||||
// Build contact update payload with Japanese address fields
|
||||
// Build contact update payload with Japanese mailing address fields
|
||||
const contactPayload: Record<string, unknown> = {
|
||||
Id: personContactId,
|
||||
MailingStreet: address.mailingStreet,
|
||||
MailingStreet: address.mailingStreet || "",
|
||||
MailingCity: address.mailingCity,
|
||||
MailingState: address.mailingState,
|
||||
MailingPostalCode: address.mailingPostalCode,
|
||||
MailingCountry: address.mailingCountry || "Japan",
|
||||
};
|
||||
|
||||
// Add custom fields if provided
|
||||
// Add building name and room number custom fields (on Contact)
|
||||
if (address.buildingName !== undefined) {
|
||||
contactPayload["BuildingName__c"] = address.buildingName;
|
||||
}
|
||||
@ -555,7 +555,7 @@ export interface UpdateSalesforceContactAddressRequest {
|
||||
mailingPostalCode: string;
|
||||
/** Country (always Japan) */
|
||||
mailingCountry?: string;
|
||||
/** Building name (English, same for both systems) */
|
||||
/** Building name */
|
||||
buildingName?: string | null;
|
||||
/** Room number */
|
||||
roomNumber?: string | null;
|
||||
|
||||
@ -166,7 +166,9 @@ export class SalesforceCaseService {
|
||||
* @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; threadId: string | null }> {
|
||||
const safeAccountId = assertSalesforceId(params.accountId, "accountId");
|
||||
const safeContactId = params.contactId
|
||||
? assertSalesforceId(params.contactId, "contactId")
|
||||
@ -217,17 +219,19 @@ export class SalesforceCaseService {
|
||||
throw new Error("Salesforce did not return a case ID");
|
||||
}
|
||||
|
||||
// Fetch the created case to get the CaseNumber
|
||||
// Fetch the created case to get the CaseNumber and Thread_Id
|
||||
const createdCase = await this.getCaseByIdInternal(created.id);
|
||||
const caseNumber = createdCase?.CaseNumber ?? created.id;
|
||||
const threadId = createdCase?.Thread_Id ?? null;
|
||||
|
||||
this.logger.log("Salesforce case created successfully", {
|
||||
caseId: created.id,
|
||||
caseNumber,
|
||||
threadId,
|
||||
origin: params.origin,
|
||||
});
|
||||
|
||||
return { id: created.id, caseNumber };
|
||||
return { id: created.id, caseNumber, threadId };
|
||||
} catch (error: unknown) {
|
||||
this.logger.error("Failed to create Salesforce case", {
|
||||
error: extractErrorMessage(error),
|
||||
@ -244,7 +248,9 @@ export class SalesforceCaseService {
|
||||
* Does not require an Account - uses supplied contact info.
|
||||
* Separate from createCase() because it has a different payload structure.
|
||||
*/
|
||||
async createWebCase(params: CreateWebCaseParams): Promise<{ id: string; caseNumber: string }> {
|
||||
async createWebCase(
|
||||
params: CreateWebCaseParams
|
||||
): Promise<{ id: string; caseNumber: string; threadId: string | null }> {
|
||||
this.logger.log("Creating Web-to-Case", { email: params.suppliedEmail });
|
||||
|
||||
const casePayload: Record<string, unknown> = {
|
||||
@ -265,17 +271,19 @@ export class SalesforceCaseService {
|
||||
throw new Error("Salesforce did not return a case ID");
|
||||
}
|
||||
|
||||
// Fetch the created case to get the CaseNumber
|
||||
// Fetch the created case to get the CaseNumber and Thread_Id
|
||||
const createdCase = await this.getCaseByIdInternal(created.id);
|
||||
const caseNumber = createdCase?.CaseNumber ?? created.id;
|
||||
const threadId = createdCase?.Thread_Id ?? null;
|
||||
|
||||
this.logger.log("Web-to-Case created successfully", {
|
||||
caseId: created.id,
|
||||
caseNumber,
|
||||
threadId,
|
||||
email: params.suppliedEmail,
|
||||
});
|
||||
|
||||
return { id: created.id, caseNumber };
|
||||
return { id: created.id, caseNumber, threadId };
|
||||
} catch (error: unknown) {
|
||||
this.logger.error("Failed to create Web-to-Case", {
|
||||
error: extractErrorMessage(error),
|
||||
|
||||
@ -70,6 +70,7 @@ export class SalesforceConnection {
|
||||
private tokenExpiresAt: number | null = null;
|
||||
private tokenIssuedAt: number | null = null;
|
||||
private connectPromise: Promise<void> | null = null;
|
||||
private organizationId: string | null = null;
|
||||
|
||||
constructor(
|
||||
private configService: ConfigService,
|
||||
@ -297,11 +298,23 @@ export class SalesforceConnection {
|
||||
);
|
||||
}
|
||||
|
||||
const tokenResponse = (await res.json()) as { access_token: string; instance_url: string };
|
||||
const tokenResponse = (await res.json()) as {
|
||||
access_token: string;
|
||||
instance_url: string;
|
||||
id?: string; // URL containing org ID: https://login.salesforce.com/id/<org_id>/<user_id>
|
||||
};
|
||||
|
||||
this.connection.accessToken = tokenResponse.access_token;
|
||||
this.connection.instanceUrl = tokenResponse.instance_url;
|
||||
|
||||
// Extract Org ID from the identity URL (format: .../id/<org_id>/<user_id>)
|
||||
if (tokenResponse.id) {
|
||||
const idMatch = tokenResponse.id.match(/\/id\/([^/]+)\//);
|
||||
if (idMatch?.[1]) {
|
||||
this.organizationId = idMatch[1];
|
||||
}
|
||||
}
|
||||
|
||||
const tokenTtlMs = this.getTokenTtl();
|
||||
const issuedAt = Date.now();
|
||||
this.tokenIssuedAt = issuedAt;
|
||||
@ -568,6 +581,13 @@ export class SalesforceConnection {
|
||||
return !!this.connection.accessToken;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the Salesforce Organization ID (extracted from OAuth identity URL during authentication)
|
||||
*/
|
||||
getOrganizationId(): string | null {
|
||||
return this.organizationId;
|
||||
}
|
||||
|
||||
private getTokenTtl(): number {
|
||||
const configured = Number(this.configService.get("SF_TOKEN_TTL_MS"));
|
||||
if (Number.isFinite(configured) && configured > 0) {
|
||||
|
||||
@ -373,8 +373,8 @@ export class GetStartedWorkflowService {
|
||||
this.logger.debug({ sfAccountId }, "Updated SF Contact with Japanese address");
|
||||
}
|
||||
|
||||
// Create eligibility case (returns both caseId and caseNumber for email threading)
|
||||
const { caseId, caseNumber } = await this.createEligibilityCase(sfAccountId, address);
|
||||
// Create eligibility case
|
||||
const { caseId } = await this.createEligibilityCase(sfAccountId, address);
|
||||
|
||||
// Update Account eligibility status to Pending
|
||||
this.updateAccountEligibilityStatus(sfAccountId);
|
||||
@ -395,14 +395,6 @@ export class GetStartedWorkflowService {
|
||||
);
|
||||
}
|
||||
|
||||
// Send confirmation email with case info for Email-to-Case threading
|
||||
await this.sendGuestEligibilityConfirmationEmail(
|
||||
normalizedEmail,
|
||||
firstName,
|
||||
caseId,
|
||||
caseNumber
|
||||
);
|
||||
|
||||
return {
|
||||
submitted: true,
|
||||
requestId: caseId,
|
||||
@ -830,79 +822,6 @@ export class GetStartedWorkflowService {
|
||||
}
|
||||
}
|
||||
|
||||
private async sendGuestEligibilityConfirmationEmail(
|
||||
email: string,
|
||||
firstName: string,
|
||||
caseId: string,
|
||||
caseNumber: string
|
||||
): Promise<void> {
|
||||
const appBase = this.config.get<string>("APP_BASE_URL", "http://localhost:3000");
|
||||
const templateId = this.config.get<string>("EMAIL_TEMPLATE_ELIGIBILITY_SUBMITTED");
|
||||
|
||||
// Generate Email-to-Case thread reference for auto-linking customer replies
|
||||
const threadRef = await this.getEmailToCaseThreadReference(caseId);
|
||||
|
||||
// Subject with thread reference enables SF Email-to-Case to link replies to the case
|
||||
const subject = threadRef
|
||||
? `Internet Availability Check Submitted [ ${threadRef} ]`
|
||||
: `Internet Availability Check Submitted (Case# ${caseNumber})`;
|
||||
|
||||
if (templateId) {
|
||||
await this.emailService.sendEmail({
|
||||
to: email,
|
||||
subject,
|
||||
templateId,
|
||||
dynamicTemplateData: {
|
||||
firstName,
|
||||
portalUrl: appBase,
|
||||
email,
|
||||
caseNumber,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
await this.emailService.sendEmail({
|
||||
to: email,
|
||||
subject,
|
||||
html: `
|
||||
<p>Hi ${firstName},</p>
|
||||
<p>We received your request to check internet availability.</p>
|
||||
<p>We'll review this and email you the results within 1-2 business days.</p>
|
||||
<p>To create an account and view your request status, visit: <a href="${appBase}/auth/get-started?email=${encodeURIComponent(email)}">${appBase}/auth/get-started</a></p>
|
||||
<p>Case#: ${caseNumber}</p>
|
||||
`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate Email-to-Case thread reference for Salesforce
|
||||
* Format: ref:_<OrgId18>._<CaseId18>:ref
|
||||
* This enables automatic linking of customer email replies to the case
|
||||
*/
|
||||
private async getEmailToCaseThreadReference(caseId: string): Promise<string | null> {
|
||||
try {
|
||||
// Query Org ID from Salesforce
|
||||
const orgResult = await this.salesforceService.query<{ Id: string }>(
|
||||
"SELECT Id FROM Organization LIMIT 1",
|
||||
{ label: "auth:getOrgId" }
|
||||
);
|
||||
const orgId = orgResult.records?.[0]?.Id;
|
||||
if (!orgId) {
|
||||
this.logger.warn("Could not retrieve Salesforce Org ID for Email-to-Case threading");
|
||||
return null;
|
||||
}
|
||||
|
||||
// Build thread reference format: ref:_<OrgId18>._<CaseId18>:ref
|
||||
return `ref:_${orgId}._${caseId}:ref`;
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
{ error: extractErrorMessage(error) },
|
||||
"Failed to generate Email-to-Case thread reference"
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async sendWelcomeWithEligibilityEmail(
|
||||
email: string,
|
||||
firstName: string,
|
||||
@ -995,7 +914,7 @@ export class GetStartedWorkflowService {
|
||||
private async createEligibilityCase(
|
||||
sfAccountId: string,
|
||||
address: BilingualEligibilityAddress | QuickEligibilityRequest["address"]
|
||||
): Promise<{ caseId: string; caseNumber: string }> {
|
||||
): Promise<{ caseId: string; caseNumber: string; threadId: string | null }> {
|
||||
// Find or create Opportunity for Internet eligibility
|
||||
const { opportunityId } =
|
||||
await this.opportunityResolution.findOrCreateForInternetEligibility(sfAccountId);
|
||||
@ -1037,7 +956,11 @@ export class GetStartedWorkflowService {
|
||||
...(japaneseAddress ? ["", "【Japanese Address】", japaneseAddress] : []),
|
||||
].join("\n");
|
||||
|
||||
const { id: caseId, caseNumber } = await this.caseService.createCase({
|
||||
const {
|
||||
id: caseId,
|
||||
caseNumber,
|
||||
threadId,
|
||||
} = await this.caseService.createCase({
|
||||
accountId: sfAccountId,
|
||||
opportunityId,
|
||||
subject: "Internet availability check request (Portal)",
|
||||
@ -1048,7 +971,7 @@ export class GetStartedWorkflowService {
|
||||
// Update Account eligibility status to Pending
|
||||
this.updateAccountEligibilityStatus(sfAccountId);
|
||||
|
||||
return { caseId, caseNumber };
|
||||
return { caseId, caseNumber, threadId };
|
||||
}
|
||||
|
||||
private updateAccountEligibilityStatus(sfAccountId: string): void {
|
||||
|
||||
@ -260,6 +260,9 @@ export function buildCaseSelectFields(additionalFields: string[] = []): string[]
|
||||
|
||||
// Flags
|
||||
"IsEscalated",
|
||||
|
||||
// Email-to-Case Threading
|
||||
"Thread_Id",
|
||||
];
|
||||
|
||||
return [...new Set([...baseFields, ...additionalFields])];
|
||||
|
||||
@ -114,6 +114,10 @@ export const salesforceCaseRecordSchema = z.object({
|
||||
MilestoneStatus: z.string().nullable().optional(), // Text(30)
|
||||
Language: z.string().nullable().optional(), // Picklist
|
||||
|
||||
// Email-to-Case Threading
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
Thread_Id: z.string().nullable().optional(), // Auto-generated thread ID for Email-to-Case
|
||||
|
||||
// Web-to-Case fields
|
||||
SuppliedName: z.string().nullable().optional(), // Web Name - Text(80)
|
||||
SuppliedEmail: z.string().nullable().optional(), // Web Email - Email
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user