Files
agent-park/src/app/api/signals/route.ts
T

282 lines
7.5 KiB
TypeScript

import { NextRequest, NextResponse } from 'next/server'
import type { Prisma } from '@prisma/client'
import { ZodError } from 'zod'
import { prisma } from '@/lib/prisma'
import {
SignalQuerySchema,
type SignalQuery,
type SignalSectionStyle,
type SignalSource,
} from '@/lib/validations'
interface SignalSectionView {
id: string
title: string
style: SignalSectionStyle
items: string[]
}
interface SignalView {
id: string
title: string
summary: string
sections: SignalSectionView[]
source: SignalSource
sourceUrl: string
engagement: number
publishedAt: string
topic: string
tags: string[]
}
const VALID_SECTION_STYLES: SignalSectionStyle[] = ['focus', 'debate', 'evidence', 'action', 'risk']
function isSignalSource(value: string): value is SignalSource {
return (
value === 'hacker_news' ||
value === 'github' ||
value === 'arxiv' ||
value === 'hugging_face' ||
value === 'reddit' ||
value === 'product_hunt'
)
}
function pickLocalizedText(locale: SignalQuery['locale'], primary: string, secondary?: string | null): string {
if (locale === 'en' && secondary && secondary.trim().length > 0) {
return secondary
}
return primary
}
function parseTags(value: unknown): string[] {
if (!Array.isArray(value)) {
return []
}
return value.filter((item): item is string => typeof item === 'string' && item.trim().length > 0)
}
function parseSections(value: unknown, locale: SignalQuery['locale'], fallbackSummary: string): SignalSectionView[] {
if (!Array.isArray(value)) {
return [
{
id: 'focus',
title: locale === 'en' ? 'Core Idea' : '核心观点',
style: 'focus',
items: [fallbackSummary],
},
]
}
const parsed = value
.map((rawSection) => {
if (!rawSection || typeof rawSection !== 'object') {
return null
}
const section = rawSection as {
id?: unknown
title?: unknown
titleEn?: unknown
style?: unknown
items?: unknown
itemsEn?: unknown
}
const id = typeof section.id === 'string' && section.id.trim().length > 0 ? section.id : 'section'
const style =
typeof section.style === 'string' && VALID_SECTION_STYLES.includes(section.style as SignalSectionStyle)
? (section.style as SignalSectionStyle)
: 'focus'
const titleZh = typeof section.title === 'string' && section.title.trim().length > 0 ? section.title : '核心观点'
const titleEn = typeof section.titleEn === 'string' && section.titleEn.trim().length > 0 ? section.titleEn : undefined
const itemsZh = Array.isArray(section.items)
? section.items.filter((item): item is string => typeof item === 'string' && item.trim().length > 0)
: []
const itemsEn = Array.isArray(section.itemsEn)
? section.itemsEn.filter((item): item is string => typeof item === 'string' && item.trim().length > 0)
: []
const items = locale === 'en' && itemsEn.length > 0 ? itemsEn : itemsZh
if (items.length === 0) {
return null
}
return {
id,
title: locale === 'en' && titleEn ? titleEn : titleZh,
style,
items,
}
})
.filter((section): section is SignalSectionView => section !== null)
if (parsed.length > 0) {
return parsed
}
return [
{
id: 'focus',
title: locale === 'en' ? 'Core Idea' : '核心观点',
style: 'focus',
items: [fallbackSummary],
},
]
}
function toSignalView(row: {
id: string
source: string
sourceUrl: string
title: string
titleEn: string | null
summary: string
summaryEn: string | null
topic: string | null
topicEn: string | null
tags: Prisma.JsonValue
sections: Prisma.JsonValue
engagement: number
publishedAt: Date
}, locale: SignalQuery['locale']): SignalView | null {
if (!isSignalSource(row.source)) {
return null
}
const title = pickLocalizedText(locale, row.title, row.titleEn)
const summary = pickLocalizedText(locale, row.summary, row.summaryEn)
return {
id: row.id,
title,
summary,
sections: parseSections(row.sections, locale, summary),
source: row.source,
sourceUrl: row.sourceUrl,
engagement: row.engagement,
publishedAt: row.publishedAt.toISOString(),
topic: pickLocalizedText(locale, row.topic || '', row.topicEn || undefined),
tags: parseTags(row.tags),
}
}
export async function GET(request: NextRequest) {
try {
const { searchParams } = request.nextUrl
const parsed = SignalQuerySchema.parse({
q: searchParams.get('q') || undefined,
source: searchParams.get('source') || undefined,
sort: searchParams.get('sort') || undefined,
cursor: searchParams.get('cursor') || undefined,
limit: searchParams.get('limit') || undefined,
locale: searchParams.get('locale') || undefined,
})
if (parsed.cursor) {
const cursorExists = await prisma.signal.findUnique({
where: { id: parsed.cursor },
select: { id: true },
})
if (!cursorExists) {
return NextResponse.json(
{
error: 'Invalid cursor',
message: 'Cursor not found. Please refresh and retry.',
},
{ status: 400 }
)
}
}
const where: Prisma.SignalWhereInput = {
isActive: true,
...(parsed.source ? { source: parsed.source } : {}),
...(parsed.q
? {
OR: [
{ title: { contains: parsed.q, mode: 'insensitive' } },
{ titleEn: { contains: parsed.q, mode: 'insensitive' } },
{ summary: { contains: parsed.q, mode: 'insensitive' } },
{ summaryEn: { contains: parsed.q, mode: 'insensitive' } },
{ topic: { contains: parsed.q, mode: 'insensitive' } },
{ topicEn: { contains: parsed.q, mode: 'insensitive' } },
],
}
: {}),
}
const orderBy: Prisma.SignalOrderByWithRelationInput[] =
parsed.sort === 'hot'
? [{ engagement: 'desc' }, { publishedAt: 'desc' }, { id: 'desc' }]
: [{ publishedAt: 'desc' }, { id: 'desc' }]
const rows = await prisma.signal.findMany({
where,
orderBy,
take: parsed.limit + 1,
...(parsed.cursor
? {
cursor: { id: parsed.cursor },
skip: 1,
}
: {}),
select: {
id: true,
source: true,
sourceUrl: true,
title: true,
titleEn: true,
summary: true,
summaryEn: true,
topic: true,
topicEn: true,
tags: true,
sections: true,
engagement: true,
publishedAt: true,
},
})
const hasMore = rows.length > parsed.limit
const pageRows = hasMore ? rows.slice(0, parsed.limit) : rows
const nextCursor = hasMore ? pageRows[pageRows.length - 1]?.id || null : null
const items = pageRows
.map((row) => toSignalView(row, parsed.locale))
.filter((item): item is SignalView => item !== null)
return NextResponse.json({
items,
nextCursor,
hasMore,
})
} catch (error) {
if (error instanceof ZodError) {
return NextResponse.json(
{
error: 'Invalid query parameters',
details: error.errors,
},
{ status: 400 }
)
}
console.error('[Signals API] GET error:', error)
return NextResponse.json(
{
error: 'Failed to fetch signals',
message: error instanceof Error ? error.message : 'Unknown error',
},
{ status: 500 }
)
}
}