From 9ca6e2306fd02c73c99bdac8a32264edf3316998 Mon Sep 17 00:00:00 2001 From: Alireza Hassani Date: Sat, 8 Aug 2026 10:40:57 +0330 Subject: [PATCH] Add per-business modules and home chart slots. Co-authored-by: Cursor --- src/business-admin/business-admin.service.ts | 55 +++++++++++++---- .../business-settings.service.ts | 23 +++++++ .../business-settings.types.ts | 44 ++++++++++++++ .../business-settings.util.ts | 60 +++++++++++++++++++ .../dto/update-business-settings.dto.ts | 27 +++++++++ src/common/dto/daily-activity-query.dto.ts | 2 +- src/tenant/tenant.service.ts | 2 + 7 files changed, 201 insertions(+), 12 deletions(-) diff --git a/src/business-admin/business-admin.service.ts b/src/business-admin/business-admin.service.ts index 7416d7b..89fbb5b 100644 --- a/src/business-admin/business-admin.service.ts +++ b/src/business-admin/business-admin.service.ts @@ -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, - primaryColor: normalizeBusinessPrimaryColorId( - item.primaryColor, - ) as BusinessPrimaryColorId, - defaultLocale: normalizeDashboardLocale( - item.defaultLocale, - ) as DashboardLocale, - })), + 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, diff --git a/src/business-settings/business-settings.service.ts b/src/business-settings/business-settings.service.ts index bf5bd22..ba53347 100644 --- a/src/business-settings/business-settings.service.ts +++ b/src/business-settings/business-settings.service.ts @@ -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({ diff --git a/src/business-settings/business-settings.types.ts b/src/business-settings/business-settings.types.ts index 2baa493..0530eb1 100644 --- a/src/business-settings/business-settings.types.ts +++ b/src/business-settings/business-settings.types.ts @@ -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, + }, }; diff --git a/src/business-settings/business-settings.util.ts b/src/business-settings/business-settings.util.ts index 8b90f2e..2bca9a0 100644 --- a/src/business-settings/business-settings.util.ts +++ b/src/business-settings/business-settings.util.ts @@ -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 { return typeof value === 'object' && value !== null && !Array.isArray(value); } +const BUSINESS_MODULE_ID_SET = new Set(BUSINESS_MODULE_IDS); +const HOME_CHART_ID_SET = new Set(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(); + 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, + }, }; } diff --git a/src/business-settings/dto/update-business-settings.dto.ts b/src/business-settings/dto/update-business-settings.dto.ts index 37e5edd..6ca0b84 100644 --- a/src/business-settings/dto/update-business-settings.dto.ts +++ b/src/business-settings/dto/update-business-settings.dto.ts @@ -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; } diff --git a/src/common/dto/daily-activity-query.dto.ts b/src/common/dto/daily-activity-query.dto.ts index f131dd1..f827b05 100644 --- a/src/common/dto/daily-activity-query.dto.ts +++ b/src/common/dto/daily-activity-query.dto.ts @@ -6,6 +6,6 @@ export class DailyActivityQueryDto { @Type(() => Number) @IsInt() @Min(1) - @Max(90) + @Max(366) days?: number; } diff --git a/src/tenant/tenant.service.ts b/src/tenant/tenant.service.ts index 3b3e140..78f00d8 100644 --- a/src/tenant/tenant.service.ts +++ b/src/tenant/tenant.service.ts @@ -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,