feat(YouTube): add YouTube Operations Hub with YtSeries collection

Complete YouTube content management system:
- YouTubeChannels: Channel management with branding and metrics
- YouTubeContent: Video pipeline with workflow, approvals, scheduling
- YtSeries: Dedicated series management per channel (NEW)
- YtBatches: Production batch tracking with targets and progress
- YtTasks: Task management with notifications
- YtNotifications: User notification system
- YtMonthlyGoals: Monthly production goals per channel
- YtScriptTemplates: Reusable script templates
- YtChecklistTemplates: Checklist templates for workflows

Features:
- Role-based access (YouTubeManager, YouTubeCreator, YouTubeViewer)
- Auto-task generation on status changes
- Series relationship with channel-based filtering
- API endpoints for dashboard, tasks, and task completion
- German/English localization support

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Martin Porwoll 2026-01-13 14:54:40 +00:00
parent 15f3fa2481
commit 3294fbb506
25 changed files with 6553 additions and 43 deletions

View file

@ -0,0 +1,506 @@
# YouTube Operations Hub - Konzeptübersicht
## 1. Übersicht
Der YouTube Operations Hub ist ein integriertes Modul im Payload CMS zur Verwaltung von YouTube-Kanälen, Video-Content, Aufgaben und Team-Workflows. Er wurde speziell für Multi-Channel-Betreiber entwickelt, die mehrere YouTube-Kanäle professionell managen.
### Hauptfunktionen
- **Kanalverwaltung**: Mehrere YouTube-Kanäle mit Branding und Metriken
- **Content-Pipeline**: Video-Workflow von Idee bis Veröffentlichung
- **Task-Management**: Aufgabenzuweisung und -verfolgung
- **Benachrichtigungen**: Systemweite Notifications für Team-Mitglieder
- **Rollenbasierter Zugriff**: Feingranulare Berechtigungen
---
## 2. Datenmodell
### 2.1 Collections
```
┌─────────────────────┐ ┌─────────────────────┐
│ YouTube Channels │────<│ YouTube Content │
│ (youtube-channels) │ │ (youtube-content) │
└─────────────────────┘ └─────────────────────┘
│ │
│ │
▼ ▼
┌─────────────────────┐ ┌─────────────────────┐
│ YT Tasks │────<│ YT Notifications │
│ (yt-tasks) │ │ (yt-notifications) │
└─────────────────────┘ └─────────────────────┘
┌─────────────────────┐
│ Users │
│ (mit youtubeRole) │
└─────────────────────┘
```
### 2.2 YouTube Channels (`youtube-channels`)
Verwaltet YouTube-Kanäle mit allen relevanten Metadaten.
| Feld | Typ | Beschreibung |
|------|-----|--------------|
| `name` | text (lokalisiert) | Kanalname (z.B. "BlogWoman by Caroline Porwoll") |
| `slug` | text (unique) | Interner Kurzname (z.B. "blogwoman") |
| `youtubeChannelId` | text | YouTube Channel ID (UCxxxxx) |
| `youtubeHandle` | text | YouTube Handle (@blogwoman) |
| `language` | select | Sprache: `de`, `en` |
| `category` | select | Kategorie: `lifestyle`, `corporate`, `b2b` |
| `status` | select | Status: `active`, `planned`, `paused`, `archived` |
**Branding-Gruppe:**
| Feld | Typ | Beschreibung |
|------|-----|--------------|
| `branding.primaryColor` | text | Primärfarbe (Hex, z.B. #1278B3) |
| `branding.secondaryColor` | text | Sekundärfarbe (Hex) |
| `branding.logo` | upload (media) | Kanal-Logo |
| `branding.thumbnailTemplate` | upload (media) | Thumbnail-Vorlage |
**Content-Serien (Array):**
| Feld | Typ | Beschreibung |
|------|-----|--------------|
| `contentSeries[].name` | text (lokalisiert) | Serienname |
| `contentSeries[].slug` | text | Serien-Slug |
| `contentSeries[].description` | textarea (lokalisiert) | Beschreibung |
| `contentSeries[].color` | text | Farbe für UI |
| `contentSeries[].isActive` | checkbox | Aktiv-Status |
**Veröffentlichungsplan:**
| Feld | Typ | Beschreibung |
|------|-----|--------------|
| `publishingSchedule.defaultDays` | select (hasMany) | Standard-Veröffentlichungstage |
| `publishingSchedule.defaultTime` | text | Standard-Uhrzeit |
| `publishingSchedule.shortsPerWeek` | number | Shorts pro Woche (Default: 4) |
| `publishingSchedule.longformPerWeek` | number | Longform pro Woche (Default: 1) |
**Metriken (via YouTube API):**
| Feld | Typ | Beschreibung |
|------|-----|--------------|
| `currentMetrics.subscriberCount` | number | Abonnenten |
| `currentMetrics.totalViews` | number | Gesamtaufrufe |
| `currentMetrics.videoCount` | number | Anzahl Videos |
| `currentMetrics.lastSyncedAt` | date | Letzter Sync |
### 2.3 YouTube Content (`youtube-content`)
Verwaltet einzelne Videos durch den gesamten Produktionsprozess.
| Feld | Typ | Beschreibung |
|------|-----|--------------|
| `title` | text (lokalisiert) | Video-Titel |
| `slug` | text | URL-Slug |
| `channel` | relationship | Zugehöriger Kanal |
| `contentSeries` | text | Content-Serie |
| `format` | select | Format: `short`, `longform`, `premiere` |
| `status` | select | Status (siehe Status-Workflow) |
| `priority` | select | Priorität: `urgent`, `high`, `normal`, `low` |
| `assignedTo` | relationship (users) | Zuständige Person |
| `createdBy` | relationship (users) | Ersteller |
**Status-Workflow:**
```
idea → script_draft → script_review → script_approved →
shoot_scheduled → shot → rough_cut → fine_cut →
final_review → approved → upload_scheduled → published → tracked
discarded
```
**Script-Felder:**
| Feld | Typ | Beschreibung |
|------|-----|--------------|
| `description` | textarea (lokalisiert) | Beschreibung |
| `hook` | text (lokalisiert) | Hook/Einstieg |
| `keyPoints[]` | array (lokalisiert) | Kernpunkte |
| `callToAction` | text (lokalisiert) | Call-to-Action |
| `scriptContent` | richText (lokalisiert) | Vollständiges Script |
| `scriptUrl` | text | Link zu externem Script |
**Zeitplanung:**
| Feld | Typ | Beschreibung |
|------|-----|--------------|
| `shootDate` | date | Drehdatum |
| `editDeadline` | date | Schnitt-Deadline |
| `reviewDeadline` | date | Review-Deadline |
| `scheduledPublishDate` | date | Geplante Veröffentlichung |
| `actualPublishDate` | date | Tatsächliche Veröffentlichung |
**Medien:**
| Feld | Typ | Beschreibung |
|------|-----|--------------|
| `thumbnail` | upload (media) | Haupt-Thumbnail |
| `thumbnailAlt` | upload (media) | Alternatives Thumbnail |
| `videoFile` | upload (media) | Video-Datei |
| `rawFootage[]` | array | Rohmaterial |
**Freigaben:**
| Freigabe | Felder |
|----------|--------|
| Script Approval | `approved`, `approvedBy`, `approvedAt`, `notes` |
| Medical Approval | `required`, `approved`, `approvedBy`, `approvedAt`, `notes` |
| Legal Approval | `approved`, `approvedBy`, `approvedAt`, `notes`, `disclaimerIncluded` |
| Final Approval | `approved`, `approvedBy`, `approvedAt`, `notes` |
**YouTube-Metadaten:**
| Feld | Typ | Beschreibung |
|------|-----|--------------|
| `youtubeVideoId` | text | YouTube Video ID |
| `youtubeUrl` | text | YouTube URL |
| `youtubeMetadata.youtubeTitle` | text (lokalisiert) | YouTube-Titel |
| `youtubeMetadata.youtubeDescription` | textarea (lokalisiert) | YouTube-Beschreibung |
| `youtubeMetadata.tags[]` | array | Tags |
| `youtubeMetadata.visibility` | select | `public`, `unlisted`, `private` |
| `youtubeMetadata.chapters` | textarea | Kapitelmarken |
| `youtubeMetadata.pinnedComment` | textarea (lokalisiert) | Angepinnter Kommentar |
**Performance-Metriken:**
| Feld | Typ | Beschreibung |
|------|-----|--------------|
| `performance.views` | number | Aufrufe |
| `performance.likes` | number | Likes |
| `performance.comments` | number | Kommentare |
| `performance.shares` | number | Shares |
| `performance.watchTimeMinutes` | number | Watch Time |
| `performance.avgViewDuration` | number | Avg. View Duration |
| `performance.avgViewPercentage` | number | Avg. View % |
| `performance.ctr` | number | Click-Through-Rate |
| `performance.impressions` | number | Impressions |
| `performance.subscribersGained` | number | Gewonnene Abonnenten |
### 2.4 YT Tasks (`yt-tasks`)
Aufgabenverwaltung für Video-Produktion.
| Feld | Typ | Beschreibung |
|------|-----|--------------|
| `title` | text (lokalisiert) | Aufgabentitel |
| `description` | textarea (lokalisiert) | Beschreibung |
| `video` | relationship | Zugehöriges Video |
| `channel` | relationship | Zugehöriger Kanal |
| `taskType` | select | Aufgabentyp |
| `status` | select | Status |
| `priority` | select | Priorität |
| `assignedTo` | relationship (users) | Zuständige Person |
| `dueDate` | date | Fälligkeitsdatum |
| `completedAt` | date | Abschlussdatum |
| `completedBy` | relationship (users) | Abgeschlossen von |
| `blockedReason` | text (lokalisiert) | Blockierungsgrund |
| `attachments[]` | array | Anhänge (file, note) |
| `comments[]` | array | Kommentare (author, content, createdAt) |
**Task Types:**
- `script_write` - Script schreiben
- `script_review` - Script Review
- `shoot_prep` - Dreh-Vorbereitung
- `shoot` - Dreh
- `edit` - Schnitt
- `graphics` - Grafiken
- `thumbnail` - Thumbnail
- `review` - Review
- `upload` - Upload
- `track` - Tracking
- `comments` - Kommentare beantworten
- `other` - Sonstiges
**Task Status:**
- `todo` - Offen
- `in_progress` - In Bearbeitung
- `blocked` - Blockiert
- `waiting_review` - Wartet auf Review
- `done` - Erledigt
- `cancelled` - Abgebrochen
### 2.5 YT Notifications (`yt-notifications`)
Benachrichtigungssystem für Team-Mitglieder.
| Feld | Typ | Beschreibung |
|------|-----|--------------|
| `title` | text (lokalisiert) | Benachrichtigungstitel |
| `message` | textarea (lokalisiert) | Nachricht |
| `recipient` | relationship (users) | Empfänger |
| `type` | select | Benachrichtigungstyp |
| `link` | text | Link zur Aktion |
| `relatedVideo` | relationship | Zugehöriges Video |
| `relatedTask` | relationship | Zugehörige Aufgabe |
| `read` | checkbox | Gelesen |
| `readAt` | date | Gelesen am |
| `emailSent` | checkbox | E-Mail gesendet |
**Notification Types:**
- `task_assigned` - Aufgabe zugewiesen
- `task_due` - Aufgabe fällig
- `task_overdue` - Aufgabe überfällig
- `approval_required` - Freigabe erforderlich
- `approved` - Freigegeben
- `rejected` - Abgelehnt
- `video_published` - Video veröffentlicht
- `comment` - Kommentar
- `mention` - Erwähnung
- `system` - Systemmeldung
---
## 3. User-Erweiterung
### 3.1 Neue Felder in Users Collection
| Feld | Typ | Beschreibung |
|------|-----|--------------|
| `youtubeRole` | select | YouTube-Rolle des Users |
| `youtubeChannels` | relationship (hasMany) | Zugewiesene Kanäle |
### 3.2 YouTube-Rollen
| Rolle | Beschreibung | Berechtigungen |
|-------|--------------|----------------|
| `none` | Kein YouTube-Zugriff | - |
| `viewer` | Nur Ansicht | Lesen |
| `editor` | Bearbeiter | Lesen, eigene Inhalte bearbeiten |
| `producer` | Produzent | Lesen, Bearbeiten, Tasks verwalten |
| `creator` | Creator | Wie Producer + Inhalte erstellen |
| `manager` | Manager | Vollzugriff auf alle YouTube-Funktionen |
---
## 4. Access Control
### 4.1 Zugriffsfunktionen (`src/lib/youtubeAccess.ts`)
```typescript
// Manager oder Super-Admin
isYouTubeManager: Access
// Creator oder höher (creator, manager)
isYouTubeCreatorOrAbove: Access
// Mindestens Viewer-Zugriff
hasYouTubeAccess: Access
// Zugewiesene Inhalte oder Manager
canAccessAssignedContent: Access
// Eigene Benachrichtigungen
canAccessOwnNotifications: Access
```
### 4.2 Berechtigungsmatrix
| Collection | Read | Create | Update | Delete |
|------------|------|--------|--------|--------|
| youtube-channels | hasYouTubeAccess | isYouTubeManager | isYouTubeManager | isYouTubeManager |
| youtube-content | canAccessAssignedContent | isYouTubeCreatorOrAbove | canAccessAssignedContent | isYouTubeManager |
| yt-tasks | canAccessAssignedContent | isYouTubeCreatorOrAbove | canAccessAssignedContent | isYouTubeManager |
| yt-notifications | canAccessOwnNotifications | isYouTubeCreatorOrAbove | canAccessOwnNotifications | isYouTubeManager |
---
## 5. API Endpoints
### 5.1 Custom API Routes
| Endpoint | Methode | Beschreibung |
|----------|---------|--------------|
| `/api/youtube/dashboard` | GET | Dashboard-Daten (Stats, Tasks, Videos) |
| `/api/youtube/my-tasks` | GET | Eigene offene Aufgaben |
| `/api/youtube/complete-task/[id]` | POST | Aufgabe als erledigt markieren |
### 5.2 Standard Payload API
| Endpoint | Beschreibung |
|----------|--------------|
| `/api/youtube-channels` | CRUD für Kanäle |
| `/api/youtube-content` | CRUD für Videos |
| `/api/yt-tasks` | CRUD für Aufgaben |
| `/api/yt-notifications` | CRUD für Benachrichtigungen |
---
## 6. Datenbank-Schema
### 6.1 Haupttabellen
```sql
youtube_channels
youtube_channels_locales
youtube_channels_content_series
youtube_channels_content_series_locales
youtube_channels_publishing_schedule_default_days
youtube_content
youtube_content_locales
youtube_content_key_points
youtube_content_key_points_locales
youtube_content_raw_footage
youtube_content_youtube_metadata_tags
yt_tasks
yt_tasks_locales
yt_tasks_attachments
yt_tasks_comments
yt_notifications
yt_notifications_locales
users_rels (für youtubeChannels Relationship)
```
### 6.2 Enums
```sql
enum_users_youtube_role
enum_youtube_channels_language
enum_youtube_channels_category
enum_youtube_channels_status
enum_youtube_content_format
enum_youtube_content_status
enum_youtube_content_priority
enum_youtube_content_visibility
enum_yt_tasks_task_type
enum_yt_tasks_status
enum_yt_tasks_priority
enum_yt_notifications_type
```
---
## 7. Hooks & Automatisierungen
### 7.1 Task-Erstellung Hook
Bei Erstellung eines neuen Videos wird automatisch eine Task erstellt:
```typescript
// src/hooks/youtube/createTaskOnVideoCreate.ts
afterChange: async ({ doc, operation, req }) => {
if (operation === 'create') {
await req.payload.create({
collection: 'yt-tasks',
data: {
title: `Script für "${doc.title}"`,
video: doc.id,
channel: doc.channel,
taskType: 'script_write',
assignedTo: doc.assignedTo,
// ...
}
})
}
}
```
### 7.2 Benachrichtigungs-Hook
Bei Task-Zuweisung wird automatisch eine Notification erstellt:
```typescript
// src/hooks/youtube/notifyOnTaskAssignment.ts
afterChange: async ({ doc, previousDoc, operation, req }) => {
if (doc.assignedTo !== previousDoc?.assignedTo) {
await req.payload.create({
collection: 'yt-notifications',
data: {
title: 'Neue Aufgabe zugewiesen',
recipient: doc.assignedTo,
type: 'task_assigned',
relatedTask: doc.id,
// ...
}
})
}
}
```
---
## 8. Admin-UI Konfiguration
### 8.1 Gruppierung
Alle YouTube-Collections sind unter der Gruppe "YouTube" zusammengefasst:
```typescript
admin: {
group: 'YouTube',
}
```
### 8.2 Spalten-Konfiguration
| Collection | Spalten |
|------------|---------|
| youtube-channels | name, youtubeHandle, status, language |
| youtube-content | title, channel, status, format, scheduledPublishDate |
| yt-tasks | title, video, taskType, status, assignedTo, dueDate |
| yt-notifications | title, type, recipient, read, createdAt |
---
## 9. Mehrsprachigkeit
Alle kundenorientierten Felder sind lokalisiert (de/en):
- Kanal-Namen
- Video-Titel und Beschreibungen
- Scripts
- Task-Titel und Beschreibungen
- Benachrichtigungen
- Content-Serien
---
## 10. Dateien-Übersicht
```
src/
├── collections/
│ ├── YouTubeChannels.ts
│ ├── YouTubeContent.ts
│ ├── YtTasks.ts
│ └── YtNotifications.ts
├── lib/
│ └── youtubeAccess.ts
├── hooks/youtube/
│ ├── createTaskOnVideoCreate.ts
│ └── notifyOnTaskAssignment.ts
├── app/(payload)/api/youtube/
│ ├── dashboard/route.ts
│ ├── my-tasks/route.ts
│ └── complete-task/[id]/route.ts
└── migrations/
└── 20260112_150000_add_youtube_operations_hub.ts
```
---
## 11. Erweiterungsmöglichkeiten
### Geplante Features
1. **YouTube API Integration**
- Automatischer Metriken-Sync
- Video-Upload direkt aus CMS
- Kommentar-Import
2. **Analytics Dashboard**
- Performance-Vergleich zwischen Videos
- Trend-Analyse
- ROI-Berechnung
3. **Workflow-Automatisierung**
- Automatische Status-Änderungen
- Deadline-Erinnerungen
- Team-Kapazitätsplanung
4. **Content-Kalender**
- Visuelle Übersicht
- Drag & Drop Planung
- Konflikt-Erkennung
---
*Letzte Aktualisierung: 12.01.2026*

View file

@ -0,0 +1,80 @@
// src/app/(payload)/api/youtube/complete-task/[id]/route.ts
import { getPayload } from 'payload'
import config from '@payload-config'
import { NextRequest, NextResponse } from 'next/server'
interface UserWithYouTubeRole {
id: number
isSuperAdmin?: boolean
youtubeRole?: string
}
interface RouteParams {
params: Promise<{ id: string }>
}
/**
* POST /api/youtube/complete-task/[id]
*
* Markiert einen Task als erledigt.
* Nur der zugewiesene User oder ein Manager/Super-Admin kann dies tun.
*/
export async function POST(req: NextRequest, { params }: RouteParams) {
try {
const { id: taskId } = await params
const payload = await getPayload({ config })
const { user } = await payload.auth({ headers: req.headers })
if (!user || !taskId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const typedUser = user as UserWithYouTubeRole
// Prüfe YouTube-Zugriff
if (!typedUser.isSuperAdmin && (!typedUser.youtubeRole || typedUser.youtubeRole === 'none')) {
return NextResponse.json({ error: 'No YouTube access' }, { status: 403 })
}
const task = await payload.findByID({
collection: 'yt-tasks',
id: taskId,
depth: 0,
})
if (!task) {
return NextResponse.json({ error: 'Task not found' }, { status: 404 })
}
// Prüfen ob User berechtigt ist
const assignedId = typeof task.assignedTo === 'object' ? task.assignedTo.id : task.assignedTo
const canComplete =
typedUser.isSuperAdmin ||
typedUser.youtubeRole === 'manager' ||
assignedId === user.id
if (!canComplete) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
const updated = await payload.update({
collection: 'yt-tasks',
id: taskId,
data: {
status: 'done',
completedAt: new Date().toISOString(),
completedBy: user.id,
},
})
return NextResponse.json({ success: true, task: updated })
} catch (error) {
console.error('[YouTube Complete-Task] Error:', error)
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Unknown error' },
{ status: 500 }
)
}
}

View file

@ -0,0 +1,141 @@
// src/app/(payload)/api/youtube/dashboard/route.ts
import { getPayload } from 'payload'
import config from '@payload-config'
import { NextRequest, NextResponse } from 'next/server'
interface UserWithYouTubeRole {
id: number
isSuperAdmin?: boolean
youtubeRole?: string
}
/**
* GET /api/youtube/dashboard
*
* Liefert Dashboard-Daten für das YouTube Operations Hub:
* - Aktive Kanäle
* - Pipeline-Status (Anzahl Videos pro Status)
* - Ausstehende Freigaben (für Manager)
* - Überfällige Tasks
* - Videos dieser Woche
* - Gesamtzahl offener Tasks
*/
export async function GET(req: NextRequest) {
try {
const payload = await getPayload({ config })
const { user } = await payload.auth({ headers: req.headers })
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const typedUser = user as UserWithYouTubeRole
// Prüfe YouTube-Zugriff
if (!typedUser.isSuperAdmin && (!typedUser.youtubeRole || typedUser.youtubeRole === 'none')) {
return NextResponse.json({ error: 'No YouTube access' }, { status: 403 })
}
// Kanäle abrufen
const channels = await payload.find({
collection: 'youtube-channels',
where: { status: { equals: 'active' } },
depth: 0,
})
// Pipeline-Status zählen (parallel für Performance)
const [idea, scriptDraft, review, production, editing, ready, published] = await Promise.all([
payload.count({ collection: 'youtube-content', where: { status: { equals: 'idea' } } }),
payload.count({ collection: 'youtube-content', where: { status: { equals: 'script_draft' } } }),
payload.count({
collection: 'youtube-content',
where: { status: { in: ['script_review', 'final_review'] } },
}),
payload.count({
collection: 'youtube-content',
where: { status: { in: ['shoot_scheduled', 'shot'] } },
}),
payload.count({
collection: 'youtube-content',
where: { status: { in: ['rough_cut', 'fine_cut'] } },
}),
payload.count({ collection: 'youtube-content', where: { status: { equals: 'approved' } } }),
payload.count({ collection: 'youtube-content', where: { status: { equals: 'published' } } }),
])
// Ausstehende Freigaben für Manager
const pendingApprovals =
typedUser.youtubeRole === 'manager' || typedUser.isSuperAdmin
? await payload.find({
collection: 'youtube-content',
where: {
or: [{ status: { equals: 'script_review' } }, { status: { equals: 'final_review' } }],
},
limit: 10,
depth: 1,
})
: { docs: [] }
// Überfällige Tasks
const overdueTasks = await payload.find({
collection: 'yt-tasks',
where: {
and: [
{ status: { not_in: ['done', 'cancelled'] } },
{ dueDate: { less_than: new Date().toISOString() } },
],
},
depth: 1,
limit: 20,
})
// Videos diese Woche geplant
const weekStart = new Date()
weekStart.setHours(0, 0, 0, 0)
const weekEnd = new Date(weekStart)
weekEnd.setDate(weekEnd.getDate() + 7)
const thisWeekVideos = await payload.find({
collection: 'youtube-content',
where: {
scheduledPublishDate: {
greater_than_equal: weekStart.toISOString(),
less_than: weekEnd.toISOString(),
},
},
sort: 'scheduledPublishDate',
depth: 1,
limit: 20,
})
// Offene Tasks zählen
const tasksTotal = await payload.count({
collection: 'yt-tasks',
where: { status: { not_equals: 'done' } },
})
return NextResponse.json({
channels: channels.docs,
pipeline: {
idea: idea.totalDocs,
script: scriptDraft.totalDocs,
review: review.totalDocs,
production: production.totalDocs,
editing: editing.totalDocs,
ready: ready.totalDocs,
published: published.totalDocs,
},
pendingApprovals: pendingApprovals.docs,
overdueTasks: overdueTasks.docs,
thisWeekVideos: thisWeekVideos.docs,
tasksTotal: tasksTotal.totalDocs,
})
} catch (error) {
console.error('[YouTube Dashboard] Error:', error)
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Unknown error' },
{ status: 500 }
)
}
}

View file

@ -0,0 +1,58 @@
// src/app/(payload)/api/youtube/my-tasks/route.ts
import { getPayload } from 'payload'
import config from '@payload-config'
import { NextRequest, NextResponse } from 'next/server'
interface UserWithYouTubeRole {
id: number
isSuperAdmin?: boolean
youtubeRole?: string
}
/**
* GET /api/youtube/my-tasks
*
* Liefert alle offenen Tasks des eingeloggten Users.
* Sortiert nach Fälligkeitsdatum.
*/
export async function GET(req: NextRequest) {
try {
const payload = await getPayload({ config })
const { user } = await payload.auth({ headers: req.headers })
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const typedUser = user as UserWithYouTubeRole
// Prüfe YouTube-Zugriff
if (!typedUser.isSuperAdmin && (!typedUser.youtubeRole || typedUser.youtubeRole === 'none')) {
return NextResponse.json({ error: 'No YouTube access' }, { status: 403 })
}
const tasks = await payload.find({
collection: 'yt-tasks',
where: {
and: [
{ assignedTo: { equals: user.id } },
{ status: { not_in: ['done', 'cancelled'] } },
],
},
sort: 'dueDate',
depth: 2, // Video und Channel laden
})
return NextResponse.json({
tasks: tasks.docs,
total: tasks.totalDocs,
})
} catch (error) {
console.error('[YouTube My-Tasks] Error:', error)
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Unknown error' },
{ status: 500 }
)
}
}

View file

@ -0,0 +1,30 @@
import type { Block } from 'payload'
export const ScriptSectionBlock: Block = {
slug: 'script-section',
dbName: 'script_sec',
labels: {
singular: 'Script Section',
plural: 'Script Sections',
},
fields: [
{
name: 'sectionType',
type: 'select',
dbName: 'sec_type',
required: true,
label: 'Section Type',
options: [
{ label: 'Hook', value: 'hook' },
{ label: 'Intro', value: 'intro_ident' },
{ label: 'Content', value: 'content_part' },
{ label: 'Outro', value: 'outro' },
],
},
{
name: 'duration',
type: 'text',
label: 'Duration',
},
],
}

View file

@ -59,5 +59,36 @@ export const Users: CollectionConfig = {
position: 'sidebar', position: 'sidebar',
}, },
}, },
// YouTube Operations Hub Felder
{
name: 'youtubeRole',
type: 'select',
label: 'YouTube-Rolle',
admin: {
position: 'sidebar',
description: 'Rolle im YouTube Operations Hub',
},
options: [
{ label: 'Kein Zugriff', value: 'none' },
{ label: 'Viewer (nur Lesen)', value: 'viewer' },
{ label: 'Editor (Schnitt)', value: 'editor' },
{ label: 'Producer (Produktion)', value: 'producer' },
{ label: 'Creator (Inhalte)', value: 'creator' },
{ label: 'Manager (Vollzugriff)', value: 'manager' },
],
defaultValue: 'none',
},
{
name: 'youtubeChannels',
type: 'relationship',
relationTo: 'youtube-channels',
hasMany: true,
label: 'YouTube-Kanäle',
admin: {
position: 'sidebar',
description: 'Zugewiesene YouTube-Kanäle',
condition: (data) => data?.youtubeRole && data.youtubeRole !== 'none',
},
},
], ],
} }

View file

@ -0,0 +1,218 @@
// src/collections/YouTubeChannels.ts
import type { CollectionConfig } from 'payload'
import { isYouTubeManager, hasYouTubeAccess } from '../lib/youtubeAccess'
/**
* YouTubeChannels Collection
*
* Verwaltet YouTube-Kanäle mit Branding, Content-Serien und Metriken.
* Teil des YouTube Operations Hub.
*/
export const YouTubeChannels: CollectionConfig = {
slug: 'youtube-channels',
labels: {
singular: 'YouTube-Kanal',
plural: 'YouTube-Kanäle',
},
admin: {
useAsTitle: 'name',
group: 'YouTube',
defaultColumns: ['name', 'youtubeHandle', 'status', 'language'],
description: 'YouTube-Kanäle und ihre Konfiguration',
},
access: {
read: hasYouTubeAccess,
create: isYouTubeManager,
update: isYouTubeManager,
delete: isYouTubeManager,
},
fields: [
{
name: 'name',
type: 'text',
required: true,
localized: true,
label: 'Kanalname',
admin: {
description: 'z.B. "BlogWoman by Caroline Porwoll"',
},
},
{
name: 'slug',
type: 'text',
required: true,
unique: true,
label: 'Slug',
admin: {
description: 'Interner Kurzname (z.B. "blogwoman", "corporate-de")',
},
},
{
name: 'youtubeChannelId',
type: 'text',
required: true,
label: 'YouTube Channel ID',
admin: {
description: 'Die YouTube Channel ID (z.B. "UCxxxxxxxxxxxxx")',
},
},
{
name: 'youtubeHandle',
type: 'text',
label: 'YouTube Handle',
admin: {
description: 'z.B. "@blogwoman" oder "@zweitmeinu.ng"',
},
},
{
name: 'language',
type: 'select',
required: true,
label: 'Sprache',
options: [
{ label: 'Deutsch', value: 'de' },
{ label: 'Englisch', value: 'en' },
],
admin: {
position: 'sidebar',
},
},
{
name: 'category',
type: 'select',
required: true,
label: 'Kategorie',
options: [
{ label: 'Lifestyle', value: 'lifestyle' },
{ label: 'Corporate', value: 'corporate' },
{ label: 'Business B2B', value: 'b2b' },
],
admin: {
position: 'sidebar',
},
},
{
name: 'status',
type: 'select',
required: true,
defaultValue: 'active',
label: 'Status',
options: [
{ label: 'Aktiv', value: 'active' },
{ label: 'In Planung', value: 'planned' },
{ label: 'Pausiert', value: 'paused' },
{ label: 'Archiviert', value: 'archived' },
],
admin: {
position: 'sidebar',
},
},
// Branding
{
name: 'branding',
type: 'group',
label: 'Branding',
fields: [
{
name: 'primaryColor',
type: 'text',
label: 'Primärfarbe (Hex)',
admin: { description: 'z.B. #1278B3' },
validate: (value: string | undefined | null) => {
if (!value) return true
const hexRegex = /^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/
if (!hexRegex.test(value)) {
return 'Bitte einen gültigen Hex-Farbcode eingeben (z.B. #1278B3)'
}
return true
},
},
{
name: 'secondaryColor',
type: 'text',
label: 'Sekundärfarbe (Hex)',
validate: (value: string | undefined | null) => {
if (!value) return true
const hexRegex = /^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/
if (!hexRegex.test(value)) {
return 'Bitte einen gültigen Hex-Farbcode eingeben'
}
return true
},
},
{
name: 'logo',
type: 'upload',
relationTo: 'media',
label: 'Logo',
},
{
name: 'thumbnailTemplate',
type: 'upload',
relationTo: 'media',
label: 'Thumbnail-Vorlage',
},
],
},
// Content-Serien sind jetzt in der YtSeries Collection verwaltet
// Siehe: YouTube → YouTube-Serien
// Veröffentlichungsplan
{
name: 'publishingSchedule',
type: 'group',
label: 'Veröffentlichungsplan',
fields: [
{
name: 'defaultDays',
type: 'select',
hasMany: true,
label: 'Standard-Tage',
options: [
{ label: 'Montag', value: 'monday' },
{ label: 'Dienstag', value: 'tuesday' },
{ label: 'Mittwoch', value: 'wednesday' },
{ label: 'Donnerstag', value: 'thursday' },
{ label: 'Freitag', value: 'friday' },
{ label: 'Samstag', value: 'saturday' },
{ label: 'Sonntag', value: 'sunday' },
],
},
{
name: 'defaultTime',
type: 'text',
label: 'Standard-Uhrzeit',
admin: { description: 'z.B. "12:00"' },
},
{
name: 'shortsPerWeek',
type: 'number',
defaultValue: 4,
label: 'Shorts pro Woche',
},
{
name: 'longformPerWeek',
type: 'number',
defaultValue: 1,
label: 'Longform pro Woche',
},
],
},
// Metriken (via YouTube API Sync)
{
name: 'currentMetrics',
type: 'group',
label: 'Aktuelle Metriken',
admin: {
description: 'Automatisch via YouTube API aktualisiert',
},
fields: [
{ name: 'subscriberCount', type: 'number', label: 'Abonnenten', admin: { readOnly: true } },
{ name: 'totalViews', type: 'number', label: 'Gesamtaufrufe', admin: { readOnly: true } },
{ name: 'videoCount', type: 'number', label: 'Anzahl Videos', admin: { readOnly: true } },
{ name: 'lastSyncedAt', type: 'date', label: 'Letzter Sync', admin: { readOnly: true } },
],
},
],
timestamps: true,
}

View file

@ -0,0 +1,669 @@
// src/collections/YouTubeContent.ts
import type { CollectionConfig } from 'payload'
import {
hasYouTubeAccess,
isYouTubeCreatorOrAbove,
isYouTubeManager,
canAccessAssignedContent,
} from '../lib/youtubeAccess'
import { createTasksOnStatusChange } from '../hooks/youtubeContent/createTasksOnStatusChange'
// TODO: ScriptSectionBlock causes admin UI rendering issues
// import { ScriptSectionBlock } from '../blocks/ScriptSectionBlock'
/**
* YouTubeContent Collection
*
* Content-Pipeline für YouTube-Videos mit Workflow-Status,
* Freigaben, Terminen und Performance-Tracking.
* Teil des YouTube Operations Hub.
*/
export const YouTubeContent: CollectionConfig = {
slug: 'youtube-content',
labels: {
singular: 'YouTube-Video',
plural: 'YouTube-Videos',
},
admin: {
useAsTitle: 'title',
group: 'YouTube',
defaultColumns: ['title', 'channel', 'status', 'format', 'scheduledPublishDate', 'assignedTo'],
listSearchableFields: ['title', 'description'],
description: 'Content-Pipeline für YouTube-Videos',
},
access: {
read: canAccessAssignedContent,
create: isYouTubeCreatorOrAbove,
update: canAccessAssignedContent,
delete: isYouTubeManager,
},
hooks: {
afterChange: [createTasksOnStatusChange],
beforeChange: [
// Auto-Slug generieren
({ data }) => {
if (!data) return data
if (!data.slug && data.title) {
const title = typeof data.title === 'string' ? data.title : data.title?.de || data.title?.en || ''
data.slug = title
.toLowerCase()
.replace(/[äöüß]/g, (char: string) => {
const map: Record<string, string> = { ä: 'ae', ö: 'oe', ü: 'ue', ß: 'ss' }
return map[char] || char
})
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '')
}
return data
},
// CreatedBy setzen
({ data, req, operation }) => {
if (!data) return data
if (operation === 'create' && req.user) {
data.createdBy = req.user.id
}
return data
},
],
},
fields: [
// === GRUNDDATEN ===
{
name: 'title',
type: 'text',
required: true,
localized: true,
label: 'Titel',
maxLength: 100,
admin: {
description: 'Max. 60 Zeichen für YouTube empfohlen',
},
},
{
name: 'slug',
type: 'text',
required: true,
label: 'Slug',
admin: {
description: 'URL-freundlicher Name (wird automatisch generiert)',
},
},
{
name: 'channel',
type: 'relationship',
relationTo: 'youtube-channels',
required: true,
label: 'Kanal',
admin: {
position: 'sidebar',
},
},
{
name: 'series',
type: 'relationship',
relationTo: 'yt-series',
label: 'Serie',
filterOptions: ({ data }) => {
if (data?.channel) {
return {
channel: {
equals: typeof data.channel === 'object' ? data.channel.id : data.channel,
},
}
}
return {}
},
admin: {
position: 'sidebar',
description: 'Content-Serie für dieses Video',
},
},
{
name: 'format',
type: 'select',
required: true,
label: 'Format',
options: [
{ label: 'Short (< 60s)', value: 'short' },
{ label: 'Longform', value: 'longform' },
{ label: 'Premiere/Live', value: 'premiere' },
],
admin: {
position: 'sidebar',
},
},
// === STATUS & WORKFLOW ===
{
name: 'status',
type: 'select',
required: true,
defaultValue: 'idea',
label: 'Status',
options: [
{ label: 'Idee', value: 'idea' },
{ label: 'Skript in Arbeit', value: 'script_draft' },
{ label: 'Skript Review', value: 'script_review' },
{ label: 'Skript freigegeben', value: 'script_approved' },
{ label: 'Dreh geplant', value: 'shoot_scheduled' },
{ label: 'Gedreht', value: 'shot' },
{ label: 'Rohschnitt', value: 'rough_cut' },
{ label: 'Feinschnitt', value: 'fine_cut' },
{ label: 'Final Review', value: 'final_review' },
{ label: 'Freigegeben', value: 'approved' },
{ label: 'Upload geplant', value: 'upload_scheduled' },
{ label: 'Live', value: 'published' },
{ label: 'Performance getrackt', value: 'tracked' },
{ label: 'Verworfen', value: 'discarded' },
],
admin: {
position: 'sidebar',
},
},
{
name: 'priority',
type: 'select',
defaultValue: 'normal',
label: 'Priorität',
options: [
{ label: 'Dringend', value: 'urgent' },
{ label: 'Hoch', value: 'high' },
{ label: 'Normal', value: 'normal' },
{ label: 'Niedrig', value: 'low' },
],
admin: {
position: 'sidebar',
},
},
// === ZUWEISUNG ===
{
name: 'assignedTo',
type: 'relationship',
relationTo: 'users',
label: 'Zugewiesen an',
admin: {
position: 'sidebar',
},
},
{
name: 'createdBy',
type: 'relationship',
relationTo: 'users',
label: 'Erstellt von',
admin: {
readOnly: true,
position: 'sidebar',
},
},
{
name: 'productionBatch',
type: 'relationship',
relationTo: 'yt-batches',
label: { de: 'Produktions-Batch', en: 'Production Batch' },
admin: {
position: 'sidebar',
},
},
// === TABS ===
{
type: 'tabs',
tabs: [
{
label: 'Inhalt',
fields: [
{
name: 'description',
type: 'textarea',
localized: true,
label: 'Beschreibung / Konzept',
},
{
name: 'hook',
type: 'text',
localized: true,
label: 'Hook (erste 3 Sekunden)',
admin: {
description: 'Was sagt/zeigt der Creator in den ersten 3 Sekunden?',
},
},
{
name: 'keyPoints',
type: 'array',
label: 'Kernpunkte',
fields: [{ name: 'point', type: 'text', localized: true, label: 'Punkt' }],
},
{
name: 'callToAction',
type: 'text',
localized: true,
label: 'Call-to-Action',
},
{
name: 'scriptUrl',
type: 'text',
label: 'Skript-Link (Google Docs)',
},
{
name: 'scriptContent',
type: 'richText',
localized: true,
label: 'Skript (optional inline)',
},
],
},
{
label: 'Termine',
fields: [
{
name: 'shootDate',
type: 'date',
label: 'Dreh-Datum',
admin: {
date: { pickerAppearance: 'dayAndTime' },
},
},
{
name: 'editDeadline',
type: 'date',
label: 'Schnitt-Deadline',
},
{
name: 'reviewDeadline',
type: 'date',
label: 'Review-Deadline',
},
{
name: 'scheduledPublishDate',
type: 'date',
label: 'Geplantes Veröffentlichungsdatum',
admin: {
date: { pickerAppearance: 'dayAndTime' },
},
},
{
name: 'actualPublishDate',
type: 'date',
label: 'Tatsächliches Veröffentlichungsdatum',
admin: { readOnly: true },
},
],
},
{
label: { de: 'Produktion', en: 'Production' },
fields: [
{
type: 'row',
fields: [
{
name: 'productionWeek',
type: 'number',
label: { de: 'Produktionswoche', en: 'Production Week' },
min: 1,
max: 52,
admin: { width: '50%' },
},
{
name: 'calendarWeek',
type: 'number',
label: { de: 'Kalenderwoche', en: 'Calendar Week' },
min: 1,
max: 52,
admin: { width: '50%' },
},
],
},
{
name: 'productionDate',
type: 'date',
label: { de: 'Produktionsdatum', en: 'Production Date' },
admin: {
date: {
pickerAppearance: 'dayOnly',
displayFormat: 'dd.MM.yyyy',
},
},
},
{
name: 'targetDuration',
type: 'text',
label: { de: 'Ziel-Dauer', en: 'Target Duration' },
admin: {
placeholder: 'z.B. "8-12 Min" oder "45-58s"',
},
},
{
name: 'bRollNotes',
type: 'textarea',
label: { de: 'B-Roll / Setting Notizen', en: 'B-Roll / Setting Notes' },
localized: true,
admin: {
rows: 3,
},
},
],
},
// TODO: Script tab disabled - ScriptSectionBlock causes admin UI rendering issues
// {
// label: 'Script',
// fields: [
// {
// name: 'script',
// type: 'blocks',
// label: { de: 'Script Sections', en: 'Script Sections' },
// labels: {
// singular: { de: 'Section', en: 'Section' },
// plural: { de: 'Script Sections', en: 'Script Sections' },
// },
// blocks: [ScriptSectionBlock],
// admin: {
// description: { de: 'Strukturiertes Video-Script mit Sections', en: 'Structured video script with sections' },
// },
// },
// ],
// },
{
label: 'Posting',
fields: [
{
name: 'publishTime',
type: 'text',
label: { de: 'Posting-Uhrzeit', en: 'Publish Time' },
admin: {
placeholder: 'z.B. "07:00" oder "17:00"',
},
},
{
name: 'thumbnailText',
type: 'text',
label: { de: 'Thumbnail-Text', en: 'Thumbnail Text' },
admin: {
placeholder: 'z.B. "BOARD READY | 7 MIN"',
},
},
{
type: 'row',
fields: [
{
name: 'ctaType',
type: 'select',
label: { de: 'CTA-Typ', en: 'CTA Type' },
options: [
{ label: 'Link in Bio', value: 'link_in_bio' },
{ label: 'Newsletter', value: 'newsletter' },
{ label: { de: 'Longform verlinken', en: 'Link Longform' }, value: 'longform_link' },
{ label: { de: 'Benutzerdefiniert', en: 'Custom' }, value: 'custom' },
],
admin: { width: '50%' },
},
{
name: 'ctaDetail',
type: 'text',
label: { de: 'CTA-Detail', en: 'CTA Detail' },
admin: {
width: '50%',
placeholder: 'z.B. "GRFI-Checkliste"',
condition: (data) => !!data?.ctaType,
},
},
],
},
{
name: 'uploadChecklist',
type: 'array',
label: { de: 'Upload-Checkliste', en: 'Upload Checklist' },
fields: [
{
type: 'row',
fields: [
{
name: 'step',
type: 'text',
required: true,
label: { de: 'Schritt', en: 'Step' },
admin: { width: '60%' },
},
{
name: 'completed',
type: 'checkbox',
label: { de: 'Erledigt', en: 'Done' },
admin: { width: '20%' },
},
{
name: 'completedAt',
type: 'date',
label: { de: 'Am', en: 'At' },
admin: {
width: '20%',
readOnly: true,
},
},
],
},
],
},
{
name: 'disclaimers',
type: 'array',
label: { de: 'Disclaimers', en: 'Disclaimers' },
fields: [
{
type: 'row',
fields: [
{
name: 'type',
type: 'select',
required: true,
label: { de: 'Typ', en: 'Type' },
options: [
{ label: { de: 'Medizinisch', en: 'Medical' }, value: 'medical' },
{ label: { de: 'Rechtlich', en: 'Legal' }, value: 'legal' },
{ label: 'Affiliate', value: 'affiliate' },
{ label: 'Sponsored', value: 'sponsored' },
],
admin: { width: '25%' },
},
{
name: 'text',
type: 'text',
label: { de: 'Text', en: 'Text' },
localized: true,
admin: { width: '50%' },
},
{
name: 'placement',
type: 'select',
label: { de: 'Platzierung', en: 'Placement' },
options: [
{ label: { de: 'Gesprochen', en: 'Spoken' }, value: 'spoken' },
{ label: 'Text-Overlay', value: 'overlay' },
{ label: { de: 'Beschreibung', en: 'Description' }, value: 'description' },
{ label: { de: 'Überall', en: 'All' }, value: 'all' },
],
admin: { width: '25%' },
},
],
},
],
},
],
},
{
label: 'Dateien',
fields: [
{
name: 'thumbnail',
type: 'upload',
relationTo: 'media',
label: 'Thumbnail',
},
{
name: 'thumbnailAlt',
type: 'upload',
relationTo: 'media',
label: 'Thumbnail (A/B-Test)',
},
{
name: 'videoFile',
type: 'upload',
relationTo: 'media',
label: 'Video-Datei (Final)',
},
{
name: 'rawFootage',
type: 'array',
label: 'Rohmaterial',
fields: [
{ name: 'file', type: 'upload', relationTo: 'media', label: 'Datei' },
{ name: 'description', type: 'text', label: 'Beschreibung' },
],
},
],
},
{
label: 'Freigaben',
fields: [
{
name: 'approvals',
type: 'group',
label: 'Freigaben',
fields: [
{
name: 'scriptApproval',
type: 'group',
label: 'Skript-Freigabe',
fields: [
{ name: 'approved', type: 'checkbox', label: 'Freigegeben' },
{ name: 'approvedBy', type: 'relationship', relationTo: 'users', label: 'Von' },
{ name: 'approvedAt', type: 'date', label: 'Am' },
{ name: 'notes', type: 'textarea', localized: true, label: 'Anmerkungen' },
],
},
{
name: 'medicalApproval',
type: 'group',
label: 'Medizinische Freigabe',
admin: {
description: 'Nur für Corporate-Kanäle relevant',
},
fields: [
{ name: 'required', type: 'checkbox', label: 'Erforderlich', defaultValue: false },
{ name: 'approved', type: 'checkbox', label: 'Freigegeben' },
{ name: 'approvedBy', type: 'relationship', relationTo: 'users', label: 'Von' },
{ name: 'approvedAt', type: 'date', label: 'Am' },
{ name: 'notes', type: 'textarea', localized: true, label: 'Anmerkungen' },
],
},
{
name: 'legalApproval',
type: 'group',
label: 'Rechtliche Freigabe',
fields: [
{ name: 'approved', type: 'checkbox', label: 'Freigegeben' },
{ name: 'approvedBy', type: 'relationship', relationTo: 'users', label: 'Von' },
{ name: 'approvedAt', type: 'date', label: 'Am' },
{ name: 'disclaimerIncluded', type: 'checkbox', label: 'Disclaimer enthalten' },
{ name: 'notes', type: 'textarea', localized: true, label: 'Anmerkungen' },
],
},
{
name: 'finalApproval',
type: 'group',
label: 'Finale Freigabe',
fields: [
{ name: 'approved', type: 'checkbox', label: 'Freigegeben' },
{ name: 'approvedBy', type: 'relationship', relationTo: 'users', label: 'Von' },
{ name: 'approvedAt', type: 'date', label: 'Am' },
{ name: 'notes', type: 'textarea', localized: true, label: 'Anmerkungen' },
],
},
],
},
],
},
{
label: 'YouTube',
fields: [
{
name: 'youtube',
type: 'group',
label: 'YouTube-Daten',
fields: [
{ name: 'videoId', type: 'text', label: 'YouTube Video ID', admin: { readOnly: true } },
{ name: 'url', type: 'text', label: 'YouTube URL', admin: { readOnly: true } },
{
name: 'metadata',
type: 'group',
label: 'Metadaten für Upload',
fields: [
{ name: 'youtubeTitle', type: 'text', localized: true, label: 'YouTube-Titel', maxLength: 100 },
{ name: 'youtubeDescription', type: 'textarea', localized: true, label: 'YouTube-Beschreibung' },
{
name: 'tags',
type: 'array',
label: 'Tags',
fields: [{ name: 'tag', type: 'text', label: 'Tag' }],
},
{
name: 'visibility',
type: 'select',
label: 'Sichtbarkeit',
options: [
{ label: 'Öffentlich', value: 'public' },
{ label: 'Nicht gelistet', value: 'unlisted' },
{ label: 'Privat', value: 'private' },
],
defaultValue: 'private',
},
{ name: 'chapters', type: 'textarea', label: 'Kapitelmarker' },
{ name: 'pinnedComment', type: 'textarea', localized: true, label: 'Gepinnter Kommentar' },
],
},
],
},
],
},
{
label: 'Performance',
fields: [
{
name: 'performance',
type: 'group',
label: 'Performance-Metriken',
admin: {
description: 'Automatisch via YouTube API aktualisiert',
},
fields: [
{ name: 'views', type: 'number', label: 'Aufrufe', admin: { readOnly: true } },
{ name: 'likes', type: 'number', label: 'Likes', admin: { readOnly: true } },
{ name: 'comments', type: 'number', label: 'Kommentare', admin: { readOnly: true } },
{ name: 'shares', type: 'number', label: 'Shares', admin: { readOnly: true } },
{ name: 'watchTimeMinutes', type: 'number', label: 'Wiedergabezeit (Min)', admin: { readOnly: true } },
{ name: 'avgViewDuration', type: 'number', label: 'Ø Wiedergabedauer (Sek)', admin: { readOnly: true } },
{ name: 'avgViewPercentage', type: 'number', label: 'Ø Wiedergabe (%)', admin: { readOnly: true } },
{ name: 'ctr', type: 'number', label: 'CTR (%)', admin: { readOnly: true } },
{ name: 'impressions', type: 'number', label: 'Impressionen', admin: { readOnly: true } },
{ name: 'subscribersGained', type: 'number', label: 'Neue Abos', admin: { readOnly: true } },
{ name: 'lastSyncedAt', type: 'date', label: 'Letzter Sync', admin: { readOnly: true } },
],
},
],
},
],
},
// === INTERNE NOTIZEN ===
{
name: 'internalNotes',
type: 'richText',
label: 'Interne Notizen',
admin: {
description: 'Nur für das Team sichtbar',
},
},
],
timestamps: true,
}

View file

@ -0,0 +1,143 @@
import type { CollectionConfig } from 'payload'
/**
* YtBatches Collection - Angepasst an DB-Schema
*/
export const YtBatches: CollectionConfig = {
slug: 'yt-batches',
labels: {
singular: 'Production Batch',
plural: 'Production Batches',
},
admin: {
group: 'YouTube',
useAsTitle: 'name',
},
access: {
read: () => true,
create: ({ req }) => !!req.user,
update: ({ req }) => !!req.user,
delete: ({ req }) => !!req.user,
},
fields: [
{
name: 'name',
type: 'text',
required: true,
label: 'Name',
},
{
name: 'channel',
type: 'relationship',
relationTo: 'youtube-channels',
label: 'Channel',
},
{
name: 'status',
type: 'select',
defaultValue: 'planning',
label: 'Status',
options: [
{ label: 'Planning', value: 'planning' },
{ label: 'Production', value: 'production' },
{ label: 'Published', value: 'published' },
],
},
// Production Period
{
name: 'productionPeriodStart',
type: 'date',
label: 'Production Start',
},
{
name: 'productionPeriodEnd',
type: 'date',
label: 'Production End',
},
// Targets Group
{
name: 'targets',
type: 'group',
label: 'Targets',
fields: [
{
name: 'shortsTarget',
type: 'number',
label: 'Shorts Target',
defaultValue: 0,
},
{
name: 'longformsTarget',
type: 'number',
label: 'Longforms Target',
defaultValue: 0,
},
{
name: 'totalTarget',
type: 'number',
label: 'Total Target',
defaultValue: 0,
},
{
name: 'bufferDays',
type: 'number',
label: 'Buffer Days',
defaultValue: 0,
},
],
},
// Progress Group
{
name: 'progress',
type: 'group',
label: 'Progress',
fields: [
{
name: 'shortsCompleted',
type: 'number',
label: 'Shorts Completed',
defaultValue: 0,
},
{
name: 'longformsCompleted',
type: 'number',
label: 'Longforms Completed',
defaultValue: 0,
},
{
name: 'percentage',
type: 'number',
label: 'Percentage',
defaultValue: 0,
},
],
},
// Team Group
{
name: 'team',
type: 'group',
label: 'Team',
fields: [
{
name: 'producer',
type: 'relationship',
relationTo: 'users',
label: 'Producer',
},
{
name: 'editor',
type: 'relationship',
relationTo: 'users',
label: 'Editor',
},
{
name: 'reviewer',
type: 'relationship',
relationTo: 'users',
label: 'Reviewer',
},
],
},
],
timestamps: true,
}

View file

@ -0,0 +1,175 @@
import type { CollectionConfig } from 'payload'
import { isYouTubeManager, hasYouTubeAccess } from '../lib/youtubeAccess'
export const YtChecklistTemplates: CollectionConfig = {
slug: 'yt-checklist-templates',
labels: {
singular: { de: 'Checklisten-Vorlage', en: 'Checklist Template' },
plural: { de: 'Checklisten-Vorlagen', en: 'Checklist Templates' },
},
admin: {
group: 'YouTube',
useAsTitle: 'name',
defaultColumns: ['name', 'type', 'channel', 'updatedAt'],
description: { de: 'Wiederverwendbare Checklisten für Upload und Produktion', en: 'Reusable checklists for upload and production' },
},
access: {
read: hasYouTubeAccess,
create: isYouTubeManager,
update: isYouTubeManager,
delete: isYouTubeManager,
},
fields: [
{
type: 'row',
fields: [
{
name: 'name',
type: 'text',
required: true,
localized: true,
label: { de: 'Vorlagen-Name', en: 'Template Name' },
admin: {
width: '50%',
placeholder: 'z.B. "Standard Upload Checklist"',
},
},
{
name: 'channel',
type: 'relationship',
relationTo: 'youtube-channels',
label: { de: 'Kanal', en: 'Channel' },
admin: {
width: '50%',
description: { de: 'Optional: Kanal-spezifische Vorlage', en: 'Optional: Channel-specific template' },
},
},
],
},
{
type: 'row',
fields: [
{
name: 'type',
type: 'select',
required: true,
label: { de: 'Typ', en: 'Type' },
options: [
{ label: { de: 'Upload-Checkliste', en: 'Upload Checklist' }, value: 'upload' },
{ label: { de: 'Produktions-Checkliste', en: 'Production Checklist' }, value: 'production' },
{ label: { de: 'Review-Checkliste', en: 'Review Checklist' }, value: 'review' },
{ label: { de: 'Post-Publish-Checkliste', en: 'Post-Publish Checklist' }, value: 'post_publish' },
],
admin: { width: '50%' },
},
{
name: 'format',
type: 'select',
label: { de: 'Format', en: 'Format' },
options: [
{ label: { de: 'Alle Formate', en: 'All Formats' }, value: 'all' },
{ label: 'Short', value: 'short' },
{ label: 'Longform', value: 'longform' },
],
defaultValue: 'all',
admin: { width: '50%' },
},
],
},
{
name: 'description',
type: 'textarea',
label: { de: 'Beschreibung', en: 'Description' },
localized: true,
admin: {
rows: 2,
description: { de: 'Wann diese Checkliste verwendet werden soll', en: 'When to use this checklist' },
},
},
{
name: 'items',
type: 'array',
required: true,
label: { de: 'Checklisten-Punkte', en: 'Checklist Items' },
labels: {
singular: { de: 'Punkt', en: 'Item' },
plural: { de: 'Punkte', en: 'Items' },
},
minRows: 1,
fields: [
{
type: 'row',
fields: [
{
name: 'order',
type: 'number',
label: { de: 'Reihenfolge', en: 'Order' },
min: 1,
admin: { width: '15%' },
},
{
name: 'task',
type: 'text',
required: true,
localized: true,
label: { de: 'Aufgabe', en: 'Task' },
admin: {
width: '55%',
placeholder: 'z.B. "Thumbnail hochladen"',
},
},
{
name: 'category',
type: 'select',
label: { de: 'Kategorie', en: 'Category' },
options: [
{ label: 'Metadaten', value: 'metadata' },
{ label: 'Assets', value: 'assets' },
{ label: 'SEO', value: 'seo' },
{ label: 'Community', value: 'community' },
{ label: { de: 'Rechtliches', en: 'Legal' }, value: 'legal' },
{ label: { de: 'Sonstiges', en: 'Other' }, value: 'other' },
],
admin: { width: '30%' },
},
],
},
{
name: 'details',
type: 'textarea',
localized: true,
label: { de: 'Details/Hinweise', en: 'Details/Notes' },
admin: {
rows: 2,
placeholder: 'Zusätzliche Anweisungen oder Hinweise',
},
},
{
name: 'isRequired',
type: 'checkbox',
label: { de: 'Pflichtfeld', en: 'Required' },
defaultValue: true,
},
],
},
{
name: 'isDefault',
type: 'checkbox',
label: { de: 'Standard-Vorlage', en: 'Default Template' },
admin: {
position: 'sidebar',
description: { de: 'Wird automatisch für neue Videos verwendet', en: 'Automatically used for new videos' },
},
},
{
name: 'isActive',
type: 'checkbox',
label: { de: 'Aktiv', en: 'Active' },
defaultValue: true,
admin: {
position: 'sidebar',
},
},
],
timestamps: true,
}

View file

@ -0,0 +1,304 @@
import type { CollectionConfig } from 'payload'
import { isYouTubeManager, hasYouTubeAccess } from '../lib/youtubeAccess'
export const YtMonthlyGoals: CollectionConfig = {
slug: 'yt-monthly-goals',
labels: {
singular: { de: 'Monatsziel', en: 'Monthly Goal' },
plural: { de: 'Monatsziele', en: 'Monthly Goals' },
},
admin: {
group: 'YouTube',
useAsTitle: 'displayTitle',
defaultColumns: ['displayTitle', 'channel', 'month', 'updatedAt'],
},
access: {
read: hasYouTubeAccess,
create: isYouTubeManager,
update: isYouTubeManager,
delete: isYouTubeManager,
},
fields: [
{
type: 'row',
fields: [
{
name: 'channel',
type: 'relationship',
relationTo: 'youtube-channels',
required: true,
label: { de: 'Kanal', en: 'Channel' },
admin: { width: '50%' },
},
{
name: 'month',
type: 'date',
required: true,
label: { de: 'Monat', en: 'Month' },
admin: {
width: '50%',
date: {
pickerAppearance: 'monthOnly',
displayFormat: 'MMMM yyyy',
},
},
},
],
},
{
name: 'displayTitle',
type: 'text',
admin: { hidden: true },
hooks: {
beforeChange: [
async ({ siblingData, req }) => {
if (siblingData.channel && siblingData.month) {
try {
const channelId = typeof siblingData.channel === 'object'
? siblingData.channel.id
: siblingData.channel
const channel = await req.payload.findByID({
collection: 'youtube-channels',
id: channelId,
})
const monthStr = new Date(siblingData.month).toLocaleDateString('de-DE', {
month: 'long',
year: 'numeric',
})
return `${channel?.name || 'Kanal'} - ${monthStr}`
} catch {
return 'Neues Monatsziel'
}
}
return 'Neues Monatsziel'
},
],
},
},
// === CONTENT GOALS ===
{
name: 'contentGoals',
type: 'group',
label: { de: 'Content-Ziele', en: 'Content Goals' },
fields: [
{
type: 'row',
fields: [
{
name: 'longformsTarget',
type: 'number',
label: { de: 'Longforms (Ziel)', en: 'Longforms (Target)' },
defaultValue: 12,
admin: { width: '25%' },
},
{
name: 'longformsCurrent',
type: 'number',
label: { de: 'Aktuell', en: 'Current' },
defaultValue: 0,
admin: { width: '25%' },
},
{
name: 'shortsTarget',
type: 'number',
label: { de: 'Shorts (Ziel)', en: 'Shorts (Target)' },
defaultValue: 28,
admin: { width: '25%' },
},
{
name: 'shortsCurrent',
type: 'number',
label: { de: 'Aktuell', en: 'Current' },
defaultValue: 0,
admin: { width: '25%' },
},
],
},
],
},
// === AUDIENCE GOALS ===
{
name: 'audienceGoals',
type: 'group',
label: { de: 'Audience-Ziele', en: 'Audience Goals' },
fields: [
{
type: 'row',
fields: [
{
name: 'subscribersTarget',
type: 'number',
label: { de: 'Neue Abos (Ziel)', en: 'New Subs (Target)' },
admin: { width: '25%' },
},
{
name: 'subscribersCurrent',
type: 'number',
label: { de: 'Aktuell', en: 'Current' },
admin: { width: '25%' },
},
{
name: 'viewsTarget',
type: 'number',
label: { de: 'Views (Ziel)', en: 'Views (Target)' },
admin: { width: '25%' },
},
{
name: 'viewsCurrent',
type: 'number',
label: { de: 'Aktuell', en: 'Current' },
admin: { width: '25%' },
},
],
},
],
},
// === ENGAGEMENT GOALS ===
{
name: 'engagementGoals',
type: 'group',
label: { de: 'Engagement-Ziele', en: 'Engagement Goals' },
fields: [
{
type: 'row',
fields: [
{
name: 'avgCtrTarget',
type: 'text',
label: { de: 'Ø CTR (Ziel)', en: 'Avg CTR (Target)' },
admin: {
width: '25%',
placeholder: 'z.B. ">4%"',
},
},
{
name: 'avgCtrCurrent',
type: 'text',
label: { de: 'Aktuell', en: 'Current' },
admin: { width: '25%' },
},
{
name: 'avgRetentionTarget',
type: 'text',
label: { de: 'Ø Retention (Ziel)', en: 'Avg Retention (Target)' },
admin: {
width: '25%',
placeholder: 'z.B. ">50%"',
},
},
{
name: 'avgRetentionCurrent',
type: 'text',
label: { de: 'Aktuell', en: 'Current' },
admin: { width: '25%' },
},
],
},
],
},
// === BUSINESS GOALS ===
{
name: 'businessGoals',
type: 'group',
label: { de: 'Business-Ziele', en: 'Business Goals' },
fields: [
{
type: 'row',
fields: [
{
name: 'newsletterSignupsTarget',
type: 'number',
label: { de: 'Newsletter-Anmeldungen (Ziel)', en: 'Newsletter Signups (Target)' },
admin: { width: '25%' },
},
{
name: 'newsletterSignupsCurrent',
type: 'number',
label: { de: 'Aktuell', en: 'Current' },
admin: { width: '25%' },
},
{
name: 'affiliateRevenueTarget',
type: 'number',
label: { de: 'Affiliate-Umsatz (Ziel)', en: 'Affiliate Revenue (Target)' },
admin: { width: '25%' },
},
{
name: 'affiliateRevenueCurrent',
type: 'number',
label: { de: 'Aktuell', en: 'Current' },
admin: { width: '25%' },
},
],
},
],
},
// === CUSTOM GOALS ===
{
name: 'customGoals',
type: 'array',
label: { de: 'Weitere Ziele', en: 'Custom Goals' },
fields: [
{
type: 'row',
fields: [
{
name: 'metric',
type: 'text',
required: true,
label: { de: 'Metrik', en: 'Metric' },
admin: {
width: '40%',
placeholder: 'z.B. "SPARK Videos"',
},
},
{
name: 'target',
type: 'text',
required: true,
label: { de: 'Ziel', en: 'Target' },
admin: {
width: '20%',
placeholder: 'Ziel',
},
},
{
name: 'current',
type: 'text',
label: { de: 'Aktuell', en: 'Current' },
admin: {
width: '20%',
placeholder: 'Aktuell',
},
},
{
name: 'status',
type: 'select',
label: 'Status',
options: [
{ label: 'On Track', value: 'on_track' },
{ label: 'At Risk', value: 'at_risk' },
{ label: 'Achieved', value: 'achieved' },
{ label: 'Missed', value: 'missed' },
],
admin: { width: '20%' },
},
],
},
],
},
{
name: 'notes',
type: 'textarea',
label: { de: 'Notizen / Learnings', en: 'Notes / Learnings' },
localized: true,
},
],
timestamps: true,
}

View file

@ -0,0 +1,121 @@
// src/collections/YtNotifications.ts
import type { CollectionConfig } from 'payload'
import { canAccessOwnNotifications } from '../lib/youtubeAccess'
/**
* YtNotifications Collection
*
* Benachrichtigungen für das YouTube Operations Team.
* Werden automatisch bei Task-Zuweisungen und Statusänderungen erstellt.
* Teil des YouTube Operations Hub.
*/
export const YtNotifications: CollectionConfig = {
slug: 'yt-notifications',
labels: {
singular: 'Benachrichtigung',
plural: 'Benachrichtigungen',
},
admin: {
group: 'YouTube',
defaultColumns: ['title', 'recipient', 'type', 'read', 'createdAt'],
description: 'Benachrichtigungen für YouTube Operations',
},
access: {
read: canAccessOwnNotifications,
create: () => true, // System kann erstellen
update: canAccessOwnNotifications,
delete: ({ req }) => !!req.user?.isSuperAdmin,
},
fields: [
{
name: 'recipient',
type: 'relationship',
relationTo: 'users',
required: true,
label: 'Empfänger',
index: true,
},
{
name: 'type',
type: 'select',
required: true,
label: 'Typ',
options: [
{ label: 'Neue Aufgabe', value: 'task_assigned' },
{ label: 'Aufgabe fällig', value: 'task_due' },
{ label: 'Aufgabe überfällig', value: 'task_overdue' },
{ label: 'Freigabe erforderlich', value: 'approval_required' },
{ label: 'Freigabe erteilt', value: 'approved' },
{ label: 'Freigabe abgelehnt', value: 'rejected' },
{ label: 'Video veröffentlicht', value: 'video_published' },
{ label: 'Kommentar', value: 'comment' },
{ label: 'Erwähnung', value: 'mention' },
{ label: 'System', value: 'system' },
],
},
{
name: 'title',
type: 'text',
required: true,
localized: true,
label: 'Titel',
},
{
name: 'message',
type: 'textarea',
localized: true,
label: 'Nachricht',
},
{
name: 'link',
type: 'text',
label: 'Link',
admin: {
description: 'Relativer Pfad zum relevanten Element',
},
},
{
name: 'relatedVideo',
type: 'relationship',
relationTo: 'youtube-content',
label: 'Video',
},
{
name: 'relatedTask',
type: 'relationship',
relationTo: 'yt-tasks',
label: 'Aufgabe',
},
{
name: 'read',
type: 'checkbox',
defaultValue: false,
label: 'Gelesen',
},
{
name: 'readAt',
type: 'date',
label: 'Gelesen am',
},
{
name: 'emailSent',
type: 'checkbox',
defaultValue: false,
label: 'E-Mail gesendet',
},
],
timestamps: true,
hooks: {
beforeChange: [
// Setze readAt wenn read auf true wechselt
({ data, originalDoc }) => {
if (!data) return data
if (data.read && !originalDoc?.read) {
data.readAt = new Date().toISOString()
}
return data
},
],
},
}

View file

@ -0,0 +1,72 @@
import type { CollectionConfig } from 'payload'
// TODO: ScriptSectionBlock causes admin UI rendering issues - needs investigation
// import { ScriptSectionBlock } from '../blocks/ScriptSectionBlock'
import { isYouTubeManager, hasYouTubeAccess } from '../lib/youtubeAccess'
/**
* YtScriptTemplates - Minimal Version
*/
export const YtScriptTemplates: CollectionConfig = {
slug: 'yt-script-templates',
dbName: 'yt_script_tpl',
labels: {
singular: 'Script Template',
plural: 'Script Templates',
},
admin: {
group: 'YouTube',
useAsTitle: 'name',
},
access: {
read: hasYouTubeAccess,
create: isYouTubeManager,
update: isYouTubeManager,
delete: isYouTubeManager,
},
fields: [
{
name: 'name',
type: 'text',
required: true,
localized: true,
label: 'Name',
},
{
name: 'channel',
type: 'relationship',
relationTo: 'youtube-channels',
required: true,
label: 'Channel',
},
{
name: 'series',
type: 'text',
required: true,
label: 'Series',
},
{
name: 'format',
type: 'select',
required: true,
label: 'Format',
options: [
{ label: 'Short (45-60s)', value: 'short' },
{ label: 'Longform (8-16 Min)', value: 'longform' },
],
},
{
name: 'description',
type: 'textarea',
label: 'Description',
localized: true,
},
// TODO: Blocks field disabled - causes admin UI rendering issues
// {
// name: 'blocks',
// type: 'blocks',
// label: 'Template Sections',
// blocks: [ScriptSectionBlock],
// },
],
timestamps: true,
}

204
src/collections/YtSeries.ts Normal file
View file

@ -0,0 +1,204 @@
// src/collections/YtSeries.ts
import type { CollectionConfig } from 'payload'
import { hasYouTubeAccess, isYouTubeManager } from '../lib/youtubeAccess'
/**
* YtSeries Collection
*
* Content-Serien für YouTube-Kanäle mit eigenem Branding.
* Ermöglicht die Verwaltung von wiederkehrenden Formaten
* wie "GRFI", "Investment-Piece", etc.
*/
export const YtSeries: CollectionConfig = {
slug: 'yt-series',
labels: {
singular: 'YouTube-Serie',
plural: 'YouTube-Serien',
},
admin: {
group: 'YouTube',
useAsTitle: 'name',
defaultColumns: ['name', 'channel', 'format', 'isActive', 'updatedAt'],
description: 'Content-Serien für YouTube-Kanäle',
},
access: {
read: hasYouTubeAccess,
create: isYouTubeManager,
update: isYouTubeManager,
delete: isYouTubeManager,
},
fields: [
// Grunddaten
{
name: 'name',
type: 'text',
required: true,
localized: true,
label: 'Name',
admin: {
placeholder: 'z.B. "GRFI", "Investment-Piece"',
},
},
{
name: 'slug',
type: 'text',
required: true,
label: 'Slug',
admin: {
description: 'URL-freundlicher Name (eindeutig pro Kanal)',
placeholder: 'z.B. "grfi", "investment-piece"',
},
},
{
name: 'channel',
type: 'relationship',
relationTo: 'youtube-channels',
required: true,
label: 'Kanal',
admin: {
position: 'sidebar',
},
},
{
name: 'description',
type: 'textarea',
localized: true,
label: 'Beschreibung',
admin: {
rows: 3,
},
},
// Branding
{
name: 'logo',
type: 'upload',
relationTo: 'media',
label: 'Logo',
admin: {
description: 'Serien-Logo (transparent PNG empfohlen)',
},
},
{
name: 'coverImage',
type: 'upload',
relationTo: 'media',
label: 'Cover-Bild',
admin: {
description: 'Hauptbild für die Serie (16:9 empfohlen)',
},
},
{
name: 'brandColor',
type: 'text',
label: 'Markenfarbe',
admin: {
placeholder: '#B08D57',
description: 'Hex-Farbcode',
},
validate: (value: string | undefined | null) => {
if (!value) return true
const hexRegex = /^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/
if (!hexRegex.test(value)) {
return 'Bitte einen gültigen Hex-Farbcode eingeben (z.B. #B08D57)'
}
return true
},
},
{
name: 'accentColor',
type: 'text',
label: 'Akzentfarbe',
admin: {
placeholder: '#FFFFFF',
description: 'Sekundäre Farbe',
},
validate: (value: string | undefined | null) => {
if (!value) return true
const hexRegex = /^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/
if (!hexRegex.test(value)) {
return 'Bitte einen gültigen Hex-Farbcode eingeben'
}
return true
},
},
// YouTube Integration
{
name: 'youtubePlaylistId',
type: 'text',
label: 'YouTube Playlist ID',
admin: {
description: 'Die Playlist-ID aus YouTube (z.B. PLxxxxxx)',
},
},
{
name: 'youtubePlaylistUrl',
type: 'text',
label: 'YouTube Playlist URL',
admin: {
description: 'Vollständiger Link zur YouTube-Playlist',
},
validate: (value: string | undefined | null) => {
if (!value) return true
try {
const url = new URL(value)
if (!url.hostname.includes('youtube.com') && !url.hostname.includes('youtu.be')) {
return 'Bitte eine gültige YouTube-URL eingeben'
}
return true
} catch {
return 'Bitte eine gültige URL eingeben'
}
},
},
// Format & Häufigkeit
{
name: 'format',
type: 'select',
label: 'Format',
options: [
{ label: 'Short (< 60s)', value: 'short' },
{ label: 'Longform', value: 'longform' },
{ label: 'Mixed', value: 'mixed' },
],
admin: {
position: 'sidebar',
},
},
{
name: 'publishingFrequency',
type: 'text',
label: 'Veröffentlichungs-Rhythmus',
admin: {
placeholder: 'z.B. "wöchentlich", "2x pro Woche"',
position: 'sidebar',
},
},
// Status & Sortierung
{
name: 'isActive',
type: 'checkbox',
label: 'Aktiv',
defaultValue: true,
admin: {
position: 'sidebar',
description: 'Inaktive Serien werden nicht angezeigt',
},
},
{
name: 'order',
type: 'number',
label: 'Sortierung',
defaultValue: 0,
admin: {
position: 'sidebar',
description: 'Niedrigere Zahlen werden zuerst angezeigt',
},
},
],
timestamps: true,
}

219
src/collections/YtTasks.ts Normal file
View file

@ -0,0 +1,219 @@
// src/collections/YtTasks.ts
import type { CollectionConfig } from 'payload'
import { isYouTubeManager, canAccessAssignedContent } from '../lib/youtubeAccess'
import { notifyOnAssignment } from '../hooks/ytTasks/notifyOnAssignment'
/**
* YtTasks Collection
*
* Aufgabenverwaltung für die Video-Produktion.
* Tasks werden automatisch bei Statuswechsel erstellt.
* Teil des YouTube Operations Hub.
*/
export const YtTasks: CollectionConfig = {
slug: 'yt-tasks',
labels: {
singular: 'YouTube-Aufgabe',
plural: 'YouTube-Aufgaben',
},
admin: {
useAsTitle: 'title',
group: 'YouTube',
defaultColumns: ['title', 'video', 'assignedTo', 'status', 'dueDate', 'priority'],
listSearchableFields: ['title'],
description: 'Aufgaben für die Video-Produktion',
},
access: {
read: canAccessAssignedContent,
create: isYouTubeManager,
update: canAccessAssignedContent,
delete: isYouTubeManager,
},
fields: [
{
name: 'title',
type: 'text',
required: true,
localized: true,
label: 'Aufgabe',
},
{
name: 'description',
type: 'textarea',
localized: true,
label: 'Beschreibung',
},
{
name: 'video',
type: 'relationship',
relationTo: 'youtube-content',
label: 'Zugehöriges Video',
admin: {
position: 'sidebar',
},
},
{
name: 'channel',
type: 'relationship',
relationTo: 'youtube-channels',
label: 'Kanal',
admin: {
position: 'sidebar',
description: 'Wird automatisch vom Video übernommen',
},
},
{
name: 'taskType',
type: 'select',
required: true,
label: 'Aufgabentyp',
options: [
{ label: 'Skript schreiben', value: 'script_write' },
{ label: 'Skript reviewen', value: 'script_review' },
{ label: 'Dreh vorbereiten', value: 'shoot_prep' },
{ label: 'Drehen', value: 'shoot' },
{ label: 'Schneiden', value: 'edit' },
{ label: 'Grafiken erstellen', value: 'graphics' },
{ label: 'Thumbnail erstellen', value: 'thumbnail' },
{ label: 'Review/Freigabe', value: 'review' },
{ label: 'Hochladen', value: 'upload' },
{ label: 'Performance tracken', value: 'track' },
{ label: 'Kommentare beantworten', value: 'comments' },
{ label: 'Sonstiges', value: 'other' },
],
admin: {
position: 'sidebar',
},
},
{
name: 'status',
type: 'select',
required: true,
defaultValue: 'todo',
label: 'Status',
options: [
{ label: 'Offen', value: 'todo' },
{ label: 'In Arbeit', value: 'in_progress' },
{ label: 'Blockiert', value: 'blocked' },
{ label: 'Wartet auf Review', value: 'waiting_review' },
{ label: 'Erledigt', value: 'done' },
{ label: 'Abgebrochen', value: 'cancelled' },
],
admin: {
position: 'sidebar',
},
},
{
name: 'priority',
type: 'select',
defaultValue: 'normal',
label: 'Priorität',
options: [
{ label: 'Dringend', value: 'urgent' },
{ label: 'Hoch', value: 'high' },
{ label: 'Normal', value: 'normal' },
{ label: 'Niedrig', value: 'low' },
],
admin: {
position: 'sidebar',
},
},
{
name: 'assignedTo',
type: 'relationship',
relationTo: 'users',
required: true,
label: 'Zugewiesen an',
admin: {
position: 'sidebar',
},
},
{
name: 'dueDate',
type: 'date',
label: 'Fälligkeitsdatum',
admin: {
position: 'sidebar',
date: { pickerAppearance: 'dayAndTime' },
},
},
{
name: 'completedAt',
type: 'date',
label: 'Abgeschlossen am',
admin: {
readOnly: true,
position: 'sidebar',
},
},
{
name: 'completedBy',
type: 'relationship',
relationTo: 'users',
label: 'Abgeschlossen von',
admin: { readOnly: true },
},
{
name: 'blockedReason',
type: 'text',
localized: true,
label: 'Grund für Blockierung',
admin: {
condition: (data) => data?.status === 'blocked',
},
},
{
name: 'attachments',
type: 'array',
label: 'Anhänge',
fields: [
{ name: 'file', type: 'upload', relationTo: 'media', label: 'Datei' },
{ name: 'note', type: 'text', label: 'Notiz' },
],
},
{
name: 'comments',
type: 'array',
label: 'Kommentare',
fields: [
{ name: 'author', type: 'relationship', relationTo: 'users', label: 'Autor' },
{ name: 'content', type: 'textarea', label: 'Inhalt' },
{ name: 'createdAt', type: 'date', label: 'Erstellt am' },
],
},
],
timestamps: true,
hooks: {
afterChange: [notifyOnAssignment],
beforeChange: [
async ({ data, originalDoc, req }) => {
if (!data) return data
// Setze completedAt wenn Status auf "done" wechselt
if (data.status === 'done' && originalDoc?.status !== 'done') {
data.completedAt = new Date().toISOString()
data.completedBy = req.user?.id
}
// Setze Channel automatisch vom Video
if (data.video && !data.channel) {
try {
const video = await req.payload.findByID({
collection: 'youtube-content',
id: typeof data.video === 'object' ? data.video.id : data.video,
depth: 0,
})
if (video?.channel) {
data.channel = typeof video.channel === 'object' ? video.channel.id : video.channel
}
} catch (error) {
console.error('[YtTasks] Error fetching video for channel:', error)
}
}
return data
},
],
},
}

View file

@ -0,0 +1,151 @@
// src/hooks/youtubeContent/createTasksOnStatusChange.ts
import type { CollectionAfterChangeHook } from 'payload'
interface TaskTemplate {
title: string
type: string
assignRole: string
}
/**
* Task-Templates für jeden Video-Status
* Definiert welche Aufgaben bei welchem Statuswechsel erstellt werden
*/
const TASK_TEMPLATES: Record<string, TaskTemplate[]> = {
script_draft: [{ title: 'Skript schreiben', type: 'script_write', assignRole: 'creator' }],
script_review: [{ title: 'Skript reviewen', type: 'script_review', assignRole: 'manager' }],
script_approved: [{ title: 'Dreh vorbereiten', type: 'shoot_prep', assignRole: 'producer' }],
shoot_scheduled: [{ title: 'Dreh durchführen', type: 'shoot', assignRole: 'producer' }],
shot: [{ title: 'Rohschnitt erstellen', type: 'edit', assignRole: 'editor' }],
rough_cut: [
{ title: 'Feinschnitt erstellen', type: 'edit', assignRole: 'editor' },
{ title: 'Grafiken erstellen', type: 'graphics', assignRole: 'editor' },
],
fine_cut: [
{ title: 'Thumbnail erstellen', type: 'thumbnail', assignRole: 'editor' },
{ title: 'Final Review', type: 'review', assignRole: 'manager' },
],
approved: [{ title: 'Video hochladen', type: 'upload', assignRole: 'manager' }],
published: [
{ title: 'Performance nach 24h tracken', type: 'track', assignRole: 'manager' },
{ title: 'Kommentare beantworten', type: 'comments', assignRole: 'creator' },
],
}
interface UserWithYouTubeRole {
id: number
youtubeRole?: string
}
/**
* Hook: Erstellt automatisch Tasks bei Video-Statuswechsel
*
* Wird aufgerufen nach jeder Änderung an einem YouTubeContent-Dokument.
* Prüft ob sich der Status geändert hat und erstellt entsprechende Tasks.
*/
export const createTasksOnStatusChange: CollectionAfterChangeHook = async ({
doc,
previousDoc,
req,
operation,
}) => {
// Nur bei Updates und wenn sich der Status geändert hat
if (operation !== 'update' || doc.status === previousDoc?.status) {
return doc
}
const tasks = TASK_TEMPLATES[doc.status]
if (!tasks || tasks.length === 0) {
return doc
}
// Mapping: Task-Rolle -> YouTube-Rolle
const roleMapping: Record<string, string[]> = {
creator: ['creator', 'manager'],
producer: ['producer', 'manager'],
editor: ['editor', 'manager'],
manager: ['manager'],
}
/**
* Findet einen User mit der passenden YouTube-Rolle
*/
const getUserByYouTubeRole = async (role: string): Promise<number | null> => {
const allowedRoles = roleMapping[role] || ['manager']
// Wenn Video einem User zugewiesen ist, prüfe dessen Rolle
if (doc.assignedTo) {
try {
const assignedUser = (await req.payload.findByID({
collection: 'users',
id: typeof doc.assignedTo === 'object' ? doc.assignedTo.id : doc.assignedTo,
depth: 0,
})) as UserWithYouTubeRole | null
if (assignedUser?.youtubeRole && allowedRoles.includes(assignedUser.youtubeRole)) {
return assignedUser.id
}
} catch (error) {
console.error('[createTasksOnStatusChange] Error fetching assigned user:', error)
}
}
// Sonst: Ersten User mit passender Rolle finden
try {
const users = await req.payload.find({
collection: 'users',
where: {
youtubeRole: { in: allowedRoles },
},
limit: 1,
depth: 0,
})
return users.docs[0]?.id || null
} catch (error) {
console.error('[createTasksOnStatusChange] Error finding user by role:', error)
return null
}
}
// Tasks erstellen
for (const taskTemplate of tasks) {
const assignedUserId = await getUserByYouTubeRole(taskTemplate.assignRole)
if (assignedUserId) {
const channelId = typeof doc.channel === 'object' ? doc.channel.id : doc.channel
const videoTitle = typeof doc.title === 'string' ? doc.title : doc.title?.de || doc.title?.en || 'Video'
try {
await req.payload.create({
collection: 'yt-tasks',
data: {
title: `${taskTemplate.title}: ${videoTitle}`,
video: doc.id,
channel: channelId,
taskType: taskTemplate.type,
status: 'todo',
priority: doc.priority || 'normal',
assignedTo: assignedUserId,
dueDate: doc.scheduledPublishDate
? new Date(
new Date(doc.scheduledPublishDate).getTime() - 2 * 24 * 60 * 60 * 1000
).toISOString()
: undefined,
},
})
console.log(`[createTasksOnStatusChange] Created task: ${taskTemplate.title} for video ${doc.id}`)
} catch (error) {
console.error('[createTasksOnStatusChange] Error creating task:', error)
}
} else {
console.warn(
`[createTasksOnStatusChange] No user found for role ${taskTemplate.assignRole}, skipping task: ${taskTemplate.title}`
)
}
}
return doc
}

View file

@ -0,0 +1,63 @@
// src/hooks/ytTasks/notifyOnAssignment.ts
import type { CollectionAfterChangeHook } from 'payload'
/**
* Hook: Erstellt Benachrichtigung bei Task-Zuweisung
*
* Wird aufgerufen nach jeder Änderung an einem YtTask-Dokument.
* Erstellt eine Notification wenn ein Task neu zugewiesen wird.
*/
export const notifyOnAssignment: CollectionAfterChangeHook = async ({
doc,
previousDoc,
req,
operation,
}) => {
// Ermittle vorherige und aktuelle Zuweisung
const previousAssignedTo = previousDoc?.assignedTo
? typeof previousDoc.assignedTo === 'object'
? previousDoc.assignedTo.id
: previousDoc.assignedTo
: null
const currentAssignedTo = doc.assignedTo
? typeof doc.assignedTo === 'object'
? doc.assignedTo.id
: doc.assignedTo
: null
// Prüfe ob es eine neue oder geänderte Zuweisung ist
const isNewAssignment = operation === 'create' || previousAssignedTo !== currentAssignedTo
if (isNewAssignment && currentAssignedTo) {
try {
const taskTitle = typeof doc.title === 'string' ? doc.title : doc.title?.de || doc.title?.en || 'Aufgabe'
await req.payload.create({
collection: 'yt-notifications',
data: {
recipient: currentAssignedTo,
type: 'task_assigned',
title: `Neue Aufgabe: ${taskTitle}`,
message: doc.dueDate
? `Fällig am ${new Date(doc.dueDate).toLocaleDateString('de-DE')}`
: 'Keine Deadline gesetzt',
link: `/admin/collections/yt-tasks/${doc.id}`,
relatedTask: doc.id,
relatedVideo: doc.video
? typeof doc.video === 'object'
? doc.video.id
: doc.video
: undefined,
},
})
console.log(`[notifyOnAssignment] Created notification for user ${currentAssignedTo}, task ${doc.id}`)
} catch (error) {
console.error('[notifyOnAssignment] Error creating notification:', error)
}
}
return doc
}

167
src/lib/utils/youtube.ts Normal file
View file

@ -0,0 +1,167 @@
// src/lib/utils/youtube.ts
/**
* YouTube URL Parser and Utilities
*
* Hilfsfunktionen zum Parsen von YouTube-URLs und Generieren von Embed-URLs.
* Unterstützt verschiedene YouTube-URL-Formate:
* - youtube.com/watch?v=VIDEO_ID
* - youtu.be/VIDEO_ID
* - youtube.com/embed/VIDEO_ID
* - youtube.com/shorts/VIDEO_ID
*/
/**
* Extrahiert die Video-ID aus einer YouTube-URL.
*
* @param url - Die YouTube-URL
* @returns Die Video-ID oder null wenn nicht gefunden
*
* @example
* extractYouTubeId('https://www.youtube.com/watch?v=dQw4w9WgXcQ') // 'dQw4w9WgXcQ'
* extractYouTubeId('https://youtu.be/dQw4w9WgXcQ') // 'dQw4w9WgXcQ'
* extractYouTubeId('https://www.youtube.com/shorts/VIDEO_ID') // 'VIDEO_ID'
*/
export function extractYouTubeId(url: string): string | null {
if (!url) return null
const patterns = [
/youtube\.com\/watch\?v=([^&]+)/,
/youtu\.be\/([^?]+)/,
/youtube\.com\/embed\/([^?]+)/,
/youtube\.com\/shorts\/([^?]+)/,
/youtube-nocookie\.com\/embed\/([^?]+)/,
]
for (const pattern of patterns) {
const match = url.match(pattern)
if (match) return match[1]
}
return null
}
/**
* Generiert eine YouTube Embed-URL.
*
* @param videoId - Die YouTube Video-ID
* @param privacyMode - Wenn true, wird youtube-nocookie.com verwendet (DSGVO-konform)
* @param options - Zusätzliche Embed-Optionen
* @returns Die vollständige Embed-URL
*
* @example
* getYouTubeEmbedUrl('dQw4w9WgXcQ') // 'https://www.youtube-nocookie.com/embed/dQw4w9WgXcQ'
* getYouTubeEmbedUrl('dQw4w9WgXcQ', false) // 'https://www.youtube.com/embed/dQw4w9WgXcQ'
*/
export function getYouTubeEmbedUrl(
videoId: string,
privacyMode = true,
options?: {
autoplay?: boolean
muted?: boolean
loop?: boolean
controls?: boolean
startTime?: number
}
): string {
const domain = privacyMode ? 'www.youtube-nocookie.com' : 'www.youtube.com'
const baseUrl = `https://${domain}/embed/${videoId}`
// Build query parameters
const params = new URLSearchParams()
if (options?.autoplay) params.set('autoplay', '1')
if (options?.muted) params.set('mute', '1')
if (options?.loop) {
params.set('loop', '1')
params.set('playlist', videoId) // Required for loop to work
}
if (options?.controls === false) params.set('controls', '0')
if (options?.startTime) params.set('start', String(options.startTime))
const queryString = params.toString()
return queryString ? `${baseUrl}?${queryString}` : baseUrl
}
/**
* Generiert eine YouTube Thumbnail-URL.
*
* @param videoId - Die YouTube Video-ID
* @param quality - Die gewünschte Qualität
* @returns Die Thumbnail-URL
*
* @example
* getYouTubeThumbnail('dQw4w9WgXcQ') // 'https://img.youtube.com/vi/dQw4w9WgXcQ/hqdefault.jpg'
* getYouTubeThumbnail('dQw4w9WgXcQ', 'maxres') // 'https://img.youtube.com/vi/dQw4w9WgXcQ/maxresdefault.jpg'
*/
export function getYouTubeThumbnail(
videoId: string,
quality: 'default' | 'mq' | 'hq' | 'sd' | 'maxres' = 'hq'
): string {
const qualityMap: Record<string, string> = {
default: 'default',
mq: 'mqdefault',
hq: 'hqdefault',
sd: 'sddefault',
maxres: 'maxresdefault',
}
return `https://img.youtube.com/vi/${videoId}/${qualityMap[quality]}.jpg`
}
/**
* Prüft, ob eine URL eine gültige YouTube-URL ist.
*
* @param url - Die zu prüfende URL
* @returns true wenn die URL eine gültige YouTube-URL ist
*/
export function isValidYouTubeUrl(url: string): boolean {
if (!url) return false
const youtubePatterns = [
/youtube\.com\/watch\?v=/,
/youtu\.be\//,
/youtube\.com\/embed\//,
/youtube\.com\/shorts\//,
/youtube-nocookie\.com\/embed\//,
]
return youtubePatterns.some((pattern) => pattern.test(url))
}
/**
* Extrahiert die Playlist-ID aus einer YouTube-Playlist-URL.
*
* @param url - Die YouTube-Playlist-URL
* @returns Die Playlist-ID oder null wenn nicht gefunden
*
* @example
* extractYouTubePlaylistId('https://www.youtube.com/playlist?list=PLxxxxxx') // 'PLxxxxxx'
*/
export function extractYouTubePlaylistId(url: string): string | null {
if (!url) return null
const patterns = [
/youtube\.com\/playlist\?list=([^&]+)/,
/youtube\.com\/watch\?.*list=([^&]+)/,
]
for (const pattern of patterns) {
const match = url.match(pattern)
if (match) return match[1]
}
return null
}
/**
* Generiert eine YouTube Playlist Embed-URL.
*
* @param playlistId - Die YouTube Playlist-ID
* @param privacyMode - Wenn true, wird youtube-nocookie.com verwendet
* @returns Die vollständige Playlist Embed-URL
*/
export function getYouTubePlaylistEmbedUrl(playlistId: string, privacyMode = true): string {
const domain = privacyMode ? 'www.youtube-nocookie.com' : 'www.youtube.com'
return `https://${domain}/embed/videoseries?list=${playlistId}`
}

88
src/lib/youtubeAccess.ts Normal file
View file

@ -0,0 +1,88 @@
// src/lib/youtubeAccess.ts
import type { Access } from 'payload'
interface UserWithYouTubeRole {
id: number
isSuperAdmin?: boolean
is_super_admin?: boolean // Alternative snake_case Variante
youtubeRole?: 'none' | 'viewer' | 'editor' | 'producer' | 'creator' | 'manager'
youtube_role?: 'none' | 'viewer' | 'editor' | 'producer' | 'creator' | 'manager' // Alternative snake_case
youtubeChannels?: Array<{ id: number } | number>
}
/**
* Hilfsfunktion: Prüft ob User Super-Admin ist (beide Feldnamen)
*/
const checkIsSuperAdmin = (user: UserWithYouTubeRole | null): boolean => {
if (!user) return false
return Boolean(user.isSuperAdmin || user.is_super_admin)
}
/**
* Hilfsfunktion: Holt die YouTube-Rolle des Users (beide Feldnamen)
*/
const getYouTubeRole = (user: UserWithYouTubeRole | null): string | undefined => {
if (!user) return undefined
return user.youtubeRole || user.youtube_role
}
/**
* Prüft ob User YouTube-Manager oder Super-Admin ist
*/
export const isYouTubeManager: Access = ({ req }) => {
const user = req.user as UserWithYouTubeRole | null
if (!user) return false
if (checkIsSuperAdmin(user)) return true
return getYouTubeRole(user) === 'manager'
}
/**
* Prüft ob User mindestens Creator-Rechte hat
*/
export const isYouTubeCreatorOrAbove: Access = ({ req }) => {
const user = req.user as UserWithYouTubeRole | null
if (!user) return false
if (checkIsSuperAdmin(user)) return true
const role = getYouTubeRole(user)
return ['creator', 'manager'].includes(role || '')
}
/**
* Prüft ob User Zugriff auf YouTube-Content hat (mindestens Viewer)
*/
export const hasYouTubeAccess: Access = ({ req }) => {
const user = req.user as UserWithYouTubeRole | null
if (!user) return false
if (checkIsSuperAdmin(user)) return true
const role = getYouTubeRole(user)
return role !== 'none' && role !== undefined
}
/**
* Zugriff auf zugewiesene Videos/Tasks oder als Manager
*/
export const canAccessAssignedContent: Access = ({ req }) => {
const user = req.user as UserWithYouTubeRole | null
if (!user) return false
if (checkIsSuperAdmin(user)) return true
if (getYouTubeRole(user) === 'manager') return true
// Für andere Rollen: Nur zugewiesene Inhalte
return {
or: [
{ assignedTo: { equals: user.id } },
{ createdBy: { equals: user.id } },
],
}
}
/**
* Zugriff auf eigene Benachrichtigungen
*/
export const canAccessOwnNotifications: Access = ({ req }) => {
const user = req.user as UserWithYouTubeRole | null
if (!user) return false
if (checkIsSuperAdmin(user)) return true
return { recipient: { equals: user.id } }
}

View file

@ -0,0 +1,580 @@
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
/**
* Migration: Add YouTube Operations Hub Collections
*
* This migration creates:
* - youtube_channels table with _locales and content_series array table
* - youtube_content table with _locales and multiple array tables
* - yt_tasks table with _locales and array tables for attachments/comments
* - yt_notifications table with _locales
* - Extends users table with youtubeRole enum
* - Adds columns to payload_locked_documents_rels for all 4 collections
*/
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
// Create enums
await db.execute(sql`
DO $$ BEGIN
CREATE TYPE "enum_users_youtube_role" AS ENUM (
'none', 'viewer', 'editor', 'producer', 'creator', 'manager'
);
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
`)
await db.execute(sql`
DO $$ BEGIN
CREATE TYPE "enum_youtube_channels_language" AS ENUM ('de', 'en');
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
`)
await db.execute(sql`
DO $$ BEGIN
CREATE TYPE "enum_youtube_channels_category" AS ENUM ('lifestyle', 'corporate', 'b2b');
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
`)
await db.execute(sql`
DO $$ BEGIN
CREATE TYPE "enum_youtube_channels_status" AS ENUM ('active', 'planned', 'paused', 'archived');
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
`)
await db.execute(sql`
DO $$ BEGIN
CREATE TYPE "enum_youtube_content_format" AS ENUM ('short', 'longform', 'premiere');
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
`)
await db.execute(sql`
DO $$ BEGIN
CREATE TYPE "enum_youtube_content_status" AS ENUM (
'idea', 'script_draft', 'script_review', 'script_approved',
'shoot_scheduled', 'shot', 'rough_cut', 'fine_cut',
'final_review', 'approved', 'upload_scheduled', 'published',
'tracked', 'discarded'
);
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
`)
await db.execute(sql`
DO $$ BEGIN
CREATE TYPE "enum_youtube_content_priority" AS ENUM ('urgent', 'high', 'normal', 'low');
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
`)
await db.execute(sql`
DO $$ BEGIN
CREATE TYPE "enum_youtube_content_visibility" AS ENUM ('public', 'unlisted', 'private');
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
`)
await db.execute(sql`
DO $$ BEGIN
CREATE TYPE "enum_yt_tasks_task_type" AS ENUM (
'script_write', 'script_review', 'shoot_prep', 'shoot',
'edit', 'graphics', 'thumbnail', 'review', 'upload',
'track', 'comments', 'other'
);
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
`)
await db.execute(sql`
DO $$ BEGIN
CREATE TYPE "enum_yt_tasks_status" AS ENUM (
'todo', 'in_progress', 'blocked', 'waiting_review', 'done', 'cancelled'
);
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
`)
await db.execute(sql`
DO $$ BEGIN
CREATE TYPE "enum_yt_tasks_priority" AS ENUM ('urgent', 'high', 'normal', 'low');
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
`)
await db.execute(sql`
DO $$ BEGIN
CREATE TYPE "enum_yt_notifications_type" AS ENUM (
'task_assigned', 'task_due', 'task_overdue', 'approval_required',
'approved', 'rejected', 'video_published', 'comment', 'mention', 'system'
);
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
`)
// Extend users table
await db.execute(sql`
ALTER TABLE "users" ADD COLUMN IF NOT EXISTS "youtube_role" "enum_users_youtube_role" DEFAULT 'none';
`)
// Create YouTube Channels table
await db.execute(sql`
CREATE TABLE IF NOT EXISTS "youtube_channels" (
"id" serial PRIMARY KEY NOT NULL,
"slug" varchar NOT NULL,
"youtube_channel_id" varchar NOT NULL,
"youtube_handle" varchar,
"language" "enum_youtube_channels_language" NOT NULL,
"category" "enum_youtube_channels_category" NOT NULL,
"status" "enum_youtube_channels_status" DEFAULT 'active' NOT NULL,
"branding_primary_color" varchar,
"branding_secondary_color" varchar,
"branding_logo_id" integer REFERENCES media(id) ON DELETE SET NULL,
"branding_thumbnail_template_id" integer REFERENCES media(id) ON DELETE SET NULL,
"publishing_schedule_default_days" jsonb,
"publishing_schedule_default_time" varchar,
"publishing_schedule_shorts_per_week" numeric DEFAULT 4,
"publishing_schedule_longform_per_week" numeric DEFAULT 1,
"current_metrics_subscriber_count" numeric,
"current_metrics_total_views" numeric,
"current_metrics_video_count" numeric,
"current_metrics_last_synced_at" timestamp(3) with time zone,
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
CONSTRAINT "youtube_channels_slug_unique" UNIQUE("slug")
);
`)
await db.execute(sql`
CREATE TABLE IF NOT EXISTS "youtube_channels_locales" (
"name" varchar NOT NULL,
"id" serial PRIMARY KEY NOT NULL,
"_locale" varchar NOT NULL,
"_parent_id" integer NOT NULL REFERENCES youtube_channels(id) ON DELETE CASCADE,
CONSTRAINT "youtube_channels_locales_locale_parent_id_unique" UNIQUE("_locale", "_parent_id")
);
`)
await db.execute(sql`
CREATE TABLE IF NOT EXISTS "youtube_channels_content_series" (
"id" serial PRIMARY KEY NOT NULL,
"_order" integer NOT NULL,
"_parent_id" integer NOT NULL REFERENCES youtube_channels(id) ON DELETE CASCADE,
"slug" varchar NOT NULL,
"color" varchar,
"is_active" boolean DEFAULT true
);
`)
await db.execute(sql`
CREATE TABLE IF NOT EXISTS "youtube_channels_content_series_locales" (
"name" varchar NOT NULL,
"description" varchar,
"id" serial PRIMARY KEY NOT NULL,
"_locale" varchar NOT NULL,
"_parent_id" integer NOT NULL REFERENCES youtube_channels_content_series(id) ON DELETE CASCADE,
CONSTRAINT "youtube_channels_content_series_locales_unique" UNIQUE("_locale", "_parent_id")
);
`)
// Table for hasMany select field: publishingSchedule.defaultDays
await db.execute(sql`
CREATE TABLE IF NOT EXISTS "youtube_channels_publishing_schedule_default_days" (
"order" integer NOT NULL,
"parent_id" integer NOT NULL REFERENCES youtube_channels(id) ON DELETE CASCADE,
"value" varchar NOT NULL,
"id" serial PRIMARY KEY NOT NULL
);
`)
await db.execute(sql`
CREATE INDEX IF NOT EXISTS "youtube_channels_slug_idx" ON "youtube_channels" USING btree ("slug");
CREATE INDEX IF NOT EXISTS "youtube_channels_branding_logo_idx" ON "youtube_channels" USING btree ("branding_logo_id");
CREATE INDEX IF NOT EXISTS "youtube_channels_branding_thumbnail_template_idx" ON "youtube_channels" USING btree ("branding_thumbnail_template_id");
CREATE INDEX IF NOT EXISTS "youtube_channels_updated_at_idx" ON "youtube_channels" USING btree ("updated_at");
CREATE INDEX IF NOT EXISTS "youtube_channels_created_at_idx" ON "youtube_channels" USING btree ("created_at");
CREATE INDEX IF NOT EXISTS "youtube_channels_locales_locale_idx" ON "youtube_channels_locales" USING btree ("_locale");
CREATE INDEX IF NOT EXISTS "youtube_channels_locales_parent_idx" ON "youtube_channels_locales" USING btree ("_parent_id");
CREATE INDEX IF NOT EXISTS "youtube_channels_content_series_order_idx" ON "youtube_channels_content_series" USING btree ("_order");
CREATE INDEX IF NOT EXISTS "youtube_channels_content_series_parent_idx" ON "youtube_channels_content_series" USING btree ("_parent_id");
CREATE INDEX IF NOT EXISTS "youtube_channels_psd_order_idx" ON "youtube_channels_publishing_schedule_default_days" USING btree ("order");
CREATE INDEX IF NOT EXISTS "youtube_channels_psd_parent_idx" ON "youtube_channels_publishing_schedule_default_days" USING btree ("parent_id");
`)
// Create YouTube Content table
await db.execute(sql`
CREATE TABLE IF NOT EXISTS "youtube_content" (
"id" serial PRIMARY KEY NOT NULL,
"slug" varchar NOT NULL,
"channel_id" integer NOT NULL REFERENCES youtube_channels(id) ON DELETE SET NULL,
"content_series" varchar,
"format" "enum_youtube_content_format" NOT NULL,
"status" "enum_youtube_content_status" DEFAULT 'idea' NOT NULL,
"priority" "enum_youtube_content_priority" DEFAULT 'normal',
"assigned_to_id" integer REFERENCES users(id) ON DELETE SET NULL,
"created_by_id" integer REFERENCES users(id) ON DELETE SET NULL,
"script_url" varchar,
"shoot_date" timestamp(3) with time zone,
"edit_deadline" timestamp(3) with time zone,
"review_deadline" timestamp(3) with time zone,
"scheduled_publish_date" timestamp(3) with time zone,
"actual_publish_date" timestamp(3) with time zone,
"thumbnail_id" integer REFERENCES media(id) ON DELETE SET NULL,
"thumbnail_alt_id" integer REFERENCES media(id) ON DELETE SET NULL,
"video_file_id" integer REFERENCES media(id) ON DELETE SET NULL,
"approvals_script_approval_approved" boolean DEFAULT false,
"approvals_script_approval_approved_by_id" integer REFERENCES users(id) ON DELETE SET NULL,
"approvals_script_approval_approved_at" timestamp(3) with time zone,
"approvals_medical_approval_required" boolean DEFAULT false,
"approvals_medical_approval_approved" boolean DEFAULT false,
"approvals_medical_approval_approved_by_id" integer REFERENCES users(id) ON DELETE SET NULL,
"approvals_medical_approval_approved_at" timestamp(3) with time zone,
"approvals_legal_approval_approved" boolean DEFAULT false,
"approvals_legal_approval_approved_by_id" integer REFERENCES users(id) ON DELETE SET NULL,
"approvals_legal_approval_approved_at" timestamp(3) with time zone,
"approvals_legal_approval_disclaimer_included" boolean DEFAULT false,
"approvals_final_approval_approved" boolean DEFAULT false,
"approvals_final_approval_approved_by_id" integer REFERENCES users(id) ON DELETE SET NULL,
"approvals_final_approval_approved_at" timestamp(3) with time zone,
"youtube_video_id" varchar,
"youtube_url" varchar,
"youtube_metadata_visibility" "enum_youtube_content_visibility" DEFAULT 'private',
"youtube_metadata_chapters" varchar,
"performance_views" numeric,
"performance_likes" numeric,
"performance_comments" numeric,
"performance_shares" numeric,
"performance_watch_time_minutes" numeric,
"performance_avg_view_duration" numeric,
"performance_avg_view_percentage" numeric,
"performance_ctr" numeric,
"performance_impressions" numeric,
"performance_subscribers_gained" numeric,
"performance_last_synced_at" timestamp(3) with time zone,
"internal_notes" jsonb,
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
);
`)
await db.execute(sql`
CREATE TABLE IF NOT EXISTS "youtube_content_locales" (
"title" varchar NOT NULL,
"description" varchar,
"hook" varchar,
"call_to_action" varchar,
"script_content" jsonb,
"approvals_script_approval_notes" varchar,
"approvals_medical_approval_notes" varchar,
"approvals_legal_approval_notes" varchar,
"approvals_final_approval_notes" varchar,
"youtube_metadata_youtube_title" varchar,
"youtube_metadata_youtube_description" varchar,
"youtube_metadata_pinned_comment" varchar,
"id" serial PRIMARY KEY NOT NULL,
"_locale" varchar NOT NULL,
"_parent_id" integer NOT NULL REFERENCES youtube_content(id) ON DELETE CASCADE,
CONSTRAINT "youtube_content_locales_locale_parent_id_unique" UNIQUE("_locale", "_parent_id")
);
`)
await db.execute(sql`
CREATE TABLE IF NOT EXISTS "youtube_content_key_points" (
"id" serial PRIMARY KEY NOT NULL,
"_order" integer NOT NULL,
"_parent_id" integer NOT NULL REFERENCES youtube_content(id) ON DELETE CASCADE
);
`)
await db.execute(sql`
CREATE TABLE IF NOT EXISTS "youtube_content_key_points_locales" (
"point" varchar,
"id" serial PRIMARY KEY NOT NULL,
"_locale" varchar NOT NULL,
"_parent_id" integer NOT NULL REFERENCES youtube_content_key_points(id) ON DELETE CASCADE,
CONSTRAINT "youtube_content_key_points_locales_unique" UNIQUE("_locale", "_parent_id")
);
`)
await db.execute(sql`
CREATE TABLE IF NOT EXISTS "youtube_content_raw_footage" (
"id" serial PRIMARY KEY NOT NULL,
"_order" integer NOT NULL,
"_parent_id" integer NOT NULL REFERENCES youtube_content(id) ON DELETE CASCADE,
"file_id" integer REFERENCES media(id) ON DELETE SET NULL,
"description" varchar
);
`)
await db.execute(sql`
CREATE TABLE IF NOT EXISTS "youtube_content_youtube_metadata_tags" (
"id" serial PRIMARY KEY NOT NULL,
"_order" integer NOT NULL,
"_parent_id" integer NOT NULL REFERENCES youtube_content(id) ON DELETE CASCADE,
"tag" varchar
);
`)
await db.execute(sql`
CREATE INDEX IF NOT EXISTS "youtube_content_slug_idx" ON "youtube_content" USING btree ("slug");
CREATE INDEX IF NOT EXISTS "youtube_content_channel_idx" ON "youtube_content" USING btree ("channel_id");
CREATE INDEX IF NOT EXISTS "youtube_content_assigned_to_idx" ON "youtube_content" USING btree ("assigned_to_id");
CREATE INDEX IF NOT EXISTS "youtube_content_created_by_idx" ON "youtube_content" USING btree ("created_by_id");
CREATE INDEX IF NOT EXISTS "youtube_content_status_idx" ON "youtube_content" USING btree ("status");
CREATE INDEX IF NOT EXISTS "youtube_content_thumbnail_idx" ON "youtube_content" USING btree ("thumbnail_id");
CREATE INDEX IF NOT EXISTS "youtube_content_thumbnail_alt_idx" ON "youtube_content" USING btree ("thumbnail_alt_id");
CREATE INDEX IF NOT EXISTS "youtube_content_video_file_idx" ON "youtube_content" USING btree ("video_file_id");
CREATE INDEX IF NOT EXISTS "youtube_content_scheduled_publish_idx" ON "youtube_content" USING btree ("scheduled_publish_date");
CREATE INDEX IF NOT EXISTS "youtube_content_updated_at_idx" ON "youtube_content" USING btree ("updated_at");
CREATE INDEX IF NOT EXISTS "youtube_content_created_at_idx" ON "youtube_content" USING btree ("created_at");
CREATE INDEX IF NOT EXISTS "youtube_content_locales_locale_idx" ON "youtube_content_locales" USING btree ("_locale");
CREATE INDEX IF NOT EXISTS "youtube_content_locales_parent_idx" ON "youtube_content_locales" USING btree ("_parent_id");
CREATE INDEX IF NOT EXISTS "youtube_content_key_points_order_idx" ON "youtube_content_key_points" USING btree ("_order");
CREATE INDEX IF NOT EXISTS "youtube_content_key_points_parent_idx" ON "youtube_content_key_points" USING btree ("_parent_id");
CREATE INDEX IF NOT EXISTS "youtube_content_raw_footage_order_idx" ON "youtube_content_raw_footage" USING btree ("_order");
CREATE INDEX IF NOT EXISTS "youtube_content_raw_footage_parent_idx" ON "youtube_content_raw_footage" USING btree ("_parent_id");
CREATE INDEX IF NOT EXISTS "youtube_content_youtube_metadata_tags_order_idx" ON "youtube_content_youtube_metadata_tags" USING btree ("_order");
CREATE INDEX IF NOT EXISTS "youtube_content_youtube_metadata_tags_parent_idx" ON "youtube_content_youtube_metadata_tags" USING btree ("_parent_id");
`)
// Create YT Tasks table
await db.execute(sql`
CREATE TABLE IF NOT EXISTS "yt_tasks" (
"id" serial PRIMARY KEY NOT NULL,
"video_id" integer REFERENCES youtube_content(id) ON DELETE SET NULL,
"channel_id" integer REFERENCES youtube_channels(id) ON DELETE SET NULL,
"task_type" "enum_yt_tasks_task_type" NOT NULL,
"status" "enum_yt_tasks_status" DEFAULT 'todo' NOT NULL,
"priority" "enum_yt_tasks_priority" DEFAULT 'normal',
"assigned_to_id" integer NOT NULL REFERENCES users(id) ON DELETE SET NULL,
"due_date" timestamp(3) with time zone,
"completed_at" timestamp(3) with time zone,
"completed_by_id" integer REFERENCES users(id) ON DELETE SET NULL,
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
);
`)
await db.execute(sql`
CREATE TABLE IF NOT EXISTS "yt_tasks_locales" (
"title" varchar NOT NULL,
"description" varchar,
"blocked_reason" varchar,
"id" serial PRIMARY KEY NOT NULL,
"_locale" varchar NOT NULL,
"_parent_id" integer NOT NULL REFERENCES yt_tasks(id) ON DELETE CASCADE,
CONSTRAINT "yt_tasks_locales_locale_parent_id_unique" UNIQUE("_locale", "_parent_id")
);
`)
await db.execute(sql`
CREATE TABLE IF NOT EXISTS "yt_tasks_attachments" (
"id" serial PRIMARY KEY NOT NULL,
"_order" integer NOT NULL,
"_parent_id" integer NOT NULL REFERENCES yt_tasks(id) ON DELETE CASCADE,
"file_id" integer REFERENCES media(id) ON DELETE SET NULL,
"note" varchar
);
`)
await db.execute(sql`
CREATE TABLE IF NOT EXISTS "yt_tasks_comments" (
"id" serial PRIMARY KEY NOT NULL,
"_order" integer NOT NULL,
"_parent_id" integer NOT NULL REFERENCES yt_tasks(id) ON DELETE CASCADE,
"author_id" integer REFERENCES users(id) ON DELETE SET NULL,
"content" varchar,
"created_at" timestamp(3) with time zone
);
`)
await db.execute(sql`
CREATE INDEX IF NOT EXISTS "yt_tasks_video_idx" ON "yt_tasks" USING btree ("video_id");
CREATE INDEX IF NOT EXISTS "yt_tasks_channel_idx" ON "yt_tasks" USING btree ("channel_id");
CREATE INDEX IF NOT EXISTS "yt_tasks_assigned_to_idx" ON "yt_tasks" USING btree ("assigned_to_id");
CREATE INDEX IF NOT EXISTS "yt_tasks_status_idx" ON "yt_tasks" USING btree ("status");
CREATE INDEX IF NOT EXISTS "yt_tasks_due_date_idx" ON "yt_tasks" USING btree ("due_date");
CREATE INDEX IF NOT EXISTS "yt_tasks_completed_by_idx" ON "yt_tasks" USING btree ("completed_by_id");
CREATE INDEX IF NOT EXISTS "yt_tasks_updated_at_idx" ON "yt_tasks" USING btree ("updated_at");
CREATE INDEX IF NOT EXISTS "yt_tasks_created_at_idx" ON "yt_tasks" USING btree ("created_at");
CREATE INDEX IF NOT EXISTS "yt_tasks_locales_locale_idx" ON "yt_tasks_locales" USING btree ("_locale");
CREATE INDEX IF NOT EXISTS "yt_tasks_locales_parent_idx" ON "yt_tasks_locales" USING btree ("_parent_id");
CREATE INDEX IF NOT EXISTS "yt_tasks_attachments_order_idx" ON "yt_tasks_attachments" USING btree ("_order");
CREATE INDEX IF NOT EXISTS "yt_tasks_attachments_parent_idx" ON "yt_tasks_attachments" USING btree ("_parent_id");
CREATE INDEX IF NOT EXISTS "yt_tasks_attachments_file_idx" ON "yt_tasks_attachments" USING btree ("file_id");
CREATE INDEX IF NOT EXISTS "yt_tasks_comments_order_idx" ON "yt_tasks_comments" USING btree ("_order");
CREATE INDEX IF NOT EXISTS "yt_tasks_comments_parent_idx" ON "yt_tasks_comments" USING btree ("_parent_id");
CREATE INDEX IF NOT EXISTS "yt_tasks_comments_author_idx" ON "yt_tasks_comments" USING btree ("author_id");
`)
// Create YT Notifications table
await db.execute(sql`
CREATE TABLE IF NOT EXISTS "yt_notifications" (
"id" serial PRIMARY KEY NOT NULL,
"recipient_id" integer NOT NULL REFERENCES users(id) ON DELETE CASCADE,
"type" "enum_yt_notifications_type" NOT NULL,
"link" varchar,
"related_video_id" integer REFERENCES youtube_content(id) ON DELETE SET NULL,
"related_task_id" integer REFERENCES yt_tasks(id) ON DELETE SET NULL,
"read" boolean DEFAULT false,
"read_at" timestamp(3) with time zone,
"email_sent" boolean DEFAULT false,
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
);
`)
await db.execute(sql`
CREATE TABLE IF NOT EXISTS "yt_notifications_locales" (
"title" varchar NOT NULL,
"message" varchar,
"id" serial PRIMARY KEY NOT NULL,
"_locale" varchar NOT NULL,
"_parent_id" integer NOT NULL REFERENCES yt_notifications(id) ON DELETE CASCADE,
CONSTRAINT "yt_notifications_locales_locale_parent_id_unique" UNIQUE("_locale", "_parent_id")
);
`)
await db.execute(sql`
CREATE INDEX IF NOT EXISTS "yt_notifications_recipient_idx" ON "yt_notifications" USING btree ("recipient_id");
CREATE INDEX IF NOT EXISTS "yt_notifications_type_idx" ON "yt_notifications" USING btree ("type");
CREATE INDEX IF NOT EXISTS "yt_notifications_related_video_idx" ON "yt_notifications" USING btree ("related_video_id");
CREATE INDEX IF NOT EXISTS "yt_notifications_related_task_idx" ON "yt_notifications" USING btree ("related_task_id");
CREATE INDEX IF NOT EXISTS "yt_notifications_read_idx" ON "yt_notifications" USING btree ("read");
CREATE INDEX IF NOT EXISTS "yt_notifications_updated_at_idx" ON "yt_notifications" USING btree ("updated_at");
CREATE INDEX IF NOT EXISTS "yt_notifications_created_at_idx" ON "yt_notifications" USING btree ("created_at");
CREATE INDEX IF NOT EXISTS "yt_notifications_locales_locale_idx" ON "yt_notifications_locales" USING btree ("_locale");
CREATE INDEX IF NOT EXISTS "yt_notifications_locales_parent_idx" ON "yt_notifications_locales" USING btree ("_parent_id");
`)
// Create users_rels table for youtubeChannels relationship
await db.execute(sql`
CREATE TABLE IF NOT EXISTS "users_rels" (
"id" serial PRIMARY KEY NOT NULL,
"order" integer,
"parent_id" integer NOT NULL REFERENCES users(id) ON DELETE CASCADE,
"path" varchar NOT NULL,
"youtube_channels_id" integer REFERENCES youtube_channels(id) ON DELETE CASCADE
);
`)
await db.execute(sql`
CREATE INDEX IF NOT EXISTS "users_rels_order_idx" ON "users_rels" USING btree ("order");
CREATE INDEX IF NOT EXISTS "users_rels_parent_idx" ON "users_rels" USING btree ("parent_id");
CREATE INDEX IF NOT EXISTS "users_rels_path_idx" ON "users_rels" USING btree ("path");
CREATE INDEX IF NOT EXISTS "users_rels_youtube_channels_idx" ON "users_rels" USING btree ("youtube_channels_id");
`)
// Add columns to payload_locked_documents_rels
await db.execute(sql`
ALTER TABLE "payload_locked_documents_rels"
ADD COLUMN IF NOT EXISTS "youtube_channels_id" integer REFERENCES youtube_channels(id) ON DELETE CASCADE;
`)
await db.execute(sql`
ALTER TABLE "payload_locked_documents_rels"
ADD COLUMN IF NOT EXISTS "youtube_content_id" integer REFERENCES youtube_content(id) ON DELETE CASCADE;
`)
await db.execute(sql`
ALTER TABLE "payload_locked_documents_rels"
ADD COLUMN IF NOT EXISTS "yt_tasks_id" integer REFERENCES yt_tasks(id) ON DELETE CASCADE;
`)
await db.execute(sql`
ALTER TABLE "payload_locked_documents_rels"
ADD COLUMN IF NOT EXISTS "yt_notifications_id" integer REFERENCES yt_notifications(id) ON DELETE CASCADE;
`)
await db.execute(sql`
CREATE INDEX IF NOT EXISTS "payload_locked_documents_rels_youtube_channels_idx"
ON "payload_locked_documents_rels" USING btree ("youtube_channels_id");
`)
await db.execute(sql`
CREATE INDEX IF NOT EXISTS "payload_locked_documents_rels_youtube_content_idx"
ON "payload_locked_documents_rels" USING btree ("youtube_content_id");
`)
await db.execute(sql`
CREATE INDEX IF NOT EXISTS "payload_locked_documents_rels_yt_tasks_idx"
ON "payload_locked_documents_rels" USING btree ("yt_tasks_id");
`)
await db.execute(sql`
CREATE INDEX IF NOT EXISTS "payload_locked_documents_rels_yt_notifications_idx"
ON "payload_locked_documents_rels" USING btree ("yt_notifications_id");
`)
}
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
// Remove indexes from system tables first
await db.execute(sql`DROP INDEX IF EXISTS "payload_locked_documents_rels_youtube_channels_idx";`)
await db.execute(sql`DROP INDEX IF EXISTS "payload_locked_documents_rels_youtube_content_idx";`)
await db.execute(sql`DROP INDEX IF EXISTS "payload_locked_documents_rels_yt_tasks_idx";`)
await db.execute(sql`DROP INDEX IF EXISTS "payload_locked_documents_rels_yt_notifications_idx";`)
// Remove columns from system tables
await db.execute(sql`ALTER TABLE "payload_locked_documents_rels" DROP COLUMN IF EXISTS "youtube_channels_id";`)
await db.execute(sql`ALTER TABLE "payload_locked_documents_rels" DROP COLUMN IF EXISTS "youtube_content_id";`)
await db.execute(sql`ALTER TABLE "payload_locked_documents_rels" DROP COLUMN IF EXISTS "yt_tasks_id";`)
await db.execute(sql`ALTER TABLE "payload_locked_documents_rels" DROP COLUMN IF EXISTS "yt_notifications_id";`)
// Drop users_rels table (created for youtubeChannels relationship)
await db.execute(sql`DROP TABLE IF EXISTS "users_rels";`)
// Drop YT Notifications tables
await db.execute(sql`DROP TABLE IF EXISTS "yt_notifications_locales";`)
await db.execute(sql`DROP TABLE IF EXISTS "yt_notifications";`)
// Drop YT Tasks tables
await db.execute(sql`DROP TABLE IF EXISTS "yt_tasks_comments";`)
await db.execute(sql`DROP TABLE IF EXISTS "yt_tasks_attachments";`)
await db.execute(sql`DROP TABLE IF EXISTS "yt_tasks_locales";`)
await db.execute(sql`DROP TABLE IF EXISTS "yt_tasks";`)
// Drop YouTube Content tables
await db.execute(sql`DROP TABLE IF EXISTS "youtube_content_youtube_metadata_tags";`)
await db.execute(sql`DROP TABLE IF EXISTS "youtube_content_raw_footage";`)
await db.execute(sql`DROP TABLE IF EXISTS "youtube_content_key_points_locales";`)
await db.execute(sql`DROP TABLE IF EXISTS "youtube_content_key_points";`)
await db.execute(sql`DROP TABLE IF EXISTS "youtube_content_locales";`)
await db.execute(sql`DROP TABLE IF EXISTS "youtube_content";`)
// Drop YouTube Channels tables
await db.execute(sql`DROP TABLE IF EXISTS "youtube_channels_publishing_schedule_default_days";`)
await db.execute(sql`DROP TABLE IF EXISTS "youtube_channels_content_series_locales";`)
await db.execute(sql`DROP TABLE IF EXISTS "youtube_channels_content_series";`)
await db.execute(sql`DROP TABLE IF EXISTS "youtube_channels_locales";`)
await db.execute(sql`DROP TABLE IF EXISTS "youtube_channels";`)
// Remove users column
await db.execute(sql`ALTER TABLE "users" DROP COLUMN IF EXISTS "youtube_role";`)
// Drop enums
await db.execute(sql`DROP TYPE IF EXISTS "enum_yt_notifications_type";`)
await db.execute(sql`DROP TYPE IF EXISTS "enum_yt_tasks_priority";`)
await db.execute(sql`DROP TYPE IF EXISTS "enum_yt_tasks_status";`)
await db.execute(sql`DROP TYPE IF EXISTS "enum_yt_tasks_task_type";`)
await db.execute(sql`DROP TYPE IF EXISTS "enum_youtube_content_visibility";`)
await db.execute(sql`DROP TYPE IF EXISTS "enum_youtube_content_priority";`)
await db.execute(sql`DROP TYPE IF EXISTS "enum_youtube_content_status";`)
await db.execute(sql`DROP TYPE IF EXISTS "enum_youtube_content_format";`)
await db.execute(sql`DROP TYPE IF EXISTS "enum_youtube_channels_status";`)
await db.execute(sql`DROP TYPE IF EXISTS "enum_youtube_channels_category";`)
await db.execute(sql`DROP TYPE IF EXISTS "enum_youtube_channels_language";`)
await db.execute(sql`DROP TYPE IF EXISTS "enum_users_youtube_role";`)
}

View file

@ -0,0 +1,646 @@
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
/**
* Migration: Add YouTube Operations Hub v2 Collections
*
* This migration creates:
* - yt_batches table with arrays (shootDays, seriesDistribution)
* - yt_monthly_goals table with arrays (customGoals)
* - yt_script_tpl table (Script Templates) with blocks
* - yt_checklist_templates table with items array
* - Extends youtube_content with production, script, posting fields
* - Adds columns to payload_locked_documents_rels for all 4 new collections
*/
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
// ===== ENUMS =====
// YtBatches enums
await db.execute(sql`
DO $$ BEGIN
CREATE TYPE "enum_yt_batches_shoot_days_location" AS ENUM ('home', 'office', 'mobile', 'external');
EXCEPTION WHEN duplicate_object THEN null;
END $$;
`)
await db.execute(sql`
DO $$ BEGIN
CREATE TYPE "enum_yt_batches_shoot_days_duration" AS ENUM ('2h', '4h', '8h');
EXCEPTION WHEN duplicate_object THEN null;
END $$;
`)
await db.execute(sql`
DO $$ BEGIN
CREATE TYPE "enum_yt_batches_series_dist_priority" AS ENUM ('high', 'normal', 'low');
EXCEPTION WHEN duplicate_object THEN null;
END $$;
`)
await db.execute(sql`
DO $$ BEGIN
CREATE TYPE "enum_yt_batches_status" AS ENUM ('planning', 'scripting', 'production', 'editing', 'review', 'ready', 'published');
EXCEPTION WHEN duplicate_object THEN null;
END $$;
`)
// YtMonthlyGoals enums
await db.execute(sql`
DO $$ BEGIN
CREATE TYPE "enum_yt_monthly_goals_custom_status" AS ENUM ('on_track', 'at_risk', 'achieved', 'missed');
EXCEPTION WHEN duplicate_object THEN null;
END $$;
`)
// YtScriptTemplates enums
await db.execute(sql`
DO $$ BEGIN
CREATE TYPE "enum_yt_script_tpl_format" AS ENUM ('short', 'longform');
EXCEPTION WHEN duplicate_object THEN null;
END $$;
`)
// ScriptSectionBlock enums
await db.execute(sql`
DO $$ BEGIN
CREATE TYPE "enum_script_sec_type" AS ENUM ('hook', 'intro_ident', 'context', 'content_part', 'summary', 'cta', 'outro', 'disclaimer');
EXCEPTION WHEN duplicate_object THEN null;
END $$;
`)
await db.execute(sql`
DO $$ BEGIN
CREATE TYPE "enum_script_sec_ovl_style" AS ENUM ('standard', 'highlight', 'quote', 'statistic', 'list');
EXCEPTION WHEN duplicate_object THEN null;
END $$;
`)
// YtChecklistTemplates enums
await db.execute(sql`
DO $$ BEGIN
CREATE TYPE "enum_yt_checklist_tpl_type" AS ENUM ('upload', 'production', 'review', 'post_publish');
EXCEPTION WHEN duplicate_object THEN null;
END $$;
`)
await db.execute(sql`
DO $$ BEGIN
CREATE TYPE "enum_yt_checklist_tpl_format" AS ENUM ('all', 'short', 'longform');
EXCEPTION WHEN duplicate_object THEN null;
END $$;
`)
await db.execute(sql`
DO $$ BEGIN
CREATE TYPE "enum_yt_checklist_tpl_items_category" AS ENUM ('metadata', 'assets', 'seo', 'community', 'legal', 'other');
EXCEPTION WHEN duplicate_object THEN null;
END $$;
`)
// YouTubeContent posting enums
await db.execute(sql`
DO $$ BEGIN
CREATE TYPE "enum_youtube_content_cta_type" AS ENUM ('link_in_bio', 'newsletter', 'longform_link', 'custom');
EXCEPTION WHEN duplicate_object THEN null;
END $$;
`)
await db.execute(sql`
DO $$ BEGIN
CREATE TYPE "enum_youtube_content_disclaimer_type" AS ENUM ('medical', 'legal', 'affiliate', 'sponsored');
EXCEPTION WHEN duplicate_object THEN null;
END $$;
`)
await db.execute(sql`
DO $$ BEGIN
CREATE TYPE "enum_youtube_content_disclaimer_placement" AS ENUM ('spoken', 'overlay', 'description', 'all');
EXCEPTION WHEN duplicate_object THEN null;
END $$;
`)
// ===== YT_BATCHES TABLE =====
await db.execute(sql`
CREATE TABLE IF NOT EXISTS "yt_batches" (
"id" serial PRIMARY KEY NOT NULL,
"name" varchar NOT NULL,
"channel_id" integer NOT NULL REFERENCES youtube_channels(id) ON DELETE SET NULL,
"production_period_start" timestamp(3) with time zone NOT NULL,
"production_period_end" timestamp(3) with time zone NOT NULL,
"targets_shorts_target" numeric DEFAULT 7 NOT NULL,
"targets_longforms_target" numeric DEFAULT 3 NOT NULL,
"targets_total_target" numeric,
"targets_buffer_days" numeric DEFAULT 3,
"status" "enum_yt_batches_status" DEFAULT 'planning' NOT NULL,
"progress_shorts_completed" numeric DEFAULT 0,
"progress_longforms_completed" numeric DEFAULT 0,
"progress_percentage" numeric DEFAULT 0,
"team_producer_id" integer REFERENCES users(id) ON DELETE SET NULL,
"team_editor_id" integer REFERENCES users(id) ON DELETE SET NULL,
"team_reviewer_id" integer REFERENCES users(id) ON DELETE SET NULL,
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
);
`)
await db.execute(sql`
CREATE TABLE IF NOT EXISTS "yt_batches_locales" (
"notes" varchar,
"id" serial PRIMARY KEY NOT NULL,
"_locale" varchar NOT NULL,
"_parent_id" integer NOT NULL REFERENCES yt_batches(id) ON DELETE CASCADE,
CONSTRAINT "yt_batches_locales_unique" UNIQUE("_locale", "_parent_id")
);
`)
await db.execute(sql`
CREATE TABLE IF NOT EXISTS "yt_batches_shoot_days" (
"id" serial PRIMARY KEY NOT NULL,
"_order" integer NOT NULL,
"_parent_id" integer NOT NULL REFERENCES yt_batches(id) ON DELETE CASCADE,
"date" timestamp(3) with time zone NOT NULL,
"location" "enum_yt_batches_shoot_days_location",
"duration" "enum_yt_batches_shoot_days_duration",
"notes" varchar
);
`)
await db.execute(sql`
CREATE TABLE IF NOT EXISTS "yt_batches_series_distribution" (
"id" serial PRIMARY KEY NOT NULL,
"_order" integer NOT NULL,
"_parent_id" integer NOT NULL REFERENCES yt_batches(id) ON DELETE CASCADE,
"series" varchar NOT NULL,
"shorts_count" numeric DEFAULT 0,
"longforms_count" numeric DEFAULT 0,
"priority" "enum_yt_batches_series_dist_priority" DEFAULT 'normal'
);
`)
await db.execute(sql`
CREATE INDEX IF NOT EXISTS "yt_batches_channel_idx" ON "yt_batches" USING btree ("channel_id");
CREATE INDEX IF NOT EXISTS "yt_batches_status_idx" ON "yt_batches" USING btree ("status");
CREATE INDEX IF NOT EXISTS "yt_batches_producer_idx" ON "yt_batches" USING btree ("team_producer_id");
CREATE INDEX IF NOT EXISTS "yt_batches_editor_idx" ON "yt_batches" USING btree ("team_editor_id");
CREATE INDEX IF NOT EXISTS "yt_batches_reviewer_idx" ON "yt_batches" USING btree ("team_reviewer_id");
CREATE INDEX IF NOT EXISTS "yt_batches_updated_at_idx" ON "yt_batches" USING btree ("updated_at");
CREATE INDEX IF NOT EXISTS "yt_batches_created_at_idx" ON "yt_batches" USING btree ("created_at");
CREATE INDEX IF NOT EXISTS "yt_batches_locales_locale_idx" ON "yt_batches_locales" USING btree ("_locale");
CREATE INDEX IF NOT EXISTS "yt_batches_locales_parent_idx" ON "yt_batches_locales" USING btree ("_parent_id");
CREATE INDEX IF NOT EXISTS "yt_batches_shoot_days_order_idx" ON "yt_batches_shoot_days" USING btree ("_order");
CREATE INDEX IF NOT EXISTS "yt_batches_shoot_days_parent_idx" ON "yt_batches_shoot_days" USING btree ("_parent_id");
CREATE INDEX IF NOT EXISTS "yt_batches_series_dist_order_idx" ON "yt_batches_series_distribution" USING btree ("_order");
CREATE INDEX IF NOT EXISTS "yt_batches_series_dist_parent_idx" ON "yt_batches_series_distribution" USING btree ("_parent_id");
`)
// ===== YT_MONTHLY_GOALS TABLE =====
await db.execute(sql`
CREATE TABLE IF NOT EXISTS "yt_monthly_goals" (
"id" serial PRIMARY KEY NOT NULL,
"channel_id" integer NOT NULL REFERENCES youtube_channels(id) ON DELETE SET NULL,
"month" timestamp(3) with time zone NOT NULL,
"display_title" varchar,
"content_goals_longforms_target" numeric DEFAULT 12,
"content_goals_longforms_current" numeric DEFAULT 0,
"content_goals_shorts_target" numeric DEFAULT 28,
"content_goals_shorts_current" numeric DEFAULT 0,
"audience_goals_subscribers_target" numeric,
"audience_goals_subscribers_current" numeric,
"audience_goals_views_target" numeric,
"audience_goals_views_current" numeric,
"engagement_goals_avg_ctr_target" varchar,
"engagement_goals_avg_ctr_current" varchar,
"engagement_goals_avg_retention_target" varchar,
"engagement_goals_avg_retention_current" varchar,
"business_goals_newsletter_signups_target" numeric,
"business_goals_newsletter_signups_current" numeric,
"business_goals_affiliate_revenue_target" numeric,
"business_goals_affiliate_revenue_current" numeric,
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
);
`)
await db.execute(sql`
CREATE TABLE IF NOT EXISTS "yt_monthly_goals_locales" (
"notes" varchar,
"id" serial PRIMARY KEY NOT NULL,
"_locale" varchar NOT NULL,
"_parent_id" integer NOT NULL REFERENCES yt_monthly_goals(id) ON DELETE CASCADE,
CONSTRAINT "yt_monthly_goals_locales_unique" UNIQUE("_locale", "_parent_id")
);
`)
await db.execute(sql`
CREATE TABLE IF NOT EXISTS "yt_monthly_goals_custom_goals" (
"id" serial PRIMARY KEY NOT NULL,
"_order" integer NOT NULL,
"_parent_id" integer NOT NULL REFERENCES yt_monthly_goals(id) ON DELETE CASCADE,
"metric" varchar NOT NULL,
"target" varchar NOT NULL,
"current" varchar,
"status" "enum_yt_monthly_goals_custom_status"
);
`)
await db.execute(sql`
CREATE INDEX IF NOT EXISTS "yt_monthly_goals_channel_idx" ON "yt_monthly_goals" USING btree ("channel_id");
CREATE INDEX IF NOT EXISTS "yt_monthly_goals_month_idx" ON "yt_monthly_goals" USING btree ("month");
CREATE INDEX IF NOT EXISTS "yt_monthly_goals_updated_at_idx" ON "yt_monthly_goals" USING btree ("updated_at");
CREATE INDEX IF NOT EXISTS "yt_monthly_goals_created_at_idx" ON "yt_monthly_goals" USING btree ("created_at");
CREATE INDEX IF NOT EXISTS "yt_monthly_goals_locales_locale_idx" ON "yt_monthly_goals_locales" USING btree ("_locale");
CREATE INDEX IF NOT EXISTS "yt_monthly_goals_locales_parent_idx" ON "yt_monthly_goals_locales" USING btree ("_parent_id");
CREATE INDEX IF NOT EXISTS "yt_monthly_goals_custom_order_idx" ON "yt_monthly_goals_custom_goals" USING btree ("_order");
CREATE INDEX IF NOT EXISTS "yt_monthly_goals_custom_parent_idx" ON "yt_monthly_goals_custom_goals" USING btree ("_parent_id");
`)
// ===== YT_SCRIPT_TPL TABLE =====
await db.execute(sql`
CREATE TABLE IF NOT EXISTS "yt_script_tpl" (
"id" serial PRIMARY KEY NOT NULL,
"channel_id" integer NOT NULL REFERENCES youtube_channels(id) ON DELETE SET NULL,
"series" varchar NOT NULL,
"format" "enum_yt_script_tpl_format" NOT NULL,
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
);
`)
await db.execute(sql`
CREATE TABLE IF NOT EXISTS "yt_script_tpl_locales" (
"name" varchar NOT NULL,
"description" varchar,
"id" serial PRIMARY KEY NOT NULL,
"_locale" varchar NOT NULL,
"_parent_id" integer NOT NULL REFERENCES yt_script_tpl(id) ON DELETE CASCADE,
CONSTRAINT "yt_script_tpl_locales_unique" UNIQUE("_locale", "_parent_id")
);
`)
await db.execute(sql`
CREATE TABLE IF NOT EXISTS "yt_script_tpl_blocks_script_sec" (
"id" serial PRIMARY KEY NOT NULL,
"_order" integer NOT NULL,
"_parent_id" integer NOT NULL REFERENCES yt_script_tpl(id) ON DELETE CASCADE,
"_path" varchar NOT NULL,
"sec_type" "enum_script_sec_type" NOT NULL,
"duration" varchar,
"section_title" varchar,
"visual_notes" varchar,
"block_name" varchar
);
`)
await db.execute(sql`
CREATE TABLE IF NOT EXISTS "yt_script_tpl_blocks_script_sec_locales" (
"spoken_text" jsonb,
"id" serial PRIMARY KEY NOT NULL,
"_locale" varchar NOT NULL,
"_parent_id" integer NOT NULL REFERENCES yt_script_tpl_blocks_script_sec(id) ON DELETE CASCADE,
CONSTRAINT "yt_script_tpl_blocks_script_sec_locales_unique" UNIQUE("_locale", "_parent_id")
);
`)
await db.execute(sql`
CREATE TABLE IF NOT EXISTS "yt_script_tpl_blocks_script_sec_b_roll" (
"id" serial PRIMARY KEY NOT NULL,
"_order" integer NOT NULL,
"_parent_id" integer NOT NULL REFERENCES yt_script_tpl_blocks_script_sec(id) ON DELETE CASCADE,
"instruction" varchar NOT NULL,
"timestamp" varchar
);
`)
await db.execute(sql`
CREATE TABLE IF NOT EXISTS "yt_script_tpl_blocks_script_sec_overlays" (
"id" serial PRIMARY KEY NOT NULL,
"_order" integer NOT NULL,
"_parent_id" integer NOT NULL REFERENCES yt_script_tpl_blocks_script_sec(id) ON DELETE CASCADE,
"text" varchar NOT NULL,
"ovl_style" "enum_script_sec_ovl_style" DEFAULT 'standard'
);
`)
await db.execute(sql`
CREATE INDEX IF NOT EXISTS "yt_script_tpl_channel_idx" ON "yt_script_tpl" USING btree ("channel_id");
CREATE INDEX IF NOT EXISTS "yt_script_tpl_format_idx" ON "yt_script_tpl" USING btree ("format");
CREATE INDEX IF NOT EXISTS "yt_script_tpl_updated_at_idx" ON "yt_script_tpl" USING btree ("updated_at");
CREATE INDEX IF NOT EXISTS "yt_script_tpl_created_at_idx" ON "yt_script_tpl" USING btree ("created_at");
CREATE INDEX IF NOT EXISTS "yt_script_tpl_locales_locale_idx" ON "yt_script_tpl_locales" USING btree ("_locale");
CREATE INDEX IF NOT EXISTS "yt_script_tpl_locales_parent_idx" ON "yt_script_tpl_locales" USING btree ("_parent_id");
CREATE INDEX IF NOT EXISTS "yt_script_tpl_blocks_order_idx" ON "yt_script_tpl_blocks_script_sec" USING btree ("_order");
CREATE INDEX IF NOT EXISTS "yt_script_tpl_blocks_parent_idx" ON "yt_script_tpl_blocks_script_sec" USING btree ("_parent_id");
CREATE INDEX IF NOT EXISTS "yt_script_tpl_blocks_path_idx" ON "yt_script_tpl_blocks_script_sec" USING btree ("_path");
CREATE INDEX IF NOT EXISTS "yt_script_tpl_blocks_locales_locale_idx" ON "yt_script_tpl_blocks_script_sec_locales" USING btree ("_locale");
CREATE INDEX IF NOT EXISTS "yt_script_tpl_blocks_locales_parent_idx" ON "yt_script_tpl_blocks_script_sec_locales" USING btree ("_parent_id");
CREATE INDEX IF NOT EXISTS "yt_script_tpl_b_roll_order_idx" ON "yt_script_tpl_blocks_script_sec_b_roll" USING btree ("_order");
CREATE INDEX IF NOT EXISTS "yt_script_tpl_b_roll_parent_idx" ON "yt_script_tpl_blocks_script_sec_b_roll" USING btree ("_parent_id");
CREATE INDEX IF NOT EXISTS "yt_script_tpl_overlays_order_idx" ON "yt_script_tpl_blocks_script_sec_overlays" USING btree ("_order");
CREATE INDEX IF NOT EXISTS "yt_script_tpl_overlays_parent_idx" ON "yt_script_tpl_blocks_script_sec_overlays" USING btree ("_parent_id");
`)
// ===== YT_CHECKLIST_TEMPLATES TABLE =====
await db.execute(sql`
CREATE TABLE IF NOT EXISTS "yt_checklist_templates" (
"id" serial PRIMARY KEY NOT NULL,
"channel_id" integer REFERENCES youtube_channels(id) ON DELETE SET NULL,
"type" "enum_yt_checklist_tpl_type" NOT NULL,
"format" "enum_yt_checklist_tpl_format" DEFAULT 'all',
"is_default" boolean DEFAULT false,
"is_active" boolean DEFAULT true,
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
);
`)
await db.execute(sql`
CREATE TABLE IF NOT EXISTS "yt_checklist_templates_locales" (
"name" varchar NOT NULL,
"description" varchar,
"id" serial PRIMARY KEY NOT NULL,
"_locale" varchar NOT NULL,
"_parent_id" integer NOT NULL REFERENCES yt_checklist_templates(id) ON DELETE CASCADE,
CONSTRAINT "yt_checklist_templates_locales_unique" UNIQUE("_locale", "_parent_id")
);
`)
await db.execute(sql`
CREATE TABLE IF NOT EXISTS "yt_checklist_templates_items" (
"id" serial PRIMARY KEY NOT NULL,
"_order" integer NOT NULL,
"_parent_id" integer NOT NULL REFERENCES yt_checklist_templates(id) ON DELETE CASCADE,
"order" numeric,
"category" "enum_yt_checklist_tpl_items_category",
"is_required" boolean DEFAULT true
);
`)
await db.execute(sql`
CREATE TABLE IF NOT EXISTS "yt_checklist_templates_items_locales" (
"task" varchar NOT NULL,
"details" varchar,
"id" serial PRIMARY KEY NOT NULL,
"_locale" varchar NOT NULL,
"_parent_id" integer NOT NULL REFERENCES yt_checklist_templates_items(id) ON DELETE CASCADE,
CONSTRAINT "yt_checklist_templates_items_locales_unique" UNIQUE("_locale", "_parent_id")
);
`)
await db.execute(sql`
CREATE INDEX IF NOT EXISTS "yt_checklist_tpl_channel_idx" ON "yt_checklist_templates" USING btree ("channel_id");
CREATE INDEX IF NOT EXISTS "yt_checklist_tpl_type_idx" ON "yt_checklist_templates" USING btree ("type");
CREATE INDEX IF NOT EXISTS "yt_checklist_tpl_format_idx" ON "yt_checklist_templates" USING btree ("format");
CREATE INDEX IF NOT EXISTS "yt_checklist_tpl_updated_at_idx" ON "yt_checklist_templates" USING btree ("updated_at");
CREATE INDEX IF NOT EXISTS "yt_checklist_tpl_created_at_idx" ON "yt_checklist_templates" USING btree ("created_at");
CREATE INDEX IF NOT EXISTS "yt_checklist_tpl_locales_locale_idx" ON "yt_checklist_templates_locales" USING btree ("_locale");
CREATE INDEX IF NOT EXISTS "yt_checklist_tpl_locales_parent_idx" ON "yt_checklist_templates_locales" USING btree ("_parent_id");
CREATE INDEX IF NOT EXISTS "yt_checklist_tpl_items_order_idx" ON "yt_checklist_templates_items" USING btree ("_order");
CREATE INDEX IF NOT EXISTS "yt_checklist_tpl_items_parent_idx" ON "yt_checklist_templates_items" USING btree ("_parent_id");
CREATE INDEX IF NOT EXISTS "yt_checklist_tpl_items_l_locale_idx" ON "yt_checklist_templates_items_locales" USING btree ("_locale");
CREATE INDEX IF NOT EXISTS "yt_checklist_tpl_items_l_parent_idx" ON "yt_checklist_templates_items_locales" USING btree ("_parent_id");
`)
// ===== EXTEND YOUTUBE_CONTENT TABLE =====
// Add productionBatch relationship
await db.execute(sql`
ALTER TABLE "youtube_content"
ADD COLUMN IF NOT EXISTS "production_batch_id" integer REFERENCES yt_batches(id) ON DELETE SET NULL;
`)
// Add production fields
await db.execute(sql`
ALTER TABLE "youtube_content"
ADD COLUMN IF NOT EXISTS "production_week" numeric,
ADD COLUMN IF NOT EXISTS "calendar_week" numeric,
ADD COLUMN IF NOT EXISTS "production_date" timestamp(3) with time zone,
ADD COLUMN IF NOT EXISTS "target_duration" varchar;
`)
// Add posting fields
await db.execute(sql`
ALTER TABLE "youtube_content"
ADD COLUMN IF NOT EXISTS "publish_time" varchar,
ADD COLUMN IF NOT EXISTS "thumbnail_text" varchar,
ADD COLUMN IF NOT EXISTS "cta_type" "enum_youtube_content_cta_type",
ADD COLUMN IF NOT EXISTS "cta_detail" varchar;
`)
// Add bRollNotes to locales table
await db.execute(sql`
ALTER TABLE "youtube_content_locales"
ADD COLUMN IF NOT EXISTS "b_roll_notes" varchar;
`)
// Create youtube_content_script blocks table
await db.execute(sql`
CREATE TABLE IF NOT EXISTS "youtube_content_blocks_script_sec" (
"id" serial PRIMARY KEY NOT NULL,
"_order" integer NOT NULL,
"_parent_id" integer NOT NULL REFERENCES youtube_content(id) ON DELETE CASCADE,
"_path" varchar NOT NULL,
"sec_type" "enum_script_sec_type" NOT NULL,
"duration" varchar,
"section_title" varchar,
"visual_notes" varchar,
"block_name" varchar
);
`)
await db.execute(sql`
CREATE TABLE IF NOT EXISTS "youtube_content_blocks_script_sec_locales" (
"spoken_text" jsonb,
"id" serial PRIMARY KEY NOT NULL,
"_locale" varchar NOT NULL,
"_parent_id" integer NOT NULL REFERENCES youtube_content_blocks_script_sec(id) ON DELETE CASCADE,
CONSTRAINT "youtube_content_blocks_script_sec_locales_unique" UNIQUE("_locale", "_parent_id")
);
`)
await db.execute(sql`
CREATE TABLE IF NOT EXISTS "youtube_content_blocks_script_sec_b_roll" (
"id" serial PRIMARY KEY NOT NULL,
"_order" integer NOT NULL,
"_parent_id" integer NOT NULL REFERENCES youtube_content_blocks_script_sec(id) ON DELETE CASCADE,
"instruction" varchar NOT NULL,
"timestamp" varchar
);
`)
await db.execute(sql`
CREATE TABLE IF NOT EXISTS "youtube_content_blocks_script_sec_overlays" (
"id" serial PRIMARY KEY NOT NULL,
"_order" integer NOT NULL,
"_parent_id" integer NOT NULL REFERENCES youtube_content_blocks_script_sec(id) ON DELETE CASCADE,
"text" varchar NOT NULL,
"ovl_style" "enum_script_sec_ovl_style" DEFAULT 'standard'
);
`)
// Create upload checklist array table
await db.execute(sql`
CREATE TABLE IF NOT EXISTS "youtube_content_upload_checklist" (
"id" serial PRIMARY KEY NOT NULL,
"_order" integer NOT NULL,
"_parent_id" integer NOT NULL REFERENCES youtube_content(id) ON DELETE CASCADE,
"step" varchar NOT NULL,
"completed" boolean DEFAULT false,
"completed_at" timestamp(3) with time zone
);
`)
// Create disclaimers array table
await db.execute(sql`
CREATE TABLE IF NOT EXISTS "youtube_content_disclaimers" (
"id" serial PRIMARY KEY NOT NULL,
"_order" integer NOT NULL,
"_parent_id" integer NOT NULL REFERENCES youtube_content(id) ON DELETE CASCADE,
"type" "enum_youtube_content_disclaimer_type" NOT NULL,
"placement" "enum_youtube_content_disclaimer_placement"
);
`)
await db.execute(sql`
CREATE TABLE IF NOT EXISTS "youtube_content_disclaimers_locales" (
"text" varchar,
"id" serial PRIMARY KEY NOT NULL,
"_locale" varchar NOT NULL,
"_parent_id" integer NOT NULL REFERENCES youtube_content_disclaimers(id) ON DELETE CASCADE,
CONSTRAINT "youtube_content_disclaimers_locales_unique" UNIQUE("_locale", "_parent_id")
);
`)
await db.execute(sql`
CREATE INDEX IF NOT EXISTS "youtube_content_production_batch_idx" ON "youtube_content" USING btree ("production_batch_id");
CREATE INDEX IF NOT EXISTS "youtube_content_production_date_idx" ON "youtube_content" USING btree ("production_date");
CREATE INDEX IF NOT EXISTS "yc_blocks_script_sec_order_idx" ON "youtube_content_blocks_script_sec" USING btree ("_order");
CREATE INDEX IF NOT EXISTS "yc_blocks_script_sec_parent_idx" ON "youtube_content_blocks_script_sec" USING btree ("_parent_id");
CREATE INDEX IF NOT EXISTS "yc_blocks_script_sec_path_idx" ON "youtube_content_blocks_script_sec" USING btree ("_path");
CREATE INDEX IF NOT EXISTS "yc_blocks_script_sec_l_locale_idx" ON "youtube_content_blocks_script_sec_locales" USING btree ("_locale");
CREATE INDEX IF NOT EXISTS "yc_blocks_script_sec_l_parent_idx" ON "youtube_content_blocks_script_sec_locales" USING btree ("_parent_id");
CREATE INDEX IF NOT EXISTS "yc_b_roll_order_idx" ON "youtube_content_blocks_script_sec_b_roll" USING btree ("_order");
CREATE INDEX IF NOT EXISTS "yc_b_roll_parent_idx" ON "youtube_content_blocks_script_sec_b_roll" USING btree ("_parent_id");
CREATE INDEX IF NOT EXISTS "yc_overlays_order_idx" ON "youtube_content_blocks_script_sec_overlays" USING btree ("_order");
CREATE INDEX IF NOT EXISTS "yc_overlays_parent_idx" ON "youtube_content_blocks_script_sec_overlays" USING btree ("_parent_id");
CREATE INDEX IF NOT EXISTS "youtube_content_upload_cl_order_idx" ON "youtube_content_upload_checklist" USING btree ("_order");
CREATE INDEX IF NOT EXISTS "youtube_content_upload_cl_parent_idx" ON "youtube_content_upload_checklist" USING btree ("_parent_id");
CREATE INDEX IF NOT EXISTS "youtube_content_discl_order_idx" ON "youtube_content_disclaimers" USING btree ("_order");
CREATE INDEX IF NOT EXISTS "youtube_content_discl_parent_idx" ON "youtube_content_disclaimers" USING btree ("_parent_id");
CREATE INDEX IF NOT EXISTS "youtube_content_discl_l_locale_idx" ON "youtube_content_disclaimers_locales" USING btree ("_locale");
CREATE INDEX IF NOT EXISTS "youtube_content_discl_l_parent_idx" ON "youtube_content_disclaimers_locales" USING btree ("_parent_id");
`)
// ===== EXTEND PAYLOAD_LOCKED_DOCUMENTS_RELS =====
await db.execute(sql`
ALTER TABLE "payload_locked_documents_rels"
ADD COLUMN IF NOT EXISTS "yt_batches_id" integer REFERENCES yt_batches(id) ON DELETE CASCADE;
`)
await db.execute(sql`
ALTER TABLE "payload_locked_documents_rels"
ADD COLUMN IF NOT EXISTS "yt_monthly_goals_id" integer REFERENCES yt_monthly_goals(id) ON DELETE CASCADE;
`)
await db.execute(sql`
ALTER TABLE "payload_locked_documents_rels"
ADD COLUMN IF NOT EXISTS "yt_script_tpl_id" integer REFERENCES yt_script_tpl(id) ON DELETE CASCADE;
`)
await db.execute(sql`
ALTER TABLE "payload_locked_documents_rels"
ADD COLUMN IF NOT EXISTS "yt_checklist_templates_id" integer REFERENCES yt_checklist_templates(id) ON DELETE CASCADE;
`)
await db.execute(sql`
CREATE INDEX IF NOT EXISTS "payload_locked_documents_rels_yt_batches_idx"
ON "payload_locked_documents_rels" USING btree ("yt_batches_id");
CREATE INDEX IF NOT EXISTS "payload_locked_documents_rels_yt_monthly_goals_idx"
ON "payload_locked_documents_rels" USING btree ("yt_monthly_goals_id");
CREATE INDEX IF NOT EXISTS "payload_locked_documents_rels_yt_script_tpl_idx"
ON "payload_locked_documents_rels" USING btree ("yt_script_tpl_id");
CREATE INDEX IF NOT EXISTS "payload_locked_documents_rels_yt_checklist_tpl_idx"
ON "payload_locked_documents_rels" USING btree ("yt_checklist_templates_id");
`)
}
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
// Remove indexes from payload_locked_documents_rels
await db.execute(sql`DROP INDEX IF EXISTS "payload_locked_documents_rels_yt_batches_idx";`)
await db.execute(sql`DROP INDEX IF EXISTS "payload_locked_documents_rels_yt_monthly_goals_idx";`)
await db.execute(sql`DROP INDEX IF EXISTS "payload_locked_documents_rels_yt_script_tpl_idx";`)
await db.execute(sql`DROP INDEX IF EXISTS "payload_locked_documents_rels_yt_checklist_tpl_idx";`)
// Remove columns from payload_locked_documents_rels
await db.execute(sql`ALTER TABLE "payload_locked_documents_rels" DROP COLUMN IF EXISTS "yt_batches_id";`)
await db.execute(sql`ALTER TABLE "payload_locked_documents_rels" DROP COLUMN IF EXISTS "yt_monthly_goals_id";`)
await db.execute(sql`ALTER TABLE "payload_locked_documents_rels" DROP COLUMN IF EXISTS "yt_script_tpl_id";`)
await db.execute(sql`ALTER TABLE "payload_locked_documents_rels" DROP COLUMN IF EXISTS "yt_checklist_templates_id";`)
// Drop youtube_content extension tables
await db.execute(sql`DROP TABLE IF EXISTS "youtube_content_disclaimers_locales";`)
await db.execute(sql`DROP TABLE IF EXISTS "youtube_content_disclaimers";`)
await db.execute(sql`DROP TABLE IF EXISTS "youtube_content_upload_checklist";`)
await db.execute(sql`DROP TABLE IF EXISTS "youtube_content_blocks_script_sec_overlays";`)
await db.execute(sql`DROP TABLE IF EXISTS "youtube_content_blocks_script_sec_b_roll";`)
await db.execute(sql`DROP TABLE IF EXISTS "youtube_content_blocks_script_sec_locales";`)
await db.execute(sql`DROP TABLE IF EXISTS "youtube_content_blocks_script_sec";`)
// Remove columns from youtube_content
await db.execute(sql`
ALTER TABLE "youtube_content"
DROP COLUMN IF EXISTS "production_batch_id",
DROP COLUMN IF EXISTS "production_week",
DROP COLUMN IF EXISTS "calendar_week",
DROP COLUMN IF EXISTS "production_date",
DROP COLUMN IF EXISTS "target_duration",
DROP COLUMN IF EXISTS "publish_time",
DROP COLUMN IF EXISTS "thumbnail_text",
DROP COLUMN IF EXISTS "cta_type",
DROP COLUMN IF EXISTS "cta_detail";
`)
await db.execute(sql`
ALTER TABLE "youtube_content_locales"
DROP COLUMN IF EXISTS "b_roll_notes";
`)
// Drop yt_checklist_templates tables
await db.execute(sql`DROP TABLE IF EXISTS "yt_checklist_templates_items_locales";`)
await db.execute(sql`DROP TABLE IF EXISTS "yt_checklist_templates_items";`)
await db.execute(sql`DROP TABLE IF EXISTS "yt_checklist_templates_locales";`)
await db.execute(sql`DROP TABLE IF EXISTS "yt_checklist_templates";`)
// Drop yt_script_tpl tables
await db.execute(sql`DROP TABLE IF EXISTS "yt_script_tpl_blocks_script_sec_overlays";`)
await db.execute(sql`DROP TABLE IF EXISTS "yt_script_tpl_blocks_script_sec_b_roll";`)
await db.execute(sql`DROP TABLE IF EXISTS "yt_script_tpl_blocks_script_sec_locales";`)
await db.execute(sql`DROP TABLE IF EXISTS "yt_script_tpl_blocks_script_sec";`)
await db.execute(sql`DROP TABLE IF EXISTS "yt_script_tpl_locales";`)
await db.execute(sql`DROP TABLE IF EXISTS "yt_script_tpl";`)
// Drop yt_monthly_goals tables
await db.execute(sql`DROP TABLE IF EXISTS "yt_monthly_goals_custom_goals";`)
await db.execute(sql`DROP TABLE IF EXISTS "yt_monthly_goals_locales";`)
await db.execute(sql`DROP TABLE IF EXISTS "yt_monthly_goals";`)
// Drop yt_batches tables
await db.execute(sql`DROP TABLE IF EXISTS "yt_batches_series_distribution";`)
await db.execute(sql`DROP TABLE IF EXISTS "yt_batches_shoot_days";`)
await db.execute(sql`DROP TABLE IF EXISTS "yt_batches_locales";`)
await db.execute(sql`DROP TABLE IF EXISTS "yt_batches";`)
// Drop enums
await db.execute(sql`DROP TYPE IF EXISTS "enum_youtube_content_disclaimer_placement";`)
await db.execute(sql`DROP TYPE IF EXISTS "enum_youtube_content_disclaimer_type";`)
await db.execute(sql`DROP TYPE IF EXISTS "enum_youtube_content_cta_type";`)
await db.execute(sql`DROP TYPE IF EXISTS "enum_yt_checklist_tpl_items_category";`)
await db.execute(sql`DROP TYPE IF EXISTS "enum_yt_checklist_tpl_format";`)
await db.execute(sql`DROP TYPE IF EXISTS "enum_yt_checklist_tpl_type";`)
await db.execute(sql`DROP TYPE IF EXISTS "enum_script_sec_ovl_style";`)
await db.execute(sql`DROP TYPE IF EXISTS "enum_script_sec_type";`)
await db.execute(sql`DROP TYPE IF EXISTS "enum_yt_script_tpl_format";`)
await db.execute(sql`DROP TYPE IF EXISTS "enum_yt_monthly_goals_custom_status";`)
await db.execute(sql`DROP TYPE IF EXISTS "enum_yt_batches_status";`)
await db.execute(sql`DROP TYPE IF EXISTS "enum_yt_batches_series_dist_priority";`)
await db.execute(sql`DROP TYPE IF EXISTS "enum_yt_batches_shoot_days_duration";`)
await db.execute(sql`DROP TYPE IF EXISTS "enum_yt_batches_shoot_days_location";`)
}

View file

@ -0,0 +1,114 @@
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
/**
* Migration: Create YtSeries Collection
*
* Creates the yt_series collection for managing YouTube channel content series
* with branding, descriptions, and YouTube playlist integration.
*/
export async function up({ db }: MigrateUpArgs): Promise<void> {
await db.execute(sql`
-- =====================================================
-- YT_SERIES COLLECTION
-- =====================================================
CREATE TABLE IF NOT EXISTS "yt_series" (
"id" serial PRIMARY KEY NOT NULL,
"slug" varchar NOT NULL,
"channel_id" integer REFERENCES youtube_channels(id) ON DELETE CASCADE,
"logo_id" integer REFERENCES media(id) ON DELETE SET NULL,
"cover_image_id" integer REFERENCES media(id) ON DELETE SET NULL,
"brand_color" varchar,
"accent_color" varchar,
"youtube_playlist_id" varchar,
"youtube_playlist_url" varchar,
"format" varchar,
"publishing_frequency" varchar,
"is_active" boolean DEFAULT true NOT NULL,
"order" numeric DEFAULT 0,
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
CONSTRAINT "yt_series_slug_channel_unique" UNIQUE("slug", "channel_id")
);
-- Localized fields for YtSeries (name, description)
CREATE TABLE IF NOT EXISTS "yt_series_locales" (
"name" varchar NOT NULL,
"description" text,
"id" serial PRIMARY KEY NOT NULL,
"_locale" varchar NOT NULL,
"_parent_id" integer NOT NULL REFERENCES yt_series(id) ON DELETE CASCADE,
CONSTRAINT "yt_series_locales_locale_parent_id_unique" UNIQUE("_locale", "_parent_id")
);
-- Relationships table
CREATE TABLE IF NOT EXISTS "yt_series_rels" (
"id" serial PRIMARY KEY NOT NULL,
"order" integer,
"parent_id" integer NOT NULL REFERENCES yt_series(id) ON DELETE CASCADE,
"path" varchar NOT NULL,
"youtube_channels_id" integer REFERENCES youtube_channels(id) ON DELETE CASCADE,
"media_id" integer REFERENCES media(id) ON DELETE CASCADE
);
-- Indexes for yt_series
CREATE INDEX IF NOT EXISTS "yt_series_slug_idx" ON "yt_series" USING btree ("slug");
CREATE INDEX IF NOT EXISTS "yt_series_channel_idx" ON "yt_series" USING btree ("channel_id");
CREATE INDEX IF NOT EXISTS "yt_series_logo_idx" ON "yt_series" USING btree ("logo_id");
CREATE INDEX IF NOT EXISTS "yt_series_cover_image_idx" ON "yt_series" USING btree ("cover_image_id");
CREATE INDEX IF NOT EXISTS "yt_series_format_idx" ON "yt_series" USING btree ("format");
CREATE INDEX IF NOT EXISTS "yt_series_is_active_idx" ON "yt_series" USING btree ("is_active");
CREATE INDEX IF NOT EXISTS "yt_series_updated_at_idx" ON "yt_series" USING btree ("updated_at");
CREATE INDEX IF NOT EXISTS "yt_series_created_at_idx" ON "yt_series" USING btree ("created_at");
-- Indexes for yt_series_locales
CREATE INDEX IF NOT EXISTS "yt_series_locales_locale_idx" ON "yt_series_locales" USING btree ("_locale");
CREATE INDEX IF NOT EXISTS "yt_series_locales_parent_idx" ON "yt_series_locales" USING btree ("_parent_id");
-- Indexes for yt_series_rels
CREATE INDEX IF NOT EXISTS "yt_series_rels_order_idx" ON "yt_series_rels" USING btree ("order");
CREATE INDEX IF NOT EXISTS "yt_series_rels_parent_idx" ON "yt_series_rels" USING btree ("parent_id");
CREATE INDEX IF NOT EXISTS "yt_series_rels_path_idx" ON "yt_series_rels" USING btree ("path");
CREATE INDEX IF NOT EXISTS "yt_series_rels_youtube_channels_idx" ON "yt_series_rels" USING btree ("youtube_channels_id");
CREATE INDEX IF NOT EXISTS "yt_series_rels_media_idx" ON "yt_series_rels" USING btree ("media_id");
-- =====================================================
-- SYSTEM TABLE UPDATES
-- Add yt_series_id to payload_locked_documents_rels
-- =====================================================
ALTER TABLE "payload_locked_documents_rels"
ADD COLUMN IF NOT EXISTS "yt_series_id" integer REFERENCES yt_series(id) ON DELETE CASCADE;
CREATE INDEX IF NOT EXISTS "payload_locked_documents_rels_yt_series_idx"
ON "payload_locked_documents_rels" USING btree ("yt_series_id");
-- =====================================================
-- ADD series_id TO youtube_content
-- For the new relationship field
-- =====================================================
ALTER TABLE "youtube_content"
ADD COLUMN IF NOT EXISTS "series_id" integer REFERENCES yt_series(id) ON DELETE SET NULL;
CREATE INDEX IF NOT EXISTS "youtube_content_series_idx"
ON "youtube_content" USING btree ("series_id");
`)
}
export async function down({ db }: MigrateDownArgs): Promise<void> {
await db.execute(sql`
-- Remove youtube_content.series_id
DROP INDEX IF EXISTS "youtube_content_series_idx";
ALTER TABLE "youtube_content" DROP COLUMN IF EXISTS "series_id";
-- Remove from system table
DROP INDEX IF EXISTS "payload_locked_documents_rels_yt_series_idx";
ALTER TABLE "payload_locked_documents_rels" DROP COLUMN IF EXISTS "yt_series_id";
-- Drop yt_series tables
DROP TABLE IF EXISTS "yt_series_rels";
DROP TABLE IF EXISTS "yt_series_locales";
DROP TABLE IF EXISTS "yt_series";
`)
}

View file

@ -28,6 +28,9 @@ import * as migration_20260108_231400_add_pages_rels_missing_columns from './202
import * as migration_20260109_020000_add_blogwoman_collections from './20260109_020000_add_blogwoman_collections'; import * as migration_20260109_020000_add_blogwoman_collections from './20260109_020000_add_blogwoman_collections';
import * as migration_20260109_023000_add_locations_hours_structured from './20260109_023000_add_locations_hours_structured'; import * as migration_20260109_023000_add_locations_hours_structured from './20260109_023000_add_locations_hours_structured';
import * as migration_20260109_030000_add_blogwoman_block_tables from './20260109_030000_add_blogwoman_block_tables'; import * as migration_20260109_030000_add_blogwoman_block_tables from './20260109_030000_add_blogwoman_block_tables';
import * as migration_20260112_150000_add_youtube_operations_hub from './20260112_150000_add_youtube_operations_hub';
import * as migration_20260112_220000_add_youtube_ops_v2 from './20260112_220000_add_youtube_ops_v2';
import * as migration_20260113_140000_create_yt_series from './20260113_140000_create_yt_series';
export const migrations = [ export const migrations = [
{ {
@ -180,4 +183,19 @@ export const migrations = [
down: migration_20260109_030000_add_blogwoman_block_tables.down, down: migration_20260109_030000_add_blogwoman_block_tables.down,
name: '20260109_030000_add_blogwoman_block_tables' name: '20260109_030000_add_blogwoman_block_tables'
}, },
{
up: migration_20260112_150000_add_youtube_operations_hub.up,
down: migration_20260112_150000_add_youtube_operations_hub.down,
name: '20260112_150000_add_youtube_operations_hub'
},
{
up: migration_20260112_220000_add_youtube_ops_v2.up,
down: migration_20260112_220000_add_youtube_ops_v2.down,
name: '20260112_220000_add_youtube_ops_v2'
},
{
up: migration_20260113_140000_create_yt_series.up,
down: migration_20260113_140000_create_yt_series.down,
name: '20260113_140000_create_yt_series'
},
]; ];

File diff suppressed because it is too large Load diff

View file

@ -68,6 +68,18 @@ import { Projects } from './collections/Projects'
import { Favorites } from './collections/Favorites' import { Favorites } from './collections/Favorites'
import { Series } from './collections/Series' import { Series } from './collections/Series'
// YouTube Operations Hub Collections
import { YouTubeChannels } from './collections/YouTubeChannels'
import { YouTubeContent } from './collections/YouTubeContent'
import { YtTasks } from './collections/YtTasks'
import { YtNotifications } from './collections/YtNotifications'
// YouTube Operations Hub v2 Collections
import { YtBatches } from './collections/YtBatches'
import { YtMonthlyGoals } from './collections/YtMonthlyGoals'
import { YtScriptTemplates } from './collections/YtScriptTemplates'
import { YtChecklistTemplates } from './collections/YtChecklistTemplates'
import { YtSeries } from './collections/YtSeries'
// Debug: Minimal test collection - DISABLED (nur für Tests) // Debug: Minimal test collection - DISABLED (nur für Tests)
// import { TestMinimal } from './collections/TestMinimal' // import { TestMinimal } from './collections/TestMinimal'
@ -207,6 +219,17 @@ export default buildConfig({
// BlogWoman Collections - ENABLED // BlogWoman Collections - ENABLED
Favorites, Favorites,
Series, Series,
// YouTube Operations Hub
YouTubeChannels,
YouTubeContent,
YtTasks,
YtNotifications,
// YouTube Operations Hub v2
YtBatches,
YtMonthlyGoals,
YtScriptTemplates,
YtChecklistTemplates,
YtSeries,
// Debug: Minimal test collection - DISABLED // Debug: Minimal test collection - DISABLED
// TestMinimal, // TestMinimal,
// Consent Management // Consent Management