diff --git a/.env.example b/.env.example index 058e52b..490b0f0 100644 --- a/.env.example +++ b/.env.example @@ -7,11 +7,6 @@ WEBHOOK_API_KEY="sk_live_your_secure_api_key_min_32_chars" # n8n AI Search Webhook N8N_AI_SEARCH_WEBHOOK="https://n8n.mzaxd.fun/webhook/ai-search" -# n8n Keyword Cloud Workflow Environment Variables -# These variables should be set in your n8n environment -# N8N_API_URL: Your API endpoint URL (e.g., http://localhost:3000 for local, or your production URL) -# N8N_API_KEY: Use the same value as WEBHOOK_API_KEY above - # Internationalization NEXT_INTL_DEFAULT_LOCALE="zh" NEXT_INTL_SUPPORTED_LOCALES="zh,en" diff --git a/prisma/migrations/20260418191500_remove_discovery_pipeline/migration.sql b/prisma/migrations/20260418191500_remove_discovery_pipeline/migration.sql new file mode 100644 index 0000000..6f60236 --- /dev/null +++ b/prisma/migrations/20260418191500_remove_discovery_pipeline/migration.sql @@ -0,0 +1,13 @@ +DROP TABLE IF EXISTS project_discovery_tasks; + +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 + FROM pg_type + WHERE typname = 'TaskStatus' + ) THEN + DROP TYPE "TaskStatus"; + END IF; +END +$$; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index fc60908..59ea62a 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -24,34 +24,11 @@ model ExternalLink { @@map("external_links") } -model ProjectDiscoveryTask { - id String @id @default(cuid()) - status TaskStatus @default(PENDING) - sourceUrl String - sourceType String @default("manual") - explorationData Json? - explorationSummary String? - errorMessage String? - retryCount Int @default(0) - lastRetryAt DateTime? - projectId String? - createdAt DateTime @default(now()) - startedAt DateTime? - completedAt DateTime? - updatedAt DateTime @updatedAt - project Project? @relation(fields: [projectId], references: [id]) - - @@index([projectId], map: "idx_task_project_id") - @@index([sourceUrl], map: "idx_task_source_url") - @@index([status, createdAt], map: "idx_task_status_created") - @@map("project_discovery_tasks") -} - model ProjectTag { projectId String tagId String - project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) - tag Tag @relation(fields: [tagId], references: [id], onDelete: Cascade) + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + tag Tag @relation(fields: [tagId], references: [id], onDelete: Cascade) @@id([projectId, tagId]) @@index([tagId]) @@ -59,25 +36,24 @@ model ProjectTag { } model Project { - id String @id @default(cuid()) - name String - nameEn String? - slug String @unique - description String - descriptionEn String? - content String? - contentEn String? - githubStars Int @default(0) - githubStarsUpdatedAt DateTime? - status ProjectStatus @default(ACTIVE) - source String? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - embedding Unsupported("vector")? - embeddingUpdatedAt DateTime? - links ExternalLink[] - projectDiscoveryTasks ProjectDiscoveryTask[] - tags ProjectTag[] + id String @id @default(cuid()) + name String + nameEn String? + slug String @unique + description String + descriptionEn String? + content String? + contentEn String? + githubStars Int @default(0) + githubStarsUpdatedAt DateTime? + status ProjectStatus @default(ACTIVE) + source String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + embedding Unsupported("vector")? + embeddingUpdatedAt DateTime? + links ExternalLink[] + tags ProjectTag[] @@index([embedding], map: "idx_project_embedding_cosine") @@index([slug], map: "idx_project_slug") @@ -87,13 +63,13 @@ model Project { } model Tag { - id String @id @default(cuid()) - name String @unique - nameEn String? - slug String @unique - category TagCategory @default(FREE_TAG) - createdAt DateTime @default(now()) - projects ProjectTag[] + id String @id @default(cuid()) + name String @unique + nameEn String? + slug String @unique + category TagCategory @default(FREE_TAG) + createdAt DateTime @default(now()) + projects ProjectTag[] @@index([category], map: "idx_tag_category") @@index([slug], map: "idx_tag_slug") @@ -141,13 +117,6 @@ enum ProjectStatus { ARCHIVED } -enum TaskStatus { - PENDING - IN_PROGRESS - COMPLETED - FAILED -} - enum TagCategory { FIXED_PROJECT_TYPE TECH_STACK diff --git a/src/app/api/chat/lib/chat-store.ts b/src/app/api/chat/lib/chat-store.ts deleted file mode 100644 index a4c6d9d..0000000 --- a/src/app/api/chat/lib/chat-store.ts +++ /dev/null @@ -1,129 +0,0 @@ -type ChatLocale = 'zh' | 'en' -type ChatRole = 'USER' | 'ASSISTANT' | 'SYSTEM' -type ChatStatus = 'ACTIVE' - -interface ChatMessage { - id: string - sessionId: string - role: ChatRole - contentText: string - mode: string | null - createdAt: string -} - -interface ChatSession { - id: string - clientId: string - locale: ChatLocale - title: string - status: ChatStatus - createdAt: string - updatedAt: string - lastMessageAt: string -} - -interface ChatStoreState { - sessions: Map - messages: Map -} - -declare global { - // eslint-disable-next-line no-var - var __agentParkChatStore: ChatStoreState | undefined -} - -function getStore(): ChatStoreState { - if (!globalThis.__agentParkChatStore) { - globalThis.__agentParkChatStore = { - sessions: new Map(), - messages: new Map(), - } - } - - return globalThis.__agentParkChatStore -} - -function nowIso(): string { - return new Date().toISOString() -} - -export async function createChatSession(input: { - clientId: string - locale: ChatLocale - title?: string -}): Promise { - const store = getStore() - const now = nowIso() - - const session: ChatSession = { - id: crypto.randomUUID(), - clientId: input.clientId, - locale: input.locale, - title: input.title?.trim() || 'New Chat', - status: 'ACTIVE', - createdAt: now, - updatedAt: now, - lastMessageAt: now, - } - - store.sessions.set(session.id, session) - store.messages.set(session.id, []) - - return session -} - -export async function listChatSessions(input: { - clientId: string - locale?: ChatLocale - limit: number - cursor?: string -}): Promise> { - const store = getStore() - - const ordered = Array.from(store.sessions.values()) - .filter((session) => - input.locale - ? session.clientId === input.clientId && session.locale === input.locale - : session.clientId === input.clientId - ) - .sort((a, b) => b.lastMessageAt.localeCompare(a.lastMessageAt)) - - const cursorIndex = input.cursor ? ordered.findIndex((session) => session.id === input.cursor) : -1 - const page = cursorIndex >= 0 ? ordered.slice(cursorIndex + 1, cursorIndex + 1 + input.limit) : ordered.slice(0, input.limit) - - return page.map((session) => { - const messageList = store.messages.get(session.id) || [] - const latest = messageList.length > 0 ? [messageList[messageList.length - 1] as ChatMessage] : [] - return { - ...session, - messages: latest, - } - }) -} - -export async function assertSessionOwnership(input: { - sessionId: string - clientId: string -}): Promise { - const store = getStore() - const session = store.sessions.get(input.sessionId) - - if (!session) { - throw new Error('CHAT_SESSION_NOT_FOUND') - } - - if (session.clientId !== input.clientId) { - throw new Error('CHAT_SESSION_FORBIDDEN') - } -} - -export async function listChatMessages(input: { - sessionId: string - limit: number -}): Promise { - const store = getStore() - const messages = store.messages.get(input.sessionId) || [] - const normalizedLimit = Math.max(1, input.limit) - - return messages.slice(-normalizedLimit) -} diff --git a/src/app/api/discovery/check-duplicates/route.ts b/src/app/api/discovery/check-duplicates/route.ts deleted file mode 100644 index c988246..0000000 --- a/src/app/api/discovery/check-duplicates/route.ts +++ /dev/null @@ -1,240 +0,0 @@ -import { NextRequest, NextResponse } from 'next/server' -import { prisma } from '@/lib/prisma' -import { CheckTaskDuplicatesSchema } from '@/lib/validations' -import { isValidApiKey } from '@/lib/auth' - -/** - * 检查 URL 是否应该创建新任务 - * - * 去重优先级: - * 1. PENDING/IN_PROGRESS 任务 → 不创建(任务处理中) - * 2. COMPLETED/FAILED 任务 → 不创建(已探索过) - * 3. 已存在的项目(通过 ExternalLink)→ 不创建(已收录) - * 4. 无任何记录 → 允许创建 - * - * @param url - 要检查的 URL - * @returns 检查结果 - */ -async function checkUrlDuplicate(url: string): Promise<{ - url: string - shouldCreate: boolean - reason: string - existingTask?: { - id: string - status: string - sourceUrl: string - createdAt: Date - projectId?: string | null - } - existingProject?: { - id: string - name: string - slug: string - } -}> { - // 优先级 1: 检查是否有 PENDING/IN_PROGRESS 的相同 URL 任务 - const activeTask = await prisma.projectDiscoveryTask.findFirst({ - where: { - sourceUrl: url, - status: { - in: ['PENDING', 'IN_PROGRESS'], - }, - }, - select: { - id: true, - status: true, - sourceUrl: true, - createdAt: true, - projectId: true, - }, - }) - - if (activeTask) { - return { - url, - shouldCreate: false, - reason: `Task already exists with status ${activeTask.status}`, - existingTask: activeTask, - } - } - - // 优先级 2: 检查是否有 COMPLETED/FAILED 的相同 URL 任务 - const finishedTask = await prisma.projectDiscoveryTask.findFirst({ - where: { - sourceUrl: url, - status: { - in: ['COMPLETED', 'FAILED'], - }, - }, - select: { - id: true, - status: true, - sourceUrl: true, - createdAt: true, - projectId: true, - }, - orderBy: { - createdAt: 'desc', - }, - }) - - if (finishedTask) { - // 如果任务已完成,返回项目信息 - let projectInfo - if (finishedTask.status === 'COMPLETED' && finishedTask.projectId) { - const project = await prisma.project.findUnique({ - where: { id: finishedTask.projectId }, - select: { - id: true, - name: true, - slug: true, - }, - }) - if (project) { - projectInfo = project - } - } - - return { - url, - shouldCreate: false, - reason: - finishedTask.status === 'COMPLETED' - ? 'Task already completed' - : 'Task already failed', - existingTask: finishedTask, - existingProject: projectInfo, - } - } - - // 优先级 3: 检查 URL 对应的项目是否已存在(通过 ExternalLink) - const existingLink = await prisma.externalLink.findFirst({ - where: { - url: url, - }, - select: { - project: { - select: { - id: true, - name: true, - slug: true, - }, - }, - }, - }) - - if (existingLink) { - return { - url, - shouldCreate: false, - reason: 'Project already exists with this URL', - existingProject: existingLink.project, - } - } - - // 默认: 允许创建新任务 - return { - url, - shouldCreate: true, - reason: 'No existing task or project found', - } -} - -/** - * POST /api/discovery/check-duplicates - * - * 检查 URL 是否应该创建新的探索任务 - * - * 请求体: - * { - * "apiKey": "xxx", - * "urls": ["https://github.com/user/repo", ...], - * "sourceType": "manual" // 可选 - * } - * - * 返回: - * { - * "success": true, - * "results": [ - * { - * "url": "...", - * "shouldCreate": false, - * "reason": "Task already exists with status PENDING", - * "existingTask": {...}, - * "existingProject": {...} - * } - * ], - * "stats": { - * "total": 10, - * "shouldCreate": 5, - * "duplicate": 5 - * } - * } - */ -export async function POST(request: NextRequest) { - const startTime = Date.now() - - try { - const body = await request.json() - - // 验证请求体 - const validationResult = CheckTaskDuplicatesSchema.safeParse(body) - - if (!validationResult.success) { - return NextResponse.json( - { - success: false, - error: 'Validation error', - details: validationResult.error.errors.map((e) => e.message), - }, - { status: 400 } - ) - } - - const { apiKey, urls, sourceType } = validationResult.data - - // 验证 API Key - if (!isValidApiKey(apiKey)) { - return NextResponse.json( - { - success: false, - error: 'Unauthorized', - details: ['Invalid or missing API Key'], - }, - { status: 401 } - ) - } - - // 并行检查所有 URL - const results = await Promise.all(urls.map((url) => checkUrlDuplicate(url))) - - // 统计信息 - const stats = { - total: results.length, - shouldCreate: results.filter((r) => r.shouldCreate).length, - duplicate: results.filter((r) => !r.shouldCreate).length, - } - - const duration = Date.now() - startTime - - console.warn( - `[CheckTaskDuplicates] Checked ${stats.total} URLs in ${duration}ms: ${stats.shouldCreate} should create, ${stats.duplicate} duplicate` - ) - - return NextResponse.json({ - success: true, - results, - stats, - }) - } catch (error) { - console.error('[CheckTaskDuplicates] Error:', error) - return NextResponse.json( - { - success: false, - error: 'Internal server error', - details: [error instanceof Error ? error.message : 'Unknown error'], - }, - { status: 500 } - ) - } -} diff --git a/src/app/api/discovery/lib/discovery-service.ts b/src/app/api/discovery/lib/discovery-service.ts deleted file mode 100644 index c97d83e..0000000 --- a/src/app/api/discovery/lib/discovery-service.ts +++ /dev/null @@ -1,564 +0,0 @@ -import { prisma } from '@/lib/prisma' -import type { ProjectInput } from '@/lib/validations' -import { generateSlug } from '@/lib/slug' -import type { Prisma } from '@prisma/client' -import { - FIXED_PROJECT_TYPE_TAGS, - inferProjectTypeSlug, - inferTagCategory, - type FixedProjectTypeSlug, -} from '@/lib/tag-taxonomy' - -const PLURAL_NORMALIZATION_MAP = new Map([ - ['agents', 'agent'], - ['assistants', 'assistant'], - ['tools', 'tool'], - ['frameworks', 'framework'], - ['models', 'model'], - ['servers', 'server'], - ['clients', 'client'], - ['workflows', 'workflow'], - ['plugins', 'plugin'], - ['libraries', 'library'], - ['datasets', 'dataset'], - ['platforms', 'platform'], - ['systems', 'system'], - ['repositories', 'repository'], - ['engines', 'engine'], -]) - -const NOISE_TAG_KEYS = new Set([ - 'ai', - 'artificial intelligence', - 'open source', - 'requires configuration', - 'requires basics', - 'low learning curve', - 'enterprise', - 'complex deployment', - 'cloud service', - 'cross platform', - 'tutorial', - 'academic research', - 'academic resource', - 'ai research resource', - 'multi language support', - 'self hosted', - 'user notification', - 'mit license', - 'apache 2 0', - '开源', - '需要配置', - '需要基础', - '低学习成本', - '企业级', - '复杂部署', - '云端服务', - '跨平台', - '教程', - '学术研究', - '学术资源', - '多语言支持', - '自托管', - '用户通知', - '许可', -]) - -const TAG_FALLBACK = { - name: 'AI开发工具', - nameEn: 'AI Development Tool', -} as const - -function normalizeWhitespace(value: string): string { - return value.replace(/\s+/g, ' ').trim() -} - -function isAsciiText(value: string): boolean { - return /^[\x00-\x7f]+$/.test(value) -} - -function canonicalizeTagKey(value: string): string { - const normalized = normalizeWhitespace(value) - .normalize('NFKC') - .toLowerCase() - .replace(/[+/_&|-]+/g, ' ') - .replace(/[^a-z0-9\u4e00-\u9fa5\s]/g, ' ') - .replace(/\s+/g, ' ') - .trim() - - if (!normalized) { - return '' - } - - return normalized - .split(' ') - .map((word) => PLURAL_NORMALIZATION_MAP.get(word) || word) - .join(' ') - .trim() -} - -function normalizeIncomingTag(tag: ProjectInput['tags'][number]) { - const normalizedName = normalizeWhitespace(tag.name) - const normalizedNameEn = normalizeWhitespace(tag.nameEn || '') - const resolvedNameEn = normalizedNameEn || (isAsciiText(normalizedName) ? normalizedName : '') - const canonicalNameKey = canonicalizeTagKey(normalizedName) - const canonicalNameEnKey = canonicalizeTagKey(resolvedNameEn) - - return { - name: normalizedName, - nameEn: resolvedNameEn || null, - canonicalNameKey, - canonicalNameEnKey, - slug: generateSlug(normalizedName, resolvedNameEn || null), - } -} - -function isMeaningfulTag(tag: ReturnType): boolean { - if (!tag.name) { - return false - } - - if (NOISE_TAG_KEYS.has(tag.canonicalNameKey)) { - return false - } - - if (tag.canonicalNameEnKey && NOISE_TAG_KEYS.has(tag.canonicalNameEnKey)) { - return false - } - - return true -} - -type TagWithProjectCount = Prisma.TagGetPayload<{ - include: { - _count: { select: { projects: true } } - } -}> - -function chooseBestTag(candidates: TagWithProjectCount[]): TagWithProjectCount { - return [...candidates].sort((a, b) => { - const projectDiff = b._count.projects - a._count.projects - if (projectDiff !== 0) { - return projectDiff - } - return a.createdAt.getTime() - b.createdAt.getTime() - })[0]! -} - -type IncomingNormalizedTag = ReturnType - -type TagLookupMaps = { - tagByExactName: Map - tagByExactNameEn: Map - tagBySlug: Map - tagByCanonicalName: Map - tagByCanonicalNameEn: Map -} - -function pushTagMapEntry( - map: Map, - key: string, - tag: TagWithProjectCount -): void { - if (!key) { - return - } - const current = map.get(key) - if (current) { - current.push(tag) - return - } - map.set(key, [tag]) -} - -function createTagLookupMaps(tags: TagWithProjectCount[]): TagLookupMaps { - const tagByExactName = new Map() - const tagByExactNameEn = new Map() - const tagBySlug = new Map() - const tagByCanonicalName = new Map() - const tagByCanonicalNameEn = new Map() - - for (const tag of tags) { - const exactNameKey = normalizeWhitespace(tag.name) - const exactNameEnKey = normalizeWhitespace(tag.nameEn || '') - const canonicalNameKey = canonicalizeTagKey(tag.name) - const canonicalNameEnKey = canonicalizeTagKey(tag.nameEn || '') - - pushTagMapEntry(tagByExactName, exactNameKey, tag) - pushTagMapEntry(tagByExactNameEn, exactNameEnKey, tag) - pushTagMapEntry(tagByCanonicalName, canonicalNameKey, tag) - pushTagMapEntry(tagByCanonicalNameEn, canonicalNameEnKey, tag) - tagBySlug.set(tag.slug, tag) - } - - return { - tagByExactName, - tagByExactNameEn, - tagBySlug, - tagByCanonicalName, - tagByCanonicalNameEn, - } -} - -function addTagToLookupMaps(lookups: TagLookupMaps, tag: TagWithProjectCount): void { - const exactNameKey = normalizeWhitespace(tag.name) - const exactNameEnKey = normalizeWhitespace(tag.nameEn || '') - const canonicalNameKey = canonicalizeTagKey(tag.name) - const canonicalNameEnKey = canonicalizeTagKey(tag.nameEn || '') - - pushTagMapEntry(lookups.tagByExactName, exactNameKey, tag) - pushTagMapEntry(lookups.tagByExactNameEn, exactNameEnKey, tag) - pushTagMapEntry(lookups.tagByCanonicalName, canonicalNameKey, tag) - pushTagMapEntry(lookups.tagByCanonicalNameEn, canonicalNameEnKey, tag) - lookups.tagBySlug.set(tag.slug, tag) -} - -async function getCandidateTags(incomingTags: IncomingNormalizedTag[]): Promise { - const nameValues = Array.from( - new Set(incomingTags.map((tag) => normalizeWhitespace(tag.name)).filter((name) => name.length > 0)) - ) - const slugValues = Array.from( - new Set(incomingTags.map((tag) => tag.slug).filter((slug) => slug.length > 0)) - ) - const nameEnValues = Array.from( - new Set( - incomingTags - .map((tag) => normalizeWhitespace(tag.nameEn || '')) - .filter((nameEn) => nameEn.length > 0) - ) - ) - - const whereOr: Prisma.TagWhereInput[] = [] - if (nameValues.length > 0) { - whereOr.push({ name: { in: nameValues } }) - } - if (slugValues.length > 0) { - whereOr.push({ slug: { in: slugValues } }) - } - if (nameEnValues.length > 0) { - whereOr.push({ nameEn: { in: nameEnValues } }) - } - - if (whereOr.length === 0) { - return [] - } - - return prisma.tag.findMany({ - where: { - OR: whereOr, - }, - include: { - _count: { - select: { projects: true }, - }, - }, - }) -} - -async function getFallbackTag(): Promise { - const fallbackSlug = generateSlug(TAG_FALLBACK.name, TAG_FALLBACK.nameEn) - const fallbackCategory = inferTagCategory({ - slug: fallbackSlug, - name: TAG_FALLBACK.name, - nameEn: TAG_FALLBACK.nameEn, - }) - const fallbackTag = await prisma.tag.upsert({ - where: { slug: fallbackSlug }, - update: { - name: TAG_FALLBACK.name, - nameEn: TAG_FALLBACK.nameEn, - category: fallbackCategory, - }, - create: { - name: TAG_FALLBACK.name, - nameEn: TAG_FALLBACK.nameEn, - slug: fallbackSlug, - category: fallbackCategory, - }, - include: { - _count: { - select: { projects: true }, - }, - }, - }) - - return fallbackTag -} - -const FIXED_PROJECT_TYPE_MAP = new Map( - FIXED_PROJECT_TYPE_TAGS.map((tag) => [tag.slug, tag] as const) -) - -export async function ensureFixedProjectTypeTag( - slug: FixedProjectTypeSlug -): Promise { - const fixedTag = FIXED_PROJECT_TYPE_MAP.get(slug) - if (!fixedTag) { - throw new Error(`Unsupported fixed project type slug: ${slug}`) - } - - return prisma.tag.upsert({ - where: { slug: fixedTag.slug }, - update: { - name: fixedTag.name, - nameEn: fixedTag.nameEn, - category: 'FIXED_PROJECT_TYPE', - }, - create: { - name: fixedTag.name, - nameEn: fixedTag.nameEn, - slug: fixedTag.slug, - category: 'FIXED_PROJECT_TYPE', - }, - include: { - _count: { - select: { projects: true }, - }, - }, - }) -} - -export async function resolveFixedProjectTypeTag( - projectData: Pick -): Promise { - const projectTypeSlug = inferProjectTypeSlug({ - name: projectData.name, - nameEn: projectData.nameEn, - description: projectData.description, - descriptionEn: projectData.descriptionEn, - tags: projectData.tags.map((tag) => ({ - name: tag.name, - nameEn: tag.nameEn, - slug: generateSlug(tag.name, tag.nameEn || null), - })), - }) - - return ensureFixedProjectTypeTag(projectTypeSlug) -} - -/** - * 多级去重策略:查找已存在的项目 - * - * 优先级: - * 1. GitHub URL 完全匹配(最准确) - * 2. Website URL 完全匹配 - * 3. slug 匹配(兜底) - * - * @param projectData - 项目数据 - * @returns 已存在的项目,如果不存在则返回 null - */ -export async function findExistingProject(projectData: ProjectInput) { - // 优先级1: 通过 GitHub URL 匹配 - const githubLink = projectData.links.find((link) => link.type === 'GITHUB') - if (githubLink) { - const existingByGithub = await prisma.externalLink.findFirst({ - where: { - type: 'GITHUB', - url: githubLink.url, - }, - include: { - project: { - include: { - links: true, - }, - }, - }, - }) - - if (existingByGithub) { - console.warn( - `[Discovery] Found existing project by GitHub URL: ${githubLink.url}` - ) - return existingByGithub.project - } - } - - // 优先级2: 通过 Website URL 匹配 - const websiteLink = projectData.links.find((link) => link.type === 'WEBSITE') - if (websiteLink) { - const existingByWebsite = await prisma.externalLink.findFirst({ - where: { - type: 'WEBSITE', - url: websiteLink.url, - }, - include: { - project: { - include: { - links: true, - }, - }, - }, - }) - - if (existingByWebsite) { - console.warn( - `[Discovery] Found existing project by Website URL: ${websiteLink.url}` - ) - return existingByWebsite.project - } - } - - // 优先级3: 通过 slug 匹配(兜底) - const slug = generateSlug(projectData.name, projectData.nameEn) - - const existingBySlug = await prisma.project.findUnique({ - where: { slug }, - include: { - links: true, - }, - }) - - if (existingBySlug) { - console.warn(`[Discovery] Found existing project by slug: ${slug}`) - return existingBySlug - } - - console.warn(`[Discovery] No existing project found, will create new one`) - return null -} - -/** - * 批量创建或获取标签 - * - * @param tags - 标签数组 - * @returns 标签连接对象数组 - */ -export async function upsertTags(tags: ProjectInput['tags']) { - const meaningfulTags = tags - .map((tag) => normalizeIncomingTag(tag)) - .filter((tag) => isMeaningfulTag(tag)) - - const normalizedIncomingTags = Array.from( - new Map( - meaningfulTags.map((normalized) => { - return [ - normalized.canonicalNameEnKey || normalized.canonicalNameKey || normalized.name, - normalized, - ] as const - }) - ).values() - ) - - if (normalizedIncomingTags.length === 0) { - return [await getFallbackTag()] - } - - const candidateTags = await getCandidateTags(normalizedIncomingTags) - const lookups = createTagLookupMaps(candidateTags) - - const resolvedTags: TagWithProjectCount[] = [] - - for (const incomingTag of normalizedIncomingTags) { - const exactNameCandidates = lookups.tagByExactName.get(incomingTag.name) || [] - let matchedTag = exactNameCandidates.length > 0 ? chooseBestTag(exactNameCandidates) : null - - if (!matchedTag && incomingTag.nameEn) { - const exactNameEnCandidates = - lookups.tagByExactNameEn.get(incomingTag.nameEn) || [] - if (exactNameEnCandidates.length > 0) { - matchedTag = chooseBestTag(exactNameEnCandidates) - } - } - - if (!matchedTag && incomingTag.canonicalNameKey) { - const canonicalNameCandidates = - lookups.tagByCanonicalName.get(incomingTag.canonicalNameKey) || [] - if (canonicalNameCandidates.length > 0) { - matchedTag = chooseBestTag(canonicalNameCandidates) - } - } - - if (!matchedTag && incomingTag.canonicalNameEnKey) { - const canonicalNameEnCandidates = - lookups.tagByCanonicalNameEn.get(incomingTag.canonicalNameEnKey) || [] - if (canonicalNameEnCandidates.length > 0) { - matchedTag = chooseBestTag(canonicalNameEnCandidates) - } - } - - if (!matchedTag) { - matchedTag = lookups.tagBySlug.get(incomingTag.slug) || null - } - - if (matchedTag) { - const inferredCategory = inferTagCategory({ - slug: incomingTag.slug, - name: incomingTag.name, - nameEn: incomingTag.nameEn, - }) - const shouldUpdateNameEn = incomingTag.nameEn && !matchedTag.nameEn - const shouldUpdateCategory = - matchedTag.category !== inferredCategory && - ['FREE_TAG', 'RESOURCE_TYPE', 'PROTOCOL_INTERFACE'].includes(matchedTag.category) - - if (shouldUpdateNameEn || shouldUpdateCategory) { - const updatedTag = await prisma.tag.update({ - where: { id: matchedTag.id }, - data: { - ...(shouldUpdateNameEn ? { nameEn: incomingTag.nameEn } : {}), - ...(shouldUpdateCategory ? { category: inferredCategory } : {}), - }, - include: { - _count: { - select: { projects: true }, - }, - }, - }) - matchedTag = updatedTag - addTagToLookupMaps(lookups, matchedTag) - } - - resolvedTags.push(matchedTag) - continue - } - - const inferredCategory = inferTagCategory({ - slug: incomingTag.slug, - name: incomingTag.name, - nameEn: incomingTag.nameEn, - }) - - try { - const createdTag = await prisma.tag.create({ - data: { - name: incomingTag.name, - nameEn: incomingTag.nameEn, - slug: incomingTag.slug, - category: inferredCategory, - }, - include: { - _count: { - select: { projects: true }, - }, - }, - }) - resolvedTags.push(createdTag) - addTagToLookupMaps(lookups, createdTag) - } catch { - const fallbackTag = await prisma.tag.findFirst({ - where: { - OR: [ - { name: incomingTag.name }, - { slug: incomingTag.slug }, - ], - }, - include: { - _count: { - select: { projects: true }, - }, - }, - }) - - if (fallbackTag) { - resolvedTags.push(fallbackTag) - continue - } - - throw new Error(`Failed to resolve tag: ${incomingTag.name}`) - } - } - - return Array.from( - new Map(resolvedTags.map((tag) => [tag.id, tag])).values() - ) -} diff --git a/src/app/api/discovery/tasks/[id]/complete/route.ts b/src/app/api/discovery/tasks/[id]/complete/route.ts deleted file mode 100644 index 7e5ea09..0000000 --- a/src/app/api/discovery/tasks/[id]/complete/route.ts +++ /dev/null @@ -1,210 +0,0 @@ -import { NextRequest, NextResponse } from 'next/server' -import { prisma } from '@/lib/prisma' -import { generateSlug } from '@/lib/slug' -import { - ProjectInputSchema, - type ProjectInput, -} from '@/lib/validations' -import { isValidApiKey } from '@/lib/auth' -import { - findExistingProject, - resolveFixedProjectTypeTag, - upsertTags, -} from '../../../lib/discovery-service' - -/** - * POST /api/discovery/tasks/:id/complete - * 完成探索并创建项目 - */ -export async function POST( - request: NextRequest, - { params }: { params: Promise<{ id: string }> } -) { - const startTime = Date.now() - const { id: taskId } = await params - - try { - const body = await request.json() - const { apiKey, explorationData } = body - - // 验证API密钥 - if (!isValidApiKey(apiKey)) { - return NextResponse.json( - { success: false, error: 'Unauthorized' }, - { status: 401 } - ) - } - - // 验证探索数据格式(符合ProjectInputSchema) - const projectValidation = ProjectInputSchema.safeParse(explorationData) - - if (!projectValidation.success) { - console.error( - `[Discovery] Invalid exploration data for task ${taskId}:`, - projectValidation.error.errors - ) - return NextResponse.json( - { - success: false, - error: 'Invalid exploration data format', - details: projectValidation.error.errors, - }, - { status: 400 } - ) - } - - const projectData = projectValidation.data as ProjectInput - - // 查找已存在的项目(复用webhook的多级去重逻辑) - const existingProject = await findExistingProject(projectData) - - // Upsert tags(复用服务层函数)+ 固定项目分类标签 - const [dynamicTagConnections, fixedProjectTypeTag] = await Promise.all([ - upsertTags(projectData.tags), - resolveFixedProjectTypeTag(projectData), - ]) - const tagConnections = Array.from( - new Map( - [...dynamicTagConnections, fixedProjectTypeTag].map((tag) => [tag.id, tag]) - ).values() - ) - - // 生成slug - const slug = generateSlug(projectData.name, projectData.nameEn) - - // 使用事务确保项目创建/更新和任务状态更新的原子性 - const result = await prisma.$transaction(async (tx) => { - let projectId: string - - if (existingProject) { - // 更新现有项目 - console.warn( - `[Discovery] Updating existing project for task ${taskId}: ${projectData.name}` - ) - - await tx.projectTag.deleteMany({ - where: { projectId: existingProject.id }, - }) - - await tx.project.update({ - where: { id: existingProject.id }, - data: { - name: projectData.name, - nameEn: projectData.nameEn || null, - description: projectData.description, - descriptionEn: projectData.descriptionEn || null, - content: projectData.content || null, - contentEn: projectData.contentEn || null, - status: projectData.status, - source: projectData.source || 'discovery', - tags: { - create: tagConnections.map((t) => ({ - tag: { connect: { id: t.id } }, - })), - }, - }, - }) - - await tx.externalLink.deleteMany({ - where: { projectId: existingProject.id }, - }) - - await tx.externalLink.createMany({ - data: projectData.links.map((link) => ({ - type: link.type, - url: link.url, - title: link.title || null, - projectId: existingProject.id, - })), - }) - - projectId = existingProject.id - } else { - // 创建新项目 - console.warn( - `[Discovery] Creating new project for task ${taskId}: ${projectData.name}` - ) - - const newProject = await tx.project.create({ - data: { - name: projectData.name, - nameEn: projectData.nameEn || null, - slug, - description: projectData.description, - descriptionEn: projectData.descriptionEn || null, - content: projectData.content || null, - contentEn: projectData.contentEn || null, - status: projectData.status, - source: projectData.source || 'discovery', - tags: { - create: tagConnections.map((t) => ({ - tag: { connect: { id: t.id } }, - })), - }, - links: { - create: projectData.links.map((link) => ({ - type: link.type, - url: link.url, - title: link.title || null, - })), - }, - }, - }) - - projectId = newProject.id - } - - // 在同一事务中更新任务状态为COMPLETED - const updatedTask = await tx.projectDiscoveryTask.update({ - where: { id: taskId }, - data: { - status: 'COMPLETED', - completedAt: new Date(), - explorationData: explorationData, - projectId, - }, - }) - - return { projectId, updatedTask } - }) - - const duration = Date.now() - startTime - console.warn( - `[Discovery] Completed task ${taskId} in ${duration}ms, project: ${result.projectId}` - ) - - return NextResponse.json({ - success: true, - taskId, - projectId: result.projectId, - action: existingProject ? 'updated' : 'created', - duration, - }) - } catch (error) { - console.error('[Discovery] Error completing task:', error) - - // 失败时更新任务状态为FAILED - try { - await prisma.projectDiscoveryTask.update({ - where: { id: taskId }, - data: { - status: 'FAILED', - completedAt: new Date(), - errorMessage: error instanceof Error ? error.message : 'Unknown error', - retryCount: { increment: 1 }, - }, - }) - } catch (updateError) { - console.error('[Discovery] Failed to update task status:', updateError) - } - - return NextResponse.json( - { - success: false, - error: 'Internal server error', - details: [error instanceof Error ? error.message : 'Unknown error'], - }, - { status: 500 } - ) - } -} diff --git a/src/app/api/discovery/tasks/[id]/route.ts b/src/app/api/discovery/tasks/[id]/route.ts deleted file mode 100644 index c8e8822..0000000 --- a/src/app/api/discovery/tasks/[id]/route.ts +++ /dev/null @@ -1,166 +0,0 @@ -import { NextRequest, NextResponse } from 'next/server' -import { prisma } from '@/lib/prisma' -import { UpdateDiscoveryTaskSchema, TaskStatus } from '@/lib/validations' -import type { Prisma } from '@prisma/client' -import { isValidApiKey } from '@/lib/auth' - -/** - * 有效的任务状态转换规则 - * PENDING -> IN_PROGRESS - * IN_PROGRESS -> COMPLETED | FAILED - * FAILED -> PENDING | IN_PROGRESS (允许重试,可直接重试或重置后重试) - * COMPLETED -> (终态,不允许转换) - */ -const VALID_STATUS_TRANSITIONS: Record = { - PENDING: ['IN_PROGRESS'], - IN_PROGRESS: ['COMPLETED', 'FAILED'], - COMPLETED: [], - FAILED: ['PENDING', 'IN_PROGRESS'], -} - -/** - * 验证状态转换是否合法 - */ -function isValidStatusTransition(from: TaskStatus, to: TaskStatus): boolean { - return VALID_STATUS_TRANSITIONS[from].includes(to) -} - -/** - * GET /api/discovery/tasks/:id - * 获取单个任务详情 - */ -export async function GET( - request: NextRequest, - { params }: { params: Promise<{ id: string }> } -) { - try { - const { id } = await params - const task = await prisma.projectDiscoveryTask.findUnique({ - where: { id }, - }) - - if (!task) { - return NextResponse.json( - { success: false, error: 'Task not found' }, - { status: 404 } - ) - } - - return NextResponse.json({ - success: true, - task, - }) - } catch (error) { - console.error('[Discovery] Error fetching task:', error) - return NextResponse.json( - { success: false, error: 'Internal server error' }, - { status: 500 } - ) - } -} - -/** - * PATCH /api/discovery/tasks/:id - * 更新任务状态 - */ -export async function PATCH( - request: NextRequest, - { params }: { params: Promise<{ id: string }> } -) { - try { - const { id } = await params - const body = await request.json() - const validation = UpdateDiscoveryTaskSchema.safeParse(body) - - if (!validation.success) { - return NextResponse.json( - { - success: false, - error: 'Validation error', - details: validation.error.errors.map((e) => e.message), - }, - { status: 400 } - ) - } - - const { apiKey, status, explorationData, explorationSummary, errorMessage } = - validation.data - - // 验证API密钥 - if (!isValidApiKey(apiKey)) { - return NextResponse.json( - { success: false, error: 'Unauthorized' }, - { status: 401 } - ) - } - - // 获取当前任务状态以验证状态转换 - const existingTask = await prisma.projectDiscoveryTask.findUnique({ - where: { id }, - select: { status: true }, - }) - - if (!existingTask) { - return NextResponse.json( - { success: false, error: 'Task not found' }, - { status: 404 } - ) - } - - // 验证状态转换是否合法 - if (!isValidStatusTransition(existingTask.status, status)) { - return NextResponse.json( - { - success: false, - error: 'Invalid status transition', - details: [ - `Cannot transition from ${existingTask.status} to ${status}. Valid transitions: ${VALID_STATUS_TRANSITIONS[existingTask.status].join(', ')}`, - ], - }, - { status: 400 } - ) - } - - // 定义更新数据类型 - interface TaskUpdateData { - status: TaskStatus - startedAt?: Date - completedAt?: Date - explorationData?: Prisma.InputJsonValue - explorationSummary?: string | null - errorMessage?: string | null - } - - const updateData: TaskUpdateData = { status } - - if (status === 'IN_PROGRESS') { - updateData.startedAt = new Date() - } else if (status === 'COMPLETED' || status === 'FAILED') { - updateData.completedAt = new Date() - } - - if (explorationData !== undefined) { - updateData.explorationData = explorationData as Prisma.InputJsonObject - } - if (explorationSummary !== undefined) updateData.explorationSummary = explorationSummary - if (errorMessage !== undefined) updateData.errorMessage = errorMessage - - const task = await prisma.projectDiscoveryTask.update({ - where: { id }, - data: updateData, - }) - - console.warn(`[Discovery] Updated task ${id} to status: ${status}`) - - return NextResponse.json({ - success: true, - task, - }) - } catch (error) { - console.error('[Discovery] Error updating task:', error) - return NextResponse.json( - { success: false, error: 'Internal server error' }, - { status: 500 } - ) - } -} diff --git a/src/app/api/discovery/tasks/batch-reset/route.ts b/src/app/api/discovery/tasks/batch-reset/route.ts deleted file mode 100644 index 40989ad..0000000 --- a/src/app/api/discovery/tasks/batch-reset/route.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { NextRequest, NextResponse } from 'next/server' -import { prisma } from '@/lib/prisma' -import { BatchResetTasksSchema } from '@/lib/validations' -import { isValidApiKey } from '@/lib/auth' - -/** - * POST /api/discovery/tasks/batch-reset - * 批量重置任务状态为 PENDING - * - * 用于 n8n 探索流程失败后重置任务,支持两种模式: - * 1. 按任务 ID 列表重置:{ taskIds: ["id1", "id2"] } - * 2. 按状态筛选重置:{ statuses: ["IN_PROGRESS", "FAILED"] } - * 不传 statuses 则默认重置 IN_PROGRESS 和 FAILED - */ -export async function POST(request: NextRequest) { - try { - const body = await request.json() - const validation = BatchResetTasksSchema.safeParse(body) - - if (!validation.success) { - return NextResponse.json( - { - success: false, - error: 'Validation error', - details: validation.error.errors.map((e) => e.message), - }, - { status: 400 } - ) - } - - const { apiKey, taskIds, statuses } = validation.data - - // 验证API密钥 - if (!isValidApiKey(apiKey)) { - return NextResponse.json( - { success: false, error: 'Unauthorized' }, - { status: 401 } - ) - } - - // 构建查询条件 - const where = taskIds - ? { id: { in: taskIds } } - : { status: { in: statuses || ['IN_PROGRESS', 'FAILED'] } } - - // 批量更新任务状态 - const result = await prisma.projectDiscoveryTask.updateMany({ - where, - data: { - status: 'PENDING', - startedAt: null, - completedAt: null, - errorMessage: null, - }, - }) - - console.warn( - `[Discovery] Batch reset ${result.count} tasks to PENDING. Condition: ${JSON.stringify(where)}` - ) - - return NextResponse.json({ - success: true, - reset: result.count, - }) - } catch (error) { - console.error('[Discovery] Error batch resetting tasks:', error) - return NextResponse.json( - { success: false, error: 'Internal server error' }, - { status: 500 } - ) - } -} diff --git a/src/app/api/discovery/tasks/route.ts b/src/app/api/discovery/tasks/route.ts deleted file mode 100644 index 537c8bb..0000000 --- a/src/app/api/discovery/tasks/route.ts +++ /dev/null @@ -1,160 +0,0 @@ -import { NextRequest, NextResponse } from 'next/server' -import { prisma } from '@/lib/prisma' -import { - CreateDiscoveryTaskSchema, - GetDiscoveryTasksQuerySchema, -} from '@/lib/validations' -import { isValidApiKey } from '@/lib/auth' - -/** - * POST /api/discovery/tasks - * 创建新的探索任务 - */ -export async function POST(request: NextRequest) { - try { - const body = await request.json() - const validation = CreateDiscoveryTaskSchema.safeParse(body) - - if (!validation.success) { - return NextResponse.json( - { - success: false, - error: 'Validation error', - details: validation.error.errors.map((e) => e.message), - }, - { status: 400 } - ) - } - - const { apiKey, tasks } = validation.data - - // 验证API密钥 - if (!isValidApiKey(apiKey)) { - return NextResponse.json( - { success: false, error: 'Unauthorized' }, - { status: 401 } - ) - } - - // 去重:检查URL是否已存在任务 - const existingUrls = new Set( - ( - await prisma.projectDiscoveryTask.findMany({ - where: { sourceUrl: { in: tasks.map((t) => t.sourceUrl) } }, - select: { sourceUrl: true }, - }) - ).map((t) => t.sourceUrl) - ) - - // 创建新任务(跳过已存在的) - const newTasks = tasks.filter((t) => !existingUrls.has(t.sourceUrl)) - - if (newTasks.length === 0) { - return NextResponse.json({ - success: true, - created: 0, - skipped: tasks.length, - total: tasks.length, - message: 'All tasks already exist', - }) - } - - const created = await prisma.projectDiscoveryTask.createMany({ - data: newTasks.map((t) => ({ - sourceUrl: t.sourceUrl, - sourceType: t.sourceType, - status: 'PENDING', - })), - }) - - console.warn( - `[Discovery] Created ${created.count} tasks, skipped ${tasks.length - created.count} existing tasks` - ) - - return NextResponse.json({ - success: true, - created: created.count, - skipped: tasks.length - created.count, - total: tasks.length, - }) - } catch (error) { - console.error('[Discovery] Error creating tasks:', error) - return NextResponse.json( - { success: false, error: 'Internal server error' }, - { status: 500 } - ) - } -} - -/** - * GET /api/discovery/tasks - * 获取探索任务列表 - */ -export async function GET(request: NextRequest) { - try { - const { searchParams } = new URL(request.url) - - // 验证 API Key(只读权限) - // 支持两种方式:1. 请求头 x-api-key 2. 查询参数 apiKey - const apiKey = request.headers.get('x-api-key') || searchParams.get('apiKey') - if (!isValidApiKey(apiKey)) { - return NextResponse.json( - { success: false, error: 'Unauthorized' }, - { status: 401 } - ) - } - const validation = GetDiscoveryTasksQuerySchema.safeParse({ - status: searchParams.get('status') || undefined, - limit: searchParams.get('limit') || '10', - offset: searchParams.get('offset') || '0', - }) - - if (!validation.success) { - return NextResponse.json( - { - success: false, - error: 'Validation error', - details: validation.error.errors.map((e) => e.message), - }, - { status: 400 } - ) - } - - const { status, limit, offset } = validation.data - - // 构建查询条件:支持单状态或多状态(逗号分隔) - let whereClause = {} - if (status) { - // 支持单状态 (?status=PENDING) 或多状态 (?status=PENDING,FAILED) - if (Array.isArray(status)) { - whereClause = { status: { in: status } } - } else { - whereClause = { status } - } - } - - const tasks = await prisma.projectDiscoveryTask.findMany({ - where: whereClause, - orderBy: { createdAt: 'asc' }, - take: limit, - skip: offset, - }) - - const total = await prisma.projectDiscoveryTask.count({ - where: whereClause, - }) - - return NextResponse.json({ - success: true, - tasks, - total, - hasMore: offset + tasks.length < total, - }) - } catch (error) { - console.error('[Discovery] Error fetching tasks:', error) - return NextResponse.json( - { success: false, error: 'Internal server error' }, - { status: 500 } - ) - } -} diff --git a/src/app/api/projects/[slug]/route.ts b/src/app/api/projects/[slug]/route.ts index 486dc7c..a08d319 100644 --- a/src/app/api/projects/[slug]/route.ts +++ b/src/app/api/projects/[slug]/route.ts @@ -1,112 +1,14 @@ -import { NextRequest, NextResponse } from 'next/server' -import { prisma } from '@/lib/prisma' -import { isValidApiKey } from '@/lib/auth' - -/** - * DELETE /api/projects/[slug] - * - * 根据项目的 slug 删除项目及其所有关联数据 - * - * 由于数据库 schema 配置了 onDelete: Cascade, - * 删除项目时会自动删除: - * - 该项目的所有外部链接(ExternalLink) - * - 该项目的所有标签关联(ProjectTag) - * - * 注意:Tag 本身不会被删除,只会删除项目与标签的关联关系 - */ -export async function DELETE( - request: NextRequest, - { params }: { params: Promise<{ slug: string }> } -) { - try { - const { slug } = await params - - // Verify API Key - const apiKey = request.headers.get('x-api-key') - if (!isValidApiKey(apiKey)) { - return NextResponse.json( - { - success: false, - error: 'Unauthorized', - details: ['Invalid or missing API Key'], - }, - { status: 401 } - ) - } - - // Check if project exists - const existingProject = await prisma.project.findUnique({ - where: { slug }, - include: { - links: true, - tags: { - include: { - tag: true, - }, - }, - }, - }) - - if (!existingProject) { - return NextResponse.json( - { - success: false, - error: 'Not Found', - details: [`Project with slug "${slug}" not found`], - }, - { status: 404 } - ) - } - - // Delete project (cascade delete will handle links and project_tags) - await prisma.project.delete({ - where: { slug }, - }) - - console.warn( - `[API] Deleted project "${existingProject.name}" (slug: ${slug}, id: ${existingProject.id})` - ) - - return NextResponse.json({ - success: true, - message: 'Project deleted successfully', - data: { - project: { - id: existingProject.id, - name: existingProject.name, - nameEn: existingProject.nameEn, - slug: existingProject.slug, - }, - deleted: { - linksCount: existingProject.links.length, - tagsCount: existingProject.tags.length, - }, - }, - }) - } catch (error) { - console.error('[API] Error deleting project:', error) - return NextResponse.json( - { - success: false, - error: 'Internal server error', - details: [error instanceof Error ? error.message : 'Unknown error'], - }, - { status: 500 } - ) - } -} +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; /** * GET /api/projects/[slug] * * 根据项目的 slug 获取项目详情 */ -export async function GET( - request: NextRequest, - { params }: { params: Promise<{ slug: string }> } -) { +export async function GET(request: NextRequest, { params }: { params: Promise<{ slug: string }> }) { try { - const { slug } = await params + const { slug } = await params; const project = await prisma.project.findUnique({ where: { slug }, @@ -118,17 +20,17 @@ export async function GET( }, }, }, - }) + }); if (!project) { return NextResponse.json( { success: false, - error: 'Not Found', + error: "Not Found", details: [`Project with slug "${slug}" not found`], }, { status: 404 } - ) + ); } // Transform response to match frontend structure @@ -140,21 +42,21 @@ export async function GET( nameEn: pt.tag.nameEn, slug: pt.tag.slug, })), - } + }; return NextResponse.json({ success: true, data: transformedProject, - }) + }); } catch (error) { - console.error('[API] Error fetching project:', error) + console.error("[API] Error fetching project:", error); return NextResponse.json( { success: false, - error: 'Internal server error', - details: [error instanceof Error ? error.message : 'Unknown error'], + error: "Internal server error", + details: [error instanceof Error ? error.message : "Unknown error"], }, { status: 500 } - ) + ); } } diff --git a/src/app/api/webhook/check-duplicates/route.ts b/src/app/api/webhook/check-duplicates/route.ts deleted file mode 100644 index f00a41f..0000000 --- a/src/app/api/webhook/check-duplicates/route.ts +++ /dev/null @@ -1,362 +0,0 @@ -import { NextRequest, NextResponse } from 'next/server' -import { prisma } from '@/lib/prisma' -import { isValidApiKey } from '@/lib/auth' -import { z } from 'zod' - -/** - * 去重检查请求 Schema - */ -const CheckDuplicatesSchema = z.object({ - apiKey: z.string(), - projects: z.array( - z.object({ - githubUrl: z.string().url().optional(), - huggingfaceUrl: z.string().url().optional(), - websiteUrl: z.string().url().optional(), - slug: z.string().optional(), - }) - ).min(1).max(100), -}) - -/** - * 匹配类型 - */ -type MatchType = - | 'GITHUB_URL' - | 'HUGGINGFACE_URL' - | 'WEBSITE_URL' - | 'SLUG' - | 'NONE' - -/** - * 检查结果 - */ -interface CheckResult { - githubUrl?: string - huggingfaceUrl?: string - websiteUrl?: string - slug?: string - exists: boolean - matchType: MatchType - projectId?: string - projectName?: string -} - -type CheckProjectInput = { - githubUrl?: string - huggingfaceUrl?: string - websiteUrl?: string - slug?: string -} - -type ProjectSummary = { - id: string - name: string -} - -type DuplicateCheckMaps = { - githubMap: Map - huggingfaceMap: Map - websiteMap: Map - slugMap: Map -} - -function buildLookupKey(project: CheckProjectInput): string { - return [ - project.githubUrl || '', - project.huggingfaceUrl || '', - project.websiteUrl || '', - project.slug || '', - ].join('|') -} - -function toExternalLinkMap( - rows: Array<{ url: string; project: ProjectSummary }> -): Map { - const map = new Map() - for (const row of rows) { - if (!map.has(row.url)) { - map.set(row.url, row.project) - } - } - return map -} - -function toSlugMap( - rows: Array<{ slug: string; id: string; name: string }> -): Map { - const map = new Map() - for (const row of rows) { - map.set(row.slug, { id: row.id, name: row.name }) - } - return map -} - -async function buildDuplicateCheckMaps( - projects: CheckProjectInput[] -): Promise { - const githubUrls = Array.from( - new Set( - projects - .map((project) => project.githubUrl?.trim()) - .filter((url): url is string => Boolean(url)) - ) - ) - const huggingfaceUrls = Array.from( - new Set( - projects - .map((project) => project.huggingfaceUrl?.trim()) - .filter((url): url is string => Boolean(url)) - ) - ) - const websiteUrls = Array.from( - new Set( - projects - .map((project) => project.websiteUrl?.trim()) - .filter((url): url is string => Boolean(url)) - ) - ) - const slugs = Array.from( - new Set( - projects - .map((project) => project.slug?.trim()) - .filter((slug): slug is string => Boolean(slug)) - ) - ) - - const [githubRows, huggingfaceRows, websiteRows, slugRows] = await Promise.all([ - githubUrls.length > 0 - ? prisma.externalLink.findMany({ - where: { - type: 'GITHUB', - url: { - in: githubUrls, - }, - }, - select: { - url: true, - project: { - select: { - id: true, - name: true, - }, - }, - }, - }) - : Promise.resolve([]), - huggingfaceUrls.length > 0 - ? prisma.externalLink.findMany({ - where: { - type: 'HUGGINGFACE', - url: { - in: huggingfaceUrls, - }, - }, - select: { - url: true, - project: { - select: { - id: true, - name: true, - }, - }, - }, - }) - : Promise.resolve([]), - websiteUrls.length > 0 - ? prisma.externalLink.findMany({ - where: { - type: 'WEBSITE', - url: { - in: websiteUrls, - }, - }, - select: { - url: true, - project: { - select: { - id: true, - name: true, - }, - }, - }, - }) - : Promise.resolve([]), - slugs.length > 0 - ? prisma.project.findMany({ - where: { - slug: { - in: slugs, - }, - }, - select: { - slug: true, - id: true, - name: true, - }, - }) - : Promise.resolve([]), - ]) - - return { - githubMap: toExternalLinkMap(githubRows), - huggingfaceMap: toExternalLinkMap(huggingfaceRows), - websiteMap: toExternalLinkMap(websiteRows), - slugMap: toSlugMap(slugRows), - } -} - -function checkProjectExists( - project: CheckProjectInput, - duplicateMaps: DuplicateCheckMaps -): CheckResult { - if (project.githubUrl) { - const existingByGithub = duplicateMaps.githubMap.get(project.githubUrl.trim()) - if (existingByGithub) { - return { - githubUrl: project.githubUrl, - exists: true, - matchType: 'GITHUB_URL', - projectId: existingByGithub.id, - projectName: existingByGithub.name, - } - } - } - - if (project.huggingfaceUrl) { - const existingByHuggingFace = duplicateMaps.huggingfaceMap.get(project.huggingfaceUrl.trim()) - if (existingByHuggingFace) { - return { - huggingfaceUrl: project.huggingfaceUrl, - exists: true, - matchType: 'HUGGINGFACE_URL', - projectId: existingByHuggingFace.id, - projectName: existingByHuggingFace.name, - } - } - } - - if (project.websiteUrl) { - const existingByWebsite = duplicateMaps.websiteMap.get(project.websiteUrl.trim()) - if (existingByWebsite) { - return { - websiteUrl: project.websiteUrl, - exists: true, - matchType: 'WEBSITE_URL', - projectId: existingByWebsite.id, - projectName: existingByWebsite.name, - } - } - } - - if (project.slug) { - const existingBySlug = duplicateMaps.slugMap.get(project.slug.trim()) - if (existingBySlug) { - return { - slug: project.slug, - exists: true, - matchType: 'SLUG', - projectId: existingBySlug.id, - projectName: existingBySlug.name, - } - } - } - - return { - githubUrl: project.githubUrl, - huggingfaceUrl: project.huggingfaceUrl, - websiteUrl: project.websiteUrl, - slug: project.slug, - exists: false, - matchType: 'NONE', - } -} - -export async function POST(request: NextRequest) { - const startTime = Date.now() - - try { - const body = await request.json() - - // Validate payload - const validationResult = CheckDuplicatesSchema.safeParse(body) - - if (!validationResult.success) { - return NextResponse.json( - { - success: false, - error: 'Validation error', - details: validationResult.error.errors.map((e) => e.message), - }, - { status: 400 } - ) - } - - const payload = validationResult.data - - // Verify API Key - if (!isValidApiKey(payload.apiKey)) { - return NextResponse.json( - { - success: false, - error: 'Unauthorized', - details: ['Invalid or missing API Key'], - }, - { status: 401 } - ) - } - - const duplicateMaps = await buildDuplicateCheckMaps(payload.projects) - const cachedResults = new Map() - - const results = payload.projects.map((project) => { - const lookupKey = buildLookupKey(project) - const cached = cachedResults.get(lookupKey) - if (cached) { - return cached - } - - const result = checkProjectExists(project, duplicateMaps) - cachedResults.set(lookupKey, result) - return result - }) - - // 统计信息 - const stats = { - total: results.length, - exists: results.filter((r) => r.exists).length, - new: results.filter((r) => !r.exists).length, - breakdown: { - githubUrl: results.filter((r) => r.matchType === 'GITHUB_URL').length, - huggingfaceUrl: results.filter( - (r) => r.matchType === 'HUGGINGFACE_URL' - ).length, - websiteUrl: results.filter((r) => r.matchType === 'WEBSITE_URL') - .length, - slug: results.filter((r) => r.matchType === 'SLUG').length, - }, - } - - const duration = Date.now() - startTime - - console.warn( - `[CheckDuplicates] Checked ${stats.total} projects in ${duration}ms: ${stats.exists} exist, ${stats.new} new` - ) - - return NextResponse.json({ - success: true, - results, - stats, - }) - } catch (error) { - console.error('[CheckDuplicates] Error:', error) - return NextResponse.json( - { - success: false, - error: 'Internal server error', - details: [error instanceof Error ? error.message : 'Unknown error'], - }, - { status: 500 } - ) - } -} diff --git a/src/app/api/webhook/projects/route.ts b/src/app/api/webhook/projects/route.ts deleted file mode 100644 index 1704546..0000000 --- a/src/app/api/webhook/projects/route.ts +++ /dev/null @@ -1,228 +0,0 @@ -import { NextRequest, NextResponse } from 'next/server' -import { prisma } from '@/lib/prisma' -import type { ProjectStatus, LinkType } from '@prisma/client' -import { isValidApiKey } from '@/lib/auth' -import { - WebhookPayloadSchema, - ProjectIngestionInputSchema, - type WebhookPayload, - type ProjectIngestionInput, -} from '@/lib/validations' -import { - findExistingProject, - resolveFixedProjectTypeTag, - upsertTags, -} from '../../discovery/lib/discovery-service' -import { generateSlug } from '@/lib/slug' - -export async function POST(request: NextRequest) { - const startTime = Date.now() - - try { - const body = await request.json() - - // Validate payload - const validationResult = WebhookPayloadSchema.safeParse(body) - - if (!validationResult.success) { - return NextResponse.json( - { - success: false, - error: 'Validation error', - details: validationResult.error.errors.map((e) => e.message), - }, - { status: 400 } - ) - } - - const payload = validationResult.data as WebhookPayload - - // Verify API Key using timing-safe comparison to prevent timing attacks - if (!isValidApiKey(payload.apiKey)) { - return NextResponse.json( - { - success: false, - error: 'Unauthorized', - details: ['Invalid or missing API Key'], - }, - { status: 401 } - ) - } - - // Process projects with partial success mode - const results = { - processed: payload.projects.length, - created: 0, - updated: 0, - failed: 0, - errors: [] as Array<{ - index: number - field: string - message: string - value: unknown - }>, - } - - for (let i = 0; i < payload.projects.length; i++) { - const projectData = payload.projects[i] - - // Validate individual project - const projectValidation = ProjectIngestionInputSchema.safeParse(projectData) - - if (!projectValidation.success) { - results.failed++ - const firstError = projectValidation.error.errors[0] - results.errors.push({ - index: i, - field: firstError?.path.join('.') || 'unknown', - message: firstError?.message || 'Validation failed', - value: projectData, - }) - continue - } - - try { - const validProject = projectValidation.data as ProjectIngestionInput - - // 多级去重:查找已存在的项目 - const existingProject = await findExistingProject(validProject) - - // 标签规范化与自动复用(防止同义标签持续膨胀) - const [dynamicTagConnections, fixedProjectTypeTag] = await Promise.all([ - upsertTags(validProject.tags), - resolveFixedProjectTypeTag(validProject), - ]) - const tagConnections = Array.from( - new Map( - [...dynamicTagConnections, fixedProjectTypeTag].map((tag) => [tag.id, tag]) - ).values() - ) - - // Generate slug for project - const slug = generateSlug(validProject.name, validProject.nameEn) - - if (existingProject) { - // Update existing project - const matchMethod = existingProject.slug === slug - ? 'slug' - : validProject.links.some( - (l) => - existingProject.links.some((el: { url: string }) => el.url === l.url) - ) - ? 'url' - : 'unknown' - - console.warn( - `[Webhook] Updating project "${validProject.name}" (matched by ${matchMethod}, id: ${existingProject.id})` - ) - - await prisma.$transaction(async (tx) => { - // Update tags (delete old ones, create new ones) - await tx.projectTag.deleteMany({ - where: { projectId: existingProject.id }, - }) - - await tx.project.update({ - where: { id: existingProject.id }, - data: { - name: validProject.name, - nameEn: validProject.nameEn || null, - description: validProject.description, - descriptionEn: validProject.descriptionEn || null, - content: validProject.content || null, - contentEn: validProject.contentEn || null, - status: validProject.status as ProjectStatus, - source: validProject.source || null, - tags: { - create: tagConnections.map((t) => ({ - tag: { connect: { id: t.id } }, - })), - }, - }, - }) - - // Update links (delete old ones, create new ones) - await tx.externalLink.deleteMany({ - where: { projectId: existingProject.id }, - }) - - await tx.externalLink.createMany({ - data: validProject.links.map((link) => ({ - type: link.type as LinkType, - url: link.url, - title: link.title || null, - projectId: existingProject.id, - })), - }) - }) - - results.updated++ - } else { - // Create new project - console.warn( - `[Webhook] Creating new project "${validProject.name}" (slug: ${slug})` - ) - - await prisma.project.create({ - data: { - name: validProject.name, - nameEn: validProject.nameEn || null, - slug, - description: validProject.description, - descriptionEn: validProject.descriptionEn || null, - content: validProject.content || null, - contentEn: validProject.contentEn || null, - status: validProject.status as ProjectStatus, - source: validProject.source || null, - tags: { - create: tagConnections.map((t) => ({ - tag: { connect: { id: t.id } }, - })), - }, - links: { - create: validProject.links.map((link) => ({ - type: link.type as LinkType, - url: link.url, - title: link.title || null, - })), - }, - }, - }) - - results.created++ - } - } catch (error) { - console.error(`[Webhook] Error processing project at index ${i}:`, error) - results.failed++ - results.errors.push({ - index: i, - field: 'general', - message: 'Failed to process project. Please check the server logs.', - value: process.env.NODE_ENV === 'development' ? projectData : undefined, - }) - } - } - - const duration = Date.now() - startTime - - // Log request - console.warn( - `[Webhook] Processed ${results.processed} projects in ${duration}ms: ${results.created} created, ${results.updated} updated, ${results.failed} failed` - ) - - return NextResponse.json({ - success: true, - ...results, - }) - } catch (error) { - console.error('[Webhook] Error:', error) - return NextResponse.json( - { - success: false, - error: 'Internal server error', - details: [error instanceof Error ? error.message : 'Unknown error'], - }, - { status: 500 } - ) - } -} diff --git a/src/lib/validations.test.ts b/src/lib/validations.test.ts index 90f505b..fdef660 100644 --- a/src/lib/validations.test.ts +++ b/src/lib/validations.test.ts @@ -1,77 +1,15 @@ -import { describe, it, expect } from 'vitest'; -import { AIEventInputSchema, ProjectInputSchema } from './validations'; +import { describe, it, expect } from "vitest"; +import { ProjectInputSchema } from "./validations"; -describe('AIEventInputSchema', () => { - const validEvent = { - title: 'GPT-4 发布', - eventDate: '2023-03-14T00:00:00Z', - description: 'OpenAI 发布多模态大语言模型', - imageUrl: 'https://example.com/gpt4.jpg', - }; - - it('should validate valid event', () => { - expect(() => AIEventInputSchema.parse(validEvent)).not.toThrow(); - }); - - it('should accept event with optional English fields', () => { - const eventWithEn = { - ...validEvent, - titleEn: 'GPT-4 Release', - descriptionEn: 'OpenAI launches multimodal LLM', - sourceUrl: 'https://openai.com/blog/gpt-4', - }; - expect(() => AIEventInputSchema.parse(eventWithEn)).not.toThrow(); - }); - - it('should reject empty title', () => { - expect(() => AIEventInputSchema.parse({ ...validEvent, title: '' })) - .toThrow(); - }); - - it('should reject title exceeding 200 characters', () => { - const longTitle = 'A'.repeat(201); - expect(() => AIEventInputSchema.parse({ ...validEvent, title: longTitle })) - .toThrow(); - }); - - it('should reject description shorter than 10 characters', () => { - expect(() => AIEventInputSchema.parse({ ...validEvent, description: '太短' })) - .toThrow(); - }); - - it('should reject description exceeding 500 characters', () => { - const longDesc = 'A'.repeat(501); - expect(() => AIEventInputSchema.parse({ ...validEvent, description: longDesc })) - .toThrow(); - }); - - it('should reject invalid eventDate format', () => { - expect(() => AIEventInputSchema.parse({ ...validEvent, eventDate: '2023-03-14' })) - .toThrow(); - }); - - it('should reject invalid imageUrl', () => { - expect(() => AIEventInputSchema.parse({ ...validEvent, imageUrl: 'not-a-url' })) - .toThrow(); - }); - - it('should reject invalid sourceUrl format', () => { - expect(() => AIEventInputSchema.parse({ - ...validEvent, - sourceUrl: 'not-a-url' - })).toThrow(); - }); -}); - -describe('ProjectInputSchema', () => { +describe("ProjectInputSchema", () => { const baseProjectInput = { - name: 'Agent Park', - description: 'A curated list of practical AI agent tools.', - tags: [{ name: 'ai-agent' }], - links: [{ type: 'GITHUB' as const, url: 'https://github.com/example/repo' }], + name: "Agent Park", + description: "A curated list of practical AI agent tools.", + tags: [{ name: "ai-agent" }], + links: [{ type: "GITHUB" as const, url: "https://github.com/example/repo" }], }; - it('should allow more than 10 tags', () => { + it("should allow more than 10 tags", () => { const manyTags = Array.from({ length: 20 }, (_, index) => ({ name: `tag-${index + 1}`, })); @@ -84,7 +22,7 @@ describe('ProjectInputSchema', () => { ).not.toThrow(); }); - it('should still require at least one tag', () => { + it("should still require at least one tag", () => { expect(() => ProjectInputSchema.parse({ ...baseProjectInput, diff --git a/src/lib/validations.ts b/src/lib/validations.ts index c597e1b..6032d9a 100644 --- a/src/lib/validations.ts +++ b/src/lib/validations.ts @@ -53,108 +53,14 @@ export const ProjectInputSchema = ProjectBaseSchema.extend({ links: z.array(ExternalLinkSchema).min(1, "At least one link is required").max(10), }); -export const ProjectIngestionInputSchema = ProjectBaseSchema.extend({ - tags: z.array(TagSchema).min(1, "At least one tag is required"), - links: z.array(ExternalLinkSchema).min(1, "At least one link is required").max(10), -}); - // ================================ -// Webhook Schemas +// Internal API Schemas // ================================ export const WebhookAuthSchema = z.object({ apiKey: z.string().min(32, "Invalid API key format"), }); -export const WebhookPayloadSchema = WebhookAuthSchema.extend({ - projects: z.array(ProjectIngestionInputSchema).min(1).max(100), -}); - -// ================================ -// Discovery Task Schemas -// ================================ - -export const TaskStatusEnum = z.enum(["PENDING", "IN_PROGRESS", "COMPLETED", "FAILED"]); - -const JsonValueSchema: z.ZodType = z.lazy(() => - z.union([ - z.string(), - z.number(), - z.boolean(), - z.null(), - z.array(JsonValueSchema), - z.record(JsonValueSchema), - ]) -); - -export const JsonObjectSchema = z.record(JsonValueSchema); - -export const CreateDiscoveryTaskSchema = WebhookAuthSchema.extend({ - tasks: z - .array( - z.object({ - sourceUrl: z.string().url().max(2000), - sourceType: z.string().max(50).default("manual"), - }) - ) - .min(1), -}); - -export const UpdateDiscoveryTaskSchema = z.object({ - apiKey: z.string().min(32), - status: TaskStatusEnum, - explorationData: JsonObjectSchema.optional(), - explorationSummary: z.string().max(1000).optional(), - errorMessage: z.string().max(2000).optional(), -}); - -export const GetDiscoveryTasksQuerySchema = z.object({ - status: z.string().optional().transform((val) => { - // 支持单状态或多状态(逗号分隔),如 "PENDING" 或 "PENDING,FAILED" - if (!val) return undefined; - const statuses = val.split(',').map(s => s.trim() as TaskStatus).filter(Boolean); - return statuses.length === 1 ? statuses[0] : statuses; - }), - limit: z.coerce.number().int().positive().max(100).default(10), - offset: z.coerce.number().int().nonnegative().default(0), -}); - -/** - * 批量重置任务 Schema - * 支持两种模式:按 ID 列表重置 或 按状态筛选重置 - */ -export const BatchResetTasksSchema = WebhookAuthSchema.extend({ - // 模式1: 指定任务 ID 列表 - taskIds: z.array(z.string()).optional(), - // 模式2: 按状态筛选(不传则默认重置 IN_PROGRESS 和 FAILED) - statuses: z.array(TaskStatusEnum).optional(), -}).refine((data) => data.taskIds || data.statuses, { - message: "必须提供 taskIds 或 statuses 之一", -}); - -/** - * 检查任务去重 Schema - * 用于在创建任务前检查 URL 是否已存在 - */ -export const CheckTaskDuplicatesSchema = WebhookAuthSchema.extend({ - urls: z.array(z.string().url().max(2000)).min(1).max(100), - sourceType: z.string().max(50).optional(), -}); - -// ================================ -// AI Timeline Schemas -// ================================ - -export const AIEventInputSchema = z.object({ - title: z.string().min(1).max(200), - titleEn: z.string().max(200).optional(), - eventDate: z.string().datetime({ offset: true }), - description: z.string().min(10).max(500), - descriptionEn: z.string().max(500).optional(), - imageUrl: z.string().url().max(2000), - sourceUrl: z.string().url().max(2000).optional(), -}); - // ================================ // Signals Schemas // ================================ @@ -168,13 +74,7 @@ export const SignalSourceEnum = z.enum([ "product_hunt", ]); -export const SignalSectionStyleEnum = z.enum([ - "focus", - "debate", - "evidence", - "action", - "risk", -]); +export const SignalSectionStyleEnum = z.enum(["focus", "debate", "evidence", "action", "risk"]); export const SignalSectionSchema = z.object({ id: z.string().min(1).max(60), @@ -232,134 +132,6 @@ export const ProjectQuerySchema = z.object({ limit: z.coerce.number().int().positive().max(100).default(20), }); -// ================================ -// Chat Schemas -// ================================ - -export const ChatModeEnum = z.enum(["stream", "job"]); -export const ChatRoleEnum = z.enum(["user", "assistant", "system"]); -export const ChatBlockTypeEnum = z.enum([ - "text", - "mermaid", - "excalidraw_image", - "code", - "table", -]); -export const ChatEventTypeEnum = z.enum([ - "start", - "progress", - "delta", - "artifact", - "final", - "error", -]); -export const ChatJobStatusEnum = z.enum(["queued", "running", "completed", "failed"]); -export const ChatFeedbackRatingEnum = z.enum(["helpful", "unhelpful"]); - -export const ChatCapabilitySchema = z.object({ - allowMermaid: z.boolean().optional().default(true), - allowExcalidrawImage: z.boolean().optional().default(true), -}); - -export const ChatCitationSchema = z.object({ - title: z.string().min(1).max(200), - url: z.string().url().max(2000), - snippet: z.string().max(1000).optional(), -}); - -export const ChatMessageBlockSchema = z - .object({ - type: ChatBlockTypeEnum, - title: z.string().max(200).optional(), - content: z.string().max(50000).optional(), - url: z.string().url().max(2000).optional(), - language: z.string().max(50).optional(), - rows: z.array(z.array(z.string().max(500))).optional(), - headers: z.array(z.string().max(200)).optional(), - }) - .superRefine((data, ctx) => { - if ((data.type === "text" || data.type === "mermaid" || data.type === "code") && !data.content) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - path: ["content"], - message: `content is required for block type: ${data.type}`, - }); - } - if (data.type === "excalidraw_image" && !data.url) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - path: ["url"], - message: "url is required for block type: excalidraw_image", - }); - } - if (data.type === "table" && (!data.headers || !data.rows)) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - path: ["rows"], - message: "headers and rows are required for block type: table", - }); - } - }); - -export const ChatAssistantMessageSchema = z.object({ - id: z.string().min(1).max(100).optional(), - role: z.literal("assistant").default("assistant"), - blocks: z.array(ChatMessageBlockSchema).default([]), - citations: z.array(ChatCitationSchema).optional().default([]), - meta: z.record(z.unknown()).optional(), - rawN8nPayload: z.unknown().optional(), -}); - -export const ChatMessageRequestSchema = z.object({ - sessionId: z.string().min(1).max(100).optional(), - clientId: z.string().min(8).max(100), - locale: z.enum(["zh", "en"]), - mode: ChatModeEnum.default("job"), - message: z.string().min(1).max(8000), - capabilities: ChatCapabilitySchema.optional().default({}), - context: JsonObjectSchema.optional(), -}); - -export const ChatSessionQuerySchema = z.object({ - clientId: z.string().min(8).max(100), - locale: z.enum(["zh", "en"]).optional(), - limit: z.coerce.number().int().positive().max(50).default(20), - cursor: z.string().min(1).optional(), -}); - -export const ChatJobStatusQuerySchema = z.object({ - clientId: z.string().min(8).max(100), - sessionId: z.string().min(1).max(100), -}); - -export const ChatFeedbackRequestSchema = z.object({ - clientId: z.string().min(8).max(100), - sessionId: z.string().min(1).max(100), - messageId: z.string().min(1).max(100), - rating: ChatFeedbackRatingEnum, - reason: z.string().max(500).optional(), -}); - -export const N8NChatJobResponseSchema = z.object({ - requestId: z.string().min(1).optional(), - jobId: z.string().min(1), - status: ChatJobStatusEnum, - progress: z - .object({ - stage: z.string().max(100).optional(), - message: z.string().max(500).optional(), - }) - .optional(), - message: ChatAssistantMessageSchema.optional(), - error: z - .object({ - code: z.string().max(100).optional(), - message: z.string().max(500), - }) - .optional(), - meta: z.record(z.unknown()).optional(), -}); - // ================================ // Types // ================================ @@ -367,36 +139,13 @@ export const N8NChatJobResponseSchema = z.object({ export type ExternalLink = z.infer; export type Tag = z.infer; export type ProjectInput = z.infer; -export type ProjectIngestionInput = z.infer; -export type WebhookPayload = z.infer; export type ProjectQuery = z.infer; -export type TaskStatus = z.infer; -export type CreateDiscoveryTask = z.infer; -export type UpdateDiscoveryTask = z.infer; -export type GetDiscoveryTasksQuery = z.infer; -export type BatchResetTasks = z.infer; -export type AIEventInput = z.infer; -export type JsonObject = z.infer; -export type ChatMode = z.infer; -export type ChatRole = z.infer; -export type ChatBlockType = z.infer; -export type ChatEventType = z.infer; export type SignalSource = z.infer; export type SignalSectionStyle = z.infer; export type SignalSectionInput = z.infer; export type SignalIngestionInput = z.infer; export type SignalWebhookPayload = z.infer; export type SignalQuery = z.infer; -export type ChatJobStatus = z.infer; -export type ChatFeedbackRating = z.infer; -export type ChatCitation = z.infer; -export type ChatMessageBlock = z.infer; -export type ChatAssistantMessage = z.infer; -export type ChatMessageRequest = z.infer; -export type ChatSessionQuery = z.infer; -export type ChatJobStatusQuery = z.infer; -export type ChatFeedbackRequest = z.infer; -export type N8NChatJobResponse = z.infer; // ================================ // Tags API Schemas @@ -420,67 +169,71 @@ const NewMergeTargetSchema = z.object({ export const MergeTargetSchema = z.union([ExistingMergeTargetSchema, NewMergeTargetSchema]); -export const TagMergeSchema = z.object({ - target: MergeTargetSchema, - sourceTagIds: z.array(z.string().min(1)).min(1, "At least one source tag required"), -}).superRefine((data, ctx) => { - const seenSourceIds = new Set(); +export const TagMergeSchema = z + .object({ + target: MergeTargetSchema, + sourceTagIds: z.array(z.string().min(1)).min(1, "At least one source tag required"), + }) + .superRefine((data, ctx) => { + const seenSourceIds = new Set(); - data.sourceTagIds.forEach((sourceTagId, index) => { - if (seenSourceIds.has(sourceTagId)) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - path: ["sourceTagIds", index], - message: `Duplicate source tag ID: ${sourceTagId}`, - }); - return; - } - seenSourceIds.add(sourceTagId); - }); - - if ("id" in data.target && seenSourceIds.has(data.target.id)) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - path: ["sourceTagIds"], - message: "Self merge is not allowed: target tag cannot be in sourceTagIds", - }); - } -}); - -export const TagMaintenanceRequestSchema = z.object({ - apiKey: z.string().min(32, "Invalid API key format"), - updates: z.array(TagUpdateSchema).default([]), - merges: z.array(TagMergeSchema).default([]), -}).superRefine((data, ctx) => { - const seenUpdateTagIds = new Set(); - const seenMergeSourceTagIds = new Set(); - - data.updates.forEach((update, index) => { - if (seenUpdateTagIds.has(update.tagId)) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - path: ["updates", index, "tagId"], - message: `Duplicate update tag ID: ${update.tagId}`, - }); - return; - } - seenUpdateTagIds.add(update.tagId); - }); - - data.merges.forEach((merge, mergeIndex) => { - merge.sourceTagIds.forEach((sourceTagId, sourceIndex) => { - if (seenMergeSourceTagIds.has(sourceTagId)) { + data.sourceTagIds.forEach((sourceTagId, index) => { + if (seenSourceIds.has(sourceTagId)) { ctx.addIssue({ code: z.ZodIssueCode.custom, - path: ["merges", mergeIndex, "sourceTagIds", sourceIndex], - message: `Source tag ID appears in multiple merges: ${sourceTagId}`, + path: ["sourceTagIds", index], + message: `Duplicate source tag ID: ${sourceTagId}`, }); return; } - seenMergeSourceTagIds.add(sourceTagId); + seenSourceIds.add(sourceTagId); + }); + + if ("id" in data.target && seenSourceIds.has(data.target.id)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["sourceTagIds"], + message: "Self merge is not allowed: target tag cannot be in sourceTagIds", + }); + } + }); + +export const TagMaintenanceRequestSchema = z + .object({ + apiKey: z.string().min(32, "Invalid API key format"), + updates: z.array(TagUpdateSchema).default([]), + merges: z.array(TagMergeSchema).default([]), + }) + .superRefine((data, ctx) => { + const seenUpdateTagIds = new Set(); + const seenMergeSourceTagIds = new Set(); + + data.updates.forEach((update, index) => { + if (seenUpdateTagIds.has(update.tagId)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["updates", index, "tagId"], + message: `Duplicate update tag ID: ${update.tagId}`, + }); + return; + } + seenUpdateTagIds.add(update.tagId); + }); + + data.merges.forEach((merge, mergeIndex) => { + merge.sourceTagIds.forEach((sourceTagId, sourceIndex) => { + if (seenMergeSourceTagIds.has(sourceTagId)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["merges", mergeIndex, "sourceTagIds", sourceIndex], + message: `Source tag ID appears in multiple merges: ${sourceTagId}`, + }); + return; + } + seenMergeSourceTagIds.add(sourceTagId); + }); }); }); -}); // ================================ // Project Tag Reset API Schemas @@ -507,62 +260,61 @@ export const ProjectTagResetItemSchema = z.object({ selectedTagSlugsByCategory: ProjectTagSelectionByCategorySchema, }); -export const ProjectTagResetRequestSchema = z.object({ - apiKey: z.string().min(32, "Invalid API key format"), - dryRun: z.boolean().default(false), - replaceAllCategories: z.boolean().default(true), - categories: z.array(ResettableTagCategorySchema).min(1).default([ - "FIXED_PROJECT_TYPE", - "TECH_STACK", - "AI_PARADIGM", - "PRODUCT_FORM", - "DOMAIN_SCENARIO", - ]), - projects: z.array(ProjectTagResetItemSchema).min(1).max(100), -}).superRefine((data, ctx) => { - const seenProjectSlugs = new Set(); +export const ProjectTagResetRequestSchema = z + .object({ + apiKey: z.string().min(32, "Invalid API key format"), + dryRun: z.boolean().default(false), + replaceAllCategories: z.boolean().default(true), + categories: z + .array(ResettableTagCategorySchema) + .min(1) + .default([ + "FIXED_PROJECT_TYPE", + "TECH_STACK", + "AI_PARADIGM", + "PRODUCT_FORM", + "DOMAIN_SCENARIO", + ]), + projects: z.array(ProjectTagResetItemSchema).min(1).max(100), + }) + .superRefine((data, ctx) => { + const seenProjectSlugs = new Set(); - data.projects.forEach((project, projectIndex) => { - const normalizedSlug = project.projectSlug.trim().toLowerCase(); - if (seenProjectSlugs.has(normalizedSlug)) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - path: ["projects", projectIndex, "projectSlug"], - message: `Duplicate projectSlug: ${project.projectSlug}`, - }); - return; - } - seenProjectSlugs.add(normalizedSlug); + data.projects.forEach((project, projectIndex) => { + const normalizedSlug = project.projectSlug.trim().toLowerCase(); + if (seenProjectSlugs.has(normalizedSlug)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["projects", projectIndex, "projectSlug"], + message: `Duplicate projectSlug: ${project.projectSlug}`, + }); + return; + } + seenProjectSlugs.add(normalizedSlug); - const categories = Object.keys(project.selectedTagSlugsByCategory) as Array< - keyof z.infer - >; + const categories = Object.keys(project.selectedTagSlugsByCategory) as Array< + keyof z.infer + >; - categories.forEach((category) => { - const selectedSlugs = project.selectedTagSlugsByCategory[category]; - const seenTagSlugs = new Set(); + categories.forEach((category) => { + const selectedSlugs = project.selectedTagSlugsByCategory[category]; + const seenTagSlugs = new Set(); - selectedSlugs.forEach((slug, slugIndex) => { - const normalizedTagSlug = slug.trim().toLowerCase(); - if (seenTagSlugs.has(normalizedTagSlug)) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - path: [ - "projects", - projectIndex, - "selectedTagSlugsByCategory", - category, - slugIndex, - ], - message: `Duplicate tag slug "${slug}" in category ${category}`, - }); - return; - } - seenTagSlugs.add(normalizedTagSlug); + selectedSlugs.forEach((slug, slugIndex) => { + const normalizedTagSlug = slug.trim().toLowerCase(); + if (seenTagSlugs.has(normalizedTagSlug)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["projects", projectIndex, "selectedTagSlugsByCategory", category, slugIndex], + message: `Duplicate tag slug "${slug}" in category ${category}`, + }); + return; + } + seenTagSlugs.add(normalizedTagSlug); + }); }); }); }); -}); // ================================ // Types