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: 'Video veröffentlicht', value: 'video_published' },
|
||||||
{ label: 'Kommentar', value: 'comment' },
|
{ label: 'Kommentar', value: 'comment' },
|
||||||
{ label: 'Erwähnung', value: 'mention' },
|
{ 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' },
|
{ label: 'System', value: 'system' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
@ -87,6 +91,15 @@ export const YtNotifications: CollectionConfig = {
|
||||||
relationTo: 'yt-tasks',
|
relationTo: 'yt-tasks',
|
||||||
label: 'Aufgabe',
|
label: 'Aufgabe',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'relatedAccount',
|
||||||
|
type: 'relationship',
|
||||||
|
relationTo: 'social-accounts',
|
||||||
|
label: 'Social Account',
|
||||||
|
admin: {
|
||||||
|
description: 'Verknüpfter Social Account (für Token-Benachrichtigungen)',
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'read',
|
name: 'read',
|
||||||
type: 'checkbox',
|
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_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_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_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 = [
|
export const migrations = [
|
||||||
{
|
{
|
||||||
|
|
@ -210,4 +211,9 @@ export const migrations = [
|
||||||
down: migration_20260114_200000_fix_community_role_enum.down,
|
down: migration_20260114_200000_fix_community_role_enum.down,
|
||||||
name: '20260114_200000_fix_community_role_enum'
|
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