From b0d491613895dffb8b437363a8e5215214522712 Mon Sep 17 00:00:00 2001 From: Alireza Hassani Date: Fri, 21 Aug 2026 11:57:49 +0330 Subject: [PATCH] Add website special-products source setting for storefronts. Let businesses choose product vs store_item sourcing, defaulting from the store module, and expose it on tenant resolve and store-specials. Co-authored-by: Cursor --- docs/PROJECT_CONTEXT.md | 14 ++++-- docs/website-api/AI_PROMPT.md | 4 +- docs/website-api/openapi.json | 10 +++- .../business-settings.service.ts | 8 ++++ .../business-settings.types.ts | 14 ++++++ .../business-settings.util.ts | 46 +++++++++++++++++-- .../dto/update-business-settings.dto.ts | 13 ++++++ src/store/store-specials.service.ts | 4 ++ src/tenant/tenant.service.ts | 1 + src/website-docs/static/AI_PROMPT.md | 4 +- src/website-docs/static/openapi.json | 10 +++- 11 files changed, 114 insertions(+), 14 deletions(-) diff --git a/docs/PROJECT_CONTEXT.md b/docs/PROJECT_CONTEXT.md index 917b384..f102900 100644 --- a/docs/PROJECT_CONTEXT.md +++ b/docs/PROJECT_CONTEXT.md @@ -1,7 +1,7 @@ # Meshkee CMS API — Project Context > Living reference for developers and AI assistants working on this codebase. -> Last updated: August 19, 2026 +> Last updated: August 21, 2026 ## What This Project Is @@ -261,8 +261,8 @@ All routes are prefixed with `/api/v1`. | POST | `/auth/send-otp` | Send OTP (Redis-backed) | | POST | `/auth/verify-otp` | Verify OTP (marks cell verified; no tokens) | | POST | `/auth/handoff/consume` | One-time SSO ticket → tokens (customer → business dashboard) | -| GET | `/tenants/:host` | Resolve business from domain | -| GET | `/tenants/:host/store-specials` | Active store specials | +| GET | `/tenants/:host` | Resolve business from domain (`specialProductsSource`, `enabledModules`, `homeCharts`, `ePayment`) | +| GET | `/tenants/:host/store-specials` | Active store specials (`source` is `product` or `store_item`) | | GET | `/tenants/:host/website/category-groups` | Homepage category rows | | GET | `/tenants/:host/website/brand-groups` | Homepage brand rows | | GET | `/tenants/:host/website/sliders` | Homepage sliders with slides | @@ -478,6 +478,14 @@ Store item variants → purchasable SKUs with price/stock per combination (s - Each variant picks one option per variation; the combination must be unique per store item. - Cart and orders reference `storeItemVariantId` (not the product directly). +### Website special products source + +Stored in `businesses.settings.website.specialProductsSource` (`product` | `store_item`). + +- Unset → `store_item` when the store module is enabled, otherwise `product`. +- Dashboard: `PATCH /businesses/:id/settings` with `{ website: { specialProductsSource } }`. +- Public: `GET /tenants/:host` includes `specialProductsSource`; `GET /tenants/:host/store-specials` includes top-level `source`. + **Example — batch create variants for a product:** ```json diff --git a/docs/website-api/AI_PROMPT.md b/docs/website-api/AI_PROMPT.md index ff5558e..0825749 100644 --- a/docs/website-api/AI_PROMPT.md +++ b/docs/website-api/AI_PROMPT.md @@ -29,8 +29,8 @@ You are building a **Meshkee business website (storefront)**. You must use the M 7. **Partner SMS** (`POST /public/sms/send`) is for external partner backends with an issued `X-Api-Key` only — not for normal storefront UI. See https://api.meshkee.com/docs/website/SMS.md ### Typical bootstrap sequence -1. `GET /tenants/{domain}` → branding + `businessId` -2. Homepage: business-info, **static-images**, sliders, category-groups, brand-groups, store-specials +1. `GET /tenants/{domain}` → branding + `businessId` + `specialProductsSource` (`product` or `store_item`) +2. Homepage: business-info, **static-images**, sliders, category-groups, brand-groups, store-specials (`source` repeats the tenant setting; items are store listings when `source` is `store_item`) 3. Catalog: categories, products (`GET /products/{slug}` includes `relatedProducts`: same category then same brand, in-stock first), store-items (`name` instant search: in-stock first, then `updatedAt`), **user-products** (customer stock listings) 4. Auth: register/login → store tokens. Optional: `POST /auth/send-otp` then `POST /auth/login-otp` (passwordless) or `POST /auth/reset-password` (forgot password). `POST /auth/verify-otp` only marks the cell verified (no tokens). 5. Cart checkout with `addressId` or inline `shippingAddress` + `payment` diff --git a/docs/website-api/openapi.json b/docs/website-api/openapi.json index 74351f8..74b06ab 100644 --- a/docs/website-api/openapi.json +++ b/docs/website-api/openapi.json @@ -169,6 +169,14 @@ "faviconUrl": { "type": "string", "nullable": true + }, + "specialProductsSource": { + "type": "string", + "enum": [ + "product", + "store_item" + ], + "description": "Where homepage special-product carousels read from. Defaults to store_item when the store module is enabled, otherwise product." } } } @@ -323,7 +331,7 @@ ], "responses": { "200": { - "description": "{ items: StoreSpecial[] } — each special has id, key, title, items[]" + "description": "{ source: product|store_item, items: StoreSpecial[] } — source is the business special-products setting; each special has id, key, title, items[]" } } } diff --git a/src/business-settings/business-settings.service.ts b/src/business-settings/business-settings.service.ts index 863433f..3167dbc 100644 --- a/src/business-settings/business-settings.service.ts +++ b/src/business-settings/business-settings.service.ts @@ -185,6 +185,14 @@ export class BusinessSettingsService { }; } + if (dto.website) { + patch.website = { + specialProductsSource: + dto.website.specialProductsSource ?? + current.website.specialProductsSource, + }; + } + 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 87c055c..bfdbef9 100644 --- a/src/business-settings/business-settings.types.ts +++ b/src/business-settings/business-settings.types.ts @@ -99,6 +99,16 @@ export type StoreSettings = { ePayment: EPaymentSettings; }; +/** Where website special-product carousels read from. */ +export const SPECIAL_PRODUCTS_SOURCE_IDS = ['product', 'store_item'] as const; + +export type SpecialProductsSource = (typeof SPECIAL_PRODUCTS_SOURCE_IDS)[number]; + +/** Per-business website CMS settings. */ +export type WebsiteSettings = { + specialProductsSource: SpecialProductsSource; +}; + /** Optional business-dashboard CMS modules. Always-on areas (customers, website, etc.) are not listed. */ export const BUSINESS_DASHBOARD_MODULE_IDS = [ 'products', @@ -148,6 +158,7 @@ export type BusinessSettings = { dashboard: DashboardSettings; store: StoreSettings; modules: ModulesSettings; + website: WebsiteSettings; }; export const DEFAULT_ORDER_PROCESS_STEPS: OrderProcessStep[] = [ @@ -238,4 +249,7 @@ export const DEFAULT_BUSINESS_SETTINGS: BusinessSettings = { enabled: DEFAULT_ENABLED_BUSINESS_MODULES, charts: DEFAULT_HOME_CHARTS, }, + website: { + specialProductsSource: 'store_item', + }, }; diff --git a/src/business-settings/business-settings.util.ts b/src/business-settings/business-settings.util.ts index 210f60f..ebcdfc7 100644 --- a/src/business-settings/business-settings.util.ts +++ b/src/business-settings/business-settings.util.ts @@ -20,10 +20,12 @@ import { HOME_CHART_IDS, MellatGatewaySettings, PAYMENT_GATEWAY_IDS, + SPECIAL_PRODUCTS_SOURCE_IDS, type DashboardLocale, type DashboardThemeMode, type HomeChartId, type PaymentGatewayId, + type SpecialProductsSource, OrderProcessStep, StubGatewaySettings, ZarinpalGatewaySettings, @@ -66,6 +68,27 @@ export function normalizeEnabledBusinessModules( 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]; @@ -343,7 +366,13 @@ export function normalizeBusinessSettings(raw: unknown): BusinessSettings { : {}; 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: { @@ -375,13 +404,15 @@ export function normalizeBusinessSettings(raw: unknown): BusinessSettings { }, modules: { // Missing `modules` → all enabled (legacy). Explicit `{ enabled: [] }` stays empty. - enabled: hasModulesKey - ? Array.isArray(modules.enabled) - ? normalizeEnabledBusinessModules(modules.enabled) - : [] - : [...DEFAULT_ENABLED_BUSINESS_MODULES], + enabled: enabledModules, charts: normalizeHomeCharts(modules.charts), }, + website: { + specialProductsSource: resolveSpecialProductsSource( + website.specialProductsSource, + enabledModules, + ), + }, }; } @@ -423,6 +454,11 @@ export function mergeBusinessSettings( enabled: patch.modules?.enabled ?? current.modules.enabled, charts: patch.modules?.charts ?? current.modules.charts, }, + website: { + specialProductsSource: + patch.website?.specialProductsSource ?? + current.website.specialProductsSource, + }, }; } diff --git a/src/business-settings/dto/update-business-settings.dto.ts b/src/business-settings/dto/update-business-settings.dto.ts index 840a9ce..152dcff 100644 --- a/src/business-settings/dto/update-business-settings.dto.ts +++ b/src/business-settings/dto/update-business-settings.dto.ts @@ -17,6 +17,7 @@ import { BUSINESS_MODULE_IDS, HOME_CHART_IDS, PAYMENT_GATEWAY_IDS, + SPECIAL_PRODUCTS_SOURCE_IDS, } from '../business-settings.types'; class BrandingSettingsDto { @@ -194,6 +195,13 @@ class ModulesSettingsDto { charts?: string[]; } +class WebsiteSettingsDto { + @IsOptional() + @IsString() + @IsIn([...SPECIAL_PRODUCTS_SOURCE_IDS]) + specialProductsSource?: (typeof SPECIAL_PRODUCTS_SOURCE_IDS)[number]; +} + export class UpdateBusinessSettingsDto { @IsOptional() @ValidateNested() @@ -214,4 +222,9 @@ export class UpdateBusinessSettingsDto { @ValidateNested() @Type(() => ModulesSettingsDto) modules?: ModulesSettingsDto; + + @IsOptional() + @ValidateNested() + @Type(() => WebsiteSettingsDto) + website?: WebsiteSettingsDto; } diff --git a/src/store/store-specials.service.ts b/src/store/store-specials.service.ts index a1edeb2..5ab8a6d 100644 --- a/src/store/store-specials.service.ts +++ b/src/store/store-specials.service.ts @@ -11,6 +11,7 @@ import { PermissionsService } from '../auth/permissions.service'; import { normalizeSpecialGroupKey } from '../common/special-group-key'; import { PrismaService } from '../prisma/prisma.service'; import { TenantService } from '../tenant/tenant.service'; +import { normalizeBusinessSettings } from '../business-settings/business-settings.util'; import { CreateStoreSpecialDto, ListStoreSpecialsDto, @@ -72,7 +73,10 @@ export class StoreSpecialsService { this.collectProductIds(specials), ); + const settings = normalizeBusinessSettings(business.settings); + return { + source: settings.website.specialProductsSource, items: specials.map((special) => this.serializeSpecial(special, true, galleryByProduct), ), diff --git a/src/tenant/tenant.service.ts b/src/tenant/tenant.service.ts index 07f5973..06425d9 100644 --- a/src/tenant/tenant.service.ts +++ b/src/tenant/tenant.service.ts @@ -59,6 +59,7 @@ export class TenantService { themeMode: settings.branding.themeMode, enabledModules: settings.modules.enabled, homeCharts: settings.modules.charts, + specialProductsSource: settings.website.specialProductsSource, logoUrl: media?.logoMedia?.publicUrl ?? null, faviconUrl: media?.faviconMedia?.publicUrl ?? media?.logoMedia?.publicUrl ?? null, diff --git a/src/website-docs/static/AI_PROMPT.md b/src/website-docs/static/AI_PROMPT.md index ff5558e..0825749 100644 --- a/src/website-docs/static/AI_PROMPT.md +++ b/src/website-docs/static/AI_PROMPT.md @@ -29,8 +29,8 @@ You are building a **Meshkee business website (storefront)**. You must use the M 7. **Partner SMS** (`POST /public/sms/send`) is for external partner backends with an issued `X-Api-Key` only — not for normal storefront UI. See https://api.meshkee.com/docs/website/SMS.md ### Typical bootstrap sequence -1. `GET /tenants/{domain}` → branding + `businessId` -2. Homepage: business-info, **static-images**, sliders, category-groups, brand-groups, store-specials +1. `GET /tenants/{domain}` → branding + `businessId` + `specialProductsSource` (`product` or `store_item`) +2. Homepage: business-info, **static-images**, sliders, category-groups, brand-groups, store-specials (`source` repeats the tenant setting; items are store listings when `source` is `store_item`) 3. Catalog: categories, products (`GET /products/{slug}` includes `relatedProducts`: same category then same brand, in-stock first), store-items (`name` instant search: in-stock first, then `updatedAt`), **user-products** (customer stock listings) 4. Auth: register/login → store tokens. Optional: `POST /auth/send-otp` then `POST /auth/login-otp` (passwordless) or `POST /auth/reset-password` (forgot password). `POST /auth/verify-otp` only marks the cell verified (no tokens). 5. Cart checkout with `addressId` or inline `shippingAddress` + `payment` diff --git a/src/website-docs/static/openapi.json b/src/website-docs/static/openapi.json index 74351f8..74b06ab 100644 --- a/src/website-docs/static/openapi.json +++ b/src/website-docs/static/openapi.json @@ -169,6 +169,14 @@ "faviconUrl": { "type": "string", "nullable": true + }, + "specialProductsSource": { + "type": "string", + "enum": [ + "product", + "store_item" + ], + "description": "Where homepage special-product carousels read from. Defaults to store_item when the store module is enabled, otherwise product." } } } @@ -323,7 +331,7 @@ ], "responses": { "200": { - "description": "{ items: StoreSpecial[] } — each special has id, key, title, items[]" + "description": "{ source: product|store_item, items: StoreSpecial[] } — source is the business special-products setting; each special has id, key, title, items[]" } } }