cms.c2sgmbh/docs/plans/2026-02-14-youtube-operations-hub-extensions.md
Martin Porwoll 2718dea356 docs: add YouTube Operations Hub extensions implementation plan
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>
2026-02-14 12:45:42 +00:00

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 (add getCommentReplies)
  • 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 (add YOUTUBE_UPLOAD to QUEUE_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 videoIds from query params (comma-separated)
  • Fetch those videos by ID
  • Return calculateComparison() result

For trends:

  • Fetch all published videos for the channel
  • Parse metric from 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"

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 BarChart comparing 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:

  1. Add to afterNavLinks array (line 131-134):
afterNavLinks: [
  '@/components/admin/CommunityNavLinks#CommunityNavLinks',
  '@/components/admin/YouTubeAnalyticsNavLinks#YouTubeAnalyticsNavLinks',
  '@/components/admin/ContentCalendarNavLinks#ContentCalendarNavLinks',
],
  1. Add to views object (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