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
+1 -1
View File
@@ -10,5 +10,5 @@ export const api = {
...systemApi,
}
export { AUTH_TOKEN_KEY } from './http'
export { AUTH_TOKEN_KEY } from './authConstants'
export { ApiRequestError } from './http'
+48 -3
View File
@@ -14,15 +14,19 @@ import type {
AdminSiteSettingsResponse,
AdminSponsorItem,
AdminTeamResponse,
AdminTrackingRulesResponse,
AdminWorkflowRulesResponse,
ApproveNominationPayload,
AddNominationLinkBlacklistEntryPayload,
BulkResolveRiskFlagsPayload,
CreateTeamMemberPayload,
CreateSeasonPayload,
ReopenRejectedNominationPayload,
RejectNominationPayload,
ResolveRiskFlagPayload,
SetAwardResultPayload,
UpdateNominationTrackingReviewPayload,
UpdateSeasonSubcategoryTemplatesPayload,
TeamMemberPasswordResponse,
UpdateClipStatusPayload,
UpdateOptionalFeatureSettingsPayload,
@@ -32,11 +36,15 @@ import type {
UpdateNominationLinkBlacklistPayload,
UpdateSeasonPayload,
UpdateSiteSettingsPayload,
UpdateTrackingReviewNotesPayload,
UpdateTrackingRulesPayload,
UpdateTrackingSourcePayload,
UpdateTeamMemberPayload,
UpdateTeamRolesPayload,
UpdateWorkflowRulesPayload,
UpsertCandidatePayload,
UpsertCategoryPayload,
UpsertCategoryGroupPayload,
UpsertSponsorPayload,
} from '../../types/awards'
import { requestJson } from '../http'
@@ -93,9 +101,16 @@ export const adminApi = {
getAdminRiskRules: () => requestJson<AdminRiskRulesResponse>('/api/admin/risk-rules'),
updateAdminRiskRules: (payload: UpdateRiskRulesPayload) =>
requestJson<AdminRiskRulesResponse>('/api/admin/risk-rules', jsonRequest('PUT', payload)),
getAdminWorkflowRules: () => requestJson<AdminWorkflowRulesResponse>('/api/admin/workflow-rules'),
updateAdminWorkflowRules: (payload: UpdateWorkflowRulesPayload) =>
requestJson<AdminWorkflowRulesResponse>('/api/admin/workflow-rules', jsonRequest('PUT', payload)),
getAdminWorkflowRules: (seasonId: number) => requestJson<AdminWorkflowRulesResponse>(`/api/admin/seasons/${seasonId}/workflow-rules`),
updateAdminWorkflowRules: (seasonId: number, payload: UpdateWorkflowRulesPayload) =>
requestJson<AdminWorkflowRulesResponse>(`/api/admin/seasons/${seasonId}/workflow-rules`, jsonRequest('PUT', payload)),
getAdminTrackingRules: () => requestJson<AdminTrackingRulesResponse>('/api/admin/tracking-rules'),
updateAdminTrackingRules: (payload: UpdateTrackingRulesPayload) =>
requestJson<AdminTrackingRulesResponse>('/api/admin/tracking-rules', jsonRequest('PUT', payload)),
updateAdminTrackingSource: (payload: UpdateTrackingSourcePayload) =>
requestJson<AdminTrackingRulesResponse>('/api/admin/tracking-rules/source', jsonRequest('PUT', payload)),
updateAdminTrackingReviewNotes: (payload: UpdateTrackingReviewNotesPayload) =>
requestJson<AdminTrackingRulesResponse>('/api/admin/tracking-rules/notes', jsonRequest('PUT', payload)),
getAdminSeasons: () => requestJson<AdminSeasonListItem[]>('/api/admin/seasons'),
getAdminSeasonDetail: (seasonId: number) =>
requestJson<AdminSeasonDetailResponse>(`/api/admin/seasons/${seasonId}`),
@@ -157,6 +172,26 @@ export const adminApi = {
requestJson<{ deleted: boolean; categoryId: number }>(`/api/admin/categories/${categoryId}`, {
method: 'DELETE',
}),
updateAdminSeasonSubcategoryTemplates: (seasonId: number, payload: UpdateSeasonSubcategoryTemplatesPayload) =>
requestJson<{ saved: boolean; seasonId: number; templateCount: number }>(
`/api/admin/seasons/${seasonId}/subcategory-templates`,
jsonRequest('PUT', payload),
),
createAdminCategoryGroup: (seasonId: number, payload: UpsertCategoryGroupPayload) =>
requestJson<{ saved: boolean; seasonId: number; groupName: string }>(
`/api/admin/seasons/${seasonId}/category-groups`,
jsonRequest('POST', payload),
),
updateAdminCategoryGroup: (seasonId: number, groupName: string, payload: UpsertCategoryGroupPayload) =>
requestJson<{ saved: boolean; seasonId: number; groupName: string }>(
`/api/admin/seasons/${seasonId}/category-groups/${encodeURIComponent(groupName)}`,
jsonRequest('PUT', payload),
),
deleteAdminCategoryGroup: (seasonId: number, groupName: string) =>
requestJson<{ deleted: boolean; seasonId: number; groupName: string }>(
`/api/admin/seasons/${seasonId}/category-groups/${encodeURIComponent(groupName)}`,
{ method: 'DELETE' },
),
createAdminCandidate: (seasonId: number, payload: UpsertCandidatePayload) =>
requestJson<{ saved: boolean; candidateId: number }>(`/api/admin/seasons/${seasonId}/candidates`, jsonRequest('POST', payload)),
updateAdminCandidate: (candidateId: number, payload: UpsertCandidatePayload) =>
@@ -190,6 +225,16 @@ export const adminApi = {
`/api/admin/nominations/${nominationId}/reject`,
jsonRequest('POST', payload),
),
reopenRejectedAdminNomination: (nominationId: number, payload: ReopenRejectedNominationPayload = {}) =>
requestJson<{ saved: boolean; nominationId: number; reopened: boolean }>(
`/api/admin/nominations/${nominationId}/reopen`,
jsonRequest('POST', payload),
),
updateNominationTrackingReview: (nominationId: number, payload: UpdateNominationTrackingReviewPayload) =>
requestJson<{ saved: boolean; nominationId: number; status: string }>(
`/api/admin/nominations/${nominationId}/tracking-review`,
jsonRequest('POST', payload),
),
getAdminNominationLinkBlacklist: () =>
requestJson<AdminNominationLinkBlacklistResponse>('/api/admin/nominations/link-blacklist'),
updateAdminNominationLinkBlacklist: (payload: UpdateNominationLinkBlacklistPayload) =>
+1
View File
@@ -0,0 +1 @@
export const AUTH_TOKEN_KEY = 'vtsa-session-token'
+106
View File
@@ -0,0 +1,106 @@
import type { AuthStore } from '../stores/auth'
import { AUTH_TOKEN_KEY } from './authConstants'
const AUTH_ACTIVITY_KEY = 'vtsa-last-activity-at'
const AUTH_CLEARED_EVENT = 'vtsa-auth-cleared'
const ACTIVITY_PERSIST_INTERVAL_MS = 30_000
const IDLE_CHECK_INTERVAL_MS = 60_000
export function notifyAuthCleared() {
if (typeof window === 'undefined') return
window.dispatchEvent(new Event(AUTH_CLEARED_EVENT))
}
export function installAuthSessionManager(authStore: AuthStore) {
if (typeof window === 'undefined') {
return () => {}
}
let lastActivityAt = readLastActivityAt()
let lastPersistedAt = 0
const syncActivity = (timestamp = Date.now(), forcePersist = false) => {
lastActivityAt = timestamp
if (!authStore.isLoggedIn) {
return
}
if (forcePersist || timestamp - lastPersistedAt >= ACTIVITY_PERSIST_INTERVAL_MS) {
window.localStorage.setItem(AUTH_ACTIVITY_KEY, String(timestamp))
lastPersistedAt = timestamp
}
}
const clearSessionLocally = () => {
authStore.clearSession()
}
const checkIdleTimeout = async () => {
const timeoutHours = authStore.session?.sessionIdleTimeoutHours
if (!authStore.isLoggedIn || !timeoutHours) {
return
}
if (Date.now() - lastActivityAt < timeoutHours * 60 * 60 * 1000) {
return
}
await authStore.logout()
}
const onStorage = (event: StorageEvent) => {
if (event.key === AUTH_ACTIVITY_KEY && event.newValue) {
const timestamp = Number(event.newValue)
if (Number.isFinite(timestamp) && timestamp > lastActivityAt) {
lastActivityAt = timestamp
}
return
}
if (event.key === AUTH_TOKEN_KEY && !event.newValue) {
clearSessionLocally()
}
}
const onAuthCleared = () => {
clearSessionLocally()
}
const activityListener = () => {
syncActivity()
}
const activityEvents = ['pointerdown', 'keydown', 'scroll', 'touchstart']
activityEvents.forEach((eventName) => {
window.addEventListener(eventName, activityListener, { passive: true })
})
window.addEventListener('storage', onStorage)
window.addEventListener(AUTH_CLEARED_EVENT, onAuthCleared)
const intervalId = window.setInterval(() => {
void checkIdleTimeout()
}, IDLE_CHECK_INTERVAL_MS)
syncActivity(Date.now(), true)
return () => {
activityEvents.forEach((eventName) => {
window.removeEventListener(eventName, activityListener)
})
window.removeEventListener('storage', onStorage)
window.removeEventListener(AUTH_CLEARED_EVENT, onAuthCleared)
window.clearInterval(intervalId)
}
}
function readLastActivityAt() {
if (typeof window === 'undefined') {
return Date.now()
}
const storedValue = Number(window.localStorage.getItem(AUTH_ACTIVITY_KEY))
return Number.isFinite(storedValue) && storedValue > 0
? storedValue
: Date.now()
}
+4 -1
View File
@@ -1,6 +1,8 @@
import { notifyAuthCleared } from './authSessionManager'
import { AUTH_TOKEN_KEY } from './authConstants'
const API_URL = (import.meta.env.VITE_API_URL ?? '').replace(/\/$/, '')
const API_LABEL = API_URL || 'same-origin /api'
export const AUTH_TOKEN_KEY = 'vtsa-session-token'
export class ApiRequestError extends Error {
status: number | null
@@ -20,6 +22,7 @@ function getAuthToken() {
function clearAuthToken() {
if (typeof window === 'undefined') return
window.localStorage.removeItem(AUTH_TOKEN_KEY)
notifyAuthCleared()
}
async function parseError(response: Response, path: string) {
+69 -49
View File
@@ -6,6 +6,15 @@ export interface ReleaseNoteItem {
description: string
}
export interface ReleaseVersionEntry {
version: string
buildVersion?: string
buildDateLabel: string
releaseType: ReleaseChangeType
summary: string
notes: ReleaseNoteItem[]
}
export interface VersionRule {
pattern: string
label: string
@@ -34,55 +43,66 @@ export const versionRules: VersionRule[] = [
},
]
export const releaseNotes: ReleaseNoteItem[] = [
export const releaseVersions: ReleaseVersionEntry[] = [
{
type: 'feature',
title: 'Twitch Login ist live',
description: 'Login und Account-Verknuepfung laufen jetzt ueber den offiziellen Twitch OAuth Flow.',
},
{
type: 'feature',
title: 'Team-Accounts koennen Twitch verbinden',
description: 'Teammitglieder koennen ihren Account mit Twitch verknuepfen, wieder loesen und danach sauber ueber Twitch einsteigen.',
},
{
type: 'feature',
title: 'Team-Status aktualisiert sich automatisch',
description: 'In der Teamverwaltung siehst du regelmaessig, wer online ist. Die Ansicht aktualisiert sich alle 30 Sekunden.',
},
{
type: 'feature',
title: 'Moderation ist schneller bedienbar',
description: 'Reviewed Nominierungen und Risiko-Queues lassen sich angenehmer mit Pfeiltasten und Tastatur bedienen.',
},
{
type: 'feature',
title: 'Admin-Bereiche wurden aufgeraeumt',
description: 'Jahre, Landingpage, Risiko, Team und Analytics sind kompakter, klarer gruppiert und weniger erschlagend.',
},
{
type: 'feature',
title: 'User-Profil wurde repariert und ueberarbeitet',
description: 'Das Profil ist wieder ein echter Teil des Flows, inklusive Twitch-Verknuepfung und sauberem Account-Status.',
},
{
type: 'feature',
title: 'Besseres Feedback beim Bearbeiten',
description: 'Buttons haben Animationen, Speichern/Bearbeiten gibt sichtbarere Rueckmeldung und Modals fuehlen sich stabiler an.',
},
{
type: 'fix',
title: 'Dropdowns sehen wieder korrekt aus',
description: 'Das Dropdown-Design wurde stabilisiert und passt nun besser zum restlichen Admin-UI.',
},
{
type: 'fix',
title: 'Backend, Auth und Sicherheit wurden gehaertet',
description: 'Sessions, Team-Rechte, Audit-Logs, Public Writes, Vote-Eindeutigkeit und Runtime-Settings wurden robuster gemacht.',
},
{
type: 'fix',
title: 'Datenbank- und Deploy-Sicherheit verbessert',
description: 'Healthchecks, Migrationen, Seed-Verhalten und produktionsnahe Konfiguration sind klarer sichtbar und weniger fehleranfaellig.',
version: semanticVersion,
buildVersion,
buildDateLabel,
releaseType: 'feature',
summary: 'Feature-Release mit Twitch Login, Team-Verknuepfung, Admin-UX-Polish und Backend-Haertung.',
notes: [
{
type: 'feature',
title: 'Twitch Login ist live',
description: 'Login und Account-Verknuepfung laufen jetzt ueber den offiziellen Twitch OAuth Flow.',
},
{
type: 'feature',
title: 'Team-Accounts koennen Twitch verbinden',
description: 'Teammitglieder koennen ihren Account mit Twitch verknuepfen, wieder loesen und danach sauber ueber Twitch einsteigen.',
},
{
type: 'feature',
title: 'Team-Status aktualisiert sich automatisch',
description: 'In der Teamverwaltung siehst du regelmaessig, wer online ist. Die Ansicht aktualisiert sich alle 30 Sekunden.',
},
{
type: 'feature',
title: 'Moderation ist schneller bedienbar',
description: 'Reviewed Nominierungen und Risiko-Queues lassen sich angenehmer mit Pfeiltasten und Tastatur bedienen.',
},
{
type: 'feature',
title: 'Admin-Bereiche wurden aufgeraeumt',
description: 'Jahre, Landingpage, Risiko, Team und Analytics sind kompakter, klarer gruppiert und weniger erschlagend.',
},
{
type: 'feature',
title: 'User-Profil wurde repariert und ueberarbeitet',
description: 'Das Profil ist wieder ein echter Teil des Flows, inklusive Twitch-Verknuepfung und sauberem Account-Status.',
},
{
type: 'feature',
title: 'Besseres Feedback beim Bearbeiten',
description: 'Buttons haben Animationen, Speichern/Bearbeiten gibt sichtbarere Rueckmeldung und Modals fuehlen sich stabiler an.',
},
{
type: 'fix',
title: 'Dropdowns sehen wieder korrekt aus',
description: 'Das Dropdown-Design wurde stabilisiert und passt nun besser zum restlichen Admin-UI.',
},
{
type: 'fix',
title: 'Backend, Auth und Sicherheit wurden gehaertet',
description: 'Sessions, Team-Rechte, Audit-Logs, Public Writes, Vote-Eindeutigkeit und Runtime-Settings wurden robuster gemacht.',
},
{
type: 'fix',
title: 'Datenbank- und Deploy-Sicherheit verbessert',
description: 'Healthchecks, Migrationen, Seed-Verhalten und produktionsnahe Konfiguration sind klarer sichtbar und weniger fehleranfaellig.',
},
],
},
]
export const releaseNotes: ReleaseNoteItem[] = releaseVersions[0]?.notes ?? []