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(); 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`, ); } } }