From 3294fbb5062fb9ef190a33dbd650202630d251df Mon Sep 17 00:00:00 2001 From: Martin Porwoll Date: Tue, 13 Jan 2026 14:54:40 +0000 Subject: [PATCH] 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 --- docs/YOUTUBE_OPERATIONS_HUB.md | 506 +++++ .../api/youtube/complete-task/[id]/route.ts | 80 + .../(payload)/api/youtube/dashboard/route.ts | 141 ++ .../(payload)/api/youtube/my-tasks/route.ts | 58 + src/blocks/ScriptSectionBlock.ts | 30 + src/collections/Users.ts | 31 + src/collections/YouTubeChannels.ts | 218 ++ src/collections/YouTubeContent.ts | 669 +++++++ src/collections/YtBatches.ts | 143 ++ src/collections/YtChecklistTemplates.ts | 175 ++ src/collections/YtMonthlyGoals.ts | 304 +++ src/collections/YtNotifications.ts | 121 ++ src/collections/YtScriptTemplates.ts | 72 + src/collections/YtSeries.ts | 204 ++ src/collections/YtTasks.ts | 219 ++ .../createTasksOnStatusChange.ts | 151 ++ src/hooks/ytTasks/notifyOnAssignment.ts | 63 + src/lib/utils/youtube.ts | 167 ++ src/lib/youtubeAccess.ts | 88 + ...60112_150000_add_youtube_operations_hub.ts | 580 ++++++ .../20260112_220000_add_youtube_ops_v2.ts | 646 ++++++ .../20260113_140000_create_yt_series.ts | 114 ++ src/migrations/index.ts | 18 + src/payload-types.ts | 1775 ++++++++++++++++- src/payload.config.ts | 23 + 25 files changed, 6553 insertions(+), 43 deletions(-) create mode 100644 docs/YOUTUBE_OPERATIONS_HUB.md create mode 100644 src/app/(payload)/api/youtube/complete-task/[id]/route.ts create mode 100644 src/app/(payload)/api/youtube/dashboard/route.ts create mode 100644 src/app/(payload)/api/youtube/my-tasks/route.ts create mode 100644 src/blocks/ScriptSectionBlock.ts create mode 100644 src/collections/YouTubeChannels.ts create mode 100644 src/collections/YouTubeContent.ts create mode 100644 src/collections/YtBatches.ts create mode 100644 src/collections/YtChecklistTemplates.ts create mode 100644 src/collections/YtMonthlyGoals.ts create mode 100644 src/collections/YtNotifications.ts create mode 100644 src/collections/YtScriptTemplates.ts create mode 100644 src/collections/YtSeries.ts create mode 100644 src/collections/YtTasks.ts create mode 100644 src/hooks/youtubeContent/createTasksOnStatusChange.ts create mode 100644 src/hooks/ytTasks/notifyOnAssignment.ts create mode 100644 src/lib/utils/youtube.ts create mode 100644 src/lib/youtubeAccess.ts create mode 100644 src/migrations/20260112_150000_add_youtube_operations_hub.ts create mode 100644 src/migrations/20260112_220000_add_youtube_ops_v2.ts create mode 100644 src/migrations/20260113_140000_create_yt_series.ts diff --git a/docs/YOUTUBE_OPERATIONS_HUB.md b/docs/YOUTUBE_OPERATIONS_HUB.md new file mode 100644 index 0000000..00ea5c9 --- /dev/null +++ b/docs/YOUTUBE_OPERATIONS_HUB.md @@ -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* diff --git a/src/app/(payload)/api/youtube/complete-task/[id]/route.ts b/src/app/(payload)/api/youtube/complete-task/[id]/route.ts new file mode 100644 index 0000000..5c1d2f7 --- /dev/null +++ b/src/app/(payload)/api/youtube/complete-task/[id]/route.ts @@ -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 } + ) + } +} diff --git a/src/app/(payload)/api/youtube/dashboard/route.ts b/src/app/(payload)/api/youtube/dashboard/route.ts new file mode 100644 index 0000000..7dd27bf --- /dev/null +++ b/src/app/(payload)/api/youtube/dashboard/route.ts @@ -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 } + ) + } +} diff --git a/src/app/(payload)/api/youtube/my-tasks/route.ts b/src/app/(payload)/api/youtube/my-tasks/route.ts new file mode 100644 index 0000000..928b817 --- /dev/null +++ b/src/app/(payload)/api/youtube/my-tasks/route.ts @@ -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 } + ) + } +} diff --git a/src/blocks/ScriptSectionBlock.ts b/src/blocks/ScriptSectionBlock.ts new file mode 100644 index 0000000..9b795e9 --- /dev/null +++ b/src/blocks/ScriptSectionBlock.ts @@ -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', + }, + ], +} diff --git a/src/collections/Users.ts b/src/collections/Users.ts index dae909d..3d05a4d 100644 --- a/src/collections/Users.ts +++ b/src/collections/Users.ts @@ -59,5 +59,36 @@ export const Users: CollectionConfig = { 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', + }, + }, ], } diff --git a/src/collections/YouTubeChannels.ts b/src/collections/YouTubeChannels.ts new file mode 100644 index 0000000..0769672 --- /dev/null +++ b/src/collections/YouTubeChannels.ts @@ -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, +} diff --git a/src/collections/YouTubeContent.ts b/src/collections/YouTubeContent.ts new file mode 100644 index 0000000..0b918f6 --- /dev/null +++ b/src/collections/YouTubeContent.ts @@ -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 = { ä: '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, +} diff --git a/src/collections/YtBatches.ts b/src/collections/YtBatches.ts new file mode 100644 index 0000000..d9b8bd2 --- /dev/null +++ b/src/collections/YtBatches.ts @@ -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, +} diff --git a/src/collections/YtChecklistTemplates.ts b/src/collections/YtChecklistTemplates.ts new file mode 100644 index 0000000..0ae88a2 --- /dev/null +++ b/src/collections/YtChecklistTemplates.ts @@ -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, +} diff --git a/src/collections/YtMonthlyGoals.ts b/src/collections/YtMonthlyGoals.ts new file mode 100644 index 0000000..b6dd3d7 --- /dev/null +++ b/src/collections/YtMonthlyGoals.ts @@ -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, +} diff --git a/src/collections/YtNotifications.ts b/src/collections/YtNotifications.ts new file mode 100644 index 0000000..1875c0b --- /dev/null +++ b/src/collections/YtNotifications.ts @@ -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 + }, + ], + }, +} diff --git a/src/collections/YtScriptTemplates.ts b/src/collections/YtScriptTemplates.ts new file mode 100644 index 0000000..971b628 --- /dev/null +++ b/src/collections/YtScriptTemplates.ts @@ -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, +} diff --git a/src/collections/YtSeries.ts b/src/collections/YtSeries.ts new file mode 100644 index 0000000..29d632f --- /dev/null +++ b/src/collections/YtSeries.ts @@ -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, +} diff --git a/src/collections/YtTasks.ts b/src/collections/YtTasks.ts new file mode 100644 index 0000000..e1fe38a --- /dev/null +++ b/src/collections/YtTasks.ts @@ -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 + }, + ], + }, +} diff --git a/src/hooks/youtubeContent/createTasksOnStatusChange.ts b/src/hooks/youtubeContent/createTasksOnStatusChange.ts new file mode 100644 index 0000000..a9e965f --- /dev/null +++ b/src/hooks/youtubeContent/createTasksOnStatusChange.ts @@ -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 = { + 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 = { + 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 => { + 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 +} diff --git a/src/hooks/ytTasks/notifyOnAssignment.ts b/src/hooks/ytTasks/notifyOnAssignment.ts new file mode 100644 index 0000000..4386081 --- /dev/null +++ b/src/hooks/ytTasks/notifyOnAssignment.ts @@ -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 +} diff --git a/src/lib/utils/youtube.ts b/src/lib/utils/youtube.ts new file mode 100644 index 0000000..94b7909 --- /dev/null +++ b/src/lib/utils/youtube.ts @@ -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 = { + 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}` +} diff --git a/src/lib/youtubeAccess.ts b/src/lib/youtubeAccess.ts new file mode 100644 index 0000000..56d6c9b --- /dev/null +++ b/src/lib/youtubeAccess.ts @@ -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 } } +} diff --git a/src/migrations/20260112_150000_add_youtube_operations_hub.ts b/src/migrations/20260112_150000_add_youtube_operations_hub.ts new file mode 100644 index 0000000..05a3a4c --- /dev/null +++ b/src/migrations/20260112_150000_add_youtube_operations_hub.ts @@ -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 { + // 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 { + // 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";`) +} diff --git a/src/migrations/20260112_220000_add_youtube_ops_v2.ts b/src/migrations/20260112_220000_add_youtube_ops_v2.ts new file mode 100644 index 0000000..e046b40 --- /dev/null +++ b/src/migrations/20260112_220000_add_youtube_ops_v2.ts @@ -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 { + // ===== 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 { + // 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";`) +} diff --git a/src/migrations/20260113_140000_create_yt_series.ts b/src/migrations/20260113_140000_create_yt_series.ts new file mode 100644 index 0000000..78ce251 --- /dev/null +++ b/src/migrations/20260113_140000_create_yt_series.ts @@ -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 { + 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 { + 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"; + `) +} diff --git a/src/migrations/index.ts b/src/migrations/index.ts index c4eda59..7ee6443 100644 --- a/src/migrations/index.ts +++ b/src/migrations/index.ts @@ -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_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_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 = [ { @@ -180,4 +183,19 @@ export const migrations = [ down: migration_20260109_030000_add_blogwoman_block_tables.down, 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' + }, ]; diff --git a/src/payload-types.ts b/src/payload-types.ts index a748716..f460746 100644 --- a/src/payload-types.ts +++ b/src/payload-types.ts @@ -98,6 +98,16 @@ export interface Config { bookings: Booking; certifications: Certification; projects: Project; + favorites: Favorite; + series: Series; + 'youtube-channels': YoutubeChannel; + 'youtube-content': YoutubeContent; + 'yt-tasks': YtTask; + 'yt-notifications': YtNotification; + 'yt-batches': YtBatch; + 'yt-monthly-goals': YtMonthlyGoal; + 'yt-script-templates': YtScriptTemplate; + 'yt-checklist-templates': YtChecklistTemplate; 'cookie-configurations': CookieConfiguration; 'cookie-inventory': CookieInventory; 'consent-logs': ConsentLog; @@ -147,6 +157,16 @@ export interface Config { bookings: BookingsSelect | BookingsSelect; certifications: CertificationsSelect | CertificationsSelect; projects: ProjectsSelect | ProjectsSelect; + favorites: FavoritesSelect | FavoritesSelect; + series: SeriesSelect | SeriesSelect; + 'youtube-channels': YoutubeChannelsSelect | YoutubeChannelsSelect; + 'youtube-content': YoutubeContentSelect | YoutubeContentSelect; + 'yt-tasks': YtTasksSelect | YtTasksSelect; + 'yt-notifications': YtNotificationsSelect | YtNotificationsSelect; + 'yt-batches': YtBatchesSelect | YtBatchesSelect; + 'yt-monthly-goals': YtMonthlyGoalsSelect | YtMonthlyGoalsSelect; + 'yt-script-templates': YtScriptTemplatesSelect | YtScriptTemplatesSelect; + 'yt-checklist-templates': YtChecklistTemplatesSelect | YtChecklistTemplatesSelect; 'cookie-configurations': CookieConfigurationsSelect | CookieConfigurationsSelect; 'cookie-inventory': CookieInventorySelect | CookieInventorySelect; 'consent-logs': ConsentLogsSelect | ConsentLogsSelect; @@ -210,6 +230,14 @@ export interface User { * Super Admins haben Zugriff auf alle Tenants und können neue Tenants erstellen. */ isSuperAdmin?: boolean | null; + /** + * Rolle im YouTube Operations Hub + */ + youtubeRole?: ('none' | 'viewer' | 'editor' | 'producer' | 'creator' | 'manager') | null; + /** + * Zugewiesene YouTube-Kanäle + */ + youtubeChannels?: (number | YoutubeChannel)[] | null; tenants?: | { tenant: number | Tenant; @@ -235,58 +263,74 @@ export interface User { password?: string | null; } /** + * YouTube-Kanäle und ihre Konfiguration + * * This interface was referenced by `Config`'s JSON-Schema - * via the `definition` "tenants". + * via the `definition` "youtube-channels". */ -export interface Tenant { +export interface YoutubeChannel { id: number; + /** + * z.B. "BlogWoman by Caroline Porwoll" + */ name: string; + /** + * Interner Kurzname (z.B. "blogwoman", "corporate-de") + */ slug: string; - domains?: + /** + * Die YouTube Channel ID (z.B. "UCxxxxxxxxxxxxx") + */ + youtubeChannelId: string; + /** + * z.B. "@blogwoman" oder "@zweitmeinu.ng" + */ + youtubeHandle?: string | null; + language: 'de' | 'en'; + category: 'lifestyle' | 'corporate' | 'b2b'; + status: 'active' | 'planned' | 'paused' | 'archived'; + branding?: { + /** + * z.B. #1278B3 + */ + primaryColor?: string | null; + secondaryColor?: string | null; + logo?: (number | null) | Media; + thumbnailTemplate?: (number | null) | Media; + }; + /** + * Wiederkehrende Formate wie "GRFI", "Investment-Piece", etc. + */ + contentSeries?: | { - domain: string; + name: string; + slug: string; + description?: string | null; + /** + * Farbe für UI + */ + color?: string | null; + isActive?: boolean | null; id?: string | null; }[] | null; + publishingSchedule?: { + defaultDays?: ('monday' | 'tuesday' | 'wednesday' | 'thursday' | 'friday' | 'saturday' | 'sunday')[] | null; + /** + * z.B. "12:00" + */ + defaultTime?: string | null; + shortsPerWeek?: number | null; + longformPerWeek?: number | null; + }; /** - * SMTP-Einstellungen für diesen Tenant. Leer = globale Einstellungen. + * Automatisch via YouTube API aktualisiert */ - email?: { - /** - * Tipp: Verwenden Sie eine E-Mail-Adresse der Domain, für die SPF/DKIM konfiguriert ist. - */ - fromAddress?: string | null; - fromName?: string | null; - replyTo?: string | null; - /** - * Aktivieren Sie diese Option, um einen eigenen SMTP-Server statt der globalen Einstellungen zu verwenden. - */ - useCustomSmtp?: boolean | null; - /** - * Hinweis: Stellen Sie sicher, dass SPF- und DKIM-Einträge für Ihre Domain konfiguriert sind, um eine optimale E-Mail-Zustellung zu gewährleisten. - */ - smtp?: { - /** - * Hostname ohne Protokoll (z.B. smtp.gmail.com) - */ - host: string; - /** - * 587 (STARTTLS) oder 465 (SSL) - */ - port?: number | null; - /** - * Für Port 465 aktivieren - */ - secure?: boolean | null; - /** - * Meist die E-Mail-Adresse - */ - user: string; - /** - * Leer lassen um bestehendes Passwort zu behalten - */ - pass?: string | null; - }; + currentMetrics?: { + subscriberCount?: number | null; + totalViews?: number | null; + videoCount?: number | null; + lastSyncedAt?: string | null; }; updatedAt: string; createdAt: string; @@ -410,6 +454,63 @@ export interface Media { }; }; } +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "tenants". + */ +export interface Tenant { + id: number; + name: string; + slug: string; + domains?: + | { + domain: string; + id?: string | null; + }[] + | null; + /** + * SMTP-Einstellungen für diesen Tenant. Leer = globale Einstellungen. + */ + email?: { + /** + * Tipp: Verwenden Sie eine E-Mail-Adresse der Domain, für die SPF/DKIM konfiguriert ist. + */ + fromAddress?: string | null; + fromName?: string | null; + replyTo?: string | null; + /** + * Aktivieren Sie diese Option, um einen eigenen SMTP-Server statt der globalen Einstellungen zu verwenden. + */ + useCustomSmtp?: boolean | null; + /** + * Hinweis: Stellen Sie sicher, dass SPF- und DKIM-Einträge für Ihre Domain konfiguriert sind, um eine optimale E-Mail-Zustellung zu gewährleisten. + */ + smtp?: { + /** + * Hostname ohne Protokoll (z.B. smtp.gmail.com) + */ + host: string; + /** + * 587 (STARTTLS) oder 465 (SSL) + */ + port?: number | null; + /** + * Für Port 465 aktivieren + */ + secure?: boolean | null; + /** + * Meist die E-Mail-Adresse + */ + user: string; + /** + * Leer lassen um bestehendes Passwort zu behalten + */ + pass?: string | null; + }; + }; + updatedAt: string; + createdAt: string; +} /** * This interface was referenced by `Config`'s JSON-Schema * via the `definition` "pages". @@ -2743,6 +2844,212 @@ export interface Page { blockName?: string | null; blockType: 'before-after'; } + | { + title?: string | null; + subtitle?: string | null; + /** + * Nur Favoriten dieser Kategorie anzeigen + */ + category?: ('all' | 'fashion' | 'beauty' | 'travel' | 'tech' | 'home') | null; + /** + * Nur als "Featured" markierte Favoriten anzeigen + */ + showFeaturedOnly?: boolean | null; + /** + * Maximale Anzahl der angezeigten Favoriten + */ + limit?: number | null; + layout?: ('grid' | 'list' | 'carousel') | null; + columns?: ('2' | '3' | '4') | null; + showPrice?: boolean | null; + showBadge?: boolean | null; + showDescription?: boolean | null; + showCategory?: boolean | null; + backgroundColor?: ('white' | 'ivory' | 'sand' | 'light' | 'dark') | null; + cta?: { + showCta?: boolean | null; + ctaText?: string | null; + ctaUrl?: string | null; + }; + id?: string | null; + blockName?: string | null; + blockType: 'favorites-block'; + } + | { + title?: string | null; + subtitle?: string | null; + layout?: ('grid' | 'list' | 'featured') | null; + columns?: ('2' | '3' | '4') | null; + showDescription?: boolean | null; + showLogo?: boolean | null; + showTagline?: boolean | null; + /** + * Serien-spezifische Farben für Akzente nutzen + */ + useBrandColors?: boolean | null; + /** + * Maximale Anzahl der angezeigten Serien + */ + limit?: number | null; + backgroundColor?: ('white' | 'ivory' | 'sand' | 'light' | 'dark') | null; + cta?: { + showCta?: boolean | null; + ctaText?: string | null; + ctaUrl?: string | null; + }; + id?: string | null; + blockName?: string | null; + blockType: 'series-block'; + } + | { + /** + * Die anzuzeigende Serie + */ + series: number | Series; + /** + * Cover-Bild mit Logo und Tagline + */ + showHero?: boolean | null; + showDescription?: boolean | null; + /** + * Serien-spezifische Farben für Akzente nutzen + */ + showBrandColors?: boolean | null; + /** + * Posts die mit dieser Serie verknüpft sind + */ + showRelatedPosts?: boolean | null; + relatedPostsLimit?: number | null; + relatedPostsTitle?: string | null; + /** + * Embed der YouTube-Playlist falls konfiguriert + */ + showYoutubePlaylist?: boolean | null; + layout?: ('full' | 'compact') | null; + hero?: { + height?: ('small' | 'medium' | 'large' | 'fullscreen') | null; + /** + * Dunkles Overlay für bessere Lesbarkeit + */ + overlay?: boolean | null; + textAlign?: ('left' | 'center' | 'right') | null; + }; + id?: string | null; + blockName?: string | null; + blockType: 'series-detail-block'; + } + | { + /** + * Optionaler Titel über dem Video + */ + title?: string | null; + videoSource: 'youtube' | 'vimeo' | 'custom'; + /** + * z.B. https://www.youtube.com/watch?v=VIDEO_ID oder https://youtu.be/VIDEO_ID + */ + youtubeUrl?: string | null; + /** + * z.B. https://vimeo.com/VIDEO_ID + */ + vimeoUrl?: string | null; + /** + * Direkte URL zu einer MP4, WebM oder anderen Video-Datei + */ + customUrl?: string | null; + /** + * Optional: Eigenes Vorschaubild. Bei YouTube wird automatisch das Thumbnail verwendet. + */ + thumbnail?: (number | null) | Media; + /** + * Optionaler Text unter dem Video + */ + caption?: string | null; + /** + * YouTube: Verwendet youtube-nocookie.com für besseren Datenschutz + */ + privacyMode?: boolean | null; + /** + * Video erst laden, wenn es im Viewport sichtbar ist + */ + lazyLoad?: boolean | null; + aspectRatio?: ('16:9' | '4:3' | '1:1' | '9:16') | null; + maxWidth?: ('full' | 'large' | 'medium' | 'small') | null; + playbackOptions?: { + /** + * Automatisch abspielen (erfordert Mute für die meisten Browser) + */ + autoplay?: boolean | null; + /** + * Video stumm abspielen + */ + muted?: boolean | null; + loop?: boolean | null; + showControls?: boolean | null; + /** + * Video ab dieser Sekunde starten + */ + startTime?: number | null; + }; + style?: { + alignment?: ('left' | 'center' | 'right') | null; + borderRadius?: ('none' | 'sm' | 'md' | 'lg') | null; + shadow?: boolean | null; + }; + id?: string | null; + blockName?: string | null; + blockType: 'video-embed-block'; + } + | { + title?: string | null; + subtitle?: string | null; + /** + * Wähle verschiedene Content-Typen aus + */ + items: { + itemType: 'post' | 'video' | 'series' | 'external'; + post?: (number | null) | Post; + video?: (number | null) | Video; + series?: (number | null) | Series; + externalTitle?: string | null; + externalUrl?: string | null; + externalImage?: (number | null) | Media; + externalDescription?: string | null; + /** + * Optionales Label (z.B. "NEU", "TRENDING", "MUSS-LESEN") + */ + customLabel?: string | null; + /** + * In featured-grid Layout größer darstellen + */ + featured?: boolean | null; + id?: string | null; + }[]; + layout?: ('grid' | 'carousel' | 'list' | 'featured-grid') | null; + columns?: ('2' | '3' | '4') | null; + showDates?: boolean | null; + /** + * Icon oder Label für Post/Video/Serie anzeigen + */ + showType?: boolean | null; + showDescription?: boolean | null; + showCustomLabels?: boolean | null; + backgroundColor?: ('white' | 'ivory' | 'sand' | 'light' | 'dark') | null; + card?: { + bg?: ('white' | 'transparent' | 'light') | null; + shadow?: boolean | null; + border?: boolean | null; + imgRatio?: ('16:9' | '4:3' | '1:1' | '3:2') | null; + hover?: ('none' | 'lift' | 'zoom' | 'shadow') | null; + }; + cta?: { + showCta?: boolean | null; + ctaText?: string | null; + ctaUrl?: string | null; + }; + id?: string | null; + blockName?: string | null; + blockType: 'featured-content-block'; + } )[] | null; seo?: { @@ -4759,6 +5066,77 @@ export interface Event { updatedAt: string; createdAt: string; } +/** + * YouTube-Serien und Content-Reihen + * + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "series". + */ +export interface Series { + id: number; + tenant?: (number | null) | Tenant; + title: string; + /** + * URL-freundlicher Name (z.B. "grfi-series") + */ + slug: string; + /** + * Kurzer Slogan oder Untertitel (max. 150 Zeichen) + */ + tagline?: string | null; + /** + * Ausführliche Beschreibung der Serie + */ + description?: { + root: { + type: string; + children: { + type: any; + version: number; + [k: string]: unknown; + }[]; + direction: ('ltr' | 'rtl') | null; + format: 'left' | 'start' | 'center' | 'right' | 'end' | 'justify' | ''; + indent: number; + version: number; + }; + [k: string]: unknown; + } | null; + /** + * Serien-Logo (empfohlen: transparent PNG) + */ + logo?: (number | null) | Media; + /** + * Hauptbild für die Serie (16:9 empfohlen) + */ + coverImage?: (number | null) | Media; + /** + * Hex-Farbcode (z.B. #B08D57) + */ + brandColor?: string | null; + /** + * Sekundäre Farbe (z.B. #FFFFFF) + */ + accentColor?: string | null; + /** + * Die Playlist-ID aus YouTube (z.B. PLxxxxxx) + */ + youtubePlaylistId?: string | null; + /** + * Vollständiger Link zur YouTube-Playlist + */ + youtubePlaylistUrl?: string | null; + /** + * Niedrigere Zahlen werden zuerst angezeigt + */ + order?: number | null; + /** + * Inaktive Serien werden nicht angezeigt + */ + isActive: boolean; + updatedAt: string; + createdAt: string; +} /** * This interface was referenced by `Config`'s JSON-Schema * via the `definition` "social-links". @@ -5769,6 +6147,581 @@ export interface Project { updatedAt: string; createdAt: string; } +/** + * Affiliate-Produkte und Favoriten + * + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "favorites". + */ +export interface Favorite { + id: number; + tenant?: (number | null) | Tenant; + title: string; + /** + * URL-freundlicher Name + */ + slug: string; + /** + * Kurze Produktbeschreibung (max. 300 Zeichen) + */ + description?: string | null; + category: 'fashion' | 'beauty' | 'travel' | 'tech' | 'home'; + /** + * z.B. "Taschen", "Hautpflege", "Laptops" + */ + subcategory?: string | null; + /** + * Aktueller Preis in Euro + */ + price?: number | null; + priceRange?: ('budget' | 'mid' | 'premium' | 'luxury') | null; + /** + * Vollständiger Affiliate-Link mit Tracking + */ + affiliateUrl: string; + affiliateNetwork?: ('amazon' | 'awin' | 'ltk' | 'direct' | 'other') | null; + /** + * Hauptbild des Produkts + */ + image: number | Media; + /** + * Optionales Label/Abzeichen + */ + badge?: ('investment-piece' | 'daily-driver' | 'grfi-approved' | 'new' | 'bestseller') | null; + /** + * In der Featured-Sektion anzeigen + */ + featured?: boolean | null; + /** + * Inaktive Favoriten werden nicht angezeigt + */ + isActive: boolean; + /** + * Niedrigere Zahlen werden zuerst angezeigt + */ + order?: number | null; + updatedAt: string; + createdAt: string; +} +/** + * Content-Pipeline für YouTube-Videos + * + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "youtube-content". + */ +export interface YoutubeContent { + id: number; + /** + * Max. 60 Zeichen für YouTube empfohlen + */ + title: string; + /** + * URL-freundlicher Name (wird automatisch generiert) + */ + slug: string; + channel: number | YoutubeChannel; + /** + * Slug der Serie aus dem Kanal (z.B. "grfi", "investment-piece") + */ + contentSeries?: string | null; + format: 'short' | 'longform' | 'premiere'; + status: + | 'idea' + | 'script_draft' + | 'script_review' + | 'script_approved' + | 'shoot_scheduled' + | 'shot' + | 'rough_cut' + | 'fine_cut' + | 'final_review' + | 'approved' + | 'upload_scheduled' + | 'published' + | 'tracked' + | 'discarded'; + priority?: ('urgent' | 'high' | 'normal' | 'low') | null; + assignedTo?: (number | null) | User; + createdBy?: (number | null) | User; + productionBatch?: (number | null) | YtBatch; + description?: string | null; + /** + * Was sagt/zeigt der Creator in den ersten 3 Sekunden? + */ + hook?: string | null; + keyPoints?: + | { + point?: string | null; + id?: string | null; + }[] + | null; + callToAction?: string | null; + scriptUrl?: string | null; + scriptContent?: { + root: { + type: string; + children: { + type: any; + version: number; + [k: string]: unknown; + }[]; + direction: ('ltr' | 'rtl') | null; + format: 'left' | 'start' | 'center' | 'right' | 'end' | 'justify' | ''; + indent: number; + version: number; + }; + [k: string]: unknown; + } | null; + shootDate?: string | null; + editDeadline?: string | null; + reviewDeadline?: string | null; + scheduledPublishDate?: string | null; + actualPublishDate?: string | null; + productionWeek?: number | null; + calendarWeek?: number | null; + productionDate?: string | null; + targetDuration?: string | null; + bRollNotes?: string | null; + /** + * Structured video script with sections + */ + script?: + | { + sectionType: 'hook' | 'intro_ident' | 'context' | 'content_part' | 'summary' | 'cta' | 'outro' | 'disclaimer'; + duration?: string | null; + sectionTitle?: string | null; + spokenText: { + root: { + type: string; + children: { + type: any; + version: number; + [k: string]: unknown; + }[]; + direction: ('ltr' | 'rtl') | null; + format: 'left' | 'start' | 'center' | 'right' | 'end' | 'justify' | ''; + indent: number; + version: number; + }; + [k: string]: unknown; + }; + bRollInstructions?: + | { + instruction: string; + timestamp?: string | null; + id?: string | null; + }[] + | null; + textOverlays?: + | { + text: string; + style?: ('standard' | 'highlight' | 'quote' | 'statistic' | 'list') | null; + id?: string | null; + }[] + | null; + visualNotes?: string | null; + id?: string | null; + blockName?: string | null; + blockType: 'script-section'; + }[] + | null; + publishTime?: string | null; + thumbnailText?: string | null; + ctaType?: ('link_in_bio' | 'newsletter' | 'longform_link' | 'custom') | null; + ctaDetail?: string | null; + uploadChecklist?: + | { + step: string; + completed?: boolean | null; + completedAt?: string | null; + id?: string | null; + }[] + | null; + disclaimers?: + | { + type: 'medical' | 'legal' | 'affiliate' | 'sponsored'; + text?: string | null; + placement?: ('spoken' | 'overlay' | 'description' | 'all') | null; + id?: string | null; + }[] + | null; + thumbnail?: (number | null) | Media; + thumbnailAlt?: (number | null) | Media; + videoFile?: (number | null) | Media; + rawFootage?: + | { + file?: (number | null) | Media; + description?: string | null; + id?: string | null; + }[] + | null; + approvals?: { + scriptApproval?: { + approved?: boolean | null; + approvedBy?: (number | null) | User; + approvedAt?: string | null; + notes?: string | null; + }; + /** + * Nur für Corporate-Kanäle relevant + */ + medicalApproval?: { + required?: boolean | null; + approved?: boolean | null; + approvedBy?: (number | null) | User; + approvedAt?: string | null; + notes?: string | null; + }; + legalApproval?: { + approved?: boolean | null; + approvedBy?: (number | null) | User; + approvedAt?: string | null; + disclaimerIncluded?: boolean | null; + notes?: string | null; + }; + finalApproval?: { + approved?: boolean | null; + approvedBy?: (number | null) | User; + approvedAt?: string | null; + notes?: string | null; + }; + }; + youtube?: { + videoId?: string | null; + url?: string | null; + metadata?: { + youtubeTitle?: string | null; + youtubeDescription?: string | null; + tags?: + | { + tag?: string | null; + id?: string | null; + }[] + | null; + visibility?: ('public' | 'unlisted' | 'private') | null; + chapters?: string | null; + pinnedComment?: string | null; + }; + }; + /** + * Automatisch via YouTube API aktualisiert + */ + performance?: { + views?: number | null; + likes?: number | null; + comments?: number | null; + shares?: number | null; + watchTimeMinutes?: number | null; + avgViewDuration?: number | null; + avgViewPercentage?: number | null; + ctr?: number | null; + impressions?: number | null; + subscribersGained?: number | null; + lastSyncedAt?: string | null; + }; + /** + * Nur für das Team sichtbar + */ + internalNotes?: { + root: { + type: string; + children: { + type: any; + version: number; + [k: string]: unknown; + }[]; + direction: ('ltr' | 'rtl') | null; + format: 'left' | 'start' | 'center' | 'right' | 'end' | 'justify' | ''; + indent: number; + version: number; + }; + [k: string]: unknown; + } | null; + updatedAt: string; + createdAt: string; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "yt-batches". + */ +export interface YtBatch { + id: number; + name: string; + channel: number | YoutubeChannel; + productionPeriod: { + start: string; + end: string; + shootDays?: + | { + date: string; + location?: ('home' | 'office' | 'mobile' | 'external') | null; + duration?: ('2h' | '4h' | '8h') | null; + notes?: string | null; + id?: string | null; + }[] + | null; + }; + targets: { + shortsTarget: number; + longformsTarget: number; + totalTarget?: number | null; + /** + * Days between production and publish + */ + bufferDays?: number | null; + }; + /** + * Which series are produced in this batch + */ + seriesDistribution?: + | { + series: string; + shortsCount?: number | null; + longformsCount?: number | null; + priority?: ('high' | 'normal' | 'low') | null; + id?: string | null; + }[] + | null; + status: 'planning' | 'scripting' | 'production' | 'editing' | 'review' | 'ready' | 'published'; + progress?: { + shortsCompleted?: number | null; + longformsCompleted?: number | null; + percentage?: number | null; + }; + team?: { + producer?: (number | null) | User; + editor?: (number | null) | User; + reviewer?: (number | null) | User; + }; + notes?: string | null; + updatedAt: string; + createdAt: string; +} +/** + * Aufgaben für die Video-Produktion + * + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "yt-tasks". + */ +export interface YtTask { + id: number; + title: string; + description?: string | null; + video?: (number | null) | YoutubeContent; + /** + * Wird automatisch vom Video übernommen + */ + channel?: (number | null) | YoutubeChannel; + taskType: + | 'script_write' + | 'script_review' + | 'shoot_prep' + | 'shoot' + | 'edit' + | 'graphics' + | 'thumbnail' + | 'review' + | 'upload' + | 'track' + | 'comments' + | 'other'; + status: 'todo' | 'in_progress' | 'blocked' | 'waiting_review' | 'done' | 'cancelled'; + priority?: ('urgent' | 'high' | 'normal' | 'low') | null; + assignedTo: number | User; + dueDate?: string | null; + completedAt?: string | null; + completedBy?: (number | null) | User; + blockedReason?: string | null; + attachments?: + | { + file?: (number | null) | Media; + note?: string | null; + id?: string | null; + }[] + | null; + comments?: + | { + author?: (number | null) | User; + content?: string | null; + createdAt?: string | null; + id?: string | null; + }[] + | null; + updatedAt: string; + createdAt: string; +} +/** + * Benachrichtigungen für YouTube Operations + * + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "yt-notifications". + */ +export interface YtNotification { + id: number; + recipient: number | User; + type: + | 'task_assigned' + | 'task_due' + | 'task_overdue' + | 'approval_required' + | 'approved' + | 'rejected' + | 'video_published' + | 'comment' + | 'mention' + | 'system'; + title: string; + message?: string | null; + /** + * Relativer Pfad zum relevanten Element + */ + link?: string | null; + relatedVideo?: (number | null) | YoutubeContent; + relatedTask?: (number | null) | YtTask; + read?: boolean | null; + readAt?: string | null; + emailSent?: boolean | null; + updatedAt: string; + createdAt: string; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "yt-monthly-goals". + */ +export interface YtMonthlyGoal { + id: number; + channel: number | YoutubeChannel; + month: string; + displayTitle?: string | null; + contentGoals?: { + longformsTarget?: number | null; + longformsCurrent?: number | null; + shortsTarget?: number | null; + shortsCurrent?: number | null; + }; + audienceGoals?: { + subscribersTarget?: number | null; + subscribersCurrent?: number | null; + viewsTarget?: number | null; + viewsCurrent?: number | null; + }; + engagementGoals?: { + avgCtrTarget?: string | null; + avgCtrCurrent?: string | null; + avgRetentionTarget?: string | null; + avgRetentionCurrent?: string | null; + }; + businessGoals?: { + newsletterSignupsTarget?: number | null; + newsletterSignupsCurrent?: number | null; + affiliateRevenueTarget?: number | null; + affiliateRevenueCurrent?: number | null; + }; + customGoals?: + | { + metric: string; + target: string; + current?: string | null; + status?: ('on_track' | 'at_risk' | 'achieved' | 'missed') | null; + id?: string | null; + }[] + | null; + notes?: string | null; + updatedAt: string; + createdAt: string; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "yt-script-templates". + */ +export interface YtScriptTemplate { + id: number; + name: string; + channel: number | YoutubeChannel; + series: string; + format: 'short' | 'longform'; + /** + * Usage notes for this template + */ + description?: string | null; + templateSections?: + | { + sectionType: 'hook' | 'intro_ident' | 'context' | 'content_part' | 'summary' | 'cta' | 'outro' | 'disclaimer'; + duration?: string | null; + sectionTitle?: string | null; + spokenText: { + root: { + type: string; + children: { + type: any; + version: number; + [k: string]: unknown; + }[]; + direction: ('ltr' | 'rtl') | null; + format: 'left' | 'start' | 'center' | 'right' | 'end' | 'justify' | ''; + indent: number; + version: number; + }; + [k: string]: unknown; + }; + bRollInstructions?: + | { + instruction: string; + timestamp?: string | null; + id?: string | null; + }[] + | null; + textOverlays?: + | { + text: string; + style?: ('standard' | 'highlight' | 'quote' | 'statistic' | 'list') | null; + id?: string | null; + }[] + | null; + visualNotes?: string | null; + id?: string | null; + blockName?: string | null; + blockType: 'script-section'; + }[] + | null; + updatedAt: string; + createdAt: string; +} +/** + * Reusable checklists for upload and production + * + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "yt-checklist-templates". + */ +export interface YtChecklistTemplate { + id: number; + name: string; + /** + * Optional: Channel-specific template + */ + channel?: (number | null) | YoutubeChannel; + type: 'upload' | 'production' | 'review' | 'post_publish'; + format?: ('all' | 'short' | 'longform') | null; + /** + * When to use this checklist + */ + description?: string | null; + items: { + order?: number | null; + task: string; + category?: ('metadata' | 'assets' | 'seo' | 'community' | 'legal' | 'other') | null; + details?: string | null; + isRequired?: boolean | null; + id?: string | null; + }[]; + /** + * Automatically used for new videos + */ + isDefault?: boolean | null; + isActive?: boolean | null; + updatedAt: string; + createdAt: string; +} /** * Cookie-Banner Konfiguration pro Tenant * @@ -6148,7 +7101,38 @@ export interface SiteSetting { contact?: { email?: string | null; phone?: string | null; - address?: string | null; + fax?: string | null; + }; + address?: { + /** + * z.B. "Hans-Böckler-Str. 19" + */ + street?: string | null; + /** + * z.B. "Gebäude B, 3. Stock" + */ + additionalLine?: string | null; + zip?: string | null; + city?: string | null; + state?: string | null; + country?: string | null; + }; + /** + * Für Kartenanzeige (Google Maps, OpenStreetMap) + */ + geo?: { + /** + * z.B. 51.521732 + */ + lat?: number | null; + /** + * z.B. 6.928850 + */ + lng?: number | null; + /** + * 1 = Weltkarte, 20 = Straßenebene + */ + zoom?: number | null; }; footer?: { copyrightText?: string | null; @@ -6391,6 +7375,46 @@ export interface PayloadLockedDocument { relationTo: 'projects'; value: number | Project; } | null) + | ({ + relationTo: 'favorites'; + value: number | Favorite; + } | null) + | ({ + relationTo: 'series'; + value: number | Series; + } | null) + | ({ + relationTo: 'youtube-channels'; + value: number | YoutubeChannel; + } | null) + | ({ + relationTo: 'youtube-content'; + value: number | YoutubeContent; + } | null) + | ({ + relationTo: 'yt-tasks'; + value: number | YtTask; + } | null) + | ({ + relationTo: 'yt-notifications'; + value: number | YtNotification; + } | null) + | ({ + relationTo: 'yt-batches'; + value: number | YtBatch; + } | null) + | ({ + relationTo: 'yt-monthly-goals'; + value: number | YtMonthlyGoal; + } | null) + | ({ + relationTo: 'yt-script-templates'; + value: number | YtScriptTemplate; + } | null) + | ({ + relationTo: 'yt-checklist-templates'; + value: number | YtChecklistTemplate; + } | null) | ({ relationTo: 'cookie-configurations'; value: number | CookieConfiguration; @@ -6483,6 +7507,8 @@ export interface PayloadMigration { */ export interface UsersSelect { isSuperAdmin?: T; + youtubeRole?: T; + youtubeChannels?: T; tenants?: | T | { @@ -8477,6 +9503,155 @@ export interface PagesSelect { id?: T; blockName?: T; }; + 'favorites-block'?: + | T + | { + title?: T; + subtitle?: T; + category?: T; + showFeaturedOnly?: T; + limit?: T; + layout?: T; + columns?: T; + showPrice?: T; + showBadge?: T; + showDescription?: T; + showCategory?: T; + backgroundColor?: T; + cta?: + | T + | { + showCta?: T; + ctaText?: T; + ctaUrl?: T; + }; + id?: T; + blockName?: T; + }; + 'series-block'?: + | T + | { + title?: T; + subtitle?: T; + layout?: T; + columns?: T; + showDescription?: T; + showLogo?: T; + showTagline?: T; + useBrandColors?: T; + limit?: T; + backgroundColor?: T; + cta?: + | T + | { + showCta?: T; + ctaText?: T; + ctaUrl?: T; + }; + id?: T; + blockName?: T; + }; + 'series-detail-block'?: + | T + | { + series?: T; + showHero?: T; + showDescription?: T; + showBrandColors?: T; + showRelatedPosts?: T; + relatedPostsLimit?: T; + relatedPostsTitle?: T; + showYoutubePlaylist?: T; + layout?: T; + hero?: + | T + | { + height?: T; + overlay?: T; + textAlign?: T; + }; + id?: T; + blockName?: T; + }; + 'video-embed-block'?: + | T + | { + title?: T; + videoSource?: T; + youtubeUrl?: T; + vimeoUrl?: T; + customUrl?: T; + thumbnail?: T; + caption?: T; + privacyMode?: T; + lazyLoad?: T; + aspectRatio?: T; + maxWidth?: T; + playbackOptions?: + | T + | { + autoplay?: T; + muted?: T; + loop?: T; + showControls?: T; + startTime?: T; + }; + style?: + | T + | { + alignment?: T; + borderRadius?: T; + shadow?: T; + }; + id?: T; + blockName?: T; + }; + 'featured-content-block'?: + | T + | { + title?: T; + subtitle?: T; + items?: + | T + | { + itemType?: T; + post?: T; + video?: T; + series?: T; + externalTitle?: T; + externalUrl?: T; + externalImage?: T; + externalDescription?: T; + customLabel?: T; + featured?: T; + id?: T; + }; + layout?: T; + columns?: T; + showDates?: T; + showType?: T; + showDescription?: T; + showCustomLabels?: T; + backgroundColor?: T; + card?: + | T + | { + bg?: T; + shadow?: T; + border?: T; + imgRatio?: T; + hover?: T; + }; + cta?: + | T + | { + showCta?: T; + ctaText?: T; + ctaUrl?: T; + }; + id?: T; + blockName?: T; + }; }; seo?: | T @@ -9867,6 +11042,503 @@ export interface ProjectsSelect { updatedAt?: T; createdAt?: T; } +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "favorites_select". + */ +export interface FavoritesSelect { + tenant?: T; + title?: T; + slug?: T; + description?: T; + category?: T; + subcategory?: T; + price?: T; + priceRange?: T; + affiliateUrl?: T; + affiliateNetwork?: T; + image?: T; + badge?: T; + featured?: T; + isActive?: T; + order?: T; + updatedAt?: T; + createdAt?: T; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "series_select". + */ +export interface SeriesSelect { + tenant?: T; + title?: T; + slug?: T; + tagline?: T; + description?: T; + logo?: T; + coverImage?: T; + brandColor?: T; + accentColor?: T; + youtubePlaylistId?: T; + youtubePlaylistUrl?: T; + order?: T; + isActive?: T; + updatedAt?: T; + createdAt?: T; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "youtube-channels_select". + */ +export interface YoutubeChannelsSelect { + name?: T; + slug?: T; + youtubeChannelId?: T; + youtubeHandle?: T; + language?: T; + category?: T; + status?: T; + branding?: + | T + | { + primaryColor?: T; + secondaryColor?: T; + logo?: T; + thumbnailTemplate?: T; + }; + contentSeries?: + | T + | { + name?: T; + slug?: T; + description?: T; + color?: T; + isActive?: T; + id?: T; + }; + publishingSchedule?: + | T + | { + defaultDays?: T; + defaultTime?: T; + shortsPerWeek?: T; + longformPerWeek?: T; + }; + currentMetrics?: + | T + | { + subscriberCount?: T; + totalViews?: T; + videoCount?: T; + lastSyncedAt?: T; + }; + updatedAt?: T; + createdAt?: T; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "youtube-content_select". + */ +export interface YoutubeContentSelect { + title?: T; + slug?: T; + channel?: T; + contentSeries?: T; + format?: T; + status?: T; + priority?: T; + assignedTo?: T; + createdBy?: T; + productionBatch?: T; + description?: T; + hook?: T; + keyPoints?: + | T + | { + point?: T; + id?: T; + }; + callToAction?: T; + scriptUrl?: T; + scriptContent?: T; + shootDate?: T; + editDeadline?: T; + reviewDeadline?: T; + scheduledPublishDate?: T; + actualPublishDate?: T; + productionWeek?: T; + calendarWeek?: T; + productionDate?: T; + targetDuration?: T; + bRollNotes?: T; + script?: + | T + | { + 'script-section'?: + | T + | { + sectionType?: T; + duration?: T; + sectionTitle?: T; + spokenText?: T; + bRollInstructions?: + | T + | { + instruction?: T; + timestamp?: T; + id?: T; + }; + textOverlays?: + | T + | { + text?: T; + style?: T; + id?: T; + }; + visualNotes?: T; + id?: T; + blockName?: T; + }; + }; + publishTime?: T; + thumbnailText?: T; + ctaType?: T; + ctaDetail?: T; + uploadChecklist?: + | T + | { + step?: T; + completed?: T; + completedAt?: T; + id?: T; + }; + disclaimers?: + | T + | { + type?: T; + text?: T; + placement?: T; + id?: T; + }; + thumbnail?: T; + thumbnailAlt?: T; + videoFile?: T; + rawFootage?: + | T + | { + file?: T; + description?: T; + id?: T; + }; + approvals?: + | T + | { + scriptApproval?: + | T + | { + approved?: T; + approvedBy?: T; + approvedAt?: T; + notes?: T; + }; + medicalApproval?: + | T + | { + required?: T; + approved?: T; + approvedBy?: T; + approvedAt?: T; + notes?: T; + }; + legalApproval?: + | T + | { + approved?: T; + approvedBy?: T; + approvedAt?: T; + disclaimerIncluded?: T; + notes?: T; + }; + finalApproval?: + | T + | { + approved?: T; + approvedBy?: T; + approvedAt?: T; + notes?: T; + }; + }; + youtube?: + | T + | { + videoId?: T; + url?: T; + metadata?: + | T + | { + youtubeTitle?: T; + youtubeDescription?: T; + tags?: + | T + | { + tag?: T; + id?: T; + }; + visibility?: T; + chapters?: T; + pinnedComment?: T; + }; + }; + performance?: + | T + | { + views?: T; + likes?: T; + comments?: T; + shares?: T; + watchTimeMinutes?: T; + avgViewDuration?: T; + avgViewPercentage?: T; + ctr?: T; + impressions?: T; + subscribersGained?: T; + lastSyncedAt?: T; + }; + internalNotes?: T; + updatedAt?: T; + createdAt?: T; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "yt-tasks_select". + */ +export interface YtTasksSelect { + title?: T; + description?: T; + video?: T; + channel?: T; + taskType?: T; + status?: T; + priority?: T; + assignedTo?: T; + dueDate?: T; + completedAt?: T; + completedBy?: T; + blockedReason?: T; + attachments?: + | T + | { + file?: T; + note?: T; + id?: T; + }; + comments?: + | T + | { + author?: T; + content?: T; + createdAt?: T; + id?: T; + }; + updatedAt?: T; + createdAt?: T; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "yt-notifications_select". + */ +export interface YtNotificationsSelect { + recipient?: T; + type?: T; + title?: T; + message?: T; + link?: T; + relatedVideo?: T; + relatedTask?: T; + read?: T; + readAt?: T; + emailSent?: T; + updatedAt?: T; + createdAt?: T; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "yt-batches_select". + */ +export interface YtBatchesSelect { + name?: T; + channel?: T; + productionPeriod?: + | T + | { + start?: T; + end?: T; + shootDays?: + | T + | { + date?: T; + location?: T; + duration?: T; + notes?: T; + id?: T; + }; + }; + targets?: + | T + | { + shortsTarget?: T; + longformsTarget?: T; + totalTarget?: T; + bufferDays?: T; + }; + seriesDistribution?: + | T + | { + series?: T; + shortsCount?: T; + longformsCount?: T; + priority?: T; + id?: T; + }; + status?: T; + progress?: + | T + | { + shortsCompleted?: T; + longformsCompleted?: T; + percentage?: T; + }; + team?: + | T + | { + producer?: T; + editor?: T; + reviewer?: T; + }; + notes?: T; + updatedAt?: T; + createdAt?: T; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "yt-monthly-goals_select". + */ +export interface YtMonthlyGoalsSelect { + channel?: T; + month?: T; + displayTitle?: T; + contentGoals?: + | T + | { + longformsTarget?: T; + longformsCurrent?: T; + shortsTarget?: T; + shortsCurrent?: T; + }; + audienceGoals?: + | T + | { + subscribersTarget?: T; + subscribersCurrent?: T; + viewsTarget?: T; + viewsCurrent?: T; + }; + engagementGoals?: + | T + | { + avgCtrTarget?: T; + avgCtrCurrent?: T; + avgRetentionTarget?: T; + avgRetentionCurrent?: T; + }; + businessGoals?: + | T + | { + newsletterSignupsTarget?: T; + newsletterSignupsCurrent?: T; + affiliateRevenueTarget?: T; + affiliateRevenueCurrent?: T; + }; + customGoals?: + | T + | { + metric?: T; + target?: T; + current?: T; + status?: T; + id?: T; + }; + notes?: T; + updatedAt?: T; + createdAt?: T; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "yt-script-templates_select". + */ +export interface YtScriptTemplatesSelect { + name?: T; + channel?: T; + series?: T; + format?: T; + description?: T; + templateSections?: + | T + | { + 'script-section'?: + | T + | { + sectionType?: T; + duration?: T; + sectionTitle?: T; + spokenText?: T; + bRollInstructions?: + | T + | { + instruction?: T; + timestamp?: T; + id?: T; + }; + textOverlays?: + | T + | { + text?: T; + style?: T; + id?: T; + }; + visualNotes?: T; + id?: T; + blockName?: T; + }; + }; + updatedAt?: T; + createdAt?: T; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "yt-checklist-templates_select". + */ +export interface YtChecklistTemplatesSelect { + name?: T; + channel?: T; + type?: T; + format?: T; + description?: T; + items?: + | T + | { + order?: T; + task?: T; + category?: T; + details?: T; + isRequired?: T; + id?: T; + }; + isDefault?: T; + isActive?: T; + updatedAt?: T; + createdAt?: T; +} /** * This interface was referenced by `Config`'s JSON-Schema * via the `definition` "cookie-configurations_select". @@ -10053,7 +11725,24 @@ export interface SiteSettingsSelect { | { email?: T; phone?: T; - address?: T; + fax?: T; + }; + address?: + | T + | { + street?: T; + additionalLine?: T; + zip?: T; + city?: T; + state?: T; + country?: T; + }; + geo?: + | T + | { + lat?: T; + lng?: T; + zoom?: T; }; footer?: | T diff --git a/src/payload.config.ts b/src/payload.config.ts index 117fda4..af5b3b5 100644 --- a/src/payload.config.ts +++ b/src/payload.config.ts @@ -68,6 +68,18 @@ import { Projects } from './collections/Projects' import { Favorites } from './collections/Favorites' 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) // import { TestMinimal } from './collections/TestMinimal' @@ -207,6 +219,17 @@ export default buildConfig({ // BlogWoman Collections - ENABLED Favorites, Series, + // YouTube Operations Hub + YouTubeChannels, + YouTubeContent, + YtTasks, + YtNotifications, + // YouTube Operations Hub v2 + YtBatches, + YtMonthlyGoals, + YtScriptTemplates, + YtChecklistTemplates, + YtSeries, // Debug: Minimal test collection - DISABLED // TestMinimal, // Consent Management