feat: 新增项目标签重置接口与n8n流程
This commit is contained in:
@@ -0,0 +1,166 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
import { POST } from "./route";
|
||||
|
||||
const { revalidatePathMock, transactionMock, txMock, projectFindManyMock, tagFindManyMock } =
|
||||
vi.hoisted(() => {
|
||||
const tx = {
|
||||
projectTag: {
|
||||
deleteMany: vi.fn(),
|
||||
createMany: vi.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
revalidatePathMock: vi.fn(),
|
||||
transactionMock: vi.fn(async (callback: (tx: typeof tx) => unknown) => callback(tx)),
|
||||
txMock: tx,
|
||||
projectFindManyMock: vi.fn(),
|
||||
tagFindManyMock: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/prisma", () => ({
|
||||
prisma: {
|
||||
project: {
|
||||
findMany: projectFindManyMock,
|
||||
},
|
||||
tag: {
|
||||
findMany: tagFindManyMock,
|
||||
},
|
||||
$transaction: transactionMock,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("next/cache", () => ({
|
||||
revalidatePath: revalidatePathMock,
|
||||
}));
|
||||
|
||||
function buildRequest(body: unknown): NextRequest {
|
||||
return new NextRequest("http://localhost:3000/api/tags/reset-projects", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
function buildValidPayload(apiKey: string) {
|
||||
return {
|
||||
apiKey,
|
||||
projects: [
|
||||
{
|
||||
projectSlug: "project-one",
|
||||
selectedTagSlugsByCategory: {
|
||||
FIXED_PROJECT_TYPE: ["agent-tooling"],
|
||||
TECH_STACK: ["typescript"],
|
||||
AI_PARADIGM: ["ai-agents"],
|
||||
PRODUCT_FORM: ["web-application"],
|
||||
DOMAIN_SCENARIO: ["code-dev"],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
describe("POST /api/tags/reset-projects", () => {
|
||||
const validApiKey = "k".repeat(32);
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.WEBHOOK_API_KEY = validApiKey;
|
||||
transactionMock.mockClear();
|
||||
revalidatePathMock.mockClear();
|
||||
txMock.projectTag.deleteMany.mockReset();
|
||||
txMock.projectTag.createMany.mockReset();
|
||||
projectFindManyMock.mockReset();
|
||||
tagFindManyMock.mockReset();
|
||||
});
|
||||
|
||||
it("returns 401 for wrong API key", async () => {
|
||||
const response = await POST(buildRequest(buildValidPayload("a".repeat(32))));
|
||||
const json = await response.json();
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(json.success).toBe(false);
|
||||
expect(json.error).toBe("Unauthorized");
|
||||
});
|
||||
|
||||
it("returns 400 when FIXED_PROJECT_TYPE is missing", async () => {
|
||||
const payload = buildValidPayload(validApiKey);
|
||||
payload.projects[0].selectedTagSlugsByCategory.FIXED_PROJECT_TYPE = [];
|
||||
|
||||
const response = await POST(buildRequest(payload));
|
||||
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("returns partial failure when project is missing", async () => {
|
||||
projectFindManyMock.mockResolvedValue([]);
|
||||
tagFindManyMock.mockResolvedValue([
|
||||
{ id: "t1", slug: "agent-tooling", category: "FIXED_PROJECT_TYPE" },
|
||||
{ id: "t2", slug: "typescript", category: "TECH_STACK" },
|
||||
{ id: "t3", slug: "ai-agents", category: "AI_PARADIGM" },
|
||||
{ id: "t4", slug: "web-application", category: "PRODUCT_FORM" },
|
||||
{ id: "t5", slug: "code-dev", category: "DOMAIN_SCENARIO" },
|
||||
]);
|
||||
|
||||
const response = await POST(buildRequest(buildValidPayload(validApiKey)));
|
||||
const json = await response.json();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(json.success).toBe(false);
|
||||
expect(json.result.failedCount).toBe(1);
|
||||
expect(transactionMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("updates project tags and revalidates pages", async () => {
|
||||
projectFindManyMock.mockResolvedValue([
|
||||
{
|
||||
id: "p1",
|
||||
slug: "project-one",
|
||||
tags: [
|
||||
{
|
||||
tag: {
|
||||
id: "old-tech",
|
||||
slug: "javascript",
|
||||
category: "TECH_STACK",
|
||||
},
|
||||
},
|
||||
{
|
||||
tag: {
|
||||
id: "old-domain",
|
||||
slug: "automation-workflow",
|
||||
category: "DOMAIN_SCENARIO",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
tagFindManyMock.mockResolvedValue([
|
||||
{ id: "t1", slug: "agent-tooling", category: "FIXED_PROJECT_TYPE" },
|
||||
{ id: "t2", slug: "typescript", category: "TECH_STACK" },
|
||||
{ id: "t3", slug: "ai-agents", category: "AI_PARADIGM" },
|
||||
{ id: "t4", slug: "web-application", category: "PRODUCT_FORM" },
|
||||
{ id: "t5", slug: "code-dev", category: "DOMAIN_SCENARIO" },
|
||||
]);
|
||||
|
||||
const response = await POST(buildRequest(buildValidPayload(validApiKey)));
|
||||
const json = await response.json();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(json.success).toBe(true);
|
||||
expect(json.result.updatedCount).toBe(1);
|
||||
expect(transactionMock).toHaveBeenCalledTimes(1);
|
||||
expect(txMock.projectTag.deleteMany).toHaveBeenCalledTimes(1);
|
||||
expect(txMock.projectTag.createMany).toHaveBeenCalledTimes(1);
|
||||
expect(revalidatePathMock).toHaveBeenCalledWith("/zh/projects", "page");
|
||||
expect(revalidatePathMock).toHaveBeenCalledWith("/en/projects", "page");
|
||||
expect(revalidatePathMock).toHaveBeenCalledWith("/zh/projects/project-one", "page");
|
||||
expect(revalidatePathMock).toHaveBeenCalledWith("/en/projects/project-one", "page");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,293 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import crypto from 'crypto'
|
||||
import { revalidatePath } from 'next/cache'
|
||||
import type { TagCategory } from '@prisma/client'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import {
|
||||
ProjectTagResetRequestSchema,
|
||||
type ProjectTagResetItem,
|
||||
type ResettableTagCategory,
|
||||
} from '@/lib/validations'
|
||||
|
||||
type ResetResultItem = {
|
||||
projectSlug: string
|
||||
status: 'updated' | 'dry-run' | 'failed'
|
||||
selectedTagCount: number
|
||||
addedCount: number
|
||||
removedCount: number
|
||||
details: string[]
|
||||
}
|
||||
|
||||
const DEFAULT_RESET_CATEGORIES: ResettableTagCategory[] = [
|
||||
'FIXED_PROJECT_TYPE',
|
||||
'TECH_STACK',
|
||||
'AI_PARADIGM',
|
||||
'PRODUCT_FORM',
|
||||
'DOMAIN_SCENARIO',
|
||||
]
|
||||
|
||||
function isApiKeyValid(providedApiKey: string, expectedApiKey?: string): boolean {
|
||||
if (!expectedApiKey) {
|
||||
return false
|
||||
}
|
||||
const providedBuf = Buffer.from(providedApiKey)
|
||||
const expectedBuf = Buffer.from(expectedApiKey)
|
||||
return (
|
||||
providedBuf.length === expectedBuf.length &&
|
||||
crypto.timingSafeEqual(providedBuf, expectedBuf)
|
||||
)
|
||||
}
|
||||
|
||||
function normalizeSlug(slug: string): string {
|
||||
return slug.trim().toLowerCase()
|
||||
}
|
||||
|
||||
function collectSelectedTagSlugs(
|
||||
item: ProjectTagResetItem,
|
||||
categories: ResettableTagCategory[]
|
||||
): string[] {
|
||||
const selectedTagSlugSet = new Set<string>()
|
||||
|
||||
for (const category of categories) {
|
||||
const categorySlugs = item.selectedTagSlugsByCategory[category] || []
|
||||
for (const slug of categorySlugs) {
|
||||
selectedTagSlugSet.add(normalizeSlug(slug))
|
||||
}
|
||||
}
|
||||
|
||||
return [...selectedTagSlugSet]
|
||||
}
|
||||
|
||||
function collectValidationErrorsForProjectItem(params: {
|
||||
item: ProjectTagResetItem
|
||||
categories: ResettableTagCategory[]
|
||||
existingTagBySlug: Map<string, { id: string; slug: string; category: TagCategory }>
|
||||
}): string[] {
|
||||
const { item, categories, existingTagBySlug } = params
|
||||
const errors: string[] = []
|
||||
|
||||
for (const category of categories) {
|
||||
for (const rawSlug of item.selectedTagSlugsByCategory[category] || []) {
|
||||
const normalizedSlug = normalizeSlug(rawSlug)
|
||||
const existingTag = existingTagBySlug.get(normalizedSlug)
|
||||
|
||||
if (!existingTag) {
|
||||
errors.push(`Unknown tag slug "${rawSlug}" in category ${category}`)
|
||||
continue
|
||||
}
|
||||
if (existingTag.category !== category) {
|
||||
errors.push(
|
||||
`Tag slug "${rawSlug}" belongs to ${existingTag.category}, expected ${category}`
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return errors
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json()
|
||||
const validation = ProjectTagResetRequestSchema.safeParse(body)
|
||||
|
||||
if (!validation.success) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Validation error',
|
||||
details: validation.error.errors.map((issue) => issue.message),
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const {
|
||||
apiKey,
|
||||
dryRun,
|
||||
replaceAllCategories,
|
||||
projects,
|
||||
categories: requestedCategories,
|
||||
} = validation.data
|
||||
|
||||
if (!isApiKeyValid(apiKey, process.env.WEBHOOK_API_KEY)) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Unauthorized',
|
||||
details: ['Invalid or missing API Key'],
|
||||
},
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
const categories = requestedCategories.length > 0
|
||||
? requestedCategories
|
||||
: DEFAULT_RESET_CATEGORIES
|
||||
|
||||
const normalizedProjectSlugs = projects.map((item) => normalizeSlug(item.projectSlug))
|
||||
const normalizedSelectedTagSlugs = [
|
||||
...new Set(projects.flatMap((item) => collectSelectedTagSlugs(item, categories))),
|
||||
]
|
||||
|
||||
const [existingProjects, existingTags] = await Promise.all([
|
||||
prisma.project.findMany({
|
||||
where: { slug: { in: normalizedProjectSlugs } },
|
||||
select: {
|
||||
id: true,
|
||||
slug: true,
|
||||
tags: {
|
||||
include: {
|
||||
tag: {
|
||||
select: {
|
||||
id: true,
|
||||
slug: true,
|
||||
category: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
prisma.tag.findMany({
|
||||
where: { slug: { in: normalizedSelectedTagSlugs } },
|
||||
select: {
|
||||
id: true,
|
||||
slug: true,
|
||||
category: true,
|
||||
},
|
||||
}),
|
||||
])
|
||||
|
||||
const projectBySlug = new Map(existingProjects.map((project) => [project.slug, project]))
|
||||
const existingTagBySlug = new Map(existingTags.map((tag) => [tag.slug, tag]))
|
||||
|
||||
const results: ResetResultItem[] = []
|
||||
const updatedProjectSlugs: string[] = []
|
||||
|
||||
for (const item of projects) {
|
||||
const projectSlug = normalizeSlug(item.projectSlug)
|
||||
const project = projectBySlug.get(projectSlug)
|
||||
|
||||
if (!project) {
|
||||
results.push({
|
||||
projectSlug,
|
||||
status: 'failed',
|
||||
selectedTagCount: 0,
|
||||
addedCount: 0,
|
||||
removedCount: 0,
|
||||
details: [`Project with slug "${projectSlug}" not found`],
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
const validationErrors = collectValidationErrorsForProjectItem({
|
||||
item,
|
||||
categories,
|
||||
existingTagBySlug,
|
||||
})
|
||||
|
||||
if (validationErrors.length > 0) {
|
||||
results.push({
|
||||
projectSlug,
|
||||
status: 'failed',
|
||||
selectedTagCount: 0,
|
||||
addedCount: 0,
|
||||
removedCount: 0,
|
||||
details: validationErrors,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
const selectedTagIds = collectSelectedTagSlugs(item, categories)
|
||||
.map((slug) => existingTagBySlug.get(slug)?.id)
|
||||
.filter((id): id is string => Boolean(id))
|
||||
|
||||
const previousCategoryTagIds = new Set(
|
||||
project.tags
|
||||
.filter((projectTag) => categories.includes(projectTag.tag.category as ResettableTagCategory))
|
||||
.map((projectTag) => projectTag.tag.id)
|
||||
)
|
||||
|
||||
const nextTagIdSet = new Set(selectedTagIds)
|
||||
const removedCount = replaceAllCategories
|
||||
? [...previousCategoryTagIds].filter((tagId) => !nextTagIdSet.has(tagId)).length
|
||||
: 0
|
||||
const addedCount = [...nextTagIdSet].filter((tagId) => !previousCategoryTagIds.has(tagId)).length
|
||||
|
||||
if (!dryRun) {
|
||||
await prisma.$transaction(async (tx) => {
|
||||
if (replaceAllCategories) {
|
||||
await tx.projectTag.deleteMany({
|
||||
where: {
|
||||
projectId: project.id,
|
||||
tag: {
|
||||
category: {
|
||||
in: categories as TagCategory[],
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if (selectedTagIds.length > 0) {
|
||||
await tx.projectTag.createMany({
|
||||
data: selectedTagIds.map((tagId) => ({
|
||||
projectId: project.id,
|
||||
tagId,
|
||||
})),
|
||||
skipDuplicates: true,
|
||||
})
|
||||
}
|
||||
})
|
||||
updatedProjectSlugs.push(projectSlug)
|
||||
}
|
||||
|
||||
results.push({
|
||||
projectSlug,
|
||||
status: dryRun ? 'dry-run' : 'updated',
|
||||
selectedTagCount: selectedTagIds.length,
|
||||
addedCount,
|
||||
removedCount,
|
||||
details: [],
|
||||
})
|
||||
}
|
||||
|
||||
const updatedCount = results.filter((item) => item.status === 'updated').length
|
||||
const dryRunCount = results.filter((item) => item.status === 'dry-run').length
|
||||
const failedCount = results.filter((item) => item.status === 'failed').length
|
||||
|
||||
if (!dryRun && updatedProjectSlugs.length > 0) {
|
||||
revalidatePath('/zh/projects', 'page')
|
||||
revalidatePath('/en/projects', 'page')
|
||||
for (const projectSlug of updatedProjectSlugs) {
|
||||
revalidatePath(`/zh/projects/${projectSlug}`, 'page')
|
||||
revalidatePath(`/en/projects/${projectSlug}`, 'page')
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: failedCount === 0,
|
||||
result: {
|
||||
dryRun,
|
||||
replaceAllCategories,
|
||||
categories,
|
||||
total: projects.length,
|
||||
updatedCount,
|
||||
dryRunCount,
|
||||
failedCount,
|
||||
results,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('[POST /api/tags/reset-projects] Error:', error)
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Internal server error',
|
||||
details: [error instanceof Error ? error.message : 'Unknown error'],
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -260,6 +260,88 @@ export const TagMaintenanceRequestSchema = z.object({
|
||||
});
|
||||
});
|
||||
|
||||
// ================================
|
||||
// Project Tag Reset API Schemas
|
||||
// ================================
|
||||
|
||||
const ResettableTagCategorySchema = z.enum([
|
||||
"FIXED_PROJECT_TYPE",
|
||||
"TECH_STACK",
|
||||
"AI_PARADIGM",
|
||||
"PRODUCT_FORM",
|
||||
"DOMAIN_SCENARIO",
|
||||
]);
|
||||
|
||||
const ProjectTagSelectionByCategorySchema = z.object({
|
||||
FIXED_PROJECT_TYPE: z.array(z.string().trim().min(1)).min(1).max(1),
|
||||
TECH_STACK: z.array(z.string().trim().min(1)).max(20).default([]),
|
||||
AI_PARADIGM: z.array(z.string().trim().min(1)).max(20).default([]),
|
||||
PRODUCT_FORM: z.array(z.string().trim().min(1)).max(20).default([]),
|
||||
DOMAIN_SCENARIO: z.array(z.string().trim().min(1)).max(20).default([]),
|
||||
});
|
||||
|
||||
export const ProjectTagResetItemSchema = z.object({
|
||||
projectSlug: z.string().trim().min(1).max(200),
|
||||
selectedTagSlugsByCategory: ProjectTagSelectionByCategorySchema,
|
||||
});
|
||||
|
||||
export const ProjectTagResetRequestSchema = z.object({
|
||||
apiKey: z.string().min(32, "Invalid API key format"),
|
||||
dryRun: z.boolean().default(false),
|
||||
replaceAllCategories: z.boolean().default(true),
|
||||
categories: z.array(ResettableTagCategorySchema).min(1).default([
|
||||
"FIXED_PROJECT_TYPE",
|
||||
"TECH_STACK",
|
||||
"AI_PARADIGM",
|
||||
"PRODUCT_FORM",
|
||||
"DOMAIN_SCENARIO",
|
||||
]),
|
||||
projects: z.array(ProjectTagResetItemSchema).min(1).max(100),
|
||||
}).superRefine((data, ctx) => {
|
||||
const seenProjectSlugs = new Set<string>();
|
||||
|
||||
data.projects.forEach((project, projectIndex) => {
|
||||
const normalizedSlug = project.projectSlug.trim().toLowerCase();
|
||||
if (seenProjectSlugs.has(normalizedSlug)) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["projects", projectIndex, "projectSlug"],
|
||||
message: `Duplicate projectSlug: ${project.projectSlug}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
seenProjectSlugs.add(normalizedSlug);
|
||||
|
||||
const categories = Object.keys(project.selectedTagSlugsByCategory) as Array<
|
||||
keyof z.infer<typeof ProjectTagSelectionByCategorySchema>
|
||||
>;
|
||||
|
||||
categories.forEach((category) => {
|
||||
const selectedSlugs = project.selectedTagSlugsByCategory[category];
|
||||
const seenTagSlugs = new Set<string>();
|
||||
|
||||
selectedSlugs.forEach((slug, slugIndex) => {
|
||||
const normalizedTagSlug = slug.trim().toLowerCase();
|
||||
if (seenTagSlugs.has(normalizedTagSlug)) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: [
|
||||
"projects",
|
||||
projectIndex,
|
||||
"selectedTagSlugsByCategory",
|
||||
category,
|
||||
slugIndex,
|
||||
],
|
||||
message: `Duplicate tag slug "${slug}" in category ${category}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
seenTagSlugs.add(normalizedTagSlug);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ================================
|
||||
// Types
|
||||
// ================================
|
||||
@@ -268,3 +350,7 @@ export type TagUpdate = z.infer<typeof TagUpdateSchema>;
|
||||
export type MergeTarget = z.infer<typeof MergeTargetSchema>;
|
||||
export type TagMerge = z.infer<typeof TagMergeSchema>;
|
||||
export type TagMaintenanceRequest = z.infer<typeof TagMaintenanceRequestSchema>;
|
||||
export type ResettableTagCategory = z.infer<typeof ResettableTagCategorySchema>;
|
||||
export type ProjectTagSelectionByCategory = z.infer<typeof ProjectTagSelectionByCategorySchema>;
|
||||
export type ProjectTagResetItem = z.infer<typeof ProjectTagResetItemSchema>;
|
||||
export type ProjectTagResetRequest = z.infer<typeof ProjectTagResetRequestSchema>;
|
||||
|
||||
Reference in New Issue
Block a user