diff --git a/scripts/seed-community-rules.ts b/scripts/seed-community-rules.ts index fb923d4..eb1e2db 100644 --- a/scripts/seed-community-rules.ts +++ b/scripts/seed-community-rules.ts @@ -133,7 +133,7 @@ async function seedRules() { await payload.create({ collection: 'community-rules', - data: rule, + data: rule as any, }) console.log(`✅ Created: ${rule.name}`) created++ diff --git a/scripts/seed-content.ts b/scripts/seed-content.ts index 98c5d8d..5740dd3 100644 --- a/scripts/seed-content.ts +++ b/scripts/seed-content.ts @@ -53,26 +53,49 @@ async function seed() { // ============================================ console.log('\n--- Updating Site Settings ---') - await payload.updateGlobal({ - slug: 'site-settings', - data: { - siteName: 'porwoll.de', - siteTagline: 'Die Webseite von Martin Porwoll', - contact: { - email: 'info@porwoll.de', - phone: '0800 80 44 100', - address: 'Hans-Böckler-Str. 19\n46236 Bottrop', - }, - footer: { - copyrightText: 'Martin Porwoll', - showSocialLinks: true, - }, - seo: { - defaultMetaTitle: 'porwoll.de | Die Webseite von Martin Porwoll', - defaultMetaDescription: 'Martin Porwoll - Whistleblower, Unternehmer, Mensch. Engagiert für Patientenwohl und Transparenz im Gesundheitswesen.', - }, + const siteSettingsData = { + tenant: tenantId, + siteName: 'porwoll.de', + siteTagline: 'Die Webseite von Martin Porwoll', + contact: { + email: 'info@porwoll.de', + phone: '0800 80 44 100', }, + address: { + street: 'Hans-Böckler-Str. 19', + zip: '46236', + city: 'Bottrop', + country: 'Deutschland', + }, + footer: { + copyrightText: 'Martin Porwoll', + showSocialLinks: true, + }, + seo: { + defaultMetaTitle: 'porwoll.de | Die Webseite von Martin Porwoll', + defaultMetaDescription: + 'Martin Porwoll - Whistleblower, Unternehmer, Mensch. Engagiert für Patientenwohl und Transparenz im Gesundheitswesen.', + }, + } + + const existingSiteSettings = await payload.find({ + collection: 'site-settings', + where: { tenant: { equals: tenantId } }, + limit: 1, }) + + if (existingSiteSettings.docs.length > 0) { + await payload.update({ + collection: 'site-settings', + id: existingSiteSettings.docs[0].id, + data: siteSettingsData as any, + }) + } else { + await payload.create({ + collection: 'site-settings', + data: siteSettingsData as any, + }) + } console.log('✓ Site Settings updated') // ============================================ @@ -891,43 +914,61 @@ async function seed() { pageIds[p.slug] = p.id as number } - await payload.updateGlobal({ - slug: 'navigation', - data: { - mainMenu: [ - { - label: 'Whistleblowing', - type: 'submenu', - submenu: [ - { label: 'Zytoskandal', linkType: 'page', page: pageIds['zytoskandal'] }, - { label: 'Whistleblowing', linkType: 'page', page: pageIds['whistleblowing'] }, - ], - }, - { - label: 'Unternehmer', - type: 'submenu', - submenu: [ - { label: 'gunshin Holding UG', linkType: 'page', page: pageIds['gunshin-holding'] }, - { label: 'complex care solutions GmbH', linkType: 'page', page: pageIds['complex-care-solutions'] }, - ], - }, - { - label: 'Mensch', - type: 'page', - page: pageIds['mensch'], - }, - { - label: 'Kontakt', - type: 'page', - page: pageIds['kontakt'], - }, - ], - footerMenu: [ - { label: 'Impressum', linkType: 'page', page: pageIds['impressum'] }, - { label: 'Datenschutzerklärung', linkType: 'page', page: pageIds['datenschutz'] }, - ], - }, + const navigationData = { + tenant: tenantId, + title: 'Hauptnavigation', + mainMenu: [ + { + label: 'Whistleblowing', + type: 'submenu', + submenu: [ + { label: 'Zytoskandal', linkType: 'page', page: pageIds['zytoskandal'] }, + { label: 'Whistleblowing', linkType: 'page', page: pageIds['whistleblowing'] }, + ], + }, + { + label: 'Unternehmer', + type: 'submenu', + submenu: [ + { label: 'gunshin Holding UG', linkType: 'page', page: pageIds['gunshin-holding'] }, + { label: 'complex care solutions GmbH', linkType: 'page', page: pageIds['complex-care-solutions'] }, + ], + }, + { + label: 'Mensch', + type: 'page', + page: pageIds['mensch'], + }, + { + label: 'Kontakt', + type: 'page', + page: pageIds['kontakt'], + }, + ], + footerMenu: [ + { label: 'Impressum', linkType: 'page', page: pageIds['impressum'] }, + { label: 'Datenschutzerklärung', linkType: 'page', page: pageIds['datenschutz'] }, + ], + } + + const existingNavigation = await payload.find({ + collection: 'navigations', + where: { tenant: { equals: tenantId } }, + limit: 1, }) + + if (existingNavigation.docs.length > 0) { + await payload.update({ + collection: 'navigations', + id: existingNavigation.docs[0].id, + data: navigationData as any, + }) + } else { + await payload.create({ + collection: 'navigations', + data: navigationData as any, + }) + } console.log('✓ Navigation configured') console.log('\n========================================') diff --git a/scripts/seed-pages-only.ts b/scripts/seed-pages-only.ts index cba3e7c..fde2caa 100644 --- a/scripts/seed-pages-only.ts +++ b/scripts/seed-pages-only.ts @@ -411,35 +411,53 @@ async function seed() { console.log('Page IDs:', pageIds) - await payload.updateGlobal({ - slug: 'navigation', - data: { - mainMenu: [ - { - label: 'Whistleblowing', - type: 'submenu', - submenu: [ - { label: 'Zytoskandal', linkType: 'page', page: pageIds['zytoskandal'] }, - { label: 'Whistleblowing', linkType: 'page', page: pageIds['whistleblowing'] }, - ], - }, - { - label: 'Unternehmer', - type: 'submenu', - submenu: [ - { label: 'gunshin Holding UG', linkType: 'page', page: pageIds['gunshin-holding'] }, - { label: 'complex care solutions GmbH', linkType: 'page', page: pageIds['complex-care-solutions'] }, - ], - }, - { label: 'Mensch', type: 'page', page: pageIds['mensch'] }, - { label: 'Kontakt', type: 'page', page: pageIds['kontakt'] }, - ], - footerMenu: [ - { label: 'Impressum', linkType: 'page', page: pageIds['impressum'] }, - { label: 'Datenschutzerklärung', linkType: 'page', page: pageIds['datenschutz'] }, - ], - }, + const navigationData = { + tenant: tenantId, + title: 'Hauptnavigation', + mainMenu: [ + { + label: 'Whistleblowing', + type: 'submenu', + submenu: [ + { label: 'Zytoskandal', linkType: 'page', page: pageIds['zytoskandal'] }, + { label: 'Whistleblowing', linkType: 'page', page: pageIds['whistleblowing'] }, + ], + }, + { + label: 'Unternehmer', + type: 'submenu', + submenu: [ + { label: 'gunshin Holding UG', linkType: 'page', page: pageIds['gunshin-holding'] }, + { label: 'complex care solutions GmbH', linkType: 'page', page: pageIds['complex-care-solutions'] }, + ], + }, + { label: 'Mensch', type: 'page', page: pageIds['mensch'] }, + { label: 'Kontakt', type: 'page', page: pageIds['kontakt'] }, + ], + footerMenu: [ + { label: 'Impressum', linkType: 'page', page: pageIds['impressum'] }, + { label: 'Datenschutzerklärung', linkType: 'page', page: pageIds['datenschutz'] }, + ], + } + + const existingNavigation = await payload.find({ + collection: 'navigations', + where: { tenant: { equals: tenantId } }, + limit: 1, }) + + if (existingNavigation.docs.length > 0) { + await payload.update({ + collection: 'navigations', + id: existingNavigation.docs[0].id, + data: navigationData as any, + }) + } else { + await payload.create({ + collection: 'navigations', + data: navigationData as any, + }) + } console.log('✓ Navigation configured') console.log('\n========================================') diff --git a/src/app/(payload)/api/auth/login/route.ts b/src/app/(payload)/api/auth/login/route.ts index be6e1f8..2986cf4 100644 --- a/src/app/(payload)/api/auth/login/route.ts +++ b/src/app/(payload)/api/auth/login/route.ts @@ -104,6 +104,10 @@ export async function POST(req: NextRequest): Promise { }, }) + if (!result.user) { + throw new Error('Login returned no user') + } + // Erfolgreicher Login - afterLogin Hook hat bereits geloggt // Setze Cookie für die Session const response = NextResponse.json({ diff --git a/src/app/(payload)/api/auth/meta/route.ts b/src/app/(payload)/api/auth/meta/route.ts index 5311c49..b9aa7cd 100644 --- a/src/app/(payload)/api/auth/meta/route.ts +++ b/src/app/(payload)/api/auth/meta/route.ts @@ -64,8 +64,11 @@ export async function GET(req: NextRequest) { } // Prüfen ob die Plattform Facebook oder Instagram ist - const platform = account.platform as string - if (!['facebook', 'instagram'].includes(platform)) { + const platform = + typeof account.platform === 'object' && account.platform + ? account.platform.slug + : undefined + if (!platform || !['facebook', 'instagram'].includes(platform)) { return NextResponse.json( { error: 'This endpoint is only for Facebook and Instagram accounts' }, { status: 400 } diff --git a/src/app/(payload)/api/community/analytics/channel-comparison/route.ts b/src/app/(payload)/api/community/analytics/channel-comparison/route.ts index c3a3208..e38d94e 100644 --- a/src/app/(payload)/api/community/analytics/channel-comparison/route.ts +++ b/src/app/(payload)/api/community/analytics/channel-comparison/route.ts @@ -112,7 +112,7 @@ export async function GET(request: NextRequest) { const topicCounts: Record = {} channelInteractions.forEach((i) => { if (i.analysis?.topics && Array.isArray(i.analysis.topics)) { - i.analysis.topics.forEach((t: { topic?: string }) => { + i.analysis.topics.forEach((t: { topic?: string | null }) => { if (t.topic) { topicCounts[t.topic] = (topicCounts[t.topic] || 0) + 1 } diff --git a/src/app/(payload)/api/community/analytics/overview/route.ts b/src/app/(payload)/api/community/analytics/overview/route.ts index 2dc9aff..3a7ed1e 100644 --- a/src/app/(payload)/api/community/analytics/overview/route.ts +++ b/src/app/(payload)/api/community/analytics/overview/route.ts @@ -70,14 +70,14 @@ export async function GET(request: NextRequest) { } // Build where clause - const baseWhere: Record = { + const baseWhere: any = { publishedAt: { greater_than_equal: periodStart.toISOString(), }, } if (channelId !== 'all') { - baseWhere.socialAccount = { equals: parseInt(channelId) } + baseWhere.socialAccount = { equals: parseInt(channelId, 10) } } // Fetch current period data @@ -141,7 +141,7 @@ export async function GET(request: NextRequest) { const escalations = docs.filter((i) => i.flags?.requiresEscalation).length // Fetch previous period for comparison - const previousWhere: Record = { + const previousWhere: any = { publishedAt: { greater_than_equal: previousPeriodStart.toISOString(), less_than: previousPeriodEnd.toISOString(), @@ -149,7 +149,7 @@ export async function GET(request: NextRequest) { } if (channelId !== 'all') { - previousWhere.socialAccount = { equals: parseInt(channelId) } + previousWhere.socialAccount = { equals: parseInt(channelId, 10) } } const previousInteractions = await payload.find({ diff --git a/src/app/(payload)/api/community/analytics/response-metrics/route.ts b/src/app/(payload)/api/community/analytics/response-metrics/route.ts index ff89cb8..821bbfa 100644 --- a/src/app/(payload)/api/community/analytics/response-metrics/route.ts +++ b/src/app/(payload)/api/community/analytics/response-metrics/route.ts @@ -68,12 +68,12 @@ export async function GET(request: NextRequest) { const prevPeriodEnd = periodStart // Build where clause - const baseWhere: Record = { + const baseWhere: any = { publishedAt: { greater_than_equal: periodStart.toISOString() }, } if (channelId !== 'all') { - baseWhere.socialAccount = { equals: parseInt(channelId) } + baseWhere.socialAccount = { equals: parseInt(channelId, 10) } } // Fetch current period data @@ -100,7 +100,7 @@ export async function GET(request: NextRequest) { const p90Time = percentile(responseTimes, 90) // Fetch previous period for trend - const prevWhere: Record = { + const prevWhere: any = { publishedAt: { greater_than_equal: prevPeriodStart.toISOString(), less_than: prevPeriodEnd.toISOString(), @@ -108,7 +108,7 @@ export async function GET(request: NextRequest) { } if (channelId !== 'all') { - prevWhere.socialAccount = { equals: parseInt(channelId) } + prevWhere.socialAccount = { equals: parseInt(channelId, 10) } } const prevInteractions = await payload.find({ diff --git a/src/app/(payload)/api/community/analytics/sentiment-trend/route.ts b/src/app/(payload)/api/community/analytics/sentiment-trend/route.ts index c1d59dd..faa138e 100644 --- a/src/app/(payload)/api/community/analytics/sentiment-trend/route.ts +++ b/src/app/(payload)/api/community/analytics/sentiment-trend/route.ts @@ -58,14 +58,14 @@ export async function GET(request: NextRequest) { const periodStart = subDays(now, days) // Build where clause - const baseWhere: Record = { + const baseWhere: any = { publishedAt: { greater_than_equal: periodStart.toISOString(), }, } if (channelId !== 'all') { - baseWhere.socialAccount = { equals: parseInt(channelId) } + baseWhere.socialAccount = { equals: parseInt(channelId, 10) } } // Fetch interactions diff --git a/src/app/(payload)/api/community/analytics/top-content/route.ts b/src/app/(payload)/api/community/analytics/top-content/route.ts index 57f906e..cb4ee61 100644 --- a/src/app/(payload)/api/community/analytics/top-content/route.ts +++ b/src/app/(payload)/api/community/analytics/top-content/route.ts @@ -54,13 +54,13 @@ export async function GET(request: NextRequest) { const periodStart = subDays(now, days) // Build where clause - const baseWhere: Record = { + const baseWhere: any = { publishedAt: { greater_than_equal: periodStart.toISOString() }, linkedContent: { exists: true }, } if (channelId !== 'all') { - baseWhere.socialAccount = { equals: parseInt(channelId) } + baseWhere.socialAccount = { equals: parseInt(channelId, 10) } } // Fetch interactions with linked content @@ -133,7 +133,7 @@ export async function GET(request: NextRequest) { const topicCounts: Record = {} contentInteractions.forEach((i) => { if (i.analysis?.topics && Array.isArray(i.analysis.topics)) { - i.analysis.topics.forEach((t: { topic?: string }) => { + i.analysis.topics.forEach((t: { topic?: string | null }) => { if (t.topic) { topicCounts[t.topic] = (topicCounts[t.topic] || 0) + 1 } diff --git a/src/app/(payload)/api/community/analytics/topic-cloud/route.ts b/src/app/(payload)/api/community/analytics/topic-cloud/route.ts index 6ed70a0..6894ce2 100644 --- a/src/app/(payload)/api/community/analytics/topic-cloud/route.ts +++ b/src/app/(payload)/api/community/analytics/topic-cloud/route.ts @@ -53,12 +53,12 @@ export async function GET(request: NextRequest) { const periodStart = subDays(now, days) // Build where clause - const baseWhere: Record = { + const baseWhere: any = { publishedAt: { greater_than_equal: periodStart.toISOString() }, } if (channelId !== 'all') { - baseWhere.socialAccount = { equals: parseInt(channelId) } + baseWhere.socialAccount = { equals: parseInt(channelId, 10) } } // Fetch interactions @@ -89,7 +89,7 @@ export async function GET(request: NextRequest) { const sentimentScore = i.analysis?.sentimentScore as number | undefined - i.analysis.topics.forEach((t: { topic?: string }) => { + i.analysis.topics.forEach((t: { topic?: string | null }) => { if (!t.topic) return const topic = t.topic.toLowerCase().trim() diff --git a/src/app/(payload)/api/community/export/route.ts b/src/app/(payload)/api/community/export/route.ts index fe4db97..06cba53 100644 --- a/src/app/(payload)/api/community/export/route.ts +++ b/src/app/(payload)/api/community/export/route.ts @@ -69,7 +69,7 @@ export async function GET(req: NextRequest) { } // Build query - const where: Record = {} + const where: any = {} if (dateFrom || dateTo) { where.publishedAt = {} diff --git a/src/app/(payload)/api/monitoring/alerts/acknowledge/route.ts b/src/app/(payload)/api/monitoring/alerts/acknowledge/route.ts index 4eaecba..38f28fa 100644 --- a/src/app/(payload)/api/monitoring/alerts/acknowledge/route.ts +++ b/src/app/(payload)/api/monitoring/alerts/acknowledge/route.ts @@ -7,7 +7,7 @@ export async function POST(req: NextRequest): Promise { const payload = await getPayload({ config }) const { user } = await payload.auth({ headers: req.headers }) - if (!user || !(user as Record).isSuperAdmin) { + if (!user || !(user as { isSuperAdmin?: boolean }).isSuperAdmin) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } diff --git a/src/app/(payload)/api/monitoring/alerts/route.ts b/src/app/(payload)/api/monitoring/alerts/route.ts index 2acb0a3..5762d43 100644 --- a/src/app/(payload)/api/monitoring/alerts/route.ts +++ b/src/app/(payload)/api/monitoring/alerts/route.ts @@ -15,7 +15,7 @@ export async function GET(req: NextRequest): Promise { const limit = parseInt(req.nextUrl.searchParams.get('limit') || '20', 10) const severity = req.nextUrl.searchParams.get('severity') - const where: Record = {} + const where: any = {} if (severity) { where.severity = { equals: severity } } diff --git a/src/app/(payload)/api/monitoring/logs/route.ts b/src/app/(payload)/api/monitoring/logs/route.ts index 89065da..78932ad 100644 --- a/src/app/(payload)/api/monitoring/logs/route.ts +++ b/src/app/(payload)/api/monitoring/logs/route.ts @@ -19,7 +19,7 @@ export async function GET(req: NextRequest): Promise { const from = req.nextUrl.searchParams.get('from') const to = req.nextUrl.searchParams.get('to') - const conditions: Record[] = [] + const conditions: any[] = [] if (level) { conditions.push({ level: { equals: level } }) @@ -37,7 +37,7 @@ export async function GET(req: NextRequest): Promise { conditions.push({ createdAt: { less_than_equal: to } }) } - const where = conditions.length > 0 ? { and: conditions } : {} + const where: any = conditions.length > 0 ? { and: conditions } : {} const logs = await payload.find({ collection: 'monitoring-logs', diff --git a/src/app/(payload)/api/monitoring/services/route.ts b/src/app/(payload)/api/monitoring/services/route.ts index 2d2f78f..0fd086c 100644 --- a/src/app/(payload)/api/monitoring/services/route.ts +++ b/src/app/(payload)/api/monitoring/services/route.ts @@ -40,15 +40,38 @@ export async function GET(req: NextRequest): Promise { return NextResponse.json({ data: { - redis: resolveSettled(redis, { status: 'offline' }), - postgresql: resolveSettled(postgresql, { status: 'offline' }), - pgbouncer: resolveSettled(pgbouncer, { status: 'offline' }), - smtp: resolveSettled(smtp, { status: 'offline' }), - oauth: resolveSettled(oauth, { - metaOAuth: { status: 'error' }, - youtubeOAuth: { status: 'error' }, + redis: resolveSettled(redis, { + status: 'offline', + memoryUsedMB: 0, + connectedClients: 0, + opsPerSec: 0, + }), + postgresql: resolveSettled(postgresql, { + status: 'offline', + connections: 0, + maxConnections: 0, + latencyMs: -1, + }), + pgbouncer: resolveSettled(pgbouncer, { + status: 'offline', + activeConnections: 0, + waitingClients: 0, + poolSize: 0, + }), + smtp: resolveSettled(smtp, { + status: 'offline', + lastCheck: new Date().toISOString(), + responseTimeMs: -1, + }), + oauth: resolveSettled(oauth, { + metaOAuth: { status: 'error', tokensTotal: 0, tokensExpiringSoon: 0, tokensExpired: 0 }, + youtubeOAuth: { status: 'error', tokensTotal: 0, tokensExpiringSoon: 0, tokensExpired: 0 }, + }), + cronJobs: resolveSettled(cronJobs, { + communitySync: { lastRun: '', status: 'unknown' }, + tokenRefresh: { lastRun: '', status: 'unknown' }, + youtubeSync: { lastRun: '', status: 'unknown' }, }), - cronJobs: resolveSettled(cronJobs, {}), queues: resolveSettled(queues, {}), }, timestamp: new Date().toISOString(), diff --git a/src/app/(payload)/api/monitoring/stream/route.ts b/src/app/(payload)/api/monitoring/stream/route.ts index f75fb0b..5b9ecba 100644 --- a/src/app/(payload)/api/monitoring/stream/route.ts +++ b/src/app/(payload)/api/monitoring/stream/route.ts @@ -32,7 +32,7 @@ export async function GET(request: NextRequest): Promise { const payload = await getPayload({ config }) const { user } = await payload.auth({ headers: request.headers }) - if (!user || !(user as Record).isSuperAdmin) { + if (!user || !(user as { isSuperAdmin?: boolean }).isSuperAdmin) { return new Response('Unauthorized', { status: 401 }) } diff --git a/src/app/(payload)/api/users/login/route.ts b/src/app/(payload)/api/users/login/route.ts index 61e968a..0cf40f5 100644 --- a/src/app/(payload)/api/users/login/route.ts +++ b/src/app/(payload)/api/users/login/route.ts @@ -18,22 +18,25 @@ import config from '@payload-config' import { NextRequest, NextResponse } from 'next/server' // Lazy imports für Security-Module um Initialisierungsfehler zu vermeiden -let securityModules: { +type SecurityModules = { authLimiter?: { check: (ip: string) => Promise<{ allowed: boolean; remaining: number; resetIn: number }> } - rateLimitHeaders?: (result: { remaining: number; resetIn: number }, max: number) => Record + rateLimitHeaders?: (result: unknown, max: number) => Record getClientIpFromRequest?: (req: NextRequest) => string isIpBlocked?: (ip: string) => boolean validateCsrf?: (req: NextRequest) => { valid: boolean; reason?: string } -} | null = null +} -async function getSecurityModules() { - if (securityModules) return securityModules +let securityModules: SecurityModules = {} +let securityModulesLoaded = false + +async function getSecurityModules(): Promise { + if (securityModulesLoaded) return securityModules try { const security = await import('@/lib/security') securityModules = { authLimiter: security.authLimiter, - rateLimitHeaders: security.rateLimitHeaders, + rateLimitHeaders: security.rateLimitHeaders as SecurityModules['rateLimitHeaders'], getClientIpFromRequest: security.getClientIpFromRequest, isIpBlocked: security.isIpBlocked, validateCsrf: security.validateCsrf, @@ -43,29 +46,46 @@ async function getSecurityModules() { securityModules = {} } + securityModulesLoaded = true + return securityModules } // Lazy import für Audit-Service -let auditService: { - logLoginFailed?: (payload: unknown, email: string, reason: string, clientInfo: { ipAddress: string; userAgent: string }) => Promise - logRateLimit?: (payload: unknown, endpoint: string, userId?: number, tenantId?: number) => Promise -} | null = null +type AuditService = { + logLoginFailed?: ( + payload: unknown, + email: string, + reason: string, + clientInfo: { ipAddress: string; userAgent: string }, + ) => Promise + logRateLimit?: ( + payload: unknown, + endpoint: string, + userId?: number, + tenantId?: number, + ) => Promise +} -async function getAuditService() { - if (auditService) return auditService +let auditService: AuditService = {} +let auditServiceLoaded = false + +async function getAuditService(): Promise { + if (auditServiceLoaded) return auditService try { const audit = await import('@/lib/audit/audit-service') auditService = { - logLoginFailed: audit.logLoginFailed, - logRateLimit: audit.logRateLimit, + logLoginFailed: audit.logLoginFailed as AuditService['logLoginFailed'], + logRateLimit: audit.logRateLimit as AuditService['logRateLimit'], } } catch (err) { console.warn('[Login] Audit service not available:', err) auditService = {} } + auditServiceLoaded = true + return auditService } diff --git a/src/app/(payload)/api/youtube/calendar/route.ts b/src/app/(payload)/api/youtube/calendar/route.ts index f2f660a..1843074 100644 --- a/src/app/(payload)/api/youtube/calendar/route.ts +++ b/src/app/(payload)/api/youtube/calendar/route.ts @@ -44,7 +44,7 @@ export async function GET(req: NextRequest): Promise { return NextResponse.json({ error: 'start and end required' }, { status: 400 }) } - const where: Record = { + const where: any = { scheduledPublishDate: { greater_than_equal: start, less_than_equal: end, @@ -74,15 +74,13 @@ export async function GET(req: NextRequest): Promise { // Build channel color lookup const channelColorMap = new Map() for (const ch of channels.docs) { - const branding = (ch as Record).branding as - | { primaryColor?: string } - | undefined + const branding = (ch as unknown as { branding?: { primaryColor?: string } }).branding channelColorMap.set(ch.id as number, branding?.primaryColor || '#3788d8') } // Build CalendarEvent array for conflict detection const calendarEvents: CalendarEvent[] = videos.docs.map((v) => { - const doc = v as Record + const doc = v as unknown as Record const channel = resolveRelation(doc.channel) const series = resolveRelation(doc.series) @@ -97,7 +95,7 @@ export async function GET(req: NextRequest): Promise { // Get schedule config from first channel const defaultSchedule = { longformPerWeek: 1, shortsPerWeek: 4 } - const firstChannel = channels.docs[0] as Record | undefined + const firstChannel = channels.docs[0] as unknown as Record | undefined const publishingSchedule = firstChannel?.publishingSchedule as | { longformPerWeek?: number; shortsPerWeek?: number } | undefined @@ -111,7 +109,7 @@ export async function GET(req: NextRequest): Promise { // Format for FullCalendar const events = videos.docs.map((v) => { - const doc = v as Record + const doc = v as unknown as Record const channel = resolveRelation(doc.channel) const series = resolveRelation(doc.series) @@ -177,7 +175,7 @@ export async function PATCH(req: NextRequest): Promise { depth: 0, }) - const status = (content as Record).status as string + const status = (content as unknown as Record).status as string if (status === 'published' || status === 'tracked') { return NextResponse.json( { error: 'Veröffentlichte Videos können nicht verschoben werden' }, diff --git a/src/app/(payload)/api/youtube/thumbnails/bulk/route.ts b/src/app/(payload)/api/youtube/thumbnails/bulk/route.ts index f560dbc..7d8154a 100644 --- a/src/app/(payload)/api/youtube/thumbnails/bulk/route.ts +++ b/src/app/(payload)/api/youtube/thumbnails/bulk/route.ts @@ -58,13 +58,15 @@ export async function POST(req: NextRequest) { try { const thumbnailUrl = getYouTubeThumbnail(videoId, 'hq') const filename = `yt-thumb-${videoId}.jpg` - const tenantId = typeof doc.tenant === 'object' ? doc.tenant?.id : doc.tenant + const title = + typeof doc.title === 'string' + ? doc.title + : `YouTube Video ${videoId}` const mediaId = await downloadAndUploadImage(payload, { url: thumbnailUrl, filename, - alt: `Thumbnail: ${typeof doc.title === 'string' ? doc.title : doc.title?.de || videoId}`, - tenantId: tenantId || undefined, + alt: `Thumbnail: ${title}`, }) if (mediaId) { diff --git a/src/app/(payload)/api/youtube/upload/route.ts b/src/app/(payload)/api/youtube/upload/route.ts index 485ca54..4988b8c 100644 --- a/src/app/(payload)/api/youtube/upload/route.ts +++ b/src/app/(payload)/api/youtube/upload/route.ts @@ -42,7 +42,7 @@ export async function POST(request: NextRequest): Promise { return NextResponse.json({ error: 'Content not found' }, { status: 404 }) } - const doc = content as Record + const doc = content as unknown as Record if (!doc.videoFile) { return NextResponse.json({ error: 'No video file attached' }, { status: 400 }) diff --git a/src/collections/ReportSchedules.ts b/src/collections/ReportSchedules.ts index 24f69c1..9cad871 100644 --- a/src/collections/ReportSchedules.ts +++ b/src/collections/ReportSchedules.ts @@ -115,7 +115,7 @@ export const ReportSchedules: CollectionConfig = { width: '50%', description: 'Format: HH:MM (24-Stunden)', }, - validate: (value) => { + validate: (value: string | null | undefined) => { if (!value) return true const regex = /^([01]\d|2[0-3]):([0-5]\d)$/ if (!regex.test(value)) { @@ -233,7 +233,6 @@ export const ReportSchedules: CollectionConfig = { // === Status (Read-only) === { - name: 'statusSection', type: 'collapsible', label: 'Status & Statistiken', admin: { diff --git a/src/collections/YouTubeContent.ts b/src/collections/YouTubeContent.ts index 25bf657..c23c007 100644 --- a/src/collections/YouTubeContent.ts +++ b/src/collections/YouTubeContent.ts @@ -115,7 +115,7 @@ export const YouTubeContent: CollectionConfig = { }, } } - return {} + return true }, admin: { position: 'sidebar', diff --git a/src/components/admin/YouTubeAnalyticsDashboard.tsx b/src/components/admin/YouTubeAnalyticsDashboard.tsx index 33ca489..c15165a 100644 --- a/src/components/admin/YouTubeAnalyticsDashboard.tsx +++ b/src/components/admin/YouTubeAnalyticsDashboard.tsx @@ -118,7 +118,7 @@ interface ApiResponse { data: PerformanceData | PipelineData | GoalsData | CommunityData } -const tabConfig: Array<{ key: Tab; label: string; icon: JSX.Element }> = [ +const tabConfig: Array<{ key: Tab; label: string; icon: React.ReactNode }> = [ { key: 'performance', label: 'Performance', @@ -201,7 +201,7 @@ const formatDate = (dateString: string): string => { }) } -const renderTrend = (value: number): JSX.Element | null => { +const renderTrend = (value: number): React.ReactNode => { if (value === 0) return null const isUp = value > 0 return ( diff --git a/src/hooks/youtubeContent/autoStatusTransitions.ts b/src/hooks/youtubeContent/autoStatusTransitions.ts index aca9642..b7e427f 100644 --- a/src/hooks/youtubeContent/autoStatusTransitions.ts +++ b/src/hooks/youtubeContent/autoStatusTransitions.ts @@ -1,8 +1,11 @@ import type { CollectionAfterChangeHook } from 'payload' import { NotificationService } from '@/lib/jobs/NotificationService' +import type { YoutubeContent } from '@/payload-types' + +type YouTubeContentStatus = NonNullable interface TransitionContext { - currentStatus: string + currentStatus: YouTubeContentStatus youtubeVideoId?: string | null hasAllChecklistsComplete: boolean } @@ -10,7 +13,7 @@ interface TransitionContext { /** * Determines the next status based on current state and conditions */ -export function getNextStatus(context: TransitionContext): string | null { +export function getNextStatus(context: TransitionContext): YouTubeContentStatus | null { const { currentStatus, youtubeVideoId } = context // Upload completed → published @@ -25,7 +28,7 @@ export function getNextStatus(context: TransitionContext): string | null { * Checks if a manual transition should be suggested */ export function shouldTransitionStatus( - status: string, + status: YouTubeContentStatus, context: { hasVideoFile?: boolean }, ): boolean { if (status === 'approved' && context.hasVideoFile) return true @@ -44,7 +47,7 @@ export const autoStatusTransitions: CollectionAfterChangeHook = async ({ if (operation !== 'update') return doc const nextStatus = getNextStatus({ - currentStatus: doc.status, + currentStatus: doc.status as YouTubeContentStatus, youtubeVideoId: doc.youtube?.videoId, hasAllChecklistsComplete: false, }) diff --git a/src/hooks/youtubeContent/createTasksOnStatusChange.ts b/src/hooks/youtubeContent/createTasksOnStatusChange.ts index a9e965f..bdd3b92 100644 --- a/src/hooks/youtubeContent/createTasksOnStatusChange.ts +++ b/src/hooks/youtubeContent/createTasksOnStatusChange.ts @@ -1,11 +1,15 @@ // src/hooks/youtubeContent/createTasksOnStatusChange.ts import type { CollectionAfterChangeHook } from 'payload' +import type { YtTask } from '@/payload-types' + +type TaskType = NonNullable +type AssignRole = 'creator' | 'producer' | 'editor' | 'manager' interface TaskTemplate { title: string - type: string - assignRole: string + type: TaskType + assignRole: AssignRole } /** diff --git a/src/jobs/syncAllComments.ts b/src/jobs/syncAllComments.ts index 396920f..df1cc7b 100644 --- a/src/jobs/syncAllComments.ts +++ b/src/jobs/syncAllComments.ts @@ -121,13 +121,14 @@ export async function runSyncAllCommentsJob(payload: Payload): Promise<{ ) const syncService = new CommentsSyncService(payload) - const result = await syncService.syncCommentsForAccount(account.id, { + const result = await syncService.syncComments({ + socialAccountId: account.id, maxComments: 100, analyzeWithAI: true, }) - const created = result.created || 0 - const updated = result.updated || 0 + const created = result.newComments || 0 + const updated = result.updatedComments || 0 totalNew += created totalUpdated += updated diff --git a/src/lib/communityAccess.ts b/src/lib/communityAccess.ts index d3effcf..9a2d8c9 100644 --- a/src/lib/communityAccess.ts +++ b/src/lib/communityAccess.ts @@ -79,5 +79,5 @@ export const canAccessAssignedInteractions: Access = ({ req }) => { { assignedTo: { equals: user.id } }, { 'response.sentBy': { equals: user.id } }, ], - } + } as any } diff --git a/src/lib/integrations/meta/FacebookSyncService.ts b/src/lib/integrations/meta/FacebookSyncService.ts index 612dfc9..b6beabd 100644 --- a/src/lib/integrations/meta/FacebookSyncService.ts +++ b/src/lib/integrations/meta/FacebookSyncService.ts @@ -279,6 +279,9 @@ export class FacebookSyncService { // Platform ID holen const platformId = await this.getPlatformId() + if (platformId === null) { + throw new Error('Facebook platform not configured') + } // Interaction-Typ bestimmen (comment oder reply) const interactionType = comment.parent?.id ? 'reply' : 'comment' @@ -299,10 +302,10 @@ export class FacebookSyncService { } // Interaction-Daten zusammenstellen - const interactionData: Record = { + const interactionData = { platform: platformId, socialAccount: account.id, - type: interactionType, + type: interactionType as 'comment' | 'reply', externalId: comment.id, parentInteraction: parentInteractionId, author: { @@ -327,7 +330,12 @@ export class FacebookSyncService { ...(comment.attachment && { attachments: [ { - type: comment.attachment.type === 'photo' ? 'image' : comment.attachment.type, + type: + comment.attachment.type === 'photo' + ? 'image' + : comment.attachment.type === 'video' + ? 'video' + : 'link' as 'image' | 'video' | 'link', url: comment.attachment.url || comment.attachment.media?.image?.src, }, ], @@ -352,6 +360,8 @@ export class FacebookSyncService { }), // Interne Notizen mit Post-Kontext internalNotes: `Facebook Post: ${post.permalink_url || post.id}`, + status: 'new' as const, + priority: 'normal' as const, } let interactionId: number @@ -359,6 +369,7 @@ export class FacebookSyncService { if (isNew) { const created = await this.payload.create({ collection: 'community-interactions', + draft: false, data: interactionData, }) interactionId = created.id as number diff --git a/src/lib/integrations/meta/InstagramSyncService.ts b/src/lib/integrations/meta/InstagramSyncService.ts index 6d0424d..6b66143 100644 --- a/src/lib/integrations/meta/InstagramSyncService.ts +++ b/src/lib/integrations/meta/InstagramSyncService.ts @@ -324,6 +324,9 @@ export class InstagramSyncService { // Platform ID holen const platformId = await this.getPlatformId() + if (platformId === null) { + throw new Error('Instagram platform not configured') + } // Interaction-Typ bestimmen const interactionType = parentCommentId ? 'reply' : 'comment' @@ -344,10 +347,10 @@ export class InstagramSyncService { } // Interaction-Daten zusammenstellen - const interactionData: Record = { + const interactionData = { platform: platformId, socialAccount: account.id, - type: interactionType, + type: interactionType as 'comment' | 'reply', externalId: comment.id, parentInteraction: parentInteractionId, author: { @@ -386,6 +389,8 @@ export class InstagramSyncService { }), // Interne Notizen mit Media-Kontext internalNotes: `Instagram Post: ${media.permalink}`, + status: 'new' as const, + priority: 'normal' as const, } let interactionId: number @@ -393,6 +398,7 @@ export class InstagramSyncService { if (isNew) { const created = await this.payload.create({ collection: 'community-interactions', + draft: false, data: interactionData, }) interactionId = created.id as number @@ -494,12 +500,15 @@ export class InstagramSyncService { // Platform ID holen const platformId = await this.getPlatformId() + if (platformId === null) { + throw new Error('Instagram platform not configured') + } // Mention als Interaction speichern - const interactionData: Record = { + const interactionData = { platform: platformId, socialAccount: account.id, - type: 'mention', + type: 'mention' as const, externalId: `mention_${mention.id}`, author: { name: mention.username, @@ -528,10 +537,13 @@ export class InstagramSyncService { }, }), internalNotes: `Instagram Mention: ${mention.permalink}`, + status: 'new' as const, + priority: 'normal' as const, } await this.payload.create({ collection: 'community-interactions', + draft: false, data: interactionData, }) diff --git a/src/lib/integrations/meta/MetaBaseClient.ts b/src/lib/integrations/meta/MetaBaseClient.ts index 11be238..c904945 100644 --- a/src/lib/integrations/meta/MetaBaseClient.ts +++ b/src/lib/integrations/meta/MetaBaseClient.ts @@ -146,7 +146,7 @@ export class MetaBaseClient { const nextResponse = await fetch(nextUrl) const nextData: MetaPaginatedResponse = await nextResponse.json() - if (nextData.error) { + if ('error' in (nextData as unknown as MetaErrorResponse)) { throw new MetaApiError((nextData as unknown as MetaErrorResponse).error) } diff --git a/src/lib/integrations/youtube/CommentsSyncService.ts b/src/lib/integrations/youtube/CommentsSyncService.ts index 703e98b..ca576c4 100644 --- a/src/lib/integrations/youtube/CommentsSyncService.ts +++ b/src/lib/integrations/youtube/CommentsSyncService.ts @@ -231,6 +231,8 @@ export class CommentsSyncService { isFromInfluencer: false, }, }), + status: 'new' as const, + priority: 'normal' as const, } let parentInteractionId: number | null = null @@ -238,6 +240,7 @@ export class CommentsSyncService { if (isNew) { const created = await this.payload.create({ collection: 'community-interactions', + draft: false, data: interactionData, }) parentInteractionId = created.id @@ -314,6 +317,7 @@ export class CommentsSyncService { await this.payload.create({ collection: 'community-interactions', + draft: false, data: { platform: platformId, socialAccount: account.id, @@ -356,6 +360,8 @@ export class CommentsSyncService { isFromInfluencer: false, }, }), + status: 'new' as const, + priority: 'normal' as const, }, }) diff --git a/src/lib/integrations/youtube/YouTubeClient.ts b/src/lib/integrations/youtube/YouTubeClient.ts index 5aa4757..1b1a929 100644 --- a/src/lib/integrations/youtube/YouTubeClient.ts +++ b/src/lib/integrations/youtube/YouTubeClient.ts @@ -34,7 +34,7 @@ interface CommentThread { export class YouTubeClient { private youtube: youtube_v3.Youtube - private oauth2Client: ReturnType + private oauth2Client: InstanceType private payload: Payload constructor(credentials: YouTubeCredentials, payload: Payload) { diff --git a/src/lib/monitoring/alert-evaluator.ts b/src/lib/monitoring/alert-evaluator.ts index a1b1c34..e9345f3 100644 --- a/src/lib/monitoring/alert-evaluator.ts +++ b/src/lib/monitoring/alert-evaluator.ts @@ -60,13 +60,13 @@ export function evaluateCondition( // ============================================================================ interface AlertRule { - id: string + id: number name: string metric: string condition: AlertCondition threshold: number severity: AlertSeverity - channels: string[] + channels: Array<'email' | 'slack' | 'discord'> recipients?: { emails?: Array<{ email: string }> slackWebhook?: string @@ -132,9 +132,10 @@ export class AlertEvaluator { if (value === undefined) continue if (evaluateCondition(rule.condition, value, rule.threshold)) { - if (this.shouldFire(rule.id, rule.cooldownMinutes)) { + const ruleKey = String(rule.id) + if (this.shouldFire(ruleKey, rule.cooldownMinutes)) { await this.dispatchAlert(payload, rule, value) - this.recordFired(rule.id) + this.recordFired(ruleKey) } } } diff --git a/src/lib/monitoring/monitoring-logger.ts b/src/lib/monitoring/monitoring-logger.ts index 1cef19d..97cda13 100644 --- a/src/lib/monitoring/monitoring-logger.ts +++ b/src/lib/monitoring/monitoring-logger.ts @@ -40,9 +40,9 @@ export interface MonitoringLoggerInstance { } /** Cached Payload instance — resolved once, reused for all subsequent writes. */ -let cachedPayload: { create: (...args: unknown[]) => Promise } | null = null +let cachedPayload: any = null -async function getPayloadInstance() { +async function getPayloadInstance(): Promise { if (cachedPayload) return cachedPayload const { getPayload } = await import('payload') const config = (await import(/* @vite-ignore */ '@payload-config')).default diff --git a/src/lib/monitoring/monitoring-service.ts b/src/lib/monitoring/monitoring-service.ts index 719e922..09e59ad 100644 --- a/src/lib/monitoring/monitoring-service.ts +++ b/src/lib/monitoring/monitoring-service.ts @@ -258,7 +258,7 @@ export async function checkOAuthTokens(): Promise<{ const youtube = { tokensTotal: 0, tokensExpiringSoon: 0, tokensExpired: 0 } for (const account of accounts.docs) { - const doc = account as Record + const doc = account as unknown as Record const target = doc.platform === 'youtube' ? youtube : meta target.tokensTotal++ @@ -304,7 +304,7 @@ export async function checkCronJobs(): Promise { if (logs.docs.length === 0) return unknownStatus - const doc = logs.docs[0] as Record + const doc = logs.docs[0] as unknown as Record return { lastRun: doc.createdAt as string, status: doc.level === 'error' ? 'failed' : 'ok', diff --git a/src/lib/monitoring/snapshot-collector.ts b/src/lib/monitoring/snapshot-collector.ts index 85e210d..7348be1 100644 --- a/src/lib/monitoring/snapshot-collector.ts +++ b/src/lib/monitoring/snapshot-collector.ts @@ -13,9 +13,9 @@ let interval: ReturnType | null = null const alertEvaluator = new AlertEvaluator() /** Cached Payload instance — resolved once, reused on every tick. */ -let cachedPayload: { create: (...args: unknown[]) => Promise; find: (...args: unknown[]) => Promise } | null = null +let cachedPayload: any = null -async function getPayloadInstance() { +async function getPayloadInstance(): Promise { if (cachedPayload) return cachedPayload const { getPayload } = await import('payload') const config = (await import(/* @vite-ignore */ '@payload-config')).default diff --git a/src/lib/services/ReportGeneratorService.ts b/src/lib/services/ReportGeneratorService.ts index cbb3c44..d840dc4 100644 --- a/src/lib/services/ReportGeneratorService.ts +++ b/src/lib/services/ReportGeneratorService.ts @@ -144,7 +144,7 @@ export class ReportGeneratorService { depth: 0, overrideAccess: true, }) - const tenant = (account as Record)?.tenant + const tenant = (account as unknown as Record)?.tenant if (typeof tenant === 'number') return tenant if (typeof tenant === 'object' && tenant && 'id' in (tenant as Record)) { return (tenant as { id: number }).id @@ -198,12 +198,13 @@ export class ReportGeneratorService { } // Update schedule stats + const existingSendCount = (await this.getSchedule(schedule.id))?.sendCount ?? 0 await this.payload.update({ collection: 'report-schedules', id: schedule.id, data: { lastSentAt: new Date().toISOString(), - sendCount: (await this.getSchedule(schedule.id))?.sendCount + 1 || 1, + sendCount: existingSendCount + 1, lastError: null, }, }) @@ -289,10 +290,12 @@ export class ReportGeneratorService { limit: 1000, }) - const replied = interactions.docs.filter((i) => i.status === 'replied') - const platforms = interactions.docs.reduce( + const docs = interactions.docs as any[] + + const replied = docs.filter((i) => i.status === 'replied') + const platforms = docs.reduce( (acc, i) => { - const platform = (i.platform as string) || 'unknown' + const platform = this.resolvePlatformName(i.platform) acc[platform] = (acc[platform] || 0) + 1 return acc }, @@ -303,9 +306,9 @@ export class ReportGeneratorService { let totalResponseTime = 0 let responseCount = 0 for (const interaction of replied) { - if (interaction.repliedAt && interaction.createdAt) { + if (interaction.response?.sentAt && interaction.createdAt) { const responseTime = - new Date(interaction.repliedAt as string).getTime() - + new Date(interaction.response.sentAt as string).getTime() - new Date(interaction.createdAt as string).getTime() totalResponseTime += responseTime responseCount++ @@ -314,10 +317,13 @@ export class ReportGeneratorService { return { totalInteractions: interactions.totalDocs, - newInteractions: interactions.docs.filter((i) => i.status === 'new').length, + newInteractions: docs.filter((i) => i.status === 'new').length, repliedCount: replied.length, avgResponseTime: responseCount > 0 ? totalResponseTime / responseCount / (1000 * 60 * 60) : 0, - platforms: Object.entries(platforms).map(([platform, count]) => ({ platform, count })), + platforms: Object.entries(platforms).map(([platform, count]) => ({ + platform, + count: typeof count === 'number' ? count : 0, + })), } } @@ -342,14 +348,16 @@ export class ReportGeneratorService { limit: 1000, }) + const docs = interactions.docs as any[] + let positive = 0 let neutral = 0 let negative = 0 const dailyData: Record = {} - for (const interaction of interactions.docs) { - const sentiment = (interaction.aiAnalysis as any)?.sentiment || 'neutral' + for (const interaction of docs) { + const sentiment = interaction.analysis?.sentiment || 'neutral' const date = new Date(interaction.createdAt as string).toISOString().split('T')[0] if (!dailyData[date]) { @@ -399,24 +407,26 @@ export class ReportGeneratorService { limit: 1000, }) - const replied = interactions.docs.filter((i) => i.status === 'replied') - const pending = interactions.docs.filter((i) => i.status === 'new' || i.status === 'in_review') + const docs = interactions.docs as any[] + + const replied = docs.filter((i) => i.status === 'replied') + const pending = docs.filter((i) => i.status === 'new' || i.status === 'in_review') // Berechne durchschnittliche Antwortzeit let totalTime = 0 let count = 0 const platformData: Record = {} - for (const interaction of interactions.docs) { - const platform = (interaction.platform as string) || 'unknown' + for (const interaction of docs) { + const platform = this.resolvePlatformName(interaction.platform) if (!platformData[platform]) { platformData[platform] = { totalTime: 0, count: 0, replied: 0 } } platformData[platform].count++ - if (interaction.status === 'replied' && interaction.repliedAt && interaction.createdAt) { + if (interaction.status === 'replied' && interaction.response?.sentAt && interaction.createdAt) { const responseTime = - new Date(interaction.repliedAt as string).getTime() - + new Date(interaction.response.sentAt as string).getTime() - new Date(interaction.createdAt as string).getTime() totalTime += responseTime count++ @@ -459,14 +469,22 @@ export class ReportGeneratorService { limit: 1000, }) + const docs = interactions.docs as any[] + // Top Posts nach Kommentaren gruppieren const postData: Record = {} const topicCount: Record = {} - for (const interaction of interactions.docs) { - const postId = (interaction.sourceId as string) || 'unknown' - const platform = (interaction.platform as string) || 'unknown' - const title = (interaction.sourceTitle as string) || postId + for (const interaction of docs) { + const linkedContent = interaction.linkedContent + const postId = + typeof linkedContent === 'object' && linkedContent && 'id' in linkedContent + ? String((linkedContent as { id: number }).id) + : typeof linkedContent === 'number' + ? String(linkedContent) + : 'unknown' + const platform = this.resolvePlatformName(interaction.platform) + const title = this.resolveContentTitle(linkedContent, postId) if (!postData[postId]) { postData[postId] = { title, platform, comments: 0 } @@ -474,9 +492,16 @@ export class ReportGeneratorService { postData[postId].comments++ // Topics aus AI-Analyse sammeln - const topics = (interaction.aiAnalysis as any)?.topics || [] + const topics = Array.isArray(interaction.analysis?.topics) + ? interaction.analysis.topics + : [] for (const topic of topics) { - topicCount[topic] = (topicCount[topic] || 0) + 1 + const topicName = + typeof topic === 'string' + ? topic + : (topic as { topic?: string | null })?.topic || '' + if (!topicName) continue + topicCount[topicName] = (topicCount[topicName] || 0) + 1 } } @@ -502,7 +527,7 @@ export class ReportGeneratorService { }.pdf` try { - const result = await generatePdfFromHtml(html, { filename }) + const result = await generatePdfFromHtml(html) if (!result.success || !result.buffer) { throw new Error(result.error || 'PDF generation failed') @@ -753,6 +778,27 @@ export class ReportGeneratorService { return html } + private resolvePlatformName(platform: unknown): string { + if (typeof platform === 'string') return platform + if (typeof platform === 'number') return String(platform) + if (platform && typeof platform === 'object') { + const p = platform as { slug?: string; name?: string } + return p.slug || p.name || 'unknown' + } + return 'unknown' + } + + private resolveContentTitle(linkedContent: unknown, fallbackId: string): string { + if (!linkedContent || typeof linkedContent !== 'object') return fallbackId + const content = linkedContent as { title?: unknown } + if (typeof content.title === 'string') return content.title + if (content.title && typeof content.title === 'object') { + const localized = content.title as { de?: string; en?: string } + return localized.de || localized.en || fallbackId + } + return fallbackId + } + /** * Generiert einfachen E-Mail-Body für Attachments */ diff --git a/src/lib/services/RulesEngine.ts b/src/lib/services/RulesEngine.ts index b6003e5..81d05e2 100644 --- a/src/lib/services/RulesEngine.ts +++ b/src/lib/services/RulesEngine.ts @@ -289,20 +289,21 @@ export class RulesEngine { if (!userId) return try { + const relatedVideo = + typeof interaction.linkedContent === 'object' + ? interaction.linkedContent.id + : interaction.linkedContent + // Prüfen ob yt-notifications Collection existiert await this.payload.create({ collection: 'yt-notifications', data: { - user: userId, - type: 'community_rule_triggered', + recipient: userId, + type: 'system', title: `🔔 Rule "${rule.name}" triggered`, message: `New interaction from ${interaction.author?.name || 'Unknown'}: "${(interaction.message || '').substring(0, 100)}..."`, - relatedContent: - typeof interaction.linkedContent === 'object' - ? interaction.linkedContent.id - : interaction.linkedContent, - priority: interaction.priority === 'urgent' ? 'urgent' : 'normal', - isRead: false, + relatedVideo, + read: false, }, }) } catch (error) { diff --git a/src/lib/youtubeAccess.ts b/src/lib/youtubeAccess.ts index 56d6c9b..15d72e5 100644 --- a/src/lib/youtubeAccess.ts +++ b/src/lib/youtubeAccess.ts @@ -74,7 +74,7 @@ export const canAccessAssignedContent: Access = ({ req }) => { { assignedTo: { equals: user.id } }, { createdBy: { equals: user.id } }, ], - } + } as any } /** diff --git a/src/payload-types.ts b/src/payload-types.ts index edf577a..a64ee21 100644 --- a/src/payload-types.ts +++ b/src/payload-types.ts @@ -121,6 +121,10 @@ export interface Config { 'privacy-policy-settings': PrivacyPolicySetting; 'email-logs': EmailLog; 'audit-logs': AuditLog; + 'monitoring-snapshots': MonitoringSnapshot; + 'monitoring-logs': MonitoringLog; + 'monitoring-alert-rules': MonitoringAlertRule; + 'monitoring-alert-history': MonitoringAlertHistory; 'site-settings': SiteSetting; navigations: Navigation; forms: Form; @@ -187,6 +191,10 @@ export interface Config { 'privacy-policy-settings': PrivacyPolicySettingsSelect | PrivacyPolicySettingsSelect; 'email-logs': EmailLogsSelect | EmailLogsSelect; 'audit-logs': AuditLogsSelect | AuditLogsSelect; + 'monitoring-snapshots': MonitoringSnapshotsSelect | MonitoringSnapshotsSelect; + 'monitoring-logs': MonitoringLogsSelect | MonitoringLogsSelect; + 'monitoring-alert-rules': MonitoringAlertRulesSelect | MonitoringAlertRulesSelect; + 'monitoring-alert-history': MonitoringAlertHistorySelect | MonitoringAlertHistorySelect; 'site-settings': SiteSettingsSelect | SiteSettingsSelect; navigations: NavigationsSelect | NavigationsSelect; forms: FormsSelect | FormsSelect; @@ -306,6 +314,10 @@ export interface YoutubeChannel { language: 'de' | 'en'; category: 'lifestyle' | 'corporate' | 'b2b'; status: 'active' | 'planned' | 'paused' | 'archived'; + /** + * Profil-Thumbnail URL von YouTube (automatisch befüllt) + */ + channelThumbnailUrl?: string | null; branding?: { /** * z.B. #1278B3 @@ -490,7 +502,7 @@ export interface Tenant { /** * Hostname ohne Protokoll (z.B. smtp.gmail.com) */ - host: string; + host?: string | null; /** * 587 (STARTTLS) oder 465 (SSL) */ @@ -502,7 +514,7 @@ export interface Tenant { /** * Meist die E-Mail-Adresse */ - user: string; + user?: string | null; /** * Leer lassen um bestehendes Passwort zu behalten */ @@ -524,11 +536,6 @@ export interface Page { * URL-Pfad (z.B. "ueber-uns" / "about-us") */ slug: string; - hero?: { - image?: (number | null) | Media; - headline?: string | null; - subline?: string | null; - }; layout?: | ( | { @@ -793,7 +800,13 @@ export interface Page { headline?: string | null; cards?: | { + mediaType?: ('none' | 'image' | 'icon') | null; image?: (number | null) | Media; + /** + * Lucide Icon-Name (z.B. "heart", "star", "shield-check", "camera") + */ + icon?: string | null; + iconPosition?: ('top' | 'left') | null; title: string; description?: string | null; link?: string | null; @@ -6377,6 +6390,17 @@ export interface YoutubeContent { subscribersGained?: number | null; lastSyncedAt?: string | null; }; + /** + * Für ROI-Berechnung (manuell pflegen) + */ + costs?: { + estimatedProductionHours?: number | null; + estimatedProductionCost?: number | null; + /** + * AdSense + Sponsoring + Affiliate + */ + estimatedRevenue?: number | null; + }; /** * Nur für das Team sichtbar */ @@ -7418,6 +7442,232 @@ export interface AuditLog { updatedAt: string; createdAt: string; } +/** + * Historische System-Metriken für Trend-Charts + * + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "monitoring-snapshots". + */ +export interface MonitoringSnapshot { + id: number; + timestamp: string; + /** + * System-Ressourcen (CPU, RAM, Disk) + */ + system?: { + cpuUsagePercent?: number | null; + memoryUsedMB?: number | null; + memoryTotalMB?: number | null; + memoryUsagePercent?: number | null; + diskUsedGB?: number | null; + diskTotalGB?: number | null; + diskUsagePercent?: number | null; + loadAvg1?: number | null; + loadAvg5?: number | null; + /** + * Uptime in Sekunden + */ + uptime?: number | null; + }; + /** + * Service-Status (PM2-Prozesse, Datenbank, Cache) + */ + services?: { + payload?: + | { + [k: string]: unknown; + } + | unknown[] + | string + | number + | boolean + | null; + queueWorker?: + | { + [k: string]: unknown; + } + | unknown[] + | string + | number + | boolean + | null; + postgresql?: + | { + [k: string]: unknown; + } + | unknown[] + | string + | number + | boolean + | null; + pgbouncer?: + | { + [k: string]: unknown; + } + | unknown[] + | string + | number + | boolean + | null; + redis?: + | { + [k: string]: unknown; + } + | unknown[] + | string + | number + | boolean + | null; + }; + /** + * Externe Services (SMTP, OAuth, Cron) + */ + external?: { + smtp?: + | { + [k: string]: unknown; + } + | unknown[] + | string + | number + | boolean + | null; + metaOAuth?: + | { + [k: string]: unknown; + } + | unknown[] + | string + | number + | boolean + | null; + youtubeOAuth?: + | { + [k: string]: unknown; + } + | unknown[] + | string + | number + | boolean + | null; + cronJobs?: + | { + [k: string]: unknown; + } + | unknown[] + | string + | number + | boolean + | null; + }; + /** + * Performance-Metriken + */ + performance?: { + avgResponseTimeMs?: number | null; + p95ResponseTimeMs?: number | null; + p99ResponseTimeMs?: number | null; + errorRate?: number | null; + requestsPerMinute?: number | null; + }; + updatedAt: string; + createdAt: string; +} +/** + * Structured Logs für Business-Events + * + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "monitoring-logs". + */ +export interface MonitoringLog { + id: number; + level: 'debug' | 'info' | 'warn' | 'error' | 'fatal'; + source: 'payload' | 'queue-worker' | 'cron' | 'email' | 'oauth' | 'sync'; + message: string; + /** + * Strukturierte Metadaten + */ + context?: + | { + [k: string]: unknown; + } + | unknown[] + | string + | number + | boolean + | null; + /** + * Korrelations-ID + */ + requestId?: string | null; + userId?: (number | null) | User; + tenant?: (number | null) | Tenant; + /** + * Dauer in ms + */ + duration?: number | null; + updatedAt: string; + createdAt: string; +} +/** + * Konfigurierbare Alert-Regeln + * + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "monitoring-alert-rules". + */ +export interface MonitoringAlertRule { + id: number; + name: string; + /** + * z.B. system.cpuUsagePercent, services.redis.memoryUsedMB + */ + metric: string; + condition: 'gt' | 'lt' | 'eq' | 'gte' | 'lte'; + threshold: number; + severity: 'warning' | 'error' | 'critical'; + channels: ('email' | 'slack' | 'discord')[]; + recipients?: { + emails?: + | { + email: string; + id?: string | null; + }[] + | null; + slackWebhook?: string | null; + discordWebhook?: string | null; + }; + /** + * Minimaler Abstand zwischen gleichen Alerts + */ + cooldownMinutes?: number | null; + enabled?: boolean | null; + /** + * Optional: Tenant-spezifische Regel + */ + tenant?: (number | null) | Tenant; + updatedAt: string; + createdAt: string; +} +/** + * Alert-Log (WORM - Write Once) + * + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "monitoring-alert-history". + */ +export interface MonitoringAlertHistory { + id: number; + rule?: (number | null) | MonitoringAlertRule; + metric: string; + value: number; + threshold: number; + severity: 'warning' | 'error' | 'critical'; + message: string; + channelsSent?: ('email' | 'slack' | 'discord')[] | null; + resolvedAt?: string | null; + acknowledgedBy?: (number | null) | User; + updatedAt: string; + createdAt: string; +} /** * Allgemeine Website-Einstellungen pro Tenant * @@ -7800,6 +8050,22 @@ export interface PayloadLockedDocument { relationTo: 'audit-logs'; value: number | AuditLog; } | null) + | ({ + relationTo: 'monitoring-snapshots'; + value: number | MonitoringSnapshot; + } | null) + | ({ + relationTo: 'monitoring-logs'; + value: number | MonitoringLog; + } | null) + | ({ + relationTo: 'monitoring-alert-rules'; + value: number | MonitoringAlertRule; + } | null) + | ({ + relationTo: 'monitoring-alert-history'; + value: number | MonitoringAlertHistory; + } | null) | ({ relationTo: 'site-settings'; value: number | SiteSetting; @@ -8061,13 +8327,6 @@ export interface PagesSelect { tenant?: T; title?: T; slug?: T; - hero?: - | T - | { - image?: T; - headline?: T; - subline?: T; - }; layout?: | T | { @@ -8293,7 +8552,10 @@ export interface PagesSelect { cards?: | T | { + mediaType?: T; image?: T; + icon?: T; + iconPosition?: T; title?: T; description?: T; link?: T; @@ -11460,6 +11722,7 @@ export interface YoutubeChannelsSelect { language?: T; category?: T; status?: T; + channelThumbnailUrl?: T; branding?: | T | { @@ -11627,6 +11890,13 @@ export interface YoutubeContentSelect { subscribersGained?: T; lastSyncedAt?: T; }; + costs?: + | T + | { + estimatedProductionHours?: T; + estimatedProductionCost?: T; + estimatedRevenue?: T; + }; internalNotes?: T; updatedAt?: T; createdAt?: T; @@ -12274,6 +12544,117 @@ export interface AuditLogsSelect { updatedAt?: T; createdAt?: T; } +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "monitoring-snapshots_select". + */ +export interface MonitoringSnapshotsSelect { + timestamp?: T; + system?: + | T + | { + cpuUsagePercent?: T; + memoryUsedMB?: T; + memoryTotalMB?: T; + memoryUsagePercent?: T; + diskUsedGB?: T; + diskTotalGB?: T; + diskUsagePercent?: T; + loadAvg1?: T; + loadAvg5?: T; + uptime?: T; + }; + services?: + | T + | { + payload?: T; + queueWorker?: T; + postgresql?: T; + pgbouncer?: T; + redis?: T; + }; + external?: + | T + | { + smtp?: T; + metaOAuth?: T; + youtubeOAuth?: T; + cronJobs?: T; + }; + performance?: + | T + | { + avgResponseTimeMs?: T; + p95ResponseTimeMs?: T; + p99ResponseTimeMs?: T; + errorRate?: T; + requestsPerMinute?: T; + }; + updatedAt?: T; + createdAt?: T; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "monitoring-logs_select". + */ +export interface MonitoringLogsSelect { + level?: T; + source?: T; + message?: T; + context?: T; + requestId?: T; + userId?: T; + tenant?: T; + duration?: T; + updatedAt?: T; + createdAt?: T; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "monitoring-alert-rules_select". + */ +export interface MonitoringAlertRulesSelect { + name?: T; + metric?: T; + condition?: T; + threshold?: T; + severity?: T; + channels?: T; + recipients?: + | T + | { + emails?: + | T + | { + email?: T; + id?: T; + }; + slackWebhook?: T; + discordWebhook?: T; + }; + cooldownMinutes?: T; + enabled?: T; + tenant?: T; + updatedAt?: T; + createdAt?: T; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "monitoring-alert-history_select". + */ +export interface MonitoringAlertHistorySelect { + rule?: T; + metric?: T; + value?: T; + threshold?: T; + severity?: T; + message?: T; + channelsSent?: T; + resolvedAt?: T; + acknowledgedBy?: T; + updatedAt?: T; + createdAt?: T; +} /** * This interface was referenced by `Config`'s JSON-Schema * via the `definition` "site-settings_select".