feat: 实现标签合并维护接口与n8n每日工作流

This commit is contained in:
2026-02-21 09:52:51 +08:00
parent 6cb4545a17
commit 220e9f8b71
12 changed files with 829 additions and 241 deletions
+8 -4
View File
@@ -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 }
+121 -115
View File
@@ -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");
});
});
+15 -99
View File
@@ -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(
{
@@ -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<typeof executeTagMaintenance>[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<typeof executeTagMaintenance>[0], {
updates: [{ tagId: "missing-tag", nameEn: "Missing" }],
merges: [],
})
).rejects.toMatchObject<TagMaintenanceApiError>({
status: 400,
message: "Validation error",
});
});
});
+232
View File
@@ -0,0 +1,232 @@
import { Prisma } from '@prisma/client'
import { generateSlug } from '@/lib/slug'
import type { MergeTarget, TagMaintenanceRequest } from '@/lib/validations'
type MaintenancePayload = Omit<TagMaintenanceRequest, 'apiKey'>
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<MergeTarget, { id: string }> {
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<string>()
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<void> {
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<string> {
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<void> {
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<TagMaintenanceResult> {
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,
}
}
+45 -15
View File
@@ -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");
});
});
@@ -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");
});
});
+63 -7
View File
@@ -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<string>();
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<string>();
const seenMergeSourceTagIds = new Set<string>();
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);
});
});
});
// ================================