init
This commit is contained in:
21
server/src/modules/access/access.controller.ts
Normal file
21
server/src/modules/access/access.controller.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { Body, Controller, Param, Post, Req } from '@nestjs/common';
|
||||
import { ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import type { RequestWithContext } from '../../common/interfaces/request-with-context.interface';
|
||||
import { LaunchToolDto } from './dto/launch-tool.dto';
|
||||
import { AccessService } from './access.service';
|
||||
|
||||
@ApiTags('public-launch')
|
||||
@Controller('tools')
|
||||
export class AccessController {
|
||||
constructor(private readonly accessService: AccessService) {}
|
||||
|
||||
@Post(':id/launch')
|
||||
@ApiOperation({ summary: 'Unified launch endpoint (web/download)' })
|
||||
launchTool(
|
||||
@Param('id') id: string,
|
||||
@Body() body: LaunchToolDto,
|
||||
@Req() request: RequestWithContext,
|
||||
) {
|
||||
return this.accessService.launchTool(id, body, request);
|
||||
}
|
||||
}
|
||||
9
server/src/modules/access/access.module.ts
Normal file
9
server/src/modules/access/access.module.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AccessController } from './access.controller';
|
||||
import { AccessService } from './access.service';
|
||||
|
||||
@Module({
|
||||
controllers: [AccessController],
|
||||
providers: [AccessService],
|
||||
})
|
||||
export class AccessModule {}
|
||||
120
server/src/modules/access/access.service.ts
Normal file
120
server/src/modules/access/access.service.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import { HttpStatus, Injectable } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { ArtifactStatus, ToolStatus } from '@prisma/client';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { ERROR_CODES } from '../../common/constants/error-codes';
|
||||
import { AppException } from '../../common/exceptions/app.exception';
|
||||
import type { RequestWithContext } from '../../common/interfaces/request-with-context.interface';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { LaunchToolDto } from './dto/launch-tool.dto';
|
||||
|
||||
@Injectable()
|
||||
export class AccessService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly configService: ConfigService,
|
||||
) {}
|
||||
|
||||
async launchTool(toolId: string, body: LaunchToolDto, request: RequestWithContext) {
|
||||
const tool = await this.prisma.tool.findFirst({
|
||||
where: {
|
||||
id: toolId,
|
||||
isDeleted: false,
|
||||
status: ToolStatus.published,
|
||||
},
|
||||
include: {
|
||||
latestArtifact: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!tool) {
|
||||
throw new AppException(ERROR_CODES.NOT_FOUND, 'tool not found', HttpStatus.NOT_FOUND);
|
||||
}
|
||||
|
||||
if (tool.accessMode === 'web') {
|
||||
if (!tool.openUrl) {
|
||||
throw new AppException(
|
||||
ERROR_CODES.WEB_OPEN_URL_NOT_CONFIGURED,
|
||||
'web open url is not configured',
|
||||
HttpStatus.CONFLICT,
|
||||
);
|
||||
}
|
||||
|
||||
await this.prisma.$transaction([
|
||||
this.prisma.openRecord.create({
|
||||
data: {
|
||||
toolId: tool.id,
|
||||
channel: body.channel,
|
||||
clientVersion: body.clientVersion,
|
||||
clientIp: this.extractIp(request),
|
||||
userAgent: request.headers['user-agent'],
|
||||
referer: this.extractHeader(request, 'referer'),
|
||||
},
|
||||
}),
|
||||
this.prisma.tool.update({
|
||||
where: { id: tool.id },
|
||||
data: { openCount: { increment: 1 } },
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
mode: 'web' as const,
|
||||
actionUrl: tool.openUrl,
|
||||
openIn: tool.openInNewTab ? 'new_tab' : 'same_tab',
|
||||
};
|
||||
}
|
||||
|
||||
if (!tool.latestArtifact || tool.latestArtifact.status !== ArtifactStatus.active) {
|
||||
throw new AppException(
|
||||
ERROR_CODES.ARTIFACT_NOT_AVAILABLE,
|
||||
'artifact not available for this download tool',
|
||||
HttpStatus.CONFLICT,
|
||||
);
|
||||
}
|
||||
|
||||
const ttlSec = this.configService.get<number>('DOWNLOAD_TICKET_TTL_SEC', 120);
|
||||
const expiresAt = new Date(Date.now() + ttlSec * 1000);
|
||||
const ticket = `dl_tk_${randomUUID().replace(/-/g, '')}`;
|
||||
|
||||
await this.prisma.downloadTicket.create({
|
||||
data: {
|
||||
ticket,
|
||||
toolId: tool.id,
|
||||
artifactId: tool.latestArtifact.id,
|
||||
channel: body.channel,
|
||||
clientVersion: body.clientVersion,
|
||||
requestIp: this.extractIp(request),
|
||||
expiresAt,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
mode: 'download' as const,
|
||||
ticket,
|
||||
expiresInSec: ttlSec,
|
||||
actionUrl: `/api/v1/downloads/${ticket}`,
|
||||
};
|
||||
}
|
||||
|
||||
private extractIp(request: RequestWithContext): string | undefined {
|
||||
const forwarded = request.headers['x-forwarded-for'];
|
||||
if (Array.isArray(forwarded) && forwarded.length > 0) {
|
||||
return forwarded[0]?.split(',')[0]?.trim();
|
||||
}
|
||||
if (typeof forwarded === 'string') {
|
||||
return forwarded.split(',')[0]?.trim();
|
||||
}
|
||||
return request.ip;
|
||||
}
|
||||
|
||||
private extractHeader(
|
||||
request: RequestWithContext,
|
||||
name: string,
|
||||
): string | undefined {
|
||||
const value = request.headers[name];
|
||||
if (!value) {
|
||||
return undefined;
|
||||
}
|
||||
return Array.isArray(value) ? value[0] : value;
|
||||
}
|
||||
}
|
||||
16
server/src/modules/access/dto/launch-tool.dto.ts
Normal file
16
server/src/modules/access/dto/launch-tool.dto.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsOptional, IsString, MaxLength } from 'class-validator';
|
||||
|
||||
export class LaunchToolDto {
|
||||
@ApiPropertyOptional({ example: 'official' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(64)
|
||||
channel?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'web-1.0.0' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(64)
|
||||
clientVersion?: string;
|
||||
}
|
||||
110
server/src/modules/admin-artifacts/admin-artifacts.controller.ts
Normal file
110
server/src/modules/admin-artifacts/admin-artifacts.controller.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Req,
|
||||
UploadedFile,
|
||||
UseGuards,
|
||||
UseInterceptors,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
ApiBearerAuth,
|
||||
ApiBody,
|
||||
ApiConsumes,
|
||||
ApiOperation,
|
||||
ApiTags,
|
||||
} from '@nestjs/swagger';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import { memoryStorage } from 'multer';
|
||||
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 type { RequestWithContext } from '../../common/interfaces/request-with-context.interface';
|
||||
import { UpdateArtifactStatusDto } from './dto/update-artifact-status.dto';
|
||||
import { UploadArtifactDto } from './dto/upload-artifact.dto';
|
||||
import { AdminArtifactsService } from './admin-artifacts.service';
|
||||
|
||||
@ApiTags('admin-artifacts')
|
||||
@ApiBearerAuth('admin-access-token')
|
||||
@UseGuards(AdminJwtGuard)
|
||||
@UseInterceptors(AdminAuditInterceptor)
|
||||
@Controller('admin/tools/:id/artifacts')
|
||||
export class AdminArtifactsController {
|
||||
constructor(private readonly adminArtifactsService: AdminArtifactsService) {}
|
||||
|
||||
@Post()
|
||||
@Audit({ action: 'artifact.upload', resourceType: 'artifact', resourceIdParam: 'id' })
|
||||
@ApiOperation({ summary: 'Upload artifact file for tool' })
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiBody({
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
file: { type: 'string', format: 'binary' },
|
||||
version: { type: 'string', example: '1.0.0' },
|
||||
releaseNotes: { type: 'string', example: 'Initial release' },
|
||||
isLatest: { type: 'boolean', example: true },
|
||||
},
|
||||
required: ['file', 'version'],
|
||||
},
|
||||
})
|
||||
@UseInterceptors(
|
||||
FileInterceptor('file', {
|
||||
storage: memoryStorage(),
|
||||
limits: {
|
||||
fileSize: 512 * 1024 * 1024,
|
||||
},
|
||||
}),
|
||||
)
|
||||
uploadArtifact(
|
||||
@Param('id') id: string,
|
||||
@UploadedFile() file: Express.Multer.File | undefined,
|
||||
@Body() body: UploadArtifactDto,
|
||||
@Req() request: RequestWithContext,
|
||||
) {
|
||||
return this.adminArtifactsService.uploadArtifact(id, file, body, request.user?.sub);
|
||||
}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'List tool artifacts' })
|
||||
listArtifacts(@Param('id') id: string) {
|
||||
return this.adminArtifactsService.listToolArtifacts(id);
|
||||
}
|
||||
|
||||
@Patch(':artifactId/latest')
|
||||
@Audit({
|
||||
action: 'artifact.set_latest',
|
||||
resourceType: 'artifact',
|
||||
resourceIdParam: 'artifactId',
|
||||
})
|
||||
@ApiOperation({ summary: 'Set latest artifact' })
|
||||
setLatestArtifact(@Param('id') id: string, @Param('artifactId') artifactId: string) {
|
||||
return this.adminArtifactsService.setLatestArtifact(id, artifactId);
|
||||
}
|
||||
|
||||
@Patch(':artifactId/status')
|
||||
@Audit({
|
||||
action: 'artifact.update_status',
|
||||
resourceType: 'artifact',
|
||||
resourceIdParam: 'artifactId',
|
||||
})
|
||||
@ApiOperation({ summary: 'Update artifact status' })
|
||||
updateArtifactStatus(
|
||||
@Param('id') id: string,
|
||||
@Param('artifactId') artifactId: string,
|
||||
@Body() body: UpdateArtifactStatusDto,
|
||||
) {
|
||||
return this.adminArtifactsService.updateArtifactStatus(id, artifactId, body.status);
|
||||
}
|
||||
|
||||
@Delete(':artifactId')
|
||||
@Audit({ action: 'artifact.delete', resourceType: 'artifact', resourceIdParam: 'artifactId' })
|
||||
@ApiOperation({ summary: 'Delete artifact metadata (soft via status=deleted)' })
|
||||
deleteArtifact(@Param('id') id: string, @Param('artifactId') artifactId: string) {
|
||||
return this.adminArtifactsService.deleteArtifact(id, artifactId);
|
||||
}
|
||||
}
|
||||
12
server/src/modules/admin-artifacts/admin-artifacts.module.ts
Normal file
12
server/src/modules/admin-artifacts/admin-artifacts.module.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AdminAuditInterceptor } from '../../common/interceptors/audit/admin-audit.interceptor';
|
||||
import { GitlabStorageModule } from '../gitlab-storage/gitlab-storage.module';
|
||||
import { AdminArtifactsController } from './admin-artifacts.controller';
|
||||
import { AdminArtifactsService } from './admin-artifacts.service';
|
||||
|
||||
@Module({
|
||||
imports: [GitlabStorageModule],
|
||||
controllers: [AdminArtifactsController],
|
||||
providers: [AdminArtifactsService, AdminAuditInterceptor],
|
||||
})
|
||||
export class AdminArtifactsModule {}
|
||||
311
server/src/modules/admin-artifacts/admin-artifacts.service.ts
Normal file
311
server/src/modules/admin-artifacts/admin-artifacts.service.ts
Normal file
@@ -0,0 +1,311 @@
|
||||
import { HttpStatus, Injectable } from '@nestjs/common';
|
||||
import { ArtifactStatus } from '@prisma/client';
|
||||
import { createHash, 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 { GitlabStorageService } from '../gitlab-storage/gitlab-storage.service';
|
||||
import { UploadArtifactDto } from './dto/upload-artifact.dto';
|
||||
|
||||
@Injectable()
|
||||
export class AdminArtifactsService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly gitlabStorageService: GitlabStorageService,
|
||||
) {}
|
||||
|
||||
async uploadArtifact(toolId: string, file: Express.Multer.File | undefined, body: UploadArtifactDto, adminId?: string) {
|
||||
const tool = await this.assertDownloadModeTool(toolId);
|
||||
this.assertUploadFile(file);
|
||||
|
||||
const uploadMaxSizeMb = Number(process.env.UPLOAD_MAX_SIZE_MB ?? 512);
|
||||
if (file!.size > uploadMaxSizeMb * 1024 * 1024) {
|
||||
throw new AppException(
|
||||
ERROR_CODES.VALIDATION_FAILED,
|
||||
`file size exceeds ${uploadMaxSizeMb}MB`,
|
||||
HttpStatus.BAD_REQUEST,
|
||||
);
|
||||
}
|
||||
|
||||
const exists = await this.prisma.toolArtifact.findFirst({
|
||||
where: {
|
||||
toolId,
|
||||
version: body.version,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
if (exists) {
|
||||
throw new AppException(ERROR_CODES.CONFLICT, 'version already exists', HttpStatus.CONFLICT);
|
||||
}
|
||||
|
||||
const sha256 = createHash('sha256').update(file!.buffer).digest('hex');
|
||||
const uploadResult = await this.gitlabStorageService.uploadArtifact({
|
||||
toolId,
|
||||
version: body.version,
|
||||
fileName: file!.originalname,
|
||||
mimeType: file!.mimetype,
|
||||
buffer: file!.buffer,
|
||||
});
|
||||
|
||||
const artifactId = this.generateBusinessId('art');
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
await tx.toolArtifact.create({
|
||||
data: {
|
||||
id: artifactId,
|
||||
toolId,
|
||||
version: body.version,
|
||||
fileName: file!.originalname,
|
||||
fileSizeBytes: file!.size,
|
||||
sha256,
|
||||
mimeType: file!.mimetype,
|
||||
gitlabProjectId: uploadResult.gitlabProjectId,
|
||||
gitlabPackageName: uploadResult.gitlabPackageName,
|
||||
gitlabPackageVersion: uploadResult.gitlabPackageVersion,
|
||||
gitlabFilePath: uploadResult.gitlabFilePath,
|
||||
releaseNotes: body.releaseNotes,
|
||||
status: ArtifactStatus.active,
|
||||
uploadedBy: adminId,
|
||||
},
|
||||
});
|
||||
|
||||
const setLatest = body.isLatest ?? true;
|
||||
if (setLatest) {
|
||||
await tx.tool.update({
|
||||
where: { id: tool.id },
|
||||
data: {
|
||||
latestArtifactId: artifactId,
|
||||
updatedAt: this.getDateOnlyString(),
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return this.getArtifactById(toolId, artifactId);
|
||||
}
|
||||
|
||||
async listToolArtifacts(toolId: string) {
|
||||
await this.assertToolExists(toolId);
|
||||
const artifacts = await this.prisma.toolArtifact.findMany({
|
||||
where: {
|
||||
toolId,
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
});
|
||||
|
||||
const tool = await this.prisma.tool.findUnique({
|
||||
where: {
|
||||
id: toolId,
|
||||
},
|
||||
select: {
|
||||
latestArtifactId: true,
|
||||
},
|
||||
});
|
||||
|
||||
return artifacts.map((item) => ({
|
||||
id: item.id,
|
||||
version: item.version,
|
||||
fileName: item.fileName,
|
||||
fileSizeBytes: item.fileSizeBytes,
|
||||
sha256: item.sha256,
|
||||
mimeType: item.mimeType,
|
||||
status: item.status,
|
||||
releaseNotes: item.releaseNotes,
|
||||
isLatest: tool?.latestArtifactId === item.id,
|
||||
createdAt: item.createdAt,
|
||||
}));
|
||||
}
|
||||
|
||||
async setLatestArtifact(toolId: string, artifactId: string) {
|
||||
const artifact = await this.assertArtifactBelongsToTool(toolId, artifactId);
|
||||
if (artifact.status !== ArtifactStatus.active) {
|
||||
throw new AppException(
|
||||
ERROR_CODES.ARTIFACT_NOT_AVAILABLE,
|
||||
'only active artifact can be set as latest',
|
||||
HttpStatus.CONFLICT,
|
||||
);
|
||||
}
|
||||
|
||||
await this.prisma.tool.update({
|
||||
where: {
|
||||
id: toolId,
|
||||
},
|
||||
data: {
|
||||
latestArtifactId: artifactId,
|
||||
updatedAt: this.getDateOnlyString(),
|
||||
},
|
||||
});
|
||||
|
||||
return this.getArtifactById(toolId, artifactId);
|
||||
}
|
||||
|
||||
async updateArtifactStatus(toolId: string, artifactId: string, status: ArtifactStatus) {
|
||||
const artifact = await this.assertArtifactBelongsToTool(toolId, artifactId);
|
||||
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
await tx.toolArtifact.update({
|
||||
where: { id: artifact.id },
|
||||
data: {
|
||||
status,
|
||||
},
|
||||
});
|
||||
|
||||
if (status !== ArtifactStatus.active) {
|
||||
const tool = await tx.tool.findUnique({
|
||||
where: { id: toolId },
|
||||
select: { latestArtifactId: true },
|
||||
});
|
||||
|
||||
if (tool?.latestArtifactId === artifactId) {
|
||||
const fallback = await tx.toolArtifact.findFirst({
|
||||
where: {
|
||||
toolId,
|
||||
id: {
|
||||
not: artifactId,
|
||||
},
|
||||
status: ArtifactStatus.active,
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
|
||||
await tx.tool.update({
|
||||
where: { id: toolId },
|
||||
data: {
|
||||
latestArtifactId: fallback?.id ?? null,
|
||||
updatedAt: this.getDateOnlyString(),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return this.getArtifactById(toolId, artifactId);
|
||||
}
|
||||
|
||||
async deleteArtifact(toolId: string, artifactId: string) {
|
||||
await this.updateArtifactStatus(toolId, artifactId, ArtifactStatus.deleted);
|
||||
return {
|
||||
success: true,
|
||||
id: artifactId,
|
||||
};
|
||||
}
|
||||
|
||||
private async assertToolExists(toolId: string) {
|
||||
const tool = await this.prisma.tool.findFirst({
|
||||
where: {
|
||||
id: toolId,
|
||||
isDeleted: false,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!tool) {
|
||||
throw new AppException(ERROR_CODES.NOT_FOUND, 'tool not found', HttpStatus.NOT_FOUND);
|
||||
}
|
||||
|
||||
return tool;
|
||||
}
|
||||
|
||||
private async assertDownloadModeTool(toolId: string) {
|
||||
const tool = await this.prisma.tool.findFirst({
|
||||
where: {
|
||||
id: toolId,
|
||||
isDeleted: false,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
accessMode: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!tool) {
|
||||
throw new AppException(ERROR_CODES.NOT_FOUND, 'tool not found', HttpStatus.NOT_FOUND);
|
||||
}
|
||||
if (tool.accessMode !== 'download') {
|
||||
throw new AppException(
|
||||
ERROR_CODES.TOOL_ACCESS_MODE_MISMATCH,
|
||||
'artifact upload is only allowed for download mode tool',
|
||||
HttpStatus.CONFLICT,
|
||||
);
|
||||
}
|
||||
|
||||
return tool;
|
||||
}
|
||||
|
||||
private async assertArtifactBelongsToTool(toolId: string, artifactId: string) {
|
||||
await this.assertToolExists(toolId);
|
||||
const artifact = await this.prisma.toolArtifact.findFirst({
|
||||
where: {
|
||||
id: artifactId,
|
||||
toolId,
|
||||
},
|
||||
});
|
||||
if (!artifact) {
|
||||
throw new AppException(ERROR_CODES.NOT_FOUND, 'artifact not found', HttpStatus.NOT_FOUND);
|
||||
}
|
||||
return artifact;
|
||||
}
|
||||
|
||||
private async getArtifactById(toolId: string, artifactId: string) {
|
||||
const artifact = await this.assertArtifactBelongsToTool(toolId, artifactId);
|
||||
const tool = await this.prisma.tool.findUnique({
|
||||
where: { id: toolId },
|
||||
select: {
|
||||
latestArtifactId: true,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
id: artifact.id,
|
||||
toolId: artifact.toolId,
|
||||
version: artifact.version,
|
||||
fileName: artifact.fileName,
|
||||
fileSizeBytes: artifact.fileSizeBytes,
|
||||
sha256: artifact.sha256,
|
||||
mimeType: artifact.mimeType,
|
||||
gitlabProjectId: artifact.gitlabProjectId,
|
||||
gitlabPackageName: artifact.gitlabPackageName,
|
||||
gitlabPackageVersion: artifact.gitlabPackageVersion,
|
||||
gitlabFilePath: artifact.gitlabFilePath,
|
||||
status: artifact.status,
|
||||
releaseNotes: artifact.releaseNotes,
|
||||
isLatest: tool?.latestArtifactId === artifact.id,
|
||||
createdAt: artifact.createdAt,
|
||||
};
|
||||
}
|
||||
|
||||
private assertUploadFile(file: Express.Multer.File | undefined): asserts file is Express.Multer.File {
|
||||
if (!file) {
|
||||
throw new AppException(ERROR_CODES.VALIDATION_FAILED, 'file is required', HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
|
||||
const fileName = file.originalname.toLowerCase();
|
||||
const allowedExtensions = ['.zip', '.tar.gz', '.tgz', '.exe', '.dmg', '.pkg', '.msi'];
|
||||
const isAllowed = allowedExtensions.some((ext) => fileName.endsWith(ext));
|
||||
if (!isAllowed) {
|
||||
throw new AppException(
|
||||
ERROR_CODES.VALIDATION_FAILED,
|
||||
`file extension is not allowed: ${file.originalname}`,
|
||||
HttpStatus.BAD_REQUEST,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private generateBusinessId(prefix: string): string {
|
||||
return `${prefix}_${randomUUID().replace(/-/g, '').slice(0, 12)}`;
|
||||
}
|
||||
|
||||
private getDateOnlyString(): string {
|
||||
return new Date().toISOString().slice(0, 10);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { ArtifactStatus } from '@prisma/client';
|
||||
import { IsEnum } from 'class-validator';
|
||||
|
||||
export class UpdateArtifactStatusDto {
|
||||
@ApiProperty({ enum: ArtifactStatus })
|
||||
@IsEnum(ArtifactStatus)
|
||||
status!: ArtifactStatus;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import { IsBoolean, IsOptional, IsString, MaxLength, MinLength } from 'class-validator';
|
||||
|
||||
export class UploadArtifactDto {
|
||||
@ApiProperty({ example: '1.0.0' })
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(64)
|
||||
version!: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'Initial release' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(5000)
|
||||
releaseNotes?: string;
|
||||
|
||||
@ApiPropertyOptional({ default: true })
|
||||
@IsOptional()
|
||||
@Type(() => Boolean)
|
||||
@IsBoolean()
|
||||
isLatest?: boolean;
|
||||
}
|
||||
19
server/src/modules/admin-audit/admin-audit.controller.ts
Normal file
19
server/src/modules/admin-audit/admin-audit.controller.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { Controller, Get, Query, UseGuards } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { AdminJwtGuard } from '../../common/guards/admin-jwt.guard';
|
||||
import { AdminAuditQueryDto } from './dto/admin-audit-query.dto';
|
||||
import { AdminAuditService } from './admin-audit.service';
|
||||
|
||||
@ApiTags('admin-audit')
|
||||
@ApiBearerAuth('admin-access-token')
|
||||
@UseGuards(AdminJwtGuard)
|
||||
@Controller('admin/audit-logs')
|
||||
export class AdminAuditController {
|
||||
constructor(private readonly adminAuditService: AdminAuditService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'Query admin audit logs' })
|
||||
getAuditLogs(@Query() query: AdminAuditQueryDto) {
|
||||
return this.adminAuditService.getAuditLogs(query);
|
||||
}
|
||||
}
|
||||
9
server/src/modules/admin-audit/admin-audit.module.ts
Normal file
9
server/src/modules/admin-audit/admin-audit.module.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AdminAuditController } from './admin-audit.controller';
|
||||
import { AdminAuditService } from './admin-audit.service';
|
||||
|
||||
@Module({
|
||||
controllers: [AdminAuditController],
|
||||
providers: [AdminAuditService],
|
||||
})
|
||||
export class AdminAuditModule {}
|
||||
74
server/src/modules/admin-audit/admin-audit.service.ts
Normal file
74
server/src/modules/admin-audit/admin-audit.service.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { AdminAuditQueryDto } from './dto/admin-audit-query.dto';
|
||||
|
||||
@Injectable()
|
||||
export class AdminAuditService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async getAuditLogs(query: AdminAuditQueryDto) {
|
||||
const page = query.page ?? 1;
|
||||
const pageSize = Math.min(query.pageSize ?? 20, 100);
|
||||
|
||||
const where: Prisma.AdminAuditLogWhereInput = {};
|
||||
if (query.action) {
|
||||
where.action = { contains: query.action };
|
||||
}
|
||||
if (query.resourceType) {
|
||||
where.resourceType = query.resourceType;
|
||||
}
|
||||
if (query.adminUserId) {
|
||||
where.adminUserId = query.adminUserId;
|
||||
}
|
||||
|
||||
const [total, logs] = await this.prisma.$transaction([
|
||||
this.prisma.adminAuditLog.count({ where }),
|
||||
this.prisma.adminAuditLog.findMany({
|
||||
where,
|
||||
include: {
|
||||
adminUser: {
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
displayName: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
list: logs.map((item) => ({
|
||||
id: item.id,
|
||||
action: item.action,
|
||||
resourceType: item.resourceType,
|
||||
resourceId: item.resourceId,
|
||||
requestMethod: item.requestMethod,
|
||||
requestPath: item.requestPath,
|
||||
requestBody: item.requestBody,
|
||||
ip: item.ip,
|
||||
userAgent: item.userAgent,
|
||||
createdAt: item.createdAt,
|
||||
adminUser: item.adminUser
|
||||
? {
|
||||
id: item.adminUser.id,
|
||||
username: item.adminUser.username,
|
||||
displayName: item.adminUser.displayName,
|
||||
}
|
||||
: null,
|
||||
})),
|
||||
pagination: {
|
||||
page,
|
||||
pageSize,
|
||||
total,
|
||||
totalPages: Math.ceil(total / pageSize),
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
35
server/src/modules/admin-audit/dto/admin-audit-query.dto.ts
Normal file
35
server/src/modules/admin-audit/dto/admin-audit-query.dto.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import { IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
|
||||
|
||||
export class AdminAuditQueryDto {
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
action?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
resourceType?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
adminUserId?: string;
|
||||
|
||||
@ApiPropertyOptional({ default: 1 })
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
page?: number;
|
||||
|
||||
@ApiPropertyOptional({ default: 20, maximum: 100 })
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(100)
|
||||
pageSize?: number;
|
||||
}
|
||||
41
server/src/modules/admin-auth/admin-auth.controller.ts
Normal file
41
server/src/modules/admin-auth/admin-auth.controller.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { Body, Controller, Get, Post, Req, UseGuards } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { AdminJwtGuard } from '../../common/guards/admin-jwt.guard';
|
||||
import type { RequestWithContext } from '../../common/interfaces/request-with-context.interface';
|
||||
import { LoginDto } from './dto/login.dto';
|
||||
import { RefreshTokenDto } from './dto/refresh-token.dto';
|
||||
import { AdminAuthService } from './admin-auth.service';
|
||||
|
||||
@ApiTags('admin-auth')
|
||||
@Controller('admin/auth')
|
||||
export class AdminAuthController {
|
||||
constructor(private readonly adminAuthService: AdminAuthService) {}
|
||||
|
||||
@Post('login')
|
||||
@ApiOperation({ summary: 'Admin login' })
|
||||
login(@Body() body: LoginDto) {
|
||||
return this.adminAuthService.login(body);
|
||||
}
|
||||
|
||||
@Post('refresh')
|
||||
@ApiOperation({ summary: 'Refresh admin token' })
|
||||
refresh(@Body() body: RefreshTokenDto) {
|
||||
return this.adminAuthService.refresh(body.refreshToken);
|
||||
}
|
||||
|
||||
@Post('logout')
|
||||
@UseGuards(AdminJwtGuard)
|
||||
@ApiBearerAuth('admin-access-token')
|
||||
@ApiOperation({ summary: 'Admin logout' })
|
||||
logout() {
|
||||
return this.adminAuthService.logout();
|
||||
}
|
||||
|
||||
@Get('me')
|
||||
@UseGuards(AdminJwtGuard)
|
||||
@ApiBearerAuth('admin-access-token')
|
||||
@ApiOperation({ summary: 'Get current admin profile' })
|
||||
me(@Req() request: RequestWithContext) {
|
||||
return this.adminAuthService.getMe(request.user?.sub ?? '');
|
||||
}
|
||||
}
|
||||
23
server/src/modules/admin-auth/admin-auth.module.ts
Normal file
23
server/src/modules/admin-auth/admin-auth.module.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
import { PassportModule } from '@nestjs/passport';
|
||||
import { AdminAuthController } from './admin-auth.controller';
|
||||
import { AdminAuthService } from './admin-auth.service';
|
||||
import { AdminJwtStrategy } from './strategies/admin-jwt.strategy';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
PassportModule.register({ defaultStrategy: 'jwt' }),
|
||||
JwtModule.registerAsync({
|
||||
inject: [ConfigService],
|
||||
useFactory: (configService: ConfigService) => ({
|
||||
secret: configService.get<string>('JWT_ACCESS_SECRET', 'change_this_access_secret'),
|
||||
}),
|
||||
}),
|
||||
],
|
||||
controllers: [AdminAuthController],
|
||||
providers: [AdminAuthService, AdminJwtStrategy],
|
||||
exports: [AdminAuthService],
|
||||
})
|
||||
export class AdminAuthModule {}
|
||||
189
server/src/modules/admin-auth/admin-auth.service.ts
Normal file
189
server/src/modules/admin-auth/admin-auth.service.ts
Normal file
@@ -0,0 +1,189 @@
|
||||
import { HttpStatus, Injectable } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { AdminUserStatus } from '@prisma/client';
|
||||
import argon2 from 'argon2';
|
||||
import { ERROR_CODES } from '../../common/constants/error-codes';
|
||||
import { AppException } from '../../common/exceptions/app.exception';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import type { LoginDto } from './dto/login.dto';
|
||||
import type { JwtPayload } from './interfaces/jwt-payload.interface';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
|
||||
@Injectable()
|
||||
export class AdminAuthService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly jwtService: JwtService,
|
||||
private readonly configService: ConfigService,
|
||||
) {}
|
||||
|
||||
async login(body: LoginDto) {
|
||||
const user = await this.prisma.adminUser.findUnique({
|
||||
where: {
|
||||
username: body.username,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user || user.status !== AdminUserStatus.active) {
|
||||
throw new AppException(
|
||||
ERROR_CODES.INVALID_CREDENTIALS,
|
||||
'invalid username or password',
|
||||
HttpStatus.UNAUTHORIZED,
|
||||
);
|
||||
}
|
||||
|
||||
const isValidPassword = await argon2.verify(user.passwordHash, body.password);
|
||||
if (!isValidPassword) {
|
||||
throw new AppException(
|
||||
ERROR_CODES.INVALID_CREDENTIALS,
|
||||
'invalid username or password',
|
||||
HttpStatus.UNAUTHORIZED,
|
||||
);
|
||||
}
|
||||
|
||||
await this.prisma.adminUser.update({
|
||||
where: { id: user.id },
|
||||
data: { lastLoginAt: new Date() },
|
||||
});
|
||||
|
||||
const tokens = await this.issueTokens({
|
||||
sub: user.id,
|
||||
username: user.username,
|
||||
type: 'access',
|
||||
});
|
||||
|
||||
return {
|
||||
...tokens,
|
||||
profile: {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
displayName: user.displayName ?? user.username,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async refresh(refreshToken: string) {
|
||||
const payload = await this.verifyRefreshToken(refreshToken);
|
||||
const user = await this.prisma.adminUser.findUnique({
|
||||
where: { id: payload.sub },
|
||||
});
|
||||
|
||||
if (!user || user.status !== AdminUserStatus.active) {
|
||||
throw new AppException(
|
||||
ERROR_CODES.TOKEN_INVALID,
|
||||
'token invalid or expired',
|
||||
HttpStatus.UNAUTHORIZED,
|
||||
);
|
||||
}
|
||||
|
||||
return this.issueTokens({
|
||||
sub: user.id,
|
||||
username: user.username,
|
||||
type: 'access',
|
||||
});
|
||||
}
|
||||
|
||||
async getMe(userId: string) {
|
||||
const user = await this.prisma.adminUser.findUnique({
|
||||
where: { id: userId },
|
||||
});
|
||||
|
||||
if (!user || user.status !== AdminUserStatus.active) {
|
||||
throw new AppException(
|
||||
ERROR_CODES.UNAUTHORIZED,
|
||||
'admin user not available',
|
||||
HttpStatus.UNAUTHORIZED,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
displayName: user.displayName ?? user.username,
|
||||
status: user.status,
|
||||
lastLoginAt: user.lastLoginAt,
|
||||
};
|
||||
}
|
||||
|
||||
async logout() {
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
}
|
||||
|
||||
private async issueTokens(payload: JwtPayload) {
|
||||
const accessSecret = this.configService.get<string>(
|
||||
'JWT_ACCESS_SECRET',
|
||||
'change_this_access_secret',
|
||||
);
|
||||
const refreshSecret = this.configService.get<string>(
|
||||
'JWT_REFRESH_SECRET',
|
||||
'change_this_refresh_secret',
|
||||
);
|
||||
const accessExpiresInRaw = this.configService.get<string>('JWT_ACCESS_EXPIRES_IN', '2h');
|
||||
const refreshExpiresInRaw = this.configService.get<string>('JWT_REFRESH_EXPIRES_IN', '7d');
|
||||
const accessExpiresIn = this.parseExpiresInSeconds(accessExpiresInRaw);
|
||||
const refreshExpiresIn = this.parseExpiresInSeconds(refreshExpiresInRaw);
|
||||
|
||||
const accessToken = await this.jwtService.signAsync(payload, {
|
||||
secret: accessSecret,
|
||||
expiresIn: accessExpiresIn,
|
||||
});
|
||||
const refreshToken = await this.jwtService.signAsync(
|
||||
{
|
||||
...payload,
|
||||
type: 'refresh' as const,
|
||||
},
|
||||
{
|
||||
secret: refreshSecret,
|
||||
expiresIn: refreshExpiresIn,
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
accessToken,
|
||||
refreshToken,
|
||||
expiresIn: accessExpiresIn,
|
||||
};
|
||||
}
|
||||
|
||||
private async verifyRefreshToken(token: string): Promise<JwtPayload> {
|
||||
try {
|
||||
const refreshSecret = this.configService.get<string>(
|
||||
'JWT_REFRESH_SECRET',
|
||||
'change_this_refresh_secret',
|
||||
);
|
||||
const payload = await this.jwtService.verifyAsync<JwtPayload>(token, {
|
||||
secret: refreshSecret,
|
||||
});
|
||||
|
||||
if (payload.type !== 'refresh') {
|
||||
throw new Error('invalid refresh token type');
|
||||
}
|
||||
|
||||
return payload;
|
||||
} catch {
|
||||
throw new AppException(
|
||||
ERROR_CODES.TOKEN_INVALID,
|
||||
'token invalid or expired',
|
||||
HttpStatus.UNAUTHORIZED,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private parseExpiresInSeconds(expiresIn: string): number {
|
||||
if (/^\d+$/.test(expiresIn)) {
|
||||
return Number(expiresIn);
|
||||
}
|
||||
if (expiresIn.endsWith('h')) {
|
||||
return Number(expiresIn.replace('h', '')) * 3600;
|
||||
}
|
||||
if (expiresIn.endsWith('m')) {
|
||||
return Number(expiresIn.replace('m', '')) * 60;
|
||||
}
|
||||
if (expiresIn.endsWith('d')) {
|
||||
return Number(expiresIn.replace('d', '')) * 86400;
|
||||
}
|
||||
return 7200;
|
||||
}
|
||||
}
|
||||
16
server/src/modules/admin-auth/dto/login.dto.ts
Normal file
16
server/src/modules/admin-auth/dto/login.dto.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsString, MaxLength, MinLength } from 'class-validator';
|
||||
|
||||
export class LoginDto {
|
||||
@ApiProperty({ example: 'admin' })
|
||||
@IsString()
|
||||
@MinLength(3)
|
||||
@MaxLength(64)
|
||||
username!: string;
|
||||
|
||||
@ApiProperty({ example: 'admin123456' })
|
||||
@IsString()
|
||||
@MinLength(6)
|
||||
@MaxLength(128)
|
||||
password!: string;
|
||||
}
|
||||
8
server/src/modules/admin-auth/dto/refresh-token.dto.ts
Normal file
8
server/src/modules/admin-auth/dto/refresh-token.dto.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsString } from 'class-validator';
|
||||
|
||||
export class RefreshTokenDto {
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
refreshToken!: string;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export interface JwtPayload {
|
||||
sub: string;
|
||||
username: string;
|
||||
type: 'access' | 'refresh';
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||
import type { JwtPayload } from '../interfaces/jwt-payload.interface';
|
||||
|
||||
@Injectable()
|
||||
export class AdminJwtStrategy extends PassportStrategy(Strategy, 'jwt') {
|
||||
constructor(configService: ConfigService) {
|
||||
super({
|
||||
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||
ignoreExpiration: false,
|
||||
secretOrKey: configService.get<string>('JWT_ACCESS_SECRET', 'change_this_access_secret'),
|
||||
});
|
||||
}
|
||||
|
||||
async validate(payload: JwtPayload): Promise<JwtPayload> {
|
||||
if (payload.type !== 'access') {
|
||||
throw new UnauthorizedException('invalid access token');
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
}
|
||||
67
server/src/modules/admin-tools/admin-tools.controller.ts
Normal file
67
server/src/modules/admin-tools/admin-tools.controller.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
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 { AdminToolsService } from './admin-tools.service';
|
||||
import { AdminToolsQueryDto } from './dto/admin-tools-query.dto';
|
||||
import { CreateToolDto } from './dto/create-tool.dto';
|
||||
import { UpdateAccessModeDto } from './dto/update-access-mode.dto';
|
||||
import { UpdateToolStatusDto } from './dto/update-tool-status.dto';
|
||||
import { UpdateToolDto } from './dto/update-tool.dto';
|
||||
|
||||
@ApiTags('admin-tools')
|
||||
@ApiBearerAuth('admin-access-token')
|
||||
@UseGuards(AdminJwtGuard)
|
||||
@UseInterceptors(AdminAuditInterceptor)
|
||||
@Controller('admin/tools')
|
||||
export class AdminToolsController {
|
||||
constructor(private readonly adminToolsService: AdminToolsService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'Admin query tools' })
|
||||
getTools(@Query() query: AdminToolsQueryDto) {
|
||||
return this.adminToolsService.getTools(query);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@Audit({ action: 'tool.create', resourceType: 'tool' })
|
||||
@ApiOperation({ summary: 'Admin create tool' })
|
||||
createTool(@Body() body: CreateToolDto) {
|
||||
return this.adminToolsService.createTool(body);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@ApiOperation({ summary: 'Admin get tool detail' })
|
||||
getToolById(@Param('id') id: string) {
|
||||
return this.adminToolsService.getToolById(id);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@Audit({ action: 'tool.update', resourceType: 'tool' })
|
||||
@ApiOperation({ summary: 'Admin update tool' })
|
||||
updateTool(@Param('id') id: string, @Body() body: UpdateToolDto) {
|
||||
return this.adminToolsService.updateTool(id, body);
|
||||
}
|
||||
|
||||
@Patch(':id/status')
|
||||
@Audit({ action: 'tool.update_status', resourceType: 'tool' })
|
||||
@ApiOperation({ summary: 'Admin update tool status' })
|
||||
updateToolStatus(@Param('id') id: string, @Body() body: UpdateToolStatusDto) {
|
||||
return this.adminToolsService.updateToolStatus(id, body.status);
|
||||
}
|
||||
|
||||
@Patch(':id/access-mode')
|
||||
@Audit({ action: 'tool.update_access_mode', resourceType: 'tool' })
|
||||
@ApiOperation({ summary: 'Admin update tool access mode' })
|
||||
updateAccessMode(@Param('id') id: string, @Body() body: UpdateAccessModeDto) {
|
||||
return this.adminToolsService.updateAccessMode(id, body);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@Audit({ action: 'tool.delete', resourceType: 'tool' })
|
||||
@ApiOperation({ summary: 'Admin soft-delete tool' })
|
||||
deleteTool(@Param('id') id: string) {
|
||||
return this.adminToolsService.deleteTool(id);
|
||||
}
|
||||
}
|
||||
11
server/src/modules/admin-tools/admin-tools.module.ts
Normal file
11
server/src/modules/admin-tools/admin-tools.module.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AdminAuditInterceptor } from '../../common/interceptors/audit/admin-audit.interceptor';
|
||||
import { AdminToolsController } from './admin-tools.controller';
|
||||
import { AdminToolsService } from './admin-tools.service';
|
||||
|
||||
@Module({
|
||||
controllers: [AdminToolsController],
|
||||
providers: [AdminToolsService, AdminAuditInterceptor],
|
||||
exports: [AdminToolsService],
|
||||
})
|
||||
export class AdminToolsModule {}
|
||||
484
server/src/modules/admin-tools/admin-tools.service.ts
Normal file
484
server/src/modules/admin-tools/admin-tools.service.ts
Normal file
@@ -0,0 +1,484 @@
|
||||
import { HttpStatus, Injectable } from '@nestjs/common';
|
||||
import { AccessMode, ArtifactStatus, Prisma, ToolStatus } from '@prisma/client';
|
||||
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 { AdminToolsQueryDto } from './dto/admin-tools-query.dto';
|
||||
import { CreateToolDto } from './dto/create-tool.dto';
|
||||
import { UpdateAccessModeDto } from './dto/update-access-mode.dto';
|
||||
import { UpdateToolDto } from './dto/update-tool.dto';
|
||||
|
||||
@Injectable()
|
||||
export class AdminToolsService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async getTools(query: AdminToolsQueryDto) {
|
||||
const page = query.page ?? 1;
|
||||
const pageSize = Math.min(query.pageSize ?? 10, 50);
|
||||
|
||||
const where: Prisma.ToolWhereInput = {
|
||||
isDeleted: false,
|
||||
};
|
||||
|
||||
if (query.query) {
|
||||
where.OR = [
|
||||
{ name: { contains: query.query } },
|
||||
{ description: { contains: query.query } },
|
||||
];
|
||||
}
|
||||
if (query.categoryId) {
|
||||
where.categoryId = query.categoryId;
|
||||
}
|
||||
if (query.status) {
|
||||
where.status = query.status;
|
||||
}
|
||||
if (query.accessMode) {
|
||||
where.accessMode = query.accessMode;
|
||||
}
|
||||
|
||||
const [total, tools] = await this.prisma.$transaction([
|
||||
this.prisma.tool.count({ where }),
|
||||
this.prisma.tool.findMany({
|
||||
where,
|
||||
include: {
|
||||
category: true,
|
||||
tags: {
|
||||
include: {
|
||||
tag: true,
|
||||
},
|
||||
},
|
||||
features: {
|
||||
orderBy: {
|
||||
sortOrder: 'asc',
|
||||
},
|
||||
},
|
||||
latestArtifact: true,
|
||||
},
|
||||
orderBy: {
|
||||
modifiedAt: 'desc',
|
||||
},
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
list: tools.map((tool) => this.mapTool(tool)),
|
||||
pagination: {
|
||||
page,
|
||||
pageSize,
|
||||
total,
|
||||
totalPages: Math.ceil(total / pageSize),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async createTool(body: CreateToolDto) {
|
||||
await this.assertCategoryExists(body.categoryId);
|
||||
await this.assertTagsExist(body.tags ?? []);
|
||||
|
||||
if ((body.status ?? ToolStatus.draft) === ToolStatus.published) {
|
||||
this.assertPublishInput(body.accessMode, body.openUrl, undefined);
|
||||
}
|
||||
|
||||
const toolId = this.generateBusinessId('tool');
|
||||
const slug = await this.ensureUniqueSlug(this.slugify(body.name));
|
||||
const updatedAt = this.getDateOnlyString();
|
||||
|
||||
await this.prisma.tool.create({
|
||||
data: {
|
||||
id: toolId,
|
||||
name: body.name.trim(),
|
||||
slug,
|
||||
categoryId: body.categoryId,
|
||||
description: body.description.trim(),
|
||||
rating: body.rating ?? 0,
|
||||
accessMode: body.accessMode,
|
||||
openUrl: body.accessMode === AccessMode.web ? body.openUrl ?? null : null,
|
||||
openInNewTab: body.openInNewTab ?? true,
|
||||
status: body.status ?? ToolStatus.draft,
|
||||
updatedAt,
|
||||
tags:
|
||||
body.tags && body.tags.length > 0
|
||||
? {
|
||||
createMany: {
|
||||
data: body.tags.map((tagId) => ({ tagId })),
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
features:
|
||||
body.features && body.features.length > 0
|
||||
? {
|
||||
createMany: {
|
||||
data: body.features.map((feature, index) => ({
|
||||
id: this.generateBusinessId('feat'),
|
||||
featureText: feature,
|
||||
sortOrder: (index + 1) * 10,
|
||||
})),
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
return this.getToolById(toolId);
|
||||
}
|
||||
|
||||
async getToolById(id: string) {
|
||||
const tool = await this.prisma.tool.findFirst({
|
||||
where: {
|
||||
id,
|
||||
isDeleted: false,
|
||||
},
|
||||
include: {
|
||||
category: true,
|
||||
tags: {
|
||||
include: {
|
||||
tag: true,
|
||||
},
|
||||
},
|
||||
features: {
|
||||
orderBy: {
|
||||
sortOrder: 'asc',
|
||||
},
|
||||
},
|
||||
latestArtifact: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!tool) {
|
||||
throw new AppException(ERROR_CODES.NOT_FOUND, 'tool not found', HttpStatus.NOT_FOUND);
|
||||
}
|
||||
|
||||
return this.mapTool(tool);
|
||||
}
|
||||
|
||||
async updateTool(id: string, body: UpdateToolDto) {
|
||||
const existingTool = await this.getToolEntity(id);
|
||||
|
||||
const nextAccessMode = body.accessMode ?? existingTool.accessMode;
|
||||
const nextOpenUrl =
|
||||
body.openUrl !== undefined
|
||||
? body.openUrl
|
||||
: nextAccessMode === AccessMode.web
|
||||
? existingTool.openUrl
|
||||
: null;
|
||||
const nextStatus = body.status ?? existingTool.status;
|
||||
|
||||
if (body.categoryId) {
|
||||
await this.assertCategoryExists(body.categoryId);
|
||||
}
|
||||
if (body.tags) {
|
||||
await this.assertTagsExist(body.tags);
|
||||
}
|
||||
|
||||
this.assertModeSwitchConstraint(existingTool.status, nextAccessMode, nextOpenUrl, existingTool);
|
||||
if (nextStatus === ToolStatus.published) {
|
||||
this.assertPublishInput(nextAccessMode, nextOpenUrl ?? undefined, existingTool.latestArtifact);
|
||||
}
|
||||
|
||||
const updatedAt = this.getDateOnlyString();
|
||||
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
await tx.tool.update({
|
||||
where: { id },
|
||||
data: {
|
||||
name: body.name?.trim(),
|
||||
categoryId: body.categoryId,
|
||||
description: body.description?.trim(),
|
||||
rating: body.rating,
|
||||
accessMode: body.accessMode,
|
||||
openUrl: body.openUrl,
|
||||
openInNewTab: body.openInNewTab,
|
||||
status: body.status,
|
||||
updatedAt,
|
||||
},
|
||||
});
|
||||
|
||||
if (body.tags) {
|
||||
await tx.toolTag.deleteMany({ where: { toolId: id } });
|
||||
if (body.tags.length > 0) {
|
||||
await tx.toolTag.createMany({
|
||||
data: body.tags.map((tagId) => ({
|
||||
toolId: id,
|
||||
tagId,
|
||||
})),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (body.features) {
|
||||
await tx.toolFeature.deleteMany({ where: { toolId: id } });
|
||||
if (body.features.length > 0) {
|
||||
await tx.toolFeature.createMany({
|
||||
data: body.features.map((feature, index) => ({
|
||||
id: this.generateBusinessId('feat'),
|
||||
toolId: id,
|
||||
featureText: feature,
|
||||
sortOrder: (index + 1) * 10,
|
||||
})),
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return this.getToolById(id);
|
||||
}
|
||||
|
||||
async updateToolStatus(id: string, status: ToolStatus) {
|
||||
const tool = await this.getToolEntity(id);
|
||||
if (status === ToolStatus.published) {
|
||||
this.assertPublishInput(tool.accessMode, tool.openUrl ?? undefined, tool.latestArtifact);
|
||||
}
|
||||
|
||||
await this.prisma.tool.update({
|
||||
where: { id },
|
||||
data: {
|
||||
status,
|
||||
updatedAt: this.getDateOnlyString(),
|
||||
},
|
||||
});
|
||||
|
||||
return this.getToolById(id);
|
||||
}
|
||||
|
||||
async updateAccessMode(id: string, body: UpdateAccessModeDto) {
|
||||
const tool = await this.getToolEntity(id);
|
||||
this.assertModeSwitchConstraint(
|
||||
tool.status,
|
||||
body.accessMode,
|
||||
body.openUrl,
|
||||
tool,
|
||||
tool.accessMode !== body.accessMode,
|
||||
);
|
||||
|
||||
await this.prisma.tool.update({
|
||||
where: { id },
|
||||
data: {
|
||||
accessMode: body.accessMode,
|
||||
openUrl: body.accessMode === AccessMode.web ? body.openUrl ?? null : null,
|
||||
openInNewTab: body.openInNewTab ?? tool.openInNewTab,
|
||||
updatedAt: this.getDateOnlyString(),
|
||||
},
|
||||
});
|
||||
|
||||
return this.getToolById(id);
|
||||
}
|
||||
|
||||
async deleteTool(id: string) {
|
||||
await this.getToolEntity(id);
|
||||
|
||||
await this.prisma.tool.update({
|
||||
where: { id },
|
||||
data: {
|
||||
isDeleted: true,
|
||||
status: ToolStatus.archived,
|
||||
updatedAt: this.getDateOnlyString(),
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
id,
|
||||
};
|
||||
}
|
||||
|
||||
private async getToolEntity(id: string) {
|
||||
const tool = await this.prisma.tool.findFirst({
|
||||
where: {
|
||||
id,
|
||||
isDeleted: false,
|
||||
},
|
||||
include: {
|
||||
category: true,
|
||||
latestArtifact: true,
|
||||
tags: {
|
||||
include: {
|
||||
tag: true,
|
||||
},
|
||||
},
|
||||
features: {
|
||||
orderBy: {
|
||||
sortOrder: 'asc',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!tool) {
|
||||
throw new AppException(ERROR_CODES.NOT_FOUND, 'tool not found', HttpStatus.NOT_FOUND);
|
||||
}
|
||||
|
||||
return tool;
|
||||
}
|
||||
|
||||
private async assertCategoryExists(categoryId: string) {
|
||||
const category = await this.prisma.category.findFirst({
|
||||
where: {
|
||||
id: categoryId,
|
||||
isDeleted: false,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!category) {
|
||||
throw new AppException(
|
||||
ERROR_CODES.NOT_FOUND,
|
||||
`category not found: ${categoryId}`,
|
||||
HttpStatus.NOT_FOUND,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async assertTagsExist(tagIds: string[]) {
|
||||
if (tagIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const count = await this.prisma.tag.count({
|
||||
where: {
|
||||
id: {
|
||||
in: tagIds,
|
||||
},
|
||||
isDeleted: false,
|
||||
},
|
||||
});
|
||||
|
||||
if (count !== tagIds.length) {
|
||||
throw new AppException(ERROR_CODES.VALIDATION_FAILED, 'contains unknown tag ids');
|
||||
}
|
||||
}
|
||||
|
||||
private assertPublishInput(
|
||||
accessMode: AccessMode,
|
||||
openUrl?: string,
|
||||
latestArtifact?: { status: ArtifactStatus } | null,
|
||||
) {
|
||||
if (accessMode === AccessMode.web) {
|
||||
if (!openUrl) {
|
||||
throw new AppException(
|
||||
ERROR_CODES.WEB_OPEN_URL_NOT_CONFIGURED,
|
||||
'openUrl is required for web mode publish',
|
||||
HttpStatus.CONFLICT,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!latestArtifact || latestArtifact.status !== ArtifactStatus.active) {
|
||||
throw new AppException(
|
||||
ERROR_CODES.ARTIFACT_NOT_AVAILABLE,
|
||||
'download mode tool requires one active latest artifact before publish',
|
||||
HttpStatus.CONFLICT,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private assertModeSwitchConstraint(
|
||||
currentStatus: ToolStatus,
|
||||
targetMode: AccessMode,
|
||||
openUrl: string | null | undefined,
|
||||
tool: { latestArtifact?: { status: ArtifactStatus } | null },
|
||||
isSwitching = false,
|
||||
) {
|
||||
if (targetMode === AccessMode.web && !openUrl) {
|
||||
throw new AppException(
|
||||
ERROR_CODES.WEB_OPEN_URL_NOT_CONFIGURED,
|
||||
'openUrl is required when switching to web mode',
|
||||
HttpStatus.CONFLICT,
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
isSwitching &&
|
||||
targetMode === AccessMode.download &&
|
||||
currentStatus === ToolStatus.published &&
|
||||
(!tool.latestArtifact || tool.latestArtifact.status !== ArtifactStatus.active)
|
||||
) {
|
||||
throw new AppException(
|
||||
ERROR_CODES.TOOL_ACCESS_MODE_MISMATCH,
|
||||
'published tool cannot switch to download mode without active artifact',
|
||||
HttpStatus.CONFLICT,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private mapTool(
|
||||
tool: Prisma.ToolGetPayload<{
|
||||
include: {
|
||||
category: true;
|
||||
tags: { include: { tag: true } };
|
||||
features: true;
|
||||
latestArtifact: true;
|
||||
};
|
||||
}>,
|
||||
) {
|
||||
return {
|
||||
id: tool.id,
|
||||
name: tool.name,
|
||||
slug: tool.slug,
|
||||
category: {
|
||||
id: tool.category.id,
|
||||
name: tool.category.name,
|
||||
},
|
||||
description: tool.description,
|
||||
rating: tool.rating,
|
||||
status: tool.status,
|
||||
accessMode: tool.accessMode,
|
||||
openUrl: tool.openUrl,
|
||||
openInNewTab: tool.openInNewTab,
|
||||
downloadCount: tool.downloadCount,
|
||||
openCount: tool.openCount,
|
||||
latestArtifact: tool.latestArtifact
|
||||
? {
|
||||
id: tool.latestArtifact.id,
|
||||
version: tool.latestArtifact.version,
|
||||
status: tool.latestArtifact.status,
|
||||
fileName: tool.latestArtifact.fileName,
|
||||
fileSizeBytes: tool.latestArtifact.fileSizeBytes,
|
||||
}
|
||||
: null,
|
||||
tags: tool.tags.map((item) => ({
|
||||
id: item.tag.id,
|
||||
name: item.tag.name,
|
||||
})),
|
||||
features: tool.features.map((item) => item.featureText),
|
||||
updatedAt: tool.updatedAt,
|
||||
createdAt: tool.createdAt,
|
||||
modifiedAt: tool.modifiedAt,
|
||||
};
|
||||
}
|
||||
|
||||
private generateBusinessId(prefix: string): string {
|
||||
return `${prefix}_${randomUUID().replace(/-/g, '').slice(0, 12)}`;
|
||||
}
|
||||
|
||||
private getDateOnlyString(): string {
|
||||
return new Date().toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
private slugify(value: string): string {
|
||||
const slug = value
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/(^-|-$)/g, '')
|
||||
.slice(0, 80);
|
||||
return slug || 'tool';
|
||||
}
|
||||
|
||||
private async ensureUniqueSlug(baseSlug: string): Promise<string> {
|
||||
let slug = baseSlug;
|
||||
let suffix = 1;
|
||||
|
||||
while (await this.prisma.tool.findUnique({ where: { slug }, select: { id: true } })) {
|
||||
slug = `${baseSlug}-${suffix}`;
|
||||
suffix += 1;
|
||||
}
|
||||
|
||||
return slug;
|
||||
}
|
||||
}
|
||||
41
server/src/modules/admin-tools/dto/admin-tools-query.dto.ts
Normal file
41
server/src/modules/admin-tools/dto/admin-tools-query.dto.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { AccessMode, ToolStatus } from '@prisma/client';
|
||||
import { Type } from 'class-transformer';
|
||||
import { IsEnum, IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
|
||||
|
||||
export class AdminToolsQueryDto {
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
query?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
categoryId?: string;
|
||||
|
||||
@ApiPropertyOptional({ enum: ToolStatus })
|
||||
@IsOptional()
|
||||
@IsEnum(ToolStatus)
|
||||
status?: ToolStatus;
|
||||
|
||||
@ApiPropertyOptional({ enum: AccessMode })
|
||||
@IsOptional()
|
||||
@IsEnum(AccessMode)
|
||||
accessMode?: AccessMode;
|
||||
|
||||
@ApiPropertyOptional({ default: 1 })
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
page?: number;
|
||||
|
||||
@ApiPropertyOptional({ default: 10, maximum: 50 })
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(50)
|
||||
pageSize?: number;
|
||||
}
|
||||
83
server/src/modules/admin-tools/dto/create-tool.dto.ts
Normal file
83
server/src/modules/admin-tools/dto/create-tool.dto.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { AccessMode, ToolStatus } from '@prisma/client';
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
ArrayMaxSize,
|
||||
ArrayUnique,
|
||||
IsArray,
|
||||
IsBoolean,
|
||||
IsEnum,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUrl,
|
||||
Max,
|
||||
MaxLength,
|
||||
Min,
|
||||
MinLength,
|
||||
ValidateIf,
|
||||
} from 'class-validator';
|
||||
|
||||
export class CreateToolDto {
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@MinLength(2)
|
||||
@MaxLength(120)
|
||||
name!: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
categoryId!: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@MinLength(10)
|
||||
@MaxLength(2000)
|
||||
description!: string;
|
||||
|
||||
@ApiPropertyOptional({ minimum: 0, maximum: 5, default: 0 })
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
@Max(5)
|
||||
rating?: number;
|
||||
|
||||
@ApiPropertyOptional({ type: [String], description: 'Tag ids' })
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ArrayUnique()
|
||||
@ArrayMaxSize(20)
|
||||
@IsString({ each: true })
|
||||
tags?: string[];
|
||||
|
||||
@ApiPropertyOptional({ type: [String] })
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ArrayMaxSize(20)
|
||||
@IsString({ each: true })
|
||||
features?: string[];
|
||||
|
||||
@ApiProperty({ enum: AccessMode, default: AccessMode.download })
|
||||
@IsEnum(AccessMode)
|
||||
accessMode!: AccessMode;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Required when accessMode=web' })
|
||||
@ValidateIf((obj: CreateToolDto) => obj.accessMode === AccessMode.web)
|
||||
@IsString()
|
||||
@IsUrl({
|
||||
require_protocol: true,
|
||||
})
|
||||
openUrl?: string;
|
||||
|
||||
@ApiPropertyOptional({ default: true })
|
||||
@IsOptional()
|
||||
@Type(() => Boolean)
|
||||
@IsBoolean()
|
||||
openInNewTab?: boolean;
|
||||
|
||||
@ApiPropertyOptional({ enum: ToolStatus, default: ToolStatus.draft })
|
||||
@IsOptional()
|
||||
@IsEnum(ToolStatus)
|
||||
status?: ToolStatus;
|
||||
}
|
||||
24
server/src/modules/admin-tools/dto/update-access-mode.dto.ts
Normal file
24
server/src/modules/admin-tools/dto/update-access-mode.dto.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { AccessMode } from '@prisma/client';
|
||||
import { Type } from 'class-transformer';
|
||||
import { IsBoolean, IsEnum, IsOptional, IsString, IsUrl, ValidateIf } from 'class-validator';
|
||||
|
||||
export class UpdateAccessModeDto {
|
||||
@ApiProperty({ enum: AccessMode })
|
||||
@IsEnum(AccessMode)
|
||||
accessMode!: AccessMode;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Required when accessMode=web' })
|
||||
@ValidateIf((obj: UpdateAccessModeDto) => obj.accessMode === AccessMode.web)
|
||||
@IsString()
|
||||
@IsUrl({
|
||||
require_protocol: true,
|
||||
})
|
||||
openUrl?: string;
|
||||
|
||||
@ApiPropertyOptional({ default: true })
|
||||
@IsOptional()
|
||||
@Type(() => Boolean)
|
||||
@IsBoolean()
|
||||
openInNewTab?: boolean;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { ToolStatus } from '@prisma/client';
|
||||
import { IsEnum } from 'class-validator';
|
||||
|
||||
export class UpdateToolStatusDto {
|
||||
@ApiProperty({ enum: ToolStatus })
|
||||
@IsEnum(ToolStatus)
|
||||
status!: ToolStatus;
|
||||
}
|
||||
4
server/src/modules/admin-tools/dto/update-tool.dto.ts
Normal file
4
server/src/modules/admin-tools/dto/update-tool.dto.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { PartialType } from '@nestjs/swagger';
|
||||
import { CreateToolDto } from './create-tool.dto';
|
||||
|
||||
export class UpdateToolDto extends PartialType(CreateToolDto) {}
|
||||
15
server/src/modules/categories/categories.controller.ts
Normal file
15
server/src/modules/categories/categories.controller.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { Controller, Get } from '@nestjs/common';
|
||||
import { ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { CategoriesService } from './categories.service';
|
||||
|
||||
@ApiTags('public-categories')
|
||||
@Controller('categories')
|
||||
export class CategoriesController {
|
||||
constructor(private readonly categoriesService: CategoriesService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'Get categories with tool count' })
|
||||
getCategories() {
|
||||
return this.categoriesService.getCategories();
|
||||
}
|
||||
}
|
||||
9
server/src/modules/categories/categories.module.ts
Normal file
9
server/src/modules/categories/categories.module.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { CategoriesController } from './categories.controller';
|
||||
import { CategoriesService } from './categories.service';
|
||||
|
||||
@Module({
|
||||
controllers: [CategoriesController],
|
||||
providers: [CategoriesService],
|
||||
})
|
||||
export class CategoriesModule {}
|
||||
37
server/src/modules/categories/categories.service.ts
Normal file
37
server/src/modules/categories/categories.service.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { ToolStatus } from '@prisma/client';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
|
||||
@Injectable()
|
||||
export class CategoriesService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async getCategories() {
|
||||
const categories = await this.prisma.category.findMany({
|
||||
where: {
|
||||
isDeleted: false,
|
||||
},
|
||||
include: {
|
||||
tools: {
|
||||
where: {
|
||||
isDeleted: false,
|
||||
status: ToolStatus.published,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
sortOrder: 'asc',
|
||||
},
|
||||
});
|
||||
|
||||
return categories.map((category) => ({
|
||||
id: category.id,
|
||||
name: category.name,
|
||||
sortOrder: category.sortOrder,
|
||||
toolCount: category.tools.length,
|
||||
}));
|
||||
}
|
||||
}
|
||||
21
server/src/modules/downloads/downloads.controller.ts
Normal file
21
server/src/modules/downloads/downloads.controller.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { Controller, Get, Param, Req, Res } from '@nestjs/common';
|
||||
import { ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import type { Request, Response } from 'express';
|
||||
import type { RequestWithContext } from '../../common/interfaces/request-with-context.interface';
|
||||
import { DownloadsService } from './downloads.service';
|
||||
|
||||
@ApiTags('public-downloads')
|
||||
@Controller('downloads')
|
||||
export class DownloadsController {
|
||||
constructor(private readonly downloadsService: DownloadsService) {}
|
||||
|
||||
@Get(':ticket')
|
||||
@ApiOperation({ summary: 'Consume ticket and stream artifact file' })
|
||||
async consumeTicket(
|
||||
@Param('ticket') ticket: string,
|
||||
@Req() request: Request,
|
||||
@Res() response: Response,
|
||||
) {
|
||||
await this.downloadsService.consumeTicketAndStream(ticket, request as RequestWithContext, response);
|
||||
}
|
||||
}
|
||||
11
server/src/modules/downloads/downloads.module.ts
Normal file
11
server/src/modules/downloads/downloads.module.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { GitlabStorageModule } from '../gitlab-storage/gitlab-storage.module';
|
||||
import { DownloadsController } from './downloads.controller';
|
||||
import { DownloadsService } from './downloads.service';
|
||||
|
||||
@Module({
|
||||
imports: [GitlabStorageModule],
|
||||
controllers: [DownloadsController],
|
||||
providers: [DownloadsService],
|
||||
})
|
||||
export class DownloadsModule {}
|
||||
144
server/src/modules/downloads/downloads.service.ts
Normal file
144
server/src/modules/downloads/downloads.service.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
import { HttpStatus, Injectable } from '@nestjs/common';
|
||||
import { ArtifactStatus, DownloadRecordStatus, ToolStatus } from '@prisma/client';
|
||||
import type { Response } from 'express';
|
||||
import { pipeline } from 'stream/promises';
|
||||
import { ERROR_CODES } from '../../common/constants/error-codes';
|
||||
import { AppException } from '../../common/exceptions/app.exception';
|
||||
import type { RequestWithContext } from '../../common/interfaces/request-with-context.interface';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { GitlabStorageService } from '../gitlab-storage/gitlab-storage.service';
|
||||
|
||||
@Injectable()
|
||||
export class DownloadsService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly gitlabStorageService: GitlabStorageService,
|
||||
) {}
|
||||
|
||||
async consumeTicketAndStream(ticket: string, request: RequestWithContext, response: Response) {
|
||||
const now = new Date();
|
||||
const ticketEntity = await this.prisma.downloadTicket.findUnique({
|
||||
where: { ticket },
|
||||
include: {
|
||||
tool: true,
|
||||
artifact: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!ticketEntity) {
|
||||
throw new AppException(
|
||||
ERROR_CODES.DOWNLOAD_TICKET_INVALID,
|
||||
'download ticket not found',
|
||||
HttpStatus.NOT_FOUND,
|
||||
);
|
||||
}
|
||||
|
||||
if (ticketEntity.consumedAt || ticketEntity.expiresAt < now) {
|
||||
throw new AppException(
|
||||
ERROR_CODES.DOWNLOAD_TICKET_INVALID,
|
||||
'download ticket expired or already consumed',
|
||||
HttpStatus.GONE,
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
ticketEntity.tool.status !== ToolStatus.published ||
|
||||
ticketEntity.tool.isDeleted ||
|
||||
ticketEntity.artifact.status !== ArtifactStatus.active
|
||||
) {
|
||||
throw new AppException(
|
||||
ERROR_CODES.ARTIFACT_NOT_AVAILABLE,
|
||||
'artifact is not available',
|
||||
HttpStatus.CONFLICT,
|
||||
);
|
||||
}
|
||||
|
||||
let downloadStream;
|
||||
try {
|
||||
downloadStream = await this.gitlabStorageService.getArtifactStream(ticketEntity.artifact);
|
||||
} catch (error) {
|
||||
await this.prisma.downloadRecord.create({
|
||||
data: {
|
||||
toolId: ticketEntity.toolId,
|
||||
artifactId: ticketEntity.artifactId,
|
||||
ticket: ticketEntity.ticket,
|
||||
clientIp: this.extractIp(request),
|
||||
userAgent: request.headers['user-agent'],
|
||||
channel: ticketEntity.channel,
|
||||
clientVersion: ticketEntity.clientVersion,
|
||||
status: DownloadRecordStatus.failed,
|
||||
errorMessage: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
const consumed = await tx.downloadTicket.updateMany({
|
||||
where: {
|
||||
ticket,
|
||||
consumedAt: null,
|
||||
expiresAt: {
|
||||
gte: now,
|
||||
},
|
||||
},
|
||||
data: {
|
||||
consumedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
if (consumed.count !== 1) {
|
||||
throw new AppException(
|
||||
ERROR_CODES.DOWNLOAD_TICKET_INVALID,
|
||||
'download ticket already consumed',
|
||||
HttpStatus.GONE,
|
||||
);
|
||||
}
|
||||
|
||||
await tx.downloadRecord.create({
|
||||
data: {
|
||||
toolId: ticketEntity.toolId,
|
||||
artifactId: ticketEntity.artifactId,
|
||||
ticket: ticketEntity.ticket,
|
||||
clientIp: this.extractIp(request),
|
||||
userAgent: request.headers['user-agent'],
|
||||
channel: ticketEntity.channel,
|
||||
clientVersion: ticketEntity.clientVersion,
|
||||
status: DownloadRecordStatus.success,
|
||||
},
|
||||
});
|
||||
|
||||
await tx.tool.update({
|
||||
where: { id: ticketEntity.toolId },
|
||||
data: {
|
||||
downloadCount: {
|
||||
increment: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
response.setHeader(
|
||||
'Content-Type',
|
||||
downloadStream.mimeType ?? 'application/octet-stream; charset=binary',
|
||||
);
|
||||
response.setHeader('Content-Length', String(downloadStream.fileSize));
|
||||
response.setHeader(
|
||||
'Content-Disposition',
|
||||
`attachment; filename="${encodeURIComponent(downloadStream.fileName)}"`,
|
||||
);
|
||||
|
||||
await pipeline(downloadStream.stream, response);
|
||||
}
|
||||
|
||||
private extractIp(request: RequestWithContext): string | undefined {
|
||||
const forwarded = request.headers['x-forwarded-for'];
|
||||
if (Array.isArray(forwarded) && forwarded.length > 0) {
|
||||
return forwarded[0]?.split(',')[0]?.trim();
|
||||
}
|
||||
if (typeof forwarded === 'string') {
|
||||
return forwarded.split(',')[0]?.trim();
|
||||
}
|
||||
return request.ip;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { GitlabStorageService } from './gitlab-storage.service';
|
||||
|
||||
@Module({
|
||||
providers: [GitlabStorageService],
|
||||
exports: [GitlabStorageService],
|
||||
})
|
||||
export class GitlabStorageModule {}
|
||||
150
server/src/modules/gitlab-storage/gitlab-storage.service.ts
Normal file
150
server/src/modules/gitlab-storage/gitlab-storage.service.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
import { HttpStatus, Injectable } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import type { ToolArtifact } from '@prisma/client';
|
||||
import { createReadStream, existsSync } from 'fs';
|
||||
import { mkdir, writeFile } from 'fs/promises';
|
||||
import { dirname, resolve } from 'path';
|
||||
import { Readable } from 'stream';
|
||||
import { ERROR_CODES } from '../../common/constants/error-codes';
|
||||
import { AppException } from '../../common/exceptions/app.exception';
|
||||
|
||||
export interface ArtifactDownloadStream {
|
||||
stream: NodeJS.ReadableStream;
|
||||
fileName: string;
|
||||
mimeType?: string;
|
||||
fileSize: number;
|
||||
}
|
||||
|
||||
export interface ArtifactUploadInput {
|
||||
toolId: string;
|
||||
version: string;
|
||||
fileName: string;
|
||||
mimeType?: string;
|
||||
buffer: Buffer;
|
||||
}
|
||||
|
||||
export interface ArtifactUploadResult {
|
||||
gitlabProjectId: number;
|
||||
gitlabPackageName: string;
|
||||
gitlabPackageVersion: string;
|
||||
gitlabFilePath: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class GitlabStorageService {
|
||||
constructor(private readonly configService: ConfigService) {}
|
||||
|
||||
async getArtifactStream(artifact: ToolArtifact): Promise<ArtifactDownloadStream> {
|
||||
const gitlabApiBase = this.configService.get<string>('GITLAB_API_BASE');
|
||||
const gitlabToken = this.configService.get<string>('GITLAB_TOKEN');
|
||||
|
||||
if (gitlabApiBase && gitlabToken && artifact.gitlabProjectId > 0) {
|
||||
return this.downloadFromGitlab(artifact, gitlabApiBase, gitlabToken);
|
||||
}
|
||||
|
||||
return this.readFromLocalStorage(artifact);
|
||||
}
|
||||
|
||||
async uploadArtifact(input: ArtifactUploadInput): Promise<ArtifactUploadResult> {
|
||||
const gitlabApiBase = this.configService.get<string>('GITLAB_API_BASE');
|
||||
const gitlabToken = this.configService.get<string>('GITLAB_TOKEN');
|
||||
const projectId = Number(this.configService.get<string>('GITLAB_PROJECT_ID', '0'));
|
||||
const packagePrefix = this.configService.get<string>('GITLAB_PACKAGE_NAME_PREFIX', 'toolsshow');
|
||||
const packageName = `${packagePrefix}/${input.toolId}`;
|
||||
|
||||
if (gitlabApiBase && gitlabToken && projectId > 0) {
|
||||
const url = `${gitlabApiBase}/projects/${encodeURIComponent(
|
||||
String(projectId),
|
||||
)}/packages/generic/${encodeURIComponent(packageName)}/${encodeURIComponent(
|
||||
input.version,
|
||||
)}/${encodeURIComponent(input.fileName)}`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'PRIVATE-TOKEN': gitlabToken,
|
||||
'Content-Type': input.mimeType ?? 'application/octet-stream',
|
||||
},
|
||||
body: input.buffer as unknown as BodyInit,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new AppException(
|
||||
ERROR_CODES.GITLAB_UPLOAD_FAILED,
|
||||
'failed to upload artifact to GitLab',
|
||||
HttpStatus.BAD_GATEWAY,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
gitlabProjectId: projectId,
|
||||
gitlabPackageName: packageName,
|
||||
gitlabPackageVersion: input.version,
|
||||
gitlabFilePath: `${packageName}/${input.version}/${input.fileName}`,
|
||||
};
|
||||
}
|
||||
|
||||
const localRelativePath = `storage/uploads/${input.toolId}/${input.version}/${input.fileName}`;
|
||||
const localAbsolutePath = resolve(process.cwd(), localRelativePath);
|
||||
await mkdir(dirname(localAbsolutePath), { recursive: true });
|
||||
await writeFile(localAbsolutePath, input.buffer);
|
||||
|
||||
return {
|
||||
gitlabProjectId: 0,
|
||||
gitlabPackageName: packageName,
|
||||
gitlabPackageVersion: input.version,
|
||||
gitlabFilePath: localRelativePath.replace(/\\/g, '/'),
|
||||
};
|
||||
}
|
||||
|
||||
private async downloadFromGitlab(
|
||||
artifact: ToolArtifact,
|
||||
gitlabApiBase: string,
|
||||
gitlabToken: string,
|
||||
): Promise<ArtifactDownloadStream> {
|
||||
const url = `${gitlabApiBase}/projects/${encodeURIComponent(
|
||||
String(artifact.gitlabProjectId),
|
||||
)}/packages/generic/${encodeURIComponent(artifact.gitlabPackageName)}/${encodeURIComponent(
|
||||
artifact.gitlabPackageVersion,
|
||||
)}/${encodeURIComponent(artifact.fileName)}`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
'PRIVATE-TOKEN': gitlabToken,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok || !response.body) {
|
||||
throw new AppException(
|
||||
ERROR_CODES.GITLAB_DOWNLOAD_FAILED,
|
||||
'failed to download artifact from GitLab',
|
||||
HttpStatus.BAD_GATEWAY,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
stream: Readable.fromWeb(response.body as any),
|
||||
fileName: artifact.fileName,
|
||||
mimeType: artifact.mimeType ?? undefined,
|
||||
fileSize: artifact.fileSizeBytes,
|
||||
};
|
||||
}
|
||||
|
||||
private readFromLocalStorage(artifact: ToolArtifact): ArtifactDownloadStream {
|
||||
const filePath = resolve(process.cwd(), artifact.gitlabFilePath);
|
||||
if (!existsSync(filePath)) {
|
||||
throw new AppException(
|
||||
ERROR_CODES.GITLAB_DOWNLOAD_FAILED,
|
||||
`artifact file not found: ${artifact.gitlabFilePath}`,
|
||||
HttpStatus.NOT_FOUND,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
stream: createReadStream(filePath),
|
||||
fileName: artifact.fileName,
|
||||
mimeType: artifact.mimeType ?? undefined,
|
||||
fileSize: artifact.fileSizeBytes,
|
||||
};
|
||||
}
|
||||
}
|
||||
14
server/src/modules/health/health.controller.ts
Normal file
14
server/src/modules/health/health.controller.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { Controller, Get } from '@nestjs/common';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
import { HealthService } from './health.service';
|
||||
|
||||
@ApiTags('health')
|
||||
@Controller('health')
|
||||
export class HealthController {
|
||||
constructor(private readonly healthService: HealthService) {}
|
||||
|
||||
@Get()
|
||||
getHealth() {
|
||||
return this.healthService.getHealth();
|
||||
}
|
||||
}
|
||||
9
server/src/modules/health/health.module.ts
Normal file
9
server/src/modules/health/health.module.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { HealthController } from './health.controller';
|
||||
import { HealthService } from './health.service';
|
||||
|
||||
@Module({
|
||||
controllers: [HealthController],
|
||||
providers: [HealthService],
|
||||
})
|
||||
export class HealthModule {}
|
||||
16
server/src/modules/health/health.service.ts
Normal file
16
server/src/modules/health/health.service.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
|
||||
@Injectable()
|
||||
export class HealthService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async getHealth(): Promise<{ status: 'ok'; database: 'up'; timestamp: string }> {
|
||||
await this.prisma.$queryRaw`SELECT 1`;
|
||||
return {
|
||||
status: 'ok',
|
||||
database: 'up',
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
15
server/src/modules/keywords/keywords.controller.ts
Normal file
15
server/src/modules/keywords/keywords.controller.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { Controller, Get } from '@nestjs/common';
|
||||
import { ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { KeywordsService } from './keywords.service';
|
||||
|
||||
@ApiTags('public-keywords')
|
||||
@Controller('keywords')
|
||||
export class KeywordsController {
|
||||
constructor(private readonly keywordsService: KeywordsService) {}
|
||||
|
||||
@Get('hot')
|
||||
@ApiOperation({ summary: 'Get hot keywords' })
|
||||
getHotKeywords() {
|
||||
return this.keywordsService.getHotKeywords();
|
||||
}
|
||||
}
|
||||
9
server/src/modules/keywords/keywords.module.ts
Normal file
9
server/src/modules/keywords/keywords.module.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { KeywordsController } from './keywords.controller';
|
||||
import { KeywordsService } from './keywords.service';
|
||||
|
||||
@Module({
|
||||
controllers: [KeywordsController],
|
||||
providers: [KeywordsService],
|
||||
})
|
||||
export class KeywordsModule {}
|
||||
24
server/src/modules/keywords/keywords.service.ts
Normal file
24
server/src/modules/keywords/keywords.service.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
|
||||
@Injectable()
|
||||
export class KeywordsService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async getHotKeywords() {
|
||||
const keywords = await this.prisma.hotKeyword.findMany({
|
||||
where: {
|
||||
isActive: true,
|
||||
},
|
||||
orderBy: {
|
||||
sortOrder: 'asc',
|
||||
},
|
||||
});
|
||||
|
||||
return keywords.map((item) => ({
|
||||
id: item.id,
|
||||
keyword: item.keyword,
|
||||
sortOrder: item.sortOrder,
|
||||
}));
|
||||
}
|
||||
}
|
||||
15
server/src/modules/overview/overview.controller.ts
Normal file
15
server/src/modules/overview/overview.controller.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { Controller, Get } from '@nestjs/common';
|
||||
import { ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { OverviewService } from './overview.service';
|
||||
|
||||
@ApiTags('public-overview')
|
||||
@Controller('overview')
|
||||
export class OverviewController {
|
||||
constructor(private readonly overviewService: OverviewService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'Get site overview KPIs' })
|
||||
getOverview() {
|
||||
return this.overviewService.getOverview();
|
||||
}
|
||||
}
|
||||
9
server/src/modules/overview/overview.module.ts
Normal file
9
server/src/modules/overview/overview.module.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { OverviewController } from './overview.controller';
|
||||
import { OverviewService } from './overview.service';
|
||||
|
||||
@Module({
|
||||
controllers: [OverviewController],
|
||||
providers: [OverviewService],
|
||||
})
|
||||
export class OverviewModule {}
|
||||
41
server/src/modules/overview/overview.service.ts
Normal file
41
server/src/modules/overview/overview.service.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { ToolStatus } from '@prisma/client';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
|
||||
@Injectable()
|
||||
export class OverviewService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async getOverview() {
|
||||
const [toolTotal, categoryTotal, counters] = await this.prisma.$transaction([
|
||||
this.prisma.tool.count({
|
||||
where: {
|
||||
isDeleted: false,
|
||||
status: ToolStatus.published,
|
||||
},
|
||||
}),
|
||||
this.prisma.category.count({
|
||||
where: {
|
||||
isDeleted: false,
|
||||
},
|
||||
}),
|
||||
this.prisma.tool.aggregate({
|
||||
where: {
|
||||
isDeleted: false,
|
||||
status: ToolStatus.published,
|
||||
},
|
||||
_sum: {
|
||||
downloadCount: true,
|
||||
openCount: true,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
toolTotal,
|
||||
categoryTotal,
|
||||
downloadTotal: counters._sum.downloadCount ?? 0,
|
||||
openTotal: counters._sum.openCount ?? 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
43
server/src/modules/tools/dto/get-tools-query.dto.ts
Normal file
43
server/src/modules/tools/dto/get-tools-query.dto.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Transform, Type } from 'class-transformer';
|
||||
import { IsEnum, IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
|
||||
|
||||
export enum ToolSortBy {
|
||||
popular = 'popular',
|
||||
latest = 'latest',
|
||||
rating = 'rating',
|
||||
name = 'name',
|
||||
}
|
||||
|
||||
export class GetToolsQueryDto {
|
||||
@ApiPropertyOptional({ description: 'Search keyword' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
|
||||
query?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Category id or all', default: 'all' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
category?: string;
|
||||
|
||||
@ApiPropertyOptional({ enum: ToolSortBy, default: ToolSortBy.latest })
|
||||
@IsOptional()
|
||||
@IsEnum(ToolSortBy)
|
||||
sortBy?: ToolSortBy;
|
||||
|
||||
@ApiPropertyOptional({ default: 1 })
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
page?: number;
|
||||
|
||||
@ApiPropertyOptional({ default: 6, maximum: 50 })
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(50)
|
||||
pageSize?: number;
|
||||
}
|
||||
22
server/src/modules/tools/tools.controller.ts
Normal file
22
server/src/modules/tools/tools.controller.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { Controller, Get, Param, Query } from '@nestjs/common';
|
||||
import { ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { GetToolsQueryDto } from './dto/get-tools-query.dto';
|
||||
import { ToolsService } from './tools.service';
|
||||
|
||||
@ApiTags('public-tools')
|
||||
@Controller('tools')
|
||||
export class ToolsController {
|
||||
constructor(private readonly toolsService: ToolsService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'Query tools' })
|
||||
getTools(@Query() query: GetToolsQueryDto) {
|
||||
return this.toolsService.getTools(query);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@ApiOperation({ summary: 'Get tool detail' })
|
||||
getToolDetail(@Param('id') id: string) {
|
||||
return this.toolsService.getToolDetail(id);
|
||||
}
|
||||
}
|
||||
10
server/src/modules/tools/tools.module.ts
Normal file
10
server/src/modules/tools/tools.module.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ToolsController } from './tools.controller';
|
||||
import { ToolsService } from './tools.service';
|
||||
|
||||
@Module({
|
||||
controllers: [ToolsController],
|
||||
providers: [ToolsService],
|
||||
exports: [ToolsService],
|
||||
})
|
||||
export class ToolsModule {}
|
||||
166
server/src/modules/tools/tools.service.ts
Normal file
166
server/src/modules/tools/tools.service.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { ArtifactStatus, Prisma, ToolStatus } from '@prisma/client';
|
||||
import { ERROR_CODES } from '../../common/constants/error-codes';
|
||||
import { AppException } from '../../common/exceptions/app.exception';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { GetToolsQueryDto, ToolSortBy } from './dto/get-tools-query.dto';
|
||||
|
||||
@Injectable()
|
||||
export class ToolsService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async getTools(query: GetToolsQueryDto) {
|
||||
const page = query.page ?? 1;
|
||||
const pageSize = Math.min(query.pageSize ?? 6, 50);
|
||||
const sortBy = query.sortBy ?? ToolSortBy.latest;
|
||||
|
||||
const where: Prisma.ToolWhereInput = {
|
||||
isDeleted: false,
|
||||
status: ToolStatus.published,
|
||||
};
|
||||
|
||||
if (query.category && query.category !== 'all') {
|
||||
where.categoryId = query.category;
|
||||
}
|
||||
|
||||
if (query.query) {
|
||||
where.OR = [
|
||||
{ name: { contains: query.query } },
|
||||
{ description: { contains: query.query } },
|
||||
];
|
||||
}
|
||||
|
||||
const [total, tools] = await this.prisma.$transaction([
|
||||
this.prisma.tool.count({ where }),
|
||||
this.prisma.tool.findMany({
|
||||
where,
|
||||
include: {
|
||||
category: true,
|
||||
tags: {
|
||||
include: {
|
||||
tag: true,
|
||||
},
|
||||
},
|
||||
features: {
|
||||
orderBy: {
|
||||
sortOrder: 'asc',
|
||||
},
|
||||
},
|
||||
latestArtifact: true,
|
||||
},
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
orderBy: this.buildOrderBy(sortBy),
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
list: tools.map((tool) => {
|
||||
const hasArtifact = Boolean(
|
||||
tool.latestArtifact && tool.latestArtifact.status === ArtifactStatus.active,
|
||||
);
|
||||
|
||||
return {
|
||||
id: tool.id,
|
||||
name: tool.name,
|
||||
slug: tool.slug,
|
||||
category: {
|
||||
id: tool.category.id,
|
||||
name: tool.category.name,
|
||||
},
|
||||
description: tool.description,
|
||||
rating: tool.rating,
|
||||
downloadCount: tool.downloadCount,
|
||||
openCount: tool.openCount,
|
||||
latestVersion:
|
||||
tool.accessMode === 'download' && tool.latestArtifact ? tool.latestArtifact.version : null,
|
||||
accessMode: tool.accessMode,
|
||||
openUrl: tool.accessMode === 'web' ? tool.openUrl : null,
|
||||
hasArtifact: tool.accessMode === 'download' ? hasArtifact : false,
|
||||
tags: tool.tags.map((item) => item.tag.name),
|
||||
features: tool.features.map((item) => item.featureText),
|
||||
updatedAt: tool.updatedAt,
|
||||
};
|
||||
}),
|
||||
pagination: {
|
||||
page,
|
||||
pageSize,
|
||||
total,
|
||||
totalPages: Math.ceil(total / pageSize),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async getToolDetail(id: string) {
|
||||
const tool = await this.prisma.tool.findFirst({
|
||||
where: {
|
||||
id,
|
||||
isDeleted: false,
|
||||
status: ToolStatus.published,
|
||||
},
|
||||
include: {
|
||||
category: true,
|
||||
tags: {
|
||||
include: {
|
||||
tag: true,
|
||||
},
|
||||
},
|
||||
features: {
|
||||
orderBy: {
|
||||
sortOrder: 'asc',
|
||||
},
|
||||
},
|
||||
latestArtifact: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!tool) {
|
||||
throw new AppException(ERROR_CODES.NOT_FOUND, 'tool not found', 404);
|
||||
}
|
||||
|
||||
const detail = {
|
||||
id: tool.id,
|
||||
name: tool.name,
|
||||
slug: tool.slug,
|
||||
category: {
|
||||
id: tool.category.id,
|
||||
name: tool.category.name,
|
||||
},
|
||||
description: tool.description,
|
||||
rating: tool.rating,
|
||||
downloadCount: tool.downloadCount,
|
||||
openCount: tool.openCount,
|
||||
accessMode: tool.accessMode,
|
||||
tags: tool.tags.map((item) => item.tag.name),
|
||||
features: tool.features.map((item) => item.featureText),
|
||||
updatedAt: tool.updatedAt,
|
||||
openUrl: tool.accessMode === 'web' ? tool.openUrl : null,
|
||||
latestVersion:
|
||||
tool.accessMode === 'download' && tool.latestArtifact ? tool.latestArtifact.version : null,
|
||||
fileSize:
|
||||
tool.accessMode === 'download' && tool.latestArtifact
|
||||
? tool.latestArtifact.fileSizeBytes
|
||||
: null,
|
||||
downloadReady:
|
||||
tool.accessMode === 'download'
|
||||
? Boolean(tool.latestArtifact && tool.latestArtifact.status === ArtifactStatus.active)
|
||||
: false,
|
||||
};
|
||||
|
||||
return detail;
|
||||
}
|
||||
|
||||
private buildOrderBy(sortBy: ToolSortBy): Prisma.ToolOrderByWithRelationInput[] {
|
||||
switch (sortBy) {
|
||||
case ToolSortBy.popular:
|
||||
return [{ downloadCount: 'desc' }, { openCount: 'desc' }, { modifiedAt: 'desc' }];
|
||||
case ToolSortBy.rating:
|
||||
return [{ rating: 'desc' }, { modifiedAt: 'desc' }];
|
||||
case ToolSortBy.name:
|
||||
return [{ name: 'asc' }];
|
||||
case ToolSortBy.latest:
|
||||
default:
|
||||
return [{ modifiedAt: 'desc' }];
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user