mirror of
https://github.com/complexcaresolutions/cms.c2sgmbh.git
synced 2026-03-17 18:34:13 +00:00
- Add .claude/ configuration (agents, commands, hooks, get-shit-done workflows) - Add prompts/ directory with development planning documents - Add scripts/setup-tenants/ with tenant configuration - Add docs/screenshots/ - Remove obsolete phase2.2-corrections-report.md - Update pnpm-lock.yaml - Update detect-secrets.sh to ignore setup.sh (env var usage, not secrets) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1763 lines
48 KiB
Markdown
1763 lines
48 KiB
Markdown
# 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<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)
|
||
|
||
```typescript
|
||
// 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
|
||
|
||
```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<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
|
||
|
||
```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<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
|
||
|
||
```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<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
|
||
|
||
```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<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:
|
||
|
||
```tsx
|
||
// 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
|
||
|
||
```tsx
|
||
// 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
|
||
|
||
```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.
|