282 lines
7.5 KiB
TypeScript
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 }
|
|
)
|
|
}
|
|
}
|