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 <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
8609d8d4fd
commit
b0d4916138
+11
-3
@@ -1,7 +1,7 @@
|
|||||||
# Meshkee CMS API — Project Context
|
# Meshkee CMS API — Project Context
|
||||||
|
|
||||||
> Living reference for developers and AI assistants working on this codebase.
|
> 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
|
## 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/send-otp` | Send OTP (Redis-backed) |
|
||||||
| POST | `/auth/verify-otp` | Verify OTP (marks cell verified; no tokens) |
|
| POST | `/auth/verify-otp` | Verify OTP (marks cell verified; no tokens) |
|
||||||
| POST | `/auth/handoff/consume` | One-time SSO ticket → tokens (customer → business dashboard) |
|
| POST | `/auth/handoff/consume` | One-time SSO ticket → tokens (customer → business dashboard) |
|
||||||
| GET | `/tenants/:host` | Resolve business from domain |
|
| GET | `/tenants/:host` | Resolve business from domain (`specialProductsSource`, `enabledModules`, `homeCharts`, `ePayment`) |
|
||||||
| GET | `/tenants/:host/store-specials` | Active store specials |
|
| 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/category-groups` | Homepage category rows |
|
||||||
| GET | `/tenants/:host/website/brand-groups` | Homepage brand rows |
|
| GET | `/tenants/:host/website/brand-groups` | Homepage brand rows |
|
||||||
| GET | `/tenants/:host/website/sliders` | Homepage sliders with slides |
|
| 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.
|
- Each variant picks one option per variation; the combination must be unique per store item.
|
||||||
- Cart and orders reference `storeItemVariantId` (not the product directly).
|
- 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:**
|
**Example — batch create variants for a product:**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
|
|||||||
@@ -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
|
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
|
### Typical bootstrap sequence
|
||||||
1. `GET /tenants/{domain}` → branding + `businessId`
|
1. `GET /tenants/{domain}` → branding + `businessId` + `specialProductsSource` (`product` or `store_item`)
|
||||||
2. Homepage: business-info, **static-images**, sliders, category-groups, brand-groups, store-specials
|
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)
|
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).
|
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`
|
5. Cart checkout with `addressId` or inline `shippingAddress` + `payment`
|
||||||
|
|||||||
@@ -169,6 +169,14 @@
|
|||||||
"faviconUrl": {
|
"faviconUrl": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"nullable": true
|
"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": {
|
"responses": {
|
||||||
"200": {
|
"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[]"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 next = mergeBusinessSettings(current, patch);
|
||||||
|
|
||||||
const updated = await this.prisma.business.update({
|
const updated = await this.prisma.business.update({
|
||||||
|
|||||||
@@ -99,6 +99,16 @@ export type StoreSettings = {
|
|||||||
ePayment: EPaymentSettings;
|
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. */
|
/** Optional business-dashboard CMS modules. Always-on areas (customers, website, etc.) are not listed. */
|
||||||
export const BUSINESS_DASHBOARD_MODULE_IDS = [
|
export const BUSINESS_DASHBOARD_MODULE_IDS = [
|
||||||
'products',
|
'products',
|
||||||
@@ -148,6 +158,7 @@ export type BusinessSettings = {
|
|||||||
dashboard: DashboardSettings;
|
dashboard: DashboardSettings;
|
||||||
store: StoreSettings;
|
store: StoreSettings;
|
||||||
modules: ModulesSettings;
|
modules: ModulesSettings;
|
||||||
|
website: WebsiteSettings;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DEFAULT_ORDER_PROCESS_STEPS: OrderProcessStep[] = [
|
export const DEFAULT_ORDER_PROCESS_STEPS: OrderProcessStep[] = [
|
||||||
@@ -238,4 +249,7 @@ export const DEFAULT_BUSINESS_SETTINGS: BusinessSettings = {
|
|||||||
enabled: DEFAULT_ENABLED_BUSINESS_MODULES,
|
enabled: DEFAULT_ENABLED_BUSINESS_MODULES,
|
||||||
charts: DEFAULT_HOME_CHARTS,
|
charts: DEFAULT_HOME_CHARTS,
|
||||||
},
|
},
|
||||||
|
website: {
|
||||||
|
specialProductsSource: 'store_item',
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -20,10 +20,12 @@ import {
|
|||||||
HOME_CHART_IDS,
|
HOME_CHART_IDS,
|
||||||
MellatGatewaySettings,
|
MellatGatewaySettings,
|
||||||
PAYMENT_GATEWAY_IDS,
|
PAYMENT_GATEWAY_IDS,
|
||||||
|
SPECIAL_PRODUCTS_SOURCE_IDS,
|
||||||
type DashboardLocale,
|
type DashboardLocale,
|
||||||
type DashboardThemeMode,
|
type DashboardThemeMode,
|
||||||
type HomeChartId,
|
type HomeChartId,
|
||||||
type PaymentGatewayId,
|
type PaymentGatewayId,
|
||||||
|
type SpecialProductsSource,
|
||||||
OrderProcessStep,
|
OrderProcessStep,
|
||||||
StubGatewaySettings,
|
StubGatewaySettings,
|
||||||
ZarinpalGatewaySettings,
|
ZarinpalGatewaySettings,
|
||||||
@@ -66,6 +68,27 @@ export function normalizeEnabledBusinessModules(
|
|||||||
return BUSINESS_MODULE_IDS.filter((id) => selected.has(id));
|
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] {
|
export function normalizeHomeCharts(value: unknown): [HomeChartId, HomeChartId] {
|
||||||
if (!Array.isArray(value)) {
|
if (!Array.isArray(value)) {
|
||||||
return [...DEFAULT_HOME_CHARTS];
|
return [...DEFAULT_HOME_CHARTS];
|
||||||
@@ -343,7 +366,13 @@ export function normalizeBusinessSettings(raw: unknown): BusinessSettings {
|
|||||||
: {};
|
: {};
|
||||||
const store = isRecord(source.store) ? source.store : {};
|
const store = isRecord(source.store) ? source.store : {};
|
||||||
const modules = isRecord(source.modules) ? source.modules : {};
|
const modules = isRecord(source.modules) ? source.modules : {};
|
||||||
|
const website = isRecord(source.website) ? source.website : {};
|
||||||
const hasModulesKey = Object.prototype.hasOwnProperty.call(source, 'modules');
|
const hasModulesKey = Object.prototype.hasOwnProperty.call(source, 'modules');
|
||||||
|
const enabledModules = hasModulesKey
|
||||||
|
? Array.isArray(modules.enabled)
|
||||||
|
? normalizeEnabledBusinessModules(modules.enabled)
|
||||||
|
: []
|
||||||
|
: [...DEFAULT_ENABLED_BUSINESS_MODULES];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
branding: {
|
branding: {
|
||||||
@@ -375,13 +404,15 @@ export function normalizeBusinessSettings(raw: unknown): BusinessSettings {
|
|||||||
},
|
},
|
||||||
modules: {
|
modules: {
|
||||||
// Missing `modules` → all enabled (legacy). Explicit `{ enabled: [] }` stays empty.
|
// Missing `modules` → all enabled (legacy). Explicit `{ enabled: [] }` stays empty.
|
||||||
enabled: hasModulesKey
|
enabled: enabledModules,
|
||||||
? Array.isArray(modules.enabled)
|
|
||||||
? normalizeEnabledBusinessModules(modules.enabled)
|
|
||||||
: []
|
|
||||||
: [...DEFAULT_ENABLED_BUSINESS_MODULES],
|
|
||||||
charts: normalizeHomeCharts(modules.charts),
|
charts: normalizeHomeCharts(modules.charts),
|
||||||
},
|
},
|
||||||
|
website: {
|
||||||
|
specialProductsSource: resolveSpecialProductsSource(
|
||||||
|
website.specialProductsSource,
|
||||||
|
enabledModules,
|
||||||
|
),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -423,6 +454,11 @@ export function mergeBusinessSettings(
|
|||||||
enabled: patch.modules?.enabled ?? current.modules.enabled,
|
enabled: patch.modules?.enabled ?? current.modules.enabled,
|
||||||
charts: patch.modules?.charts ?? current.modules.charts,
|
charts: patch.modules?.charts ?? current.modules.charts,
|
||||||
},
|
},
|
||||||
|
website: {
|
||||||
|
specialProductsSource:
|
||||||
|
patch.website?.specialProductsSource ??
|
||||||
|
current.website.specialProductsSource,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
BUSINESS_MODULE_IDS,
|
BUSINESS_MODULE_IDS,
|
||||||
HOME_CHART_IDS,
|
HOME_CHART_IDS,
|
||||||
PAYMENT_GATEWAY_IDS,
|
PAYMENT_GATEWAY_IDS,
|
||||||
|
SPECIAL_PRODUCTS_SOURCE_IDS,
|
||||||
} from '../business-settings.types';
|
} from '../business-settings.types';
|
||||||
|
|
||||||
class BrandingSettingsDto {
|
class BrandingSettingsDto {
|
||||||
@@ -194,6 +195,13 @@ class ModulesSettingsDto {
|
|||||||
charts?: string[];
|
charts?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class WebsiteSettingsDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@IsIn([...SPECIAL_PRODUCTS_SOURCE_IDS])
|
||||||
|
specialProductsSource?: (typeof SPECIAL_PRODUCTS_SOURCE_IDS)[number];
|
||||||
|
}
|
||||||
|
|
||||||
export class UpdateBusinessSettingsDto {
|
export class UpdateBusinessSettingsDto {
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@ValidateNested()
|
@ValidateNested()
|
||||||
@@ -214,4 +222,9 @@ export class UpdateBusinessSettingsDto {
|
|||||||
@ValidateNested()
|
@ValidateNested()
|
||||||
@Type(() => ModulesSettingsDto)
|
@Type(() => ModulesSettingsDto)
|
||||||
modules?: ModulesSettingsDto;
|
modules?: ModulesSettingsDto;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@ValidateNested()
|
||||||
|
@Type(() => WebsiteSettingsDto)
|
||||||
|
website?: WebsiteSettingsDto;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { PermissionsService } from '../auth/permissions.service';
|
|||||||
import { normalizeSpecialGroupKey } from '../common/special-group-key';
|
import { normalizeSpecialGroupKey } from '../common/special-group-key';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { TenantService } from '../tenant/tenant.service';
|
import { TenantService } from '../tenant/tenant.service';
|
||||||
|
import { normalizeBusinessSettings } from '../business-settings/business-settings.util';
|
||||||
import {
|
import {
|
||||||
CreateStoreSpecialDto,
|
CreateStoreSpecialDto,
|
||||||
ListStoreSpecialsDto,
|
ListStoreSpecialsDto,
|
||||||
@@ -72,7 +73,10 @@ export class StoreSpecialsService {
|
|||||||
this.collectProductIds(specials),
|
this.collectProductIds(specials),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const settings = normalizeBusinessSettings(business.settings);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
source: settings.website.specialProductsSource,
|
||||||
items: specials.map((special) =>
|
items: specials.map((special) =>
|
||||||
this.serializeSpecial(special, true, galleryByProduct),
|
this.serializeSpecial(special, true, galleryByProduct),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ export class TenantService {
|
|||||||
themeMode: settings.branding.themeMode,
|
themeMode: settings.branding.themeMode,
|
||||||
enabledModules: settings.modules.enabled,
|
enabledModules: settings.modules.enabled,
|
||||||
homeCharts: settings.modules.charts,
|
homeCharts: settings.modules.charts,
|
||||||
|
specialProductsSource: settings.website.specialProductsSource,
|
||||||
logoUrl: media?.logoMedia?.publicUrl ?? null,
|
logoUrl: media?.logoMedia?.publicUrl ?? null,
|
||||||
faviconUrl:
|
faviconUrl:
|
||||||
media?.faviconMedia?.publicUrl ?? media?.logoMedia?.publicUrl ?? null,
|
media?.faviconMedia?.publicUrl ?? media?.logoMedia?.publicUrl ?? null,
|
||||||
|
|||||||
@@ -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
|
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
|
### Typical bootstrap sequence
|
||||||
1. `GET /tenants/{domain}` → branding + `businessId`
|
1. `GET /tenants/{domain}` → branding + `businessId` + `specialProductsSource` (`product` or `store_item`)
|
||||||
2. Homepage: business-info, **static-images**, sliders, category-groups, brand-groups, store-specials
|
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)
|
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).
|
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`
|
5. Cart checkout with `addressId` or inline `shippingAddress` + `payment`
|
||||||
|
|||||||
@@ -169,6 +169,14 @@
|
|||||||
"faviconUrl": {
|
"faviconUrl": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"nullable": true
|
"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": {
|
"responses": {
|
||||||
"200": {
|
"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[]"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user