22-task implementation plan covering 4 phases: - Phase 1: YouTube API Integration (10 tasks) - Phase 2: Analytics Dashboard (3 tasks) - Phase 3: Workflow Automation (3 tasks) - Phase 4: Content Calendar (6 tasks) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
94 KiB
YouTube Operations Hub Extensions - Implementation Plan
For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
Goal: Implement 4 extensions for the YouTube Operations Hub: YouTube API Integration (metrics sync, video upload, enhanced comment import), Analytics Dashboard (comparison, trends, ROI), Workflow Automation (auto-status, deadline reminders, capacity planning), and Content Calendar (FullCalendar, drag & drop, conflict detection).
Architecture: Extends the existing YouTube Operations Hub by adding real YouTube Data API v3 and Analytics API v2 calls to populate existing empty performance.* fields, a BullMQ-based video upload pipeline, enhanced comment threading, server-side analytics computations exposed via new API tabs, cron-based deadline monitoring, and a FullCalendar-based content calendar registered as a Payload admin custom view.
Tech Stack: Payload CMS 3.76.1, Next.js 16, googleapis v170, BullMQ, Recharts 3.6.0, FullCalendar 6.x, date-fns 4.1.0, TypeScript
Design Document: docs/plans/2026-02-14-youtube-operations-hub-extensions-design.md
Phase 1: YouTube API Integration
Task 1: Add OAuth Scopes for Upload and Analytics
Files:
- Modify:
src/lib/integrations/youtube/oauth.ts:9-13
Step 1: Write the failing test
Create: tests/unit/youtube/oauth-scopes.test.ts
import { describe, it, expect, vi } from 'vitest'
// Mock googleapis before import
vi.mock('googleapis', () => ({
google: {
auth: {
OAuth2: vi.fn().mockImplementation(() => ({
generateAuthUrl: vi.fn(({ scope }) => `https://accounts.google.com/o/oauth2/v2/auth?scope=${encodeURIComponent(scope.join(' '))}`),
})),
},
},
}))
describe('YouTube OAuth Scopes', () => {
it('should include youtube.upload scope', async () => {
// Reset module cache to get fresh import
vi.resetModules()
const { getAuthUrl } = await import('@/lib/integrations/youtube/oauth')
const url = getAuthUrl()
expect(url).toContain('youtube.upload')
})
it('should include yt-analytics.readonly scope', async () => {
vi.resetModules()
const { getAuthUrl } = await import('@/lib/integrations/youtube/oauth')
const url = getAuthUrl()
expect(url).toContain('yt-analytics.readonly')
})
})
Step 2: Run test to verify it fails
Run: pnpm vitest run tests/unit/youtube/oauth-scopes.test.ts
Expected: FAIL — current scopes don't include youtube.upload or yt-analytics.readonly
Step 3: Add the new scopes
In src/lib/integrations/youtube/oauth.ts, replace the SCOPES array (lines 9-13):
const SCOPES = [
'https://www.googleapis.com/auth/youtube.readonly',
'https://www.googleapis.com/auth/youtube.force-ssl',
'https://www.googleapis.com/auth/youtube',
'https://www.googleapis.com/auth/youtube.upload',
'https://www.googleapis.com/auth/yt-analytics.readonly',
]
Step 4: Run test to verify it passes
Run: pnpm vitest run tests/unit/youtube/oauth-scopes.test.ts
Expected: PASS
Step 5: Commit
git add src/lib/integrations/youtube/oauth.ts tests/unit/youtube/oauth-scopes.test.ts
git commit -m "feat(youtube): add upload and analytics OAuth scopes"
Task 2: Add Video Statistics Methods to YouTubeClient
Files:
- Modify:
src/lib/integrations/youtube/YouTubeClient.ts - Test:
tests/unit/youtube/youtube-client-stats.test.ts
Step 1: Write the failing test
Create: tests/unit/youtube/youtube-client-stats.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest'
// Mock googleapis
const mockVideosList = vi.fn()
const mockChannelsList = vi.fn()
vi.mock('googleapis', () => ({
google: {
auth: {
OAuth2: vi.fn().mockImplementation(() => ({
setCredentials: vi.fn(),
})),
},
youtube: vi.fn(() => ({
videos: { list: mockVideosList },
channels: { list: mockChannelsList },
commentThreads: { list: vi.fn() },
comments: { insert: vi.fn(), setModerationStatus: vi.fn(), delete: vi.fn() },
})),
},
}))
import { YouTubeClient } from '@/lib/integrations/youtube/YouTubeClient'
describe('YouTubeClient - Video Statistics', () => {
let client: YouTubeClient
beforeEach(() => {
vi.clearAllMocks()
client = new YouTubeClient(
{
clientId: 'test-id',
clientSecret: 'test-secret',
accessToken: 'test-token',
refreshToken: 'test-refresh',
},
{} as any // mock payload
)
})
it('should fetch video statistics for multiple IDs', async () => {
mockVideosList.mockResolvedValue({
data: {
items: [
{
id: 'vid1',
statistics: { viewCount: '1000', likeCount: '50', commentCount: '10' },
},
{
id: 'vid2',
statistics: { viewCount: '2000', likeCount: '100', commentCount: '20' },
},
],
},
})
const result = await client.getVideoStatistics(['vid1', 'vid2'])
expect(mockVideosList).toHaveBeenCalledWith({
part: ['statistics'],
id: ['vid1', 'vid2'],
maxResults: 50,
})
expect(result).toHaveLength(2)
expect(result[0]).toEqual({
id: 'vid1',
views: 1000,
likes: 50,
comments: 10,
})
})
it('should return empty array for no video IDs', async () => {
const result = await client.getVideoStatistics([])
expect(result).toEqual([])
expect(mockVideosList).not.toHaveBeenCalled()
})
})
Step 2: Run test to verify it fails
Run: pnpm vitest run tests/unit/youtube/youtube-client-stats.test.ts
Expected: FAIL — client.getVideoStatistics is not a function
Step 3: Add getVideoStatistics method
Add to src/lib/integrations/youtube/YouTubeClient.ts before the closing } of the class (before line 229):
/**
* Video-Statistiken für mehrere Videos abrufen (Batch)
* YouTube API erlaubt max. 50 IDs pro Request
*/
async getVideoStatistics(videoIds: string[]): Promise<Array<{
id: string
views: number
likes: number
comments: number
}>> {
if (videoIds.length === 0) return []
try {
const response = await this.youtube.videos.list({
part: ['statistics'],
id: videoIds,
maxResults: 50,
})
return (response.data.items || []).map((item) => ({
id: item.id!,
views: parseInt(item.statistics?.viewCount || '0', 10),
likes: parseInt(item.statistics?.likeCount || '0', 10),
comments: parseInt(item.statistics?.commentCount || '0', 10),
}))
} catch (error) {
console.error('Error fetching video statistics:', error)
throw error
}
}
Step 4: Run test to verify it passes
Run: pnpm vitest run tests/unit/youtube/youtube-client-stats.test.ts
Expected: PASS
Step 5: Commit
git add src/lib/integrations/youtube/YouTubeClient.ts tests/unit/youtube/youtube-client-stats.test.ts
git commit -m "feat(youtube): add getVideoStatistics to YouTubeClient"
Task 3: VideoMetricsSyncService
Files:
- Create:
src/lib/integrations/youtube/VideoMetricsSyncService.ts - Test:
tests/unit/youtube/video-metrics-sync.test.ts
Step 1: Write the failing test
Create: tests/unit/youtube/video-metrics-sync.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest'
// Mock YouTubeClient
vi.mock('@/lib/integrations/youtube/YouTubeClient', () => ({
YouTubeClient: vi.fn().mockImplementation(() => ({
getVideoStatistics: vi.fn().mockResolvedValue([
{ id: 'yt-vid-1', views: 5000, likes: 200, comments: 30 },
]),
})),
}))
import { VideoMetricsSyncService } from '@/lib/integrations/youtube/VideoMetricsSyncService'
describe('VideoMetricsSyncService', () => {
let mockPayload: any
beforeEach(() => {
vi.clearAllMocks()
mockPayload = {
find: vi.fn(),
update: vi.fn(),
findByID: vi.fn(),
}
})
it('should sync video metrics for published videos', async () => {
// Mock: find published videos with youtube videoId
mockPayload.find.mockImplementation(({ collection }: any) => {
if (collection === 'youtube-content') {
return {
docs: [
{ id: 1, youtube: { videoId: 'yt-vid-1' }, status: 'published' },
],
totalDocs: 1,
}
}
if (collection === 'social-accounts') {
return {
docs: [{
id: 10,
platform: { slug: 'youtube' },
credentials: { accessToken: 'token', refreshToken: 'refresh' },
externalId: 'UC123',
}],
}
}
return { docs: [] }
})
mockPayload.update.mockResolvedValue({})
const service = new VideoMetricsSyncService(mockPayload)
const result = await service.syncVideoMetrics({ channelId: 1 })
expect(result.success).toBe(true)
expect(result.syncedCount).toBe(1)
expect(mockPayload.update).toHaveBeenCalledWith(expect.objectContaining({
collection: 'youtube-content',
id: 1,
data: expect.objectContaining({
performance: expect.objectContaining({
views: 5000,
likes: 200,
comments: 30,
}),
}),
}))
})
it('should skip videos without youtube videoId', async () => {
mockPayload.find.mockImplementation(({ collection }: any) => {
if (collection === 'youtube-content') {
return {
docs: [
{ id: 1, youtube: { videoId: null }, status: 'published' },
],
totalDocs: 1,
}
}
if (collection === 'social-accounts') {
return {
docs: [{
id: 10,
platform: { slug: 'youtube' },
credentials: { accessToken: 'token', refreshToken: 'refresh' },
}],
}
}
return { docs: [] }
})
const service = new VideoMetricsSyncService(mockPayload)
const result = await service.syncVideoMetrics({ channelId: 1 })
expect(result.syncedCount).toBe(0)
expect(mockPayload.update).not.toHaveBeenCalled()
})
})
Step 2: Run test to verify it fails
Run: pnpm vitest run tests/unit/youtube/video-metrics-sync.test.ts
Expected: FAIL — module not found
Step 3: Implement VideoMetricsSyncService
Create: src/lib/integrations/youtube/VideoMetricsSyncService.ts
// src/lib/integrations/youtube/VideoMetricsSyncService.ts
import type { Payload } from 'payload'
import { YouTubeClient } from './YouTubeClient'
interface SyncOptions {
channelId: number
socialAccountId?: number
}
interface SyncResult {
success: boolean
syncedCount: number
errors: string[]
syncedAt: Date
}
export class VideoMetricsSyncService {
private payload: Payload
constructor(payload: Payload) {
this.payload = payload
}
/**
* Synchronisiert Video-Metriken von YouTube für einen Kanal
*/
async syncVideoMetrics(options: SyncOptions): Promise<SyncResult> {
const result: SyncResult = {
success: false,
syncedCount: 0,
errors: [],
syncedAt: new Date(),
}
try {
// 1. YouTube-Client erstellen
const client = await this.getYouTubeClient(options)
if (!client) {
result.errors.push('Kein YouTube-Account gefunden oder keine gültigen Credentials')
return result
}
// 2. Veröffentlichte Videos mit YouTube-Video-ID laden
const videos = await this.payload.find({
collection: 'youtube-content',
where: {
channel: { equals: options.channelId },
status: { in: ['published', 'tracked'] },
},
limit: 500,
depth: 0,
})
// 3. Videos mit YouTube-ID filtern
const videosWithYtId = videos.docs.filter(
(doc: any) => doc.youtube?.videoId
)
if (videosWithYtId.length === 0) {
result.success = true
return result
}
// 4. In Batches von 50 verarbeiten (YouTube API Limit)
const batches = this.chunkArray(
videosWithYtId.map((v: any) => ({ id: v.id, ytVideoId: v.youtube.videoId })),
50
)
for (const batch of batches) {
try {
const ytIds = batch.map((v) => v.ytVideoId)
const stats = await client.getVideoStatistics(ytIds)
// Stats den Videos zuordnen und updaten
for (const stat of stats) {
const video = batch.find((v) => v.ytVideoId === stat.id)
if (!video) continue
await this.payload.update({
collection: 'youtube-content',
id: video.id,
data: {
performance: {
views: stat.views,
likes: stat.likes,
comments: stat.comments,
lastSyncedAt: new Date().toISOString(),
},
},
})
result.syncedCount++
}
} catch (batchError) {
result.errors.push(`Batch-Fehler: ${batchError}`)
}
}
result.success = true
} catch (error) {
result.errors.push(`Sync-Fehler: ${error}`)
}
return result
}
/**
* YouTubeClient aus SocialAccount erstellen
*/
private async getYouTubeClient(options: SyncOptions): Promise<YouTubeClient | null> {
const accounts = await this.payload.find({
collection: 'social-accounts',
where: {
...(options.socialAccountId
? { id: { equals: options.socialAccountId } }
: {}),
},
depth: 2,
limit: 10,
})
const ytAccount = accounts.docs.find((acc: any) => {
const platform = acc.platform as { slug?: string }
return platform?.slug === 'youtube'
})
if (!ytAccount) return null
const credentials = ytAccount.credentials as {
accessToken?: string
refreshToken?: string
}
if (!credentials?.accessToken || !credentials?.refreshToken) return null
return new YouTubeClient(
{
clientId: process.env.YOUTUBE_CLIENT_ID!,
clientSecret: process.env.YOUTUBE_CLIENT_SECRET!,
accessToken: credentials.accessToken,
refreshToken: credentials.refreshToken,
},
this.payload,
)
}
private chunkArray<T>(arr: T[], size: number): T[][] {
const chunks: T[][] = []
for (let i = 0; i < arr.length; i += size) {
chunks.push(arr.slice(i, i + size))
}
return chunks
}
}
export type { SyncOptions, SyncResult }
Step 4: Run test to verify it passes
Run: pnpm vitest run tests/unit/youtube/video-metrics-sync.test.ts
Expected: PASS
Step 5: Commit
git add src/lib/integrations/youtube/VideoMetricsSyncService.ts tests/unit/youtube/video-metrics-sync.test.ts
git commit -m "feat(youtube): add VideoMetricsSyncService for batch metrics sync"
Task 4: ChannelMetricsSyncService
Files:
- Create:
src/lib/integrations/youtube/ChannelMetricsSyncService.ts - Test:
tests/unit/youtube/channel-metrics-sync.test.ts
Step 1: Write the failing test
Create: tests/unit/youtube/channel-metrics-sync.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest'
vi.mock('@/lib/integrations/youtube/YouTubeClient', () => ({
YouTubeClient: vi.fn().mockImplementation(() => ({
getChannelStats: vi.fn().mockResolvedValue({
subscriberCount: 15000,
videoCount: 120,
viewCount: 500000,
}),
})),
}))
import { ChannelMetricsSyncService } from '@/lib/integrations/youtube/ChannelMetricsSyncService'
describe('ChannelMetricsSyncService', () => {
let mockPayload: any
beforeEach(() => {
vi.clearAllMocks()
mockPayload = {
find: vi.fn(),
update: vi.fn(),
}
})
it('should sync channel metrics and update YouTubeChannels', async () => {
mockPayload.find.mockImplementation(({ collection }: any) => {
if (collection === 'youtube-channels') {
return {
docs: [{ id: 1, youtubeChannelId: 'UC123', status: 'active' }],
}
}
if (collection === 'social-accounts') {
return {
docs: [{
id: 10,
platform: { slug: 'youtube' },
credentials: { accessToken: 'tok', refreshToken: 'ref' },
}],
}
}
return { docs: [] }
})
mockPayload.update.mockResolvedValue({})
const service = new ChannelMetricsSyncService(mockPayload)
const result = await service.syncAllChannels()
expect(result.success).toBe(true)
expect(result.channelsSynced).toBe(1)
expect(mockPayload.update).toHaveBeenCalledWith(expect.objectContaining({
collection: 'youtube-channels',
id: 1,
data: expect.objectContaining({
currentMetrics: expect.objectContaining({
subscriberCount: 15000,
totalViews: 500000,
videoCount: 120,
}),
}),
}))
})
})
Step 2: Run test to verify it fails
Run: pnpm vitest run tests/unit/youtube/channel-metrics-sync.test.ts
Expected: FAIL
Step 3: Implement ChannelMetricsSyncService
Create: src/lib/integrations/youtube/ChannelMetricsSyncService.ts
// src/lib/integrations/youtube/ChannelMetricsSyncService.ts
import type { Payload } from 'payload'
import { YouTubeClient } from './YouTubeClient'
interface ChannelSyncResult {
success: boolean
channelsSynced: number
errors: string[]
}
export class ChannelMetricsSyncService {
private payload: Payload
constructor(payload: Payload) {
this.payload = payload
}
async syncAllChannels(): Promise<ChannelSyncResult> {
const result: ChannelSyncResult = {
success: false,
channelsSynced: 0,
errors: [],
}
try {
// 1. Alle aktiven Kanäle laden
const channels = await this.payload.find({
collection: 'youtube-channels',
where: { status: { equals: 'active' } },
limit: 50,
depth: 0,
})
// 2. YouTube-Client holen
const client = await this.getYouTubeClient()
if (!client) {
result.errors.push('Kein YouTube-Account mit gültigen Credentials gefunden')
return result
}
// 3. Für jeden Kanal Statistiken abrufen
for (const channel of channels.docs) {
const ytChannelId = (channel as any).youtubeChannelId
if (!ytChannelId) {
result.errors.push(`Kanal ${(channel as any).id}: Keine YouTube Channel ID`)
continue
}
try {
const stats = await client.getChannelStats(ytChannelId)
await this.payload.update({
collection: 'youtube-channels',
id: (channel as any).id,
data: {
currentMetrics: {
subscriberCount: stats.subscriberCount,
totalViews: stats.viewCount,
videoCount: stats.videoCount,
lastSyncedAt: new Date().toISOString(),
},
},
})
result.channelsSynced++
} catch (error) {
result.errors.push(`Kanal ${ytChannelId}: ${error}`)
}
}
result.success = true
} catch (error) {
result.errors.push(`Sync-Fehler: ${error}`)
}
return result
}
private async getYouTubeClient(): Promise<YouTubeClient | null> {
const accounts = await this.payload.find({
collection: 'social-accounts',
depth: 2,
limit: 10,
})
const ytAccount = accounts.docs.find((acc: any) => {
const platform = acc.platform as { slug?: string }
return platform?.slug === 'youtube'
})
if (!ytAccount) return null
const credentials = (ytAccount as any).credentials as {
accessToken?: string
refreshToken?: string
}
if (!credentials?.accessToken || !credentials?.refreshToken) return null
return new YouTubeClient(
{
clientId: process.env.YOUTUBE_CLIENT_ID!,
clientSecret: process.env.YOUTUBE_CLIENT_SECRET!,
accessToken: credentials.accessToken,
refreshToken: credentials.refreshToken,
},
this.payload,
)
}
}
Step 4: Run test to verify it passes
Run: pnpm vitest run tests/unit/youtube/channel-metrics-sync.test.ts
Expected: PASS
Step 5: Commit
git add src/lib/integrations/youtube/ChannelMetricsSyncService.ts tests/unit/youtube/channel-metrics-sync.test.ts
git commit -m "feat(youtube): add ChannelMetricsSyncService"
Task 5: Metrics Sync Cron Endpoints
Files:
- Create:
src/app/(payload)/api/cron/youtube-metrics-sync/route.ts - Create:
src/app/(payload)/api/cron/youtube-channel-sync/route.ts - Modify:
vercel.json
Step 1: Create the video metrics sync cron endpoint
Create: src/app/(payload)/api/cron/youtube-metrics-sync/route.ts
Follow the exact pattern from src/app/(payload)/api/cron/community-sync/route.ts:
// src/app/(payload)/api/cron/youtube-metrics-sync/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { getPayload } from 'payload'
import config from '@payload-config'
import { VideoMetricsSyncService } from '@/lib/integrations/youtube/VideoMetricsSyncService'
const CRON_SECRET = process.env.CRON_SECRET
export async function GET(request: NextRequest) {
// Auth prüfen
if (CRON_SECRET) {
const authHeader = request.headers.get('authorization')
if (authHeader !== `Bearer ${CRON_SECRET}`) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
}
try {
const payload = await getPayload({ config })
const service = new VideoMetricsSyncService(payload)
// Alle aktiven Kanäle finden
const channels = await payload.find({
collection: 'youtube-channels',
where: { status: { equals: 'active' } },
limit: 50,
depth: 0,
})
const results = []
for (const channel of channels.docs) {
const result = await service.syncVideoMetrics({
channelId: (channel as any).id,
})
results.push({
channelId: (channel as any).id,
channelName: (channel as any).name,
...result,
})
}
const totalSynced = results.reduce((sum, r) => sum + r.syncedCount, 0)
const allErrors = results.flatMap((r) => r.errors)
return NextResponse.json({
success: true,
totalSynced,
channels: results.length,
errors: allErrors,
syncedAt: new Date().toISOString(),
})
} catch (error) {
console.error('[Cron] youtube-metrics-sync error:', error)
return NextResponse.json(
{ error: 'Internal Server Error' },
{ status: 500 }
)
}
}
Step 2: Create the channel metrics sync cron endpoint
Create: src/app/(payload)/api/cron/youtube-channel-sync/route.ts
// src/app/(payload)/api/cron/youtube-channel-sync/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { getPayload } from 'payload'
import config from '@payload-config'
import { ChannelMetricsSyncService } from '@/lib/integrations/youtube/ChannelMetricsSyncService'
const CRON_SECRET = process.env.CRON_SECRET
export async function GET(request: NextRequest) {
if (CRON_SECRET) {
const authHeader = request.headers.get('authorization')
if (authHeader !== `Bearer ${CRON_SECRET}`) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
}
try {
const payload = await getPayload({ config })
const service = new ChannelMetricsSyncService(payload)
const result = await service.syncAllChannels()
return NextResponse.json({
...result,
syncedAt: new Date().toISOString(),
})
} catch (error) {
console.error('[Cron] youtube-channel-sync error:', error)
return NextResponse.json(
{ error: 'Internal Server Error' },
{ status: 500 }
)
}
}
Step 3: Add cron entries to vercel.json
In vercel.json, add to the crons array:
{
"path": "/api/cron/youtube-metrics-sync",
"schedule": "0 */6 * * *"
},
{
"path": "/api/cron/youtube-channel-sync",
"schedule": "0 4 * * *"
}
Step 4: Commit
git add src/app/\(payload\)/api/cron/youtube-metrics-sync/route.ts \
src/app/\(payload\)/api/cron/youtube-channel-sync/route.ts \
vercel.json
git commit -m "feat(youtube): add metrics sync cron endpoints"
Task 6: Enhanced Comment Import with Reply Threads
Files:
- Modify:
src/lib/integrations/youtube/YouTubeClient.ts(addgetCommentReplies) - Modify:
src/lib/integrations/youtube/CommentsSyncService.ts - Test:
tests/unit/youtube/comment-replies.test.ts
Step 1: Write the failing test
Create: tests/unit/youtube/comment-replies.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest'
const mockCommentsList = vi.fn()
vi.mock('googleapis', () => ({
google: {
auth: {
OAuth2: vi.fn().mockImplementation(() => ({
setCredentials: vi.fn(),
})),
},
youtube: vi.fn(() => ({
videos: { list: vi.fn() },
channels: { list: vi.fn() },
commentThreads: { list: vi.fn() },
comments: {
list: mockCommentsList,
insert: vi.fn(),
setModerationStatus: vi.fn(),
delete: vi.fn(),
},
})),
},
}))
import { YouTubeClient } from '@/lib/integrations/youtube/YouTubeClient'
describe('YouTubeClient - Comment Replies', () => {
let client: YouTubeClient
beforeEach(() => {
vi.clearAllMocks()
client = new YouTubeClient(
{ clientId: 'id', clientSecret: 'secret', accessToken: 'tok', refreshToken: 'ref' },
{} as any,
)
})
it('should fetch replies for a comment thread', async () => {
mockCommentsList.mockResolvedValue({
data: {
items: [
{
id: 'reply-1',
snippet: {
parentId: 'parent-1',
textOriginal: 'Great reply!',
authorDisplayName: 'User2',
authorChannelId: { value: 'UC456' },
likeCount: 3,
publishedAt: '2026-01-15T10:00:00Z',
},
},
],
nextPageToken: undefined,
},
})
const result = await client.getCommentReplies('parent-1')
expect(mockCommentsList).toHaveBeenCalledWith({
part: ['snippet'],
parentId: 'parent-1',
maxResults: 100,
textFormat: 'plainText',
})
expect(result.replies).toHaveLength(1)
expect(result.replies[0].snippet.textOriginal).toBe('Great reply!')
})
})
Step 2: Run test to verify it fails
Run: pnpm vitest run tests/unit/youtube/comment-replies.test.ts
Expected: FAIL — getCommentReplies is not a function
Step 3: Add getCommentReplies to YouTubeClient
Add to src/lib/integrations/youtube/YouTubeClient.ts (after getVideoStatistics):
/**
* Antworten auf einen Kommentar-Thread abrufen
*/
async getCommentReplies(
parentCommentId: string,
maxResults: number = 100
): Promise<{
replies: Array<{
id: string
snippet: {
parentId: string
textOriginal: string
textDisplay: string
authorDisplayName: string
authorProfileImageUrl: string
authorChannelUrl: string
authorChannelId: { value: string }
likeCount: number
publishedAt: string
updatedAt: string
}
}>
nextPageToken?: string
}> {
try {
const response = await this.youtube.comments.list({
part: ['snippet'],
parentId: parentCommentId,
maxResults,
textFormat: 'plainText',
})
return {
replies: (response.data.items || []) as any[],
nextPageToken: response.data.nextPageToken || undefined,
}
} catch (error) {
console.error('Error fetching comment replies:', error)
throw error
}
}
Step 4: Run test to verify it passes
Run: pnpm vitest run tests/unit/youtube/comment-replies.test.ts
Expected: PASS
Step 5: Update CommentsSyncService to import reply threads
In src/lib/integrations/youtube/CommentsSyncService.ts, modify processComment method. After the existing if (isNew) { ... } else { ... } block (around line 248), add reply thread processing:
// Import reply threads (max 2 levels deep - YouTube limit)
if (comment.snippet.totalReplyCount > 0 && comment.snippet.topLevelComment) {
try {
const youtubeClient = new YouTubeClient(
{
clientId: process.env.YOUTUBE_CLIENT_ID!,
clientSecret: process.env.YOUTUBE_CLIENT_SECRET!,
accessToken: (account.credentials as any).accessToken,
refreshToken: (account.credentials as any).refreshToken,
},
this.payload,
)
const { replies } = await youtubeClient.getCommentReplies(
comment.snippet.topLevelComment.id,
20, // Max 20 replies per thread
)
for (const reply of replies) {
const replyExisting = await this.payload.find({
collection: 'community-interactions',
where: { externalId: { equals: reply.id } },
limit: 1,
})
if (replyExisting.totalDocs === 0) {
// Find parent interaction ID
const parentInteraction = await this.payload.find({
collection: 'community-interactions',
where: { externalId: { equals: reply.snippet.parentId } },
limit: 1,
})
await this.payload.create({
collection: 'community-interactions',
data: {
platform: platformId,
socialAccount: account.id,
linkedContent: linkedContentId,
type: 'reply' as any,
externalId: reply.id,
parentComment: parentInteraction.docs[0]?.id || undefined,
author: {
name: reply.snippet.authorDisplayName,
handle: reply.snippet.authorChannelId?.value,
avatarUrl: reply.snippet.authorProfileImageUrl,
isVerified: false,
isSubscriber: false,
isMember: false,
},
message: reply.snippet.textOriginal,
messageHtml: reply.snippet.textDisplay,
publishedAt: new Date(reply.snippet.publishedAt).toISOString(),
engagement: {
likes: reply.snippet.likeCount || 0,
replies: 0,
isHearted: false,
isPinned: false,
},
},
})
}
}
} catch (replyError) {
console.error(`Error syncing replies for comment ${comment.id}:`, replyError)
}
}
Step 6: Commit
git add src/lib/integrations/youtube/YouTubeClient.ts \
src/lib/integrations/youtube/CommentsSyncService.ts \
tests/unit/youtube/comment-replies.test.ts
git commit -m "feat(youtube): add reply thread import to comment sync"
Task 7: YouTube Upload Queue Job Definition
Files:
- Create:
src/lib/queue/jobs/youtube-upload-job.ts - Modify:
src/lib/queue/queue-service.ts(addYOUTUBE_UPLOADtoQUEUE_NAMES)
Step 1: Add YOUTUBE_UPLOAD to queue names
In src/lib/queue/queue-service.ts, change the QUEUE_NAMES object (line 12-16):
export const QUEUE_NAMES = {
EMAIL: 'email',
PDF: 'pdf',
CLEANUP: 'cleanup',
YOUTUBE_UPLOAD: 'youtube-upload',
} as const
Step 2: Create the job definition
Create: src/lib/queue/jobs/youtube-upload-job.ts
Follow the exact pattern of src/lib/queue/jobs/email-job.ts:
// src/lib/queue/jobs/youtube-upload-job.ts
import { Job } from 'bullmq'
import { getQueue, QUEUE_NAMES, defaultJobOptions } from '../queue-service'
export interface YouTubeUploadJobData {
contentId: number
channelId: number
mediaId: number // Payload Media ID for the video file
metadata: {
title: string
description: string
tags: string[]
visibility: 'public' | 'unlisted' | 'private'
categoryId?: string
}
scheduledPublishAt?: string // ISO date for scheduled publish
triggeredBy: number // User ID
}
export interface YouTubeUploadJobResult {
success: boolean
youtubeVideoId?: string
youtubeUrl?: string
error?: string
timestamp: string
}
export async function enqueueYouTubeUpload(
data: YouTubeUploadJobData
): Promise<Job<YouTubeUploadJobData>> {
const queue = getQueue(QUEUE_NAMES.YOUTUBE_UPLOAD)
const job = await queue.add('youtube-upload', data, {
...defaultJobOptions,
attempts: 2, // Fewer retries for uploads (expensive quota)
backoff: { type: 'exponential', delay: 5000 },
})
console.log(`[YouTubeUploadQueue] Job ${job.id} queued for content ${data.contentId}`)
return job
}
export async function getYouTubeUploadJobStatus(jobId: string) {
const queue = getQueue(QUEUE_NAMES.YOUTUBE_UPLOAD)
const job = await queue.getJob(jobId)
if (!job) return null
const state = await job.getState()
return {
state,
progress: typeof job.progress === 'number' ? job.progress : 0,
result: job.returnvalue as YouTubeUploadJobResult | undefined,
failedReason: job.failedReason,
}
}
Step 3: Commit
git add src/lib/queue/queue-service.ts src/lib/queue/jobs/youtube-upload-job.ts
git commit -m "feat(youtube): add upload queue job definition"
Task 8: VideoUploadService
Files:
- Create:
src/lib/integrations/youtube/VideoUploadService.ts - Test:
tests/unit/youtube/video-upload-service.test.ts
Step 1: Write the failing test
Create: tests/unit/youtube/video-upload-service.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest'
const mockInsert = vi.fn()
vi.mock('googleapis', () => ({
google: {
auth: {
OAuth2: vi.fn().mockImplementation(() => ({
setCredentials: vi.fn(),
})),
},
youtube: vi.fn(() => ({
videos: { insert: mockInsert, list: vi.fn() },
channels: { list: vi.fn() },
commentThreads: { list: vi.fn() },
comments: { list: vi.fn(), insert: vi.fn(), setModerationStatus: vi.fn(), delete: vi.fn() },
})),
},
}))
vi.mock('fs', () => ({
createReadStream: vi.fn().mockReturnValue('mock-stream'),
}))
import { VideoUploadService } from '@/lib/integrations/youtube/VideoUploadService'
describe('VideoUploadService', () => {
let mockPayload: any
let service: VideoUploadService
beforeEach(() => {
vi.clearAllMocks()
mockPayload = {
findByID: vi.fn(),
update: vi.fn(),
find: vi.fn(),
}
service = new VideoUploadService(mockPayload)
})
it('should upload video and return YouTube video ID', async () => {
// Mock: find social account
mockPayload.find.mockResolvedValue({
docs: [{
id: 10,
platform: { slug: 'youtube' },
credentials: { accessToken: 'tok', refreshToken: 'ref' },
}],
})
// Mock: find media file
mockPayload.findByID.mockImplementation(({ collection }: any) => {
if (collection === 'media') {
return { id: 5, filename: 'video.mp4', url: '/media/video.mp4' }
}
return null
})
mockInsert.mockResolvedValue({
data: { id: 'YT_NEW_VID_123', snippet: { title: 'Test Video' } },
})
const result = await service.uploadVideo({
mediaId: 5,
metadata: {
title: 'Test Video',
description: 'A test',
tags: ['test'],
visibility: 'private',
},
})
expect(result.success).toBe(true)
expect(result.youtubeVideoId).toBe('YT_NEW_VID_123')
})
})
Step 2: Run test to verify it fails
Run: pnpm vitest run tests/unit/youtube/video-upload-service.test.ts
Expected: FAIL
Step 3: Implement VideoUploadService
Create: src/lib/integrations/youtube/VideoUploadService.ts
// src/lib/integrations/youtube/VideoUploadService.ts
import type { Payload } from 'payload'
import { google } from 'googleapis'
import fs from 'fs'
import path from 'path'
interface UploadOptions {
mediaId: number
metadata: {
title: string
description: string
tags: string[]
visibility: 'public' | 'unlisted' | 'private'
categoryId?: string
}
scheduledPublishAt?: string
}
interface UploadResult {
success: boolean
youtubeVideoId?: string
youtubeUrl?: string
error?: string
}
export class VideoUploadService {
private payload: Payload
constructor(payload: Payload) {
this.payload = payload
}
async uploadVideo(options: UploadOptions): Promise<UploadResult> {
try {
// 1. YouTube-Client erstellen
const oauth2Client = await this.getOAuth2Client()
if (!oauth2Client) {
return { success: false, error: 'Keine gültigen YouTube-Credentials' }
}
const youtube = google.youtube({ version: 'v3', auth: oauth2Client })
// 2. Media-Datei laden
const media = await this.payload.findByID({
collection: 'media',
id: options.mediaId,
})
if (!media) {
return { success: false, error: 'Media-Datei nicht gefunden' }
}
const mediaDir = path.resolve(process.cwd(), 'media')
const filePath = path.join(mediaDir, (media as any).filename)
// 3. Video hochladen
const response = await youtube.videos.insert({
part: ['snippet', 'status'],
requestBody: {
snippet: {
title: options.metadata.title,
description: options.metadata.description,
tags: options.metadata.tags,
categoryId: options.metadata.categoryId || '22', // People & Blogs
},
status: {
privacyStatus: options.metadata.visibility,
...(options.scheduledPublishAt && options.metadata.visibility === 'private'
? {
publishAt: options.scheduledPublishAt,
privacyStatus: 'private',
}
: {}),
},
},
media: {
body: fs.createReadStream(filePath),
},
})
const videoId = response.data.id!
return {
success: true,
youtubeVideoId: videoId,
youtubeUrl: `https://www.youtube.com/watch?v=${videoId}`,
}
} catch (error) {
const msg = error instanceof Error ? error.message : String(error)
console.error('[VideoUploadService] Upload failed:', msg)
return { success: false, error: msg }
}
}
private async getOAuth2Client() {
const accounts = await this.payload.find({
collection: 'social-accounts',
depth: 2,
limit: 10,
})
const ytAccount = accounts.docs.find((acc: any) => {
const platform = acc.platform as { slug?: string }
return platform?.slug === 'youtube'
})
if (!ytAccount) return null
const credentials = (ytAccount as any).credentials as {
accessToken?: string
refreshToken?: string
}
if (!credentials?.accessToken) return null
const oauth2Client = new google.auth.OAuth2(
process.env.YOUTUBE_CLIENT_ID,
process.env.YOUTUBE_CLIENT_SECRET,
)
oauth2Client.setCredentials({
access_token: credentials.accessToken,
refresh_token: credentials.refreshToken,
})
return oauth2Client
}
}
Step 4: Run test to verify it passes
Run: pnpm vitest run tests/unit/youtube/video-upload-service.test.ts
Expected: PASS
Step 5: Commit
git add src/lib/integrations/youtube/VideoUploadService.ts tests/unit/youtube/video-upload-service.test.ts
git commit -m "feat(youtube): add VideoUploadService with resumable upload"
Task 9: YouTube Upload Worker + API Route
Files:
- Create:
src/lib/queue/workers/youtube-upload-worker.ts - Create:
src/app/(payload)/api/youtube/upload/route.ts
Step 1: Create the upload worker
Create: src/lib/queue/workers/youtube-upload-worker.ts
Follow the exact pattern of src/lib/queue/workers/email-worker.ts:
// src/lib/queue/workers/youtube-upload-worker.ts
import { Worker, Job } from 'bullmq'
import { getPayload } from 'payload'
import config from '@payload-config'
import { QUEUE_NAMES, getQueueRedisConnection } from '../queue-service'
import type { YouTubeUploadJobData, YouTubeUploadJobResult } from '../jobs/youtube-upload-job'
import { VideoUploadService } from '../../integrations/youtube/VideoUploadService'
import { NotificationService } from '../../jobs/NotificationService'
const CONCURRENCY = parseInt(process.env.QUEUE_YOUTUBE_UPLOAD_CONCURRENCY || '1', 10)
async function processUploadJob(
job: Job<YouTubeUploadJobData>,
): Promise<YouTubeUploadJobResult> {
const { contentId, mediaId, metadata, scheduledPublishAt, triggeredBy } = job.data
console.log(`[YouTubeUploadWorker] Processing job ${job.id} for content ${contentId}`)
try {
const payload = await getPayload({ config })
const uploadService = new VideoUploadService(payload)
const result = await uploadService.uploadVideo({
mediaId,
metadata,
scheduledPublishAt,
})
if (!result.success) {
throw new Error(result.error || 'Upload failed')
}
// Update YouTubeContent with video ID and URL
await payload.update({
collection: 'youtube-content',
id: contentId,
data: {
youtube: {
videoId: result.youtubeVideoId,
url: result.youtubeUrl,
},
status: 'published',
actualPublishDate: new Date().toISOString(),
},
})
// Notification erstellen
const notificationService = new NotificationService(payload)
await notificationService.createNotification({
recipientId: triggeredBy,
type: 'video_published',
title: `Video "${metadata.title}" erfolgreich hochgeladen`,
message: `YouTube-URL: ${result.youtubeUrl}`,
link: `/admin/collections/youtube-content/${contentId}`,
relatedVideoId: contentId,
})
return {
success: true,
youtubeVideoId: result.youtubeVideoId,
youtubeUrl: result.youtubeUrl,
timestamp: new Date().toISOString(),
}
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error)
console.error(`[YouTubeUploadWorker] Job ${job.id} failed:`, errorMsg)
throw error
}
}
let uploadWorker: Worker<YouTubeUploadJobData, YouTubeUploadJobResult> | null = null
export function startYouTubeUploadWorker() {
if (uploadWorker) return uploadWorker
uploadWorker = new Worker<YouTubeUploadJobData, YouTubeUploadJobResult>(
QUEUE_NAMES.YOUTUBE_UPLOAD,
processUploadJob,
{
connection: getQueueRedisConnection(),
concurrency: CONCURRENCY,
stalledInterval: 120000, // 2min - uploads take time
maxStalledCount: 1,
},
)
uploadWorker.on('ready', () => console.log(`[YouTubeUploadWorker] Ready`))
uploadWorker.on('completed', (job) => console.log(`[YouTubeUploadWorker] Job ${job.id} completed`))
uploadWorker.on('failed', (job, err) => console.error(`[YouTubeUploadWorker] Job ${job?.id} failed:`, err.message))
return uploadWorker
}
export async function stopYouTubeUploadWorker() {
if (uploadWorker) {
await uploadWorker.close()
uploadWorker = null
}
}
Step 2: Create the upload API route
Create: src/app/(payload)/api/youtube/upload/route.ts
// src/app/(payload)/api/youtube/upload/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { getPayload } from 'payload'
import config from '@payload-config'
import {
enqueueYouTubeUpload,
getYouTubeUploadJobStatus,
} from '@/lib/queue/jobs/youtube-upload-job'
export async function POST(request: NextRequest) {
try {
const payload = await getPayload({ config })
// Auth prüfen
const { user } = await payload.auth({ headers: request.headers })
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
const { contentId } = body
if (!contentId) {
return NextResponse.json({ error: 'contentId required' }, { status: 400 })
}
// YouTubeContent laden
const content = await payload.findByID({
collection: 'youtube-content',
id: contentId,
depth: 1,
})
if (!content) {
return NextResponse.json({ error: 'Content not found' }, { status: 404 })
}
const doc = content as any
if (!doc.videoFile) {
return NextResponse.json({ error: 'No video file attached' }, { status: 400 })
}
// Upload-Job erstellen
const job = await enqueueYouTubeUpload({
contentId: doc.id,
channelId: typeof doc.channel === 'object' ? doc.channel.id : doc.channel,
mediaId: typeof doc.videoFile === 'object' ? doc.videoFile.id : doc.videoFile,
metadata: {
title: doc.youtube?.metadata?.youtubeTitle || doc.title || 'Untitled',
description: doc.youtube?.metadata?.youtubeDescription || doc.description || '',
tags: (doc.youtube?.metadata?.tags || []).map((t: any) => t.tag).filter(Boolean),
visibility: doc.youtube?.metadata?.visibility || 'private',
categoryId: undefined,
},
scheduledPublishAt: doc.scheduledPublishDate || undefined,
triggeredBy: user.id,
})
// Status updaten
await payload.update({
collection: 'youtube-content',
id: contentId,
data: { status: 'upload_scheduled' },
})
return NextResponse.json({
success: true,
jobId: job.id,
message: 'Upload-Job erstellt',
})
} catch (error) {
console.error('[YouTube Upload API] Error:', error)
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 })
}
}
export async function GET(request: NextRequest) {
const jobId = request.nextUrl.searchParams.get('jobId')
if (!jobId) {
return NextResponse.json({ error: 'jobId required' }, { status: 400 })
}
const status = await getYouTubeUploadJobStatus(jobId)
if (!status) {
return NextResponse.json({ error: 'Job not found' }, { status: 404 })
}
return NextResponse.json(status)
}
Step 3: Commit
git add src/lib/queue/workers/youtube-upload-worker.ts \
src/app/\(payload\)/api/youtube/upload/route.ts
git commit -m "feat(youtube): add upload worker and API route"
Task 10: Register Upload Worker in Queue Worker Startup
Files:
- Modify:
scripts/run-queue-worker.ts
Step 1: Find and read the worker startup script
Run: cat scripts/run-queue-worker.ts to see how email/PDF workers are started.
Step 2: Add YouTube upload worker import and startup
Add alongside the existing startEmailWorker() and startPdfWorker() calls:
import { startYouTubeUploadWorker, stopYouTubeUploadWorker } from '../src/lib/queue/workers/youtube-upload-worker'
// In the startup section:
startYouTubeUploadWorker()
// In the shutdown section:
await stopYouTubeUploadWorker()
Step 3: Commit
git add scripts/run-queue-worker.ts
git commit -m "feat(youtube): register upload worker in queue startup"
Phase 2: Analytics Dashboard
Task 11: Add ROI Cost Fields to YouTubeContent
Files:
- Modify:
src/collections/YouTubeContent.ts
Step 1: Add cost fields to the Performance tab
In src/collections/YouTubeContent.ts, inside the Performance tab fields array (after the performance group ending around line 653), add a new costs group:
{
name: 'costs',
type: 'group',
label: 'Kosten & Einnahmen',
admin: {
description: 'Für ROI-Berechnung (manuell pflegen)',
},
fields: [
{
name: 'estimatedProductionHours',
type: 'number',
label: 'Geschätzte Produktionsstunden',
min: 0,
},
{
name: 'estimatedProductionCost',
type: 'number',
label: 'Geschätzte Produktionskosten (EUR)',
min: 0,
},
{
name: 'estimatedRevenue',
type: 'number',
label: 'Geschätzte Einnahmen (EUR)',
min: 0,
admin: {
description: 'AdSense + Sponsoring + Affiliate',
},
},
],
},
Step 2: Create migration
Run: pnpm payload migrate:create
This generates a migration file. The migration should include:
ALTER TABLE "youtube_content"
ADD COLUMN IF NOT EXISTS "costs_estimated_production_hours" numeric,
ADD COLUMN IF NOT EXISTS "costs_estimated_production_cost" numeric,
ADD COLUMN IF NOT EXISTS "costs_estimated_revenue" numeric;
Step 3: Run migration
Run: pnpm payload migrate
Step 4: Commit
git add src/collections/YouTubeContent.ts src/migrations/
git commit -m "feat(youtube): add ROI cost fields to YouTubeContent"
Task 12: Analytics API - Comparison Tab
Files:
- Modify:
src/app/(payload)/api/youtube/analytics/route.ts - Test:
tests/unit/youtube/analytics-comparison.test.ts
Step 1: Write the failing test
Create: tests/unit/youtube/analytics-comparison.test.ts
import { describe, it, expect } from 'vitest'
// Test the comparison calculation logic as a pure function
import { calculateComparison } from '@/lib/youtube/analytics-helpers'
describe('Analytics Comparison', () => {
it('should calculate comparison metrics for multiple videos', () => {
const videos = [
{
id: 1, title: 'Video A',
performance: { views: 1000, likes: 50, ctr: 5.2, watchTimeMinutes: 300 },
},
{
id: 2, title: 'Video B',
performance: { views: 2000, likes: 80, ctr: 3.1, watchTimeMinutes: 600 },
},
]
const result = calculateComparison(videos, 'views')
expect(result).toHaveLength(2)
expect(result[0].videoId).toBe(1)
expect(result[0].value).toBe(1000)
expect(result[1].value).toBe(2000)
})
it('should handle empty video list', () => {
const result = calculateComparison([], 'views')
expect(result).toEqual([])
})
})
Step 2: Create analytics helpers module
Create: src/lib/youtube/analytics-helpers.ts
// src/lib/youtube/analytics-helpers.ts
interface VideoWithPerformance {
id: number
title: string
performance: {
views?: number
likes?: number
comments?: number
ctr?: number
watchTimeMinutes?: number
impressions?: number
subscribersGained?: number
avgViewPercentage?: number
}
costs?: {
estimatedProductionCost?: number
estimatedProductionHours?: number
estimatedRevenue?: number
}
}
type Metric = 'views' | 'likes' | 'comments' | 'ctr' | 'watchTimeMinutes' | 'impressions' | 'subscribersGained'
export function calculateComparison(
videos: VideoWithPerformance[],
metric: Metric,
) {
return videos.map((v) => ({
videoId: v.id,
title: v.title,
value: (v.performance as any)?.[metric] || 0,
}))
}
export function calculateTrends(
videos: VideoWithPerformance[],
metric: Metric,
) {
if (videos.length < 2) return { trend: 'insufficient_data', growth: 0 }
const sorted = [...videos].sort((a, b) => {
const aVal = (a.performance as any)?.[metric] || 0
const bVal = (b.performance as any)?.[metric] || 0
return aVal - bVal
})
const values = sorted.map((v) => (v.performance as any)?.[metric] || 0)
const avg = values.reduce((s, v) => s + v, 0) / values.length
const latest = values[values.length - 1]
return {
trend: latest > avg ? 'up' : latest < avg ? 'down' : 'stable',
average: avg,
latest,
growth: avg > 0 ? ((latest - avg) / avg) * 100 : 0,
min: values[0],
max: values[values.length - 1],
}
}
export function calculateROI(videos: VideoWithPerformance[]) {
return videos
.filter((v) => v.costs?.estimatedProductionCost && v.costs.estimatedProductionCost > 0)
.map((v) => {
const cost = v.costs!.estimatedProductionCost!
const revenue = v.costs?.estimatedRevenue || 0
const views = v.performance?.views || 0
return {
videoId: v.id,
title: v.title,
cost,
revenue,
roi: cost > 0 ? ((revenue - cost) / cost) * 100 : 0,
cpv: views > 0 ? cost / views : 0,
revenuePerView: views > 0 ? revenue / views : 0,
views,
}
})
}
Step 3: Run test to verify it passes
Run: pnpm vitest run tests/unit/youtube/analytics-comparison.test.ts
Expected: PASS
Step 4: Add comparison, trends, and ROI tabs to the analytics API route
In src/app/(payload)/api/youtube/analytics/route.ts, add new tab handlers. Import the helpers at the top:
import { calculateComparison, calculateTrends, calculateROI } from '@/lib/youtube/analytics-helpers'
Add new tab parameter handling in the GET handler. Add cases for comparison, trends, and roi alongside the existing performance, pipeline, goals, community tabs.
For comparison:
- Parse
videoIdsfrom query params (comma-separated) - Fetch those videos by ID
- Return
calculateComparison()result
For trends:
- Fetch all published videos for the channel
- Parse
metricfrom query params (default:views) - Return
calculateTrends()result
For roi:
- Fetch published videos with cost data
- Return
calculateROI()result
Step 5: Commit
git add src/lib/youtube/analytics-helpers.ts \
src/app/\(payload\)/api/youtube/analytics/route.ts \
tests/unit/youtube/analytics-comparison.test.ts
git commit -m "feat(youtube): add comparison, trends, ROI analytics tabs"
Task 13: Dashboard UI - Comparison, Trends, ROI Tabs
Files:
- Modify:
src/components/admin/YouTubeAnalyticsDashboard.tsx - Modify:
src/components/admin/YouTubeAnalyticsDashboard.scss
Step 1: Extend the Tab type and add new tab buttons
In src/components/admin/YouTubeAnalyticsDashboard.tsx, change the Tab type (line 7):
type Tab = 'performance' | 'pipeline' | 'goals' | 'community' | 'comparison' | 'trends' | 'roi'
Step 2: Add comparison tab component
Add a new component ComparisonTab that:
- Has a multi-select for choosing up to 5 videos (fetched from API)
- Has a metric selector (views, likes, ctr, watchTime)
- Renders a Recharts
BarChartcomparing the selected videos - Uses the existing dashboard SCSS patterns
Step 3: Add trends tab component
Add a TrendsTab component that:
- Shows trend direction (up/down/stable) with arrow icons
- Displays growth percentage
- Shows min/max/average values
- Uses Recharts for visualization
Step 4: Add ROI tab component
Add an ROITab component that:
- Shows ROI, CPV, Revenue/View for each video
- Renders a Recharts
ComposedChart(Bar for cost/revenue, Line for ROI%) - Summary cards for total cost, total revenue, average ROI
Step 5: Add tab navigation buttons
In the tab bar section, add buttons for the 3 new tabs alongside existing ones.
Step 6: Commit
git add src/components/admin/YouTubeAnalyticsDashboard.tsx \
src/components/admin/YouTubeAnalyticsDashboard.scss
git commit -m "feat(youtube): add comparison, trends, ROI tabs to dashboard"
Phase 3: Workflow Automation
Task 14: Auto Status Transitions Hook
Files:
- Create:
src/hooks/youtubeContent/autoStatusTransitions.ts - Test:
tests/unit/youtube/auto-status-transitions.test.ts - Modify:
src/collections/YouTubeContent.ts(register hook)
Step 1: Write the failing test
Create: tests/unit/youtube/auto-status-transitions.test.ts
import { describe, it, expect, vi } from 'vitest'
import { shouldTransitionStatus, getNextStatus } from '@/hooks/youtubeContent/autoStatusTransitions'
describe('Auto Status Transitions', () => {
it('should transition to published when upload is complete', () => {
const result = getNextStatus({
currentStatus: 'upload_scheduled',
youtubeVideoId: 'VID123',
hasAllChecklistsComplete: false,
})
expect(result).toBe('published')
})
it('should not transition if no video ID on upload_scheduled', () => {
const result = getNextStatus({
currentStatus: 'upload_scheduled',
youtubeVideoId: null,
hasAllChecklistsComplete: false,
})
expect(result).toBeNull()
})
it('should transition approved to upload_scheduled when video file exists', () => {
const result = shouldTransitionStatus('approved', { hasVideoFile: true })
expect(result).toBe(true)
})
})
Step 2: Implement the hook
Create: src/hooks/youtubeContent/autoStatusTransitions.ts
// src/hooks/youtubeContent/autoStatusTransitions.ts
import type { CollectionAfterChangeHook } from 'payload'
import { NotificationService } from '@/lib/jobs/NotificationService'
interface TransitionContext {
currentStatus: string
youtubeVideoId?: string | null
hasAllChecklistsComplete: boolean
}
/**
* Determines the next status based on current state and conditions
*/
export function getNextStatus(context: TransitionContext): string | null {
const { currentStatus, youtubeVideoId } = context
// Upload completed → published
if (currentStatus === 'upload_scheduled' && youtubeVideoId) {
return 'published'
}
return null
}
/**
* Checks if a manual transition should be suggested
*/
export function shouldTransitionStatus(
status: string,
context: { hasVideoFile?: boolean },
): boolean {
if (status === 'approved' && context.hasVideoFile) return true
return false
}
/**
* Hook: Automatische Status-Übergänge nach Änderungen
*/
export const autoStatusTransitions: CollectionAfterChangeHook = async ({
doc,
previousDoc,
req,
operation,
}) => {
if (operation !== 'update') return doc
const nextStatus = getNextStatus({
currentStatus: doc.status,
youtubeVideoId: doc.youtube?.videoId,
hasAllChecklistsComplete: false,
})
if (nextStatus && nextStatus !== doc.status) {
console.log(`[autoStatusTransitions] ${doc.id}: ${doc.status} → ${nextStatus}`)
await req.payload.update({
collection: 'youtube-content',
id: doc.id,
data: { status: nextStatus },
depth: 0,
})
// Notification
if (doc.assignedTo) {
const assignedToId = typeof doc.assignedTo === 'object' ? doc.assignedTo.id : doc.assignedTo
const notificationService = new NotificationService(req.payload)
await notificationService.createNotification({
recipientId: assignedToId,
type: 'system',
title: `Video-Status automatisch geändert: ${nextStatus}`,
link: `/admin/collections/youtube-content/${doc.id}`,
relatedVideoId: doc.id,
})
}
}
return doc
}
Step 3: Register the hook in YouTubeContent
In src/collections/YouTubeContent.ts, add import:
import { autoStatusTransitions } from '../hooks/youtubeContent/autoStatusTransitions'
Add to the hooks.afterChange array (line 42):
hooks: {
afterChange: [createTasksOnStatusChange, downloadThumbnail, autoStatusTransitions],
Step 4: Run tests
Run: pnpm vitest run tests/unit/youtube/auto-status-transitions.test.ts
Expected: PASS
Step 5: Commit
git add src/hooks/youtubeContent/autoStatusTransitions.ts \
src/collections/YouTubeContent.ts \
tests/unit/youtube/auto-status-transitions.test.ts
git commit -m "feat(youtube): add auto status transition hook"
Task 15: Deadline Reminders Cron
Files:
- Create:
src/app/(payload)/api/cron/deadline-reminders/route.ts - Modify:
vercel.json - Test:
tests/unit/youtube/deadline-reminders.test.ts
Step 1: Write the failing test
Create: tests/unit/youtube/deadline-reminders.test.ts
import { describe, it, expect, vi } from 'vitest'
import { findUpcomingDeadlines, type DeadlineCheck } from '@/lib/youtube/deadline-checker'
describe('Deadline Checker', () => {
it('should detect edit deadline approaching in 2 days', () => {
const now = new Date('2026-02-14T09:00:00Z')
const editDeadline = new Date('2026-02-16T09:00:00Z') // 2 days from now
const result = findUpcomingDeadlines(
{ id: 1, title: 'Test Video', editDeadline: editDeadline.toISOString(), status: 'rough_cut' },
now,
)
expect(result).toHaveLength(1)
expect(result[0].type).toBe('task_due')
expect(result[0].field).toBe('editDeadline')
})
it('should detect overdue deadline', () => {
const now = new Date('2026-02-14T09:00:00Z')
const editDeadline = new Date('2026-02-12T09:00:00Z') // 2 days ago
const result = findUpcomingDeadlines(
{ id: 1, title: 'Test Video', editDeadline: editDeadline.toISOString(), status: 'rough_cut' },
now,
)
expect(result).toHaveLength(1)
expect(result[0].type).toBe('task_overdue')
})
it('should not flag deadlines for published videos', () => {
const now = new Date('2026-02-14T09:00:00Z')
const result = findUpcomingDeadlines(
{ id: 1, title: 'Test', editDeadline: '2026-02-12T09:00:00Z', status: 'published' },
now,
)
expect(result).toHaveLength(0)
})
})
Step 2: Create deadline checker utility
Create: src/lib/youtube/deadline-checker.ts
// src/lib/youtube/deadline-checker.ts
import type { NotificationType } from '@/lib/jobs/NotificationService'
export interface DeadlineCheck {
type: NotificationType
field: string
title: string
daysUntil: number
contentId: number
contentTitle: string
}
const COMPLETED_STATUSES = ['published', 'tracked', 'discarded']
interface VideoDoc {
id: number
title: string
status: string
editDeadline?: string
reviewDeadline?: string
scheduledPublishDate?: string
assignedTo?: number | { id: number }
}
export function findUpcomingDeadlines(video: VideoDoc, now: Date): DeadlineCheck[] {
if (COMPLETED_STATUSES.includes(video.status)) return []
const checks: DeadlineCheck[] = []
const title = typeof video.title === 'string' ? video.title : (video.title as any)?.de || 'Video'
const deadlineFields: Array<{ field: keyof VideoDoc; label: string; warnDays: number }> = [
{ field: 'editDeadline', label: 'Schnitt-Deadline', warnDays: 2 },
{ field: 'reviewDeadline', label: 'Review-Deadline', warnDays: 1 },
{ field: 'scheduledPublishDate', label: 'Veröffentlichung', warnDays: 3 },
]
for (const { field, label, warnDays } of deadlineFields) {
const dateStr = video[field] as string | undefined
if (!dateStr) continue
const deadline = new Date(dateStr)
const diffMs = deadline.getTime() - now.getTime()
const diffDays = Math.ceil(diffMs / (1000 * 60 * 60 * 24))
if (diffDays < 0) {
checks.push({
type: 'task_overdue',
field,
title: `${label} überschritten: "${title}"`,
daysUntil: diffDays,
contentId: video.id,
contentTitle: title,
})
} else if (diffDays <= warnDays) {
checks.push({
type: 'task_due',
field,
title: `${label} in ${diffDays} Tag(en): "${title}"`,
daysUntil: diffDays,
contentId: video.id,
contentTitle: title,
})
}
}
return checks
}
Step 3: Create cron endpoint
Create: src/app/(payload)/api/cron/deadline-reminders/route.ts
// src/app/(payload)/api/cron/deadline-reminders/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { getPayload } from 'payload'
import config from '@payload-config'
import { findUpcomingDeadlines } from '@/lib/youtube/deadline-checker'
import { NotificationService } from '@/lib/jobs/NotificationService'
const CRON_SECRET = process.env.CRON_SECRET
export async function GET(request: NextRequest) {
if (CRON_SECRET) {
const authHeader = request.headers.get('authorization')
if (authHeader !== `Bearer ${CRON_SECRET}`) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
}
try {
const payload = await getPayload({ config })
const now = new Date()
const notificationService = new NotificationService(payload)
// Alle aktiven Videos mit Deadlines laden
const videos = await payload.find({
collection: 'youtube-content',
where: {
status: {
not_in: ['published', 'tracked', 'discarded'],
},
},
limit: 500,
depth: 0,
})
let notificationsCreated = 0
const errors: string[] = []
for (const video of videos.docs) {
const doc = video as any
const deadlines = findUpcomingDeadlines(doc, now)
for (const deadline of deadlines) {
const recipientId = doc.assignedTo
? (typeof doc.assignedTo === 'object' ? doc.assignedTo.id : doc.assignedTo)
: null
if (!recipientId) continue
try {
await notificationService.createNotification({
recipientId,
type: deadline.type,
title: deadline.title,
link: `/admin/collections/youtube-content/${deadline.contentId}`,
relatedVideoId: deadline.contentId,
})
notificationsCreated++
} catch (error) {
errors.push(`Notification für Video ${deadline.contentId}: ${error}`)
}
}
}
return NextResponse.json({
success: true,
videosChecked: videos.docs.length,
notificationsCreated,
errors,
})
} catch (error) {
console.error('[Cron] deadline-reminders error:', error)
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 })
}
}
Step 4: Add cron entry to vercel.json
Add to the crons array:
{
"path": "/api/cron/deadline-reminders",
"schedule": "0 9 * * 1-5"
}
Step 5: Run test
Run: pnpm vitest run tests/unit/youtube/deadline-reminders.test.ts
Expected: PASS
Step 6: Commit
git add src/lib/youtube/deadline-checker.ts \
src/app/\(payload\)/api/cron/deadline-reminders/route.ts \
vercel.json \
tests/unit/youtube/deadline-reminders.test.ts
git commit -m "feat(youtube): add deadline reminders cron endpoint"
Task 16: Team Capacity API
Files:
- Create:
src/app/(payload)/api/youtube/capacity/route.ts - Test:
tests/unit/youtube/capacity.test.ts
Step 1: Write the failing test
Create: tests/unit/youtube/capacity.test.ts
import { describe, it, expect } from 'vitest'
import { calculateCapacity, type CapacityInput } from '@/lib/youtube/capacity-calculator'
describe('Capacity Calculator', () => {
it('should calculate utilization percentage', () => {
const input: CapacityInput = {
userId: 1,
userName: 'Max',
activeTasks: 5,
estimatedHours: 20,
videosInPipeline: 3,
availableHoursPerWeek: 40,
}
const result = calculateCapacity(input)
expect(result.utilization).toBe(50) // 20/40 * 100
expect(result.status).toBe('green') // <70%
})
it('should flag overloaded team members as red', () => {
const input: CapacityInput = {
userId: 2,
userName: 'Anna',
activeTasks: 10,
estimatedHours: 38,
videosInPipeline: 8,
availableHoursPerWeek: 40,
}
const result = calculateCapacity(input)
expect(result.utilization).toBe(95)
expect(result.status).toBe('red') // >90%
})
})
Step 2: Create capacity calculator
Create: src/lib/youtube/capacity-calculator.ts
// src/lib/youtube/capacity-calculator.ts
export interface CapacityInput {
userId: number
userName: string
activeTasks: number
estimatedHours: number
videosInPipeline: number
availableHoursPerWeek: number
}
export interface CapacityResult {
userId: number
userName: string
activeTasks: number
estimatedHours: number
videosInPipeline: number
availableHoursPerWeek: number
utilization: number // 0-100+
status: 'green' | 'yellow' | 'red'
}
export function calculateCapacity(input: CapacityInput): CapacityResult {
const utilization = input.availableHoursPerWeek > 0
? Math.round((input.estimatedHours / input.availableHoursPerWeek) * 100)
: 0
let status: 'green' | 'yellow' | 'red' = 'green'
if (utilization > 90) status = 'red'
else if (utilization > 70) status = 'yellow'
return { ...input, utilization, status }
}
Step 3: Create the API route
Create: src/app/(payload)/api/youtube/capacity/route.ts
// src/app/(payload)/api/youtube/capacity/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { getPayload } from 'payload'
import config from '@payload-config'
import { calculateCapacity, type CapacityInput } from '@/lib/youtube/capacity-calculator'
export async function GET(request: NextRequest) {
try {
const payload = await getPayload({ config })
const { user } = await payload.auth({ headers: request.headers })
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// Alle Users mit YouTube-Rolle finden
const users = await payload.find({
collection: 'users',
where: {
youtubeRole: { exists: true },
},
limit: 50,
depth: 0,
})
const capacities = []
for (const u of users.docs) {
const userId = (u as any).id
// Aktive Tasks zählen
const tasks = await payload.find({
collection: 'yt-tasks',
where: {
assignedTo: { equals: userId },
status: { in: ['todo', 'in_progress'] },
},
limit: 0, // Only count
depth: 0,
})
// Videos in Pipeline zählen
const videos = await payload.find({
collection: 'youtube-content',
where: {
assignedTo: { equals: userId },
status: { not_in: ['published', 'tracked', 'discarded', 'idea'] },
},
limit: 0,
depth: 0,
})
// Geschätzte Stunden summieren (aus Task estimatedHours falls vorhanden)
const activeTasks = await payload.find({
collection: 'yt-tasks',
where: {
assignedTo: { equals: userId },
status: { in: ['todo', 'in_progress'] },
},
limit: 100,
depth: 0,
})
const estimatedHours = activeTasks.docs.reduce((sum, t: any) => {
return sum + (t.estimatedHours || 2) // Default: 2h per task
}, 0)
const input: CapacityInput = {
userId,
userName: (u as any).email || `User ${userId}`,
activeTasks: tasks.totalDocs,
estimatedHours,
videosInPipeline: videos.totalDocs,
availableHoursPerWeek: 40,
}
capacities.push(calculateCapacity(input))
}
return NextResponse.json({
success: true,
team: capacities,
summary: {
totalMembers: capacities.length,
overloaded: capacities.filter((c) => c.status === 'red').length,
atCapacity: capacities.filter((c) => c.status === 'yellow').length,
available: capacities.filter((c) => c.status === 'green').length,
},
})
} catch (error) {
console.error('[Capacity API] Error:', error)
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 })
}
}
Step 4: Run tests
Run: pnpm vitest run tests/unit/youtube/capacity.test.ts
Expected: PASS
Step 5: Commit
git add src/lib/youtube/capacity-calculator.ts \
src/app/\(payload\)/api/youtube/capacity/route.ts \
tests/unit/youtube/capacity.test.ts
git commit -m "feat(youtube): add team capacity planning API"
Phase 4: Content Calendar
Task 17: Install FullCalendar
Step 1: Install dependencies
Run:
pnpm add @fullcalendar/core @fullcalendar/react @fullcalendar/daygrid @fullcalendar/timegrid @fullcalendar/interaction @fullcalendar/list
Step 2: Commit
git add package.json pnpm-lock.yaml
git commit -m "chore: add FullCalendar dependencies"
Task 18: Conflict Detection Service
Files:
- Create:
src/lib/youtube/ConflictDetectionService.ts - Test:
tests/unit/youtube/conflict-detection.test.ts
Step 1: Write the failing test
Create: tests/unit/youtube/conflict-detection.test.ts
import { describe, it, expect } from 'vitest'
import { detectConflicts, type CalendarEvent } from '@/lib/youtube/ConflictDetectionService'
describe('ConflictDetectionService', () => {
it('should detect two videos on the same day for the same channel', () => {
const events: CalendarEvent[] = [
{ id: 1, channelId: 1, scheduledDate: '2026-03-01', contentType: 'longform' },
{ id: 2, channelId: 1, scheduledDate: '2026-03-01', contentType: 'longform' },
]
const schedule = { longformPerWeek: 1, shortsPerWeek: 4 }
const conflicts = detectConflicts(events, schedule)
expect(conflicts.length).toBeGreaterThan(0)
expect(conflicts[0].type).toBe('same_day')
expect(conflicts[0].eventIds).toContain(1)
expect(conflicts[0].eventIds).toContain(2)
})
it('should detect weekly frequency exceeded', () => {
const events: CalendarEvent[] = [
{ id: 1, channelId: 1, scheduledDate: '2026-03-02', contentType: 'longform' }, // Monday
{ id: 2, channelId: 1, scheduledDate: '2026-03-04', contentType: 'longform' }, // Wednesday
{ id: 3, channelId: 1, scheduledDate: '2026-03-06', contentType: 'longform' }, // Friday
]
const schedule = { longformPerWeek: 1, shortsPerWeek: 4 }
const conflicts = detectConflicts(events, schedule)
const frequencyConflict = conflicts.find((c) => c.type === 'frequency_exceeded')
expect(frequencyConflict).toBeDefined()
})
it('should not flag conflicts for different channels', () => {
const events: CalendarEvent[] = [
{ id: 1, channelId: 1, scheduledDate: '2026-03-01', contentType: 'longform' },
{ id: 2, channelId: 2, scheduledDate: '2026-03-01', contentType: 'longform' },
]
const conflicts = detectConflicts(events, { longformPerWeek: 1, shortsPerWeek: 4 })
expect(conflicts).toHaveLength(0)
})
})
Step 2: Implement ConflictDetectionService
Create: src/lib/youtube/ConflictDetectionService.ts
// src/lib/youtube/ConflictDetectionService.ts
import { startOfWeek, endOfWeek, isSameDay, parseISO } from 'date-fns'
export interface CalendarEvent {
id: number
channelId: number
scheduledDate: string // ISO date
contentType: 'short' | 'longform' | 'premiere'
seriesId?: number
seriesOrder?: number
}
export interface Conflict {
type: 'same_day' | 'frequency_exceeded' | 'series_order' | 'weekend'
message: string
eventIds: number[]
severity: 'warning' | 'error'
}
interface ScheduleConfig {
longformPerWeek: number
shortsPerWeek: number
}
export function detectConflicts(
events: CalendarEvent[],
schedule: ScheduleConfig,
): Conflict[] {
const conflicts: Conflict[] = []
// 1. Same-day conflicts (per channel)
const byDayChannel = new Map<string, CalendarEvent[]>()
for (const event of events) {
const key = `${event.channelId}-${event.scheduledDate.split('T')[0]}`
const existing = byDayChannel.get(key) || []
existing.push(event)
byDayChannel.set(key, existing)
}
for (const [, dayEvents] of byDayChannel) {
// Only flag if same content type on same day
const longforms = dayEvents.filter((e) => e.contentType === 'longform')
if (longforms.length > 1) {
conflicts.push({
type: 'same_day',
message: `${longforms.length} Longform-Videos am selben Tag geplant`,
eventIds: longforms.map((e) => e.id),
severity: 'error',
})
}
}
// 2. Weekly frequency check (per channel)
const byWeekChannel = new Map<string, CalendarEvent[]>()
for (const event of events) {
const date = parseISO(event.scheduledDate)
const weekStart = startOfWeek(date, { weekStartsOn: 1 })
const key = `${event.channelId}-${weekStart.toISOString()}`
const existing = byWeekChannel.get(key) || []
existing.push(event)
byWeekChannel.set(key, existing)
}
for (const [, weekEvents] of byWeekChannel) {
const longforms = weekEvents.filter((e) => e.contentType === 'longform')
const shorts = weekEvents.filter((e) => e.contentType === 'short')
if (longforms.length > schedule.longformPerWeek) {
conflicts.push({
type: 'frequency_exceeded',
message: `${longforms.length}/${schedule.longformPerWeek} Longform-Videos diese Woche`,
eventIds: longforms.map((e) => e.id),
severity: 'warning',
})
}
if (shorts.length > schedule.shortsPerWeek) {
conflicts.push({
type: 'frequency_exceeded',
message: `${shorts.length}/${schedule.shortsPerWeek} Shorts diese Woche`,
eventIds: shorts.map((e) => e.id),
severity: 'warning',
})
}
}
// 3. Weekend warnings
for (const event of events) {
const date = parseISO(event.scheduledDate)
const dayOfWeek = date.getDay() // 0=Sun, 6=Sat
if (dayOfWeek === 0 || dayOfWeek === 6) {
conflicts.push({
type: 'weekend',
message: 'Video am Wochenende geplant',
eventIds: [event.id],
severity: 'warning',
})
}
}
return conflicts
}
Step 3: Run tests
Run: pnpm vitest run tests/unit/youtube/conflict-detection.test.ts
Expected: PASS
Step 4: Commit
git add src/lib/youtube/ConflictDetectionService.ts tests/unit/youtube/conflict-detection.test.ts
git commit -m "feat(youtube): add conflict detection service"
Task 19: Calendar API Route
Files:
- Create:
src/app/(payload)/api/youtube/calendar/route.ts
Step 1: Create the calendar API
Create: src/app/(payload)/api/youtube/calendar/route.ts
// src/app/(payload)/api/youtube/calendar/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { getPayload } from 'payload'
import config from '@payload-config'
import { detectConflicts, type CalendarEvent } from '@/lib/youtube/ConflictDetectionService'
export async function GET(request: NextRequest) {
try {
const payload = await getPayload({ config })
const { user } = await payload.auth({ headers: request.headers })
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { searchParams } = request.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 })
}
// Videos mit scheduledPublishDate im Zeitraum laden
const where: Record<string, any> = {
scheduledPublishDate: {
greater_than_equal: start,
less_than_equal: end,
},
status: { not_equals: 'discarded' },
}
if (channelId && channelId !== 'all') {
where.channel = { equals: parseInt(channelId) }
}
const videos = await payload.find({
collection: 'youtube-content',
where,
limit: 200,
depth: 1,
sort: 'scheduledPublishDate',
})
// Channel-Branding für Farben laden
const channels = await payload.find({
collection: 'youtube-channels',
where: { status: { equals: 'active' } },
limit: 50,
depth: 0,
})
const channelColorMap = new Map<number, string>()
for (const ch of channels.docs) {
const c = ch as any
channelColorMap.set(c.id, c.branding?.primaryColor || '#3788d8')
}
// Conflict detection
const calendarEvents: CalendarEvent[] = videos.docs.map((v: any) => ({
id: v.id,
channelId: typeof v.channel === 'object' ? v.channel.id : v.channel,
scheduledDate: v.scheduledPublishDate,
contentType: v.format || 'longform',
seriesId: typeof v.series === 'object' ? v.series?.id : v.series,
}))
// Get schedule config from first matching channel
let scheduleConfig = { longformPerWeek: 1, shortsPerWeek: 4 }
if (channels.docs.length > 0) {
const ch = channels.docs[0] as any
scheduleConfig = {
longformPerWeek: ch.publishingSchedule?.longformPerWeek || 1,
shortsPerWeek: ch.publishingSchedule?.shortsPerWeek || 4,
}
}
const conflicts = detectConflicts(calendarEvents, scheduleConfig)
const conflictEventIds = new Set(conflicts.flatMap((c) => c.eventIds))
// Format for FullCalendar
const events = videos.docs.map((v: any) => {
const chId = typeof v.channel === 'object' ? v.channel.id : v.channel
const chName = typeof v.channel === 'object' ? v.channel.name : undefined
const seriesName = typeof v.series === 'object' ? v.series?.name : undefined
return {
id: String(v.id),
title: typeof v.title === 'string' ? v.title : v.title?.de || v.title?.en || 'Untitled',
start: v.scheduledPublishDate,
color: channelColorMap.get(chId) || '#3788d8',
extendedProps: {
status: v.status,
contentType: v.format || 'longform',
channelId: chId,
channelName: chName,
seriesName,
hasConflict: conflictEventIds.has(v.id),
assignedTo: v.assignedTo,
},
}
})
return NextResponse.json({
events,
conflicts,
meta: {
totalEvents: events.length,
conflictsCount: conflicts.length,
},
})
} catch (error) {
console.error('[Calendar API] Error:', error)
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 })
}
}
// PATCH: Reschedule via drag & drop
export async function PATCH(request: NextRequest) {
try {
const payload = await getPayload({ config })
const { user } = await payload.auth({ headers: request.headers })
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
const { contentId, newDate } = body
if (!contentId || !newDate) {
return NextResponse.json({ error: 'contentId and newDate required' }, { status: 400 })
}
// Prüfe ob Video noch nicht veröffentlicht
const content = await payload.findByID({
collection: 'youtube-content',
id: contentId,
depth: 0,
})
const doc = content as any
if (['published', 'tracked'].includes(doc.status)) {
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: 'Internal Server Error' }, { status: 500 })
}
}
Step 2: Commit
git add src/app/\(payload\)/api/youtube/calendar/route.ts
git commit -m "feat(youtube): add content calendar API with conflict detection"
Task 20: Content Calendar Component
Files:
- Create:
src/components/admin/ContentCalendar.tsx - Create:
src/components/admin/ContentCalendar.module.scss
Step 1: Create the SCSS module
Create: src/components/admin/ContentCalendar.module.scss
// src/components/admin/ContentCalendar.module.scss
.contentCalendar {
padding: var(--base);
&__header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--base);
flex-wrap: wrap;
gap: var(--base);
}
&__title {
font-size: 1.5rem;
font-weight: 600;
color: var(--theme-text);
margin: 0;
}
&__filters {
display: flex;
gap: calc(var(--base) / 2);
align-items: center;
}
&__select {
padding: calc(var(--base) / 4) calc(var(--base) / 2);
border: 1px solid var(--theme-elevation-150);
border-radius: 4px;
background: var(--theme-input-bg);
color: var(--theme-text);
font-size: 0.875rem;
}
&__calendar {
background: var(--theme-bg);
border-radius: 8px;
padding: var(--base);
border: 1px solid var(--theme-elevation-100);
}
&__conflict {
border: 2px solid #e74c3c !important;
animation: pulse 2s infinite;
}
&__legend {
display: flex;
gap: var(--base);
margin-top: var(--base);
flex-wrap: wrap;
}
&__legendItem {
display: flex;
align-items: center;
gap: calc(var(--base) / 4);
font-size: 0.8rem;
color: var(--theme-text);
}
&__legendColor {
width: 12px;
height: 12px;
border-radius: 2px;
}
&__conflicts {
margin-top: var(--base);
padding: var(--base);
background: #fff3cd;
border: 1px solid #ffc107;
border-radius: 4px;
color: #856404;
h4 {
margin: 0 0 calc(var(--base) / 2) 0;
}
ul {
margin: 0;
padding-left: 1.5rem;
}
}
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.7; }
}
Step 2: Create the calendar component
Create: src/components/admin/ContentCalendar.tsx
'use client'
import React, { useState, useEffect, useCallback } from 'react'
import FullCalendar from '@fullcalendar/react'
import dayGridPlugin from '@fullcalendar/daygrid'
import timeGridPlugin from '@fullcalendar/timegrid'
import interactionPlugin from '@fullcalendar/interaction'
import listPlugin from '@fullcalendar/list'
import styles from './ContentCalendar.module.scss'
interface CalendarEvent {
id: string
title: string
start: string
color: string
extendedProps: {
status: string
contentType: string
channelId: number
channelName?: string
seriesName?: string
hasConflict: boolean
}
}
interface Conflict {
type: string
message: string
eventIds: number[]
severity: 'warning' | 'error'
}
interface Channel {
id: number
name: string
branding?: { primaryColor?: string }
}
export function ContentCalendar() {
const [events, setEvents] = useState<CalendarEvent[]>([])
const [conflicts, setConflicts] = useState<Conflict[]>([])
const [channels, setChannels] = useState<Channel[]>([])
const [selectedChannel, setSelectedChannel] = useState('all')
const [loading, setLoading] = useState(true)
const fetchEvents = useCallback(async (start: string, end: string) => {
setLoading(true)
try {
const params = new URLSearchParams({ start, end })
if (selectedChannel !== 'all') params.set('channelId', selectedChannel)
const res = await fetch(`/api/youtube/calendar?${params}`, { credentials: 'include' })
const data = await res.json()
setEvents(data.events || [])
setConflicts(data.conflicts || [])
} catch (error) {
console.error('Failed to fetch calendar events:', error)
} finally {
setLoading(false)
}
}, [selectedChannel])
// Fetch channels for filter
useEffect(() => {
fetch('/api/youtube-channels?limit=50&depth=0', { credentials: 'include' })
.then((r) => r.json())
.then((data) => setChannels(data.docs || []))
.catch(console.error)
}, [])
const handleDatesSet = useCallback((arg: { startStr: string; endStr: string }) => {
fetchEvents(arg.startStr, arg.endStr)
}, [fetchEvents])
const handleEventDrop = useCallback(async (info: any) => {
const { id } = info.event
const newDate = info.event.start.toISOString()
// Confirm dialog
if (!window.confirm(`Video auf ${info.event.start.toLocaleDateString('de')} verschieben?`)) {
info.revert()
return
}
try {
const res = await fetch('/api/youtube/calendar', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ contentId: parseInt(id), newDate }),
})
if (!res.ok) {
const data = await res.json()
alert(data.error || 'Fehler beim Verschieben')
info.revert()
}
} catch {
info.revert()
}
}, [])
const handleEventClick = useCallback((info: any) => {
window.location.href = `/admin/collections/youtube-content/${info.event.id}`
}, [])
const renderEventContent = useCallback((eventInfo: any) => {
const { hasConflict, contentType, status } = eventInfo.event.extendedProps
const statusEmoji: Record<string, string> = {
idea: '\u{1F4A1}',
script_draft: '\u{270F}\u{FE0F}',
script_review: '\u{1F50D}',
approved: '\u{2705}',
upload_scheduled: '\u{2B06}\u{FE0F}',
published: '\u{1F4FA}',
tracked: '\u{1F4CA}',
}
return (
<div className={hasConflict ? styles.contentCalendar__conflict : ''}>
<span>{statusEmoji[status] || '\u{1F3AC}'} </span>
<span>{contentType === 'short' ? 'S' : 'L'} </span>
<b>{eventInfo.event.title}</b>
</div>
)
}, [])
return (
<div className={styles.contentCalendar}>
<div className={styles.contentCalendar__header}>
<h1 className={styles.contentCalendar__title}>Content-Kalender</h1>
<div className={styles.contentCalendar__filters}>
<select
className={styles.contentCalendar__select}
value={selectedChannel}
onChange={(e) => setSelectedChannel(e.target.value)}
>
<option value="all">Alle Kanäle</option>
{channels.map((ch) => (
<option key={ch.id} value={ch.id}>{ch.name}</option>
))}
</select>
</div>
</div>
<div className={styles.contentCalendar__calendar}>
<FullCalendar
plugins={[dayGridPlugin, timeGridPlugin, interactionPlugin, listPlugin]}
initialView="dayGridMonth"
locale="de"
headerToolbar={{
left: 'prev,next today',
center: 'title',
right: 'dayGridMonth,timeGridWeek,listWeek',
}}
events={events}
editable={true}
droppable={false}
eventDrop={handleEventDrop}
eventClick={handleEventClick}
eventContent={renderEventContent}
datesSet={handleDatesSet}
height="auto"
firstDay={1}
eventTimeFormat={{ hour: '2-digit', minute: '2-digit', hour12: false }}
/>
</div>
{/* Channel Legend */}
{channels.length > 0 && (
<div className={styles.contentCalendar__legend}>
{channels.map((ch) => (
<div key={ch.id} className={styles.contentCalendar__legendItem}>
<div
className={styles.contentCalendar__legendColor}
style={{ background: ch.branding?.primaryColor || '#3788d8' }}
/>
<span>{ch.name}</span>
</div>
))}
</div>
)}
{/* Conflict warnings */}
{conflicts.length > 0 && (
<div className={styles.contentCalendar__conflicts}>
<h4>Konflikte ({conflicts.length})</h4>
<ul>
{conflicts.map((c, i) => (
<li key={i}>{c.message}</li>
))}
</ul>
</div>
)}
</div>
)
}
Step 3: Commit
git add src/components/admin/ContentCalendar.tsx \
src/components/admin/ContentCalendar.module.scss
git commit -m "feat(youtube): add FullCalendar content calendar component"
Task 21: Register Content Calendar as Admin View
Files:
- Create:
src/components/admin/ContentCalendarView.tsx - Create:
src/components/admin/ContentCalendarNavLinks.tsx - Modify:
src/payload.config.ts
Step 1: Create the view wrapper
Create: src/components/admin/ContentCalendarView.tsx
Follow the pattern of src/components/admin/YouTubeAnalyticsDashboardView:
'use client'
import React from 'react'
import { ContentCalendar } from './ContentCalendar'
export function ContentCalendarView() {
return <ContentCalendar />
}
Step 2: Create nav links
Create: src/components/admin/ContentCalendarNavLinks.tsx
Follow the pattern of src/components/admin/YouTubeAnalyticsNavLinks:
'use client'
import React from 'react'
import { NavGroup } from '@payloadcms/ui'
export function ContentCalendarNavLinks() {
return (
<NavGroup label="YouTube">
<a
href="/admin/content-calendar"
style={{
display: 'flex',
alignItems: 'center',
padding: '0.5rem 1rem',
color: 'var(--theme-text)',
textDecoration: 'none',
fontSize: '0.875rem',
}}
>
Content-Kalender
</a>
</NavGroup>
)
}
Step 3: Register in payload.config.ts
In src/payload.config.ts:
- Add to
afterNavLinksarray (line 131-134):
afterNavLinks: [
'@/components/admin/CommunityNavLinks#CommunityNavLinks',
'@/components/admin/YouTubeAnalyticsNavLinks#YouTubeAnalyticsNavLinks',
'@/components/admin/ContentCalendarNavLinks#ContentCalendarNavLinks',
],
- Add to
viewsobject (line 135-144):
ContentCalendar: {
Component: '@/components/admin/ContentCalendarView#ContentCalendarView',
path: '/content-calendar',
},
Step 4: Regenerate import map
Run: pnpm payload generate:importmap
Step 5: Commit
git add src/components/admin/ContentCalendarView.tsx \
src/components/admin/ContentCalendarNavLinks.tsx \
src/payload.config.ts \
src/app/\(payload\)/importMap.js
git commit -m "feat(youtube): register content calendar as admin view"
Task 22: Final Integration Test & Build
Step 1: Run all tests
Run: pnpm test
Expected: All tests pass
Step 2: TypeScript check
Run: pnpm typecheck
Expected: No errors
Step 3: Lint check
Run: pnpm lint
Expected: No errors (warnings are OK)
Step 4: Build
Run: pnpm build
Expected: Build succeeds
Step 5: Final commit (if any fixes needed)
git add -A
git commit -m "fix: resolve build/lint issues for YouTube Operations Hub extensions"
Summary
| Phase | Tasks | Commits |
|---|---|---|
| Phase 1: YouTube API Integration | Tasks 1-10 | 10 commits |
| Phase 2: Analytics Dashboard | Tasks 11-13 | 3 commits |
| Phase 3: Workflow Automation | Tasks 14-16 | 3 commits |
| Phase 4: Content Calendar | Tasks 17-22 | 6 commits |
| Total | 22 tasks | 22 commits |
New Files Created
| File | Purpose |
|---|---|
src/lib/integrations/youtube/VideoMetricsSyncService.ts |
Batch video metrics sync |
src/lib/integrations/youtube/ChannelMetricsSyncService.ts |
Channel statistics sync |
src/lib/integrations/youtube/VideoUploadService.ts |
YouTube video upload |
src/lib/queue/jobs/youtube-upload-job.ts |
Upload queue job definition |
src/lib/queue/workers/youtube-upload-worker.ts |
Upload queue worker |
src/lib/youtube/analytics-helpers.ts |
Comparison/trend/ROI calculations |
src/lib/youtube/deadline-checker.ts |
Deadline detection logic |
src/lib/youtube/capacity-calculator.ts |
Team capacity calculation |
src/lib/youtube/ConflictDetectionService.ts |
Calendar conflict detection |
src/app/(payload)/api/youtube/upload/route.ts |
Upload API |
src/app/(payload)/api/youtube/calendar/route.ts |
Calendar API |
src/app/(payload)/api/youtube/capacity/route.ts |
Capacity API |
src/app/(payload)/api/cron/youtube-metrics-sync/route.ts |
Metrics sync cron |
src/app/(payload)/api/cron/youtube-channel-sync/route.ts |
Channel sync cron |
src/app/(payload)/api/cron/deadline-reminders/route.ts |
Deadline cron |
src/hooks/youtubeContent/autoStatusTransitions.ts |
Auto status hook |
src/components/admin/ContentCalendar.tsx |
Calendar UI component |
src/components/admin/ContentCalendar.module.scss |
Calendar styles |
src/components/admin/ContentCalendarView.tsx |
Admin view wrapper |
src/components/admin/ContentCalendarNavLinks.tsx |
Nav link component |