diff --git a/src/App.tsx b/src/App.tsx index 648f747..f7eb726 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -21,6 +21,7 @@ import CompetitionPerson from './pages/Competition/Person'; import CompetitionPersonalBests from './pages/Competition/Person/PersonalBests'; import { PsychSheetEvent } from './pages/Competition/PsychSheet/PsychSheetEvent'; import CompetitionRemote from './pages/Competition/Remote'; +import CompetitionRemoteWebhooks from './pages/Competition/Remote/Webhooks'; import CompetitionResults from './pages/Competition/Results'; import { CompetitionActivity, @@ -152,10 +153,12 @@ const Navigation = () => { } /> } /> + } /> } /> } /> } /> } /> + } /> } /> } /> } /> diff --git a/src/containers/CompetitionRound/CompetitionRound.test.tsx b/src/containers/CompetitionRound/CompetitionRound.test.tsx index 7e627dc..b949b53 100644 --- a/src/containers/CompetitionRound/CompetitionRound.test.tsx +++ b/src/containers/CompetitionRound/CompetitionRound.test.tsx @@ -57,6 +57,10 @@ jest.mock('react-i18next', () => ({ return `Round ${options?.roundNumber}`; } + if (key === 'common.activityCodeToName.group') { + return `Group ${options?.groupNumber}`; + } + return key; }, }), @@ -89,7 +93,15 @@ const linkedRoundsCompetition = { cutoff: null, timeLimit: null, advancementCondition: null, - results: [], + results: [ + { + personId: 1, + ranking: 1, + attempts: [], + best: 1000, + average: 1200, + }, + ], }, { id: '333-r3', @@ -195,9 +207,29 @@ describe('CompetitionRoundContainer', () => { it('links to results for the selected round', () => { renderRound('333-r2'); - expect(screen.getByText('See Results')).toHaveAttribute( - 'href', - '/competitions/TestComp2026/results/333-r2', + const resultsLink = screen.getByText('See Results'); + + expect(resultsLink).toHaveAttribute('href', '/competitions/TestComp2026/results/333-r2'); + expect(screen.getByText('Group 1').compareDocumentPosition(resultsLink)).toBe( + Node.DOCUMENT_POSITION_FOLLOWING, ); }); + + it('uses shared button spacing for the back link', () => { + renderRound('333-r2'); + + expect(screen.getByText('Back To Events')).toHaveClass('btn', 'btn-block'); + }); + + it('shows when the selected round has results', () => { + renderRound('333-r2'); + + expect(screen.getByText('See Results')).toHaveClass('btn-green'); + }); + + it('shows a neutral results link when the selected round has no results yet', () => { + renderRound('333-r3'); + + expect(screen.getByText('See Results')).toHaveClass('btn-light'); + }); }); diff --git a/src/containers/CompetitionRound/CompetitionRound.tsx b/src/containers/CompetitionRound/CompetitionRound.tsx index 16e5201..eb3c87e 100644 --- a/src/containers/CompetitionRound/CompetitionRound.tsx +++ b/src/containers/CompetitionRound/CompetitionRound.tsx @@ -60,6 +60,7 @@ export function CompetitionRoundContainer({ const rounds = roundActivies.filter((ra) => toRoundAttemptId(ra.activityCode) === roundId); const groups = rounds.flatMap((r) => r.childActivities); const uniqueGroupCodes = [...new Set(groups.map((g) => g.activityCode))]; + const hasResults = (round?.results.length ?? 0) > 0; return ( @@ -79,14 +80,6 @@ export function CompetitionRoundContainer({

)} {round && } - {round && ( - - )}
    @@ -111,12 +104,22 @@ export function CompetitionRoundContainer({ ); })}
+ {round && ( +
+ +
+ )}
- - {t('competition.groups.backToEvents')} - + title={t('competition.groups.backToEvents')} + LinkComponent={LinkComponent} + />
); diff --git a/src/containers/MyCompetitions/MyCompetitions.query.ts b/src/containers/MyCompetitions/MyCompetitions.query.ts index 1be874c..1932a9b 100644 --- a/src/containers/MyCompetitions/MyCompetitions.query.ts +++ b/src/containers/MyCompetitions/MyCompetitions.query.ts @@ -25,12 +25,18 @@ export const useMyCompetitionsQuery = (userId?: number, options: { enabled?: boo return undefined; } - const upcoming_competitions = JSON.parse( - getLocalStorage('my.upcoming_competitions') || '[]', - ) as ApiCompetition[]; - const ongoing_competitions = JSON.parse( - getLocalStorage('my.ongoing_competitions') || '[]', - ) as ApiCompetition[]; + const rawUpcomingCompetitions = getLocalStorage('my.upcoming_competitions'); + const rawOngoingCompetitions = getLocalStorage('my.ongoing_competitions'); + if (!rawUpcomingCompetitions && !rawOngoingCompetitions) { + return undefined; + } + + const upcoming_competitions = JSON.parse(rawUpcomingCompetitions || '[]') as ApiCompetition[]; + const ongoing_competitions = JSON.parse(rawOngoingCompetitions || '[]') as ApiCompetition[]; + + if (!upcoming_competitions.length && !ongoing_competitions.length) { + return undefined; + } return { user: user, upcoming_competitions, ongoing_competitions }; }, diff --git a/src/hooks/useNotifyCompRemoteWebhooks/index.ts b/src/hooks/useNotifyCompRemoteWebhooks/index.ts new file mode 100644 index 0000000..66cb51e --- /dev/null +++ b/src/hooks/useNotifyCompRemoteWebhooks/index.ts @@ -0,0 +1 @@ +export { useNotifyCompRemoteWebhooks } from './useNotifyCompRemoteWebhooks'; diff --git a/src/hooks/useNotifyCompRemoteWebhooks/useNotifyCompRemoteWebhooks.test.tsx b/src/hooks/useNotifyCompRemoteWebhooks/useNotifyCompRemoteWebhooks.test.tsx new file mode 100644 index 0000000..6d8c5ab --- /dev/null +++ b/src/hooks/useNotifyCompRemoteWebhooks/useNotifyCompRemoteWebhooks.test.tsx @@ -0,0 +1,224 @@ +import { MockedProvider, MockedResponse } from '@apollo/client/testing'; +import { act, renderHook, waitFor } from '@testing-library/react'; +import { PropsWithChildren } from 'react'; +import { + CreateRemoteWebhookDocument, + DeleteRemoteWebhookDocument, + RemoteWebhooksDocument, + TestEditingRemoteWebhookDocument, + TestRemoteWebhookDocument, + UpdateRemoteWebhookDocument, +} from '@/lib/notifyCompRemoteGraphql'; +import { useNotifyCompRemoteWebhooks } from './useNotifyCompRemoteWebhooks'; + +const competitionId = 'ExampleComp2026'; + +const webhooksMock = (webhooks: unknown[]): MockedResponse => ({ + request: { + query: RemoteWebhooksDocument, + variables: { competitionId }, + }, + result: { + data: { + competition: { + __typename: 'Competition', + id: competitionId, + webhooks, + }, + }, + }, +}); + +const createWrapper = (mocks: MockedResponse[]) => + function MockedApolloWrapper({ children }: PropsWithChildren) { + return {children}; + }; + +describe('useNotifyCompRemoteWebhooks', () => { + it('loads competition webhooks', async () => { + const { result } = renderHook(() => useNotifyCompRemoteWebhooks({ competitionId }), { + wrapper: createWrapper([ + webhooksMock([ + { + __typename: 'Webhook', + id: 1, + method: 'POST', + url: 'https://example.com/notify', + }, + ]), + ]), + }); + + await waitFor(() => { + expect(result.current.webhooks).toHaveLength(1); + }); + + expect(result.current.webhooks[0]).toMatchObject({ + method: 'POST', + url: 'https://example.com/notify', + }); + }); + + it('creates, updates, and deletes webhooks with NotifyComp variables', async () => { + const createVariables = { + competitionId, + webhook: { + method: 'POST' as const, + url: 'https://example.com/created', + }, + }; + const updateVariables = { + id: 4, + webhook: { + method: 'GET' as const, + url: 'https://example.com/updated', + }, + }; + + const { result } = renderHook(() => useNotifyCompRemoteWebhooks({ competitionId }), { + wrapper: createWrapper([ + webhooksMock([]), + { + request: { + query: CreateRemoteWebhookDocument, + variables: createVariables, + }, + result: { + data: { + createWebhook: { + __typename: 'Webhook', + id: 4, + ...createVariables.webhook, + }, + }, + }, + }, + webhooksMock([ + { + __typename: 'Webhook', + id: 4, + ...createVariables.webhook, + }, + ]), + { + request: { + query: UpdateRemoteWebhookDocument, + variables: updateVariables, + }, + result: { + data: { + updateWebhook: { + __typename: 'Webhook', + id: 4, + ...updateVariables.webhook, + }, + }, + }, + }, + webhooksMock([ + { + __typename: 'Webhook', + id: 4, + ...updateVariables.webhook, + }, + ]), + { + request: { + query: DeleteRemoteWebhookDocument, + variables: { id: 4 }, + }, + result: { + data: { + deleteWebhook: null, + }, + }, + }, + webhooksMock([]), + ]), + }); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + await act(async () => { + await result.current.saveWebhook(createVariables.webhook); + }); + await act(async () => { + await result.current.saveWebhook(updateVariables.webhook, 4); + }); + await act(async () => { + await result.current.removeWebhook(4); + }); + + expect(result.current.error).toBeNull(); + }); + + it('tests saved and unsaved webhook settings', async () => { + const webhook = { + method: 'PUT' as const, + url: 'https://example.com/test', + }; + const { result } = renderHook(() => useNotifyCompRemoteWebhooks({ competitionId }), { + wrapper: createWrapper([ + webhooksMock([]), + { + request: { + query: TestRemoteWebhookDocument, + variables: { id: 8 }, + }, + result: { + data: { + testWebhook: { + __typename: 'WebhookResponse', + body: 'ok', + status: 200, + statusText: 'OK', + url: webhook.url, + }, + }, + }, + }, + { + request: { + query: TestEditingRemoteWebhookDocument, + variables: { competitionId, webhook }, + }, + result: { + data: { + testEditingWebhook: { + __typename: 'WebhookResponse', + body: 'failed', + status: 500, + statusText: 'Server Error', + url: webhook.url, + }, + }, + }, + }, + ]), + }); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + await act(async () => { + await result.current.testSavedWebhook(8); + }); + + expect(result.current.testResult).toMatchObject({ + status: 200, + statusText: 'OK', + }); + + await act(async () => { + await result.current.testWebhookSettings(webhook); + }); + + expect(result.current.testResult).toMatchObject({ + status: 500, + statusText: 'Server Error', + }); + }); +}); diff --git a/src/hooks/useNotifyCompRemoteWebhooks/useNotifyCompRemoteWebhooks.ts b/src/hooks/useNotifyCompRemoteWebhooks/useNotifyCompRemoteWebhooks.ts new file mode 100644 index 0000000..72f96b1 --- /dev/null +++ b/src/hooks/useNotifyCompRemoteWebhooks/useNotifyCompRemoteWebhooks.ts @@ -0,0 +1,133 @@ +import { useMutation, useQuery } from '@apollo/client'; +import { useMemo, useState } from 'react'; +import { + CreateRemoteWebhookDocument, + DeleteRemoteWebhookDocument, + NotifyCompWebhook, + NotifyCompWebhookInput, + NotifyCompWebhookResponse, + RemoteWebhooksDocument, + TestEditingRemoteWebhookDocument, + TestRemoteWebhookDocument, + UpdateRemoteWebhookDocument, +} from '@/lib/notifyCompRemoteGraphql'; + +interface UseNotifyCompRemoteWebhooksParams { + competitionId: string; + enabled?: boolean; +} + +interface RemoteWebhooksQueryData { + competition: { + id: string; + webhooks: NotifyCompWebhook[]; + } | null; +} + +export function useNotifyCompRemoteWebhooks({ + competitionId, + enabled = true, +}: UseNotifyCompRemoteWebhooksParams) { + const [mutationError, setMutationError] = useState(null); + const [testResult, setTestResult] = useState(null); + + const webhooksQuery = useQuery(RemoteWebhooksDocument, { + variables: { competitionId }, + skip: !competitionId || !enabled, + }); + + const refetchQueries = useMemo( + () => [ + { + query: RemoteWebhooksDocument, + variables: { competitionId }, + }, + ], + [competitionId], + ); + + const mutationOptions = { + refetchQueries, + }; + + const [createWebhook, createWebhookStatus] = useMutation< + { createWebhook: NotifyCompWebhook }, + { competitionId: string; webhook: NotifyCompWebhookInput } + >(CreateRemoteWebhookDocument, mutationOptions); + const [updateWebhook, updateWebhookStatus] = useMutation< + { updateWebhook: NotifyCompWebhook }, + { id: number; webhook: NotifyCompWebhookInput } + >(UpdateRemoteWebhookDocument, mutationOptions); + const [deleteWebhook, deleteWebhookStatus] = useMutation< + { deleteWebhook?: null }, + { id: number } + >(DeleteRemoteWebhookDocument, mutationOptions); + const [testWebhook, testWebhookStatus] = useMutation< + { testWebhook: NotifyCompWebhookResponse | null }, + { id: number } + >(TestRemoteWebhookDocument); + const [testEditingWebhook, testEditingWebhookStatus] = useMutation< + { testEditingWebhook: NotifyCompWebhookResponse | null }, + { competitionId: string; webhook: NotifyCompWebhookInput } + >(TestEditingRemoteWebhookDocument); + + const runMutation = async (operation: () => Promise) => { + setMutationError(null); + setTestResult(null); + + try { + await operation(); + } catch (err) { + setMutationError(err instanceof Error ? err.message : 'Webhook operation failed.'); + throw err; + } + }; + + const saveWebhook = (webhook: NotifyCompWebhookInput, id?: number) => + runMutation(() => + id + ? updateWebhook({ + variables: { id, webhook }, + }) + : createWebhook({ + variables: { competitionId, webhook }, + }), + ); + + const removeWebhook = (id: number) => + runMutation(() => + deleteWebhook({ + variables: { id }, + }), + ); + + const testSavedWebhook = (id: number) => + runMutation(async () => { + const result = await testWebhook({ + variables: { id }, + }); + setTestResult(result.data?.testWebhook || null); + }); + + const testWebhookSettings = (webhook: NotifyCompWebhookInput) => + runMutation(async () => { + const result = await testEditingWebhook({ + variables: { competitionId, webhook }, + }); + setTestResult(result.data?.testEditingWebhook || null); + }); + + return { + error: mutationError || webhooksQuery.error?.message || null, + isLoading: webhooksQuery.loading, + isSaving: + createWebhookStatus.loading || updateWebhookStatus.loading || deleteWebhookStatus.loading, + isTesting: testWebhookStatus.loading || testEditingWebhookStatus.loading, + removeWebhook, + saveWebhook, + testResult, + testSavedWebhook, + testWebhookSettings, + webhooks: webhooksQuery.data?.competition?.webhooks || [], + }; +} diff --git a/src/layouts/CompetitionLayout/CompetitionLayout.tabs.tsx b/src/layouts/CompetitionLayout/CompetitionLayout.tabs.tsx index 7e66508..22db621 100644 --- a/src/layouts/CompetitionLayout/CompetitionLayout.tabs.tsx +++ b/src/layouts/CompetitionLayout/CompetitionLayout.tabs.tsx @@ -71,12 +71,20 @@ export const useCompetitionLayoutTabs = ({ competitionId, wcif }: CompetitionLay } if (canManageRemote) { - _adminTabs.push({ - href: `/competitions/${competitionId}/admin/remote`, - text: t('header.tabs.remote', { - defaultValue: 'Remote', - }), - }); + _adminTabs.push( + { + href: `/competitions/${competitionId}/admin/remote`, + text: t('header.tabs.remote', { + defaultValue: 'Remote', + }), + }, + { + href: `/competitions/${competitionId}/admin/webhooks`, + text: t('header.tabs.webhooks', { + defaultValue: 'Webhooks', + }), + }, + ); } if (hasStream) { diff --git a/src/lib/notifications/assignmentNotifications.ts b/src/lib/notifications/assignmentNotifications.ts index e97cb13..ebfe566 100644 --- a/src/lib/notifications/assignmentNotifications.ts +++ b/src/lib/notifications/assignmentNotifications.ts @@ -1,5 +1,10 @@ import { deleteLocalStorage, getLocalStorage, setLocalStorage } from '@/lib/localStorage'; import { getStoredWcaAccessToken } from '@/lib/wcaAccessToken'; +import { + clearNotifyCompPushSessionToken, + getNotifyCompPushSessionToken, + setNotifyCompPushSessionToken, +} from './notifyCompPushSession'; const NOTIFY_COMP_ORIGIN = import.meta.env.VITE_NOTIFY_COMP_ORIGIN ?? 'https://api.notifycomp.com/api'; @@ -15,6 +20,17 @@ interface PushSubscriptionJson { }; } +interface PushSessionResponse { + sessionToken?: string; + token?: string; +} + +interface PushSubscriptionPayload { + endpoint: string; + p256dh: string; + auth: string; +} + export interface AssignmentNotificationWatch { competitionId: string; wcaUserId: number; @@ -25,7 +41,7 @@ export type AssignmentNotificationStatus = NotificationPermission | 'reauthorize const notifyCompUrl = (path: string) => `${NOTIFY_COMP_ORIGIN}${path}`; export const isAssignmentNotificationsEnabled = () => - getLocalStorage(ENABLED_STORAGE_KEY) === 'true'; + Boolean(getNotifyCompPushSessionToken()) || getLocalStorage(ENABLED_STORAGE_KEY) === 'true'; const toUint8Array = (base64: string) => { const padding = '='.repeat((4 - (base64.length % 4)) % 4); @@ -49,7 +65,7 @@ export const getAssignmentNotificationStatus = (): AssignmentNotificationStatus return 'unsupported'; } - if (!getStoredWcaAccessToken()) { + if (!getNotifyCompPushSessionToken() && !getStoredWcaAccessToken()) { return 'reauthorize'; } @@ -105,6 +121,9 @@ const fetchNotifyCompToken = async () => { return payload.token; }; +const canFallbackToLegacySubscriptions = (response: Response) => + response.status === 404 || response.status === 405; + const fetchVapidPublicKey = async () => { const response = await fetch(notifyCompUrl('/v0/external/push/vapid-public-key')); @@ -153,30 +172,33 @@ const getPushSubscription = async () => { }); }; -export const enableAssignmentNotifications = async (watches: AssignmentNotificationWatch[]) => { - const permission = await requestAssignmentNotificationPermission(); - if (permission !== 'granted') { - return permission; - } - - const token = await fetchNotifyCompToken(); - const subscription = await getPushSubscription(); +const pushSubscriptionPayload = (subscription: PushSubscription): PushSubscriptionPayload => { const payload = subscription.toJSON() as PushSubscriptionJson; if (!payload.endpoint || !payload.keys?.p256dh || !payload.keys.auth) { throw new Error('Browser push subscription is missing required keys.'); } + return { + auth: payload.keys.auth, + endpoint: payload.endpoint, + p256dh: payload.keys.p256dh, + }; +}; + +const registerLegacySubscription = async ( + authToken: string, + payload: PushSubscriptionPayload, + watches: AssignmentNotificationWatch[], +) => { const response = await fetch(notifyCompUrl('/v0/external/push/subscriptions'), { method: 'POST', headers: { - Authorization: `Bearer ${token}`, + Authorization: `Bearer ${authToken}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ - endpoint: payload.endpoint, - p256dh: payload.keys.p256dh, - auth: payload.keys.auth, + ...payload, watches, }), }); @@ -185,34 +207,150 @@ export const enableAssignmentNotifications = async (watches: AssignmentNotificat deleteLocalStorage(ENABLED_STORAGE_KEY); throw new Error(await readErrorMessage(response)); } +}; + +const createPushSession = async ( + authToken: string, + payload: PushSubscriptionPayload, + watches: AssignmentNotificationWatch[], +) => { + const response = await fetch(notifyCompUrl('/v0/external/push/sessions'), { + method: 'POST', + headers: { + Authorization: `Bearer ${authToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + ...payload, + watches, + }), + }); + + if (canFallbackToLegacySubscriptions(response)) { + await registerLegacySubscription(authToken, payload, watches); + return; + } + + if (!response.ok) { + clearNotifyCompPushSessionToken(); + deleteLocalStorage(ENABLED_STORAGE_KEY); + throw new Error(await readErrorMessage(response)); + } + + const session = (await response.json()) as PushSessionResponse; + const sessionToken = session.sessionToken || session.token; + if (!sessionToken) { + clearNotifyCompPushSessionToken(); + deleteLocalStorage(ENABLED_STORAGE_KEY); + throw new Error('NotifyComp push session response was missing a session token.'); + } + + setNotifyCompPushSessionToken(sessionToken); +}; + +const updatePushSession = async ( + sessionToken: string, + payload: PushSubscriptionPayload, + watches: AssignmentNotificationWatch[], +) => { + const response = await fetch(notifyCompUrl('/v0/external/push/sessions/current'), { + method: 'PUT', + headers: { + Authorization: `Bearer ${sessionToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + ...payload, + watches, + }), + }); + + if (response.status === 401 || response.status === 403) { + clearNotifyCompPushSessionToken(); + return false; + } + + if (!response.ok) { + throw new Error(await readErrorMessage(response)); + } + + return true; +}; + +export const enableAssignmentNotifications = async (watches: AssignmentNotificationWatch[]) => { + const permission = await requestAssignmentNotificationPermission(); + if (permission !== 'granted') { + return permission; + } + + const subscription = await getPushSubscription(); + const payload = pushSubscriptionPayload(subscription); + const sessionToken = getNotifyCompPushSessionToken(); + + if (sessionToken && (await updatePushSession(sessionToken, payload, watches))) { + setLocalStorage(ENABLED_STORAGE_KEY, 'true'); + return permission; + } + + await createPushSession(await fetchNotifyCompToken(), payload, watches); setLocalStorage(ENABLED_STORAGE_KEY, 'true'); return permission; }; +const deletePushSession = async (sessionToken: string, endpoint?: string) => { + const response = await fetch(notifyCompUrl('/v0/external/push/sessions/current'), { + method: 'DELETE', + headers: { + Authorization: `Bearer ${sessionToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ endpoint }), + }); + + if ( + !response.ok && + response.status !== 401 && + response.status !== 403 && + response.status !== 404 + ) { + throw new Error(await readErrorMessage(response)); + } +}; + +const deleteLegacySubscription = async (endpoint: string) => { + const token = await fetchNotifyCompToken(); + + await fetch(notifyCompUrl('/v0/external/push/subscriptions'), { + method: 'DELETE', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ endpoint }), + }); +}; + export const disableAssignmentNotifications = async () => { const registration = await getServiceWorkerRegistration(); const subscription = await registration.pushManager.getSubscription(); + const sessionToken = getNotifyCompPushSessionToken(); if (!subscription) { + clearNotifyCompPushSessionToken(); deleteLocalStorage(ENABLED_STORAGE_KEY); return; } - const token = await fetchNotifyCompToken(); const payload = subscription.toJSON() as PushSubscriptionJson; - if (payload.endpoint) { - await fetch(notifyCompUrl('/v0/external/push/subscriptions'), { - method: 'DELETE', - headers: { - Authorization: `Bearer ${token}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ endpoint: payload.endpoint }), - }); + if (sessionToken) { + await deletePushSession(sessionToken, payload.endpoint); + } else if (payload.endpoint) { + await deleteLegacySubscription(payload.endpoint); } await subscription.unsubscribe(); + clearNotifyCompPushSessionToken(); deleteLocalStorage(ENABLED_STORAGE_KEY); }; diff --git a/src/lib/notifications/notifyCompPushSession.test.ts b/src/lib/notifications/notifyCompPushSession.test.ts new file mode 100644 index 0000000..6c01dc4 --- /dev/null +++ b/src/lib/notifications/notifyCompPushSession.test.ts @@ -0,0 +1,53 @@ +import { + clearNotifyCompPushSessionToken, + getNotifyCompPushSessionToken, + setNotifyCompPushSessionToken, +} from './notifyCompPushSession'; + +jest.mock('@/lib/localStorage', () => ({ + deleteLocalStorage: (key: string) => localStorage.removeItem(key), + getLocalStorage: (key: string) => localStorage.getItem(key), + setLocalStorage: (key: string, value: string) => localStorage.setItem(key, value), +})); + +const base64Url = (value: unknown) => + Buffer.from(JSON.stringify(value)).toString('base64url').replace(/=/g, ''); + +const jwtWithClaims = (claims: Record) => + `${base64Url({ alg: 'HS256', typ: 'JWT' })}.${base64Url(claims)}.signature`; + +describe('notifyCompPushSession', () => { + beforeEach(() => { + localStorage.clear(); + }); + + afterEach(() => { + clearNotifyCompPushSessionToken(); + }); + + it('stores opaque NotifyComp push session tokens', () => { + setNotifyCompPushSessionToken('opaque-session-token'); + + expect(getNotifyCompPushSessionToken()).toBe('opaque-session-token'); + }); + + it('reads unpadded JWT push session tokens', () => { + const token = jwtWithClaims({ + exp: Math.floor(Date.now() / 1000) + 60, + }); + + setNotifyCompPushSessionToken(token); + + expect(getNotifyCompPushSessionToken()).toBe(token); + }); + + it('clears expired JWT push session tokens', () => { + setNotifyCompPushSessionToken( + jwtWithClaims({ + exp: Math.floor(Date.now() / 1000) - 60, + }), + ); + + expect(getNotifyCompPushSessionToken()).toBe(null); + }); +}); diff --git a/src/lib/notifications/notifyCompPushSession.ts b/src/lib/notifications/notifyCompPushSession.ts new file mode 100644 index 0000000..95f2e07 --- /dev/null +++ b/src/lib/notifications/notifyCompPushSession.ts @@ -0,0 +1,45 @@ +import { deleteLocalStorage, getLocalStorage, setLocalStorage } from '@/lib/localStorage'; + +const PUSH_SESSION_TOKEN_KEY = 'notifyComp.pushSessionToken'; + +interface JwtClaims { + exp?: number; +} + +const decodeJwtPayload = (token: string): JwtClaims | null => { + try { + const payload = token.split('.')[1]; + if (!payload) { + return null; + } + + const base64 = payload.replace(/-/g, '+').replace(/_/g, '/'); + const normalized = base64.padEnd(base64.length + ((4 - (base64.length % 4)) % 4), '='); + return JSON.parse(window.atob(normalized)) as JwtClaims; + } catch { + return null; + } +}; + +export const getNotifyCompPushSessionToken = () => { + const token = getLocalStorage(PUSH_SESSION_TOKEN_KEY); + if (!token) { + return null; + } + + const claims = decodeJwtPayload(token); + if (claims?.exp && claims.exp * 1000 <= Date.now()) { + deleteLocalStorage(PUSH_SESSION_TOKEN_KEY); + return null; + } + + return token; +}; + +export const setNotifyCompPushSessionToken = (token: string) => { + setLocalStorage(PUSH_SESSION_TOKEN_KEY, token); +}; + +export const clearNotifyCompPushSessionToken = () => { + deleteLocalStorage(PUSH_SESSION_TOKEN_KEY); +}; diff --git a/src/lib/notifyCompRemoteGraphql.ts b/src/lib/notifyCompRemoteGraphql.ts index ddaf9ce..1cb945b 100644 --- a/src/lib/notifyCompRemoteGraphql.ts +++ b/src/lib/notifyCompRemoteGraphql.ts @@ -14,6 +14,26 @@ export interface NotifyCompCompetition { autoAdvanceDelay?: number | null; } +export type HttpMethod = 'DELETE' | 'GET' | 'PATCH' | 'POST' | 'PUT'; + +export interface NotifyCompWebhook { + id: number; + method: HttpMethod; + url: string; +} + +export interface NotifyCompWebhookInput { + method: HttpMethod; + url: string; +} + +export interface NotifyCompWebhookResponse { + body?: string | null; + status: number; + statusText: string; + url: string; +} + export const RemoteActivityFragment = gql` fragment RemoteActivityFragment on Activity { activityId @@ -34,6 +54,23 @@ export const RemoteCompetitionDocument = gql` } `; +export const RemoteWebhookFragment = gql` + fragment RemoteWebhookFragment on Webhook { + id + url + method + } +`; + +export const RemoteWebhookResponseFragment = gql` + fragment RemoteWebhookResponseFragment on WebhookResponse { + url + status + statusText + body + } +`; + export const RemoteActivitiesDocument = gql` query RemoteActivities($competitionId: String!, $roomId: Int) { activities(competitionId: $competitionId, roomId: $roomId) { @@ -43,6 +80,18 @@ export const RemoteActivitiesDocument = gql` ${RemoteActivityFragment} `; +export const RemoteWebhooksDocument = gql` + query RemoteWebhooks($competitionId: String!) { + competition(competitionId: $competitionId) { + id + webhooks { + ...RemoteWebhookFragment + } + } + } + ${RemoteWebhookFragment} +`; + export const RemoteActivitiesSubscriptionDocument = gql` subscription RemoteActivities($competitionIds: [String!]!) { activity: activityUpdated(competitionIds: $competitionIds) { @@ -52,6 +101,48 @@ export const RemoteActivitiesSubscriptionDocument = gql` ${RemoteActivityFragment} `; +export const CreateRemoteWebhookDocument = gql` + mutation CreateRemoteWebhook($competitionId: String!, $webhook: WebhookInput!) { + createWebhook(competitionId: $competitionId, webhook: $webhook) { + ...RemoteWebhookFragment + } + } + ${RemoteWebhookFragment} +`; + +export const UpdateRemoteWebhookDocument = gql` + mutation UpdateRemoteWebhook($id: Int!, $webhook: WebhookInput!) { + updateWebhook(id: $id, webhook: $webhook) { + ...RemoteWebhookFragment + } + } + ${RemoteWebhookFragment} +`; + +export const DeleteRemoteWebhookDocument = gql` + mutation DeleteRemoteWebhook($id: Int!) { + deleteWebhook(id: $id) + } +`; + +export const TestRemoteWebhookDocument = gql` + mutation TestRemoteWebhook($id: Int!) { + testWebhook(id: $id) { + ...RemoteWebhookResponseFragment + } + } + ${RemoteWebhookResponseFragment} +`; + +export const TestEditingRemoteWebhookDocument = gql` + mutation TestEditingRemoteWebhook($competitionId: String!, $webhook: WebhookInput!) { + testEditingWebhook(competitionId: $competitionId, webhook: $webhook) { + ...RemoteWebhookResponseFragment + } + } + ${RemoteWebhookResponseFragment} +`; + export const ImportRemoteCompetitionDocument = gql` mutation ImportRemoteCompetition($competitionId: String!) { importCompetition(competitionId: $competitionId) { diff --git a/src/pages/Competition/Admin/index.stories.tsx b/src/pages/Competition/Admin/index.stories.tsx new file mode 100644 index 0000000..7d0b93c --- /dev/null +++ b/src/pages/Competition/Admin/index.stories.tsx @@ -0,0 +1,56 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { storybookCompetitionFixture } from '@/storybook/competitionFixtures'; +import { makeCompetitionContainerDecorator } from '@/storybook/competitionStorybook'; +import CompetitionAdmin from './index'; + +const adminUser: User = { + id: 9001, + name: 'Nick Silvestri', + email: '', + wca_id: '2016SILV08', + avatar: { + url: 'https://avatars.worldcubeassociation.org/nsg38gkpoch8xiji3hodmrs672m4', + thumb_url: 'https://avatars.worldcubeassociation.org/uge6fzvlpmz6c8ztn8ey5wi4i8uf', + }, + delegate_status: 'delegate', +}; + +const nonAdminUser: User = { + id: 1005, + name: 'Eva Park', + email: '', + wca_id: '2026PARK05', + avatar: { + url: '', + thumb_url: '', + }, + delegate_status: '', +}; + +const meta = { + title: 'Pages/Competition/Admin', + component: CompetitionAdmin, + decorators: [makeCompetitionContainerDecorator()], + parameters: { + layout: 'padded', + }, + tags: ['autodocs'], +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const AvailableTools: Story = { + parameters: { + competition: storybookCompetitionFixture, + currentUser: adminUser, + }, +}; + +export const NoAvailableTools: Story = { + parameters: { + competition: storybookCompetitionFixture, + currentUser: nonAdminUser, + }, +}; diff --git a/src/pages/Competition/Admin/index.tsx b/src/pages/Competition/Admin/index.tsx index 32bcb24..4b336ee 100644 --- a/src/pages/Competition/Admin/index.tsx +++ b/src/pages/Competition/Admin/index.tsx @@ -7,6 +7,7 @@ import { useWCIF } from '@/providers/WCIFProvider'; const adminDescriptions: Record = { scramblers: 'View scrambler assignments by event and round.', remote: 'Control Live Activities updates for the competition.', + webhooks: 'Send Live Activities updates to external services.', 'sum-of-ranks': 'Review competitor rankings across completed rounds.', stats: 'View competition registration and event counts.', }; diff --git a/src/pages/Competition/Remote/RemoteAutoAdvanceToggle.stories.tsx b/src/pages/Competition/Remote/RemoteAutoAdvanceToggle.stories.tsx new file mode 100644 index 0000000..820d140 --- /dev/null +++ b/src/pages/Competition/Remote/RemoteAutoAdvanceToggle.stories.tsx @@ -0,0 +1,66 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { useState } from 'react'; +import { RemoteAutoAdvanceToggle } from './RemoteAutoAdvanceToggle'; + +interface AutoAdvanceStoryArgs { + checked: boolean; + disabled: boolean; +} + +function AutoAdvanceStory({ checked, disabled }: AutoAdvanceStoryArgs) { + const [isChecked, setIsChecked] = useState(checked); + + return ( +
+
+
Auto-advance
+
+ Automatically moves Live Activities to the next scheduled group when the current group + ends. +
+
+
+ { + setIsChecked((currentValue) => !currentValue); + }} + /> +
+
+ ); +} + +const meta = { + title: 'Pages/Competition/Remote/Auto Advance Toggle', + component: AutoAdvanceStory, + render: (args) => , + args: { + checked: false, + disabled: false, + }, + parameters: { + layout: 'centered', + }, + tags: ['autodocs'], +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Off: Story = {}; + +export const On: Story = { + args: { + checked: true, + }, +}; + +export const Disabled: Story = { + args: { + checked: true, + disabled: true, + }, +}; diff --git a/src/pages/Competition/Remote/RemoteWebhookSetup.stories.tsx b/src/pages/Competition/Remote/RemoteWebhookSetup.stories.tsx new file mode 100644 index 0000000..317c63e --- /dev/null +++ b/src/pages/Competition/Remote/RemoteWebhookSetup.stories.tsx @@ -0,0 +1,157 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { useState } from 'react'; +import { ConfirmProvider } from '@/providers/ConfirmProvider'; +import { RemoteWebhookSetupController, RemoteWebhookSetupPanel } from './RemoteWebhookSetup'; + +type StoryWebhook = RemoteWebhookSetupController['webhooks'][number]; +type StoryTestResult = RemoteWebhookSetupController['testResult']; + +interface WebhookSetupStoryArgs { + disabled: boolean; + error: string | null; + initialTestResult: StoryTestResult; + initialWebhooks: StoryWebhook[]; + isLoading: boolean; +} + +const savedWebhooks: StoryWebhook[] = [ + { + id: 1, + method: 'POST', + url: 'https://hooks.example.com/live-activities', + }, + { + id: 2, + method: 'GET', + url: 'https://status.example.com/ping', + }, +]; + +function WebhookSetupStory({ + disabled, + error, + initialTestResult, + initialWebhooks, + isLoading, +}: WebhookSetupStoryArgs) { + const [webhooks, setWebhooks] = useState(initialWebhooks); + const [testResult, setTestResult] = useState(initialTestResult); + + const controller: RemoteWebhookSetupController = { + error, + isLoading, + isSaving: false, + isTesting: false, + removeWebhook: async (id) => { + setWebhooks((currentWebhooks) => currentWebhooks.filter((webhook) => webhook.id !== id)); + }, + saveWebhook: async (webhook, id) => { + setWebhooks((currentWebhooks) => { + if (id) { + return currentWebhooks.map((currentWebhook) => + currentWebhook.id === id ? { id, ...webhook } : currentWebhook, + ); + } + + return [ + ...currentWebhooks, + { + id: Math.max(0, ...currentWebhooks.map((currentWebhook) => currentWebhook.id)) + 1, + ...webhook, + }, + ]; + }); + }, + testResult, + testSavedWebhook: async (id) => { + const webhook = webhooks.find((currentWebhook) => currentWebhook.id === id); + + setTestResult({ + body: '{"ok":true,"message":"Ping accepted"}', + status: 202, + statusText: 'Accepted', + url: webhook?.url || 'https://hooks.example.com/live-activities', + }); + }, + testWebhookSettings: async (webhook) => { + setTestResult({ + body: 'Draft webhook settings responded successfully.', + status: 200, + statusText: 'OK', + url: webhook.url, + }); + }, + webhooks, + }; + + return ; +} + +const meta = { + title: 'Pages/Competition/Remote/Webhook Setup', + component: WebhookSetupStory, + render: (args) => , + decorators: [ + (Story) => ( + +
+ +
+
+ ), + ], + args: { + disabled: false, + error: null, + initialTestResult: null, + initialWebhooks: savedWebhooks, + isLoading: false, + }, + parameters: { + layout: 'padded', + }, + tags: ['autodocs'], +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const SavedWebhooks: Story = {}; + +export const Empty: Story = { + args: { + initialWebhooks: [], + }, +}; + +export const TestResult: Story = { + args: { + initialTestResult: { + body: '{"ok":true,"received":1}', + status: 200, + statusText: 'OK', + url: 'https://hooks.example.com/live-activities', + }, + }, +}; + +export const Loading: Story = { + args: { + initialWebhooks: [], + isLoading: true, + }, +}; + +export const Error: Story = { + args: { + error: 'Webhook URL must use HTTPS', + initialWebhooks: [], + }, +}; + +export const Disabled: Story = { + args: { + disabled: true, + }, +}; diff --git a/src/pages/Competition/Remote/RemoteWebhookSetup.test.tsx b/src/pages/Competition/Remote/RemoteWebhookSetup.test.tsx new file mode 100644 index 0000000..d39f2c2 --- /dev/null +++ b/src/pages/Competition/Remote/RemoteWebhookSetup.test.tsx @@ -0,0 +1,198 @@ +import { act, render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { useNotifyCompRemoteWebhooks } from '@/hooks/useNotifyCompRemoteWebhooks'; +import { ConfirmProvider } from '@/providers/ConfirmProvider'; +import { RemoteWebhookSetup } from './RemoteWebhookSetup'; + +jest.mock('@/hooks/useNotifyCompRemoteWebhooks', () => ({ + useNotifyCompRemoteWebhooks: jest.fn(), +})); + +const competitionId = 'ExampleComp2026'; + +const defaultHookValue = { + error: null, + isLoading: false, + isSaving: false, + isTesting: false, + removeWebhook: jest.fn().mockResolvedValue(undefined), + saveWebhook: jest.fn().mockResolvedValue(undefined), + testResult: null, + testSavedWebhook: jest.fn().mockResolvedValue(undefined), + testWebhookSettings: jest.fn().mockResolvedValue(undefined), + webhooks: [], +}; + +const renderSetup = (hookValue = {}) => { + const value = { + ...defaultHookValue, + ...hookValue, + }; + jest + .mocked(useNotifyCompRemoteWebhooks) + .mockReturnValue(value as unknown as ReturnType); + + render( + + + , + ); + + return value; +}; + +describe('RemoteWebhookSetup', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('lists saved webhooks with method and URL', () => { + renderSetup({ + webhooks: [ + { + id: 1, + method: 'POST', + url: 'https://example.com/notify', + }, + ], + }); + + expect(screen.getByRole('heading', { name: 'Webhook setup' })).toBeInTheDocument(); + expect(screen.getByText('POST')).toBeInTheDocument(); + expect(screen.getByText('https://example.com/notify')).toBeInTheDocument(); + }); + + it('creates a webhook from the form', async () => { + const user = userEvent.setup(); + const hook = renderSetup(); + + await act(async () => { + await user.click(screen.getByRole('button', { name: 'Add webhook' })); + }); + await act(async () => { + await user.selectOptions(screen.getByLabelText('HTTP method'), 'GET'); + await user.type(screen.getByLabelText('URL'), 'https://example.com/new'); + await user.click(screen.getByRole('button', { name: 'Create webhook' })); + }); + + await waitFor(() => { + expect(hook.saveWebhook).toHaveBeenCalledWith( + { + method: 'GET', + url: 'https://example.com/new', + }, + undefined, + ); + }); + }); + + it('updates an existing webhook from the edit form', async () => { + const user = userEvent.setup(); + const hook = renderSetup({ + webhooks: [ + { + id: 2, + method: 'POST', + url: 'https://example.com/old', + }, + ], + }); + + await act(async () => { + await user.click(screen.getByRole('button', { name: 'Edit' })); + }); + await act(async () => { + await user.clear(screen.getByLabelText('URL')); + await user.type(screen.getByLabelText('URL'), 'https://example.com/updated'); + await user.click(screen.getByRole('button', { name: 'Save webhook' })); + }); + + await waitFor(() => { + expect(hook.saveWebhook).toHaveBeenCalledWith( + { + method: 'POST', + url: 'https://example.com/updated', + }, + 2, + ); + }); + }); + + it('deletes a webhook after confirmation', async () => { + const user = userEvent.setup(); + const hook = renderSetup({ + webhooks: [ + { + id: 3, + method: 'DELETE', + url: 'https://example.com/remove', + }, + ], + }); + + await act(async () => { + await user.click(screen.getByRole('button', { name: 'Delete' })); + }); + await act(async () => { + await user.click(screen.getByRole('button', { name: 'Delete webhook' })); + }); + + await waitFor(() => { + expect(hook.removeWebhook).toHaveBeenCalledWith(3); + }); + }); + + it('tests saved and unsaved webhook settings', async () => { + const user = userEvent.setup(); + const hook = renderSetup({ + testResult: { + body: 'Request accepted', + status: 202, + statusText: 'Accepted', + url: 'https://example.com/test', + }, + webhooks: [ + { + id: 4, + method: 'POST', + url: 'https://example.com/test', + }, + ], + }); + + await act(async () => { + await user.click(screen.getByRole('button', { name: 'Test' })); + }); + + expect(hook.testSavedWebhook).toHaveBeenCalledWith(4); + + await act(async () => { + await user.click(screen.getByRole('button', { name: 'Add webhook' })); + }); + await act(async () => { + await user.type(screen.getByLabelText('URL'), 'https://example.com/draft'); + await user.click(screen.getAllByRole('button', { name: 'Test' })[0]); + }); + + expect(hook.testWebhookSettings).toHaveBeenCalledWith({ + method: 'POST', + url: 'https://example.com/draft', + }); + expect(screen.getByText('202 Accepted')).toBeInTheDocument(); + expect(screen.getByText('Request accepted')).toBeInTheDocument(); + }); + + it('renders failed test results', () => { + renderSetup({ + testResult: { + body: '', + status: 0, + statusText: '', + url: 'https://example.com/fail', + }, + }); + + expect(screen.getByText('0 Request failed')).toBeInTheDocument(); + expect(screen.getByText('Empty response body')).toBeInTheDocument(); + }); +}); diff --git a/src/pages/Competition/Remote/RemoteWebhookSetup.tsx b/src/pages/Competition/Remote/RemoteWebhookSetup.tsx new file mode 100644 index 0000000..0b95d05 --- /dev/null +++ b/src/pages/Competition/Remote/RemoteWebhookSetup.tsx @@ -0,0 +1,347 @@ +import { FormEvent, useEffect, useState } from 'react'; +import { BarLoader } from 'react-spinners'; +import { Button } from '@/components/Button'; +import { NoteBox } from '@/components/Notebox'; +import { useNotifyCompRemoteWebhooks } from '@/hooks/useNotifyCompRemoteWebhooks'; +import { + HttpMethod, + NotifyCompWebhook, + NotifyCompWebhookInput, + NotifyCompWebhookResponse, +} from '@/lib/notifyCompRemoteGraphql'; +import { useConfirm } from '@/providers/ConfirmProvider'; + +const HTTP_METHODS: HttpMethod[] = ['POST', 'GET', 'PUT', 'PATCH', 'DELETE']; +const BODY_SUMMARY_LENGTH = 300; + +interface WebhookFormState { + id?: number; + method: HttpMethod; + url: string; +} + +export interface RemoteWebhookSetupController { + error: string | null; + isLoading: boolean; + isSaving: boolean; + isTesting: boolean; + removeWebhook: (id: number) => Promise; + saveWebhook: (webhook: NotifyCompWebhookInput, id?: number) => Promise; + testResult: NotifyCompWebhookResponse | null; + testSavedWebhook: (id: number) => Promise; + testWebhookSettings: (webhook: NotifyCompWebhookInput) => Promise; + webhooks: NotifyCompWebhook[]; +} + +interface RemoteWebhookSetupPanelProps { + disabled?: boolean; + webhooks: RemoteWebhookSetupController; +} + +interface RemoteWebhookSetupProps { + competitionId: string; + disabled?: boolean; +} + +const emptyForm: WebhookFormState = { + method: 'POST', + url: '', +}; + +const webhookToForm = (webhook: NotifyCompWebhook): WebhookFormState => ({ + id: webhook.id, + method: webhook.method, + url: webhook.url, +}); + +const toWebhookInput = (form: WebhookFormState): NotifyCompWebhookInput => ({ + method: form.method, + url: form.url.trim(), +}); + +const bodySummary = (body?: string | null) => { + const trimmedBody = body?.trim(); + + if (!trimmedBody) { + return 'Empty response body'; + } + + return trimmedBody.length > BODY_SUMMARY_LENGTH + ? `${trimmedBody.slice(0, BODY_SUMMARY_LENGTH)}...` + : trimmedBody; +}; + +function WebhookTestResult({ result }: { result: NotifyCompWebhookResponse }) { + const statusText = result.statusText || (result.status === 0 ? 'Request failed' : 'No text'); + + return ( +
+
+ Test result + + {result.status} {statusText} + +
+

{result.url}

+
+        {bodySummary(result.body)}
+      
+
+ ); +} + +interface WebhookFormProps { + disabled: boolean; + form: WebhookFormState; + isSaving: boolean; + isTesting: boolean; + onCancel: () => void; + onChange: (form: WebhookFormState) => void; + onSave: (form: WebhookFormState) => void; + onTest: (form: WebhookFormState) => void; +} + +function WebhookForm({ + disabled, + form, + isSaving, + isTesting, + onCancel, + onChange, + onSave, + onTest, +}: WebhookFormProps) { + const isBusy = disabled || isSaving || isTesting; + const canSubmit = Boolean(form.url.trim()) && !isBusy; + + const submit = (event: FormEvent) => { + event.preventDefault(); + + if (canSubmit) { + onSave(form); + } + }; + + return ( +
+
+ + + +
+ +
+ + + +
+
+ ); +} + +export function RemoteWebhookSetupPanel({ + disabled = false, + webhooks, +}: RemoteWebhookSetupPanelProps) { + const confirm = useConfirm(); + const [form, setForm] = useState(null); + + useEffect(() => { + if (form?.id) { + const latestWebhook = webhooks.webhooks.find((webhook) => webhook.id === form.id); + + if (latestWebhook) { + setForm(webhookToForm(latestWebhook)); + } + } + }, [form?.id, webhooks.webhooks]); + + const controlsDisabled = disabled || webhooks.isSaving || webhooks.isTesting; + + const saveWebhook = async (nextForm: WebhookFormState) => { + try { + await webhooks.saveWebhook(toWebhookInput(nextForm), nextForm.id); + setForm(null); + } catch { + // The hook exposes the mutation error inline. + } + }; + + const removeWebhook = async (webhook: NotifyCompWebhook) => { + const confirmed = await confirm({ + confirmLabel: 'Delete webhook', + confirmVariant: 'gray', + message: `Delete the ${webhook.method} webhook for ${webhook.url}?`, + }); + + if (confirmed) { + try { + await webhooks.removeWebhook(webhook.id); + } catch { + // The hook exposes the mutation error inline. + } + } + }; + + const testWebhookSettings = async (nextForm: WebhookFormState) => { + try { + await webhooks.testWebhookSettings(toWebhookInput(nextForm)); + } catch { + // The hook exposes the mutation error inline. + } + }; + + const testSavedWebhook = async (webhook: NotifyCompWebhook) => { + try { + await webhooks.testSavedWebhook(webhook.id); + } catch { + // The hook exposes the mutation error inline. + } + }; + + return ( +
+
+
+
+

Webhook setup

+

+ Notify external services when Live Activities are started or stopped. +

+
+ +
+ + {webhooks.isLoading && } + {webhooks.error && } + + {form && ( + { + setForm(null); + }} + onChange={setForm} + onSave={(nextForm) => { + void saveWebhook(nextForm); + }} + onTest={(nextForm) => { + void testWebhookSettings(nextForm); + }} + /> + )} + + {webhooks.testResult && } + + {!webhooks.isLoading && webhooks.webhooks.length === 0 ? ( + + ) : ( +
+ {webhooks.webhooks.map((webhook) => ( +
+
+
+ + {webhook.method} + + {webhook.url} +
+
+ +
+ + + +
+
+ ))} +
+ )} +
+
+ ); +} + +export function RemoteWebhookSetup({ competitionId, disabled = false }: RemoteWebhookSetupProps) { + const webhooks = useNotifyCompRemoteWebhooks({ competitionId }); + + return ; +} diff --git a/src/pages/Competition/Remote/Webhooks.stories.tsx b/src/pages/Competition/Remote/Webhooks.stories.tsx new file mode 100644 index 0000000..a30ce91 --- /dev/null +++ b/src/pages/Competition/Remote/Webhooks.stories.tsx @@ -0,0 +1,127 @@ +import { MockedProvider, MockedResponse } from '@apollo/client/testing'; +import type { Meta, StoryObj } from '@storybook/react'; +import { ReactNode } from 'react'; +import { RemoteCompetitionDocument, RemoteWebhooksDocument } from '@/lib/notifyCompRemoteGraphql'; +import { ConfirmProvider } from '@/providers/ConfirmProvider'; +import { + NotifyCompRemoteAuthContext, + NotifyCompRemoteAuthContextValue, +} from '@/providers/NotifyCompRemoteAuthProvider/NotifyCompRemoteAuthContext'; +import { storybookCompetitionFixture } from '@/storybook/competitionFixtures'; +import { + makeCompetitionContainerDecorator, + makeRouteDecorator, +} from '@/storybook/competitionStorybook'; +import CompetitionRemoteWebhooks from './Webhooks'; + +const competitionId = storybookCompetitionFixture.id; + +const remoteAdminUser: User = { + id: 9001, + name: 'Nick Silvestri', + email: '', + wca_id: '2016SILV08', + avatar: { + url: 'https://avatars.worldcubeassociation.org/nsg38gkpoch8xiji3hodmrs672m4', + thumb_url: 'https://avatars.worldcubeassociation.org/uge6fzvlpmz6c8ztn8ey5wi4i8uf', + }, + delegate_status: 'delegate', +}; + +const remoteAuthContext: NotifyCompRemoteAuthContextValue = { + authenticating: false, + error: null, + isAuthenticated: true, + isAuthenticatedForCompetition: () => true, + signIn: async () => {}, + signOut: () => {}, + userName: remoteAdminUser.name, +}; + +const webhookMocks: MockedResponse[] = [ + { + request: { + query: RemoteCompetitionDocument, + variables: { + competitionId, + }, + }, + result: { + data: { + competition: { + id: competitionId, + autoAdvance: true, + autoAdvanceDelay: null, + }, + }, + }, + }, + { + request: { + query: RemoteWebhooksDocument, + variables: { + competitionId, + }, + }, + result: { + data: { + competition: { + id: competitionId, + webhooks: [ + { + id: 1, + method: 'POST', + url: 'https://hooks.example.com/live-activities', + }, + { + id: 2, + method: 'GET', + url: 'https://status.example.com/ping', + }, + ], + }, + }, + }, + }, +]; + +function WebhookPageStoryProviders({ children }: { children: ReactNode }) { + return ( + + + + {children} + + + + ); +} + +const meta = { + title: 'Pages/Competition/Remote/Webhooks', + component: CompetitionRemoteWebhooks, + decorators: [ + (Story) => ( + + + + ), + makeRouteDecorator({ + initialPath: `/competitions/${competitionId}/admin/webhooks`, + routePath: '/competitions/:competitionId/admin/webhooks', + }), + makeCompetitionContainerDecorator({ + currentUser: remoteAdminUser, + }), + ], + parameters: { + layout: 'padded', + }, + tags: ['autodocs'], +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const WebhookEditor: Story = {}; diff --git a/src/pages/Competition/Remote/Webhooks.test.tsx b/src/pages/Competition/Remote/Webhooks.test.tsx new file mode 100644 index 0000000..f6dccad --- /dev/null +++ b/src/pages/Competition/Remote/Webhooks.test.tsx @@ -0,0 +1,124 @@ +import { useQuery } from '@apollo/client'; +import { render, screen } from '@testing-library/react'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import { isCompetitionDelegateOrOrganizer } from '@/lib/competitionAuthorization'; +import { useAuth } from '@/providers/AuthProvider'; +import { useNotifyCompRemoteAuth } from '@/providers/NotifyCompRemoteAuthProvider'; +import { useWCIF } from '@/providers/WCIFProvider'; +import CompetitionRemoteWebhooks from './Webhooks'; + +jest.mock('@apollo/client', () => ({ + ...jest.requireActual('@apollo/client'), + useQuery: jest.fn(), +})); +jest.mock('@/lib/competitionAuthorization', () => ({ + isCompetitionDelegateOrOrganizer: jest.fn(), +})); +jest.mock('@/providers/AuthProvider', () => ({ + useAuth: jest.fn(), +})); +jest.mock('@/providers/NotifyCompRemoteAuthProvider', () => ({ + useNotifyCompRemoteAuth: jest.fn(), +})); +jest.mock('@/providers/WCIFProvider', () => ({ + useWCIF: jest.fn(), +})); +jest.mock('./RemoteWebhookSetup', () => ({ + RemoteWebhookSetup: ({ competitionId }: { competitionId: string }) => ( +
Webhook editor for {competitionId}
+ ), +})); + +const wcif = { + persons: [], + schedule: { + numberOfDays: 1, + startDate: '2026-06-01', + venues: [], + }, +}; + +const renderWebhooksPage = () => { + render( + + + } + /> + + , + ); +}; + +describe('CompetitionRemoteWebhooks', () => { + beforeEach(() => { + jest.clearAllMocks(); + jest.mocked(useWCIF).mockReturnValue({ + competitionId: 'ExampleComp2026', + setTitle: jest.fn(), + wcif, + } as unknown as ReturnType); + jest.mocked(useAuth).mockReturnValue({ + user: { id: 1 }, + } as unknown as ReturnType); + jest.mocked(isCompetitionDelegateOrOrganizer).mockReturnValue(true); + jest.mocked(useNotifyCompRemoteAuth).mockReturnValue({ + authenticating: false, + error: null, + isAuthenticatedForCompetition: () => true, + signIn: jest.fn(), + } as unknown as ReturnType); + jest.mocked(useQuery).mockReturnValue({ + data: { + competition: { + id: 'ExampleComp2026', + }, + }, + error: null, + loading: false, + } as unknown as ReturnType); + }); + + it('asks users to authenticate before managing webhooks', () => { + jest.mocked(useNotifyCompRemoteAuth).mockReturnValue({ + authenticating: false, + error: null, + isAuthenticatedForCompetition: () => false, + signIn: jest.fn(), + } as unknown as ReturnType); + + renderWebhooksPage(); + + expect( + screen.getByRole('button', { name: 'Sign in to Live Activities Remote' }), + ).toBeInTheDocument(); + expect(screen.queryByText('Webhook editor for ExampleComp2026')).not.toBeInTheDocument(); + }); + + it('asks users to import the competition before managing webhooks', () => { + jest.mocked(useQuery).mockReturnValue({ + data: { + competition: null, + }, + error: null, + loading: false, + } as unknown as ReturnType); + + renderWebhooksPage(); + + expect(screen.getByText(/Not imported/)).toBeInTheDocument(); + expect(screen.getByRole('link', { name: 'Go to Remote' })).toHaveAttribute( + 'href', + '/competitions/ExampleComp2026/admin/remote', + ); + expect(screen.queryByText('Webhook editor for ExampleComp2026')).not.toBeInTheDocument(); + }); + + it('renders the webhook editor for imported competitions', () => { + renderWebhooksPage(); + + expect(screen.getByRole('heading', { name: 'Webhooks' })).toBeInTheDocument(); + expect(screen.getByText('Webhook editor for ExampleComp2026')).toBeInTheDocument(); + }); +}); diff --git a/src/pages/Competition/Remote/Webhooks.tsx b/src/pages/Competition/Remote/Webhooks.tsx new file mode 100644 index 0000000..8c95c03 --- /dev/null +++ b/src/pages/Competition/Remote/Webhooks.tsx @@ -0,0 +1,107 @@ +import { useQuery } from '@apollo/client'; +import { useEffect } from 'react'; +import { Link, useParams } from 'react-router-dom'; +import { BarLoader } from 'react-spinners'; +import { Button } from '@/components/Button'; +import { Container } from '@/components/Container'; +import { NoteBox } from '@/components/Notebox'; +import { isCompetitionDelegateOrOrganizer } from '@/lib/competitionAuthorization'; +import { NotifyCompCompetition, RemoteCompetitionDocument } from '@/lib/notifyCompRemoteGraphql'; +import { useAuth } from '@/providers/AuthProvider'; +import { useNotifyCompRemoteAuth } from '@/providers/NotifyCompRemoteAuthProvider'; +import { useWCIF } from '@/providers/WCIFProvider'; +import { RemoteWebhookSetup } from './RemoteWebhookSetup'; + +export default function CompetitionRemoteWebhooks() { + const { competitionId } = useParams<{ competitionId: string }>(); + const { wcif, setTitle } = useWCIF(); + const { user } = useAuth(); + const remoteAuth = useNotifyCompRemoteAuth(); + const canManageRemote = isCompetitionDelegateOrOrganizer(wcif, user); + const isAuthenticated = remoteAuth.isAuthenticatedForCompetition(competitionId || ''); + const canLoadRemoteCompetition = Boolean(competitionId && isAuthenticated && canManageRemote); + + const remoteCompetitionQuery = useQuery<{ competition: NotifyCompCompetition | null }>( + RemoteCompetitionDocument, + { + variables: { competitionId }, + skip: !canLoadRemoteCompetition, + }, + ); + + useEffect(() => { + setTitle('Webhooks'); + }, [setTitle]); + + if (!competitionId || !wcif) { + return null; + } + + const accessDenied = (Boolean(user) || isAuthenticated) && !canManageRemote; + + return ( + +
+
+

Webhooks

+

+ Send Live Activities updates to another service when activities start or stop. +

+ + Back to Live Activities Remote + +
+ + {remoteAuth.error && } + + {accessDenied ? ( + + ) : !isAuthenticated ? ( +
+

+ Sign in to Live Activities Remote with your WCA account before managing webhooks. +

+ +
+ ) : ( + <> + {remoteCompetitionQuery.loading && } + {remoteCompetitionQuery.error && ( + + )} + + {!remoteCompetitionQuery.loading && !remoteCompetitionQuery.data?.competition ? ( +
+ + + Go to Remote + +
+ ) : ( + remoteCompetitionQuery.data?.competition && ( + + ) + )} + + )} +
+
+ ); +} diff --git a/src/pages/Competition/Remote/index.stories.tsx b/src/pages/Competition/Remote/index.stories.tsx new file mode 100644 index 0000000..b168e99 --- /dev/null +++ b/src/pages/Competition/Remote/index.stories.tsx @@ -0,0 +1,126 @@ +import { MockedProvider, MockedResponse } from '@apollo/client/testing'; +import type { Meta, StoryObj } from '@storybook/react'; +import { ReactNode, useEffect } from 'react'; +import { RemoteActivitiesDocument, RemoteCompetitionDocument } from '@/lib/notifyCompRemoteGraphql'; +import { setNotifyCompWebSocketStatus } from '@/lib/notifyCompWebSocketStatus'; +import { ConfirmProvider } from '@/providers/ConfirmProvider'; +import { + NotifyCompRemoteAuthContext, + NotifyCompRemoteAuthContextValue, +} from '@/providers/NotifyCompRemoteAuthProvider/NotifyCompRemoteAuthContext'; +import { + storybookCompetitionFixture, + storybookOngoingActivitiesFixture, +} from '@/storybook/competitionFixtures'; +import { + makeCompetitionContainerDecorator, + makeRouteDecorator, +} from '@/storybook/competitionStorybook'; +import CompetitionRemote from './index'; + +const competitionId = storybookCompetitionFixture.id; + +const remoteAdminUser: User = { + id: 9001, + name: 'Nick Silvestri', + email: '', + wca_id: '2016SILV08', + avatar: { + url: 'https://avatars.worldcubeassociation.org/nsg38gkpoch8xiji3hodmrs672m4', + thumb_url: 'https://avatars.worldcubeassociation.org/uge6fzvlpmz6c8ztn8ey5wi4i8uf', + }, + delegate_status: 'delegate', +}; + +const remoteAuthContext: NotifyCompRemoteAuthContextValue = { + authenticating: false, + error: null, + isAuthenticated: true, + isAuthenticatedForCompetition: () => true, + signIn: async () => {}, + signOut: () => {}, + userName: remoteAdminUser.name, +}; + +const remoteMocks: MockedResponse[] = [ + { + request: { + query: RemoteCompetitionDocument, + variables: { + competitionId, + }, + }, + result: { + data: { + competition: { + id: competitionId, + autoAdvance: true, + autoAdvanceDelay: null, + }, + }, + }, + }, + { + request: { + query: RemoteActivitiesDocument, + variables: { + competitionId, + roomId: undefined, + }, + }, + result: { + data: { + activities: storybookOngoingActivitiesFixture, + }, + }, + }, +]; + +function RemoteStoryProviders({ children }: { children: ReactNode }) { + useEffect(() => { + setNotifyCompWebSocketStatus({ status: 'connected' }); + + return () => { + setNotifyCompWebSocketStatus({ status: 'idle' }); + }; + }, []); + + return ( + + + + {children} + + + + ); +} + +const meta = { + title: 'Pages/Competition/Remote', + component: CompetitionRemote, + decorators: [ + (Story) => ( + + + + ), + makeRouteDecorator({ + initialPath: `/competitions/${competitionId}/admin/remote`, + routePath: '/competitions/:competitionId/admin/remote', + }), + makeCompetitionContainerDecorator({ + currentUser: remoteAdminUser, + }), + ], + parameters: { + layout: 'padded', + }, + tags: ['autodocs'], +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const LiveActivitiesRemote: Story = {}; diff --git a/src/pages/Competition/Remote/index.test.tsx b/src/pages/Competition/Remote/index.test.tsx new file mode 100644 index 0000000..1a56d68 --- /dev/null +++ b/src/pages/Competition/Remote/index.test.tsx @@ -0,0 +1,124 @@ +import { render, screen } from '@testing-library/react'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import { useCompetitionRemoteControl } from '@/hooks/useCompetitionRemoteControl'; +import { useNotifyCompWebSocketStatus } from '@/hooks/useNotifyCompWebSocketStatus'; +import { useAuth } from '@/providers/AuthProvider'; +import { useNotifyCompRemoteAuth } from '@/providers/NotifyCompRemoteAuthProvider'; +import { useWCIF } from '@/providers/WCIFProvider'; +import CompetitionRemote from './index'; + +jest.mock('@/hooks/useCompetitionRemoteControl', () => ({ + useCompetitionRemoteControl: jest.fn(), +})); +jest.mock('@/hooks/useNotifyCompWebSocketStatus', () => ({ + useNotifyCompWebSocketStatus: jest.fn(), +})); +jest.mock('./RemoteActivityList', () => ({ + RemoteGroupList: () =>
, +})); +jest.mock('@/providers/AuthProvider', () => ({ + useAuth: jest.fn(), +})); +jest.mock('@/providers/NotifyCompRemoteAuthProvider', () => ({ + useNotifyCompRemoteAuth: jest.fn(), +})); +jest.mock('@/providers/WCIFProvider', () => ({ + useWCIF: jest.fn(), +})); + +const wcif = { + persons: [], + schedule: { + numberOfDays: 1, + startDate: '2026-06-01', + venues: [], + }, +}; + +const remoteDefaults = { + activities: [], + activityGroups: [], + autoAdvance: false, + canManageRemote: true, + error: null, + importCompetition: jest.fn(), + isAuthenticated: true, + isLoading: false, + isSaving: false, + nextGroup: null, + scheduledActivities: [], +}; + +const renderRemote = () => { + render( + + + } /> + + , + ); +}; + +describe('CompetitionRemote webhook management link', () => { + beforeEach(() => { + jest.clearAllMocks(); + jest.mocked(useWCIF).mockReturnValue({ + setTitle: jest.fn(), + wcif, + } as unknown as ReturnType); + jest.mocked(useAuth).mockReturnValue({ + user: { id: 1 }, + } as unknown as ReturnType); + jest.mocked(useNotifyCompWebSocketStatus).mockReturnValue({ + status: 'connected', + } as ReturnType); + jest.mocked(useNotifyCompRemoteAuth).mockReturnValue({ + authenticating: false, + error: null, + signIn: jest.fn(), + } as unknown as ReturnType); + }); + + it('does not show webhook management before Remote authentication', () => { + jest.mocked(useCompetitionRemoteControl).mockReturnValue({ + ...remoteDefaults, + isAuthenticated: false, + } as unknown as ReturnType); + + renderRemote(); + + expect( + screen.getByRole('button', { name: 'Sign in to Live Activities Remote' }), + ).toBeInTheDocument(); + expect(screen.queryByRole('link', { name: 'Manage webhooks' })).not.toBeInTheDocument(); + }); + + it('does not show webhook management before the competition is imported', () => { + jest.mocked(useCompetitionRemoteControl).mockReturnValue({ + ...remoteDefaults, + competition: null, + } as unknown as ReturnType); + + renderRemote(); + + expect(screen.getByText('Import schedule')).toBeInTheDocument(); + expect(screen.queryByRole('link', { name: 'Manage webhooks' })).not.toBeInTheDocument(); + }); + + it('links to webhook management after the competition is imported', () => { + jest.mocked(useCompetitionRemoteControl).mockReturnValue({ + ...remoteDefaults, + competition: { + id: 'ExampleComp2026', + }, + } as unknown as ReturnType); + + renderRemote(); + + expect(screen.getByRole('link', { name: 'Manage webhooks' })).toHaveAttribute( + 'href', + '/competitions/ExampleComp2026/admin/webhooks', + ); + expect(screen.queryByRole('heading', { name: 'Webhook setup' })).not.toBeInTheDocument(); + }); +}); diff --git a/src/pages/Competition/Remote/index.tsx b/src/pages/Competition/Remote/index.tsx index 10c27cb..007f250 100644 --- a/src/pages/Competition/Remote/index.tsx +++ b/src/pages/Competition/Remote/index.tsx @@ -2,13 +2,11 @@ import classNames from 'classnames'; import { useEffect } from 'react'; import { Link, useParams } from 'react-router-dom'; import { BarLoader } from 'react-spinners'; -import { - Button, - Container, - NoteBox, - NotifyCompConnectionStatus, - RemoteActivitySummaryList, -} from '@/components'; +import { Button } from '@/components/Button'; +import { Container } from '@/components/Container'; +import { NoteBox } from '@/components/Notebox'; +import { NotifyCompConnectionStatus } from '@/components/NotifyCompConnectionStatus'; +import { RemoteActivitySummaryList } from '@/components/RemoteActivitySummaryList'; import { useCompetitionRemoteControl } from '@/hooks/useCompetitionRemoteControl'; import { useNotifyCompWebSocketStatus } from '@/hooks/useNotifyCompWebSocketStatus'; import { isCompetitionDayOrAfter } from '@/lib/competitionDates'; @@ -166,7 +164,24 @@ export default function CompetitionRemote() { <> {remote.isLoading && } {remote.error && } - {remote.competition && } + {remote.competition && ( +
+ +
+
+

Webhooks

+

+ Configure external Live Activities update webhooks on a separate page. +

+
+ + Manage webhooks + +
+
+ )} {!remote.isLoading && !remote.competition ? (
diff --git a/src/providers/AuthProvider/AuthProvider.tsx b/src/providers/AuthProvider/AuthProvider.tsx index e46ac1c..524a42f 100644 --- a/src/providers/AuthProvider/AuthProvider.tsx +++ b/src/providers/AuthProvider/AuthProvider.tsx @@ -84,7 +84,10 @@ export function AuthProvider({ children }: PropsWithChildren) { return; } - fetchMe(accessToken) + fetchMe(accessToken, { + upcoming_competitions: true, + ongoing_competitions: true, + }) .then(({ me, ongoing_competitions, upcoming_competitions }) => { setLocalStorage('accessToken', accessToken); setLocalStorage( @@ -92,6 +95,8 @@ export function AuthProvider({ children }: PropsWithChildren) { String(Date.now() + Number(hashParams.get('expires_in') ?? 0) * 1000), ); setUserAndSave(me); + setLocalStorage('my.upcoming_competitions', JSON.stringify(upcoming_competitions || [])); + setLocalStorage('my.ongoing_competitions', JSON.stringify(ongoing_competitions || [])); queryClient.setQueryData(['userCompetitions', me.id], { user: me, ongoing_competitions,