Enhance Salesforce query builders and validation for SOQL field names

- Updated the product query builder to accept a dynamic category field, improving flexibility in SOQL queries.
- Introduced a new utility function to validate SOQL field names, ensuring safe inclusion in queries.
- Refactored catalog service queries to utilize the new category field validation.
- Enhanced the SIM catalog controller to return structured data, improving response consistency.
- Updated various components to reflect changes in pricing display logic, ensuring accurate representation of plan costs.
This commit is contained in:
barsa 2025-10-21 18:24:47 +09:00
parent 82bb590023
commit e42d474048
9 changed files with 88 additions and 28 deletions

View File

@ -4,24 +4,28 @@
* SOQL query builders for Product2 catalog queries.
* Extracted from BaseCatalogService for consistency with order query builders.
*/
import { sanitizeSoqlLiteral } from "./soql.util";
import { sanitizeSoqlLiteral, assertSoqlFieldName } from "./soql.util";
/**
* Build base product query with optional filtering
*/
export function buildProductQuery(
portalPricebookId: string,
portalCategoryField: string,
category: string,
itemClass: string,
additionalFields: string[] = [],
additionalConditions: string = ""
): string {
const categoryField = assertSoqlFieldName(
portalCategoryField,
"PRODUCT_PORTAL_CATEGORY_FIELD"
);
const baseFields = [
"Id",
"Name",
"StockKeepingUnit",
"Portal_Category__c",
categoryField,
"Item_Class__c",
];
const allFields = [...baseFields, ...additionalFields].join(", ");
@ -37,7 +41,7 @@ export function buildProductQuery(
AND IsActive = true
LIMIT 1)
FROM Product2
WHERE Portal_Category__c = '${safeCategory}'
WHERE ${categoryField} = '${safeCategory}'
AND Item_Class__c = '${safeItemClass}'
AND Portal_Accessible__c = true
${additionalConditions}
@ -50,11 +54,13 @@ export function buildProductQuery(
*/
export function buildCatalogServiceQuery(
portalPricebookId: string,
portalCategoryField: string,
category: string,
additionalFields: string[] = []
): string {
return buildProductQuery(
portalPricebookId,
portalCategoryField,
category,
"Service",
additionalFields,
@ -73,4 +79,3 @@ export function buildAccountEligibilityQuery(sfAccountId: string): string {
LIMIT 1
`;
}

View File

@ -27,6 +27,21 @@ export function sanitizeSoqlLiteral(value: string): string {
// Schema for validating non-empty string values
const nonEmptyStringSchema = z.string().min(1, "Value cannot be empty").trim();
const soqlFieldNameSchema = z
.string()
.trim()
.regex(/^[A-Za-z0-9_.]+$/, "Invalid SOQL field name");
/**
* Ensures a provided field name is safe for inclusion in SOQL statements.
*/
export function assertSoqlFieldName(value: unknown, fieldName: string): string {
try {
return soqlFieldNameSchema.parse(value);
} catch {
throw new Error(`Invalid SOQL field name for ${fieldName}`);
}
}
/**
* Builds an IN clause for SOQL queries from a list of literal values.

View File

@ -2,10 +2,12 @@ import { Controller, Get, Request } from "@nestjs/common";
import type { RequestWithUser } from "@bff/modules/auth/auth.types";
import {
parseInternetCatalog,
parseSimCatalog,
type InternetAddonCatalogItem,
type InternetInstallationCatalogItem,
type InternetPlanCatalogItem,
type SimActivationFeeCatalogItem,
type SimCatalogCollection,
type SimCatalogProduct,
type VpnCatalogProduct,
} from "@customer-portal/domain/catalog";
@ -53,14 +55,23 @@ export class CatalogController {
}
@Get("sim/plans")
async getSimPlans(@Request() req: RequestWithUser): Promise<SimCatalogProduct[]> {
async getSimCatalogData(@Request() req: RequestWithUser): Promise<SimCatalogCollection> {
const userId = req.user?.id;
if (!userId) {
// Fallback to all regular plans if no user context
const allPlans = await this.simCatalog.getPlans();
return allPlans.filter(plan => !plan.simHasFamilyDiscount);
const catalog = await this.simCatalog.getCatalogData();
return parseSimCatalog({
...catalog,
plans: catalog.plans.filter(plan => !plan.simHasFamilyDiscount),
});
}
return this.simCatalog.getPlansForUser(userId);
const [plans, activationFees, addons] = await Promise.all([
this.simCatalog.getPlansForUser(userId),
this.simCatalog.getActivationFees(),
this.simCatalog.getAddons(),
]);
return parseSimCatalog({ plans, activationFees, addons });
}
@Get("sim/activation-fees")

View File

@ -4,6 +4,7 @@ import { Logger } from "nestjs-pino";
import { SalesforceConnection } from "@bff/integrations/salesforce/services/salesforce-connection.service";
import {
assertSalesforceId,
assertSoqlFieldName,
} from "@bff/integrations/salesforce/utils/soql.util";
import {
buildProductQuery,
@ -20,6 +21,7 @@ import type { SalesforceResponse } from "@customer-portal/domain/common";
@Injectable()
export class BaseCatalogService {
protected readonly portalPriceBookId: string;
protected readonly portalCategoryField: string;
constructor(
protected readonly sf: SalesforceConnection,
@ -28,6 +30,11 @@ export class BaseCatalogService {
) {
const portalPricebook = this.configService.get<string>("PORTAL_PRICEBOOK_ID")!;
this.portalPriceBookId = assertSalesforceId(portalPricebook, "PORTAL_PRICEBOOK_ID");
const portalCategory = this.configService.get<string>("PRODUCT_PORTAL_CATEGORY_FIELD") ?? "Product2Categories1__c";
this.portalCategoryField = assertSoqlFieldName(
portalCategory,
"PRODUCT_PORTAL_CATEGORY_FIELD"
);
}
protected async executeQuery<TRecord extends SalesforceProduct2WithPricebookEntries>(
@ -70,6 +77,7 @@ export class BaseCatalogService {
): string {
return buildProductQuery(
this.portalPriceBookId,
this.portalCategoryField,
category,
itemClass,
additionalFields,
@ -94,6 +102,7 @@ export class BaseCatalogService {
protected buildCatalogServiceQuery(category: string, additionalFields: string[] = []): string {
return buildCatalogServiceQuery(
this.portalPriceBookId,
this.portalCategoryField,
category,
additionalFields
);

View File

@ -165,16 +165,16 @@ export function SimConfigureView({
<span>
<strong>Type:</strong>{" "}
{plan.simPlanType === "DataSmsVoice"
? "Data + Voice"
? "Data + SMS + Voice"
: plan.simPlanType === "DataOnly"
? "Data Only"
: "Voice Only"}
: "Voice + SMS Only"}
</span>
</div>
</div>
<div className="text-right">
<div className="text-2xl font-bold text-blue-600">
¥{(plan.monthlyPrice ?? plan.unitPrice ?? 0).toLocaleString()}/mo
¥{(plan.monthlyPrice ?? plan.unitPrice ?? plan.oneTimePrice ?? 0).toLocaleString()}/mo
</div>
{plan.simHasFamilyDiscount && (
<div className="text-sm text-green-600 font-medium">Discounted Price</div>

View File

@ -11,7 +11,7 @@ interface SimPlanCardProps {
}
export function SimPlanCard({ plan, isFamily }: { plan: SimCatalogProduct; isFamily?: boolean }) {
const monthlyPrice = plan.monthlyPrice ?? 0;
const displayPrice = plan.monthlyPrice ?? plan.unitPrice ?? plan.oneTimePrice ?? 0;
const isFamilyPlan = isFamily ?? Boolean(plan.simHasFamilyDiscount);
return (
@ -35,7 +35,7 @@ export function SimPlanCard({ plan, isFamily }: { plan: SimCatalogProduct; isFam
<div className="mb-3">
<div className="flex items-baseline gap-1">
<CurrencyYenIcon className="h-4 w-4 text-gray-600" />
<span className="text-xl font-bold text-gray-900">{monthlyPrice.toLocaleString()}</span>
<span className="text-xl font-bold text-gray-900">{displayPrice.toLocaleString()}</span>
<span className="text-gray-600 text-sm">/month</span>
</div>
{isFamilyPlan && (

View File

@ -55,7 +55,7 @@ export function CatalogHomeView() {
icon={<DevicePhoneMobileIcon className="h-12 w-12" />}
features={[
"Physical SIM & eSIM",
"Data + SMS/Voice plans",
"Data + SMS + Voice plans",
"Family discounts",
"Multiple data options",
]}

View File

@ -197,7 +197,7 @@ export function SimPlansContainer() {
<PhoneIcon
className={`h-5 w-5 transition-transform duration-300 ${activeTab === "data-voice" ? "scale-110" : ""}`}
/>
Data + SMS/Voice
Data + SMS + Voice
{plansByType.DataSmsVoice.length > 0 && (
<span
className={`bg-blue-100 text-blue-600 text-xs px-2 py-0.5 rounded-full transition-all duration-300 ${activeTab === "data-voice" ? "scale-110 bg-blue-200" : ""}`}
@ -229,7 +229,7 @@ export function SimPlansContainer() {
<PhoneIcon
className={`h-5 w-5 transition-transform duration-300 ${activeTab === "voice-only" ? "scale-110" : ""}`}
/>
Voice Only
Voice + SMS Only
{plansByType.VoiceOnly.length > 0 && (
<span
className={`bg-orange-100 text-orange-600 text-xs px-2 py-0.5 rounded-full transition-all duration-300 ${activeTab === "voice-only" ? "scale-110 bg-orange-200" : ""}`}
@ -247,11 +247,11 @@ export function SimPlansContainer() {
className={`transition-all duration-500 ease-in-out ${activeTab === "data-voice" ? "opacity-100 translate-y-0" : "opacity-0 translate-y-4 absolute inset-0 pointer-events-none"}`}
>
<SimPlanTypeSection
title="Data + SMS/Voice Plans"
title="Data + SMS + Voice Plans"
description={
hasExistingSim
? "Family discount shown where eligible"
: "Comprehensive plans with data, SMS, and voice calling"
: "Comprehensive plans with high-speed data, messaging, and calling"
}
icon={<DevicePhoneMobileIcon className="h-6 w-6 text-blue-600" />}
plans={plansByType.DataSmsVoice}
@ -277,11 +277,11 @@ export function SimPlansContainer() {
className={`transition-all duration-500 ease-in-out ${activeTab === "voice-only" ? "opacity-100 translate-y-0" : "opacity-0 translate-y-4 absolute inset-0 pointer-events-none"}`}
>
<SimPlanTypeSection
title="Voice Only Plans"
title="Voice + SMS Only Plans"
description={
hasExistingSim
? "Family discount shown where eligible"
: "Plans focused on voice calling features"
: "Plans focused on voice calling and messaging without data bundles"
}
icon={<PhoneIcon className="h-6 w-6 text-orange-600" />}
plans={plansByType.VoiceOnly}

View File

@ -69,14 +69,35 @@ function baseProduct(
// Derive prices
const billingCycle = product.Billing_Cycle__c?.toLowerCase();
const unitPrice = coerceNumber(pricebookEntry?.UnitPrice);
const unitPriceFromPricebook = coerceNumber(pricebookEntry?.UnitPrice);
const priceField = coerceNumber(product.Price__c);
const monthlyField = coerceNumber(product.Monthly_Price__c);
const oneTimeField = coerceNumber(product.One_Time_Price__c);
if (unitPrice !== undefined) {
base.unitPrice = unitPrice;
if (unitPriceFromPricebook !== undefined) {
base.unitPrice = unitPriceFromPricebook;
} else if (priceField !== undefined) {
base.unitPrice = priceField;
}
if (monthlyField !== undefined) {
base.monthlyPrice = monthlyField;
}
if (oneTimeField !== undefined) {
base.oneTimePrice = oneTimeField;
}
const primaryPrice =
unitPriceFromPricebook ?? monthlyField ?? priceField ?? oneTimeField;
if (primaryPrice !== undefined) {
if (billingCycle === "monthly") {
base.monthlyPrice = unitPrice;
base.monthlyPrice = base.monthlyPrice ?? primaryPrice;
} else if (billingCycle) {
base.oneTimePrice = unitPrice;
base.oneTimePrice = base.oneTimePrice ?? primaryPrice;
} else {
base.monthlyPrice = base.monthlyPrice ?? primaryPrice;
}
}
@ -208,4 +229,3 @@ export function extractPricebookEntry(
const activeEntry = entries.find(e => e.IsActive === true);
return activeEntry ?? entries[0];
}