mirror of
https://github.com/complexcaresolutions/cms.c2sgmbh.git
synced 2026-03-17 18:34:13 +00:00
22-task implementation plan covering 4 phases: - Phase 1: YouTube API Integration (10 tasks) - Phase 2: Analytics Dashboard (3 tasks) - Phase 3: Workflow Automation (3 tasks) - Phase 4: Content Calendar (6 tasks) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
3478 lines
94 KiB
Markdown
3478 lines
94 KiB
Markdown
# 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<Array<{
|
|
id: string
|
|
views: number
|
|
likes: number
|
|
comments: number
|
|
}>> {
|
|
if (videoIds.length === 0) return []
|
|
|
|
try {
|
|
const response = await this.youtube.videos.list({
|
|
part: ['statistics'],
|
|
id: videoIds,
|
|
maxResults: 50,
|
|
})
|
|
|
|
return (response.data.items || []).map((item) => ({
|
|
id: item.id!,
|
|
views: parseInt(item.statistics?.viewCount || '0', 10),
|
|
likes: parseInt(item.statistics?.likeCount || '0', 10),
|
|
comments: parseInt(item.statistics?.commentCount || '0', 10),
|
|
}))
|
|
} catch (error) {
|
|
console.error('Error fetching video statistics:', error)
|
|
throw error
|
|
}
|
|
}
|
|
```
|
|
|
|
**Step 4: Run test to verify it passes**
|
|
|
|
Run: `pnpm vitest run tests/unit/youtube/youtube-client-stats.test.ts`
|
|
Expected: PASS
|
|
|
|
**Step 5: Commit**
|
|
|
|
```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<SyncResult> {
|
|
const result: SyncResult = {
|
|
success: false,
|
|
syncedCount: 0,
|
|
errors: [],
|
|
syncedAt: new Date(),
|
|
}
|
|
|
|
try {
|
|
// 1. YouTube-Client erstellen
|
|
const client = await this.getYouTubeClient(options)
|
|
if (!client) {
|
|
result.errors.push('Kein YouTube-Account gefunden oder keine gültigen Credentials')
|
|
return result
|
|
}
|
|
|
|
// 2. Veröffentlichte Videos mit YouTube-Video-ID laden
|
|
const videos = await this.payload.find({
|
|
collection: 'youtube-content',
|
|
where: {
|
|
channel: { equals: options.channelId },
|
|
status: { in: ['published', 'tracked'] },
|
|
},
|
|
limit: 500,
|
|
depth: 0,
|
|
})
|
|
|
|
// 3. Videos mit YouTube-ID filtern
|
|
const videosWithYtId = videos.docs.filter(
|
|
(doc: any) => doc.youtube?.videoId
|
|
)
|
|
|
|
if (videosWithYtId.length === 0) {
|
|
result.success = true
|
|
return result
|
|
}
|
|
|
|
// 4. In Batches von 50 verarbeiten (YouTube API Limit)
|
|
const batches = this.chunkArray(
|
|
videosWithYtId.map((v: any) => ({ id: v.id, ytVideoId: v.youtube.videoId })),
|
|
50
|
|
)
|
|
|
|
for (const batch of batches) {
|
|
try {
|
|
const ytIds = batch.map((v) => v.ytVideoId)
|
|
const stats = await client.getVideoStatistics(ytIds)
|
|
|
|
// Stats den Videos zuordnen und updaten
|
|
for (const stat of stats) {
|
|
const video = batch.find((v) => v.ytVideoId === stat.id)
|
|
if (!video) continue
|
|
|
|
await this.payload.update({
|
|
collection: 'youtube-content',
|
|
id: video.id,
|
|
data: {
|
|
performance: {
|
|
views: stat.views,
|
|
likes: stat.likes,
|
|
comments: stat.comments,
|
|
lastSyncedAt: new Date().toISOString(),
|
|
},
|
|
},
|
|
})
|
|
result.syncedCount++
|
|
}
|
|
} catch (batchError) {
|
|
result.errors.push(`Batch-Fehler: ${batchError}`)
|
|
}
|
|
}
|
|
|
|
result.success = true
|
|
} catch (error) {
|
|
result.errors.push(`Sync-Fehler: ${error}`)
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
/**
|
|
* YouTubeClient aus SocialAccount erstellen
|
|
*/
|
|
private async getYouTubeClient(options: SyncOptions): Promise<YouTubeClient | null> {
|
|
const accounts = await this.payload.find({
|
|
collection: 'social-accounts',
|
|
where: {
|
|
...(options.socialAccountId
|
|
? { id: { equals: options.socialAccountId } }
|
|
: {}),
|
|
},
|
|
depth: 2,
|
|
limit: 10,
|
|
})
|
|
|
|
const ytAccount = accounts.docs.find((acc: any) => {
|
|
const platform = acc.platform as { slug?: string }
|
|
return platform?.slug === 'youtube'
|
|
})
|
|
|
|
if (!ytAccount) return null
|
|
|
|
const credentials = ytAccount.credentials as {
|
|
accessToken?: string
|
|
refreshToken?: string
|
|
}
|
|
|
|
if (!credentials?.accessToken || !credentials?.refreshToken) return null
|
|
|
|
return new YouTubeClient(
|
|
{
|
|
clientId: process.env.YOUTUBE_CLIENT_ID!,
|
|
clientSecret: process.env.YOUTUBE_CLIENT_SECRET!,
|
|
accessToken: credentials.accessToken,
|
|
refreshToken: credentials.refreshToken,
|
|
},
|
|
this.payload,
|
|
)
|
|
}
|
|
|
|
private chunkArray<T>(arr: T[], size: number): T[][] {
|
|
const chunks: T[][] = []
|
|
for (let i = 0; i < arr.length; i += size) {
|
|
chunks.push(arr.slice(i, i + size))
|
|
}
|
|
return chunks
|
|
}
|
|
}
|
|
|
|
export type { SyncOptions, SyncResult }
|
|
```
|
|
|
|
**Step 4: Run test to verify it passes**
|
|
|
|
Run: `pnpm vitest run tests/unit/youtube/video-metrics-sync.test.ts`
|
|
Expected: PASS
|
|
|
|
**Step 5: Commit**
|
|
|
|
```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<ChannelSyncResult> {
|
|
const result: ChannelSyncResult = {
|
|
success: false,
|
|
channelsSynced: 0,
|
|
errors: [],
|
|
}
|
|
|
|
try {
|
|
// 1. Alle aktiven Kanäle laden
|
|
const channels = await this.payload.find({
|
|
collection: 'youtube-channels',
|
|
where: { status: { equals: 'active' } },
|
|
limit: 50,
|
|
depth: 0,
|
|
})
|
|
|
|
// 2. YouTube-Client holen
|
|
const client = await this.getYouTubeClient()
|
|
if (!client) {
|
|
result.errors.push('Kein YouTube-Account mit gültigen Credentials gefunden')
|
|
return result
|
|
}
|
|
|
|
// 3. Für jeden Kanal Statistiken abrufen
|
|
for (const channel of channels.docs) {
|
|
const ytChannelId = (channel as any).youtubeChannelId
|
|
if (!ytChannelId) {
|
|
result.errors.push(`Kanal ${(channel as any).id}: Keine YouTube Channel ID`)
|
|
continue
|
|
}
|
|
|
|
try {
|
|
const stats = await client.getChannelStats(ytChannelId)
|
|
|
|
await this.payload.update({
|
|
collection: 'youtube-channels',
|
|
id: (channel as any).id,
|
|
data: {
|
|
currentMetrics: {
|
|
subscriberCount: stats.subscriberCount,
|
|
totalViews: stats.viewCount,
|
|
videoCount: stats.videoCount,
|
|
lastSyncedAt: new Date().toISOString(),
|
|
},
|
|
},
|
|
})
|
|
result.channelsSynced++
|
|
} catch (error) {
|
|
result.errors.push(`Kanal ${ytChannelId}: ${error}`)
|
|
}
|
|
}
|
|
|
|
result.success = true
|
|
} catch (error) {
|
|
result.errors.push(`Sync-Fehler: ${error}`)
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
private async getYouTubeClient(): Promise<YouTubeClient | null> {
|
|
const accounts = await this.payload.find({
|
|
collection: 'social-accounts',
|
|
depth: 2,
|
|
limit: 10,
|
|
})
|
|
|
|
const ytAccount = accounts.docs.find((acc: any) => {
|
|
const platform = acc.platform as { slug?: string }
|
|
return platform?.slug === 'youtube'
|
|
})
|
|
|
|
if (!ytAccount) return null
|
|
|
|
const credentials = (ytAccount as any).credentials as {
|
|
accessToken?: string
|
|
refreshToken?: string
|
|
}
|
|
|
|
if (!credentials?.accessToken || !credentials?.refreshToken) return null
|
|
|
|
return new YouTubeClient(
|
|
{
|
|
clientId: process.env.YOUTUBE_CLIENT_ID!,
|
|
clientSecret: process.env.YOUTUBE_CLIENT_SECRET!,
|
|
accessToken: credentials.accessToken,
|
|
refreshToken: credentials.refreshToken,
|
|
},
|
|
this.payload,
|
|
)
|
|
}
|
|
}
|
|
```
|
|
|
|
**Step 4: Run test to verify it passes**
|
|
|
|
Run: `pnpm vitest run tests/unit/youtube/channel-metrics-sync.test.ts`
|
|
Expected: PASS
|
|
|
|
**Step 5: Commit**
|
|
|
|
```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<Job<YouTubeUploadJobData>> {
|
|
const queue = getQueue(QUEUE_NAMES.YOUTUBE_UPLOAD)
|
|
|
|
const job = await queue.add('youtube-upload', data, {
|
|
...defaultJobOptions,
|
|
attempts: 2, // Fewer retries for uploads (expensive quota)
|
|
backoff: { type: 'exponential', delay: 5000 },
|
|
})
|
|
|
|
console.log(`[YouTubeUploadQueue] Job ${job.id} queued for content ${data.contentId}`)
|
|
return job
|
|
}
|
|
|
|
export async function getYouTubeUploadJobStatus(jobId: string) {
|
|
const queue = getQueue(QUEUE_NAMES.YOUTUBE_UPLOAD)
|
|
const job = await queue.getJob(jobId)
|
|
if (!job) return null
|
|
|
|
const state = await job.getState()
|
|
return {
|
|
state,
|
|
progress: typeof job.progress === 'number' ? job.progress : 0,
|
|
result: job.returnvalue as YouTubeUploadJobResult | undefined,
|
|
failedReason: job.failedReason,
|
|
}
|
|
}
|
|
```
|
|
|
|
**Step 3: Commit**
|
|
|
|
```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<UploadResult> {
|
|
try {
|
|
// 1. YouTube-Client erstellen
|
|
const oauth2Client = await this.getOAuth2Client()
|
|
if (!oauth2Client) {
|
|
return { success: false, error: 'Keine gültigen YouTube-Credentials' }
|
|
}
|
|
|
|
const youtube = google.youtube({ version: 'v3', auth: oauth2Client })
|
|
|
|
// 2. Media-Datei laden
|
|
const media = await this.payload.findByID({
|
|
collection: 'media',
|
|
id: options.mediaId,
|
|
})
|
|
|
|
if (!media) {
|
|
return { success: false, error: 'Media-Datei nicht gefunden' }
|
|
}
|
|
|
|
const mediaDir = path.resolve(process.cwd(), 'media')
|
|
const filePath = path.join(mediaDir, (media as any).filename)
|
|
|
|
// 3. Video hochladen
|
|
const response = await youtube.videos.insert({
|
|
part: ['snippet', 'status'],
|
|
requestBody: {
|
|
snippet: {
|
|
title: options.metadata.title,
|
|
description: options.metadata.description,
|
|
tags: options.metadata.tags,
|
|
categoryId: options.metadata.categoryId || '22', // People & Blogs
|
|
},
|
|
status: {
|
|
privacyStatus: options.metadata.visibility,
|
|
...(options.scheduledPublishAt && options.metadata.visibility === 'private'
|
|
? {
|
|
publishAt: options.scheduledPublishAt,
|
|
privacyStatus: 'private',
|
|
}
|
|
: {}),
|
|
},
|
|
},
|
|
media: {
|
|
body: fs.createReadStream(filePath),
|
|
},
|
|
})
|
|
|
|
const videoId = response.data.id!
|
|
return {
|
|
success: true,
|
|
youtubeVideoId: videoId,
|
|
youtubeUrl: `https://www.youtube.com/watch?v=${videoId}`,
|
|
}
|
|
} catch (error) {
|
|
const msg = error instanceof Error ? error.message : String(error)
|
|
console.error('[VideoUploadService] Upload failed:', msg)
|
|
return { success: false, error: msg }
|
|
}
|
|
}
|
|
|
|
private async getOAuth2Client() {
|
|
const accounts = await this.payload.find({
|
|
collection: 'social-accounts',
|
|
depth: 2,
|
|
limit: 10,
|
|
})
|
|
|
|
const ytAccount = accounts.docs.find((acc: any) => {
|
|
const platform = acc.platform as { slug?: string }
|
|
return platform?.slug === 'youtube'
|
|
})
|
|
|
|
if (!ytAccount) return null
|
|
|
|
const credentials = (ytAccount as any).credentials as {
|
|
accessToken?: string
|
|
refreshToken?: string
|
|
}
|
|
if (!credentials?.accessToken) return null
|
|
|
|
const oauth2Client = new google.auth.OAuth2(
|
|
process.env.YOUTUBE_CLIENT_ID,
|
|
process.env.YOUTUBE_CLIENT_SECRET,
|
|
)
|
|
oauth2Client.setCredentials({
|
|
access_token: credentials.accessToken,
|
|
refresh_token: credentials.refreshToken,
|
|
})
|
|
|
|
return oauth2Client
|
|
}
|
|
}
|
|
```
|
|
|
|
**Step 4: Run test to verify it passes**
|
|
|
|
Run: `pnpm vitest run tests/unit/youtube/video-upload-service.test.ts`
|
|
Expected: PASS
|
|
|
|
**Step 5: Commit**
|
|
|
|
```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<YouTubeUploadJobData>,
|
|
): Promise<YouTubeUploadJobResult> {
|
|
const { contentId, mediaId, metadata, scheduledPublishAt, triggeredBy } = job.data
|
|
|
|
console.log(`[YouTubeUploadWorker] Processing job ${job.id} for content ${contentId}`)
|
|
|
|
try {
|
|
const payload = await getPayload({ config })
|
|
const uploadService = new VideoUploadService(payload)
|
|
|
|
const result = await uploadService.uploadVideo({
|
|
mediaId,
|
|
metadata,
|
|
scheduledPublishAt,
|
|
})
|
|
|
|
if (!result.success) {
|
|
throw new Error(result.error || 'Upload failed')
|
|
}
|
|
|
|
// Update YouTubeContent with video ID and URL
|
|
await payload.update({
|
|
collection: 'youtube-content',
|
|
id: contentId,
|
|
data: {
|
|
youtube: {
|
|
videoId: result.youtubeVideoId,
|
|
url: result.youtubeUrl,
|
|
},
|
|
status: 'published',
|
|
actualPublishDate: new Date().toISOString(),
|
|
},
|
|
})
|
|
|
|
// Notification erstellen
|
|
const notificationService = new NotificationService(payload)
|
|
await notificationService.createNotification({
|
|
recipientId: triggeredBy,
|
|
type: 'video_published',
|
|
title: `Video "${metadata.title}" erfolgreich hochgeladen`,
|
|
message: `YouTube-URL: ${result.youtubeUrl}`,
|
|
link: `/admin/collections/youtube-content/${contentId}`,
|
|
relatedVideoId: contentId,
|
|
})
|
|
|
|
return {
|
|
success: true,
|
|
youtubeVideoId: result.youtubeVideoId,
|
|
youtubeUrl: result.youtubeUrl,
|
|
timestamp: new Date().toISOString(),
|
|
}
|
|
} catch (error) {
|
|
const errorMsg = error instanceof Error ? error.message : String(error)
|
|
console.error(`[YouTubeUploadWorker] Job ${job.id} failed:`, errorMsg)
|
|
throw error
|
|
}
|
|
}
|
|
|
|
let uploadWorker: Worker<YouTubeUploadJobData, YouTubeUploadJobResult> | null = null
|
|
|
|
export function startYouTubeUploadWorker() {
|
|
if (uploadWorker) return uploadWorker
|
|
|
|
uploadWorker = new Worker<YouTubeUploadJobData, YouTubeUploadJobResult>(
|
|
QUEUE_NAMES.YOUTUBE_UPLOAD,
|
|
processUploadJob,
|
|
{
|
|
connection: getQueueRedisConnection(),
|
|
concurrency: CONCURRENCY,
|
|
stalledInterval: 120000, // 2min - uploads take time
|
|
maxStalledCount: 1,
|
|
},
|
|
)
|
|
|
|
uploadWorker.on('ready', () => console.log(`[YouTubeUploadWorker] Ready`))
|
|
uploadWorker.on('completed', (job) => console.log(`[YouTubeUploadWorker] Job ${job.id} completed`))
|
|
uploadWorker.on('failed', (job, err) => console.error(`[YouTubeUploadWorker] Job ${job?.id} failed:`, err.message))
|
|
|
|
return uploadWorker
|
|
}
|
|
|
|
export async function stopYouTubeUploadWorker() {
|
|
if (uploadWorker) {
|
|
await uploadWorker.close()
|
|
uploadWorker = null
|
|
}
|
|
}
|
|
```
|
|
|
|
**Step 2: Create the upload API route**
|
|
|
|
Create: `src/app/(payload)/api/youtube/upload/route.ts`
|
|
|
|
```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<string, CalendarEvent[]>()
|
|
for (const event of events) {
|
|
const key = `${event.channelId}-${event.scheduledDate.split('T')[0]}`
|
|
const existing = byDayChannel.get(key) || []
|
|
existing.push(event)
|
|
byDayChannel.set(key, existing)
|
|
}
|
|
|
|
for (const [, dayEvents] of byDayChannel) {
|
|
// Only flag if same content type on same day
|
|
const longforms = dayEvents.filter((e) => e.contentType === 'longform')
|
|
if (longforms.length > 1) {
|
|
conflicts.push({
|
|
type: 'same_day',
|
|
message: `${longforms.length} Longform-Videos am selben Tag geplant`,
|
|
eventIds: longforms.map((e) => e.id),
|
|
severity: 'error',
|
|
})
|
|
}
|
|
}
|
|
|
|
// 2. Weekly frequency check (per channel)
|
|
const byWeekChannel = new Map<string, CalendarEvent[]>()
|
|
for (const event of events) {
|
|
const date = parseISO(event.scheduledDate)
|
|
const weekStart = startOfWeek(date, { weekStartsOn: 1 })
|
|
const key = `${event.channelId}-${weekStart.toISOString()}`
|
|
const existing = byWeekChannel.get(key) || []
|
|
existing.push(event)
|
|
byWeekChannel.set(key, existing)
|
|
}
|
|
|
|
for (const [, weekEvents] of byWeekChannel) {
|
|
const longforms = weekEvents.filter((e) => e.contentType === 'longform')
|
|
const shorts = weekEvents.filter((e) => e.contentType === 'short')
|
|
|
|
if (longforms.length > schedule.longformPerWeek) {
|
|
conflicts.push({
|
|
type: 'frequency_exceeded',
|
|
message: `${longforms.length}/${schedule.longformPerWeek} Longform-Videos diese Woche`,
|
|
eventIds: longforms.map((e) => e.id),
|
|
severity: 'warning',
|
|
})
|
|
}
|
|
|
|
if (shorts.length > schedule.shortsPerWeek) {
|
|
conflicts.push({
|
|
type: 'frequency_exceeded',
|
|
message: `${shorts.length}/${schedule.shortsPerWeek} Shorts diese Woche`,
|
|
eventIds: shorts.map((e) => e.id),
|
|
severity: 'warning',
|
|
})
|
|
}
|
|
}
|
|
|
|
// 3. Weekend warnings
|
|
for (const event of events) {
|
|
const date = parseISO(event.scheduledDate)
|
|
const dayOfWeek = date.getDay() // 0=Sun, 6=Sat
|
|
if (dayOfWeek === 0 || dayOfWeek === 6) {
|
|
conflicts.push({
|
|
type: 'weekend',
|
|
message: 'Video am Wochenende geplant',
|
|
eventIds: [event.id],
|
|
severity: 'warning',
|
|
})
|
|
}
|
|
}
|
|
|
|
return conflicts
|
|
}
|
|
```
|
|
|
|
**Step 3: Run tests**
|
|
|
|
Run: `pnpm vitest run tests/unit/youtube/conflict-detection.test.ts`
|
|
Expected: PASS
|
|
|
|
**Step 4: Commit**
|
|
|
|
```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<string, any> = {
|
|
scheduledPublishDate: {
|
|
greater_than_equal: start,
|
|
less_than_equal: end,
|
|
},
|
|
status: { not_equals: 'discarded' },
|
|
}
|
|
if (channelId && channelId !== 'all') {
|
|
where.channel = { equals: parseInt(channelId) }
|
|
}
|
|
|
|
const videos = await payload.find({
|
|
collection: 'youtube-content',
|
|
where,
|
|
limit: 200,
|
|
depth: 1,
|
|
sort: 'scheduledPublishDate',
|
|
})
|
|
|
|
// Channel-Branding für Farben laden
|
|
const channels = await payload.find({
|
|
collection: 'youtube-channels',
|
|
where: { status: { equals: 'active' } },
|
|
limit: 50,
|
|
depth: 0,
|
|
})
|
|
const channelColorMap = new Map<number, string>()
|
|
for (const ch of channels.docs) {
|
|
const c = ch as any
|
|
channelColorMap.set(c.id, c.branding?.primaryColor || '#3788d8')
|
|
}
|
|
|
|
// Conflict detection
|
|
const calendarEvents: CalendarEvent[] = videos.docs.map((v: any) => ({
|
|
id: v.id,
|
|
channelId: typeof v.channel === 'object' ? v.channel.id : v.channel,
|
|
scheduledDate: v.scheduledPublishDate,
|
|
contentType: v.format || 'longform',
|
|
seriesId: typeof v.series === 'object' ? v.series?.id : v.series,
|
|
}))
|
|
|
|
// Get schedule config from first matching channel
|
|
let scheduleConfig = { longformPerWeek: 1, shortsPerWeek: 4 }
|
|
if (channels.docs.length > 0) {
|
|
const ch = channels.docs[0] as any
|
|
scheduleConfig = {
|
|
longformPerWeek: ch.publishingSchedule?.longformPerWeek || 1,
|
|
shortsPerWeek: ch.publishingSchedule?.shortsPerWeek || 4,
|
|
}
|
|
}
|
|
|
|
const conflicts = detectConflicts(calendarEvents, scheduleConfig)
|
|
const conflictEventIds = new Set(conflicts.flatMap((c) => c.eventIds))
|
|
|
|
// Format for FullCalendar
|
|
const events = videos.docs.map((v: any) => {
|
|
const chId = typeof v.channel === 'object' ? v.channel.id : v.channel
|
|
const chName = typeof v.channel === 'object' ? v.channel.name : undefined
|
|
const seriesName = typeof v.series === 'object' ? v.series?.name : undefined
|
|
|
|
return {
|
|
id: String(v.id),
|
|
title: typeof v.title === 'string' ? v.title : v.title?.de || v.title?.en || 'Untitled',
|
|
start: v.scheduledPublishDate,
|
|
color: channelColorMap.get(chId) || '#3788d8',
|
|
extendedProps: {
|
|
status: v.status,
|
|
contentType: v.format || 'longform',
|
|
channelId: chId,
|
|
channelName: chName,
|
|
seriesName,
|
|
hasConflict: conflictEventIds.has(v.id),
|
|
assignedTo: v.assignedTo,
|
|
},
|
|
}
|
|
})
|
|
|
|
return NextResponse.json({
|
|
events,
|
|
conflicts,
|
|
meta: {
|
|
totalEvents: events.length,
|
|
conflictsCount: conflicts.length,
|
|
},
|
|
})
|
|
} catch (error) {
|
|
console.error('[Calendar API] Error:', error)
|
|
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 })
|
|
}
|
|
}
|
|
|
|
// PATCH: Reschedule via drag & drop
|
|
export async function PATCH(request: NextRequest) {
|
|
try {
|
|
const payload = await getPayload({ config })
|
|
|
|
const { user } = await payload.auth({ headers: request.headers })
|
|
if (!user) {
|
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
|
}
|
|
|
|
const body = await request.json()
|
|
const { contentId, newDate } = body
|
|
|
|
if (!contentId || !newDate) {
|
|
return NextResponse.json({ error: 'contentId and newDate required' }, { status: 400 })
|
|
}
|
|
|
|
// Prüfe ob Video noch nicht veröffentlicht
|
|
const content = await payload.findByID({
|
|
collection: 'youtube-content',
|
|
id: contentId,
|
|
depth: 0,
|
|
})
|
|
|
|
const doc = content as any
|
|
if (['published', 'tracked'].includes(doc.status)) {
|
|
return NextResponse.json(
|
|
{ error: 'Veröffentlichte Videos können nicht verschoben werden' },
|
|
{ status: 400 },
|
|
)
|
|
}
|
|
|
|
await payload.update({
|
|
collection: 'youtube-content',
|
|
id: contentId,
|
|
data: {
|
|
scheduledPublishDate: newDate,
|
|
},
|
|
})
|
|
|
|
return NextResponse.json({
|
|
success: true,
|
|
contentId,
|
|
newDate,
|
|
})
|
|
} catch (error) {
|
|
console.error('[Calendar API] PATCH error:', error)
|
|
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 })
|
|
}
|
|
}
|
|
```
|
|
|
|
**Step 2: Commit**
|
|
|
|
```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<CalendarEvent[]>([])
|
|
const [conflicts, setConflicts] = useState<Conflict[]>([])
|
|
const [channels, setChannels] = useState<Channel[]>([])
|
|
const [selectedChannel, setSelectedChannel] = useState('all')
|
|
const [loading, setLoading] = useState(true)
|
|
|
|
const fetchEvents = useCallback(async (start: string, end: string) => {
|
|
setLoading(true)
|
|
try {
|
|
const params = new URLSearchParams({ start, end })
|
|
if (selectedChannel !== 'all') params.set('channelId', selectedChannel)
|
|
|
|
const res = await fetch(`/api/youtube/calendar?${params}`, { credentials: 'include' })
|
|
const data = await res.json()
|
|
|
|
setEvents(data.events || [])
|
|
setConflicts(data.conflicts || [])
|
|
} catch (error) {
|
|
console.error('Failed to fetch calendar events:', error)
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}, [selectedChannel])
|
|
|
|
// Fetch channels for filter
|
|
useEffect(() => {
|
|
fetch('/api/youtube-channels?limit=50&depth=0', { credentials: 'include' })
|
|
.then((r) => r.json())
|
|
.then((data) => setChannels(data.docs || []))
|
|
.catch(console.error)
|
|
}, [])
|
|
|
|
const handleDatesSet = useCallback((arg: { startStr: string; endStr: string }) => {
|
|
fetchEvents(arg.startStr, arg.endStr)
|
|
}, [fetchEvents])
|
|
|
|
const handleEventDrop = useCallback(async (info: any) => {
|
|
const { id } = info.event
|
|
const newDate = info.event.start.toISOString()
|
|
|
|
// Confirm dialog
|
|
if (!window.confirm(`Video auf ${info.event.start.toLocaleDateString('de')} verschieben?`)) {
|
|
info.revert()
|
|
return
|
|
}
|
|
|
|
try {
|
|
const res = await fetch('/api/youtube/calendar', {
|
|
method: 'PATCH',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
credentials: 'include',
|
|
body: JSON.stringify({ contentId: parseInt(id), newDate }),
|
|
})
|
|
|
|
if (!res.ok) {
|
|
const data = await res.json()
|
|
alert(data.error || 'Fehler beim Verschieben')
|
|
info.revert()
|
|
}
|
|
} catch {
|
|
info.revert()
|
|
}
|
|
}, [])
|
|
|
|
const handleEventClick = useCallback((info: any) => {
|
|
window.location.href = `/admin/collections/youtube-content/${info.event.id}`
|
|
}, [])
|
|
|
|
const renderEventContent = useCallback((eventInfo: any) => {
|
|
const { hasConflict, contentType, status } = eventInfo.event.extendedProps
|
|
|
|
const statusEmoji: Record<string, string> = {
|
|
idea: '\u{1F4A1}',
|
|
script_draft: '\u{270F}\u{FE0F}',
|
|
script_review: '\u{1F50D}',
|
|
approved: '\u{2705}',
|
|
upload_scheduled: '\u{2B06}\u{FE0F}',
|
|
published: '\u{1F4FA}',
|
|
tracked: '\u{1F4CA}',
|
|
}
|
|
|
|
return (
|
|
<div className={hasConflict ? styles.contentCalendar__conflict : ''}>
|
|
<span>{statusEmoji[status] || '\u{1F3AC}'} </span>
|
|
<span>{contentType === 'short' ? 'S' : 'L'} </span>
|
|
<b>{eventInfo.event.title}</b>
|
|
</div>
|
|
)
|
|
}, [])
|
|
|
|
return (
|
|
<div className={styles.contentCalendar}>
|
|
<div className={styles.contentCalendar__header}>
|
|
<h1 className={styles.contentCalendar__title}>Content-Kalender</h1>
|
|
<div className={styles.contentCalendar__filters}>
|
|
<select
|
|
className={styles.contentCalendar__select}
|
|
value={selectedChannel}
|
|
onChange={(e) => setSelectedChannel(e.target.value)}
|
|
>
|
|
<option value="all">Alle Kanäle</option>
|
|
{channels.map((ch) => (
|
|
<option key={ch.id} value={ch.id}>{ch.name}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
<div className={styles.contentCalendar__calendar}>
|
|
<FullCalendar
|
|
plugins={[dayGridPlugin, timeGridPlugin, interactionPlugin, listPlugin]}
|
|
initialView="dayGridMonth"
|
|
locale="de"
|
|
headerToolbar={{
|
|
left: 'prev,next today',
|
|
center: 'title',
|
|
right: 'dayGridMonth,timeGridWeek,listWeek',
|
|
}}
|
|
events={events}
|
|
editable={true}
|
|
droppable={false}
|
|
eventDrop={handleEventDrop}
|
|
eventClick={handleEventClick}
|
|
eventContent={renderEventContent}
|
|
datesSet={handleDatesSet}
|
|
height="auto"
|
|
firstDay={1}
|
|
eventTimeFormat={{ hour: '2-digit', minute: '2-digit', hour12: false }}
|
|
/>
|
|
</div>
|
|
|
|
{/* Channel Legend */}
|
|
{channels.length > 0 && (
|
|
<div className={styles.contentCalendar__legend}>
|
|
{channels.map((ch) => (
|
|
<div key={ch.id} className={styles.contentCalendar__legendItem}>
|
|
<div
|
|
className={styles.contentCalendar__legendColor}
|
|
style={{ background: ch.branding?.primaryColor || '#3788d8' }}
|
|
/>
|
|
<span>{ch.name}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* Conflict warnings */}
|
|
{conflicts.length > 0 && (
|
|
<div className={styles.contentCalendar__conflicts}>
|
|
<h4>Konflikte ({conflicts.length})</h4>
|
|
<ul>
|
|
{conflicts.map((c, i) => (
|
|
<li key={i}>{c.message}</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
```
|
|
|
|
**Step 3: Commit**
|
|
|
|
```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 <ContentCalendar />
|
|
}
|
|
```
|
|
|
|
**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 (
|
|
<NavGroup label="YouTube">
|
|
<a
|
|
href="/admin/content-calendar"
|
|
style={{
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
padding: '0.5rem 1rem',
|
|
color: 'var(--theme-text)',
|
|
textDecoration: 'none',
|
|
fontSize: '0.875rem',
|
|
}}
|
|
>
|
|
Content-Kalender
|
|
</a>
|
|
</NavGroup>
|
|
)
|
|
}
|
|
```
|
|
|
|
**Step 3: Register in payload.config.ts**
|
|
|
|
In `src/payload.config.ts`:
|
|
|
|
1. Add to `afterNavLinks` array (line 131-134):
|
|
|
|
```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 |
|