# YouTube Operations Hub – Payload CMS Integration Prompt ## Kontext für Claude Code Du arbeitest auf dem Payload CMS Development Server (sv-payload, 10.10.181.100) der Complex Care Solutions GmbH. Deine Aufgabe ist die Integration eines "YouTube Operations Hub" – eines internen Tools zur Steuerung von drei YouTube-Kanälen. ### Bestehende Infrastruktur ``` Server: sv-payload (LXC 700) IP: 10.10.181.100 Port: 3000 OS: Debian 13 Stack: Payload CMS 3.69.0, Next.js 15.5.9, PostgreSQL 17, Redis, PgBouncer Datenbank: payload_db auf sv-postgres (10.10.181.101) ``` ### Projektstruktur ``` /home/payload/payload-cms/ ├── src/ │ ├── collections/ # Alle Collections │ ├── globals/ # Globale Einstellungen │ ├── hooks/ # Collection Hooks (separate Dateien!) │ ├── lib/ │ │ ├── tenantAccess.ts # Access Control Funktionen │ │ ├── access.ts # Zusätzliche Access-Helfer │ │ ├── validation.ts # Validierungs-Hooks │ │ └── security/ # Security-Module │ ├── app/(payload)/api/ # Custom API Routes (Next.js App Router) │ └── payload.config.ts # Haupt-Konfiguration ├── .env └── ecosystem.config.cjs # PM2 Config ``` --- ## WICHTIGE KONVENTIONEN ### 1. Multi-Tenant Architektur Das System nutzt `@payloadcms/plugin-multi-tenant`. **Alle neuen Collections müssen:** 1. Die Access-Control-Funktionen aus `src/lib/tenantAccess.ts` verwenden: ```typescript import { authenticatedOnly, tenantScopedPublicRead } from '../lib/tenantAccess' access: { read: tenantScopedPublicRead, // Öffentlich lesbar, aber tenant-isoliert create: authenticatedOnly, update: authenticatedOnly, delete: authenticatedOnly, } ``` 2. In `payload.config.ts` im `multiTenantPlugin` registriert werden: ```typescript multiTenantPlugin({ collections: { // ... bestehende Collections 'youtube-channels': {}, 'youtube-content': {}, 'yt-tasks': {}, // ... }, }) ``` ### 2. Lokalisierung (i18n) Das System unterstützt Deutsch (default) und Englisch. **Alle text-basierten Felder sollten lokalisiert sein:** ```typescript { name: 'title', type: 'text', required: true, localized: true, // WICHTIG! label: 'Titel', } ``` ### 3. Users Collection Die bestehende Users Collection nutzt `isSuperAdmin: boolean` für Super-Admin-Rechte. **Nicht überschreiben!** Für YouTube-spezifische Rollen: Separates Array-Feld oder eigene Collection. ### 4. Bestehende Collections - KONFLIKTE VERMEIDEN | Bestehend | Slug | Neuer Name für YouTube | |-----------|------|------------------------| | Videos | `videos` | `youtube-content` | | Series | `series` | `youtube-channels.series` (Array) | ### 5. Admin Group Namen Verwende deutsche Gruppennamen: - `YouTube` (für alle YouTube Operations Collections) - Nicht: "YouTube Operations" ### 6. Hooks-Struktur Hooks gehören in **separate Dateien** unter `src/hooks/`: ``` src/hooks/ ├── youtubeContent/ │ ├── createTasksOnStatusChange.ts │ └── notifyOnApproval.ts └── ytTasks/ └── notifyOnAssignment.ts ``` ### 7. API Routes (Next.js App Router) API-Endpoints unter `src/app/(payload)/api/`: ``` src/app/(payload)/api/ └── youtube/ ├── dashboard/ │ └── route.ts ├── my-tasks/ │ └── route.ts └── complete-task/ └── [id]/ └── route.ts ``` ### 8. Migrationen - KRITISCH Nach dem Erstellen neuer Collections **MUSS** die Migration die System-Tabelle erweitern: ```sql -- Für JEDE neue Collection: ALTER TABLE "payload_locked_documents_rels" ADD COLUMN IF NOT EXISTS "youtube_channels_id" integer REFERENCES youtube_channels(id) ON DELETE CASCADE; CREATE INDEX IF NOT EXISTS "payload_locked_documents_rels_youtube_channels_idx" ON "payload_locked_documents_rels" ("youtube_channels_id"); ``` ### 9. Slug-Validierung Verwende den bestehenden Slug-Validierungs-Hook: ```typescript import { createSlugValidationHook } from '../lib/validation' hooks: { beforeValidate: [ createSlugValidationHook({ collection: 'youtube-content' }), ], } ``` --- ## Aufgabe Implementiere die folgenden Collections und Funktionalitäten für den YouTube Operations Hub. Das System dient zur Verwaltung von drei YouTube-Kanälen mit unterschiedlichen Teams. --- ## 1. Rollen & Access Control ### YouTube-spezifische Rollen Da die Users Collection `isSuperAdmin` verwendet, implementieren wir YouTube-Rollen als **separates Feld auf Users**: ```typescript // In Users.ts hinzufügen (NICHT ersetzen!): { 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', }, }, ``` ### Access Control Funktionen ```typescript // src/lib/youtubeAccess.ts import type { Access, PayloadRequest } from 'payload' interface UserWithYouTubeRole { id: number isSuperAdmin?: boolean youtubeRole?: 'none' | 'viewer' | 'editor' | 'producer' | 'creator' | 'manager' youtubeChannels?: Array<{ id: number } | number> } /** * 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 (user.isSuperAdmin) return true return user.youtubeRole === '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 (user.isSuperAdmin) return true return ['creator', 'manager'].includes(user.youtubeRole || '') } /** * 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 (user.isSuperAdmin) return true return user.youtubeRole !== 'none' && user.youtubeRole !== undefined } /** * Zugriff auf zugewiesene Videos oder als Manager */ export const canAccessAssignedContent: Access = async ({ req }) => { const user = req.user as UserWithYouTubeRole | null if (!user) return false if (user.isSuperAdmin) return true if (user.youtubeRole === 'manager') return true // Für andere Rollen: Nur zugewiesene Inhalte return { or: [ { assignedTo: { equals: user.id } }, { createdBy: { equals: user.id } }, ], } } ``` --- ## 2. Collections ### 2.1 YouTubeChannels (Kanäle) ```typescript // src/collections/YouTubeChannels.ts import type { CollectionConfig } from 'payload' import { authenticatedOnly } from '../lib/tenantAccess' import { isYouTubeManager, hasYouTubeAccess } from '../lib/youtubeAccess' 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 (inline Array statt separate Collection) { name: 'contentSeries', type: 'array', label: 'Content-Serien', admin: { description: 'Wiederkehrende Formate wie "GRFI", "Investment-Piece", etc.', }, fields: [ { name: 'name', type: 'text', required: true, localized: true, label: 'Name', }, { name: 'slug', type: 'text', required: true, label: 'Slug', }, { name: 'description', type: 'textarea', localized: true, label: 'Beschreibung', }, { name: 'color', type: 'text', label: 'Farbe (Hex)', admin: { description: 'Farbe für UI' }, }, { name: 'isActive', type: 'checkbox', defaultValue: true, label: 'Aktiv', }, ], }, // 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, } ``` ### 2.2 YouTubeContent (Content Pipeline) ```typescript // src/collections/YouTubeContent.ts import type { CollectionConfig } from 'payload' import { hasYouTubeAccess, isYouTubeCreatorOrAbove, isYouTubeManager, canAccessAssignedContent } from '../lib/youtubeAccess' import { createSlugValidationHook } from '../lib/validation' 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: { beforeValidate: [ createSlugValidationHook({ collection: 'youtube-content' }), ], afterChange: [ // Hook wird in separater Datei definiert // async ({ doc, previousDoc, req, operation }) => { ... } ], }, 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', }, }, { name: 'channel', type: 'relationship', relationTo: 'youtube-channels', required: true, label: 'Kanal', admin: { position: 'sidebar', }, }, { name: 'contentSeries', type: 'text', label: 'Serie', admin: { description: 'Slug der Serie aus dem Kanal (z.B. "grfi", "investment-piece")', position: 'sidebar', }, }, { 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', }, }, // === 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: '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, } ``` ### 2.3 YtTasks (Aufgaben) ```typescript // src/collections/YtTasks.ts import type { CollectionConfig } from 'payload' import { hasYouTubeAccess, isYouTubeManager, canAccessAssignedContent } from '../lib/youtubeAccess' 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: { 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) { 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 } } return data }, ], }, } ``` ### 2.4 YtNotifications (Benachrichtigungen) ```typescript // src/collections/YtNotifications.ts import type { CollectionConfig, Access } from 'payload' const canAccessOwnNotifications: Access = ({ req }) => { const user = req.user if (!user) return false if (user.isSuperAdmin) return true return { recipient: { equals: user.id } } } 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, } ``` --- ## 3. Hooks (Separate Dateien) ### 3.1 Auto-Task-Erstellung ```typescript // src/hooks/youtubeContent/createTasksOnStatusChange.ts import type { CollectionAfterChangeHook } from 'payload' interface TaskTemplate { title: string type: string assignRole: string } 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 } 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 } // User mit der entsprechenden YouTube-Rolle finden const getUserByYouTubeRole = async (role: string): Promise => { // Mapping: Task-Rolle -> YouTube-Rolle const roleMapping: Record = { creator: ['creator', 'manager'], producer: ['producer', 'manager'], editor: ['editor', 'manager'], manager: ['manager'], } const allowedRoles = roleMapping[role] || ['manager'] // Wenn Video einem User zugewiesen ist, prüfe dessen Rolle if (doc.assignedTo) { 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 } } // Sonst: Ersten User mit passender Rolle finden const users = await req.payload.find({ collection: 'users', where: { youtubeRole: { in: allowedRoles }, }, limit: 1, depth: 0, }) return users.docs[0]?.id || 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 await req.payload.create({ collection: 'yt-tasks', data: { title: `${taskTemplate.title}: ${doc.title}`, 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, }, }) } } return doc } ``` ### 3.2 Benachrichtigungs-Hook ```typescript // src/hooks/ytTasks/notifyOnAssignment.ts import type { CollectionAfterChangeHook } from 'payload' export const notifyOnAssignment: CollectionAfterChangeHook = async ({ doc, previousDoc, req, operation, }) => { // Neue Zuweisung oder geänderte 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 const isNewAssignment = operation === 'create' || previousAssignedTo !== currentAssignedTo if (isNewAssignment && currentAssignedTo) { await req.payload.create({ collection: 'yt-notifications', data: { recipient: currentAssignedTo, type: 'task_assigned', title: `Neue Aufgabe: ${doc.title}`, 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, }, }) } return doc } ``` --- ## 4. API Routes (Next.js App Router) ### 4.1 Dashboard Overview ```typescript // 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 } 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 // Kanäle abrufen const channels = await payload.find({ collection: 'youtube-channels', where: { status: { equals: 'active' } }, depth: 0, }) // Pipeline-Status zählen 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, }) // 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, }) // 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 } ) } } ``` ### 4.2 My Tasks ```typescript // src/app/(payload)/api/youtube/my-tasks/route.ts import { getPayload } from 'payload' import config from '@payload-config' import { NextRequest, NextResponse } from 'next/server' 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 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 } ) } } ``` ### 4.3 Complete Task ```typescript // 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 RouteParams { params: Promise<{ id: string }> } 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 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 if (!user.isSuperAdmin && assignedId !== user.id) { 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 } ) } } ``` --- ## 5. Payload Config Integration ```typescript // In src/payload.config.ts hinzufügen: // Imports import { YouTubeChannels } from './collections/YouTubeChannels' import { YouTubeContent } from './collections/YouTubeContent' import { YtTasks } from './collections/YtTasks' import { YtNotifications } from './collections/YtNotifications' // In collections Array: collections: [ // ... bestehende Collections // YouTube Operations Hub YouTubeChannels, YouTubeContent, YtTasks, YtNotifications, ], // In multiTenantPlugin collections: multiTenantPlugin({ collections: { // ... bestehende Collections // YouTube Operations (NICHT tenant-scoped, da internes Tool) // Falls doch tenant-scoped gewünscht: // 'youtube-channels': {}, // 'youtube-content': {}, // 'yt-tasks': {}, // 'yt-notifications': {}, }, }), ``` --- ## 6. Migration erstellen Nach dem Erstellen der Collections: ```bash cd /home/payload/payload-cms # Migration erstellen pnpm payload migrate:create # Migration manuell erweitern (KRITISCH!): ``` Die generierte Migration unter `src/migrations/` muss erweitert werden: ```typescript // In der up() Funktion NACH den CREATE TABLE Statements: // System-Tabelle für Document Locking erweitern await payload.db.drizzle.execute(sql` ALTER TABLE "payload_locked_documents_rels" ADD COLUMN IF NOT EXISTS "youtube_channels_id" integer REFERENCES youtube_channels(id) ON DELETE CASCADE; ALTER TABLE "payload_locked_documents_rels" ADD COLUMN IF NOT EXISTS "youtube_content_id" integer REFERENCES youtube_content(id) ON DELETE CASCADE; ALTER TABLE "payload_locked_documents_rels" ADD COLUMN IF NOT EXISTS "yt_tasks_id" integer REFERENCES yt_tasks(id) ON DELETE CASCADE; ALTER TABLE "payload_locked_documents_rels" ADD COLUMN IF NOT EXISTS "yt_notifications_id" integer REFERENCES yt_notifications(id) ON DELETE CASCADE; CREATE INDEX IF NOT EXISTS "payload_locked_documents_rels_youtube_channels_idx" ON "payload_locked_documents_rels" ("youtube_channels_id"); CREATE INDEX IF NOT EXISTS "payload_locked_documents_rels_youtube_content_idx" ON "payload_locked_documents_rels" ("youtube_content_id"); CREATE INDEX IF NOT EXISTS "payload_locked_documents_rels_yt_tasks_idx" ON "payload_locked_documents_rels" ("yt_tasks_id"); CREATE INDEX IF NOT EXISTS "payload_locked_documents_rels_yt_notifications_idx" ON "payload_locked_documents_rels" ("yt_notifications_id"); `); ``` Dann Migration ausführen: ```bash # Direkte DB-Verbindung für Migrationen (umgeht PgBouncer) ./scripts/db-direct.sh migrate # Oder standard: pnpm payload migrate ``` --- ## 7. Entwicklungsschritte ### Phase 1: Basis-Setup 1. `src/lib/youtubeAccess.ts` erstellen 2. Users Collection erweitern (youtubeRole, youtubeChannels) 3. YouTubeChannels Collection erstellen 4. YouTubeContent Collection erstellen 5. YtTasks Collection erstellen 6. YtNotifications Collection erstellen 7. `payload.config.ts` aktualisieren 8. Migration erstellen und System-Tabellen erweitern 9. `pnpm payload generate:importmap` 10. `pnpm build` ### Phase 2: Hooks 1. `src/hooks/youtubeContent/createTasksOnStatusChange.ts` 2. `src/hooks/ytTasks/notifyOnAssignment.ts` 3. Hooks in Collections einbinden ### Phase 3: API Routes 1. `src/app/(payload)/api/youtube/dashboard/route.ts` 2. `src/app/(payload)/api/youtube/my-tasks/route.ts` 3. `src/app/(payload)/api/youtube/complete-task/[id]/route.ts` ### Phase 4: Test & Deployment 1. `pm2 restart payload` 2. Admin-UI testen unter https://pl.porwoll.tech/admin 3. API-Endpoints testen 4. Access Control mit verschiedenen Rollen testen --- ## 8. Wichtige Befehle ```bash # Entwicklung pnpm dev # Build pnpm build # Migration erstellen pnpm payload migrate:create # Migration ausführen (direkt, umgeht PgBouncer) ./scripts/db-direct.sh migrate # ImportMap generieren (nach Plugin/Collection-Änderungen) pnpm payload generate:importmap # PM2 Neustart pm2 restart payload # Logs pm2 logs payload ``` --- ## Zusammenfassung Dieser Prompt beschreibt die Integration eines YouTube Operations Hub mit: - **4 neue Collections**: YouTubeChannels, YouTubeContent, YtTasks, YtNotifications - **Keine Konflikte** mit bestehenden Collections (Videos, Series) - **Korrekte Access Control** via `src/lib/youtubeAccess.ts` - **Lokalisierung** für alle text-basierten Felder - **Hooks in separaten Dateien** unter `src/hooks/` - **API Routes im Next.js App Router Format** - **Vollständige Migrationsanforderungen** inkl. System-Tabellen - **Deutsche Admin-Gruppen** ("YouTube") Die Integration respektiert die bestehende Multi-Tenant-Architektur und alle Konventionen des Payload CMS Projekts.