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,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`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user