Files
vtuber-awards/frontend/src/components/admin/useAdminAuditManager.ts
T

634 lines
20 KiB
TypeScript

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<string, string> = {
'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<string, string> = {
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<string, { label: string; to: string }> = {
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<T>(items: T[], getKey: (item: T) => string) {
const counts = new Map<string, number>()
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<string, unknown>
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<string, unknown>
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<string, unknown>
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<Date | null>(null)
const auditEntries = ref<AdminAuditEntry[]>([])
const totalCount = ref(0)
const nextCursor = ref<string | null>(null)
const selectedEntry = ref<AuditLogRow | null>(null)
const appliedPresetKey = ref('')
let searchTimer: ReturnType<typeof window.setTimeout> | null = null
let exportMessageTimer: ReturnType<typeof window.setTimeout> | 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<AuditLogRow[]>(() => 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<AuditStat[]>(() => [
{ 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<AuditFocusCard[]>(() => {
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,
}
}