From cdaa87143669e2c078a94723dad7f2806e6c59d3 Mon Sep 17 00:00:00 2001 From: Martin Porwoll Date: Fri, 16 Jan 2026 21:50:08 +0000 Subject: [PATCH] feat(community): Phase 2.5 - Token Refresh Service MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Automatischer Token-Refresh für alle Social-Media-Plattformen: - TokenRefreshService: Proaktiver Refresh 7 Tage vor Ablauf - YouTube: Refresh via refresh_token - Meta (Facebook/Instagram): Long-lived Token Exchange - Rate-Limiting zwischen Accounts - Dry-Run Modus für Tests - NotificationService: Benachrichtigungen für YouTube Manager - Token läuft ab (warning) - Token abgelaufen (error) - Token-Refresh fehlgeschlagen (error) - Token erfolgreich erneuert (info) - Cron-Endpoint: /api/cron/token-refresh - GET: Automatischer Cron-Trigger - POST: Manueller Trigger mit erweiterten Optionen - HEAD: Status-Check für Monitoring - Query-Parameter: platforms, thresholdDays, includeExpired, dryRun - YtNotifications erweitert: - Neue Typen: token_expiring, token_expired, token_refresh_failed, token_refreshed - Neues Feld: relatedAccount für Social Account Relationship - Migration: 20260116_100000_add_token_notification_fields Co-Authored-By: Claude Opus 4.5 --- .../(payload)/api/cron/token-refresh/route.ts | 183 +++++ src/collections/YtNotifications.ts | 13 + src/lib/jobs/NotificationService.ts | 217 ++++++ src/lib/jobs/TokenRefreshService.ts | 643 ++++++++++++++++++ ...16_100000_add_token_notification_fields.ts | 53 ++ src/migrations/index.ts | 6 + 6 files changed, 1115 insertions(+) create mode 100644 src/app/(payload)/api/cron/token-refresh/route.ts create mode 100644 src/lib/jobs/NotificationService.ts create mode 100644 src/lib/jobs/TokenRefreshService.ts create mode 100644 src/migrations/20260116_100000_add_token_notification_fields.ts diff --git a/src/app/(payload)/api/cron/token-refresh/route.ts b/src/app/(payload)/api/cron/token-refresh/route.ts new file mode 100644 index 0000000..00c8ca2 --- /dev/null +++ b/src/app/(payload)/api/cron/token-refresh/route.ts @@ -0,0 +1,183 @@ +// src/app/(payload)/api/cron/token-refresh/route.ts +// Token Refresh Cron Endpoint + +import { NextRequest, NextResponse } from 'next/server' +import { + runTokenRefresh, + getTokenRefreshStatus, + type TokenRefreshOptions, + type TokenPlatform, +} from '@/lib/jobs/TokenRefreshService' + +// Geheimer Token für Cron-Authentifizierung +const CRON_SECRET = process.env.CRON_SECRET + +/** + * GET /api/cron/token-refresh + * Prüft und erneuert ablaufende Tokens + * + * Query Parameters: + * - platforms: Komma-separierte Liste (youtube,facebook,instagram) + * - thresholdDays: Tage vor Ablauf für Refresh (default: 7) + * - includeExpired: true/false - auch abgelaufene Tokens versuchen + * - dryRun: true/false - nur prüfen, nicht erneuern + */ +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 token-refresh') + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + } + + // Query-Parameter parsen + const searchParams = request.nextUrl.searchParams + const options: TokenRefreshOptions = {} + + // 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 TokenPlatform => + ['youtube', 'facebook', 'instagram'].includes(p) + ) + if (validPlatforms.length > 0) { + options.platforms = validPlatforms + } + } + + // Threshold Days + const thresholdParam = searchParams.get('thresholdDays') + if (thresholdParam) { + const days = parseInt(thresholdParam, 10) + if (!isNaN(days) && days > 0) { + options.refreshThresholdDays = days + } + } + + // Include Expired + const includeExpiredParam = searchParams.get('includeExpired') + if (includeExpiredParam === 'true') { + options.includeExpired = true + } + + // Dry Run + const dryRunParam = searchParams.get('dryRun') + if (dryRunParam === 'true') { + options.dryRun = true + } + + console.log('[Cron] Starting token refresh', { options }) + + const result = await runTokenRefresh(options) + + if (result.success) { + return NextResponse.json({ + success: true, + message: `Token refresh completed: ${result.stats.refreshed} refreshed, ${result.stats.skipped} skipped`, + duration: result.duration, + stats: result.stats, + notifications: result.notifications.filter((n) => n.type !== 'info'), + }) + } + + // Teilerfolg oder Fehler + return NextResponse.json( + { + success: false, + message: `Token refresh completed with issues: ${result.stats.failed} failed, ${result.stats.expired} expired`, + duration: result.duration, + stats: result.stats, + notifications: result.notifications, + results: result.results.filter((r) => r.action !== 'skipped'), + }, + { status: result.stats.failed > 0 || result.stats.expired > 0 ? 207 : 200 } + ) +} + +/** + * POST /api/cron/token-refresh + * Manueller Token-Refresh 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 token-refresh') + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + } + + try { + const body = await request.json() + const options: TokenRefreshOptions = {} + + // Optionen aus Body übernehmen + if (body.platforms && Array.isArray(body.platforms)) { + options.platforms = body.platforms.filter((p: string): p is TokenPlatform => + ['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.refreshThresholdDays === 'number' && body.refreshThresholdDays > 0) { + options.refreshThresholdDays = body.refreshThresholdDays + } + + if (typeof body.includeExpired === 'boolean') { + options.includeExpired = body.includeExpired + } + + if (typeof body.dryRun === 'boolean') { + options.dryRun = body.dryRun + } + + console.log('[Cron] Manual token refresh triggered', { options }) + + const result = await runTokenRefresh(options) + + return NextResponse.json({ + success: result.success, + startedAt: result.startedAt, + completedAt: result.completedAt, + duration: result.duration, + stats: result.stats, + results: result.results, + notifications: result.notifications, + }) + } 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/token-refresh + * Status-Check für Monitoring + */ +export async function HEAD() { + const status = getTokenRefreshStatus() + + return new NextResponse(null, { + status: status.isRunning ? 423 : 200, + headers: { + 'X-Refresh-Running': status.isRunning.toString(), + 'X-Last-Run': status.lastRunAt || 'never', + 'X-Last-Success': status.lastResult?.success.toString() || 'unknown', + }, + }) +} + +export const dynamic = 'force-dynamic' +export const maxDuration = 120 // 2 Minuten max diff --git a/src/collections/YtNotifications.ts b/src/collections/YtNotifications.ts index 1875c0b..4a96c93 100644 --- a/src/collections/YtNotifications.ts +++ b/src/collections/YtNotifications.ts @@ -51,6 +51,10 @@ export const YtNotifications: CollectionConfig = { { label: 'Video veröffentlicht', value: 'video_published' }, { label: 'Kommentar', value: 'comment' }, { label: 'Erwähnung', value: 'mention' }, + { label: 'Token läuft ab', value: 'token_expiring' }, + { label: 'Token abgelaufen', value: 'token_expired' }, + { label: 'Token-Refresh fehlgeschlagen', value: 'token_refresh_failed' }, + { label: 'Token erneuert', value: 'token_refreshed' }, { label: 'System', value: 'system' }, ], }, @@ -87,6 +91,15 @@ export const YtNotifications: CollectionConfig = { relationTo: 'yt-tasks', label: 'Aufgabe', }, + { + name: 'relatedAccount', + type: 'relationship', + relationTo: 'social-accounts', + label: 'Social Account', + admin: { + description: 'Verknüpfter Social Account (für Token-Benachrichtigungen)', + }, + }, { name: 'read', type: 'checkbox', diff --git a/src/lib/jobs/NotificationService.ts b/src/lib/jobs/NotificationService.ts new file mode 100644 index 0000000..c6d5eb7 --- /dev/null +++ b/src/lib/jobs/NotificationService.ts @@ -0,0 +1,217 @@ +// src/lib/jobs/NotificationService.ts +// Service für das Erstellen von Benachrichtigungen + +import { Payload } from 'payload' + +// ============================================================================= +// Types +// ============================================================================= + +export type NotificationType = + | 'task_assigned' + | 'task_due' + | 'task_overdue' + | 'approval_required' + | 'approved' + | 'rejected' + | 'video_published' + | 'comment' + | 'mention' + | 'token_expiring' + | 'token_expired' + | 'token_refresh_failed' + | 'token_refreshed' + | 'system' + +export interface NotificationData { + recipientId: number + type: NotificationType + title: string + message?: string + link?: string + relatedVideoId?: number + relatedTaskId?: number + relatedAccountId?: number +} + +export interface NotificationResult { + success: boolean + notificationId?: number + error?: string +} + +// ============================================================================= +// Notification Service +// ============================================================================= + +export class NotificationService { + private payload: Payload + + constructor(payload: Payload) { + this.payload = payload + } + + /** + * Erstellt eine einzelne Benachrichtigung + */ + async createNotification(data: NotificationData): Promise { + try { + const notification = await this.payload.create({ + collection: 'yt-notifications', + data: { + recipient: data.recipientId, + type: data.type, + title: data.title, + message: data.message, + link: data.link, + relatedVideo: data.relatedVideoId, + relatedTask: data.relatedTaskId, + relatedAccount: data.relatedAccountId, + read: false, + }, + }) + + return { + success: true, + notificationId: notification.id as number, + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + console.error('[NotificationService] Error creating notification:', error) + return { + success: false, + error: message, + } + } + } + + /** + * Erstellt Benachrichtigungen für mehrere Empfänger + */ + async notifyMany( + recipientIds: number[], + data: Omit + ): Promise { + const results: NotificationResult[] = [] + + for (const recipientId of recipientIds) { + const result = await this.createNotification({ + ...data, + recipientId, + }) + results.push(result) + } + + return results + } + + /** + * Erstellt Benachrichtigungen für alle YouTube Manager + */ + async notifyYouTubeManagers( + data: Omit + ): Promise { + try { + // Alle User mit YouTube-Manager Rolle finden + const users = await this.payload.find({ + collection: 'users', + where: { + or: [ + { roles: { contains: 'youtube_manager' } }, + { isSuperAdmin: { equals: true } }, + ], + }, + limit: 100, + }) + + const recipientIds = users.docs.map((u) => u.id as number) + + if (recipientIds.length === 0) { + console.warn('[NotificationService] No YouTube managers found') + return [] + } + + return this.notifyMany(recipientIds, data) + } catch (error) { + console.error('[NotificationService] Error finding YouTube managers:', error) + return [] + } + } + + /** + * Erstellt Token-Ablauf-Benachrichtigung + */ + async notifyTokenExpiring( + accountId: number, + accountName: string, + platform: string, + daysUntilExpiry: number + ): Promise { + return this.notifyYouTubeManagers({ + type: 'token_expiring', + title: `Token läuft bald ab: ${accountName}`, + message: `Der ${platform}-Token für "${accountName}" läuft in ${daysUntilExpiry} Tagen ab. Bitte rechtzeitig erneuern.`, + link: `/admin/collections/social-accounts/${accountId}`, + relatedAccountId: accountId, + }) + } + + /** + * Erstellt Token-Abgelaufen-Benachrichtigung + */ + async notifyTokenExpired( + accountId: number, + accountName: string, + platform: string + ): Promise { + return this.notifyYouTubeManagers({ + type: 'token_expired', + title: `Token abgelaufen: ${accountName}`, + message: `Der ${platform}-Token für "${accountName}" ist abgelaufen. Bitte neu authentifizieren.`, + link: `/admin/collections/social-accounts/${accountId}`, + relatedAccountId: accountId, + }) + } + + /** + * Erstellt Token-Refresh-Fehlgeschlagen-Benachrichtigung + */ + async notifyTokenRefreshFailed( + accountId: number, + accountName: string, + platform: string, + errorMessage: string + ): Promise { + return this.notifyYouTubeManagers({ + type: 'token_refresh_failed', + title: `Token-Refresh fehlgeschlagen: ${accountName}`, + message: `Der automatische Token-Refresh für "${accountName}" (${platform}) ist fehlgeschlagen: ${errorMessage}`, + link: `/admin/collections/social-accounts/${accountId}`, + relatedAccountId: accountId, + }) + } + + /** + * Erstellt Token-Erfolgreich-Erneuert-Benachrichtigung (optional, nur bei Warnung) + */ + async notifyTokenRefreshed( + accountId: number, + accountName: string, + platform: string, + newExpiryDate: Date + ): Promise { + const daysUntilExpiry = Math.ceil( + (newExpiryDate.getTime() - Date.now()) / (1000 * 60 * 60 * 24) + ) + + return this.notifyYouTubeManagers({ + type: 'token_refreshed', + title: `Token erneuert: ${accountName}`, + message: `Der ${platform}-Token für "${accountName}" wurde erfolgreich erneuert. Gültig für ${daysUntilExpiry} Tage.`, + link: `/admin/collections/social-accounts/${accountId}`, + relatedAccountId: accountId, + }) + } +} + +export default NotificationService diff --git a/src/lib/jobs/TokenRefreshService.ts b/src/lib/jobs/TokenRefreshService.ts new file mode 100644 index 0000000..0e05aa3 --- /dev/null +++ b/src/lib/jobs/TokenRefreshService.ts @@ -0,0 +1,643 @@ +// src/lib/jobs/TokenRefreshService.ts +// Automatischer Token Refresh Service für alle Plattformen + +import { getPayload, Payload } from 'payload' +import config from '@payload-config' +import { refreshAccessToken as refreshYouTubeToken } from '../integrations/youtube/oauth' +import { + refreshLongLivedToken as refreshMetaToken, + isTokenValid as isMetaTokenValid, +} from '../integrations/meta/oauth' +import { JobLogger } from './JobLogger' +import { NotificationService } from './NotificationService' + +// ============================================================================= +// Types +// ============================================================================= + +export type TokenPlatform = 'youtube' | 'facebook' | 'instagram' + +export interface TokenRefreshResult { + accountId: number + accountName: string + platform: TokenPlatform + success: boolean + previousExpiry: Date | null + newExpiry: Date | null + error?: string + action: 'refreshed' | 'skipped' | 'failed' | 'expired' +} + +export interface TokenRefreshSummary { + success: boolean + startedAt: Date + completedAt: Date + duration: number + results: TokenRefreshResult[] + stats: { + total: number + refreshed: number + skipped: number + failed: number + expired: number + } + notifications: TokenNotification[] +} + +export interface TokenNotification { + type: 'warning' | 'error' | 'info' + accountId: number + accountName: string + platform: TokenPlatform + message: string + expiresAt?: Date + daysUntilExpiry?: number +} + +export interface TokenRefreshOptions { + /** Nur bestimmte Plattformen prüfen */ + platforms?: TokenPlatform[] + /** Nur bestimmte Account-IDs prüfen */ + accountIds?: number[] + /** Tage vor Ablauf für Refresh (default: 7) */ + refreshThresholdDays?: number + /** Auch bereits abgelaufene Tokens versuchen zu erneuern */ + includeExpired?: boolean + /** Dry-Run: Nur prüfen, nicht erneuern */ + dryRun?: boolean +} + +// ============================================================================= +// Global State +// ============================================================================= + +let isRunning = false +let lastRunAt: Date | null = null +let lastResult: TokenRefreshSummary | null = null + +// ============================================================================= +// Token Refresh Service +// ============================================================================= + +export class TokenRefreshService { + private payload: Payload + private logger: JobLogger + private notificationService: NotificationService + + constructor(payload: Payload) { + this.payload = payload + this.logger = new JobLogger('token-refresh') + this.notificationService = new NotificationService(payload) + } + + /** + * Prüft und erneuert alle Tokens die bald ablaufen + */ + async refreshExpiringTokens(options: TokenRefreshOptions = {}): Promise { + const { + platforms, + accountIds, + refreshThresholdDays = 7, + includeExpired = false, + dryRun = false, + } = options + + const startedAt = new Date() + const results: TokenRefreshResult[] = [] + const notifications: TokenNotification[] = [] + + if (isRunning) { + return { + success: false, + startedAt, + completedAt: new Date(), + duration: 0, + results: [], + stats: { total: 0, refreshed: 0, skipped: 0, failed: 0, expired: 0 }, + notifications: [ + { + type: 'warning', + accountId: 0, + accountName: 'System', + platform: 'youtube', + message: 'Token refresh already running', + }, + ], + } + } + + isRunning = true + + try { + this.logger.info('Starting token refresh check', { + refreshThresholdDays, + dryRun, + platforms, + }) + + // Alle aktiven Accounts laden + const accounts = await this.loadAccounts(platforms, accountIds) + this.logger.info(`Found ${accounts.length} accounts to check`) + + const thresholdDate = new Date() + thresholdDate.setDate(thresholdDate.getDate() + refreshThresholdDays) + + for (const account of accounts) { + const result = await this.processAccount(account, { + thresholdDate, + includeExpired, + dryRun, + }) + + results.push(result) + + // Benachrichtigungen generieren + if (result.action === 'expired') { + notifications.push({ + type: 'error', + accountId: result.accountId, + accountName: result.accountName, + platform: result.platform, + message: `Token abgelaufen! Bitte neu authentifizieren.`, + expiresAt: result.previousExpiry || undefined, + }) + } else if (result.action === 'failed') { + notifications.push({ + type: 'error', + accountId: result.accountId, + accountName: result.accountName, + platform: result.platform, + message: `Token-Refresh fehlgeschlagen: ${result.error}`, + }) + } else if (result.action === 'refreshed') { + const daysUntilExpiry = result.newExpiry + ? Math.ceil((result.newExpiry.getTime() - Date.now()) / (1000 * 60 * 60 * 24)) + : undefined + + notifications.push({ + type: 'info', + accountId: result.accountId, + accountName: result.accountName, + platform: result.platform, + message: `Token erfolgreich erneuert`, + expiresAt: result.newExpiry || undefined, + daysUntilExpiry, + }) + + // Warnung wenn neue Gültigkeit unter 14 Tagen liegt + if (daysUntilExpiry !== undefined && daysUntilExpiry < 14) { + notifications.push({ + type: 'warning', + accountId: result.accountId, + accountName: result.accountName, + platform: result.platform, + message: `Token nur noch ${daysUntilExpiry} Tage gültig nach Refresh`, + expiresAt: result.newExpiry || undefined, + daysUntilExpiry, + }) + } + } + + // Rate Limiting + await this.sleep(200) + } + + // Statistiken berechnen + const stats = { + total: results.length, + refreshed: results.filter((r) => r.action === 'refreshed').length, + skipped: results.filter((r) => r.action === 'skipped').length, + failed: results.filter((r) => r.action === 'failed').length, + expired: results.filter((r) => r.action === 'expired').length, + } + + const summary: TokenRefreshSummary = { + success: stats.failed === 0 && stats.expired === 0, + startedAt, + completedAt: new Date(), + duration: Date.now() - startedAt.getTime(), + results, + stats, + notifications, + } + + lastRunAt = startedAt + lastResult = summary + + // Benachrichtigungen in Datenbank persistieren + await this.persistNotifications(notifications) + + this.logger.summary({ + success: summary.success, + processed: stats.total, + errors: stats.failed + stats.expired, + }) + + return summary + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error) + this.logger.error('Token refresh failed', error) + + return { + success: false, + startedAt, + completedAt: new Date(), + duration: Date.now() - startedAt.getTime(), + results, + stats: { + total: results.length, + refreshed: results.filter((r) => r.action === 'refreshed').length, + skipped: results.filter((r) => r.action === 'skipped').length, + failed: results.filter((r) => r.action === 'failed').length + 1, + expired: results.filter((r) => r.action === 'expired').length, + }, + notifications: [ + { + type: 'error', + accountId: 0, + accountName: 'System', + platform: 'youtube', + message: `System error: ${errorMessage}`, + }, + ], + } + } finally { + isRunning = false + } + } + + /** + * Lädt alle relevanten Accounts + */ + private async loadAccounts( + platforms?: TokenPlatform[], + accountIds?: number[] + ): Promise { + const where: any = { + and: [{ isActive: { equals: true } }], + } + + if (accountIds && accountIds.length > 0) { + where.and.push({ id: { in: accountIds } }) + } + + const accounts = await this.payload.find({ + collection: 'social-accounts', + where, + limit: 200, + depth: 2, + }) + + // Nach Plattform filtern + return accounts.docs.filter((account) => { + const platform = this.detectPlatform(account) + if (!platform) return false + if (platforms && platforms.length > 0) { + return platforms.includes(platform) + } + return true + }) + } + + /** + * Erkennt die Plattform eines Accounts + */ + private detectPlatform(account: any): TokenPlatform | null { + const platformDoc = account.platform as { slug?: string; apiConfig?: { apiType?: string } } + 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 + } + + /** + * Verarbeitet einen einzelnen Account + */ + private async processAccount( + account: any, + options: { + thresholdDate: Date + includeExpired: boolean + dryRun: boolean + } + ): Promise { + const { thresholdDate, includeExpired, dryRun } = options + const accountId = account.id as number + const accountName = (account.displayName as string) || `Account ${accountId}` + const platform = this.detectPlatform(account)! + + const credentials = account.credentials as { + accessToken?: string + refreshToken?: string + tokenExpiresAt?: string + } | undefined + + // Kein Token vorhanden + if (!credentials?.accessToken) { + return { + accountId, + accountName, + platform, + success: false, + previousExpiry: null, + newExpiry: null, + action: 'skipped', + error: 'No access token', + } + } + + // Ablaufdatum prüfen + const expiresAt = credentials.tokenExpiresAt + ? new Date(credentials.tokenExpiresAt) + : null + + // Token ohne Ablaufdatum (YouTube mit Refresh Token) - nur prüfen wenn explizit angefragt + if (!expiresAt && platform === 'youtube' && credentials.refreshToken) { + return { + accountId, + accountName, + platform, + success: true, + previousExpiry: null, + newExpiry: null, + action: 'skipped', + } + } + + // Token noch lange gültig + if (expiresAt && expiresAt > thresholdDate) { + const daysLeft = Math.ceil((expiresAt.getTime() - Date.now()) / (1000 * 60 * 60 * 24)) + this.logger.info(`${accountName}: Token valid for ${daysLeft} more days`) + + return { + accountId, + accountName, + platform, + success: true, + previousExpiry: expiresAt, + newExpiry: expiresAt, + action: 'skipped', + } + } + + // Token abgelaufen + const isExpired = expiresAt && expiresAt < new Date() + if (isExpired && !includeExpired) { + this.logger.warn(`${accountName}: Token expired, re-authentication required`) + + return { + accountId, + accountName, + platform, + success: false, + previousExpiry: expiresAt, + newExpiry: null, + action: 'expired', + error: 'Token expired, re-authentication required', + } + } + + // Dry-Run: Nur melden + if (dryRun) { + this.logger.info(`${accountName}: Would refresh token (dry-run)`) + + return { + accountId, + accountName, + platform, + success: true, + previousExpiry: expiresAt, + newExpiry: null, + action: 'skipped', + } + } + + // Token erneuern + try { + const newExpiry = await this.refreshToken(account, platform, credentials) + + this.logger.info(`${accountName}: Token refreshed, new expiry: ${newExpiry?.toISOString()}`) + + return { + accountId, + accountName, + platform, + success: true, + previousExpiry: expiresAt, + newExpiry, + action: 'refreshed', + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error) + this.logger.error(`${accountName}: Token refresh failed`, error) + + return { + accountId, + accountName, + platform, + success: false, + previousExpiry: expiresAt, + newExpiry: null, + action: 'failed', + error: errorMessage, + } + } + } + + /** + * Erneuert den Token für einen Account + */ + private async refreshToken( + account: any, + platform: TokenPlatform, + credentials: { + accessToken?: string + refreshToken?: string + tokenExpiresAt?: string + } + ): Promise { + const accountId = account.id as number + + switch (platform) { + case 'youtube': + return this.refreshYouTubeToken(accountId, credentials) + + case 'facebook': + case 'instagram': + return this.refreshMetaToken(accountId, credentials) + + default: + throw new Error(`Unsupported platform: ${platform}`) + } + } + + /** + * Erneuert YouTube Token + */ + private async refreshYouTubeToken( + accountId: number, + credentials: { refreshToken?: string } + ): Promise { + if (!credentials.refreshToken) { + throw new Error('No refresh token available') + } + + const newCredentials = await refreshYouTubeToken(credentials.refreshToken) + + const newExpiry = newCredentials.expiry_date + ? new Date(newCredentials.expiry_date) + : null + + await this.payload.update({ + collection: 'social-accounts', + id: accountId, + data: { + credentials: { + accessToken: newCredentials.access_token || undefined, + refreshToken: newCredentials.refresh_token || credentials.refreshToken, + tokenExpiresAt: newExpiry?.toISOString(), + }, + }, + }) + + return newExpiry + } + + /** + * Erneuert Meta (Facebook/Instagram) Token + */ + private async refreshMetaToken( + accountId: number, + credentials: { accessToken?: string } + ): Promise { + if (!credentials.accessToken) { + throw new Error('No access token available') + } + + // Prüfen ob Token noch gültig ist (für Refresh muss er noch gültig sein!) + const isValid = await isMetaTokenValid(credentials.accessToken) + if (!isValid) { + throw new Error('Token already expired, re-authentication required') + } + + // Long-lived Token erneuern + const newToken = await refreshMetaToken(credentials.accessToken) + + const newExpiry = new Date(Date.now() + newToken.expires_in * 1000) + + await this.payload.update({ + collection: 'social-accounts', + id: accountId, + data: { + credentials: { + accessToken: newToken.access_token, + tokenExpiresAt: newExpiry.toISOString(), + }, + }, + }) + + return newExpiry + } + + /** + * Persistiert Benachrichtigungen in die Datenbank + */ + private async persistNotifications(notifications: TokenNotification[]): Promise { + // Nur error und warning notifications persistieren + const criticalNotifications = notifications.filter( + (n) => n.type === 'error' || n.type === 'warning' + ) + + if (criticalNotifications.length === 0) { + return + } + + this.logger.info(`Persisting ${criticalNotifications.length} notifications`) + + for (const notification of criticalNotifications) { + try { + switch (notification.type) { + case 'error': + if (notification.message.includes('abgelaufen') || notification.message.includes('expired')) { + await this.notificationService.notifyTokenExpired( + notification.accountId, + notification.accountName, + notification.platform + ) + } else { + await this.notificationService.notifyTokenRefreshFailed( + notification.accountId, + notification.accountName, + notification.platform, + notification.message + ) + } + break + + case 'warning': + if (notification.daysUntilExpiry !== undefined) { + await this.notificationService.notifyTokenExpiring( + notification.accountId, + notification.accountName, + notification.platform, + notification.daysUntilExpiry + ) + } + break + } + } catch (error) { + this.logger.error(`Failed to persist notification for ${notification.accountName}`, error) + } + } + } + + private sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)) + } +} + +// ============================================================================= +// Convenience Functions +// ============================================================================= + +/** + * Führt Token-Refresh aus (Singleton-Pattern) + */ +export async function runTokenRefresh( + options: TokenRefreshOptions = {} +): Promise { + const payload = await getPayload({ config }) + const service = new TokenRefreshService(payload) + return service.refreshExpiringTokens(options) +} + +/** + * Gibt den Status des letzten Token-Refresh zurück + */ +export function getTokenRefreshStatus(): { + isRunning: boolean + lastRunAt: string | null + lastResult: TokenRefreshSummary | null +} { + return { + isRunning, + lastRunAt: lastRunAt?.toISOString() || null, + lastResult, + } +} + +/** + * Setzt den Status zurück (für Tests) + */ +export function resetTokenRefreshStatus(): void { + isRunning = false + lastRunAt = null + lastResult = null +} + +export default TokenRefreshService diff --git a/src/migrations/20260116_100000_add_token_notification_fields.ts b/src/migrations/20260116_100000_add_token_notification_fields.ts new file mode 100644 index 0000000..b50ed63 --- /dev/null +++ b/src/migrations/20260116_100000_add_token_notification_fields.ts @@ -0,0 +1,53 @@ +import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres' + +/** + * Migration: Add Token Notification Fields + * + * Adds: + * 1. New notification types for token management + * 2. relatedAccount field for social account relationship + */ +export async function up({ db }: MigrateUpArgs): Promise { + // Add new enum values to yt_notifications type enum + await db.execute(sql` + ALTER TYPE "enum_yt_notifications_type" ADD VALUE IF NOT EXISTS 'token_expiring'; + `) + await db.execute(sql` + ALTER TYPE "enum_yt_notifications_type" ADD VALUE IF NOT EXISTS 'token_expired'; + `) + await db.execute(sql` + ALTER TYPE "enum_yt_notifications_type" ADD VALUE IF NOT EXISTS 'token_refresh_failed'; + `) + await db.execute(sql` + ALTER TYPE "enum_yt_notifications_type" ADD VALUE IF NOT EXISTS 'token_refreshed'; + `) + + // Add relatedAccount field to yt_notifications + await db.execute(sql` + ALTER TABLE "yt_notifications" + ADD COLUMN IF NOT EXISTS "related_account_id" integer + REFERENCES social_accounts(id) ON DELETE SET NULL; + `) + + // Create index for the new relationship + await db.execute(sql` + CREATE INDEX IF NOT EXISTS "yt_notifications_related_account_idx" + ON "yt_notifications" ("related_account_id"); + `) +} + +export async function down({ db }: MigrateDownArgs): Promise { + // Drop the index + await db.execute(sql` + DROP INDEX IF EXISTS "yt_notifications_related_account_idx"; + `) + + // Remove the relatedAccount column + await db.execute(sql` + ALTER TABLE "yt_notifications" + DROP COLUMN IF EXISTS "related_account_id"; + `) + + // Note: PostgreSQL doesn't support removing enum values easily + // The enum values will remain but won't cause issues +} diff --git a/src/migrations/index.ts b/src/migrations/index.ts index d17c689..c84d04d 100644 --- a/src/migrations/index.ts +++ b/src/migrations/index.ts @@ -33,6 +33,7 @@ import * as migration_20260112_220000_add_youtube_ops_v2 from './20260112_220000 import * as migration_20260113_140000_create_yt_series from './20260113_140000_create_yt_series'; import * as migration_20260113_180000_add_community_phase1 from './20260113_180000_add_community_phase1'; import * as migration_20260114_200000_fix_community_role_enum from './20260114_200000_fix_community_role_enum'; +import * as migration_20260116_100000_add_token_notification_fields from './20260116_100000_add_token_notification_fields'; export const migrations = [ { @@ -210,4 +211,9 @@ export const migrations = [ down: migration_20260114_200000_fix_community_role_enum.down, name: '20260114_200000_fix_community_role_enum' }, + { + up: migration_20260116_100000_add_token_notification_fields.up, + down: migration_20260116_100000_add_token_notification_fields.down, + name: '20260116_100000_add_token_notification_fields' + }, ];