diff --git a/README.md b/README.md index c3c9b4b..d60007d 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,8 @@ Required push-notification environment variables: - `VAPID_PUBLIC_KEY`: public VAPID key returned to competitiongroups.com. - `VAPID_PRIVATE_KEY`: private VAPID key used to send browser push messages. - `COMPETITION_GROUPS_JWT_SECRET`: shared HS256 secret used to authenticate - CompetitionGroups subscription requests. + CompetitionGroups subscription requests. Also used for push session tokens + unless `COMPETITION_GROUPS_PUSH_SESSION_SECRET` is configured. Optional push-notification environment variables: @@ -39,6 +40,10 @@ Optional push-notification environment variables: - `COMPETITION_GROUPS_JWT_ISSUER`: expected `iss` claim when configured. - `COMPETITION_GROUPS_JWT_AUDIENCE`: expected `aud` claim when configured. - `COMPETITION_GROUPS_ORIGIN`: origin used when building notification click URLs. +- `COMPETITION_GROUPS_PUSH_SESSION_SECRET`: dedicated HS256 secret for durable + CompetitionGroups push session tokens. +- `COMPETITION_GROUPS_PUSH_SESSION_TTL_SECONDS`: optional push session token + lifetime. When omitted, push sessions last until the client disables them. The question should be asked: How does the server know who is in what group? The main answer to this is through the [WCIF](https://github.com/thewca/wcif/blob/master/specification.md). But the other question is does this data get saved? **An argument for saving the data in databases**: potentially faster, could cache, only retreive the data you need. diff --git a/packages/server/auth/Auth.spec.ts b/packages/server/auth/Auth.spec.ts index be16ff5..341c61e 100644 --- a/packages/server/auth/Auth.spec.ts +++ b/packages/server/auth/Auth.spec.ts @@ -98,9 +98,8 @@ const loadAuth = async ( sign: jwtSign, }, })); - jest.doMock('node-fetch', () => ({ - __esModule: true, - default: fetchMock, + jest.doMock('../lib/fetchWithTimeout', () => ({ + fetchWithTimeout: fetchMock, })); jest.doMock('../mocks/config', () => ({ isMocksMode: jest.fn(() => mocksMode), @@ -110,7 +109,7 @@ const loadAuth = async ( })), })); jest.doMock('./AuthMiddleware', () => ({ - authMiddlewareDecode: jest.fn( + authMiddlewareVerifyIgnoringExpiration: jest.fn( (req: MockRequest, _res: MockResponse, next: () => void) => next() ), })); @@ -146,6 +145,17 @@ describe('Auth router', () => { expect(response.send).toHaveBeenCalledWith('public-key'); }); + it('redacts client secrets from startup config logs', async () => { + await loadAuth(false); + + expect(console.log).toHaveBeenCalledWith( + 'Loading values from environment variables', + expect.objectContaining({ + CLIENT_SECRET: '[redacted]', + }) + ); + }); + it('redirects mock WCA auth requests back with a mock code', async () => { const { getRoutes } = await loadAuth(true); const response = createResponse(); @@ -233,12 +243,16 @@ describe('Auth router', () => { 'https://wca.example/oauth/token', expect.objectContaining({ method: 'POST' }) ); - expect(fetchMock).toHaveBeenCalledWith('https://wca.example/api/v0/me', { - headers: { - Authorization: 'Bearer access-token', - 'Content-Type': 'application/x-www-form-urlencoded', + expect(fetchMock).toHaveBeenCalledWith( + 'https://wca.example/api/v0/me', + { + headers: { + Authorization: 'Bearer access-token', + 'Content-Type': 'application/x-www-form-urlencoded', + }, }, - }); + { retries: 2 } + ); expect(jwtSign).toHaveBeenCalled(); expect(response.json).toHaveBeenCalledWith({ jwt: 'signed-jwt' }); }); diff --git a/packages/server/auth/Auth.ts b/packages/server/auth/Auth.ts index 70f258f..c215200 100644 --- a/packages/server/auth/Auth.ts +++ b/packages/server/auth/Auth.ts @@ -1,9 +1,9 @@ import fs from 'fs'; import express from 'express'; import jwt from 'jsonwebtoken'; -import fetch from 'node-fetch'; -import { authMiddlewareDecode } from './AuthMiddleware'; +import { authMiddlewareVerifyIgnoringExpiration } from './AuthMiddleware'; import { getMockUser, isMocksMode } from '../mocks/config'; +import { fetchWithTimeout } from '../lib/fetchWithTimeout'; // TODO Should really be fetched from environment variables // Depending on how we want to deploy this @@ -15,7 +15,7 @@ const { WCA_ORIGIN, CLIENT_ID, CLIENT_SECRET, REDIRECT_URI } = process.env; console.log('Loading values from environment variables', { WCA_ORIGIN, CLIENT_ID, - CLIENT_SECRET, + CLIENT_SECRET: CLIENT_SECRET ? '[redacted]' : undefined, REDIRECT_URI, }); @@ -183,7 +183,7 @@ router.get('/wca/callback', async (req, res) => { }); try { - const response = await fetch(`${resolvedWcaOrigin}/oauth/token`, { + const response = await fetchWithTimeout(`${resolvedWcaOrigin}/oauth/token`, { method: 'POST', body: params, headers: { @@ -197,9 +197,13 @@ router.get('/wca/callback', async (req, res) => { const wcaToken = (await response.json()) as WcaOauthRes; - const profileRes = await fetch(`${resolvedWcaOrigin}/api/v0/me`, { - headers: createHeaders(wcaToken.access_token), - }); + const profileRes = await fetchWithTimeout( + `${resolvedWcaOrigin}/api/v0/me`, + { + headers: createHeaders(wcaToken.access_token), + }, + { retries: 2 } + ); if (!profileRes.ok) { throw await profileRes.json(); @@ -215,55 +219,59 @@ router.get('/wca/callback', async (req, res) => { } }); -router.post('/wca/refresh', authMiddlewareDecode, async (req, res) => { - if (!req.user) { - return res.status(403).send('Unauthenticated'); - } - - if (isMocksMode()) { - return res.json({ jwt: await resignJWT(req.user) }); - } +router.post( + '/wca/refresh', + authMiddlewareVerifyIgnoringExpiration, + async (req, res) => { + if (!req.user) { + return res.status(403).send('Unauthenticated'); + } - const params = new URLSearchParams({ - grant_type: 'refresh_token', - client_id: resolvedClientId, - client_secret: resolvedClientSecret, - refresh_token: req.user.wca.refreshToken, - code: req.user.wca.code, - scope: SCOPE, - redirect_uri: req.get('Referer') ?? resolvedRedirectUri, - }); + if (isMocksMode()) { + return res.json({ jwt: await resignJWT(req.user) }); + } - try { - const response = await fetch(`${resolvedWcaOrigin}/oauth/token`, { - method: 'POST', - body: params, - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - }, + const params = new URLSearchParams({ + grant_type: 'refresh_token', + client_id: resolvedClientId, + client_secret: resolvedClientSecret, + refresh_token: req.user.wca.refreshToken, + code: req.user.wca.code, + scope: SCOPE, + redirect_uri: req.get('Referer') ?? resolvedRedirectUri, }); - if (!response.ok) { - throw await response.json(); - } + try { + const response = await fetchWithTimeout(`${resolvedWcaOrigin}/oauth/token`, { + method: 'POST', + body: params, + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + }); + + if (!response.ok) { + throw await response.json(); + } - const tokens = (await response.json()) as WcaOauthRes; + const tokens = (await response.json()) as WcaOauthRes; - const token = await resignJWT({ - ...req.user, - wca: { - ...req.user.wca, - accessToken: tokens.access_token, - expiration: new Date(Date.now() + tokens.expires_in * 1000).getTime(), - refreshToken: tokens.refresh_token, - }, - }); + const token = await resignJWT({ + ...req.user, + wca: { + ...req.user.wca, + accessToken: tokens.access_token, + expiration: new Date(Date.now() + tokens.expires_in * 1000).getTime(), + refreshToken: tokens.refresh_token, + }, + }); - return res.json({ jwt: token }); - } catch (e) { - console.error(e); - res.status(500).json(e); + return res.json({ jwt: token }); + } catch (e) { + console.error(e); + res.status(500).json(e); + } } -}); +); export default router; diff --git a/packages/server/auth/AuthMiddleware.spec.ts b/packages/server/auth/AuthMiddleware.spec.ts index 1cb8bad..56c5894 100644 --- a/packages/server/auth/AuthMiddleware.spec.ts +++ b/packages/server/auth/AuthMiddleware.spec.ts @@ -15,7 +15,11 @@ jest.mock('../lib/competitionGroupsToken', () => ({ verifyCompetitionGroupsToken, })); -import { authMiddlewareDecode, authMiddlewareVerify } from './AuthMiddleware'; +import { + authMiddlewareDecode, + authMiddlewareVerify, + authMiddlewareVerifyIgnoringExpiration, +} from './AuthMiddleware'; const createRequest = (authorization?: string) => ({ headers: { @@ -35,6 +39,13 @@ const callAuthMiddlewareDecode = authMiddlewareDecode as unknown as ( next: jest.Mock ) => void; +const callAuthMiddlewareVerifyIgnoringExpiration = + authMiddlewareVerifyIgnoringExpiration as unknown as ( + req: ReturnType, + res: unknown, + next: jest.Mock + ) => void; + describe('AuthMiddleware', () => { beforeEach(() => { jwtVerify.mockReset(); @@ -52,6 +63,17 @@ describe('AuthMiddleware', () => { expect(jwtVerify).not.toHaveBeenCalled(); }); + it('continues without verifying non-Bearer authorization headers', () => { + const request = createRequest('Basic credentials'); + const next = jest.fn(); + + callAuthMiddlewareVerify(request, {}, next); + + expect(next).toHaveBeenCalledWith(null); + expect(jwtVerify).not.toHaveBeenCalled(); + expect(verifyCompetitionGroupsToken).not.toHaveBeenCalled(); + }); + it('verifies normal bearer JWT users', () => { const user = { id: 123, name: 'Test User' }; const request = createRequest('Bearer jwt-token'); @@ -65,6 +87,39 @@ describe('AuthMiddleware', () => { expect(next).toHaveBeenCalledWith(null); }); + it('can verify expired app JWTs for refresh without checking expiration', () => { + const user = { id: 123, name: 'Expired User' }; + const request = createRequest('Bearer expired-jwt-token'); + const next = jest.fn(); + jwtVerify.mockReturnValue(user); + + callAuthMiddlewareVerifyIgnoringExpiration(request, {}, next); + + expect(jwtVerify).toHaveBeenCalledWith( + 'expired-jwt-token', + expect.anything(), + { + ignoreExpiration: true, + } + ); + expect(request).toMatchObject({ user }); + expect(next).toHaveBeenCalledWith(null); + }); + + it('does not fall back to Competition Groups tokens for refresh JWT verification', () => { + const request = createRequest('Bearer competition-groups-token'); + const next = jest.fn(); + const jwtError = new Error('invalid jwt'); + jwtVerify.mockImplementation(() => { + throw jwtError; + }); + + callAuthMiddlewareVerifyIgnoringExpiration(request, {}, next); + + expect(verifyCompetitionGroupsToken).not.toHaveBeenCalled(); + expect(next).toHaveBeenCalledWith(jwtError); + }); + it('falls back to Competition Groups tokens for remote-control users', () => { const request = createRequest('Bearer competition-groups-token'); const next = jest.fn(); diff --git a/packages/server/auth/AuthMiddleware.ts b/packages/server/auth/AuthMiddleware.ts index d32748b..9763d0f 100644 --- a/packages/server/auth/AuthMiddleware.ts +++ b/packages/server/auth/AuthMiddleware.ts @@ -5,15 +5,25 @@ import { CompetitionGroupsClaims, verifyCompetitionGroupsToken, } from '../lib/competitionGroupsToken'; -const PUBLIC_KEY = process.env.PUBLIC_KEY ?? fs.readFileSync('public.key'); const COMPETITION_GROUPS_REMOTE_SCOPE = 'notifycomp.remote'; +let cachedPublicKey: string | Buffer | undefined; + +const getPublicKey = () => { + if (process.env.PUBLIC_KEY) { + return process.env.PUBLIC_KEY; + } + + cachedPublicKey ??= fs.readFileSync('public.key'); + return cachedPublicKey; +}; + const scopesForClaims = (claims: CompetitionGroupsClaims) => [ ...(Array.isArray(claims.scope) ? claims.scope : claims.scope - ? [claims.scope] - : []), + ? [claims.scope] + : []), ...(claims.scopes ?? []), ]; @@ -70,12 +80,13 @@ export const authMiddlewareVerify = ( if (split[0] !== 'Bearer') { next(null); + return; } const token = split[1]; try { - req.user = jwt.verify(token, PUBLIC_KEY) as User | undefined; + req.user = jwt.verify(token, getPublicKey()) as User | undefined; next(null); } catch (e) { try { @@ -88,6 +99,36 @@ export const authMiddlewareVerify = ( } } }; + +export const authMiddlewareVerifyIgnoringExpiration = ( + req: Request, + _: Response, + next: NextFunction +) => { + const { headers } = req; + + const split = headers?.authorization?.split(/\s/); + + if (!split || split.length < 2) { + next(); + return; + } + + if (split[0] !== 'Bearer') { + next(null); + return; + } + + try { + req.user = jwt.verify(split[1], getPublicKey(), { + ignoreExpiration: true, + }) as User | undefined; + next(null); + } catch (e) { + next(e); + } +}; + export const authMiddlewareDecode = ( req: Request, _: Response, @@ -104,6 +145,7 @@ export const authMiddlewareDecode = ( if (split[0] !== 'Bearer') { next(null); + return; } const token = split[1]; diff --git a/packages/server/controllers/pushSubscriptions.spec.ts b/packages/server/controllers/pushSubscriptions.spec.ts index 5f775cd..f62355a 100644 --- a/packages/server/controllers/pushSubscriptions.spec.ts +++ b/packages/server/controllers/pushSubscriptions.spec.ts @@ -1,9 +1,12 @@ /* eslint-disable import/first */ const transaction = jest.fn(); const pushSubscriptionUpdateMany = jest.fn(); +const pushSubscriptionFindFirstOrThrow = jest.fn(); +const sendAssignmentPush = jest.fn(); const tx = { pushSubscription: { upsert: jest.fn(), + update: jest.fn(), findFirstOrThrow: jest.fn(), }, assignmentWatch: { @@ -18,12 +21,20 @@ jest.mock('../db', () => ({ $transaction: transaction, pushSubscription: { updateMany: pushSubscriptionUpdateMany, + findFirstOrThrow: pushSubscriptionFindFirstOrThrow, }, }, })); +jest.mock('../services/webPush', () => ({ + sendAssignmentPush, +})); + import { disableCompetitionGroupsPushSubscription, + disableCompetitionGroupsPushSubscriptionSession, + testCompetitionGroupsPushSubscriptionSession, + updateCompetitionGroupsPushSubscriptionSession, upsertCompetitionGroupsPushSubscription, } from './pushSubscriptions'; @@ -33,12 +44,20 @@ describe('push subscription controllers', () => { transaction.mockImplementation(async (callback) => callback(tx)); pushSubscriptionUpdateMany.mockReset().mockResolvedValue({ count: 1 }); tx.pushSubscription.upsert.mockReset().mockResolvedValue({ id: 10 }); + tx.pushSubscription.update.mockReset().mockResolvedValue({ id: 10 }); tx.pushSubscription.findFirstOrThrow.mockReset().mockResolvedValue({ id: 10, watches: [], }); tx.assignmentWatch.deleteMany.mockReset().mockResolvedValue({ count: 1 }); tx.assignmentWatch.createMany.mockReset().mockResolvedValue({ count: 2 }); + pushSubscriptionFindFirstOrThrow.mockReset().mockResolvedValue({ + id: 10, + endpoint: 'https://push.example/subscription', + p256dh: 'p256dh', + auth: 'auth', + }); + sendAssignmentPush.mockReset().mockResolvedValue({ success: true, error: null }); }); afterEach(() => { @@ -139,4 +158,173 @@ describe('push subscription controllers', () => { }, }); }); + + it('updates a Competition Groups subscription session and replaces watches', async () => { + tx.pushSubscription.findFirstOrThrow + .mockResolvedValueOnce({ + id: 10, + endpoint: 'https://push.example/subscription', + source: 'competitiongroups', + externalSubject: 'remote-user', + watches: [], + }) + .mockResolvedValueOnce({ + id: 10, + endpoint: 'https://push.example/subscription', + source: 'competitiongroups', + externalSubject: 'remote-user', + watches: [], + }); + + await expect( + updateCompetitionGroupsPushSubscriptionSession({ + endpoint: 'https://push.example/new-subscription', + p256dh: 'new-p256dh', + auth: 'new-auth', + externalSubject: 'remote-user', + pushSubscriptionId: 10, + watches: [{ competitionId: 'Alpha2026', wcaUserId: 123 }], + }) + ).resolves.toEqual({ + id: 10, + endpoint: 'https://push.example/subscription', + source: 'competitiongroups', + externalSubject: 'remote-user', + watches: [], + }); + + expect(tx.pushSubscription.findFirstOrThrow).toHaveBeenCalledWith({ + where: { + id: 10, + source: 'competitiongroups', + externalSubject: 'remote-user', + disabledAt: null, + }, + }); + expect(tx.pushSubscription.update).toHaveBeenCalledWith({ + where: { + id: 10, + }, + data: { + endpoint: 'https://push.example/new-subscription', + p256dh: 'new-p256dh', + auth: 'new-auth', + }, + }); + expect(tx.assignmentWatch.deleteMany).toHaveBeenCalledWith({ + where: { + pushSubscriptionId: 10, + }, + }); + expect(tx.assignmentWatch.createMany).toHaveBeenCalledWith({ + data: [ + { + pushSubscriptionId: 10, + competitionId: 'Alpha2026', + wcaUserId: 123, + }, + ], + skipDuplicates: true, + }); + expect(tx.pushSubscription.findFirstOrThrow).toHaveBeenLastCalledWith({ + where: { + id: 10, + source: 'competitiongroups', + externalSubject: 'remote-user', + disabledAt: null, + }, + include: { + watches: true, + }, + }); + }); + + it('disables a Competition Groups subscription session', async () => { + await expect( + disableCompetitionGroupsPushSubscriptionSession(10, 'remote-user') + ).resolves.toEqual({ count: 1 }); + + expect(pushSubscriptionUpdateMany).toHaveBeenCalledWith({ + where: { + id: 10, + source: 'competitiongroups', + externalSubject: 'remote-user', + disabledAt: null, + }, + data: { + disabledAt: new Date('2026-01-01T10:00:00Z'), + }, + }); + }); + + it('sends a test notification to a Competition Groups subscription session', async () => { + const originalCompetitionGroupsOrigin = process.env.COMPETITION_GROUPS_ORIGIN; + process.env.COMPETITION_GROUPS_ORIGIN = 'https://competitiongroups.com/'; + + try { + await expect( + testCompetitionGroupsPushSubscriptionSession(10, 'remote-user') + ).resolves.toEqual({ success: true, error: null }); + + expect(pushSubscriptionFindFirstOrThrow).toHaveBeenCalledWith({ + where: { + id: 10, + source: 'competitiongroups', + externalSubject: 'remote-user', + disabledAt: null, + }, + }); + expect(sendAssignmentPush).toHaveBeenCalledWith( + { + id: 10, + endpoint: 'https://push.example/subscription', + p256dh: 'p256dh', + auth: 'auth', + }, + { + type: 'assignment-change', + competitionId: 'test-notification', + wcaUserId: 0, + title: 'Test notification', + body: 'Assignment notifications are working.', + url: 'https://competitiongroups.com/settings', + } + ); + } finally { + process.env.COMPETITION_GROUPS_ORIGIN = originalCompetitionGroupsOrigin; + } + }); + + it('disables a Competition Groups session when the push service rejects the subscription auth', async () => { + sendAssignmentPush.mockResolvedValue({ + success: false, + error: { + message: 'Received unexpected response code', + statusCode: 401, + }, + }); + + await expect( + testCompetitionGroupsPushSubscriptionSession(10, 'remote-user') + ).resolves.toEqual({ + success: false, + error: { + message: + 'This browser push subscription is no longer valid. Re-enable assignment notifications and try again.', + statusCode: 401, + }, + }); + + expect(pushSubscriptionUpdateMany).toHaveBeenCalledWith({ + where: { + id: 10, + source: 'competitiongroups', + externalSubject: 'remote-user', + disabledAt: null, + }, + data: { + disabledAt: new Date('2026-01-01T10:00:00Z'), + }, + }); + }); }); diff --git a/packages/server/controllers/pushSubscriptions.ts b/packages/server/controllers/pushSubscriptions.ts index 5fbf4dc..f3a7ef6 100644 --- a/packages/server/controllers/pushSubscriptions.ts +++ b/packages/server/controllers/pushSubscriptions.ts @@ -1,5 +1,6 @@ import prisma from '../db'; import { PushSubscriptionSource } from '../prisma/generated/client'; +import { sendAssignmentPush } from '../services/webPush'; export interface PushWatchInput { competitionId: string; @@ -14,6 +15,15 @@ export interface PushSubscriptionInput { watches: PushWatchInput[]; } +export interface PushSubscriptionSessionInput { + endpoint: string; + p256dh: string; + auth: string; + externalSubject: string; + pushSubscriptionId: number; + watches: PushWatchInput[]; +} + export const upsertCompetitionGroupsPushSubscription = async ({ endpoint, p256dh, @@ -83,3 +93,124 @@ export const disableCompetitionGroupsPushSubscription = async ( disabledAt: new Date(), }, }); + +export const updateCompetitionGroupsPushSubscriptionSession = async ({ + endpoint, + p256dh, + auth, + externalSubject, + pushSubscriptionId, + watches, +}: PushSubscriptionSessionInput) => + prisma.$transaction(async (tx) => { + const existingSubscription = await tx.pushSubscription.findFirstOrThrow({ + where: { + id: pushSubscriptionId, + source: PushSubscriptionSource.competitiongroups, + externalSubject, + disabledAt: null, + }, + }); + + const subscription = await tx.pushSubscription.update({ + where: { + id: existingSubscription.id, + }, + data: { + endpoint, + p256dh, + auth, + }, + }); + + await tx.assignmentWatch.deleteMany({ + where: { + pushSubscriptionId: subscription.id, + }, + }); + + if (watches.length) { + await tx.assignmentWatch.createMany({ + data: watches.map((watch) => ({ + pushSubscriptionId: subscription.id, + competitionId: watch.competitionId, + wcaUserId: watch.wcaUserId, + })), + skipDuplicates: true, + }); + } + + return tx.pushSubscription.findFirstOrThrow({ + where: { + id: subscription.id, + source: PushSubscriptionSource.competitiongroups, + externalSubject, + disabledAt: null, + }, + include: { + watches: true, + }, + }); + }); + +export const disableCompetitionGroupsPushSubscriptionSession = async ( + pushSubscriptionId: number, + externalSubject: string +) => + prisma.pushSubscription.updateMany({ + where: { + id: pushSubscriptionId, + source: PushSubscriptionSource.competitiongroups, + externalSubject, + disabledAt: null, + }, + data: { + disabledAt: new Date(), + }, + }); + +export const testCompetitionGroupsPushSubscriptionSession = async ( + pushSubscriptionId: number, + externalSubject: string +) => { + const subscription = await prisma.pushSubscription.findFirstOrThrow({ + where: { + id: pushSubscriptionId, + source: PushSubscriptionSource.competitiongroups, + externalSubject, + disabledAt: null, + }, + }); + + const result = await sendAssignmentPush(subscription, { + type: 'assignment-change', + competitionId: 'test-notification', + wcaUserId: 0, + title: 'Test notification', + body: 'Assignment notifications are working.', + url: process.env.COMPETITION_GROUPS_ORIGIN + ? `${process.env.COMPETITION_GROUPS_ORIGIN.replace(/\/$/, '')}/settings` + : undefined, + }); + + if ( + !result.success && + (result.error?.statusCode === 401 || result.error?.statusCode === 403) + ) { + await disableCompetitionGroupsPushSubscriptionSession( + pushSubscriptionId, + externalSubject + ); + + return { + success: false, + error: { + ...result.error, + message: + 'This browser push subscription is no longer valid. Re-enable assignment notifications and try again.', + }, + }; + } + + return result; +}; diff --git a/packages/server/controllers/webhooks.spec.ts b/packages/server/controllers/webhooks.spec.ts index 486938a..e2f48ad 100644 --- a/packages/server/controllers/webhooks.spec.ts +++ b/packages/server/controllers/webhooks.spec.ts @@ -1,10 +1,20 @@ /* eslint-disable import/first */ +import { HTTPMethod, Webhook } from '../prisma/generated/client'; + const fetchMock = jest.fn(); const webhookFindMany = jest.fn(); +const webhookAgent = { pinned: true }; +const resolvePublicWebhookUrl = jest.fn(async (url: string) => ({ + url, + agent: webhookAgent, +})); -jest.mock('node-fetch', () => ({ - __esModule: true, - default: fetchMock, +jest.mock('../lib/fetchWithTimeout', () => ({ + fetchWithTimeout: fetchMock, +})); + +jest.mock('../lib/webhookUrls', () => ({ + resolvePublicWebhookUrl, })); jest.mock('../db', () => ({ @@ -22,11 +32,11 @@ import { webhookFetch, } from './webhooks'; -const webhook = { +const webhook: Webhook = { id: 1, competitionId: 'TestComp2026', url: 'https://hooks.example/notify', - method: 'POST', + method: HTTPMethod.POST, headers: [{ key: 'X-Test', value: 'true' }], }; @@ -34,6 +44,7 @@ describe('webhook controllers', () => { beforeEach(() => { fetchMock.mockReset(); webhookFindMany.mockReset(); + resolvePublicWebhookUrl.mockClear(); }); it('sends JSON data with stored webhook headers', async () => { @@ -54,6 +65,8 @@ describe('webhook controllers', () => { expect(fetchMock).toHaveBeenCalledWith('https://hooks.example/notify', { method: 'POST', + size: 64 * 1024, + agent: webhookAgent, headers: { 'Content-Type': 'application/json', 'X-Test': 'true', @@ -65,6 +78,59 @@ describe('webhook controllers', () => { }); }); + it('sends webhooks without stored headers', async () => { + const response = { + ok: true, + status: 200, + statusText: 'OK', + text: jest.fn().mockResolvedValue(''), + }; + fetchMock.mockResolvedValue(response); + + const webhookWithoutHeaders: Webhook = { ...webhook, headers: null }; + + await webhookFetch(webhookWithoutHeaders, { + competitionId: 'TestComp2026', + }); + + expect(fetchMock).toHaveBeenCalledWith('https://hooks.example/notify', { + method: 'POST', + size: 64 * 1024, + agent: webhookAgent, + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + competitionId: 'TestComp2026', + }), + }); + }); + + it('does not attach a JSON body to GET webhooks', async () => { + const response = { + ok: true, + status: 200, + statusText: 'OK', + text: jest.fn().mockResolvedValue(''), + }; + fetchMock.mockResolvedValue(response); + + const getWebhook: Webhook = { ...webhook, method: HTTPMethod.GET }; + + await webhookFetch(getWebhook, { + competitionId: 'TestComp2026', + }); + + expect(fetchMock).toHaveBeenCalledWith('https://hooks.example/notify', { + method: 'GET', + size: 64 * 1024, + agent: webhookAgent, + headers: { + 'X-Test': 'true', + }, + }); + }); + it('throws with response details for failed webhook responses', async () => { fetchMock.mockResolvedValue({ ok: false, diff --git a/packages/server/controllers/webhooks.ts b/packages/server/controllers/webhooks.ts index 8d2b53d..9c74467 100644 --- a/packages/server/controllers/webhooks.ts +++ b/packages/server/controllers/webhooks.ts @@ -1,23 +1,45 @@ -import fetch from 'node-fetch'; import prisma from '../db'; import { Header } from '../generated/graphql'; import { Webhook } from '../prisma/generated/client'; +import { fetchWithTimeout } from '../lib/fetchWithTimeout'; +import { resolvePublicWebhookUrl } from '../lib/webhookUrls'; +import { settleWithConcurrency } from '../lib/runWithConcurrency'; + +const BODYLESS_METHODS = new Set(['GET', 'HEAD']); +const MAX_WEBHOOK_ERROR_BODY_LENGTH = 2048; +const MAX_WEBHOOK_RESPONSE_SIZE_BYTES = 64 * 1024; + +const webhookConcurrency = () => { + const value = Number(process.env.WEBHOOK_DELIVERY_CONCURRENCY); + return Number.isInteger(value) && value > 0 ? value : 5; +}; + +const truncate = (value: string, maxLength: number) => + value.length > maxLength ? `${value.slice(0, maxLength)}...` : value; export const webhookFetch = async ( webhook: Webhook, data: Record ) => { - const headers = (webhook.headers as Header[]).reduce( + const webhookHeaders = Array.isArray(webhook.headers) + ? (webhook.headers as Header[]) + : []; + const headers = webhookHeaders.reduce( (acc, h) => ({ ...acc, [h.key]: h.value }), {} ); - return fetch(webhook.url, { + const resolvedUrl = await resolvePublicWebhookUrl(webhook.url); + const canSendBody = !BODYLESS_METHODS.has(webhook.method); + + return fetchWithTimeout(resolvedUrl.url, { method: webhook.method, + size: MAX_WEBHOOK_RESPONSE_SIZE_BYTES, + ...(resolvedUrl.agent && { agent: resolvedUrl.agent }), headers: { - 'Content-Type': 'application/json', + ...(canSendBody && { 'Content-Type': 'application/json' }), ...headers, }, - body: JSON.stringify(data), + ...(canSendBody && { body: JSON.stringify(data) }), }); }; @@ -28,10 +50,10 @@ export const sendWebhook = async ( const response = await webhookFetch(webhook, data); if (!response.ok) { + const body = truncate(await response.text(), MAX_WEBHOOK_ERROR_BODY_LENGTH); + throw new Error( - `Webhook failed with status code ${response.status} and message ${ - response.statusText - }: ${await response.text()}` + `Webhook failed with status code ${response.status} and message ${response.statusText}: ${body}` ); } @@ -51,5 +73,9 @@ export const sendWebhooksForCompetition = async ( }, }); - return Promise.allSettled(webhooks.map(async (w) => sendWebhook(w, data))); + return settleWithConcurrency( + webhooks, + async (webhook) => sendWebhook(webhook, data), + webhookConcurrency() + ); }; diff --git a/packages/server/graphql/datasources/WcaApi.ts b/packages/server/graphql/datasources/WcaApi.ts index 933c494..9c0c209 100644 --- a/packages/server/graphql/datasources/WcaApi.ts +++ b/packages/server/graphql/datasources/WcaApi.ts @@ -5,12 +5,15 @@ import { } from '@apollo/datasource-rest'; import { isMocksMode } from '../../mocks/config'; import { getMockCompetition } from '../../mocks/wca'; +import { fetchWithTimeout } from '../../lib/fetchWithTimeout'; class WcaApi extends RESTDataSource { accessToken?: string; constructor(origin: string, accessToken?: string) { - super(); + super({ + fetch: async (url, init) => fetchWithTimeout(url, init, { retries: 2 }), + }); this.baseURL = origin + '/api/v0/'; this.accessToken = accessToken; } diff --git a/packages/server/graphql/resolvers/mutations/ActivityMutations.spec.ts b/packages/server/graphql/resolvers/mutations/ActivityMutations.spec.ts index 5754fd8..1337a52 100644 --- a/packages/server/graphql/resolvers/mutations/ActivityMutations.spec.ts +++ b/packages/server/graphql/resolvers/mutations/ActivityMutations.spec.ts @@ -122,31 +122,48 @@ const callCancelScheduledActivity = cancelScheduledActivity as ( info: unknown ) => Promise; -const createDb = () => ({ - competitionAccess: { - findFirst: jest.fn().mockResolvedValue({ userId: 123 }), - }, - competition: { - findFirst: jest.fn().mockResolvedValue({ id: 'TestComp2026' }), - }, - activityHistory: { - update: jest.fn().mockImplementation(async ({ where }) => ({ - competitionId: where.competitionId_activityId.competitionId, - activityId: where.competitionId_activityId.activityId, - })), - updateMany: jest.fn().mockResolvedValue({ count: 2 }), - findMany: jest.fn().mockResolvedValue([ - { competitionId: 'TestComp2026', activityId: 1 }, - { competitionId: 'TestComp2026', activityId: 2 }, - ]), - findUnique: jest.fn().mockResolvedValue({ - competitionId: 'TestComp2026', - activityId: 1, - startTime: new Date('2026-01-01T10:00:00Z'), - endTime: null, - }), - }, -}); +const createDb = () => { + const db = { + competitionAccess: { + findFirst: jest.fn().mockResolvedValue({ userId: 123 }), + }, + competition: { + findFirst: jest.fn().mockResolvedValue({ id: 'TestComp2026' }), + }, + activityHistory: { + upsert: jest.fn().mockImplementation(async ({ where, update, create }) => ({ + competitionId: + where.competitionId_activityId?.competitionId ?? create.competitionId, + activityId: where.competitionId_activityId?.activityId ?? create.activityId, + ...create, + ...update, + })), + update: jest.fn().mockImplementation(async ({ where, data = {} }) => ({ + competitionId: where.competitionId_activityId.competitionId, + activityId: where.competitionId_activityId.activityId, + ...data, + })), + updateMany: jest.fn().mockResolvedValue({ count: 2 }), + findMany: jest.fn().mockResolvedValue([ + { competitionId: 'TestComp2026', activityId: 1 }, + { competitionId: 'TestComp2026', activityId: 2 }, + ]), + findUnique: jest.fn().mockResolvedValue({ + competitionId: 'TestComp2026', + activityId: 1, + startTime: new Date('2026-01-01T10:00:00Z'), + endTime: null, + }), + }, + }; + + return { + ...db, + $transaction: jest.fn(async (callback: (tx: typeof db) => unknown) => + callback(db) + ), + }; +}; describe('ActivityMutations scheduling', () => { beforeEach(() => { @@ -232,6 +249,44 @@ describe('ActivityMutations scheduling', () => { expect(mockStartActivityController).not.toHaveBeenCalled(); }); + it('allows Competition Groups users scoped to the competition without delegate access rows', async () => { + const db = createDb(); + db.competitionAccess.findFirst.mockResolvedValue(null); + + await expect( + callStartActivity( + {}, + { + competitionId: 'testcomp2026', + activityId: 1, + startTime: '2026-01-01T09:55:00Z', + }, + { + db, + user: userFixture({ + competitionGroups: { + competitionIds: ['TestComp2026'], + scopes: ['notifycomp.remote'], + }, + }), + wcaApi: { + getWcif: jest.fn().mockResolvedValue({ + id: 'TestComp2026', + persons: [], + }), + }, + }, + {} + ) + ).resolves.toEqual({ + competitionId: 'testcomp2026', + activityId: 1, + startTime: new Date('2026-01-01T09:55:00Z'), + }); + + expect(db.competitionAccess.findFirst).not.toHaveBeenCalled(); + }); + it('rejects users without delegate access', async () => { const db = createDb(); db.competitionAccess.findFirst.mockResolvedValue(null); @@ -350,7 +405,12 @@ describe('ActivityMutations scheduling', () => { ).rejects.toThrow('Only a running activity can have its end time queued'); }); - it('schedules multiple activities and cancels stale jobs first', async () => { + it('schedules multiple activities transactionally and cancels stale jobs after persistence', async () => { + const db = createDb(); + const pubsub = { + publish: jest.fn().mockResolvedValue(undefined), + }; + await expect( callScheduleActivities( {}, @@ -360,11 +420,13 @@ describe('ActivityMutations scheduling', () => { scheduledStartTime: '2026-01-01T10:05:00Z', scheduledEndTime: null, }, - { db: createDb(), user: userFixture() }, + { db, user: userFixture(), pubsub }, {} ) ).resolves.toHaveLength(2); + expect(db.$transaction).toHaveBeenCalled(); + expect(db.activityHistory.upsert).toHaveBeenCalledTimes(2); expect(mockCancelScheduledActivityJob).toHaveBeenCalledTimes(2); expect(mockCancelScheduledActivityJob).toHaveBeenCalledWith( 'TestComp2026', @@ -374,7 +436,8 @@ describe('ActivityMutations scheduling', () => { 'TestComp2026', 2 ); - expect(mockScheduleActivityController).toHaveBeenCalledTimes(2); + expect(pubsub.publish).toHaveBeenCalledTimes(2); + expect(mockScheduleActivityController).not.toHaveBeenCalled(); expect(mockScheduleActivityJob).toHaveBeenCalledTimes(2); }); @@ -386,7 +449,6 @@ describe('ActivityMutations scheduling', () => { persons: [], }), }; - await expect( callStartActivity( {}, @@ -434,6 +496,9 @@ describe('ActivityMutations scheduling', () => { persons: [], }), }; + const pubsub = { + publish: jest.fn().mockResolvedValue(undefined), + }; await expect( callStartActivities( @@ -443,10 +508,10 @@ describe('ActivityMutations scheduling', () => { activityIds: [1, 2], startTime: '2026-01-01T09:55:00Z', }, - { db, user: userFixture(), wcaApi }, + { db, user: userFixture(), wcaApi, pubsub }, {} ) - ).resolves.toEqual([ + ).resolves.toMatchObject([ { competitionId: 'TestComp2026', activityId: 1, @@ -459,8 +524,11 @@ describe('ActivityMutations scheduling', () => { }, ]); + expect(db.$transaction).toHaveBeenCalled(); + expect(db.activityHistory.upsert).toHaveBeenCalledTimes(2); expect(mockCancelScheduledActivityJob).toHaveBeenCalledTimes(2); - expect(mockStartActivityController).toHaveBeenCalledTimes(2); + expect(pubsub.publish).toHaveBeenCalledTimes(2); + expect(mockStartActivityController).not.toHaveBeenCalled(); expect(mockDetermineAndScheduleCompetition).toHaveBeenCalledWith({ id: 'TestComp2026', }); @@ -540,7 +608,7 @@ describe('ActivityMutations scheduling', () => { { db, user: userFixture(), pubsub }, {} ) - ).resolves.toEqual([ + ).resolves.toMatchObject([ { competitionId: 'TestComp2026', activityId: 1 }, { competitionId: 'TestComp2026', activityId: 2 }, ]); @@ -642,7 +710,7 @@ describe('ActivityMutations scheduling', () => { { db, user: userFixture(), pubsub }, {} ) - ).resolves.toEqual({ competitionId: 'TestComp2026', activityId: 1 }); + ).resolves.toMatchObject({ competitionId: 'TestComp2026', activityId: 1 }); expect(db.activityHistory.update).toHaveBeenCalledWith({ where: { @@ -659,25 +727,36 @@ describe('ActivityMutations scheduling', () => { }, }); expect(pubsub.publish).toHaveBeenCalledWith('ACTIVITY_UPDATED', { - activityUpdated: { competitionId: 'TestComp2026', activityId: 1 }, + activityUpdated: expect.objectContaining({ + competitionId: 'TestComp2026', + activityId: 1, + }), }); }); - it('cancels queued activity times for multiple activities', async () => { + it('cancels queued activity times for multiple activities transactionally', async () => { + const db = createDb(); + const pubsub = { + publish: jest.fn().mockResolvedValue(undefined), + }; + await expect( callCancelScheduledActivities( {}, { competitionId: 'TestComp2026', activityIds: [1, 2] }, - { db: createDb(), user: userFixture() }, + { db, user: userFixture(), pubsub }, {} ) - ).resolves.toEqual([ + ).resolves.toMatchObject([ { competitionId: 'TestComp2026', activityId: 1 }, { competitionId: 'TestComp2026', activityId: 2 }, ]); + expect(db.$transaction).toHaveBeenCalled(); + expect(db.activityHistory.update).toHaveBeenCalledTimes(2); expect(mockCancelScheduledActivityJob).toHaveBeenCalledTimes(2); - expect(mockCancelScheduledActivityController).toHaveBeenCalledTimes(2); + expect(pubsub.publish).toHaveBeenCalledTimes(2); + expect(mockCancelScheduledActivityController).not.toHaveBeenCalled(); }); it('cancels one queued activity time', async () => { diff --git a/packages/server/graphql/resolvers/mutations/ActivityMutations.ts b/packages/server/graphql/resolvers/mutations/ActivityMutations.ts index 3e0258f..b752a5c 100644 --- a/packages/server/graphql/resolvers/mutations/ActivityMutations.ts +++ b/packages/server/graphql/resolvers/mutations/ActivityMutations.ts @@ -23,11 +23,16 @@ const isAuthorized = async ( return; } - if ( - user.competitionGroups?.competitionIds && - !user.competitionGroups.competitionIds.includes(competitionId) - ) { - throw new Error('Not Authorized'); + if (user.competitionGroups) { + const allowedCompetitionIds = ( + user.competitionGroups.competitionIds ?? [] + ).map((id) => id.toLowerCase()); + + if (!allowedCompetitionIds.includes(competitionId.toLowerCase())) { + throw new Error('Not Authorized'); + } + + return; } const compAccess = await db.competitionAccess.findFirst({ @@ -131,6 +136,20 @@ const ensureRunningActivity = async ( } }; +const publishActivityUpdates = async ( + pubsub: AppContext['pubsub'], + activities: unknown[] +) => { + await Promise.all( + activities.map( + async (activity) => + await pubsub.publish('ACTIVITY_UPDATED', { + activityUpdated: activity, + }) + ) + ); +}; + export const startActivity: MutationResolvers['startActivity'] = async (_, { competitionId, activityId, startTime }, { db, user, wcaApi }) => { await isAuthorized(db, competitionId, user); @@ -171,21 +190,47 @@ export const startActivity: MutationResolvers['startActivity'] = }; export const startActivities: MutationResolvers['startActivities'] = - async (_, { competitionId, activityIds, startTime }, { db, user, wcaApi }) => { + async ( + _, + { competitionId, activityIds, startTime }, + { db, user, wcaApi, pubsub } + ) => { await isAuthorized(db, competitionId, user); const parsedStartTime = parseStartTime(startTime); + const effectiveStartTime = parsedStartTime ?? new Date(); + + const activities = await db.$transaction(async (tx) => + Promise.all( + activityIds.map(async (activityId) => + tx.activityHistory.upsert({ + where: { + competitionId_activityId: { + competitionId, + activityId, + }, + }, + update: { + startTime: effectiveStartTime, + endTime: null, + scheduledStartTime: null, + scheduledEndTime: null, + }, + create: { + competitionId, + activityId, + startTime: effectiveStartTime, + endTime: null, + }, + }) + ) + ) + ); activityIds.forEach((activityId) => { cancelScheduledActivityJob(competitionId, activityId); }); + await publishActivityUpdates(pubsub, activities); - const activities = await Promise.all( - activityIds.map(async (activityId) => - activitiesController.startActivity(competitionId, activityId, { - startTime: parsedStartTime, - }) - ) - ); await scheduleAutoAdvanceIfEnabled(db, competitionId); const wcif = await wcaApi.getWcif(competitionId); @@ -233,39 +278,32 @@ export const stopActivities: MutationResolvers['stopActivities'] = async (_, { competitionId, activityIds }, { db, user, pubsub }) => { await isAuthorized(db, competitionId, user); - activityIds.forEach((activityId) => { - cancelScheduledActivityJob(competitionId, activityId); - }); - - const activities = await Promise.all( - activityIds.map(async (activityId) => { - const activity = await db.activityHistory.update({ - where: { - competitionId_activityId: { - competitionId, - activityId, + const endTime = new Date(); + const activities = await db.$transaction(async (tx) => + Promise.all( + activityIds.map(async (activityId) => + tx.activityHistory.update({ + where: { + competitionId_activityId: { + competitionId, + activityId, + }, + }, + data: { + endTime, + scheduledStartTime: null, + scheduledEndTime: null, }, - }, - data: { - endTime: new Date(), - scheduledStartTime: null, - scheduledEndTime: null, - }, - }); - - return activity; - }) - ); - - await Promise.all( - activities.map( - async (activity) => - await pubsub.publish('ACTIVITY_UPDATED', { - activityUpdated: activity, }) + ) ) ); + activityIds.forEach((activityId) => { + cancelScheduledActivityJob(competitionId, activityId); + }); + await publishActivityUpdates(pubsub, activities); + await scheduleAutoAdvanceIfEnabled(db, competitionId); return activities; @@ -275,6 +313,38 @@ export const resetActivities: MutationResolvers['resetActivities'] = async (_, { competitionId, activityIds }, { db, user, pubsub }) => { await isAuthorized(db, competitionId, user); + const { findActivities } = await db.$transaction(async (tx) => { + await tx.activityHistory.updateMany({ + where: { + competitionId, + ...(activityIds && { + activityId: { + in: activityIds, + }, + }), + }, + data: { + startTime: null, + endTime: null, + scheduledStartTime: null, + scheduledEndTime: null, + }, + }); + + return { + findActivities: await tx.activityHistory.findMany({ + where: { + competitionId, + ...(activityIds && { + activityId: { + in: activityIds, + }, + }), + }, + }), + }; + }); + if (activityIds) { activityIds.forEach((activityId) => { cancelScheduledActivityJob(competitionId, activityId); @@ -283,44 +353,7 @@ export const resetActivities: MutationResolvers['resetActivities'] = cancelCompetitionActivityJobs(competitionId); } - await db.activityHistory.updateMany({ - where: { - competitionId, - ...(activityIds && { - activityId: { - in: activityIds, - }, - }), - }, - data: { - startTime: null, - endTime: null, - scheduledStartTime: null, - scheduledEndTime: null, - }, - }); - - const findActivities = await db.activityHistory.findMany({ - where: { - competitionId, - ...(activityIds && { - activityId: { - in: activityIds, - }, - }), - }, - }); - - console.log(findActivities); - - await Promise.all( - findActivities.map( - async (activity) => - await pubsub.publish('ACTIVITY_UPDATED', { - activityUpdated: activity, - }) - ) - ); + await publishActivityUpdates(pubsub, findActivities); return findActivities; }; @@ -356,7 +389,7 @@ export const scheduleActivity: MutationResolvers['scheduleActivity'] async ( _, { competitionId, activityId, scheduledStartTime, scheduledEndTime }, - { db, user } + { db, user, pubsub } ) => { await isAuthorized(db, competitionId, user); @@ -389,7 +422,7 @@ export const scheduleActivities: MutationResolvers['scheduleActiviti async ( _, { competitionId, activityIds, scheduledStartTime, scheduledEndTime }, - { db, user } + { db, user, pubsub } ) => { await isAuthorized(db, competitionId, user); @@ -413,15 +446,45 @@ export const scheduleActivities: MutationResolvers['scheduleActiviti ? { scheduledStartTime: parseScheduledTime(scheduledStartTime) } : { scheduledEndTime: parseScheduledTime(scheduledEndTime) }; + const activities = await db.$transaction(async (tx) => + Promise.all( + activityIds.map(async (activityId) => + tx.activityHistory.upsert({ + where: { + competitionId_activityId: { + competitionId, + activityId, + }, + }, + update: { + ...('scheduledStartTime' in props && { + scheduledStartTime: props.scheduledStartTime, + scheduledEndTime: null, + }), + ...('scheduledEndTime' in props && { + scheduledStartTime: null, + scheduledEndTime: props.scheduledEndTime, + }), + }, + create: { + competitionId, + activityId, + ...('scheduledStartTime' in props && { + scheduledStartTime: props.scheduledStartTime, + }), + ...('scheduledEndTime' in props && { + scheduledEndTime: props.scheduledEndTime, + }), + }, + }) + ) + ) + ); + activityIds.forEach((activityId) => { cancelScheduledActivityJob(competitionId, activityId); }); - - const activities = await Promise.all( - activityIds.map(async (activityId) => - activitiesController.scheduleActivity(competitionId, activityId, props) - ) - ); + await publishActivityUpdates(pubsub, activities); await Promise.all( activities.map(async (activity) => scheduleActivityJob(activity)) @@ -443,16 +506,32 @@ export const cancelScheduledActivity: MutationResolvers['cancelSched }; export const cancelScheduledActivities: MutationResolvers['cancelScheduledActivities'] = - async (_, { competitionId, activityIds }, { db, user }) => { + async (_, { competitionId, activityIds }, { db, user, pubsub }) => { await isAuthorized(db, competitionId, user); + const activities = await db.$transaction(async (tx) => + Promise.all( + activityIds.map(async (activityId) => + tx.activityHistory.update({ + where: { + competitionId_activityId: { + competitionId, + activityId, + }, + }, + data: { + scheduledStartTime: null, + scheduledEndTime: null, + }, + }) + ) + ) + ); + activityIds.forEach((activityId) => { cancelScheduledActivityJob(competitionId, activityId); }); + await publishActivityUpdates(pubsub, activities); - return Promise.all( - activityIds.map(async (activityId) => - activitiesController.cancelScheduledActivity(competitionId, activityId) - ) - ); + return activities; }; diff --git a/packages/server/graphql/resolvers/mutations/CompetitionMutations.spec.ts b/packages/server/graphql/resolvers/mutations/CompetitionMutations.spec.ts index 336ef94..cf561c8 100644 --- a/packages/server/graphql/resolvers/mutations/CompetitionMutations.spec.ts +++ b/packages/server/graphql/resolvers/mutations/CompetitionMutations.spec.ts @@ -34,26 +34,40 @@ const callUpdateAutoAdvance = updateAutoAdvance as ( info: unknown ) => Promise; -const createDb = () => ({ - competitionAccess: { - findFirst: jest.fn().mockResolvedValue({ userId: 123 }), - }, - activityHistory: { - updateMany: jest.fn().mockResolvedValue({ count: 2 }), - }, - competition: { - create: jest.fn().mockResolvedValue({ - id: 'TestComp2026', - name: 'Test Competition', - competitionAccess: [{ userId: 123, roomId: 0 }], - }), - update: jest.fn().mockResolvedValue({ - id: 'TestComp2026', - autoAdvance: false, - autoAdvanceDelay: 0, - }), - }, -}); +const createDb = () => { + const db = { + competitionAccess: { + findFirst: jest.fn().mockResolvedValue({ userId: 123 }), + createMany: jest.fn().mockResolvedValue({ count: 3 }), + }, + activityHistory: { + updateMany: jest.fn().mockResolvedValue({ count: 2 }), + }, + competition: { + upsert: jest.fn().mockResolvedValue({ + id: 'TestComp2026', + name: 'Test Competition', + }), + findFirstOrThrow: jest.fn().mockResolvedValue({ + id: 'TestComp2026', + name: 'Test Competition', + competitionAccess: [{ userId: 123, roomId: 0 }], + }), + update: jest.fn().mockResolvedValue({ + id: 'TestComp2026', + autoAdvance: false, + autoAdvanceDelay: 0, + }), + }, + }; + + return { + ...db, + $transaction: jest.fn(async (callback: (tx: typeof db) => unknown) => + callback(db) + ), + }; +}; describe('CompetitionMutations.importCompetition', () => { it('rejects unauthenticated imports', async () => { @@ -67,7 +81,7 @@ describe('CompetitionMutations.importCompetition', () => { ).rejects.toThrow('Not Authenticated'); }); - it('creates a competition with delegate and organizer access', async () => { + it('upserts a competition with delegate and organizer access', async () => { const db = createDb(); const wcaApi = { getWcif: jest.fn().mockResolvedValue({ @@ -101,25 +115,60 @@ describe('CompetitionMutations.importCompetition', () => { }); expect(wcaApi.getWcif).toHaveBeenCalledWith('TestComp2026'); - expect(db.competition.create).toHaveBeenCalledWith({ - include: { - competitionAccess: true, + expect(db.$transaction).toHaveBeenCalled(); + expect(db.competition.upsert).toHaveBeenCalledWith({ + where: { + id: 'TestComp2026', }, - data: { + update: { + name: 'Test Competition', + startDate: '2026-05-01', + endDate: '2026-05-03', + country: 'US', + }, + create: { id: 'TestComp2026', name: 'Test Competition', startDate: '2026-05-01', endDate: '2026-05-03', country: 'US', - competitionAccess: { - create: [ - { userId: 111, roomId: 0 }, - { userId: 222, roomId: 0 }, - { userId: 333, roomId: 0 }, - ], - }, }, }); + expect(db.competitionAccess.createMany).toHaveBeenCalledWith({ + data: [ + { competitionId: 'TestComp2026', userId: 111, roomId: 0 }, + { competitionId: 'TestComp2026', userId: 222, roomId: 0 }, + { competitionId: 'TestComp2026', userId: 333, roomId: 0 }, + ], + skipDuplicates: true, + }); + }); + + it('rejects imports when the WCIF has invalid schedule metadata', async () => { + const db = createDb(); + const wcaApi = { + getWcif: jest.fn().mockResolvedValue({ + id: 'TestComp2026', + name: 'Test Competition', + persons: [], + schedule: { + startDate: 'not-a-date', + numberOfDays: 1, + venues: [{ countryIso2: 'US' }], + }, + }), + }; + + await expect( + callImportCompetition( + {}, + { competitionId: 'TestComp2026' }, + { db, user: userFixture(), wcaApi }, + {} + ) + ).rejects.toThrow('WCIF competition has an invalid start date'); + + expect(db.competition.upsert).not.toHaveBeenCalled(); }); }); @@ -175,6 +224,88 @@ describe('CompetitionMutations.updateAutoAdvance', () => { }); }); + it('allows Competition Groups users scoped to the competition without access rows', async () => { + const db = createDb(); + db.competitionAccess.findFirst.mockResolvedValue(null); + db.competition.update.mockResolvedValue({ + id: 'TestComp2026', + autoAdvance: false, + autoAdvanceDelay: 45, + }); + + await expect( + callUpdateAutoAdvance( + {}, + { + competitionId: 'testcomp2026', + autoAdvance: null, + autoAdvanceDelay: 45, + }, + { + db, + user: userFixture({ + competitionGroups: { + competitionIds: ['TestComp2026'], + scopes: ['notifycomp.remote'], + }, + }), + }, + {} + ) + ).resolves.toEqual({ + id: 'TestComp2026', + autoAdvance: false, + autoAdvanceDelay: 45, + }); + + expect(db.competitionAccess.findFirst).not.toHaveBeenCalled(); + }); + + it('does not schedule when only updating the auto-advance delay', async () => { + const db = createDb(); + + await callUpdateAutoAdvance( + {}, + { + competitionId: 'TestComp2026', + autoAdvance: null, + autoAdvanceDelay: 45, + }, + { db, user: userFixture() }, + {} + ); + + expect(mockFetchCompWithNoScheduledActivities).not.toHaveBeenCalled(); + expect(mockDetermineAndScheduleCompetition).not.toHaveBeenCalled(); + expect(db.competition.update).toHaveBeenCalledWith({ + where: { + id: 'TestComp2026', + }, + data: { + autoAdvanceDelay: 45, + }, + }); + }); + + it('rejects negative auto-advance delays', async () => { + const db = createDb(); + + await expect( + callUpdateAutoAdvance( + {}, + { + competitionId: 'TestComp2026', + autoAdvance: null, + autoAdvanceDelay: -1, + }, + { db, user: userFixture() }, + {} + ) + ).rejects.toThrow('Auto advance delay must be non-negative'); + + expect(db.competition.update).not.toHaveBeenCalled(); + }); + it('schedules the competition when enabling auto advance and no queued activities exist', async () => { const db = createDb(); const competition = { id: 'TestComp2026', activityHistory: [] }; diff --git a/packages/server/graphql/resolvers/mutations/CompetitionMutations.ts b/packages/server/graphql/resolvers/mutations/CompetitionMutations.ts index 1f45fdb..a5c7dea 100644 --- a/packages/server/graphql/resolvers/mutations/CompetitionMutations.ts +++ b/packages/server/graphql/resolvers/mutations/CompetitionMutations.ts @@ -19,11 +19,16 @@ const isAuthorized = async ( return; } - if ( - user.competitionGroups?.competitionIds && - !user.competitionGroups.competitionIds.includes(competitionId) - ) { - throw new Error('Not Authorized'); + if (user.competitionGroups) { + const allowedCompetitionIds = ( + user.competitionGroups.competitionIds ?? [] + ).map((id) => id.toLowerCase()); + + if (!allowedCompetitionIds.includes(competitionId.toLowerCase())) { + throw new Error('Not Authorized'); + } + + return; } const compAccess = await db.competitionAccess.findFirst({ @@ -41,6 +46,52 @@ const isAuthorized = async ( } }; +const getCompetitionImportMetadata = ( + competition: Awaited>, + requestedCompetitionId: string +) => { + if ( + !competition?.id || + competition.id.toLowerCase() !== requestedCompetitionId.toLowerCase() + ) { + throw new Error('WCIF competition ID did not match requested competition'); + } + + if (!competition.name) { + throw new Error('WCIF competition is missing a name'); + } + + if ( + !competition.schedule?.startDate || + !Number.isInteger(competition.schedule.numberOfDays) || + competition.schedule.numberOfDays < 1 + ) { + throw new Error('WCIF competition has an invalid schedule'); + } + + const country = competition.schedule.venues?.[0]?.countryIso2; + if (!country) { + throw new Error('WCIF competition is missing a venue country'); + } + + const startDate = new Date(competition.schedule.startDate); + if (Number.isNaN(startDate.getTime())) { + throw new Error('WCIF competition has an invalid start date'); + } + + const endDate = new Date( + startDate.getTime() + + 1000 * 60 * 60 * 24 * (competition.schedule.numberOfDays - 1) + ) + .toISOString() + .split('T')[0]; + + return { + country, + endDate, + }; +}; + export const importCompetition: MutationResolvers['importCompetition'] = async (_, { competitionId }, { db, wcaApi, user }) => { if (!user) { @@ -48,6 +99,10 @@ export const importCompetition: MutationResolvers['importCompetition } const competition = await wcaApi.getWcif(competitionId); + const { country, endDate } = getCompetitionImportMetadata( + competition, + competitionId + ); const delegatesAndOrganizers = competition.persons.filter((person) => { const roles = person.roles ?? []; @@ -57,32 +112,49 @@ export const importCompetition: MutationResolvers['importCompetition roles.includes('organizer') ); }); + const accessRows = delegatesAndOrganizers + .filter((person) => Number.isInteger(person.wcaUserId)) + .map((person) => ({ + competitionId: competition.id, + userId: person.wcaUserId, + roomId: 0, + })); + + const newCompetition = await db.$transaction(async (tx) => { + await tx.competition.upsert({ + where: { + id: competition.id, + }, + update: { + name: competition.name, + startDate: competition.schedule.startDate, + endDate, + country, + }, + create: { + id: competition.id, + name: competition.name, + startDate: competition.schedule.startDate, + endDate, + country, + }, + }); - // Have to calculate end Date - const endDate = new Date( - new Date(competition.schedule.startDate).getTime() + - 1000 * 60 * 60 * 24 * (competition.schedule.numberOfDays - 1) - ) - .toISOString() - .split('T')[0]; - - const newCompetition = await db.competition.create({ - include: { - competitionAccess: true, - }, - data: { - id: competition.id, - name: competition.name, - startDate: competition.schedule.startDate, - endDate, - country: competition.schedule.venues[0].countryIso2, - competitionAccess: { - create: delegatesAndOrganizers.map((person) => ({ - userId: person.wcaUserId, - roomId: 0, - })), + if (accessRows.length) { + await tx.competitionAccess.createMany({ + data: accessRows, + skipDuplicates: true, + }); + } + + return tx.competition.findFirstOrThrow({ + where: { + id: competition.id, }, - }, + include: { + competitionAccess: true, + }, + }); }); return newCompetition as Competition; @@ -92,38 +164,64 @@ export const updateAutoAdvance: MutationResolvers['updateAutoAdvance async (_, { competitionId, autoAdvance, autoAdvanceDelay }, { db, user }) => { await isAuthorized(db, competitionId, user); + if ( + autoAdvanceDelay !== null && + autoAdvanceDelay !== undefined && + autoAdvanceDelay < 0 + ) { + throw new Error('Auto advance delay must be non-negative'); + } + if (autoAdvance === false) { console.log('Cancelling all scheduled activities', competitionId); - await db.activityHistory.updateMany({ - where: { - competitionId, - OR: [ - { scheduledEndTime: { not: null } }, - { scheduledStartTime: { not: null } }, - ], - }, - data: { - scheduledStartTime: null, - scheduledEndTime: null, - }, + const updatedCompetition = await db.$transaction(async (tx) => { + await tx.activityHistory.updateMany({ + where: { + competitionId, + OR: [ + { scheduledEndTime: { not: null } }, + { scheduledStartTime: { not: null } }, + ], + }, + data: { + scheduledStartTime: null, + scheduledEndTime: null, + }, + }); + + return tx.competition.update({ + where: { + id: competitionId, + }, + data: { + autoAdvance: false, + ...(autoAdvanceDelay != null && { autoAdvanceDelay }), + }, + }); }); cancelCompetitionActivityJobs(competitionId); - } else { - const comp = await fetchCompWithNoScheduledActivities(competitionId); - if (comp) { - await determineAndScheduleCompetition(comp); - } + + return updatedCompetition as Competition; } - return (await db.competition.update({ + const updatedCompetition = (await db.competition.update({ where: { id: competitionId, }, data: { - ...(autoAdvance !== null && { autoAdvance }), - ...(autoAdvanceDelay !== null && { autoAdvanceDelay }), + ...(autoAdvance != null && { autoAdvance }), + ...(autoAdvanceDelay != null && { autoAdvanceDelay }), }, })) as Competition; + + if (autoAdvance === true) { + const comp = await fetchCompWithNoScheduledActivities(competitionId); + if (comp) { + await determineAndScheduleCompetition(comp); + } + } + + return updatedCompetition; }; diff --git a/packages/server/graphql/resolvers/mutations/WebhookMutations.spec.ts b/packages/server/graphql/resolvers/mutations/WebhookMutations.spec.ts index ca4f615..a0f3eae 100644 --- a/packages/server/graphql/resolvers/mutations/WebhookMutations.spec.ts +++ b/packages/server/graphql/resolvers/mutations/WebhookMutations.spec.ts @@ -175,6 +175,76 @@ describe('WebhookMutations', () => { }); }); + it('creates webhooks for Competition Groups users scoped to the competition', async () => { + const db = createDb(); + db.competition.findFirst.mockResolvedValue({ + id: 'TestComp2026', + competitionAccess: [], + webhooks: [], + }); + + await expect( + callCreateWebhook( + {}, + { + competitionId: 'testcomp2026', + webhook: { + url: 'https://hooks.example/new', + method: 'POST', + }, + }, + { + db, + user: userFixture({ + competitionGroups: { + competitionIds: ['TestComp2026'], + scopes: ['notifycomp.remote'], + }, + }), + }, + {} + ) + ).resolves.toEqual({ + id: 10, + url: 'https://hooks.example/new', + method: 'POST', + }); + + expect(db.webhook.create).toHaveBeenCalled(); + }); + + it('rejects Competition Groups users scoped away from the competition', async () => { + const db = createDb(); + db.competition.findFirst.mockResolvedValue({ + id: 'TestComp2026', + competitionAccess: [], + webhooks: [], + }); + + await expect( + callCreateWebhook( + {}, + { + competitionId: 'TestComp2026', + webhook: { + url: 'https://hooks.example/new', + method: 'POST', + }, + }, + { + db, + user: userFixture({ + competitionGroups: { + competitionIds: ['OtherComp2026'], + scopes: ['notifycomp.remote'], + }, + }), + }, + {} + ) + ).rejects.toThrow('Not Authorized'); + }); + it('allows the super-admin user to create and read custom headers', async () => { const db = createDb(); @@ -267,7 +337,7 @@ describe('WebhookMutations', () => { }); }); - it('deletes webhooks for authenticated users', async () => { + it('deletes webhooks for competition staff', async () => { const db = createDb(); await expect( @@ -286,6 +356,42 @@ describe('WebhookMutations', () => { }); }); + it('rejects webhook deletes from users without competition access', async () => { + const db = createDb(); + db.competition.findFirst.mockResolvedValue({ + competitionAccess: [{ userId: 999 }], + webhooks: [{ id: 10 }], + }); + + await expect( + callDeleteWebhook( + {}, + { id: 10 }, + { db, user: userFixture({ id: 123 }) }, + {} + ) + ).rejects.toThrow('Not Authorized'); + + expect(db.webhook.delete).not.toHaveBeenCalled(); + }); + + it('rejects webhook URLs that do not use HTTPS', async () => { + await expect( + callCreateWebhook( + {}, + { + competitionId: 'TestComp2026', + webhook: { + url: 'http://hooks.example/new', + method: 'POST', + }, + }, + { db: createDb(), user: userFixture({ id: 123 }) }, + {} + ) + ).rejects.toThrow('Webhook URL must use HTTPS'); + }); + it('tests unsaved webhook settings and returns response details', async () => { await expect( callTestEditingWebhook( @@ -322,6 +428,50 @@ describe('WebhookMutations', () => { ); }); + it('rejects unsaved webhook tests from users without competition access', async () => { + const db = createDb(); + db.competition.findFirst.mockResolvedValue({ + competitionAccess: [{ userId: 999 }], + webhooks: [], + }); + + await expect( + callTestEditingWebhook( + {}, + { + competitionId: 'TestComp2026', + webhook: { + url: 'https://hooks.example/new', + method: 'POST', + }, + }, + { db, user: userFixture({ id: 123 }) }, + {} + ) + ).rejects.toThrow('Not Authorized'); + + expect(webhookFetch).not.toHaveBeenCalled(); + }); + + it('rejects unsaved webhook tests for local URLs', async () => { + await expect( + callTestEditingWebhook( + {}, + { + competitionId: 'TestComp2026', + webhook: { + url: 'https://localhost/new', + method: 'POST', + }, + }, + { db: createDb(), user: userFixture({ id: 123 }) }, + {} + ) + ).rejects.toThrow('Webhook URL cannot target local hosts'); + + expect(webhookFetch).not.toHaveBeenCalled(); + }); + it('tests all saved webhooks and converts thrown fetches into empty responses', async () => { const db = createDb(); db.competition.findFirst.mockResolvedValue({ diff --git a/packages/server/graphql/resolvers/mutations/WebhookMutations.ts b/packages/server/graphql/resolvers/mutations/WebhookMutations.ts index e299a08..05f3780 100644 --- a/packages/server/graphql/resolvers/mutations/WebhookMutations.ts +++ b/packages/server/graphql/resolvers/mutations/WebhookMutations.ts @@ -6,40 +6,106 @@ import { } from '../../../generated/graphql'; import { HTTPMethod } from '../../../prisma/generated/client'; import { AppContext } from '../../../server'; +import { assertValidWebhookUrl } from '../../../lib/webhookUrls'; +import { runWithConcurrency } from '../../../lib/runWithConcurrency'; + +const canAccessCompetition = ( + user: User, + competitionId: string, + competitionAccess?: Array<{ userId: number }> +) => + user.id === 8184 || + Boolean( + (user.competitionGroups?.competitionIds ?? []) + .map((id) => id.toLowerCase()) + .includes(competitionId.toLowerCase()) + ) || + Boolean(competitionAccess?.some((ca) => ca.userId === user.id)); + +const findCompetitionForWebhook = (db: AppContext['db'], id: number) => + db.competition.findFirst({ + where: { + webhooks: { + some: { + id, + }, + }, + }, + include: { + competitionAccess: true, + webhooks: true, + }, + }); -export const createWebhook: MutationResolvers['createWebhook'] = - async (_, { competitionId, webhook }, { db, user }) => { - if (!user) { - throw new Error('Not Authenticated'); - } +const webhookTestConcurrency = () => { + const value = Number(process.env.WEBHOOK_DELIVERY_CONCURRENCY); + return Number.isInteger(value) && value > 0 ? value : 5; +}; - const comp = await db.competition.findFirst({ - where: { - id: { - equals: competitionId, - mode: 'insensitive', +const requireCompetitionAccess = async ({ + db, + user, + competitionId, + webhookId, +}: { + db: AppContext['db']; + user?: AppContext['user']; + competitionId?: string; + webhookId?: number; +}) => { + if (!user) { + throw new Error('Not Authenticated'); + } + + const competition = competitionId + ? await db.competition.findFirst({ + where: { + id: { + equals: competitionId, + mode: 'insensitive', + }, }, - }, - include: { - competitionAccess: true, - }, - }); + include: { + competitionAccess: true, + webhooks: true, + }, + }) + : await findCompetitionForWebhook(db, Number(webhookId)); - if ( - !( - !!comp?.competitionAccess?.some((ca) => ca.userId === user.id) || - user.id === 8184 - ) - ) { - throw new Error('Not Authorized'); - } + if (!competition) { + throw new Error('Competition not found'); + } + + const resolvedCompetitionId = competition.id ?? competitionId; + if ( + !resolvedCompetitionId || + !canAccessCompetition( + user, + resolvedCompetitionId, + competition.competitionAccess + ) + ) { + throw new Error('Not Authorized'); + } + + return { competition, user }; +}; + +export const createWebhook: MutationResolvers['createWebhook'] = + async (_, { competitionId, webhook }, { db, user }) => { + const { user: authorizedUser } = await requireCompetitionAccess({ + db, + user, + competitionId, + }); + const url = assertValidWebhookUrl(webhook.url); const wh = await db.webhook.create({ data: { competitionId, - url: webhook.url, + url, method: webhook.method, - ...(user.id === 8184 && { + ...(authorizedUser.id === 8184 && { headers: webhook.headers?.map((wh) => ({ key: wh.key, @@ -53,46 +119,29 @@ export const createWebhook: MutationResolvers['createWebhook'] = id: wh.id, url: wh.url, method: wh.method as HttpMethod, - ...(user.id === 8184 && { headers: (wh.headers as Header[]) || [] }), + ...(authorizedUser.id === 8184 && { + headers: (wh.headers as Header[]) || [], + }), }; }; export const updateWebhook: MutationResolvers['updateWebhook'] = async (_, { id, webhook }, { db, user }) => { - if (!user) { - throw new Error('Not Authenticated'); - } - - const comp = await db.competition.findFirst({ - where: { - webhooks: { - some: { - id, - }, - }, - }, - include: { - competitionAccess: true, - }, + const { user: authorizedUser } = await requireCompetitionAccess({ + db, + user, + webhookId: id, }); - - if ( - !( - !!comp?.competitionAccess?.some((ca) => ca.userId === user.id) || - user.id === 8184 - ) - ) { - throw new Error('Not Authorized'); - } + const url = assertValidWebhookUrl(webhook.url); const wh = await db.webhook.update({ where: { id, }, data: { - url: webhook.url, + url, method: webhook.method, - ...(user.id === 8184 && { + ...(authorizedUser.id === 8184 && { headers: webhook.headers?.map((h) => ({ key: h.key, @@ -108,15 +157,15 @@ export const updateWebhook: MutationResolvers['updateWebhook'] = id: wh.id, url: wh.url, method: wh.method as HttpMethod, - ...(user.id === 8184 && { headers: (wh.headers as Header[]) ?? [] }), + ...(authorizedUser.id === 8184 && { + headers: (wh.headers as Header[]) ?? [], + }), }; }; export const deleteWebhook: MutationResolvers['deleteWebhook'] = async (_, { id }, { db, user }) => { - if (!user) { - throw new Error('Not Authenticated'); - } + await requireCompetitionAccess({ db, user, webhookId: id }); await db.webhook.delete({ where: { @@ -129,44 +178,21 @@ export const deleteWebhook: MutationResolvers['deleteWebhook'] = export const testWebhooks: MutationResolvers['testWebhooks'] = async (_, { competitionId }, { db, user }) => { - if (!user) { - throw new Error('Not Authenticated'); - } - - const competition = await db.competition.findFirst({ - where: { - id: { - equals: competitionId, - mode: 'insensitive', - }, - }, - include: { - competitionAccess: true, - webhooks: true, - }, + const { competition } = await requireCompetitionAccess({ + db, + user, + competitionId, }); - if (!competition) { - throw new Error('Competition not found'); - } - - if ( - !( - !!competition?.competitionAccess?.some((ca) => ca.userId === user.id) || - user.id === 8184 - ) - ) { - throw new Error('Not Authorized'); - } - const whs = competition?.webhooks; if (!whs) { throw new Error('Webhooks not found'); } - const responses = await Promise.all( - whs.map(async (wh) => { + const responses = await runWithConcurrency( + whs, + async (wh) => { try { const res = await webhookFetch(wh, { competitionId: competition.id, @@ -192,7 +218,8 @@ export const testWebhooks: MutationResolvers['testWebhooks'] = body: '', }; } - }) + }, + webhookTestConcurrency() ); return responses; @@ -203,37 +230,12 @@ export const testWebhook: MutationResolvers['testWebhook'] = async ( { id }, { db, user } ) => { - if (!user) { - throw new Error('Not Authenticated'); - } - - const competition = await db.competition.findFirst({ - where: { - webhooks: { - some: { - id, - }, - }, - }, - include: { - competitionAccess: true, - webhooks: true, - }, + const { competition } = await requireCompetitionAccess({ + db, + user, + webhookId: id, }); - if (!competition) { - throw new Error('Competition not found'); - } - - if ( - !( - !!competition?.competitionAccess?.some((ca) => ca.userId === user.id) || - user.id === 8184 - ) - ) { - throw new Error('Not Authorized'); - } - const wh = competition?.webhooks.find((wh) => wh.id === id); if (!wh) { @@ -259,15 +261,14 @@ export const testWebhook: MutationResolvers['testWebhook'] = async ( export const testEditingWebhook: MutationResolvers['testEditingWebhook'] = async (_, { competitionId, webhook }, { db, user }) => { - if (!user) { - throw new Error('Not Authenticated'); - } + await requireCompetitionAccess({ db, user, competitionId }); + const url = assertValidWebhookUrl(webhook.url); const res = await webhookFetch( { id: 0, competitionId, - url: webhook.url, + url, method: webhook.method as HTTPMethod, headers: [], }, diff --git a/packages/server/index.ts b/packages/server/index.ts index 28097e8..7493506 100644 --- a/packages/server/index.ts +++ b/packages/server/index.ts @@ -1,14 +1,66 @@ -import { init } from './server'; +import { AppHttpServer, init } from './server'; const port = process.env.PORT ? +process.env.PORT : 8080; const host = process.env.HOST ?? '0.0.0.0'; -console.log('Connecting to database at ', process.env.DATABASE_URL); +const maskDatabaseUrl = (value: string | undefined) => { + if (!value) { + return 'not configured'; + } + + try { + const url = new URL(value); + if (url.password) { + url.password = '***'; + } + return url.toString(); + } catch { + return 'configured'; + } +}; + +console.log( + 'Connecting to database at ', + maskDatabaseUrl(process.env.DATABASE_URL) +); + +let server: AppHttpServer | undefined; +let shutdownStarted = false; + +const shutdown = (signal: string) => { + if (shutdownStarted) { + return; + } + + shutdownStarted = true; + console.log(`Received ${signal}, shutting down`); + + if (!server) { + process.exit(0); + } + + void server + .shutdown() + .then(() => { + process.exit(0); + }) + .catch((e) => { + console.error('Graceful shutdown failed', e); + process.exit(1); + }); +}; + +process.once('SIGTERM', () => shutdown('SIGTERM')); +process.once('SIGINT', () => shutdown('SIGINT')); init() - .then((app) => + .then((app) => { + server = app; app.listen(port, host, () => { console.log('Server is running on port', port); - }) - ) - .catch((e) => console.error(e)); + }); + }) + .catch((e) => { + console.error(e); + process.exitCode = 1; + }); diff --git a/packages/server/lib/assignmentSnapshots.ts b/packages/server/lib/assignmentSnapshots.ts index 0e0d7d9..cb87bff 100644 --- a/packages/server/lib/assignmentSnapshots.ts +++ b/packages/server/lib/assignmentSnapshots.ts @@ -1,4 +1,5 @@ import { createHash } from 'crypto'; +import type { Schedule } from '@wca/helpers'; export interface WcifPersonAssignment { activityId?: number; @@ -13,7 +14,9 @@ export interface WcifPerson { export interface WcifPayload { id: string; + name?: string | null; persons: WcifPerson[]; + schedule?: Schedule | null; } const sortObject = (value: unknown): unknown => { diff --git a/packages/server/lib/competitionGroupsPushSessionToken.spec.ts b/packages/server/lib/competitionGroupsPushSessionToken.spec.ts new file mode 100644 index 0000000..24c5121 --- /dev/null +++ b/packages/server/lib/competitionGroupsPushSessionToken.spec.ts @@ -0,0 +1,111 @@ +import { createHmac } from 'crypto'; +import { + signCompetitionGroupsPushSessionToken, + verifyCompetitionGroupsPushSessionToken, +} from './competitionGroupsPushSessionToken'; + +const base64UrlJson = (value: unknown) => + Buffer.from(JSON.stringify(value)).toString('base64url'); + +const createToken = (payload: Record, secret = 'secret') => { + const encodedHeader = base64UrlJson({ alg: 'HS256', typ: 'JWT' }); + const encodedPayload = base64UrlJson(payload); + const signature = createHmac('sha256', secret) + .update(`${encodedHeader}.${encodedPayload}`) + .digest('base64url'); + return `${encodedHeader}.${encodedPayload}.${signature}`; +}; + +describe('CompetitionGroups push session tokens', () => { + const env = process.env; + + beforeEach(() => { + jest.useFakeTimers().setSystemTime(new Date('2026-01-01T00:00:00Z')); + process.env = { + ...env, + COMPETITION_GROUPS_JWT_SECRET: 'secret', + }; + }); + + afterEach(() => { + jest.useRealTimers(); + process.env = env; + }); + + it('signs durable push session tokens', () => { + const token = signCompetitionGroupsPushSessionToken({ + pushSubscriptionId: 10, + sub: 'wca:123', + wcaUserIds: [123], + }); + + expect(verifyCompetitionGroupsPushSessionToken(token)).toMatchObject({ + pushSubscriptionId: 10, + sub: 'wca:123', + wcaUserIds: [123], + scope: 'competitiongroups.push_session', + }); + }); + + it('uses the dedicated push session secret when configured', () => { + process.env.COMPETITION_GROUPS_PUSH_SESSION_SECRET = 'push-secret'; + + const token = signCompetitionGroupsPushSessionToken({ + pushSubscriptionId: 10, + sub: 'wca:123', + wcaUserIds: [123], + }); + + expect(verifyCompetitionGroupsPushSessionToken(token).sub).toBe('wca:123'); + expect(() => + verifyCompetitionGroupsPushSessionToken( + createToken({ + pushSubscriptionId: 10, + sub: 'wca:123', + scope: 'competitiongroups.push_session', + wcaUserIds: [123], + }) + ) + ).toThrow('Invalid push session token signature'); + }); + + it('can enforce an optional session TTL', () => { + process.env.COMPETITION_GROUPS_PUSH_SESSION_TTL_SECONDS = '60'; + + const token = signCompetitionGroupsPushSessionToken({ + pushSubscriptionId: 10, + sub: 'wca:123', + wcaUserIds: [123], + }); + + jest.setSystemTime(new Date('2026-01-01T00:02:00Z')); + + expect(() => verifyCompetitionGroupsPushSessionToken(token)).toThrow( + 'Push session token expired' + ); + }); + + it('rejects invalid push session claims', () => { + expect(() => + verifyCompetitionGroupsPushSessionToken( + createToken({ + pushSubscriptionId: 10, + sub: 'wca:123', + scope: 'wrong', + wcaUserIds: [123], + }) + ) + ).toThrow('Invalid push session scope'); + + expect(() => + verifyCompetitionGroupsPushSessionToken( + createToken({ + pushSubscriptionId: 10, + sub: 'wca:123', + scope: 'competitiongroups.push_session', + wcaUserIds: [1.5], + }) + ) + ).toThrow('Invalid push session WCA user list'); + }); +}); diff --git a/packages/server/lib/competitionGroupsPushSessionToken.ts b/packages/server/lib/competitionGroupsPushSessionToken.ts new file mode 100644 index 0000000..a7cada7 --- /dev/null +++ b/packages/server/lib/competitionGroupsPushSessionToken.ts @@ -0,0 +1,135 @@ +import { createHmac, timingSafeEqual } from 'crypto'; + +export interface CompetitionGroupsPushSessionClaims { + pushSubscriptionId: number; + sub: string; + wcaUserIds: number[]; + iat?: number; + exp?: number; + scope?: string; +} + +const PUSH_SESSION_SCOPE = 'competitiongroups.push_session'; + +const decodeBase64Url = (value: string) => + Buffer.from(value.replace(/-/g, '+').replace(/_/g, '/'), 'base64'); + +const parseJsonPart = (value: string): T => + JSON.parse(decodeBase64Url(value).toString('utf8')) as T; + +const sessionSecret = () => { + const secret = + process.env.COMPETITION_GROUPS_PUSH_SESSION_SECRET ?? + process.env.COMPETITION_GROUPS_JWT_SECRET; + + if (!secret) { + throw new Error('CompetitionGroups push session secret is not configured'); + } + + return secret; +}; + +const sign = (input: string, secret: string) => + createHmac('sha256', secret).update(input).digest('base64url'); + +const base64UrlJson = (value: unknown) => + Buffer.from(JSON.stringify(value)).toString('base64url'); + +const parseTtlSeconds = () => { + const raw = process.env.COMPETITION_GROUPS_PUSH_SESSION_TTL_SECONDS; + if (!raw) { + return null; + } + + const ttl = Number(raw); + if (!Number.isInteger(ttl) || ttl <= 0) { + throw new Error('CompetitionGroups push session TTL is not valid'); + } + + return ttl; +}; + +export const signCompetitionGroupsPushSessionToken = ({ + pushSubscriptionId, + sub, + wcaUserIds, +}: { + pushSubscriptionId: number; + sub: string; + wcaUserIds: number[]; +}) => { + const now = Math.floor(Date.now() / 1000); + const ttl = parseTtlSeconds(); + const claims: CompetitionGroupsPushSessionClaims = { + pushSubscriptionId, + sub, + wcaUserIds, + iat: now, + exp: ttl ? now + ttl : undefined, + scope: PUSH_SESSION_SCOPE, + }; + + const encodedHeader = base64UrlJson({ alg: 'HS256', typ: 'JWT' }); + const encodedPayload = base64UrlJson(claims); + const signature = sign(`${encodedHeader}.${encodedPayload}`, sessionSecret()); + + return `${encodedHeader}.${encodedPayload}.${signature}`; +}; + +export const verifyCompetitionGroupsPushSessionToken = ( + token: string +): CompetitionGroupsPushSessionClaims => { + const parts = token.split('.'); + if (parts.length !== 3) { + throw new Error('Invalid push session token format'); + } + + const [encodedHeader, encodedPayload, signature] = parts; + const header = parseJsonPart<{ alg?: string }>(encodedHeader); + if (header.alg !== 'HS256') { + throw new Error('Unsupported push session token algorithm'); + } + + const expectedSignature = sign( + `${encodedHeader}.${encodedPayload}`, + sessionSecret() + ); + const signatureBuffer = Buffer.from(signature); + const expectedBuffer = Buffer.from(expectedSignature); + + if ( + signatureBuffer.length !== expectedBuffer.length || + !timingSafeEqual(signatureBuffer, expectedBuffer) + ) { + throw new Error('Invalid push session token signature'); + } + + const claims = + parseJsonPart(encodedPayload); + const now = Math.floor(Date.now() / 1000); + + if (!Number.isInteger(claims.pushSubscriptionId)) { + throw new Error('Invalid push session subscription ID'); + } + + if (!claims.sub) { + throw new Error('Missing push session subject'); + } + + if (claims.scope !== PUSH_SESSION_SCOPE) { + throw new Error('Invalid push session scope'); + } + + if (claims.exp && claims.exp <= now) { + throw new Error('Push session token expired'); + } + + if ( + !Array.isArray(claims.wcaUserIds) || + claims.wcaUserIds.some((id) => !Number.isInteger(id)) + ) { + throw new Error('Invalid push session WCA user list'); + } + + return claims; +}; diff --git a/packages/server/lib/fetchWithTimeout.spec.ts b/packages/server/lib/fetchWithTimeout.spec.ts new file mode 100644 index 0000000..7741aa5 --- /dev/null +++ b/packages/server/lib/fetchWithTimeout.spec.ts @@ -0,0 +1,66 @@ +/* eslint-disable import/first */ +const fetchMock = jest.fn(); + +jest.mock('node-fetch', () => ({ + __esModule: true, + default: fetchMock, +})); + +import { fetchWithTimeout } from './fetchWithTimeout'; + +describe('fetchWithTimeout', () => { + beforeEach(() => { + fetchMock.mockReset().mockResolvedValue({ ok: true }); + }); + + it('adds a default timeout to fetch options', async () => { + await fetchWithTimeout('https://wca.example/api', { + headers: { Accept: 'application/json' }, + }); + + expect(fetchMock).toHaveBeenCalledWith('https://wca.example/api', { + headers: { Accept: 'application/json' }, + timeout: 10000, + }); + }); + + it('allows callers to override the timeout', async () => { + await fetchWithTimeout('https://wca.example/api', {}, { timeoutMs: 2500 }); + + expect(fetchMock).toHaveBeenCalledWith('https://wca.example/api', { + timeout: 2500, + }); + }); + + it('retries transient response statuses', async () => { + fetchMock + .mockResolvedValueOnce({ ok: false, status: 503 }) + .mockResolvedValueOnce({ ok: true, status: 200 }); + + await expect( + fetchWithTimeout( + 'https://wca.example/api', + {}, + { retries: 1, retryDelayMs: 0 } + ) + ).resolves.toEqual({ ok: true, status: 200 }); + + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it('retries thrown network errors', async () => { + fetchMock + .mockRejectedValueOnce(new Error('socket closed')) + .mockResolvedValueOnce({ ok: true, status: 200 }); + + await expect( + fetchWithTimeout( + 'https://wca.example/api', + {}, + { retries: 1, retryDelayMs: 0 } + ) + ).resolves.toEqual({ ok: true, status: 200 }); + + expect(fetchMock).toHaveBeenCalledTimes(2); + }); +}); diff --git a/packages/server/lib/fetchWithTimeout.ts b/packages/server/lib/fetchWithTimeout.ts new file mode 100644 index 0000000..6c17a6e --- /dev/null +++ b/packages/server/lib/fetchWithTimeout.ts @@ -0,0 +1,61 @@ +import fetch, { RequestInfo, RequestInit, Response } from 'node-fetch'; + +const DEFAULT_TIMEOUT_MS = 10000; +const DEFAULT_RETRY_DELAY_MS = 250; +const DEFAULT_RETRY_STATUSES = new Set([408, 429, 500, 502, 503, 504]); + +export interface FetchPolicy { + timeoutMs?: number; + retries?: number; + retryDelayMs?: number; + retryStatuses?: number[]; +} + +const wait = async (delayMs: number) => + new Promise((resolve) => { + setTimeout(resolve, delayMs); + }); + +const shouldRetryResponse = (response: Response, retryStatuses: Set) => + retryStatuses.has(response.status); + +export const fetchWithTimeout = async ( + url: RequestInfo, + init: RequestInit = {}, + policy: FetchPolicy | number = {} +): Promise => { + const { + timeoutMs = typeof policy === 'number' ? policy : DEFAULT_TIMEOUT_MS, + retries = 0, + retryDelayMs = DEFAULT_RETRY_DELAY_MS, + retryStatuses = [...DEFAULT_RETRY_STATUSES], + } = typeof policy === 'number' ? {} : policy; + const retryStatusSet = new Set(retryStatuses); + let lastError: unknown; + + for (let attempt = 0; attempt <= retries; attempt += 1) { + try { + const response = await fetch(url, { + ...init, + timeout: timeoutMs, + }); + + if (attempt < retries && shouldRetryResponse(response, retryStatusSet)) { + await wait(retryDelayMs); + continue; + } + + return response; + } catch (e) { + lastError = e; + + if (attempt >= retries) { + throw e; + } + + await wait(retryDelayMs); + } + } + + throw lastError; +}; diff --git a/packages/server/lib/runWithConcurrency.spec.ts b/packages/server/lib/runWithConcurrency.spec.ts new file mode 100644 index 0000000..87c4ea8 --- /dev/null +++ b/packages/server/lib/runWithConcurrency.spec.ts @@ -0,0 +1,52 @@ +import { + runWithConcurrency, + settleWithConcurrency, +} from './runWithConcurrency'; + +describe('runWithConcurrency', () => { + it('runs all workers and preserves result order', async () => { + await expect( + runWithConcurrency([1, 2, 3], async (value) => value * 2, 2) + ).resolves.toEqual([2, 4, 6]); + }); + + it('limits concurrent workers', async () => { + let active = 0; + let maxActive = 0; + + await runWithConcurrency( + [1, 2, 3, 4], + async () => { + active += 1; + maxActive = Math.max(maxActive, active); + await new Promise((resolve) => { + setTimeout(resolve, 1); + }); + active -= 1; + }, + 2 + ); + + expect(maxActive).toBe(2); + }); + + it('settles worker failures without aborting remaining work', async () => { + const results = await settleWithConcurrency( + [1, 2, 3], + async (value) => { + if (value === 2) { + throw new Error('failed'); + } + + return value; + }, + 2 + ); + + expect(results.map((result) => result.status)).toEqual([ + 'fulfilled', + 'rejected', + 'fulfilled', + ]); + }); +}); diff --git a/packages/server/lib/runWithConcurrency.ts b/packages/server/lib/runWithConcurrency.ts new file mode 100644 index 0000000..588d07d --- /dev/null +++ b/packages/server/lib/runWithConcurrency.ts @@ -0,0 +1,51 @@ +export const runWithConcurrency = async ( + items: T[], + worker: (item: T, index: number) => Promise, + concurrency: number +): Promise => { + if (!items.length) { + return []; + } + + const workerCount = Math.min( + items.length, + Math.max(1, Math.floor(concurrency)) + ); + const results = new Array(items.length); + let nextIndex = 0; + + await Promise.all( + Array.from({ length: workerCount }, async () => { + while (nextIndex < items.length) { + const index = nextIndex; + nextIndex += 1; + results[index] = await worker(items[index], index); + } + }) + ); + + return results; +}; + +export const settleWithConcurrency = async ( + items: T[], + worker: (item: T, index: number) => Promise, + concurrency: number +): Promise>> => + runWithConcurrency( + items, + async (item, index): Promise> => { + try { + return { + status: 'fulfilled', + value: await worker(item, index), + }; + } catch (reason) { + return { + status: 'rejected', + reason, + }; + } + }, + concurrency + ); diff --git a/packages/server/lib/webhookUrls.spec.ts b/packages/server/lib/webhookUrls.spec.ts new file mode 100644 index 0000000..04a6788 --- /dev/null +++ b/packages/server/lib/webhookUrls.spec.ts @@ -0,0 +1,111 @@ +/* eslint-disable import/first */ +const dnsLookup = jest.fn(); + +jest.mock('dns', () => ({ + promises: { + lookup: dnsLookup, + }, +})); + +import { + assertValidWebhookUrl, + assertWebhookUrlResolvesPublicly, + resolvePublicWebhookUrl, +} from './webhookUrls'; + +describe('assertValidWebhookUrl', () => { + it('accepts HTTPS webhook URLs', () => { + expect(assertValidWebhookUrl('https://hooks.example/notify')).toBe( + 'https://hooks.example/notify' + ); + }); + + it('rejects non-HTTPS URLs', () => { + expect(() => assertValidWebhookUrl('http://hooks.example/notify')).toThrow( + 'Webhook URL must use HTTPS' + ); + }); + + it('rejects local and private targets', () => { + expect(() => assertValidWebhookUrl('https://localhost/notify')).toThrow( + 'Webhook URL cannot target local hosts' + ); + expect(() => assertValidWebhookUrl('https://127.0.0.1/notify')).toThrow( + 'Webhook URL cannot target private addresses' + ); + expect(() => assertValidWebhookUrl('https://192.168.1.10/notify')).toThrow( + 'Webhook URL cannot target private addresses' + ); + }); + + it('rejects URLs with credentials', () => { + expect(() => + assertValidWebhookUrl('https://user:pass@hooks.example/notify') + ).toThrow('Webhook URL cannot include credentials'); + }); + + it('rejects broader private and reserved IP ranges', () => { + expect(() => assertValidWebhookUrl('https://100.64.0.1/notify')).toThrow( + 'Webhook URL cannot target private addresses' + ); + expect(() => assertValidWebhookUrl('https://[::1]/notify')).toThrow( + 'Webhook URL cannot target private addresses' + ); + expect(() => + assertValidWebhookUrl('https://[::ffff:127.0.0.1]/notify') + ).toThrow('Webhook URL cannot target private addresses'); + expect(() => assertValidWebhookUrl('https://203.0.113.10/notify')).toThrow( + 'Webhook URL cannot target private addresses' + ); + }); +}); + +describe('assertWebhookUrlResolvesPublicly', () => { + beforeEach(() => { + dnsLookup.mockReset(); + }); + + it('accepts hostnames resolving to public addresses', async () => { + dnsLookup.mockResolvedValue([{ address: '93.184.216.34', family: 4 }]); + + await expect( + assertWebhookUrlResolvesPublicly('https://hooks.example/notify') + ).resolves.toBe('https://hooks.example/notify'); + + expect(dnsLookup).toHaveBeenCalledWith('hooks.example', { all: true }); + }); + + it('rejects hostnames resolving to private addresses', async () => { + dnsLookup.mockResolvedValue([{ address: '10.0.0.5', family: 4 }]); + + await expect( + assertWebhookUrlResolvesPublicly('https://hooks.example/notify') + ).rejects.toThrow('Webhook URL cannot target private addresses'); + }); + + it('returns an HTTPS agent pinned to the vetted public addresses', async () => { + dnsLookup.mockResolvedValue([{ address: '93.184.216.34', family: 4 }]); + + const resolved = await resolvePublicWebhookUrl( + 'https://hooks.example/notify' + ); + const lookup = resolved.agent?.options.lookup; + const callback = jest.fn(); + + expect(resolved.url).toBe('https://hooks.example/notify'); + expect(lookup).toBeDefined(); + + lookup?.('hooks.example', {}, callback); + + expect(callback).toHaveBeenCalledWith(null, '93.184.216.34', 4); + }); + + it('does not add a pinned agent for direct public IP webhook URLs', async () => { + await expect( + resolvePublicWebhookUrl('https://93.184.216.34/notify') + ).resolves.toEqual({ + url: 'https://93.184.216.34/notify', + }); + expect(dnsLookup).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/server/lib/webhookUrls.ts b/packages/server/lib/webhookUrls.ts new file mode 100644 index 0000000..ae63fc8 --- /dev/null +++ b/packages/server/lib/webhookUrls.ts @@ -0,0 +1,121 @@ +import { promises as dns } from 'dns'; +import type { LookupAddress, LookupOptions } from 'dns'; +import https from 'https'; +import type { LookupFunction } from 'net'; +import ipaddr from 'ipaddr.js'; + +const LOCAL_HOSTNAMES = new Set(['localhost']); + +const isIpAddress = (host: string) => ipaddr.isValid(host); + +const assertPublicAddress = (address: string) => { + const parsedAddress = ipaddr.process(address); + + if (parsedAddress.range() !== 'unicast') { + throw new Error('Webhook URL cannot target private addresses'); + } +}; + +const createLookupError = () => { + const error = new Error( + 'Webhook URL host could not be resolved to a public address' + ) as NodeJS.ErrnoException; + error.code = 'ENOTFOUND'; + return error; +}; + +const pickAddress = ( + addresses: LookupAddress[], + options: LookupOptions +): LookupAddress[] => { + const family = typeof options.family === 'number' ? options.family : undefined; + + if (!family) { + return addresses; + } + + return addresses.filter((address) => address.family === family); +}; + +const createPinnedHttpsAgent = (addresses: LookupAddress[]) => { + const lookup: LookupFunction = (_hostname, options, callback) => { + const candidates = pickAddress(addresses, options); + const family = typeof options.family === 'number' ? options.family : 0; + + if (!candidates.length) { + callback(createLookupError(), options.all ? [] : '', family); + return; + } + + if (options.all) { + callback(null, candidates); + return; + } + + callback(null, candidates[0].address, candidates[0].family); + }; + + return new https.Agent({ lookup }); +}; + +export const assertValidWebhookUrl = (value: string) => { + let url: URL; + + try { + url = new URL(value); + } catch { + throw new Error('Invalid webhook URL'); + } + + if (url.protocol !== 'https:') { + throw new Error('Webhook URL must use HTTPS'); + } + + if (url.username || url.password) { + throw new Error('Webhook URL cannot include credentials'); + } + + const hostname = url.hostname.replace(/^\[|\]$/g, '').toLowerCase(); + if ( + LOCAL_HOSTNAMES.has(hostname) || + hostname.endsWith('.local') || + hostname.endsWith('.localhost') + ) { + throw new Error('Webhook URL cannot target local hosts'); + } + + if (isIpAddress(hostname)) { + assertPublicAddress(hostname); + } + + return url.toString(); +}; + +export const assertWebhookUrlResolvesPublicly = async (value: string) => { + const { url } = await resolvePublicWebhookUrl(value); + return url; +}; + +export const resolvePublicWebhookUrl = async (value: string) => { + const normalizedUrl = assertValidWebhookUrl(value); + const { hostname } = new URL(normalizedUrl); + const normalizedHostname = hostname.replace(/^\[|\]$/g, '').toLowerCase(); + + if (isIpAddress(normalizedHostname)) { + return { url: normalizedUrl }; + } + + const addresses = await dns.lookup(normalizedHostname, { all: true }); + if (!addresses.length) { + throw new Error('Webhook URL host could not be resolved'); + } + + addresses.forEach(({ address }) => { + assertPublicAddress(address); + }); + + return { + url: normalizedUrl, + agent: createPinnedHttpsAgent(addresses), + }; +}; diff --git a/packages/server/package.json b/packages/server/package.json index 05b8aed..547e10a 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -52,6 +52,7 @@ "graphql-subscriptions": "^2.0.0", "graphql-type-json": "^0.3.2", "graphql-ws": "^5.11.2", + "ipaddr.js": "^2.4.0", "jsonwebtoken": "^8.5.1", "morgan": "^1.10.1", "node-fetch": "2", diff --git a/packages/server/prisma/migrations/20260528010000_reliability_indexes/migration.sql b/packages/server/prisma/migrations/20260528010000_reliability_indexes/migration.sql new file mode 100644 index 0000000..1d2b8aa --- /dev/null +++ b/packages/server/prisma/migrations/20260528010000_reliability_indexes/migration.sql @@ -0,0 +1,20 @@ +-- CreateIndex +CREATE INDEX CONCURRENTLY "Competition_autoAdvance_status_idx" ON "Competition"("autoAdvance", "status"); + +-- CreateIndex +CREATE INDEX CONCURRENTLY "CompetitionAccess_userId_idx" ON "CompetitionAccess"("userId"); + +-- CreateIndex +CREATE INDEX CONCURRENTLY "CompetitionAccess_competitionId_roomId_userId_idx" ON "CompetitionAccess"("competitionId", "roomId", "userId"); + +-- CreateIndex +CREATE INDEX CONCURRENTLY "ActivityHistory_scheduledStartTime_idx" ON "ActivityHistory"("scheduledStartTime"); + +-- CreateIndex +CREATE INDEX CONCURRENTLY "ActivityHistory_scheduledEndTime_idx" ON "ActivityHistory"("scheduledEndTime"); + +-- CreateIndex +CREATE INDEX CONCURRENTLY "ActivityHistory_comp_start_sched_idx" ON "ActivityHistory"("competitionId", "startTime", "scheduledStartTime", "scheduledEndTime"); + +-- CreateIndex +CREATE INDEX CONCURRENTLY "PushSubscription_disabledAt_idx" ON "PushSubscription"("disabledAt"); diff --git a/packages/server/prisma/schema.prisma b/packages/server/prisma/schema.prisma index b00f8ce..f8160f0 100644 --- a/packages/server/prisma/schema.prisma +++ b/packages/server/prisma/schema.prisma @@ -26,6 +26,8 @@ model Competition { competitionAccess CompetitionAccess[] activityHistory ActivityHistory[] webhooks Webhook[] + + @@index([autoAdvance, status], map: "Competition_autoAdvance_status_idx") } model CompetitionAccess { @@ -35,20 +37,25 @@ model CompetitionAccess { competition Competition @relation(fields: [competitionId], references: [id]) @@unique([competitionId, userId]) + @@index([userId], map: "CompetitionAccess_userId_idx") + @@index([competitionId, roomId, userId], map: "CompetitionAccess_competitionId_roomId_userId_idx") } model ActivityHistory { - competitionId String @db.VarChar(255) - activityId Int - startTime DateTime? - endTime DateTime? + competitionId String @db.VarChar(255) + activityId Int + startTime DateTime? + endTime DateTime? // For autoAdvance competitions, the server will advance to the next activity when the scheduledEnd is reached scheduledStartTime DateTime? scheduledEndTime DateTime? - competition Competition @relation(fields: [competitionId], references: [id]) + competition Competition @relation(fields: [competitionId], references: [id]) @@unique([competitionId, activityId]) + @@index([scheduledStartTime], map: "ActivityHistory_scheduledStartTime_idx") + @@index([scheduledEndTime], map: "ActivityHistory_scheduledEndTime_idx") + @@index([competitionId, startTime, scheduledStartTime, scheduledEndTime], map: "ActivityHistory_comp_start_sched_idx") } enum HTTPMethod { @@ -88,6 +95,7 @@ model PushSubscription { deliveries PushDelivery[] @@index([source, externalSubject]) + @@index([disabledAt], map: "PushSubscription_disabledAt_idx") } model AssignmentWatch { diff --git a/packages/server/routes/v0/external/push.ts b/packages/server/routes/v0/external/push.ts index 87c44ef..cd2ada8 100644 --- a/packages/server/routes/v0/external/push.ts +++ b/packages/server/routes/v0/external/push.ts @@ -2,9 +2,16 @@ import { Request, Router } from 'express'; import { competitionGroupsAuth } from '../../../middlewares/competitionGroupsAuth'; import { disableCompetitionGroupsPushSubscription, + disableCompetitionGroupsPushSubscriptionSession, PushWatchInput, + testCompetitionGroupsPushSubscriptionSession, + updateCompetitionGroupsPushSubscriptionSession, upsertCompetitionGroupsPushSubscription, } from '../../../controllers/pushSubscriptions'; +import { + signCompetitionGroupsPushSessionToken, + verifyCompetitionGroupsPushSessionToken, +} from '../../../lib/competitionGroupsPushSessionToken'; const router = Router(); const VAPID_PUBLIC_KEY_PATTERN = /^[A-Za-z0-9_-]{40,256}$/; @@ -16,6 +23,28 @@ interface SubscriptionBody { watches?: PushWatchInput[]; } +interface SubscriptionResponseInput { + id: number; + endpoint: string; + watches: PushWatchInput[]; +} + +const bearerToken = (req: Request) => { + const authorization = req.headers.authorization; + return authorization?.startsWith('Bearer ') + ? authorization.slice('Bearer '.length) + : null; +}; + +const serializeSubscription = (subscription: SubscriptionResponseInput) => ({ + id: subscription.id, + endpoint: subscription.endpoint, + watches: subscription.watches.map((watch) => ({ + competitionId: watch.competitionId, + wcaUserId: watch.wcaUserId, + })), +}); + const normalizeWatches = ( watches: PushWatchInput[] | undefined, allowedWcaUserIds: number[] @@ -97,14 +126,7 @@ router.post('/subscriptions', competitionGroupsAuth, async (req: Request, res) = res.status(201).json({ success: true, - subscription: { - id: subscription.id, - endpoint: subscription.endpoint, - watches: subscription.watches.map((watch) => ({ - competitionId: watch.competitionId, - wcaUserId: watch.wcaUserId, - })), - }, + subscription: serializeSubscription(subscription), }); } catch (e) { res.status(400).json({ @@ -114,6 +136,148 @@ router.post('/subscriptions', competitionGroupsAuth, async (req: Request, res) = } }); +router.post('/sessions', competitionGroupsAuth, async (req: Request, res) => { + if (!req.competitionGroups) { + res.status(401).json({ success: false }); + return; + } + + const { endpoint, p256dh, auth, watches } = req.body as SubscriptionBody; + + if (!endpoint || !p256dh || !auth) { + res.status(400).json({ + success: false, + message: 'Missing endpoint, p256dh, or auth', + }); + return; + } + + try { + const subscription = await upsertCompetitionGroupsPushSubscription({ + endpoint, + p256dh, + auth, + externalSubject: req.competitionGroups.sub, + watches: normalizeWatches(watches, req.competitionGroups.wcaUserIds), + }); + const sessionToken = signCompetitionGroupsPushSessionToken({ + pushSubscriptionId: subscription.id, + sub: req.competitionGroups.sub, + wcaUserIds: req.competitionGroups.wcaUserIds, + }); + + res.status(201).json({ + success: true, + sessionToken, + subscription: serializeSubscription(subscription), + }); + } catch (e) { + res.status(400).json({ + success: false, + message: e instanceof Error ? e.message : 'Invalid push session', + }); + } +}); + +router.put('/sessions/current', async (req: Request, res) => { + const token = bearerToken(req); + if (!token) { + res.status(401).json({ success: false, message: 'Missing bearer token' }); + return; + } + + const { endpoint, p256dh, auth, watches } = req.body as SubscriptionBody; + + if (!endpoint || !p256dh || !auth) { + res.status(400).json({ + success: false, + message: 'Missing endpoint, p256dh, or auth', + }); + return; + } + + try { + const claims = verifyCompetitionGroupsPushSessionToken(token); + const subscription = await updateCompetitionGroupsPushSubscriptionSession({ + endpoint, + p256dh, + auth, + externalSubject: claims.sub, + pushSubscriptionId: claims.pushSubscriptionId, + watches: normalizeWatches(watches, claims.wcaUserIds), + }); + + res.json({ + success: true, + subscription: serializeSubscription(subscription), + }); + } catch (e) { + res.status(401).json({ + success: false, + message: e instanceof Error ? e.message : 'Invalid push session', + }); + } +}); + +router.delete('/sessions/current', async (req: Request, res) => { + const token = bearerToken(req); + if (!token) { + res.status(401).json({ success: false, message: 'Missing bearer token' }); + return; + } + + try { + const claims = verifyCompetitionGroupsPushSessionToken(token); + await disableCompetitionGroupsPushSubscriptionSession( + claims.pushSubscriptionId, + claims.sub + ); + + res.json({ success: true }); + } catch (e) { + res.status(401).json({ + success: false, + message: e instanceof Error ? e.message : 'Invalid push session', + }); + } +}); + +router.post('/sessions/current/test', async (req: Request, res) => { + const token = bearerToken(req); + if (!token) { + res.status(401).json({ success: false, message: 'Missing bearer token' }); + return; + } + + try { + const claims = verifyCompetitionGroupsPushSessionToken(token); + const result = await testCompetitionGroupsPushSubscriptionSession( + claims.pushSubscriptionId, + claims.sub + ); + + if (!result.success) { + const statusCode = result.error?.statusCode; + const responseStatus = + statusCode === 401 || statusCode === 403 ? 410 : 502; + + res.status(responseStatus).json({ + success: false, + message: result.error?.message ?? 'Unable to send test notification', + error: result.error, + }); + return; + } + + res.json({ success: true }); + } catch (e) { + res.status(401).json({ + success: false, + message: e instanceof Error ? e.message : 'Invalid push session', + }); + } +}); + router.delete( '/subscriptions', competitionGroupsAuth, diff --git a/packages/server/scheduler/index.ts b/packages/server/scheduler/index.ts index 5293069..9957d2d 100644 --- a/packages/server/scheduler/index.ts +++ b/packages/server/scheduler/index.ts @@ -13,6 +13,7 @@ import { getFlatActivities } from './utils'; import * as activitiesController from '../controllers/activities'; import { determineAutoAdvancePlan } from './autoAdvance'; import { sendActivityHeadsUpPush } from '../services/activityHeadsUpNotifications'; +import { settleWithConcurrency } from '../lib/runWithConcurrency'; export const CompetitionActivitiesJobsMap = new Map< string, @@ -31,6 +32,14 @@ export const CompetitionActivitiesJobsMap = new Map< const competitionActivityKey = (competitionId: string, activityId: number) => `${competitionId}_${activityId}`; +const positiveIntFromEnv = (name: string, fallback: number) => { + const value = Number(process.env[name]); + return Number.isInteger(value) && value > 0 ? value : fallback; +}; + +const schedulerInitConcurrency = () => + positiveIntFromEnv('SCHEDULER_INIT_CONCURRENCY', 4); + export function cancelScheduledActivityJob( competitionId: string, activityId: number @@ -43,13 +52,19 @@ export function cancelScheduledActivityJob( } entry.job.cancel(); - entry.headsUpJob?.cancel(); + + const headsUpJobs = new Set(); for (const [jobKey, jobEntry] of CompetitionActivitiesJobsMap.entries()) { if (jobEntry.job === entry.job) { + if (jobEntry.headsUpJob) { + headsUpJobs.add(jobEntry.headsUpJob); + } CompetitionActivitiesJobsMap.delete(jobKey); } } + + headsUpJobs.forEach((headsUpJob) => headsUpJob.cancel()); } export function cancelCompetitionActivityJobs(competitionId: string) { @@ -63,6 +78,11 @@ export function cancelCompetitionActivityJobs(competitionId: string) { } } +export async function shutdownScheduler() { + CompetitionActivitiesJobsMap.clear(); + await schedule.gracefulShutdown(); +} + const wcaApi = new WcaApi(WCA_ORIGIN); const activityHistoryClause: Prisma.Competition$activityHistoryArgs = { @@ -89,9 +109,15 @@ export async function initScheduler() { }, }); - competitions.forEach(async (competition) => { - await determineAndScheduleCompetition(competition); - }); + const competitionResults = await settleWithConcurrency( + competitions, + async (competition) => determineAndScheduleCompetition(competition), + schedulerInitConcurrency() + ); + logRejectedResults( + 'Failed to initialize auto-advance competition', + competitionResults + ); const activities = await prisma.activityHistory.findMany({ where: { @@ -110,29 +136,37 @@ export async function initScheduler() { }, }); - activities.forEach(async (activity) => { - console.log( - 'Scheduling', - competitionActivityKey(activity.competitionId, activity.activityId) - ); - - const scheduledStartIsFuture = Boolean( - activity.scheduledStartTime && - new Date(activity.scheduledStartTime).getTime() > Date.now() - ); - const scheduledEndIsFuture = Boolean( - activity.scheduledEndTime && - new Date(activity.scheduledEndTime).getTime() > Date.now() - ); + const activityResults = await settleWithConcurrency( + activities, + async (activity) => { + console.log( + 'Scheduling', + competitionActivityKey(activity.competitionId, activity.activityId) + ); - if (scheduledStartIsFuture || scheduledEndIsFuture) { - void scheduleActivity(activity); - } else { - console.log('Activity is in the past', activity); - } - }); + await scheduleActivity(activity); + }, + schedulerInitConcurrency() + ); + logRejectedResults( + 'Failed to initialize scheduled activity', + activityResults + ); } +const logRejectedResults = ( + message: string, + results: Array> +) => { + results + .filter( + (result): result is PromiseRejectedResult => result.status === 'rejected' + ) + .forEach((result) => { + console.error(message, result.reason); + }); +}; + export async function determineAndScheduleCompetition( competition: Competition & { activityHistory: ActivityHistory[]; @@ -182,51 +216,60 @@ async function startAndStopActivities( } const job = schedule.scheduleJob(jobTime, async () => { - stopActivities.forEach((activity) => { - console.log(`Stopping activity ${activity.id} ${activity.name}`); - }); - startActivities.forEach((activity) => { - console.log(`Starting activity ${activity.id} ${activity.name}`); - }); - - await Promise.all([ - ...stopActivities.map(async (activity) => - activitiesController.stopActivity(competitionId, activity.id) - ), - ...startActivities.map(async (activity) => - activitiesController.startActivity(competitionId, activity.id) - ), - ]); - [...stopActivities, ...startActivities].forEach((activity) => { - cancelScheduledActivityJob(competitionId, activity.id); - }); - - const updatedCompetition = await prisma.competition.findFirst({ - where: { - id: competitionId, - autoAdvance: true, - activityHistory: { - some: { - scheduledEndTime: null, - scheduledStartTime: null, + try { + stopActivities.forEach((activity) => { + console.log(`Stopping activity ${activity.id} ${activity.name}`); + }); + startActivities.forEach((activity) => { + console.log(`Starting activity ${activity.id} ${activity.name}`); + }); + + await Promise.all([ + ...stopActivities.map(async (activity) => + activitiesController.stopActivity(competitionId, activity.id) + ), + ...startActivities.map(async (activity) => + activitiesController.startActivity(competitionId, activity.id) + ), + ]); + [...stopActivities, ...startActivities].forEach((activity) => { + cancelScheduledActivityJob(competitionId, activity.id); + }); + + const updatedCompetition = await prisma.competition.findFirst({ + where: { + id: competitionId, + autoAdvance: true, + activityHistory: { + some: { + scheduledEndTime: null, + scheduledStartTime: null, + }, }, }, - }, - include: { - activityHistory: activityHistoryClause, - }, - }); + include: { + activityHistory: activityHistoryClause, + }, + }); - if (!updatedCompetition) { - console.error( - 193, - 'Competition not found, really fucking weird', - competitionId - ); - return; - } + if (!updatedCompetition) { + console.error( + 'Competition not found while rescheduling', + competitionId + ); + return; + } - void determineAndScheduleCompetition(updatedCompetition); + await determineAndScheduleCompetition(updatedCompetition); + } catch (e) { + console.error('Scheduled auto-advance job failed', { + competitionId, + activityIds: [...stopActivities, ...startActivities].map( + (activity) => activity.id + ), + error: e, + }); + } }); if (!job) { @@ -290,19 +333,89 @@ function scheduleActivityHeadsUp( } return schedule.scheduleJob(headsUpTime, async () => { - await sendActivityHeadsUpPush( - competitionId, - startActivities.map((activity) => activity.id), - startTime - ); + try { + await sendActivityHeadsUpPush( + competitionId, + startActivities.map((activity) => activity.id), + startTime + ); + } catch (e) { + console.error('Scheduled activity heads-up push failed', { + competitionId, + activityIds: startActivities.map((activity) => activity.id), + error: e, + }); + } }); } +const handleScheduledActivityStart = async (activity: ActivityHistory) => { + await activitiesController.startActivity( + activity.competitionId, + activity.activityId + ); + cancelScheduledActivityJob(activity.competitionId, activity.activityId); + + const comp = await prisma.competition.findFirst({ + where: { + id: activity.competitionId, + autoAdvance: true, + }, + include: { + activityHistory: true, + }, + }); + + if (comp) { + await determineAndScheduleCompetition(comp); + } +}; + +const handleScheduledActivityEnd = async (activity: ActivityHistory) => { + await activitiesController.stopActivity( + activity.competitionId, + activity.activityId + ); + cancelScheduledActivityJob(activity.competitionId, activity.activityId); + + // If this is the last activity that ends, determine the next activities to start. + const comp = await prisma.competition.findFirst({ + where: { + id: activity.competitionId, + autoAdvance: true, + }, + include: { + activityHistory: { + where: { + startTime: { + not: null, + }, + scheduledEndTime: null, + scheduledStartTime: null, + }, + }, + }, + }); + + if (!comp) { + console.error('Competition not found', activity.competitionId); + return; + } + if (!comp.activityHistory.length) { + await determineAndScheduleCompetition(comp); + } +}; + export async function scheduleActivity(activity: ActivityHistory) { cancelScheduledActivityJob(activity.competitionId, activity.activityId); if (activity.scheduledStartTime) { const startTime = new Date(activity.scheduledStartTime); + if (startTime <= new Date()) { + await handleScheduledActivityStart(activity); + return; + } + const headsUpJob = scheduleActivityHeadsUp( activity.competitionId, [ @@ -318,30 +431,21 @@ export async function scheduleActivity(activity: ActivityHistory) { ], startTime ); - const job = schedule.scheduleJob( - startTime, - async () => { - await activitiesController.startActivity( - activity.competitionId, - activity.activityId - ); - cancelScheduledActivityJob(activity.competitionId, activity.activityId); - - const comp = await prisma.competition.findFirst({ - where: { - id: activity.competitionId, - autoAdvance: true, - }, - include: { - activityHistory: true, - }, + const job = schedule.scheduleJob(startTime, async () => { + try { + await handleScheduledActivityStart(activity); + } catch (e) { + console.error('Scheduled activity start failed', { + competitionId: activity.competitionId, + activityId: activity.activityId, + error: e, }); - - if (comp) { - void determineAndScheduleCompetition(comp); - } } - ); + }); + + if (!job) { + throw new Error('Scheduled activity start job could not be created'); + } CompetitionActivitiesJobsMap.set( competitionActivityKey(activity.competitionId, activity.activityId), @@ -354,50 +458,33 @@ export async function scheduleActivity(activity: ActivityHistory) { } if (activity.scheduledEndTime) { - const job = schedule.scheduleJob( - new Date(activity.scheduledEndTime), - async () => { - await activitiesController.stopActivity( - activity.competitionId, - activity.activityId - ); - cancelScheduledActivityJob(activity.competitionId, activity.activityId); - - // If this is the last activity that ends, we should determine the next activities to start. + const endTime = new Date(activity.scheduledEndTime); + if (endTime <= new Date()) { + await handleScheduledActivityEnd(activity); + return; + } - const comp = await prisma.competition.findFirst({ - where: { - id: activity.competitionId, - autoAdvance: true, - }, - include: { - activityHistory: { - where: { - startTime: { - not: null, - }, - scheduledEndTime: null, - scheduledStartTime: null, - }, - }, - }, + const job = schedule.scheduleJob(endTime, async () => { + try { + await handleScheduledActivityEnd(activity); + } catch (e) { + console.error('Scheduled activity end failed', { + competitionId: activity.competitionId, + activityId: activity.activityId, + error: e, }); - - if (!comp) { - console.error('Competition not found', activity.competitionId); - return; - } - if (!comp.activityHistory.length) { - void determineAndScheduleCompetition(comp); - } } - ); + }); + + if (!job) { + throw new Error('Scheduled activity end job could not be created'); + } CompetitionActivitiesJobsMap.set( competitionActivityKey(activity.competitionId, activity.activityId), { job, - endTime: new Date(activity.scheduledEndTime), + endTime, } ); } diff --git a/packages/server/server.ts b/packages/server/server.ts index 22624ad..1d4fec0 100644 --- a/packages/server/server.ts +++ b/packages/server/server.ts @@ -32,7 +32,7 @@ import { makeExecutableSchema } from '@graphql-tools/schema'; import WcaApi from './graphql/datasources/WcaApi'; import { authMiddlewareVerify } from './auth/AuthMiddleware'; import { WCA_ORIGIN } from './env'; -import { initScheduler } from './scheduler'; +import { initScheduler, shutdownScheduler } from './scheduler'; import { pubsub } from './graphql/pubsub'; import pushRouter from './routes/v0/external/push'; import { startAssignmentNotificationWorker } from './services/assignmentNotificationWorker'; @@ -47,20 +47,41 @@ export interface AppContext { wcaApi: WcaApi; } -export async function init() { +export interface AppHttpServer extends http.Server { + shutdown: () => Promise; +} + +const shouldRunBackgroundJobs = () => + process.env.BACKGROUND_JOBS_ENABLED !== 'false'; + +const shouldLogGraphqlQueries = () => + process.env.LOG_GRAPHQL_QUERIES === 'true'; + +export async function init(): Promise { if (isMocksMode()) { await seedMockCompetition(db); } - await initScheduler(); + if (shouldRunBackgroundJobs()) { + await initScheduler(); + } else { + console.info('Background jobs disabled'); + } const app = express(); - startAssignmentNotificationWorker(); + const stopAssignmentNotificationWorker = shouldRunBackgroundJobs() + ? startAssignmentNotificationWorker() + : undefined; app.use(cors()); app.use(json()); - app.use(morgan('tiny')); + app.use( + morgan('tiny', { + skip: (req) => + req.path === '/ping' && process.env.LOG_HEALTHCHECKS !== 'true', + }) + ); app.get('/', (_, res) => { res.end('Hello World!'); @@ -134,7 +155,10 @@ export async function init() { return next(); } - console.log('graphql', req.body.query); + if (shouldLogGraphqlQueries()) { + console.log('graphql', req.body.operationName ?? 'anonymous'); + } + return next(); }, expressMiddleware(server, { @@ -147,5 +171,18 @@ export async function init() { }) ); - return httpServer; + let shutdownStarted = false; + const shutdown = async () => { + if (shutdownStarted) { + return; + } + + shutdownStarted = true; + stopAssignmentNotificationWorker?.(); + await server.stop(); + await shutdownScheduler(); + await db.$disconnect(); + }; + + return Object.assign(httpServer, { shutdown }); } diff --git a/packages/server/services/activityHeadsUpNotifications.spec.ts b/packages/server/services/activityHeadsUpNotifications.spec.ts index 55c64e5..befec97 100644 --- a/packages/server/services/activityHeadsUpNotifications.spec.ts +++ b/packages/server/services/activityHeadsUpNotifications.spec.ts @@ -3,6 +3,7 @@ const competitionAccessFindMany = jest.fn(); const assignmentWatchFindMany = jest.fn(); const pushDeliveryFindFirst = jest.fn(); const pushDeliveryCreate = jest.fn(); +const pushDeliveryUpdateMany = jest.fn(); const pushDeliveryUpdate = jest.fn(); const sendAssignmentPush = jest.fn(); @@ -18,6 +19,7 @@ jest.mock('../db', () => ({ pushDelivery: { findFirst: pushDeliveryFindFirst, create: pushDeliveryCreate, + updateMany: pushDeliveryUpdateMany, update: pushDeliveryUpdate, }, }, @@ -47,11 +49,13 @@ describe('sendActivityHeadsUpPush', () => { competitionAccessFindMany.mockReset().mockResolvedValue([{ userId: 123 }]); assignmentWatchFindMany.mockReset().mockResolvedValue([ { + wcaUserId: 123, pushSubscription: subscription, }, ]); pushDeliveryFindFirst.mockReset().mockResolvedValue(null); pushDeliveryCreate.mockReset().mockResolvedValue({ id: 20 }); + pushDeliveryUpdateMany.mockReset().mockResolvedValue({ count: 1 }); pushDeliveryUpdate.mockReset().mockResolvedValue({ id: 20 }); sendAssignmentPush.mockReset().mockResolvedValue({ success: true, @@ -84,7 +88,9 @@ describe('sendActivityHeadsUpPush', () => { expect(assignmentWatchFindMany).toHaveBeenCalledWith({ where: { competitionId: 'TestComp2026', - wcaUserId: 123, + wcaUserId: { + in: [123], + }, pushSubscription: { disabledAt: null, }, @@ -117,13 +123,13 @@ describe('sendActivityHeadsUpPush', () => { }, data: { status: 'sent', - error: undefined, + error: expect.anything(), }, }); }); it('skips subscriptions that already received the same heads-up delivery', async () => { - pushDeliveryFindFirst.mockResolvedValue({ id: 99 }); + pushDeliveryFindFirst.mockResolvedValue({ id: 99, status: 'sent' }); await sendActivityHeadsUpPush( 'TestComp2026', @@ -132,10 +138,42 @@ describe('sendActivityHeadsUpPush', () => { ); expect(pushDeliveryCreate).not.toHaveBeenCalled(); + expect(pushDeliveryUpdateMany).not.toHaveBeenCalled(); expect(sendAssignmentPush).not.toHaveBeenCalled(); expect(pushDeliveryUpdate).not.toHaveBeenCalled(); }); + it('retries failed heads-up deliveries', async () => { + pushDeliveryFindFirst.mockResolvedValue({ id: 99, status: 'failed' }); + + await sendActivityHeadsUpPush( + 'TestComp2026', + [1], + new Date('2026-01-01T10:00:00Z') + ); + + expect(pushDeliveryCreate).not.toHaveBeenCalled(); + expect(pushDeliveryUpdateMany).toHaveBeenCalledWith({ + where: expect.objectContaining({ + id: 99, + }), + data: { + status: 'pending', + error: expect.anything(), + }, + }); + expect(sendAssignmentPush).toHaveBeenCalled(); + expect(pushDeliveryUpdate).toHaveBeenCalledWith({ + where: { + id: 99, + }, + data: { + status: 'sent', + error: expect.anything(), + }, + }); + }); + it('records failed deliveries with the push error payload', async () => { delete process.env.COMPETITION_GROUPS_ORIGIN; sendAssignmentPush.mockResolvedValue({ diff --git a/packages/server/services/activityHeadsUpNotifications.ts b/packages/server/services/activityHeadsUpNotifications.ts index a986f9f..fd231cd 100644 --- a/packages/server/services/activityHeadsUpNotifications.ts +++ b/packages/server/services/activityHeadsUpNotifications.ts @@ -1,15 +1,8 @@ import prisma from '../db'; -import { PushDeliveryStatus } from '../prisma/generated/client'; -import { sendAssignmentPush } from './webPush'; - -const competitionGroupsUrl = (competitionId: string, wcaUserId: number) => { - const origin = process.env.COMPETITION_GROUPS_ORIGIN; - if (!origin) { - return undefined; - } - - return `${origin.replace(/\/$/, '')}/competitions/${competitionId}/persons/${wcaUserId}`; -}; +import { PushSubscription } from '../prisma/generated/client'; +import { runWithConcurrency } from '../lib/runWithConcurrency'; +import { deliverAssignmentPush } from './assignmentPushDeliveries'; +import { competitionGroupsPersonUrl } from './competitionGroupsUrls'; const createDedupeKey = ( competitionId: string, @@ -37,14 +30,21 @@ const getDelegateAndOrganizerTargets = async (competitionId: string) => }, }); -const getSubscriptionsForTarget = async ( +const pushDeliveryConcurrency = () => { + const value = Number(process.env.ACTIVITY_HEADS_UP_DELIVERY_CONCURRENCY); + return Number.isInteger(value) && value > 0 ? value : 10; +}; + +const getSubscriptionsByTarget = async ( competitionId: string, - wcaUserId: number + wcaUserIds: number[] ) => { const watches = await prisma.assignmentWatch.findMany({ where: { competitionId, - wcaUserId, + wcaUserId: { + in: wcaUserIds, + }, pushSubscription: { disabledAt: null, }, @@ -54,7 +54,46 @@ const getSubscriptionsForTarget = async ( }, }); - return watches.map((watch) => watch.pushSubscription); + return watches.reduce>((acc, watch) => { + acc[watch.wcaUserId] = [...(acc[watch.wcaUserId] ?? []), watch]; + return acc; + }, {}); +}; + +const sendActivityHeadsUpPushDelivery = async ({ + competitionId, + activityIds, + startsAt, + targetUserId, + subscription, +}: { + competitionId: string; + activityIds: number[]; + startsAt: Date; + targetUserId: number; + subscription: PushSubscription; +}) => { + const dedupeKey = createDedupeKey( + competitionId, + targetUserId, + activityIds, + startsAt + ); + await deliverAssignmentPush({ + subscription, + competitionId, + wcaUserId: targetUserId, + dedupeKey, + payload: { + type: 'activity-heads-up', + competitionId, + activityIds, + startsAt: startsAt.toISOString(), + title: 'Activity starting soon', + body: `${formatActivityCount(activityIds)} will start in 5 minutes.`, + url: competitionGroupsPersonUrl(competitionId, targetUserId), + }, + }); }; export const sendActivityHeadsUpPush = async ( @@ -63,62 +102,27 @@ export const sendActivityHeadsUpPush = async ( startsAt: Date ) => { const targets = await getDelegateAndOrganizerTargets(competitionId); + if (!targets.length) { + return; + } - for (const target of targets) { - const subscriptions = await getSubscriptionsForTarget( + const subscriptionsByTarget = await getSubscriptionsByTarget( + competitionId, + targets.map((target) => target.userId) + ); + const deliveries = targets.flatMap((target) => + (subscriptionsByTarget[target.userId] ?? []).map((watch) => ({ competitionId, - target.userId - ); - - for (const subscription of subscriptions) { - const dedupeKey = createDedupeKey( - competitionId, - target.userId, - activityIds, - startsAt - ); - const existingDelivery = await prisma.pushDelivery.findFirst({ - where: { - pushSubscriptionId: subscription.id, - dedupeKey, - }, - }); + activityIds, + startsAt, + targetUserId: target.userId, + subscription: watch.pushSubscription, + })) + ); - if (existingDelivery) { - continue; - } - - const delivery = await prisma.pushDelivery.create({ - data: { - pushSubscriptionId: subscription.id, - competitionId, - wcaUserId: target.userId, - dedupeKey, - status: PushDeliveryStatus.pending, - }, - }); - - const result = await sendAssignmentPush(subscription, { - type: 'activity-heads-up', - competitionId, - activityIds, - startsAt: startsAt.toISOString(), - title: 'Activity starting soon', - body: `${formatActivityCount(activityIds)} will start in 5 minutes.`, - url: competitionGroupsUrl(competitionId, target.userId), - }); - - await prisma.pushDelivery.update({ - where: { - id: delivery.id, - }, - data: { - status: result.success - ? PushDeliveryStatus.sent - : PushDeliveryStatus.failed, - error: result.error ?? undefined, - }, - }); - } - } + await runWithConcurrency( + deliveries, + sendActivityHeadsUpPushDelivery, + pushDeliveryConcurrency() + ); }; diff --git a/packages/server/services/assignmentNotificationWorker.spec.ts b/packages/server/services/assignmentNotificationWorker.spec.ts index 1cd90a7..90a22a3 100644 --- a/packages/server/services/assignmentNotificationWorker.spec.ts +++ b/packages/server/services/assignmentNotificationWorker.spec.ts @@ -4,6 +4,7 @@ const assignmentSnapshotFindUnique = jest.fn(); const assignmentSnapshotUpsert = jest.fn(); const pushDeliveryFindFirst = jest.fn(); const pushDeliveryCreate = jest.fn(); +const pushDeliveryUpdateMany = jest.fn(); const pushDeliveryUpdate = jest.fn(); const sendAssignmentPush = jest.fn(); const fetchWcif = jest.fn(); @@ -21,6 +22,7 @@ jest.mock('../db', () => ({ pushDelivery: { findFirst: pushDeliveryFindFirst, create: pushDeliveryCreate, + updateMany: pushDeliveryUpdateMany, update: pushDeliveryUpdate, }, }, @@ -46,6 +48,81 @@ const subscription = { auth: 'auth', }; +const activeWatch = { + competitionId: 'TestComp2026', + wcaUserId: 123, + pushSubscription: subscription, +}; + +const pollNow = new Date('2026-01-01T10:00:00Z'); + +interface TestActivity { + id: number; + name: string; + activityCode: string; + startTime: string; + endTime: string; + childActivities: TestActivity[]; + extensions: never[]; +} + +const activity = ( + id: number, + startTime: string, + childActivities: TestActivity[] = [] +): TestActivity => ({ + id, + name: `activity-${id}`, + activityCode: `activity-${id}`, + startTime, + endTime: new Date( + new Date(startTime).getTime() + 30 * 60 * 1000 + ).toISOString(), + childActivities, + extensions: [], +}); + +const scheduleWithRooms = (rooms: Array<{ activities: TestActivity[] }>) => ({ + startDate: '2026-01-01', + numberOfDays: 1, + venues: [ + { + id: 1, + name: 'Main venue', + latitudeMicrodegrees: 0, + longitudeMicrodegrees: 0, + countryIso2: 'US', + timezone: 'UTC', + rooms: rooms.map((room, index) => ({ + id: index + 1, + name: `Room ${index + 1}`, + color: '#ffffff', + extensions: [], + ...room, + })), + extensions: [], + }, + ], +}); + +const wcifWithSchedule = (schedule: ReturnType) => ({ + id: 'TestComp2026', + name: 'Test Competition 2026', + persons: [ + { + wcaUserId: 123, + assignments: [ + { + activityId: 1, + assignmentCode: 'competitor', + stationNumber: null, + }, + ], + }, + ], + schedule, +}); + describe('assignment notification worker', () => { const env = process.env; @@ -61,7 +138,10 @@ describe('assignment notification worker', () => { assignmentSnapshotUpsert.mockReset().mockResolvedValue({}); pushDeliveryFindFirst.mockReset().mockResolvedValue(null); pushDeliveryCreate.mockReset().mockResolvedValue({ id: 20 }); - pushDeliveryUpdate.mockReset().mockResolvedValue({ id: 20 }); + pushDeliveryUpdateMany.mockReset().mockResolvedValue({ count: 1 }); + pushDeliveryUpdate + .mockReset() + .mockImplementation(async ({ where }) => ({ id: where.id })); sendAssignmentPush.mockReset().mockResolvedValue({ success: true, error: null, @@ -91,14 +171,20 @@ describe('assignment notification worker', () => { }); it('updates snapshots and sends pushes when assignments change', async () => { - assignmentWatchFindMany - .mockResolvedValueOnce([ - { competitionId: 'TestComp2026', wcaUserId: 123 }, - ]) - .mockResolvedValueOnce([{ pushSubscription: subscription }]); + assignmentWatchFindMany.mockResolvedValue([activeWatch]); await runAssignmentNotificationPoll(); + expect(assignmentWatchFindMany).toHaveBeenCalledWith({ + where: { + pushSubscription: { + disabledAt: null, + }, + }, + include: { + pushSubscription: true, + }, + }); expect(fetchWcif).toHaveBeenCalledWith('TestComp2026'); expect(assignmentSnapshotUpsert).toHaveBeenCalledWith({ where: { @@ -124,7 +210,9 @@ describe('assignment notification worker', () => { wcaUserId: 123, title: 'Assignment update', url: 'https://groups.example/competitions/TestComp2026/persons/123', - dedupeKey: expect.stringContaining('assignment-change:TestComp2026:123:'), + dedupeKey: expect.stringContaining( + 'assignment-change:TestComp2026:123:' + ), }) ); expect(pushDeliveryUpdate).toHaveBeenCalledWith({ @@ -133,15 +221,13 @@ describe('assignment notification worker', () => { }, data: { status: 'sent', - error: undefined, + error: expect.anything(), }, }); }); it('does not send a push for first snapshots or unchanged assignments', async () => { - assignmentWatchFindMany.mockResolvedValueOnce([ - { competitionId: 'TestComp2026', wcaUserId: 123 }, - ]); + assignmentWatchFindMany.mockResolvedValue([activeWatch]); assignmentSnapshotFindUnique.mockResolvedValue(null); await runAssignmentNotificationPoll(); @@ -150,27 +236,55 @@ describe('assignment notification worker', () => { expect(sendAssignmentPush).not.toHaveBeenCalled(); }); - it('skips delivery when a matching push delivery already exists', async () => { - assignmentWatchFindMany - .mockResolvedValueOnce([ - { competitionId: 'TestComp2026', wcaUserId: 123 }, - ]) - .mockResolvedValueOnce([{ pushSubscription: subscription }]); - pushDeliveryFindFirst.mockResolvedValue({ id: 99 }); + it('skips delivery when a matching push delivery was already sent', async () => { + assignmentWatchFindMany.mockResolvedValue([activeWatch]); + pushDeliveryFindFirst.mockResolvedValue({ id: 99, status: 'sent' }); + + await runAssignmentNotificationPoll(); + + expect(pushDeliveryCreate).not.toHaveBeenCalled(); + expect(sendAssignmentPush).not.toHaveBeenCalled(); + expect(assignmentSnapshotUpsert).toHaveBeenCalled(); + }); + + it('retries failed deliveries before advancing the assignment snapshot', async () => { + assignmentWatchFindMany.mockResolvedValue([activeWatch]); + pushDeliveryFindFirst.mockResolvedValue({ id: 99, status: 'failed' }); + + await runAssignmentNotificationPoll(); + + expect(pushDeliveryUpdateMany).toHaveBeenCalledWith({ + where: expect.objectContaining({ + id: 99, + }), + data: { + status: 'pending', + error: expect.anything(), + }, + }); + expect(sendAssignmentPush).toHaveBeenCalled(); + expect(assignmentSnapshotUpsert).toHaveBeenCalled(); + }); + + it('does not retry a matching push delivery that is already in flight', async () => { + assignmentWatchFindMany.mockResolvedValue([activeWatch]); + pushDeliveryFindFirst.mockResolvedValue({ + id: 99, + status: 'pending', + updatedAt: new Date(), + }); + pushDeliveryUpdateMany.mockResolvedValue({ count: 0 }); await runAssignmentNotificationPoll(); expect(pushDeliveryCreate).not.toHaveBeenCalled(); expect(sendAssignmentPush).not.toHaveBeenCalled(); + expect(assignmentSnapshotUpsert).not.toHaveBeenCalled(); }); it('records failed assignment-change deliveries without a Competition Groups URL', async () => { delete process.env.COMPETITION_GROUPS_ORIGIN; - assignmentWatchFindMany - .mockResolvedValueOnce([ - { competitionId: 'TestComp2026', wcaUserId: 123 }, - ]) - .mockResolvedValueOnce([{ pushSubscription: subscription }]); + assignmentWatchFindMany.mockResolvedValue([activeWatch]); sendAssignmentPush.mockResolvedValue({ success: false, error: { message: 'push failed' }, @@ -193,6 +307,224 @@ describe('assignment notification worker', () => { error: { message: 'push failed' }, }, }); + expect(assignmentSnapshotUpsert).not.toHaveBeenCalled(); + }); + + it('sends one reminder when the earliest activity starts within 24 hours', async () => { + assignmentWatchFindMany.mockResolvedValue([activeWatch]); + assignmentSnapshotFindUnique.mockResolvedValue(null); + fetchWcif.mockResolvedValue( + wcifWithSchedule( + scheduleWithRooms([ + { + activities: [activity(1, '2026-01-02T09:00:00.000Z')], + }, + ]) + ) + ); + + await runAssignmentNotificationPoll(pollNow); + + expect(pushDeliveryCreate).toHaveBeenCalledWith({ + data: { + pushSubscriptionId: subscription.id, + competitionId: 'TestComp2026', + wcaUserId: 123, + dedupeKey: + 'competition-start-reminder:TestComp2026:123:2026-01-02T09:00:00.000Z', + status: 'pending', + }, + }); + expect(sendAssignmentPush).toHaveBeenCalledWith( + subscription, + expect.objectContaining({ + type: 'competition-start-reminder', + competitionId: 'TestComp2026', + wcaUserId: 123, + startsAt: '2026-01-02T09:00:00.000Z', + title: 'Competition tomorrow', + body: 'Test Competition 2026 starts within 24 hours.', + url: 'https://groups.example/competitions/TestComp2026/persons/123', + dedupeKey: + 'competition-start-reminder:TestComp2026:123:2026-01-02T09:00:00.000Z', + }) + ); + expect(pushDeliveryUpdate).toHaveBeenCalledWith({ + where: { + id: 20, + }, + data: { + status: 'sent', + error: expect.anything(), + }, + }); + }); + + it('uses the earliest activity across rooms and child activities', async () => { + assignmentWatchFindMany.mockResolvedValue([activeWatch]); + assignmentSnapshotFindUnique.mockResolvedValue(null); + fetchWcif.mockResolvedValue( + wcifWithSchedule( + scheduleWithRooms([ + { + activities: [activity(1, '2026-01-02T12:00:00.000Z')], + }, + { + activities: [ + activity(2, '2026-01-02T11:00:00.000Z', [ + activity(3, '2026-01-02T08:30:00.000Z'), + activity(4, '2026-01-02T10:00:00.000Z'), + ]), + ], + }, + ]) + ) + ); + + await runAssignmentNotificationPoll(pollNow); + + expect(sendAssignmentPush).toHaveBeenCalledWith( + subscription, + expect.objectContaining({ + type: 'competition-start-reminder', + startsAt: '2026-01-02T08:30:00.000Z', + dedupeKey: + 'competition-start-reminder:TestComp2026:123:2026-01-02T08:30:00.000Z', + }) + ); + }); + + it('skips reminders when the earliest activity is more than 24 hours away', async () => { + assignmentWatchFindMany.mockResolvedValue([activeWatch]); + assignmentSnapshotFindUnique.mockResolvedValue(null); + fetchWcif.mockResolvedValue( + wcifWithSchedule( + scheduleWithRooms([ + { + activities: [activity(1, '2026-01-02T10:00:01.000Z')], + }, + ]) + ) + ); + + await runAssignmentNotificationPoll(pollNow); + + expect(sendAssignmentPush).not.toHaveBeenCalled(); + expect(pushDeliveryCreate).not.toHaveBeenCalled(); + }); + + it('skips reminders when the earliest activity is already past', async () => { + assignmentWatchFindMany.mockResolvedValue([activeWatch]); + assignmentSnapshotFindUnique.mockResolvedValue(null); + fetchWcif.mockResolvedValue( + wcifWithSchedule( + scheduleWithRooms([ + { + activities: [activity(1, '2026-01-01T09:59:59.000Z')], + }, + ]) + ) + ); + + await runAssignmentNotificationPoll(pollNow); + + expect(sendAssignmentPush).not.toHaveBeenCalled(); + expect(pushDeliveryCreate).not.toHaveBeenCalled(); + }); + + it('skips reminders when a matching delivery was already sent', async () => { + assignmentWatchFindMany.mockResolvedValue([activeWatch]); + assignmentSnapshotFindUnique.mockResolvedValue(null); + pushDeliveryFindFirst.mockResolvedValue({ id: 99, status: 'sent' }); + fetchWcif.mockResolvedValue( + wcifWithSchedule( + scheduleWithRooms([ + { + activities: [activity(1, '2026-01-02T09:00:00.000Z')], + }, + ]) + ) + ); + + await runAssignmentNotificationPoll(pollNow); + + expect(pushDeliveryCreate).not.toHaveBeenCalled(); + expect(sendAssignmentPush).not.toHaveBeenCalled(); + expect(assignmentSnapshotUpsert).toHaveBeenCalled(); + }); + + it('retries failed reminder deliveries while still in the window', async () => { + assignmentWatchFindMany.mockResolvedValue([activeWatch]); + assignmentSnapshotFindUnique.mockResolvedValue(null); + pushDeliveryFindFirst.mockResolvedValue({ id: 99, status: 'failed' }); + fetchWcif.mockResolvedValue( + wcifWithSchedule( + scheduleWithRooms([ + { + activities: [activity(1, '2026-01-02T09:00:00.000Z')], + }, + ]) + ) + ); + + await runAssignmentNotificationPoll(pollNow); + + expect(pushDeliveryCreate).not.toHaveBeenCalled(); + expect(pushDeliveryUpdateMany).toHaveBeenCalledWith({ + where: expect.objectContaining({ + id: 99, + }), + data: { + status: 'pending', + error: expect.anything(), + }, + }); + expect(sendAssignmentPush).toHaveBeenCalledWith( + subscription, + expect.objectContaining({ + type: 'competition-start-reminder', + }) + ); + expect(pushDeliveryUpdate).toHaveBeenCalledWith({ + where: { + id: 99, + }, + data: { + status: 'sent', + error: expect.anything(), + }, + }); + }); + + it('still runs assignment-change logic after reminder checks', async () => { + assignmentWatchFindMany.mockResolvedValue([activeWatch]); + fetchWcif.mockResolvedValue( + wcifWithSchedule( + scheduleWithRooms([ + { + activities: [activity(1, '2026-01-02T09:00:00.000Z')], + }, + ]) + ) + ); + + await runAssignmentNotificationPoll(pollNow); + + expect(sendAssignmentPush).toHaveBeenNthCalledWith( + 1, + subscription, + expect.objectContaining({ + type: 'competition-start-reminder', + }) + ); + expect(sendAssignmentPush).toHaveBeenNthCalledWith( + 2, + subscription, + expect.objectContaining({ + type: 'assignment-change', + }) + ); + expect(assignmentSnapshotUpsert).toHaveBeenCalled(); }); it('leaves the worker disabled unless assignment pushes are enabled', () => { @@ -200,6 +532,8 @@ describe('assignment notification worker', () => { startAssignmentNotificationWorker(); - expect(console.info).toHaveBeenCalledWith('Assignment push worker disabled'); + expect(console.info).toHaveBeenCalledWith( + 'Assignment push worker disabled' + ); }); }); diff --git a/packages/server/services/assignmentNotificationWorker.ts b/packages/server/services/assignmentNotificationWorker.ts index 799d972..b07f860 100644 --- a/packages/server/services/assignmentNotificationWorker.ts +++ b/packages/server/services/assignmentNotificationWorker.ts @@ -1,42 +1,60 @@ import prisma from '../db'; import { createAssignmentSnapshot } from '../lib/assignmentSnapshots'; -import { - PushDeliveryStatus, - PushSubscription, -} from '../prisma/generated/client'; -import { sendAssignmentPush } from './webPush'; +import { PushSubscription } from '../prisma/generated/client'; import { fetchWcif } from './wcif'; +import { runWithConcurrency } from '../lib/runWithConcurrency'; +import { deliverAssignmentPush } from './assignmentPushDeliveries'; +import { competitionGroupsPersonUrl } from './competitionGroupsUrls'; +import { + deliverCompetitionStartReminders, + getEarliestCompetitionStartTime, + shouldSendCompetitionStartReminder, +} from './competitionStartReminders'; interface WatchTarget { competitionId: string; wcaUserId: number; } -const groupTargetsByCompetition = (targets: WatchTarget[]) => - targets.reduce>((acc, target) => { - acc[target.competitionId] = [ - ...(acc[target.competitionId] ?? []), - target.wcaUserId, - ]; - return acc; - }, {}); - -const competitionGroupsUrl = (competitionId: string, wcaUserId: number) => { - const origin = process.env.COMPETITION_GROUPS_ORIGIN; - if (!origin) { - return undefined; - } +type ActiveWatch = WatchTarget & { + pushSubscription: PushSubscription; +}; + +const positiveIntFromEnv = (name: string, fallback: number) => { + const value = Number(process.env[name]); + return Number.isInteger(value) && value > 0 ? value : fallback; +}; + +const assignmentCompetitionConcurrency = () => + positiveIntFromEnv('ASSIGNMENT_POLL_COMPETITION_CONCURRENCY', 2); - return `${origin.replace(/\/$/, '')}/competitions/${competitionId}/persons/${wcaUserId}`; +const assignmentDeliveryConcurrency = () => + positiveIntFromEnv('ASSIGNMENT_PUSH_DELIVERY_CONCURRENCY', 10); + +const groupWatchesByCompetition = (watches: ActiveWatch[]) => { + const competitions = new Map>(); + + watches.forEach((watch) => { + const users = + competitions.get(watch.competitionId) ?? + new Map(); + users.set(watch.wcaUserId, [ + ...(users.get(watch.wcaUserId) ?? []), + watch.pushSubscription, + ]); + competitions.set(watch.competitionId, users); + }); + + return competitions; }; -const createDedupeKey = ( +const createAssignmentChangeDedupeKey = ( competitionId: string, wcaUserId: number, assignmentsHash: string ) => `assignment-change:${competitionId}:${wcaUserId}:${assignmentsHash}`; -const createPayload = ( +const createAssignmentChangePayload = ( competitionId: string, wcaUserId: number, assignmentsHash: string @@ -46,32 +64,17 @@ const createPayload = ( wcaUserId, title: 'Assignment update', body: 'Your competition assignments changed. Open competitiongroups.com to review the latest groups.', - url: competitionGroupsUrl(competitionId, wcaUserId), - dedupeKey: createDedupeKey(competitionId, wcaUserId, assignmentsHash), + url: competitionGroupsPersonUrl(competitionId, wcaUserId), + dedupeKey: createAssignmentChangeDedupeKey( + competitionId, + wcaUserId, + assignmentsHash + ), }); -const getActiveTargets = async (): Promise => +const getActiveWatches = async (): Promise => prisma.assignmentWatch.findMany({ - distinct: ['competitionId', 'wcaUserId'], - select: { - competitionId: true, - wcaUserId: true, - }, - where: { - pushSubscription: { - disabledAt: null, - }, - }, - }); - -const getSubscriptionsForTarget = async ( - competitionId: string, - wcaUserId: number -): Promise => { - const watches = await prisma.assignmentWatch.findMany({ where: { - competitionId, - wcaUserId, pushSubscription: { disabledAt: null, }, @@ -81,116 +84,119 @@ const getSubscriptionsForTarget = async ( }, }); - return watches.map((watch) => watch.pushSubscription); -}; - const deliverAssignmentChange = async ( subscription: PushSubscription, competitionId: string, wcaUserId: number, assignmentsHash: string ) => { - const dedupeKey = createDedupeKey(competitionId, wcaUserId, assignmentsHash); - const existingDelivery = await prisma.pushDelivery.findFirst({ - where: { - pushSubscriptionId: subscription.id, - dedupeKey, - }, - }); - - if (existingDelivery) { - return; - } + const dedupeKey = createAssignmentChangeDedupeKey( + competitionId, + wcaUserId, + assignmentsHash + ); - const delivery = await prisma.pushDelivery.create({ - data: { - pushSubscriptionId: subscription.id, + return deliverAssignmentPush({ + subscription, + competitionId, + wcaUserId, + dedupeKey, + payload: createAssignmentChangePayload( competitionId, wcaUserId, - dedupeKey, - status: PushDeliveryStatus.pending, - }, - }); - - const result = await sendAssignmentPush( - subscription, - createPayload(competitionId, wcaUserId, assignmentsHash) - ); - - await prisma.pushDelivery.update({ - where: { - id: delivery.id, - }, - data: { - status: result.success - ? PushDeliveryStatus.sent - : PushDeliveryStatus.failed, - error: result.error ?? undefined, - }, + assignmentsHash + ), }); }; -export const runAssignmentNotificationPoll = async () => { - const targets = await getActiveTargets(); - const targetsByCompetition = groupTargetsByCompetition(targets); - - for (const [competitionId, wcaUserIds] of Object.entries( - targetsByCompetition - )) { - const wcif = await fetchWcif(competitionId); +export const runAssignmentNotificationPoll = async (now = new Date()) => { + const targetsByCompetition = groupWatchesByCompetition( + await getActiveWatches() + ); - for (const wcaUserId of wcaUserIds) { - const nextSnapshot = createAssignmentSnapshot(wcif, wcaUserId); - if (!nextSnapshot) { - continue; - } - - const previousSnapshot = await prisma.assignmentSnapshot.findUnique({ - where: { - competitionId_wcaUserId: { - competitionId, - wcaUserId, - }, - }, - }); - - await prisma.assignmentSnapshot.upsert({ - where: { - competitionId_wcaUserId: { - competitionId, - wcaUserId, - }, - }, - update: { - assignmentsHash: nextSnapshot.assignmentsHash, - }, - create: nextSnapshot, - }); + await runWithConcurrency( + [...targetsByCompetition.entries()], + async ([competitionId, targets]) => { + const wcif = await fetchWcif(competitionId); + const earliestStartTime = getEarliestCompetitionStartTime(wcif); if ( - !previousSnapshot || - previousSnapshot.assignmentsHash === nextSnapshot.assignmentsHash + earliestStartTime && + shouldSendCompetitionStartReminder(earliestStartTime, now) ) { - continue; + await deliverCompetitionStartReminders({ + competitionId, + competitionName: wcif.name, + targets, + earliestStartTime, + concurrency: assignmentDeliveryConcurrency(), + }); } - const subscriptions = await getSubscriptionsForTarget( - competitionId, - wcaUserId - ); - - await Promise.all( - subscriptions.map(async (subscription) => - deliverAssignmentChange( - subscription, - competitionId, - wcaUserId, - nextSnapshot.assignmentsHash - ) - ) - ); - } - } + for (const [wcaUserId, subscriptions] of targets.entries()) { + const nextSnapshot = createAssignmentSnapshot(wcif, wcaUserId); + if (!nextSnapshot) { + continue; + } + + const previousSnapshot = await prisma.assignmentSnapshot.findUnique({ + where: { + competitionId_wcaUserId: { + competitionId, + wcaUserId, + }, + }, + }); + + if ( + !previousSnapshot || + previousSnapshot.assignmentsHash === nextSnapshot.assignmentsHash + ) { + await prisma.assignmentSnapshot.upsert({ + where: { + competitionId_wcaUserId: { + competitionId, + wcaUserId, + }, + }, + update: { + assignmentsHash: nextSnapshot.assignmentsHash, + }, + create: nextSnapshot, + }); + continue; + } + + const deliveryResults = await runWithConcurrency( + subscriptions, + async (subscription) => + deliverAssignmentChange( + subscription, + competitionId, + wcaUserId, + nextSnapshot.assignmentsHash + ), + assignmentDeliveryConcurrency() + ); + + if (deliveryResults.every(Boolean)) { + await prisma.assignmentSnapshot.upsert({ + where: { + competitionId_wcaUserId: { + competitionId, + wcaUserId, + }, + }, + update: { + assignmentsHash: nextSnapshot.assignmentsHash, + }, + create: nextSnapshot, + }); + } + } + }, + assignmentCompetitionConcurrency() + ); }; export const startAssignmentNotificationWorker = () => { @@ -218,7 +224,11 @@ export const startAssignmentNotificationWorker = () => { }; void poll(); - setInterval(() => { + const interval = setInterval(() => { void poll(); }, intervalMs); + + return () => { + clearInterval(interval); + }; }; diff --git a/packages/server/services/assignmentPushDeliveries.ts b/packages/server/services/assignmentPushDeliveries.ts new file mode 100644 index 0000000..789652d --- /dev/null +++ b/packages/server/services/assignmentPushDeliveries.ts @@ -0,0 +1,40 @@ +import { PushSubscription } from '../prisma/generated/client'; +import { claimPushDelivery, completePushDelivery } from './pushDeliveries'; +import type { PushPayload } from './webPush'; +import { sendAssignmentPush } from './webPush'; + +interface AssignmentPushDeliveryInput { + subscription: PushSubscription; + competitionId: string; + wcaUserId: number; + dedupeKey: string; + payload: PushPayload; +} + +export const deliverAssignmentPush = async ({ + subscription, + competitionId, + wcaUserId, + dedupeKey, + payload, +}: AssignmentPushDeliveryInput) => { + const deliveryClaim = await claimPushDelivery({ + pushSubscriptionId: subscription.id, + competitionId, + wcaUserId, + dedupeKey, + }); + + if (deliveryClaim.status === 'already-sent') { + return true; + } + + if (deliveryClaim.status === 'in-flight') { + return false; + } + + const result = await sendAssignmentPush(subscription, payload); + await completePushDelivery(deliveryClaim.deliveryId, result); + + return result.success; +}; diff --git a/packages/server/services/competitionGroupsUrls.ts b/packages/server/services/competitionGroupsUrls.ts new file mode 100644 index 0000000..7e5db92 --- /dev/null +++ b/packages/server/services/competitionGroupsUrls.ts @@ -0,0 +1,14 @@ +export const competitionGroupsPersonUrl = ( + competitionId: string, + wcaUserId: number +) => { + const origin = process.env.COMPETITION_GROUPS_ORIGIN; + if (!origin) { + return undefined; + } + + return `${origin.replace( + /\/$/, + '' + )}/competitions/${competitionId}/persons/${wcaUserId}`; +}; diff --git a/packages/server/services/competitionStartReminders.ts b/packages/server/services/competitionStartReminders.ts new file mode 100644 index 0000000..63bfbe5 --- /dev/null +++ b/packages/server/services/competitionStartReminders.ts @@ -0,0 +1,115 @@ +import type { WcifPayload } from '../lib/assignmentSnapshots'; +import { runWithConcurrency } from '../lib/runWithConcurrency'; +import { PushSubscription } from '../prisma/generated/client'; +import { getFlatActivities } from '../scheduler/utils'; +import { deliverAssignmentPush } from './assignmentPushDeliveries'; +import { competitionGroupsPersonUrl } from './competitionGroupsUrls'; + +const competitionStartReminderWindowMs = 24 * 60 * 60 * 1000; + +export type CompetitionStartReminderTargets = Map; + +const createCompetitionStartReminderDedupeKey = ( + competitionId: string, + wcaUserId: number, + earliestStartTime: Date +) => + `competition-start-reminder:${competitionId}:${wcaUserId}:${earliestStartTime.toISOString()}`; + +const createCompetitionStartReminderPayload = ( + competitionId: string, + competitionName: string | null | undefined, + wcaUserId: number, + earliestStartTime: Date +) => ({ + type: 'competition-start-reminder' as const, + competitionId, + wcaUserId, + startsAt: earliestStartTime.toISOString(), + title: 'Competition tomorrow', + body: `${competitionName ?? competitionId} starts within 24 hours.`, + url: competitionGroupsPersonUrl(competitionId, wcaUserId), + dedupeKey: createCompetitionStartReminderDedupeKey( + competitionId, + wcaUserId, + earliestStartTime + ), +}); + +export const getEarliestCompetitionStartTime = (wcif: WcifPayload) => { + if (!wcif.schedule) { + return null; + } + + const earliestStartTime = getFlatActivities(wcif.schedule).reduce< + number | null + >((earliest, activity) => { + const startTime = new Date(activity.startTime).getTime(); + if (!Number.isFinite(startTime)) { + return earliest; + } + + if (earliest === null || startTime < earliest) { + return startTime; + } + + return earliest; + }, null); + + return earliestStartTime === null ? null : new Date(earliestStartTime); +}; + +export const shouldSendCompetitionStartReminder = ( + earliestStartTime: Date, + now: Date +) => + earliestStartTime.getTime() > now.getTime() && + earliestStartTime.getTime() <= + now.getTime() + competitionStartReminderWindowMs; + +export const deliverCompetitionStartReminders = async ({ + competitionId, + competitionName, + targets, + earliestStartTime, + concurrency, +}: { + competitionId: string; + competitionName: string | null | undefined; + targets: CompetitionStartReminderTargets; + earliestStartTime: Date; + concurrency: number; +}) => { + const deliveries = [...targets.entries()].flatMap( + ([wcaUserId, subscriptions]) => + subscriptions.map((subscription) => ({ + subscription, + wcaUserId, + })) + ); + + await runWithConcurrency( + deliveries, + async ({ subscription, wcaUserId }) => { + const dedupeKey = createCompetitionStartReminderDedupeKey( + competitionId, + wcaUserId, + earliestStartTime + ); + + return deliverAssignmentPush({ + subscription, + competitionId, + wcaUserId, + dedupeKey, + payload: createCompetitionStartReminderPayload( + competitionId, + competitionName, + wcaUserId, + earliestStartTime + ), + }); + }, + concurrency + ); +}; diff --git a/packages/server/services/pushDeliveries.spec.ts b/packages/server/services/pushDeliveries.spec.ts new file mode 100644 index 0000000..1b993e7 --- /dev/null +++ b/packages/server/services/pushDeliveries.spec.ts @@ -0,0 +1,118 @@ +/* eslint-disable import/first */ +const pushDeliveryFindFirst = jest.fn(); +const pushDeliveryCreate = jest.fn(); +const pushDeliveryUpdateMany = jest.fn(); +const pushDeliveryUpdate = jest.fn(); + +jest.mock('../db', () => ({ + __esModule: true, + default: { + pushDelivery: { + findFirst: pushDeliveryFindFirst, + create: pushDeliveryCreate, + updateMany: pushDeliveryUpdateMany, + update: pushDeliveryUpdate, + }, + }, +})); + +import { claimPushDelivery, completePushDelivery } from './pushDeliveries'; + +const claimInput = { + pushSubscriptionId: 10, + competitionId: 'TestComp2026', + wcaUserId: 123, + dedupeKey: 'assignment-change:TestComp2026:123:hash', +}; + +describe('push delivery claims', () => { + beforeEach(() => { + pushDeliveryFindFirst.mockReset().mockResolvedValue(null); + pushDeliveryCreate.mockReset().mockResolvedValue({ id: 20 }); + pushDeliveryUpdateMany.mockReset().mockResolvedValue({ count: 1 }); + pushDeliveryUpdate.mockReset().mockResolvedValue({ id: 20 }); + jest.spyOn(Date, 'now').mockReturnValue(1000000); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('creates and claims a new delivery', async () => { + await expect(claimPushDelivery(claimInput)).resolves.toEqual({ + status: 'claimed', + deliveryId: 20, + }); + + expect(pushDeliveryCreate).toHaveBeenCalledWith({ + data: { + pushSubscriptionId: 10, + competitionId: 'TestComp2026', + wcaUserId: 123, + dedupeKey: 'assignment-change:TestComp2026:123:hash', + status: 'pending', + }, + }); + }); + + it('does not claim deliveries that were already sent', async () => { + pushDeliveryFindFirst.mockResolvedValue({ id: 20, status: 'sent' }); + + await expect(claimPushDelivery(claimInput)).resolves.toEqual({ + status: 'already-sent', + }); + + expect(pushDeliveryCreate).not.toHaveBeenCalled(); + expect(pushDeliveryUpdateMany).not.toHaveBeenCalled(); + }); + + it('leaves fresh pending deliveries in flight', async () => { + pushDeliveryFindFirst.mockResolvedValue({ + id: 20, + status: 'pending', + updatedAt: new Date(999000), + }); + pushDeliveryUpdateMany.mockResolvedValue({ count: 0 }); + + await expect(claimPushDelivery(claimInput)).resolves.toEqual({ + status: 'in-flight', + }); + }); + + it('claims stale pending deliveries for retry', async () => { + pushDeliveryFindFirst.mockResolvedValue({ + id: 20, + status: 'pending', + updatedAt: new Date(1), + }); + + await expect(claimPushDelivery(claimInput)).resolves.toEqual({ + status: 'claimed', + deliveryId: 20, + }); + + expect(pushDeliveryUpdateMany).toHaveBeenCalledWith({ + where: expect.objectContaining({ + id: 20, + }), + data: { + status: 'pending', + error: expect.anything(), + }, + }); + }); + + it('records successful delivery completion', async () => { + await completePushDelivery(20, { success: true, error: null }); + + expect(pushDeliveryUpdate).toHaveBeenCalledWith({ + where: { + id: 20, + }, + data: { + status: 'sent', + error: expect.anything(), + }, + }); + }); +}); diff --git a/packages/server/services/pushDeliveries.ts b/packages/server/services/pushDeliveries.ts new file mode 100644 index 0000000..320bd79 --- /dev/null +++ b/packages/server/services/pushDeliveries.ts @@ -0,0 +1,125 @@ +import prisma from '../db'; +import { Prisma, PushDeliveryStatus } from '../prisma/generated/client'; + +const DEFAULT_PENDING_TIMEOUT_MS = 15 * 60 * 1000; + +export interface PushDeliveryClaimInput { + pushSubscriptionId: number; + competitionId: string; + wcaUserId: number; + dedupeKey: string; +} + +export type PushDeliveryClaim = + | { + status: 'claimed'; + deliveryId: number; + } + | { + status: 'already-sent'; + } + | { + status: 'in-flight'; + }; + +export interface PushDeliveryResult { + success: boolean; + error?: unknown; +} + +const pendingTimeoutMs = () => { + const value = Number(process.env.PUSH_DELIVERY_PENDING_TIMEOUT_MS); + return Number.isInteger(value) && value > 0 + ? value + : DEFAULT_PENDING_TIMEOUT_MS; +}; + +const stalePendingCutoff = () => new Date(Date.now() - pendingTimeoutMs()); + +const isUniqueConstraintError = (error: unknown) => + error instanceof Prisma.PrismaClientKnownRequestError && + error.code === 'P2002'; + +export const claimPushDelivery = async ( + input: PushDeliveryClaimInput, + retryOnCreateRace = true +): Promise => { + const existingDelivery = await prisma.pushDelivery.findFirst({ + where: { + pushSubscriptionId: input.pushSubscriptionId, + dedupeKey: input.dedupeKey, + }, + }); + + if (existingDelivery?.status === PushDeliveryStatus.sent) { + return { status: 'already-sent' }; + } + + if (existingDelivery) { + const result = await prisma.pushDelivery.updateMany({ + where: { + id: existingDelivery.id, + OR: [ + { + status: { + in: [PushDeliveryStatus.failed, PushDeliveryStatus.skipped], + }, + }, + { + status: PushDeliveryStatus.pending, + updatedAt: { + lte: stalePendingCutoff(), + }, + }, + ], + }, + data: { + status: PushDeliveryStatus.pending, + error: Prisma.JsonNull, + }, + }); + + return result.count === 1 + ? { status: 'claimed', deliveryId: existingDelivery.id } + : { status: 'in-flight' }; + } + + try { + const delivery = await prisma.pushDelivery.create({ + data: { + pushSubscriptionId: input.pushSubscriptionId, + competitionId: input.competitionId, + wcaUserId: input.wcaUserId, + dedupeKey: input.dedupeKey, + status: PushDeliveryStatus.pending, + }, + }); + + return { status: 'claimed', deliveryId: delivery.id }; + } catch (error) { + if (retryOnCreateRace && isUniqueConstraintError(error)) { + return claimPushDelivery(input, false); + } + + throw error; + } +}; + +export const completePushDelivery = async ( + deliveryId: number, + result: PushDeliveryResult +) => + prisma.pushDelivery.update({ + where: { + id: deliveryId, + }, + data: { + status: result.success + ? PushDeliveryStatus.sent + : PushDeliveryStatus.failed, + error: + result.error === null || result.error === undefined + ? Prisma.JsonNull + : (result.error as Prisma.InputJsonValue), + }, + }); diff --git a/packages/server/services/wcif.spec.ts b/packages/server/services/wcif.spec.ts index 42638fc..baf488d 100644 --- a/packages/server/services/wcif.spec.ts +++ b/packages/server/services/wcif.spec.ts @@ -1,9 +1,8 @@ /* eslint-disable import/first */ const fetchMock = jest.fn(); -jest.mock('node-fetch', () => ({ - __esModule: true, - default: fetchMock, +jest.mock('../lib/fetchWithTimeout', () => ({ + fetchWithTimeout: fetchMock, })); import { fetchWcif } from './wcif'; @@ -39,7 +38,8 @@ describe('fetchWcif', () => { headers: { Authorization: 'Bearer access-token', }, - } + }, + { retries: 2 } ); }); diff --git a/packages/server/services/wcif.ts b/packages/server/services/wcif.ts index 80cc10a..d8c8de3 100644 --- a/packages/server/services/wcif.ts +++ b/packages/server/services/wcif.ts @@ -1,5 +1,5 @@ -import fetch from 'node-fetch'; import { WcifPayload } from '../lib/assignmentSnapshots'; +import { fetchWithTimeout } from '../lib/fetchWithTimeout'; export const fetchWcif = async (competitionId: string): Promise => { const origin = process.env.WCA_ORIGIN ?? 'https://www.worldcubeassociation.org'; @@ -9,9 +9,10 @@ export const fetchWcif = async (competitionId: string): Promise => headers.Authorization = `Bearer ${process.env.WCA_OAUTH_TOKEN}`; } - const response = await fetch( + const response = await fetchWithTimeout( `${origin}/api/v0/competitions/${competitionId}/wcif`, - { headers } + { headers }, + { retries: 2 } ); if (!response.ok) { diff --git a/packages/server/services/webPush.spec.ts b/packages/server/services/webPush.spec.ts index 994d9a1..9382780 100644 --- a/packages/server/services/webPush.spec.ts +++ b/packages/server/services/webPush.spec.ts @@ -83,6 +83,37 @@ describe('sendAssignmentPush', () => { ); }); + it('sends a competition start reminder payload', async () => { + await expect( + sendAssignmentPush(subscription as never, { + type: 'competition-start-reminder', + competitionId: 'TestComp2026', + wcaUserId: 123, + startsAt: '2026-01-02T09:00:00.000Z', + title: 'Competition tomorrow', + body: 'Test Competition 2026 starts within 24 hours.', + }) + ).resolves.toEqual({ success: true, error: null }); + + expect(sendNotification).toHaveBeenCalledWith( + { + endpoint: subscription.endpoint, + keys: { + p256dh: subscription.p256dh, + auth: subscription.auth, + }, + }, + JSON.stringify({ + type: 'competition-start-reminder', + competitionId: 'TestComp2026', + wcaUserId: 123, + startsAt: '2026-01-02T09:00:00.000Z', + title: 'Competition tomorrow', + body: 'Test Competition 2026 starts within 24 hours.', + }) + ); + }); + it('returns a failed result when VAPID config is missing', async () => { delete process.env.VAPID_PUBLIC_KEY; diff --git a/packages/server/services/webPush.ts b/packages/server/services/webPush.ts index e82dc57..6741a5b 100644 --- a/packages/server/services/webPush.ts +++ b/packages/server/services/webPush.ts @@ -9,6 +9,7 @@ export interface AssignmentPushPayload { title: string; body: string; url?: string; + dedupeKey?: string; } export interface ActivityHeadsUpPushPayload { @@ -19,16 +20,33 @@ export interface ActivityHeadsUpPushPayload { title: string; body: string; url?: string; + dedupeKey?: string; } -export type PushPayload = AssignmentPushPayload | ActivityHeadsUpPushPayload; +export interface CompetitionStartReminderPushPayload { + type: 'competition-start-reminder'; + competitionId: string; + wcaUserId: number; + startsAt: string; + title: string; + body: string; + url?: string; + dedupeKey?: string; +} + +export type PushPayload = + | AssignmentPushPayload + | ActivityHeadsUpPushPayload + | CompetitionStartReminderPushPayload; const configureWebPush = () => { const publicKey = process.env.VAPID_PUBLIC_KEY; const privateKey = process.env.VAPID_PRIVATE_KEY; if (!publicKey || !privateKey) { - throw new Error('VAPID_PUBLIC_KEY and VAPID_PRIVATE_KEY must be configured'); + throw new Error( + 'VAPID_PUBLIC_KEY and VAPID_PRIVATE_KEY must be configured' + ); } webPush.setVapidDetails( diff --git a/yarn.lock b/yarn.lock index 4ab5b07..2715c4c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8696,6 +8696,11 @@ ipaddr.js@1.9.1: resolved "https://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-1.9.1.tgz#bff38543eeb8984825079ff3a2a8e6cbd46781b3" integrity sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g== +ipaddr.js@^2.4.0: + version "2.4.0" + resolved "https://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-2.4.0.tgz#038e9ceaf8219efc5bb76347b7eb787875d5095b" + integrity sha512-9VGk3HGanVE6JoZXHiCpnGy5X0jYDnN4EA4lntFPj+1vIWlFhIylq2CrrCOJH9EAhc5CYhq18F2Av2tgoAPsYQ== + is-absolute@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/is-absolute/-/is-absolute-1.0.0.tgz#395e1ae84b11f26ad1795e73c17378e48a301576"