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>
This commit is contained in:
@@ -0,0 +1,278 @@
|
||||
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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user