Add blog categories and default SUPER_ADMIN store scope to Mana Collection.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
3da8a5d4a2
commit
20e81ade19
@@ -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
@@ -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])
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 } },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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('فروشگاه یافت نشد');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user