diff --git a/prisma/migrations/20260908060000_blog_categories/migration.sql b/prisma/migrations/20260908060000_blog_categories/migration.sql new file mode 100644 index 0000000..5be65a4 --- /dev/null +++ b/prisma/migrations/20260908060000_blog_categories/migration.sql @@ -0,0 +1,35 @@ +-- CreateTable +CREATE TABLE "BlogCategory" ( + "id" TEXT NOT NULL, + "storeId" TEXT NOT NULL, + "parentId" TEXT, + "nameFa" TEXT NOT NULL, + "nameEn" TEXT NOT NULL DEFAULT '', + "sortOrder" INTEGER NOT NULL DEFAULT 0, + + CONSTRAINT "BlogCategory_pkey" PRIMARY KEY ("id") +); + +-- AlterTable +ALTER TABLE "BlogPost" ADD COLUMN "categoryId" TEXT; + +-- CreateIndex +CREATE INDEX "BlogCategory_storeId_idx" ON "BlogCategory"("storeId"); + +-- CreateIndex +CREATE INDEX "BlogCategory_parentId_idx" ON "BlogCategory"("parentId"); + +-- CreateIndex +CREATE INDEX "BlogCategory_sortOrder_idx" ON "BlogCategory"("sortOrder"); + +-- CreateIndex +CREATE INDEX "BlogPost_categoryId_idx" ON "BlogPost"("categoryId"); + +-- AddForeignKey +ALTER TABLE "BlogCategory" ADD CONSTRAINT "BlogCategory_storeId_fkey" FOREIGN KEY ("storeId") REFERENCES "Store"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "BlogCategory" ADD CONSTRAINT "BlogCategory_parentId_fkey" FOREIGN KEY ("parentId") REFERENCES "BlogCategory"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "BlogPost" ADD CONSTRAINT "BlogPost_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "BlogCategory"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 7268234..dafb6b2 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -126,6 +126,7 @@ model Store { products Product[] categories Category[] + blogCategories BlogCategory[] brands Brand[] blogs BlogPost[] orderItems OrderItem[] @@ -219,24 +220,44 @@ model ProductVariant { @@index([productId]) } +model BlogCategory { + id String @id @default(cuid()) + storeId String + store Store @relation(fields: [storeId], references: [id], onDelete: Cascade) + parentId String? + parent BlogCategory? @relation("BlogCategoryTree", fields: [parentId], references: [id], onDelete: SetNull) + children BlogCategory[] @relation("BlogCategoryTree") + nameFa String + nameEn String @default("") + sortOrder Int @default(0) + blogs BlogPost[] + + @@index([storeId]) + @@index([parentId]) + @@index([sortOrder]) +} + model BlogPost { - id String @id @default(cuid()) + id String @id @default(cuid()) storeId String - store Store @relation(fields: [storeId], references: [id], onDelete: Cascade) + store Store @relation(fields: [storeId], references: [id], onDelete: Cascade) + categoryId String? + category BlogCategory? @relation(fields: [categoryId], references: [id], onDelete: SetNull) title String - summary String @default("") - body String @default("") - author String @default("") + summary String @default("") + body String @default("") + author String @default("") imageUrl String? imageKey String? - published Boolean @default(true) + published Boolean @default(true) metaTitle String? metaDescription String? slug String? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt @@index([storeId]) + @@index([categoryId]) @@index([published]) @@index([slug]) } diff --git a/src/app.module.ts b/src/app.module.ts index f69ba2c..42f7cf7 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -1,6 +1,7 @@ import { Module } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; import { AuthModule } from './auth/auth.module'; +import { BlogCategoriesModule } from './blog-categories/blog-categories.module'; import { BlogsModule } from './blogs/blogs.module'; import { BrandsModule } from './brands/brands.module'; import { CatalogModule } from './catalog/catalog.module'; @@ -29,6 +30,7 @@ import { StoresModule } from './stores/stores.module'; StoresModule, ProductsModule, CategoriesModule, + BlogCategoriesModule, BrandsModule, BlogsModule, CustomersModule, diff --git a/src/blog-categories/blog-categories.controller.ts b/src/blog-categories/blog-categories.controller.ts new file mode 100644 index 0000000..71ee26c --- /dev/null +++ b/src/blog-categories/blog-categories.controller.ts @@ -0,0 +1,56 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + Patch, + Post, + Query, + UseGuards, +} from '@nestjs/common'; +import { UserRole } from '@prisma/client'; +import { CurrentUser } from '../auth/decorators/current-user.decorator'; +import { Roles } from '../auth/decorators/roles.decorator'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { RolesGuard } from '../auth/guards/roles.guard'; +import type { AuthUser } from '../auth/auth.types'; +import { + CreateBlogCategoryDto, + UpdateBlogCategoryDto, +} from './dto/blog-category.dto'; +import { BlogCategoriesService } from './blog-categories.service'; + +@Controller('blog-categories') +@UseGuards(JwtAuthGuard, RolesGuard) +@Roles(UserRole.STORE_ADMIN, UserRole.SUPER_ADMIN) +export class BlogCategoriesController { + constructor(private readonly categories: BlogCategoriesService) {} + + @Get() + list( + @CurrentUser() user: AuthUser, + @Query('storeId') storeId?: string, + ) { + return this.categories.list(user, storeId); + } + + @Post() + create(@CurrentUser() user: AuthUser, @Body() dto: CreateBlogCategoryDto) { + return this.categories.create(user, dto); + } + + @Patch(':id') + update( + @CurrentUser() user: AuthUser, + @Param('id') id: string, + @Body() dto: UpdateBlogCategoryDto, + ) { + return this.categories.update(user, id, dto); + } + + @Delete(':id') + remove(@CurrentUser() user: AuthUser, @Param('id') id: string) { + return this.categories.remove(user, id); + } +} diff --git a/src/blog-categories/blog-categories.module.ts b/src/blog-categories/blog-categories.module.ts new file mode 100644 index 0000000..13515ac --- /dev/null +++ b/src/blog-categories/blog-categories.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { BlogCategoriesController } from './blog-categories.controller'; +import { BlogCategoriesService } from './blog-categories.service'; + +@Module({ + controllers: [BlogCategoriesController], + providers: [BlogCategoriesService], + exports: [BlogCategoriesService], +}) +export class BlogCategoriesModule {} diff --git a/src/blog-categories/blog-categories.service.ts b/src/blog-categories/blog-categories.service.ts new file mode 100644 index 0000000..b152365 --- /dev/null +++ b/src/blog-categories/blog-categories.service.ts @@ -0,0 +1,110 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { UserRole } from '@prisma/client'; +import { AuthUser } from '../auth/auth.types'; +import { StoreScopeService } from '../common/store-scope.service'; +import { PrismaService } from '../prisma/prisma.service'; +import { + CreateBlogCategoryDto, + UpdateBlogCategoryDto, +} from './dto/blog-category.dto'; + +@Injectable() +export class BlogCategoriesService { + constructor( + private readonly prisma: PrismaService, + private readonly storeScope: StoreScopeService, + ) {} + + async list(actor: AuthUser, storeIdQuery?: string) { + const storeId = + actor.role === UserRole.SUPER_ADMIN + ? storeIdQuery + : (await this.storeScope.requireActiveStore(actor)).id; + + const categories = await this.prisma.blogCategory.findMany({ + where: storeId ? { storeId } : undefined, + orderBy: [{ sortOrder: 'asc' }, { nameFa: 'asc' }], + }); + + return this.buildTree(categories); + } + + async create(actor: AuthUser, dto: CreateBlogCategoryDto) { + const store = await this.storeScope.requireActiveStore( + actor, + dto.storeId, + ); + + return this.prisma.blogCategory.create({ + data: { + storeId: store.id, + nameFa: dto.nameFa, + nameEn: dto.nameEn ?? '', + parentId: dto.parentId, + sortOrder: dto.sortOrder ?? 0, + }, + }); + } + + async update(actor: AuthUser, id: string, dto: UpdateBlogCategoryDto) { + const existing = await this.prisma.blogCategory.findUnique({ + where: { id }, + }); + if (!existing) { + throw new NotFoundException('دسته بلاگ یافت نشد'); + } + await this.assertStoreAccess(actor, existing.storeId); + + return this.prisma.blogCategory.update({ + where: { id }, + data: { + ...(dto.nameFa !== undefined ? { nameFa: dto.nameFa } : {}), + ...(dto.nameEn !== undefined ? { nameEn: dto.nameEn } : {}), + ...(dto.parentId !== undefined ? { parentId: dto.parentId } : {}), + ...(dto.sortOrder !== undefined ? { sortOrder: dto.sortOrder } : {}), + }, + }); + } + + async remove(actor: AuthUser, id: string) { + const existing = await this.prisma.blogCategory.findUnique({ + where: { id }, + }); + if (!existing) { + throw new NotFoundException('دسته بلاگ یافت نشد'); + } + await this.assertStoreAccess(actor, existing.storeId); + await this.prisma.blogCategory.delete({ where: { id } }); + return { ok: true }; + } + + private buildTree( + items: T[], + ) { + const byParent = new Map(); + for (const item of items) { + const key = item.parentId; + const list = byParent.get(key) ?? []; + list.push(item); + byParent.set(key, list); + } + + const walk = ( + parentId: string | null, + ): Array => + (byParent.get(parentId) ?? []).map((node) => ({ + ...node, + children: walk(node.id), + })); + + return walk(null); + } + + private async assertStoreAccess(actor: AuthUser, storeId: string) { + if (actor.role === UserRole.SUPER_ADMIN) return; + const store = await this.storeScope.requireActiveStore(actor); + if (store.id !== storeId) { + throw new NotFoundException('دسته بلاگ یافت نشد'); + } + } +} diff --git a/src/blog-categories/dto/blog-category.dto.ts b/src/blog-categories/dto/blog-category.dto.ts new file mode 100644 index 0000000..e3a6db5 --- /dev/null +++ b/src/blog-categories/dto/blog-category.dto.ts @@ -0,0 +1,50 @@ +import { + IsInt, + IsOptional, + IsString, + Min, + MinLength, +} from 'class-validator'; + +export class CreateBlogCategoryDto { + @IsString() + @MinLength(1) + nameFa!: string; + + @IsOptional() + @IsString() + nameEn?: string; + + @IsOptional() + @IsString() + parentId?: string; + + @IsOptional() + @IsInt() + @Min(0) + sortOrder?: number; + + @IsOptional() + @IsString() + storeId?: string; +} + +export class UpdateBlogCategoryDto { + @IsOptional() + @IsString() + @MinLength(1) + nameFa?: string; + + @IsOptional() + @IsString() + nameEn?: string; + + @IsOptional() + @IsString() + parentId?: string | null; + + @IsOptional() + @IsInt() + @Min(0) + sortOrder?: number; +} diff --git a/src/blogs/blogs.service.ts b/src/blogs/blogs.service.ts index b9fef7a..b4a12ab 100644 --- a/src/blogs/blogs.service.ts +++ b/src/blogs/blogs.service.ts @@ -1,6 +1,6 @@ import { Injectable, NotFoundException } from '@nestjs/common'; import { UserRole } from '@prisma/client'; -import { AuthUser } from '../auth/auth.types'; +import { AuthUser, displayName } from '../auth/auth.types'; import { StoreScopeService } from '../common/store-scope.service'; import { slugify } from '../common/slugify'; import { PrismaService } from '../prisma/prisma.service'; @@ -21,12 +21,20 @@ export class BlogsService { return this.prisma.blogPost.findMany({ where: storeId ? { storeId } : undefined, + include: { + category: { select: { id: true, nameFa: true, nameEn: true } }, + }, orderBy: { createdAt: 'desc' }, }); } async findOne(actor: AuthUser, id: string) { - const post = await this.prisma.blogPost.findUnique({ where: { id } }); + const post = await this.prisma.blogPost.findUnique({ + where: { id }, + include: { + category: { select: { id: true, nameFa: true, nameEn: true } }, + }, + }); if (!post) { throw new NotFoundException('مطلب یافت نشد'); } @@ -46,7 +54,8 @@ export class BlogsService { title: dto.title, summary: dto.summary ?? '', body: dto.body ?? '', - author: dto.author ?? '', + author: dto.author?.trim() || displayName(actor), + categoryId: dto.categoryId, imageUrl: dto.imageUrl, imageKey: dto.imageKey, published: dto.published ?? true, @@ -54,6 +63,9 @@ export class BlogsService { metaDescription: dto.metaDescription, slug: dto.slug ?? (slugify(dto.title) || undefined), }, + include: { + category: { select: { id: true, nameFa: true, nameEn: true } }, + }, }); } @@ -71,6 +83,9 @@ export class BlogsService { ...(dto.summary !== undefined ? { summary: dto.summary } : {}), ...(dto.body !== undefined ? { body: dto.body } : {}), ...(dto.author !== undefined ? { author: dto.author } : {}), + ...(dto.categoryId !== undefined + ? { categoryId: dto.categoryId } + : {}), ...(dto.imageUrl !== undefined ? { imageUrl: dto.imageUrl } : {}), ...(dto.imageKey !== undefined ? { imageKey: dto.imageKey } : {}), ...(dto.published !== undefined ? { published: dto.published } : {}), @@ -80,6 +95,9 @@ export class BlogsService { : {}), ...(dto.slug !== undefined ? { slug: dto.slug } : {}), }, + include: { + category: { select: { id: true, nameFa: true, nameEn: true } }, + }, }); } diff --git a/src/blogs/dto/blog.dto.ts b/src/blogs/dto/blog.dto.ts index 1fd743f..fb70522 100644 --- a/src/blogs/dto/blog.dto.ts +++ b/src/blogs/dto/blog.dto.ts @@ -22,6 +22,10 @@ export class CreateBlogDto { @IsString() author?: string; + @IsOptional() + @IsString() + categoryId?: string; + @IsOptional() @IsString() imageUrl?: string; @@ -69,6 +73,10 @@ export class UpdateBlogDto { @IsString() author?: string; + @IsOptional() + @IsString() + categoryId?: string | null; + @IsOptional() @IsString() imageUrl?: string | null; diff --git a/src/common/store-scope.service.ts b/src/common/store-scope.service.ts index b6717cf..dc62656 100644 --- a/src/common/store-scope.service.ts +++ b/src/common/store-scope.service.ts @@ -13,19 +13,19 @@ export class StoreScopeService { /** * STORE_ADMIN must own an active store for product/catalog CRUD. - * SUPER_ADMIN may pass an explicit storeId. + * SUPER_ADMIN may pass an explicit storeId; otherwise defaults to + * the Mana Collection flagship store (slug: mana-collection). */ async requireActiveStore( actor: AuthUser, storeId?: string, ): Promise { if (actor.role === UserRole.SUPER_ADMIN) { - if (!storeId) { - throw new ForbiddenException('شناسه فروشگاه الزامی است'); - } - const store = await this.prisma.store.findUnique({ - where: { id: storeId }, - }); + const store = storeId + ? await this.prisma.store.findUnique({ where: { id: storeId } }) + : await this.prisma.store.findUnique({ + where: { slug: 'mana-collection' }, + }); if (!store) { throw new NotFoundException('فروشگاه یافت نشد'); }