mirror of
https://github.com/complexcaresolutions/cms.c2sgmbh.git
synced 2026-03-17 20:54:11 +00:00
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:
parent
2466745e7f
commit
95079ec652
1 changed files with 208 additions and 0 deletions
208
src/app/(payload)/api/youtube/calendar/route.ts
Normal file
208
src/app/(payload)/api/youtube/calendar/route.ts
Normal 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 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue