Files
backend/src/website/website-blog.service.ts
T
2026-10-03 23:17:33 +03:30

297 lines
8.1 KiB
TypeScript

import {
BadRequestException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import type {
CreateWebsiteBlogCategoryDto,
CreateWebsiteBlogPostDto,
} from './dto/website-blog.dto';
@Injectable()
export class WebsiteBlogService {
constructor(private readonly prisma: PrismaService) {}
async listCategories() {
const items = await this.prisma.websiteBlogCategory.findMany({
orderBy: { name: 'asc' },
});
return { items };
}
async createCategory(dto: CreateWebsiteBlogCategoryDto) {
const name = dto.name.trim();
if (!name) {
throw new BadRequestException('Enter a category name.');
}
const parentId = dto.parentId?.trim()
? this.parseId(dto.parentId.trim())
: null;
if (parentId) {
const parent = await this.prisma.websiteBlogCategory.findUnique({
where: { id: parentId },
});
if (!parent) {
throw new NotFoundException('Parent category not found.');
}
}
const existing = await this.prisma.websiteBlogCategory.findFirst({
where: {
parentId,
name: { equals: name, mode: 'insensitive' },
},
});
if (existing) {
throw new BadRequestException('This category already exists here.');
}
try {
return await this.prisma.websiteBlogCategory.create({
data: {
name,
nameFa: dto.nameFa?.trim() || null,
description: dto.description?.trim() || null,
parentId: parentId ?? null,
},
});
} catch (error) {
if (
error instanceof Prisma.PrismaClientKnownRequestError &&
error.code === 'P2002'
) {
throw new BadRequestException('This category already exists here.');
}
throw error;
}
}
async updateCategory(id: string, dto: CreateWebsiteBlogCategoryDto) {
const categoryId = this.parseId(id);
const current = await this.prisma.websiteBlogCategory.findUnique({
where: { id: categoryId },
});
if (!current) {
throw new NotFoundException('Category not found.');
}
const name = dto.name.trim();
if (!name) {
throw new BadRequestException('Enter a category name.');
}
const parentId = dto.parentId?.trim()
? this.parseId(dto.parentId.trim())
: null;
if (parentId === categoryId) {
throw new BadRequestException('A category cannot be its own parent.');
}
const rows = await this.prisma.websiteBlogCategory.findMany({
select: { id: true, parentId: true },
});
if (parentId) {
const descendants = this.descendantIds(categoryId, rows);
if (descendants.has(parentId.toString())) {
throw new BadRequestException(
'Choose a parent outside this category.',
);
}
const parent = rows.find((row) => row.id === parentId);
if (!parent) {
throw new NotFoundException('Parent category not found.');
}
}
const existing = await this.prisma.websiteBlogCategory.findFirst({
where: {
parentId,
name: { equals: name, mode: 'insensitive' },
NOT: { id: categoryId },
},
});
if (existing) {
throw new BadRequestException('This category already exists here.');
}
return this.prisma.websiteBlogCategory.update({
where: { id: categoryId },
data: {
name,
nameFa: dto.nameFa?.trim() || null,
description: dto.description?.trim() || null,
parentId,
},
});
}
async deleteCategory(id: string) {
const rootId = this.parseId(id);
const rows = await this.prisma.websiteBlogCategory.findMany({
select: { id: true, parentId: true },
});
if (!rows.some((row) => row.id === rootId)) {
throw new NotFoundException('Category not found.');
}
const subtree = this.subtreePostOrder(rootId, rows);
const posts = await this.prisma.websiteBlogPost.count({
where: { categoryId: { in: subtree } },
});
if (posts > 0) {
throw new BadRequestException(
'Remove posts in this category before deleting it.',
);
}
for (const categoryId of subtree) {
await this.prisma.websiteBlogCategory.delete({ where: { id: categoryId } });
}
return { ok: true };
}
async listPosts() {
const posts = await this.prisma.websiteBlogPost.findMany({
orderBy: { createdAt: 'desc' },
include: { category: true },
});
return {
total: posts.length,
items: posts.map((post) => ({
id: post.id,
title: post.title,
abstract: post.abstract,
imageUrl: post.imageUrl,
categoryId: post.categoryId,
categoryName: post.category.name,
tags: post.tags,
createdAt: post.createdAt,
updatedAt: post.updatedAt,
})),
};
}
async createPost(dto: CreateWebsiteBlogPostDto) {
const categoryId = this.parseId(dto.categoryId);
const category = await this.prisma.websiteBlogCategory.findUnique({
where: { id: categoryId },
});
if (!category) {
throw new NotFoundException('Category not found.');
}
const post = await this.prisma.websiteBlogPost.create({
data: {
title: dto.title.trim(),
abstract: dto.abstract?.trim() || null,
mainTextHtml: dto.mainTextHtml?.trim() || null,
imageUrl: dto.imageUrl?.trim() || null,
categoryId,
tags: this.normalizeTags(dto.tags),
},
include: { category: true },
});
return {
id: post.id,
title: post.title,
abstract: post.abstract,
mainTextHtml: post.mainTextHtml,
imageUrl: post.imageUrl,
categoryId: post.categoryId,
categoryName: post.category.name,
tags: post.tags,
createdAt: post.createdAt,
updatedAt: post.updatedAt,
};
}
async deletePost(id: string) {
const postId = this.parseId(id);
try {
await this.prisma.websiteBlogPost.delete({ where: { id: postId } });
} catch (error) {
if (
error instanceof Prisma.PrismaClientKnownRequestError &&
error.code === 'P2025'
) {
throw new NotFoundException('Blog post not found.');
}
throw error;
}
return { ok: true };
}
private descendantIds(
rootId: bigint,
rows: { id: bigint; parentId: bigint | null }[],
) {
const childrenByParent = new Map<string, bigint[]>();
for (const row of rows) {
if (row.parentId == null) continue;
const key = row.parentId.toString();
const list = childrenByParent.get(key) ?? [];
list.push(row.id);
childrenByParent.set(key, list);
}
const result = new Set<string>();
const queue = [...(childrenByParent.get(rootId.toString()) ?? [])];
while (queue.length) {
const current = queue.shift()!;
const key = current.toString();
if (result.has(key)) continue;
result.add(key);
queue.push(...(childrenByParent.get(key) ?? []));
}
return result;
}
private subtreePostOrder(
rootId: bigint,
rows: { id: bigint; parentId: bigint | null }[],
) {
const childrenByParent = new Map<string, bigint[]>();
for (const row of rows) {
if (row.parentId == null) continue;
const key = row.parentId.toString();
const list = childrenByParent.get(key) ?? [];
list.push(row.id);
childrenByParent.set(key, list);
}
const order: bigint[] = [];
const walk = (current: bigint) => {
for (const child of childrenByParent.get(current.toString()) ?? []) {
walk(child);
}
order.push(current);
};
walk(rootId);
return order;
}
private parseId(value: string) {
if (!/^\d+$/.test(value)) {
throw new BadRequestException('Invalid id.');
}
return BigInt(value);
}
private normalizeTags(tags: string[] | undefined) {
const seen = new Set<string>();
const next: string[] = [];
for (const tag of tags ?? []) {
const trimmed = tag.trim();
if (!trimmed) continue;
const key = trimmed.toLowerCase();
if (seen.has(key)) continue;
seen.add(key);
next.push(trimmed);
}
return next;
}
}