# 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 |