Files
backend/src/business-settings/business-settings.util.ts
T

491 lines
15 KiB
TypeScript

import { Prisma } from '@prisma/client';
import {
normalizeBusinessPrimaryColorId,
} from './business-primary-colors';
import {
normalizeWebsiteSitemapConfig,
} from '../sitemap/sitemap-config.util';
import {
BUSINESS_MODULE_IDS,
BusinessModuleId,
BusinessSettings,
DEFAULT_BUSINESS_DASHBOARD_LOCALE,
DEFAULT_BUSINESS_SETTINGS,
DEFAULT_BUSINESS_THEME_MODE,
DEFAULT_ENABLED_BUSINESS_MODULES,
DEFAULT_EPAYMENT_SETTINGS,
DEFAULT_HOME_CHARTS,
DEFAULT_MELLAT_GATEWAY_SETTINGS,
DEFAULT_ORDER_PROCESS_STEPS,
DEFAULT_STUB_GATEWAY_SETTINGS,
DEFAULT_ZARINPAL_GATEWAY_SETTINGS,
EPaymentSettings,
HOME_CHART_IDS,
MellatGatewaySettings,
PAYMENT_GATEWAY_IDS,
SPECIAL_PRODUCTS_SOURCE_IDS,
type DashboardLocale,
type DashboardThemeMode,
type HomeChartId,
type PaymentGatewayId,
type SpecialProductsSource,
OrderProcessStep,
StubGatewaySettings,
WebsiteSitemapConfig,
ZarinpalGatewaySettings,
} from './business-settings.types';
import {
defaultOrderStepColor,
normalizeOrderStepColor,
} from './order-step-colors';
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
const BUSINESS_MODULE_ID_SET = new Set<string>(BUSINESS_MODULE_IDS);
const HOME_CHART_ID_SET = new Set<string>(HOME_CHART_IDS);
export function isBusinessModuleId(value: unknown): value is BusinessModuleId {
return typeof value === 'string' && BUSINESS_MODULE_ID_SET.has(value);
}
export function isHomeChartId(value: unknown): value is HomeChartId {
return typeof value === 'string' && HOME_CHART_ID_SET.has(value);
}
/** Dedupes and orders enabled modules by canonical BUSINESS_MODULE_IDS order. */
export function normalizeEnabledBusinessModules(
value: unknown,
): BusinessModuleId[] {
if (!Array.isArray(value)) {
return [...DEFAULT_ENABLED_BUSINESS_MODULES];
}
const selected = new Set<BusinessModuleId>();
for (const item of value) {
if (isBusinessModuleId(item)) {
selected.add(item);
}
}
return BUSINESS_MODULE_IDS.filter((id) => selected.has(id));
}
export function isSpecialProductsSource(
value: unknown,
): value is SpecialProductsSource {
return (
typeof value === 'string' &&
(SPECIAL_PRODUCTS_SOURCE_IDS as readonly string[]).includes(value)
);
}
/**
* Explicit saved source wins. Otherwise store businesses default to store items,
* catalog-only businesses default to products.
*/
export function resolveSpecialProductsSource(
value: unknown,
enabledModules: readonly BusinessModuleId[],
): SpecialProductsSource {
if (isSpecialProductsSource(value)) return value;
return enabledModules.includes('store') ? 'store_item' : 'product';
}
export function normalizeHomeCharts(value: unknown): [HomeChartId, HomeChartId] {
if (!Array.isArray(value)) {
return [...DEFAULT_HOME_CHARTS];
}
const first = isHomeChartId(value[0]) ? value[0] : DEFAULT_HOME_CHARTS[0];
const second = isHomeChartId(value[1]) ? value[1] : DEFAULT_HOME_CHARTS[1];
return [first, second];
}
export function normalizeDashboardLocale(value: unknown): DashboardLocale {
return value === 'en' || value === 'fa' ? value : DEFAULT_BUSINESS_DASHBOARD_LOCALE;
}
export function normalizeDashboardThemeMode(value: unknown): DashboardThemeMode {
return value === 'dark' || value === 'light'
? value
: DEFAULT_BUSINESS_THEME_MODE;
}
function readBoolean(value: unknown, fallback: boolean) {
return typeof value === 'boolean' ? value : fallback;
}
function readString(value: unknown, fallback = '') {
return typeof value === 'string' ? value.trim() : fallback;
}
function isPaymentGatewayId(value: unknown): value is PaymentGatewayId {
return (
typeof value === 'string' &&
(PAYMENT_GATEWAY_IDS as readonly string[]).includes(value)
);
}
function readMellatGateway(value: unknown): MellatGatewaySettings {
const source = isRecord(value) ? value : {};
return {
enabled: readBoolean(
source.enabled,
DEFAULT_MELLAT_GATEWAY_SETTINGS.enabled,
),
terminalId: readString(
source.terminalId,
DEFAULT_MELLAT_GATEWAY_SETTINGS.terminalId,
),
username: readString(
source.username,
DEFAULT_MELLAT_GATEWAY_SETTINGS.username,
),
password:
typeof source.password === 'string'
? source.password
: DEFAULT_MELLAT_GATEWAY_SETTINGS.password,
};
}
function readStubGateway(value: unknown): StubGatewaySettings {
const source = isRecord(value) ? value : {};
return {
enabled: readBoolean(
source.enabled,
DEFAULT_STUB_GATEWAY_SETTINGS.enabled,
),
};
}
function readZarinpalGateway(value: unknown): ZarinpalGatewaySettings {
const source = isRecord(value) ? value : {};
return {
enabled: readBoolean(
source.enabled,
DEFAULT_ZARINPAL_GATEWAY_SETTINGS.enabled,
),
merchantId: readString(
source.merchantId,
DEFAULT_ZARINPAL_GATEWAY_SETTINGS.merchantId,
),
sandbox: readBoolean(
source.sandbox,
DEFAULT_ZARINPAL_GATEWAY_SETTINGS.sandbox,
),
};
}
export function normalizeEPaymentSettings(value: unknown): EPaymentSettings {
const source = isRecord(value) ? value : {};
const gateways = isRecord(source.gateways) ? source.gateways : {};
const defaultGateway = isPaymentGatewayId(source.defaultGateway)
? source.defaultGateway
: null;
return {
enabled: readBoolean(source.enabled, DEFAULT_EPAYMENT_SETTINGS.enabled),
defaultGateway,
gateways: {
mellat: readMellatGateway(gateways.mellat),
sep: readStubGateway(gateways.sep),
snappay: readStubGateway(gateways.snappay),
digipay: readStubGateway(gateways.digipay),
zarinpal: readZarinpalGateway(gateways.zarinpal),
},
};
}
/** Merge e-payment patch; empty mellat password keeps the current secret. */
export function mergeEPaymentSettings(
current: EPaymentSettings,
patch: EPaymentSettings | undefined,
): EPaymentSettings {
if (!patch) return current;
const nextPassword = patch.gateways.mellat.password;
const password =
typeof nextPassword === 'string' && nextPassword.length > 0
? nextPassword
: current.gateways.mellat.password;
return {
enabled: patch.enabled,
defaultGateway: patch.defaultGateway,
gateways: {
mellat: {
enabled: patch.gateways.mellat.enabled,
terminalId: patch.gateways.mellat.terminalId.trim(),
username: patch.gateways.mellat.username.trim(),
password,
},
sep: { enabled: patch.gateways.sep.enabled },
snappay: { enabled: patch.gateways.snappay.enabled },
digipay: { enabled: patch.gateways.digipay.enabled },
zarinpal: {
enabled: patch.gateways.zarinpal.enabled,
merchantId: patch.gateways.zarinpal.merchantId.trim(),
sandbox: patch.gateways.zarinpal.sandbox,
},
},
};
}
/** Redact gateway secrets for dashboard API responses. */
export function sanitizeBusinessSettingsForClient(
settings: BusinessSettings,
): BusinessSettings {
const mellat = settings.store.ePayment.gateways.mellat;
return {
...settings,
store: {
...settings.store,
ePayment: {
...settings.store.ePayment,
gateways: {
...settings.store.ePayment.gateways,
mellat: {
...mellat,
password: '',
},
},
},
},
};
}
/** Whether a gateway is enabled and has enough credentials to initiate. */
export function isGatewayReady(
settings: EPaymentSettings,
gatewayId: PaymentGatewayId,
): boolean {
if (!settings.enabled) return false;
switch (gatewayId) {
case 'mellat': {
const g = settings.gateways.mellat;
return (
g.enabled &&
g.terminalId.length > 0 &&
g.username.length > 0 &&
g.password.length > 0
);
}
case 'zarinpal': {
const g = settings.gateways.zarinpal;
return g.enabled && g.merchantId.length > 0;
}
case 'sep':
case 'snappay':
case 'digipay':
return false;
default:
return false;
}
}
export function listPublicPaymentGateways(settings: EPaymentSettings) {
const labels: Record<
PaymentGatewayId,
{ label: string; labelFa: string }
> = {
mellat: { label: 'Bank Mellat', labelFa: 'بانک ملت' },
sep: { label: 'SEP (Saman)', labelFa: 'سپ (سامان)' },
snappay: { label: 'Snapp Pay', labelFa: 'اسنپ‌پی' },
digipay: { label: 'DigiPay', labelFa: 'دیجی‌پی' },
zarinpal: { label: 'Zarinpal', labelFa: 'زرین‌پال' },
};
const gateways = PAYMENT_GATEWAY_IDS.filter((id) =>
isGatewayReady(settings, id),
).map((id) => ({
id,
...labels[id],
}));
let defaultGateway = settings.defaultGateway;
if (defaultGateway && !gateways.some((g) => g.id === defaultGateway)) {
defaultGateway = null;
}
if (!defaultGateway && gateways.length) {
defaultGateway = gateways[0].id;
}
return {
enabled: settings.enabled && gateways.length > 0,
defaultGateway,
gateways,
};
}
const PAYMENT_PROCESS_STEP_IDS = ['awaiting-payment', 'payment-successful'] as const;
/** Prepend default payment steps when an older store settings JSON omits them. */
function ensurePaymentProcessSteps(steps: OrderProcessStep[]): OrderProcessStep[] {
const missing = DEFAULT_ORDER_PROCESS_STEPS.filter(
(defaults) =>
PAYMENT_PROCESS_STEP_IDS.includes(
defaults.id as (typeof PAYMENT_PROCESS_STEP_IDS)[number],
) && !steps.some((step) => step.id === defaults.id),
);
return missing.length ? [...missing, ...steps] : steps;
}
function readOrderProcessSteps(value: unknown): OrderProcessStep[] {
if (!Array.isArray(value)) {
return DEFAULT_ORDER_PROCESS_STEPS;
}
const steps = value
.map((item, index) => {
if (!isRecord(item)) return null;
const id = typeof item.id === 'string' ? item.id.trim() : '';
const label = typeof item.label === 'string' ? item.label.trim() : '';
if (!id || !label) return null;
const defaults = DEFAULT_ORDER_PROCESS_STEPS.find((step) => step.id === id);
const byEnglishLabel = DEFAULT_ORDER_PROCESS_STEPS.find(
(step) => step.label.toLowerCase() === label.toLowerCase(),
);
const labelFaRaw =
typeof item.labelFa === 'string' ? item.labelFa.trim() : '';
return {
id,
label,
// Never fall back to the English label — that leaks EN into FA UIs.
labelFa:
labelFaRaw || defaults?.labelFa || byEnglishLabel?.labelFa || '',
color: normalizeOrderStepColor(
item.color,
defaultOrderStepColor(id, index),
),
};
})
.filter((step): step is OrderProcessStep => step !== null);
return steps.length
? ensurePaymentProcessSteps(steps)
: DEFAULT_ORDER_PROCESS_STEPS;
}
export function normalizeBusinessSettings(raw: unknown): BusinessSettings {
const source = isRecord(raw) ? raw : {};
const branding = isRecord(source.branding) ? source.branding : {};
const dashboard = isRecord(source.dashboard) ? source.dashboard : {};
const comments = isRecord(dashboard.comments) ? dashboard.comments : {};
const expertReviews = isRecord(dashboard.expertReviews)
? dashboard.expertReviews
: {};
const store = isRecord(source.store) ? source.store : {};
const modules = isRecord(source.modules) ? source.modules : {};
const website = isRecord(source.website) ? source.website : {};
const hasModulesKey = Object.prototype.hasOwnProperty.call(source, 'modules');
const enabledModules = hasModulesKey
? Array.isArray(modules.enabled)
? normalizeEnabledBusinessModules(modules.enabled)
: []
: [...DEFAULT_ENABLED_BUSINESS_MODULES];
return {
branding: {
primaryColor: normalizeBusinessPrimaryColorId(branding.primaryColor),
defaultLocale: normalizeDashboardLocale(branding.defaultLocale),
themeMode: normalizeDashboardThemeMode(branding.themeMode),
},
dashboard: {
comments: {
autoApprove: readBoolean(
comments.autoApprove,
DEFAULT_BUSINESS_SETTINGS.dashboard.comments.autoApprove,
),
},
expertReviews: {
autoApprove: readBoolean(
expertReviews.autoApprove,
DEFAULT_BUSINESS_SETTINGS.dashboard.expertReviews.autoApprove,
),
},
},
store: {
onlineSellEnabled: readBoolean(
store.onlineSellEnabled,
DEFAULT_BUSINESS_SETTINGS.store.onlineSellEnabled,
),
torobEnabled: readBoolean(
store.torobEnabled,
DEFAULT_BUSINESS_SETTINGS.store.torobEnabled,
),
orderProcessSteps: readOrderProcessSteps(store.orderProcessSteps),
ePayment: normalizeEPaymentSettings(store.ePayment),
},
modules: {
// Missing `modules` → all enabled (legacy). Explicit `{ enabled: [] }` stays empty.
enabled: enabledModules,
charts: normalizeHomeCharts(modules.charts),
},
website: {
specialProductsSource: resolveSpecialProductsSource(
website.specialProductsSource,
enabledModules,
),
sitemapConfig: normalizeWebsiteSitemapConfig(website.sitemapConfig),
},
};
}
export function mergeBusinessSettings(
current: BusinessSettings,
patch: Partial<BusinessSettings>,
): BusinessSettings {
return {
branding: {
primaryColor:
patch.branding?.primaryColor ?? current.branding.primaryColor,
defaultLocale:
patch.branding?.defaultLocale ?? current.branding.defaultLocale,
themeMode: patch.branding?.themeMode ?? current.branding.themeMode,
},
dashboard: {
comments: {
autoApprove:
patch.dashboard?.comments?.autoApprove ??
current.dashboard.comments.autoApprove,
},
expertReviews: {
autoApprove:
patch.dashboard?.expertReviews?.autoApprove ??
current.dashboard.expertReviews.autoApprove,
},
},
store: {
onlineSellEnabled:
patch.store?.onlineSellEnabled ?? current.store.onlineSellEnabled,
torobEnabled: patch.store?.torobEnabled ?? current.store.torobEnabled,
orderProcessSteps:
patch.store?.orderProcessSteps ?? current.store.orderProcessSteps,
ePayment: mergeEPaymentSettings(
current.store.ePayment,
patch.store?.ePayment,
),
},
modules: {
enabled: patch.modules?.enabled ?? current.modules.enabled,
charts: patch.modules?.charts ?? current.modules.charts,
},
website: {
specialProductsSource:
patch.website?.specialProductsSource ??
current.website.specialProductsSource,
sitemapConfig:
patch.website?.sitemapConfig !== undefined
? patch.website.sitemapConfig
: current.website.sitemapConfig,
},
};
}
export function toPrismaJson(settings: BusinessSettings): Prisma.InputJsonValue {
return settings as Prisma.InputJsonValue;
}