feat(youtube): add content calendar API with conflict detection

GET endpoint returns FullCalendar-compatible events with schedule
conflict detection. PATCH endpoint supports drag & drop rescheduling
with published-video protection.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Martin Porwoll 2026-02-14 13:52:17 +00:00
parent 2466745e7f
commit 95079ec652

View file

@ -0,0 +1,208 @@
import { getPayload } from 'payload'
import config from '@payload-config'
import { NextRequest, NextResponse } from 'next/server'
import { detectConflicts, type CalendarEvent } from '@/lib/youtube/ConflictDetectionService'
function resolveTitle(title: unknown): string {
if (typeof title === 'string') return title
if (title && typeof title === 'object') {
const localized = title as Record<string, string>
return localized.de || localized.en || 'Untitled'
}
return 'Untitled'
}
function resolveRelation(field: unknown): { id: number; name?: string } {
if (field && typeof field === 'object' && 'id' in field) {
const obj = field as { id: number; name?: string }
return { id: obj.id, name: obj.name }
}
return { id: typeof field === 'number' ? field : 0 }
}
/**
* GET /api/youtube/calendar
*
* Returns calendar events for FullCalendar with conflict detection.
* Query params: channelId, start, end (ISO dates).
*/
export async function GET(req: NextRequest): Promise<NextResponse> {
try {
const payload = await getPayload({ config })
const { user } = await payload.auth({ headers: req.headers })
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { searchParams } = req.nextUrl
const channelId = searchParams.get('channelId')
const start = searchParams.get('start')
const end = searchParams.get('end')
if (!start || !end) {
return NextResponse.json({ error: 'start and end required' }, { status: 400 })
}
const where: Record<string, unknown> = {
scheduledPublishDate: {
greater_than_equal: start,
less_than_equal: end,
},
status: { not_equals: 'discarded' },
}
if (channelId && channelId !== 'all') {
where.channel = { equals: parseInt(channelId) }
}
const [videos, channels] = await Promise.all([
payload.find({
collection: 'youtube-content',
where,
limit: 200,
depth: 1,
sort: 'scheduledPublishDate',
}),
payload.find({
collection: 'youtube-channels',
where: { status: { equals: 'active' } },
limit: 50,
depth: 0,
}),
])
// Build channel color lookup
const channelColorMap = new Map<number, string>()
for (const ch of channels.docs) {
const branding = (ch as Record<string, unknown>).branding as
| { primaryColor?: string }
| undefined
channelColorMap.set(ch.id as number, branding?.primaryColor || '#3788d8')
}
// Build CalendarEvent array for conflict detection
const calendarEvents: CalendarEvent[] = videos.docs.map((v) => {
const doc = v as Record<string, unknown>
const channel = resolveRelation(doc.channel)
const series = resolveRelation(doc.series)
return {
id: doc.id as number,
channelId: channel.id,
scheduledDate: doc.scheduledPublishDate as string,
contentType: (doc.format as CalendarEvent['contentType']) || 'longform',
seriesId: series.id || undefined,
}
})
// Get schedule config from first channel
const defaultSchedule = { longformPerWeek: 1, shortsPerWeek: 4 }
const firstChannel = channels.docs[0] as Record<string, unknown> | undefined
const publishingSchedule = firstChannel?.publishingSchedule as
| { longformPerWeek?: number; shortsPerWeek?: number }
| undefined
const scheduleConfig = {
longformPerWeek: publishingSchedule?.longformPerWeek ?? defaultSchedule.longformPerWeek,
shortsPerWeek: publishingSchedule?.shortsPerWeek ?? defaultSchedule.shortsPerWeek,
}
const conflicts = detectConflicts(calendarEvents, scheduleConfig)
const conflictEventIds = new Set(conflicts.flatMap((c) => c.eventIds))
// Format for FullCalendar
const events = videos.docs.map((v) => {
const doc = v as Record<string, unknown>
const channel = resolveRelation(doc.channel)
const series = resolveRelation(doc.series)
return {
id: String(doc.id),
title: resolveTitle(doc.title),
start: doc.scheduledPublishDate,
color: channelColorMap.get(channel.id) || '#3788d8',
extendedProps: {
status: doc.status,
contentType: doc.format || 'longform',
channelId: channel.id,
channelName: channel.name,
seriesName: series.name,
hasConflict: conflictEventIds.has(doc.id as number),
assignedTo: doc.assignedTo,
},
}
})
return NextResponse.json({
events,
conflicts,
meta: {
totalEvents: events.length,
conflictsCount: conflicts.length,
},
})
} catch (error) {
console.error('[Calendar API] GET error:', error)
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Internal Server Error' },
{ status: 500 },
)
}
}
/**
* PATCH /api/youtube/calendar
*
* Reschedules a content item via drag & drop.
* Body: { contentId: number, newDate: string (ISO) }
*/
export async function PATCH(req: NextRequest): Promise<NextResponse> {
try {
const payload = await getPayload({ config })
const { user } = await payload.auth({ headers: req.headers })
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const body = (await req.json()) as { contentId?: number; newDate?: string }
const { contentId, newDate } = body
if (!contentId || !newDate) {
return NextResponse.json({ error: 'contentId and newDate required' }, { status: 400 })
}
const content = await payload.findByID({
collection: 'youtube-content',
id: contentId,
depth: 0,
})
const status = (content as Record<string, unknown>).status as string
if (status === 'published' || status === 'tracked') {
return NextResponse.json(
{ error: 'Veröffentlichte Videos können nicht verschoben werden' },
{ status: 400 },
)
}
await payload.update({
collection: 'youtube-content',
id: contentId,
data: {
scheduledPublishDate: newDate,
},
})
return NextResponse.json({
success: true,
contentId,
newDate,
})
} catch (error) {
console.error('[Calendar API] PATCH error:', error)
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Internal Server Error' },
{ status: 500 },
)
}
}