mirror of
https://github.com/complexcaresolutions/cms.c2sgmbh.git
synced 2026-03-17 17:24:12 +00:00
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:
parent
15f3fa2481
commit
3294fbb506
25 changed files with 6553 additions and 43 deletions
506
docs/YOUTUBE_OPERATIONS_HUB.md
Normal file
506
docs/YOUTUBE_OPERATIONS_HUB.md
Normal 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*
|
||||||
80
src/app/(payload)/api/youtube/complete-task/[id]/route.ts
Normal file
80
src/app/(payload)/api/youtube/complete-task/[id]/route.ts
Normal 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 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
141
src/app/(payload)/api/youtube/dashboard/route.ts
Normal file
141
src/app/(payload)/api/youtube/dashboard/route.ts
Normal 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 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
58
src/app/(payload)/api/youtube/my-tasks/route.ts
Normal file
58
src/app/(payload)/api/youtube/my-tasks/route.ts
Normal 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 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
30
src/blocks/ScriptSectionBlock.ts
Normal file
30
src/blocks/ScriptSectionBlock.ts
Normal 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',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
@ -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',
|
||||||
|
},
|
||||||
|
},
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
|
||||||
218
src/collections/YouTubeChannels.ts
Normal file
218
src/collections/YouTubeChannels.ts
Normal 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,
|
||||||
|
}
|
||||||
669
src/collections/YouTubeContent.ts
Normal file
669
src/collections/YouTubeContent.ts
Normal 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,
|
||||||
|
}
|
||||||
143
src/collections/YtBatches.ts
Normal file
143
src/collections/YtBatches.ts
Normal 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,
|
||||||
|
}
|
||||||
175
src/collections/YtChecklistTemplates.ts
Normal file
175
src/collections/YtChecklistTemplates.ts
Normal 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,
|
||||||
|
}
|
||||||
304
src/collections/YtMonthlyGoals.ts
Normal file
304
src/collections/YtMonthlyGoals.ts
Normal 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,
|
||||||
|
}
|
||||||
121
src/collections/YtNotifications.ts
Normal file
121
src/collections/YtNotifications.ts
Normal 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
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
72
src/collections/YtScriptTemplates.ts
Normal file
72
src/collections/YtScriptTemplates.ts
Normal 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
204
src/collections/YtSeries.ts
Normal 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
219
src/collections/YtTasks.ts
Normal 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
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
151
src/hooks/youtubeContent/createTasksOnStatusChange.ts
Normal file
151
src/hooks/youtubeContent/createTasksOnStatusChange.ts
Normal 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
|
||||||
|
}
|
||||||
63
src/hooks/ytTasks/notifyOnAssignment.ts
Normal file
63
src/hooks/ytTasks/notifyOnAssignment.ts
Normal 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
167
src/lib/utils/youtube.ts
Normal 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
88
src/lib/youtubeAccess.ts
Normal 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 } }
|
||||||
|
}
|
||||||
580
src/migrations/20260112_150000_add_youtube_operations_hub.ts
Normal file
580
src/migrations/20260112_150000_add_youtube_operations_hub.ts
Normal 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";`)
|
||||||
|
}
|
||||||
646
src/migrations/20260112_220000_add_youtube_ops_v2.ts
Normal file
646
src/migrations/20260112_220000_add_youtube_ops_v2.ts
Normal 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";`)
|
||||||
|
}
|
||||||
114
src/migrations/20260113_140000_create_yt_series.ts
Normal file
114
src/migrations/20260113_140000_create_yt_series.ts
Normal 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";
|
||||||
|
`)
|
||||||
|
}
|
||||||
|
|
@ -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'
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
|
||||||
1775
src/payload-types.ts
1775
src/payload-types.ts
File diff suppressed because it is too large
Load diff
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue