cms.c2sgmbh/prompts/Phase2.2 youtube sync ai replies prompt.md
Martin Porwoll 77f70876f4 chore: add Claude Code config, prompts, and tenant setup scripts
- Add .claude/ configuration (agents, commands, hooks, get-shit-done workflows)
- Add prompts/ directory with development planning documents
- Add scripts/setup-tenants/ with tenant configuration
- Add docs/screenshots/
- Remove obsolete phase2.2-corrections-report.md
- Update pnpm-lock.yaml
- Update detect-secrets.sh to ignore setup.sh (env var usage, not secrets)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-18 10:18:05 +00:00

48 KiB
Raw Permalink Blame History

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)

// src/lib/integrations/youtube/oauth.ts
// - getAuthUrl(): string
// - exchangeCodeForTokens(code: string): Promise<Tokens>
// - refreshAccessToken(refreshToken: string): Promise<Tokens>

// src/lib/integrations/youtube/YouTubeClient.ts
// - getComments(videoId: string): Promise<Comment[]>
// - replyToComment(commentId: string, text: string): Promise<void>

Claude Integration (bereits implementiert)

// src/lib/integrations/claude/ClaudeAnalysisService.ts
// - analyzeSentiment(message: string): Promise<SentimentResult>
// - extractTopics(message: string): Promise<string[]>
// - detectMedicalContent(message: string): Promise<boolean>

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

// 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

// 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<string> {
    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<YouTubeClient | null> {
    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

// 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<ReturnType<typeof getPayload>>
  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<SyncResult[]> {
    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<void> {
    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<void> {
    return new Promise(resolve => setTimeout(resolve, ms))
  }
}

1.6 Cron Job Entry Point

// 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

// 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

// 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',
    },
  })
}
// 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

// 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<GeneratedReply> {
    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<GeneratedReply[]> {
    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<string, string> = {
      'corporate': 'Warm, empathisch, ermutigend, professionell',
      'lifestyle': 'Editorial Premium, systemorientiert, "Performance & Pleasure"',
      'business': 'Professional, fact-based, ROI-oriented',
    }

    const sentimentAdjustments: Record<string, string> = {
      '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<void> {
    return new Promise(resolve => setTimeout(resolve, ms))
  }
}

2.2 API Endpoint für Reply Generation

// 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

// 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<AnalysisResult> {
    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": <number zwischen -1.0 und 1.0>,
  "confidence": <number zwischen 0 und 100>,
  "topics": ["topic1", "topic2"],
  "isMedicalQuestion": <true|false>,
  "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:

// Ergänzungen für CommunityInbox.tsx

// State für AI Reply
const [isGeneratingReply, setIsGeneratingReply] = useState(false)
const [generatedReplies, setGeneratedReplies] = useState<any[]>([])

// 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<any>(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

// Innerhalb des Detail-Panels in CommunityInbox.tsx

{/* AI Reply Section */}
<div className="inbox__ai-reply">
  <div className="inbox__ai-reply-header">
    <h4>🤖 KI-Antwortvorschläge</h4>
    <div className="inbox__ai-reply-actions">
      <button
        onClick={() => handleGenerateReply(selectedInteraction.id, false)}
        disabled={isGeneratingReply}
        className="inbox__btn inbox__btn--secondary"
      >
        {isGeneratingReply ? 'Generiere...' : '1 Vorschlag'}
      </button>
      <button
        onClick={() => handleGenerateReply(selectedInteraction.id, true)}
        disabled={isGeneratingReply}
        className="inbox__btn inbox__btn--secondary"
      >
        {isGeneratingReply ? 'Generiere...' : '3 Varianten'}
      </button>
    </div>
  </div>

  {generatedReplies.length > 0 && (
    <div className="inbox__ai-reply-suggestions">
      {generatedReplies.map((reply, index) => (
        <div key={index} className="inbox__ai-reply-card">
          {reply.tone && (
            <span className="inbox__ai-reply-tone">
              Ton: {reply.tone}
            </span>
          )}
          <p className="inbox__ai-reply-text">{reply.text}</p>
          
          {reply.warnings?.length > 0 && (
            <div className="inbox__ai-reply-warnings">
              {reply.warnings.map((w: string, i: number) => (
                <span key={i} className="inbox__ai-reply-warning">⚠️ {w}</span>
              ))}
            </div>
          )}
          
          <button
            onClick={() => {
              setReplyText(reply.text)
              // Scroll zum Reply-Editor
            }}
            className="inbox__btn inbox__btn--primary inbox__btn--small"
          >
            Übernehmen
          </button>
        </div>
      ))}
    </div>
  )}
</div>

{/* Sync Status Badge */}
<div className="inbox__sync-status">
  {syncStatus && (
    <>
      <span className={`inbox__sync-indicator ${syncStatus.isRunning ? 'inbox__sync-indicator--active' : ''}`} />
      <span className="inbox__sync-text">
        {syncStatus.isRunning 
          ? 'Sync läuft...' 
          : `Letzter Sync: ${syncStatus.lastRunAt ? new Date(syncStatus.lastRunAt).toLocaleString('de-DE') : 'Nie'}`
        }
      </span>
      <button
        onClick={handleManualSync}
        disabled={isSyncing || syncStatus.isRunning}
        className="inbox__btn inbox__btn--icon"
        title="Manuell synchronisieren"
      >
        🔄
      </button>
    </>
  )}
</div>

3.3 SCSS Ergänzungen

// 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)

{
  "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)

# /etc/systemd/system/youtube-sync.timer
[Unit]
Description=YouTube Comments Sync Timer

[Timer]
OnCalendar=*:0/15
Persistent=true

[Install]
WantedBy=timers.target
# /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

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