This commit is contained in:
admin
2026-04-11 20:46:55 +08:00
parent e6c2d76238
commit e04405d0bc
70 changed files with 10438 additions and 332 deletions

View File

@@ -8,10 +8,10 @@
"scripts": {
"build": "nest build",
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
"start": "nest start",
"start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/src/main.js",
"start": "node scripts/start-with-migrate.js npx nest start",
"start:dev": "node scripts/start-with-migrate.js npx nest start --watch",
"start:debug": "node scripts/start-with-migrate.js npx nest start --debug --watch",
"start:prod": "node scripts/start-with-migrate.js node dist/src/main.js",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"test": "jest",
"test:watch": "jest --watch",

View File

@@ -1,15 +1,173 @@
import argon2 from 'argon2';
import { PrismaClient, AccessMode, ToolStatus, ArtifactStatus, AdminUserStatus } from '@prisma/client';
import {
PrismaClient,
AccessMode,
ToolStatus,
ArtifactStatus,
DownloadRecordStatus,
AdminUserStatus,
} from '@prisma/client';
const prisma = new PrismaClient();
function todayDateString(): string {
return new Date().toISOString().slice(0, 10);
type ArtifactSeed = {
id: string;
version: string;
fileName: string;
fileSizeBytes: number;
sha256: string;
mimeType: string;
gitlabProjectId: number;
gitlabPackageName: string;
gitlabPackageVersion: string;
gitlabFilePath: string;
status: ArtifactStatus;
releaseNotes: string;
uploadedBy?: string;
createdAt: Date;
};
type ToolSeed = {
id: string;
name: string;
slug: string;
categoryId: string;
description: string;
rating: number;
downloadCount: number;
openCount: number;
accessMode: AccessMode;
openUrl?: string;
openInNewTab?: boolean;
status: ToolStatus;
createdAt: Date;
updatedAt: string;
features: Array<{
id: string;
featureText: string;
sortOrder: number;
}>;
tagIds: string[];
artifacts?: ArtifactSeed[];
latestArtifactId?: string;
};
const USER_AGENTS = [
'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_4)',
'Mozilla/5.0 (Windows NT 10.0; Win64; x64)',
'Mozilla/5.0 (X11; Linux x86_64)',
'ToolsShowDesktop/1.1.0',
'curl/8.7.1',
];
function dateOnly(date: Date): string {
return date.toISOString().slice(0, 10);
}
function daysAgo(days: number, hour = 10, minute = 0): Date {
const date = new Date();
date.setHours(hour, minute, 0, 0);
date.setDate(date.getDate() - days);
return date;
}
function makeIp(seed: number): string {
return `10.24.${Math.floor(seed / 200) + 10}.${(seed % 200) + 20}`;
}
function buildOpenRecords(
toolId: string,
dailyCounts: number[],
channel: string,
referer: string,
) {
return dailyCounts.flatMap((count, index) => {
const dayOffset = dailyCounts.length - index - 1;
return Array.from({ length: count }, (_, slot) => ({
toolId,
openedAt: daysAgo(dayOffset, 9 + (slot % 8), (slot * 11) % 60),
clientIp: makeIp(index * 40 + slot),
userAgent: USER_AGENTS[(index + slot) % USER_AGENTS.length],
channel,
clientVersion: channel === 'desktop' ? '1.1.0' : 'web',
referer,
}));
});
}
function buildDownloadRecords(
toolId: string,
artifactId: string,
dailyCounts: number[],
channel: string,
clientVersion: string,
) {
return dailyCounts.flatMap((count, index) => {
const dayOffset = dailyCounts.length - index - 1;
return Array.from({ length: count }, (_, slot) => ({
toolId,
artifactId,
ticket: `ticket_${toolId}_${dayOffset}_${slot}`,
downloadedAt: daysAgo(dayOffset, 8 + (slot % 10), (slot * 7) % 60),
clientIp: makeIp(index * 60 + slot + 300),
userAgent: USER_AGENTS[(index + slot + 1) % USER_AGENTS.length],
channel,
clientVersion,
status: DownloadRecordStatus.success,
errorMessage: null,
}));
});
}
async function createToolWithRelations(tool: ToolSeed) {
await prisma.tool.create({
data: {
id: tool.id,
name: tool.name,
slug: tool.slug,
categoryId: tool.categoryId,
description: tool.description,
rating: tool.rating,
downloadCount: tool.downloadCount,
openCount: tool.openCount,
accessMode: tool.accessMode,
openUrl: tool.openUrl,
openInNewTab: tool.openInNewTab ?? true,
status: tool.status,
createdAt: tool.createdAt,
updatedAt: tool.updatedAt,
features: {
createMany: {
data: tool.features,
},
},
tags: {
create: tool.tagIds.map((tagId) => ({ tagId })),
},
},
});
if (tool.artifacts?.length) {
await prisma.toolArtifact.createMany({
data: tool.artifacts.map((artifact) => ({
...artifact,
toolId: tool.id,
})),
});
}
if (tool.latestArtifactId) {
await prisma.tool.update({
where: { id: tool.id },
data: {
latestArtifactId: tool.latestArtifactId,
updatedAt: tool.updatedAt,
},
});
}
}
async function main() {
const nowDate = todayDateString();
await prisma.$transaction([
prisma.toolTag.deleteMany(),
prisma.toolFeature.deleteMany(),
@@ -30,6 +188,8 @@ async function main() {
{ id: 'cat_ai', name: 'AI', sortOrder: 10 },
{ id: 'cat_dev', name: 'Developer', sortOrder: 20 },
{ id: 'cat_ops', name: 'Operations', sortOrder: 30 },
{ id: 'cat_design', name: 'Design', sortOrder: 40 },
{ id: 'cat_productivity', name: 'Productivity', sortOrder: 50 },
],
});
@@ -38,107 +198,744 @@ async function main() {
{ id: 'tag_hot', name: 'Hot' },
{ id: 'tag_free', name: 'Free' },
{ id: 'tag_official', name: 'Official' },
{ id: 'tag_new', name: 'New' },
{ id: 'tag_team', name: 'Team' },
{ id: 'tag_self_hosted', name: 'Self-hosted' },
{ id: 'tag_enterprise', name: 'Enterprise' },
{ id: 'tag_recommended', name: 'Recommended' },
],
});
const webToolId = 'tool_web_001';
const downloadToolId = 'tool_dl_001';
const artifactId = 'art_001';
const passwordHash = await argon2.hash('admin123456', {
type: argon2.argon2id,
});
await prisma.adminUser.createMany({
data: [
{
id: 'u_admin_001',
username: 'admin',
passwordHash,
displayName: 'System Admin',
status: AdminUserStatus.active,
lastLoginAt: daysAgo(0, 9, 25),
},
{
id: 'u_admin_002',
username: 'ops-admin',
passwordHash,
displayName: 'Operations Admin',
status: AdminUserStatus.active,
lastLoginAt: daysAgo(1, 18, 5),
},
{
id: 'u_admin_003',
username: 'auditor',
passwordHash,
displayName: 'Read Only Auditor',
status: AdminUserStatus.disabled,
lastLoginAt: daysAgo(11, 13, 10),
},
],
});
await prisma.tool.create({
data: {
id: webToolId,
const tools: ToolSeed[] = [
{
id: 'tool_web_001',
name: 'OpenAI Playground',
slug: 'openai-playground',
categoryId: 'cat_ai',
description: 'OpenAI web playground for prompt testing.',
rating: 4.8,
description:
'OpenAI web playground for prompt iteration, parameter tuning, and quick eval loops.',
rating: 4.9,
downloadCount: 0,
openCount: 128,
openCount: 642,
accessMode: AccessMode.web,
openUrl: 'https://platform.openai.com/playground',
openInNewTab: true,
status: ToolStatus.published,
updatedAt: nowDate,
features: {
createMany: {
data: [
{ id: 'feat_web_001', featureText: 'Prompt debugging', sortOrder: 10 },
{ id: 'feat_web_002', featureText: 'Model parameter tuning', sortOrder: 20 },
],
createdAt: daysAgo(45, 10, 0),
updatedAt: dateOnly(daysAgo(0)),
features: [
{ id: 'feat_web_001', featureText: 'Prompt debugging', sortOrder: 10 },
{
id: 'feat_web_002',
featureText: 'Model parameter tuning',
sortOrder: 20,
},
},
tags: {
create: [{ tagId: 'tag_hot' }, { tagId: 'tag_official' }],
},
{
id: 'feat_web_003',
featureText: 'Quick compare workflow',
sortOrder: 30,
},
],
tagIds: ['tag_hot', 'tag_official', 'tag_recommended'],
},
});
await prisma.tool.create({
data: {
id: downloadToolId,
{
id: 'tool_web_002',
name: 'Claude Artifacts',
slug: 'claude-artifacts',
categoryId: 'cat_ai',
description:
'Web workspace for iterating on prompts, generated UI fragments, and artifact previews.',
rating: 4.7,
downloadCount: 0,
openCount: 438,
accessMode: AccessMode.web,
openUrl: 'https://claude.ai',
openInNewTab: true,
status: ToolStatus.published,
createdAt: daysAgo(31, 11, 0),
updatedAt: dateOnly(daysAgo(1)),
features: [
{ id: 'feat_web_101', featureText: 'Artifact preview', sortOrder: 10 },
{
id: 'feat_web_102',
featureText: 'Long context drafting',
sortOrder: 20,
},
{
id: 'feat_web_103',
featureText: 'Fast iteration loop',
sortOrder: 30,
},
],
tagIds: ['tag_hot', 'tag_new'],
},
{
id: 'tool_web_003',
name: 'Figma AI Board',
slug: 'figma-ai-board',
categoryId: 'cat_design',
description:
'Design board for collecting references, generating layout ideas, and collaborating on concepts.',
rating: 4.5,
downloadCount: 0,
openCount: 271,
accessMode: AccessMode.web,
openUrl: 'https://www.figma.com',
openInNewTab: true,
status: ToolStatus.published,
createdAt: daysAgo(22, 14, 0),
updatedAt: dateOnly(daysAgo(2)),
features: [
{ id: 'feat_web_201', featureText: 'Shared whiteboard', sortOrder: 10 },
{
id: 'feat_web_202',
featureText: 'Prompt-based explorations',
sortOrder: 20,
},
{
id: 'feat_web_203',
featureText: 'Reference clustering',
sortOrder: 30,
},
],
tagIds: ['tag_official', 'tag_team'],
},
{
id: 'tool_web_004',
name: 'Runbook Atlas',
slug: 'runbook-atlas',
categoryId: 'cat_ops',
description:
'Operational runbook viewer with incident links, deployment guides, and on-call context.',
rating: 4.4,
downloadCount: 0,
openCount: 196,
accessMode: AccessMode.web,
openUrl: 'https://atlas.example.internal/runbooks',
openInNewTab: false,
status: ToolStatus.published,
createdAt: daysAgo(19, 9, 0),
updatedAt: dateOnly(daysAgo(3)),
features: [
{
id: 'feat_web_301',
featureText: 'Incident handoff docs',
sortOrder: 10,
},
{
id: 'feat_web_302',
featureText: 'Change checklist templates',
sortOrder: 20,
},
{
id: 'feat_web_303',
featureText: 'Service ownership map',
sortOrder: 30,
},
],
tagIds: ['tag_team', 'tag_self_hosted'],
},
{
id: 'tool_web_005',
name: 'Notion Workflow Hub',
slug: 'notion-workflow-hub',
categoryId: 'cat_productivity',
description:
'Documentation and request-tracking workspace for internal workflow handoffs.',
rating: 4.2,
downloadCount: 0,
openCount: 153,
accessMode: AccessMode.web,
openUrl: 'https://www.notion.so',
openInNewTab: true,
status: ToolStatus.published,
createdAt: daysAgo(12, 13, 30),
updatedAt: dateOnly(daysAgo(1)),
features: [
{
id: 'feat_web_401',
featureText: 'Workflow templates',
sortOrder: 10,
},
{
id: 'feat_web_402',
featureText: 'Knowledge base linking',
sortOrder: 20,
},
{
id: 'feat_web_403',
featureText: 'Review queue boards',
sortOrder: 30,
},
],
tagIds: ['tag_team', 'tag_recommended'],
},
{
id: 'tool_dl_001',
name: 'ToolsShow Desktop',
slug: 'toolsshow-desktop',
categoryId: 'cat_dev',
description: 'Desktop bundle for local workflows.',
rating: 4.6,
downloadCount: 52,
description:
'Desktop bundle for local workflows, offline browsing, and curated plugin execution.',
rating: 4.8,
downloadCount: 182,
openCount: 0,
accessMode: AccessMode.download,
status: ToolStatus.published,
updatedAt: nowDate,
features: {
createMany: {
data: [
{ id: 'feat_dl_001', featureText: 'Offline usage', sortOrder: 10 },
{ id: 'feat_dl_002', featureText: 'Bundled plugins', sortOrder: 20 },
],
createdAt: daysAgo(40, 10, 15),
updatedAt: dateOnly(daysAgo(0)),
features: [
{ id: 'feat_dl_001', featureText: 'Offline usage', sortOrder: 10 },
{ id: 'feat_dl_002', featureText: 'Bundled plugins', sortOrder: 20 },
{
id: 'feat_dl_003',
featureText: 'Local workspace sync',
sortOrder: 30,
},
},
tags: {
create: [{ tagId: 'tag_free' }],
},
artifacts: {
create: {
id: artifactId,
],
tagIds: ['tag_hot', 'tag_free', 'tag_recommended'],
artifacts: [
{
id: 'art_001',
version: '1.0.0',
fileName: 'toolsshow-desktop-1.0.0.zip',
fileSizeBytes: 12_345_678,
sha256: 'sample-sha256-not-real',
fileSizeBytes: 12345678,
sha256: 'sha256-toolsshow-100',
mimeType: 'application/zip',
gitlabProjectId: 0,
gitlabProjectId: 1001,
gitlabPackageName: 'toolsshow/toolsshow-desktop',
gitlabPackageVersion: '1.0.0',
gitlabFilePath: 'storage/toolsshow-desktop-1.0.0.zip',
status: ArtifactStatus.active,
releaseNotes: 'Initial release',
status: ArtifactStatus.deprecated,
releaseNotes: 'Initial public release with bundled runtime.',
uploadedBy: 'u_admin_001',
createdAt: daysAgo(35, 15, 30),
},
},
{
id: 'art_002',
version: '1.1.0',
fileName: 'toolsshow-desktop-1.1.0.zip',
fileSizeBytes: 14567890,
sha256: 'sha256-toolsshow-110',
mimeType: 'application/zip',
gitlabProjectId: 1001,
gitlabPackageName: 'toolsshow/toolsshow-desktop',
gitlabPackageVersion: '1.1.0',
gitlabFilePath: 'storage/toolsshow-desktop-1.1.0.zip',
status: ArtifactStatus.active,
releaseNotes: 'Adds plugin auto-update and workspace pinning.',
uploadedBy: 'u_admin_002',
createdAt: daysAgo(8, 16, 45),
},
],
latestArtifactId: 'art_002',
},
});
{
id: 'tool_dl_002',
name: 'MCP Inspector',
slug: 'mcp-inspector',
categoryId: 'cat_dev',
description:
'Desktop inspector for local MCP servers, request payloads, and connector metadata.',
rating: 4.6,
downloadCount: 134,
openCount: 0,
accessMode: AccessMode.download,
openUrl: 'https://github.com/modelcontextprotocol/inspector/releases',
status: ToolStatus.published,
createdAt: daysAgo(28, 12, 0),
updatedAt: dateOnly(daysAgo(2)),
features: [
{ id: 'feat_dl_101', featureText: 'Connection tracing', sortOrder: 10 },
{ id: 'feat_dl_102', featureText: 'Schema inspection', sortOrder: 20 },
{ id: 'feat_dl_103', featureText: 'Replay requests', sortOrder: 30 },
],
tagIds: ['tag_new', 'tag_recommended'],
artifacts: [
{
id: 'art_101',
version: '0.9.2',
fileName: 'mcp-inspector-0.9.2.dmg',
fileSizeBytes: 67452311,
sha256: 'sha256-mcp-092',
mimeType: 'application/x-apple-diskimage',
gitlabProjectId: 1002,
gitlabPackageName: 'toolsshow/mcp-inspector',
gitlabPackageVersion: '0.9.2',
gitlabFilePath: 'storage/mcp-inspector-0.9.2.dmg',
status: ArtifactStatus.active,
releaseNotes:
'Adds server capability panel and request timeline export.',
uploadedBy: 'u_admin_001',
createdAt: daysAgo(9, 17, 20),
},
],
latestArtifactId: 'art_101',
},
{
id: 'tool_dl_003',
name: 'LogScope Agent',
slug: 'logscope-agent',
categoryId: 'cat_ops',
description:
'Lightweight collector for shipping local logs, traces, and health snapshots to a central view.',
rating: 4.3,
downloadCount: 89,
openCount: 0,
accessMode: AccessMode.download,
status: ToolStatus.published,
createdAt: daysAgo(26, 9, 40),
updatedAt: dateOnly(daysAgo(4)),
features: [
{ id: 'feat_dl_201', featureText: 'Log tail capture', sortOrder: 10 },
{
id: 'feat_dl_202',
featureText: 'Service heartbeat sync',
sortOrder: 20,
},
{
id: 'feat_dl_203',
featureText: 'Remote support bundle',
sortOrder: 30,
},
],
tagIds: ['tag_enterprise', 'tag_self_hosted'],
artifacts: [
{
id: 'art_201',
version: '2.0.0',
fileName: 'logscope-agent-2.0.0.tar.gz',
fileSizeBytes: 28765432,
sha256: 'sha256-logscope-200',
mimeType: 'application/gzip',
gitlabProjectId: 1003,
gitlabPackageName: 'toolsshow/logscope-agent',
gitlabPackageVersion: '2.0.0',
gitlabFilePath: 'storage/logscope-agent-2.0.0.tar.gz',
status: ArtifactStatus.deleted,
releaseNotes: 'Pulled due to startup regression on Linux hosts.',
uploadedBy: 'u_admin_002',
createdAt: daysAgo(21, 14, 0),
},
{
id: 'art_202',
version: '2.1.0',
fileName: 'logscope-agent-2.1.0.tar.gz',
fileSizeBytes: 29876543,
sha256: 'sha256-logscope-210',
mimeType: 'application/gzip',
gitlabProjectId: 1003,
gitlabPackageName: 'toolsshow/logscope-agent',
gitlabPackageVersion: '2.1.0',
gitlabFilePath: 'storage/logscope-agent-2.1.0.tar.gz',
status: ArtifactStatus.active,
releaseNotes:
'Fixes Linux startup regression and improves compression.',
uploadedBy: 'u_admin_002',
createdAt: daysAgo(6, 11, 45),
},
],
latestArtifactId: 'art_202',
},
{
id: 'tool_draft_001',
name: 'Local Agent Kit',
slug: 'local-agent-kit',
categoryId: 'cat_dev',
description:
'Work-in-progress desktop kit for packaging internal prompts, tools, and local runtime configs.',
rating: 0,
downloadCount: 0,
openCount: 0,
accessMode: AccessMode.download,
status: ToolStatus.draft,
createdAt: daysAgo(5, 10, 10),
updatedAt: dateOnly(daysAgo(0)),
features: [
{
id: 'feat_draft_001',
featureText: 'Local prompt packs',
sortOrder: 10,
},
{
id: 'feat_draft_002',
featureText: 'Workspace policies',
sortOrder: 20,
},
],
tagIds: ['tag_new'],
},
{
id: 'tool_archived_001',
name: 'Canvas Pilot',
slug: 'canvas-pilot',
categoryId: 'cat_design',
description:
'Legacy internal design helper kept for historical reference and migration support.',
rating: 3.9,
downloadCount: 14,
openCount: 25,
accessMode: AccessMode.web,
openUrl: 'https://legacy.example.internal/canvas-pilot',
openInNewTab: false,
status: ToolStatus.archived,
createdAt: daysAgo(90, 9, 0),
updatedAt: dateOnly(daysAgo(14)),
features: [
{
id: 'feat_arc_001',
featureText: 'Legacy board import',
sortOrder: 10,
},
{ id: 'feat_arc_002', featureText: 'Snapshot export', sortOrder: 20 },
],
tagIds: ['tag_team'],
},
];
await prisma.tool.update({
where: { id: downloadToolId },
data: { latestArtifactId: artifactId },
});
for (const tool of tools) {
await createToolWithRelations(tool);
}
await prisma.hotKeyword.createMany({
data: [
{ id: 'kw_001', keyword: 'agent', sortOrder: 10, isActive: true },
{ id: 'kw_002', keyword: 'automation', sortOrder: 20, isActive: true },
{ id: 'kw_003', keyword: 'open-source', sortOrder: 30, isActive: true },
{ id: 'kw_003', keyword: 'mcp', sortOrder: 30, isActive: true },
{ id: 'kw_004', keyword: 'design', sortOrder: 40, isActive: true },
{ id: 'kw_005', keyword: 'workflow', sortOrder: 50, isActive: true },
{ id: 'kw_006', keyword: 'self-hosted', sortOrder: 60, isActive: false },
],
});
const passwordHash = await argon2.hash('admin123456', { type: argon2.argon2id });
await prisma.adminUser.create({
data: {
id: 'u_admin_001',
username: 'admin',
passwordHash,
displayName: 'System Admin',
status: AdminUserStatus.active,
},
await prisma.openRecord.createMany({
data: [
...buildOpenRecords(
'tool_web_001',
[14, 17, 19, 21, 24, 26, 28],
'web',
'/',
),
...buildOpenRecords(
'tool_web_002',
[8, 10, 12, 14, 16, 18, 20],
'web',
'/tools',
),
...buildOpenRecords(
'tool_web_003',
[4, 6, 7, 8, 8, 9, 10],
'web',
'/design',
),
...buildOpenRecords('tool_web_004', [3, 4, 5, 6, 5, 7, 6], 'web', '/ops'),
...buildOpenRecords(
'tool_web_005',
[2, 3, 3, 4, 4, 5, 6],
'web',
'/workspace',
),
...buildOpenRecords(
'tool_dl_002',
[1, 1, 2, 2, 2, 3, 3],
'desktop',
'/admin/tools',
),
],
});
await prisma.downloadRecord.createMany({
data: [
...buildDownloadRecords(
'tool_dl_001',
'art_002',
[3, 4, 5, 6, 8, 9, 10],
'desktop',
'1.1.0',
),
...buildDownloadRecords(
'tool_dl_002',
'art_101',
[2, 2, 3, 4, 4, 5, 6],
'cli',
'0.9.2',
),
...buildDownloadRecords(
'tool_dl_003',
'art_202',
[1, 2, 2, 3, 3, 4, 5],
'agent',
'2.1.0',
),
{
toolId: 'tool_dl_003',
artifactId: 'art_202',
ticket: 'ticket_failed_001',
downloadedAt: daysAgo(2, 23, 15),
clientIp: '10.24.99.40',
userAgent: 'curl/8.7.1',
channel: 'agent',
clientVersion: '2.1.0',
status: DownloadRecordStatus.failed,
errorMessage: 'checksum mismatch',
},
{
toolId: 'tool_dl_001',
artifactId: 'art_002',
ticket: 'ticket_failed_002',
downloadedAt: daysAgo(5, 7, 50),
clientIp: '10.24.99.41',
userAgent: 'ToolsShowDesktop/1.1.0',
channel: 'desktop',
clientVersion: '1.1.0',
status: DownloadRecordStatus.failed,
errorMessage: 'network timeout',
},
],
});
await prisma.downloadTicket.createMany({
data: [
{
ticket: 'seed_ticket_001',
toolId: 'tool_dl_001',
artifactId: 'art_002',
channel: 'desktop',
clientVersion: '1.1.0',
requestIp: '10.24.88.11',
expiresAt: daysAgo(-1, 12, 0),
consumedAt: null,
createdAt: daysAgo(0, 11, 45),
},
{
ticket: 'seed_ticket_002',
toolId: 'tool_dl_002',
artifactId: 'art_101',
channel: 'cli',
clientVersion: '0.9.2',
requestIp: '10.24.88.12',
expiresAt: daysAgo(0, 13, 0),
consumedAt: daysAgo(0, 12, 10),
createdAt: daysAgo(0, 12, 0),
},
{
ticket: 'seed_ticket_003',
toolId: 'tool_dl_003',
artifactId: 'art_202',
channel: 'agent',
clientVersion: '2.1.0',
requestIp: '10.24.88.13',
expiresAt: daysAgo(1, 14, 0),
consumedAt: null,
createdAt: daysAgo(1, 13, 20),
},
{
ticket: 'seed_ticket_004',
toolId: 'tool_dl_001',
artifactId: 'art_002',
channel: 'desktop',
clientVersion: '1.1.0',
requestIp: '10.24.88.14',
expiresAt: daysAgo(3, 10, 0),
consumedAt: daysAgo(3, 9, 12),
createdAt: daysAgo(3, 9, 0),
},
{
ticket: 'seed_ticket_005',
toolId: 'tool_dl_003',
artifactId: 'art_202',
channel: 'agent',
clientVersion: '2.1.0',
requestIp: '10.24.88.15',
expiresAt: daysAgo(6, 17, 0),
consumedAt: null,
createdAt: daysAgo(6, 16, 30),
},
],
});
await prisma.adminAuditLog.createMany({
data: [
{
adminUserId: 'u_admin_001',
action: 'LOGIN',
resourceType: 'auth',
resourceId: 'u_admin_001',
requestMethod: 'POST',
requestPath: '/admin/auth/login',
requestBody: JSON.stringify({ username: 'admin' }),
ip: '10.24.77.10',
userAgent: USER_AGENTS[0],
createdAt: daysAgo(0, 9, 25),
},
{
adminUserId: 'u_admin_001',
action: 'CREATE_TOOL',
resourceType: 'tool',
resourceId: 'tool_draft_001',
requestMethod: 'POST',
requestPath: '/admin/tools',
requestBody: JSON.stringify({
name: 'Local Agent Kit',
accessMode: 'download',
}),
ip: '10.24.77.10',
userAgent: USER_AGENTS[0],
createdAt: daysAgo(0, 10, 5),
},
{
adminUserId: 'u_admin_002',
action: 'UPLOAD_ARTIFACT',
resourceType: 'artifact',
resourceId: 'art_202',
requestMethod: 'POST',
requestPath: '/admin/tools/tool_dl_003/artifacts',
requestBody: JSON.stringify({ version: '2.1.0' }),
ip: '10.24.77.21',
userAgent: USER_AGENTS[1],
createdAt: daysAgo(1, 14, 12),
},
{
adminUserId: 'u_admin_002',
action: 'SET_LATEST_ARTIFACT',
resourceType: 'tool',
resourceId: 'tool_dl_003',
requestMethod: 'PATCH',
requestPath: '/admin/tools/tool_dl_003/artifacts/art_202/latest',
requestBody: null,
ip: '10.24.77.21',
userAgent: USER_AGENTS[1],
createdAt: daysAgo(1, 14, 18),
},
{
adminUserId: 'u_admin_001',
action: 'UPDATE_TOOL_STATUS',
resourceType: 'tool',
resourceId: 'tool_dl_001',
requestMethod: 'PATCH',
requestPath: '/admin/tools/tool_dl_001/status',
requestBody: JSON.stringify({ status: 'published' }),
ip: '10.24.77.10',
userAgent: USER_AGENTS[0],
createdAt: daysAgo(2, 11, 40),
},
{
adminUserId: 'u_admin_002',
action: 'UPDATE_TOOL',
resourceType: 'tool',
resourceId: 'tool_web_004',
requestMethod: 'PUT',
requestPath: '/admin/tools/tool_web_004',
requestBody: JSON.stringify({ openInNewTab: false }),
ip: '10.24.77.21',
userAgent: USER_AGENTS[2],
createdAt: daysAgo(2, 17, 5),
},
{
adminUserId: 'u_admin_001',
action: 'CREATE_CATEGORY',
resourceType: 'category',
resourceId: 'cat_productivity',
requestMethod: 'POST',
requestPath: '/admin/categories',
requestBody: JSON.stringify({ name: 'Productivity' }),
ip: '10.24.77.10',
userAgent: USER_AGENTS[0],
createdAt: daysAgo(3, 9, 45),
},
{
adminUserId: 'u_admin_001',
action: 'CREATE_TAG',
resourceType: 'tag',
resourceId: 'tag_recommended',
requestMethod: 'POST',
requestPath: '/admin/tags',
requestBody: JSON.stringify({ name: 'Recommended' }),
ip: '10.24.77.10',
userAgent: USER_AGENTS[0],
createdAt: daysAgo(3, 10, 10),
},
{
adminUserId: 'u_admin_002',
action: 'UPDATE_ARTIFACT_STATUS',
resourceType: 'artifact',
resourceId: 'art_001',
requestMethod: 'PATCH',
requestPath: '/admin/tools/tool_dl_001/artifacts/art_001/status',
requestBody: JSON.stringify({ status: 'deprecated' }),
ip: '10.24.77.21',
userAgent: USER_AGENTS[3],
createdAt: daysAgo(4, 15, 30),
},
{
adminUserId: 'u_admin_002',
action: 'LOGIN',
resourceType: 'auth',
resourceId: 'u_admin_002',
requestMethod: 'POST',
requestPath: '/admin/auth/login',
requestBody: JSON.stringify({ username: 'ops-admin' }),
ip: '10.24.77.21',
userAgent: USER_AGENTS[1],
createdAt: daysAgo(5, 8, 55),
},
{
adminUserId: 'u_admin_001',
action: 'DELETE_ARTIFACT',
resourceType: 'artifact',
resourceId: 'art_201',
requestMethod: 'DELETE',
requestPath: '/admin/tools/tool_dl_003/artifacts/art_201',
requestBody: null,
ip: '10.24.77.10',
userAgent: USER_AGENTS[0],
createdAt: daysAgo(6, 16, 40),
},
{
adminUserId: null,
action: 'SYSTEM_BOOTSTRAP',
resourceType: 'system',
resourceId: null,
requestMethod: 'POST',
requestPath: '/system/bootstrap',
requestBody: JSON.stringify({ source: 'seed' }),
ip: '127.0.0.1',
userAgent: 'seed-script',
createdAt: daysAgo(6, 7, 0),
},
],
});
}

View File

@@ -0,0 +1,60 @@
const { spawn, spawnSync } = require('child_process');
function normalizeWrappedEnv(name) {
const value = process.env[name];
if (!value || value.length < 2) {
return;
}
const first = value[0];
const last = value[value.length - 1];
if ((first === '"' && last === '"') || (first === "'" && last === "'")) {
process.env[name] = value.slice(1, -1);
}
}
function run(command, args, options = {}) {
const result = spawnSync(command, args, {
stdio: 'inherit',
env: process.env,
...options,
});
if (result.error) {
throw result.error;
}
if (typeof result.status === 'number' && result.status !== 0) {
process.exit(result.status);
}
}
function main() {
if (!process.env.DATABASE_URL) {
process.env.DATABASE_URL = 'file:./dev.db';
}
normalizeWrappedEnv('DATABASE_URL');
run(process.platform === 'win32' ? 'npx.cmd' : 'npx', ['prisma', 'migrate', 'deploy']);
const args = process.argv.slice(2);
if (args.length === 0) {
return;
}
const child = spawn(args[0], args.slice(1), {
stdio: 'inherit',
env: process.env,
});
child.on('exit', (code, signal) => {
if (signal) {
process.kill(process.pid, signal);
return;
}
process.exit(code ?? 0);
});
}
main();

10
server/skills-lock.json Normal file
View File

@@ -0,0 +1,10 @@
{
"version": 1,
"skills": {
"better-icons": {
"source": "better-auth/better-icons",
"sourceType": "github",
"computedHash": "656a618d6d5d5e9c567253eff57a06bee3be5d4fb983bdc401a46a209c5b76de"
}
}
}

View File

@@ -37,7 +37,9 @@ async function bootstrap() {
const swaggerConfig = new DocumentBuilder()
.setTitle('ToolsShow Backend API')
.setDescription('NestJS backend for ToolsShow with hybrid web/download tool access.')
.setDescription(
'NestJS backend for ToolsShow with hybrid web/download tool access.',
)
.setVersion('1.0.0')
.addBearerAuth(
{

View File

@@ -31,7 +31,6 @@ export class CreateToolDto {
@ApiProperty()
@IsString()
@MinLength(10)
@MaxLength(2000)
description!: string;
@ApiPropertyOptional({ minimum: 0, maximum: 5, default: 0 })

View File

@@ -14,6 +14,12 @@ export class ToolsController {
return this.toolsService.getTools(query);
}
@Get('slug/:slug')
@ApiOperation({ summary: 'Get tool detail by slug' })
getToolDetailBySlug(@Param('slug') slug: string) {
return this.toolsService.getToolDetailBySlug(slug);
}
@Get(':id')
@ApiOperation({ summary: 'Get tool detail' })
getToolDetail(@Param('id') id: string) {

View File

@@ -0,0 +1,119 @@
import { AccessMode, ArtifactStatus, Prisma } from '@prisma/client';
import { ERROR_CODES } from '../../common/constants/error-codes';
import { AppException } from '../../common/exceptions/app.exception';
import { ToolsService } from './tools.service';
function createToolEntity(overrides = {}) {
return {
id: 'tool_demo',
name: 'Demo Tool',
slug: 'demo-tool',
description: '# Demo Tool\n\n## Install',
rating: 4.5,
downloadCount: 18,
openCount: 42,
accessMode: AccessMode.download,
openUrl: 'https://example.com/download',
updatedAt: '2026-04-11',
isDeleted: false,
status: 'published',
category: {
id: 'cat_dev',
name: 'Developer Tools',
},
tags: [{ tag: { name: 'cli' } }, { tag: { name: 'automation' } }],
features: [{ featureText: 'Fast setup' }, { featureText: 'Offline mode' }],
latestArtifact: {
version: '2.0.0',
fileSizeBytes: 4096,
status: ArtifactStatus.active,
},
...overrides,
};
}
describe('ToolsService', () => {
type ToolEntity = ReturnType<typeof createToolEntity>;
type FindFirstArgs = {
where?: Prisma.ToolWhereInput;
};
function createService() {
const findFirst = jest.fn<Promise<ToolEntity | null>, [FindFirstArgs]>();
const prisma = {
tool: {
findFirst,
},
};
return {
prisma,
service: new ToolsService(prisma as never),
};
}
it('returns published tool detail by slug', async () => {
const { prisma, service } = createService();
prisma.tool.findFirst.mockResolvedValue(createToolEntity());
await expect(
service.getToolDetailBySlug('demo-tool'),
).resolves.toMatchObject({
id: 'tool_demo',
slug: 'demo-tool',
name: 'Demo Tool',
latestVersion: '2.0.0',
fileSize: 4096,
downloadReady: true,
});
const [callArg] = prisma.tool.findFirst.mock.calls[0];
expect(callArg?.where).toMatchObject({
slug: 'demo-tool',
isDeleted: false,
});
});
it('throws not found when slug does not match a published tool', async () => {
const { prisma, service } = createService();
prisma.tool.findFirst.mockResolvedValue(null);
await expect(
service.getToolDetailBySlug('missing-tool'),
).rejects.toMatchObject({
errorCode: ERROR_CODES.NOT_FOUND,
} satisfies Partial<AppException>);
});
it('returns the same detail shape for slug and id lookups', async () => {
const { prisma, service } = createService();
const tool = createToolEntity();
prisma.tool.findFirst
.mockResolvedValueOnce(tool)
.mockResolvedValueOnce(tool);
const detailById = await service.getToolDetail('tool_demo');
const detailBySlug = await service.getToolDetailBySlug('demo-tool');
expect(detailBySlug).toEqual(detailById);
});
it('marks downloadReady false when no artifact or external url exists', async () => {
const { prisma, service } = createService();
prisma.tool.findFirst.mockResolvedValue(
createToolEntity({
accessMode: AccessMode.download,
openUrl: null,
latestArtifact: null,
}),
);
await expect(
service.getToolDetailBySlug('demo-tool'),
).resolves.toMatchObject({
downloadReady: false,
latestVersion: null,
fileSize: null,
});
});
});

View File

@@ -5,6 +5,25 @@ import { AppException } from '../../common/exceptions/app.exception';
import { PrismaService } from '../../prisma/prisma.service';
import { GetToolsQueryDto, ToolSortBy } from './dto/get-tools-query.dto';
const toolDetailInclude = {
category: true,
tags: {
include: {
tag: true,
},
},
features: {
orderBy: {
sortOrder: 'asc',
},
},
latestArtifact: true,
} satisfies Prisma.ToolInclude;
type ToolDetailEntity = Prisma.ToolGetPayload<{
include: typeof toolDetailInclude;
}>;
@Injectable()
export class ToolsService {
constructor(private readonly prisma: PrismaService) {}
@@ -34,20 +53,7 @@ export class ToolsService {
this.prisma.tool.count({ where }),
this.prisma.tool.findMany({
where,
include: {
category: true,
tags: {
include: {
tag: true,
},
},
features: {
orderBy: {
sortOrder: 'asc',
},
},
latestArtifact: true,
},
include: toolDetailInclude,
skip: (page - 1) * pageSize,
take: pageSize,
orderBy: this.buildOrderBy(sortBy),
@@ -55,37 +61,7 @@ export class ToolsService {
]);
return {
list: tools.map((tool) => {
const hasArtifact = Boolean(
tool.latestArtifact && tool.latestArtifact.status === ArtifactStatus.active,
);
const hasExternalDownloadUrl =
tool.accessMode === 'download' && Boolean(tool.openUrl);
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.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,
};
}),
list: tools.map((tool) => this.mapToolListItem(tool)),
pagination: {
page,
pageSize,
@@ -96,33 +72,95 @@ export class ToolsService {
}
async getToolDetail(id: string) {
const tool = await this.findPublishedToolDetail({ id });
return this.mapToolDetail(tool);
}
// Keep both public entry points so legacy id-based flows and new shareable slug URLs
// can reuse the exact same detail mapping without drifting apart.
async getToolDetailBySlug(slug: string) {
const tool = await this.findPublishedToolDetail({ slug });
return this.mapToolDetail(tool);
}
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:
return [{ rating: 'desc' }, { modifiedAt: 'desc' }];
case ToolSortBy.name:
return [{ name: 'asc' }];
case ToolSortBy.latest:
default:
return [{ modifiedAt: 'desc' }];
}
}
private async findPublishedToolDetail(where: Prisma.ToolWhereInput) {
const tool = await this.prisma.tool.findFirst({
where: {
id,
...where,
isDeleted: false,
status: ToolStatus.published,
},
include: {
category: true,
tags: {
include: {
tag: true,
},
},
features: {
orderBy: {
sortOrder: 'asc',
},
},
latestArtifact: true,
},
include: toolDetailInclude,
});
if (!tool) {
throw new AppException(ERROR_CODES.NOT_FOUND, 'tool not found', 404);
}
const detail = {
return tool;
}
private mapToolListItem(tool: ToolDetailEntity) {
const hasArtifact = Boolean(
tool.latestArtifact &&
tool.latestArtifact.status === ArtifactStatus.active,
);
const hasExternalDownloadUrl =
tool.accessMode === 'download' && Boolean(tool.openUrl);
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.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,
};
}
private mapToolDetail(tool: ToolDetailEntity) {
return {
id: tool.id,
name: tool.name,
slug: tool.slug,
@@ -140,7 +178,9 @@ export class ToolsService {
updatedAt: tool.updatedAt,
openUrl: tool.openUrl,
latestVersion:
tool.accessMode === 'download' && tool.latestArtifact ? tool.latestArtifact.version : null,
tool.accessMode === 'download' && tool.latestArtifact
? tool.latestArtifact.version
: null,
fileSize:
tool.accessMode === 'download' && tool.latestArtifact
? tool.latestArtifact.fileSizeBytes
@@ -149,27 +189,10 @@ export class ToolsService {
tool.accessMode === 'download'
? Boolean(
tool.openUrl ||
(tool.latestArtifact && tool.latestArtifact.status === ArtifactStatus.active),
(tool.latestArtifact &&
tool.latestArtifact.status === ArtifactStatus.active),
)
: false,
};
return detail;
}
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:
return [{ rating: 'desc' }, { modifiedAt: 'desc' }];
case ToolSortBy.name:
return [{ name: 'asc' }];
case ToolSortBy.latest:
default:
return [{ modifiedAt: 'desc' }];
}
}
}

View File

@@ -0,0 +1,96 @@
import { INestApplication } from '@nestjs/common';
import { Test, TestingModule } from '@nestjs/testing';
import { AccessMode, ToolStatus } from '@prisma/client';
import { randomUUID } from 'crypto';
import request from 'supertest';
import { AppModule } from '../src/app.module';
import { PrismaService } from '../src/prisma/prisma.service';
interface ToolDetailResponse {
id: string;
slug: string;
name: string;
description: string;
}
describe('Tools detail by slug (e2e)', () => {
let app: INestApplication;
let prisma: PrismaService;
let categoryId = '';
let toolId = '';
let toolSlug = '';
beforeAll(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = moduleFixture.createNestApplication();
await app.init();
prisma = app.get(PrismaService);
});
beforeEach(async () => {
categoryId = `cat_${randomUUID().replace(/-/g, '')}`;
toolId = `tool_${randomUUID().replace(/-/g, '')}`;
toolSlug = `tool-${randomUUID().slice(0, 8)}`;
await prisma.category.create({
data: {
id: categoryId,
name: `Category ${toolSlug}`,
},
});
await prisma.tool.create({
data: {
id: toolId,
name: 'Slug Detail Tool',
slug: toolSlug,
categoryId,
description: '# Manual\n\n## Install',
accessMode: AccessMode.web,
openUrl: 'https://example.com/tool',
status: ToolStatus.published,
updatedAt: '2026-04-11',
},
});
});
afterEach(async () => {
await prisma.tool.deleteMany({
where: {
id: toolId,
},
});
await prisma.category.deleteMany({
where: {
id: categoryId,
},
});
});
afterAll(async () => {
await app.close();
});
function getHttpServer(): Parameters<typeof request>[0] {
return app.getHttpServer() as Parameters<typeof request>[0];
}
it('returns a published tool detail by slug', async () => {
await request(getHttpServer())
.get(`/tools/slug/${toolSlug}`)
.expect(200)
.expect(({ body }: { body: ToolDetailResponse }) => {
expect(body.id).toBe(toolId);
expect(body.slug).toBe(toolSlug);
expect(body.name).toBe('Slug Detail Tool');
expect(body.description).toContain('# Manual');
});
});
it('returns 404 for an unknown slug', async () => {
await request(getHttpServer()).get('/tools/slug/missing-tool').expect(404);
});
});