feat(youtube): add reply thread import to comment sync

Add getCommentReplies method to YouTubeClient for fetching reply threads
via the YouTube comments.list API. Modify CommentsSyncService to import
reply threads during sync, storing them as type 'reply' with
parentInteraction relationship in community-interactions.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Martin Porwoll 2026-02-14 13:28:33 +00:00
parent 13507d1361
commit 289b69380f
3 changed files with 410 additions and 5 deletions

View file

@ -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<number> {
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
}
}

View file

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

View file

@ -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')
})
})