mirror of
https://github.com/complexcaresolutions/cms.c2sgmbh.git
synced 2026-03-17 19:44:12 +00:00
feat(youtube): add ChannelMetricsSyncService
Syncs channel-level statistics (subscribers, views, video count) from YouTube Data API to YouTubeChannels.currentMetrics fields for all active channels. Follows the same credential-loading pattern as existing sync services. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
065e75b014
commit
b52e668ecb
2 changed files with 508 additions and 0 deletions
143
src/lib/integrations/youtube/ChannelMetricsSyncService.ts
Normal file
143
src/lib/integrations/youtube/ChannelMetricsSyncService.ts
Normal file
|
|
@ -0,0 +1,143 @@
|
||||||
|
// src/lib/integrations/youtube/ChannelMetricsSyncService.ts
|
||||||
|
|
||||||
|
import type { Payload } from 'payload'
|
||||||
|
|
||||||
|
import { YouTubeClient } from './YouTubeClient.js'
|
||||||
|
|
||||||
|
interface ChannelMetricsSyncResult {
|
||||||
|
success: boolean
|
||||||
|
channelsSynced: number
|
||||||
|
errors: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Syncs channel-level statistics from YouTube API to YouTubeChannels.currentMetrics fields.
|
||||||
|
*
|
||||||
|
* For each active YouTube channel, fetches subscriber count, video count, and total views
|
||||||
|
* via the YouTube Data API and persists them in the database.
|
||||||
|
*/
|
||||||
|
export class ChannelMetricsSyncService {
|
||||||
|
private payload: Payload
|
||||||
|
|
||||||
|
constructor(payload: Payload) {
|
||||||
|
this.payload = payload
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sync metrics for all active YouTube channels.
|
||||||
|
*
|
||||||
|
* Finds every channel with status "active", resolves its linked social account
|
||||||
|
* for API credentials, calls getChannelStats, and updates currentMetrics.
|
||||||
|
*/
|
||||||
|
async syncAllChannels(): Promise<ChannelMetricsSyncResult> {
|
||||||
|
const result: ChannelMetricsSyncResult = {
|
||||||
|
success: false,
|
||||||
|
channelsSynced: 0,
|
||||||
|
errors: [],
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. Find all active YouTube channels
|
||||||
|
const channels = await this.payload.find({
|
||||||
|
collection: 'youtube-channels',
|
||||||
|
where: {
|
||||||
|
status: { equals: 'active' },
|
||||||
|
},
|
||||||
|
limit: 0,
|
||||||
|
depth: 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (channels.docs.length === 0) {
|
||||||
|
result.success = true
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Process each channel
|
||||||
|
for (const channel of channels.docs) {
|
||||||
|
const channelName = (channel as any).name ?? `ID ${channel.id}`
|
||||||
|
const youtubeChannelId = (channel as any).youtubeChannelId as string | undefined
|
||||||
|
|
||||||
|
if (!youtubeChannelId) {
|
||||||
|
result.errors.push(`Channel "${channelName}" has no youtubeChannelId, skipping`)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.syncSingleChannel(channel, youtubeChannelId)
|
||||||
|
result.channelsSynced++
|
||||||
|
} catch (error) {
|
||||||
|
result.errors.push(`Failed to sync channel "${channelName}": ${error}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result.success = result.errors.length === 0
|
||||||
|
} catch (error) {
|
||||||
|
result.errors.push(`Sync error: ${error}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sync metrics for a single YouTube channel.
|
||||||
|
*/
|
||||||
|
private async syncSingleChannel(
|
||||||
|
channel: { id: number | string },
|
||||||
|
youtubeChannelId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
// 1. Find the social account linked to this channel
|
||||||
|
const socialAccounts = await this.payload.find({
|
||||||
|
collection: 'social-accounts',
|
||||||
|
where: {
|
||||||
|
linkedChannel: { equals: channel.id },
|
||||||
|
isActive: { equals: true },
|
||||||
|
},
|
||||||
|
limit: 1,
|
||||||
|
})
|
||||||
|
|
||||||
|
const account = socialAccounts.docs[0]
|
||||||
|
if (!account) {
|
||||||
|
throw new Error(`No active social account found for channel ${channel.id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Validate credentials
|
||||||
|
const credentials = account.credentials as {
|
||||||
|
accessToken?: string
|
||||||
|
refreshToken?: string
|
||||||
|
} | undefined
|
||||||
|
|
||||||
|
if (!credentials?.accessToken || !credentials?.refreshToken) {
|
||||||
|
throw new Error('No valid API credentials on social account')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Initialize YouTube client
|
||||||
|
const youtubeClient = new YouTubeClient(
|
||||||
|
{
|
||||||
|
clientId: process.env.GOOGLE_CLIENT_ID!,
|
||||||
|
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
|
||||||
|
accessToken: credentials.accessToken,
|
||||||
|
refreshToken: credentials.refreshToken,
|
||||||
|
},
|
||||||
|
this.payload,
|
||||||
|
)
|
||||||
|
|
||||||
|
// 4. Fetch channel statistics from YouTube
|
||||||
|
const stats = await youtubeClient.getChannelStats(youtubeChannelId)
|
||||||
|
|
||||||
|
// 5. Update currentMetrics fields
|
||||||
|
await this.payload.update({
|
||||||
|
collection: 'youtube-channels',
|
||||||
|
id: channel.id,
|
||||||
|
data: {
|
||||||
|
currentMetrics: {
|
||||||
|
subscriberCount: stats.subscriberCount,
|
||||||
|
totalViews: stats.viewCount,
|
||||||
|
videoCount: stats.videoCount,
|
||||||
|
lastSyncedAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type { ChannelMetricsSyncResult }
|
||||||
365
tests/unit/youtube/channel-metrics-sync.unit.spec.ts
Normal file
365
tests/unit/youtube/channel-metrics-sync.unit.spec.ts
Normal file
|
|
@ -0,0 +1,365 @@
|
||||||
|
/**
|
||||||
|
* ChannelMetricsSyncService Unit Tests
|
||||||
|
*
|
||||||
|
* Tests the service that syncs channel-level statistics from YouTube API
|
||||||
|
* to YouTubeChannels.currentMetrics fields.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
|
||||||
|
// Mock YouTubeClient before importing the service
|
||||||
|
const mockGetChannelStats = vi.fn()
|
||||||
|
|
||||||
|
vi.mock('googleapis', () => {
|
||||||
|
class MockOAuth2 {
|
||||||
|
setCredentials(): void {
|
||||||
|
// no-op
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
google: {
|
||||||
|
auth: {
|
||||||
|
OAuth2: MockOAuth2,
|
||||||
|
},
|
||||||
|
youtube: () => ({
|
||||||
|
channels: { list: vi.fn() },
|
||||||
|
videos: { list: vi.fn() },
|
||||||
|
commentThreads: { list: vi.fn() },
|
||||||
|
comments: {
|
||||||
|
insert: vi.fn(),
|
||||||
|
setModerationStatus: vi.fn(),
|
||||||
|
delete: vi.fn(),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Patch getChannelStats on the prototype after mocking googleapis
|
||||||
|
vi.mock('@/lib/integrations/youtube/YouTubeClient', async (importOriginal) => {
|
||||||
|
const mod = await importOriginal<typeof import('@/lib/integrations/youtube/YouTubeClient')>()
|
||||||
|
const OriginalClient = mod.YouTubeClient
|
||||||
|
|
||||||
|
class MockedYouTubeClient extends OriginalClient {
|
||||||
|
async getChannelStats(channelId: string) {
|
||||||
|
return mockGetChannelStats(channelId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...mod,
|
||||||
|
YouTubeClient: MockedYouTubeClient,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
vi.stubEnv('GOOGLE_CLIENT_ID', 'test-client-id')
|
||||||
|
vi.stubEnv('GOOGLE_CLIENT_SECRET', 'test-client-secret')
|
||||||
|
|
||||||
|
// --- Payload mock ---
|
||||||
|
|
||||||
|
function createMockPayload(overrides: {
|
||||||
|
channels?: any[]
|
||||||
|
socialAccounts?: any[]
|
||||||
|
}) {
|
||||||
|
const { channels = [], socialAccounts = [] } = overrides
|
||||||
|
const updatedDocs: Array<{ collection: string; id: any; data: any }> = []
|
||||||
|
|
||||||
|
return {
|
||||||
|
find: vi.fn(async ({ collection, where }: any) => {
|
||||||
|
if (collection === 'youtube-channels') {
|
||||||
|
return { docs: channels, totalDocs: channels.length }
|
||||||
|
}
|
||||||
|
if (collection === 'social-accounts') {
|
||||||
|
const linkedId = where?.linkedChannel?.equals
|
||||||
|
const matching = socialAccounts.filter(
|
||||||
|
(a: any) => a.linkedChannel === linkedId && a.isActive,
|
||||||
|
)
|
||||||
|
return { docs: matching, totalDocs: matching.length }
|
||||||
|
}
|
||||||
|
return { docs: [], totalDocs: 0 }
|
||||||
|
}),
|
||||||
|
update: vi.fn(async ({ collection, id, data }: any) => {
|
||||||
|
updatedDocs.push({ collection, id, data })
|
||||||
|
return { id, ...data }
|
||||||
|
}),
|
||||||
|
_updatedDocs: updatedDocs,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('ChannelMetricsSyncService', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should sync metrics for active channels with valid credentials', async () => {
|
||||||
|
const mockPayload = createMockPayload({
|
||||||
|
channels: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: 'BlogWoman',
|
||||||
|
youtubeChannelId: 'UC_test_123',
|
||||||
|
status: 'active',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
socialAccounts: [
|
||||||
|
{
|
||||||
|
id: 10,
|
||||||
|
linkedChannel: 1,
|
||||||
|
isActive: true,
|
||||||
|
credentials: {
|
||||||
|
accessToken: 'token-abc',
|
||||||
|
refreshToken: 'refresh-xyz',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
mockGetChannelStats.mockResolvedValueOnce({
|
||||||
|
subscriberCount: 15000,
|
||||||
|
videoCount: 245,
|
||||||
|
viewCount: 3200000,
|
||||||
|
})
|
||||||
|
|
||||||
|
const { ChannelMetricsSyncService } = await import(
|
||||||
|
'@/lib/integrations/youtube/ChannelMetricsSyncService'
|
||||||
|
)
|
||||||
|
|
||||||
|
const service = new ChannelMetricsSyncService(mockPayload as any)
|
||||||
|
const result = await service.syncAllChannels()
|
||||||
|
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
expect(result.channelsSynced).toBe(1)
|
||||||
|
expect(result.errors).toHaveLength(0)
|
||||||
|
|
||||||
|
expect(mockGetChannelStats).toHaveBeenCalledWith('UC_test_123')
|
||||||
|
|
||||||
|
expect(mockPayload.update).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
collection: 'youtube-channels',
|
||||||
|
id: 1,
|
||||||
|
data: expect.objectContaining({
|
||||||
|
currentMetrics: expect.objectContaining({
|
||||||
|
subscriberCount: 15000,
|
||||||
|
totalViews: 3200000,
|
||||||
|
videoCount: 245,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Verify lastSyncedAt is an ISO string
|
||||||
|
const updateCall = mockPayload.update.mock.calls[0][0]
|
||||||
|
expect(updateCall.data.currentMetrics.lastSyncedAt).toBeDefined()
|
||||||
|
expect(new Date(updateCall.data.currentMetrics.lastSyncedAt).toISOString()).toBe(
|
||||||
|
updateCall.data.currentMetrics.lastSyncedAt,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should skip channels without youtubeChannelId', async () => {
|
||||||
|
const mockPayload = createMockPayload({
|
||||||
|
channels: [
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
name: 'Planned Channel',
|
||||||
|
youtubeChannelId: undefined,
|
||||||
|
status: 'active',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
socialAccounts: [],
|
||||||
|
})
|
||||||
|
|
||||||
|
const { ChannelMetricsSyncService } = await import(
|
||||||
|
'@/lib/integrations/youtube/ChannelMetricsSyncService'
|
||||||
|
)
|
||||||
|
|
||||||
|
const service = new ChannelMetricsSyncService(mockPayload as any)
|
||||||
|
const result = await service.syncAllChannels()
|
||||||
|
|
||||||
|
expect(result.channelsSynced).toBe(0)
|
||||||
|
expect(result.errors).toHaveLength(1)
|
||||||
|
expect(result.errors[0]).toContain('has no youtubeChannelId')
|
||||||
|
expect(mockGetChannelStats).not.toHaveBeenCalled()
|
||||||
|
expect(mockPayload.update).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return success with zero synced when no active channels exist', async () => {
|
||||||
|
const mockPayload = createMockPayload({
|
||||||
|
channels: [],
|
||||||
|
socialAccounts: [],
|
||||||
|
})
|
||||||
|
|
||||||
|
const { ChannelMetricsSyncService } = await import(
|
||||||
|
'@/lib/integrations/youtube/ChannelMetricsSyncService'
|
||||||
|
)
|
||||||
|
|
||||||
|
const service = new ChannelMetricsSyncService(mockPayload as any)
|
||||||
|
const result = await service.syncAllChannels()
|
||||||
|
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
expect(result.channelsSynced).toBe(0)
|
||||||
|
expect(result.errors).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should record error when no social account is linked to channel', async () => {
|
||||||
|
const mockPayload = createMockPayload({
|
||||||
|
channels: [
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
name: 'Orphan Channel',
|
||||||
|
youtubeChannelId: 'UC_orphan',
|
||||||
|
status: 'active',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
socialAccounts: [],
|
||||||
|
})
|
||||||
|
|
||||||
|
const { ChannelMetricsSyncService } = await import(
|
||||||
|
'@/lib/integrations/youtube/ChannelMetricsSyncService'
|
||||||
|
)
|
||||||
|
|
||||||
|
const service = new ChannelMetricsSyncService(mockPayload as any)
|
||||||
|
const result = await service.syncAllChannels()
|
||||||
|
|
||||||
|
expect(result.channelsSynced).toBe(0)
|
||||||
|
expect(result.errors).toHaveLength(1)
|
||||||
|
expect(result.errors[0]).toContain('No active social account')
|
||||||
|
expect(mockPayload.update).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should record error when credentials are missing', async () => {
|
||||||
|
const mockPayload = createMockPayload({
|
||||||
|
channels: [
|
||||||
|
{
|
||||||
|
id: 4,
|
||||||
|
name: 'No Creds Channel',
|
||||||
|
youtubeChannelId: 'UC_nocreds',
|
||||||
|
status: 'active',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
socialAccounts: [
|
||||||
|
{
|
||||||
|
id: 20,
|
||||||
|
linkedChannel: 4,
|
||||||
|
isActive: true,
|
||||||
|
credentials: {
|
||||||
|
accessToken: undefined,
|
||||||
|
refreshToken: undefined,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
const { ChannelMetricsSyncService } = await import(
|
||||||
|
'@/lib/integrations/youtube/ChannelMetricsSyncService'
|
||||||
|
)
|
||||||
|
|
||||||
|
const service = new ChannelMetricsSyncService(mockPayload as any)
|
||||||
|
const result = await service.syncAllChannels()
|
||||||
|
|
||||||
|
expect(result.channelsSynced).toBe(0)
|
||||||
|
expect(result.errors).toHaveLength(1)
|
||||||
|
expect(result.errors[0]).toContain('No valid API credentials')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle YouTube API errors gracefully per channel', async () => {
|
||||||
|
const mockPayload = createMockPayload({
|
||||||
|
channels: [
|
||||||
|
{
|
||||||
|
id: 5,
|
||||||
|
name: 'Failing Channel',
|
||||||
|
youtubeChannelId: 'UC_fail',
|
||||||
|
status: 'active',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 6,
|
||||||
|
name: 'Working Channel',
|
||||||
|
youtubeChannelId: 'UC_works',
|
||||||
|
status: 'active',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
socialAccounts: [
|
||||||
|
{
|
||||||
|
id: 30,
|
||||||
|
linkedChannel: 5,
|
||||||
|
isActive: true,
|
||||||
|
credentials: { accessToken: 'tok', refreshToken: 'ref' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 31,
|
||||||
|
linkedChannel: 6,
|
||||||
|
isActive: true,
|
||||||
|
credentials: { accessToken: 'tok2', refreshToken: 'ref2' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
mockGetChannelStats
|
||||||
|
.mockRejectedValueOnce(new Error('API quota exceeded'))
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
subscriberCount: 500,
|
||||||
|
videoCount: 20,
|
||||||
|
viewCount: 100000,
|
||||||
|
})
|
||||||
|
|
||||||
|
const { ChannelMetricsSyncService } = await import(
|
||||||
|
'@/lib/integrations/youtube/ChannelMetricsSyncService'
|
||||||
|
)
|
||||||
|
|
||||||
|
const service = new ChannelMetricsSyncService(mockPayload as any)
|
||||||
|
const result = await service.syncAllChannels()
|
||||||
|
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
expect(result.channelsSynced).toBe(1)
|
||||||
|
expect(result.errors).toHaveLength(1)
|
||||||
|
expect(result.errors[0]).toContain('API quota exceeded')
|
||||||
|
|
||||||
|
// The working channel should still have been updated
|
||||||
|
expect(mockPayload.update).toHaveBeenCalledTimes(1)
|
||||||
|
expect(mockPayload.update).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
collection: 'youtube-channels',
|
||||||
|
id: 6,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should sync multiple channels in a single call', async () => {
|
||||||
|
const mockPayload = createMockPayload({
|
||||||
|
channels: [
|
||||||
|
{ id: 7, name: 'Channel A', youtubeChannelId: 'UC_a', status: 'active' },
|
||||||
|
{ id: 8, name: 'Channel B', youtubeChannelId: 'UC_b', status: 'active' },
|
||||||
|
],
|
||||||
|
socialAccounts: [
|
||||||
|
{
|
||||||
|
id: 40,
|
||||||
|
linkedChannel: 7,
|
||||||
|
isActive: true,
|
||||||
|
credentials: { accessToken: 'a_tok', refreshToken: 'a_ref' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 41,
|
||||||
|
linkedChannel: 8,
|
||||||
|
isActive: true,
|
||||||
|
credentials: { accessToken: 'b_tok', refreshToken: 'b_ref' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
mockGetChannelStats
|
||||||
|
.mockResolvedValueOnce({ subscriberCount: 1000, videoCount: 50, viewCount: 200000 })
|
||||||
|
.mockResolvedValueOnce({ subscriberCount: 2000, videoCount: 100, viewCount: 400000 })
|
||||||
|
|
||||||
|
const { ChannelMetricsSyncService } = await import(
|
||||||
|
'@/lib/integrations/youtube/ChannelMetricsSyncService'
|
||||||
|
)
|
||||||
|
|
||||||
|
const service = new ChannelMetricsSyncService(mockPayload as any)
|
||||||
|
const result = await service.syncAllChannels()
|
||||||
|
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
expect(result.channelsSynced).toBe(2)
|
||||||
|
expect(result.errors).toHaveLength(0)
|
||||||
|
expect(mockPayload.update).toHaveBeenCalledTimes(2)
|
||||||
|
})
|
||||||
|
})
|
||||||
Loading…
Reference in a new issue