diff --git a/n8n-workflows/AGENTS.md b/n8n-workflows/AGENTS.md index c50f6e6..f435aa0 100644 --- a/n8n-workflows/AGENTS.md +++ b/n8n-workflows/AGENTS.md @@ -53,7 +53,7 @@ RAG项目搜索 ← User Query - `POST /api/discovery/tasks` - Create discovery tasks - `POST /api/keyword-cloud/keywords` - Bulk keyword upload -- `PATCH /api/tags/maintenance` - Tag cleanup (n8n integration) +- `POST /api/tags/maintenance` - Tag cleanup (n8n integration) ### Environment Variables (n8n) diff --git a/n8n-workflows/README.md b/n8n-workflows/README.md index fbf2b45..4c9d488 100644 --- a/n8n-workflows/README.md +++ b/n8n-workflows/README.md @@ -2,6 +2,13 @@ 本目录包含 Agent Park 的 n8n 工作流配置。 +## 工作流清单 + +| 文件 | 功能 | +| --- | --- | +| `keyword-cloud-workflow.json` | Google Trends → 关键词词云入库 | +| `tag-janitor-workflow.json` | 每日 AI 标签合并与 `nameEn` 补全 | + ## 关键词词云工作流 **文件**: `keyword-cloud-workflow.json` @@ -56,6 +63,29 @@ - `GET {API_URL}/api/keyword-cloud/rules` - 获取视觉规则 - `POST {API_URL}/api/keyword-cloud/keywords` - 批量写入关键词 +## Tag Janitor 工作流 + +**文件**: `tag-janitor-workflow.json` + +### 功能 + +每日自动拉取标签,利用 LLM 生成 `merges + updates` 计划,并调用维护 API 执行标签合并。 + +### 执行流程 + +1. **Schedule Trigger**: 每日 UTC 03:00 触发 +2. **Fetch Tags**: 调用 `GET /api/tags` 获取标签和项目计数 +3. **Preprocess Tags**: 预处理排序(按 projectCount) +4. **AI Analyze Tags**: 生成严格 JSON 计划(`merges`、`updates`) +5. **Validate JSON**: 校验输出结构和自合并风险 +6. **Execute Maintenance**: 调用 `POST /api/tags/maintenance` 执行 +7. **Summarize Results**: 输出日报(merged/deleted/updated) + +### API 端点 + +- `GET {API_URL}/api/tags` - 拉取标签列表(含 `_count.projects`) +- `POST {API_URL}/api/tags/maintenance` - 执行批量 updates/merges + ### 调试 检查每个节点的输出,确保数据格式正确: diff --git a/n8n-workflows/tag-janitor-workflow.json b/n8n-workflows/tag-janitor-workflow.json new file mode 100644 index 0000000..b58e088 --- /dev/null +++ b/n8n-workflows/tag-janitor-workflow.json @@ -0,0 +1,151 @@ +{ + "name": "Tag Janitor - Daily Cleanup", + "nodes": [ + { + "parameters": { + "rule": { + "interval": [ + { + "triggerAtHour": 3 + } + ] + } + }, + "id": "schedule", + "name": "Daily 3AM UTC", + "type": "n8n-nodes-base.scheduleTrigger", + "typeVersion": 1.2, + "position": [0, 0] + }, + { + "parameters": { + "url": "={{$env.SITE_BASE_URL}}/api/tags", + "options": {} + }, + "id": "fetch-tags", + "name": "Fetch Tags", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [220, 0] + }, + { + "parameters": { + "jsCode": "const response = $input.first().json;\nif (!response.success) {\n throw new Error('Failed to fetch tags: ' + JSON.stringify(response));\n}\n\nconst tags = response.tags;\nconst formatted = tags.map(t => ({\n id: t.id,\n name: t.name,\n nameEn: t.nameEn || '',\n projectCount: t._count?.projects || 0\n}));\n\n// Sort by project count descending\nformatted.sort((a, b) => b.projectCount - a.projectCount);\n\nreturn [{ json: { tags: formatted, total: formatted.length } }];" + }, + "id": "preprocess", + "name": "Preprocess Tags", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [440, 0] + }, + { + "parameters": { + "model": "gpt-4o", + "messages": { + "values": [ + { + "role": "system", + "content": "You are a tag management expert. Analyze tags and identify semantic duplicates for merging and missing English names for completion. Output STRICT JSON only." + }, + { + "role": "user", + "content": "Analyze these tags and identify:\n1. Semantic duplicates to merge (e.g., \"机器学习\" and \"ML\" → keep higher projectCount)\n2. Tags missing nameEn that need English names\n\nTags ({{$json.total}} total):\n{{JSON.stringify($json.tags)}}\n\nOutput STRICT JSON (no markdown):\n{\n \"merges\": [\n {\n \"target\": { \"name\": \"保留的标签\", \"nameEn\": \"Canonical Name\" },\n \"sourceTagIds\": [\"id1\", \"id2\"]\n }\n ],\n \"updates\": [\n { \"tagId\": \"id\", \"nameEn\": \"English Name\" }\n ]\n}\n\nRules:\n- Keep tag with higher projectCount as merge target\n- target can use { \"id\": \"existing\" } to keep existing tag\n- Only include tags needing action (empty arrays if none)\n- nameEn must be proper English, not pinyin" + } + ] + }, + "options": { + "temperature": 0.1 + } + }, + "id": "ai-analyze", + "name": "AI Analyze Tags", + "type": "@n8n/n8n-nodes-langchain.openAi", + "typeVersion": 1.8, + "position": [660, 0] + }, + { + "parameters": { + "jsCode": "const response = $input.first().json;\nlet plan;\n\ntry {\n const content = response.message?.content || response.text || response;\n plan = typeof content === 'string' ? JSON.parse(content) : content;\n} catch (e) {\n throw new Error('Failed to parse AI response: ' + e.message);\n}\n\nif (!Array.isArray(plan.merges)) plan.merges = [];\nif (!Array.isArray(plan.updates)) plan.updates = [];\n\nfor (const merge of plan.merges) {\n if (merge.target.id && merge.sourceTagIds.includes(merge.target.id)) {\n throw new Error('Self-merge detected: ' + merge.target.id);\n }\n}\n\nif (plan.merges.length === 0 && plan.updates.length === 0) {\n return [{ json: { skipped: true, reason: 'No changes needed' } }];\n}\n\nreturn [{ json: plan }];" + }, + "id": "validate-json", + "name": "Validate JSON", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [880, 0] + }, + { + "parameters": { + "conditions": { + "boolean": [ + { + "value1": "={{$json.skipped}}", + "value2": true + } + ] + } + }, + "id": "check-skip", + "name": "Check Skip", + "type": "n8n-nodes-base.if", + "typeVersion": 2, + "position": [1100, 0] + }, + { + "parameters": { + "method": "POST", + "url": "={{$env.SITE_BASE_URL}}/api/tags/maintenance", + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={\n \"apiKey\": \"{{$env.WEBHOOK_API_KEY}}\",\n \"updates\": {{JSON.stringify($json.updates)}},\n \"merges\": {{JSON.stringify($json.merges)}}\n}", + "options": {} + }, + "id": "execute-maintenance", + "name": "Execute Maintenance", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [1320, -100] + }, + { + "parameters": { + "jsCode": "const maintenanceResult = $('Execute Maintenance').first()?.json || {};\nconst skipped = $('Check Skip').first()?.json?.skipped;\n\nif (skipped) {\n return [{ json: {\n status: 'skipped',\n message: 'No changes needed',\n timestamp: new Date().toISOString()\n }}];\n}\n\nconst result = maintenanceResult.result || {};\n\nreturn [{ json: {\n status: maintenanceResult.success ? 'success' : 'failed',\n updatedCount: result.updatedCount || 0,\n mergedCount: result.mergedCount || 0,\n deletedTagCount: result.deletedTagCount || 0,\n timestamp: new Date().toISOString(),\n error: maintenanceResult.error || null\n}}];" + }, + "id": "summarize", + "name": "Summarize Results", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [1540, 0] + } + ], + "connections": { + "Daily 3AM UTC": { + "main": [[{ "node": "Fetch Tags", "type": "main", "index": 0 }]] + }, + "Fetch Tags": { + "main": [[{ "node": "Preprocess Tags", "type": "main", "index": 0 }]] + }, + "Preprocess Tags": { + "main": [[{ "node": "AI Analyze Tags", "type": "main", "index": 0 }]] + }, + "AI Analyze Tags": { + "main": [[{ "node": "Validate JSON", "type": "main", "index": 0 }]] + }, + "Validate JSON": { + "main": [[{ "node": "Check Skip", "type": "main", "index": 0 }]] + }, + "Check Skip": { + "main": [ + [{ "node": "Execute Maintenance", "type": "main", "index": 0 }], + [{ "node": "Summarize Results", "type": "main", "index": 0 }] + ] + }, + "Execute Maintenance": { + "main": [[{ "node": "Summarize Results", "type": "main", "index": 0 }]] + } + }, + "settings": { + "executionOrder": "v1" + }, + "meta": { + "templateCredsSetupCompleted": true + } +} diff --git a/src/app/api/events/route.ts b/src/app/api/events/route.ts index a4fed4a..ff82c41 100644 --- a/src/app/api/events/route.ts +++ b/src/app/api/events/route.ts @@ -7,11 +7,15 @@ export async function POST(request: NextRequest) { // 1. API Key 验证 const apiKey = request.headers.get('X-API-Key'); const expectedKey = process.env.WEBHOOK_API_KEY; + const providedBuf = Buffer.from(apiKey || ''); + const expectedBuf = Buffer.from(expectedKey || ''); - if (!apiKey || !expectedKey || !crypto.timingSafeEqual( - Buffer.from(apiKey), - Buffer.from(expectedKey) - )) { + if ( + !apiKey || + !expectedKey || + providedBuf.length !== expectedBuf.length || + !crypto.timingSafeEqual(providedBuf, expectedBuf) + ) { return NextResponse.json( { error: 'Unauthorized' }, { status: 401 } diff --git a/src/app/api/tags/maintenance/route.test.ts b/src/app/api/tags/maintenance/route.test.ts index f75327d..8ab266c 100644 --- a/src/app/api/tags/maintenance/route.test.ts +++ b/src/app/api/tags/maintenance/route.test.ts @@ -1,148 +1,154 @@ -import { describe, it, expect, beforeEach } from "vitest"; -import { POST } from "./route"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { NextRequest } from "next/server"; +import { POST } from "./route"; + +const { transactionMock, revalidatePathMock, txMock } = vi.hoisted(() => { + const tx = { + tag: { + findMany: vi.fn(), + findUnique: vi.fn(), + update: vi.fn(), + create: vi.fn(), + deleteMany: vi.fn(), + }, + projectTag: { + findMany: vi.fn(), + createMany: vi.fn(), + }, + }; + + return { + transactionMock: vi.fn(async (callback: (tx: typeof tx) => unknown) => callback(tx)), + revalidatePathMock: vi.fn(), + txMock: tx, + }; +}); + +vi.mock("@/lib/prisma", () => ({ + prisma: { + $transaction: transactionMock, + }, +})); + +vi.mock("next/cache", () => ({ + revalidatePath: revalidatePathMock, +})); + +function buildRequest(body: unknown): NextRequest { + return new NextRequest("http://localhost:3000/api/tags/maintenance", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(body), + }); +} describe("POST /api/tags/maintenance", () => { + const validApiKey = "k".repeat(32); + beforeEach(() => { - process.env.WEBHOOK_API_KEY = "test-key-32-characters-long!!"; + process.env.WEBHOOK_API_KEY = validApiKey; + transactionMock.mockClear(); + revalidatePathMock.mockClear(); + txMock.tag.findMany.mockReset(); + txMock.tag.findUnique.mockReset(); + txMock.tag.update.mockReset(); + txMock.tag.create.mockReset(); + txMock.tag.deleteMany.mockReset(); + txMock.projectTag.findMany.mockReset(); + txMock.projectTag.createMany.mockReset(); }); - it("should reject without API key", async () => { - const request = new NextRequest("http://localhost:3000/api/tags/maintenance", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ updates: [], merges: [] }), - }); - - const response = await POST(request); - expect(response.status).toBe(400); - - const json = await response.json(); - expect(json.success).toBe(false); - expect(json.error).toBe("Validation error"); - }); - - it("should reject with invalid API key format", async () => { - const request = new NextRequest("http://localhost:3000/api/tags/maintenance", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - apiKey: "short", - updates: [], - merges: [], - }), - }); - - const response = await POST(request); - expect(response.status).toBe(400); - - const json = await response.json(); - expect(json.success).toBe(false); - expect(json.error).toBe("Validation error"); - }); - - it("should reject with wrong API key", async () => { - const request = new NextRequest("http://localhost:3000/api/tags/maintenance", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ + it("returns 401 for wrong API key", async () => { + const response = await POST( + buildRequest({ apiKey: "a".repeat(32), updates: [], merges: [], - }), - }); - - const response = await POST(request); - expect(response.status).toBe(401); - + }) + ); const json = await response.json(); + + expect(response.status).toBe(401); expect(json.success).toBe(false); expect(json.error).toBe("Unauthorized"); }); - it("should validate updates array structure", async () => { - const request = new NextRequest("http://localhost:3000/api/tags/maintenance", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - apiKey: "test-key-32-characters-long!!", - updates: [{ tagId: "" }], + it("returns 400 for schema errors (missing nameEn)", async () => { + const response = await POST( + buildRequest({ + apiKey: validApiKey, + updates: [{ tagId: "tag-1" }], merges: [], - }), - }); - - const response = await POST(request); - expect(response.status).toBe(400); - + }) + ); const json = await response.json(); + + expect(response.status).toBe(400); expect(json.success).toBe(false); + expect(json.error).toBe("Validation error"); + expect(transactionMock).not.toHaveBeenCalled(); }); - it("should validate merges array structure", async () => { - const request = new NextRequest("http://localhost:3000/api/tags/maintenance", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - apiKey: "test-key-32-characters-long!!", + it("returns 400 for self merge payload", async () => { + const response = await POST( + buildRequest({ + apiKey: validApiKey, updates: [], - merges: [{ target: {}, sourceTagIds: [] }], - }), - }); - - const response = await POST(request); - expect(response.status).toBe(400); - + merges: [ + { + target: { id: "target-tag" }, + sourceTagIds: ["target-tag"], + }, + ], + }) + ); const json = await response.json(); + + expect(response.status).toBe(400); expect(json.success).toBe(false); + expect(json.error).toBe("Validation error"); + expect(transactionMock).not.toHaveBeenCalled(); }); - it("should accept valid updates with proper structure", async () => { - const request = new NextRequest("http://localhost:3000/api/tags/maintenance", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - apiKey: "test-key-32-characters-long!!", - updates: [{ tagId: "test-id", nameEn: "Test Tag English" }], + it("returns 200 for authorized empty operations", async () => { + const response = await POST( + buildRequest({ + apiKey: validApiKey, + updates: [], merges: [], - }), - }); - - const response = await POST(request); - expect(response.status).toBe(200); - + }) + ); const json = await response.json(); + + expect(response.status).toBe(200); expect(json.success).toBe(true); + expect(json.result).toEqual({ + updatedCount: 0, + mergedCount: 0, + deletedTagCount: 0, + }); + expect(revalidatePathMock).toHaveBeenCalledTimes(2); + expect(revalidatePathMock).toHaveBeenCalledWith("/zh/projects", "page"); + expect(revalidatePathMock).toHaveBeenCalledWith("/en/projects", "page"); }); - it("should accept valid merges with proper structure", async () => { - const request = new NextRequest("http://localhost:3000/api/tags/maintenance", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - apiKey: "test-key-32-characters-long!!", - updates: [], - merges: [{ target: { name: "Target Tag" }, sourceTagIds: ["tag1", "tag2"] }], - }), - }); - - const response = await POST(request); - expect(response.status).toBe(200); + it("returns 400 for unknown tag IDs", async () => { + txMock.tag.findMany.mockResolvedValue([]); + const response = await POST( + buildRequest({ + apiKey: validApiKey, + updates: [{ tagId: "missing-tag", nameEn: "Missing" }], + merges: [], + }) + ); const json = await response.json(); - expect(json.success).toBe(true); + + expect(response.status).toBe(400); + expect(json.success).toBe(false); + expect(json.error).toBe("Validation error"); + expect(json.details).toContain("Unknown tagId: missing-tag"); }); }); diff --git a/src/app/api/tags/maintenance/route.ts b/src/app/api/tags/maintenance/route.ts index 2503b44..73ecc2c 100644 --- a/src/app/api/tags/maintenance/route.ts +++ b/src/app/api/tags/maintenance/route.ts @@ -3,7 +3,7 @@ import crypto from "crypto"; import { revalidatePath } from "next/cache"; import { prisma } from "@/lib/prisma"; import { TagMaintenanceRequestSchema } from "@/lib/validations"; -import { generateSlug } from "@/lib/slug"; +import { executeTagMaintenance, TagMaintenanceApiError } from "./service"; export async function POST(request: NextRequest) { try { @@ -45,104 +45,9 @@ export async function POST(request: NextRequest) { } // 4. Execute in transaction - const result = await prisma.$transaction(async (tx) => { - let updatedCount = 0; - let mergedCount = 0; - let deletedTagCount = 0; - - // 4a. Execute updates (nameEn补全) - for (const update of updates) { - await tx.tag.update({ - where: { id: update.tagId }, - data: { nameEn: update.nameEn }, - }); - updatedCount++; - } - - // 4b. Execute merges - for (const merge of merges) { - // Resolve or create target tag - let targetTagId: string; - - if ("id" in merge.target) { - // Use existing tag - targetTagId = merge.target.id; - } else { - // Create or find by name - const slug = generateSlug(merge.target.name, merge.target.nameEn); - const existing = await tx.tag.findFirst({ - where: { - OR: [{ name: merge.target.name }, { slug }], - }, - select: { id: true }, - }); - - if (existing) { - // Update existing tag's nameEn - await tx.tag.update({ - where: { id: existing.id }, - data: { nameEn: merge.target.nameEn }, - }); - targetTagId = existing.id; - } else { - // Create new tag - const newTag = await tx.tag.create({ - data: { - name: merge.target.name, - nameEn: merge.target.nameEn, - slug, - }, - select: { id: true }, - }); - targetTagId = newTag.id; - } - } - - const sourceTagIds = merge.sourceTagIds.filter((id) => id !== targetTagId); - if (sourceTagIds.length === 0) { - mergedCount++; - continue; - } - - // Get all projectIds from source tags - const sourceProjectTags = await tx.projectTag.findMany({ - where: { tagId: { in: sourceTagIds } }, - select: { projectId: true }, - }); - - // Get existing projectIds for target tag (to avoid duplicates) - const existingTargetProjectTags = await tx.projectTag.findMany({ - where: { tagId: targetTagId }, - select: { projectId: true }, - }); - const existingProjectIds = new Set(existingTargetProjectTags.map((pt) => pt.projectId)); - - // Filter to only new projectIds (deduplication) - const newProjectIds = [...new Set(sourceProjectTags.map((pt) => pt.projectId))].filter( - (pid) => !existingProjectIds.has(pid) - ); - - // Create new ProjectTag entries for target - if (newProjectIds.length > 0) { - await tx.projectTag.createMany({ - data: newProjectIds.map((projectId) => ({ - projectId, - tagId: targetTagId, - })), - }); - } - - // Delete source tags (cascade deletes their ProjectTags) - const deleteResult = await tx.tag.deleteMany({ - where: { id: { in: sourceTagIds } }, - }); - - mergedCount++; - deletedTagCount += deleteResult.count; - } - - return { updatedCount, mergedCount, deletedTagCount }; - }); + const result = await prisma.$transaction((tx) => + executeTagMaintenance(tx, { updates, merges }) + ); // 5. Revalidate ISR paths revalidatePath("/zh/projects", "page"); @@ -153,6 +58,17 @@ export async function POST(request: NextRequest) { result, }); } catch (error) { + if (error instanceof TagMaintenanceApiError) { + return NextResponse.json( + { + success: false, + error: error.message, + details: error.details, + }, + { status: error.status } + ); + } + console.error("[POST /api/tags/maintenance] Error:", error); return NextResponse.json( { diff --git a/src/app/api/tags/maintenance/service.test.ts b/src/app/api/tags/maintenance/service.test.ts new file mode 100644 index 0000000..11b52ea --- /dev/null +++ b/src/app/api/tags/maintenance/service.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it, vi } from "vitest"; +import { executeTagMaintenance, TagMaintenanceApiError } from "./service"; + +function createTxMock() { + return { + tag: { + findMany: vi.fn(), + findUnique: vi.fn(), + update: vi.fn(), + create: vi.fn(), + deleteMany: vi.fn(), + }, + projectTag: { + findMany: vi.fn(), + createMany: vi.fn(), + }, + }; +} + +describe("executeTagMaintenance", () => { + it("deduplicates projectTag migration and deletes source tags", async () => { + const tx = createTxMock(); + tx.tag.findMany.mockResolvedValue([ + { id: "target-tag" }, + { id: "source-1" }, + { id: "source-2" }, + ]); + tx.tag.update.mockResolvedValue({ id: "target-tag" }); + tx.projectTag.findMany.mockResolvedValue([ + { projectId: "project-1" }, + { projectId: "project-2" }, + { projectId: "project-2" }, + ]); + tx.projectTag.createMany.mockResolvedValue({ count: 2 }); + tx.tag.deleteMany.mockResolvedValue({ count: 2 }); + + const result = await executeTagMaintenance(tx as Parameters[0], { + updates: [], + merges: [ + { + target: { id: "target-tag", nameEn: "Machine Learning" }, + sourceTagIds: ["source-1", "source-2"], + }, + ], + }); + + expect(tx.tag.update).toHaveBeenCalledWith({ + where: { id: "target-tag" }, + data: { nameEn: "Machine Learning" }, + }); + expect(tx.projectTag.createMany).toHaveBeenCalledWith({ + data: [ + { projectId: "project-1", tagId: "target-tag" }, + { projectId: "project-2", tagId: "target-tag" }, + ], + skipDuplicates: true, + }); + expect(tx.tag.deleteMany).toHaveBeenCalledWith({ + where: { id: { in: ["source-1", "source-2"] } }, + }); + expect(result).toEqual({ + updatedCount: 0, + mergedCount: 1, + deletedTagCount: 2, + }); + }); + + it("throws 400 for invalid tag IDs", async () => { + const tx = createTxMock(); + tx.tag.findMany.mockResolvedValue([]); + + await expect( + executeTagMaintenance(tx as Parameters[0], { + updates: [{ tagId: "missing-tag", nameEn: "Missing" }], + merges: [], + }) + ).rejects.toMatchObject({ + status: 400, + message: "Validation error", + }); + }); +}); diff --git a/src/app/api/tags/maintenance/service.ts b/src/app/api/tags/maintenance/service.ts new file mode 100644 index 0000000..480c04f --- /dev/null +++ b/src/app/api/tags/maintenance/service.ts @@ -0,0 +1,232 @@ +import { Prisma } from '@prisma/client' +import { generateSlug } from '@/lib/slug' +import type { MergeTarget, TagMaintenanceRequest } from '@/lib/validations' + +type MaintenancePayload = Omit +type TransactionClient = Prisma.TransactionClient + +export type TagMaintenanceResult = { + updatedCount: number + mergedCount: number + deletedTagCount: number +} + +export class TagMaintenanceApiError extends Error { + status: number + details: string[] + + constructor(status: number, message: string, details: string[] = []) { + super(message) + this.status = status + this.details = details + this.name = 'TagMaintenanceApiError' + } +} + +function isMergeTargetById(target: MergeTarget): target is Extract { + return 'id' in target +} + +function isUniqueConstraintError(error: unknown): error is Prisma.PrismaClientKnownRequestError { + return ( + error instanceof Prisma.PrismaClientKnownRequestError && + error.code === 'P2002' + ) +} + +function collectReferencedTagIds(payload: MaintenancePayload): string[] { + const ids = new Set() + + for (const update of payload.updates) { + ids.add(update.tagId) + } + + for (const merge of payload.merges) { + for (const sourceTagId of merge.sourceTagIds) { + ids.add(sourceTagId) + } + if (isMergeTargetById(merge.target)) { + ids.add(merge.target.id) + } + } + + return [...ids] +} + +async function assertTagIdsExist( + tx: TransactionClient, + tagIds: string[] +): Promise { + if (tagIds.length === 0) { + return + } + + const existingTags = await tx.tag.findMany({ + where: { id: { in: tagIds } }, + select: { id: true }, + }) + const existingTagIds = new Set(existingTags.map((tag) => tag.id)) + + const missingTagIds = tagIds.filter((tagId) => !existingTagIds.has(tagId)) + if (missingTagIds.length > 0) { + throw new TagMaintenanceApiError( + 400, + 'Validation error', + missingTagIds.map((tagId) => `Unknown tagId: ${tagId}`) + ) + } +} + +async function resolveTargetTagId( + tx: TransactionClient, + target: MergeTarget +): Promise { + if (isMergeTargetById(target)) { + if (target.name !== undefined || target.nameEn !== undefined) { + const data: { name?: string; nameEn?: string } = {} + if (target.name !== undefined) { + data.name = target.name + } + if (target.nameEn !== undefined) { + data.nameEn = target.nameEn + } + + try { + await tx.tag.update({ + where: { id: target.id }, + data, + }) + } catch (error) { + if (isUniqueConstraintError(error)) { + throw new TagMaintenanceApiError( + 409, + 'Tag conflict', + ['Failed to update target tag due to name or slug conflict'] + ) + } + throw error + } + } + + return target.id + } + + const slug = generateSlug(target.name, target.nameEn) + const existingByName = await tx.tag.findUnique({ + where: { name: target.name }, + select: { id: true, nameEn: true }, + }) + + if (existingByName) { + if (existingByName.nameEn !== target.nameEn) { + await tx.tag.update({ + where: { id: existingByName.id }, + data: { nameEn: target.nameEn }, + }) + } + return existingByName.id + } + + const existingBySlug = await tx.tag.findUnique({ + where: { slug }, + select: { name: true }, + }) + if (existingBySlug) { + throw new TagMaintenanceApiError( + 409, + 'Tag conflict', + [`Target slug "${slug}" already exists on tag "${existingBySlug.name}"`] + ) + } + + try { + const created = await tx.tag.create({ + data: { + name: target.name, + nameEn: target.nameEn, + slug, + }, + select: { id: true }, + }) + return created.id + } catch (error) { + if (isUniqueConstraintError(error)) { + throw new TagMaintenanceApiError( + 409, + 'Tag conflict', + ['Failed to create target tag due to unique constraint conflict'] + ) + } + throw error + } +} + +async function migrateProjectTags( + tx: TransactionClient, + targetTagId: string, + sourceTagIds: string[] +): Promise { + const sourceProjectTags = await tx.projectTag.findMany({ + where: { tagId: { in: sourceTagIds } }, + select: { projectId: true }, + }) + const uniqueProjectIds = [...new Set(sourceProjectTags.map((tag) => tag.projectId))] + + if (uniqueProjectIds.length === 0) { + return + } + + await tx.projectTag.createMany({ + data: uniqueProjectIds.map((projectId) => ({ + projectId, + tagId: targetTagId, + })), + skipDuplicates: true, + }) +} + +export async function executeTagMaintenance( + tx: TransactionClient, + payload: MaintenancePayload +): Promise { + const referencedTagIds = collectReferencedTagIds(payload) + await assertTagIdsExist(tx, referencedTagIds) + + let updatedCount = 0 + let mergedCount = 0 + let deletedTagCount = 0 + + for (const update of payload.updates) { + await tx.tag.update({ + where: { id: update.tagId }, + data: { nameEn: update.nameEn }, + }) + updatedCount++ + } + + for (const merge of payload.merges) { + const targetTagId = await resolveTargetTagId(tx, merge.target) + const sourceTagIds = [...new Set(merge.sourceTagIds)].filter( + (sourceTagId) => sourceTagId !== targetTagId + ) + + if (sourceTagIds.length === 0) { + mergedCount++ + continue + } + + await migrateProjectTags(tx, targetTagId, sourceTagIds) + + const deleteResult = await tx.tag.deleteMany({ + where: { id: { in: sourceTagIds } }, + }) + deletedTagCount += deleteResult.count + mergedCount++ + } + + return { + updatedCount, + mergedCount, + deletedTagCount, + } +} diff --git a/src/app/api/tags/route.test.ts b/src/app/api/tags/route.test.ts index bfb56e1..7ba7331 100644 --- a/src/app/api/tags/route.test.ts +++ b/src/app/api/tags/route.test.ts @@ -1,27 +1,57 @@ -import { describe, it, expect } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { GET } from "./route"; +const { findManyMock } = vi.hoisted(() => ({ + findManyMock: vi.fn(), +})); + +vi.mock("@/lib/prisma", () => ({ + prisma: { + tag: { + findMany: findManyMock, + }, + }, +})); + describe("GET /api/tags", () => { - it("should return success with tags array", async () => { - const response = await GET(); - - expect(response.status).toBe(200); - - const json = await response.json(); - expect(json.success).toBe(true); - expect(json).toHaveProperty("tags"); - expect(Array.isArray(json.tags)).toBe(true); + beforeEach(() => { + findManyMock.mockReset(); }); - it("should include _count.projects in tags", async () => { + it("returns tags list with project count", async () => { + findManyMock.mockResolvedValue([ + { + id: "tag-1", + name: "机器学习", + nameEn: "Machine Learning", + slug: "machine-learning", + createdAt: new Date("2026-01-01T00:00:00.000Z"), + _count: { projects: 4 }, + }, + ]); + const response = await GET(); + const json = await response.json(); expect(response.status).toBe(200); + expect(json.success).toBe(true); + expect(json.tags).toHaveLength(1); + expect(json.tags[0]).toMatchObject({ + id: "tag-1", + slug: "machine-learning", + _count: { projects: 4 }, + }); + }); + it("returns 500 when prisma query fails", async () => { + findManyMock.mockRejectedValue(new Error("db unavailable")); + + const response = await GET(); const json = await response.json(); - if (json.tags.length > 0) { - expect(json.tags[0]).toHaveProperty("_count"); - expect(json.tags[0]._count).toHaveProperty("projects"); - } + + expect(response.status).toBe(500); + expect(json.success).toBe(false); + expect(json.error).toBe("Internal server error"); + expect(json.details).toContain("db unavailable"); }); }); diff --git a/src/lib/validations.tag-maintenance.test.ts b/src/lib/validations.tag-maintenance.test.ts new file mode 100644 index 0000000..e700683 --- /dev/null +++ b/src/lib/validations.tag-maintenance.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from "vitest"; +import { TagMaintenanceRequestSchema } from "./validations"; + +describe("TagMaintenanceRequestSchema", () => { + const apiKey = "k".repeat(32); + + it("accepts empty updates and merges", () => { + const result = TagMaintenanceRequestSchema.safeParse({ + apiKey, + updates: [], + merges: [], + }); + + expect(result.success).toBe(true); + }); + + it("rejects duplicate update tag IDs", () => { + const result = TagMaintenanceRequestSchema.safeParse({ + apiKey, + updates: [ + { tagId: "tag-1", nameEn: "Tag 1" }, + { tagId: "tag-1", nameEn: "Tag One" }, + ], + merges: [], + }); + + expect(result.success).toBe(false); + expect(result.error?.errors[0]?.message).toContain("Duplicate update tag ID"); + }); + + it("rejects self merge", () => { + const result = TagMaintenanceRequestSchema.safeParse({ + apiKey, + updates: [], + merges: [ + { + target: { id: "tag-1" }, + sourceTagIds: ["tag-1", "tag-2"], + }, + ], + }); + + expect(result.success).toBe(false); + expect(result.error?.errors[0]?.message).toContain("Self merge is not allowed"); + }); + + it("rejects one source tag used in multiple merges", () => { + const result = TagMaintenanceRequestSchema.safeParse({ + apiKey, + updates: [], + merges: [ + { + target: { id: "target-1" }, + sourceTagIds: ["source-1"], + }, + { + target: { id: "target-2" }, + sourceTagIds: ["source-1"], + }, + ], + }); + + expect(result.success).toBe(false); + expect(result.error?.errors[0]?.message).toContain("Source tag ID appears in multiple merges"); + }); +}); diff --git a/src/lib/validations.ts b/src/lib/validations.ts index c748b95..a550952 100644 --- a/src/lib/validations.ts +++ b/src/lib/validations.ts @@ -269,23 +269,79 @@ export const TagUpdateSchema = z.object({ nameEn: z.string().min(1, "English name is required").max(100), }); -export const MergeTargetSchema = z.union([ - z.object({ id: z.string().min(1) }), - z.object({ - name: z.string().min(1).max(100), - nameEn: z.string().min(1).max(100), - }), -]); +const ExistingMergeTargetSchema = z.object({ + id: z.string().min(1, "Target tag ID is required"), + name: z.string().min(1).max(100).optional(), + nameEn: z.string().min(1).max(100).optional(), +}); + +const NewMergeTargetSchema = z.object({ + name: z.string().min(1, "Target name is required").max(100), + nameEn: z.string().min(1, "Target English name is required").max(100), +}); + +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(); + + 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)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["merges", mergeIndex, "sourceTagIds", sourceIndex], + message: `Source tag ID appears in multiple merges: ${sourceTagId}`, + }); + return; + } + seenMergeSourceTagIds.add(sourceTagId); + }); + }); }); // ================================ diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..09a2687 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,15 @@ +import path from "node:path"; +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + resolve: { + alias: { + "@": path.resolve(__dirname, "./src"), + }, + }, + test: { + environment: "node", + include: ["src/**/*.test.ts"], + watch: false, + }, +});