Support duplicatable free-text lists, always append سایر ویژگیها on AI category forms, and fill leftover product specs into that field. Co-authored-by: Cursor <cursoragent@cursor.com>
446 lines
12 KiB
TypeScript
446 lines
12 KiB
TypeScript
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';
|
|
import {
|
|
decodeMultiValueText,
|
|
encodeMultiValueText,
|
|
} from '../common/technical-multi-value.util';
|
|
|
|
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 === 'multi_value') {
|
|
const items = Array.isArray(entry.value)
|
|
? entry.value.map((item) => String(item).trim()).filter(Boolean)
|
|
: [];
|
|
if (!items.length) continue;
|
|
await tx.productTechnicalFieldValue.create({
|
|
data: {
|
|
businessId,
|
|
productId: product.id,
|
|
fieldId,
|
|
textValue: encodeMultiValueText(items),
|
|
},
|
|
});
|
|
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[];
|
|
if (!Array.isArray(selectedValues) || !selectedValues.length) {
|
|
continue;
|
|
}
|
|
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 === 'multi_value') {
|
|
if (!Array.isArray(value)) {
|
|
throw new BadRequestException(
|
|
`Field "${field.label}" expects an array of text values`,
|
|
);
|
|
}
|
|
const items = value.map((item) => String(item).trim()).filter(Boolean);
|
|
if (!items.length) {
|
|
if (field.isRequired) {
|
|
throw new BadRequestException(
|
|
`Required field "${field.label}" cannot be empty`,
|
|
);
|
|
}
|
|
return;
|
|
}
|
|
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' || field.type === 'multi_value' ? [] : null,
|
|
};
|
|
}
|
|
|
|
if (field.type === 'multi_value') {
|
|
return {
|
|
fieldKey: field.key,
|
|
fieldLabel: field.label,
|
|
type: field.type,
|
|
value: decodeMultiValueText(record.textValue),
|
|
};
|
|
}
|
|
|
|
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`,
|
|
);
|
|
}
|
|
}
|
|
}
|