mirror of
https://github.com/complexcaresolutions/cms.c2sgmbh.git
synced 2026-03-17 17:24:12 +00:00
- Add automatic cleanup for email-logs (90 days default) - Add automatic cleanup for audit-logs (90 days default) - Add consent-logs archival based on expiresAt (3 years GDPR) - Add media orphan cleanup for unreferenced files (30 days min age) - Add BullMQ-based retention worker with daily scheduler - Add /api/retention endpoint for manual triggers (super-admin only) - Update queue worker to include retention worker - Add comprehensive documentation to CLAUDE.md and TODO.md New files: - src/lib/retention/retention-config.ts - src/lib/retention/cleanup-service.ts - src/lib/retention/index.ts - src/lib/queue/jobs/retention-job.ts - src/lib/queue/workers/retention-worker.ts - src/app/(payload)/api/retention/route.ts 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
191 lines
5.2 KiB
TypeScript
191 lines
5.2 KiB
TypeScript
/**
|
|
* Retention Worker
|
|
*
|
|
* Verarbeitet Cleanup-Jobs aus der Queue.
|
|
*/
|
|
|
|
import { Worker, Job } from 'bullmq'
|
|
import { getPayload } from 'payload'
|
|
import config from '@payload-config'
|
|
import { QUEUE_NAMES, getQueueRedisConnection } from '../queue-service'
|
|
import type { RetentionJobData, RetentionJobResult } from '../jobs/retention-job'
|
|
import {
|
|
cleanupCollection,
|
|
cleanupExpiredConsentLogs,
|
|
cleanupOrphanedMedia,
|
|
runFullRetention,
|
|
} from '../../retention/cleanup-service'
|
|
import { getCutoffDate } from '../../retention/retention-config'
|
|
|
|
// Worker-Konfiguration
|
|
const CONCURRENCY = parseInt(process.env.QUEUE_RETENTION_CONCURRENCY || '1', 10)
|
|
|
|
/**
|
|
* Retention Job Processor
|
|
*/
|
|
async function processRetentionJob(job: Job<RetentionJobData>): Promise<RetentionJobResult> {
|
|
const { type, collection, cutoffDate, batchSize, dateField, triggeredBy } = job.data
|
|
const startTime = Date.now()
|
|
|
|
console.log(`[RetentionWorker] Processing job ${job.id} (type: ${type})`)
|
|
console.log(`[RetentionWorker] Triggered by: ${triggeredBy || 'unknown'}`)
|
|
|
|
try {
|
|
// Payload-Instanz holen
|
|
const payload = await getPayload({ config })
|
|
|
|
let deletedCount = 0
|
|
let errorCount = 0
|
|
const errors: string[] = []
|
|
|
|
switch (type) {
|
|
case 'cleanup-collection': {
|
|
if (!collection) {
|
|
throw new Error('Collection is required for cleanup-collection job')
|
|
}
|
|
|
|
const cutoff = cutoffDate ? new Date(cutoffDate) : getCutoffDate(90)
|
|
const result = await cleanupCollection(payload, collection, cutoff, {
|
|
dateField,
|
|
batchSize,
|
|
})
|
|
|
|
deletedCount = result.deletedCount
|
|
errorCount = result.errorCount
|
|
errors.push(...result.errors)
|
|
break
|
|
}
|
|
|
|
case 'cleanup-media-orphans': {
|
|
const result = await cleanupOrphanedMedia(payload, {
|
|
batchSize,
|
|
minAgeDays: cutoffDate
|
|
? Math.ceil((Date.now() - new Date(cutoffDate).getTime()) / (1000 * 60 * 60 * 24))
|
|
: undefined,
|
|
})
|
|
|
|
deletedCount = result.deletedCount
|
|
errorCount = result.errorCount
|
|
errors.push(...result.errors)
|
|
break
|
|
}
|
|
|
|
case 'retention-full': {
|
|
const result = await runFullRetention(payload)
|
|
|
|
deletedCount = result.totalDeleted
|
|
errorCount = result.totalErrors
|
|
|
|
// Sammle alle Fehler
|
|
for (const r of result.results) {
|
|
errors.push(...r.errors)
|
|
}
|
|
if (result.mediaOrphanResult) {
|
|
errors.push(...result.mediaOrphanResult.errors)
|
|
}
|
|
break
|
|
}
|
|
|
|
default:
|
|
throw new Error(`Unknown retention job type: ${type}`)
|
|
}
|
|
|
|
const duration = Date.now() - startTime
|
|
|
|
const jobResult: RetentionJobResult = {
|
|
success: errorCount === 0,
|
|
type,
|
|
collection,
|
|
deletedCount,
|
|
errorCount,
|
|
errors: errors.length > 0 ? errors.slice(0, 20) : undefined, // Limitiere Fehler-Anzahl
|
|
duration,
|
|
timestamp: new Date().toISOString(),
|
|
}
|
|
|
|
console.log(
|
|
`[RetentionWorker] Job ${job.id} completed: ${deletedCount} deleted, ${errorCount} errors, ${duration}ms`
|
|
)
|
|
|
|
return jobResult
|
|
} catch (error) {
|
|
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
|
|
console.error(`[RetentionWorker] Job ${job.id} failed:`, errorMessage)
|
|
throw error
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Retention Worker Instanz
|
|
*/
|
|
let retentionWorker: Worker<RetentionJobData, RetentionJobResult> | null = null
|
|
|
|
/**
|
|
* Startet den Retention Worker
|
|
*/
|
|
export function startRetentionWorker(): Worker<RetentionJobData, RetentionJobResult> {
|
|
if (retentionWorker) {
|
|
console.warn('[RetentionWorker] Worker already running')
|
|
return retentionWorker
|
|
}
|
|
|
|
retentionWorker = new Worker<RetentionJobData, RetentionJobResult>(
|
|
QUEUE_NAMES.CLEANUP,
|
|
processRetentionJob,
|
|
{
|
|
connection: getQueueRedisConnection(),
|
|
concurrency: CONCURRENCY,
|
|
// Retention Jobs können lange dauern
|
|
lockDuration: 300000, // 5 Minuten
|
|
stalledInterval: 60000, // 1 Minute
|
|
maxStalledCount: 2,
|
|
}
|
|
)
|
|
|
|
// Event Handlers
|
|
retentionWorker.on('ready', () => {
|
|
console.log(`[RetentionWorker] Ready (concurrency: ${CONCURRENCY})`)
|
|
})
|
|
|
|
retentionWorker.on('completed', (job, result) => {
|
|
console.log(
|
|
`[RetentionWorker] Job ${job.id} completed: ${result.deletedCount} deleted in ${result.duration}ms`
|
|
)
|
|
})
|
|
|
|
retentionWorker.on('failed', (job, error) => {
|
|
console.error(
|
|
`[RetentionWorker] Job ${job?.id} failed after ${job?.attemptsMade} attempts:`,
|
|
error.message
|
|
)
|
|
})
|
|
|
|
retentionWorker.on('stalled', (jobId) => {
|
|
console.warn(`[RetentionWorker] Job ${jobId} stalled`)
|
|
})
|
|
|
|
retentionWorker.on('error', (error) => {
|
|
console.error('[RetentionWorker] Error:', error)
|
|
})
|
|
|
|
return retentionWorker
|
|
}
|
|
|
|
/**
|
|
* Stoppt den Retention Worker
|
|
*/
|
|
export async function stopRetentionWorker(): Promise<void> {
|
|
if (retentionWorker) {
|
|
console.log('[RetentionWorker] Stopping...')
|
|
await retentionWorker.close()
|
|
retentionWorker = null
|
|
console.log('[RetentionWorker] Stopped')
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Gibt die Worker-Instanz zurück (falls aktiv)
|
|
*/
|
|
export function getRetentionWorker(): Worker<RetentionJobData, RetentionJobResult> | null {
|
|
return retentionWorker
|
|
}
|