Add per-business modules and home chart slots.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-08-08 10:40:57 +03:30
co-authored by Cursor
parent 0632f67bda
commit 9ca6e2306f
7 changed files with 201 additions and 12 deletions
+38 -5
View File
@@ -25,8 +25,18 @@ import { normalizeBusinessPrimaryColorId } from '../business-settings/business-p
import type { BusinessPrimaryColorId } from '../business-settings/business-primary-colors';
import {
normalizeDashboardLocale,
normalizeEnabledBusinessModules,
normalizeHomeCharts,
} from '../business-settings/business-settings.util';
import type { DashboardLocale } from '../business-settings/business-settings.types';
import type {
BusinessModuleId,
DashboardLocale,
HomeChartId,
} from '../business-settings/business-settings.types';
import {
DEFAULT_ENABLED_BUSINESS_MODULES,
DEFAULT_HOME_CHARTS,
} from '../business-settings/business-settings.types';
type BusinessRow = {
id: bigint;
@@ -46,6 +56,8 @@ type BusinessRow = {
ownerCellNumber: string | null;
primaryColor: string | null;
defaultLocale: string | null;
enabledModules: unknown;
homeCharts: unknown;
};
function slugify(value: string) {
@@ -121,7 +133,9 @@ export class BusinessAdminService {
own."ownerNameEn" AS "ownerNameEn",
own."ownerCellNumber" AS "ownerCellNumber",
b.settings->'branding'->>'primaryColor' AS "primaryColor",
b.settings->'branding'->>'defaultLocale' AS "defaultLocale"
b.settings->'branding'->>'defaultLocale' AS "defaultLocale",
b.settings->'modules'->'enabled' AS "enabledModules",
b.settings->'modules'->'charts' AS "homeCharts"
FROM businesses b
LEFT JOIN LATERAL (
SELECT d.id, d.host, d.ssl_enabled
@@ -153,15 +167,34 @@ export class BusinessAdminService {
]);
return {
items: items.map((item) => ({
...item,
items: items.map((item) => {
const {
enabledModules: rawEnabledModules,
homeCharts: rawHomeCharts,
...rest
} = item;
const enabledModules: BusinessModuleId[] =
rawEnabledModules == null
? [...DEFAULT_ENABLED_BUSINESS_MODULES]
: normalizeEnabledBusinessModules(rawEnabledModules);
const homeCharts: [HomeChartId, HomeChartId] =
rawHomeCharts == null
? [...DEFAULT_HOME_CHARTS]
: normalizeHomeCharts(rawHomeCharts);
return {
...rest,
primaryColor: normalizeBusinessPrimaryColorId(
item.primaryColor,
) as BusinessPrimaryColorId,
defaultLocale: normalizeDashboardLocale(
item.defaultLocale,
) as DashboardLocale,
})),
enabledModules,
moduleCount: enabledModules.length,
homeCharts,
};
}),
total: totalRow[0]?.total ?? 0,
page,
pageSize,
@@ -10,6 +10,8 @@ import { BusinessSettings } from './business-settings.types';
import {
mergeBusinessSettings,
normalizeBusinessSettings,
normalizeEnabledBusinessModules,
normalizeHomeCharts,
toPrismaJson,
} from './business-settings.util';
import { UpdateBusinessSettingsDto } from './dto/update-business-settings.dto';
@@ -49,6 +51,14 @@ export class BusinessSettingsService {
const businessId = BigInt(businessIdRaw);
await this.assertPermission(businessId, actor.id, 'business.update');
if (dto.modules !== undefined) {
if (!(await this.permissions.isSuperAdmin(actor.id))) {
throw new ForbiddenException(
'Only super admins can change business modules',
);
}
}
const business = await this.prisma.business.findUnique({
where: { id: businessId },
select: { id: true, settings: true },
@@ -98,6 +108,19 @@ export class BusinessSettingsService {
};
}
if (dto.modules) {
patch.modules = {
enabled:
dto.modules.enabled !== undefined
? normalizeEnabledBusinessModules(dto.modules.enabled)
: current.modules.enabled,
charts:
dto.modules.charts !== undefined
? normalizeHomeCharts(dto.modules.charts)
: current.modules.charts,
};
}
const next = mergeBusinessSettings(current, patch);
const updated = await this.prisma.business.update({
@@ -38,11 +38,41 @@ export type StoreSettings = {
orderProcessSteps: OrderProcessStep[];
};
/** Optional CMS modules a business can have. Always-on areas (customers, website, etc.) are not listed. */
export const BUSINESS_MODULE_IDS = [
'products',
'store',
'portfolio',
'blog',
'warehouse',
'videos',
] as const;
export type BusinessModuleId = (typeof BUSINESS_MODULE_IDS)[number];
/** Home dashboard chart types (super-admin selectable). */
export const HOME_CHART_IDS = [
'none',
'orders_30d',
'customers_joined_1y',
'blog_views_30d',
'products_added_1y',
] as const;
export type HomeChartId = (typeof HOME_CHART_IDS)[number];
export type ModulesSettings = {
enabled: BusinessModuleId[];
/** Two home-page chart slots (left, right). */
charts: [HomeChartId, HomeChartId];
};
/** Top-level business settings stored in `businesses.settings` JSONB. */
export type BusinessSettings = {
branding: BrandingSettings;
dashboard: DashboardSettings;
store: StoreSettings;
modules: ModulesSettings;
};
export const DEFAULT_ORDER_PROCESS_STEPS: OrderProcessStep[] = [
@@ -72,6 +102,16 @@ export const DEFAULT_ORDER_PROCESS_STEPS: OrderProcessStep[] = [
},
];
/** Existing tenants without `settings.modules` keep every module enabled. */
export const DEFAULT_ENABLED_BUSINESS_MODULES: BusinessModuleId[] = [
...BUSINESS_MODULE_IDS,
];
export const DEFAULT_HOME_CHARTS: [HomeChartId, HomeChartId] = [
'orders_30d',
'customers_joined_1y',
];
export const DEFAULT_BUSINESS_SETTINGS: BusinessSettings = {
branding: {
primaryColor: DEFAULT_BUSINESS_PRIMARY_COLOR_ID,
@@ -85,4 +125,8 @@ export const DEFAULT_BUSINESS_SETTINGS: BusinessSettings = {
onlineSellEnabled: true,
orderProcessSteps: DEFAULT_ORDER_PROCESS_STEPS,
},
modules: {
enabled: DEFAULT_ENABLED_BUSINESS_MODULES,
charts: DEFAULT_HOME_CHARTS,
},
};
@@ -3,11 +3,17 @@ import {
normalizeBusinessPrimaryColorId,
} from './business-primary-colors';
import {
BUSINESS_MODULE_IDS,
BusinessModuleId,
BusinessSettings,
DEFAULT_BUSINESS_DASHBOARD_LOCALE,
DEFAULT_BUSINESS_SETTINGS,
DEFAULT_ENABLED_BUSINESS_MODULES,
DEFAULT_HOME_CHARTS,
DEFAULT_ORDER_PROCESS_STEPS,
HOME_CHART_IDS,
type DashboardLocale,
type HomeChartId,
OrderProcessStep,
} from './business-settings.types';
import {
@@ -19,6 +25,45 @@ 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 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;
}
@@ -70,6 +115,8 @@ export function normalizeBusinessSettings(raw: unknown): BusinessSettings {
? dashboard.expertReviews
: {};
const store = isRecord(source.store) ? source.store : {};
const modules = isRecord(source.modules) ? source.modules : {};
const hasModulesKey = Object.prototype.hasOwnProperty.call(source, 'modules');
return {
branding: {
@@ -97,6 +144,15 @@ export function normalizeBusinessSettings(raw: unknown): BusinessSettings {
),
orderProcessSteps: readOrderProcessSteps(store.orderProcessSteps),
},
modules: {
// Missing `modules` → all enabled (legacy). Explicit `{ enabled: [] }` stays empty.
enabled: hasModulesKey
? Array.isArray(modules.enabled)
? normalizeEnabledBusinessModules(modules.enabled)
: []
: [...DEFAULT_ENABLED_BUSINESS_MODULES],
charts: normalizeHomeCharts(modules.charts),
},
};
}
@@ -129,6 +185,10 @@ export function mergeBusinessSettings(
orderProcessSteps:
patch.store?.orderProcessSteps ?? current.store.orderProcessSteps,
},
modules: {
enabled: patch.modules?.enabled ?? current.modules.enabled,
charts: patch.modules?.charts ?? current.modules.charts,
},
};
}
@@ -1,5 +1,7 @@
import { Type } from 'class-transformer';
import {
ArrayMaxSize,
ArrayMinSize,
IsArray,
IsBoolean,
IsIn,
@@ -10,6 +12,10 @@ import {
} from 'class-validator';
import { ORDER_STEP_COLOR_HEXES } from '../order-step-colors';
import { BUSINESS_PRIMARY_COLOR_IDS } from '../business-primary-colors';
import {
BUSINESS_MODULE_IDS,
HOME_CHART_IDS,
} from '../business-settings.types';
class BrandingSettingsDto {
@IsOptional()
@@ -78,6 +84,22 @@ class StoreSettingsDto {
orderProcessSteps?: OrderProcessStepDto[];
}
class ModulesSettingsDto {
@IsOptional()
@IsArray()
@IsString({ each: true })
@IsIn([...BUSINESS_MODULE_IDS], { each: true })
enabled?: string[];
@IsOptional()
@IsArray()
@ArrayMinSize(2)
@ArrayMaxSize(2)
@IsString({ each: true })
@IsIn([...HOME_CHART_IDS], { each: true })
charts?: string[];
}
export class UpdateBusinessSettingsDto {
@IsOptional()
@ValidateNested()
@@ -93,4 +115,9 @@ export class UpdateBusinessSettingsDto {
@ValidateNested()
@Type(() => StoreSettingsDto)
store?: StoreSettingsDto;
@IsOptional()
@ValidateNested()
@Type(() => ModulesSettingsDto)
modules?: ModulesSettingsDto;
}
+1 -1
View File
@@ -6,6 +6,6 @@ export class DailyActivityQueryDto {
@Type(() => Number)
@IsInt()
@Min(1)
@Max(90)
@Max(366)
days?: number;
}
+2
View File
@@ -56,6 +56,8 @@ export class TenantService {
domain: normalizedHost,
primaryColor: settings.branding.primaryColor,
defaultLocale: settings.branding.defaultLocale,
enabledModules: settings.modules.enabled,
homeCharts: settings.modules.charts,
logoUrl: media?.logoMedia?.publicUrl ?? null,
faviconUrl:
media?.faviconMedia?.publicUrl ?? media?.logoMedia?.publicUrl ?? null,