chore: remove admin and agent pipeline remnants

This commit is contained in:
2026-04-18 18:57:50 +08:00
parent f09364ebf7
commit bc93755f87
15 changed files with 169 additions and 2731 deletions
-5
View File
@@ -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"
@@ -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
$$;
-31
View File
@@ -24,29 +24,6 @@ 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
@@ -76,7 +53,6 @@ model Project {
embedding Unsupported("vector")?
embeddingUpdatedAt DateTime?
links ExternalLink[]
projectDiscoveryTasks ProjectDiscoveryTask[]
tags ProjectTag[]
@@index([embedding], map: "idx_project_embedding_cosine")
@@ -141,13 +117,6 @@ enum ProjectStatus {
ARCHIVED
}
enum TaskStatus {
PENDING
IN_PROGRESS
COMPLETED
FAILED
}
enum TagCategory {
FIXED_PROJECT_TYPE
TECH_STACK
-129
View File
@@ -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<string, ChatSession>
messages: Map<string, ChatMessage[]>
}
declare global {
// eslint-disable-next-line no-var
var __agentParkChatStore: ChatStoreState | undefined
}
function getStore(): ChatStoreState {
if (!globalThis.__agentParkChatStore) {
globalThis.__agentParkChatStore = {
sessions: new Map<string, ChatSession>(),
messages: new Map<string, ChatMessage[]>(),
}
}
return globalThis.__agentParkChatStore
}
function nowIso(): string {
return new Date().toISOString()
}
export async function createChatSession(input: {
clientId: string
locale: ChatLocale
title?: string
}): Promise<ChatSession> {
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<Array<ChatSession & { messages: ChatMessage[] }>> {
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<void> {
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<ChatMessage[]> {
const store = getStore()
const messages = store.messages.get(input.sessionId) || []
const normalizedLimit = Math.max(1, input.limit)
return messages.slice(-normalizedLimit)
}
@@ -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 }
)
}
}
@@ -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<typeof normalizeIncomingTag>): 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<typeof normalizeIncomingTag>
type TagLookupMaps = {
tagByExactName: Map<string, TagWithProjectCount[]>
tagByExactNameEn: Map<string, TagWithProjectCount[]>
tagBySlug: Map<string, TagWithProjectCount>
tagByCanonicalName: Map<string, TagWithProjectCount[]>
tagByCanonicalNameEn: Map<string, TagWithProjectCount[]>
}
function pushTagMapEntry(
map: Map<string, TagWithProjectCount[]>,
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<string, TagWithProjectCount[]>()
const tagByExactNameEn = new Map<string, TagWithProjectCount[]>()
const tagBySlug = new Map<string, TagWithProjectCount>()
const tagByCanonicalName = new Map<string, TagWithProjectCount[]>()
const tagByCanonicalNameEn = new Map<string, TagWithProjectCount[]>()
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<TagWithProjectCount[]> {
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<TagWithProjectCount> {
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<TagWithProjectCount> {
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<ProjectInput, 'name' | 'nameEn' | 'description' | 'descriptionEn' | 'tags'>
): Promise<TagWithProjectCount> {
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()
)
}
@@ -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 }
)
}
}
-166
View File
@@ -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<TaskStatus, TaskStatus[]> = {
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 }
)
}
}
@@ -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 }
)
}
}
-160
View File
@@ -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 }
)
}
}
+13 -111
View File
@@ -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 }
)
);
}
}
@@ -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<string, ProjectSummary>
huggingfaceMap: Map<string, ProjectSummary>
websiteMap: Map<string, ProjectSummary>
slugMap: Map<string, ProjectSummary>
}
function buildLookupKey(project: CheckProjectInput): string {
return [
project.githubUrl || '',
project.huggingfaceUrl || '',
project.websiteUrl || '',
project.slug || '',
].join('|')
}
function toExternalLinkMap(
rows: Array<{ url: string; project: ProjectSummary }>
): Map<string, ProjectSummary> {
const map = new Map<string, ProjectSummary>()
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<string, ProjectSummary> {
const map = new Map<string, ProjectSummary>()
for (const row of rows) {
map.set(row.slug, { id: row.id, name: row.name })
}
return map
}
async function buildDuplicateCheckMaps(
projects: CheckProjectInput[]
): Promise<DuplicateCheckMaps> {
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<string, CheckResult>()
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 }
)
}
}
-228
View File
@@ -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 }
)
}
}
+9 -71
View File
@@ -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,
+22 -270
View File
@@ -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<unknown> = 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<typeof ExternalLinkSchema>;
export type Tag = z.infer<typeof TagSchema>;
export type ProjectInput = z.infer<typeof ProjectInputSchema>;
export type ProjectIngestionInput = z.infer<typeof ProjectIngestionInputSchema>;
export type WebhookPayload = z.infer<typeof WebhookPayloadSchema>;
export type ProjectQuery = z.infer<typeof ProjectQuerySchema>;
export type TaskStatus = z.infer<typeof TaskStatusEnum>;
export type CreateDiscoveryTask = z.infer<typeof CreateDiscoveryTaskSchema>;
export type UpdateDiscoveryTask = z.infer<typeof UpdateDiscoveryTaskSchema>;
export type GetDiscoveryTasksQuery = z.infer<typeof GetDiscoveryTasksQuerySchema>;
export type BatchResetTasks = z.infer<typeof BatchResetTasksSchema>;
export type AIEventInput = z.infer<typeof AIEventInputSchema>;
export type JsonObject = z.infer<typeof JsonObjectSchema>;
export type ChatMode = z.infer<typeof ChatModeEnum>;
export type ChatRole = z.infer<typeof ChatRoleEnum>;
export type ChatBlockType = z.infer<typeof ChatBlockTypeEnum>;
export type ChatEventType = z.infer<typeof ChatEventTypeEnum>;
export type SignalSource = z.infer<typeof SignalSourceEnum>;
export type SignalSectionStyle = z.infer<typeof SignalSectionStyleEnum>;
export type SignalSectionInput = z.infer<typeof SignalSectionSchema>;
export type SignalIngestionInput = z.infer<typeof SignalIngestionInputSchema>;
export type SignalWebhookPayload = z.infer<typeof SignalWebhookPayloadSchema>;
export type SignalQuery = z.infer<typeof SignalQuerySchema>;
export type ChatJobStatus = z.infer<typeof ChatJobStatusEnum>;
export type ChatFeedbackRating = z.infer<typeof ChatFeedbackRatingEnum>;
export type ChatCitation = z.infer<typeof ChatCitationSchema>;
export type ChatMessageBlock = z.infer<typeof ChatMessageBlockSchema>;
export type ChatAssistantMessage = z.infer<typeof ChatAssistantMessageSchema>;
export type ChatMessageRequest = z.infer<typeof ChatMessageRequestSchema>;
export type ChatSessionQuery = z.infer<typeof ChatSessionQuerySchema>;
export type ChatJobStatusQuery = z.infer<typeof ChatJobStatusQuerySchema>;
export type ChatFeedbackRequest = z.infer<typeof ChatFeedbackRequestSchema>;
export type N8NChatJobResponse = z.infer<typeof N8NChatJobResponseSchema>;
// ================================
// Tags API Schemas
@@ -420,10 +169,12 @@ const NewMergeTargetSchema = z.object({
export const MergeTargetSchema = z.union([ExistingMergeTargetSchema, NewMergeTargetSchema]);
export const TagMergeSchema = z.object({
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) => {
})
.superRefine((data, ctx) => {
const seenSourceIds = new Set<string>();
data.sourceTagIds.forEach((sourceTagId, index) => {
@@ -445,13 +196,15 @@ export const TagMergeSchema = z.object({
message: "Self merge is not allowed: target tag cannot be in sourceTagIds",
});
}
});
});
export const TagMaintenanceRequestSchema = z.object({
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) => {
})
.superRefine((data, ctx) => {
const seenUpdateTagIds = new Set<string>();
const seenMergeSourceTagIds = new Set<string>();
@@ -480,7 +233,7 @@ export const TagMaintenanceRequestSchema = z.object({
seenMergeSourceTagIds.add(sourceTagId);
});
});
});
});
// ================================
// Project Tag Reset API Schemas
@@ -507,11 +260,15 @@ export const ProjectTagResetItemSchema = z.object({
selectedTagSlugsByCategory: ProjectTagSelectionByCategorySchema,
});
export const ProjectTagResetRequestSchema = z.object({
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([
categories: z
.array(ResettableTagCategorySchema)
.min(1)
.default([
"FIXED_PROJECT_TYPE",
"TECH_STACK",
"AI_PARADIGM",
@@ -519,7 +276,8 @@ export const ProjectTagResetRequestSchema = z.object({
"DOMAIN_SCENARIO",
]),
projects: z.array(ProjectTagResetItemSchema).min(1).max(100),
}).superRefine((data, ctx) => {
})
.superRefine((data, ctx) => {
const seenProjectSlugs = new Set<string>();
data.projects.forEach((project, projectIndex) => {
@@ -547,13 +305,7 @@ export const ProjectTagResetRequestSchema = z.object({
if (seenTagSlugs.has(normalizedTagSlug)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: [
"projects",
projectIndex,
"selectedTagSlugsByCategory",
category,
slugIndex,
],
path: ["projects", projectIndex, "selectedTagSlugsByCategory", category, slugIndex],
message: `Duplicate tag slug "${slug}" in category ${category}`,
});
return;
@@ -562,7 +314,7 @@ export const ProjectTagResetRequestSchema = z.object({
});
});
});
});
});
// ================================
// Types