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
+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