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:
parent
82bb590023
commit
e42d474048
@ -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
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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
|
||||
);
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 && (
|
||||
|
||||
@ -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",
|
||||
]}
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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];
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user