Initial commit: backend
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,296 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user