297 lines
8.1 KiB
TypeScript
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;
|
|
}
|
|
}
|