From 95079ec6522f86c0cc91de658166e24fd98fba97 Mon Sep 17 00:00:00 2001 From: Martin Porwoll Date: Sat, 14 Feb 2026 13:52:17 +0000 Subject: [PATCH] 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 --- .../(payload)/api/youtube/calendar/route.ts | 208 ++++++++++++++++++ 1 file changed, 208 insertions(+) create mode 100644 src/app/(payload)/api/youtube/calendar/route.ts diff --git a/src/app/(payload)/api/youtube/calendar/route.ts b/src/app/(payload)/api/youtube/calendar/route.ts new file mode 100644 index 0000000..f2f660a --- /dev/null +++ b/src/app/(payload)/api/youtube/calendar/route.ts @@ -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 + 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 { + 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 = { + 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() + for (const ch of channels.docs) { + const branding = (ch as Record).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 + 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 | 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 + 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 { + 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).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 }, + ) + } +}