mirror of
https://github.com/complexcaresolutions/cms.c2sgmbh.git
synced 2026-03-17 17:24:12 +00:00
feat(community): Phase 2.5 - Token Refresh Service
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 <noreply@anthropic.com>
This commit is contained in:
parent
89882a545b
commit
cdaa871436
6 changed files with 1115 additions and 0 deletions
183
src/app/(payload)/api/cron/token-refresh/route.ts
Normal file
183
src/app/(payload)/api/cron/token-refresh/route.ts
Normal file
|
|
@ -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
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
217
src/lib/jobs/NotificationService.ts
Normal file
217
src/lib/jobs/NotificationService.ts
Normal file
|
|
@ -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<NotificationResult> {
|
||||
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<NotificationData, 'recipientId'>
|
||||
): Promise<NotificationResult[]> {
|
||||
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<NotificationData, 'recipientId'>
|
||||
): Promise<NotificationResult[]> {
|
||||
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<NotificationResult[]> {
|
||||
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<NotificationResult[]> {
|
||||
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<NotificationResult[]> {
|
||||
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<NotificationResult[]> {
|
||||
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
|
||||
643
src/lib/jobs/TokenRefreshService.ts
Normal file
643
src/lib/jobs/TokenRefreshService.ts
Normal file
|
|
@ -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<TokenRefreshSummary> {
|
||||
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<any[]> {
|
||||
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<TokenRefreshResult> {
|
||||
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<Date | null> {
|
||||
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<Date | null> {
|
||||
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<Date | null> {
|
||||
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<void> {
|
||||
// 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<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Convenience Functions
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Führt Token-Refresh aus (Singleton-Pattern)
|
||||
*/
|
||||
export async function runTokenRefresh(
|
||||
options: TokenRefreshOptions = {}
|
||||
): Promise<TokenRefreshSummary> {
|
||||
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
|
||||
|
|
@ -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<void> {
|
||||
// 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<void> {
|
||||
// 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
|
||||
}
|
||||
|
|
@ -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'
|
||||
},
|
||||
];
|
||||
|
|
|
|||
Loading…
Reference in a new issue