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:
Martin Porwoll 2026-01-16 21:50:08 +00:00
parent 89882a545b
commit cdaa871436
6 changed files with 1115 additions and 0 deletions

View 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

View file

@ -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',

View 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

View 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

View file

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

View file

@ -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'
},
];