b53c7fb736
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>
279 lines
10 KiB
TypeScript
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,
|
|
}
|
|
}
|