mirror of
https://git.meshkee.com/Meshkee/backend.git
synced 2026-08-12 06:40:58 +04:30
Initial commit: Meshkee CMS API
NestJS backend with Prisma, Docker Compose for Postgres/Redis, and deploy docs for the production VM.
This commit is contained in:
@@ -0,0 +1,19 @@
|
||||
import { IsEnum, IsString, MinLength } from 'class-validator';
|
||||
|
||||
export enum ProductAiLanguage {
|
||||
en = 'en',
|
||||
fa = 'fa',
|
||||
}
|
||||
|
||||
export class CreateProductByAiDto {
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
categoryId!: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(2)
|
||||
name!: string;
|
||||
|
||||
@IsEnum(ProductAiLanguage)
|
||||
language!: ProductAiLanguage;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
IsArray,
|
||||
IsOptional,
|
||||
IsString,
|
||||
MinLength,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
|
||||
export class ProductTechnicalFieldValueDto {
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
fieldKey!: string;
|
||||
|
||||
@IsOptional()
|
||||
value?: string | string[] | null;
|
||||
}
|
||||
|
||||
export class ReplaceProductTechnicalInfoDto {
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => ProductTechnicalFieldValueDto)
|
||||
values!: ProductTechnicalFieldValueDto[];
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
IsArray,
|
||||
IsString,
|
||||
MinLength,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
|
||||
export class ProductVariationSelectionDto {
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
variationId!: string;
|
||||
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
optionIds!: string[];
|
||||
}
|
||||
|
||||
export class ReplaceProductVariationValuesDto {
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => ProductVariationSelectionDto)
|
||||
selections!: ProductVariationSelectionDto[];
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
import { ContentStatus } from '@prisma/client';
|
||||
import { Transform, Type } from 'class-transformer';
|
||||
import {
|
||||
IsArray,
|
||||
IsBoolean,
|
||||
IsEnum,
|
||||
IsInt,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Matches,
|
||||
Min,
|
||||
MinLength,
|
||||
} from 'class-validator';
|
||||
|
||||
export class ListProductsDto {
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
page?: number;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
pageSize?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(ContentStatus)
|
||||
status?: ContentStatus;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
name?: string;
|
||||
}
|
||||
|
||||
export class ListPublicProductsDto {
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
page?: number;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
pageSize?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
name?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
categoryId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
brandId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
tag?: string;
|
||||
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => value === 'true' || value === true)
|
||||
@IsBoolean()
|
||||
inStore?: boolean;
|
||||
}
|
||||
|
||||
export class CreateProductDto {
|
||||
@IsString()
|
||||
@MinLength(2)
|
||||
title!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
nameFa?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
summary?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
descriptionHtml?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
categoryId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
brandId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(ContentStatus)
|
||||
status?: ContentStatus;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
featuredMediaId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
galleryMediaIds?: string[];
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
tags?: string[];
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Matches(/^[a-z0-9]+(?:-[a-z0-9]+)*$/)
|
||||
slug?: string;
|
||||
}
|
||||
|
||||
export class UpdateProductDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(2)
|
||||
title?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
nameFa?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
summary?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
descriptionHtml?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
categoryId?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
brandId?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(ContentStatus)
|
||||
status?: ContentStatus;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
featuredMediaId?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
galleryMediaIds?: string[];
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
tags?: string[];
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Matches(/^[a-z0-9]+(?:-[a-z0-9]+)*$/)
|
||||
slug?: string;
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
} from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { ContentStatus, TechnicalFieldType } from '@prisma/client';
|
||||
import { AuthUser } from '../auth/auth.types';
|
||||
import { CategoryTechnicalFormService } from '../categories/category-technical-form.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { CreateProductByAiDto, ProductAiLanguage } from './dto/product-ai.dto';
|
||||
import { ProductTechnicalInfoService } from './product-technical-info.service';
|
||||
import { ProductsService } from './products.service';
|
||||
import {
|
||||
requestAiJsonCompletion,
|
||||
resolveAiProvider,
|
||||
} from '../common/ai-provider.util';
|
||||
|
||||
type TechnicalFormField = {
|
||||
key: string;
|
||||
label: string;
|
||||
type: TechnicalFieldType;
|
||||
isRequired: boolean;
|
||||
options: { value: string; label: string }[];
|
||||
};
|
||||
|
||||
type AiProductDraft = {
|
||||
title: string;
|
||||
nameFa: string;
|
||||
summary: string;
|
||||
descriptionHtml: string;
|
||||
tags: string[];
|
||||
technicalValues: Record<string, string | string[]>;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class ProductAiService {
|
||||
constructor(
|
||||
private readonly config: ConfigService,
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly products: ProductsService,
|
||||
private readonly technicalInfo: ProductTechnicalInfoService,
|
||||
private readonly technicalFormService: CategoryTechnicalFormService,
|
||||
) {}
|
||||
|
||||
async createFromAi(
|
||||
businessIdRaw: string,
|
||||
dto: CreateProductByAiDto,
|
||||
actor: AuthUser,
|
||||
) {
|
||||
const businessId = BigInt(businessIdRaw);
|
||||
const categoryId = BigInt(dto.categoryId);
|
||||
|
||||
const category = await this.prisma.category.findFirst({
|
||||
where: {
|
||||
id: categoryId,
|
||||
businessId,
|
||||
isActive: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!category) {
|
||||
throw new BadRequestException('Product category not found');
|
||||
}
|
||||
|
||||
const form = await this.technicalFormService.getFormForCategory(
|
||||
businessId,
|
||||
categoryId,
|
||||
);
|
||||
|
||||
const draft = await this.generateDraft({
|
||||
categoryName: category.name,
|
||||
productName: dto.name.trim(),
|
||||
language: dto.language,
|
||||
fields: form?.fields ?? [],
|
||||
});
|
||||
|
||||
const created = await this.products.create(
|
||||
businessIdRaw,
|
||||
{
|
||||
title: draft.title,
|
||||
nameFa: draft.nameFa,
|
||||
summary: draft.summary,
|
||||
descriptionHtml: draft.descriptionHtml,
|
||||
categoryId: dto.categoryId,
|
||||
tags: draft.tags,
|
||||
status: ContentStatus.published,
|
||||
},
|
||||
actor,
|
||||
);
|
||||
|
||||
if (form && Object.keys(draft.technicalValues).length > 0) {
|
||||
const values = Object.entries(draft.technicalValues)
|
||||
.filter(([, value]) => {
|
||||
if (Array.isArray(value)) return value.length > 0;
|
||||
return value !== '';
|
||||
})
|
||||
.map(([fieldKey, value]) => ({ fieldKey, value }));
|
||||
|
||||
if (values.length > 0) {
|
||||
await this.technicalInfo.replaceForProduct(
|
||||
businessIdRaw,
|
||||
created.product.id,
|
||||
{ values },
|
||||
actor,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
message: 'Product created with AI. Add images and variations next.',
|
||||
product: created.product,
|
||||
};
|
||||
}
|
||||
|
||||
private async generateDraft(input: {
|
||||
categoryName: string;
|
||||
productName: string;
|
||||
language: ProductAiLanguage;
|
||||
fields: TechnicalFormField[];
|
||||
}): Promise<AiProductDraft> {
|
||||
const provider = resolveAiProvider(this.config);
|
||||
|
||||
const technicalSchema = input.fields.map((field) => ({
|
||||
key: field.key,
|
||||
label: field.label,
|
||||
type: field.type,
|
||||
required: field.isRequired,
|
||||
options:
|
||||
field.type === 'select' || field.type === 'multi_select'
|
||||
? field.options.map((option) => option.value)
|
||||
: undefined,
|
||||
}));
|
||||
|
||||
const inputLanguage =
|
||||
input.language === ProductAiLanguage.fa ? 'Persian (Farsi)' : 'English';
|
||||
|
||||
const systemPrompt = `You generate e-commerce product drafts for an Iranian marketplace.
|
||||
Return ONLY valid JSON with this shape:
|
||||
{
|
||||
"title": "English product title",
|
||||
"nameFa": "Persian product name",
|
||||
"summary": "short listing summary",
|
||||
"descriptionHtml": "HTML description using <p> and <ul><li> only",
|
||||
"tags": ["tag1", "tag2"],
|
||||
"technicalValues": { "fieldKey": "value" }
|
||||
}
|
||||
|
||||
Rules:
|
||||
- Be factual and realistic for the named product in the given category.
|
||||
- If technical form fields are provided, fill technicalValues using exact field keys.
|
||||
- For select fields use one allowed option value exactly.
|
||||
- For multi_select fields use an array of allowed option values.
|
||||
- For text/textarea fields use plain strings.
|
||||
- The user input name is in ${inputLanguage}; keep that spelling in the matching field and translate/generate the other language field.
|
||||
- descriptionHtml should be 2-4 paragraphs with key specs.
|
||||
- tags: 3-8 relevant lowercase tags.`;
|
||||
|
||||
const userPrompt = JSON.stringify({
|
||||
category: input.categoryName,
|
||||
inputName: input.productName,
|
||||
inputLanguage: input.language,
|
||||
technicalFields: technicalSchema,
|
||||
});
|
||||
|
||||
let content: string;
|
||||
try {
|
||||
content = await requestAiJsonCompletion(provider, systemPrompt, userPrompt);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'AI request failed';
|
||||
throw new BadRequestException(message);
|
||||
}
|
||||
|
||||
let parsed: AiProductDraft;
|
||||
try {
|
||||
parsed = JSON.parse(content) as AiProductDraft;
|
||||
} catch {
|
||||
throw new BadRequestException('AI returned invalid JSON');
|
||||
}
|
||||
|
||||
return this.normalizeDraft(parsed, input);
|
||||
}
|
||||
|
||||
private normalizeDraft(
|
||||
draft: AiProductDraft,
|
||||
input: {
|
||||
productName: string;
|
||||
language: ProductAiLanguage;
|
||||
fields: TechnicalFormField[];
|
||||
},
|
||||
): AiProductDraft {
|
||||
const title =
|
||||
input.language === ProductAiLanguage.en
|
||||
? input.productName
|
||||
: String(draft.title ?? input.productName).trim();
|
||||
|
||||
const nameFa =
|
||||
input.language === ProductAiLanguage.fa
|
||||
? input.productName
|
||||
: String(draft.nameFa ?? '').trim();
|
||||
|
||||
const summary = String(draft.summary ?? '').trim();
|
||||
const descriptionHtml = String(draft.descriptionHtml ?? '').trim();
|
||||
const tags = Array.isArray(draft.tags)
|
||||
? draft.tags.map((tag) => String(tag).trim()).filter(Boolean).slice(0, 12)
|
||||
: [];
|
||||
|
||||
if (!title || title.length < 2) {
|
||||
throw new BadRequestException('AI draft is missing a valid English title');
|
||||
}
|
||||
|
||||
if (!nameFa) {
|
||||
throw new BadRequestException('AI draft is missing a Persian product name');
|
||||
}
|
||||
|
||||
const technicalValues: Record<string, string | string[]> = {};
|
||||
const rawTechnical = draft.technicalValues ?? {};
|
||||
|
||||
for (const field of input.fields) {
|
||||
const raw = rawTechnical[field.key];
|
||||
if (raw === undefined || raw === null || raw === '') continue;
|
||||
|
||||
if (field.type === 'multi_select') {
|
||||
const values = Array.isArray(raw) ? raw : [String(raw)];
|
||||
const allowed = new Set(field.options.map((option) => option.value));
|
||||
const filtered = values
|
||||
.map((value) => String(value).trim())
|
||||
.filter((value) => allowed.has(value));
|
||||
if (filtered.length > 0) {
|
||||
technicalValues[field.key] = filtered;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (field.type === 'select') {
|
||||
const value = String(raw).trim();
|
||||
const allowed = field.options.some((option) => option.value === value);
|
||||
if (allowed) {
|
||||
technicalValues[field.key] = value;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
technicalValues[field.key] = String(raw).trim();
|
||||
}
|
||||
|
||||
return {
|
||||
title,
|
||||
nameFa,
|
||||
summary: summary || `${title} — product details generated by AI.`,
|
||||
descriptionHtml:
|
||||
descriptionHtml ||
|
||||
`<p>${summary || `${title} product description.`}</p>`,
|
||||
tags,
|
||||
technicalValues,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,394 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { ContentStatus, MediaEntityType, TechnicalFieldType } from '@prisma/client';
|
||||
import { AuthUser } from '../auth/auth.types';
|
||||
import { PermissionsService } from '../auth/permissions.service';
|
||||
import { CategoryTechnicalFormService } from '../categories/category-technical-form.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { ReplaceProductTechnicalInfoDto } from './dto/product-technical-info.dto';
|
||||
|
||||
type FormField = {
|
||||
id: string;
|
||||
label: string;
|
||||
key: string;
|
||||
type: TechnicalFieldType;
|
||||
isRequired: boolean;
|
||||
sortOrder: number;
|
||||
options: { id: string; label: string; value: string; sortOrder: number }[];
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class ProductTechnicalInfoService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly permissions: PermissionsService,
|
||||
private readonly technicalFormService: CategoryTechnicalFormService,
|
||||
) {}
|
||||
|
||||
async getForProduct(
|
||||
businessIdRaw: string,
|
||||
productIdRaw: string,
|
||||
actor: AuthUser,
|
||||
) {
|
||||
const businessId = BigInt(businessIdRaw);
|
||||
const productId = BigInt(productIdRaw);
|
||||
await this.assertPermission(businessId, actor.id, 'products.read');
|
||||
|
||||
return this.buildTechnicalInfoForProduct(businessId, productId);
|
||||
}
|
||||
|
||||
async getPublicForProduct(businessIdRaw: string, productIdRaw: string) {
|
||||
const businessId = BigInt(businessIdRaw);
|
||||
const productId = BigInt(productIdRaw);
|
||||
|
||||
await this.assertPublishedProductExists(businessId, productId);
|
||||
|
||||
return this.buildTechnicalInfoForProduct(businessId, productId);
|
||||
}
|
||||
|
||||
private async buildTechnicalInfoForProduct(
|
||||
businessId: bigint,
|
||||
productId: bigint,
|
||||
) {
|
||||
const { product, categoryId } = await this.getProductWithCategory(
|
||||
businessId,
|
||||
productId,
|
||||
);
|
||||
|
||||
if (!categoryId) {
|
||||
return {
|
||||
form: null,
|
||||
values: [],
|
||||
message: 'Product has no category assigned',
|
||||
};
|
||||
}
|
||||
|
||||
const form = await this.technicalFormService.getFormForCategory(
|
||||
businessId,
|
||||
categoryId,
|
||||
);
|
||||
|
||||
if (!form) {
|
||||
return { form: null, values: [] };
|
||||
}
|
||||
|
||||
const values = await this.loadProductValues(
|
||||
businessId,
|
||||
product.id,
|
||||
form.fields,
|
||||
);
|
||||
|
||||
return { form, values };
|
||||
}
|
||||
|
||||
async replaceForProduct(
|
||||
businessIdRaw: string,
|
||||
productIdRaw: string,
|
||||
dto: ReplaceProductTechnicalInfoDto,
|
||||
actor: AuthUser,
|
||||
) {
|
||||
const businessId = BigInt(businessIdRaw);
|
||||
const productId = BigInt(productIdRaw);
|
||||
await this.assertPermission(businessId, actor.id, 'products.update');
|
||||
|
||||
const { product, categoryId } = await this.getProductWithCategory(
|
||||
businessId,
|
||||
productId,
|
||||
);
|
||||
|
||||
if (!categoryId) {
|
||||
throw new BadRequestException(
|
||||
'Product must be assigned to a category before filling technical info',
|
||||
);
|
||||
}
|
||||
|
||||
const form = await this.technicalFormService.getFormForCategory(
|
||||
businessId,
|
||||
categoryId,
|
||||
);
|
||||
|
||||
if (!form) {
|
||||
throw new BadRequestException(
|
||||
'No technical form is defined for this product category',
|
||||
);
|
||||
}
|
||||
|
||||
const fieldMap = new Map(form.fields.map((field) => [field.key, field]));
|
||||
const submittedKeys = new Set<string>();
|
||||
|
||||
for (const entry of dto.values) {
|
||||
if (submittedKeys.has(entry.fieldKey)) {
|
||||
throw new BadRequestException(
|
||||
`Duplicate value for field "${entry.fieldKey}"`,
|
||||
);
|
||||
}
|
||||
submittedKeys.add(entry.fieldKey);
|
||||
|
||||
const field = fieldMap.get(entry.fieldKey);
|
||||
if (!field) {
|
||||
throw new BadRequestException(
|
||||
`Unknown field key "${entry.fieldKey}"`,
|
||||
);
|
||||
}
|
||||
|
||||
this.validateFieldValue(field, entry.value);
|
||||
}
|
||||
|
||||
for (const field of form.fields) {
|
||||
if (field.isRequired && !submittedKeys.has(field.key)) {
|
||||
throw new BadRequestException(
|
||||
`Required field "${field.label}" is missing`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
await tx.productTechnicalFieldValue.deleteMany({
|
||||
where: { businessId, productId: product.id },
|
||||
});
|
||||
|
||||
for (const entry of dto.values) {
|
||||
const field = fieldMap.get(entry.fieldKey)!;
|
||||
const fieldId = BigInt(field.id);
|
||||
|
||||
if (entry.value === null || entry.value === undefined || entry.value === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (field.type === 'text' || field.type === 'textarea') {
|
||||
await tx.productTechnicalFieldValue.create({
|
||||
data: {
|
||||
businessId,
|
||||
productId: product.id,
|
||||
fieldId,
|
||||
textValue: String(entry.value),
|
||||
},
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (field.type === 'select') {
|
||||
const option = this.findOptionByValue(field, entry.value as string);
|
||||
await tx.productTechnicalFieldValue.create({
|
||||
data: {
|
||||
businessId,
|
||||
productId: product.id,
|
||||
fieldId,
|
||||
optionId: BigInt(option.id),
|
||||
},
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const selectedValues = entry.value as string[];
|
||||
const fieldValue = await tx.productTechnicalFieldValue.create({
|
||||
data: {
|
||||
businessId,
|
||||
productId: product.id,
|
||||
fieldId,
|
||||
},
|
||||
});
|
||||
|
||||
for (const rawValue of selectedValues) {
|
||||
const option = this.findOptionByValue(field, rawValue);
|
||||
await tx.productTechnicalFieldValueOption.create({
|
||||
data: {
|
||||
fieldValueId: fieldValue.id,
|
||||
optionId: BigInt(option.id),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return this.getForProduct(businessIdRaw, productIdRaw, actor);
|
||||
}
|
||||
|
||||
private validateFieldValue(
|
||||
field: FormField,
|
||||
value: string | string[] | null | undefined,
|
||||
) {
|
||||
if (value === null || value === undefined || value === '') {
|
||||
if (field.isRequired) {
|
||||
throw new BadRequestException(
|
||||
`Required field "${field.label}" cannot be empty`,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (field.type === 'text' || field.type === 'textarea') {
|
||||
if (typeof value !== 'string') {
|
||||
throw new BadRequestException(
|
||||
`Field "${field.label}" expects a text value`,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (field.type === 'select') {
|
||||
if (typeof value !== 'string') {
|
||||
throw new BadRequestException(
|
||||
`Field "${field.label}" expects a single option value`,
|
||||
);
|
||||
}
|
||||
this.findOptionByValue(field, value);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Array.isArray(value) || !value.length) {
|
||||
throw new BadRequestException(
|
||||
`Field "${field.label}" expects an array of option values`,
|
||||
);
|
||||
}
|
||||
|
||||
for (const item of value) {
|
||||
if (typeof item !== 'string') {
|
||||
throw new BadRequestException(
|
||||
`Field "${field.label}" expects string option values`,
|
||||
);
|
||||
}
|
||||
this.findOptionByValue(field, item);
|
||||
}
|
||||
}
|
||||
|
||||
private findOptionByValue(field: FormField, rawValue: string) {
|
||||
const normalized = rawValue.trim().toLowerCase();
|
||||
const option =
|
||||
field.options.find((item) => item.value === rawValue) ??
|
||||
field.options.find((item) => item.value.toLowerCase() === normalized) ??
|
||||
field.options.find((item) => item.label.toLowerCase() === normalized);
|
||||
|
||||
if (!option) {
|
||||
throw new BadRequestException(
|
||||
`Invalid option "${rawValue}" for field "${field.label}"`,
|
||||
);
|
||||
}
|
||||
|
||||
return option;
|
||||
}
|
||||
|
||||
private async loadProductValues(
|
||||
businessId: bigint,
|
||||
productId: bigint,
|
||||
fields: FormField[],
|
||||
) {
|
||||
const fieldIds = fields.map((field) => BigInt(field.id));
|
||||
|
||||
const stored = await this.prisma.productTechnicalFieldValue.findMany({
|
||||
where: {
|
||||
businessId,
|
||||
productId,
|
||||
fieldId: { in: fieldIds },
|
||||
},
|
||||
include: {
|
||||
option: true,
|
||||
selectedOptions: { include: { option: true } },
|
||||
field: true,
|
||||
},
|
||||
});
|
||||
|
||||
const byFieldId = new Map(
|
||||
stored.map((item) => [item.fieldId.toString(), item]),
|
||||
);
|
||||
|
||||
return fields.map((field) => {
|
||||
const record = byFieldId.get(field.id);
|
||||
if (!record) {
|
||||
return {
|
||||
fieldKey: field.key,
|
||||
fieldLabel: field.label,
|
||||
type: field.type,
|
||||
value: field.type === 'multi_select' ? [] : null,
|
||||
};
|
||||
}
|
||||
|
||||
if (field.type === 'text' || field.type === 'textarea') {
|
||||
return {
|
||||
fieldKey: field.key,
|
||||
fieldLabel: field.label,
|
||||
type: field.type,
|
||||
value: record.textValue,
|
||||
};
|
||||
}
|
||||
|
||||
if (field.type === 'select') {
|
||||
return {
|
||||
fieldKey: field.key,
|
||||
fieldLabel: field.label,
|
||||
type: field.type,
|
||||
value: record.option?.value ?? null,
|
||||
optionLabel: record.option?.label ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
fieldKey: field.key,
|
||||
fieldLabel: field.label,
|
||||
type: field.type,
|
||||
value: record.selectedOptions.map((item) => item.option.value),
|
||||
optionLabels: record.selectedOptions.map((item) => item.option.label),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private async getProductWithCategory(businessId: bigint, productId: bigint) {
|
||||
const product = await this.prisma.product.findFirst({
|
||||
where: { id: productId, businessId },
|
||||
});
|
||||
|
||||
if (!product) {
|
||||
throw new NotFoundException('Product not found');
|
||||
}
|
||||
|
||||
const assignment = await this.prisma.categoryAssignment.findFirst({
|
||||
where: {
|
||||
businessId,
|
||||
entityType: MediaEntityType.product,
|
||||
entityId: productId,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
product,
|
||||
categoryId: assignment?.categoryId ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
private async assertPublishedProductExists(businessId: bigint, productId: bigint) {
|
||||
const product = await this.prisma.product.findFirst({
|
||||
where: {
|
||||
id: productId,
|
||||
businessId,
|
||||
status: ContentStatus.published,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (!product) {
|
||||
throw new NotFoundException('Product not found');
|
||||
}
|
||||
}
|
||||
|
||||
private async assertPermission(
|
||||
businessId: bigint,
|
||||
userId: bigint,
|
||||
permission: string,
|
||||
) {
|
||||
const allowed = await this.permissions.hasBusinessPermission(
|
||||
userId,
|
||||
businessId,
|
||||
permission,
|
||||
);
|
||||
|
||||
if (!allowed) {
|
||||
throw new ForbiddenException(
|
||||
`Missing permission: ${permission} for this business`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { ContentStatus, MediaEntityType } from '@prisma/client';
|
||||
import { AuthUser } from '../auth/auth.types';
|
||||
import { PermissionsService } from '../auth/permissions.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { ReplaceProductVariationValuesDto } from './dto/product-variation-values.dto';
|
||||
|
||||
@Injectable()
|
||||
export class ProductVariationValuesService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly permissions: PermissionsService,
|
||||
) {}
|
||||
|
||||
async getForProduct(
|
||||
businessIdRaw: string,
|
||||
productIdRaw: string,
|
||||
actor: AuthUser,
|
||||
) {
|
||||
const businessId = BigInt(businessIdRaw);
|
||||
const productId = BigInt(productIdRaw);
|
||||
await this.assertPermission(businessId, actor.id, 'products.read');
|
||||
|
||||
return this.buildVariationValuesForProduct(businessId, productId);
|
||||
}
|
||||
|
||||
async getPublicForProduct(businessIdRaw: string, productIdRaw: string) {
|
||||
const businessId = BigInt(businessIdRaw);
|
||||
const productId = BigInt(productIdRaw);
|
||||
|
||||
await this.assertPublishedProductExists(businessId, productId);
|
||||
|
||||
return this.buildVariationValuesForProduct(businessId, productId);
|
||||
}
|
||||
|
||||
private async buildVariationValuesForProduct(
|
||||
businessId: bigint,
|
||||
productId: bigint,
|
||||
) {
|
||||
const { categoryId } = await this.getProductWithCategory(businessId, productId);
|
||||
|
||||
if (!categoryId) {
|
||||
return {
|
||||
variations: [],
|
||||
message: 'Product has no category assigned',
|
||||
};
|
||||
}
|
||||
|
||||
const [categoryVariations, selectedValues] = await Promise.all([
|
||||
this.prisma.categoryVariation.findMany({
|
||||
where: { businessId, categoryId },
|
||||
orderBy: [{ sortOrder: 'asc' }, { createdAt: 'asc' }],
|
||||
include: {
|
||||
options: { orderBy: [{ sortOrder: 'asc' }, { createdAt: 'asc' }] },
|
||||
},
|
||||
}),
|
||||
this.prisma.productVariationValue.findMany({
|
||||
where: { productId },
|
||||
}),
|
||||
]);
|
||||
|
||||
const selectedByVariation = new Map<string, string[]>();
|
||||
for (const item of selectedValues) {
|
||||
const key = item.variationId.toString();
|
||||
const list = selectedByVariation.get(key) ?? [];
|
||||
list.push(item.optionId.toString());
|
||||
selectedByVariation.set(key, list);
|
||||
}
|
||||
|
||||
return {
|
||||
variations: categoryVariations.map((variation) => ({
|
||||
id: variation.id.toString(),
|
||||
name: variation.name,
|
||||
type: variation.variationType,
|
||||
sortOrder: variation.sortOrder,
|
||||
options: variation.options.map((option) => ({
|
||||
id: option.id.toString(),
|
||||
label: option.label,
|
||||
value: option.value,
|
||||
colorHex: option.colorHex,
|
||||
sortOrder: option.sortOrder,
|
||||
})),
|
||||
selectedOptionIds: selectedByVariation.get(variation.id.toString()) ?? [],
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
async replaceForProduct(
|
||||
businessIdRaw: string,
|
||||
productIdRaw: string,
|
||||
dto: ReplaceProductVariationValuesDto,
|
||||
actor: AuthUser,
|
||||
) {
|
||||
const businessId = BigInt(businessIdRaw);
|
||||
const productId = BigInt(productIdRaw);
|
||||
await this.assertPermission(businessId, actor.id, 'products.update');
|
||||
|
||||
const { categoryId } = await this.getProductWithCategory(businessId, productId);
|
||||
|
||||
if (!categoryId) {
|
||||
throw new BadRequestException(
|
||||
'Product must be assigned to a category before managing variations',
|
||||
);
|
||||
}
|
||||
|
||||
const categoryVariations = await this.prisma.categoryVariation.findMany({
|
||||
where: { businessId, categoryId },
|
||||
include: { options: true },
|
||||
});
|
||||
|
||||
const variationMap = new Map(
|
||||
categoryVariations.map((variation) => [variation.id.toString(), variation]),
|
||||
);
|
||||
|
||||
const optionToVariation = new Map<string, string>();
|
||||
for (const variation of categoryVariations) {
|
||||
for (const option of variation.options) {
|
||||
optionToVariation.set(option.id.toString(), variation.id.toString());
|
||||
}
|
||||
}
|
||||
|
||||
const seenOptionIds = new Set<string>();
|
||||
const rows: { productId: bigint; variationId: bigint; optionId: bigint }[] = [];
|
||||
|
||||
for (const selection of dto.selections) {
|
||||
const variation = variationMap.get(selection.variationId);
|
||||
if (!variation) {
|
||||
throw new BadRequestException(
|
||||
`Variation "${selection.variationId}" is not defined for this product category`,
|
||||
);
|
||||
}
|
||||
|
||||
const validOptionIds = new Set(
|
||||
variation.options.map((option) => option.id.toString()),
|
||||
);
|
||||
|
||||
for (const optionId of selection.optionIds) {
|
||||
if (seenOptionIds.has(optionId)) {
|
||||
throw new BadRequestException(`Duplicate option "${optionId}" in request`);
|
||||
}
|
||||
seenOptionIds.add(optionId);
|
||||
|
||||
if (!validOptionIds.has(optionId)) {
|
||||
throw new BadRequestException(
|
||||
`Option "${optionId}" does not belong to variation "${variation.name}"`,
|
||||
);
|
||||
}
|
||||
|
||||
const ownerVariationId = optionToVariation.get(optionId);
|
||||
if (ownerVariationId !== selection.variationId) {
|
||||
throw new BadRequestException(
|
||||
`Option "${optionId}" does not belong to variation "${variation.name}"`,
|
||||
);
|
||||
}
|
||||
|
||||
rows.push({
|
||||
productId,
|
||||
variationId: variation.id,
|
||||
optionId: BigInt(optionId),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
await tx.productVariationValue.deleteMany({ where: { productId } });
|
||||
|
||||
if (rows.length > 0) {
|
||||
await tx.productVariationValue.createMany({ data: rows });
|
||||
}
|
||||
});
|
||||
|
||||
return this.getForProduct(businessIdRaw, productIdRaw, actor);
|
||||
}
|
||||
|
||||
async countForProducts(businessId: bigint, productIds: bigint[]) {
|
||||
if (productIds.length === 0) {
|
||||
return new Map<string, number>();
|
||||
}
|
||||
|
||||
const rows = await this.prisma.productVariationValue.groupBy({
|
||||
by: ['productId'],
|
||||
where: { productId: { in: productIds } },
|
||||
_count: { optionId: true },
|
||||
});
|
||||
|
||||
return new Map(
|
||||
rows.map((row) => [row.productId.toString(), row._count.optionId]),
|
||||
);
|
||||
}
|
||||
|
||||
private async getProductWithCategory(businessId: bigint, productId: bigint) {
|
||||
const product = await this.prisma.product.findFirst({
|
||||
where: { id: productId, businessId },
|
||||
});
|
||||
|
||||
if (!product) {
|
||||
throw new NotFoundException('Product not found');
|
||||
}
|
||||
|
||||
const assignment = await this.prisma.categoryAssignment.findFirst({
|
||||
where: {
|
||||
businessId,
|
||||
entityType: MediaEntityType.product,
|
||||
entityId: productId,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
product,
|
||||
categoryId: assignment?.categoryId ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
private async assertPublishedProductExists(businessId: bigint, productId: bigint) {
|
||||
const product = await this.prisma.product.findFirst({
|
||||
where: {
|
||||
id: productId,
|
||||
businessId,
|
||||
status: ContentStatus.published,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (!product) {
|
||||
throw new NotFoundException('Product not found');
|
||||
}
|
||||
}
|
||||
|
||||
private async assertPermission(
|
||||
businessId: bigint,
|
||||
userId: bigint,
|
||||
permission: string,
|
||||
) {
|
||||
const allowed = await this.permissions.hasBusinessPermission(
|
||||
userId,
|
||||
businessId,
|
||||
permission,
|
||||
);
|
||||
|
||||
if (!allowed) {
|
||||
throw new ForbiddenException(
|
||||
`Missing permission: ${permission} for this business`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Put,
|
||||
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 { CreateProductByAiDto } from './dto/product-ai.dto';
|
||||
import { CreateProductDto, ListProductsDto, ListPublicProductsDto, UpdateProductDto } from './dto/product.dto';
|
||||
import { ReplaceProductVariationValuesDto } from './dto/product-variation-values.dto';
|
||||
import { ReplaceProductTechnicalInfoDto } from './dto/product-technical-info.dto';
|
||||
import { ProductAiService } from './product-ai.service';
|
||||
import { ProductTechnicalInfoService } from './product-technical-info.service';
|
||||
import { ProductVariationValuesService } from './product-variation-values.service';
|
||||
import { ProductsService } from './products.service';
|
||||
|
||||
@Controller('businesses/:businessId/products')
|
||||
@UseGuards(JwtAuthGuard, BusinessPermissionGuard)
|
||||
export class ProductsController {
|
||||
constructor(
|
||||
private readonly service: ProductsService,
|
||||
private readonly aiService: ProductAiService,
|
||||
private readonly variationValuesService: ProductVariationValuesService,
|
||||
private readonly technicalInfoService: ProductTechnicalInfoService,
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
@RequireBusinessPermission('products.read')
|
||||
list(
|
||||
@Param('businessId') businessId: string,
|
||||
@Query() query: ListProductsDto,
|
||||
@CurrentUser() user: AuthUser,
|
||||
) {
|
||||
return this.service.list(businessId, query, user);
|
||||
}
|
||||
|
||||
@Post('ai-create')
|
||||
@RequireBusinessPermission('products.create')
|
||||
createByAi(
|
||||
@Param('businessId') businessId: string,
|
||||
@Body() dto: CreateProductByAiDto,
|
||||
@CurrentUser() user: AuthUser,
|
||||
) {
|
||||
return this.aiService.createFromAi(businessId, dto, user);
|
||||
}
|
||||
|
||||
@Get(':productId')
|
||||
@RequireBusinessPermission('products.read')
|
||||
getOne(
|
||||
@Param('businessId') businessId: string,
|
||||
@Param('productId') productId: string,
|
||||
@CurrentUser() user: AuthUser,
|
||||
) {
|
||||
return this.service.getOne(businessId, productId, user);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@RequireBusinessPermission('products.create')
|
||||
create(
|
||||
@Param('businessId') businessId: string,
|
||||
@Body() dto: CreateProductDto,
|
||||
@CurrentUser() user: AuthUser,
|
||||
) {
|
||||
return this.service.create(businessId, dto, user);
|
||||
}
|
||||
|
||||
@Patch(':productId')
|
||||
@RequireBusinessPermission('products.update')
|
||||
update(
|
||||
@Param('businessId') businessId: string,
|
||||
@Param('productId') productId: string,
|
||||
@Body() dto: UpdateProductDto,
|
||||
@CurrentUser() user: AuthUser,
|
||||
) {
|
||||
return this.service.update(businessId, productId, dto, user);
|
||||
}
|
||||
|
||||
@Delete(':productId')
|
||||
@RequireBusinessPermission('products.delete')
|
||||
remove(
|
||||
@Param('businessId') businessId: string,
|
||||
@Param('productId') productId: string,
|
||||
@CurrentUser() user: AuthUser,
|
||||
) {
|
||||
return this.service.remove(businessId, productId, user);
|
||||
}
|
||||
|
||||
@Get(':productId/variations')
|
||||
@RequireBusinessPermission('products.read')
|
||||
getVariationValues(
|
||||
@Param('businessId') businessId: string,
|
||||
@Param('productId') productId: string,
|
||||
@CurrentUser() user: AuthUser,
|
||||
) {
|
||||
return this.variationValuesService.getForProduct(businessId, productId, user);
|
||||
}
|
||||
|
||||
@Put(':productId/variations')
|
||||
@RequireBusinessPermission('products.update')
|
||||
replaceVariationValues(
|
||||
@Param('businessId') businessId: string,
|
||||
@Param('productId') productId: string,
|
||||
@Body() dto: ReplaceProductVariationValuesDto,
|
||||
@CurrentUser() user: AuthUser,
|
||||
) {
|
||||
return this.variationValuesService.replaceForProduct(
|
||||
businessId,
|
||||
productId,
|
||||
dto,
|
||||
user,
|
||||
);
|
||||
}
|
||||
|
||||
@Get(':productId/technical-info')
|
||||
@RequireBusinessPermission('products.read')
|
||||
getTechnicalInfo(
|
||||
@Param('businessId') businessId: string,
|
||||
@Param('productId') productId: string,
|
||||
@CurrentUser() user: AuthUser,
|
||||
) {
|
||||
return this.technicalInfoService.getForProduct(businessId, productId, user);
|
||||
}
|
||||
|
||||
@Put(':productId/technical-info')
|
||||
@RequireBusinessPermission('products.update')
|
||||
replaceTechnicalInfo(
|
||||
@Param('businessId') businessId: string,
|
||||
@Param('productId') productId: string,
|
||||
@Body() dto: ReplaceProductTechnicalInfoDto,
|
||||
@CurrentUser() user: AuthUser,
|
||||
) {
|
||||
return this.technicalInfoService.replaceForProduct(
|
||||
businessId,
|
||||
productId,
|
||||
dto,
|
||||
user,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@Controller('tenants/:host/products')
|
||||
export class PublicProductsController {
|
||||
constructor(
|
||||
private readonly service: ProductsService,
|
||||
private readonly variationValuesService: ProductVariationValuesService,
|
||||
private readonly technicalInfoService: ProductTechnicalInfoService,
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
list(@Param('host') host: string, @Query() query: ListPublicProductsDto) {
|
||||
return this.service.listPublic(host, query);
|
||||
}
|
||||
|
||||
@Get(':slug/variations')
|
||||
async getVariations(@Param('host') host: string, @Param('slug') slug: string) {
|
||||
const { businessId, productId } = await this.service.assertPublishedProductBySlug(
|
||||
host,
|
||||
slug,
|
||||
);
|
||||
return this.variationValuesService.getPublicForProduct(
|
||||
businessId.toString(),
|
||||
productId.toString(),
|
||||
);
|
||||
}
|
||||
|
||||
@Get(':slug/technical-info')
|
||||
async getTechnicalInfo(@Param('host') host: string, @Param('slug') slug: string) {
|
||||
const { businessId, productId } = await this.service.assertPublishedProductBySlug(
|
||||
host,
|
||||
slug,
|
||||
);
|
||||
return this.technicalInfoService.getPublicForProduct(
|
||||
businessId.toString(),
|
||||
productId.toString(),
|
||||
);
|
||||
}
|
||||
|
||||
@Get(':slug')
|
||||
getBySlug(@Param('host') host: string, @Param('slug') slug: string) {
|
||||
return this.service.getPublicBySlug(host, slug);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { BrandsModule } from '../brands/brands.module';
|
||||
import { CategoriesModule } from '../categories/categories.module';
|
||||
import { TenantModule } from '../tenant/tenant.module';
|
||||
import { ProductAiService } from './product-ai.service';
|
||||
import { ProductTechnicalInfoService } from './product-technical-info.service';
|
||||
import { ProductVariationValuesService } from './product-variation-values.service';
|
||||
import { ProductsController, PublicProductsController } from './products.controller';
|
||||
import { ProductsService } from './products.service';
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule, BrandsModule, CategoriesModule, TenantModule],
|
||||
controllers: [ProductsController, PublicProductsController],
|
||||
providers: [
|
||||
ProductsService,
|
||||
ProductAiService,
|
||||
ProductTechnicalInfoService,
|
||||
ProductVariationValuesService,
|
||||
],
|
||||
exports: [ProductVariationValuesService],
|
||||
})
|
||||
export class ProductsModule {}
|
||||
@@ -0,0 +1,810 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { ContentStatus, MediaEntityType, Prisma } from '@prisma/client';
|
||||
import { AuthUser } from '../auth/auth.types';
|
||||
import { PermissionsService } from '../auth/permissions.service';
|
||||
import { BrandsService } from '../brands/brands.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { TenantService } from '../tenant/tenant.service';
|
||||
import { CreateProductDto, ListProductsDto, ListPublicProductsDto, UpdateProductDto } from './dto/product.dto';
|
||||
import { ProductVariationValuesService } from './product-variation-values.service';
|
||||
|
||||
function slugify(value: string): string {
|
||||
return (
|
||||
value
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '') || 'product'
|
||||
);
|
||||
}
|
||||
|
||||
type ProductWithRelations = Prisma.ProductGetPayload<{
|
||||
include: {
|
||||
featuredMedia: true;
|
||||
brand: { include: { imageMedia: true } };
|
||||
};
|
||||
}>;
|
||||
|
||||
@Injectable()
|
||||
export class ProductsService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly permissions: PermissionsService,
|
||||
private readonly productVariationValues: ProductVariationValuesService,
|
||||
private readonly brands: BrandsService,
|
||||
private readonly tenant: TenantService,
|
||||
) {}
|
||||
|
||||
async list(businessIdRaw: string, query: ListProductsDto, actor: AuthUser) {
|
||||
const businessId = BigInt(businessIdRaw);
|
||||
await this.assertPermission(businessId, actor.id, 'products.read');
|
||||
|
||||
const page = query.page ?? 1;
|
||||
const pageSize = query.pageSize ?? 12;
|
||||
const skip = (page - 1) * pageSize;
|
||||
|
||||
const where: Prisma.ProductWhereInput = {
|
||||
businessId,
|
||||
...(query.status ? { status: query.status } : {}),
|
||||
...(query.name?.trim()
|
||||
? {
|
||||
OR: [
|
||||
{ title: { contains: query.name.trim(), mode: 'insensitive' } },
|
||||
{
|
||||
content: {
|
||||
path: ['nameFa'],
|
||||
string_contains: query.name.trim(),
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.product.findMany({
|
||||
where,
|
||||
orderBy: [{ sortOrder: 'asc' }, { createdAt: 'desc' }],
|
||||
skip,
|
||||
take: pageSize,
|
||||
include: {
|
||||
featuredMedia: true,
|
||||
brand: { include: { imageMedia: true } },
|
||||
},
|
||||
}),
|
||||
this.prisma.product.count({ where }),
|
||||
]);
|
||||
|
||||
const variantCounts = await this.productVariationValues.countForProducts(
|
||||
businessId,
|
||||
items.map((item) => item.id),
|
||||
);
|
||||
|
||||
const serialized = await Promise.all(
|
||||
items.map((item) =>
|
||||
this.serializeProduct(item, variantCounts.get(item.id.toString()) ?? 0),
|
||||
),
|
||||
);
|
||||
|
||||
return { items: serialized, total, page, pageSize };
|
||||
}
|
||||
|
||||
async getOne(businessIdRaw: string, productIdRaw: string, actor: AuthUser) {
|
||||
const businessId = BigInt(businessIdRaw);
|
||||
const productId = BigInt(productIdRaw);
|
||||
await this.assertPermission(businessId, actor.id, 'products.read');
|
||||
|
||||
const product = await this.prisma.product.findFirst({
|
||||
where: { id: productId, businessId },
|
||||
include: {
|
||||
featuredMedia: true,
|
||||
brand: { include: { imageMedia: true } },
|
||||
},
|
||||
});
|
||||
|
||||
if (!product) {
|
||||
throw new NotFoundException('Product not found');
|
||||
}
|
||||
|
||||
return { product: await this.serializeProduct(product, undefined) };
|
||||
}
|
||||
|
||||
async listPublic(host: string, query: ListPublicProductsDto) {
|
||||
const business = await this.tenant.resolveBusinessByDomain(host);
|
||||
const businessId = business.id;
|
||||
|
||||
const page = query.page ?? 1;
|
||||
const pageSize = query.pageSize ?? 12;
|
||||
const skip = (page - 1) * pageSize;
|
||||
const where = await this.buildPublicWhere(businessId, query);
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.product.findMany({
|
||||
where,
|
||||
orderBy: [{ sortOrder: 'asc' }, { publishedAt: 'desc' }, { createdAt: 'desc' }],
|
||||
skip,
|
||||
take: pageSize,
|
||||
include: {
|
||||
featuredMedia: true,
|
||||
brand: { include: { imageMedia: true } },
|
||||
},
|
||||
}),
|
||||
this.prisma.product.count({ where }),
|
||||
]);
|
||||
|
||||
const storeSummaries = await this.loadStoreSummariesForProducts(
|
||||
businessId,
|
||||
items.map((item) => item.id),
|
||||
);
|
||||
|
||||
const serialized = await Promise.all(
|
||||
items.map(async (item) => {
|
||||
const product = await this.serializeProduct(item, undefined, {
|
||||
approvedCommentsOnly: true,
|
||||
});
|
||||
const store = storeSummaries.get(item.id.toString());
|
||||
return {
|
||||
...product,
|
||||
store: store ?? {
|
||||
variantCount: 0,
|
||||
minPrice: null,
|
||||
maxPrice: null,
|
||||
inStock: false,
|
||||
},
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
return { items: serialized, total, page, pageSize };
|
||||
}
|
||||
|
||||
async getPublicBySlug(host: string, slug: string) {
|
||||
const business = await this.tenant.resolveBusinessByDomain(host);
|
||||
const businessId = business.id;
|
||||
|
||||
const product = await this.prisma.product.findFirst({
|
||||
where: {
|
||||
businessId,
|
||||
slug,
|
||||
status: ContentStatus.published,
|
||||
},
|
||||
include: {
|
||||
featuredMedia: true,
|
||||
brand: { include: { imageMedia: true } },
|
||||
},
|
||||
});
|
||||
|
||||
if (!product) {
|
||||
throw new NotFoundException('Product not found');
|
||||
}
|
||||
|
||||
const storeSummaries = await this.loadStoreSummariesForProducts(businessId, [product.id]);
|
||||
const serialized = await this.serializeProduct(product, undefined, {
|
||||
approvedCommentsOnly: true,
|
||||
});
|
||||
|
||||
return {
|
||||
product: {
|
||||
...serialized,
|
||||
store: storeSummaries.get(product.id.toString()) ?? {
|
||||
variantCount: 0,
|
||||
minPrice: null,
|
||||
maxPrice: null,
|
||||
inStock: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async assertPublishedProductBySlug(host: string, slug: string) {
|
||||
const business = await this.tenant.resolveBusinessByDomain(host);
|
||||
const product = await this.prisma.product.findFirst({
|
||||
where: {
|
||||
businessId: business.id,
|
||||
slug,
|
||||
status: ContentStatus.published,
|
||||
},
|
||||
select: { id: true, businessId: true },
|
||||
});
|
||||
|
||||
if (!product) {
|
||||
throw new NotFoundException('Product not found');
|
||||
}
|
||||
|
||||
return { businessId: product.businessId, productId: product.id };
|
||||
}
|
||||
|
||||
async create(businessIdRaw: string, dto: CreateProductDto, actor: AuthUser) {
|
||||
const businessId = BigInt(businessIdRaw);
|
||||
await this.assertPermission(businessId, actor.id, 'products.create');
|
||||
|
||||
const slug = await this.ensureUniqueSlug(
|
||||
businessId,
|
||||
dto.slug ?? slugify(dto.title),
|
||||
);
|
||||
|
||||
const status = dto.status ?? ContentStatus.published;
|
||||
const featuredMediaId = dto.featuredMediaId
|
||||
? BigInt(dto.featuredMediaId)
|
||||
: null;
|
||||
|
||||
if (featuredMediaId) {
|
||||
await this.assertMediaBelongsToBusiness(businessId, featuredMediaId);
|
||||
}
|
||||
|
||||
const galleryMediaIds = await this.resolveGalleryMediaIds(
|
||||
businessId,
|
||||
dto.galleryMediaIds ?? [],
|
||||
);
|
||||
|
||||
if (dto.categoryId) {
|
||||
await this.assertCategoryBelongsToBusiness(businessId, BigInt(dto.categoryId));
|
||||
}
|
||||
|
||||
let brandId: bigint | null = null;
|
||||
if (dto.brandId) {
|
||||
brandId = BigInt(dto.brandId);
|
||||
await this.brands.assertBrandBelongsToBusiness(businessId, brandId);
|
||||
}
|
||||
|
||||
const created = await this.prisma.$transaction(async (tx) => {
|
||||
const product = await tx.product.create({
|
||||
data: {
|
||||
businessId,
|
||||
title: dto.title.trim(),
|
||||
slug,
|
||||
description: dto.summary?.trim() || null,
|
||||
content: this.buildContent(dto.nameFa, dto.descriptionHtml),
|
||||
status,
|
||||
featuredMediaId,
|
||||
brandId,
|
||||
publishedAt: status === ContentStatus.published ? new Date() : null,
|
||||
metadata: this.buildMetadata(dto.tags),
|
||||
},
|
||||
include: {
|
||||
featuredMedia: true,
|
||||
brand: { include: { imageMedia: true } },
|
||||
},
|
||||
});
|
||||
|
||||
if (dto.categoryId) {
|
||||
await tx.categoryAssignment.create({
|
||||
data: {
|
||||
businessId,
|
||||
categoryId: BigInt(dto.categoryId),
|
||||
entityType: MediaEntityType.product,
|
||||
entityId: product.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
await this.syncGalleryAttachments(
|
||||
tx,
|
||||
businessId,
|
||||
product.id,
|
||||
galleryMediaIds,
|
||||
);
|
||||
|
||||
return product;
|
||||
});
|
||||
|
||||
return {
|
||||
message: 'Product created successfully',
|
||||
product: await this.serializeProduct(created),
|
||||
};
|
||||
}
|
||||
|
||||
async update(
|
||||
businessIdRaw: string,
|
||||
productIdRaw: string,
|
||||
dto: UpdateProductDto,
|
||||
actor: AuthUser,
|
||||
) {
|
||||
const businessId = BigInt(businessIdRaw);
|
||||
const productId = BigInt(productIdRaw);
|
||||
await this.assertPermission(businessId, actor.id, 'products.update');
|
||||
|
||||
const existing = await this.prisma.product.findFirst({
|
||||
where: { id: productId, businessId },
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
throw new NotFoundException('Product not found');
|
||||
}
|
||||
|
||||
let slug = existing.slug;
|
||||
if (dto.slug) {
|
||||
slug = await this.ensureUniqueSlug(businessId, dto.slug, productId);
|
||||
} else if (dto.title && dto.title !== existing.title) {
|
||||
slug = await this.ensureUniqueSlug(businessId, slugify(dto.title), productId);
|
||||
}
|
||||
|
||||
let featuredMediaId: bigint | null | undefined = undefined;
|
||||
if (dto.featuredMediaId !== undefined) {
|
||||
if (dto.featuredMediaId === null || dto.featuredMediaId === '') {
|
||||
featuredMediaId = null;
|
||||
} else {
|
||||
featuredMediaId = BigInt(dto.featuredMediaId);
|
||||
await this.assertMediaBelongsToBusiness(businessId, featuredMediaId);
|
||||
}
|
||||
}
|
||||
|
||||
const existingContent = this.asRecord(existing.content);
|
||||
const existingMetadata = this.asRecord(existing.metadata);
|
||||
|
||||
const nextContent = { ...existingContent };
|
||||
if (dto.nameFa !== undefined) {
|
||||
nextContent.nameFa = dto.nameFa?.trim() || null;
|
||||
}
|
||||
if (dto.descriptionHtml !== undefined) {
|
||||
nextContent.html = dto.descriptionHtml ?? '';
|
||||
}
|
||||
|
||||
const nextMetadata = { ...existingMetadata };
|
||||
if (dto.tags !== undefined) {
|
||||
nextMetadata.tags = dto.tags;
|
||||
}
|
||||
|
||||
let publishedAt: Date | null | undefined = undefined;
|
||||
if (dto.status !== undefined) {
|
||||
if (dto.status === ContentStatus.published && existing.status !== ContentStatus.published) {
|
||||
publishedAt = new Date();
|
||||
}
|
||||
if (dto.status !== ContentStatus.published) {
|
||||
publishedAt = null;
|
||||
}
|
||||
}
|
||||
|
||||
let brandId: bigint | null | undefined = undefined;
|
||||
if (dto.brandId !== undefined) {
|
||||
if (dto.brandId === null || dto.brandId === '') {
|
||||
brandId = null;
|
||||
} else {
|
||||
brandId = BigInt(dto.brandId);
|
||||
await this.brands.assertBrandBelongsToBusiness(businessId, brandId);
|
||||
}
|
||||
}
|
||||
|
||||
const updated = await this.prisma.$transaction(async (tx) => {
|
||||
const product = await tx.product.update({
|
||||
where: { id: productId },
|
||||
data: {
|
||||
...(dto.title !== undefined ? { title: dto.title.trim() } : {}),
|
||||
...(dto.summary !== undefined
|
||||
? { description: dto.summary?.trim() || null }
|
||||
: {}),
|
||||
...(dto.status !== undefined ? { status: dto.status } : {}),
|
||||
...(featuredMediaId !== undefined ? { featuredMediaId } : {}),
|
||||
...(brandId !== undefined ? { brandId } : {}),
|
||||
...(publishedAt !== undefined ? { publishedAt } : {}),
|
||||
slug,
|
||||
content: nextContent as Prisma.InputJsonValue,
|
||||
metadata: nextMetadata as Prisma.InputJsonValue,
|
||||
},
|
||||
include: {
|
||||
featuredMedia: true,
|
||||
brand: { include: { imageMedia: true } },
|
||||
},
|
||||
});
|
||||
|
||||
if (dto.categoryId !== undefined) {
|
||||
await tx.categoryAssignment.deleteMany({
|
||||
where: {
|
||||
businessId,
|
||||
entityType: MediaEntityType.product,
|
||||
entityId: productId,
|
||||
},
|
||||
});
|
||||
|
||||
if (dto.categoryId) {
|
||||
const categoryId = BigInt(dto.categoryId);
|
||||
await this.assertCategoryBelongsToBusiness(businessId, categoryId);
|
||||
await tx.categoryAssignment.create({
|
||||
data: {
|
||||
businessId,
|
||||
categoryId,
|
||||
entityType: MediaEntityType.product,
|
||||
entityId: productId,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (dto.galleryMediaIds !== undefined) {
|
||||
const galleryMediaIds = await this.resolveGalleryMediaIds(
|
||||
businessId,
|
||||
dto.galleryMediaIds,
|
||||
);
|
||||
await this.syncGalleryAttachments(
|
||||
tx,
|
||||
businessId,
|
||||
productId,
|
||||
galleryMediaIds,
|
||||
);
|
||||
}
|
||||
|
||||
return product;
|
||||
});
|
||||
|
||||
return {
|
||||
message: 'Product updated successfully',
|
||||
product: await this.serializeProduct(updated),
|
||||
};
|
||||
}
|
||||
|
||||
async remove(businessIdRaw: string, productIdRaw: string, actor: AuthUser) {
|
||||
const businessId = BigInt(businessIdRaw);
|
||||
const productId = BigInt(productIdRaw);
|
||||
await this.assertPermission(businessId, actor.id, 'products.delete');
|
||||
|
||||
const existing = await this.prisma.product.findFirst({
|
||||
where: { id: productId, businessId },
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
throw new NotFoundException('Product not found');
|
||||
}
|
||||
|
||||
await this.prisma.$transaction([
|
||||
this.prisma.mediaAttachment.deleteMany({
|
||||
where: {
|
||||
businessId,
|
||||
entityType: MediaEntityType.product,
|
||||
entityId: productId,
|
||||
},
|
||||
}),
|
||||
this.prisma.categoryAssignment.deleteMany({
|
||||
where: {
|
||||
businessId,
|
||||
entityType: MediaEntityType.product,
|
||||
entityId: productId,
|
||||
},
|
||||
}),
|
||||
this.prisma.product.delete({ where: { id: productId } }),
|
||||
]);
|
||||
|
||||
return { message: 'Product deleted successfully' };
|
||||
}
|
||||
|
||||
private async serializeProduct(
|
||||
product: ProductWithRelations,
|
||||
variantCount?: number,
|
||||
options: { approvedCommentsOnly?: boolean } = {},
|
||||
) {
|
||||
const content = this.asRecord(product.content);
|
||||
const metadata = this.asRecord(product.metadata);
|
||||
|
||||
const [categoryAssignment, galleryAttachments, resolvedVariantCount, commentCount] =
|
||||
await Promise.all([
|
||||
this.prisma.categoryAssignment.findFirst({
|
||||
where: {
|
||||
businessId: product.businessId,
|
||||
entityType: MediaEntityType.product,
|
||||
entityId: product.id,
|
||||
},
|
||||
include: { category: true },
|
||||
}),
|
||||
this.prisma.mediaAttachment.findMany({
|
||||
where: {
|
||||
businessId: product.businessId,
|
||||
entityType: MediaEntityType.product,
|
||||
entityId: product.id,
|
||||
isFeatured: false,
|
||||
},
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
include: { media: true },
|
||||
}),
|
||||
variantCount === undefined
|
||||
? this.prisma.productVariationValue.count({
|
||||
where: { productId: product.id },
|
||||
})
|
||||
: Promise.resolve(variantCount),
|
||||
this.prisma.comment.count({
|
||||
where: {
|
||||
businessId: product.businessId,
|
||||
entityType: MediaEntityType.product,
|
||||
entityId: product.id,
|
||||
...(options.approvedCommentsOnly ? { isApproved: true } : {}),
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
const thumbnailUrl = product.featuredMedia?.publicUrl ?? null;
|
||||
const image = thumbnailUrl ?? galleryAttachments[0]?.media.publicUrl ?? '';
|
||||
|
||||
return {
|
||||
id: product.id.toString(),
|
||||
businessId: product.businessId.toString(),
|
||||
title: product.title,
|
||||
nameFa: (content.nameFa as string | null | undefined) ?? '',
|
||||
summary: product.description ?? '',
|
||||
descriptionHtml: (content.html as string | undefined) ?? '',
|
||||
slug: product.slug,
|
||||
status: product.status,
|
||||
categoryId: categoryAssignment?.categoryId.toString() ?? null,
|
||||
categoryName: categoryAssignment?.category.name ?? '',
|
||||
brandId: product.brandId?.toString() ?? null,
|
||||
brand: this.brands.serializeBrandSummary(product.brand),
|
||||
tags: Array.isArray(metadata.tags)
|
||||
? (metadata.tags as string[])
|
||||
: [],
|
||||
thumbnailUrl,
|
||||
thumbnailMediaId: product.featuredMediaId?.toString() ?? null,
|
||||
image,
|
||||
thumbnail: thumbnailUrl ?? image,
|
||||
images: galleryAttachments.map((item) => ({
|
||||
mediaId: item.mediaId.toString(),
|
||||
url: item.media.publicUrl,
|
||||
})),
|
||||
galleryMediaIds: galleryAttachments.map((item) => item.mediaId.toString()),
|
||||
commentCount,
|
||||
variantCount: resolvedVariantCount,
|
||||
createdAt: product.createdAt,
|
||||
updatedAt: product.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
private buildContent(nameFa?: string, descriptionHtml?: string) {
|
||||
return {
|
||||
nameFa: nameFa?.trim() || null,
|
||||
html: descriptionHtml ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
private buildMetadata(tags?: string[]) {
|
||||
return {
|
||||
tags: tags?.map((tag) => tag.trim()).filter(Boolean) ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
private asRecord(value: Prisma.JsonValue): Record<string, unknown> {
|
||||
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
private async syncGalleryAttachments(
|
||||
tx: Prisma.TransactionClient,
|
||||
businessId: bigint,
|
||||
productId: bigint,
|
||||
mediaIds: bigint[],
|
||||
) {
|
||||
await tx.mediaAttachment.deleteMany({
|
||||
where: {
|
||||
businessId,
|
||||
entityType: MediaEntityType.product,
|
||||
entityId: productId,
|
||||
isFeatured: false,
|
||||
},
|
||||
});
|
||||
|
||||
for (const [index, mediaId] of mediaIds.entries()) {
|
||||
await tx.mediaAttachment.create({
|
||||
data: {
|
||||
businessId,
|
||||
mediaId,
|
||||
entityType: MediaEntityType.product,
|
||||
entityId: productId,
|
||||
sortOrder: index,
|
||||
isFeatured: false,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async resolveGalleryMediaIds(businessId: bigint, rawIds: string[]) {
|
||||
const ids = rawIds.map((id) => BigInt(id));
|
||||
for (const mediaId of ids) {
|
||||
await this.assertMediaBelongsToBusiness(businessId, mediaId);
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
private async assertMediaBelongsToBusiness(businessId: bigint, mediaId: bigint) {
|
||||
const media = await this.prisma.media.findFirst({
|
||||
where: { id: mediaId, businessId },
|
||||
});
|
||||
if (!media) {
|
||||
throw new BadRequestException('Media not found for this business');
|
||||
}
|
||||
}
|
||||
|
||||
private async assertCategoryBelongsToBusiness(
|
||||
businessId: bigint,
|
||||
categoryId: bigint,
|
||||
) {
|
||||
const category = await this.prisma.category.findFirst({
|
||||
where: {
|
||||
id: categoryId,
|
||||
businessId,
|
||||
entityType: MediaEntityType.product,
|
||||
isActive: true,
|
||||
},
|
||||
});
|
||||
if (!category) {
|
||||
throw new BadRequestException('Category not found for this business');
|
||||
}
|
||||
}
|
||||
|
||||
private async buildPublicWhere(
|
||||
businessId: bigint,
|
||||
query: ListPublicProductsDto,
|
||||
): Promise<Prisma.ProductWhereInput> {
|
||||
let entityIds: bigint[] | undefined;
|
||||
|
||||
if (query.categoryId) {
|
||||
const assignments = await this.prisma.categoryAssignment.findMany({
|
||||
where: {
|
||||
businessId,
|
||||
categoryId: BigInt(query.categoryId),
|
||||
entityType: MediaEntityType.product,
|
||||
},
|
||||
select: { entityId: true },
|
||||
});
|
||||
|
||||
entityIds = assignments.map((item) => item.entityId);
|
||||
if (entityIds.length === 0) {
|
||||
return { id: { in: [] } };
|
||||
}
|
||||
}
|
||||
|
||||
const tag = query.tag?.trim();
|
||||
const name = query.name?.trim();
|
||||
|
||||
return {
|
||||
businessId,
|
||||
status: ContentStatus.published,
|
||||
...(query.brandId ? { brandId: BigInt(query.brandId) } : {}),
|
||||
...(entityIds ? { id: { in: entityIds } } : {}),
|
||||
...(tag
|
||||
? {
|
||||
metadata: {
|
||||
path: ['tags'],
|
||||
array_contains: tag,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
...(name
|
||||
? {
|
||||
OR: [
|
||||
{ title: { contains: name, mode: 'insensitive' } },
|
||||
{
|
||||
content: {
|
||||
path: ['nameFa'],
|
||||
string_contains: name,
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
: {}),
|
||||
...(query.inStore
|
||||
? {
|
||||
storeItem: {
|
||||
some: {
|
||||
isActive: true,
|
||||
variants: { some: { isActive: true } },
|
||||
},
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
private async loadStoreSummariesForProducts(
|
||||
businessId: bigint,
|
||||
productIds: bigint[],
|
||||
) {
|
||||
if (productIds.length === 0) {
|
||||
return new Map<
|
||||
string,
|
||||
{
|
||||
variantCount: number;
|
||||
minPrice: number | null;
|
||||
maxPrice: number | null;
|
||||
inStock: boolean;
|
||||
}
|
||||
>();
|
||||
}
|
||||
|
||||
const variants = await this.prisma.storeItemVariant.findMany({
|
||||
where: {
|
||||
businessId,
|
||||
isActive: true,
|
||||
storeItem: {
|
||||
isActive: true,
|
||||
productId: { in: productIds },
|
||||
product: { status: ContentStatus.published },
|
||||
},
|
||||
},
|
||||
select: {
|
||||
price: true,
|
||||
stockQuantity: true,
|
||||
storeItem: { select: { productId: true } },
|
||||
},
|
||||
});
|
||||
|
||||
const map = new Map<
|
||||
string,
|
||||
{
|
||||
variantCount: number;
|
||||
minPrice: number | null;
|
||||
maxPrice: number | null;
|
||||
inStock: boolean;
|
||||
}
|
||||
>();
|
||||
|
||||
for (const variant of variants) {
|
||||
const productId = variant.storeItem.productId.toString();
|
||||
const entry = map.get(productId) ?? {
|
||||
variantCount: 0,
|
||||
minPrice: null,
|
||||
maxPrice: null,
|
||||
inStock: false,
|
||||
};
|
||||
|
||||
entry.variantCount += 1;
|
||||
const price = variant.price === null ? null : Number(variant.price);
|
||||
if (price !== null) {
|
||||
entry.minPrice =
|
||||
entry.minPrice === null ? price : Math.min(entry.minPrice, price);
|
||||
entry.maxPrice =
|
||||
entry.maxPrice === null ? price : Math.max(entry.maxPrice, price);
|
||||
}
|
||||
if ((variant.stockQuantity ?? 0) > 0) {
|
||||
entry.inStock = true;
|
||||
}
|
||||
|
||||
map.set(productId, entry);
|
||||
}
|
||||
|
||||
return map;
|
||||
}
|
||||
|
||||
private async ensureUniqueSlug(
|
||||
businessId: bigint,
|
||||
baseSlug: string,
|
||||
excludeId?: bigint,
|
||||
) {
|
||||
let slug = baseSlug;
|
||||
let suffix = 1;
|
||||
|
||||
while (true) {
|
||||
const existing = await this.prisma.product.findFirst({
|
||||
where: {
|
||||
businessId,
|
||||
slug,
|
||||
...(excludeId ? { NOT: { id: excludeId } } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
return slug;
|
||||
}
|
||||
|
||||
suffix += 1;
|
||||
slug = `${baseSlug}-${suffix}`;
|
||||
}
|
||||
}
|
||||
|
||||
private async assertPermission(
|
||||
businessId: bigint,
|
||||
userId: bigint,
|
||||
permission: string,
|
||||
) {
|
||||
const allowed = await this.permissions.hasBusinessPermission(
|
||||
userId,
|
||||
businessId,
|
||||
permission,
|
||||
);
|
||||
|
||||
if (!allowed) {
|
||||
throw new ForbiddenException(`Missing permission: ${permission} for this business`);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user