diff --git a/docs/plans/2026-02-14-youtube-operations-hub-extensions.md b/docs/plans/2026-02-14-youtube-operations-hub-extensions.md new file mode 100644 index 0000000..3d9797a --- /dev/null +++ b/docs/plans/2026-02-14-youtube-operations-hub-extensions.md @@ -0,0 +1,3478 @@ +# 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` + +```typescript +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): + +```typescript +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** + +```bash +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` + +```typescript +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): + +```typescript + /** + * Video-Statistiken für mehrere Videos abrufen (Batch) + * YouTube API erlaubt max. 50 IDs pro Request + */ + async getVideoStatistics(videoIds: string[]): Promise> { + 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** + +```bash +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` + +```typescript +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` + +```typescript +// 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 { + 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 { + 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(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** + +```bash +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` + +```typescript +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` + +```typescript +// 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 { + 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 { + 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** + +```bash +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`: + +```typescript +// 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` + +```typescript +// 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: + +```json +{ + "path": "/api/cron/youtube-metrics-sync", + "schedule": "0 */6 * * *" +}, +{ + "path": "/api/cron/youtube-channel-sync", + "schedule": "0 4 * * *" +} +``` + +**Step 4: Commit** + +```bash +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` + +```typescript +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`): + +```typescript + /** + * 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: + +```typescript + // 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** + +```bash +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): + +```typescript +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`: + +```typescript +// 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> { + 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** + +```bash +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` + +```typescript +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` + +```typescript +// 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 { + 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** + +```bash +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`: + +```typescript +// 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, +): Promise { + 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 | null = null + +export function startYouTubeUploadWorker() { + if (uploadWorker) return uploadWorker + + uploadWorker = new Worker( + 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` + +```typescript +// 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** + +```bash +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: + +```typescript +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** + +```bash +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: + +```typescript + { + 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: + +```sql +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** + +```bash +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` + +```typescript +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` + +```typescript +// 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: + +```typescript +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** + +```bash +git add src/lib/youtube/analytics-helpers.ts \ + src/app/\(payload\)/api/youtube/analytics/route.ts \ + tests/unit/youtube/analytics-comparison.test.ts +git commit -m "feat(youtube): add comparison, trends, ROI analytics tabs" +``` + +--- + +### Task 13: Dashboard UI - Comparison, Trends, ROI Tabs + +**Files:** +- Modify: `src/components/admin/YouTubeAnalyticsDashboard.tsx` +- Modify: `src/components/admin/YouTubeAnalyticsDashboard.scss` + +**Step 1: Extend the Tab type and add new tab buttons** + +In `src/components/admin/YouTubeAnalyticsDashboard.tsx`, change the Tab type (line 7): + +```typescript +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** + +```bash +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` + +```typescript +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` + +```typescript +// 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: + +```typescript +import { autoStatusTransitions } from '../hooks/youtubeContent/autoStatusTransitions' +``` + +Add to the `hooks.afterChange` array (line 42): + +```typescript +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** + +```bash +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` + +```typescript +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` + +```typescript +// 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` + +```typescript +// 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: + +```json +{ + "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** + +```bash +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` + +```typescript +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` + +```typescript +// 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` + +```typescript +// 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** + +```bash +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: + +```bash +pnpm add @fullcalendar/core @fullcalendar/react @fullcalendar/daygrid @fullcalendar/timegrid @fullcalendar/interaction @fullcalendar/list +``` + +**Step 2: Commit** + +```bash +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` + +```typescript +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` + +```typescript +// 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() + 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() + 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** + +```bash +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` + +```typescript +// 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 = { + 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() + 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** + +```bash +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` + +```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` + +```typescript +'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([]) + const [conflicts, setConflicts] = useState([]) + const [channels, setChannels] = useState([]) + 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 = { + 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 ( +
+ {statusEmoji[status] || '\u{1F3AC}'} + {contentType === 'short' ? 'S' : 'L'} + {eventInfo.event.title} +
+ ) + }, []) + + return ( +
+
+

Content-Kalender

+
+ +
+
+ +
+ +
+ + {/* Channel Legend */} + {channels.length > 0 && ( +
+ {channels.map((ch) => ( +
+
+ {ch.name} +
+ ))} +
+ )} + + {/* Conflict warnings */} + {conflicts.length > 0 && ( +
+

Konflikte ({conflicts.length})

+
    + {conflicts.map((c, i) => ( +
  • {c.message}
  • + ))} +
+
+ )} +
+ ) +} +``` + +**Step 3: Commit** + +```bash +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`: + +```typescript +'use client' + +import React from 'react' +import { ContentCalendar } from './ContentCalendar' + +export function ContentCalendarView() { + return +} +``` + +**Step 2: Create nav links** + +Create: `src/components/admin/ContentCalendarNavLinks.tsx` + +Follow the pattern of `src/components/admin/YouTubeAnalyticsNavLinks`: + +```typescript +'use client' + +import React from 'react' +import { NavGroup } from '@payloadcms/ui' + +export function ContentCalendarNavLinks() { + return ( + + + Content-Kalender + + + ) +} +``` + +**Step 3: Register in payload.config.ts** + +In `src/payload.config.ts`: + +1. Add to `afterNavLinks` array (line 131-134): + +```typescript +afterNavLinks: [ + '@/components/admin/CommunityNavLinks#CommunityNavLinks', + '@/components/admin/YouTubeAnalyticsNavLinks#YouTubeAnalyticsNavLinks', + '@/components/admin/ContentCalendarNavLinks#ContentCalendarNavLinks', +], +``` + +2. Add to `views` object (line 135-144): + +```typescript +ContentCalendar: { + Component: '@/components/admin/ContentCalendarView#ContentCalendarView', + path: '/content-calendar', +}, +``` + +**Step 4: Regenerate import map** + +Run: `pnpm payload generate:importmap` + +**Step 5: Commit** + +```bash +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)** + +```bash +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 |