import { z } from "zod"; // ================================ // Enums // ================================ export const ProjectStatusEnum = z.enum(["ACTIVE", "ARCHIVED"]); export const LinkTypeEnum = z.enum(["WEBSITE", "GITHUB", "HUGGINGFACE", "PAPER"]); // ================================ // Base Schemas // ================================ export const ExternalLinkSchema = z.object({ type: LinkTypeEnum, url: z .string() .min(1, "URL is required") .max(2000, "URL is too long") .refine((url) => { try { const parsed = new URL(url); return ["http:", "https:"].includes(parsed.protocol); } catch { return false; } }, "URL must use http or https protocol"), title: z.string().max(200).optional(), }); export const TagSchema = z.object({ name: z.string().min(1).max(50), nameEn: z.string().max(50).optional(), }); // ================================ // Project Schemas // ================================ export const ProjectBaseSchema = z.object({ name: z.string().min(1).max(200), nameEn: z.string().max(200).optional(), description: z.string().min(10).max(500), descriptionEn: z.string().max(500).optional(), content: z.string().max(10000).optional(), contentEn: z.string().max(10000).optional(), status: ProjectStatusEnum.default("ACTIVE"), source: z.string().max(100).optional(), }); export const ProjectInputSchema = ProjectBaseSchema.extend({ tags: z.array(TagSchema).min(1, "At least one tag is required").max(10), links: z.array(ExternalLinkSchema).min(1, "At least one link is required").max(10), }); // ================================ // Webhook Schemas // ================================ export const WebhookAuthSchema = z.object({ apiKey: z.string().min(32, "Invalid API key format"), }); export const WebhookPayloadSchema = WebhookAuthSchema.extend({ projects: z.array(ProjectInputSchema).min(1).max(100), }); // ================================ // Discovery Task Schemas // ================================ export const TaskStatusEnum = z.enum(["PENDING", "IN_PROGRESS", "COMPLETED", "FAILED"]); 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: z.record(z.unknown()).optional(), explorationSummary: z.string().max(1000).optional(), errorMessage: z.string().max(2000).optional(), }); export const GetDiscoveryTasksQuerySchema = z.object({ status: TaskStatusEnum.optional(), 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(), }); // ================================ // Query Schemas // ================================ export const ProjectQuerySchema = z.object({ search: z.string().min(2).max(100).optional(), tags: z.array(z.string()).optional(), domains: z.array(z.string()).optional(), productForms: z.array(z.string()).optional(), status: ProjectStatusEnum.optional(), page: z.coerce.number().int().positive().default(1), limit: z.coerce.number().int().positive().max(100).default(20), }); // ================================ // Types // ================================ export type ExternalLink = z.infer; export type Tag = z.infer; export type ProjectInput = z.infer; export type WebhookPayload = z.infer; export type ProjectQuery = z.infer; export type TaskStatus = z.infer; export type CreateDiscoveryTask = z.infer; export type UpdateDiscoveryTask = z.infer; export type GetDiscoveryTasksQuery = z.infer; export type BatchResetTasks = z.infer; // ================================ // Tags API Schemas // ================================ export const TagUpdateSchema = z.object({ tagId: z.string().min(1, "Tag ID is required"), nameEn: z.string().min(1, "English name is required").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); }); }); }); // ================================ // Types // ================================ export type TagUpdate = z.infer; export type MergeTarget = z.infer; export type TagMerge = z.infer; export type TagMaintenanceRequest = z.infer;