mirror of
https://github.com/complexcaresolutions/cms.c2sgmbh.git
synced 2026-03-17 19:44:12 +00:00
- 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>
1859 lines
52 KiB
Markdown
1859 lines
52 KiB
Markdown
# 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.
|