From 85fa8d75214dbf3289d6e1c8b80a149ca8ae6c1a Mon Sep 17 00:00:00 2001 From: Cailyn Sinclair Date: Wed, 27 May 2026 21:14:46 -0700 Subject: [PATCH 1/5] Add CompetitionGroups push sessions --- README.md | 7 +- .../controllers/pushSubscriptions.spec.ts | 102 +++++++++++++ .../server/controllers/pushSubscriptions.ts | 84 ++++++++++ .../competitionGroupsPushSessionToken.spec.ts | 111 ++++++++++++++ .../lib/competitionGroupsPushSessionToken.ts | 135 +++++++++++++++++ packages/server/routes/v0/external/push.ts | 143 +++++++++++++++++- 6 files changed, 573 insertions(+), 9 deletions(-) create mode 100644 packages/server/lib/competitionGroupsPushSessionToken.spec.ts create mode 100644 packages/server/lib/competitionGroupsPushSessionToken.ts 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/controllers/pushSubscriptions.spec.ts b/packages/server/controllers/pushSubscriptions.spec.ts index 5f775cd..fbb3b52 100644 --- a/packages/server/controllers/pushSubscriptions.spec.ts +++ b/packages/server/controllers/pushSubscriptions.spec.ts @@ -4,6 +4,7 @@ const pushSubscriptionUpdateMany = jest.fn(); const tx = { pushSubscription: { upsert: jest.fn(), + update: jest.fn(), findFirstOrThrow: jest.fn(), }, assignmentWatch: { @@ -24,6 +25,8 @@ jest.mock('../db', () => ({ import { disableCompetitionGroupsPushSubscription, + disableCompetitionGroupsPushSubscriptionSession, + updateCompetitionGroupsPushSubscriptionSession, upsertCompetitionGroupsPushSubscription, } from './pushSubscriptions'; @@ -33,6 +36,7 @@ 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: [], @@ -139,4 +143,102 @@ 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'), + }, + }); + }); }); diff --git a/packages/server/controllers/pushSubscriptions.ts b/packages/server/controllers/pushSubscriptions.ts index 5fbf4dc..7289429 100644 --- a/packages/server/controllers/pushSubscriptions.ts +++ b/packages/server/controllers/pushSubscriptions.ts @@ -14,6 +14,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 +92,78 @@ 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(), + }, + }); 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/routes/v0/external/push.ts b/packages/server/routes/v0/external/push.ts index 87c44ef..1a01249 100644 --- a/packages/server/routes/v0/external/push.ts +++ b/packages/server/routes/v0/external/push.ts @@ -2,9 +2,15 @@ import { Request, Router } from 'express'; import { competitionGroupsAuth } from '../../../middlewares/competitionGroupsAuth'; import { disableCompetitionGroupsPushSubscription, + disableCompetitionGroupsPushSubscriptionSession, PushWatchInput, + 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 +22,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 +125,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 +135,112 @@ 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.delete( '/subscriptions', competitionGroupsAuth, From f05aa909dedb7d005795e34fff63fbb29797359c Mon Sep 17 00:00:00 2001 From: Cailyn Sinclair Date: Wed, 27 May 2026 21:26:14 -0700 Subject: [PATCH 2/5] Improve server reliability under scale --- packages/server/auth/Auth.spec.ts | 32 +- packages/server/auth/Auth.ts | 22 +- packages/server/auth/AuthMiddleware.spec.ts | 11 + packages/server/auth/AuthMiddleware.ts | 19 +- .../controllers/pushSubscriptions.spec.ts | 86 +++++ .../server/controllers/pushSubscriptions.ts | 47 +++ packages/server/controllers/webhooks.spec.ts | 69 +++- packages/server/controllers/webhooks.ts | 43 ++- packages/server/graphql/datasources/WcaApi.ts | 5 +- .../mutations/ActivityMutations.spec.ts | 157 ++++++-- .../resolvers/mutations/ActivityMutations.ts | 265 +++++++++----- .../mutations/CompetitionMutations.spec.ts | 195 ++++++++-- .../mutations/CompetitionMutations.ts | 196 +++++++--- .../mutations/WebhookMutations.spec.ts | 152 +++++++- .../resolvers/mutations/WebhookMutations.ts | 241 ++++++------ packages/server/index.ts | 64 +++- packages/server/lib/fetchWithTimeout.spec.ts | 66 ++++ packages/server/lib/fetchWithTimeout.ts | 61 ++++ .../server/lib/runWithConcurrency.spec.ts | 52 +++ packages/server/lib/runWithConcurrency.ts | 51 +++ packages/server/lib/webhookUrls.spec.ts | 78 ++++ packages/server/lib/webhookUrls.ts | 99 +++++ .../migration.sql | 20 + packages/server/prisma/schema.prisma | 18 +- packages/server/routes/v0/external/push.ts | 37 ++ packages/server/scheduler/index.ts | 343 +++++++++++------- packages/server/server.ts | 51 ++- .../activityHeadsUpNotifications.spec.ts | 5 +- .../services/activityHeadsUpNotifications.ts | 169 +++++---- .../assignmentNotificationWorker.spec.ts | 73 ++-- .../services/assignmentNotificationWorker.ts | 239 +++++++----- packages/server/services/wcif.spec.ts | 8 +- packages/server/services/wcif.ts | 7 +- 33 files changed, 2271 insertions(+), 710 deletions(-) create mode 100644 packages/server/lib/fetchWithTimeout.spec.ts create mode 100644 packages/server/lib/fetchWithTimeout.ts create mode 100644 packages/server/lib/runWithConcurrency.spec.ts create mode 100644 packages/server/lib/runWithConcurrency.ts create mode 100644 packages/server/lib/webhookUrls.spec.ts create mode 100644 packages/server/lib/webhookUrls.ts create mode 100644 packages/server/prisma/migrations/20260528010000_reliability_indexes/migration.sql diff --git a/packages/server/auth/Auth.spec.ts b/packages/server/auth/Auth.spec.ts index be16ff5..6457f67 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( + authMiddlewareVerify: 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..7916fa1 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 { authMiddlewareVerify } 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,7 +219,7 @@ router.get('/wca/callback', async (req, res) => { } }); -router.post('/wca/refresh', authMiddlewareDecode, async (req, res) => { +router.post('/wca/refresh', authMiddlewareVerify, async (req, res) => { if (!req.user) { return res.status(403).send('Unauthenticated'); } @@ -235,7 +239,7 @@ router.post('/wca/refresh', authMiddlewareDecode, async (req, res) => { }); try { - const response = await fetch(`${resolvedWcaOrigin}/oauth/token`, { + const response = await fetchWithTimeout(`${resolvedWcaOrigin}/oauth/token`, { method: 'POST', body: params, headers: { diff --git a/packages/server/auth/AuthMiddleware.spec.ts b/packages/server/auth/AuthMiddleware.spec.ts index 1cb8bad..7e710f3 100644 --- a/packages/server/auth/AuthMiddleware.spec.ts +++ b/packages/server/auth/AuthMiddleware.spec.ts @@ -52,6 +52,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'); diff --git a/packages/server/auth/AuthMiddleware.ts b/packages/server/auth/AuthMiddleware.ts index d32748b..5484306 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 { diff --git a/packages/server/controllers/pushSubscriptions.spec.ts b/packages/server/controllers/pushSubscriptions.spec.ts index fbb3b52..f62355a 100644 --- a/packages/server/controllers/pushSubscriptions.spec.ts +++ b/packages/server/controllers/pushSubscriptions.spec.ts @@ -1,6 +1,8 @@ /* 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(), @@ -19,13 +21,19 @@ jest.mock('../db', () => ({ $transaction: transaction, pushSubscription: { updateMany: pushSubscriptionUpdateMany, + findFirstOrThrow: pushSubscriptionFindFirstOrThrow, }, }, })); +jest.mock('../services/webPush', () => ({ + sendAssignmentPush, +})); + import { disableCompetitionGroupsPushSubscription, disableCompetitionGroupsPushSubscriptionSession, + testCompetitionGroupsPushSubscriptionSession, updateCompetitionGroupsPushSubscriptionSession, upsertCompetitionGroupsPushSubscription, } from './pushSubscriptions'; @@ -43,6 +51,13 @@ describe('push subscription controllers', () => { }); 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(() => { @@ -241,4 +256,75 @@ describe('push subscription controllers', () => { }, }); }); + + 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 7289429..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; @@ -167,3 +168,49 @@ export const disableCompetitionGroupsPushSubscriptionSession = async ( 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..bed8dfa 100644 --- a/packages/server/controllers/webhooks.spec.ts +++ b/packages/server/controllers/webhooks.spec.ts @@ -1,10 +1,16 @@ /* eslint-disable import/first */ +import { HTTPMethod, Webhook } from '../prisma/generated/client'; + const fetchMock = jest.fn(); const webhookFindMany = jest.fn(); +const assertWebhookUrlResolvesPublicly = jest.fn(async (url: string) => url); -jest.mock('node-fetch', () => ({ - __esModule: true, - default: fetchMock, +jest.mock('../lib/fetchWithTimeout', () => ({ + fetchWithTimeout: fetchMock, +})); + +jest.mock('../lib/webhookUrls', () => ({ + assertWebhookUrlResolvesPublicly, })); jest.mock('../db', () => ({ @@ -22,11 +28,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 +40,7 @@ describe('webhook controllers', () => { beforeEach(() => { fetchMock.mockReset(); webhookFindMany.mockReset(); + assertWebhookUrlResolvesPublicly.mockClear(); }); it('sends JSON data with stored webhook headers', async () => { @@ -54,6 +61,7 @@ describe('webhook controllers', () => { expect(fetchMock).toHaveBeenCalledWith('https://hooks.example/notify', { method: 'POST', + size: 64 * 1024, headers: { 'Content-Type': 'application/json', 'X-Test': 'true', @@ -65,6 +73,57 @@ 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, + 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, + 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..e5267fb 100644 --- a/packages/server/controllers/webhooks.ts +++ b/packages/server/controllers/webhooks.ts @@ -1,23 +1,44 @@ -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 { assertWebhookUrlResolvesPublicly } 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 url = await assertWebhookUrlResolvesPublicly(webhook.url); + const canSendBody = !BODYLESS_METHODS.has(webhook.method); + + return fetchWithTimeout(url, { method: webhook.method, + size: MAX_WEBHOOK_RESPONSE_SIZE_BYTES, headers: { - 'Content-Type': 'application/json', + ...(canSendBody && { 'Content-Type': 'application/json' }), ...headers, }, - body: JSON.stringify(data), + ...(canSendBody && { body: JSON.stringify(data) }), }); }; @@ -28,10 +49,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 +72,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/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..8d51625 --- /dev/null +++ b/packages/server/lib/webhookUrls.spec.ts @@ -0,0 +1,78 @@ +/* eslint-disable import/first */ +const dnsLookup = jest.fn(); + +jest.mock('dns', () => ({ + promises: { + lookup: dnsLookup, + }, +})); + +import { + assertValidWebhookUrl, + assertWebhookUrlResolvesPublicly, +} 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' + ); + }); +}); + +describe('assertWebhookUrlResolvesPublicly', () => { + beforeEach(() => { + dnsLookup.mockReset(); + }); + + it('accepts hostnames resolving to public addresses', async () => { + dnsLookup.mockResolvedValue([{ address: '203.0.113.10', 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'); + }); +}); diff --git a/packages/server/lib/webhookUrls.ts b/packages/server/lib/webhookUrls.ts new file mode 100644 index 0000000..c167678 --- /dev/null +++ b/packages/server/lib/webhookUrls.ts @@ -0,0 +1,99 @@ +import net from 'net'; +import { promises as dns } from 'dns'; + +const LOCAL_HOSTNAMES = new Set(['localhost']); + +const isPrivateIpv4 = (host: string) => { + const parts = host.split('.').map(Number); + const [first, second] = parts; + + return ( + first === 10 || + first === 127 || + first === 0 || + first >= 224 || + (first === 100 && second >= 64 && second <= 127) || + (first === 169 && second === 254) || + (first === 172 && second >= 16 && second <= 31) || + (first === 192 && second === 0) || + (first === 192 && second === 168) || + (first === 198 && (second === 18 || second === 19)) + ); +}; + +const isPrivateIpv6 = (host: string) => { + const normalized = host.toLowerCase(); + return ( + normalized === '::1' || + normalized === '::' || + normalized.startsWith('fc') || + normalized.startsWith('fd') || + normalized.startsWith('fe80:') || + normalized.startsWith('ff') + ); +}; + +const assertPublicAddress = (address: string) => { + const ipVersion = net.isIP(address); + if ( + (ipVersion === 4 && isPrivateIpv4(address)) || + (ipVersion === 6 && isPrivateIpv6(address)) + ) { + throw new Error('Webhook URL cannot target private addresses'); + } +}; + +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'); + } + + const ipVersion = net.isIP(hostname); + if (ipVersion) { + assertPublicAddress(hostname); + } + + return url.toString(); +}; + +export const assertWebhookUrlResolvesPublicly = async (value: string) => { + const normalizedUrl = assertValidWebhookUrl(value); + const { hostname } = new URL(normalizedUrl); + const normalizedHostname = hostname.replace(/^\[|\]$/g, '').toLowerCase(); + + if (net.isIP(normalizedHostname)) { + return 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 normalizedUrl; +}; 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 1a01249..cd2ada8 100644 --- a/packages/server/routes/v0/external/push.ts +++ b/packages/server/routes/v0/external/push.ts @@ -4,6 +4,7 @@ import { disableCompetitionGroupsPushSubscription, disableCompetitionGroupsPushSubscriptionSession, PushWatchInput, + testCompetitionGroupsPushSubscriptionSession, updateCompetitionGroupsPushSubscriptionSession, upsertCompetitionGroupsPushSubscription, } from '../../../controllers/pushSubscriptions'; @@ -241,6 +242,42 @@ router.delete('/sessions/current', async (req: Request, res) => { } }); +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..c3d94dd 100644 --- a/packages/server/services/activityHeadsUpNotifications.spec.ts +++ b/packages/server/services/activityHeadsUpNotifications.spec.ts @@ -47,6 +47,7 @@ describe('sendActivityHeadsUpPush', () => { competitionAccessFindMany.mockReset().mockResolvedValue([{ userId: 123 }]); assignmentWatchFindMany.mockReset().mockResolvedValue([ { + wcaUserId: 123, pushSubscription: subscription, }, ]); @@ -84,7 +85,9 @@ describe('sendActivityHeadsUpPush', () => { expect(assignmentWatchFindMany).toHaveBeenCalledWith({ where: { competitionId: 'TestComp2026', - wcaUserId: 123, + wcaUserId: { + in: [123], + }, pushSubscription: { disabledAt: null, }, diff --git a/packages/server/services/activityHeadsUpNotifications.ts b/packages/server/services/activityHeadsUpNotifications.ts index a986f9f..bb83447 100644 --- a/packages/server/services/activityHeadsUpNotifications.ts +++ b/packages/server/services/activityHeadsUpNotifications.ts @@ -1,6 +1,10 @@ import prisma from '../db'; -import { PushDeliveryStatus } from '../prisma/generated/client'; +import { + PushDeliveryStatus, + PushSubscription, +} from '../prisma/generated/client'; import { sendAssignmentPush } from './webPush'; +import { runWithConcurrency } from '../lib/runWithConcurrency'; const competitionGroupsUrl = (competitionId: string, wcaUserId: number) => { const origin = process.env.COMPETITION_GROUPS_ORIGIN; @@ -8,7 +12,10 @@ const competitionGroupsUrl = (competitionId: string, wcaUserId: number) => { return undefined; } - return `${origin.replace(/\/$/, '')}/competitions/${competitionId}/persons/${wcaUserId}`; + return `${origin.replace( + /\/$/, + '' + )}/competitions/${competitionId}/persons/${wcaUserId}`; }; const createDedupeKey = ( @@ -37,14 +44,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 +68,73 @@ 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 + ); + const existingDelivery = await prisma.pushDelivery.findFirst({ + where: { + pushSubscriptionId: subscription.id, + dedupeKey, + }, + }); + + if (existingDelivery) { + return; + } + + const delivery = await prisma.pushDelivery.create({ + data: { + pushSubscriptionId: subscription.id, + competitionId, + wcaUserId: targetUserId, + 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, targetUserId), + }); + + await prisma.pushDelivery.update({ + where: { + id: delivery.id, + }, + data: { + status: result.success + ? PushDeliveryStatus.sent + : PushDeliveryStatus.failed, + error: result.error ?? undefined, + }, + }); }; export const sendActivityHeadsUpPush = async ( @@ -63,62 +143,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, - }, - }); - - 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, - }, - }); - } - } + activityIds, + startsAt, + targetUserId: target.userId, + subscription: watch.pushSubscription, + })) + ); + + await runWithConcurrency( + deliveries, + sendActivityHeadsUpPushDelivery, + pushDeliveryConcurrency() + ); }; diff --git a/packages/server/services/assignmentNotificationWorker.spec.ts b/packages/server/services/assignmentNotificationWorker.spec.ts index 1cd90a7..6c45d34 100644 --- a/packages/server/services/assignmentNotificationWorker.spec.ts +++ b/packages/server/services/assignmentNotificationWorker.spec.ts @@ -46,6 +46,12 @@ const subscription = { auth: 'auth', }; +const activeWatch = { + competitionId: 'TestComp2026', + wcaUserId: 123, + pushSubscription: subscription, +}; + describe('assignment notification worker', () => { const env = process.env; @@ -91,14 +97,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 +136,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 +147,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 +162,39 @@ 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(pushDeliveryUpdate).toHaveBeenCalledWith({ + where: { + id: 99, + }, + data: { + status: 'pending', + error: expect.anything(), + }, + }); + expect(sendAssignmentPush).toHaveBeenCalled(); + expect(assignmentSnapshotUpsert).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 +217,7 @@ describe('assignment notification worker', () => { error: { message: 'push failed' }, }, }); + expect(assignmentSnapshotUpsert).not.toHaveBeenCalled(); }); it('leaves the worker disabled unless assignment pushes are enabled', () => { @@ -200,6 +225,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..9736e13 100644 --- a/packages/server/services/assignmentNotificationWorker.ts +++ b/packages/server/services/assignmentNotificationWorker.ts @@ -1,25 +1,50 @@ import prisma from '../db'; import { createAssignmentSnapshot } from '../lib/assignmentSnapshots'; import { + Prisma, PushDeliveryStatus, PushSubscription, } from '../prisma/generated/client'; import { sendAssignmentPush } from './webPush'; import { fetchWcif } from './wcif'; +import { runWithConcurrency } from '../lib/runWithConcurrency'; interface WatchTarget { competitionId: string; wcaUserId: number; } -const groupTargetsByCompetition = (targets: WatchTarget[]) => - targets.reduce>((acc, target) => { - acc[target.competitionId] = [ - ...(acc[target.competitionId] ?? []), - target.wcaUserId, - ]; - return acc; - }, {}); +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); + +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 competitionGroupsUrl = (competitionId: string, wcaUserId: number) => { const origin = process.env.COMPETITION_GROUPS_ORIGIN; @@ -27,7 +52,10 @@ const competitionGroupsUrl = (competitionId: string, wcaUserId: number) => { return undefined; } - return `${origin.replace(/\/$/, '')}/competitions/${competitionId}/persons/${wcaUserId}`; + return `${origin.replace( + /\/$/, + '' + )}/competitions/${competitionId}/persons/${wcaUserId}`; }; const createDedupeKey = ( @@ -50,28 +78,9 @@ const createPayload = ( dedupeKey: createDedupeKey(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,9 +90,6 @@ const getSubscriptionsForTarget = async ( }, }); - return watches.map((watch) => watch.pushSubscription); -}; - const deliverAssignmentChange = async ( subscription: PushSubscription, competitionId: string, @@ -98,19 +104,33 @@ const deliverAssignmentChange = async ( }, }); - if (existingDelivery) { - return; + if (existingDelivery?.status === PushDeliveryStatus.sent) { + return true; } - const delivery = await prisma.pushDelivery.create({ - data: { - pushSubscriptionId: subscription.id, - competitionId, - wcaUserId, - dedupeKey, - status: PushDeliveryStatus.pending, - }, - }); + if (existingDelivery?.status === PushDeliveryStatus.pending) { + return false; + } + + const delivery = existingDelivery + ? await prisma.pushDelivery.update({ + where: { + id: existingDelivery.id, + }, + data: { + status: PushDeliveryStatus.pending, + error: Prisma.JsonNull, + }, + }) + : await prisma.pushDelivery.create({ + data: { + pushSubscriptionId: subscription.id, + competitionId, + wcaUserId, + dedupeKey, + status: PushDeliveryStatus.pending, + }, + }); const result = await sendAssignmentPush( subscription, @@ -125,72 +145,87 @@ const deliverAssignmentChange = async ( status: result.success ? PushDeliveryStatus.sent : PushDeliveryStatus.failed, - error: result.error ?? undefined, + error: result.error ?? Prisma.JsonNull, }, }); + + return result.success; }; 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); - - for (const wcaUserId of wcaUserIds) { - const nextSnapshot = createAssignmentSnapshot(wcif, wcaUserId); - if (!nextSnapshot) { - continue; - } + const targetsByCompetition = groupWatchesByCompetition( + await getActiveWatches() + ); - const previousSnapshot = await prisma.assignmentSnapshot.findUnique({ - where: { - competitionId_wcaUserId: { - competitionId, - wcaUserId, - }, - }, - }); + await runWithConcurrency( + [...targetsByCompetition.entries()], + async ([competitionId, targets]) => { + const wcif = await fetchWcif(competitionId); - await prisma.assignmentSnapshot.upsert({ - where: { - competitionId_wcaUserId: { - competitionId, - wcaUserId, + 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, + }, }, - }, - update: { - assignmentsHash: nextSnapshot.assignmentsHash, - }, - create: nextSnapshot, - }); + }); - if ( - !previousSnapshot || - previousSnapshot.assignmentsHash === nextSnapshot.assignmentsHash - ) { - continue; - } + if ( + !previousSnapshot || + previousSnapshot.assignmentsHash === nextSnapshot.assignmentsHash + ) { + await prisma.assignmentSnapshot.upsert({ + where: { + competitionId_wcaUserId: { + competitionId, + wcaUserId, + }, + }, + update: { + assignmentsHash: nextSnapshot.assignmentsHash, + }, + create: nextSnapshot, + }); + continue; + } - const subscriptions = await getSubscriptionsForTarget( - competitionId, - wcaUserId - ); - - await Promise.all( - subscriptions.map(async (subscription) => - deliverAssignmentChange( - subscription, - competitionId, - wcaUserId, - nextSnapshot.assignmentsHash - ) - ) - ); - } - } + 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 +253,11 @@ export const startAssignmentNotificationWorker = () => { }; void poll(); - setInterval(() => { + const interval = setInterval(() => { void poll(); }, intervalMs); + + return () => { + clearInterval(interval); + }; }; 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) { From 9cfd1e1ee78e77c8fd2d1763697569408e73bfe9 Mon Sep 17 00:00:00 2001 From: Cailyn Sinclair Date: Wed, 27 May 2026 21:39:50 -0700 Subject: [PATCH 3/5] Use ipaddr.js for webhook IP checks --- packages/server/lib/webhookUrls.spec.ts | 8 ++++- packages/server/lib/webhookUrls.ts | 45 ++++--------------------- packages/server/package.json | 1 + yarn.lock | 5 +++ 4 files changed, 20 insertions(+), 39 deletions(-) diff --git a/packages/server/lib/webhookUrls.spec.ts b/packages/server/lib/webhookUrls.spec.ts index 8d51625..a6e5964 100644 --- a/packages/server/lib/webhookUrls.spec.ts +++ b/packages/server/lib/webhookUrls.spec.ts @@ -50,6 +50,12 @@ describe('assertValidWebhookUrl', () => { 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' + ); }); }); @@ -59,7 +65,7 @@ describe('assertWebhookUrlResolvesPublicly', () => { }); it('accepts hostnames resolving to public addresses', async () => { - dnsLookup.mockResolvedValue([{ address: '203.0.113.10', family: 4 }]); + dnsLookup.mockResolvedValue([{ address: '93.184.216.34', family: 4 }]); await expect( assertWebhookUrlResolvesPublicly('https://hooks.example/notify') diff --git a/packages/server/lib/webhookUrls.ts b/packages/server/lib/webhookUrls.ts index c167678..9e9c9a9 100644 --- a/packages/server/lib/webhookUrls.ts +++ b/packages/server/lib/webhookUrls.ts @@ -1,44 +1,14 @@ -import net from 'net'; import { promises as dns } from 'dns'; +import ipaddr from 'ipaddr.js'; const LOCAL_HOSTNAMES = new Set(['localhost']); -const isPrivateIpv4 = (host: string) => { - const parts = host.split('.').map(Number); - const [first, second] = parts; - - return ( - first === 10 || - first === 127 || - first === 0 || - first >= 224 || - (first === 100 && second >= 64 && second <= 127) || - (first === 169 && second === 254) || - (first === 172 && second >= 16 && second <= 31) || - (first === 192 && second === 0) || - (first === 192 && second === 168) || - (first === 198 && (second === 18 || second === 19)) - ); -}; - -const isPrivateIpv6 = (host: string) => { - const normalized = host.toLowerCase(); - return ( - normalized === '::1' || - normalized === '::' || - normalized.startsWith('fc') || - normalized.startsWith('fd') || - normalized.startsWith('fe80:') || - normalized.startsWith('ff') - ); -}; +const isIpAddress = (host: string) => ipaddr.isValid(host); const assertPublicAddress = (address: string) => { - const ipVersion = net.isIP(address); - if ( - (ipVersion === 4 && isPrivateIpv4(address)) || - (ipVersion === 6 && isPrivateIpv6(address)) - ) { + const parsedAddress = ipaddr.process(address); + + if (parsedAddress.range() !== 'unicast') { throw new Error('Webhook URL cannot target private addresses'); } }; @@ -69,8 +39,7 @@ export const assertValidWebhookUrl = (value: string) => { throw new Error('Webhook URL cannot target local hosts'); } - const ipVersion = net.isIP(hostname); - if (ipVersion) { + if (isIpAddress(hostname)) { assertPublicAddress(hostname); } @@ -82,7 +51,7 @@ export const assertWebhookUrlResolvesPublicly = async (value: string) => { const { hostname } = new URL(normalizedUrl); const normalizedHostname = hostname.replace(/^\[|\]$/g, '').toLowerCase(); - if (net.isIP(normalizedHostname)) { + if (isIpAddress(normalizedHostname)) { return normalizedUrl; } 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/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" From 8cb040540bf925350687ba4274c409719ab6cdfe Mon Sep 17 00:00:00 2001 From: Cailyn Sinclair Date: Wed, 27 May 2026 22:00:43 -0700 Subject: [PATCH 4/5] Fix PR 18 reliability review issues --- packages/server/auth/Auth.spec.ts | 2 +- packages/server/auth/Auth.ts | 90 +++++++------ packages/server/auth/AuthMiddleware.spec.ts | 46 ++++++- packages/server/auth/AuthMiddleware.ts | 31 +++++ packages/server/controllers/webhooks.spec.ts | 13 +- packages/server/controllers/webhooks.ts | 7 +- packages/server/lib/webhookUrls.spec.ts | 27 ++++ packages/server/lib/webhookUrls.ts | 57 +++++++- .../activityHeadsUpNotifications.spec.ts | 39 +++++- .../services/activityHeadsUpNotifications.ts | 40 ++---- .../assignmentNotificationWorker.spec.ts | 25 +++- .../services/assignmentNotificationWorker.ts | 54 ++------ .../server/services/pushDeliveries.spec.ts | 118 +++++++++++++++++ packages/server/services/pushDeliveries.ts | 125 ++++++++++++++++++ 14 files changed, 541 insertions(+), 133 deletions(-) create mode 100644 packages/server/services/pushDeliveries.spec.ts create mode 100644 packages/server/services/pushDeliveries.ts diff --git a/packages/server/auth/Auth.spec.ts b/packages/server/auth/Auth.spec.ts index 6457f67..341c61e 100644 --- a/packages/server/auth/Auth.spec.ts +++ b/packages/server/auth/Auth.spec.ts @@ -109,7 +109,7 @@ const loadAuth = async ( })), })); jest.doMock('./AuthMiddleware', () => ({ - authMiddlewareVerify: jest.fn( + authMiddlewareVerifyIgnoringExpiration: jest.fn( (req: MockRequest, _res: MockResponse, next: () => void) => next() ), })); diff --git a/packages/server/auth/Auth.ts b/packages/server/auth/Auth.ts index 7916fa1..c215200 100644 --- a/packages/server/auth/Auth.ts +++ b/packages/server/auth/Auth.ts @@ -1,7 +1,7 @@ import fs from 'fs'; import express from 'express'; import jwt from 'jsonwebtoken'; -import { authMiddlewareVerify } from './AuthMiddleware'; +import { authMiddlewareVerifyIgnoringExpiration } from './AuthMiddleware'; import { getMockUser, isMocksMode } from '../mocks/config'; import { fetchWithTimeout } from '../lib/fetchWithTimeout'; @@ -219,55 +219,59 @@ router.get('/wca/callback', async (req, res) => { } }); -router.post('/wca/refresh', authMiddlewareVerify, 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 fetchWithTimeout(`${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', + }, + }); - const tokens = (await response.json()) as WcaOauthRes; + if (!response.ok) { + throw await response.json(); + } - 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 tokens = (await response.json()) as WcaOauthRes; - return res.json({ jwt: token }); - } catch (e) { - console.error(e); - res.status(500).json(e); + 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); + } } -}); +); export default router; diff --git a/packages/server/auth/AuthMiddleware.spec.ts b/packages/server/auth/AuthMiddleware.spec.ts index 7e710f3..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(); @@ -76,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 5484306..9763d0f 100644 --- a/packages/server/auth/AuthMiddleware.ts +++ b/packages/server/auth/AuthMiddleware.ts @@ -99,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, @@ -115,6 +145,7 @@ export const authMiddlewareDecode = ( if (split[0] !== 'Bearer') { next(null); + return; } const token = split[1]; diff --git a/packages/server/controllers/webhooks.spec.ts b/packages/server/controllers/webhooks.spec.ts index bed8dfa..e2f48ad 100644 --- a/packages/server/controllers/webhooks.spec.ts +++ b/packages/server/controllers/webhooks.spec.ts @@ -3,14 +3,18 @@ import { HTTPMethod, Webhook } from '../prisma/generated/client'; const fetchMock = jest.fn(); const webhookFindMany = jest.fn(); -const assertWebhookUrlResolvesPublicly = jest.fn(async (url: string) => url); +const webhookAgent = { pinned: true }; +const resolvePublicWebhookUrl = jest.fn(async (url: string) => ({ + url, + agent: webhookAgent, +})); jest.mock('../lib/fetchWithTimeout', () => ({ fetchWithTimeout: fetchMock, })); jest.mock('../lib/webhookUrls', () => ({ - assertWebhookUrlResolvesPublicly, + resolvePublicWebhookUrl, })); jest.mock('../db', () => ({ @@ -40,7 +44,7 @@ describe('webhook controllers', () => { beforeEach(() => { fetchMock.mockReset(); webhookFindMany.mockReset(); - assertWebhookUrlResolvesPublicly.mockClear(); + resolvePublicWebhookUrl.mockClear(); }); it('sends JSON data with stored webhook headers', async () => { @@ -62,6 +66,7 @@ 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', @@ -91,6 +96,7 @@ describe('webhook controllers', () => { expect(fetchMock).toHaveBeenCalledWith('https://hooks.example/notify', { method: 'POST', size: 64 * 1024, + agent: webhookAgent, headers: { 'Content-Type': 'application/json', }, @@ -118,6 +124,7 @@ describe('webhook controllers', () => { expect(fetchMock).toHaveBeenCalledWith('https://hooks.example/notify', { method: 'GET', size: 64 * 1024, + agent: webhookAgent, headers: { 'X-Test': 'true', }, diff --git a/packages/server/controllers/webhooks.ts b/packages/server/controllers/webhooks.ts index e5267fb..9c74467 100644 --- a/packages/server/controllers/webhooks.ts +++ b/packages/server/controllers/webhooks.ts @@ -2,7 +2,7 @@ import prisma from '../db'; import { Header } from '../generated/graphql'; import { Webhook } from '../prisma/generated/client'; import { fetchWithTimeout } from '../lib/fetchWithTimeout'; -import { assertWebhookUrlResolvesPublicly } from '../lib/webhookUrls'; +import { resolvePublicWebhookUrl } from '../lib/webhookUrls'; import { settleWithConcurrency } from '../lib/runWithConcurrency'; const BODYLESS_METHODS = new Set(['GET', 'HEAD']); @@ -28,12 +28,13 @@ export const webhookFetch = async ( (acc, h) => ({ ...acc, [h.key]: h.value }), {} ); - const url = await assertWebhookUrlResolvesPublicly(webhook.url); + const resolvedUrl = await resolvePublicWebhookUrl(webhook.url); const canSendBody = !BODYLESS_METHODS.has(webhook.method); - return fetchWithTimeout(url, { + return fetchWithTimeout(resolvedUrl.url, { method: webhook.method, size: MAX_WEBHOOK_RESPONSE_SIZE_BYTES, + ...(resolvedUrl.agent && { agent: resolvedUrl.agent }), headers: { ...(canSendBody && { 'Content-Type': 'application/json' }), ...headers, diff --git a/packages/server/lib/webhookUrls.spec.ts b/packages/server/lib/webhookUrls.spec.ts index a6e5964..04a6788 100644 --- a/packages/server/lib/webhookUrls.spec.ts +++ b/packages/server/lib/webhookUrls.spec.ts @@ -10,6 +10,7 @@ jest.mock('dns', () => ({ import { assertValidWebhookUrl, assertWebhookUrlResolvesPublicly, + resolvePublicWebhookUrl, } from './webhookUrls'; describe('assertValidWebhookUrl', () => { @@ -81,4 +82,30 @@ describe('assertWebhookUrlResolvesPublicly', () => { 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 index 9e9c9a9..ae63fc8 100644 --- a/packages/server/lib/webhookUrls.ts +++ b/packages/server/lib/webhookUrls.ts @@ -1,4 +1,7 @@ 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']); @@ -13,6 +16,48 @@ const assertPublicAddress = (address: string) => { } }; +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; @@ -47,12 +92,17 @@ export const assertValidWebhookUrl = (value: string) => { }; 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 normalizedUrl; + return { url: normalizedUrl }; } const addresses = await dns.lookup(normalizedHostname, { all: true }); @@ -64,5 +114,8 @@ export const assertWebhookUrlResolvesPublicly = async (value: string) => { assertPublicAddress(address); }); - return normalizedUrl; + return { + url: normalizedUrl, + agent: createPinnedHttpsAgent(addresses), + }; }; diff --git a/packages/server/services/activityHeadsUpNotifications.spec.ts b/packages/server/services/activityHeadsUpNotifications.spec.ts index c3d94dd..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, }, }, @@ -53,6 +55,7 @@ describe('sendActivityHeadsUpPush', () => { ]); pushDeliveryFindFirst.mockReset().mockResolvedValue(null); pushDeliveryCreate.mockReset().mockResolvedValue({ id: 20 }); + pushDeliveryUpdateMany.mockReset().mockResolvedValue({ count: 1 }); pushDeliveryUpdate.mockReset().mockResolvedValue({ id: 20 }); sendAssignmentPush.mockReset().mockResolvedValue({ success: true, @@ -120,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', @@ -135,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 bb83447..9173d3d 100644 --- a/packages/server/services/activityHeadsUpNotifications.ts +++ b/packages/server/services/activityHeadsUpNotifications.ts @@ -1,10 +1,8 @@ import prisma from '../db'; -import { - PushDeliveryStatus, - PushSubscription, -} from '../prisma/generated/client'; +import { PushSubscription } from '../prisma/generated/client'; import { sendAssignmentPush } from './webPush'; import { runWithConcurrency } from '../lib/runWithConcurrency'; +import { claimPushDelivery, completePushDelivery } from './pushDeliveries'; const competitionGroupsUrl = (competitionId: string, wcaUserId: number) => { const origin = process.env.COMPETITION_GROUPS_ORIGIN; @@ -93,27 +91,17 @@ const sendActivityHeadsUpPushDelivery = async ({ activityIds, startsAt ); - const existingDelivery = await prisma.pushDelivery.findFirst({ - where: { - pushSubscriptionId: subscription.id, - dedupeKey, - }, + const deliveryClaim = await claimPushDelivery({ + pushSubscriptionId: subscription.id, + competitionId, + wcaUserId: targetUserId, + dedupeKey, }); - if (existingDelivery) { + if (deliveryClaim.status !== 'claimed') { return; } - const delivery = await prisma.pushDelivery.create({ - data: { - pushSubscriptionId: subscription.id, - competitionId, - wcaUserId: targetUserId, - dedupeKey, - status: PushDeliveryStatus.pending, - }, - }); - const result = await sendAssignmentPush(subscription, { type: 'activity-heads-up', competitionId, @@ -124,17 +112,7 @@ const sendActivityHeadsUpPushDelivery = async ({ url: competitionGroupsUrl(competitionId, targetUserId), }); - await prisma.pushDelivery.update({ - where: { - id: delivery.id, - }, - data: { - status: result.success - ? PushDeliveryStatus.sent - : PushDeliveryStatus.failed, - error: result.error ?? undefined, - }, - }); + await completePushDelivery(deliveryClaim.deliveryId, result); }; export const sendActivityHeadsUpPush = async ( diff --git a/packages/server/services/assignmentNotificationWorker.spec.ts b/packages/server/services/assignmentNotificationWorker.spec.ts index 6c45d34..f7fb23f 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, }, }, @@ -67,6 +69,7 @@ describe('assignment notification worker', () => { assignmentSnapshotUpsert.mockReset().mockResolvedValue({}); pushDeliveryFindFirst.mockReset().mockResolvedValue(null); pushDeliveryCreate.mockReset().mockResolvedValue({ id: 20 }); + pushDeliveryUpdateMany.mockReset().mockResolvedValue({ count: 1 }); pushDeliveryUpdate.mockReset().mockResolvedValue({ id: 20 }); sendAssignmentPush.mockReset().mockResolvedValue({ success: true, @@ -179,10 +182,10 @@ describe('assignment notification worker', () => { await runAssignmentNotificationPoll(); - expect(pushDeliveryUpdate).toHaveBeenCalledWith({ - where: { + expect(pushDeliveryUpdateMany).toHaveBeenCalledWith({ + where: expect.objectContaining({ id: 99, - }, + }), data: { status: 'pending', error: expect.anything(), @@ -192,6 +195,22 @@ describe('assignment notification worker', () => { 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.mockResolvedValue([activeWatch]); diff --git a/packages/server/services/assignmentNotificationWorker.ts b/packages/server/services/assignmentNotificationWorker.ts index 9736e13..314e6ce 100644 --- a/packages/server/services/assignmentNotificationWorker.ts +++ b/packages/server/services/assignmentNotificationWorker.ts @@ -1,13 +1,10 @@ import prisma from '../db'; import { createAssignmentSnapshot } from '../lib/assignmentSnapshots'; -import { - Prisma, - PushDeliveryStatus, - PushSubscription, -} from '../prisma/generated/client'; +import { PushSubscription } from '../prisma/generated/client'; import { sendAssignmentPush } from './webPush'; import { fetchWcif } from './wcif'; import { runWithConcurrency } from '../lib/runWithConcurrency'; +import { claimPushDelivery, completePushDelivery } from './pushDeliveries'; interface WatchTarget { competitionId: string; @@ -97,57 +94,26 @@ const deliverAssignmentChange = async ( assignmentsHash: string ) => { const dedupeKey = createDedupeKey(competitionId, wcaUserId, assignmentsHash); - const existingDelivery = await prisma.pushDelivery.findFirst({ - where: { - pushSubscriptionId: subscription.id, - dedupeKey, - }, + const deliveryClaim = await claimPushDelivery({ + pushSubscriptionId: subscription.id, + competitionId, + wcaUserId, + dedupeKey, }); - if (existingDelivery?.status === PushDeliveryStatus.sent) { + if (deliveryClaim.status === 'already-sent') { return true; } - if (existingDelivery?.status === PushDeliveryStatus.pending) { + if (deliveryClaim.status === 'in-flight') { return false; } - const delivery = existingDelivery - ? await prisma.pushDelivery.update({ - where: { - id: existingDelivery.id, - }, - data: { - status: PushDeliveryStatus.pending, - error: Prisma.JsonNull, - }, - }) - : await prisma.pushDelivery.create({ - data: { - pushSubscriptionId: subscription.id, - 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 ?? Prisma.JsonNull, - }, - }); + await completePushDelivery(deliveryClaim.deliveryId, result); return result.success; }; 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), + }, + }); From 44b58ecc22da64857490526a2a20c7f9bd41366e Mon Sep 17 00:00:00 2001 From: Cailyn Sinclair Date: Wed, 27 May 2026 22:06:55 -0700 Subject: [PATCH 5/5] Add competition start push reminders Send a deduped assignment push when a watched competition enters the 24-hour window before its earliest WCIF schedule activity. This reuses the PR 18 push delivery claim helper so sent reminders are idempotent and failed reminders can retry while still in the window. The reminder-specific schedule scan and payload creation now live in their own service, and shared Competition Groups URLs plus deduped assignment push delivery are reused by assignment-change and activity heads-up notifications. --- packages/server/lib/assignmentSnapshots.ts | 3 + .../services/activityHeadsUpNotifications.ts | 45 +-- .../assignmentNotificationWorker.spec.ts | 290 +++++++++++++++++- .../services/assignmentNotificationWorker.ts | 81 ++--- .../services/assignmentPushDeliveries.ts | 40 +++ .../server/services/competitionGroupsUrls.ts | 14 + .../services/competitionStartReminders.ts | 115 +++++++ packages/server/services/webPush.spec.ts | 31 ++ packages/server/services/webPush.ts | 22 +- 9 files changed, 568 insertions(+), 73 deletions(-) create mode 100644 packages/server/services/assignmentPushDeliveries.ts create mode 100644 packages/server/services/competitionGroupsUrls.ts create mode 100644 packages/server/services/competitionStartReminders.ts 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/services/activityHeadsUpNotifications.ts b/packages/server/services/activityHeadsUpNotifications.ts index 9173d3d..fd231cd 100644 --- a/packages/server/services/activityHeadsUpNotifications.ts +++ b/packages/server/services/activityHeadsUpNotifications.ts @@ -1,20 +1,8 @@ import prisma from '../db'; import { PushSubscription } from '../prisma/generated/client'; -import { sendAssignmentPush } from './webPush'; import { runWithConcurrency } from '../lib/runWithConcurrency'; -import { claimPushDelivery, completePushDelivery } from './pushDeliveries'; - -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 { deliverAssignmentPush } from './assignmentPushDeliveries'; +import { competitionGroupsPersonUrl } from './competitionGroupsUrls'; const createDedupeKey = ( competitionId: string, @@ -91,28 +79,21 @@ const sendActivityHeadsUpPushDelivery = async ({ activityIds, startsAt ); - const deliveryClaim = await claimPushDelivery({ - pushSubscriptionId: subscription.id, + 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), + }, }); - - if (deliveryClaim.status !== 'claimed') { - return; - } - - 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, targetUserId), - }); - - await completePushDelivery(deliveryClaim.deliveryId, result); }; export const sendActivityHeadsUpPush = async ( diff --git a/packages/server/services/assignmentNotificationWorker.spec.ts b/packages/server/services/assignmentNotificationWorker.spec.ts index f7fb23f..90a22a3 100644 --- a/packages/server/services/assignmentNotificationWorker.spec.ts +++ b/packages/server/services/assignmentNotificationWorker.spec.ts @@ -54,6 +54,75 @@ const activeWatch = { 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; @@ -70,7 +139,9 @@ describe('assignment notification worker', () => { pushDeliveryFindFirst.mockReset().mockResolvedValue(null); pushDeliveryCreate.mockReset().mockResolvedValue({ id: 20 }); pushDeliveryUpdateMany.mockReset().mockResolvedValue({ count: 1 }); - pushDeliveryUpdate.mockReset().mockResolvedValue({ id: 20 }); + pushDeliveryUpdate + .mockReset() + .mockImplementation(async ({ where }) => ({ id: where.id })); sendAssignmentPush.mockReset().mockResolvedValue({ success: true, error: null, @@ -239,6 +310,223 @@ describe('assignment notification worker', () => { 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', () => { delete process.env.ASSIGNMENT_PUSH_ENABLED; diff --git a/packages/server/services/assignmentNotificationWorker.ts b/packages/server/services/assignmentNotificationWorker.ts index 314e6ce..b07f860 100644 --- a/packages/server/services/assignmentNotificationWorker.ts +++ b/packages/server/services/assignmentNotificationWorker.ts @@ -1,10 +1,15 @@ import prisma from '../db'; import { createAssignmentSnapshot } from '../lib/assignmentSnapshots'; import { PushSubscription } from '../prisma/generated/client'; -import { sendAssignmentPush } from './webPush'; import { fetchWcif } from './wcif'; import { runWithConcurrency } from '../lib/runWithConcurrency'; -import { claimPushDelivery, completePushDelivery } from './pushDeliveries'; +import { deliverAssignmentPush } from './assignmentPushDeliveries'; +import { competitionGroupsPersonUrl } from './competitionGroupsUrls'; +import { + deliverCompetitionStartReminders, + getEarliestCompetitionStartTime, + shouldSendCompetitionStartReminder, +} from './competitionStartReminders'; interface WatchTarget { competitionId: string; @@ -43,25 +48,13 @@ const groupWatchesByCompetition = (watches: ActiveWatch[]) => { return competitions; }; -const competitionGroupsUrl = (competitionId: string, wcaUserId: number) => { - const origin = process.env.COMPETITION_GROUPS_ORIGIN; - if (!origin) { - return undefined; - } - - return `${origin.replace( - /\/$/, - '' - )}/competitions/${competitionId}/persons/${wcaUserId}`; -}; - -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 @@ -71,8 +64,12 @@ 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 getActiveWatches = async (): Promise => @@ -93,32 +90,26 @@ const deliverAssignmentChange = async ( wcaUserId: number, assignmentsHash: string ) => { - const dedupeKey = createDedupeKey(competitionId, wcaUserId, assignmentsHash); - const deliveryClaim = await claimPushDelivery({ - pushSubscriptionId: subscription.id, + const dedupeKey = createAssignmentChangeDedupeKey( competitionId, wcaUserId, - dedupeKey, - }); - - if (deliveryClaim.status === 'already-sent') { - return true; - } - - if (deliveryClaim.status === 'in-flight') { - return false; - } - - const result = await sendAssignmentPush( - subscription, - createPayload(competitionId, wcaUserId, assignmentsHash) + assignmentsHash ); - await completePushDelivery(deliveryClaim.deliveryId, result); - return result.success; + return deliverAssignmentPush({ + subscription, + competitionId, + wcaUserId, + dedupeKey, + payload: createAssignmentChangePayload( + competitionId, + wcaUserId, + assignmentsHash + ), + }); }; -export const runAssignmentNotificationPoll = async () => { +export const runAssignmentNotificationPoll = async (now = new Date()) => { const targetsByCompetition = groupWatchesByCompetition( await getActiveWatches() ); @@ -127,6 +118,20 @@ export const runAssignmentNotificationPoll = async () => { [...targetsByCompetition.entries()], async ([competitionId, targets]) => { const wcif = await fetchWcif(competitionId); + const earliestStartTime = getEarliestCompetitionStartTime(wcif); + + if ( + earliestStartTime && + shouldSendCompetitionStartReminder(earliestStartTime, now) + ) { + await deliverCompetitionStartReminders({ + competitionId, + competitionName: wcif.name, + targets, + earliestStartTime, + concurrency: assignmentDeliveryConcurrency(), + }); + } for (const [wcaUserId, subscriptions] of targets.entries()) { const nextSnapshot = createAssignmentSnapshot(wcif, wcaUserId); 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/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(