mirror of
https://git.meshkee.com/Meshkee/backend.git
synced 2026-08-11 22:30:59 +04:30
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:
co-authored by
Cursor
parent
158523df7b
commit
953b87b616
@@ -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,
|
||||
})),
|
||||
});
|
||||
|
||||
@@ -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' }],
|
||||
|
||||
@@ -7,5 +7,6 @@ import { MediaService } from './media.service';
|
||||
imports: [AuthModule],
|
||||
controllers: [MediaController],
|
||||
providers: [MediaService],
|
||||
exports: [MediaService],
|
||||
})
|
||||
export class MediaModule {}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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": [
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 listing’s 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"],
|
||||
|
||||
Reference in New Issue
Block a user