mirror of
https://github.com/complexcaresolutions/cms.c2sgmbh.git
synced 2026-03-17 17:24:12 +00:00
feat(community): Phase 2.4 - Unified Sync Service
Implements a unified sync service that orchestrates comment synchronization across all social media platforms. UnifiedSyncService: - Platform-agnostic sync orchestration - Support for YouTube, Facebook, and Instagram - Parallel platform detection and account grouping - Progress tracking with live status updates - Aggregated results per platform - Error handling with partial results support New API Endpoints: - GET/POST /api/cron/community-sync - Cron endpoint for scheduled multi-platform sync - Query params: platforms, accountIds, analyzeWithAI, maxItems - HEAD for monitoring status - GET /api/community/sync-status - Live sync status for dashboard - Platform overview with account details - Interaction statistics (total, today, unanswered) - Last sync result summary Configuration: - vercel.json updated to use community-sync cron - 15-minute sync interval maintained Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
b107d60183
commit
89882a545b
4 changed files with 916 additions and 1 deletions
158
src/app/(payload)/api/community/sync-status/route.ts
Normal file
158
src/app/(payload)/api/community/sync-status/route.ts
Normal file
|
|
@ -0,0 +1,158 @@
|
||||||
|
// src/app/(payload)/api/community/sync-status/route.ts
|
||||||
|
// Sync Status API für Dashboard
|
||||||
|
|
||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { getPayload } from 'payload'
|
||||||
|
import config from '@payload-config'
|
||||||
|
import { getUnifiedSyncStatus } from '@/lib/jobs/UnifiedSyncService'
|
||||||
|
import { getSyncStatus as getYouTubeSyncStatus } from '@/lib/jobs/syncAllComments'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/community/sync-status
|
||||||
|
* Gibt den aktuellen Sync-Status für alle Plattformen zurück
|
||||||
|
*/
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const payload = await getPayload({ config })
|
||||||
|
|
||||||
|
// Authentifizierung prüfen
|
||||||
|
const { user } = await payload.auth({ headers: request.headers })
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sync-Status abrufen
|
||||||
|
const unifiedStatus = getUnifiedSyncStatus()
|
||||||
|
const youtubeStatus = getYouTubeSyncStatus()
|
||||||
|
|
||||||
|
// Account-Statistiken laden
|
||||||
|
const accounts = await payload.find({
|
||||||
|
collection: 'social-accounts',
|
||||||
|
where: {
|
||||||
|
isActive: { equals: true },
|
||||||
|
},
|
||||||
|
limit: 100,
|
||||||
|
depth: 2,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Nach Plattform aggregieren
|
||||||
|
const platformStats: Record<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
platform: string
|
||||||
|
accountCount: number
|
||||||
|
accounts: Array<{
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
lastSyncedAt: string | null
|
||||||
|
followers: number
|
||||||
|
totalPosts: number
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
> = {}
|
||||||
|
|
||||||
|
for (const account of accounts.docs) {
|
||||||
|
const platformDoc = account.platform as { slug?: string; name?: string } | undefined
|
||||||
|
const platformSlug = platformDoc?.slug || 'unknown'
|
||||||
|
const platformName = platformDoc?.name || platformSlug
|
||||||
|
|
||||||
|
if (!platformStats[platformSlug]) {
|
||||||
|
platformStats[platformSlug] = {
|
||||||
|
platform: platformName,
|
||||||
|
accountCount: 0,
|
||||||
|
accounts: [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const stats = account.stats as {
|
||||||
|
lastSyncedAt?: string
|
||||||
|
followers?: number
|
||||||
|
totalPosts?: number
|
||||||
|
} | undefined
|
||||||
|
|
||||||
|
platformStats[platformSlug].accountCount++
|
||||||
|
platformStats[platformSlug].accounts.push({
|
||||||
|
id: account.id as number,
|
||||||
|
name: (account.displayName as string) || `Account ${account.id}`,
|
||||||
|
lastSyncedAt: stats?.lastSyncedAt || null,
|
||||||
|
followers: stats?.followers || 0,
|
||||||
|
totalPosts: stats?.totalPosts || 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Interaktions-Statistiken
|
||||||
|
const interactionStats = await payload.find({
|
||||||
|
collection: 'community-interactions',
|
||||||
|
limit: 0, // Nur Count
|
||||||
|
})
|
||||||
|
|
||||||
|
// Neue Interaktionen heute
|
||||||
|
const todayStart = new Date()
|
||||||
|
todayStart.setHours(0, 0, 0, 0)
|
||||||
|
|
||||||
|
const todayInteractions = await payload.find({
|
||||||
|
collection: 'community-interactions',
|
||||||
|
where: {
|
||||||
|
createdAt: { greater_than: todayStart.toISOString() },
|
||||||
|
},
|
||||||
|
limit: 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Unbeantwortete Interaktionen
|
||||||
|
const unansweredInteractions = await payload.find({
|
||||||
|
collection: 'community-interactions',
|
||||||
|
where: {
|
||||||
|
status: { in: ['new', 'in_review'] },
|
||||||
|
},
|
||||||
|
limit: 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
// Live Sync-Status
|
||||||
|
syncStatus: {
|
||||||
|
isRunning: unifiedStatus.isRunning || youtubeStatus.isRunning,
|
||||||
|
currentPlatform: unifiedStatus.currentPlatform,
|
||||||
|
currentAccount: unifiedStatus.currentAccount,
|
||||||
|
progress: unifiedStatus.progress,
|
||||||
|
lastRunAt: unifiedStatus.lastRunAt || youtubeStatus.lastRunAt,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Plattform-Übersicht
|
||||||
|
platforms: Object.values(platformStats),
|
||||||
|
|
||||||
|
// Interaktions-Statistiken
|
||||||
|
interactions: {
|
||||||
|
total: interactionStats.totalDocs,
|
||||||
|
today: todayInteractions.totalDocs,
|
||||||
|
unanswered: unansweredInteractions.totalDocs,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Letztes Sync-Ergebnis
|
||||||
|
lastSyncResult: unifiedStatus.lastResult
|
||||||
|
? {
|
||||||
|
success: unifiedStatus.lastResult.success,
|
||||||
|
startedAt: unifiedStatus.lastResult.startedAt,
|
||||||
|
completedAt: unifiedStatus.lastResult.completedAt,
|
||||||
|
duration: unifiedStatus.lastResult.duration,
|
||||||
|
platforms: unifiedStatus.lastResult.platforms,
|
||||||
|
totalNewComments: unifiedStatus.lastResult.results.reduce(
|
||||||
|
(sum, r) => sum + r.newComments,
|
||||||
|
0
|
||||||
|
),
|
||||||
|
totalUpdatedComments: unifiedStatus.lastResult.results.reduce(
|
||||||
|
(sum, r) => sum + r.updatedComments,
|
||||||
|
0
|
||||||
|
),
|
||||||
|
totalErrors: unifiedStatus.lastResult.errors.length,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Unknown error'
|
||||||
|
console.error('[API] sync-status error:', error)
|
||||||
|
return NextResponse.json({ error: message }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic'
|
||||||
203
src/app/(payload)/api/cron/community-sync/route.ts
Normal file
203
src/app/(payload)/api/cron/community-sync/route.ts
Normal file
|
|
@ -0,0 +1,203 @@
|
||||||
|
// src/app/(payload)/api/cron/community-sync/route.ts
|
||||||
|
// Unified Community Sync Cron Endpoint
|
||||||
|
|
||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import {
|
||||||
|
runUnifiedSync,
|
||||||
|
getUnifiedSyncStatus,
|
||||||
|
type SupportedPlatform,
|
||||||
|
type UnifiedSyncOptions,
|
||||||
|
} from '@/lib/jobs/UnifiedSyncService'
|
||||||
|
|
||||||
|
// Geheimer Token für Cron-Authentifizierung
|
||||||
|
const CRON_SECRET = process.env.CRON_SECRET
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/cron/community-sync
|
||||||
|
* Wird von externem Cron-Job aufgerufen (z.B. Vercel Cron, cron-job.org)
|
||||||
|
*
|
||||||
|
* Query Parameters:
|
||||||
|
* - platforms: Komma-separierte Liste (youtube,facebook,instagram)
|
||||||
|
* - accountIds: Komma-separierte Account-IDs
|
||||||
|
* - analyzeWithAI: true/false (default: true)
|
||||||
|
* - maxItems: Maximale Items pro Account (default: 100)
|
||||||
|
*/
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
// Auth prüfen wenn CRON_SECRET gesetzt
|
||||||
|
if (CRON_SECRET) {
|
||||||
|
const authHeader = request.headers.get('authorization')
|
||||||
|
|
||||||
|
if (authHeader !== `Bearer ${CRON_SECRET}`) {
|
||||||
|
console.warn('[Cron] Unauthorized request to community-sync')
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Query-Parameter parsen
|
||||||
|
const searchParams = request.nextUrl.searchParams
|
||||||
|
const options: UnifiedSyncOptions = {}
|
||||||
|
|
||||||
|
// Plattform-Filter
|
||||||
|
const platformsParam = searchParams.get('platforms')
|
||||||
|
if (platformsParam) {
|
||||||
|
const platforms = platformsParam.split(',').map((p) => p.trim().toLowerCase())
|
||||||
|
const validPlatforms = platforms.filter((p): p is SupportedPlatform =>
|
||||||
|
['youtube', 'facebook', 'instagram'].includes(p)
|
||||||
|
)
|
||||||
|
if (validPlatforms.length > 0) {
|
||||||
|
options.platforms = validPlatforms
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Account-ID Filter
|
||||||
|
const accountIdsParam = searchParams.get('accountIds')
|
||||||
|
if (accountIdsParam) {
|
||||||
|
const ids = accountIdsParam
|
||||||
|
.split(',')
|
||||||
|
.map((id) => parseInt(id.trim(), 10))
|
||||||
|
.filter((id) => !isNaN(id))
|
||||||
|
if (ids.length > 0) {
|
||||||
|
options.accountIds = ids
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AI-Analyse
|
||||||
|
const analyzeParam = searchParams.get('analyzeWithAI')
|
||||||
|
if (analyzeParam !== null) {
|
||||||
|
options.analyzeWithAI = analyzeParam !== 'false'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Max Items
|
||||||
|
const maxItemsParam = searchParams.get('maxItems')
|
||||||
|
if (maxItemsParam) {
|
||||||
|
const maxItems = parseInt(maxItemsParam, 10)
|
||||||
|
if (!isNaN(maxItems) && maxItems > 0) {
|
||||||
|
options.maxItemsPerAccount = maxItems
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[Cron] Starting community sync', { options })
|
||||||
|
|
||||||
|
const result = await runUnifiedSync(options)
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
// Gesamtstatistiken berechnen
|
||||||
|
let totalNew = 0
|
||||||
|
let totalUpdated = 0
|
||||||
|
let totalErrors = 0
|
||||||
|
|
||||||
|
for (const platform of Object.values(result.platforms)) {
|
||||||
|
if (platform) {
|
||||||
|
totalNew += platform.totalNewComments
|
||||||
|
totalUpdated += platform.totalUpdatedComments
|
||||||
|
totalErrors += platform.totalErrors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
message: `Sync completed: ${totalNew} new, ${totalUpdated} updated comments across ${result.results.length} accounts`,
|
||||||
|
duration: result.duration,
|
||||||
|
platforms: result.platforms,
|
||||||
|
results: result.results,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
errors: result.errors,
|
||||||
|
partialResults: result.results,
|
||||||
|
},
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/cron/community-sync
|
||||||
|
* Manueller Sync-Trigger mit erweiterten Optionen
|
||||||
|
*/
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
// Auth prüfen
|
||||||
|
if (CRON_SECRET) {
|
||||||
|
const authHeader = request.headers.get('authorization')
|
||||||
|
|
||||||
|
if (authHeader !== `Bearer ${CRON_SECRET}`) {
|
||||||
|
console.warn('[Cron] Unauthorized POST to community-sync')
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const body = await request.json()
|
||||||
|
const options: UnifiedSyncOptions = {}
|
||||||
|
|
||||||
|
// Optionen aus Body übernehmen
|
||||||
|
if (body.platforms && Array.isArray(body.platforms)) {
|
||||||
|
options.platforms = body.platforms.filter((p: string): p is SupportedPlatform =>
|
||||||
|
['youtube', 'facebook', 'instagram'].includes(p)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body.accountIds && Array.isArray(body.accountIds)) {
|
||||||
|
options.accountIds = body.accountIds.filter(
|
||||||
|
(id: unknown) => typeof id === 'number' && id > 0
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof body.analyzeWithAI === 'boolean') {
|
||||||
|
options.analyzeWithAI = body.analyzeWithAI
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof body.maxItemsPerAccount === 'number' && body.maxItemsPerAccount > 0) {
|
||||||
|
options.maxItemsPerAccount = body.maxItemsPerAccount
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body.sinceDate) {
|
||||||
|
const date = new Date(body.sinceDate)
|
||||||
|
if (!isNaN(date.getTime())) {
|
||||||
|
options.sinceDate = date
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[Cron] Manual community sync triggered', { options })
|
||||||
|
|
||||||
|
const result = await runUnifiedSync(options)
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: result.success,
|
||||||
|
startedAt: result.startedAt,
|
||||||
|
completedAt: result.completedAt,
|
||||||
|
duration: result.duration,
|
||||||
|
platforms: result.platforms,
|
||||||
|
results: result.results,
|
||||||
|
errors: result.errors,
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Unknown error'
|
||||||
|
console.error('[Cron] POST error:', error)
|
||||||
|
return NextResponse.json({ error: message }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HEAD /api/cron/community-sync
|
||||||
|
* Status-Check für Monitoring
|
||||||
|
*/
|
||||||
|
export async function HEAD() {
|
||||||
|
const status = getUnifiedSyncStatus()
|
||||||
|
|
||||||
|
return new NextResponse(null, {
|
||||||
|
status: status.isRunning ? 423 : 200, // 423 = Locked (running)
|
||||||
|
headers: {
|
||||||
|
'X-Sync-Running': status.isRunning.toString(),
|
||||||
|
'X-Current-Platform': status.currentPlatform || 'none',
|
||||||
|
'X-Current-Account': status.currentAccount || 'none',
|
||||||
|
'X-Progress': `${status.progress.percentage}%`,
|
||||||
|
'X-Last-Run': status.lastRunAt || 'never',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic'
|
||||||
|
export const maxDuration = 300 // 5 Minuten max (Vercel)
|
||||||
554
src/lib/jobs/UnifiedSyncService.ts
Normal file
554
src/lib/jobs/UnifiedSyncService.ts
Normal file
|
|
@ -0,0 +1,554 @@
|
||||||
|
// src/lib/jobs/UnifiedSyncService.ts
|
||||||
|
// Unified Sync Service für alle Social Media Plattformen
|
||||||
|
|
||||||
|
import { getPayload, Payload } from 'payload'
|
||||||
|
import config from '@payload-config'
|
||||||
|
import { CommentsSyncService } from '../integrations/youtube/CommentsSyncService'
|
||||||
|
import { FacebookSyncService } from '../integrations/meta/FacebookSyncService'
|
||||||
|
import { InstagramSyncService } from '../integrations/meta/InstagramSyncService'
|
||||||
|
import { JobLogger } from './JobLogger'
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Types
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
export type SupportedPlatform = 'youtube' | 'facebook' | 'instagram'
|
||||||
|
|
||||||
|
export interface PlatformSyncResult {
|
||||||
|
platform: SupportedPlatform
|
||||||
|
accountId: number
|
||||||
|
accountName: string
|
||||||
|
success: boolean
|
||||||
|
newComments: number
|
||||||
|
updatedComments: number
|
||||||
|
newMessages: number
|
||||||
|
postsProcessed: number
|
||||||
|
errors: string[]
|
||||||
|
duration: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UnifiedSyncResult {
|
||||||
|
success: boolean
|
||||||
|
startedAt: Date
|
||||||
|
completedAt: Date
|
||||||
|
duration: number
|
||||||
|
platforms: {
|
||||||
|
[key in SupportedPlatform]?: {
|
||||||
|
accountsProcessed: number
|
||||||
|
totalNewComments: number
|
||||||
|
totalUpdatedComments: number
|
||||||
|
totalErrors: number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
results: PlatformSyncResult[]
|
||||||
|
errors: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UnifiedSyncOptions {
|
||||||
|
/** Nur bestimmte Plattformen synchronisieren */
|
||||||
|
platforms?: SupportedPlatform[]
|
||||||
|
/** Nur bestimmte Account-IDs synchronisieren */
|
||||||
|
accountIds?: number[]
|
||||||
|
/** AI-Analyse aktivieren */
|
||||||
|
analyzeWithAI?: boolean
|
||||||
|
/** Maximale Items pro Account */
|
||||||
|
maxItemsPerAccount?: number
|
||||||
|
/** Nur seit diesem Datum synchronisieren */
|
||||||
|
sinceDate?: Date
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SyncStatus {
|
||||||
|
isRunning: boolean
|
||||||
|
currentPlatform: SupportedPlatform | null
|
||||||
|
currentAccount: string | null
|
||||||
|
progress: {
|
||||||
|
totalAccounts: number
|
||||||
|
processedAccounts: number
|
||||||
|
percentage: number
|
||||||
|
}
|
||||||
|
lastRunAt: string | null
|
||||||
|
lastResult: UnifiedSyncResult | null
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Global State
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
let syncStatus: SyncStatus = {
|
||||||
|
isRunning: false,
|
||||||
|
currentPlatform: null,
|
||||||
|
currentAccount: null,
|
||||||
|
progress: {
|
||||||
|
totalAccounts: 0,
|
||||||
|
processedAccounts: 0,
|
||||||
|
percentage: 0,
|
||||||
|
},
|
||||||
|
lastRunAt: null,
|
||||||
|
lastResult: null,
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Platform Detection
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
function detectPlatform(platformDoc: any): SupportedPlatform | null {
|
||||||
|
const slug = platformDoc?.slug?.toLowerCase()
|
||||||
|
const apiType = platformDoc?.apiConfig?.apiType
|
||||||
|
|
||||||
|
if (slug === 'youtube' || apiType === 'youtube_v3') {
|
||||||
|
return 'youtube'
|
||||||
|
}
|
||||||
|
if (slug === 'facebook' || apiType === 'facebook_graph' || apiType === 'meta_graph') {
|
||||||
|
return 'facebook'
|
||||||
|
}
|
||||||
|
if (slug === 'instagram' || apiType === 'instagram_graph') {
|
||||||
|
return 'instagram'
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Unified Sync Service
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
export class UnifiedSyncService {
|
||||||
|
private payload: Payload
|
||||||
|
private logger: JobLogger
|
||||||
|
private youtubeSyncService: CommentsSyncService
|
||||||
|
private facebookSyncService: FacebookSyncService
|
||||||
|
private instagramSyncService: InstagramSyncService
|
||||||
|
|
||||||
|
constructor(payload: Payload) {
|
||||||
|
this.payload = payload
|
||||||
|
this.logger = new JobLogger('unified-sync')
|
||||||
|
this.youtubeSyncService = new CommentsSyncService(payload)
|
||||||
|
this.facebookSyncService = new FacebookSyncService(payload)
|
||||||
|
this.instagramSyncService = new InstagramSyncService(payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Führt einen vollständigen Sync aller Plattformen durch
|
||||||
|
*/
|
||||||
|
async runFullSync(options: UnifiedSyncOptions = {}): Promise<UnifiedSyncResult> {
|
||||||
|
const {
|
||||||
|
platforms,
|
||||||
|
accountIds,
|
||||||
|
analyzeWithAI = true,
|
||||||
|
maxItemsPerAccount = 100,
|
||||||
|
sinceDate,
|
||||||
|
} = options
|
||||||
|
|
||||||
|
const startedAt = new Date()
|
||||||
|
const results: PlatformSyncResult[] = []
|
||||||
|
const errors: string[] = []
|
||||||
|
|
||||||
|
// Prüfen ob bereits ein Sync läuft
|
||||||
|
if (syncStatus.isRunning) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
startedAt,
|
||||||
|
completedAt: new Date(),
|
||||||
|
duration: 0,
|
||||||
|
platforms: {},
|
||||||
|
results: [],
|
||||||
|
errors: ['Sync already running'],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
syncStatus.isRunning = true
|
||||||
|
syncStatus.progress = { totalAccounts: 0, processedAccounts: 0, percentage: 0 }
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.logger.info('Starting unified sync', { platforms, accountIds })
|
||||||
|
|
||||||
|
// Alle aktiven Accounts laden
|
||||||
|
const accounts = await this.loadActiveAccounts(platforms, accountIds)
|
||||||
|
syncStatus.progress.totalAccounts = accounts.length
|
||||||
|
|
||||||
|
this.logger.info(`Found ${accounts.length} active accounts to sync`)
|
||||||
|
|
||||||
|
if (accounts.length === 0) {
|
||||||
|
return this.createResult(startedAt, results, errors)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Accounts nach Plattform gruppieren
|
||||||
|
const accountsByPlatform = this.groupAccountsByPlatform(accounts)
|
||||||
|
|
||||||
|
// Jede Plattform synchronisieren
|
||||||
|
for (const [platform, platformAccounts] of Object.entries(accountsByPlatform)) {
|
||||||
|
syncStatus.currentPlatform = platform as SupportedPlatform
|
||||||
|
|
||||||
|
for (const account of platformAccounts) {
|
||||||
|
syncStatus.currentAccount = account.displayName || `Account ${account.id}`
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await this.syncAccount(
|
||||||
|
platform as SupportedPlatform,
|
||||||
|
account,
|
||||||
|
{
|
||||||
|
analyzeWithAI,
|
||||||
|
maxItems: maxItemsPerAccount,
|
||||||
|
sinceDate,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
results.push(result)
|
||||||
|
} catch (error) {
|
||||||
|
const errorMsg = error instanceof Error ? error.message : String(error)
|
||||||
|
errors.push(`${platform}/${account.id}: ${errorMsg}`)
|
||||||
|
this.logger.error(`Failed to sync account ${account.id}`, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
syncStatus.progress.processedAccounts++
|
||||||
|
syncStatus.progress.percentage = Math.round(
|
||||||
|
(syncStatus.progress.processedAccounts / syncStatus.progress.totalAccounts) * 100
|
||||||
|
)
|
||||||
|
|
||||||
|
// Rate Limiting zwischen Accounts
|
||||||
|
await this.sleep(500)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const finalResult = this.createResult(startedAt, results, errors)
|
||||||
|
syncStatus.lastResult = finalResult
|
||||||
|
syncStatus.lastRunAt = startedAt.toISOString()
|
||||||
|
|
||||||
|
this.logger.summary({
|
||||||
|
success: finalResult.success,
|
||||||
|
processed: results.length,
|
||||||
|
errors: errors.length,
|
||||||
|
})
|
||||||
|
|
||||||
|
return finalResult
|
||||||
|
} catch (error) {
|
||||||
|
const errorMsg = error instanceof Error ? error.message : String(error)
|
||||||
|
this.logger.error('Unified sync failed', error)
|
||||||
|
errors.push(errorMsg)
|
||||||
|
return this.createResult(startedAt, results, errors)
|
||||||
|
} finally {
|
||||||
|
syncStatus.isRunning = false
|
||||||
|
syncStatus.currentPlatform = null
|
||||||
|
syncStatus.currentAccount = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lädt alle aktiven Social Accounts
|
||||||
|
*/
|
||||||
|
private async loadActiveAccounts(
|
||||||
|
platforms?: SupportedPlatform[],
|
||||||
|
accountIds?: number[]
|
||||||
|
): Promise<any[]> {
|
||||||
|
const where: any = {
|
||||||
|
and: [{ isActive: { equals: true } }],
|
||||||
|
}
|
||||||
|
|
||||||
|
// Account-ID Filter
|
||||||
|
if (accountIds && accountIds.length > 0) {
|
||||||
|
where.and.push({
|
||||||
|
id: { in: accountIds },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const accounts = await this.payload.find({
|
||||||
|
collection: 'social-accounts',
|
||||||
|
where,
|
||||||
|
limit: 100,
|
||||||
|
depth: 2,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Plattform-Filter anwenden
|
||||||
|
if (platforms && platforms.length > 0) {
|
||||||
|
return accounts.docs.filter((account) => {
|
||||||
|
const platform = detectPlatform(account.platform)
|
||||||
|
return platform && platforms.includes(platform)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nur Accounts mit unterstützter Plattform zurückgeben
|
||||||
|
return accounts.docs.filter((account) => {
|
||||||
|
const platform = detectPlatform(account.platform)
|
||||||
|
return platform !== null
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gruppiert Accounts nach Plattform
|
||||||
|
*/
|
||||||
|
private groupAccountsByPlatform(accounts: any[]): Record<SupportedPlatform, any[]> {
|
||||||
|
const grouped: Record<SupportedPlatform, any[]> = {
|
||||||
|
youtube: [],
|
||||||
|
facebook: [],
|
||||||
|
instagram: [],
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const account of accounts) {
|
||||||
|
const platform = detectPlatform(account.platform)
|
||||||
|
if (platform) {
|
||||||
|
grouped[platform].push(account)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return grouped
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Synchronisiert einen einzelnen Account
|
||||||
|
*/
|
||||||
|
private async syncAccount(
|
||||||
|
platform: SupportedPlatform,
|
||||||
|
account: any,
|
||||||
|
options: {
|
||||||
|
analyzeWithAI: boolean
|
||||||
|
maxItems: number
|
||||||
|
sinceDate?: Date
|
||||||
|
}
|
||||||
|
): Promise<PlatformSyncResult> {
|
||||||
|
const { analyzeWithAI, maxItems, sinceDate } = options
|
||||||
|
const accountId = account.id as number
|
||||||
|
const accountName = (account.displayName as string) || `Account ${accountId}`
|
||||||
|
const startTime = Date.now()
|
||||||
|
|
||||||
|
this.logger.info(`Syncing ${platform} account: ${accountName}`)
|
||||||
|
|
||||||
|
// Default since date: letzte 7 Tage oder letzter Sync
|
||||||
|
const stats = account.stats as { lastSyncedAt?: string } | undefined
|
||||||
|
const effectiveSinceDate =
|
||||||
|
sinceDate ||
|
||||||
|
(stats?.lastSyncedAt
|
||||||
|
? new Date(stats.lastSyncedAt)
|
||||||
|
: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000))
|
||||||
|
|
||||||
|
try {
|
||||||
|
let result: PlatformSyncResult
|
||||||
|
|
||||||
|
switch (platform) {
|
||||||
|
case 'youtube':
|
||||||
|
result = await this.syncYouTubeAccount(accountId, accountName, {
|
||||||
|
sinceDate: effectiveSinceDate,
|
||||||
|
maxItems,
|
||||||
|
analyzeWithAI,
|
||||||
|
})
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'facebook':
|
||||||
|
result = await this.syncFacebookAccount(accountId, accountName, {
|
||||||
|
sinceDate: effectiveSinceDate,
|
||||||
|
maxItems,
|
||||||
|
analyzeWithAI,
|
||||||
|
})
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'instagram':
|
||||||
|
result = await this.syncInstagramAccount(accountId, accountName, {
|
||||||
|
sinceDate: effectiveSinceDate,
|
||||||
|
maxItems,
|
||||||
|
analyzeWithAI,
|
||||||
|
})
|
||||||
|
break
|
||||||
|
|
||||||
|
default:
|
||||||
|
throw new Error(`Unsupported platform: ${platform}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
result.duration = Date.now() - startTime
|
||||||
|
|
||||||
|
this.logger.info(
|
||||||
|
`${platform}/${accountName}: ${result.newComments} new, ${result.updatedComments} updated`
|
||||||
|
)
|
||||||
|
|
||||||
|
return result
|
||||||
|
} catch (error) {
|
||||||
|
const errorMsg = error instanceof Error ? error.message : String(error)
|
||||||
|
return {
|
||||||
|
platform,
|
||||||
|
accountId,
|
||||||
|
accountName,
|
||||||
|
success: false,
|
||||||
|
newComments: 0,
|
||||||
|
updatedComments: 0,
|
||||||
|
newMessages: 0,
|
||||||
|
postsProcessed: 0,
|
||||||
|
errors: [errorMsg],
|
||||||
|
duration: Date.now() - startTime,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* YouTube Account synchronisieren
|
||||||
|
*/
|
||||||
|
private async syncYouTubeAccount(
|
||||||
|
accountId: number,
|
||||||
|
accountName: string,
|
||||||
|
options: { sinceDate: Date; maxItems: number; analyzeWithAI: boolean }
|
||||||
|
): Promise<PlatformSyncResult> {
|
||||||
|
const syncResult = await this.youtubeSyncService.syncComments({
|
||||||
|
socialAccountId: accountId,
|
||||||
|
sinceDate: options.sinceDate,
|
||||||
|
maxComments: options.maxItems,
|
||||||
|
analyzeWithAI: options.analyzeWithAI,
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
platform: 'youtube',
|
||||||
|
accountId,
|
||||||
|
accountName,
|
||||||
|
success: syncResult.success,
|
||||||
|
newComments: syncResult.newComments,
|
||||||
|
updatedComments: syncResult.updatedComments,
|
||||||
|
newMessages: 0,
|
||||||
|
postsProcessed: 0,
|
||||||
|
errors: syncResult.errors,
|
||||||
|
duration: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Facebook Account synchronisieren
|
||||||
|
*/
|
||||||
|
private async syncFacebookAccount(
|
||||||
|
accountId: number,
|
||||||
|
accountName: string,
|
||||||
|
options: { sinceDate: Date; maxItems: number; analyzeWithAI: boolean }
|
||||||
|
): Promise<PlatformSyncResult> {
|
||||||
|
const syncResult = await this.facebookSyncService.syncComments({
|
||||||
|
socialAccountId: accountId,
|
||||||
|
sinceDate: options.sinceDate,
|
||||||
|
maxItems: options.maxItems,
|
||||||
|
analyzeWithAI: options.analyzeWithAI,
|
||||||
|
syncComments: true,
|
||||||
|
syncMessages: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
platform: 'facebook',
|
||||||
|
accountId,
|
||||||
|
accountName,
|
||||||
|
success: syncResult.errors.length === 0,
|
||||||
|
newComments: syncResult.newComments,
|
||||||
|
updatedComments: syncResult.updatedComments,
|
||||||
|
newMessages: syncResult.newMessages,
|
||||||
|
postsProcessed: syncResult.postsProcessed,
|
||||||
|
errors: syncResult.errors,
|
||||||
|
duration: syncResult.duration,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Instagram Account synchronisieren
|
||||||
|
*/
|
||||||
|
private async syncInstagramAccount(
|
||||||
|
accountId: number,
|
||||||
|
accountName: string,
|
||||||
|
options: { sinceDate: Date; maxItems: number; analyzeWithAI: boolean }
|
||||||
|
): Promise<PlatformSyncResult> {
|
||||||
|
const syncResult = await this.instagramSyncService.syncComments({
|
||||||
|
socialAccountId: accountId,
|
||||||
|
sinceDate: options.sinceDate,
|
||||||
|
maxItems: options.maxItems,
|
||||||
|
analyzeWithAI: options.analyzeWithAI,
|
||||||
|
syncComments: true,
|
||||||
|
syncMentions: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
platform: 'instagram',
|
||||||
|
accountId,
|
||||||
|
accountName,
|
||||||
|
success: syncResult.errors.length === 0,
|
||||||
|
newComments: syncResult.newComments,
|
||||||
|
updatedComments: syncResult.updatedComments,
|
||||||
|
newMessages: syncResult.newMessages,
|
||||||
|
postsProcessed: syncResult.postsProcessed,
|
||||||
|
errors: syncResult.errors,
|
||||||
|
duration: syncResult.duration,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erstellt das finale Sync-Ergebnis
|
||||||
|
*/
|
||||||
|
private createResult(
|
||||||
|
startedAt: Date,
|
||||||
|
results: PlatformSyncResult[],
|
||||||
|
errors: string[]
|
||||||
|
): UnifiedSyncResult {
|
||||||
|
const completedAt = new Date()
|
||||||
|
|
||||||
|
// Ergebnisse nach Plattform aggregieren
|
||||||
|
const platforms: UnifiedSyncResult['platforms'] = {}
|
||||||
|
|
||||||
|
for (const result of results) {
|
||||||
|
if (!platforms[result.platform]) {
|
||||||
|
platforms[result.platform] = {
|
||||||
|
accountsProcessed: 0,
|
||||||
|
totalNewComments: 0,
|
||||||
|
totalUpdatedComments: 0,
|
||||||
|
totalErrors: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
platforms[result.platform]!.accountsProcessed++
|
||||||
|
platforms[result.platform]!.totalNewComments += result.newComments
|
||||||
|
platforms[result.platform]!.totalUpdatedComments += result.updatedComments
|
||||||
|
platforms[result.platform]!.totalErrors += result.errors.length
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: errors.length === 0 && results.every((r) => r.success),
|
||||||
|
startedAt,
|
||||||
|
completedAt,
|
||||||
|
duration: completedAt.getTime() - startedAt.getTime(),
|
||||||
|
platforms,
|
||||||
|
results,
|
||||||
|
errors,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sleep(ms: number): Promise<void> {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Convenience Functions
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Führt einen vollständigen Sync aus (Singleton-Pattern)
|
||||||
|
*/
|
||||||
|
export async function runUnifiedSync(
|
||||||
|
options: UnifiedSyncOptions = {}
|
||||||
|
): Promise<UnifiedSyncResult> {
|
||||||
|
const payload = await getPayload({ config })
|
||||||
|
const service = new UnifiedSyncService(payload)
|
||||||
|
return service.runFullSync(options)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gibt den aktuellen Sync-Status zurück
|
||||||
|
*/
|
||||||
|
export function getUnifiedSyncStatus(): SyncStatus {
|
||||||
|
return { ...syncStatus }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setzt den Sync-Status zurück (für Tests)
|
||||||
|
*/
|
||||||
|
export function resetUnifiedSyncStatus(): void {
|
||||||
|
syncStatus = {
|
||||||
|
isRunning: false,
|
||||||
|
currentPlatform: null,
|
||||||
|
currentAccount: null,
|
||||||
|
progress: {
|
||||||
|
totalAccounts: 0,
|
||||||
|
processedAccounts: 0,
|
||||||
|
percentage: 0,
|
||||||
|
},
|
||||||
|
lastRunAt: null,
|
||||||
|
lastResult: null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default UnifiedSyncService
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
"$schema": "https://openapi.vercel.sh/vercel.json",
|
"$schema": "https://openapi.vercel.sh/vercel.json",
|
||||||
"crons": [
|
"crons": [
|
||||||
{
|
{
|
||||||
"path": "/api/cron/youtube-sync",
|
"path": "/api/cron/community-sync",
|
||||||
"schedule": "*/15 * * * *"
|
"schedule": "*/15 * * * *"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue