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:
@@ -10,5 +10,5 @@ export const api = {
|
||||
...systemApi,
|
||||
}
|
||||
|
||||
export { AUTH_TOKEN_KEY } from './http'
|
||||
export { AUTH_TOKEN_KEY } from './authConstants'
|
||||
export { ApiRequestError } from './http'
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export const AUTH_TOKEN_KEY = 'vtsa-session-token'
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 ?? []
|
||||
|
||||
Reference in New Issue
Block a user