fix: resolve global typecheck errors

This commit is contained in:
Martin Porwoll 2026-02-17 08:57:32 +00:00
parent 6b4dae8eeb
commit 4386ac5d8d
41 changed files with 797 additions and 223 deletions

View file

@ -133,7 +133,7 @@ async function seedRules() {
await payload.create({ await payload.create({
collection: 'community-rules', collection: 'community-rules',
data: rule, data: rule as any,
}) })
console.log(`✅ Created: ${rule.name}`) console.log(`✅ Created: ${rule.name}`)
created++ created++

View file

@ -53,26 +53,49 @@ async function seed() {
// ============================================ // ============================================
console.log('\n--- Updating Site Settings ---') console.log('\n--- Updating Site Settings ---')
await payload.updateGlobal({ const siteSettingsData = {
slug: 'site-settings', tenant: tenantId,
data: { siteName: 'porwoll.de',
siteName: 'porwoll.de', siteTagline: 'Die Webseite von Martin Porwoll',
siteTagline: 'Die Webseite von Martin Porwoll', contact: {
contact: { email: 'info@porwoll.de',
email: 'info@porwoll.de', phone: '0800 80 44 100',
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.',
},
}, },
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') console.log('✓ Site Settings updated')
// ============================================ // ============================================
@ -891,43 +914,61 @@ async function seed() {
pageIds[p.slug] = p.id as number pageIds[p.slug] = p.id as number
} }
await payload.updateGlobal({ const navigationData = {
slug: 'navigation', tenant: tenantId,
data: { title: 'Hauptnavigation',
mainMenu: [ mainMenu: [
{ {
label: 'Whistleblowing', label: 'Whistleblowing',
type: 'submenu', type: 'submenu',
submenu: [ submenu: [
{ label: 'Zytoskandal', linkType: 'page', page: pageIds['zytoskandal'] }, { label: 'Zytoskandal', linkType: 'page', page: pageIds['zytoskandal'] },
{ label: 'Whistleblowing', linkType: 'page', page: pageIds['whistleblowing'] }, { label: 'Whistleblowing', linkType: 'page', page: pageIds['whistleblowing'] },
], ],
}, },
{ {
label: 'Unternehmer', label: 'Unternehmer',
type: 'submenu', type: 'submenu',
submenu: [ submenu: [
{ label: 'gunshin Holding UG', linkType: 'page', page: pageIds['gunshin-holding'] }, { label: 'gunshin Holding UG', linkType: 'page', page: pageIds['gunshin-holding'] },
{ label: 'complex care solutions GmbH', linkType: 'page', page: pageIds['complex-care-solutions'] }, { label: 'complex care solutions GmbH', linkType: 'page', page: pageIds['complex-care-solutions'] },
], ],
}, },
{ {
label: 'Mensch', label: 'Mensch',
type: 'page', type: 'page',
page: pageIds['mensch'], page: pageIds['mensch'],
}, },
{ {
label: 'Kontakt', label: 'Kontakt',
type: 'page', type: 'page',
page: pageIds['kontakt'], page: pageIds['kontakt'],
}, },
], ],
footerMenu: [ footerMenu: [
{ label: 'Impressum', linkType: 'page', page: pageIds['impressum'] }, { label: 'Impressum', linkType: 'page', page: pageIds['impressum'] },
{ label: 'Datenschutzerklärung', linkType: 'page', page: pageIds['datenschutz'] }, { 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('✓ Navigation configured')
console.log('\n========================================') console.log('\n========================================')

View file

@ -411,35 +411,53 @@ async function seed() {
console.log('Page IDs:', pageIds) console.log('Page IDs:', pageIds)
await payload.updateGlobal({ const navigationData = {
slug: 'navigation', tenant: tenantId,
data: { title: 'Hauptnavigation',
mainMenu: [ mainMenu: [
{ {
label: 'Whistleblowing', label: 'Whistleblowing',
type: 'submenu', type: 'submenu',
submenu: [ submenu: [
{ label: 'Zytoskandal', linkType: 'page', page: pageIds['zytoskandal'] }, { label: 'Zytoskandal', linkType: 'page', page: pageIds['zytoskandal'] },
{ label: 'Whistleblowing', linkType: 'page', page: pageIds['whistleblowing'] }, { label: 'Whistleblowing', linkType: 'page', page: pageIds['whistleblowing'] },
], ],
}, },
{ {
label: 'Unternehmer', label: 'Unternehmer',
type: 'submenu', type: 'submenu',
submenu: [ submenu: [
{ label: 'gunshin Holding UG', linkType: 'page', page: pageIds['gunshin-holding'] }, { label: 'gunshin Holding UG', linkType: 'page', page: pageIds['gunshin-holding'] },
{ label: 'complex care solutions GmbH', linkType: 'page', page: pageIds['complex-care-solutions'] }, { label: 'complex care solutions GmbH', linkType: 'page', page: pageIds['complex-care-solutions'] },
], ],
}, },
{ label: 'Mensch', type: 'page', page: pageIds['mensch'] }, { label: 'Mensch', type: 'page', page: pageIds['mensch'] },
{ label: 'Kontakt', type: 'page', page: pageIds['kontakt'] }, { label: 'Kontakt', type: 'page', page: pageIds['kontakt'] },
], ],
footerMenu: [ footerMenu: [
{ label: 'Impressum', linkType: 'page', page: pageIds['impressum'] }, { label: 'Impressum', linkType: 'page', page: pageIds['impressum'] },
{ label: 'Datenschutzerklärung', linkType: 'page', page: pageIds['datenschutz'] }, { 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('✓ Navigation configured')
console.log('\n========================================') console.log('\n========================================')

View file

@ -104,6 +104,10 @@ export async function POST(req: NextRequest): Promise<NextResponse> {
}, },
}) })
if (!result.user) {
throw new Error('Login returned no user')
}
// Erfolgreicher Login - afterLogin Hook hat bereits geloggt // Erfolgreicher Login - afterLogin Hook hat bereits geloggt
// Setze Cookie für die Session // Setze Cookie für die Session
const response = NextResponse.json({ const response = NextResponse.json({

View file

@ -64,8 +64,11 @@ export async function GET(req: NextRequest) {
} }
// Prüfen ob die Plattform Facebook oder Instagram ist // Prüfen ob die Plattform Facebook oder Instagram ist
const platform = account.platform as string const platform =
if (!['facebook', 'instagram'].includes(platform)) { typeof account.platform === 'object' && account.platform
? account.platform.slug
: undefined
if (!platform || !['facebook', 'instagram'].includes(platform)) {
return NextResponse.json( return NextResponse.json(
{ error: 'This endpoint is only for Facebook and Instagram accounts' }, { error: 'This endpoint is only for Facebook and Instagram accounts' },
{ status: 400 } { status: 400 }

View file

@ -112,7 +112,7 @@ export async function GET(request: NextRequest) {
const topicCounts: Record<string, number> = {} const topicCounts: Record<string, number> = {}
channelInteractions.forEach((i) => { channelInteractions.forEach((i) => {
if (i.analysis?.topics && Array.isArray(i.analysis.topics)) { 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) { if (t.topic) {
topicCounts[t.topic] = (topicCounts[t.topic] || 0) + 1 topicCounts[t.topic] = (topicCounts[t.topic] || 0) + 1
} }

View file

@ -70,14 +70,14 @@ export async function GET(request: NextRequest) {
} }
// Build where clause // Build where clause
const baseWhere: Record<string, unknown> = { const baseWhere: any = {
publishedAt: { publishedAt: {
greater_than_equal: periodStart.toISOString(), greater_than_equal: periodStart.toISOString(),
}, },
} }
if (channelId !== 'all') { if (channelId !== 'all') {
baseWhere.socialAccount = { equals: parseInt(channelId) } baseWhere.socialAccount = { equals: parseInt(channelId, 10) }
} }
// Fetch current period data // Fetch current period data
@ -141,7 +141,7 @@ export async function GET(request: NextRequest) {
const escalations = docs.filter((i) => i.flags?.requiresEscalation).length const escalations = docs.filter((i) => i.flags?.requiresEscalation).length
// Fetch previous period for comparison // Fetch previous period for comparison
const previousWhere: Record<string, unknown> = { const previousWhere: any = {
publishedAt: { publishedAt: {
greater_than_equal: previousPeriodStart.toISOString(), greater_than_equal: previousPeriodStart.toISOString(),
less_than: previousPeriodEnd.toISOString(), less_than: previousPeriodEnd.toISOString(),
@ -149,7 +149,7 @@ export async function GET(request: NextRequest) {
} }
if (channelId !== 'all') { if (channelId !== 'all') {
previousWhere.socialAccount = { equals: parseInt(channelId) } previousWhere.socialAccount = { equals: parseInt(channelId, 10) }
} }
const previousInteractions = await payload.find({ const previousInteractions = await payload.find({

View file

@ -68,12 +68,12 @@ export async function GET(request: NextRequest) {
const prevPeriodEnd = periodStart const prevPeriodEnd = periodStart
// Build where clause // Build where clause
const baseWhere: Record<string, unknown> = { const baseWhere: any = {
publishedAt: { greater_than_equal: periodStart.toISOString() }, publishedAt: { greater_than_equal: periodStart.toISOString() },
} }
if (channelId !== 'all') { if (channelId !== 'all') {
baseWhere.socialAccount = { equals: parseInt(channelId) } baseWhere.socialAccount = { equals: parseInt(channelId, 10) }
} }
// Fetch current period data // Fetch current period data
@ -100,7 +100,7 @@ export async function GET(request: NextRequest) {
const p90Time = percentile(responseTimes, 90) const p90Time = percentile(responseTimes, 90)
// Fetch previous period for trend // Fetch previous period for trend
const prevWhere: Record<string, unknown> = { const prevWhere: any = {
publishedAt: { publishedAt: {
greater_than_equal: prevPeriodStart.toISOString(), greater_than_equal: prevPeriodStart.toISOString(),
less_than: prevPeriodEnd.toISOString(), less_than: prevPeriodEnd.toISOString(),
@ -108,7 +108,7 @@ export async function GET(request: NextRequest) {
} }
if (channelId !== 'all') { if (channelId !== 'all') {
prevWhere.socialAccount = { equals: parseInt(channelId) } prevWhere.socialAccount = { equals: parseInt(channelId, 10) }
} }
const prevInteractions = await payload.find({ const prevInteractions = await payload.find({

View file

@ -58,14 +58,14 @@ export async function GET(request: NextRequest) {
const periodStart = subDays(now, days) const periodStart = subDays(now, days)
// Build where clause // Build where clause
const baseWhere: Record<string, unknown> = { const baseWhere: any = {
publishedAt: { publishedAt: {
greater_than_equal: periodStart.toISOString(), greater_than_equal: periodStart.toISOString(),
}, },
} }
if (channelId !== 'all') { if (channelId !== 'all') {
baseWhere.socialAccount = { equals: parseInt(channelId) } baseWhere.socialAccount = { equals: parseInt(channelId, 10) }
} }
// Fetch interactions // Fetch interactions

View file

@ -54,13 +54,13 @@ export async function GET(request: NextRequest) {
const periodStart = subDays(now, days) const periodStart = subDays(now, days)
// Build where clause // Build where clause
const baseWhere: Record<string, unknown> = { const baseWhere: any = {
publishedAt: { greater_than_equal: periodStart.toISOString() }, publishedAt: { greater_than_equal: periodStart.toISOString() },
linkedContent: { exists: true }, linkedContent: { exists: true },
} }
if (channelId !== 'all') { if (channelId !== 'all') {
baseWhere.socialAccount = { equals: parseInt(channelId) } baseWhere.socialAccount = { equals: parseInt(channelId, 10) }
} }
// Fetch interactions with linked content // Fetch interactions with linked content
@ -133,7 +133,7 @@ export async function GET(request: NextRequest) {
const topicCounts: Record<string, number> = {} const topicCounts: Record<string, number> = {}
contentInteractions.forEach((i) => { contentInteractions.forEach((i) => {
if (i.analysis?.topics && Array.isArray(i.analysis.topics)) { 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) { if (t.topic) {
topicCounts[t.topic] = (topicCounts[t.topic] || 0) + 1 topicCounts[t.topic] = (topicCounts[t.topic] || 0) + 1
} }

View file

@ -53,12 +53,12 @@ export async function GET(request: NextRequest) {
const periodStart = subDays(now, days) const periodStart = subDays(now, days)
// Build where clause // Build where clause
const baseWhere: Record<string, unknown> = { const baseWhere: any = {
publishedAt: { greater_than_equal: periodStart.toISOString() }, publishedAt: { greater_than_equal: periodStart.toISOString() },
} }
if (channelId !== 'all') { if (channelId !== 'all') {
baseWhere.socialAccount = { equals: parseInt(channelId) } baseWhere.socialAccount = { equals: parseInt(channelId, 10) }
} }
// Fetch interactions // Fetch interactions
@ -89,7 +89,7 @@ export async function GET(request: NextRequest) {
const sentimentScore = i.analysis?.sentimentScore as number | undefined 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 if (!t.topic) return
const topic = t.topic.toLowerCase().trim() const topic = t.topic.toLowerCase().trim()

View file

@ -69,7 +69,7 @@ export async function GET(req: NextRequest) {
} }
// Build query // Build query
const where: Record<string, unknown> = {} const where: any = {}
if (dateFrom || dateTo) { if (dateFrom || dateTo) {
where.publishedAt = {} where.publishedAt = {}

View file

@ -7,7 +7,7 @@ export async function POST(req: NextRequest): Promise<NextResponse> {
const payload = await getPayload({ config }) const payload = await getPayload({ config })
const { user } = await payload.auth({ headers: req.headers }) const { user } = await payload.auth({ headers: req.headers })
if (!user || !(user as Record<string, unknown>).isSuperAdmin) { if (!user || !(user as { isSuperAdmin?: boolean }).isSuperAdmin) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
} }

View file

@ -15,7 +15,7 @@ export async function GET(req: NextRequest): Promise<NextResponse> {
const limit = parseInt(req.nextUrl.searchParams.get('limit') || '20', 10) const limit = parseInt(req.nextUrl.searchParams.get('limit') || '20', 10)
const severity = req.nextUrl.searchParams.get('severity') const severity = req.nextUrl.searchParams.get('severity')
const where: Record<string, unknown> = {} const where: any = {}
if (severity) { if (severity) {
where.severity = { equals: severity } where.severity = { equals: severity }
} }

View file

@ -19,7 +19,7 @@ export async function GET(req: NextRequest): Promise<NextResponse> {
const from = req.nextUrl.searchParams.get('from') const from = req.nextUrl.searchParams.get('from')
const to = req.nextUrl.searchParams.get('to') const to = req.nextUrl.searchParams.get('to')
const conditions: Record<string, unknown>[] = [] const conditions: any[] = []
if (level) { if (level) {
conditions.push({ level: { equals: level } }) conditions.push({ level: { equals: level } })
@ -37,7 +37,7 @@ export async function GET(req: NextRequest): Promise<NextResponse> {
conditions.push({ createdAt: { less_than_equal: to } }) 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({ const logs = await payload.find({
collection: 'monitoring-logs', collection: 'monitoring-logs',

View file

@ -40,15 +40,38 @@ export async function GET(req: NextRequest): Promise<NextResponse> {
return NextResponse.json({ return NextResponse.json({
data: { data: {
redis: resolveSettled(redis, { status: 'offline' }), redis: resolveSettled(redis, {
postgresql: resolveSettled(postgresql, { status: 'offline' }), status: 'offline',
pgbouncer: resolveSettled(pgbouncer, { status: 'offline' }), memoryUsedMB: 0,
smtp: resolveSettled(smtp, { status: 'offline' }), connectedClients: 0,
oauth: resolveSettled(oauth, { opsPerSec: 0,
metaOAuth: { status: 'error' }, }),
youtubeOAuth: { status: 'error' }, 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, {}), queues: resolveSettled(queues, {}),
}, },
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),

View file

@ -32,7 +32,7 @@ export async function GET(request: NextRequest): Promise<Response> {
const payload = await getPayload({ config }) const payload = await getPayload({ config })
const { user } = await payload.auth({ headers: request.headers }) const { user } = await payload.auth({ headers: request.headers })
if (!user || !(user as Record<string, unknown>).isSuperAdmin) { if (!user || !(user as { isSuperAdmin?: boolean }).isSuperAdmin) {
return new Response('Unauthorized', { status: 401 }) return new Response('Unauthorized', { status: 401 })
} }

View file

@ -18,22 +18,25 @@ import config from '@payload-config'
import { NextRequest, NextResponse } from 'next/server' import { NextRequest, NextResponse } from 'next/server'
// Lazy imports für Security-Module um Initialisierungsfehler zu vermeiden // 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 }> } authLimiter?: { check: (ip: string) => Promise<{ allowed: boolean; remaining: number; resetIn: number }> }
rateLimitHeaders?: (result: { remaining: number; resetIn: number }, max: number) => Record<string, string> rateLimitHeaders?: (result: unknown, max: number) => Record<string, string>
getClientIpFromRequest?: (req: NextRequest) => string getClientIpFromRequest?: (req: NextRequest) => string
isIpBlocked?: (ip: string) => boolean isIpBlocked?: (ip: string) => boolean
validateCsrf?: (req: NextRequest) => { valid: boolean; reason?: string } validateCsrf?: (req: NextRequest) => { valid: boolean; reason?: string }
} | null = null }
async function getSecurityModules() { let securityModules: SecurityModules = {}
if (securityModules) return securityModules let securityModulesLoaded = false
async function getSecurityModules(): Promise<SecurityModules> {
if (securityModulesLoaded) return securityModules
try { try {
const security = await import('@/lib/security') const security = await import('@/lib/security')
securityModules = { securityModules = {
authLimiter: security.authLimiter, authLimiter: security.authLimiter,
rateLimitHeaders: security.rateLimitHeaders, rateLimitHeaders: security.rateLimitHeaders as SecurityModules['rateLimitHeaders'],
getClientIpFromRequest: security.getClientIpFromRequest, getClientIpFromRequest: security.getClientIpFromRequest,
isIpBlocked: security.isIpBlocked, isIpBlocked: security.isIpBlocked,
validateCsrf: security.validateCsrf, validateCsrf: security.validateCsrf,
@ -43,29 +46,46 @@ async function getSecurityModules() {
securityModules = {} securityModules = {}
} }
securityModulesLoaded = true
return securityModules return securityModules
} }
// Lazy import für Audit-Service // Lazy import für Audit-Service
let auditService: { type AuditService = {
logLoginFailed?: (payload: unknown, email: string, reason: string, clientInfo: { ipAddress: string; userAgent: string }) => Promise<void> logLoginFailed?: (
logRateLimit?: (payload: unknown, endpoint: string, userId?: number, tenantId?: number) => Promise<void> payload: unknown,
} | null = null email: string,
reason: string,
clientInfo: { ipAddress: string; userAgent: string },
) => Promise<void>
logRateLimit?: (
payload: unknown,
endpoint: string,
userId?: number,
tenantId?: number,
) => Promise<void>
}
async function getAuditService() { let auditService: AuditService = {}
if (auditService) return auditService let auditServiceLoaded = false
async function getAuditService(): Promise<AuditService> {
if (auditServiceLoaded) return auditService
try { try {
const audit = await import('@/lib/audit/audit-service') const audit = await import('@/lib/audit/audit-service')
auditService = { auditService = {
logLoginFailed: audit.logLoginFailed, logLoginFailed: audit.logLoginFailed as AuditService['logLoginFailed'],
logRateLimit: audit.logRateLimit, logRateLimit: audit.logRateLimit as AuditService['logRateLimit'],
} }
} catch (err) { } catch (err) {
console.warn('[Login] Audit service not available:', err) console.warn('[Login] Audit service not available:', err)
auditService = {} auditService = {}
} }
auditServiceLoaded = true
return auditService return auditService
} }

View file

@ -44,7 +44,7 @@ export async function GET(req: NextRequest): Promise<NextResponse> {
return NextResponse.json({ error: 'start and end required' }, { status: 400 }) return NextResponse.json({ error: 'start and end required' }, { status: 400 })
} }
const where: Record<string, unknown> = { const where: any = {
scheduledPublishDate: { scheduledPublishDate: {
greater_than_equal: start, greater_than_equal: start,
less_than_equal: end, less_than_equal: end,
@ -74,15 +74,13 @@ export async function GET(req: NextRequest): Promise<NextResponse> {
// Build channel color lookup // Build channel color lookup
const channelColorMap = new Map<number, string>() const channelColorMap = new Map<number, string>()
for (const ch of channels.docs) { for (const ch of channels.docs) {
const branding = (ch as Record<string, unknown>).branding as const branding = (ch as unknown as { branding?: { primaryColor?: string } }).branding
| { primaryColor?: string }
| undefined
channelColorMap.set(ch.id as number, branding?.primaryColor || '#3788d8') channelColorMap.set(ch.id as number, branding?.primaryColor || '#3788d8')
} }
// Build CalendarEvent array for conflict detection // Build CalendarEvent array for conflict detection
const calendarEvents: CalendarEvent[] = videos.docs.map((v) => { const calendarEvents: CalendarEvent[] = videos.docs.map((v) => {
const doc = v as Record<string, unknown> const doc = v as unknown as Record<string, unknown>
const channel = resolveRelation(doc.channel) const channel = resolveRelation(doc.channel)
const series = resolveRelation(doc.series) const series = resolveRelation(doc.series)
@ -97,7 +95,7 @@ export async function GET(req: NextRequest): Promise<NextResponse> {
// Get schedule config from first channel // Get schedule config from first channel
const defaultSchedule = { longformPerWeek: 1, shortsPerWeek: 4 } const defaultSchedule = { longformPerWeek: 1, shortsPerWeek: 4 }
const firstChannel = channels.docs[0] as Record<string, unknown> | undefined const firstChannel = channels.docs[0] as unknown as Record<string, unknown> | undefined
const publishingSchedule = firstChannel?.publishingSchedule as const publishingSchedule = firstChannel?.publishingSchedule as
| { longformPerWeek?: number; shortsPerWeek?: number } | { longformPerWeek?: number; shortsPerWeek?: number }
| undefined | undefined
@ -111,7 +109,7 @@ export async function GET(req: NextRequest): Promise<NextResponse> {
// Format for FullCalendar // Format for FullCalendar
const events = videos.docs.map((v) => { const events = videos.docs.map((v) => {
const doc = v as Record<string, unknown> const doc = v as unknown as Record<string, unknown>
const channel = resolveRelation(doc.channel) const channel = resolveRelation(doc.channel)
const series = resolveRelation(doc.series) const series = resolveRelation(doc.series)
@ -177,7 +175,7 @@ export async function PATCH(req: NextRequest): Promise<NextResponse> {
depth: 0, depth: 0,
}) })
const status = (content as Record<string, unknown>).status as string const status = (content as unknown as Record<string, unknown>).status as string
if (status === 'published' || status === 'tracked') { if (status === 'published' || status === 'tracked') {
return NextResponse.json( return NextResponse.json(
{ error: 'Veröffentlichte Videos können nicht verschoben werden' }, { error: 'Veröffentlichte Videos können nicht verschoben werden' },

View file

@ -58,13 +58,15 @@ export async function POST(req: NextRequest) {
try { try {
const thumbnailUrl = getYouTubeThumbnail(videoId, 'hq') const thumbnailUrl = getYouTubeThumbnail(videoId, 'hq')
const filename = `yt-thumb-${videoId}.jpg` 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, { const mediaId = await downloadAndUploadImage(payload, {
url: thumbnailUrl, url: thumbnailUrl,
filename, filename,
alt: `Thumbnail: ${typeof doc.title === 'string' ? doc.title : doc.title?.de || videoId}`, alt: `Thumbnail: ${title}`,
tenantId: tenantId || undefined,
}) })
if (mediaId) { if (mediaId) {

View file

@ -42,7 +42,7 @@ export async function POST(request: NextRequest): Promise<NextResponse> {
return NextResponse.json({ error: 'Content not found' }, { status: 404 }) return NextResponse.json({ error: 'Content not found' }, { status: 404 })
} }
const doc = content as Record<string, unknown> const doc = content as unknown as Record<string, unknown>
if (!doc.videoFile) { if (!doc.videoFile) {
return NextResponse.json({ error: 'No video file attached' }, { status: 400 }) return NextResponse.json({ error: 'No video file attached' }, { status: 400 })

View file

@ -115,7 +115,7 @@ export const ReportSchedules: CollectionConfig = {
width: '50%', width: '50%',
description: 'Format: HH:MM (24-Stunden)', description: 'Format: HH:MM (24-Stunden)',
}, },
validate: (value) => { validate: (value: string | null | undefined) => {
if (!value) return true if (!value) return true
const regex = /^([01]\d|2[0-3]):([0-5]\d)$/ const regex = /^([01]\d|2[0-3]):([0-5]\d)$/
if (!regex.test(value)) { if (!regex.test(value)) {
@ -233,7 +233,6 @@ export const ReportSchedules: CollectionConfig = {
// === Status (Read-only) === // === Status (Read-only) ===
{ {
name: 'statusSection',
type: 'collapsible', type: 'collapsible',
label: 'Status & Statistiken', label: 'Status & Statistiken',
admin: { admin: {

View file

@ -115,7 +115,7 @@ export const YouTubeContent: CollectionConfig = {
}, },
} }
} }
return {} return true
}, },
admin: { admin: {
position: 'sidebar', position: 'sidebar',

View file

@ -118,7 +118,7 @@ interface ApiResponse {
data: PerformanceData | PipelineData | GoalsData | CommunityData 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', key: 'performance',
label: '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 if (value === 0) return null
const isUp = value > 0 const isUp = value > 0
return ( return (

View file

@ -1,8 +1,11 @@
import type { CollectionAfterChangeHook } from 'payload' import type { CollectionAfterChangeHook } from 'payload'
import { NotificationService } from '@/lib/jobs/NotificationService' import { NotificationService } from '@/lib/jobs/NotificationService'
import type { YoutubeContent } from '@/payload-types'
type YouTubeContentStatus = NonNullable<YoutubeContent['status']>
interface TransitionContext { interface TransitionContext {
currentStatus: string currentStatus: YouTubeContentStatus
youtubeVideoId?: string | null youtubeVideoId?: string | null
hasAllChecklistsComplete: boolean hasAllChecklistsComplete: boolean
} }
@ -10,7 +13,7 @@ interface TransitionContext {
/** /**
* Determines the next status based on current state and conditions * 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 const { currentStatus, youtubeVideoId } = context
// Upload completed → published // Upload completed → published
@ -25,7 +28,7 @@ export function getNextStatus(context: TransitionContext): string | null {
* Checks if a manual transition should be suggested * Checks if a manual transition should be suggested
*/ */
export function shouldTransitionStatus( export function shouldTransitionStatus(
status: string, status: YouTubeContentStatus,
context: { hasVideoFile?: boolean }, context: { hasVideoFile?: boolean },
): boolean { ): boolean {
if (status === 'approved' && context.hasVideoFile) return true if (status === 'approved' && context.hasVideoFile) return true
@ -44,7 +47,7 @@ export const autoStatusTransitions: CollectionAfterChangeHook = async ({
if (operation !== 'update') return doc if (operation !== 'update') return doc
const nextStatus = getNextStatus({ const nextStatus = getNextStatus({
currentStatus: doc.status, currentStatus: doc.status as YouTubeContentStatus,
youtubeVideoId: doc.youtube?.videoId, youtubeVideoId: doc.youtube?.videoId,
hasAllChecklistsComplete: false, hasAllChecklistsComplete: false,
}) })

View file

@ -1,11 +1,15 @@
// src/hooks/youtubeContent/createTasksOnStatusChange.ts // src/hooks/youtubeContent/createTasksOnStatusChange.ts
import type { CollectionAfterChangeHook } from 'payload' import type { CollectionAfterChangeHook } from 'payload'
import type { YtTask } from '@/payload-types'
type TaskType = NonNullable<YtTask['taskType']>
type AssignRole = 'creator' | 'producer' | 'editor' | 'manager'
interface TaskTemplate { interface TaskTemplate {
title: string title: string
type: string type: TaskType
assignRole: string assignRole: AssignRole
} }
/** /**

View file

@ -121,13 +121,14 @@ export async function runSyncAllCommentsJob(payload: Payload): Promise<{
) )
const syncService = new CommentsSyncService(payload) const syncService = new CommentsSyncService(payload)
const result = await syncService.syncCommentsForAccount(account.id, { const result = await syncService.syncComments({
socialAccountId: account.id,
maxComments: 100, maxComments: 100,
analyzeWithAI: true, analyzeWithAI: true,
}) })
const created = result.created || 0 const created = result.newComments || 0
const updated = result.updated || 0 const updated = result.updatedComments || 0
totalNew += created totalNew += created
totalUpdated += updated totalUpdated += updated

View file

@ -79,5 +79,5 @@ export const canAccessAssignedInteractions: Access = ({ req }) => {
{ assignedTo: { equals: user.id } }, { assignedTo: { equals: user.id } },
{ 'response.sentBy': { equals: user.id } }, { 'response.sentBy': { equals: user.id } },
], ],
} } as any
} }

View file

@ -279,6 +279,9 @@ export class FacebookSyncService {
// Platform ID holen // Platform ID holen
const platformId = await this.getPlatformId() const platformId = await this.getPlatformId()
if (platformId === null) {
throw new Error('Facebook platform not configured')
}
// Interaction-Typ bestimmen (comment oder reply) // Interaction-Typ bestimmen (comment oder reply)
const interactionType = comment.parent?.id ? 'reply' : 'comment' const interactionType = comment.parent?.id ? 'reply' : 'comment'
@ -299,10 +302,10 @@ export class FacebookSyncService {
} }
// Interaction-Daten zusammenstellen // Interaction-Daten zusammenstellen
const interactionData: Record<string, unknown> = { const interactionData = {
platform: platformId, platform: platformId,
socialAccount: account.id, socialAccount: account.id,
type: interactionType, type: interactionType as 'comment' | 'reply',
externalId: comment.id, externalId: comment.id,
parentInteraction: parentInteractionId, parentInteraction: parentInteractionId,
author: { author: {
@ -327,7 +330,12 @@ export class FacebookSyncService {
...(comment.attachment && { ...(comment.attachment && {
attachments: [ 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, url: comment.attachment.url || comment.attachment.media?.image?.src,
}, },
], ],
@ -352,6 +360,8 @@ export class FacebookSyncService {
}), }),
// Interne Notizen mit Post-Kontext // Interne Notizen mit Post-Kontext
internalNotes: `Facebook Post: ${post.permalink_url || post.id}`, internalNotes: `Facebook Post: ${post.permalink_url || post.id}`,
status: 'new' as const,
priority: 'normal' as const,
} }
let interactionId: number let interactionId: number
@ -359,6 +369,7 @@ export class FacebookSyncService {
if (isNew) { if (isNew) {
const created = await this.payload.create({ const created = await this.payload.create({
collection: 'community-interactions', collection: 'community-interactions',
draft: false,
data: interactionData, data: interactionData,
}) })
interactionId = created.id as number interactionId = created.id as number

View file

@ -324,6 +324,9 @@ export class InstagramSyncService {
// Platform ID holen // Platform ID holen
const platformId = await this.getPlatformId() const platformId = await this.getPlatformId()
if (platformId === null) {
throw new Error('Instagram platform not configured')
}
// Interaction-Typ bestimmen // Interaction-Typ bestimmen
const interactionType = parentCommentId ? 'reply' : 'comment' const interactionType = parentCommentId ? 'reply' : 'comment'
@ -344,10 +347,10 @@ export class InstagramSyncService {
} }
// Interaction-Daten zusammenstellen // Interaction-Daten zusammenstellen
const interactionData: Record<string, unknown> = { const interactionData = {
platform: platformId, platform: platformId,
socialAccount: account.id, socialAccount: account.id,
type: interactionType, type: interactionType as 'comment' | 'reply',
externalId: comment.id, externalId: comment.id,
parentInteraction: parentInteractionId, parentInteraction: parentInteractionId,
author: { author: {
@ -386,6 +389,8 @@ export class InstagramSyncService {
}), }),
// Interne Notizen mit Media-Kontext // Interne Notizen mit Media-Kontext
internalNotes: `Instagram Post: ${media.permalink}`, internalNotes: `Instagram Post: ${media.permalink}`,
status: 'new' as const,
priority: 'normal' as const,
} }
let interactionId: number let interactionId: number
@ -393,6 +398,7 @@ export class InstagramSyncService {
if (isNew) { if (isNew) {
const created = await this.payload.create({ const created = await this.payload.create({
collection: 'community-interactions', collection: 'community-interactions',
draft: false,
data: interactionData, data: interactionData,
}) })
interactionId = created.id as number interactionId = created.id as number
@ -494,12 +500,15 @@ export class InstagramSyncService {
// Platform ID holen // Platform ID holen
const platformId = await this.getPlatformId() const platformId = await this.getPlatformId()
if (platformId === null) {
throw new Error('Instagram platform not configured')
}
// Mention als Interaction speichern // Mention als Interaction speichern
const interactionData: Record<string, unknown> = { const interactionData = {
platform: platformId, platform: platformId,
socialAccount: account.id, socialAccount: account.id,
type: 'mention', type: 'mention' as const,
externalId: `mention_${mention.id}`, externalId: `mention_${mention.id}`,
author: { author: {
name: mention.username, name: mention.username,
@ -528,10 +537,13 @@ export class InstagramSyncService {
}, },
}), }),
internalNotes: `Instagram Mention: ${mention.permalink}`, internalNotes: `Instagram Mention: ${mention.permalink}`,
status: 'new' as const,
priority: 'normal' as const,
} }
await this.payload.create({ await this.payload.create({
collection: 'community-interactions', collection: 'community-interactions',
draft: false,
data: interactionData, data: interactionData,
}) })

View file

@ -146,7 +146,7 @@ export class MetaBaseClient {
const nextResponse = await fetch(nextUrl) const nextResponse = await fetch(nextUrl)
const nextData: MetaPaginatedResponse<T> = await nextResponse.json() const nextData: MetaPaginatedResponse<T> = await nextResponse.json()
if (nextData.error) { if ('error' in (nextData as unknown as MetaErrorResponse)) {
throw new MetaApiError((nextData as unknown as MetaErrorResponse).error) throw new MetaApiError((nextData as unknown as MetaErrorResponse).error)
} }

View file

@ -231,6 +231,8 @@ export class CommentsSyncService {
isFromInfluencer: false, isFromInfluencer: false,
}, },
}), }),
status: 'new' as const,
priority: 'normal' as const,
} }
let parentInteractionId: number | null = null let parentInteractionId: number | null = null
@ -238,6 +240,7 @@ export class CommentsSyncService {
if (isNew) { if (isNew) {
const created = await this.payload.create({ const created = await this.payload.create({
collection: 'community-interactions', collection: 'community-interactions',
draft: false,
data: interactionData, data: interactionData,
}) })
parentInteractionId = created.id parentInteractionId = created.id
@ -314,6 +317,7 @@ export class CommentsSyncService {
await this.payload.create({ await this.payload.create({
collection: 'community-interactions', collection: 'community-interactions',
draft: false,
data: { data: {
platform: platformId, platform: platformId,
socialAccount: account.id, socialAccount: account.id,
@ -356,6 +360,8 @@ export class CommentsSyncService {
isFromInfluencer: false, isFromInfluencer: false,
}, },
}), }),
status: 'new' as const,
priority: 'normal' as const,
}, },
}) })

View file

@ -34,7 +34,7 @@ interface CommentThread {
export class YouTubeClient { export class YouTubeClient {
private youtube: youtube_v3.Youtube private youtube: youtube_v3.Youtube
private oauth2Client: ReturnType<typeof google.auth.OAuth2> private oauth2Client: InstanceType<typeof google.auth.OAuth2>
private payload: Payload private payload: Payload
constructor(credentials: YouTubeCredentials, payload: Payload) { constructor(credentials: YouTubeCredentials, payload: Payload) {

View file

@ -60,13 +60,13 @@ export function evaluateCondition(
// ============================================================================ // ============================================================================
interface AlertRule { interface AlertRule {
id: string id: number
name: string name: string
metric: string metric: string
condition: AlertCondition condition: AlertCondition
threshold: number threshold: number
severity: AlertSeverity severity: AlertSeverity
channels: string[] channels: Array<'email' | 'slack' | 'discord'>
recipients?: { recipients?: {
emails?: Array<{ email: string }> emails?: Array<{ email: string }>
slackWebhook?: string slackWebhook?: string
@ -132,9 +132,10 @@ export class AlertEvaluator {
if (value === undefined) continue if (value === undefined) continue
if (evaluateCondition(rule.condition, value, rule.threshold)) { 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) await this.dispatchAlert(payload, rule, value)
this.recordFired(rule.id) this.recordFired(ruleKey)
} }
} }
} }

View file

@ -40,9 +40,9 @@ export interface MonitoringLoggerInstance {
} }
/** Cached Payload instance — resolved once, reused for all subsequent writes. */ /** Cached Payload instance — resolved once, reused for all subsequent writes. */
let cachedPayload: { create: (...args: unknown[]) => Promise<unknown> } | null = null let cachedPayload: any = null
async function getPayloadInstance() { async function getPayloadInstance(): Promise<any> {
if (cachedPayload) return cachedPayload if (cachedPayload) return cachedPayload
const { getPayload } = await import('payload') const { getPayload } = await import('payload')
const config = (await import(/* @vite-ignore */ '@payload-config')).default const config = (await import(/* @vite-ignore */ '@payload-config')).default

View file

@ -258,7 +258,7 @@ export async function checkOAuthTokens(): Promise<{
const youtube = { tokensTotal: 0, tokensExpiringSoon: 0, tokensExpired: 0 } const youtube = { tokensTotal: 0, tokensExpiringSoon: 0, tokensExpired: 0 }
for (const account of accounts.docs) { for (const account of accounts.docs) {
const doc = account as Record<string, unknown> const doc = account as unknown as Record<string, unknown>
const target = doc.platform === 'youtube' ? youtube : meta const target = doc.platform === 'youtube' ? youtube : meta
target.tokensTotal++ target.tokensTotal++
@ -304,7 +304,7 @@ export async function checkCronJobs(): Promise<CronStatuses> {
if (logs.docs.length === 0) return unknownStatus if (logs.docs.length === 0) return unknownStatus
const doc = logs.docs[0] as Record<string, unknown> const doc = logs.docs[0] as unknown as Record<string, unknown>
return { return {
lastRun: doc.createdAt as string, lastRun: doc.createdAt as string,
status: doc.level === 'error' ? 'failed' : 'ok', status: doc.level === 'error' ? 'failed' : 'ok',

View file

@ -13,9 +13,9 @@ let interval: ReturnType<typeof setInterval> | null = null
const alertEvaluator = new AlertEvaluator() const alertEvaluator = new AlertEvaluator()
/** Cached Payload instance — resolved once, reused on every tick. */ /** Cached Payload instance — resolved once, reused on every tick. */
let cachedPayload: { create: (...args: unknown[]) => Promise<unknown>; find: (...args: unknown[]) => Promise<unknown> } | null = null let cachedPayload: any = null
async function getPayloadInstance() { async function getPayloadInstance(): Promise<any> {
if (cachedPayload) return cachedPayload if (cachedPayload) return cachedPayload
const { getPayload } = await import('payload') const { getPayload } = await import('payload')
const config = (await import(/* @vite-ignore */ '@payload-config')).default const config = (await import(/* @vite-ignore */ '@payload-config')).default

View file

@ -144,7 +144,7 @@ export class ReportGeneratorService {
depth: 0, depth: 0,
overrideAccess: true, overrideAccess: true,
}) })
const tenant = (account as Record<string, unknown>)?.tenant const tenant = (account as unknown as Record<string, unknown>)?.tenant
if (typeof tenant === 'number') return tenant if (typeof tenant === 'number') return tenant
if (typeof tenant === 'object' && tenant && 'id' in (tenant as Record<string, unknown>)) { if (typeof tenant === 'object' && tenant && 'id' in (tenant as Record<string, unknown>)) {
return (tenant as { id: number }).id return (tenant as { id: number }).id
@ -198,12 +198,13 @@ export class ReportGeneratorService {
} }
// Update schedule stats // Update schedule stats
const existingSendCount = (await this.getSchedule(schedule.id))?.sendCount ?? 0
await this.payload.update({ await this.payload.update({
collection: 'report-schedules', collection: 'report-schedules',
id: schedule.id, id: schedule.id,
data: { data: {
lastSentAt: new Date().toISOString(), lastSentAt: new Date().toISOString(),
sendCount: (await this.getSchedule(schedule.id))?.sendCount + 1 || 1, sendCount: existingSendCount + 1,
lastError: null, lastError: null,
}, },
}) })
@ -289,10 +290,12 @@ export class ReportGeneratorService {
limit: 1000, limit: 1000,
}) })
const replied = interactions.docs.filter((i) => i.status === 'replied') const docs = interactions.docs as any[]
const platforms = interactions.docs.reduce(
const replied = docs.filter((i) => i.status === 'replied')
const platforms = docs.reduce(
(acc, i) => { (acc, i) => {
const platform = (i.platform as string) || 'unknown' const platform = this.resolvePlatformName(i.platform)
acc[platform] = (acc[platform] || 0) + 1 acc[platform] = (acc[platform] || 0) + 1
return acc return acc
}, },
@ -303,9 +306,9 @@ export class ReportGeneratorService {
let totalResponseTime = 0 let totalResponseTime = 0
let responseCount = 0 let responseCount = 0
for (const interaction of replied) { for (const interaction of replied) {
if (interaction.repliedAt && interaction.createdAt) { if (interaction.response?.sentAt && interaction.createdAt) {
const responseTime = const responseTime =
new Date(interaction.repliedAt as string).getTime() - new Date(interaction.response.sentAt as string).getTime() -
new Date(interaction.createdAt as string).getTime() new Date(interaction.createdAt as string).getTime()
totalResponseTime += responseTime totalResponseTime += responseTime
responseCount++ responseCount++
@ -314,10 +317,13 @@ export class ReportGeneratorService {
return { return {
totalInteractions: interactions.totalDocs, totalInteractions: interactions.totalDocs,
newInteractions: interactions.docs.filter((i) => i.status === 'new').length, newInteractions: docs.filter((i) => i.status === 'new').length,
repliedCount: replied.length, repliedCount: replied.length,
avgResponseTime: responseCount > 0 ? totalResponseTime / responseCount / (1000 * 60 * 60) : 0, 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, limit: 1000,
}) })
const docs = interactions.docs as any[]
let positive = 0 let positive = 0
let neutral = 0 let neutral = 0
let negative = 0 let negative = 0
const dailyData: Record<string, { positive: number; neutral: number; negative: number }> = {} const dailyData: Record<string, { positive: number; neutral: number; negative: number }> = {}
for (const interaction of interactions.docs) { for (const interaction of docs) {
const sentiment = (interaction.aiAnalysis as any)?.sentiment || 'neutral' const sentiment = interaction.analysis?.sentiment || 'neutral'
const date = new Date(interaction.createdAt as string).toISOString().split('T')[0] const date = new Date(interaction.createdAt as string).toISOString().split('T')[0]
if (!dailyData[date]) { if (!dailyData[date]) {
@ -399,24 +407,26 @@ export class ReportGeneratorService {
limit: 1000, limit: 1000,
}) })
const replied = interactions.docs.filter((i) => i.status === 'replied') const docs = interactions.docs as any[]
const pending = interactions.docs.filter((i) => i.status === 'new' || i.status === 'in_review')
const replied = docs.filter((i) => i.status === 'replied')
const pending = docs.filter((i) => i.status === 'new' || i.status === 'in_review')
// Berechne durchschnittliche Antwortzeit // Berechne durchschnittliche Antwortzeit
let totalTime = 0 let totalTime = 0
let count = 0 let count = 0
const platformData: Record<string, { totalTime: number; count: number; replied: number }> = {} const platformData: Record<string, { totalTime: number; count: number; replied: number }> = {}
for (const interaction of interactions.docs) { for (const interaction of docs) {
const platform = (interaction.platform as string) || 'unknown' const platform = this.resolvePlatformName(interaction.platform)
if (!platformData[platform]) { if (!platformData[platform]) {
platformData[platform] = { totalTime: 0, count: 0, replied: 0 } platformData[platform] = { totalTime: 0, count: 0, replied: 0 }
} }
platformData[platform].count++ platformData[platform].count++
if (interaction.status === 'replied' && interaction.repliedAt && interaction.createdAt) { if (interaction.status === 'replied' && interaction.response?.sentAt && interaction.createdAt) {
const responseTime = const responseTime =
new Date(interaction.repliedAt as string).getTime() - new Date(interaction.response.sentAt as string).getTime() -
new Date(interaction.createdAt as string).getTime() new Date(interaction.createdAt as string).getTime()
totalTime += responseTime totalTime += responseTime
count++ count++
@ -459,14 +469,22 @@ export class ReportGeneratorService {
limit: 1000, limit: 1000,
}) })
const docs = interactions.docs as any[]
// Top Posts nach Kommentaren gruppieren // Top Posts nach Kommentaren gruppieren
const postData: Record<string, { title: string; platform: string; comments: number }> = {} const postData: Record<string, { title: string; platform: string; comments: number }> = {}
const topicCount: Record<string, number> = {} const topicCount: Record<string, number> = {}
for (const interaction of interactions.docs) { for (const interaction of docs) {
const postId = (interaction.sourceId as string) || 'unknown' const linkedContent = interaction.linkedContent
const platform = (interaction.platform as string) || 'unknown' const postId =
const title = (interaction.sourceTitle as string) || 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]) { if (!postData[postId]) {
postData[postId] = { title, platform, comments: 0 } postData[postId] = { title, platform, comments: 0 }
@ -474,9 +492,16 @@ export class ReportGeneratorService {
postData[postId].comments++ postData[postId].comments++
// Topics aus AI-Analyse sammeln // 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) { 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` }.pdf`
try { try {
const result = await generatePdfFromHtml(html, { filename }) const result = await generatePdfFromHtml(html)
if (!result.success || !result.buffer) { if (!result.success || !result.buffer) {
throw new Error(result.error || 'PDF generation failed') throw new Error(result.error || 'PDF generation failed')
@ -753,6 +778,27 @@ export class ReportGeneratorService {
return html 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 * Generiert einfachen E-Mail-Body für Attachments
*/ */

View file

@ -289,20 +289,21 @@ export class RulesEngine {
if (!userId) return if (!userId) return
try { try {
const relatedVideo =
typeof interaction.linkedContent === 'object'
? interaction.linkedContent.id
: interaction.linkedContent
// Prüfen ob yt-notifications Collection existiert // Prüfen ob yt-notifications Collection existiert
await this.payload.create({ await this.payload.create({
collection: 'yt-notifications', collection: 'yt-notifications',
data: { data: {
user: userId, recipient: userId,
type: 'community_rule_triggered', type: 'system',
title: `🔔 Rule "${rule.name}" triggered`, title: `🔔 Rule "${rule.name}" triggered`,
message: `New interaction from ${interaction.author?.name || 'Unknown'}: "${(interaction.message || '').substring(0, 100)}..."`, message: `New interaction from ${interaction.author?.name || 'Unknown'}: "${(interaction.message || '').substring(0, 100)}..."`,
relatedContent: relatedVideo,
typeof interaction.linkedContent === 'object' read: false,
? interaction.linkedContent.id
: interaction.linkedContent,
priority: interaction.priority === 'urgent' ? 'urgent' : 'normal',
isRead: false,
}, },
}) })
} catch (error) { } catch (error) {

View file

@ -74,7 +74,7 @@ export const canAccessAssignedContent: Access = ({ req }) => {
{ assignedTo: { equals: user.id } }, { assignedTo: { equals: user.id } },
{ createdBy: { equals: user.id } }, { createdBy: { equals: user.id } },
], ],
} } as any
} }
/** /**

View file

@ -121,6 +121,10 @@ export interface Config {
'privacy-policy-settings': PrivacyPolicySetting; 'privacy-policy-settings': PrivacyPolicySetting;
'email-logs': EmailLog; 'email-logs': EmailLog;
'audit-logs': AuditLog; 'audit-logs': AuditLog;
'monitoring-snapshots': MonitoringSnapshot;
'monitoring-logs': MonitoringLog;
'monitoring-alert-rules': MonitoringAlertRule;
'monitoring-alert-history': MonitoringAlertHistory;
'site-settings': SiteSetting; 'site-settings': SiteSetting;
navigations: Navigation; navigations: Navigation;
forms: Form; forms: Form;
@ -187,6 +191,10 @@ export interface Config {
'privacy-policy-settings': PrivacyPolicySettingsSelect<false> | PrivacyPolicySettingsSelect<true>; 'privacy-policy-settings': PrivacyPolicySettingsSelect<false> | PrivacyPolicySettingsSelect<true>;
'email-logs': EmailLogsSelect<false> | EmailLogsSelect<true>; 'email-logs': EmailLogsSelect<false> | EmailLogsSelect<true>;
'audit-logs': AuditLogsSelect<false> | AuditLogsSelect<true>; 'audit-logs': AuditLogsSelect<false> | AuditLogsSelect<true>;
'monitoring-snapshots': MonitoringSnapshotsSelect<false> | MonitoringSnapshotsSelect<true>;
'monitoring-logs': MonitoringLogsSelect<false> | MonitoringLogsSelect<true>;
'monitoring-alert-rules': MonitoringAlertRulesSelect<false> | MonitoringAlertRulesSelect<true>;
'monitoring-alert-history': MonitoringAlertHistorySelect<false> | MonitoringAlertHistorySelect<true>;
'site-settings': SiteSettingsSelect<false> | SiteSettingsSelect<true>; 'site-settings': SiteSettingsSelect<false> | SiteSettingsSelect<true>;
navigations: NavigationsSelect<false> | NavigationsSelect<true>; navigations: NavigationsSelect<false> | NavigationsSelect<true>;
forms: FormsSelect<false> | FormsSelect<true>; forms: FormsSelect<false> | FormsSelect<true>;
@ -306,6 +314,10 @@ export interface YoutubeChannel {
language: 'de' | 'en'; language: 'de' | 'en';
category: 'lifestyle' | 'corporate' | 'b2b'; category: 'lifestyle' | 'corporate' | 'b2b';
status: 'active' | 'planned' | 'paused' | 'archived'; status: 'active' | 'planned' | 'paused' | 'archived';
/**
* Profil-Thumbnail URL von YouTube (automatisch befüllt)
*/
channelThumbnailUrl?: string | null;
branding?: { branding?: {
/** /**
* z.B. #1278B3 * z.B. #1278B3
@ -490,7 +502,7 @@ export interface Tenant {
/** /**
* Hostname ohne Protokoll (z.B. smtp.gmail.com) * Hostname ohne Protokoll (z.B. smtp.gmail.com)
*/ */
host: string; host?: string | null;
/** /**
* 587 (STARTTLS) oder 465 (SSL) * 587 (STARTTLS) oder 465 (SSL)
*/ */
@ -502,7 +514,7 @@ export interface Tenant {
/** /**
* Meist die E-Mail-Adresse * Meist die E-Mail-Adresse
*/ */
user: string; user?: string | null;
/** /**
* Leer lassen um bestehendes Passwort zu behalten * Leer lassen um bestehendes Passwort zu behalten
*/ */
@ -524,11 +536,6 @@ export interface Page {
* URL-Pfad (z.B. "ueber-uns" / "about-us") * URL-Pfad (z.B. "ueber-uns" / "about-us")
*/ */
slug: string; slug: string;
hero?: {
image?: (number | null) | Media;
headline?: string | null;
subline?: string | null;
};
layout?: layout?:
| ( | (
| { | {
@ -793,7 +800,13 @@ export interface Page {
headline?: string | null; headline?: string | null;
cards?: cards?:
| { | {
mediaType?: ('none' | 'image' | 'icon') | null;
image?: (number | null) | Media; image?: (number | null) | Media;
/**
* Lucide Icon-Name (z.B. "heart", "star", "shield-check", "camera")
*/
icon?: string | null;
iconPosition?: ('top' | 'left') | null;
title: string; title: string;
description?: string | null; description?: string | null;
link?: string | null; link?: string | null;
@ -6377,6 +6390,17 @@ export interface YoutubeContent {
subscribersGained?: number | null; subscribersGained?: number | null;
lastSyncedAt?: string | 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 * Nur für das Team sichtbar
*/ */
@ -7418,6 +7442,232 @@ export interface AuditLog {
updatedAt: string; updatedAt: string;
createdAt: 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 * Allgemeine Website-Einstellungen pro Tenant
* *
@ -7800,6 +8050,22 @@ export interface PayloadLockedDocument {
relationTo: 'audit-logs'; relationTo: 'audit-logs';
value: number | AuditLog; value: number | AuditLog;
} | null) } | 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'; relationTo: 'site-settings';
value: number | SiteSetting; value: number | SiteSetting;
@ -8061,13 +8327,6 @@ export interface PagesSelect<T extends boolean = true> {
tenant?: T; tenant?: T;
title?: T; title?: T;
slug?: T; slug?: T;
hero?:
| T
| {
image?: T;
headline?: T;
subline?: T;
};
layout?: layout?:
| T | T
| { | {
@ -8293,7 +8552,10 @@ export interface PagesSelect<T extends boolean = true> {
cards?: cards?:
| T | T
| { | {
mediaType?: T;
image?: T; image?: T;
icon?: T;
iconPosition?: T;
title?: T; title?: T;
description?: T; description?: T;
link?: T; link?: T;
@ -11460,6 +11722,7 @@ export interface YoutubeChannelsSelect<T extends boolean = true> {
language?: T; language?: T;
category?: T; category?: T;
status?: T; status?: T;
channelThumbnailUrl?: T;
branding?: branding?:
| T | T
| { | {
@ -11627,6 +11890,13 @@ export interface YoutubeContentSelect<T extends boolean = true> {
subscribersGained?: T; subscribersGained?: T;
lastSyncedAt?: T; lastSyncedAt?: T;
}; };
costs?:
| T
| {
estimatedProductionHours?: T;
estimatedProductionCost?: T;
estimatedRevenue?: T;
};
internalNotes?: T; internalNotes?: T;
updatedAt?: T; updatedAt?: T;
createdAt?: T; createdAt?: T;
@ -12274,6 +12544,117 @@ export interface AuditLogsSelect<T extends boolean = true> {
updatedAt?: T; updatedAt?: T;
createdAt?: T; createdAt?: T;
} }
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "monitoring-snapshots_select".
*/
export interface MonitoringSnapshotsSelect<T extends boolean = true> {
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<T extends boolean = true> {
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<T extends boolean = true> {
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<T extends boolean = true> {
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 * This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "site-settings_select". * via the `definition` "site-settings_select".