update
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
60
server/scripts/start-with-migrate.js
Normal file
60
server/scripts/start-with-migrate.js
Normal 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
10
server/skills-lock.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"version": 1,
|
||||
"skills": {
|
||||
"better-icons": {
|
||||
"source": "better-auth/better-icons",
|
||||
"sourceType": "github",
|
||||
"computedHash": "656a618d6d5d5e9c567253eff57a06bee3be5d4fb983bdc401a46a209c5b76de"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
@@ -31,7 +31,6 @@ export class CreateToolDto {
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@MinLength(10)
|
||||
@MaxLength(2000)
|
||||
description!: string;
|
||||
|
||||
@ApiPropertyOptional({ minimum: 0, maximum: 5, default: 0 })
|
||||
|
||||
@@ -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) {
|
||||
|
||||
119
server/src/modules/tools/tools.service.spec.ts
Normal file
119
server/src/modules/tools/tools.service.spec.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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' }];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
96
server/test/tools-detail.e2e-spec.ts
Normal file
96
server/test/tools-detail.e2e-spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user