Add public user-products storefront API and website docs.

Expose published customer listings under /tenants/:host/user-products (list, search, details, technical-info) and document them in the website API pack.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-08-10 00:19:13 +03:30
co-authored by Cursor
parent 158523df7b
commit 953b87b616
32 changed files with 3326 additions and 138 deletions
+2
View File
@@ -28,6 +28,7 @@ import { CustomersModule } from './customers/customers.module';
import { ShoppingCardsModule } from './shopping-cards/shopping-cards.module';
import { ContactSubmissionsModule } from './contact-submissions/contact-submissions.module';
import { FavoritesModule } from './favorites/favorites.module';
import { UserProductsModule } from './user-products/user-products.module';
import { BrandsModule } from './brands/brands.module';
import { WebsiteModule } from './website/website.module';
import { InternalSslModule } from './internal-ssl/internal-ssl.module';
@@ -69,6 +70,7 @@ import { PublicSmsModule } from './public-sms/public-sms.module';
ShoppingCardsModule,
ContactSubmissionsModule,
FavoritesModule,
UserProductsModule,
BrandsModule,
WebsiteModule,
WebsiteDocsModule,
@@ -25,12 +25,14 @@ import { normalizeBusinessPrimaryColorId } from '../business-settings/business-p
import type { BusinessPrimaryColorId } from '../business-settings/business-primary-colors';
import {
normalizeDashboardLocale,
normalizeDashboardThemeMode,
normalizeEnabledBusinessModules,
normalizeHomeCharts,
} from '../business-settings/business-settings.util';
import type {
BusinessModuleId,
DashboardLocale,
DashboardThemeMode,
HomeChartId,
} from '../business-settings/business-settings.types';
import {
@@ -64,6 +66,7 @@ type BusinessRow = {
ownerCellNumber: string | null;
primaryColor: string | null;
defaultLocale: string | null;
themeMode: string | null;
enabledModules: unknown;
homeCharts: unknown;
};
@@ -145,6 +148,7 @@ export class BusinessAdminService {
own."ownerCellNumber" AS "ownerCellNumber",
b.settings->'branding'->>'primaryColor' AS "primaryColor",
b.settings->'branding'->>'defaultLocale' AS "defaultLocale",
b.settings->'branding'->>'themeMode' AS "themeMode",
b.settings->'modules'->'enabled' AS "enabledModules",
b.settings->'modules'->'charts' AS "homeCharts"
FROM businesses b
@@ -201,6 +205,9 @@ export class BusinessAdminService {
defaultLocale: normalizeDashboardLocale(
item.defaultLocale,
) as DashboardLocale,
themeMode: normalizeDashboardThemeMode(
item.themeMode,
) as DashboardThemeMode,
enabledModules,
moduleCount: enabledModules.length,
homeCharts,
@@ -78,6 +78,7 @@ export class BusinessSettingsService {
),
defaultLocale:
dto.branding.defaultLocale ?? current.branding.defaultLocale,
themeMode: dto.branding.themeMode ?? current.branding.themeMode,
};
}
@@ -5,10 +5,16 @@ export type DashboardLocale = 'en' | 'fa';
export const DEFAULT_BUSINESS_DASHBOARD_LOCALE: DashboardLocale = 'fa';
export type DashboardThemeMode = 'light' | 'dark';
export const DEFAULT_BUSINESS_THEME_MODE: DashboardThemeMode = 'light';
export type BrandingSettings = {
primaryColor: BusinessPrimaryColorId;
/** Default UI language for business + customer dashboards. */
defaultLocale: DashboardLocale;
/** Dashboard surface theme (neutral light/dark). Independent of primary brand color. */
themeMode: DashboardThemeMode;
};
export type DashboardCommentsSettings = {
@@ -38,8 +44,8 @@ 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 = [
/** Optional business-dashboard CMS modules. Always-on areas (customers, website, etc.) are not listed. */
export const BUSINESS_DASHBOARD_MODULE_IDS = [
'products',
'store',
'portfolio',
@@ -48,6 +54,18 @@ export const BUSINESS_MODULE_IDS = [
'videos',
] as const;
/** Optional customer-dashboard modules. Always-on: home, profile, addresses, orders, favorites. */
export const CUSTOMER_MODULE_IDS = ['customer_products'] as const;
/** All optional modules stored in `settings.modules.enabled`. */
export const BUSINESS_MODULE_IDS = [
...BUSINESS_DASHBOARD_MODULE_IDS,
...CUSTOMER_MODULE_IDS,
] as const;
export type BusinessDashboardModuleId =
(typeof BUSINESS_DASHBOARD_MODULE_IDS)[number];
export type CustomerModuleId = (typeof CUSTOMER_MODULE_IDS)[number];
export type BusinessModuleId = (typeof BUSINESS_MODULE_IDS)[number];
/** Home dashboard chart types (super-admin selectable). */
@@ -102,9 +120,12 @@ export const DEFAULT_ORDER_PROCESS_STEPS: OrderProcessStep[] = [
},
];
/** Existing tenants without `settings.modules` keep every module enabled. */
/**
* Existing tenants without `settings.modules` keep every business-dashboard module
* enabled. Customer modules stay opt-in.
*/
export const DEFAULT_ENABLED_BUSINESS_MODULES: BusinessModuleId[] = [
...BUSINESS_MODULE_IDS,
...BUSINESS_DASHBOARD_MODULE_IDS,
];
export const DEFAULT_HOME_CHARTS: [HomeChartId, HomeChartId] = [
@@ -116,6 +137,7 @@ export const DEFAULT_BUSINESS_SETTINGS: BusinessSettings = {
branding: {
primaryColor: DEFAULT_BUSINESS_PRIMARY_COLOR_ID,
defaultLocale: DEFAULT_BUSINESS_DASHBOARD_LOCALE,
themeMode: DEFAULT_BUSINESS_THEME_MODE,
},
dashboard: {
comments: { autoApprove: false },
@@ -8,11 +8,13 @@ import {
BusinessSettings,
DEFAULT_BUSINESS_DASHBOARD_LOCALE,
DEFAULT_BUSINESS_SETTINGS,
DEFAULT_BUSINESS_THEME_MODE,
DEFAULT_ENABLED_BUSINESS_MODULES,
DEFAULT_HOME_CHARTS,
DEFAULT_ORDER_PROCESS_STEPS,
HOME_CHART_IDS,
type DashboardLocale,
type DashboardThemeMode,
type HomeChartId,
OrderProcessStep,
} from './business-settings.types';
@@ -68,6 +70,12 @@ 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;
}
@@ -122,6 +130,7 @@ export function normalizeBusinessSettings(raw: unknown): BusinessSettings {
branding: {
primaryColor: normalizeBusinessPrimaryColorId(branding.primaryColor),
defaultLocale: normalizeDashboardLocale(branding.defaultLocale),
themeMode: normalizeDashboardThemeMode(branding.themeMode),
},
dashboard: {
comments: {
@@ -166,6 +175,7 @@ export function mergeBusinessSettings(
patch.branding?.primaryColor ?? current.branding.primaryColor,
defaultLocale:
patch.branding?.defaultLocale ?? current.branding.defaultLocale,
themeMode: patch.branding?.themeMode ?? current.branding.themeMode,
},
dashboard: {
comments: {
@@ -27,6 +27,11 @@ class BrandingSettingsDto {
@IsString()
@IsIn(['en', 'fa'])
defaultLocale?: 'en' | 'fa';
@IsOptional()
@IsString()
@IsIn(['light', 'dark'])
themeMode?: 'light' | 'dark';
}
class DashboardCommentsSettingsDto {
@@ -14,13 +14,43 @@ import {
} from './dto/category-technical-form.dto';
function slugifyKey(value: string): string {
return (
value
.toLowerCase()
.trim()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '') || 'field'
);
const ascii = value
.toLowerCase()
.trim()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
if (ascii) {
return ascii;
}
// Keep letters/numbers from any script (e.g. Farsi labels) when ASCII strip is empty
const unicode = value
.trim()
.toLowerCase()
.normalize('NFKC')
.replace(/\s+/g, '-')
.replace(/[^\p{L}\p{N}-]+/gu, '')
.replace(/-+/g, '-')
.replace(/^-+|-+$/g, '');
return unicode || 'field';
}
function uniqueSlug(base: string, used: Set<string>): string {
let value = base || 'field';
if (!used.has(value)) {
used.add(value);
return value;
}
let suffix = 2;
while (used.has(`${value}-${suffix}`)) {
suffix += 1;
}
const next = `${value}-${suffix}`;
used.add(next);
return next;
}
@Injectable()
@@ -88,15 +118,7 @@ export class CategoryTechnicalFormService {
const usedKeys = new Set<string>();
for (const [index, field] of dto.fields.entries()) {
let fieldKey = slugifyKey(field.label);
if (usedKeys.has(fieldKey)) {
let suffix = 2;
while (usedKeys.has(`${fieldKey}-${suffix}`)) {
suffix += 1;
}
fieldKey = `${fieldKey}-${suffix}`;
}
usedKeys.add(fieldKey);
const fieldKey = uniqueSlug(slugifyKey(field.label), usedKeys);
const createdField = await tx.categoryTechnicalFormField.create({
data: {
@@ -113,12 +135,13 @@ export class CategoryTechnicalFormService {
const uniqueOptions = [
...new Set(field.options!.map((o) => o.trim()).filter(Boolean)),
];
const usedValues = new Set<string>();
await tx.categoryTechnicalFormFieldOption.createMany({
data: uniqueOptions.map((label, optionIndex) => ({
fieldId: createdField.id,
label,
value: slugifyKey(label) || `option-${optionIndex + 1}`,
value: uniqueSlug(slugifyKey(label), usedValues),
sortOrder: optionIndex,
})),
});
+35 -9
View File
@@ -26,25 +26,51 @@ export class CitiesService {
...(query.level ? { level: query.level } : {}),
};
if (query.parentId) {
where.parentId = BigInt(query.parentId);
} else if (query.parentSlug) {
const parent = await this.prisma.city.findFirst({
where: { slug: query.parentSlug, isActive: true },
select: { id: true },
});
let parent:
| { id: bigint; level: CityLevel }
| null = null;
if (query.parentId) {
parent = await this.prisma.city.findFirst({
where: { id: BigInt(query.parentId), isActive: true },
select: { id: true, level: true },
});
if (!parent) {
return { items: [] };
}
} else if (query.parentSlug) {
parent = await this.prisma.city.findFirst({
where: { slug: query.parentSlug, isActive: true },
select: { id: true, level: true },
});
if (!parent) {
return { items: [] };
}
where.parentId = parent.id;
} else if (query.level === CityLevel.province || query.level === CityLevel.city) {
throw new BadRequestException('parentId or parentSlug is required for this level');
} else {
where.level = CityLevel.country;
}
if (parent) {
// Cities under a country: direct children + cities under that country's provinces.
if (query.level === CityLevel.city && parent.level === CityLevel.country) {
const provinces = await this.prisma.city.findMany({
where: {
parentId: parent.id,
level: CityLevel.province,
isActive: true,
},
select: { id: true },
});
where.parentId = {
in: [parent.id, ...provinces.map((item) => item.id)],
};
} else {
where.parentId = parent.id;
}
}
const items = await this.prisma.city.findMany({
where,
orderBy: [{ sortOrder: 'asc' }, { nameEn: 'asc' }],
+1
View File
@@ -7,5 +7,6 @@ import { MediaService } from './media.service';
imports: [AuthModule],
controllers: [MediaController],
providers: [MediaService],
exports: [MediaService],
})
export class MediaModule {}
+48
View File
@@ -99,6 +99,38 @@ export class MediaService {
return { items };
}
/**
* Customer-dashboard uploads for user-product images.
* Requires business-customer membership instead of media.create.
*/
async uploadManyForCustomer(
businessIdRaw: string,
files: Express.Multer.File[],
actor: AuthUser,
) {
const businessId = BigInt(businessIdRaw);
await this.assertBusinessCustomer(businessId, actor.id);
if (!files.length) {
throw new BadRequestException('At least one file is required');
}
const business = await this.prisma.business.findUnique({
where: { id: businessId },
});
if (!business?.isActive) {
throw new NotFoundException('Business not found');
}
const items = [];
for (const file of files) {
items.push(await this.uploadOne(businessId, file, actor.id));
}
return { items };
}
async update(
businessIdRaw: string,
mediaIdRaw: string,
@@ -371,4 +403,20 @@ export class MediaService {
throw new ForbiddenException('You cannot delete media for this business');
}
}
private async assertBusinessCustomer(businessId: bigint, userId: bigint) {
if (await this.permissions.isSuperAdmin(userId)) {
return;
}
const membership = await this.prisma.businessCustomer.findUnique({
where: {
businessId_userId: { businessId, userId },
},
});
if (!membership) {
throw new ForbiddenException('You are not a customer of this business');
}
}
}
+1
View File
@@ -56,6 +56,7 @@ export class TenantService {
domain: normalizedHost,
primaryColor: settings.branding.primaryColor,
defaultLocale: settings.branding.defaultLocale,
themeMode: settings.branding.themeMode,
enabledModules: settings.modules.enabled,
homeCharts: settings.modules.charts,
logoUrl: media?.logoMedia?.publicUrl ?? null,
+176
View File
@@ -0,0 +1,176 @@
import { Transform, Type } from 'class-transformer';
import {
ArrayUnique,
IsArray,
IsBoolean,
IsEnum,
IsIn,
IsInt,
IsNumber,
IsOptional,
IsString,
Min,
MinLength,
ValidateNested,
} from 'class-validator';
import { ContentStatus, UserProductCondition } from '@prisma/client';
export const USER_PRODUCT_PRICE_CURRENCIES = ['IRT', 'USD', 'EUR', 'AED'] as const;
export type UserProductPriceCurrency =
(typeof USER_PRODUCT_PRICE_CURRENCIES)[number];
export class ListMyUserProductsDto {
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page?: number;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
pageSize?: number;
}
export class ListAdminUserProductsDto extends ListMyUserProductsDto {
@IsOptional()
@IsEnum(ContentStatus)
status?: ContentStatus;
}
export class ListPublicUserProductsDto {
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page?: number;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
pageSize?: number;
@IsOptional()
@IsString()
name?: string;
@IsOptional()
@IsString()
q?: string;
@IsOptional()
@IsString()
categoryId?: string;
@IsOptional()
@IsString()
cityId?: string;
@IsOptional()
@IsString()
countryId?: string;
@IsOptional()
@IsEnum(UserProductCondition)
condition?: UserProductCondition;
@IsOptional()
@Transform(({ value }) => value === 'true' || value === true)
@IsBoolean()
promoted?: boolean;
}
export class UserProductTechnicalValueDto {
@IsString()
@MinLength(1)
fieldId!: string;
@IsOptional()
@IsString()
textValue?: string;
@IsOptional()
@IsString()
optionId?: string;
@IsOptional()
@IsArray()
@ArrayUnique()
@IsString({ each: true })
optionIds?: string[];
}
export class CreateUserProductDto {
@IsString()
@MinLength(1)
titleFa!: string;
@IsOptional()
@IsString()
titleEn?: string;
@IsOptional()
@IsString()
description?: string;
@IsString()
@MinLength(1)
categoryId!: string;
@IsOptional()
@Type(() => Number)
@IsNumber({ maxDecimalPlaces: 2 })
@Min(0)
price?: number;
@IsOptional()
@IsIn(USER_PRODUCT_PRICE_CURRENCIES)
priceCurrency?: UserProductPriceCurrency;
@IsOptional()
@IsBoolean()
priceByExpert?: boolean;
@IsString()
@MinLength(1)
countryId!: string;
@IsString()
@MinLength(1)
cityId!: string;
@IsOptional()
@IsString()
deliveryNote?: string;
@IsEnum(UserProductCondition)
condition!: UserProductCondition;
@IsOptional()
@IsString()
technicalNotes?: string;
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => UserProductTechnicalValueDto)
technicalValues?: UserProductTechnicalValueDto[];
@IsOptional()
@IsString()
featuredMediaId?: string;
@IsOptional()
@IsArray()
@IsString({ each: true })
galleryMediaIds?: string[];
}
export class UpdateUserProductDto extends CreateUserProductDto {}
export class UpdateUserProductStatusDto {
@IsEnum(ContentStatus)
status!: ContentStatus;
}
@@ -0,0 +1,124 @@
import {
Body,
Controller,
Delete,
Get,
Param,
Patch,
Post,
Query,
UseGuards,
} from '@nestjs/common';
import { AuthUser } from '../auth/auth.types';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import { RequireBusinessPermission } from '../auth/decorators/require-business-permission.decorator';
import { BusinessPermissionGuard } from '../auth/guards/business-permission.guard';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import {
CreateUserProductDto,
ListAdminUserProductsDto,
UpdateUserProductDto,
UpdateUserProductStatusDto,
} from './dto/user-product.dto';
import { UserProductsService } from './user-products.service';
@Controller('businesses/:businessId/user-products')
@UseGuards(JwtAuthGuard, BusinessPermissionGuard)
export class UserProductsAdminController {
constructor(private readonly service: UserProductsService) {}
@Get('categories')
@RequireBusinessPermission('user_products.read')
listCategories(
@Param('businessId') businessId: string,
@CurrentUser() user: AuthUser,
) {
return this.service.listCategoriesForAdmin(businessId, user);
}
@Get('categories/:categoryId/technical-form')
@RequireBusinessPermission('user_products.read')
getCategoryTechnicalForm(
@Param('businessId') businessId: string,
@Param('categoryId') categoryId: string,
@CurrentUser() user: AuthUser,
) {
return this.service.getCategoryTechnicalFormForAdmin(
businessId,
categoryId,
user,
);
}
@Get()
@RequireBusinessPermission('user_products.read')
list(
@Param('businessId') businessId: string,
@Query() query: ListAdminUserProductsDto,
@CurrentUser() user: AuthUser,
) {
return this.service.adminList(businessId, query, user);
}
@Post()
@RequireBusinessPermission('user_products.create')
create(
@Param('businessId') businessId: string,
@Body() dto: CreateUserProductDto,
@CurrentUser() user: AuthUser,
) {
return this.service.adminCreate(businessId, dto, user);
}
@Get(':productId')
@RequireBusinessPermission('user_products.read')
getOne(
@Param('businessId') businessId: string,
@Param('productId') productId: string,
@CurrentUser() user: AuthUser,
) {
return this.service.adminGetOne(businessId, productId, user);
}
@Patch(':productId')
@RequireBusinessPermission('user_products.update')
update(
@Param('businessId') businessId: string,
@Param('productId') productId: string,
@Body() dto: UpdateUserProductDto,
@CurrentUser() user: AuthUser,
) {
return this.service.adminUpdate(businessId, productId, dto, user);
}
@Patch(':productId/status')
@RequireBusinessPermission('user_products.update')
updateStatus(
@Param('businessId') businessId: string,
@Param('productId') productId: string,
@Body() dto: UpdateUserProductStatusDto,
@CurrentUser() user: AuthUser,
) {
return this.service.adminUpdateStatus(businessId, productId, dto, user);
}
@Post(':productId/promote')
@RequireBusinessPermission('user_products.update')
promote(
@Param('businessId') businessId: string,
@Param('productId') productId: string,
@CurrentUser() user: AuthUser,
) {
return this.service.adminPromote(businessId, productId, user);
}
@Delete(':productId')
@RequireBusinessPermission('user_products.delete')
remove(
@Param('businessId') businessId: string,
@Param('productId') productId: string,
@CurrentUser() user: AuthUser,
) {
return this.service.adminRemove(businessId, productId, user);
}
}
@@ -0,0 +1,120 @@
import {
Body,
Controller,
Delete,
Get,
Param,
Patch,
Post,
Query,
UploadedFiles,
UseGuards,
UseInterceptors,
} from '@nestjs/common';
import { FilesInterceptor } from '@nestjs/platform-express';
import { memoryStorage } from 'multer';
import { AuthUser } from '../auth/auth.types';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import {
CreateUserProductDto,
ListMyUserProductsDto,
UpdateUserProductDto,
} from './dto/user-product.dto';
import { UserProductsService } from './user-products.service';
@Controller('businesses/:businessId/my-user-products')
@UseGuards(JwtAuthGuard)
export class UserProductsController {
constructor(private readonly service: UserProductsService) {}
@Get('categories')
listCategories(
@Param('businessId') businessId: string,
@CurrentUser() user: AuthUser,
) {
return this.service.listCategories(businessId, user);
}
@Get('categories/:categoryId/technical-form')
getCategoryTechnicalForm(
@Param('businessId') businessId: string,
@Param('categoryId') categoryId: string,
@CurrentUser() user: AuthUser,
) {
return this.service.getCategoryTechnicalForm(
businessId,
categoryId,
user,
);
}
@Post('media')
@UseInterceptors(
FilesInterceptor('files', 10, {
storage: memoryStorage(),
}),
)
uploadMedia(
@Param('businessId') businessId: string,
@UploadedFiles() files: Express.Multer.File[],
@CurrentUser() user: AuthUser,
) {
return this.service.uploadMedia(businessId, files ?? [], user);
}
@Get()
list(
@Param('businessId') businessId: string,
@Query() query: ListMyUserProductsDto,
@CurrentUser() user: AuthUser,
) {
return this.service.list(businessId, query, user);
}
@Post()
create(
@Param('businessId') businessId: string,
@Body() dto: CreateUserProductDto,
@CurrentUser() user: AuthUser,
) {
return this.service.create(businessId, dto, user);
}
@Get(':productId')
getOne(
@Param('businessId') businessId: string,
@Param('productId') productId: string,
@CurrentUser() user: AuthUser,
) {
return this.service.getOne(businessId, productId, user);
}
@Patch(':productId')
update(
@Param('businessId') businessId: string,
@Param('productId') productId: string,
@Body() dto: UpdateUserProductDto,
@CurrentUser() user: AuthUser,
) {
return this.service.update(businessId, productId, dto, user);
}
@Post(':productId/promote')
promote(
@Param('businessId') businessId: string,
@Param('productId') productId: string,
@CurrentUser() user: AuthUser,
) {
return this.service.promote(businessId, productId, user);
}
@Delete(':productId')
remove(
@Param('businessId') businessId: string,
@Param('productId') productId: string,
@CurrentUser() user: AuthUser,
) {
return this.service.remove(businessId, productId, user);
}
}
+20
View File
@@ -0,0 +1,20 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { CategoriesModule } from '../categories/categories.module';
import { MediaModule } from '../media/media.module';
import { TenantModule } from '../tenant/tenant.module';
import { UserProductsAdminController } from './user-products.admin.controller';
import { UserProductsController } from './user-products.controller';
import { PublicUserProductsController } from './user-products.public.controller';
import { UserProductsService } from './user-products.service';
@Module({
imports: [AuthModule, CategoriesModule, MediaModule, TenantModule],
controllers: [
UserProductsController,
UserProductsAdminController,
PublicUserProductsController,
],
providers: [UserProductsService],
})
export class UserProductsModule {}
@@ -0,0 +1,26 @@
import { Controller, Get, Param, Query } from '@nestjs/common';
import { ListPublicUserProductsDto } from './dto/user-product.dto';
import { UserProductsService } from './user-products.service';
@Controller('tenants/:host/user-products')
export class PublicUserProductsController {
constructor(private readonly service: UserProductsService) {}
@Get()
list(
@Param('host') host: string,
@Query() query: ListPublicUserProductsDto,
) {
return this.service.listPublic(host, query);
}
@Get(':slug/technical-info')
getTechnicalInfo(@Param('host') host: string, @Param('slug') slug: string) {
return this.service.getPublicTechnicalInfoBySlug(host, slug);
}
@Get(':slug')
getBySlug(@Param('host') host: string, @Param('slug') slug: string) {
return this.service.getPublicBySlug(host, slug);
}
}
File diff suppressed because it is too large Load Diff
+8 -1
View File
@@ -31,10 +31,17 @@ You are building a **Meshkee business website (storefront)**. You must use the M
### Typical bootstrap sequence
1. `GET /tenants/{domain}` → branding + `businessId`
2. Homepage: business-info, sliders, category-groups, brand-groups, store-specials
3. Catalog: categories, products, store-items
3. Catalog: categories, products, store-items, **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`
### User products (customer listings)
Public marketplace listings owned by customers — not catalog `products`.
- `GET /tenants/{domain}/user-products` — list published (`name`/`q`, `categoryId`, `cityId`, `countryId`, `condition`, `promoted`, pagination)
- `GET /tenants/{domain}/user-products/{slug}` — details + gallery
- `GET /tenants/{domain}/user-products/{slug}/technical-info` — category form + values
Use product categories from `GET /tenants/{domain}/categories?entityType=product` for filters. Creating/editing listings is customer-dashboard only (`/businesses/.../my-user-products`), not website-facing.
If OpenAPI and this brief conflict, **OpenAPI wins**.
---
@@ -33,6 +33,14 @@
"key": "productSlug",
"value": ""
},
{
"key": "userProductId",
"value": ""
},
{
"key": "userProductSlug",
"value": ""
},
{
"key": "blogId",
"value": ""
@@ -1101,6 +1109,133 @@
}
]
},
{
"name": "User Products",
"item": [
{
"name": "List published user products",
"event": [
{
"listen": "test",
"script": {
"type": "text/javascript",
"exec": [
"if (pm.response.code === 200) {",
" const json = pm.response.json();",
" if (json.items?.[0]?.id) pm.collectionVariables.set('userProductId', json.items[0].id);",
" if (json.items?.[0]?.slug) pm.collectionVariables.set('userProductSlug', json.items[0].slug);",
"}"
]
}
}
],
"request": {
"method": "GET",
"url": {
"raw": "{{baseUrl}}/tenants/{{domain}}/user-products?page=1&pageSize=12",
"host": [
"{{baseUrl}}"
],
"path": [
"tenants",
"{{domain}}",
"user-products"
],
"query": [
{
"key": "page",
"value": "1"
},
{
"key": "pageSize",
"value": "12"
},
{
"key": "name",
"value": "",
"disabled": true
},
{
"key": "q",
"value": "",
"disabled": true
},
{
"key": "categoryId",
"value": "{{categoryId}}",
"disabled": true
},
{
"key": "cityId",
"value": "",
"disabled": true
},
{
"key": "countryId",
"value": "",
"disabled": true
},
{
"key": "condition",
"value": "new",
"disabled": true
},
{
"key": "promoted",
"value": "true",
"disabled": true
}
]
}
}
},
{
"name": "Search user products",
"request": {
"method": "GET",
"url": {
"raw": "{{baseUrl}}/tenants/{{domain}}/user-products?q=boiler&page=1&pageSize=12",
"host": [
"{{baseUrl}}"
],
"path": [
"tenants",
"{{domain}}",
"user-products"
],
"query": [
{
"key": "q",
"value": "boiler"
},
{
"key": "page",
"value": "1"
},
{
"key": "pageSize",
"value": "12"
}
]
}
}
},
{
"name": "Get user product by slug",
"request": {
"method": "GET",
"url": "{{baseUrl}}/tenants/{{domain}}/user-products/{{userProductSlug}}"
}
},
{
"name": "Get user product technical info by slug",
"request": {
"method": "GET",
"url": "{{baseUrl}}/tenants/{{domain}}/user-products/{{userProductSlug}}/technical-info"
}
}
]
},
{
"name": "Store Items",
"item": [
+8 -1
View File
@@ -88,10 +88,17 @@
<ol>
<li>Variable <code>domain</code> = website apex only (no <code>www</code>/<code>api</code>/<code>customer</code>/<code>business</code>).</li>
<li><code>GET /tenants/{domain}</code><code>businessId</code>.</li>
<li>Public pages: <code>/tenants/{domain}/...</code> (no auth).</li>
<li>Public pages: <code>/tenants/{domain}/...</code> (no auth) — products, <strong>user-products</strong>, blogs, portfolios, store-items, etc.</li>
<li>Cart / orders / favorites: <code>/businesses/{businessId}/...</code> + Bearer JWT.</li>
</ol>
<h2>User products (customer listings)</h2>
<p>
Marketplace-style stock listings created by customers. Public read-only under
<code>/tenants/{domain}/user-products</code> (list / details / technical-info).
See OpenAPI tag <strong>User Products</strong>.
</p>
<h2>For a new website AI / designer</h2>
<ol>
<li>Open <a href="/docs/website/AI_PROMPT.md">AI_PROMPT.md</a> and paste it into the AI chat.</li>
+61
View File
@@ -25,6 +25,7 @@
{ "name": "Homepage" },
{ "name": "Categories" },
{ "name": "Products" },
{ "name": "User Products" },
{ "name": "Store" },
{ "name": "Blogs" },
{ "name": "Portfolios" },
@@ -210,6 +211,66 @@
"responses": { "200": { "description": "{ form, values }" } }
}
},
"/tenants/{domain}/user-products": {
"get": {
"tags": ["User Products"],
"summary": "List published customer / stock listings",
"description": "Public marketplace-style listings created by customers (user products). Only `status=published`. Search with `name` or `q` (title/description). Filter by category, city, country, condition, or promoted.",
"parameters": [
{ "$ref": "#/components/parameters/domain" },
{ "name": "page", "in": "query", "schema": { "type": "integer", "default": 1 } },
{ "name": "pageSize", "in": "query", "schema": { "type": "integer", "default": 12 } },
{ "name": "name", "in": "query", "description": "Search title/description (alias of q)", "schema": { "type": "string" } },
{ "name": "q", "in": "query", "description": "Search title/description (alias of name)", "schema": { "type": "string" } },
{ "name": "categoryId", "in": "query", "schema": { "type": "string" } },
{ "name": "cityId", "in": "query", "schema": { "type": "string" } },
{ "name": "countryId", "in": "query", "schema": { "type": "string" } },
{
"name": "condition",
"in": "query",
"schema": {
"type": "string",
"enum": ["new", "stock", "needs_repair", "scrap"]
}
},
{ "name": "promoted", "in": "query", "schema": { "type": "boolean" } }
],
"responses": {
"200": {
"description": "{ items: UserProductListItem[], total, page, pageSize }. Each item includes id, slug, titleFa/titleEn, price, priceCurrency, condition, city/country names, imageUrl, category*, promoted, publishedAt."
}
}
}
},
"/tenants/{domain}/user-products/{slug}": {
"get": {
"tags": ["User Products"],
"summary": "User product details by slug",
"description": "Full published listing: location IDs, gallery images (`images`, `galleryMediaIds`), technical field values, delivery/technical notes.",
"parameters": [
{ "$ref": "#/components/parameters/domain" },
{ "name": "slug", "in": "path", "required": true, "schema": { "type": "string" } }
],
"responses": {
"200": {
"description": "{ product } with gallery (`images`: [{ mediaId, url }]), featuredMediaId, technicalValues, countryId, cityId, countrySlug"
},
"404": { "description": "Not found or not published" }
}
}
},
"/tenants/{domain}/user-products/{slug}/technical-info": {
"get": {
"tags": ["User Products"],
"summary": "User product technical form + values",
"description": "Category technical form schema plus the listings submitted values (same shape as dashboard technical values).",
"parameters": [
{ "$ref": "#/components/parameters/domain" },
{ "name": "slug", "in": "path", "required": true, "schema": { "type": "string" } }
],
"responses": { "200": { "description": "{ form, values }" } }
}
},
"/tenants/{domain}/store-items": {
"get": {
"tags": ["Store"],