Files
vtuber-awards/frontend/src/components/admin/useAdminTrackingRules.ts
T
AzuTear b53c7fb736 Add viewer-range categories, nomination tracking, dynamic showact form, session timeout, share URLs, and workflow-per-season
Features:
- Category viewer ranges + subcategory templates (admin group modal, tree workspace)
- Nomination enrichment via TwitchTracker API (NominationEnrichmentService,
  TwitchTrackerViewerStatsProvider) with admin tracking rules editor
- Nomination group tracker: CategoryGroupName as primary identifier,
  CategoryId stays as nullable legacy field; StreamerIdentity table
- Dynamic showact application form builder (AdminShowactFormBuilder,
  ShowactApplicationSchedule)
- Session idle timeout setting (AdminSessionTimeoutCard)
- Share URLs for X and Discord (SiteSettings, public extras)
- Workflow rules now stored per season (falls back to global SiteSettings)
- New admin routes: settings/access, settings/workflows, tracking-rules
- New admin review workspace with subcategory tabs
- AdminCategoriesView rebuilt with group/subcategory modals

Migrations (all additive):
- AddShareUrls, AddShowactDynamicForm, AddCategoryViewerRanges,
  AddSessionIdleTimeoutSettings, AddSeasonSubcategoryTemplates,
  AddNominationGroupTrackerIdentity, AddShowactApplicationSchedule,
  AddSeasonWorkflowRulesJson

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-28 23:32:21 +02:00

279 lines
10 KiB
TypeScript

import { computed, onMounted, ref } from 'vue'
import { useAwardsStore } from '../../stores/awards'
import type {
AdminTrackingFlagRule,
AdminTrackingMetricRule,
AdminTrackingRulesResponse,
AdminTrackingSource,
} from '../../types/awards'
function cloneMetricRules(rules: AdminTrackingMetricRule[]) {
return rules.map((rule) => ({
...rule,
autoSupportedWindowKeys: [...(rule.autoSupportedWindowKeys ?? [])],
ignoredCategories: [...(rule.ignoredCategories ?? [])],
}))
}
function cloneFlagRules(rules: AdminTrackingFlagRule[]) {
return rules.map((rule) => ({ ...rule }))
}
function normalizeMetricRule(rule: AdminTrackingMetricRule): AdminTrackingMetricRule {
const autoSupportedWindowKeys = Array.isArray(rule.autoSupportedWindowKeys)
? [...new Set(rule.autoSupportedWindowKeys.map((item) => String(item).trim()).filter(Boolean))]
: []
return {
...rule,
enabled: Boolean(rule.enabled),
requiredForAutoClassification: Boolean(rule.requiredForAutoClassification),
showInReview: Boolean(rule.showInReview),
showInAdminSummary: Boolean(rule.showInAdminSummary),
manualOverrideAllowed: Boolean(rule.manualOverrideAllowed),
windowKey: String(rule.windowKey || '30d'),
autoSupportedWindowKeys,
providerFieldKey: rule.providerFieldKey?.trim() || null,
topCount: rule.topCount == null ? null : Number(rule.topCount),
minPrimaryCategorySharePercent: rule.minPrimaryCategorySharePercent == null ? null : Number(rule.minPrimaryCategorySharePercent),
minPrimaryCategoryHours: rule.minPrimaryCategoryHours == null ? null : Number(rule.minPrimaryCategoryHours),
maxDistinctCategoriesBeforeFlag: rule.maxDistinctCategoriesBeforeFlag == null ? null : Number(rule.maxDistinctCategoriesBeforeFlag),
ignoredCategories: Array.isArray(rule.ignoredCategories)
? [...new Set(rule.ignoredCategories.map((item) => String(item).trim()).filter(Boolean))]
: [],
matchAwardCategoryAgainstTopCategories: Boolean(rule.matchAwardCategoryAgainstTopCategories),
flagIfAwardCategoryNotInTopX: Boolean(rule.flagIfAwardCategoryNotInTopX),
flagIfCategorySpreadTooWide: Boolean(rule.flagIfCategorySpreadTooWide),
flagIfNoCategoryContextAvailable: Boolean(rule.flagIfNoCategoryContextAvailable),
minValue: rule.minValue == null ? null : Number(rule.minValue),
maxValue: rule.maxValue == null ? null : Number(rule.maxValue),
}
}
function normalizeFlagRule(rule: AdminTrackingFlagRule): AdminTrackingFlagRule {
return {
...rule,
enabled: Boolean(rule.enabled),
autoTriggerEnabled: Boolean(rule.autoTriggerEnabled),
requiresManualReview: Boolean(rule.requiresManualReview),
blocksApproval: Boolean(rule.blocksApproval),
adminNoteRequiredOnOverride: Boolean(rule.adminNoteRequiredOnOverride),
severity: rule.severity === 'high' || rule.severity === 'low' ? rule.severity : 'medium',
}
}
function createEmptyTrackingRules(): AdminTrackingRulesResponse {
return {
source: {
providerKey: 'twitchtracker',
providerLabel: 'TwitchTracker Basic API',
baseUrl: 'https://twitchtracker.com/api',
notesSummary: '',
showManualReviewNotesInReview: true,
},
importantMetrics: [],
optionalMetrics: [],
flags: [],
manualReviewNotes: '',
}
}
function createSourceSnapshot(source: AdminTrackingSource) {
return JSON.stringify(source)
}
function parseSourceSnapshot(snapshot: string): AdminTrackingSource {
if (!snapshot) {
return createEmptyTrackingRules().source
}
try {
const parsed = JSON.parse(snapshot) as AdminTrackingSource
return {
providerKey: parsed.providerKey || 'twitchtracker',
providerLabel: parsed.providerLabel || 'TwitchTracker Basic API',
baseUrl: parsed.baseUrl || 'https://twitchtracker.com/api',
notesSummary: parsed.notesSummary || '',
showManualReviewNotesInReview: parsed.showManualReviewNotesInReview !== false,
}
} catch {
return createEmptyTrackingRules().source
}
}
function createRulesSnapshot(
importantMetrics: AdminTrackingMetricRule[],
optionalMetrics: AdminTrackingMetricRule[],
flags: AdminTrackingFlagRule[],
) {
return JSON.stringify({
importantMetrics: importantMetrics.map(normalizeMetricRule),
optionalMetrics: optionalMetrics.map(normalizeMetricRule),
flags: flags.map(normalizeFlagRule),
})
}
export function useAdminTrackingRules() {
const store = useAwardsStore()
const trackingLoading = ref(false)
const rulesSaving = ref(false)
const sourceSaving = ref(false)
const notesSaving = ref(false)
const trackingError = ref('')
const trackingSuccess = ref('')
const trackingSource = ref<AdminTrackingSource>(createEmptyTrackingRules().source)
const importantMetrics = ref<AdminTrackingMetricRule[]>([])
const optionalMetrics = ref<AdminTrackingMetricRule[]>([])
const trackingFlags = ref<AdminTrackingFlagRule[]>([])
const manualReviewNotes = ref('')
const sourceSnapshot = ref('')
const rulesSnapshot = ref('')
const notesSnapshot = ref('')
const trackingSummary = computed(() => ({
importantActive: importantMetrics.value.filter((item) => item.enabled).length,
optionalActive: optionalMetrics.value.filter((item) => item.enabled).length,
flagsActive: trackingFlags.value.filter((item) => item.enabled).length,
manualReviewFlags: trackingFlags.value.filter((item) => item.enabled && item.requiresManualReview).length,
}))
const hasUnsavedTrackingSourceChanges = computed(() => createSourceSnapshot(trackingSource.value) !== sourceSnapshot.value)
const hasUnsavedTrackingRuleChanges = computed(() =>
createRulesSnapshot(importantMetrics.value, optionalMetrics.value, trackingFlags.value) !== rulesSnapshot.value,
)
const hasUnsavedTrackingNotesChanges = computed(() => manualReviewNotes.value !== notesSnapshot.value)
function applyResponse(response: AdminTrackingRulesResponse) {
trackingSource.value = {
providerKey: response.source?.providerKey || 'twitchtracker',
providerLabel: response.source?.providerLabel || 'TwitchTracker Basic API',
baseUrl: response.source?.baseUrl || 'https://twitchtracker.com/api',
notesSummary: response.source?.notesSummary || '',
showManualReviewNotesInReview: response.source?.showManualReviewNotesInReview !== false,
}
importantMetrics.value = cloneMetricRules(response.importantMetrics)
optionalMetrics.value = cloneMetricRules(response.optionalMetrics)
trackingFlags.value = cloneFlagRules(response.flags)
manualReviewNotes.value = response.manualReviewNotes ?? ''
sourceSnapshot.value = createSourceSnapshot(trackingSource.value)
rulesSnapshot.value = createRulesSnapshot(importantMetrics.value, optionalMetrics.value, trackingFlags.value)
notesSnapshot.value = manualReviewNotes.value
}
async function loadTrackingRules() {
trackingLoading.value = true
trackingError.value = ''
trackingSuccess.value = ''
try {
applyResponse(await store.loadAdminTrackingRules())
} catch (error) {
trackingError.value = error instanceof Error ? error.message : 'Tracking Rules konnten nicht geladen werden.'
applyResponse(createEmptyTrackingRules())
} finally {
trackingLoading.value = false
}
}
function updateImportantMetric(ruleKey: string, patch: Partial<AdminTrackingMetricRule>) {
importantMetrics.value = importantMetrics.value.map((rule) =>
rule.key === ruleKey ? normalizeMetricRule({ ...rule, ...patch }) : rule,
)
}
function updateOptionalMetric(ruleKey: string, patch: Partial<AdminTrackingMetricRule>) {
optionalMetrics.value = optionalMetrics.value.map((rule) =>
rule.key === ruleKey ? normalizeMetricRule({ ...rule, ...patch }) : rule,
)
}
function updateTrackingFlag(ruleKey: string, patch: Partial<AdminTrackingFlagRule>) {
trackingFlags.value = trackingFlags.value.map((rule) =>
rule.key === ruleKey ? normalizeFlagRule({ ...rule, ...patch }) : rule,
)
}
async function saveTrackingSource() {
sourceSaving.value = true
trackingError.value = ''
trackingSuccess.value = ''
try {
const response = await store.updateAdminTrackingSource({
source: { ...trackingSource.value },
})
applyResponse(response)
trackingSuccess.value = 'Tracking Source wurde gespeichert.'
} catch (error) {
trackingError.value = error instanceof Error ? error.message : 'Tracking Source konnte nicht gespeichert werden.'
} finally {
sourceSaving.value = false
}
}
async function saveTrackingNotes() {
notesSaving.value = true
trackingError.value = ''
trackingSuccess.value = ''
try {
const response = await store.updateAdminTrackingReviewNotes({
manualReviewNotes: manualReviewNotes.value,
showManualReviewNotesInReview: trackingSource.value.showManualReviewNotesInReview,
})
applyResponse(response)
trackingSuccess.value = 'Manual Review Notes wurden gespeichert.'
} catch (error) {
trackingError.value = error instanceof Error ? error.message : 'Manual Review Notes konnten nicht gespeichert werden.'
} finally {
notesSaving.value = false
}
}
async function saveTrackingRules() {
rulesSaving.value = true
trackingError.value = ''
trackingSuccess.value = ''
try {
const response = await store.updateAdminTrackingRules({
source: parseSourceSnapshot(sourceSnapshot.value),
importantMetrics: importantMetrics.value.map(normalizeMetricRule),
optionalMetrics: optionalMetrics.value.map(normalizeMetricRule),
flags: trackingFlags.value.map(normalizeFlagRule),
manualReviewNotes: notesSnapshot.value,
})
applyResponse(response)
trackingSuccess.value = 'Tracking Rules wurden gespeichert.'
} catch (error) {
trackingError.value = error instanceof Error ? error.message : 'Tracking Rules konnten nicht gespeichert werden.'
} finally {
rulesSaving.value = false
}
}
onMounted(loadTrackingRules)
return {
trackingLoading,
rulesSaving,
sourceSaving,
notesSaving,
trackingError,
trackingSuccess,
trackingSource,
importantMetrics,
optionalMetrics,
trackingFlags,
manualReviewNotes,
trackingSummary,
hasUnsavedTrackingSourceChanges,
hasUnsavedTrackingRuleChanges,
hasUnsavedTrackingNotesChanges,
loadTrackingRules,
updateImportantMetric,
updateOptionalMetric,
updateTrackingFlag,
saveTrackingSource,
saveTrackingNotes,
saveTrackingRules,
}
}