init
This commit is contained in:
@@ -5,6 +5,8 @@ import { AccessModule } from './modules/access/access.module';
|
||||
import { AdminArtifactsModule } from './modules/admin-artifacts/admin-artifacts.module';
|
||||
import { AdminAuditModule } from './modules/admin-audit/admin-audit.module';
|
||||
import { AdminAuthModule } from './modules/admin-auth/admin-auth.module';
|
||||
import { AdminCategoriesModule } from './modules/admin-categories/admin-categories.module';
|
||||
import { AdminTagsModule } from './modules/admin-tags/admin-tags.module';
|
||||
import { AdminToolsModule } from './modules/admin-tools/admin-tools.module';
|
||||
import { CategoriesModule } from './modules/categories/categories.module';
|
||||
import { DownloadsModule } from './modules/downloads/downloads.module';
|
||||
@@ -30,6 +32,8 @@ import { ToolsModule } from './modules/tools/tools.module';
|
||||
GitlabStorageModule,
|
||||
DownloadsModule,
|
||||
AdminAuthModule,
|
||||
AdminCategoriesModule,
|
||||
AdminTagsModule,
|
||||
AdminToolsModule,
|
||||
AdminArtifactsModule,
|
||||
AdminAuditModule,
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { Body, Controller, Delete, Get, Param, Patch, Post, Query, UseGuards, UseInterceptors } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { Audit } from '../../common/decorators/audit.decorator';
|
||||
import { AdminJwtGuard } from '../../common/guards/admin-jwt.guard';
|
||||
import { AdminAuditInterceptor } from '../../common/interceptors/audit/admin-audit.interceptor';
|
||||
import { AdminCategoriesService } from './admin-categories.service';
|
||||
import { AdminCategoriesQueryDto } from './dto/admin-categories-query.dto';
|
||||
import { CreateCategoryDto } from './dto/create-category.dto';
|
||||
import { UpdateCategoryDto } from './dto/update-category.dto';
|
||||
|
||||
@ApiTags('admin-categories')
|
||||
@ApiBearerAuth('admin-access-token')
|
||||
@UseGuards(AdminJwtGuard)
|
||||
@UseInterceptors(AdminAuditInterceptor)
|
||||
@Controller('admin/categories')
|
||||
export class AdminCategoriesController {
|
||||
constructor(private readonly adminCategoriesService: AdminCategoriesService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'Admin list categories' })
|
||||
getCategories(@Query() query: AdminCategoriesQueryDto) {
|
||||
return this.adminCategoriesService.getCategories(query);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@Audit({ action: 'category.create', resourceType: 'category' })
|
||||
@ApiOperation({ summary: 'Admin create category' })
|
||||
createCategory(@Body() body: CreateCategoryDto) {
|
||||
return this.adminCategoriesService.createCategory(body);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@Audit({ action: 'category.update', resourceType: 'category' })
|
||||
@ApiOperation({ summary: 'Admin update category' })
|
||||
updateCategory(@Param('id') id: string, @Body() body: UpdateCategoryDto) {
|
||||
return this.adminCategoriesService.updateCategory(id, body);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@Audit({ action: 'category.delete', resourceType: 'category' })
|
||||
@ApiOperation({ summary: 'Admin delete category' })
|
||||
deleteCategory(@Param('id') id: string) {
|
||||
return this.adminCategoriesService.deleteCategory(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AdminCategoriesController } from './admin-categories.controller';
|
||||
import { AdminCategoriesService } from './admin-categories.service';
|
||||
|
||||
@Module({
|
||||
controllers: [AdminCategoriesController],
|
||||
providers: [AdminCategoriesService],
|
||||
})
|
||||
export class AdminCategoriesModule {}
|
||||
207
server/src/modules/admin-categories/admin-categories.service.ts
Normal file
207
server/src/modules/admin-categories/admin-categories.service.ts
Normal file
@@ -0,0 +1,207 @@
|
||||
import { HttpStatus, Injectable } from '@nestjs/common';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { ERROR_CODES } from '../../common/constants/error-codes';
|
||||
import { AppException } from '../../common/exceptions/app.exception';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { AdminCategoriesQueryDto } from './dto/admin-categories-query.dto';
|
||||
import { CreateCategoryDto } from './dto/create-category.dto';
|
||||
import { UpdateCategoryDto } from './dto/update-category.dto';
|
||||
|
||||
@Injectable()
|
||||
export class AdminCategoriesService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async getCategories(query: AdminCategoriesQueryDto) {
|
||||
const keyword = query.query?.trim();
|
||||
const categories = await this.prisma.category.findMany({
|
||||
where: {
|
||||
isDeleted: false,
|
||||
...(keyword
|
||||
? {
|
||||
name: {
|
||||
contains: keyword,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
include: {
|
||||
tools: {
|
||||
where: {
|
||||
isDeleted: false,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: [{ sortOrder: 'asc' }, { name: 'asc' }],
|
||||
});
|
||||
|
||||
return categories.map((item) => ({
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
sortOrder: item.sortOrder,
|
||||
toolCount: item.tools.length,
|
||||
}));
|
||||
}
|
||||
|
||||
async createCategory(body: CreateCategoryDto) {
|
||||
const name = body.name.trim();
|
||||
const existing = await this.prisma.category.findUnique({
|
||||
where: { name },
|
||||
include: {
|
||||
tools: {
|
||||
where: {
|
||||
isDeleted: false,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
if (existing) {
|
||||
if (!existing.isDeleted) {
|
||||
throw new AppException(
|
||||
ERROR_CODES.CONFLICT,
|
||||
'category name already exists',
|
||||
HttpStatus.CONFLICT,
|
||||
);
|
||||
}
|
||||
|
||||
const restored = await this.prisma.category.update({
|
||||
where: { id: existing.id },
|
||||
data: {
|
||||
isDeleted: false,
|
||||
sortOrder: body.sortOrder ?? existing.sortOrder,
|
||||
},
|
||||
include: {
|
||||
tools: {
|
||||
where: {
|
||||
isDeleted: false,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
id: restored.id,
|
||||
name: restored.name,
|
||||
sortOrder: restored.sortOrder,
|
||||
toolCount: restored.tools.length,
|
||||
};
|
||||
}
|
||||
|
||||
const category = await this.prisma.category.create({
|
||||
data: {
|
||||
id: this.generateBusinessId('cat'),
|
||||
name,
|
||||
sortOrder: body.sortOrder ?? 100,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
id: category.id,
|
||||
name: category.name,
|
||||
sortOrder: category.sortOrder,
|
||||
toolCount: 0,
|
||||
};
|
||||
}
|
||||
|
||||
async updateCategory(id: string, body: UpdateCategoryDto) {
|
||||
const existing = await this.getCategoryEntity(id);
|
||||
const name =
|
||||
body.name !== undefined
|
||||
? body.name.trim()
|
||||
: existing.name;
|
||||
|
||||
await this.assertNameAvailable(name, id);
|
||||
|
||||
const updated = await this.prisma.category.update({
|
||||
where: { id },
|
||||
data: {
|
||||
name: body.name !== undefined ? name : undefined,
|
||||
sortOrder: body.sortOrder,
|
||||
},
|
||||
include: {
|
||||
tools: {
|
||||
where: {
|
||||
isDeleted: false,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
id: updated.id,
|
||||
name: updated.name,
|
||||
sortOrder: updated.sortOrder,
|
||||
toolCount: updated.tools.length,
|
||||
};
|
||||
}
|
||||
|
||||
async deleteCategory(id: string) {
|
||||
await this.getCategoryEntity(id);
|
||||
|
||||
const usedCount = await this.prisma.tool.count({
|
||||
where: {
|
||||
categoryId: id,
|
||||
isDeleted: false,
|
||||
},
|
||||
});
|
||||
if (usedCount > 0) {
|
||||
throw new AppException(
|
||||
ERROR_CODES.CONFLICT,
|
||||
'category is still used by tools',
|
||||
HttpStatus.CONFLICT,
|
||||
);
|
||||
}
|
||||
|
||||
await this.prisma.category.update({
|
||||
where: { id },
|
||||
data: {
|
||||
isDeleted: true,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
id,
|
||||
};
|
||||
}
|
||||
|
||||
private async getCategoryEntity(id: string) {
|
||||
const category = await this.prisma.category.findFirst({
|
||||
where: {
|
||||
id,
|
||||
isDeleted: false,
|
||||
},
|
||||
});
|
||||
if (!category) {
|
||||
throw new AppException(ERROR_CODES.NOT_FOUND, 'category not found', HttpStatus.NOT_FOUND);
|
||||
}
|
||||
return category;
|
||||
}
|
||||
|
||||
private async assertNameAvailable(name: string, excludeId?: string) {
|
||||
const conflict = await this.prisma.category.findUnique({
|
||||
where: { name },
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
if (conflict && conflict.id !== excludeId) {
|
||||
throw new AppException(ERROR_CODES.CONFLICT, 'category name already exists', HttpStatus.CONFLICT);
|
||||
}
|
||||
}
|
||||
|
||||
private generateBusinessId(prefix: string): string {
|
||||
return `${prefix}_${randomUUID().replace(/-/g, '').slice(0, 12)}`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsOptional, IsString, MaxLength } from 'class-validator';
|
||||
|
||||
export class AdminCategoriesQueryDto {
|
||||
@ApiPropertyOptional({ description: 'Category name query' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(80)
|
||||
query?: string;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Transform, Type } from 'class-transformer';
|
||||
import { IsInt, IsOptional, IsString, Max, MaxLength, Min, MinLength } from 'class-validator';
|
||||
|
||||
export class CreateCategoryDto {
|
||||
@ApiProperty({ description: 'Category name' })
|
||||
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(80)
|
||||
name!: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Sort order', default: 100, minimum: 0, maximum: 9999 })
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
@Max(9999)
|
||||
sortOrder?: number;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import { PartialType } from '@nestjs/swagger';
|
||||
import { CreateCategoryDto } from './create-category.dto';
|
||||
|
||||
export class UpdateCategoryDto extends PartialType(CreateCategoryDto) {}
|
||||
29
server/src/modules/admin-tags/admin-tags.controller.ts
Normal file
29
server/src/modules/admin-tags/admin-tags.controller.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { Body, Controller, Get, Post, UseGuards, UseInterceptors } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { Audit } from '../../common/decorators/audit.decorator';
|
||||
import { AdminJwtGuard } from '../../common/guards/admin-jwt.guard';
|
||||
import { AdminAuditInterceptor } from '../../common/interceptors/audit/admin-audit.interceptor';
|
||||
import { AdminTagsService } from './admin-tags.service';
|
||||
import { CreateTagDto } from './dto/create-tag.dto';
|
||||
|
||||
@ApiTags('admin-tags')
|
||||
@ApiBearerAuth('admin-access-token')
|
||||
@UseGuards(AdminJwtGuard)
|
||||
@UseInterceptors(AdminAuditInterceptor)
|
||||
@Controller('admin/tags')
|
||||
export class AdminTagsController {
|
||||
constructor(private readonly adminTagsService: AdminTagsService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'Admin list tags' })
|
||||
getTags() {
|
||||
return this.adminTagsService.getTags();
|
||||
}
|
||||
|
||||
@Post()
|
||||
@Audit({ action: 'tag.create', resourceType: 'tag' })
|
||||
@ApiOperation({ summary: 'Admin create tag' })
|
||||
createTag(@Body() body: CreateTagDto) {
|
||||
return this.adminTagsService.createTag(body);
|
||||
}
|
||||
}
|
||||
9
server/src/modules/admin-tags/admin-tags.module.ts
Normal file
9
server/src/modules/admin-tags/admin-tags.module.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AdminTagsController } from './admin-tags.controller';
|
||||
import { AdminTagsService } from './admin-tags.service';
|
||||
|
||||
@Module({
|
||||
controllers: [AdminTagsController],
|
||||
providers: [AdminTagsService],
|
||||
})
|
||||
export class AdminTagsModule {}
|
||||
65
server/src/modules/admin-tags/admin-tags.service.ts
Normal file
65
server/src/modules/admin-tags/admin-tags.service.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { CreateTagDto } from './dto/create-tag.dto';
|
||||
|
||||
@Injectable()
|
||||
export class AdminTagsService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async getTags() {
|
||||
const tags = await this.prisma.tag.findMany({
|
||||
where: {
|
||||
isDeleted: false,
|
||||
},
|
||||
orderBy: {
|
||||
name: 'asc',
|
||||
},
|
||||
});
|
||||
|
||||
return tags.map((tag) => ({
|
||||
id: tag.id,
|
||||
name: tag.name,
|
||||
}));
|
||||
}
|
||||
|
||||
async createTag(body: CreateTagDto) {
|
||||
const name = body.name.trim();
|
||||
|
||||
const existing = await this.prisma.tag.findUnique({
|
||||
where: { name },
|
||||
});
|
||||
if (existing) {
|
||||
if (existing.isDeleted) {
|
||||
const restored = await this.prisma.tag.update({
|
||||
where: { id: existing.id },
|
||||
data: { isDeleted: false },
|
||||
});
|
||||
return {
|
||||
id: restored.id,
|
||||
name: restored.name,
|
||||
};
|
||||
}
|
||||
return {
|
||||
id: existing.id,
|
||||
name: existing.name,
|
||||
};
|
||||
}
|
||||
|
||||
const created = await this.prisma.tag.create({
|
||||
data: {
|
||||
id: this.generateBusinessId('tag'),
|
||||
name,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
id: created.id,
|
||||
name: created.name,
|
||||
};
|
||||
}
|
||||
|
||||
private generateBusinessId(prefix: string): string {
|
||||
return `${prefix}_${randomUUID().replace(/-/g, '').slice(0, 12)}`;
|
||||
}
|
||||
}
|
||||
12
server/src/modules/admin-tags/dto/create-tag.dto.ts
Normal file
12
server/src/modules/admin-tags/dto/create-tag.dto.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { Transform } from 'class-transformer';
|
||||
import { IsString, MaxLength, MinLength } from 'class-validator';
|
||||
|
||||
export class CreateTagDto {
|
||||
@ApiProperty({ description: 'Tag display name' })
|
||||
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(50)
|
||||
name!: string;
|
||||
}
|
||||
Reference in New Issue
Block a user