update
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
PORT=3000
|
||||
DATABASE_URL="file:./dev.db"
|
||||
DATABASE_URL=file:./dev.db
|
||||
DOWNLOAD_TICKET_TTL_SEC=120
|
||||
|
||||
JWT_ACCESS_SECRET=change_this_access_secret
|
||||
|
||||
@@ -6,6 +6,7 @@ import { AdminArtifactsModule } from './modules/admin-artifacts/admin-artifacts.
|
||||
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 { AdminOverviewModule } from './modules/admin-overview/admin-overview.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';
|
||||
@@ -32,6 +33,7 @@ import { ToolsModule } from './modules/tools/tools.module';
|
||||
GitlabStorageModule,
|
||||
DownloadsModule,
|
||||
AdminAuthModule,
|
||||
AdminOverviewModule,
|
||||
AdminCategoriesModule,
|
||||
AdminTagsModule,
|
||||
AdminToolsModule,
|
||||
|
||||
@@ -2,6 +2,7 @@ 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 { TrackToolInteractionDto } from './dto/track-tool-interaction.dto';
|
||||
import { AccessService } from './access.service';
|
||||
|
||||
@ApiTags('public-launch')
|
||||
@@ -18,4 +19,14 @@ export class AccessController {
|
||||
) {
|
||||
return this.accessService.launchTool(id, body, request);
|
||||
}
|
||||
|
||||
@Post(':id/interaction')
|
||||
@ApiOperation({ summary: 'Track tool interaction (open/download) asynchronously' })
|
||||
trackInteraction(
|
||||
@Param('id') id: string,
|
||||
@Body() body: TrackToolInteractionDto,
|
||||
@Req() request: RequestWithContext,
|
||||
) {
|
||||
return this.accessService.trackInteraction(id, body, request);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ 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';
|
||||
import { ToolInteractionAction, TrackToolInteractionDto } from './dto/track-tool-interaction.dto';
|
||||
|
||||
@Injectable()
|
||||
export class AccessService {
|
||||
@@ -40,23 +41,6 @@ export class AccessService {
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -64,6 +48,14 @@ export class AccessService {
|
||||
};
|
||||
}
|
||||
|
||||
if (tool.openUrl) {
|
||||
return {
|
||||
mode: 'download' 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,
|
||||
@@ -93,9 +85,63 @@ export class AccessService {
|
||||
ticket,
|
||||
expiresInSec: ttlSec,
|
||||
actionUrl: `/api/v1/downloads/${ticket}`,
|
||||
openIn: 'new_tab' as const,
|
||||
};
|
||||
}
|
||||
|
||||
async trackInteraction(
|
||||
toolId: string,
|
||||
body: TrackToolInteractionDto,
|
||||
request: RequestWithContext,
|
||||
) {
|
||||
const tool = await this.prisma.tool.findFirst({
|
||||
where: {
|
||||
id: toolId,
|
||||
isDeleted: false,
|
||||
status: ToolStatus.published,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!tool) {
|
||||
throw new AppException(ERROR_CODES.NOT_FOUND, 'tool not found', HttpStatus.NOT_FOUND);
|
||||
}
|
||||
|
||||
if (body.action === ToolInteractionAction.open) {
|
||||
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 { success: true };
|
||||
}
|
||||
|
||||
await this.prisma.tool.update({
|
||||
where: { id: tool.id },
|
||||
data: {
|
||||
downloadCount: {
|
||||
increment: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
private extractIp(request: RequestWithContext): string | undefined {
|
||||
const forwarded = request.headers['x-forwarded-for'];
|
||||
if (Array.isArray(forwarded) && forwarded.length > 0) {
|
||||
|
||||
25
server/src/modules/access/dto/track-tool-interaction.dto.ts
Normal file
25
server/src/modules/access/dto/track-tool-interaction.dto.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsEnum, IsOptional, IsString, MaxLength } from 'class-validator';
|
||||
|
||||
export enum ToolInteractionAction {
|
||||
open = 'open',
|
||||
download = 'download',
|
||||
}
|
||||
|
||||
export class TrackToolInteractionDto {
|
||||
@ApiProperty({ enum: ToolInteractionAction })
|
||||
@IsEnum(ToolInteractionAction)
|
||||
action!: ToolInteractionAction;
|
||||
|
||||
@ApiPropertyOptional({ example: 'official' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(32)
|
||||
channel?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'web-1.0.0' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(64)
|
||||
clientVersion?: string;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { Controller, Get, UseGuards } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { AdminJwtGuard } from '../../common/guards/admin-jwt.guard';
|
||||
import { AdminOverviewService } from './admin-overview.service';
|
||||
|
||||
@ApiTags('admin-overview')
|
||||
@ApiBearerAuth('admin-access-token')
|
||||
@UseGuards(AdminJwtGuard)
|
||||
@Controller('admin/overview')
|
||||
export class AdminOverviewController {
|
||||
constructor(private readonly adminOverviewService: AdminOverviewService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'Get admin dashboard overview metrics' })
|
||||
getOverview() {
|
||||
return this.adminOverviewService.getOverview();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AdminOverviewController } from './admin-overview.controller';
|
||||
import { AdminOverviewService } from './admin-overview.service';
|
||||
|
||||
@Module({
|
||||
controllers: [AdminOverviewController],
|
||||
providers: [AdminOverviewService],
|
||||
})
|
||||
export class AdminOverviewModule {}
|
||||
300
server/src/modules/admin-overview/admin-overview.service.ts
Normal file
300
server/src/modules/admin-overview/admin-overview.service.ts
Normal file
@@ -0,0 +1,300 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { AccessMode, ArtifactStatus, DownloadRecordStatus, ToolStatus } from '@prisma/client';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
|
||||
type DailyRange = {
|
||||
date: string;
|
||||
start: Date;
|
||||
end: Date;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class AdminOverviewService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async getOverview() {
|
||||
const [
|
||||
categoryTotal,
|
||||
tagTotal,
|
||||
artifactTotal,
|
||||
activeArtifactTotal,
|
||||
auditLogTotal,
|
||||
downloadReadyToolTotal,
|
||||
toolStatusBuckets,
|
||||
accessModeBuckets,
|
||||
publishedTraffic,
|
||||
publishedTools,
|
||||
dailyActivity,
|
||||
] = await Promise.all([
|
||||
this.prisma.category.count({
|
||||
where: {
|
||||
isDeleted: false,
|
||||
},
|
||||
}),
|
||||
this.prisma.tag.count({
|
||||
where: {
|
||||
isDeleted: false,
|
||||
},
|
||||
}),
|
||||
this.prisma.toolArtifact.count({
|
||||
where: {
|
||||
status: {
|
||||
not: ArtifactStatus.deleted,
|
||||
},
|
||||
},
|
||||
}),
|
||||
this.prisma.toolArtifact.count({
|
||||
where: {
|
||||
status: ArtifactStatus.active,
|
||||
},
|
||||
}),
|
||||
this.prisma.adminAuditLog.count(),
|
||||
this.prisma.tool.count({
|
||||
where: {
|
||||
isDeleted: false,
|
||||
accessMode: AccessMode.download,
|
||||
OR: [
|
||||
{
|
||||
AND: [
|
||||
{
|
||||
openUrl: {
|
||||
not: null,
|
||||
},
|
||||
},
|
||||
{
|
||||
openUrl: {
|
||||
not: '',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
latestArtifact: {
|
||||
is: {
|
||||
status: ArtifactStatus.active,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
this.prisma.tool.groupBy({
|
||||
by: ['status'],
|
||||
where: {
|
||||
isDeleted: false,
|
||||
},
|
||||
_count: {
|
||||
_all: true,
|
||||
},
|
||||
}),
|
||||
this.prisma.tool.groupBy({
|
||||
by: ['accessMode'],
|
||||
where: {
|
||||
isDeleted: false,
|
||||
},
|
||||
_count: {
|
||||
_all: true,
|
||||
},
|
||||
}),
|
||||
this.prisma.tool.aggregate({
|
||||
where: {
|
||||
isDeleted: false,
|
||||
status: ToolStatus.published,
|
||||
},
|
||||
_sum: {
|
||||
openCount: true,
|
||||
downloadCount: true,
|
||||
},
|
||||
}),
|
||||
this.prisma.tool.findMany({
|
||||
where: {
|
||||
isDeleted: false,
|
||||
status: ToolStatus.published,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
categoryId: true,
|
||||
category: {
|
||||
select: {
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
accessMode: true,
|
||||
openCount: true,
|
||||
downloadCount: true,
|
||||
modifiedAt: true,
|
||||
},
|
||||
}),
|
||||
this.getDailyActivity(7),
|
||||
]);
|
||||
|
||||
const statusStats = {
|
||||
draft: 0,
|
||||
published: 0,
|
||||
archived: 0,
|
||||
};
|
||||
toolStatusBuckets.forEach((item) => {
|
||||
statusStats[item.status] = item._count._all;
|
||||
});
|
||||
|
||||
const modeStats = {
|
||||
web: 0,
|
||||
download: 0,
|
||||
};
|
||||
accessModeBuckets.forEach((item) => {
|
||||
modeStats[item.accessMode] = item._count._all;
|
||||
});
|
||||
|
||||
const openTotal = publishedTraffic._sum.openCount ?? 0;
|
||||
const downloadTotal = publishedTraffic._sum.downloadCount ?? 0;
|
||||
const interactionTotal = openTotal + downloadTotal;
|
||||
|
||||
const topTools = publishedTools
|
||||
.map((tool) => ({
|
||||
id: tool.id,
|
||||
name: tool.name,
|
||||
categoryName: tool.category.name,
|
||||
accessMode: tool.accessMode,
|
||||
openCount: tool.openCount,
|
||||
downloadCount: tool.downloadCount,
|
||||
interactionTotal: tool.openCount + tool.downloadCount,
|
||||
updatedAt: tool.modifiedAt.toISOString(),
|
||||
}))
|
||||
.sort((a, b) => {
|
||||
if (b.interactionTotal !== a.interactionTotal) {
|
||||
return b.interactionTotal - a.interactionTotal;
|
||||
}
|
||||
return a.name.localeCompare(b.name);
|
||||
})
|
||||
.slice(0, 8);
|
||||
|
||||
const categoryStatMap = new Map<
|
||||
string,
|
||||
{
|
||||
categoryId: string;
|
||||
categoryName: string;
|
||||
toolTotal: number;
|
||||
openTotal: number;
|
||||
downloadTotal: number;
|
||||
}
|
||||
>();
|
||||
publishedTools.forEach((tool) => {
|
||||
const existing = categoryStatMap.get(tool.categoryId) ?? {
|
||||
categoryId: tool.categoryId,
|
||||
categoryName: tool.category.name,
|
||||
toolTotal: 0,
|
||||
openTotal: 0,
|
||||
downloadTotal: 0,
|
||||
};
|
||||
existing.toolTotal += 1;
|
||||
existing.openTotal += tool.openCount;
|
||||
existing.downloadTotal += tool.downloadCount;
|
||||
categoryStatMap.set(tool.categoryId, existing);
|
||||
});
|
||||
|
||||
const topCategories = Array.from(categoryStatMap.values())
|
||||
.map((item) => ({
|
||||
...item,
|
||||
interactionTotal: item.openTotal + item.downloadTotal,
|
||||
}))
|
||||
.sort((a, b) => {
|
||||
if (b.interactionTotal !== a.interactionTotal) {
|
||||
return b.interactionTotal - a.interactionTotal;
|
||||
}
|
||||
return b.toolTotal - a.toolTotal;
|
||||
})
|
||||
.slice(0, 8);
|
||||
|
||||
const toolTotal = statusStats.draft + statusStats.published + statusStats.archived;
|
||||
|
||||
return {
|
||||
generatedAt: new Date().toISOString(),
|
||||
summary: {
|
||||
toolTotal,
|
||||
draftTotal: statusStats.draft,
|
||||
publishedTotal: statusStats.published,
|
||||
archivedTotal: statusStats.archived,
|
||||
categoryTotal,
|
||||
tagTotal,
|
||||
webToolTotal: modeStats.web,
|
||||
downloadToolTotal: modeStats.download,
|
||||
downloadReadyToolTotal,
|
||||
openTotal,
|
||||
downloadTotal,
|
||||
interactionTotal,
|
||||
artifactTotal,
|
||||
activeArtifactTotal,
|
||||
auditLogTotal,
|
||||
},
|
||||
dailyActivity,
|
||||
topCategories,
|
||||
topTools,
|
||||
};
|
||||
}
|
||||
|
||||
private async getDailyActivity(days: number) {
|
||||
const ranges = this.buildDailyRanges(days);
|
||||
const queries = ranges.flatMap((range) => [
|
||||
this.prisma.openRecord.count({
|
||||
where: {
|
||||
openedAt: {
|
||||
gte: range.start,
|
||||
lt: range.end,
|
||||
},
|
||||
},
|
||||
}),
|
||||
this.prisma.downloadRecord.count({
|
||||
where: {
|
||||
downloadedAt: {
|
||||
gte: range.start,
|
||||
lt: range.end,
|
||||
},
|
||||
status: DownloadRecordStatus.success,
|
||||
},
|
||||
}),
|
||||
this.prisma.adminAuditLog.count({
|
||||
where: {
|
||||
createdAt: {
|
||||
gte: range.start,
|
||||
lt: range.end,
|
||||
},
|
||||
},
|
||||
}),
|
||||
]);
|
||||
const counts = await this.prisma.$transaction(queries);
|
||||
|
||||
return ranges.map((range, index) => {
|
||||
const openCount = counts[index * 3] ?? 0;
|
||||
const downloadCount = counts[index * 3 + 1] ?? 0;
|
||||
const auditCount = counts[index * 3 + 2] ?? 0;
|
||||
return {
|
||||
date: range.date,
|
||||
openCount,
|
||||
downloadCount,
|
||||
interactionTotal: openCount + downloadCount,
|
||||
auditCount,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private buildDailyRanges(days: number): DailyRange[] {
|
||||
const safeDays = Math.max(1, days);
|
||||
const now = new Date();
|
||||
const todayStart = new Date(now);
|
||||
todayStart.setHours(0, 0, 0, 0);
|
||||
|
||||
return Array.from({ length: safeDays }, (_, index) => {
|
||||
const offset = safeDays - index - 1;
|
||||
const start = new Date(todayStart);
|
||||
start.setDate(todayStart.getDate() - offset);
|
||||
const end = new Date(start);
|
||||
end.setDate(start.getDate() + 1);
|
||||
return {
|
||||
date: start.toISOString().slice(0, 10),
|
||||
start,
|
||||
end,
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -77,9 +77,10 @@ export class AdminToolsService {
|
||||
async createTool(body: CreateToolDto) {
|
||||
await this.assertCategoryExists(body.categoryId);
|
||||
await this.assertTagsExist(body.tags ?? []);
|
||||
const openUrl = this.normalizeOptionalUrl(body.openUrl);
|
||||
|
||||
if ((body.status ?? ToolStatus.draft) === ToolStatus.published) {
|
||||
this.assertPublishInput(body.accessMode, body.openUrl, undefined);
|
||||
this.assertPublishInput(body.accessMode, openUrl, undefined);
|
||||
}
|
||||
|
||||
const toolId = this.generateBusinessId('tool');
|
||||
@@ -95,7 +96,7 @@ export class AdminToolsService {
|
||||
description: body.description.trim(),
|
||||
rating: body.rating ?? 0,
|
||||
accessMode: body.accessMode,
|
||||
openUrl: body.accessMode === AccessMode.web ? body.openUrl ?? null : null,
|
||||
openUrl,
|
||||
openInNewTab: body.openInNewTab ?? true,
|
||||
status: body.status ?? ToolStatus.draft,
|
||||
updatedAt,
|
||||
@@ -156,14 +157,12 @@ export class AdminToolsService {
|
||||
|
||||
async updateTool(id: string, body: UpdateToolDto) {
|
||||
const existingTool = await this.getToolEntity(id);
|
||||
const normalizedOpenUrl =
|
||||
body.openUrl !== undefined ? this.normalizeOptionalUrl(body.openUrl) : undefined;
|
||||
|
||||
const nextAccessMode = body.accessMode ?? existingTool.accessMode;
|
||||
const nextOpenUrl =
|
||||
body.openUrl !== undefined
|
||||
? body.openUrl
|
||||
: nextAccessMode === AccessMode.web
|
||||
? existingTool.openUrl
|
||||
: null;
|
||||
normalizedOpenUrl !== undefined ? normalizedOpenUrl : existingTool.openUrl ?? null;
|
||||
const nextStatus = body.status ?? existingTool.status;
|
||||
|
||||
if (body.categoryId) {
|
||||
@@ -189,7 +188,7 @@ export class AdminToolsService {
|
||||
description: body.description?.trim(),
|
||||
rating: body.rating,
|
||||
accessMode: body.accessMode,
|
||||
openUrl: body.openUrl,
|
||||
openUrl: normalizedOpenUrl,
|
||||
openInNewTab: body.openInNewTab,
|
||||
status: body.status,
|
||||
updatedAt,
|
||||
@@ -245,10 +244,12 @@ export class AdminToolsService {
|
||||
|
||||
async updateAccessMode(id: string, body: UpdateAccessModeDto) {
|
||||
const tool = await this.getToolEntity(id);
|
||||
const nextOpenUrl =
|
||||
body.openUrl !== undefined ? this.normalizeOptionalUrl(body.openUrl) : tool.openUrl ?? null;
|
||||
this.assertModeSwitchConstraint(
|
||||
tool.status,
|
||||
body.accessMode,
|
||||
body.openUrl,
|
||||
nextOpenUrl,
|
||||
tool,
|
||||
tool.accessMode !== body.accessMode,
|
||||
);
|
||||
@@ -257,7 +258,7 @@ export class AdminToolsService {
|
||||
where: { id },
|
||||
data: {
|
||||
accessMode: body.accessMode,
|
||||
openUrl: body.accessMode === AccessMode.web ? body.openUrl ?? null : null,
|
||||
openUrl: nextOpenUrl,
|
||||
openInNewTab: body.openInNewTab ?? tool.openInNewTab,
|
||||
updatedAt: this.getDateOnlyString(),
|
||||
},
|
||||
@@ -354,7 +355,7 @@ export class AdminToolsService {
|
||||
|
||||
private assertPublishInput(
|
||||
accessMode: AccessMode,
|
||||
openUrl?: string,
|
||||
openUrl?: string | null,
|
||||
latestArtifact?: { status: ArtifactStatus } | null,
|
||||
) {
|
||||
if (accessMode === AccessMode.web) {
|
||||
@@ -368,10 +369,14 @@ export class AdminToolsService {
|
||||
return;
|
||||
}
|
||||
|
||||
if (openUrl) {
|
||||
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',
|
||||
'download mode tool requires one active latest artifact or one download URL before publish',
|
||||
HttpStatus.CONFLICT,
|
||||
);
|
||||
}
|
||||
@@ -396,11 +401,12 @@ export class AdminToolsService {
|
||||
isSwitching &&
|
||||
targetMode === AccessMode.download &&
|
||||
currentStatus === ToolStatus.published &&
|
||||
!openUrl &&
|
||||
(!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',
|
||||
'published tool cannot switch to download mode without active artifact or download URL',
|
||||
HttpStatus.CONFLICT,
|
||||
);
|
||||
}
|
||||
@@ -481,4 +487,13 @@ export class AdminToolsService {
|
||||
|
||||
return slug;
|
||||
}
|
||||
|
||||
private normalizeOptionalUrl(value: string | null | undefined): string | null | undefined {
|
||||
if (value === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const trimmed = String(value ?? '').trim();
|
||||
return trimmed ? trimmed : null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,6 @@ import {
|
||||
MaxLength,
|
||||
Min,
|
||||
MinLength,
|
||||
ValidateIf,
|
||||
} from 'class-validator';
|
||||
|
||||
export class CreateToolDto {
|
||||
@@ -62,8 +61,10 @@ export class CreateToolDto {
|
||||
@IsEnum(AccessMode)
|
||||
accessMode!: AccessMode;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Required when accessMode=web' })
|
||||
@ValidateIf((obj: CreateToolDto) => obj.accessMode === AccessMode.web)
|
||||
@ApiPropertyOptional({
|
||||
description: 'Required when accessMode=web; optional external download URL when accessMode=download',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@IsUrl({
|
||||
require_protocol: true,
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
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';
|
||||
import { IsBoolean, IsEnum, IsOptional, IsString, IsUrl } 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)
|
||||
@ApiPropertyOptional({
|
||||
description: 'Required when accessMode=web; optional external download URL when accessMode=download',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@IsUrl({
|
||||
require_protocol: true,
|
||||
|
||||
@@ -107,15 +107,6 @@ export class DownloadsService {
|
||||
status: DownloadRecordStatus.success,
|
||||
},
|
||||
});
|
||||
|
||||
await tx.tool.update({
|
||||
where: { id: ticketEntity.toolId },
|
||||
data: {
|
||||
downloadCount: {
|
||||
increment: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
response.setHeader(
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Transform, Type } from 'class-transformer';
|
||||
import { IsEnum, IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
|
||||
|
||||
export enum ToolSortBy {
|
||||
created = 'created',
|
||||
popular = 'popular',
|
||||
latest = 'latest',
|
||||
rating = 'rating',
|
||||
@@ -21,7 +22,7 @@ export class GetToolsQueryDto {
|
||||
@IsString()
|
||||
category?: string;
|
||||
|
||||
@ApiPropertyOptional({ enum: ToolSortBy, default: ToolSortBy.latest })
|
||||
@ApiPropertyOptional({ enum: ToolSortBy, default: ToolSortBy.created })
|
||||
@IsOptional()
|
||||
@IsEnum(ToolSortBy)
|
||||
sortBy?: ToolSortBy;
|
||||
|
||||
@@ -12,7 +12,7 @@ export class ToolsService {
|
||||
async getTools(query: GetToolsQueryDto) {
|
||||
const page = query.page ?? 1;
|
||||
const pageSize = Math.min(query.pageSize ?? 6, 50);
|
||||
const sortBy = query.sortBy ?? ToolSortBy.latest;
|
||||
const sortBy = query.sortBy ?? ToolSortBy.created;
|
||||
|
||||
const where: Prisma.ToolWhereInput = {
|
||||
isDeleted: false,
|
||||
@@ -59,6 +59,8 @@ export class ToolsService {
|
||||
const hasArtifact = Boolean(
|
||||
tool.latestArtifact && tool.latestArtifact.status === ArtifactStatus.active,
|
||||
);
|
||||
const hasExternalDownloadUrl =
|
||||
tool.accessMode === 'download' && Boolean(tool.openUrl);
|
||||
|
||||
return {
|
||||
id: tool.id,
|
||||
@@ -75,8 +77,10 @@ export class ToolsService {
|
||||
latestVersion:
|
||||
tool.accessMode === 'download' && tool.latestArtifact ? tool.latestArtifact.version : null,
|
||||
accessMode: tool.accessMode,
|
||||
openUrl: tool.accessMode === 'web' ? tool.openUrl : null,
|
||||
openUrl: tool.openUrl,
|
||||
hasArtifact: tool.accessMode === 'download' ? hasArtifact : false,
|
||||
downloadReady:
|
||||
tool.accessMode === 'download' ? hasArtifact || hasExternalDownloadUrl : false,
|
||||
tags: tool.tags.map((item) => item.tag.name),
|
||||
features: tool.features.map((item) => item.featureText),
|
||||
updatedAt: tool.updatedAt,
|
||||
@@ -134,7 +138,7 @@ export class ToolsService {
|
||||
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,
|
||||
openUrl: tool.openUrl,
|
||||
latestVersion:
|
||||
tool.accessMode === 'download' && tool.latestArtifact ? tool.latestArtifact.version : null,
|
||||
fileSize:
|
||||
@@ -143,7 +147,10 @@ export class ToolsService {
|
||||
: null,
|
||||
downloadReady:
|
||||
tool.accessMode === 'download'
|
||||
? Boolean(tool.latestArtifact && tool.latestArtifact.status === ArtifactStatus.active)
|
||||
? Boolean(
|
||||
tool.openUrl ||
|
||||
(tool.latestArtifact && tool.latestArtifact.status === ArtifactStatus.active),
|
||||
)
|
||||
: false,
|
||||
};
|
||||
|
||||
@@ -152,6 +159,8 @@ export class ToolsService {
|
||||
|
||||
private buildOrderBy(sortBy: ToolSortBy): Prisma.ToolOrderByWithRelationInput[] {
|
||||
switch (sortBy) {
|
||||
case ToolSortBy.created:
|
||||
return [{ createdAt: 'desc' }, { modifiedAt: 'desc' }];
|
||||
case ToolSortBy.popular:
|
||||
return [{ downloadCount: 'desc' }, { openCount: 'desc' }, { modifiedAt: 'desc' }];
|
||||
case ToolSortBy.rating:
|
||||
|
||||
Reference in New Issue
Block a user