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:
AzuTear
2026-06-28 23:32:21 +02:00
parent 4b2c5fa15d
commit b53c7fb736
178 changed files with 28933 additions and 1908 deletions
+8 -6
View File
@@ -63,8 +63,13 @@ export const useAuthStore = defineStore('auth', {
isTeamSession: (state) => Boolean(state.session?.teamLogin),
},
actions: {
clearSession() {
this.session = null
writeStoredToken(null)
},
async hydrate() {
if (!readStoredToken()) {
this.session = null
this.hydrated = true
return
}
@@ -72,9 +77,8 @@ export const useAuthStore = defineStore('auth', {
try {
this.session = await api.getSession()
} catch (error) {
this.session = null
if (error instanceof ApiRequestError && (error.status === 401 || error.status === 403)) {
writeStoredToken(null)
this.clearSession()
}
} finally {
this.hydrated = true
@@ -162,8 +166,7 @@ export const useAuthStore = defineStore('auth', {
try {
await api.logout()
} finally {
this.session = null
writeStoredToken(null)
this.clearSession()
this.loading = false
}
},
@@ -171,8 +174,7 @@ export const useAuthStore = defineStore('auth', {
this.loading = true
try {
const result = await api.deleteMyData()
this.session = null
writeStoredToken(null)
this.clearSession()
return result
} finally {
this.loading = false
+111 -12
View File
@@ -9,6 +9,7 @@ import {
createEmptyAdminSeasonDetail,
createEmptyAdminOptionalFeatureSettings,
createEmptyAdminSiteSettings,
createEmptyAdminTrackingRulesResponse,
createEmptyAdminWorkflowRulesResponse,
createEmptyArchive,
createEmptyDatabaseHealth,
@@ -23,20 +24,27 @@ import type {
CreateNominationPayload,
CreateShowactApplicationPayload,
CreateVotePayload,
ReopenRejectedNominationPayload,
RejectNominationPayload,
ResolveRiskFlagPayload,
SetAwardResultPayload,
AdminAuditQueryOptions,
AdminRiskQueryOptions,
UpdateSeasonSubcategoryTemplatesPayload,
UpdateSeasonPayload,
UpdateClipStatusPayload,
UpdateNominationLinkBlacklistPayload,
UpdateOptionalFeatureSettingsPayload,
UpdateNominationTrackingReviewPayload,
UpdateShowactStatusPayload,
UpdateSiteSettingsPayload,
UpdateTrackingReviewNotesPayload,
UpdateTrackingRulesPayload,
UpdateTrackingSourcePayload,
UpdateWorkflowRulesPayload,
UpsertCandidatePayload,
UpsertCategoryPayload,
UpsertCategoryGroupPayload,
UpsertSponsorPayload,
} from '../types/awards'
@@ -57,7 +65,9 @@ export const useAwardsStore = defineStore('awards', {
this.categories = await api.getSeasonCategories(this.overview.year)
await this.loadPublicSponsors(this.overview.year)
const overviewArchiveYears = this.overview.archiveYears ?? []
const initialArchiveYear = overviewArchiveYears[0]?.year
const initialArchiveYear = canExposeCurrentSeasonWinners(this.overview.currentPhase)
? this.overview.year
: overviewArchiveYears[0]?.year
?? this.overview.winnersPreview[0]?.year
?? this.overview.year - 1
this.archive = await api.getWinnerArchive(initialArchiveYear)
@@ -92,12 +102,11 @@ export const useAwardsStore = defineStore('awards', {
},
async loadAdmin() {
try {
const [admin, adminSeasons, adminSiteSettings, adminOptionalFeatureSettings, adminWorkflowRules, databaseHealth] = await Promise.all([
const [admin, adminSeasons, adminSiteSettings, adminOptionalFeatureSettings, databaseHealth] = await Promise.all([
api.getAdminDashboard(),
api.getAdminSeasons(),
api.getAdminSiteSettings(),
api.getAdminOptionalFeatureSettings(),
api.getAdminWorkflowRules(),
api.getDatabaseHealth(),
])
@@ -105,7 +114,6 @@ export const useAwardsStore = defineStore('awards', {
this.adminSeasons = adminSeasons
this.adminSiteSettings = adminSiteSettings
this.adminOptionalFeatureSettings = adminOptionalFeatureSettings
this.adminWorkflowRules = adminWorkflowRules
this.databaseHealth = databaseHealth
if (!this.adminSelectedSeasonId || !this.adminSeasons.some((season) => season.id === this.adminSelectedSeasonId)) {
@@ -113,26 +121,32 @@ export const useAwardsStore = defineStore('awards', {
}
if (this.adminSelectedSeasonId) {
this.adminSeasonDetail = normalizeSeasonDetail(await api.getAdminSeasonDetail(this.adminSelectedSeasonId))
const [sponsors, showacts] = await Promise.all([
const [seasonDetail, sponsors, showacts, adminWorkflowRules] = await Promise.all([
api.getAdminSeasonDetail(this.adminSelectedSeasonId),
api.getAdminSponsors(this.adminSelectedSeasonId),
api.getAdminShowactApplications(this.adminSelectedSeasonId),
api.getAdminWorkflowRules(this.adminSelectedSeasonId),
])
this.adminSeasonDetail = normalizeSeasonDetail(seasonDetail)
this.adminSponsors = sponsors
this.adminShowactApplications = showacts
this.adminWorkflowRules = adminWorkflowRules
} else {
this.adminSeasonDetail = createEmptyAdminSeasonDetail()
this.adminSponsors = []
this.adminShowactApplications = []
this.adminWorkflowRules = createEmptyAdminWorkflowRulesResponse()
}
this.apiMode = 'api'
} catch {
this.apiMode = 'fallback'
this.admin = createEmptyAdminDashboard()
this.adminSeasons = []
this.adminSeasonDetail = createEmptyAdminSeasonDetail()
this.adminSiteSettings = createEmptyAdminSiteSettings()
this.adminOptionalFeatureSettings = createEmptyAdminOptionalFeatureSettings()
this.adminWorkflowRules = createEmptyAdminWorkflowRulesResponse()
this.adminTrackingRules = createEmptyAdminTrackingRulesResponse()
this.adminRiskHistory = []
this.adminRiskFlagsPage = createEmptyAdminRiskFlagsResponse()
this.adminRiskHistoryPage = createEmptyAdminRiskFlagsResponse()
@@ -155,30 +169,43 @@ export const useAwardsStore = defineStore('awards', {
this.databaseHealth = databaseHealth
this.apiMode = 'api'
} catch {
this.apiMode = 'fallback'
this.adminSiteSettings = createEmptyAdminSiteSettings()
this.adminOptionalFeatureSettings = createEmptyAdminOptionalFeatureSettings()
this.databaseHealth = createEmptyDatabaseHealth()
}
},
async loadDatabaseHealth() {
this.databaseHealth = await api.getDatabaseHealth()
return this.databaseHealth
try {
this.databaseHealth = await api.getDatabaseHealth()
this.apiMode = 'api'
return this.databaseHealth
} catch (error) {
this.databaseHealth = createEmptyDatabaseHealth()
this.apiMode = 'fallback'
throw error
}
},
async loadAdminSeasonDetail(seasonId: number) {
try {
this.adminSelectedSeasonId = seasonId
this.adminSeasonDetail = normalizeSeasonDetail(await api.getAdminSeasonDetail(seasonId))
const [sponsors, showacts] = await Promise.all([
const [seasonDetail, sponsors, showacts, adminWorkflowRules] = await Promise.all([
api.getAdminSeasonDetail(seasonId),
api.getAdminSponsors(seasonId),
api.getAdminShowactApplications(seasonId),
api.getAdminWorkflowRules(seasonId),
])
this.adminSeasonDetail = normalizeSeasonDetail(seasonDetail)
this.adminSponsors = sponsors
this.adminShowactApplications = showacts
this.adminWorkflowRules = adminWorkflowRules
this.apiMode = 'api'
} catch {
this.apiMode = 'fallback'
this.adminSeasonDetail = createEmptyAdminSeasonDetail()
this.adminSponsors = []
this.adminShowactApplications = []
this.adminWorkflowRules = createEmptyAdminWorkflowRulesResponse()
}
},
async initializeAdminWorkspace() {
@@ -325,6 +352,26 @@ export const useAwardsStore = defineStore('awards', {
await this.refreshAdminSeasonWorkspace(seasonId, { reloadHome: true })
return result
},
async updateAdminSeasonSubcategoryTemplates(seasonId: number, payload: UpdateSeasonSubcategoryTemplatesPayload) {
const result = await api.updateAdminSeasonSubcategoryTemplates(seasonId, payload)
await this.refreshAdminSeasonWorkspace(seasonId, { reloadHome: true })
return result
},
async createAdminCategoryGroup(seasonId: number, payload: UpsertCategoryGroupPayload) {
const result = await api.createAdminCategoryGroup(seasonId, payload)
await this.refreshAdminSeasonWorkspace(seasonId, { reloadHome: true })
return result
},
async updateAdminCategoryGroup(seasonId: number, groupName: string, payload: UpsertCategoryGroupPayload) {
const result = await api.updateAdminCategoryGroup(seasonId, groupName, payload)
await this.refreshAdminSeasonWorkspace(seasonId, { reloadHome: true })
return result
},
async deleteAdminCategoryGroup(seasonId: number, groupName: string) {
const result = await api.deleteAdminCategoryGroup(seasonId, groupName)
await this.refreshAdminSeasonWorkspace(seasonId, { reloadHome: true })
return result
},
async createAdminCandidate(seasonId: number, payload: UpsertCandidatePayload) {
const result = await api.createAdminCandidate(seasonId, payload)
await this.refreshAdminSeasonWorkspace(seasonId, { reloadHome: true })
@@ -379,6 +426,11 @@ export const useAwardsStore = defineStore('awards', {
await this.refreshAdminSeasonWorkspace(seasonId, { reloadAdmin: true })
return result
},
async reopenRejectedAdminNomination(nominationId: number, seasonId: number, payload: ReopenRejectedNominationPayload = {}) {
const result = await api.reopenRejectedAdminNomination(nominationId, payload)
await this.refreshAdminSeasonWorkspace(seasonId, { reloadAdmin: true })
return result
},
async bulkRejectAdminNominations(nominationIds: number[], seasonId: number, reviewNote?: string) {
await Promise.allSettled(nominationIds.map((id) => api.rejectAdminNomination(id, { reviewNote })))
await this.refreshAdminSeasonWorkspace(seasonId, { reloadAdmin: true })
@@ -438,13 +490,52 @@ export const useAwardsStore = defineStore('awards', {
return this.adminOptionalFeatureSettings
},
async loadAdminWorkflowRules() {
this.adminWorkflowRules = await api.getAdminWorkflowRules()
if (!this.adminSelectedSeasonId) {
this.adminWorkflowRules = createEmptyAdminWorkflowRulesResponse()
return this.adminWorkflowRules
}
this.adminWorkflowRules = await api.getAdminWorkflowRules(this.adminSelectedSeasonId)
return this.adminWorkflowRules
},
async updateAdminWorkflowRules(payload: UpdateWorkflowRulesPayload) {
this.adminWorkflowRules = await api.updateAdminWorkflowRules(payload)
if (!this.adminSelectedSeasonId) {
throw new Error('Kein Award-Jahr fuer Workflow-Regeln ausgewaehlt.')
}
this.adminWorkflowRules = await api.updateAdminWorkflowRules(this.adminSelectedSeasonId, payload)
return this.adminWorkflowRules
},
async loadAdminTrackingRules() {
this.adminTrackingRules = await api.getAdminTrackingRules()
return this.adminTrackingRules
},
async updateAdminTrackingRules(payload: UpdateTrackingRulesPayload) {
this.adminTrackingRules = await api.updateAdminTrackingRules(payload)
if (this.adminSelectedSeasonId) {
await this.loadAdminSeasonDetail(this.adminSelectedSeasonId)
}
return this.adminTrackingRules
},
async updateAdminTrackingSource(payload: UpdateTrackingSourcePayload) {
this.adminTrackingRules = await api.updateAdminTrackingSource(payload)
if (this.adminSelectedSeasonId) {
await this.loadAdminSeasonDetail(this.adminSelectedSeasonId)
}
return this.adminTrackingRules
},
async updateAdminTrackingReviewNotes(payload: UpdateTrackingReviewNotesPayload) {
this.adminTrackingRules = await api.updateAdminTrackingReviewNotes(payload)
if (this.adminSelectedSeasonId) {
await this.loadAdminSeasonDetail(this.adminSelectedSeasonId)
}
return this.adminTrackingRules
},
async updateNominationTrackingReview(nominationId: number, seasonId: number, payload: UpdateNominationTrackingReviewPayload) {
const result = await api.updateNominationTrackingReview(nominationId, payload)
await this.refreshAdminSeasonWorkspace(seasonId, { reloadAdmin: true })
return result
},
async updateAdminSiteSettings(payload: UpdateSiteSettingsPayload) {
const result = await api.updateAdminSiteSettings(payload)
this.adminSiteSettings = await api.getAdminSiteSettings()
@@ -454,4 +545,12 @@ export const useAwardsStore = defineStore('awards', {
},
})
function canExposeCurrentSeasonWinners(currentPhase: string) {
const normalized = currentPhase.trim().toLowerCase()
return normalized.includes('abgeschlossen')
|| normalized.includes('archiv')
|| normalized.includes('complete')
|| normalized.includes('ended')
}
export type AwardsStore = ReturnType<typeof useAwardsStore>
+38
View File
@@ -9,6 +9,7 @@ import type {
AdminShowactApplicationItem,
AdminSiteSettingsResponse,
AdminSponsorItem,
AdminTrackingRulesResponse,
AdminWorkflowRulesResponse,
DatabaseHealthResponse,
OverviewResponse,
@@ -39,6 +40,8 @@ export function createEmptyOverview(): OverviewResponse {
hostDisplayName: '',
hostTagline: '',
newsletterUrl: '',
shareXUrl: '',
shareDiscordUrl: '',
privacyEmail: '',
privacyPolicyContent: '',
socialLinks: [],
@@ -50,8 +53,11 @@ export function createEmptyOverview(): OverviewResponse {
clipAdminMenuVisible: true,
clipSubmissionDisabledMessage: 'Clip-Einreichungen sind aktuell geschlossen.',
showactApplicationsEnabled: false,
showactApplicationStartsAt: null,
showactApplicationEndsAt: null,
showactApplicationDisabledMessage: 'Showact-Bewerbungen sind aktuell geschlossen.',
sponsorsVisible: true,
showactFormSchemaJson: '[]',
},
faq: [],
}
@@ -108,6 +114,22 @@ export function createEmptyAdminWorkflowRulesResponse(): AdminWorkflowRulesRespo
}
}
export function createEmptyAdminTrackingRulesResponse(): AdminTrackingRulesResponse {
return {
source: {
providerKey: 'twitchtracker',
providerLabel: 'TwitchTracker Basic API',
baseUrl: 'https://twitchtracker.com/api',
notesSummary: '',
showManualReviewNotesInReview: true,
},
importantMetrics: [],
optionalMetrics: [],
flags: [],
manualReviewNotes: '',
}
}
export function createEmptyAdminSeasonDetail(): AdminSeasonDetailResponse {
return {
id: 0,
@@ -125,10 +147,14 @@ export function createEmptyAdminSeasonDetail(): AdminSeasonDetailResponse {
reviewEndsAt: '',
showDate: '',
showStartsAt: '20:00:00',
subcategoryTemplates: [],
categories: [],
candidates: [],
pendingNominations: [],
pendingNominationGroups: [],
reviewedNominations: [],
trackingReviewNotes: '',
showTrackingReviewNotes: true,
results: [],
clipSubmissions: [],
}
@@ -139,6 +165,8 @@ export function createEmptyAdminSiteSettings(): AdminSiteSettingsResponse {
hostDisplayName: '',
hostTagline: '',
newsletterUrl: '',
shareXUrl: '',
shareDiscordUrl: '',
privacyEmail: '',
privacyPolicyContent: '',
privacyPolicyUpdatedBy: null,
@@ -153,6 +181,7 @@ export function createEmptyAdminSiteSettings(): AdminSiteSettingsResponse {
showactsContent: '',
socialLinks: [],
faq: [],
showactFormSchemaJson: '[]',
}
}
@@ -163,6 +192,9 @@ export function createEmptyAdminOptionalFeatureSettings(): AdminOptionalFeatureS
clipAdminMenuVisible: true,
clipSubmissionDisabledMessage: 'Clip-Einreichungen sind aktuell geschlossen.',
showactApplicationsEnabled: false,
showactApplicationStartsAt: null,
showactApplicationEndsAt: null,
showactApplicationsOpenNow: false,
showactApplicationDisabledMessage: 'Showact-Bewerbungen sind aktuell geschlossen.',
sponsorsVisible: true,
}
@@ -191,6 +223,7 @@ export function createAwardsState() {
adminSiteSettings: createEmptyAdminSiteSettings(),
adminOptionalFeatureSettings: createEmptyAdminOptionalFeatureSettings(),
adminWorkflowRules: createEmptyAdminWorkflowRulesResponse(),
adminTrackingRules: createEmptyAdminTrackingRulesResponse(),
adminSponsors: [] as AdminSponsorItem[],
adminShowactApplications: [] as AdminShowactApplicationItem[],
adminRiskHistory: [] as AdminRiskFlag[],
@@ -212,6 +245,7 @@ export function createAwardsState() {
export function normalizeSeasonDetail(detail: AdminSeasonDetailResponse): AdminSeasonDetailResponse {
return {
...detail,
subcategoryTemplates: detail.subcategoryTemplates ?? [],
categories: detail.categories ?? [],
candidates: (detail.candidates ?? []).map((candidate) => ({
...candidate,
@@ -221,9 +255,13 @@ export function normalizeSeasonDetail(detail: AdminSeasonDetailResponse): AdminS
clipCompilationTitle: candidate.clipCompilationTitle ?? null,
clipCompilationPlatform: candidate.clipCompilationPlatform ?? null,
clipEmbedStatus: candidate.clipEmbedStatus ?? 'unchecked',
streamerIdentityId: candidate.streamerIdentityId ?? null,
nominationTally: candidate.nominationTally ?? 0,
})),
pendingNominations: detail.pendingNominations ?? [],
pendingNominationGroups: detail.pendingNominationGroups ?? [],
reviewedNominations: detail.reviewedNominations ?? [],
trackingReviewNotes: detail.trackingReviewNotes ?? '',
results: detail.results ?? [],
clipSubmissions: detail.clipSubmissions ?? [],
}