diff --git a/src/lib/integrations/youtube/CommentsSyncService.ts b/src/lib/integrations/youtube/CommentsSyncService.ts index 4aa009b..703e98b 100644 --- a/src/lib/integrations/youtube/CommentsSyncService.ts +++ b/src/lib/integrations/youtube/CommentsSyncService.ts @@ -15,6 +15,7 @@ interface SyncResult { success: boolean newComments: number updatedComments: number + newReplies: number errors: string[] syncedAt: Date } @@ -43,6 +44,7 @@ export class CommentsSyncService { success: false, newComments: 0, updatedComments: 0, + newReplies: 0, errors: [], syncedAt: new Date(), } @@ -102,13 +104,15 @@ export class CommentsSyncService { const processResult = await this.processComment( comment, account, - analyzeWithAI + analyzeWithAI, + youtubeClient, ) if (processResult.isNew) { result.newComments++ } else { result.updatedComments++ } + result.newReplies += processResult.newReplies } catch (error) { result.errors.push(`Fehler bei Kommentar ${comment.id}: ${error}`) } @@ -139,8 +143,9 @@ export class CommentsSyncService { private async processComment( comment: CommentThread, account: any, - analyzeWithAI: boolean - ): Promise<{ isNew: boolean }> { + analyzeWithAI: boolean, + youtubeClient?: YouTubeClient, + ): Promise<{ isNew: boolean; newReplies: number }> { const snippet = comment.snippet.topLevelComment.snippet // Prüfen ob Kommentar bereits existiert @@ -228,11 +233,14 @@ export class CommentsSyncService { }), } + let parentInteractionId: number | null = null + if (isNew) { - await this.payload.create({ + const created = await this.payload.create({ collection: 'community-interactions', data: interactionData, }) + parentInteractionId = created.id } else { await this.payload.update({ collection: 'community-interactions', @@ -245,9 +253,122 @@ export class CommentsSyncService { assignedTo: existing.docs[0].assignedTo, }, }) + parentInteractionId = existing.docs[0].id } - return { isNew } + // Reply-Threads synchronisieren + let newReplies = 0 + if (youtubeClient && comment.snippet.totalReplyCount > 0) { + newReplies = await this.processReplyThread( + comment, + account, + parentInteractionId, + platformId, + linkedContentId, + youtubeClient, + analyzeWithAI, + ) + } + + return { isNew, newReplies } + } + + /** + * Antworten eines Kommentar-Threads synchronisieren + */ + private async processReplyThread( + comment: CommentThread, + account: any, + parentInteractionId: number | null, + platformId: any, + linkedContentId: any, + youtubeClient: YouTubeClient, + analyzeWithAI: boolean, + ): Promise { + let newReplies = 0 + + try { + const { replies } = await youtubeClient.getCommentReplies( + comment.snippet.topLevelComment.id, + ) + + for (const reply of replies) { + const replyExists = await this.payload.find({ + collection: 'community-interactions', + where: { + externalId: { equals: reply.id }, + }, + limit: 1, + }) + + if (replyExists.totalDocs > 0) { + continue + } + + let replyAnalysis = null + if (analyzeWithAI) { + replyAnalysis = await this.claudeService.analyzeComment( + reply.snippet.textOriginal, + ) + } + + await this.payload.create({ + collection: 'community-interactions', + data: { + platform: platformId, + socialAccount: account.id, + linkedContent: linkedContentId, + type: 'reply' as const, + externalId: reply.id, + parentInteraction: parentInteractionId, + author: { + name: reply.snippet.authorDisplayName, + handle: reply.snippet.authorChannelId?.value, + profileUrl: reply.snippet.authorChannelUrl, + avatarUrl: reply.snippet.authorProfileImageUrl, + isVerified: false, + isSubscriber: false, + isMember: false, + }, + message: reply.snippet.textOriginal, + messageHtml: reply.snippet.textDisplay, + publishedAt: new Date(reply.snippet.publishedAt).toISOString(), + engagement: { + likes: reply.snippet.likeCount || 0, + replies: 0, + isHearted: false, + isPinned: false, + }, + ...(replyAnalysis && { + analysis: { + sentiment: replyAnalysis.sentiment, + sentimentScore: replyAnalysis.sentimentScore, + confidence: replyAnalysis.confidence, + topics: replyAnalysis.topics.map((t) => ({ topic: t })), + language: replyAnalysis.language, + suggestedReply: replyAnalysis.suggestedReply, + analyzedAt: new Date().toISOString(), + }, + flags: { + isMedicalQuestion: replyAnalysis.isMedicalQuestion, + requiresEscalation: replyAnalysis.requiresEscalation, + isSpam: replyAnalysis.isSpam, + isFromInfluencer: false, + }, + }), + }, + }) + + newReplies++ + } + } catch (error) { + console.error( + `Error syncing replies for comment ${comment.id}:`, + error, + ) + } + + return newReplies } } diff --git a/src/lib/integrations/youtube/YouTubeClient.ts b/src/lib/integrations/youtube/YouTubeClient.ts index 88e097e..5aa4757 100644 --- a/src/lib/integrations/youtube/YouTubeClient.ts +++ b/src/lib/integrations/youtube/YouTubeClient.ts @@ -232,6 +232,48 @@ export class YouTubeClient { } } + /** + * Antworten auf einen Kommentar-Thread abrufen + */ + async getCommentReplies( + parentCommentId: string, + maxResults: number = 100 + ): Promise<{ + replies: Array<{ + id: string + snippet: { + parentId: string + textOriginal: string + textDisplay: string + authorDisplayName: string + authorProfileImageUrl: string + authorChannelUrl: string + authorChannelId: { value: string } + likeCount: number + publishedAt: string + updatedAt: string + } + }> + nextPageToken?: string + }> { + try { + const response = await this.youtube.comments.list({ + part: ['snippet'], + parentId: parentCommentId, + maxResults, + textFormat: 'plainText', + }) + + return { + replies: (response.data.items || []) as any[], + nextPageToken: response.data.nextPageToken || undefined, + } + } catch (error) { + console.error('Error fetching comment replies:', error) + throw error + } + } + /** * Kanal-Statistiken abrufen */ diff --git a/tests/unit/youtube/comment-replies.unit.spec.ts b/tests/unit/youtube/comment-replies.unit.spec.ts new file mode 100644 index 0000000..7f91270 --- /dev/null +++ b/tests/unit/youtube/comment-replies.unit.spec.ts @@ -0,0 +1,242 @@ +/** + * YouTubeClient.getCommentReplies Unit Tests + * + * Tests the comment reply retrieval method that fetches replies + * for a given parent comment thread from the YouTube Data API. + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest' + +const mockCommentsList = vi.fn() + +vi.mock('googleapis', () => { + class MockOAuth2 { + setCredentials(): void { + // no-op + } + } + + return { + google: { + auth: { + OAuth2: MockOAuth2, + }, + youtube: () => ({ + videos: { list: vi.fn() }, + commentThreads: { list: vi.fn() }, + comments: { + list: mockCommentsList, + insert: vi.fn(), + setModerationStatus: vi.fn(), + delete: vi.fn(), + }, + channels: { list: vi.fn() }, + }), + }, + } +}) + +const mockPayload = {} as import('payload').Payload + +describe('YouTubeClient.getCommentReplies', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should return parsed replies for a parent comment', async () => { + mockCommentsList.mockResolvedValueOnce({ + data: { + items: [ + { + id: 'reply-1', + snippet: { + parentId: 'comment-abc', + textOriginal: 'Great point!', + textDisplay: 'Great point!', + authorDisplayName: 'User A', + authorProfileImageUrl: 'https://example.com/a.jpg', + authorChannelUrl: 'https://youtube.com/channel/UC_a', + authorChannelId: { value: 'UC_a' }, + likeCount: 5, + publishedAt: '2026-01-15T10:00:00Z', + updatedAt: '2026-01-15T10:00:00Z', + }, + }, + { + id: 'reply-2', + snippet: { + parentId: 'comment-abc', + textOriginal: 'Thanks for sharing', + textDisplay: 'Thanks for sharing', + authorDisplayName: 'User B', + authorProfileImageUrl: 'https://example.com/b.jpg', + authorChannelUrl: 'https://youtube.com/channel/UC_b', + authorChannelId: { value: 'UC_b' }, + likeCount: 2, + publishedAt: '2026-01-15T11:00:00Z', + updatedAt: '2026-01-15T11:00:00Z', + }, + }, + ], + nextPageToken: undefined, + }, + }) + + const { YouTubeClient } = await import( + '@/lib/integrations/youtube/YouTubeClient' + ) + + const client = new YouTubeClient( + { + clientId: 'test-id', + clientSecret: 'test-secret', + accessToken: 'test-token', + refreshToken: 'test-refresh', + }, + mockPayload, + ) + + const result = await client.getCommentReplies('comment-abc') + + expect(result.replies).toHaveLength(2) + expect(result.replies[0].id).toBe('reply-1') + expect(result.replies[0].snippet.parentId).toBe('comment-abc') + expect(result.replies[0].snippet.textOriginal).toBe('Great point!') + expect(result.replies[0].snippet.likeCount).toBe(5) + expect(result.replies[1].id).toBe('reply-2') + expect(result.nextPageToken).toBeUndefined() + + expect(mockCommentsList).toHaveBeenCalledWith({ + part: ['snippet'], + parentId: 'comment-abc', + maxResults: 100, + textFormat: 'plainText', + }) + }) + + it('should pass custom maxResults parameter', async () => { + mockCommentsList.mockResolvedValueOnce({ + data: { + items: [], + nextPageToken: undefined, + }, + }) + + const { YouTubeClient } = await import( + '@/lib/integrations/youtube/YouTubeClient' + ) + + const client = new YouTubeClient( + { + clientId: 'test-id', + clientSecret: 'test-secret', + accessToken: 'test-token', + refreshToken: 'test-refresh', + }, + mockPayload, + ) + + await client.getCommentReplies('comment-xyz', 20) + + expect(mockCommentsList).toHaveBeenCalledWith({ + part: ['snippet'], + parentId: 'comment-xyz', + maxResults: 20, + textFormat: 'plainText', + }) + }) + + it('should return empty replies array when no items exist', async () => { + mockCommentsList.mockResolvedValueOnce({ + data: { + items: null, + nextPageToken: undefined, + }, + }) + + const { YouTubeClient } = await import( + '@/lib/integrations/youtube/YouTubeClient' + ) + + const client = new YouTubeClient( + { + clientId: 'test-id', + clientSecret: 'test-secret', + accessToken: 'test-token', + refreshToken: 'test-refresh', + }, + mockPayload, + ) + + const result = await client.getCommentReplies('comment-empty') + + expect(result.replies).toEqual([]) + expect(result.nextPageToken).toBeUndefined() + }) + + it('should return nextPageToken when more replies are available', async () => { + mockCommentsList.mockResolvedValueOnce({ + data: { + items: [ + { + id: 'reply-page1', + snippet: { + parentId: 'comment-many', + textOriginal: 'First page reply', + textDisplay: 'First page reply', + authorDisplayName: 'User C', + authorProfileImageUrl: 'https://example.com/c.jpg', + authorChannelUrl: 'https://youtube.com/channel/UC_c', + authorChannelId: { value: 'UC_c' }, + likeCount: 0, + publishedAt: '2026-02-01T12:00:00Z', + updatedAt: '2026-02-01T12:00:00Z', + }, + }, + ], + nextPageToken: 'page2token', + }, + }) + + const { YouTubeClient } = await import( + '@/lib/integrations/youtube/YouTubeClient' + ) + + const client = new YouTubeClient( + { + clientId: 'test-id', + clientSecret: 'test-secret', + accessToken: 'test-token', + refreshToken: 'test-refresh', + }, + mockPayload, + ) + + const result = await client.getCommentReplies('comment-many') + + expect(result.replies).toHaveLength(1) + expect(result.nextPageToken).toBe('page2token') + }) + + it('should propagate API errors', async () => { + mockCommentsList.mockRejectedValueOnce(new Error('commentListForbidden')) + + const { YouTubeClient } = await import( + '@/lib/integrations/youtube/YouTubeClient' + ) + + const client = new YouTubeClient( + { + clientId: 'test-id', + clientSecret: 'test-secret', + accessToken: 'test-token', + refreshToken: 'test-refresh', + }, + mockPayload, + ) + + await expect( + client.getCommentReplies('comment-forbidden'), + ).rejects.toThrow('commentListForbidden') + }) +})