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:
Ali Reza
2026-07-21 17:52:36 +03:30
commit bb59d5e9ba
254 changed files with 37031 additions and 0 deletions
@@ -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`,
);
}
}
}