Add blog categories and default SUPER_ADMIN store scope to Mana Collection.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-09-09 12:28:36 +03:30
co-authored by Cursor
parent 3da8a5d4a2
commit 20e81ade19
10 changed files with 328 additions and 18 deletions
@@ -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;
+29 -8
View File
@@ -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])
}
+2
View File
@@ -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,
@@ -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);
}
}
@@ -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 {}
@@ -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<T extends { id: string; parentId: string | null }>(
items: T[],
) {
const byParent = new Map<string | null, T[]>();
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<T & { children: unknown[] }> =>
(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('دسته بلاگ یافت نشد');
}
}
}
@@ -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;
}
+21 -3
View File
@@ -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 } },
},
});
}
+8
View File
@@ -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;
+7 -7
View File
@@ -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<Store> {
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('فروشگاه یافت نشد');
}