import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue' import { useAwardsStore } from '../../stores/awards' import type { AdminAuditEntry, AdminAuditQueryOptions } from '../../types/awards' const auditPageLimit = 80 const allFilter = 'all' const actionLabels: Record = { 'candidate.create': 'Kandidat angelegt', 'candidate.delete': 'Kandidat gelöscht', 'candidate.update': 'Kandidat aktualisiert', 'category.create': 'Kategorie angelegt', 'category.delete': 'Kategorie gelöscht', 'category.update': 'Kategorie aktualisiert', 'clip.delete': 'Clip gelöscht', 'clip.status.update': 'Clip-Status geändert', 'nomination.approve': 'Nominierung übernommen', 'nomination.reject': 'Nominierung verworfen', 'operational-settings.update': 'Betriebseinstellungen geändert', 'result.delete': 'Winner entfernt', 'result.set': 'Winner gesetzt', 'risk.resolve': 'Risk Flag entschieden', 'season.create': 'Jahr angelegt', 'season.delete': 'Jahr gelöscht', 'season.phase.update': 'Phase geändert', 'season.public.update': 'Public-Kontext geändert', 'season.update': 'Jahr aktualisiert', 'seed.initialize': 'Seed-Daten initialisiert', 'site-settings.update': 'Seiteneinstellungen geändert', } const groupLabels: Record = { candidate: 'Kandidaten', category: 'Kategorien', clip: 'Clips', nomination: 'Reviews', result: 'Gewinner', risk: 'Risiko', seed: 'System', season: 'Jahre', site: 'Seite', 'site-settings': 'Seite', 'operational-settings': 'Betrieb', } const relatedRoutes: Record = { candidate: { label: 'Kandidaten öffnen', to: '/admin/candidates' }, category: { label: 'Kategorien öffnen', to: '/admin/categories' }, clip: { label: 'Clips öffnen', to: '/admin/clips' }, nomination: { label: 'Review-Fokus öffnen', to: '/admin/nominations?review=1' }, result: { label: 'Gewinner öffnen', to: '/admin/winners' }, 'risk-flag': { label: 'Risiko öffnen', to: '/admin/risk' }, season: { label: 'Jahre öffnen', to: '/admin/years' }, 'site-settings': { label: 'Landingpage öffnen', to: '/admin/content' }, 'operational-settings': { label: 'Einstellungen öffnen', to: '/admin/settings' }, } export interface AuditStat { label: string value: string note: string } export interface AuditCountItem { key: string label: string count: number } export interface AuditFocusCard { label: string value: string note: string } export interface AuditMetadataItem { key: string value: string } export interface AuditChangeItem { field: string label: string from: string to: string sensitive: boolean } export interface AuditRelatedLink { label: string to: string } export interface AuditFilterPreset { key: string label: string description: string filters: { query?: string action?: string entityType?: string } } export interface AuditEntityOption { value: string label: string } export interface AuditLogRow extends AdminAuditEntry { actionLabel: string actionGroup: string actionToneClass: string dotClass: string entityLabel: string createdLabel: string ageLabel: string metadataItems: AuditMetadataItem[] changeItems: AuditChangeItem[] requestContextItems: AuditMetadataItem[] relatedLink: AuditRelatedLink | null rawMetadataJson: string } const entityOptions: AuditEntityOption[] = [ { value: allFilter, label: 'Alle Objekte' }, { value: 'season', label: 'Jahre' }, { value: 'category', label: 'Kategorien' }, { value: 'candidate', label: 'Kandidaten' }, { value: 'nomination', label: 'Reviews' }, { value: 'clip', label: 'Clips' }, { value: 'risk-flag', label: 'Risiko' }, { value: 'result', label: 'Gewinner' }, { value: 'site-settings', label: 'Landingpage' }, { value: 'operational-settings', label: 'Betrieb' }, { value: 'seed', label: 'System' }, ] const filterPresets: AuditFilterPreset[] = [ { key: 'risk-security', label: 'Risk & Security', description: 'Entscheidungen mit Sicherheits- oder Missbrauchskontext.', filters: { action: 'risk.resolve' }, }, { key: 'public-context', label: 'Public-Kontext', description: 'Jahreswechsel, Phasen und sichtbare Public-Änderungen.', filters: { entityType: 'season' }, }, { key: 'content-privacy', label: 'Content & Privacy', description: 'Landingpage, Datenschutz und Content-Konfiguration.', filters: { action: 'site-settings.update' }, }, { key: 'destructive', label: 'Löschungen', description: 'Entfernende Aktionen aus Kategorien, Kandidaten, Clips und Jahren.', filters: { query: 'delete' }, }, ] function countBy(items: T[], getKey: (item: T) => string) { const counts = new Map() for (const item of items) { const key = getKey(item).trim() || 'unbekannt' counts.set(key, (counts.get(key) ?? 0) + 1) } return [...counts.entries()] .map(([key, count]) => ({ key, label: key, count })) .sort((a, b) => b.count - a.count || a.label.localeCompare(b.label)) } function humanizeAction(action: string) { if (actionLabels[action]) return actionLabels[action] return action .split('.') .filter(Boolean) .map((part) => part.charAt(0).toUpperCase() + part.slice(1).replaceAll('_', ' ')) .join(' ') } function getActionGroup(action: string) { const group = action.split('.')[0] ?? '' return groupLabels[group] ?? 'System' } function getActionToneClass(action: string) { if (action.startsWith('risk')) return 'border-rose-100 bg-rose-50 text-rose-700' if (action.startsWith('clip') || action.startsWith('nomination')) return 'border-amber-100 bg-amber-50 text-amber-700' if (action.startsWith('result')) return 'border-emerald-100 bg-emerald-50 text-emerald-700' if (action.startsWith('season')) return 'border-sky-100 bg-sky-50 text-sky-700' if (action.startsWith('site') || action.startsWith('operational')) return 'border-indigo-100 bg-indigo-50 text-indigo-700' if (action.includes('delete')) return 'border-slate-200 bg-slate-100 text-slate-700' return 'border-cyan-100 bg-cyan-50 text-cyan-700' } function getDotClass(action: string) { if (action.startsWith('risk')) return 'bg-rose-400' if (action.startsWith('clip') || action.startsWith('nomination')) return 'bg-amber-400' if (action.startsWith('result')) return 'bg-emerald-400' if (action.startsWith('season')) return 'bg-sky-400' if (action.startsWith('site') || action.startsWith('operational')) return 'bg-indigo-400' if (action.includes('delete')) return 'bg-slate-400' return 'bg-cyan-400' } function stringifyMetadataValue(entryValue: unknown) { if (typeof entryValue === 'object') return JSON.stringify(entryValue) return String(entryValue) } function parseMetadata(metadataJson: string | undefined) { if (!metadataJson) return [] try { const value = JSON.parse(metadataJson) as Record if (!value || typeof value !== 'object' || Array.isArray(value)) return [] return Object.entries(value) .filter(([key]) => key !== 'changes' && key !== 'Changes') .filter(([, entryValue]) => entryValue !== null && entryValue !== undefined && entryValue !== '') .map(([key, entryValue]) => ({ key, value: stringifyMetadataValue(entryValue), })) } catch { return [{ key: 'metadata', value: metadataJson }] } } function parseChangeItems(metadataJson: string | undefined): AuditChangeItem[] { if (!metadataJson) return [] try { const value = JSON.parse(metadataJson) as Record const changes = value.changes ?? value.Changes if (!Array.isArray(changes)) return [] return changes .map((change) => { if (!change || typeof change !== 'object' || Array.isArray(change)) return null const item = change as Record return { field: String(item.field ?? item.Field ?? ''), label: String(item.label ?? item.Label ?? item.field ?? item.Field ?? 'Änderung'), from: String(item.from ?? item.From ?? ''), to: String(item.to ?? item.To ?? ''), sensitive: Boolean(item.sensitive ?? item.Sensitive), } }) .filter((change): change is AuditChangeItem => Boolean(change?.field || change?.label)) } catch { return [] } } function formatDate(value: string) { const date = new Date(value) if (Number.isNaN(date.getTime())) return value return date.toLocaleString('de-DE', { dateStyle: 'short', timeStyle: 'short' }) } function formatAge(value: string) { const timestamp = new Date(value).getTime() if (Number.isNaN(timestamp)) return 'Zeitpunkt unbekannt' const minutes = Math.max(0, Math.round((Date.now() - timestamp) / 60_000)) if (minutes < 2) return 'gerade eben' if (minutes < 60) return `vor ${minutes} Min.` const hours = Math.round(minutes / 60) if (hours < 48) return `vor ${hours} Std.` return `vor ${Math.round(hours / 24)} Tagen` } function escapeCsvCell(value: unknown) { const text = String(value ?? '') if (!/[",\n\r;]/.test(text)) return text return `"${text.replaceAll('"', '""')}"` } function getDateBoundary(value: string, isEndOfDay: boolean) { if (!value) return undefined const suffix = isEndOfDay ? 'T23:59:59.999' : 'T00:00:00.000' const date = new Date(`${value}${suffix}`) return Number.isNaN(date.getTime()) ? undefined : date.toISOString() } function buildRequestContextItems(entry: AdminAuditEntry) { return [ { key: 'IP', value: entry.createdFromIp || 'nicht erfasst' }, { key: 'User-Agent', value: entry.userAgent || 'nicht erfasst' }, ] } function buildRelatedLink(entry: AdminAuditEntry) { const route = relatedRoutes[entry.entityType] if (!route) return null if (entry.entityType === 'nomination' && entry.entityId) { return { label: route.label, to: `${route.to}?nominationId=${encodeURIComponent(entry.entityId)}` } } return route } function createAuditRow(entry: AdminAuditEntry): AuditLogRow { return { ...entry, actionLabel: humanizeAction(entry.actionType), actionGroup: getActionGroup(entry.actionType), actionToneClass: getActionToneClass(entry.actionType), dotClass: getDotClass(entry.actionType), entityLabel: `${entry.entityType} ${entry.entityId}`.trim(), createdLabel: formatDate(entry.createdAt), ageLabel: formatAge(entry.createdAt), metadataItems: parseMetadata(entry.metadataJson), changeItems: parseChangeItems(entry.metadataJson), requestContextItems: buildRequestContextItems(entry), relatedLink: buildRelatedLink(entry), rawMetadataJson: entry.metadataJson || '{}', } } function downloadCsv(entries: AdminAuditEntry[]) { const rows = [ [ 'Id', 'Admin', 'Aktion', 'Objekt-Typ', 'Objekt-Id', 'Zusammenfassung', 'Zeitpunkt', 'IP', 'User-Agent', 'Metadaten', ], ...entries.map((entry) => [ entry.id, entry.adminTwitchUserId, entry.actionType, entry.entityType, entry.entityId, entry.summary, new Date(entry.createdAt).toISOString(), entry.createdFromIp, entry.userAgent, entry.metadataJson, ]), ] const csv = rows.map((row) => row.map(escapeCsvCell).join(';')).join('\n') const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' }) const url = URL.createObjectURL(blob) const link = document.createElement('a') link.href = url link.download = `vtuber-star-awards-audit-${new Date().toISOString().slice(0, 10)}.csv` document.body.append(link) link.click() link.remove() URL.revokeObjectURL(url) } export function useAdminAuditManager() { const store = useAwardsStore() const query = ref('') const selectedAdmin = ref(allFilter) const selectedAction = ref(allFilter) const entityFilter = ref(allFilter) const fromDate = ref('') const toDate = ref('') const loadingAudit = ref(false) const loadingMore = ref(false) const auditError = ref('') const exportMessage = ref('') const lastLoadedAt = ref(null) const auditEntries = ref([]) const totalCount = ref(0) const nextCursor = ref(null) const selectedEntry = ref(null) const appliedPresetKey = ref('') let searchTimer: ReturnType | null = null let exportMessageTimer: ReturnType | null = null let suppressFilterWatch = false const adminCounts = computed(() => countBy(auditEntries.value, (entry) => entry.adminTwitchUserId)) const actionCounts = computed(() => countBy(auditEntries.value, (entry) => entry.actionType).map((item) => ({ ...item, label: humanizeAction(item.key), })), ) const entityCounts = computed(() => countBy(auditEntries.value, (entry) => entry.entityType).map((item) => ({ ...item, label: entityOptions.find((option) => option.value === item.key)?.label ?? item.key, })), ) const auditRows = computed(() => auditEntries.value.map(createAuditRow)) const metadataCount = computed(() => auditEntries.value.filter((entry) => parseMetadata(entry.metadataJson).length > 0).length) const requestContextCount = computed(() => auditEntries.value.filter((entry) => entry.createdFromIp || entry.userAgent).length) const recentDayCount = computed(() => { const minTimestamp = Date.now() - 24 * 60 * 60 * 1000 return auditEntries.value.filter((entry) => new Date(entry.createdAt).getTime() >= minTimestamp).length }) const hasMore = computed(() => Boolean(nextCursor.value)) const loadedCountLabel = computed(() => auditEntries.value.length.toLocaleString('de-DE')) const totalCountLabel = computed(() => totalCount.value.toLocaleString('de-DE')) const logStats = computed(() => [ { label: 'Geladen', value: loadedCountLabel.value, note: `Page ${auditPageLimit}` }, { label: 'Treffer', value: totalCountLabel.value, note: 'serverseitig gefiltert' }, { label: '24h', value: recentDayCount.value.toLocaleString('de-DE'), note: 'neue Aktionen' }, { label: 'Kontext', value: requestContextCount.value.toLocaleString('de-DE'), note: 'mit IP oder User-Agent' }, ]) const focusCards = computed(() => { const topAdmin = adminCounts.value[0] const topAction = actionCounts.value[0] const topEntity = entityCounts.value[0] return [ { label: 'Top Admin', value: topAdmin?.label ?? 'Keine Daten', note: topAdmin ? `${topAdmin.count} Aktionen in den geladenen Treffern` : 'Noch kein Admin aktiv', }, { label: 'Top Aktion', value: topAction?.label ?? 'Keine Daten', note: topAction ? `${topAction.count} Einträge` : 'Noch kein Aktionstyp vorhanden', }, { label: 'Objekt-Fokus', value: topEntity?.label ?? 'Keine Daten', note: topEntity ? `${topEntity.count} Einträge` : 'Noch kein Objekttyp vorhanden', }, { label: 'Details', value: metadataCount.value.toLocaleString('de-DE'), note: 'Einträge mit sichtbaren Metadaten', }, ] }) const activeFilterCount = computed(() => (query.value.trim() ? 1 : 0) + (selectedAdmin.value !== allFilter ? 1 : 0) + (selectedAction.value !== allFilter ? 1 : 0) + (entityFilter.value !== allFilter ? 1 : 0) + (fromDate.value ? 1 : 0) + (toDate.value ? 1 : 0), ) const lastLoadedLabel = computed(() => lastLoadedAt.value ? lastLoadedAt.value.toLocaleString('de-DE', { dateStyle: 'short', timeStyle: 'short' }) : 'Noch nicht aktualisiert', ) const emptyStateText = computed(() => activeFilterCount.value > 0 ? 'Keine Log-Einträge passen zu den aktiven Filtern.' : 'Noch keine Audit-Einträge vorhanden.', ) const pageSummaryLabel = computed(() => `${loadedCountLabel.value} von ${totalCountLabel.value} Treffern geladen`, ) function buildAuditOptions(cursor: string | null = null): AdminAuditQueryOptions { return { limit: auditPageLimit, query: query.value, admin: selectedAdmin.value === allFilter ? undefined : selectedAdmin.value, action: selectedAction.value === allFilter ? undefined : selectedAction.value, entityType: entityFilter.value === allFilter ? undefined : entityFilter.value, from: getDateBoundary(fromDate.value, false), to: getDateBoundary(toDate.value, true), cursor, } } function clearTimers() { if (searchTimer) { window.clearTimeout(searchTimer) searchTimer = null } } function scheduleLoad() { clearTimers() searchTimer = window.setTimeout(() => { void loadAuditEntries() }, 350) } async function loadAuditEntries(options: { append?: boolean } = {}) { const append = options.append ?? false if (append && !nextCursor.value) return if (append) { loadingMore.value = true } else { loadingAudit.value = true selectedEntry.value = null } auditError.value = '' try { const response = await store.loadAdminAuditEntriesPage( buildAuditOptions(append ? nextCursor.value : null), append, ) auditEntries.value = append ? [...auditEntries.value, ...response.items] : response.items totalCount.value = response.totalCount nextCursor.value = response.nextCursor lastLoadedAt.value = new Date() } catch (error) { auditError.value = error instanceof Error ? error.message : 'Audit-Logs konnten nicht geladen werden.' } finally { loadingAudit.value = false loadingMore.value = false } } async function loadNextPage() { await loadAuditEntries({ append: true }) } function exportAuditCsv() { if (auditEntries.value.length === 0) return downloadCsv(auditEntries.value) exportMessage.value = `CSV mit ${auditEntries.value.length.toLocaleString('de-DE')} geladenen Einträgen erstellt.` if (exportMessageTimer) window.clearTimeout(exportMessageTimer) exportMessageTimer = window.setTimeout(() => { exportMessage.value = '' }, 3500) } async function clearFilters() { clearTimers() suppressFilterWatch = true query.value = '' selectedAdmin.value = allFilter selectedAction.value = allFilter entityFilter.value = allFilter fromDate.value = '' toDate.value = '' appliedPresetKey.value = '' suppressFilterWatch = false await loadAuditEntries() } async function applyPreset(presetKey: string) { const preset = filterPresets.find((item) => item.key === presetKey) if (!preset) return clearTimers() suppressFilterWatch = true query.value = preset.filters.query ?? '' selectedAdmin.value = allFilter selectedAction.value = preset.filters.action ?? allFilter entityFilter.value = preset.filters.entityType ?? allFilter fromDate.value = '' toDate.value = '' appliedPresetKey.value = preset.key suppressFilterWatch = false await loadAuditEntries() } function openAuditEntry(entry: AuditLogRow) { selectedEntry.value = entry } function closeAuditEntry() { selectedEntry.value = null } watch([query, selectedAdmin, selectedAction, entityFilter, fromDate, toDate], () => { if (suppressFilterWatch) return appliedPresetKey.value = '' scheduleLoad() }) onMounted(() => { auditEntries.value = store.admin.auditEntries totalCount.value = store.admin.auditEntries.length void loadAuditEntries() }) onBeforeUnmount(() => { clearTimers() if (exportMessageTimer) window.clearTimeout(exportMessageTimer) }) return { query, selectedAdmin, selectedAction, entityFilter, fromDate, toDate, loadingAudit, loadingMore, auditError, exportMessage, selectedEntry, auditRows, adminCounts, actionCounts, entityCounts, logStats, focusCards, activeFilterCount, lastLoadedLabel, emptyStateText, pageSummaryLabel, hasMore, filterPresets, appliedPresetKey, entityOptions, loadAuditEntries, loadNextPage, exportAuditCsv, clearFilters, applyPreset, openAuditEntry, closeAuditEntry, } }