mirror of
https://github.com/complexcaresolutions/cms.c2sgmbh.git
synced 2026-03-17 16:14:12 +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