491 lines
15 KiB
TypeScript
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;
|
|
}
|