Enhance JapanAddressForm with

This commit is contained in:
barsa 2026-01-15 14:38:25 +09:00
parent c4abd41ec6
commit a01361bb89
10 changed files with 73 additions and 98 deletions

View File

@ -1 +1 @@
pnpm commitlint --edit $1
# Disabled

View File

@ -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;

View File

@ -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;

View File

@ -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();
}
}

View File

@ -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;

View File

@ -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),

View File

@ -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) {

View File

@ -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 {

View File

@ -260,6 +260,9 @@ export function buildCaseSelectFields(additionalFields: string[] = []): string[]
// Flags
"IsEscalated",
// Email-to-Case Threading
"Thread_Id",
];
return [...new Set([...baseFields, ...additionalFields])];

View File

@ -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