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

1859 lines
52 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# YouTube Operations Hub Payload CMS Integration Prompt
## Kontext für Claude Code
Du arbeitest auf dem Payload CMS Development Server (sv-payload, 10.10.181.100) der Complex Care Solutions GmbH. Deine Aufgabe ist die Integration eines "YouTube Operations Hub" eines internen Tools zur Steuerung von drei YouTube-Kanälen.
### Bestehende Infrastruktur
```
Server: sv-payload (LXC 700)
IP: 10.10.181.100
Port: 3000
OS: Debian 13
Stack: Payload CMS 3.69.0, Next.js 15.5.9, PostgreSQL 17, Redis, PgBouncer
Datenbank: payload_db auf sv-postgres (10.10.181.101)
```
### Projektstruktur
```
/home/payload/payload-cms/
├── src/
│ ├── collections/ # Alle Collections
│ ├── globals/ # Globale Einstellungen
│ ├── hooks/ # Collection Hooks (separate Dateien!)
│ ├── lib/
│ │ ├── tenantAccess.ts # Access Control Funktionen
│ │ ├── access.ts # Zusätzliche Access-Helfer
│ │ ├── validation.ts # Validierungs-Hooks
│ │ └── security/ # Security-Module
│ ├── app/(payload)/api/ # Custom API Routes (Next.js App Router)
│ └── payload.config.ts # Haupt-Konfiguration
├── .env
└── ecosystem.config.cjs # PM2 Config
```
---
## WICHTIGE KONVENTIONEN
### 1. Multi-Tenant Architektur
Das System nutzt `@payloadcms/plugin-multi-tenant`. **Alle neuen Collections müssen:**
1. Die Access-Control-Funktionen aus `src/lib/tenantAccess.ts` verwenden:
```typescript
import { authenticatedOnly, tenantScopedPublicRead } from '../lib/tenantAccess'
access: {
read: tenantScopedPublicRead, // Öffentlich lesbar, aber tenant-isoliert
create: authenticatedOnly,
update: authenticatedOnly,
delete: authenticatedOnly,
}
```
2. In `payload.config.ts` im `multiTenantPlugin` registriert werden:
```typescript
multiTenantPlugin({
collections: {
// ... bestehende Collections
'youtube-channels': {},
'youtube-content': {},
'yt-tasks': {},
// ...
},
})
```
### 2. Lokalisierung (i18n)
Das System unterstützt Deutsch (default) und Englisch. **Alle text-basierten Felder sollten lokalisiert sein:**
```typescript
{
name: 'title',
type: 'text',
required: true,
localized: true, // WICHTIG!
label: 'Titel',
}
```
### 3. Users Collection
Die bestehende Users Collection nutzt `isSuperAdmin: boolean` für Super-Admin-Rechte. **Nicht überschreiben!**
Für YouTube-spezifische Rollen: Separates Array-Feld oder eigene Collection.
### 4. Bestehende Collections - KONFLIKTE VERMEIDEN
| Bestehend | Slug | Neuer Name für YouTube |
|-----------|------|------------------------|
| Videos | `videos` | `youtube-content` |
| Series | `series` | `youtube-channels.series` (Array) |
### 5. Admin Group Namen
Verwende deutsche Gruppennamen:
- `YouTube` (für alle YouTube Operations Collections)
- Nicht: "YouTube Operations"
### 6. Hooks-Struktur
Hooks gehören in **separate Dateien** unter `src/hooks/`:
```
src/hooks/
├── youtubeContent/
│ ├── createTasksOnStatusChange.ts
│ └── notifyOnApproval.ts
└── ytTasks/
└── notifyOnAssignment.ts
```
### 7. API Routes (Next.js App Router)
API-Endpoints unter `src/app/(payload)/api/`:
```
src/app/(payload)/api/
└── youtube/
├── dashboard/
│ └── route.ts
├── my-tasks/
│ └── route.ts
└── complete-task/
└── [id]/
└── route.ts
```
### 8. Migrationen - KRITISCH
Nach dem Erstellen neuer Collections **MUSS** die Migration die System-Tabelle erweitern:
```sql
-- Für JEDE neue Collection:
ALTER TABLE "payload_locked_documents_rels"
ADD COLUMN IF NOT EXISTS "youtube_channels_id" integer
REFERENCES youtube_channels(id) ON DELETE CASCADE;
CREATE INDEX IF NOT EXISTS "payload_locked_documents_rels_youtube_channels_idx"
ON "payload_locked_documents_rels" ("youtube_channels_id");
```
### 9. Slug-Validierung
Verwende den bestehenden Slug-Validierungs-Hook:
```typescript
import { createSlugValidationHook } from '../lib/validation'
hooks: {
beforeValidate: [
createSlugValidationHook({ collection: 'youtube-content' }),
],
}
```
---
## Aufgabe
Implementiere die folgenden Collections und Funktionalitäten für den YouTube Operations Hub. Das System dient zur Verwaltung von drei YouTube-Kanälen mit unterschiedlichen Teams.
---
## 1. Rollen & Access Control
### YouTube-spezifische Rollen
Da die Users Collection `isSuperAdmin` verwendet, implementieren wir YouTube-Rollen als **separates Feld auf Users**:
```typescript
// In Users.ts hinzufügen (NICHT ersetzen!):
{
name: 'youtubeRole',
type: 'select',
label: 'YouTube-Rolle',
admin: {
position: 'sidebar',
description: 'Rolle im YouTube Operations Hub',
},
options: [
{ label: 'Kein Zugriff', value: 'none' },
{ label: 'Viewer (nur Lesen)', value: 'viewer' },
{ label: 'Editor (Schnitt)', value: 'editor' },
{ label: 'Producer (Produktion)', value: 'producer' },
{ label: 'Creator (Inhalte)', value: 'creator' },
{ label: 'Manager (Vollzugriff)', value: 'manager' },
],
defaultValue: 'none',
},
{
name: 'youtubeChannels',
type: 'relationship',
relationTo: 'youtube-channels',
hasMany: true,
label: 'YouTube-Kanäle',
admin: {
position: 'sidebar',
description: 'Zugewiesene YouTube-Kanäle',
condition: (data) => data?.youtubeRole && data.youtubeRole !== 'none',
},
},
```
### Access Control Funktionen
```typescript
// src/lib/youtubeAccess.ts
import type { Access, PayloadRequest } from 'payload'
interface UserWithYouTubeRole {
id: number
isSuperAdmin?: boolean
youtubeRole?: 'none' | 'viewer' | 'editor' | 'producer' | 'creator' | 'manager'
youtubeChannels?: Array<{ id: number } | number>
}
/**
* Prüft ob User YouTube-Manager oder Super-Admin ist
*/
export const isYouTubeManager: Access = ({ req }) => {
const user = req.user as UserWithYouTubeRole | null
if (!user) return false
if (user.isSuperAdmin) return true
return user.youtubeRole === 'manager'
}
/**
* Prüft ob User mindestens Creator-Rechte hat
*/
export const isYouTubeCreatorOrAbove: Access = ({ req }) => {
const user = req.user as UserWithYouTubeRole | null
if (!user) return false
if (user.isSuperAdmin) return true
return ['creator', 'manager'].includes(user.youtubeRole || '')
}
/**
* Prüft ob User Zugriff auf YouTube-Content hat (mindestens Viewer)
*/
export const hasYouTubeAccess: Access = ({ req }) => {
const user = req.user as UserWithYouTubeRole | null
if (!user) return false
if (user.isSuperAdmin) return true
return user.youtubeRole !== 'none' && user.youtubeRole !== undefined
}
/**
* Zugriff auf zugewiesene Videos oder als Manager
*/
export const canAccessAssignedContent: Access = async ({ req }) => {
const user = req.user as UserWithYouTubeRole | null
if (!user) return false
if (user.isSuperAdmin) return true
if (user.youtubeRole === 'manager') return true
// Für andere Rollen: Nur zugewiesene Inhalte
return {
or: [
{ assignedTo: { equals: user.id } },
{ createdBy: { equals: user.id } },
],
}
}
```
---
## 2. Collections
### 2.1 YouTubeChannels (Kanäle)
```typescript
// src/collections/YouTubeChannels.ts
import type { CollectionConfig } from 'payload'
import { authenticatedOnly } from '../lib/tenantAccess'
import { isYouTubeManager, hasYouTubeAccess } from '../lib/youtubeAccess'
export const YouTubeChannels: CollectionConfig = {
slug: 'youtube-channels',
labels: {
singular: 'YouTube-Kanal',
plural: 'YouTube-Kanäle',
},
admin: {
useAsTitle: 'name',
group: 'YouTube',
defaultColumns: ['name', 'youtubeHandle', 'status', 'language'],
description: 'YouTube-Kanäle und ihre Konfiguration',
},
access: {
read: hasYouTubeAccess,
create: isYouTubeManager,
update: isYouTubeManager,
delete: isYouTubeManager,
},
fields: [
{
name: 'name',
type: 'text',
required: true,
localized: true,
label: 'Kanalname',
admin: {
description: 'z.B. "BlogWoman by Caroline Porwoll"',
},
},
{
name: 'slug',
type: 'text',
required: true,
unique: true,
label: 'Slug',
admin: {
description: 'Interner Kurzname (z.B. "blogwoman", "corporate-de")',
},
},
{
name: 'youtubeChannelId',
type: 'text',
required: true,
label: 'YouTube Channel ID',
admin: {
description: 'Die YouTube Channel ID (z.B. "UCxxxxxxxxxxxxx")',
},
},
{
name: 'youtubeHandle',
type: 'text',
label: 'YouTube Handle',
admin: {
description: 'z.B. "@blogwoman" oder "@zweitmeinu.ng"',
},
},
{
name: 'language',
type: 'select',
required: true,
label: 'Sprache',
options: [
{ label: 'Deutsch', value: 'de' },
{ label: 'Englisch', value: 'en' },
],
admin: {
position: 'sidebar',
},
},
{
name: 'category',
type: 'select',
required: true,
label: 'Kategorie',
options: [
{ label: 'Lifestyle', value: 'lifestyle' },
{ label: 'Corporate', value: 'corporate' },
{ label: 'Business B2B', value: 'b2b' },
],
admin: {
position: 'sidebar',
},
},
{
name: 'status',
type: 'select',
required: true,
defaultValue: 'active',
label: 'Status',
options: [
{ label: 'Aktiv', value: 'active' },
{ label: 'In Planung', value: 'planned' },
{ label: 'Pausiert', value: 'paused' },
{ label: 'Archiviert', value: 'archived' },
],
admin: {
position: 'sidebar',
},
},
// Branding
{
name: 'branding',
type: 'group',
label: 'Branding',
fields: [
{
name: 'primaryColor',
type: 'text',
label: 'Primärfarbe (Hex)',
admin: { description: 'z.B. #1278B3' },
validate: (value: string | undefined | null) => {
if (!value) return true
const hexRegex = /^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/
if (!hexRegex.test(value)) {
return 'Bitte einen gültigen Hex-Farbcode eingeben (z.B. #1278B3)'
}
return true
},
},
{
name: 'secondaryColor',
type: 'text',
label: 'Sekundärfarbe (Hex)',
validate: (value: string | undefined | null) => {
if (!value) return true
const hexRegex = /^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/
if (!hexRegex.test(value)) {
return 'Bitte einen gültigen Hex-Farbcode eingeben'
}
return true
},
},
{
name: 'logo',
type: 'upload',
relationTo: 'media',
label: 'Logo',
},
{
name: 'thumbnailTemplate',
type: 'upload',
relationTo: 'media',
label: 'Thumbnail-Vorlage',
},
],
},
// Content-Serien (inline Array statt separate Collection)
{
name: 'contentSeries',
type: 'array',
label: 'Content-Serien',
admin: {
description: 'Wiederkehrende Formate wie "GRFI", "Investment-Piece", etc.',
},
fields: [
{
name: 'name',
type: 'text',
required: true,
localized: true,
label: 'Name',
},
{
name: 'slug',
type: 'text',
required: true,
label: 'Slug',
},
{
name: 'description',
type: 'textarea',
localized: true,
label: 'Beschreibung',
},
{
name: 'color',
type: 'text',
label: 'Farbe (Hex)',
admin: { description: 'Farbe für UI' },
},
{
name: 'isActive',
type: 'checkbox',
defaultValue: true,
label: 'Aktiv',
},
],
},
// Veröffentlichungsplan
{
name: 'publishingSchedule',
type: 'group',
label: 'Veröffentlichungsplan',
fields: [
{
name: 'defaultDays',
type: 'select',
hasMany: true,
label: 'Standard-Tage',
options: [
{ label: 'Montag', value: 'monday' },
{ label: 'Dienstag', value: 'tuesday' },
{ label: 'Mittwoch', value: 'wednesday' },
{ label: 'Donnerstag', value: 'thursday' },
{ label: 'Freitag', value: 'friday' },
{ label: 'Samstag', value: 'saturday' },
{ label: 'Sonntag', value: 'sunday' },
],
},
{
name: 'defaultTime',
type: 'text',
label: 'Standard-Uhrzeit',
admin: { description: 'z.B. "12:00"' },
},
{
name: 'shortsPerWeek',
type: 'number',
defaultValue: 4,
label: 'Shorts pro Woche',
},
{
name: 'longformPerWeek',
type: 'number',
defaultValue: 1,
label: 'Longform pro Woche',
},
],
},
// Metriken (via YouTube API Sync)
{
name: 'currentMetrics',
type: 'group',
label: 'Aktuelle Metriken',
admin: {
description: 'Automatisch via YouTube API aktualisiert',
},
fields: [
{ name: 'subscriberCount', type: 'number', label: 'Abonnenten', admin: { readOnly: true } },
{ name: 'totalViews', type: 'number', label: 'Gesamtaufrufe', admin: { readOnly: true } },
{ name: 'videoCount', type: 'number', label: 'Anzahl Videos', admin: { readOnly: true } },
{ name: 'lastSyncedAt', type: 'date', label: 'Letzter Sync', admin: { readOnly: true } },
],
},
],
timestamps: true,
}
```
### 2.2 YouTubeContent (Content Pipeline)
```typescript
// src/collections/YouTubeContent.ts
import type { CollectionConfig } from 'payload'
import { hasYouTubeAccess, isYouTubeCreatorOrAbove, isYouTubeManager, canAccessAssignedContent } from '../lib/youtubeAccess'
import { createSlugValidationHook } from '../lib/validation'
export const YouTubeContent: CollectionConfig = {
slug: 'youtube-content',
labels: {
singular: 'YouTube-Video',
plural: 'YouTube-Videos',
},
admin: {
useAsTitle: 'title',
group: 'YouTube',
defaultColumns: ['title', 'channel', 'status', 'format', 'scheduledPublishDate', 'assignedTo'],
listSearchableFields: ['title', 'description'],
description: 'Content-Pipeline für YouTube-Videos',
},
access: {
read: canAccessAssignedContent,
create: isYouTubeCreatorOrAbove,
update: canAccessAssignedContent,
delete: isYouTubeManager,
},
hooks: {
beforeValidate: [
createSlugValidationHook({ collection: 'youtube-content' }),
],
afterChange: [
// Hook wird in separater Datei definiert
// async ({ doc, previousDoc, req, operation }) => { ... }
],
},
fields: [
// === GRUNDDATEN ===
{
name: 'title',
type: 'text',
required: true,
localized: true,
label: 'Titel',
maxLength: 100,
admin: {
description: 'Max. 60 Zeichen für YouTube empfohlen',
},
},
{
name: 'slug',
type: 'text',
required: true,
label: 'Slug',
admin: {
description: 'URL-freundlicher Name',
},
},
{
name: 'channel',
type: 'relationship',
relationTo: 'youtube-channels',
required: true,
label: 'Kanal',
admin: {
position: 'sidebar',
},
},
{
name: 'contentSeries',
type: 'text',
label: 'Serie',
admin: {
description: 'Slug der Serie aus dem Kanal (z.B. "grfi", "investment-piece")',
position: 'sidebar',
},
},
{
name: 'format',
type: 'select',
required: true,
label: 'Format',
options: [
{ label: 'Short (< 60s)', value: 'short' },
{ label: 'Longform', value: 'longform' },
{ label: 'Premiere/Live', value: 'premiere' },
],
admin: {
position: 'sidebar',
},
},
// === STATUS & WORKFLOW ===
{
name: 'status',
type: 'select',
required: true,
defaultValue: 'idea',
label: 'Status',
options: [
{ label: 'Idee', value: 'idea' },
{ label: 'Skript in Arbeit', value: 'script_draft' },
{ label: 'Skript Review', value: 'script_review' },
{ label: 'Skript freigegeben', value: 'script_approved' },
{ label: 'Dreh geplant', value: 'shoot_scheduled' },
{ label: 'Gedreht', value: 'shot' },
{ label: 'Rohschnitt', value: 'rough_cut' },
{ label: 'Feinschnitt', value: 'fine_cut' },
{ label: 'Final Review', value: 'final_review' },
{ label: 'Freigegeben', value: 'approved' },
{ label: 'Upload geplant', value: 'upload_scheduled' },
{ label: 'Live', value: 'published' },
{ label: 'Performance getrackt', value: 'tracked' },
{ label: 'Verworfen', value: 'discarded' },
],
admin: {
position: 'sidebar',
},
},
{
name: 'priority',
type: 'select',
defaultValue: 'normal',
label: 'Priorität',
options: [
{ label: 'Dringend', value: 'urgent' },
{ label: 'Hoch', value: 'high' },
{ label: 'Normal', value: 'normal' },
{ label: 'Niedrig', value: 'low' },
],
admin: {
position: 'sidebar',
},
},
// === ZUWEISUNG ===
{
name: 'assignedTo',
type: 'relationship',
relationTo: 'users',
label: 'Zugewiesen an',
admin: {
position: 'sidebar',
},
},
{
name: 'createdBy',
type: 'relationship',
relationTo: 'users',
label: 'Erstellt von',
admin: {
readOnly: true,
position: 'sidebar',
},
},
// === TABS ===
{
type: 'tabs',
tabs: [
{
label: 'Inhalt',
fields: [
{
name: 'description',
type: 'textarea',
localized: true,
label: 'Beschreibung / Konzept',
},
{
name: 'hook',
type: 'text',
localized: true,
label: 'Hook (erste 3 Sekunden)',
admin: {
description: 'Was sagt/zeigt der Creator in den ersten 3 Sekunden?',
},
},
{
name: 'keyPoints',
type: 'array',
label: 'Kernpunkte',
fields: [
{ name: 'point', type: 'text', localized: true, label: 'Punkt' },
],
},
{
name: 'callToAction',
type: 'text',
localized: true,
label: 'Call-to-Action',
},
{
name: 'scriptUrl',
type: 'text',
label: 'Skript-Link (Google Docs)',
},
{
name: 'scriptContent',
type: 'richText',
localized: true,
label: 'Skript (optional inline)',
},
],
},
{
label: 'Termine',
fields: [
{
name: 'shootDate',
type: 'date',
label: 'Dreh-Datum',
admin: {
date: { pickerAppearance: 'dayAndTime' },
},
},
{
name: 'editDeadline',
type: 'date',
label: 'Schnitt-Deadline',
},
{
name: 'reviewDeadline',
type: 'date',
label: 'Review-Deadline',
},
{
name: 'scheduledPublishDate',
type: 'date',
label: 'Geplantes Veröffentlichungsdatum',
admin: {
date: { pickerAppearance: 'dayAndTime' },
},
},
{
name: 'actualPublishDate',
type: 'date',
label: 'Tatsächliches Veröffentlichungsdatum',
admin: { readOnly: true },
},
],
},
{
label: 'Dateien',
fields: [
{
name: 'thumbnail',
type: 'upload',
relationTo: 'media',
label: 'Thumbnail',
},
{
name: 'thumbnailAlt',
type: 'upload',
relationTo: 'media',
label: 'Thumbnail (A/B-Test)',
},
{
name: 'videoFile',
type: 'upload',
relationTo: 'media',
label: 'Video-Datei (Final)',
},
{
name: 'rawFootage',
type: 'array',
label: 'Rohmaterial',
fields: [
{ name: 'file', type: 'upload', relationTo: 'media', label: 'Datei' },
{ name: 'description', type: 'text', label: 'Beschreibung' },
],
},
],
},
{
label: 'Freigaben',
fields: [
{
name: 'approvals',
type: 'group',
label: 'Freigaben',
fields: [
{
name: 'scriptApproval',
type: 'group',
label: 'Skript-Freigabe',
fields: [
{ name: 'approved', type: 'checkbox', label: 'Freigegeben' },
{ name: 'approvedBy', type: 'relationship', relationTo: 'users', label: 'Von' },
{ name: 'approvedAt', type: 'date', label: 'Am' },
{ name: 'notes', type: 'textarea', localized: true, label: 'Anmerkungen' },
],
},
{
name: 'medicalApproval',
type: 'group',
label: 'Medizinische Freigabe',
admin: {
description: 'Nur für Corporate-Kanäle relevant',
},
fields: [
{ name: 'required', type: 'checkbox', label: 'Erforderlich', defaultValue: false },
{ name: 'approved', type: 'checkbox', label: 'Freigegeben' },
{ name: 'approvedBy', type: 'relationship', relationTo: 'users', label: 'Von' },
{ name: 'approvedAt', type: 'date', label: 'Am' },
{ name: 'notes', type: 'textarea', localized: true, label: 'Anmerkungen' },
],
},
{
name: 'legalApproval',
type: 'group',
label: 'Rechtliche Freigabe',
fields: [
{ name: 'approved', type: 'checkbox', label: 'Freigegeben' },
{ name: 'approvedBy', type: 'relationship', relationTo: 'users', label: 'Von' },
{ name: 'approvedAt', type: 'date', label: 'Am' },
{ name: 'disclaimerIncluded', type: 'checkbox', label: 'Disclaimer enthalten' },
{ name: 'notes', type: 'textarea', localized: true, label: 'Anmerkungen' },
],
},
{
name: 'finalApproval',
type: 'group',
label: 'Finale Freigabe',
fields: [
{ name: 'approved', type: 'checkbox', label: 'Freigegeben' },
{ name: 'approvedBy', type: 'relationship', relationTo: 'users', label: 'Von' },
{ name: 'approvedAt', type: 'date', label: 'Am' },
{ name: 'notes', type: 'textarea', localized: true, label: 'Anmerkungen' },
],
},
],
},
],
},
{
label: 'YouTube',
fields: [
{
name: 'youtube',
type: 'group',
label: 'YouTube-Daten',
fields: [
{ name: 'videoId', type: 'text', label: 'YouTube Video ID', admin: { readOnly: true } },
{ name: 'url', type: 'text', label: 'YouTube URL', admin: { readOnly: true } },
{
name: 'metadata',
type: 'group',
label: 'Metadaten für Upload',
fields: [
{ name: 'youtubeTitle', type: 'text', localized: true, label: 'YouTube-Titel', maxLength: 100 },
{ name: 'youtubeDescription', type: 'textarea', localized: true, label: 'YouTube-Beschreibung' },
{
name: 'tags',
type: 'array',
label: 'Tags',
fields: [{ name: 'tag', type: 'text', label: 'Tag' }],
},
{
name: 'visibility',
type: 'select',
label: 'Sichtbarkeit',
options: [
{ label: 'Öffentlich', value: 'public' },
{ label: 'Nicht gelistet', value: 'unlisted' },
{ label: 'Privat', value: 'private' },
],
defaultValue: 'private',
},
{ name: 'chapters', type: 'textarea', label: 'Kapitelmarker' },
{ name: 'pinnedComment', type: 'textarea', localized: true, label: 'Gepinnter Kommentar' },
],
},
],
},
],
},
{
label: 'Performance',
fields: [
{
name: 'performance',
type: 'group',
label: 'Performance-Metriken',
admin: {
description: 'Automatisch via YouTube API aktualisiert',
},
fields: [
{ name: 'views', type: 'number', label: 'Aufrufe', admin: { readOnly: true } },
{ name: 'likes', type: 'number', label: 'Likes', admin: { readOnly: true } },
{ name: 'comments', type: 'number', label: 'Kommentare', admin: { readOnly: true } },
{ name: 'shares', type: 'number', label: 'Shares', admin: { readOnly: true } },
{ name: 'watchTimeMinutes', type: 'number', label: 'Wiedergabezeit (Min)', admin: { readOnly: true } },
{ name: 'avgViewDuration', type: 'number', label: 'Ø Wiedergabedauer (Sek)', admin: { readOnly: true } },
{ name: 'avgViewPercentage', type: 'number', label: 'Ø Wiedergabe (%)', admin: { readOnly: true } },
{ name: 'ctr', type: 'number', label: 'CTR (%)', admin: { readOnly: true } },
{ name: 'impressions', type: 'number', label: 'Impressionen', admin: { readOnly: true } },
{ name: 'subscribersGained', type: 'number', label: 'Neue Abos', admin: { readOnly: true } },
{ name: 'lastSyncedAt', type: 'date', label: 'Letzter Sync', admin: { readOnly: true } },
],
},
],
},
],
},
// === INTERNE NOTIZEN ===
{
name: 'internalNotes',
type: 'richText',
label: 'Interne Notizen',
admin: {
description: 'Nur für das Team sichtbar',
},
},
],
timestamps: true,
}
```
### 2.3 YtTasks (Aufgaben)
```typescript
// src/collections/YtTasks.ts
import type { CollectionConfig } from 'payload'
import { hasYouTubeAccess, isYouTubeManager, canAccessAssignedContent } from '../lib/youtubeAccess'
export const YtTasks: CollectionConfig = {
slug: 'yt-tasks',
labels: {
singular: 'YouTube-Aufgabe',
plural: 'YouTube-Aufgaben',
},
admin: {
useAsTitle: 'title',
group: 'YouTube',
defaultColumns: ['title', 'video', 'assignedTo', 'status', 'dueDate', 'priority'],
listSearchableFields: ['title'],
description: 'Aufgaben für die Video-Produktion',
},
access: {
read: canAccessAssignedContent,
create: isYouTubeManager,
update: canAccessAssignedContent,
delete: isYouTubeManager,
},
fields: [
{
name: 'title',
type: 'text',
required: true,
localized: true,
label: 'Aufgabe',
},
{
name: 'description',
type: 'textarea',
localized: true,
label: 'Beschreibung',
},
{
name: 'video',
type: 'relationship',
relationTo: 'youtube-content',
label: 'Zugehöriges Video',
admin: {
position: 'sidebar',
},
},
{
name: 'channel',
type: 'relationship',
relationTo: 'youtube-channels',
label: 'Kanal',
admin: {
position: 'sidebar',
description: 'Wird automatisch vom Video übernommen',
},
},
{
name: 'taskType',
type: 'select',
required: true,
label: 'Aufgabentyp',
options: [
{ label: 'Skript schreiben', value: 'script_write' },
{ label: 'Skript reviewen', value: 'script_review' },
{ label: 'Dreh vorbereiten', value: 'shoot_prep' },
{ label: 'Drehen', value: 'shoot' },
{ label: 'Schneiden', value: 'edit' },
{ label: 'Grafiken erstellen', value: 'graphics' },
{ label: 'Thumbnail erstellen', value: 'thumbnail' },
{ label: 'Review/Freigabe', value: 'review' },
{ label: 'Hochladen', value: 'upload' },
{ label: 'Performance tracken', value: 'track' },
{ label: 'Kommentare beantworten', value: 'comments' },
{ label: 'Sonstiges', value: 'other' },
],
admin: {
position: 'sidebar',
},
},
{
name: 'status',
type: 'select',
required: true,
defaultValue: 'todo',
label: 'Status',
options: [
{ label: 'Offen', value: 'todo' },
{ label: 'In Arbeit', value: 'in_progress' },
{ label: 'Blockiert', value: 'blocked' },
{ label: 'Wartet auf Review', value: 'waiting_review' },
{ label: 'Erledigt', value: 'done' },
{ label: 'Abgebrochen', value: 'cancelled' },
],
admin: {
position: 'sidebar',
},
},
{
name: 'priority',
type: 'select',
defaultValue: 'normal',
label: 'Priorität',
options: [
{ label: 'Dringend', value: 'urgent' },
{ label: 'Hoch', value: 'high' },
{ label: 'Normal', value: 'normal' },
{ label: 'Niedrig', value: 'low' },
],
admin: {
position: 'sidebar',
},
},
{
name: 'assignedTo',
type: 'relationship',
relationTo: 'users',
required: true,
label: 'Zugewiesen an',
admin: {
position: 'sidebar',
},
},
{
name: 'dueDate',
type: 'date',
label: 'Fälligkeitsdatum',
admin: {
position: 'sidebar',
date: { pickerAppearance: 'dayAndTime' },
},
},
{
name: 'completedAt',
type: 'date',
label: 'Abgeschlossen am',
admin: {
readOnly: true,
position: 'sidebar',
},
},
{
name: 'completedBy',
type: 'relationship',
relationTo: 'users',
label: 'Abgeschlossen von',
admin: { readOnly: true },
},
{
name: 'blockedReason',
type: 'text',
localized: true,
label: 'Grund für Blockierung',
admin: {
condition: (data) => data?.status === 'blocked',
},
},
{
name: 'attachments',
type: 'array',
label: 'Anhänge',
fields: [
{ name: 'file', type: 'upload', relationTo: 'media', label: 'Datei' },
{ name: 'note', type: 'text', label: 'Notiz' },
],
},
{
name: 'comments',
type: 'array',
label: 'Kommentare',
fields: [
{ name: 'author', type: 'relationship', relationTo: 'users', label: 'Autor' },
{ name: 'content', type: 'textarea', label: 'Inhalt' },
{ name: 'createdAt', type: 'date', label: 'Erstellt am' },
],
},
],
timestamps: true,
hooks: {
beforeChange: [
async ({ data, originalDoc, req }) => {
if (!data) return data
// Setze completedAt wenn Status auf "done" wechselt
if (data.status === 'done' && originalDoc?.status !== 'done') {
data.completedAt = new Date().toISOString()
data.completedBy = req.user?.id
}
// Setze Channel automatisch vom Video
if (data.video && !data.channel) {
const video = await req.payload.findByID({
collection: 'youtube-content',
id: typeof data.video === 'object' ? data.video.id : data.video,
depth: 0,
})
if (video?.channel) {
data.channel = typeof video.channel === 'object' ? video.channel.id : video.channel
}
}
return data
},
],
},
}
```
### 2.4 YtNotifications (Benachrichtigungen)
```typescript
// src/collections/YtNotifications.ts
import type { CollectionConfig, Access } from 'payload'
const canAccessOwnNotifications: Access = ({ req }) => {
const user = req.user
if (!user) return false
if (user.isSuperAdmin) return true
return { recipient: { equals: user.id } }
}
export const YtNotifications: CollectionConfig = {
slug: 'yt-notifications',
labels: {
singular: 'Benachrichtigung',
plural: 'Benachrichtigungen',
},
admin: {
group: 'YouTube',
defaultColumns: ['title', 'recipient', 'type', 'read', 'createdAt'],
description: 'Benachrichtigungen für YouTube Operations',
},
access: {
read: canAccessOwnNotifications,
create: () => true, // System kann erstellen
update: canAccessOwnNotifications,
delete: ({ req }) => !!req.user?.isSuperAdmin,
},
fields: [
{
name: 'recipient',
type: 'relationship',
relationTo: 'users',
required: true,
label: 'Empfänger',
index: true,
},
{
name: 'type',
type: 'select',
required: true,
label: 'Typ',
options: [
{ label: 'Neue Aufgabe', value: 'task_assigned' },
{ label: 'Aufgabe fällig', value: 'task_due' },
{ label: 'Aufgabe überfällig', value: 'task_overdue' },
{ label: 'Freigabe erforderlich', value: 'approval_required' },
{ label: 'Freigabe erteilt', value: 'approved' },
{ label: 'Freigabe abgelehnt', value: 'rejected' },
{ label: 'Video veröffentlicht', value: 'video_published' },
{ label: 'Kommentar', value: 'comment' },
{ label: 'Erwähnung', value: 'mention' },
{ label: 'System', value: 'system' },
],
},
{
name: 'title',
type: 'text',
required: true,
localized: true,
label: 'Titel',
},
{
name: 'message',
type: 'textarea',
localized: true,
label: 'Nachricht',
},
{
name: 'link',
type: 'text',
label: 'Link',
admin: {
description: 'Relativer Pfad zum relevanten Element',
},
},
{
name: 'relatedVideo',
type: 'relationship',
relationTo: 'youtube-content',
label: 'Video',
},
{
name: 'relatedTask',
type: 'relationship',
relationTo: 'yt-tasks',
label: 'Aufgabe',
},
{
name: 'read',
type: 'checkbox',
defaultValue: false,
label: 'Gelesen',
},
{
name: 'readAt',
type: 'date',
label: 'Gelesen am',
},
{
name: 'emailSent',
type: 'checkbox',
defaultValue: false,
label: 'E-Mail gesendet',
},
],
timestamps: true,
}
```
---
## 3. Hooks (Separate Dateien)
### 3.1 Auto-Task-Erstellung
```typescript
// src/hooks/youtubeContent/createTasksOnStatusChange.ts
import type { CollectionAfterChangeHook } from 'payload'
interface TaskTemplate {
title: string
type: string
assignRole: string
}
const TASK_TEMPLATES: Record<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
```typescript
// src/hooks/ytTasks/notifyOnAssignment.ts
import type { CollectionAfterChangeHook } from 'payload'
export const notifyOnAssignment: CollectionAfterChangeHook = async ({
doc,
previousDoc,
req,
operation,
}) => {
// Neue Zuweisung oder geänderte Zuweisung
const previousAssignedTo = previousDoc?.assignedTo
? (typeof previousDoc.assignedTo === 'object' ? previousDoc.assignedTo.id : previousDoc.assignedTo)
: null
const currentAssignedTo = doc.assignedTo
? (typeof doc.assignedTo === 'object' ? doc.assignedTo.id : doc.assignedTo)
: null
const isNewAssignment = operation === 'create' || previousAssignedTo !== currentAssignedTo
if (isNewAssignment && currentAssignedTo) {
await req.payload.create({
collection: 'yt-notifications',
data: {
recipient: currentAssignedTo,
type: 'task_assigned',
title: `Neue Aufgabe: ${doc.title}`,
message: doc.dueDate
? `Fällig am ${new Date(doc.dueDate).toLocaleDateString('de-DE')}`
: 'Keine Deadline gesetzt',
link: `/admin/collections/yt-tasks/${doc.id}`,
relatedTask: doc.id,
relatedVideo: doc.video
? (typeof doc.video === 'object' ? doc.video.id : doc.video)
: undefined,
},
})
}
return doc
}
```
---
## 4. API Routes (Next.js App Router)
### 4.1 Dashboard Overview
```typescript
// src/app/(payload)/api/youtube/dashboard/route.ts
import { getPayload } from 'payload'
import config from '@payload-config'
import { NextRequest, NextResponse } from 'next/server'
interface UserWithYouTubeRole {
id: number
isSuperAdmin?: boolean
youtubeRole?: string
}
export async function GET(req: NextRequest) {
try {
const payload = await getPayload({ config })
const { user } = await payload.auth({ headers: req.headers })
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const typedUser = user as UserWithYouTubeRole
// Kanäle abrufen
const channels = await payload.find({
collection: 'youtube-channels',
where: { status: { equals: 'active' } },
depth: 0,
})
// Pipeline-Status zählen
const [idea, scriptDraft, review, production, editing, ready, published] = await Promise.all([
payload.count({ collection: 'youtube-content', where: { status: { equals: 'idea' } } }),
payload.count({ collection: 'youtube-content', where: { status: { equals: 'script_draft' } } }),
payload.count({ collection: 'youtube-content', where: { status: { in: ['script_review', 'final_review'] } } }),
payload.count({ collection: 'youtube-content', where: { status: { in: ['shoot_scheduled', 'shot'] } } }),
payload.count({ collection: 'youtube-content', where: { status: { in: ['rough_cut', 'fine_cut'] } } }),
payload.count({ collection: 'youtube-content', where: { status: { equals: 'approved' } } }),
payload.count({ collection: 'youtube-content', where: { status: { equals: 'published' } } }),
])
// Ausstehende Freigaben für Manager
const pendingApprovals = typedUser.youtubeRole === 'manager' || typedUser.isSuperAdmin
? await payload.find({
collection: 'youtube-content',
where: {
or: [
{ status: { equals: 'script_review' } },
{ status: { equals: 'final_review' } },
],
},
limit: 10,
depth: 1,
})
: { docs: [] }
// Überfällige Tasks
const overdueTasks = await payload.find({
collection: 'yt-tasks',
where: {
and: [
{ status: { not_in: ['done', 'cancelled'] } },
{ dueDate: { less_than: new Date().toISOString() } },
],
},
depth: 1,
})
// Videos diese Woche geplant
const weekStart = new Date()
weekStart.setHours(0, 0, 0, 0)
const weekEnd = new Date(weekStart)
weekEnd.setDate(weekEnd.getDate() + 7)
const thisWeekVideos = await payload.find({
collection: 'youtube-content',
where: {
scheduledPublishDate: {
greater_than_equal: weekStart.toISOString(),
less_than: weekEnd.toISOString(),
},
},
sort: 'scheduledPublishDate',
depth: 1,
})
// Offene Tasks zählen
const tasksTotal = await payload.count({
collection: 'yt-tasks',
where: { status: { not_equals: 'done' } },
})
return NextResponse.json({
channels: channels.docs,
pipeline: {
idea: idea.totalDocs,
script: scriptDraft.totalDocs,
review: review.totalDocs,
production: production.totalDocs,
editing: editing.totalDocs,
ready: ready.totalDocs,
published: published.totalDocs,
},
pendingApprovals: pendingApprovals.docs,
overdueTasks: overdueTasks.docs,
thisWeekVideos: thisWeekVideos.docs,
tasksTotal: tasksTotal.totalDocs,
})
} catch (error) {
console.error('[YouTube Dashboard] Error:', error)
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Unknown error' },
{ status: 500 }
)
}
}
```
### 4.2 My Tasks
```typescript
// src/app/(payload)/api/youtube/my-tasks/route.ts
import { getPayload } from 'payload'
import config from '@payload-config'
import { NextRequest, NextResponse } from 'next/server'
export async function GET(req: NextRequest) {
try {
const payload = await getPayload({ config })
const { user } = await payload.auth({ headers: req.headers })
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const tasks = await payload.find({
collection: 'yt-tasks',
where: {
and: [
{ assignedTo: { equals: user.id } },
{ status: { not_in: ['done', 'cancelled'] } },
],
},
sort: 'dueDate',
depth: 2, // Video und Channel laden
})
return NextResponse.json({
tasks: tasks.docs,
total: tasks.totalDocs,
})
} catch (error) {
console.error('[YouTube My-Tasks] Error:', error)
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Unknown error' },
{ status: 500 }
)
}
}
```
### 4.3 Complete Task
```typescript
// src/app/(payload)/api/youtube/complete-task/[id]/route.ts
import { getPayload } from 'payload'
import config from '@payload-config'
import { NextRequest, NextResponse } from 'next/server'
interface RouteParams {
params: Promise<{ id: string }>
}
export async function POST(req: NextRequest, { params }: RouteParams) {
try {
const { id: taskId } = await params
const payload = await getPayload({ config })
const { user } = await payload.auth({ headers: req.headers })
if (!user || !taskId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const task = await payload.findByID({
collection: 'yt-tasks',
id: taskId,
depth: 0,
})
if (!task) {
return NextResponse.json({ error: 'Task not found' }, { status: 404 })
}
// Prüfen ob User berechtigt ist
const assignedId = typeof task.assignedTo === 'object'
? task.assignedTo.id
: task.assignedTo
if (!user.isSuperAdmin && assignedId !== user.id) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
const updated = await payload.update({
collection: 'yt-tasks',
id: taskId,
data: {
status: 'done',
completedAt: new Date().toISOString(),
completedBy: user.id,
},
})
return NextResponse.json({ success: true, task: updated })
} catch (error) {
console.error('[YouTube Complete-Task] Error:', error)
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Unknown error' },
{ status: 500 }
)
}
}
```
---
## 5. Payload Config Integration
```typescript
// In src/payload.config.ts hinzufügen:
// Imports
import { YouTubeChannels } from './collections/YouTubeChannels'
import { YouTubeContent } from './collections/YouTubeContent'
import { YtTasks } from './collections/YtTasks'
import { YtNotifications } from './collections/YtNotifications'
// In collections Array:
collections: [
// ... bestehende Collections
// YouTube Operations Hub
YouTubeChannels,
YouTubeContent,
YtTasks,
YtNotifications,
],
// In multiTenantPlugin collections:
multiTenantPlugin({
collections: {
// ... bestehende Collections
// YouTube Operations (NICHT tenant-scoped, da internes Tool)
// Falls doch tenant-scoped gewünscht:
// 'youtube-channels': {},
// 'youtube-content': {},
// 'yt-tasks': {},
// 'yt-notifications': {},
},
}),
```
---
## 6. Migration erstellen
Nach dem Erstellen der Collections:
```bash
cd /home/payload/payload-cms
# Migration erstellen
pnpm payload migrate:create
# Migration manuell erweitern (KRITISCH!):
```
Die generierte Migration unter `src/migrations/` muss erweitert werden:
```typescript
// In der up() Funktion NACH den CREATE TABLE Statements:
// System-Tabelle für Document Locking erweitern
await payload.db.drizzle.execute(sql`
ALTER TABLE "payload_locked_documents_rels"
ADD COLUMN IF NOT EXISTS "youtube_channels_id" integer
REFERENCES youtube_channels(id) ON DELETE CASCADE;
ALTER TABLE "payload_locked_documents_rels"
ADD COLUMN IF NOT EXISTS "youtube_content_id" integer
REFERENCES youtube_content(id) ON DELETE CASCADE;
ALTER TABLE "payload_locked_documents_rels"
ADD COLUMN IF NOT EXISTS "yt_tasks_id" integer
REFERENCES yt_tasks(id) ON DELETE CASCADE;
ALTER TABLE "payload_locked_documents_rels"
ADD COLUMN IF NOT EXISTS "yt_notifications_id" integer
REFERENCES yt_notifications(id) ON DELETE CASCADE;
CREATE INDEX IF NOT EXISTS "payload_locked_documents_rels_youtube_channels_idx"
ON "payload_locked_documents_rels" ("youtube_channels_id");
CREATE INDEX IF NOT EXISTS "payload_locked_documents_rels_youtube_content_idx"
ON "payload_locked_documents_rels" ("youtube_content_id");
CREATE INDEX IF NOT EXISTS "payload_locked_documents_rels_yt_tasks_idx"
ON "payload_locked_documents_rels" ("yt_tasks_id");
CREATE INDEX IF NOT EXISTS "payload_locked_documents_rels_yt_notifications_idx"
ON "payload_locked_documents_rels" ("yt_notifications_id");
`);
```
Dann Migration ausführen:
```bash
# Direkte DB-Verbindung für Migrationen (umgeht PgBouncer)
./scripts/db-direct.sh migrate
# Oder standard:
pnpm payload migrate
```
---
## 7. Entwicklungsschritte
### Phase 1: Basis-Setup
1. `src/lib/youtubeAccess.ts` erstellen
2. Users Collection erweitern (youtubeRole, youtubeChannels)
3. YouTubeChannels Collection erstellen
4. YouTubeContent Collection erstellen
5. YtTasks Collection erstellen
6. YtNotifications Collection erstellen
7. `payload.config.ts` aktualisieren
8. Migration erstellen und System-Tabellen erweitern
9. `pnpm payload generate:importmap`
10. `pnpm build`
### Phase 2: Hooks
1. `src/hooks/youtubeContent/createTasksOnStatusChange.ts`
2. `src/hooks/ytTasks/notifyOnAssignment.ts`
3. Hooks in Collections einbinden
### Phase 3: API Routes
1. `src/app/(payload)/api/youtube/dashboard/route.ts`
2. `src/app/(payload)/api/youtube/my-tasks/route.ts`
3. `src/app/(payload)/api/youtube/complete-task/[id]/route.ts`
### Phase 4: Test & Deployment
1. `pm2 restart payload`
2. Admin-UI testen unter https://pl.porwoll.tech/admin
3. API-Endpoints testen
4. Access Control mit verschiedenen Rollen testen
---
## 8. Wichtige Befehle
```bash
# Entwicklung
pnpm dev
# Build
pnpm build
# Migration erstellen
pnpm payload migrate:create
# Migration ausführen (direkt, umgeht PgBouncer)
./scripts/db-direct.sh migrate
# ImportMap generieren (nach Plugin/Collection-Änderungen)
pnpm payload generate:importmap
# PM2 Neustart
pm2 restart payload
# Logs
pm2 logs payload
```
---
## Zusammenfassung
Dieser Prompt beschreibt die Integration eines YouTube Operations Hub mit:
- **4 neue Collections**: YouTubeChannels, YouTubeContent, YtTasks, YtNotifications
- **Keine Konflikte** mit bestehenden Collections (Videos, Series)
- **Korrekte Access Control** via `src/lib/youtubeAccess.ts`
- **Lokalisierung** für alle text-basierten Felder
- **Hooks in separaten Dateien** unter `src/hooks/`
- **API Routes im Next.js App Router Format**
- **Vollständige Migrationsanforderungen** inkl. System-Tabellen
- **Deutsche Admin-Gruppen** ("YouTube")
Die Integration respektiert die bestehende Multi-Tenant-Architektur und alle Konventionen des Payload CMS Projekts.