# Community Management Phase 2.2 – YouTube Auto-Sync & AI Reply Suggestions ## Projekt-Kontext Du arbeitest am Community Management System für Complex Care Solutions (CCS). **Abgeschlossene Phasen:** - ✅ Phase 1: Community Inbox, YouTube OAuth, Rules Engine, Export, Claude Sentiment-Analyse - ✅ Phase 2.1: Analytics Dashboard mit 6 Komponenten **Deine Aufgabe:** Implementiere automatischen YouTube-Kommentar-Sync und intelligente AI-Antwortvorschläge. --- ## Tech Stack | Technologie | Version | |-------------|---------| | Payload CMS | 3.x | | Next.js | 15.x (App Router) | | React | 19.x | | TypeScript | 5.x | | Datenbank | PostgreSQL 17 | | AI Service | **Claude (Anthropic)** – NICHT OpenAI | | YouTube API | googleapis v140+ | --- ## Bestehende Infrastruktur ### YouTube OAuth (bereits implementiert) ```typescript // src/lib/integrations/youtube/oauth.ts // - getAuthUrl(): string // - exchangeCodeForTokens(code: string): Promise // - refreshAccessToken(refreshToken: string): Promise // src/lib/integrations/youtube/YouTubeClient.ts // - getComments(videoId: string): Promise // - replyToComment(commentId: string, text: string): Promise ``` ### Claude Integration (bereits implementiert) ```typescript // src/lib/integrations/claude/ClaudeAnalysisService.ts // - analyzeSentiment(message: string): Promise // - extractTopics(message: string): Promise // - detectMedicalContent(message: string): Promise ``` ### Collections | Collection | Slug | Relevante Felder | |------------|------|------------------| | SocialAccounts | social-accounts | `platform`, `channelId`, `accessToken`, `refreshToken`, `tokenExpiresAt`, `isActive`, `lastSyncAt` | | CommunityInteractions | community-interactions | `externalId`, `platform`, `socialAccount`, `linkedContent`, `analysis`, `status` | | YouTubeContent | youtube-content | `videoId`, `title`, `publishedAt`, `channelId` | --- ## Teil 1: YouTube Auto-Sync ### 1.1 Architektur ``` ┌─────────────────────────────────────────────────────────────────┐ │ SYNC ARCHITECTURE │ ├─────────────────────────────────────────────────────────────────┤ │ │ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ │ │ Cron Job │────▶│ Sync │────▶│ YouTube │ │ │ │ (15 min) │ │ Service │ │ API │ │ │ └─────────────┘ └──────┬──────┘ └─────────────┘ │ │ │ │ │ ▼ │ │ ┌─────────────┐ │ │ │ Claude │ │ │ │ Analysis │ │ │ └──────┬──────┘ │ │ │ │ │ ▼ │ │ ┌─────────────┐ │ │ │ Database │ │ │ │ (Payload) │ │ │ └─────────────┘ │ │ │ └─────────────────────────────────────────────────────────────────┘ ``` ### 1.2 Dateistruktur erstellen ``` src/ ├── lib/ │ ├── integrations/ │ │ ├── youtube/ │ │ │ ├── oauth.ts # Bestehend │ │ │ ├── YouTubeClient.ts # Erweitern │ │ │ └── CommentsSyncService.ts # NEU │ │ └── claude/ │ │ ├── ClaudeAnalysisService.ts # Bestehend │ │ └── ClaudeReplyService.ts # NEU │ └── jobs/ │ ├── syncAllComments.ts # NEU - Cron Entry Point │ └── JobLogger.ts # NEU - Logging ├── app/(payload)/api/ │ ├── community/ │ │ └── sync/ │ │ ├── route.ts # Manueller Sync Trigger │ │ └── status/route.ts # Sync Status │ └── cron/ │ └── youtube-sync/route.ts # Cron Endpoint (Vercel/externe Cron) └── types/ └── youtube.ts # Type Definitions ``` ### 1.3 Type Definitions ```typescript // src/types/youtube.ts export interface YouTubeComment { id: string snippet: { videoId: string topLevelComment: { id: string snippet: { textDisplay: string textOriginal: string authorDisplayName: string authorProfileImageUrl: string authorChannelId: { value: string } likeCount: number publishedAt: string updatedAt: string } } totalReplyCount: number canReply: boolean } } export interface YouTubeCommentThread { kind: string etag: string id: string snippet: YouTubeComment['snippet'] replies?: { comments: Array<{ id: string snippet: { textDisplay: string textOriginal: string authorDisplayName: string authorProfileImageUrl: string authorChannelId: { value: string } parentId: string likeCount: number publishedAt: string } }> } } export interface SyncResult { accountId: number accountName: string videosProcessed: number newComments: number updatedComments: number errors: string[] duration: number } export interface SyncJobStatus { isRunning: boolean lastRunAt: string | null lastResult: SyncResult[] | null nextRunAt: string | null } ``` ### 1.4 YouTubeClient erweitern ```typescript // src/lib/integrations/youtube/YouTubeClient.ts import { google, youtube_v3 } from 'googleapis' import { getPayload } from 'payload' import config from '@payload-config' import type { YouTubeCommentThread } from '@/types/youtube' export class YouTubeClient { private youtube: youtube_v3.Youtube private accountId: number constructor(accessToken: string, accountId: number) { const auth = new google.auth.OAuth2() auth.setCredentials({ access_token: accessToken }) this.youtube = google.youtube({ version: 'v3', auth }) this.accountId = accountId } /** * Hole alle Kommentare für einen Kanal (über alle Videos) */ async getChannelComments( channelId: string, options: { maxResults?: number publishedAfter?: Date pageToken?: string } = {} ): Promise<{ comments: YouTubeCommentThread[] nextPageToken?: string }> { const { maxResults = 100, publishedAfter, pageToken } = options try { const response = await this.youtube.commentThreads.list({ part: ['snippet', 'replies'], allThreadsRelatedToChannelId: channelId, maxResults, pageToken, order: 'time', // Wenn publishedAfter gesetzt, nur neuere Kommentare ...(publishedAfter && { searchTerms: '', // Workaround: API hat keinen direkten Filter }), }) const comments = (response.data.items || []) as YouTubeCommentThread[] // Manuell nach Datum filtern wenn nötig const filteredComments = publishedAfter ? comments.filter(c => new Date(c.snippet.topLevelComment.snippet.publishedAt) > publishedAfter ) : comments return { comments: filteredComments, nextPageToken: response.data.nextPageToken || undefined, } } catch (error: any) { console.error('YouTube API Error:', error.message) throw new Error(`Failed to fetch comments: ${error.message}`) } } /** * Hole Kommentare für ein spezifisches Video */ async getVideoComments( videoId: string, options: { maxResults?: number pageToken?: string } = {} ): Promise<{ comments: YouTubeCommentThread[] nextPageToken?: string }> { const { maxResults = 100, pageToken } = options try { const response = await this.youtube.commentThreads.list({ part: ['snippet', 'replies'], videoId, maxResults, pageToken, order: 'time', }) return { comments: (response.data.items || []) as YouTubeCommentThread[], nextPageToken: response.data.nextPageToken || undefined, } } catch (error: any) { // Video hat möglicherweise Kommentare deaktiviert if (error.code === 403) { console.warn(`Comments disabled for video ${videoId}`) return { comments: [] } } throw error } } /** * Antwort auf einen Kommentar posten */ async replyToComment(parentId: string, text: string): Promise { try { const response = await this.youtube.comments.insert({ part: ['snippet'], requestBody: { snippet: { parentId, textOriginal: text, }, }, }) return response.data.id || '' } catch (error: any) { console.error('Failed to post reply:', error.message) throw new Error(`Failed to post reply: ${error.message}`) } } /** * Prüfe ob Token noch gültig, sonst refreshen */ static async getValidClient(accountId: number): Promise { const payload = await getPayload({ config }) const account = await payload.findByID({ collection: 'social-accounts', id: accountId, }) if (!account || !account.accessToken) { return null } // Token abgelaufen? const now = new Date() const expiresAt = account.tokenExpiresAt ? new Date(account.tokenExpiresAt) : null if (expiresAt && expiresAt <= now) { // Token refreshen if (!account.refreshToken) { console.error(`No refresh token for account ${accountId}`) return null } try { const { refreshAccessToken } = await import('./oauth') const newTokens = await refreshAccessToken(account.refreshToken) // Update in DB await payload.update({ collection: 'social-accounts', id: accountId, data: { accessToken: newTokens.access_token, tokenExpiresAt: new Date(Date.now() + (newTokens.expires_in || 3600) * 1000).toISOString(), }, }) return new YouTubeClient(newTokens.access_token, accountId) } catch (error) { console.error(`Failed to refresh token for account ${accountId}:`, error) return null } } return new YouTubeClient(account.accessToken, accountId) } } ``` ### 1.5 CommentsSyncService ```typescript // src/lib/integrations/youtube/CommentsSyncService.ts import { getPayload } from 'payload' import config from '@payload-config' import { YouTubeClient } from './YouTubeClient' import { ClaudeAnalysisService } from '../claude/ClaudeAnalysisService' import type { YouTubeCommentThread, SyncResult } from '@/types/youtube' export class CommentsSyncService { private payload: Awaited> private claude: ClaudeAnalysisService constructor() { this.claude = new ClaudeAnalysisService() } private async getPayload() { if (!this.payload) { this.payload = await getPayload({ config }) } return this.payload } /** * Sync alle aktiven YouTube-Accounts */ async syncAllAccounts(): Promise { const payload = await this.getPayload() const results: SyncResult[] = [] // Finde alle aktiven YouTube-Accounts const accounts = await payload.find({ collection: 'social-accounts', where: { and: [ { isActive: { equals: true } }, { 'platform.slug': { equals: 'youtube' } }, ], }, limit: 100, }) console.log(`[Sync] Found ${accounts.docs.length} active YouTube accounts`) for (const account of accounts.docs) { const startTime = Date.now() const result: SyncResult = { accountId: account.id, accountName: account.displayName || `Account ${account.id}`, videosProcessed: 0, newComments: 0, updatedComments: 0, errors: [], duration: 0, } try { const client = await YouTubeClient.getValidClient(account.id) if (!client) { result.errors.push('Could not get valid YouTube client') results.push(result) continue } // Hole letzte Sync-Zeit const lastSyncAt = account.lastSyncAt ? new Date(account.lastSyncAt) : new Date(Date.now() - 24 * 60 * 60 * 1000) // Default: letzte 24h // Sync Kommentare für diesen Account const syncResult = await this.syncAccountComments( client, account, lastSyncAt ) result.videosProcessed = syncResult.videosProcessed result.newComments = syncResult.newComments result.updatedComments = syncResult.updatedComments result.errors = syncResult.errors // Update lastSyncAt await payload.update({ collection: 'social-accounts', id: account.id, data: { lastSyncAt: new Date().toISOString(), }, }) } catch (error: any) { result.errors.push(error.message) console.error(`[Sync] Error syncing account ${account.id}:`, error) } result.duration = Date.now() - startTime results.push(result) } return results } /** * Sync Kommentare für einen spezifischen Account */ private async syncAccountComments( client: YouTubeClient, account: any, since: Date ): Promise<{ videosProcessed: number newComments: number updatedComments: number errors: string[] }> { const payload = await this.getPayload() const errors: string[] = [] let videosProcessed = 0 let newComments = 0 let updatedComments = 0 // Hole alle Videos dieses Kanals aus der DB const videos = await payload.find({ collection: 'youtube-content', where: { channelId: { equals: account.channelId }, }, limit: 50, // Letzte 50 Videos sort: '-publishedAt', }) console.log(`[Sync] Processing ${videos.docs.length} videos for ${account.displayName}`) for (const video of videos.docs) { try { let pageToken: string | undefined let pageCount = 0 const maxPages = 5 // Max 500 Kommentare pro Video do { const { comments, nextPageToken } = await client.getVideoComments( video.videoId, { maxResults: 100, pageToken } ) for (const thread of comments) { const result = await this.processCommentThread( thread, account, video ) if (result === 'new') newComments++ else if (result === 'updated') updatedComments++ } pageToken = nextPageToken pageCount++ // Rate limiting await this.sleep(100) } while (pageToken && pageCount < maxPages) videosProcessed++ } catch (error: any) { errors.push(`Video ${video.videoId}: ${error.message}`) } // Rate limiting zwischen Videos await this.sleep(200) } return { videosProcessed, newComments, updatedComments, errors } } /** * Verarbeite einen Kommentar-Thread */ private async processCommentThread( thread: YouTubeCommentThread, account: any, video: any ): Promise<'new' | 'updated' | 'skipped'> { const payload = await this.getPayload() const comment = thread.snippet.topLevelComment.snippet // Prüfe ob Kommentar bereits existiert const existing = await payload.find({ collection: 'community-interactions', where: { externalId: { equals: thread.id }, }, limit: 1, }) // Analysiere mit Claude (nur für neue Kommentare) let analysis = null if (existing.docs.length === 0) { try { analysis = await this.analyzeComment(comment.textOriginal) } catch (error) { console.error('[Sync] Analysis failed:', error) // Fallback-Analyse analysis = { sentiment: 'neutral', sentimentScore: 0, confidence: 50, topics: [], isMedicalQuestion: false, suggestedReply: null, } } } const interactionData = { platform: account.platform.id || account.platform, socialAccount: account.id, linkedContent: video.id, type: 'comment' as const, externalId: thread.id, externalParentId: null, author: { externalId: comment.authorChannelId?.value || '', name: comment.authorDisplayName, handle: comment.authorDisplayName, avatarUrl: comment.authorProfileImageUrl, isVerified: false, isSubscriber: false, // Müsste separat geprüft werden subscriberCount: 0, }, message: comment.textOriginal, publishedAt: comment.publishedAt, engagement: { likes: comment.likeCount || 0, replies: thread.snippet.totalReplyCount || 0, }, ...(analysis && { analysis: { sentiment: analysis.sentiment, sentimentScore: analysis.sentimentScore, confidence: analysis.confidence, topics: analysis.topics.map((t: string) => ({ topic: t })), suggestedReply: analysis.suggestedReply, }, flags: { isMedicalQuestion: analysis.isMedicalQuestion, requiresEscalation: analysis.isMedicalQuestion, // Med. Fragen = Eskalation isSpam: false, isFromInfluencer: false, }, }), status: 'new' as const, priority: analysis?.isMedicalQuestion ? 'high' as const : 'normal' as const, } if (existing.docs.length === 0) { // Neuer Kommentar await payload.create({ collection: 'community-interactions', data: interactionData, }) // Verarbeite auch Replies if (thread.replies?.comments) { for (const reply of thread.replies.comments) { await this.processReply(reply, thread.id, account, video) } } return 'new' } else { // Update Engagement-Zahlen const existingDoc = existing.docs[0] if ( existingDoc.engagement?.likes !== comment.likeCount || existingDoc.engagement?.replies !== thread.snippet.totalReplyCount ) { await payload.update({ collection: 'community-interactions', id: existingDoc.id, data: { engagement: { likes: comment.likeCount || 0, replies: thread.snippet.totalReplyCount || 0, }, }, }) return 'updated' } } return 'skipped' } /** * Verarbeite eine Reply auf einen Kommentar */ private async processReply( reply: any, parentId: string, account: any, video: any ): Promise { const payload = await this.getPayload() const snippet = reply.snippet // Prüfe ob bereits existiert const existing = await payload.find({ collection: 'community-interactions', where: { externalId: { equals: reply.id }, }, limit: 1, }) if (existing.docs.length > 0) return // Prüfe ob es unsere eigene Antwort ist (dann überspringen) if (snippet.authorChannelId?.value === account.channelId) { return } // Analysiere let analysis = null try { analysis = await this.analyzeComment(snippet.textOriginal) } catch (error) { analysis = { sentiment: 'neutral', sentimentScore: 0, confidence: 50, topics: [], isMedicalQuestion: false, suggestedReply: null, } } await payload.create({ collection: 'community-interactions', data: { platform: account.platform.id || account.platform, socialAccount: account.id, linkedContent: video.id, type: 'reply', externalId: reply.id, externalParentId: parentId, author: { externalId: snippet.authorChannelId?.value || '', name: snippet.authorDisplayName, handle: snippet.authorDisplayName, avatarUrl: snippet.authorProfileImageUrl, isVerified: false, isSubscriber: false, subscriberCount: 0, }, message: snippet.textOriginal, publishedAt: snippet.publishedAt, engagement: { likes: snippet.likeCount || 0, replies: 0, }, analysis: { sentiment: analysis.sentiment, sentimentScore: analysis.sentimentScore, confidence: analysis.confidence, topics: analysis.topics.map((t: string) => ({ topic: t })), suggestedReply: analysis.suggestedReply, }, flags: { isMedicalQuestion: analysis.isMedicalQuestion, requiresEscalation: analysis.isMedicalQuestion, isSpam: false, isFromInfluencer: false, }, status: 'new', priority: analysis.isMedicalQuestion ? 'high' : 'normal', }, }) } /** * Analysiere Kommentar mit Claude */ private async analyzeComment(text: string): Promise<{ sentiment: string sentimentScore: number confidence: number topics: string[] isMedicalQuestion: boolean suggestedReply: string | null }> { return await this.claude.analyzeInteraction(text) } private sleep(ms: number): Promise { return new Promise(resolve => setTimeout(resolve, ms)) } } ``` ### 1.6 Cron Job Entry Point ```typescript // src/lib/jobs/syncAllComments.ts import { CommentsSyncService } from '../integrations/youtube/CommentsSyncService' import { JobLogger } from './JobLogger' let isRunning = false let lastRunAt: Date | null = null let lastResult: any = null export async function runSync(): Promise<{ success: boolean results?: any[] error?: string }> { if (isRunning) { return { success: false, error: 'Sync already running' } } isRunning = true const logger = new JobLogger('youtube-sync') try { logger.info('Starting YouTube comments sync') const syncService = new CommentsSyncService() const results = await syncService.syncAllAccounts() lastRunAt = new Date() lastResult = results const totalNew = results.reduce((sum, r) => sum + r.newComments, 0) const totalUpdated = results.reduce((sum, r) => sum + r.updatedComments, 0) const totalErrors = results.reduce((sum, r) => sum + r.errors.length, 0) logger.info(`Sync completed: ${totalNew} new, ${totalUpdated} updated, ${totalErrors} errors`) return { success: true, results } } catch (error: any) { logger.error('Sync failed', error) return { success: false, error: error.message } } finally { isRunning = false } } export function getSyncStatus() { return { isRunning, lastRunAt: lastRunAt?.toISOString() || null, lastResult, } } ``` ### 1.7 Job Logger ```typescript // src/lib/jobs/JobLogger.ts export class JobLogger { private jobName: string constructor(jobName: string) { this.jobName = jobName } info(message: string, data?: any) { console.log(`[${this.timestamp()}] [${this.jobName}] INFO: ${message}`, data || '') } warn(message: string, data?: any) { console.warn(`[${this.timestamp()}] [${this.jobName}] WARN: ${message}`, data || '') } error(message: string, error?: any) { console.error(`[${this.timestamp()}] [${this.jobName}] ERROR: ${message}`, error || '') } private timestamp(): string { return new Date().toISOString() } } ``` ### 1.8 API Endpoints für Sync ```typescript // src/app/(payload)/api/cron/youtube-sync/route.ts import { NextRequest, NextResponse } from 'next/server' import { runSync, getSyncStatus } from '@/lib/jobs/syncAllComments' // Geheimer Token für Cron-Authentifizierung const CRON_SECRET = process.env.CRON_SECRET export async function GET(request: NextRequest) { // Auth prüfen const authHeader = request.headers.get('authorization') if (CRON_SECRET && authHeader !== `Bearer ${CRON_SECRET}`) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } const result = await runSync() return NextResponse.json(result, { status: result.success ? 200 : 500, }) } // Status-Endpoint export async function HEAD() { const status = getSyncStatus() return new NextResponse(null, { status: status.isRunning ? 423 : 200, headers: { 'X-Sync-Running': status.isRunning.toString(), 'X-Last-Run': status.lastRunAt || 'never', }, }) } ``` ```typescript // src/app/(payload)/api/community/sync/route.ts import { NextRequest, NextResponse } from 'next/server' import { runSync, getSyncStatus } from '@/lib/jobs/syncAllComments' // Manueller Sync Trigger (aus Admin Panel) export async function POST(request: NextRequest) { const result = await runSync() return NextResponse.json(result, { status: result.success ? 200 : 500, }) } // Status abfragen export async function GET() { const status = getSyncStatus() return NextResponse.json(status) } ``` --- ## Teil 2: AI Reply Suggestions ### 2.1 ClaudeReplyService ```typescript // src/lib/integrations/claude/ClaudeReplyService.ts import Anthropic from '@anthropic-ai/sdk' import { getPayload } from 'payload' import config from '@payload-config' const anthropic = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY, }) interface ReplyContext { channelName: string channelType: 'corporate' | 'lifestyle' | 'business' videoTitle: string videoTopics: string[] commentText: string commentSentiment: string authorName: string isMedicalQuestion: boolean previousReplies?: string[] } interface GeneratedReply { text: string tone: string confidence: number alternatives: string[] warnings: string[] } export class ClaudeReplyService { /** * Generiere Antwortvorschlag basierend auf Kontext */ async generateReply(context: ReplyContext): Promise { const systemPrompt = this.buildSystemPrompt(context) const userPrompt = this.buildUserPrompt(context) try { const response = await anthropic.messages.create({ model: 'claude-3-haiku-20240307', // Schnell & kostengünstig max_tokens: 500, system: systemPrompt, messages: [ { role: 'user', content: userPrompt } ], }) const content = response.content[0] if (content.type !== 'text') { throw new Error('Unexpected response type') } return this.parseReplyResponse(content.text, context) } catch (error: any) { console.error('[ClaudeReply] Generation failed:', error) throw error } } /** * Generiere mehrere Varianten für A/B-Testing */ async generateReplyVariants( context: ReplyContext, count: number = 3 ): Promise { const variants: GeneratedReply[] = [] const tones = ['freundlich', 'professionell', 'empathisch'] for (let i = 0; i < Math.min(count, tones.length); i++) { const contextWithTone = { ...context, requestedTone: tones[i] } const reply = await this.generateReply(contextWithTone) reply.tone = tones[i] variants.push(reply) // Rate limiting await this.sleep(100) } return variants } private buildSystemPrompt(context: ReplyContext): string { const basePrompt = `Du bist der Community Manager für "${context.channelName}", einen deutschen YouTube-Kanal. KANAL-KONTEXT: ${this.getChannelContext(context.channelType)} WICHTIGE REGELN: 1. Antworte IMMER auf Deutsch 2. Verwende "Du" (nicht "Sie") 3. Halte Antworten kurz (max. 2-3 Sätze für normale Kommentare) 4. Unterschreibe mit "— ${this.getSignature(context.channelType)}" 5. Sei authentisch, nicht roboterhaft 6. Verwende KEINE Emojis außer: ❤️ 👍 😊 (sparsam) ${context.isMedicalQuestion ? this.getMedicalWarning() : ''} TONALITÄT: ${this.getToneGuidelines(context.channelType, context.commentSentiment)} ` return basePrompt } private buildUserPrompt(context: ReplyContext): string { return `KOMMENTAR VON "${context.authorName}": "${context.commentText}" VIDEO: "${context.videoTitle}" SENTIMENT: ${context.commentSentiment} ${context.isMedicalQuestion ? '⚠️ MEDIZINISCHE FRAGE ERKANNT' : ''} Generiere eine passende Antwort. Antworte NUR mit dem Antworttext, keine Erklärungen.` } private getChannelContext(channelType: string): string { switch (channelType) { case 'corporate': return `Kanal: zweitmeinu.ng (Complex Care Solutions) Thema: Medizinische Zweitmeinungen, Patientenrechte, Gesundheitsentscheidungen Zielgruppe: Patienten, Angehörige, Krankenkassen Persona: Dr. Caroline Porwoll (Gesundheitsberaterin, NICHT Ärztin) Wichtig: Keine medizinische Beratung geben, auf Hotline verweisen` case 'lifestyle': return `Kanal: BlogWoman by Caroline Porwoll Thema: Premium-Lifestyle für Karrierefrauen (Mode, Systeme, Regeneration) Zielgruppe: Berufstätige Mütter 35-45 Persona: Caroline Porwoll (Unternehmerin, Mutter, Stilexpertin) Wichtig: Editorial-Premium-Ton, keine Influencer-Sprache` case 'business': return `Kanal: CCS Business (English) Thema: B2B Second Opinion Programs, ROI, Healthcare Governance Zielgruppe: International Healthcare Decision-Makers Persona: Dr. Caroline Porwoll (CEO, Healthcare Expert) Wichtig: Professionell, faktenbasiert, auf Demo/Whitepaper verweisen` default: return 'Allgemeiner YouTube-Kanal' } } private getToneGuidelines(channelType: string, sentiment: string): string { const baseGuidelines: Record = { 'corporate': 'Warm, empathisch, ermutigend, professionell', 'lifestyle': 'Editorial Premium, systemorientiert, "Performance & Pleasure"', 'business': 'Professional, fact-based, ROI-oriented', } const sentimentAdjustments: Record = { 'positive': 'Teile die Freude, bedanke dich herzlich', 'negative': 'Zeige Verständnis, biete Hilfe an, eskaliere nicht', 'question': 'Beantworte klar und hilfreich, verweise auf Ressourcen', 'gratitude': 'Bedanke dich aufrichtig, zeige dass es geschätzt wird', 'frustration': 'Validiere Gefühle, biete konkrete Lösung, bleib ruhig', 'neutral': 'Freundlich und hilfsbereit', } return `Grundton: ${baseGuidelines[channelType] || 'Freundlich'} Bei ${sentiment}: ${sentimentAdjustments[sentiment] || sentimentAdjustments['neutral']}` } private getMedicalWarning(): string { return ` ⚠️ MEDIZINISCHE FRAGE - BESONDERE VORSICHT: - Gib KEINE medizinische Beratung - Gib KEINE Diagnosen oder Behandlungsempfehlungen - Verweise IMMER auf professionelle Beratung - Standardformulierung: "Für eine persönliche Einschätzung Ihrer Situation empfehlen wir ein Gespräch mit unserem Beratungsteam: [Hotline/Link]" ` } private getSignature(channelType: string): string { switch (channelType) { case 'corporate': return 'Caroline' case 'lifestyle': return 'Caroline' case 'business': return 'The CCS Team' default: return 'Das Team' } } private parseReplyResponse(text: string, context: ReplyContext): GeneratedReply { // Bereinige die Antwort let cleanedText = text.trim() // Entferne mögliche Anführungszeichen am Anfang/Ende cleanedText = cleanedText.replace(/^["']|["']$/g, '') const warnings: string[] = [] // Prüfe auf potenzielle Probleme if (context.isMedicalQuestion && !cleanedText.includes('Beratung')) { warnings.push('Medizinische Frage: Verweis auf Beratung fehlt möglicherweise') } if (cleanedText.length > 500) { warnings.push('Antwort ist möglicherweise zu lang') } return { text: cleanedText, tone: 'standard', confidence: 85, alternatives: [], warnings, } } private sleep(ms: number): Promise { return new Promise(resolve => setTimeout(resolve, ms)) } } ``` ### 2.2 API Endpoint für Reply Generation ```typescript // src/app/(payload)/api/community/generate-reply/route.ts import { NextRequest, NextResponse } from 'next/server' import { getPayload } from 'payload' import config from '@payload-config' import { ClaudeReplyService } from '@/lib/integrations/claude/ClaudeReplyService' export async function POST(request: NextRequest) { try { const body = await request.json() const { interactionId, variants = false } = body if (!interactionId) { return NextResponse.json( { error: 'interactionId is required' }, { status: 400 } ) } const payload = await getPayload({ config }) // Hole Interaction mit Relations const interaction = await payload.findByID({ collection: 'community-interactions', id: interactionId, depth: 2, // Inkludiert platform, socialAccount, linkedContent }) if (!interaction) { return NextResponse.json( { error: 'Interaction not found' }, { status: 404 } ) } // Bestimme Channel-Typ basierend auf Account const channelType = determineChannelType(interaction.socialAccount) // Baue Kontext const context = { channelName: interaction.socialAccount?.displayName || 'YouTube Channel', channelType, videoTitle: interaction.linkedContent?.title || 'Video', videoTopics: interaction.linkedContent?.topics?.map((t: any) => t.topic) || [], commentText: interaction.message, commentSentiment: interaction.analysis?.sentiment || 'neutral', authorName: interaction.author?.name || 'User', isMedicalQuestion: interaction.flags?.isMedicalQuestion || false, } const replyService = new ClaudeReplyService() if (variants) { // Generiere mehrere Varianten const replies = await replyService.generateReplyVariants(context, 3) return NextResponse.json({ variants: replies }) } else { // Einzelne Antwort const reply = await replyService.generateReply(context) return NextResponse.json({ reply }) } } catch (error: any) { console.error('[GenerateReply] Error:', error) return NextResponse.json( { error: error.message || 'Failed to generate reply' }, { status: 500 } ) } } function determineChannelType(account: any): 'corporate' | 'lifestyle' | 'business' { const name = account?.displayName?.toLowerCase() || '' if (name.includes('blogwoman') || name.includes('lifestyle')) { return 'lifestyle' } if (name.includes('business') || name.includes('ccs business')) { return 'business' } return 'corporate' } ``` ### 2.3 ClaudeAnalysisService erweitern ```typescript // src/lib/integrations/claude/ClaudeAnalysisService.ts import Anthropic from '@anthropic-ai/sdk' const anthropic = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY, }) export interface AnalysisResult { sentiment: 'positive' | 'neutral' | 'negative' | 'question' | 'gratitude' | 'frustration' sentimentScore: number confidence: number topics: string[] isMedicalQuestion: boolean suggestedReply: string | null } export class ClaudeAnalysisService { /** * Analysiere eine Community-Interaktion */ async analyzeInteraction(text: string): Promise { const prompt = `Analysiere folgenden YouTube-Kommentar und antworte NUR mit einem JSON-Objekt (keine Erklärungen): KOMMENTAR: "${text}" Antworte mit diesem exakten JSON-Format: { "sentiment": "positive|neutral|negative|question|gratitude|frustration", "sentimentScore": , "confidence": , "topics": ["topic1", "topic2"], "isMedicalQuestion": , "suggestedReplyType": "thanks|answer|empathy|redirect|none" } Regeln: - sentiment: Wähle das passendste aus den 6 Optionen - sentimentScore: -1.0 = sehr negativ, 0 = neutral, 1.0 = sehr positiv - confidence: Wie sicher bist du bei der Einschätzung (0-100%) - topics: Extrahiere 1-3 Hauptthemen (auf Deutsch) - isMedicalQuestion: true wenn nach medizinischem Rat gefragt wird - suggestedReplyType: Art der empfohlenen Antwort` try { const response = await anthropic.messages.create({ model: 'claude-3-haiku-20240307', max_tokens: 300, messages: [ { role: 'user', content: prompt } ], }) const content = response.content[0] if (content.type !== 'text') { throw new Error('Unexpected response type') } // Parse JSON aus der Antwort const jsonMatch = content.text.match(/\{[\s\S]*\}/) if (!jsonMatch) { throw new Error('No JSON found in response') } const parsed = JSON.parse(jsonMatch[0]) return { sentiment: this.validateSentiment(parsed.sentiment), sentimentScore: this.clamp(parsed.sentimentScore || 0, -1, 1), confidence: this.clamp(parsed.confidence || 50, 0, 100), topics: Array.isArray(parsed.topics) ? parsed.topics.slice(0, 5) : [], isMedicalQuestion: Boolean(parsed.isMedicalQuestion), suggestedReply: null, // Wird separat generiert wenn gewünscht } } catch (error: any) { console.error('[ClaudeAnalysis] Error:', error) // Fallback-Analyse bei Fehler return { sentiment: 'neutral', sentimentScore: 0, confidence: 30, topics: [], isMedicalQuestion: false, suggestedReply: null, } } } private validateSentiment( sentiment: string ): 'positive' | 'neutral' | 'negative' | 'question' | 'gratitude' | 'frustration' { const valid = ['positive', 'neutral', 'negative', 'question', 'gratitude', 'frustration'] return valid.includes(sentiment) ? sentiment as any : 'neutral' } private clamp(value: number, min: number, max: number): number { return Math.max(min, Math.min(max, value)) } } ``` --- ## Teil 3: UI Integration ### 3.1 CommunityInbox erweitern Füge folgende Funktionen zur bestehenden `CommunityInbox.tsx` hinzu: ```tsx // Ergänzungen für CommunityInbox.tsx // State für AI Reply const [isGeneratingReply, setIsGeneratingReply] = useState(false) const [generatedReplies, setGeneratedReplies] = useState([]) // Funktion zum Generieren von Antworten const handleGenerateReply = async (interactionId: number, variants: boolean = false) => { setIsGeneratingReply(true) setGeneratedReplies([]) try { const response = await fetch('/api/community/generate-reply', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ interactionId, variants }), }) if (!response.ok) throw new Error('Generation failed') const data = await response.json() if (variants && data.variants) { setGeneratedReplies(data.variants) } else if (data.reply) { setGeneratedReplies([data.reply]) } } catch (error) { console.error('Failed to generate reply:', error) // Toast oder Error-Anzeige } finally { setIsGeneratingReply(false) } } // Sync-Status und manueller Trigger const [syncStatus, setSyncStatus] = useState(null) const [isSyncing, setIsSyncing] = useState(false) const handleManualSync = async () => { setIsSyncing(true) try { const response = await fetch('/api/community/sync', { method: 'POST' }) const result = await response.json() if (result.success) { // Refresh Interactions fetchInteractions() } } catch (error) { console.error('Sync failed:', error) } finally { setIsSyncing(false) } } // Fetch Sync Status useEffect(() => { const fetchSyncStatus = async () => { const response = await fetch('/api/community/sync') const status = await response.json() setSyncStatus(status) } fetchSyncStatus() const interval = setInterval(fetchSyncStatus, 60000) // Jede Minute return () => clearInterval(interval) }, []) ``` ### 3.2 UI-Komponente für AI Replies ```tsx // Innerhalb des Detail-Panels in CommunityInbox.tsx {/* AI Reply Section */}

🤖 KI-Antwortvorschläge

{generatedReplies.length > 0 && (
{generatedReplies.map((reply, index) => (
{reply.tone && ( Ton: {reply.tone} )}

{reply.text}

{reply.warnings?.length > 0 && (
{reply.warnings.map((w: string, i: number) => ( ⚠️ {w} ))}
)}
))}
)}
{/* Sync Status Badge */}
{syncStatus && ( <> {syncStatus.isRunning ? 'Sync läuft...' : `Letzter Sync: ${syncStatus.lastRunAt ? new Date(syncStatus.lastRunAt).toLocaleString('de-DE') : 'Nie'}` } )}
``` ### 3.3 SCSS Ergänzungen ```scss // Ergänzungen für inbox.scss // AI Reply Section .inbox__ai-reply { margin-top: 1.5rem; padding-top: 1.5rem; border-top: 1px solid var(--theme-elevation-100); &-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem; h4 { margin: 0; font-size: 0.875rem; font-weight: 600; } } &-actions { display: flex; gap: 0.5rem; } &-suggestions { display: flex; flex-direction: column; gap: 0.75rem; } &-card { background: var(--theme-elevation-50); border: 1px solid var(--theme-elevation-100); border-radius: 8px; padding: 1rem; position: relative; } &-tone { position: absolute; top: 0.5rem; right: 0.5rem; font-size: 0.625rem; text-transform: uppercase; color: var(--theme-elevation-500); background: var(--theme-elevation-100); padding: 0.125rem 0.5rem; border-radius: 4px; } &-text { margin: 0 0 0.75rem 0; font-size: 0.875rem; line-height: 1.5; white-space: pre-wrap; } &-warnings { margin-bottom: 0.75rem; } &-warning { display: block; font-size: 0.75rem; color: #F59E0B; margin-bottom: 0.25rem; } } // Sync Status .inbox__sync-status { display: flex; align-items: center; gap: 0.5rem; padding: 0.5rem 1rem; background: var(--theme-elevation-50); border-radius: 6px; font-size: 0.75rem; color: var(--theme-elevation-600); } .inbox__sync-indicator { width: 8px; height: 8px; border-radius: 50%; background: var(--theme-elevation-300); &--active { background: #10B981; animation: pulse 1.5s infinite; } } @keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.5; } } .inbox__sync-text { flex: 1; } ``` --- ## Teil 4: Cron-Konfiguration ### 4.1 Vercel Cron (vercel.json) ```json { "crons": [ { "path": "/api/cron/youtube-sync", "schedule": "*/15 * * * *" } ] } ``` ### 4.2 Alternative: Externes Cron (z.B. cron-job.org) ``` URL: https://your-domain.com/api/cron/youtube-sync Methode: GET Header: Authorization: Bearer YOUR_CRON_SECRET Intervall: Alle 15 Minuten ``` ### 4.3 Alternative: Systemd Timer (Self-hosted) ```bash # /etc/systemd/system/youtube-sync.timer [Unit] Description=YouTube Comments Sync Timer [Timer] OnCalendar=*:0/15 Persistent=true [Install] WantedBy=timers.target ``` ```bash # /etc/systemd/system/youtube-sync.service [Unit] Description=YouTube Comments Sync [Service] Type=oneshot ExecStart=/usr/bin/curl -s -H "Authorization: Bearer $CRON_SECRET" https://localhost:3000/api/cron/youtube-sync ``` --- ## Teil 5: Environment Variables ```bash # .env.local (ergänzen) # Bestehend ANTHROPIC_API_KEY=sk-ant-api03-... YOUTUBE_CLIENT_ID=... YOUTUBE_CLIENT_SECRET=... YOUTUBE_REDIRECT_URI=https://your-domain.com/api/youtube/callback # NEU CRON_SECRET=your-secure-random-string-here ``` --- ## Implementierungs-Reihenfolge ### Tag 1-2: YouTube Auto-Sync 1. Type Definitions (`youtube.ts`) 2. YouTubeClient erweitern 3. CommentsSyncService 4. Job Runner + Logger 5. API Endpoints ### Tag 3: Claude Reply Service 1. ClaudeReplyService 2. API Endpoint `/generate-reply` 3. ClaudeAnalysisService erweitern ### Tag 4: UI Integration 1. Inbox-Komponente erweitern 2. AI Reply Section 3. Sync Status UI 4. SCSS Ergänzungen ### Tag 5: Testing & Deployment 1. Manueller Sync testen 2. Cron-Job einrichten 3. Reply-Generation testen 4. Error Handling prüfen **Gesamtaufwand: ~5 Tage** --- ## Testfälle ### YouTube Sync - [ ] Manueller Sync über API funktioniert - [ ] Cron-Endpoint authentifiziert korrekt - [ ] Neue Kommentare werden importiert - [ ] Bestehende Kommentare werden aktualisiert (Engagement) - [ ] Token-Refresh funktioniert automatisch - [ ] Rate Limiting wird eingehalten - [ ] Fehler werden geloggt und nicht propagiert ### AI Replies - [ ] Single Reply Generation funktioniert - [ ] 3 Varianten werden generiert - [ ] Channel-Typ wird korrekt erkannt - [ ] Medizinische Warnung erscheint bei Med. Fragen - [ ] Signatur ist kanalspezifisch - [ ] "Übernehmen" kopiert Text in Editor - [ ] Warnings werden angezeigt ### UI - [ ] Sync-Status wird angezeigt - [ ] Manueller Sync-Button funktioniert - [ ] AI Reply Section erscheint - [ ] Loading States für alle Aktionen - [ ] Error States mit Feedback --- ## Deliverables Nach Abschluss solltest du haben: 1. ✅ YouTubeClient mit getChannelComments, getVideoComments, replyToComment 2. ✅ CommentsSyncService mit vollständigem Sync-Flow 3. ✅ Cron-Endpoint mit Authentifizierung 4. ✅ ClaudeReplyService mit kanalspezifischen Prompts 5. ✅ API-Endpoint für Reply-Generation 6. ✅ UI-Integration in CommunityInbox 7. ✅ Sync-Status Anzeige 8. ✅ SCSS für neue Komponenten 9. ✅ Environment Variables dokumentiert 10. ✅ Cron-Konfiguration (Vercel oder Alternative) --- **Beginne mit den Type Definitions und arbeite dich durch die Services zur UI vor.** Bei Fragen zur YouTube API Quota oder Claude API Kosten: Implementiere erst die Grundfunktion, Optimierungen können danach erfolgen.