diff --git a/src/app/api/discovery/check-duplicates/route.ts b/src/app/api/discovery/check-duplicates/route.ts index 3f22956..c988246 100644 --- a/src/app/api/discovery/check-duplicates/route.ts +++ b/src/app/api/discovery/check-duplicates/route.ts @@ -1,7 +1,7 @@ import { NextRequest, NextResponse } from 'next/server' -import crypto from 'crypto' import { prisma } from '@/lib/prisma' import { CheckTaskDuplicatesSchema } from '@/lib/validations' +import { isValidApiKey } from '@/lib/auth' /** * 检查 URL 是否应该创建新任务 @@ -194,14 +194,7 @@ export async function POST(request: NextRequest) { const { apiKey, urls, sourceType } = validationResult.data // 验证 API Key - const validApiKey = process.env.WEBHOOK_API_KEY - if ( - !validApiKey || - !crypto.timingSafeEqual( - Buffer.from(apiKey), - Buffer.from(validApiKey) - ) - ) { + if (!isValidApiKey(apiKey)) { return NextResponse.json( { success: false, diff --git a/src/app/api/discovery/tasks/[id]/complete/route.ts b/src/app/api/discovery/tasks/[id]/complete/route.ts index 5c61f37..7e5ea09 100644 --- a/src/app/api/discovery/tasks/[id]/complete/route.ts +++ b/src/app/api/discovery/tasks/[id]/complete/route.ts @@ -1,11 +1,11 @@ import { NextRequest, NextResponse } from 'next/server' -import crypto from 'crypto' 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, @@ -28,11 +28,7 @@ export async function POST( const { apiKey, explorationData } = body // 验证API密钥 - const validApiKey = process.env.WEBHOOK_API_KEY - if ( - !validApiKey || - !crypto.timingSafeEqual(Buffer.from(apiKey), Buffer.from(validApiKey)) - ) { + if (!isValidApiKey(apiKey)) { return NextResponse.json( { success: false, error: 'Unauthorized' }, { status: 401 } diff --git a/src/app/api/discovery/tasks/[id]/route.ts b/src/app/api/discovery/tasks/[id]/route.ts index 4d28ce7..c8e8822 100644 --- a/src/app/api/discovery/tasks/[id]/route.ts +++ b/src/app/api/discovery/tasks/[id]/route.ts @@ -1,8 +1,8 @@ import { NextRequest, NextResponse } from 'next/server' -import crypto from 'crypto' import { prisma } from '@/lib/prisma' import { UpdateDiscoveryTaskSchema, TaskStatus } from '@/lib/validations' import type { Prisma } from '@prisma/client' +import { isValidApiKey } from '@/lib/auth' /** * 有效的任务状态转换规则 @@ -87,11 +87,7 @@ export async function PATCH( validation.data // 验证API密钥 - const validApiKey = process.env.WEBHOOK_API_KEY - if ( - !validApiKey || - !crypto.timingSafeEqual(Buffer.from(apiKey), Buffer.from(validApiKey)) - ) { + if (!isValidApiKey(apiKey)) { return NextResponse.json( { success: false, error: 'Unauthorized' }, { status: 401 } @@ -143,7 +139,9 @@ export async function PATCH( updateData.completedAt = new Date() } - if (explorationData !== undefined) updateData.explorationData = explorationData as Prisma.InputJsonObject + if (explorationData !== undefined) { + updateData.explorationData = explorationData as Prisma.InputJsonObject + } if (explorationSummary !== undefined) updateData.explorationSummary = explorationSummary if (errorMessage !== undefined) updateData.errorMessage = errorMessage diff --git a/src/app/api/discovery/tasks/batch-reset/route.ts b/src/app/api/discovery/tasks/batch-reset/route.ts index 51c2ed4..40989ad 100644 --- a/src/app/api/discovery/tasks/batch-reset/route.ts +++ b/src/app/api/discovery/tasks/batch-reset/route.ts @@ -1,7 +1,7 @@ import { NextRequest, NextResponse } from 'next/server' -import crypto from 'crypto' import { prisma } from '@/lib/prisma' import { BatchResetTasksSchema } from '@/lib/validations' +import { isValidApiKey } from '@/lib/auth' /** * POST /api/discovery/tasks/batch-reset @@ -31,11 +31,7 @@ export async function POST(request: NextRequest) { const { apiKey, taskIds, statuses } = validation.data // 验证API密钥 - const validApiKey = process.env.WEBHOOK_API_KEY - if ( - !validApiKey || - !crypto.timingSafeEqual(Buffer.from(apiKey), Buffer.from(validApiKey)) - ) { + if (!isValidApiKey(apiKey)) { return NextResponse.json( { success: false, error: 'Unauthorized' }, { status: 401 } diff --git a/src/app/api/discovery/tasks/route.ts b/src/app/api/discovery/tasks/route.ts index 4a82d0e..42ba7f9 100644 --- a/src/app/api/discovery/tasks/route.ts +++ b/src/app/api/discovery/tasks/route.ts @@ -1,10 +1,10 @@ import { NextRequest, NextResponse } from 'next/server' -import crypto from 'crypto' import { prisma } from '@/lib/prisma' import { CreateDiscoveryTaskSchema, GetDiscoveryTasksQuerySchema, } from '@/lib/validations' +import { isValidApiKey } from '@/lib/auth' /** * POST /api/discovery/tasks @@ -29,11 +29,7 @@ export async function POST(request: NextRequest) { const { apiKey, tasks } = validation.data // 验证API密钥 - const validApiKey = process.env.WEBHOOK_API_KEY - if ( - !validApiKey || - !crypto.timingSafeEqual(Buffer.from(apiKey), Buffer.from(validApiKey)) - ) { + if (!isValidApiKey(apiKey)) { return NextResponse.json( { success: false, error: 'Unauthorized' }, { status: 401 } @@ -101,12 +97,7 @@ export async function GET(request: NextRequest) { // 验证 API Key(只读权限) // 支持两种方式:1. 请求头 x-api-key 2. 查询参数 apiKey const apiKey = request.headers.get('x-api-key') || searchParams.get('apiKey') - const validApiKey = process.env.WEBHOOK_API_KEY - if ( - !validApiKey || - !apiKey || - !crypto.timingSafeEqual(Buffer.from(apiKey), Buffer.from(validApiKey)) - ) { + if (!isValidApiKey(apiKey)) { return NextResponse.json( { success: false, error: 'Unauthorized' }, { status: 401 } diff --git a/src/app/api/projects/[slug]/route.ts b/src/app/api/projects/[slug]/route.ts index 353f598..486dc7c 100644 --- a/src/app/api/projects/[slug]/route.ts +++ b/src/app/api/projects/[slug]/route.ts @@ -1,5 +1,6 @@ import { NextRequest, NextResponse } from 'next/server' import { prisma } from '@/lib/prisma' +import { isValidApiKey } from '@/lib/auth' /** * DELETE /api/projects/[slug] @@ -21,10 +22,8 @@ export async function DELETE( const { slug } = await params // Verify API Key - const apiKey = request.headers.get('x-api-key') || process.env.WEBHOOK_API_KEY - const validApiKey = process.env.WEBHOOK_API_KEY - - if (apiKey !== validApiKey) { + const apiKey = request.headers.get('x-api-key') + if (!isValidApiKey(apiKey)) { return NextResponse.json( { success: false, diff --git a/src/app/api/tags/maintenance/route.ts b/src/app/api/tags/maintenance/route.ts index 73ecc2c..aeb6191 100644 --- a/src/app/api/tags/maintenance/route.ts +++ b/src/app/api/tags/maintenance/route.ts @@ -1,8 +1,8 @@ import { NextRequest, NextResponse } from "next/server"; -import crypto from "crypto"; import { revalidatePath } from "next/cache"; import { prisma } from "@/lib/prisma"; import { TagMaintenanceRequestSchema } from "@/lib/validations"; +import { isValidApiKey } from "@/lib/auth"; import { executeTagMaintenance, TagMaintenanceApiError } from "./service"; export async function POST(request: NextRequest) { @@ -26,14 +26,7 @@ export async function POST(request: NextRequest) { const { apiKey, updates, merges } = validation.data; // 3. Authenticate with timing-safe comparison - const expectedApiKey = process.env.WEBHOOK_API_KEY; - const providedBuf = Buffer.from(apiKey); - const expectedBuf = Buffer.from(expectedApiKey || ""); - if ( - !expectedApiKey || - providedBuf.length !== expectedBuf.length || - !crypto.timingSafeEqual(providedBuf, expectedBuf) - ) { + if (!isValidApiKey(apiKey)) { return NextResponse.json( { success: false, diff --git a/src/app/api/webhook/check-duplicates/route.ts b/src/app/api/webhook/check-duplicates/route.ts index dd21026..6244544 100644 --- a/src/app/api/webhook/check-duplicates/route.ts +++ b/src/app/api/webhook/check-duplicates/route.ts @@ -1,5 +1,6 @@ import { NextRequest, NextResponse } from 'next/server' import { prisma } from '@/lib/prisma' +import { isValidApiKey } from '@/lib/auth' import { z } from 'zod' /** @@ -179,8 +180,7 @@ export async function POST(request: NextRequest) { const payload = validationResult.data // Verify API Key - const apiKey = process.env.WEBHOOK_API_KEY - if (payload.apiKey !== apiKey) { + if (!isValidApiKey(payload.apiKey)) { return NextResponse.json( { success: false, diff --git a/src/app/api/webhook/projects/route.ts b/src/app/api/webhook/projects/route.ts index 76cb145..edaf6f3 100644 --- a/src/app/api/webhook/projects/route.ts +++ b/src/app/api/webhook/projects/route.ts @@ -1,7 +1,7 @@ import { NextRequest, NextResponse } from 'next/server' -import crypto from 'crypto' import { prisma } from '@/lib/prisma' import type { ProjectStatus, LinkType } from '@prisma/client' +import { isValidApiKey } from '@/lib/auth' import { WebhookPayloadSchema, ProjectInputSchema, @@ -38,14 +38,7 @@ export async function POST(request: NextRequest) { const payload = validationResult.data as WebhookPayload // Verify API Key using timing-safe comparison to prevent timing attacks - const apiKey = process.env.WEBHOOK_API_KEY - if ( - !apiKey || - !crypto.timingSafeEqual( - Buffer.from(payload.apiKey), - Buffer.from(apiKey) - ) - ) { + if (!isValidApiKey(payload.apiKey)) { return NextResponse.json( { success: false, @@ -66,7 +59,7 @@ export async function POST(request: NextRequest) { index: number field: string message: string - value: any + value: unknown }>, } @@ -123,42 +116,44 @@ export async function POST(request: NextRequest) { `[Webhook] Updating project "${validProject.name}" (matched by ${matchMethod}, id: ${existingProject.id})` ) - // Update tags (delete old ones, create new ones) - await prisma.projectTag.deleteMany({ - where: { projectId: existingProject.id }, - }) + await prisma.$transaction(async (tx) => { + // Update tags (delete old ones, create new ones) + await tx.projectTag.deleteMany({ + where: { projectId: existingProject.id }, + }) - await prisma.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 } }, - })), + 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 prisma.externalLink.deleteMany({ - where: { projectId: existingProject.id }, - }) + // Update links (delete old ones, create new ones) + await tx.externalLink.deleteMany({ + where: { projectId: existingProject.id }, + }) - await prisma.externalLink.createMany({ - data: validProject.links.map((link) => ({ - type: link.type as LinkType, - url: link.url, - title: link.title || null, - 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++ diff --git a/src/app/globals.css b/src/app/globals.css index 2e4c1d9..73aa239 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -1,4 +1,5 @@ @import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Space+Mono:wght@400;700&display=swap'); +@import url('https://fonts.googleapis.com/icon?family=Material+Icons'); @tailwind base; @tailwind components; diff --git a/src/app/icon.svg b/src/app/icon.svg new file mode 100644 index 0000000..d50a79d --- /dev/null +++ b/src/app/icon.svg @@ -0,0 +1,15 @@ + diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 2be2960..fb157ed 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -13,12 +13,6 @@ export default function RootLayout({ }>) { return ( -
- {/* Load fonts via CSS */} - - {/* Material Icons */} - - {children} diff --git a/src/components/project/MarkdownContent.tsx b/src/components/project/MarkdownContent.tsx index cfe0b56..c9801ce 100644 --- a/src/components/project/MarkdownContent.tsx +++ b/src/components/project/MarkdownContent.tsx @@ -9,6 +9,23 @@ interface MarkdownContentProps { className?: string } +const BLOCK_LEVEL_TAGS = new Set([ + 'pre', + 'table', + 'blockquote', + 'ul', + 'ol', + 'h1', + 'h2', + 'h3', + 'h4', + 'h5', + 'h6', + 'div', + 'img', + 'hr', +]) + // Generate heading ID from text function generateHeadingId(text: string): string { return text @@ -22,14 +39,6 @@ function generateHeadingId(text: string): string { .replace(/-+$/, '') // Trim - from end } -// Helper function to escape HTML -function escapeHtml(code: string): string { - return code - .replace(/&/g, '&') - .replace(//g, '>') -} - // Custom GitHub-style components const components: Components = { // Headings with anchor links @@ -87,13 +96,13 @@ const components: Components = { ), // Paragraphs - skip wrapping if contains block-level elements like pre - p: ({ children, node, ...props }: any) => { + p: ({ children, node, ...props }) => { // Check if the paragraph node contains block-level elements in its children // This uses the AST node data from react-markdown - const hasBlockElement = node?.children?.some((child: any) => { - const tagName = child?.tagName + const hasBlockElement = node?.children?.some((child) => { + const tagName = 'tagName' in child ? child.tagName : undefined // Check if any direct child is a block-level element - return ['pre', 'table', 'blockquote', 'ul', 'ol', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'div', 'img', 'hr'].includes(tagName) + return typeof tagName === 'string' && BLOCK_LEVEL_TAGS.has(tagName) }) if (hasBlockElement) { @@ -131,7 +140,7 @@ const components: Components = { ), // Code blocks (pre element wrapper) - pre: ({ children, ...props }: any) => { + pre: ({ children, ...props }) => { return (
{children}
@@ -140,12 +149,12 @@ const components: Components = {
},
// Code elements (both inline and in code blocks)
- code: ({ inline, className, children, ...props }: any) => {
- // If inline is explicitly true, or if there's no language class and no newlines, treat as inline
+ code: ({ className, children, ...props }) => {
+ // If there's no language class and no newlines, treat as inline code.
const hasLanguageClass = className && typeof className === 'string' && className.startsWith('language-')
const childStr = String(children)
const hasNewlines = childStr.includes('\n')
- const isInline = inline === true || (!hasLanguageClass && !hasNewlines)
+ const isInline = !hasLanguageClass && !hasNewlines
if (isInline) {
return (
@@ -217,6 +226,8 @@ const components: Components = {
img: ({ src, alt, ...props }) => (
+ {/* External markdown images use arbitrary remote hosts; Next/Image is not suitable here. */}
+ {/* eslint-disable-next-line @next/next/no-img-element */}
{
@@ -105,7 +104,7 @@ export function ProjectCard({ project, locale, featured = false, translations }:
{/* GitHub Stars Badge */}
{badges.stars && (
-
)}
diff --git a/src/components/project/ProjectDetail.tsx b/src/components/project/ProjectDetail.tsx
index 5c0f033..cd1d889 100644
--- a/src/components/project/ProjectDetail.tsx
+++ b/src/components/project/ProjectDetail.tsx
@@ -1,9 +1,5 @@
-import Link from 'next/link'
import { getTranslations } from 'next-intl/server'
import { MarkdownContent } from './MarkdownContent'
-import { ShareButtons } from './ShareButtons'
-import { GitHubBadges } from './GitHubBadges'
-import { getGitHubBadgesFromLinks } from '@/lib/github/badges'
import { isFixedProjectTypeSlug } from '@/lib/tag-taxonomy'
interface ProjectDetailProps {
@@ -57,9 +53,6 @@ export async function ProjectDetail({ project, locale }: ProjectDetailProps) {
locale === 'en' && project.descriptionEn ? project.descriptionEn : project.description
const displayContent = locale === 'en' && project.contentEn ? project.contentEn : project.content
- // Generate GitHub badge URLs
- const badges = getGitHubBadgesFromLinks(project.links as any)
-
const fixedTypeTag = project.tags.find((tag) => isFixedProjectTypeSlug(tag.slug))
const category = fixedTypeTag?.name || project.tags[0]?.name || 'AI Agent'
const categoryEn =
diff --git a/src/hooks/useProjects.ts b/src/hooks/useProjects.ts
index 52cadcf..5c37184 100644
--- a/src/hooks/useProjects.ts
+++ b/src/hooks/useProjects.ts
@@ -92,6 +92,9 @@ export type FixedProjectTypeFilter = {
export const PROJECT_SORT_OPTIONS = ['latest', 'stars_desc', 'stars_asc'] as const
export type ProjectSortOption = (typeof PROJECT_SORT_OPTIONS)[number]
+const DEFAULT_PAGE = 1
+const DEFAULT_LIMIT = 20
+const MAX_LIMIT = 100
export function normalizeProjectSort(value?: string): ProjectSortOption {
const candidate = String(value || '').trim()
@@ -101,6 +104,16 @@ export function normalizeProjectSort(value?: string): ProjectSortOption {
return 'latest'
}
+function normalizePositiveInteger(
+ value: number | undefined,
+ fallback: number,
+ max?: number
+): number {
+ const normalized = Number.isFinite(value) ? Math.trunc(value as number) : fallback
+ const bounded = normalized > 0 ? normalized : fallback
+ return typeof max === 'number' ? Math.min(bounded, max) : bounded
+}
+
export async function getProjects(options?: {
search?: string
tag?: string
@@ -130,10 +143,13 @@ export async function getProjects(options?: {
projectType,
sort = 'latest',
status = 'ACTIVE',
- page = 1,
- limit = 20,
+ page = DEFAULT_PAGE,
+ limit = DEFAULT_LIMIT,
} = options || {}
+ const safePage = normalizePositiveInteger(page, DEFAULT_PAGE)
+ const safeLimit = normalizePositiveInteger(limit, DEFAULT_LIMIT, MAX_LIMIT)
+
const where: Prisma.ProjectWhereInput = {
status,
}
@@ -251,8 +267,8 @@ export async function getProjects(options?: {
links: true,
},
orderBy,
- skip: (page - 1) * limit,
- take: limit,
+ skip: (safePage - 1) * safeLimit,
+ take: safeLimit,
}),
prisma.project.count({ where }),
])
@@ -273,10 +289,10 @@ export async function getProjects(options?: {
return {
projects: transformedProjects,
pagination: {
- page,
- limit,
+ page: safePage,
+ limit: safeLimit,
total,
- totalPages: Math.ceil(total / limit),
+ totalPages: Math.ceil(total / safeLimit),
},
}
}
diff --git a/src/lib/auth.test.ts b/src/lib/auth.test.ts
new file mode 100644
index 0000000..6318017
--- /dev/null
+++ b/src/lib/auth.test.ts
@@ -0,0 +1,25 @@
+import { describe, expect, it } from 'vitest'
+import { isValidApiKey } from './auth'
+
+describe('isValidApiKey', () => {
+ it('returns false when provided key is missing', () => {
+ expect(isValidApiKey(undefined, 'a'.repeat(32))).toBe(false)
+ expect(isValidApiKey(null, 'a'.repeat(32))).toBe(false)
+ })
+
+ it('returns false when expected key is missing', () => {
+ expect(isValidApiKey('a'.repeat(32), undefined)).toBe(false)
+ })
+
+ it('returns false when key lengths differ', () => {
+ expect(isValidApiKey('a'.repeat(31), 'a'.repeat(32))).toBe(false)
+ })
+
+ it('returns false when keys have same length but different value', () => {
+ expect(isValidApiKey('b'.repeat(32), 'a'.repeat(32))).toBe(false)
+ })
+
+ it('returns true when keys match exactly', () => {
+ expect(isValidApiKey('a'.repeat(32), 'a'.repeat(32))).toBe(true)
+ })
+})
diff --git a/src/lib/auth.ts b/src/lib/auth.ts
new file mode 100644
index 0000000..811555c
--- /dev/null
+++ b/src/lib/auth.ts
@@ -0,0 +1,23 @@
+import crypto from 'crypto'
+
+/**
+ * Compare API keys with constant-time semantics.
+ * Returns false when either key is missing or lengths differ.
+ */
+export function isValidApiKey(
+ providedApiKey: string | null | undefined,
+ expectedApiKey: string | undefined = process.env.WEBHOOK_API_KEY
+): boolean {
+ if (!providedApiKey || !expectedApiKey) {
+ return false
+ }
+
+ const providedBuffer = Buffer.from(providedApiKey)
+ const expectedBuffer = Buffer.from(expectedApiKey)
+
+ if (providedBuffer.length !== expectedBuffer.length) {
+ return false
+ }
+
+ return crypto.timingSafeEqual(providedBuffer, expectedBuffer)
+}
diff --git a/src/lib/github/badges.ts b/src/lib/github/badges.ts
index 81b1f05..7055bd6 100644
--- a/src/lib/github/badges.ts
+++ b/src/lib/github/badges.ts
@@ -1,4 +1,7 @@
-import { ExternalLink, LinkType } from '@prisma/client'
+type LinkLike = {
+ type: string
+ url: string
+}
/**
* 从 GitHub URL 提取 owner 和 repo
@@ -60,7 +63,7 @@ export function getAllGitHubBadgeUrls(owner: string, repo: string): {
* @returns stars 徽章 URL 或 null
*/
export function getGitHubBadgesFromLinks(
- links: ExternalLink[]
+ links: ReadonlyArray
): { stars: string | null } {
const githubLink = links.find(link => link.type === 'GITHUB')
diff --git a/src/lib/validations.ts b/src/lib/validations.ts
index 3f25677..e52d557 100644
--- a/src/lib/validations.ts
+++ b/src/lib/validations.ts
@@ -71,6 +71,19 @@ export const WebhookPayloadSchema = WebhookAuthSchema.extend({
export const TaskStatusEnum = z.enum(["PENDING", "IN_PROGRESS", "COMPLETED", "FAILED"]);
+const JsonValueSchema: z.ZodType = 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(
@@ -85,7 +98,7 @@ export const CreateDiscoveryTaskSchema = WebhookAuthSchema.extend({
export const UpdateDiscoveryTaskSchema = z.object({
apiKey: z.string().min(32),
status: TaskStatusEnum,
- explorationData: z.record(z.unknown()).optional(),
+ explorationData: JsonObjectSchema.optional(),
explorationSummary: z.string().max(1000).optional(),
errorMessage: z.string().max(2000).optional(),
});
@@ -118,6 +131,20 @@ export const CheckTaskDuplicatesSchema = WebhookAuthSchema.extend({
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(),
+});
+
// ================================
// Query Schemas
// ================================
@@ -146,6 +173,8 @@ export type CreateDiscoveryTask = z.infer;
export type UpdateDiscoveryTask = z.infer;
export type GetDiscoveryTasksQuery = z.infer;
export type BatchResetTasks = z.infer;
+export type AIEventInput = z.infer;
+export type JsonObject = z.infer;
// ================================
// Tags API Schemas