# Phase 1: Community Core – Detailplan ## Übersicht **Ziel:** Community Management direkt im Hub mit YouTube API Integration und Vorbereitung für LinkedIn/Instagram/Facebook. **Voraussetzung:** YouTube Operations Hub muss implementiert sein (YouTubeChannels, YouTubeContent Collections). **Kern-Features:** 1. Community Inbox (Unified View) 2. YouTube Comments Sync (Direkte API) 3. Response Templates mit Variablen 4. Medical Flag System (CCS-spezifisch) 5. AI-gestützte Sentiment-Analyse (Claude API) 6. Multi-Platform Grundstruktur --- ## Architektur Phase 1 ``` ┌─────────────────────────────────────────────────────────────────────────┐ │ COMMUNITY CORE │ ├─────────────────────────────────────────────────────────────────────────┤ │ │ │ ┌────────────────────────────────────────────────────────────────┐ │ │ │ COMMUNITY INBOX VIEW │ │ │ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ │ │ │ │ YouTube │ │ LinkedIn │ │Instagram │ │ Facebook │ │ │ │ │ │ ✓ │ │ (prep) │ │ (prep) │ │ (prep) │ │ │ │ │ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │ │ │ └────────────────────────────────────────────────────────────────┘ │ │ │ │ │ ▼ │ │ ┌────────────────────────────────────────────────────────────────┐ │ │ │ INTEGRATION LAYER │ │ │ │ │ │ │ │ ┌─────────────────┐ ┌─────────────────┐ ┌────────────────┐ │ │ │ │ │ YouTube Data │ │ Claude AI │ │ Notification │ │ │ │ │ │ API v3 │ │ (Sentiment) │ │ Service │ │ │ │ │ └─────────────────┘ └─────────────────┘ └────────────────┘ │ │ │ │ │ │ │ │ ┌─────────────────────────────────────────────────────────┐ │ │ │ │ │ Platform Adapters (Prepared for Phase 2) │ │ │ │ │ │ • YouTubeAdapter ✓ • LinkedInAdapter (stub) │ │ │ │ │ │ • InstagramAdapter (stub) • FacebookAdapter (stub) │ │ │ │ │ └─────────────────────────────────────────────────────────┘ │ │ │ └────────────────────────────────────────────────────────────────┘ │ │ │ │ ┌────────────────────────────────────────────────────────────────┐ │ │ │ PAYLOAD COLLECTIONS │ │ │ │ │ │ │ │ • social-platforms • community-interactions │ │ │ │ • social-accounts • community-templates │ │ │ │ • community-rules │ │ │ └────────────────────────────────────────────────────────────────┘ │ │ │ └─────────────────────────────────────────────────────────────────────────┘ ``` --- ## Teil 1: Access Control erweitern ### 1.0 Community Access Functions ```typescript // src/lib/communityAccess.ts import type { Access } from 'payload' interface UserWithRoles { id: number isSuperAdmin?: boolean is_super_admin?: boolean youtubeRole?: 'none' | 'viewer' | 'editor' | 'producer' | 'creator' | 'manager' youtube_role?: 'none' | 'viewer' | 'editor' | 'producer' | 'creator' | 'manager' communityRole?: 'none' | 'viewer' | 'moderator' | 'manager' community_role?: 'none' | 'viewer' | 'moderator' | 'manager' } const checkIsSuperAdmin = (user: UserWithRoles | null): boolean => { if (!user) return false return Boolean(user.isSuperAdmin || user.is_super_admin) } const getCommunityRole = (user: UserWithRoles | null): string | undefined => { if (!user) return undefined return user.communityRole || user.community_role } const getYouTubeRole = (user: UserWithRoles | null): string | undefined => { if (!user) return undefined return user.youtubeRole || user.youtube_role } /** * Prüft ob User Community-Manager oder Super-Admin ist */ export const isCommunityManager: Access = ({ req }) => { const user = req.user as UserWithRoles | null if (!user) return false if (checkIsSuperAdmin(user)) return true // YouTube-Manager haben auch Community-Zugriff if (getYouTubeRole(user) === 'manager') return true return getCommunityRole(user) === 'manager' } /** * Prüft ob User mindestens Moderator-Rechte hat */ export const isCommunityModeratorOrAbove: Access = ({ req }) => { const user = req.user as UserWithRoles | null if (!user) return false if (checkIsSuperAdmin(user)) return true if (['manager', 'creator'].includes(getYouTubeRole(user) || '')) return true return ['moderator', 'manager'].includes(getCommunityRole(user) || '') } /** * Prüft ob User Zugriff auf Community-Features hat (mindestens Viewer) */ export const hasCommunityAccess: Access = ({ req }) => { const user = req.user as UserWithRoles | null if (!user) return false if (checkIsSuperAdmin(user)) return true // YouTube-Zugriff impliziert Community-Lesezugriff const ytRole = getYouTubeRole(user) if (ytRole && ytRole !== 'none') return true const commRole = getCommunityRole(user) return commRole !== 'none' && commRole !== undefined } /** * Zugriff auf zugewiesene Interactions */ export const canAccessAssignedInteractions: Access = ({ req }) => { const user = req.user as UserWithRoles | null if (!user) return false if (checkIsSuperAdmin(user)) return true if (getYouTubeRole(user) === 'manager') return true if (getCommunityRole(user) === 'manager') return true // Für andere Rollen: Nur zugewiesene Interactions return { or: [ { assignedTo: { equals: user.id } }, { 'response.sentBy': { equals: user.id } }, ], } } ``` --- ## Teil 2: Collections ### 2.1 Social Platforms (`social-platforms`) ```typescript // src/collections/SocialPlatforms.ts import type { CollectionConfig } from 'payload' import { isCommunityManager, hasCommunityAccess } from '../lib/communityAccess' export const SocialPlatforms: CollectionConfig = { slug: 'social-platforms', labels: { singular: 'Social Platform', plural: 'Social Platforms', }, admin: { group: 'Community', useAsTitle: 'name', defaultColumns: ['name', 'slug', 'isActive', 'apiStatus'], }, access: { read: hasCommunityAccess, create: isCommunityManager, update: isCommunityManager, delete: isCommunityManager, }, fields: [ { type: 'row', fields: [ { name: 'name', type: 'text', required: true, label: 'Name', admin: { width: '50%' }, }, { name: 'slug', type: 'text', required: true, unique: true, label: 'Slug', admin: { width: '50%' }, }, ], }, { type: 'row', fields: [ { name: 'icon', type: 'text', label: 'Icon (Emoji)', admin: { width: '25%', placeholder: '📺', }, }, { name: 'color', type: 'text', label: 'Brand Color', admin: { width: '25%', placeholder: '#FF0000', }, }, { name: 'isActive', type: 'checkbox', label: 'Aktiv', defaultValue: true, admin: { width: '25%' }, }, { name: 'apiStatus', type: 'select', label: 'API Status', options: [ { label: 'Verbunden', value: 'connected' }, { label: 'Eingeschränkt', value: 'limited' }, { label: 'Nicht verbunden', value: 'disconnected' }, { label: 'In Entwicklung', value: 'development' }, ], defaultValue: 'disconnected', admin: { width: '25%' }, }, ], }, // API Configuration { name: 'apiConfig', type: 'group', label: 'API Konfiguration', admin: { condition: (data) => data?.isActive, }, fields: [ { name: 'apiType', type: 'select', label: 'API Type', options: [ { label: 'YouTube Data API v3', value: 'youtube_v3' }, { label: 'LinkedIn API', value: 'linkedin' }, { label: 'Instagram Graph API', value: 'instagram_graph' }, { label: 'Facebook Graph API', value: 'facebook_graph' }, { label: 'Custom/Webhook', value: 'custom' }, ], }, { name: 'baseUrl', type: 'text', label: 'Base URL', admin: { placeholder: 'https://www.googleapis.com/youtube/v3', }, }, { name: 'authType', type: 'select', label: 'Auth Type', options: [ { label: 'OAuth 2.0', value: 'oauth2' }, { label: 'API Key', value: 'api_key' }, { label: 'Bearer Token', value: 'bearer' }, ], }, { name: 'scopes', type: 'array', label: 'OAuth Scopes', admin: { condition: (data, siblingData) => siblingData?.authType === 'oauth2', }, fields: [ { name: 'scope', type: 'text', label: 'Scope' }, ], }, ], }, // Interaction Types für diese Plattform { name: 'interactionTypes', type: 'array', label: 'Interaction Types', fields: [ { type: 'row', fields: [ { name: 'type', type: 'text', required: true, label: 'Type', admin: { width: '30%', placeholder: 'comment', }, }, { name: 'label', type: 'text', required: true, label: 'Label', admin: { width: '30%', placeholder: 'Kommentar', }, }, { name: 'icon', type: 'text', label: 'Icon', admin: { width: '20%', placeholder: '💬', }, }, { name: 'canReply', type: 'checkbox', label: 'Reply möglich', defaultValue: true, admin: { width: '20%' }, }, ], }, ], }, // Rate Limits { name: 'rateLimits', type: 'group', label: 'Rate Limits', fields: [ { type: 'row', fields: [ { name: 'requestsPerMinute', type: 'number', label: 'Requests/Minute', admin: { width: '33%' }, }, { name: 'requestsPerDay', type: 'number', label: 'Requests/Tag', admin: { width: '33%' }, }, { name: 'quotaUnitsPerDay', type: 'number', label: 'Quota Units/Tag', admin: { width: '33%', description: 'YouTube: 10.000/Tag', }, }, ], }, ], }, ], timestamps: true, } ``` ### 2.2 Social Accounts (`social-accounts`) ```typescript // src/collections/SocialAccounts.ts import type { CollectionConfig } from 'payload' import { isCommunityManager, hasCommunityAccess } from '../lib/communityAccess' export const SocialAccounts: CollectionConfig = { slug: 'social-accounts', labels: { singular: 'Social Account', plural: 'Social Accounts', }, admin: { group: 'Community', useAsTitle: 'displayName', defaultColumns: ['displayName', 'platform', 'linkedChannel', 'isActive'], }, access: { read: hasCommunityAccess, create: isCommunityManager, update: isCommunityManager, delete: isCommunityManager, }, fields: [ { type: 'row', fields: [ { name: 'platform', type: 'relationship', relationTo: 'social-platforms', required: true, label: 'Plattform', admin: { width: '50%' }, }, { name: 'linkedChannel', type: 'relationship', relationTo: 'youtube-channels', label: 'Verknüpfter YouTube-Kanal', admin: { width: '50%', description: 'Für Zuordnung zu Brand/Kanal', }, }, ], }, { type: 'row', fields: [ { name: 'displayName', type: 'text', required: true, label: 'Anzeigename', admin: { width: '50%', placeholder: 'BlogWoman YouTube', }, }, { name: 'accountHandle', type: 'text', label: 'Handle / Username', admin: { width: '50%', placeholder: '@blogwoman', }, }, ], }, { type: 'row', fields: [ { name: 'externalId', type: 'text', label: 'Platform Account ID', admin: { width: '50%', description: 'YouTube Channel ID, LinkedIn URN, etc.', }, }, { name: 'accountUrl', type: 'text', label: 'Account URL', admin: { width: '50%' }, }, ], }, { name: 'isActive', type: 'checkbox', label: 'Aktiv', defaultValue: true, admin: { position: 'sidebar', }, }, // OAuth Credentials (verschlüsselt speichern!) { name: 'credentials', type: 'group', label: 'API Credentials', admin: { description: 'Sensible Daten – nur für Admins sichtbar', condition: (data, siblingData, { user }) => Boolean((user as any)?.isSuperAdmin || (user as any)?.is_super_admin), }, fields: [ { name: 'accessToken', type: 'text', label: 'Access Token', admin: { description: 'OAuth Access Token', }, }, { name: 'refreshToken', type: 'text', label: 'Refresh Token', }, { name: 'tokenExpiresAt', type: 'date', label: 'Token Ablauf', }, { name: 'apiKey', type: 'text', label: 'API Key', admin: { description: 'Für API-Key basierte Auth', }, }, ], }, // Stats (periodisch aktualisiert) { name: 'stats', type: 'group', label: 'Account Stats', admin: { position: 'sidebar', }, fields: [ { name: 'followers', type: 'number', label: 'Followers/Subscribers', admin: { readOnly: true }, }, { name: 'totalPosts', type: 'number', label: 'Total Posts/Videos', admin: { readOnly: true }, }, { name: 'lastSyncedAt', type: 'date', label: 'Letzter Sync', admin: { readOnly: true }, }, ], }, // Sync Settings { name: 'syncSettings', type: 'group', label: 'Sync Settings', fields: [ { name: 'autoSyncEnabled', type: 'checkbox', label: 'Auto-Sync aktiviert', defaultValue: true, }, { name: 'syncIntervalMinutes', type: 'number', label: 'Sync-Intervall (Minuten)', defaultValue: 15, min: 5, max: 1440, }, { name: 'syncComments', type: 'checkbox', label: 'Kommentare synchronisieren', defaultValue: true, }, { name: 'syncDMs', type: 'checkbox', label: 'DMs synchronisieren', defaultValue: false, admin: { description: 'Nicht alle Plattformen unterstützen DM-API', }, }, ], }, ], timestamps: true, } ``` ### 2.3 Community Interactions (`community-interactions`) ```typescript // src/collections/CommunityInteractions.ts import type { CollectionConfig } from 'payload' import { hasCommunityAccess, isCommunityModeratorOrAbove, isCommunityManager, canAccessAssignedInteractions } from '../lib/communityAccess' export const CommunityInteractions: CollectionConfig = { slug: 'community-interactions', labels: { singular: 'Interaction', plural: 'Interactions', }, admin: { group: 'Community', defaultColumns: ['platform', 'type', 'authorName', 'status', 'priority', 'createdAt'], listSearchableFields: ['author.name', 'author.handle', 'message'], pagination: { defaultLimit: 50, }, }, access: { read: canAccessAssignedInteractions, create: isCommunityModeratorOrAbove, update: canAccessAssignedInteractions, delete: isCommunityManager, }, fields: [ // === SOURCE === { type: 'row', fields: [ { name: 'platform', type: 'relationship', relationTo: 'social-platforms', required: true, label: 'Plattform', admin: { width: '33%' }, }, { name: 'socialAccount', type: 'relationship', relationTo: 'social-accounts', required: true, label: 'Account', admin: { width: '33%' }, }, { name: 'linkedContent', type: 'relationship', relationTo: 'youtube-content', label: 'Verknüpfter Content', admin: { width: '33%' }, }, ], }, // === INTERACTION TYPE === { type: 'row', fields: [ { name: 'type', type: 'select', required: true, label: 'Typ', options: [ { label: 'Kommentar', value: 'comment' }, { label: 'Antwort', value: 'reply' }, { label: 'Direktnachricht', value: 'dm' }, { label: 'Erwähnung', value: 'mention' }, { label: 'Bewertung', value: 'review' }, { label: 'Frage', value: 'question' }, ], admin: { width: '50%' }, }, { name: 'externalId', type: 'text', label: 'External ID', required: true, unique: true, index: true, admin: { width: '50%', description: 'YouTube Comment ID, etc.', }, }, ], }, // === PARENT (für Threads) === { name: 'parentInteraction', type: 'relationship', relationTo: 'community-interactions', label: 'Parent (bei Replies)', admin: { condition: (data) => data?.type === 'reply', }, }, // === AUTHOR INFO === { name: 'author', type: 'group', label: 'Author', fields: [ { type: 'row', fields: [ { name: 'name', type: 'text', label: 'Name', admin: { width: '50%' }, }, { name: 'handle', type: 'text', label: 'Handle', admin: { width: '50%' }, }, ], }, { type: 'row', fields: [ { name: 'profileUrl', type: 'text', label: 'Profile URL', admin: { width: '50%' }, }, { name: 'avatarUrl', type: 'text', label: 'Avatar URL', admin: { width: '50%' }, }, ], }, { type: 'row', fields: [ { name: 'isVerified', type: 'checkbox', label: 'Verifiziert', admin: { width: '25%' }, }, { name: 'isSubscriber', type: 'checkbox', label: 'Subscriber/Follower', admin: { width: '25%' }, }, { name: 'isMember', type: 'checkbox', label: 'Channel Member', admin: { width: '25%' }, }, { name: 'subscriberCount', type: 'number', label: 'Ihre Subscriber', admin: { width: '25%' }, }, ], }, ], }, // === MESSAGE CONTENT === { name: 'message', type: 'textarea', label: 'Nachricht', required: true, admin: { rows: 4, }, }, { name: 'messageHtml', type: 'textarea', label: 'Original HTML', admin: { rows: 2, description: 'Falls Plattform HTML liefert', }, }, { name: 'attachments', type: 'array', label: 'Attachments', fields: [ { type: 'row', fields: [ { name: 'type', type: 'select', label: 'Typ', options: [ { label: 'Bild', value: 'image' }, { label: 'Video', value: 'video' }, { label: 'Link', value: 'link' }, { label: 'Sticker', value: 'sticker' }, ], admin: { width: '30%' }, }, { name: 'url', type: 'text', label: 'URL', admin: { width: '70%' }, }, ], }, ], }, { name: 'publishedAt', type: 'date', label: 'Veröffentlicht am', required: true, admin: { date: { pickerAppearance: 'dayAndTime', }, }, }, // === AI ANALYSIS === { name: 'analysis', type: 'group', label: 'AI Analyse', admin: { description: 'Automatisch via Claude API', }, fields: [ { type: 'row', fields: [ { name: 'sentiment', type: 'select', label: 'Sentiment', options: [ { label: 'Positiv', value: 'positive' }, { label: 'Neutral', value: 'neutral' }, { label: 'Negativ', value: 'negative' }, { label: 'Frage', value: 'question' }, { label: 'Dankbarkeit', value: 'gratitude' }, { label: 'Frustration', value: 'frustration' }, ], admin: { width: '33%' }, }, { name: 'sentimentScore', type: 'number', label: 'Score (-1 bis 1)', min: -1, max: 1, admin: { width: '33%' }, }, { name: 'confidence', type: 'number', label: 'Confidence %', min: 0, max: 100, admin: { width: '33%' }, }, ], }, { name: 'topics', type: 'array', label: 'Erkannte Themen', fields: [ { name: 'topic', type: 'text', label: 'Thema', }, ], }, { name: 'language', type: 'text', label: 'Sprache', admin: { placeholder: 'de', }, }, { name: 'suggestedTemplate', type: 'relationship', relationTo: 'community-templates', label: 'Vorgeschlagenes Template', }, { name: 'suggestedReply', type: 'textarea', label: 'AI-generierter Antwortvorschlag', }, { name: 'analyzedAt', type: 'date', label: 'Analysiert am', }, ], }, // === FLAGS === { name: 'flags', type: 'group', label: 'Flags', fields: [ { type: 'row', fields: [ { name: 'isMedicalQuestion', type: 'checkbox', label: 'Medizinische Frage', admin: { width: '25%', description: 'Erfordert ärztliche Review', }, }, { name: 'requiresEscalation', type: 'checkbox', label: 'Eskalation nötig', admin: { width: '25%' }, }, { name: 'isSpam', type: 'checkbox', label: 'Spam', admin: { width: '25%' }, }, { name: 'isFromInfluencer', type: 'checkbox', label: 'Influencer', admin: { width: '25%', description: '>10k Follower', }, }, ], }, ], }, // === WORKFLOW === { name: 'status', type: 'select', required: true, defaultValue: 'new', index: true, label: 'Status', options: [ { label: 'Neu', value: 'new' }, { label: 'In Review', value: 'in_review' }, { label: 'Warten auf Info', value: 'waiting' }, { label: 'Beantwortet', value: 'replied' }, { label: 'Erledigt', value: 'resolved' }, { label: 'Archiviert', value: 'archived' }, { label: 'Spam', value: 'spam' }, ], admin: { position: 'sidebar', }, }, { name: 'priority', type: 'select', required: true, defaultValue: 'normal', index: true, label: 'Priorität', options: [ { label: 'Urgent', value: 'urgent' }, { label: 'Hoch', value: 'high' }, { label: 'Normal', value: 'normal' }, { label: 'Niedrig', value: 'low' }, ], admin: { position: 'sidebar', }, }, { name: 'assignedTo', type: 'relationship', relationTo: 'users', label: 'Zugewiesen an', admin: { position: 'sidebar', }, }, { name: 'responseDeadline', type: 'date', label: 'Antwort-Deadline', admin: { position: 'sidebar', date: { pickerAppearance: 'dayAndTime', }, }, }, // === OUR RESPONSE === { name: 'response', type: 'group', label: 'Unsere Antwort', fields: [ { name: 'text', type: 'textarea', label: 'Antwort-Text', admin: { rows: 4 }, }, { name: 'usedTemplate', type: 'relationship', relationTo: 'community-templates', label: 'Verwendetes Template', }, { name: 'sentAt', type: 'date', label: 'Gesendet am', }, { name: 'sentBy', type: 'relationship', relationTo: 'users', label: 'Gesendet von', }, { name: 'externalReplyId', type: 'text', label: 'Reply ID (extern)', }, ], }, // === ENGAGEMENT (Platform-spezifisch) === { name: 'engagement', type: 'group', label: 'Engagement', admin: { description: 'Wird beim Sync aktualisiert', }, fields: [ { type: 'row', fields: [ { name: 'likes', type: 'number', label: 'Likes', admin: { width: '25%' }, }, { name: 'replies', type: 'number', label: 'Replies', admin: { width: '25%' }, }, { name: 'isHearted', type: 'checkbox', label: 'Creator Heart', admin: { width: '25%' }, }, { name: 'isPinned', type: 'checkbox', label: 'Angepinnt', admin: { width: '25%' }, }, ], }, ], }, // === INTERNAL NOTES === { name: 'internalNotes', type: 'textarea', label: 'Interne Notizen', admin: { rows: 2, description: 'Nur für Team sichtbar', }, }, ], // === HOOKS === hooks: { beforeChange: [ // Auto-set priority based on flags async ({ data, operation }) => { if (!data) return data if (operation === 'create' || !data.priority) { if (data?.flags?.isMedicalQuestion) { data.priority = 'high' } if (data?.flags?.requiresEscalation) { data.priority = 'urgent' } if (data?.flags?.isFromInfluencer) { data.priority = data.priority === 'urgent' ? 'urgent' : 'high' } } return data }, ], afterChange: [ // Send notification for urgent items async ({ doc, operation }) => { if (operation === 'create' && doc.priority === 'urgent') { // TODO: Notification Logic console.log(`🚨 Urgent interaction: ${doc.id}`) } }, ], }, timestamps: true, } ``` ### 2.4 Community Templates (`community-templates`) ```typescript // src/collections/CommunityTemplates.ts import type { CollectionConfig } from 'payload' import { isCommunityManager, isCommunityModeratorOrAbove, hasCommunityAccess } from '../lib/communityAccess' export const CommunityTemplates: CollectionConfig = { slug: 'community-templates', labels: { singular: 'Response Template', plural: 'Response Templates', }, admin: { group: 'Community', useAsTitle: 'name', defaultColumns: ['name', 'category', 'channel', 'usageCount'], }, access: { read: hasCommunityAccess, create: isCommunityModeratorOrAbove, update: isCommunityModeratorOrAbove, delete: isCommunityManager, }, fields: [ { type: 'row', fields: [ { name: 'name', type: 'text', required: true, localized: true, label: 'Name', admin: { width: '50%' }, }, { name: 'category', type: 'select', required: true, label: 'Kategorie', options: [ { label: 'Danke', value: 'thank_you' }, { label: 'Frage beantworten', value: 'question_answer' }, { label: 'Hotline-Verweis', value: 'redirect_hotline' }, { label: 'Medizinischer Disclaimer', value: 'medical_disclaimer' }, { label: 'Produkt-Info', value: 'product_info' }, { label: 'Content-Verweis', value: 'content_reference' }, { label: 'Follow-up', value: 'follow_up' }, { label: 'Negatives Feedback', value: 'negative_feedback' }, { label: 'Spam-Antwort', value: 'spam_response' }, { label: 'Begrüßung', value: 'welcome' }, ], admin: { width: '50%' }, }, ], }, { type: 'row', fields: [ { name: 'channel', type: 'relationship', relationTo: 'youtube-channels', label: 'Kanal (optional)', admin: { width: '50%', description: 'Leer = für alle Kanäle', }, }, { name: 'platforms', type: 'relationship', relationTo: 'social-platforms', hasMany: true, label: 'Plattformen', admin: { width: '50%', description: 'Leer = für alle Plattformen', }, }, ], }, // Template Text mit Variablen { name: 'template', type: 'textarea', required: true, localized: true, label: 'Template Text', admin: { rows: 6, description: 'Variablen: {{author_name}}, {{video_title}}, {{channel_name}}, {{hotline_number}}', }, }, // Verfügbare Variablen { name: 'variables', type: 'array', label: 'Verfügbare Variablen', admin: { description: 'Dokumentation der Variablen in diesem Template', }, fields: [ { type: 'row', fields: [ { name: 'variable', type: 'text', required: true, label: 'Variable', admin: { width: '30%', placeholder: '{{author_name}}', }, }, { name: 'description', type: 'text', label: 'Beschreibung', admin: { width: '50%', placeholder: 'Name des Kommentar-Autors', }, }, { name: 'defaultValue', type: 'text', label: 'Fallback', admin: { width: '20%', }, }, ], }, ], }, // Auto-Suggest Keywords { name: 'autoSuggestKeywords', type: 'array', label: 'Auto-Suggest Keywords', admin: { description: 'Bei diesen Keywords wird das Template vorgeschlagen', }, fields: [ { name: 'keyword', type: 'text', required: true, label: 'Keyword', }, ], }, // Flags { type: 'row', fields: [ { name: 'requiresReview', type: 'checkbox', label: 'Review erforderlich', admin: { width: '33%', description: 'Für medizinische Antworten', }, }, { name: 'isActive', type: 'checkbox', label: 'Aktiv', defaultValue: true, admin: { width: '33%' }, }, { name: 'usageCount', type: 'number', label: 'Verwendungen', defaultValue: 0, admin: { width: '33%', readOnly: true, }, }, ], }, // Beispiel-Output { name: 'exampleOutput', type: 'textarea', label: 'Beispiel-Output', admin: { rows: 3, description: 'So sieht die Antwort mit ausgefüllten Variablen aus', }, }, ], timestamps: true, } ``` ### 2.5 Community Rules (`community-rules`) ```typescript // src/collections/CommunityRules.ts import type { CollectionConfig } from 'payload' import { isCommunityManager, hasCommunityAccess } from '../lib/communityAccess' export const CommunityRules: CollectionConfig = { slug: 'community-rules', labels: { singular: 'Community Rule', plural: 'Community Rules', }, admin: { group: 'Community', useAsTitle: 'name', defaultColumns: ['name', 'trigger.type', 'isActive', 'priority'], }, access: { read: hasCommunityAccess, create: isCommunityManager, update: isCommunityManager, delete: isCommunityManager, }, fields: [ { type: 'row', fields: [ { name: 'name', type: 'text', required: true, label: 'Name', admin: { width: '50%' }, }, { name: 'priority', type: 'number', required: true, defaultValue: 100, label: 'Priorität', admin: { width: '25%', description: 'Niedrigere Zahl = höhere Priorität', }, }, { name: 'isActive', type: 'checkbox', label: 'Aktiv', defaultValue: true, admin: { width: '25%' }, }, ], }, { name: 'description', type: 'textarea', label: 'Beschreibung', admin: { rows: 2 }, }, // Scope { type: 'row', fields: [ { name: 'channel', type: 'relationship', relationTo: 'youtube-channels', label: 'Kanal (optional)', admin: { width: '50%', description: 'Leer = alle Kanäle', }, }, { name: 'platforms', type: 'relationship', relationTo: 'social-platforms', hasMany: true, label: 'Plattformen', admin: { width: '50%', description: 'Leer = alle Plattformen', }, }, ], }, // Trigger { name: 'trigger', type: 'group', label: 'Trigger', fields: [ { name: 'type', type: 'select', required: true, label: 'Trigger-Typ', options: [ { label: 'Keyword Match', value: 'keyword' }, { label: 'Sentiment', value: 'sentiment' }, { label: 'Frage erkannt', value: 'question_detected' }, { label: 'Medizinisch erkannt', value: 'medical_detected' }, { label: 'Influencer', value: 'influencer' }, { label: 'Alle neuen', value: 'all_new' }, { label: 'Enthält Link', value: 'contains_link' }, { label: 'Enthält Email', value: 'contains_email' }, ], }, { name: 'keywords', type: 'array', label: 'Keywords', admin: { condition: (data, siblingData) => siblingData?.type === 'keyword', }, fields: [ { name: 'keyword', type: 'text', required: true, label: 'Keyword', }, { name: 'matchType', type: 'select', label: 'Match-Typ', options: [ { label: 'Enthält', value: 'contains' }, { label: 'Exakt', value: 'exact' }, { label: 'Regex', value: 'regex' }, ], defaultValue: 'contains', }, ], }, { name: 'sentimentValues', type: 'select', hasMany: true, label: 'Sentiment-Werte', options: [ { label: 'Positiv', value: 'positive' }, { label: 'Negativ', value: 'negative' }, { label: 'Neutral', value: 'neutral' }, { label: 'Frage', value: 'question' }, ], admin: { condition: (data, siblingData) => siblingData?.type === 'sentiment', }, }, { name: 'influencerMinFollowers', type: 'number', label: 'Min. Follower', defaultValue: 10000, admin: { condition: (data, siblingData) => siblingData?.type === 'influencer', }, }, ], }, // Actions { name: 'actions', type: 'array', label: 'Aktionen', required: true, minRows: 1, fields: [ { type: 'row', fields: [ { name: 'action', type: 'select', required: true, label: 'Aktion', options: [ { label: 'Priorität setzen', value: 'set_priority' }, { label: 'Zuweisen', value: 'assign_to' }, { label: 'Flag setzen', value: 'set_flag' }, { label: 'Template vorschlagen', value: 'suggest_template' }, { label: 'Notification senden', value: 'send_notification' }, { label: 'Medical Flag', value: 'flag_medical' }, { label: 'Eskalieren', value: 'escalate' }, { label: 'Als Spam markieren', value: 'mark_spam' }, { label: 'Deadline setzen', value: 'set_deadline' }, ], admin: { width: '40%' }, }, { name: 'value', type: 'text', label: 'Wert', admin: { width: '40%', description: 'Priority: urgent/high/normal/low, Deadline: Stunden', }, }, { name: 'targetUser', type: 'relationship', relationTo: 'users', label: 'User', admin: { width: '20%', condition: (data, siblingData) => ['assign_to', 'send_notification'].includes(siblingData?.action || ''), }, }, ], }, { name: 'targetTemplate', type: 'relationship', relationTo: 'community-templates', label: 'Template', admin: { condition: (data, siblingData) => siblingData?.action === 'suggest_template', }, }, ], }, // Stats { name: 'stats', type: 'group', label: 'Statistiken', fields: [ { type: 'row', fields: [ { name: 'timesTriggered', type: 'number', label: 'Ausgelöst', defaultValue: 0, admin: { width: '50%', readOnly: true }, }, { name: 'lastTriggeredAt', type: 'date', label: 'Zuletzt ausgelöst', admin: { width: '50%', readOnly: true }, }, ], }, ], }, ], timestamps: true, } ``` --- ## Teil 3: Users Collection erweitern ### 3.1 Community-Rolle zu Users hinzufügen ```typescript // Ergänzung in src/collections/Users.ts (zu den bestehenden YouTube-Feldern) { name: 'communityRole', type: 'select', label: 'Community-Rolle', defaultValue: 'none', options: [ { label: 'Keine', value: 'none' }, { label: 'Viewer', value: 'viewer' }, { label: 'Moderator', value: 'moderator' }, { label: 'Manager', value: 'manager' }, ], admin: { position: 'sidebar', description: 'Zugriff auf Community Management Features', }, }, ``` --- ## Teil 4: YouTube API Integration ### 4.1 YouTube API Client ```typescript // src/lib/integrations/youtube/YouTubeClient.ts import { google, youtube_v3 } from 'googleapis' import type { Payload } from 'payload' interface YouTubeCredentials { clientId: string clientSecret: string accessToken: string refreshToken: string } interface CommentThread { id: string snippet: { videoId: string topLevelComment: { id: string snippet: { textDisplay: string textOriginal: string authorDisplayName: string authorProfileImageUrl: string authorChannelUrl: string authorChannelId: { value: string } likeCount: number publishedAt: string updatedAt: string } } totalReplyCount: number } } export class YouTubeClient { private youtube: youtube_v3.Youtube private oauth2Client: any private payload: Payload constructor(credentials: YouTubeCredentials, payload: Payload) { this.payload = payload this.oauth2Client = new google.auth.OAuth2( credentials.clientId, credentials.clientSecret, process.env.YOUTUBE_REDIRECT_URI ) this.oauth2Client.setCredentials({ access_token: credentials.accessToken, refresh_token: credentials.refreshToken, }) this.youtube = google.youtube({ version: 'v3', auth: this.oauth2Client, }) } /** * Alle Kommentar-Threads eines Videos abrufen */ async getVideoComments( videoId: string, pageToken?: string, maxResults: number = 100 ): Promise<{ comments: CommentThread[] nextPageToken?: string }> { try { const response = await this.youtube.commentThreads.list({ part: ['snippet', 'replies'], videoId: videoId, maxResults: maxResults, pageToken: pageToken, order: 'time', textFormat: 'plainText', }) return { comments: response.data.items as CommentThread[], nextPageToken: response.data.nextPageToken || undefined, } } catch (error) { console.error('Error fetching comments:', error) throw error } } /** * Alle Kommentare eines Kanals abrufen (alle Videos) */ async getChannelComments( channelId: string, publishedAfter?: Date, maxResults: number = 100 ): Promise { try { const params: any = { part: ['snippet', 'replies'], allThreadsRelatedToChannelId: channelId, maxResults: maxResults, order: 'time', textFormat: 'plainText', } if (publishedAfter) { params.publishedAfter = publishedAfter.toISOString() } const response = await this.youtube.commentThreads.list(params) return response.data.items as CommentThread[] } catch (error) { console.error('Error fetching channel comments:', error) throw error } } /** * Auf einen Kommentar antworten */ async replyToComment(parentCommentId: string, text: string): Promise { try { const response = await this.youtube.comments.insert({ part: ['snippet'], requestBody: { snippet: { parentId: parentCommentId, textOriginal: text, }, }, }) return response.data.id! } catch (error) { console.error('Error replying to comment:', error) throw error } } /** * Kommentar als Spam markieren */ async markAsSpam(commentId: string): Promise { try { await this.youtube.comments.setModerationStatus({ id: [commentId], moderationStatus: 'rejected', }) } catch (error) { console.error('Error marking as spam:', error) throw error } } /** * Kommentar löschen */ async deleteComment(commentId: string): Promise { try { await this.youtube.comments.delete({ id: commentId, }) } catch (error) { console.error('Error deleting comment:', error) throw error } } /** * Access Token erneuern */ async refreshAccessToken(): Promise<{ accessToken: string expiresAt: Date }> { try { const { credentials } = await this.oauth2Client.refreshAccessToken() return { accessToken: credentials.access_token!, expiresAt: new Date(credentials.expiry_date!), } } catch (error) { console.error('Error refreshing token:', error) throw error } } } ``` ### 4.2 Claude Analysis Service ```typescript // src/lib/integrations/claude/ClaudeAnalysisService.ts import Anthropic from '@anthropic-ai/sdk' interface CommentAnalysis { sentiment: 'positive' | 'neutral' | 'negative' | 'question' | 'gratitude' | 'frustration' sentimentScore: number confidence: number topics: string[] language: string isMedicalQuestion: boolean requiresEscalation: boolean isSpam: boolean suggestedReply?: string } export class ClaudeAnalysisService { private client: Anthropic constructor() { this.client = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY, }) } /** * Kommentar analysieren */ async analyzeComment(message: string): Promise { const systemPrompt = `Du bist ein Analyse-Assistent für YouTube-Kommentare eines deutschen Healthcare-Unternehmens (Medizinische Zweitmeinung) und eines Lifestyle-Kanals. Analysiere den Kommentar und gib ein JSON-Objekt zurück mit: - sentiment: "positive", "neutral", "negative", "question", "gratitude", "frustration" - sentimentScore: Zahl von -1 (sehr negativ) bis 1 (sehr positiv) - confidence: Konfidenz der Analyse 0-100 - topics: Array von erkannten Themen (max 3) - language: ISO-639-1 Sprachcode (z.B. "de", "en") - isMedicalQuestion: true wenn es um medizinische Fragen/Gesundheit geht - requiresEscalation: true wenn dringend/kritisch/negativ oder Beschwerden - isSpam: true wenn Spam/Werbung/Bot - suggestedReply: Kurzer Antwortvorschlag auf Deutsch (optional, nur wenn sinnvoll) WICHTIG für isMedicalQuestion: - Fragen zu Intensivmedizin, Diagnosen, Behandlungen, Medikamenten = true - Fragen zu Angehörigen von Patienten = true - Allgemeine Lifestyle-Fragen (Mode, Zeitmanagement) = false Antworte NUR mit dem JSON-Objekt, kein anderer Text.` try { const response = await this.client.messages.create({ model: 'claude-3-haiku-20240307', max_tokens: 500, system: systemPrompt, messages: [ { role: 'user', content: `Analysiere diesen Kommentar:\n\n"${message}"`, }, ], }) const content = response.content[0] if (content.type !== 'text') { throw new Error('Unexpected response type') } const analysis = JSON.parse(content.text) as CommentAnalysis return analysis } catch (error) { console.error('Claude analysis error:', error) // Fallback bei Fehler return { sentiment: 'neutral', sentimentScore: 0, confidence: 0, topics: [], language: 'de', isMedicalQuestion: false, requiresEscalation: false, isSpam: false, } } } /** * Antwort-Vorschlag generieren */ async generateReply( comment: string, context: { videoTitle: string channelName: string isBusinessChannel: boolean template?: string } ): Promise { const systemPrompt = `Du bist ein Community-Manager für ${context.channelName}. ${context.isBusinessChannel ? 'Dies ist ein Healthcare-Kanal für medizinische Zweitmeinungen. Antworten müssen professionell sein und dürfen keine medizinischen Ratschläge geben. Bei medizinischen Fragen immer auf die Hotline verweisen.' : 'Dies ist ein Lifestyle-Kanal für berufstätige Mütter. Antworten sollten warm, persönlich und hilfreich sein.' } Erstelle eine passende, kurze Antwort auf den Kommentar. - Maximal 2-3 Sätze - Persönlich und authentisch - Auf Deutsch ${context.template ? `\nVerwende dieses Template als Basis:\n${context.template}` : ''} Antworte NUR mit dem Antworttext, kein anderer Text.` try { const response = await this.client.messages.create({ model: 'claude-3-haiku-20240307', max_tokens: 200, system: systemPrompt, messages: [ { role: 'user', content: `Video: "${context.videoTitle}"\n\nKommentar: "${comment}"\n\nErstelle eine Antwort:`, }, ], }) const content = response.content[0] if (content.type !== 'text') { throw new Error('Unexpected response type') } return content.text.trim() } catch (error) { console.error('Claude reply generation error:', error) throw error } } } ``` --- ## Teil 5: API Endpoints ### 5.1 Comments Sync Endpoint ```typescript // src/app/(payload)/api/community/sync-comments/route.ts import { NextRequest, NextResponse } from 'next/server' import { getPayload } from 'payload' import config from '@payload-config' import { CommentsSyncService } from '@/lib/integrations/youtube/CommentsSyncService' export async function POST(req: NextRequest) { try { const payload = await getPayload({ config }) const body = await req.json() const { socialAccountId, sinceDate, maxComments, analyzeWithAI } = body if (!socialAccountId) { return NextResponse.json( { error: 'socialAccountId required' }, { status: 400 } ) } const syncService = new CommentsSyncService(payload) const result = await syncService.syncComments({ socialAccountId, sinceDate: sinceDate ? new Date(sinceDate) : undefined, maxComments: maxComments || 100, analyzeWithAI: analyzeWithAI ?? true, }) return NextResponse.json(result) } catch (error: any) { console.error('Sync error:', error) return NextResponse.json( { error: error.message }, { status: 500 } ) } } ``` ### 5.2 Reply Endpoint ```typescript // src/app/(payload)/api/community/reply/route.ts import { NextRequest, NextResponse } from 'next/server' import { getPayload } from 'payload' import config from '@payload-config' import { YouTubeClient } from '@/lib/integrations/youtube/YouTubeClient' export async function POST(req: NextRequest) { try { const payload = await getPayload({ config }) const body = await req.json() const { interactionId, replyText, templateId } = body if (!interactionId || !replyText) { return NextResponse.json( { error: 'interactionId and replyText required' }, { status: 400 } ) } // 1. Interaction laden const interaction = await payload.findByID({ collection: 'community-interactions', id: interactionId, depth: 2, }) if (!interaction) { return NextResponse.json( { error: 'Interaction not found' }, { status: 404 } ) } // 2. YouTube Client initialisieren const account = interaction.socialAccount as any const youtubeClient = new YouTubeClient({ clientId: process.env.YOUTUBE_CLIENT_ID!, clientSecret: process.env.YOUTUBE_CLIENT_SECRET!, accessToken: account.credentials.accessToken, refreshToken: account.credentials.refreshToken, }, payload) // 3. Reply senden const replyId = await youtubeClient.replyToComment( interaction.externalId, replyText ) // 4. Interaction aktualisieren await payload.update({ collection: 'community-interactions', id: interactionId, data: { status: 'replied', response: { text: replyText, usedTemplate: templateId || null, sentAt: new Date().toISOString(), externalReplyId: replyId, }, }, }) // 5. Template Usage Counter erhöhen if (templateId) { const template = await payload.findByID({ collection: 'community-templates', id: templateId, }) await payload.update({ collection: 'community-templates', id: templateId, data: { usageCount: ((template as any).usageCount || 0) + 1, }, }) } return NextResponse.json({ success: true, replyId, }) } catch (error: any) { console.error('Reply error:', error) return NextResponse.json( { error: error.message }, { status: 500 } ) } } ``` --- ## Teil 6: Environment Variables ```bash # .env - Neue Variablen für Community Phase 1 # YouTube API (Google Cloud Console) YOUTUBE_CLIENT_ID=your-client-id.apps.googleusercontent.com YOUTUBE_CLIENT_SECRET=your-client-secret YOUTUBE_REDIRECT_URI=https://pl.porwoll.tech/api/youtube/callback # Claude API (Anthropic) - bereits vorhanden für andere Features ANTHROPIC_API_KEY=sk-ant-api03-... # Optional: Für spätere Phasen vorbereitet LINKEDIN_CLIENT_ID= LINKEDIN_CLIENT_SECRET= INSTAGRAM_ACCESS_TOKEN= FACEBOOK_ACCESS_TOKEN= ``` --- ## Teil 7: payload.config.ts Ergänzungen ```typescript // payload.config.ts - Ergänzungen für Community Phase 1 import { SocialPlatforms } from './collections/SocialPlatforms' import { SocialAccounts } from './collections/SocialAccounts' import { CommunityInteractions } from './collections/CommunityInteractions' import { CommunityTemplates } from './collections/CommunityTemplates' import { CommunityRules } from './collections/CommunityRules' export default buildConfig({ // ... collections: [ // Existing YouTube Collections YouTubeChannels, YouTubeContent, YtSeries, YtTasks, YtNotifications, YtBatches, YtMonthlyGoals, YtScriptTemplates, YtChecklistTemplates, // NEW: Community Collections SocialPlatforms, SocialAccounts, CommunityInteractions, CommunityTemplates, CommunityRules, ], // ... }) ``` --- ## Teil 8: Migration Die Migration muss folgende Tabellen erstellen: - `social_platforms` + `social_platforms_interaction_types` + `social_platforms_api_config_scopes` - `social_accounts` - `community_interactions` + `community_interactions_attachments` + `community_interactions_analysis_topics` - `community_templates` + `community_templates_locales` + `community_templates_variables` + `community_templates_auto_suggest_keywords` - `community_rules` + `community_rules_trigger_keywords` + `community_rules_actions` - `payload_locked_documents_rels` erweitern um alle neuen Collection-IDs **WICHTIG:** Siehe `CLAUDE.md` Abschnitt "KRITISCH: Neue Collections hinzufügen" für das korrekte Migrations-Muster. --- ## Zusammenfassung ### Neue Collections (5) 1. `social-platforms` – Plattform-Definitionen 2. `social-accounts` – Account-Verknüpfungen 3. `community-interactions` – Alle Kommentare/DMs 4. `community-templates` – Antwort-Vorlagen 5. `community-rules` – Auto-Regeln ### Neue Lib-Dateien (3) 1. `src/lib/communityAccess.ts` – Access Control 2. `src/lib/integrations/youtube/YouTubeClient.ts` – YouTube API 3. `src/lib/integrations/claude/ClaudeAnalysisService.ts` – AI Analyse ### API Endpoints (2) 1. `POST /api/community/sync-comments` – Manueller Sync 2. `POST /api/community/reply` – Antwort senden ### User-Feld (1) 1. `communityRole` in Users Collection ### Abhängigkeiten zu bestehendem Code - `youtube-channels` Collection (linkedChannel Referenz) - `youtube-content` Collection (linkedContent Referenz) - `users` Collection (assignedTo, sentBy Referenzen) - Bestehende YouTube-Access-Rollen werden berücksichtigt