cms.c2sgmbh/prompts/youtube.md
Martin Porwoll 77f70876f4 chore: add Claude Code config, prompts, and tenant setup scripts
- 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>
2026-01-18 10:18:05 +00:00

52 KiB
Raw Blame History

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:

    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:

    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

  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

# 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.