- Add .claude/ configuration (agents, commands, hooks, get-shit-done workflows) - Add prompts/ directory with development planning documents - Add scripts/setup-tenants/ with tenant configuration - Add docs/screenshots/ - Remove obsolete phase2.2-corrections-report.md - Update pnpm-lock.yaml - Update detect-secrets.sh to ignore setup.sh (env var usage, not secrets) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
52 KiB
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:
-
Die Access-Control-Funktionen aus
src/lib/tenantAccess.tsverwenden:import { authenticatedOnly, tenantScopedPublicRead } from '../lib/tenantAccess' access: { read: tenantScopedPublicRead, // Öffentlich lesbar, aber tenant-isoliert create: authenticatedOnly, update: authenticatedOnly, delete: authenticatedOnly, } -
In
payload.config.tsimmultiTenantPluginregistriert werden: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:
{
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:
-- 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:
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:
// 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
// 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)
// 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)
// 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)
// 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)
// 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
// src/hooks/youtubeContent/createTasksOnStatusChange.ts
import type { CollectionAfterChangeHook } from 'payload'
interface TaskTemplate {
title: string
type: string
assignRole: string
}
const TASK_TEMPLATES: Record<string, TaskTemplate[]> = {
script_draft: [
{ title: 'Skript schreiben', type: 'script_write', assignRole: 'creator' },
],
script_review: [
{ title: 'Skript reviewen', type: 'script_review', assignRole: 'manager' },
],
script_approved: [
{ title: 'Dreh vorbereiten', type: 'shoot_prep', assignRole: 'producer' },
],
shoot_scheduled: [
{ title: 'Dreh durchführen', type: 'shoot', assignRole: 'producer' },
],
shot: [
{ title: 'Rohschnitt erstellen', type: 'edit', assignRole: 'editor' },
],
rough_cut: [
{ title: 'Feinschnitt erstellen', type: 'edit', assignRole: 'editor' },
{ title: 'Grafiken erstellen', type: 'graphics', assignRole: 'editor' },
],
fine_cut: [
{ title: 'Thumbnail erstellen', type: 'thumbnail', assignRole: 'editor' },
{ title: 'Final Review', type: 'review', assignRole: 'manager' },
],
approved: [
{ title: 'Video hochladen', type: 'upload', assignRole: 'manager' },
],
published: [
{ title: 'Performance nach 24h tracken', type: 'track', assignRole: 'manager' },
{ title: 'Kommentare beantworten', type: 'comments', assignRole: 'creator' },
],
}
interface UserWithYouTubeRole {
id: number
youtubeRole?: string
}
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<number | null> => {
// Mapping: Task-Rolle -> YouTube-Rolle
const roleMapping: Record<string, string[]> = {
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
// 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
// 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
// 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
// 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
// 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:
cd /home/payload/payload-cms
# Migration erstellen
pnpm payload migrate:create
# Migration manuell erweitern (KRITISCH!):
Die generierte Migration unter src/migrations/ muss erweitert werden:
// 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:
# Direkte DB-Verbindung für Migrationen (umgeht PgBouncer)
./scripts/db-direct.sh migrate
# Oder standard:
pnpm payload migrate
7. Entwicklungsschritte
Phase 1: Basis-Setup
src/lib/youtubeAccess.tserstellen- Users Collection erweitern (youtubeRole, youtubeChannels)
- YouTubeChannels Collection erstellen
- YouTubeContent Collection erstellen
- YtTasks Collection erstellen
- YtNotifications Collection erstellen
payload.config.tsaktualisieren- Migration erstellen und System-Tabellen erweitern
pnpm payload generate:importmappnpm build
Phase 2: Hooks
src/hooks/youtubeContent/createTasksOnStatusChange.tssrc/hooks/ytTasks/notifyOnAssignment.ts- Hooks in Collections einbinden
Phase 3: API Routes
src/app/(payload)/api/youtube/dashboard/route.tssrc/app/(payload)/api/youtube/my-tasks/route.tssrc/app/(payload)/api/youtube/complete-task/[id]/route.ts
Phase 4: Test & Deployment
pm2 restart payload- Admin-UI testen unter https://pl.porwoll.tech/admin
- API-Endpoints testen
- Access Control mit verschiedenen Rollen testen
8. Wichtige Befehle
# 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.