From e2f41a06601ef19f8f5bb4a1ad5f52e59d75b052 Mon Sep 17 00:00:00 2001 From: itamaroryan Date: Fri, 3 Jul 2026 16:10:44 +0300 Subject: [PATCH 1/3] Update to typescript 6, strict mode on backend and lint --- apps/admin/tsconfig.json | 2 +- .../backend/src/lib/blob-storage/s3-client.ts | 11 +- apps/backend/src/lib/graphql/auth-context.ts | 5 +- .../resolvers/divisions/division-agenda.ts | 2 +- .../resolvers/divisions/field/matches.ts | 4 +- .../judging/judging-session-length.ts | 4 + .../lib/graphql/resolvers/events/resolver.ts | 16 +- .../src/lib/graphql/resolvers/index.ts | 46 +- .../audience-display/update-presentation.ts | 8 +- .../advance-final-deliberation-stage.ts | 13 +- .../complete-final-deliberation.ts | 5 +- .../deliberations/handlers/champions.ts | 5 +- .../deliberations/handlers/core-awards.ts | 18 +- .../deliberations/handlers/optional-awards.ts | 10 +- .../mutations/deliberations/handlers/utils.ts | 2 +- .../deliberations/start-deliberation.ts | 7 + .../update-final-deliberation-awards.ts | 11 +- .../judging-sessions/start-judging-session.ts | 6 + .../scoresheets/update-mission-clause.ts | 11 +- .../src/lib/graphql/utils/rubric-builder.ts | 2 +- apps/backend/src/lib/redis/redis-pubsub.ts | 3 +- apps/backend/src/main.ts | 14 +- apps/backend/src/routers/admin/auth.ts | 12 +- .../admin/events/divisions/awards/index.ts | 13 +- .../admin/events/divisions/awards/utils.ts | 2 +- .../routers/admin/events/divisions/index.ts | 17 +- .../admin/events/divisions/pit-map/index.ts | 133 ++--- .../admin/events/divisions/rooms/index.ts | 179 ++++--- .../admin/events/divisions/schedule/agenda.ts | 77 +-- .../admin/events/divisions/schedule/index.ts | 25 +- .../admin/events/divisions/tables/index.ts | 179 ++++--- .../admin/events/divisions/teams/index.ts | 25 +- .../backend/src/routers/admin/events/index.ts | 489 ++++++++--------- .../admin/events/integrations/index.ts | 325 +++++------ .../routers/admin/events/settings/index.ts | 26 +- .../src/routers/admin/events/teams/index.ts | 25 +- .../src/routers/admin/events/users/index.ts | 284 +++++----- .../src/routers/admin/events/users/util.ts | 11 +- .../admin/middleware/attach-division.ts | 7 +- .../routers/admin/middleware/attach-event.ts | 7 +- .../src/routers/admin/middleware/auth.ts | 8 +- .../middleware/require-event-assignment.ts | 7 +- .../admin/middleware/require-permission.ts | 7 +- .../src/routers/admin/seasons/index.ts | 21 +- apps/backend/src/routers/admin/teams/index.ts | 50 +- apps/backend/src/routers/admin/users/index.ts | 25 +- .../src/routers/admin/users/permissions.ts | 145 ++--- .../src/routers/admin/users/register.ts | 10 +- .../first-israel-dashboard/index.ts | 5 +- .../middleware/event.ts | 6 +- .../first-israel-dashboard/middleware/team.ts | 6 +- .../team/event/export/index.ts | 119 ++-- .../team/event/index.ts | 145 ++--- .../routers/integrations/sendgrid/index.ts | 13 +- apps/backend/src/routers/lems/auth/index.ts | 6 +- apps/backend/src/routers/lems/export/index.ts | 441 +++++++-------- .../src/routers/portal/divisions/index.ts | 37 +- .../src/routers/portal/divisions/util.ts | 2 +- .../src/routers/portal/events/index.ts | 22 +- .../src/routers/portal/events/teams/index.ts | 37 +- .../backend/src/routers/portal/events/util.ts | 2 +- .../portal/middleware/attach-division.ts | 2 +- .../routers/portal/middleware/attach-event.ts | 7 +- .../src/routers/portal/seasons/index.ts | 7 +- .../backend/src/routers/portal/teams/index.ts | 45 +- apps/backend/src/routers/portal/teams/util.ts | 2 +- .../portal/utils/ranking-calculator.ts | 6 +- .../src/routers/scheduler/divisions/index.ts | 507 +++++++++--------- .../src/routers/scheduler/middleware/auth.ts | 4 + apps/backend/src/types/express-handlers.ts | 20 + apps/backend/tsconfig.app.json | 1 + apps/frontend/tsconfig.json | 2 +- apps/portal/tsconfig.json | 2 +- libs/localization/tsconfig.json | 1 - libs/presentations/tsconfig.json | 1 - .../src/lib/components/color-picker.tsx | 18 +- libs/shared/src/lib/hooks/use-audio-player.ts | 4 +- libs/shared/tsconfig.json | 1 - package-lock.json | 232 ++------ package.json | 4 +- tsconfig.base.json | 17 +- 81 files changed, 2074 insertions(+), 1964 deletions(-) create mode 100644 apps/backend/src/types/express-handlers.ts diff --git a/apps/admin/tsconfig.json b/apps/admin/tsconfig.json index 648939200..ba5dd64c0 100644 --- a/apps/admin/tsconfig.json +++ b/apps/admin/tsconfig.json @@ -3,6 +3,7 @@ "compilerOptions": { "jsx": "preserve", "strict": true, + "types": ["node", "@types/grecaptcha"], "noEmit": true, "emitDeclarationOnly": false, "esModuleInterop": true, @@ -12,7 +13,6 @@ "isolatedModules": true, "lib": [ "dom", - "dom.iterable", "esnext" ], "allowJs": true, diff --git a/apps/backend/src/lib/blob-storage/s3-client.ts b/apps/backend/src/lib/blob-storage/s3-client.ts index b228476cf..63bb2991d 100644 --- a/apps/backend/src/lib/blob-storage/s3-client.ts +++ b/apps/backend/src/lib/blob-storage/s3-client.ts @@ -1,12 +1,19 @@ import { S3 } from '@aws-sdk/client-s3'; +const accessKeyId = process.env.DIGITALOCEAN_KEY; +const secretAccessKey = process.env.DIGITALOCEAN_SECRET; + +if (!accessKeyId || !secretAccessKey) { + throw new Error('DIGITALOCEAN_KEY and DIGITALOCEAN_SECRET environment variables are required'); +} + const s3Client = new S3({ forcePathStyle: false, endpoint: `https://${process.env.DIGITALOCEAN_ENDPOINT}`, region: 'us-east-1', credentials: { - accessKeyId: process.env.DIGITALOCEAN_KEY, - secretAccessKey: process.env.DIGITALOCEAN_SECRET + accessKeyId, + secretAccessKey } }); diff --git a/apps/backend/src/lib/graphql/auth-context.ts b/apps/backend/src/lib/graphql/auth-context.ts index a96567437..74b9a23af 100644 --- a/apps/backend/src/lib/graphql/auth-context.ts +++ b/apps/backend/src/lib/graphql/auth-context.ts @@ -39,7 +39,10 @@ function extractTokenFromWebsocketConnection( function verifyToken(token: string): { userId: string; userType: string } | null { try { - const decoded = jwt.verify(token, jwtSecret) as { userId: string; userType: string }; + const decoded = jwt.verify(token, jwtSecret!) as unknown as { + userId: string; + userType: string; + }; if (decoded.userType !== 'volunteer') { console.warn('[Auth] Invalid user type in token:', decoded.userType); return null; diff --git a/apps/backend/src/lib/graphql/resolvers/divisions/division-agenda.ts b/apps/backend/src/lib/graphql/resolvers/divisions/division-agenda.ts index a8572d238..41e5c6276 100644 --- a/apps/backend/src/lib/graphql/resolvers/divisions/division-agenda.ts +++ b/apps/backend/src/lib/graphql/resolvers/divisions/division-agenda.ts @@ -44,7 +44,7 @@ export const divisionAgendaResolver: GraphQLFieldResolver< startTime: event.start_time.toISOString(), duration: event.duration, visibility: event.visibility, - location: event.location || undefined + location: event.location ?? null })); } catch (error) { console.error('Error fetching agenda events for division:', division.id, error); diff --git a/apps/backend/src/lib/graphql/resolvers/divisions/field/matches.ts b/apps/backend/src/lib/graphql/resolvers/divisions/field/matches.ts index 6d505521d..33c6dd778 100644 --- a/apps/backend/src/lib/graphql/resolvers/divisions/field/matches.ts +++ b/apps/backend/src/lib/graphql/resolvers/divisions/field/matches.ts @@ -66,7 +66,9 @@ export const matchesResolver: GraphQLFieldResolver< // Filter by team IDs if provided if (args.teamIds && args.teamIds.length > 0) { const teamIdsSet = new Set(args.teamIds); - matches = matches.filter(match => match.participants.some(p => teamIdsSet.has(p.team_id))); + matches = matches.filter(match => + match.participants.some(p => p.team_id != null && teamIdsSet.has(p.team_id)) + ); } // Fetch state data from MongoDB for all matches diff --git a/apps/backend/src/lib/graphql/resolvers/divisions/judging/judging-session-length.ts b/apps/backend/src/lib/graphql/resolvers/divisions/judging/judging-session-length.ts index 8eacf4e65..f153ed4d2 100644 --- a/apps/backend/src/lib/graphql/resolvers/divisions/judging/judging-session-length.ts +++ b/apps/backend/src/lib/graphql/resolvers/divisions/judging/judging-session-length.ts @@ -22,6 +22,10 @@ export const judgingSessionLengthResolver: GraphQLFieldResolver< throw new Error(`Division not found for division ID: ${judging.divisionId}`); } + if (!division?.schedule_settings) { + throw new Error(`Division schedule settings not found for division ID: ${judging.divisionId}`); + } + return division.schedule_settings.judging_session_length; } catch (error) { console.error('Error fetching judging rooms for division:', judging.divisionId, error); diff --git a/apps/backend/src/lib/graphql/resolvers/events/resolver.ts b/apps/backend/src/lib/graphql/resolvers/events/resolver.ts index 254c0ed5d..0efbcf598 100644 --- a/apps/backend/src/lib/graphql/resolvers/events/resolver.ts +++ b/apps/backend/src/lib/graphql/resolvers/events/resolver.ts @@ -107,7 +107,21 @@ function buildEventQuery(args: EventsArgs) { * Converts database date format to ISO strings for GraphQL. * Optionally includes isFullySetUp if provided (e.g., from aggregated queries). */ -function buildResult(event: Partial & { is_fully_set_up?: boolean; official?: boolean }): EventGraphQL { +function buildResult( + event: Partial & { is_fully_set_up?: boolean; official?: boolean | null } +): EventGraphQL { + if ( + !event.id || + !event.slug || + !event.name || + !event.start_date || + !event.end_date || + !event.region || + !event.timezone + ) { + throw new Error('Incomplete event data'); + } + return { id: event.id, slug: event.slug, diff --git a/apps/backend/src/lib/graphql/resolvers/index.ts b/apps/backend/src/lib/graphql/resolvers/index.ts index f5b4279d1..84a7cefba 100644 --- a/apps/backend/src/lib/graphql/resolvers/index.ts +++ b/apps/backend/src/lib/graphql/resolvers/index.ts @@ -1,4 +1,4 @@ -import { GraphQLScalarType, Kind } from 'graphql'; +import { GraphQLScalarType, Kind, ValueNode } from 'graphql'; import db from '../../database'; import { eventResolvers } from './events/resolver'; import { divisionResolver } from './divisions/resolver'; @@ -60,31 +60,33 @@ import { } from './subscriptions/deliberations'; // JSON scalar resolver - passes through any valid JSON value +function parseJsonLiteral(ast: ValueNode): unknown { + switch (ast.kind) { + case Kind.STRING: + case Kind.BOOLEAN: + return ast.value; + case Kind.INT: + case Kind.FLOAT: + return parseFloat(ast.value); + case Kind.OBJECT: + return Object.fromEntries( + ast.fields.map(field => [field.name.value, parseJsonLiteral(field.value)]) + ); + case Kind.LIST: + return ast.values.map(value => parseJsonLiteral(value)); + case Kind.NULL: + return null; + default: + return null; + } +} + const JSONScalar = new GraphQLScalarType({ name: 'JSON', description: 'Arbitrary JSON value', serialize: (value: unknown) => value, parseValue: (value: unknown) => value, - parseLiteral: ast => { - switch (ast.kind) { - case Kind.STRING: - case Kind.BOOLEAN: - return ast.value; - case Kind.INT: - case Kind.FLOAT: - return parseFloat(ast.value); - case Kind.OBJECT: - return Object.fromEntries( - ast.fields.map(field => [field.name.value, JSONScalar.parseLiteral(field.value)]) - ); - case Kind.LIST: - return ast.values.map(value => JSONScalar.parseLiteral(value)); - case Kind.NULL: - return null; - default: - return null; - } - } + parseLiteral: parseJsonLiteral }); export const resolvers = { @@ -98,7 +100,7 @@ export const resolvers = { Subscription: subscriptionResolvers, Event: { isFullySetUp: isFullySetUpResolver, - seasonName: async event => { + seasonName: async (event: { id: string }) => { const dbEvent = await db.events.byId(event.id).get(); if (!dbEvent) { return null; diff --git a/apps/backend/src/lib/graphql/resolvers/mutations/audience-display/update-presentation.ts b/apps/backend/src/lib/graphql/resolvers/mutations/audience-display/update-presentation.ts index 7a865c9cb..5281cf009 100644 --- a/apps/backend/src/lib/graphql/resolvers/mutations/audience-display/update-presentation.ts +++ b/apps/backend/src/lib/graphql/resolvers/mutations/audience-display/update-presentation.ts @@ -55,20 +55,22 @@ export const updatePresentationResolver: GraphQLFieldResolver< { returnDocument: 'after' } ); - if (!result) { + if (!result?.audienceDisplay?.awardsPresentation) { throw new MutationError( MutationErrorCode.INTERNAL_ERROR, `Failed to update audience display for ${divisionId}` ); } + const awardsPresentation = result.audienceDisplay.awardsPresentation; + // Publish event to notify subscribers const pubSub = getRedisPubSub(); await pubSub.publish(divisionId, RedisEventTypes.AWARDS_PRESENTATION_UPDATED, { - awardsPresentation: result.audienceDisplay.awardsPresentation + awardsPresentation }); - return { awardsPresentation: result.audienceDisplay.awardsPresentation }; + return { awardsPresentation }; } catch (error) { throw error instanceof Error ? error : new Error(String(error)); } diff --git a/apps/backend/src/lib/graphql/resolvers/mutations/deliberations/advance-final-deliberation-stage.ts b/apps/backend/src/lib/graphql/resolvers/mutations/deliberations/advance-final-deliberation-stage.ts index 1bc2ae1a9..2b0a78a4d 100644 --- a/apps/backend/src/lib/graphql/resolvers/mutations/deliberations/advance-final-deliberation-stage.ts +++ b/apps/backend/src/lib/graphql/resolvers/mutations/deliberations/advance-final-deliberation-stage.ts @@ -93,6 +93,15 @@ export const advanceFinalDeliberationStageResolver: GraphQLFieldResolver< nextStage = STAGE_PROGRESSION[nextStage]; } + if (!nextStage) { + throw new MutationError( + MutationErrorCode.FORBIDDEN, + 'Cannot advance beyond review stage. Use completeFinalDeliberation instead.' + ); + } + + const stageToAdvance = nextStage; + // Handle stage specific advancement logic switch (deliberation.stage) { case 'champions': @@ -113,11 +122,11 @@ export const advanceFinalDeliberationStageResolver: GraphQLFieldResolver< // Update to next stage and clear stage-specific data const updated = await db.finalDeliberations.byDivision(divisionId).update({ - stage: nextStage, + stage: stageToAdvance, status: 'not-started', stageData: { ...deliberation.stageData, - [nextStage]: {} + [stageToAdvance]: {} } }); diff --git a/apps/backend/src/lib/graphql/resolvers/mutations/deliberations/complete-final-deliberation.ts b/apps/backend/src/lib/graphql/resolvers/mutations/deliberations/complete-final-deliberation.ts index 181bb723d..d10730ffb 100644 --- a/apps/backend/src/lib/graphql/resolvers/mutations/deliberations/complete-final-deliberation.ts +++ b/apps/backend/src/lib/graphql/resolvers/mutations/deliberations/complete-final-deliberation.ts @@ -115,9 +115,10 @@ function validateFinalAwards(deliberation: { awards: FinalDeliberationAwards }): } // Validate core awards - const requiredCoreAwards = ['innovation-project', 'robot-design', 'core-values']; + const requiredCoreAwards = ['innovation-project', 'robot-design', 'core-values'] as const; for (const awardName of requiredCoreAwards) { - if (!awards[awardName] || awards[awardName].length === 0) { + const winners = awards[awardName]; + if (!winners || winners.length === 0) { throw new MutationError(MutationErrorCode.FORBIDDEN, `${awardName} award must be assigned`); } } diff --git a/apps/backend/src/lib/graphql/resolvers/mutations/deliberations/handlers/champions.ts b/apps/backend/src/lib/graphql/resolvers/mutations/deliberations/handlers/champions.ts index 3eed3de66..1ee44be12 100644 --- a/apps/backend/src/lib/graphql/resolvers/mutations/deliberations/handlers/champions.ts +++ b/apps/backend/src/lib/graphql/resolvers/mutations/deliberations/handlers/champions.ts @@ -100,7 +100,7 @@ export async function handleChampionsStageCompletion( const advancingTeamIds = selectAdvancingTeams( teamsWithRanks, - Object.values(champions), + Object.values(champions ?? {}), advancementConfig.advancement_percent, teams.length ); @@ -142,7 +142,8 @@ const assignChampionsToTeams = async ( ): Promise => { const championsAwards = await db.awards.byDivisionId(divisionId).get('champions'); for (const award of championsAwards) { - const teamId = champions[award.place]; + const placeKey = String(award.place) as '1' | '2' | '3' | '4'; + const teamId = champions?.[placeKey]; if (!teamId) { throw new MutationError( MutationErrorCode.FORBIDDEN, diff --git a/apps/backend/src/lib/graphql/resolvers/mutations/deliberations/handlers/core-awards.ts b/apps/backend/src/lib/graphql/resolvers/mutations/deliberations/handlers/core-awards.ts index 4a10a8c92..d4ee5ab27 100644 --- a/apps/backend/src/lib/graphql/resolvers/mutations/deliberations/handlers/core-awards.ts +++ b/apps/backend/src/lib/graphql/resolvers/mutations/deliberations/handlers/core-awards.ts @@ -62,15 +62,19 @@ export async function handleCoreAwardsStageCompletion( const teamScores = await calculateAllTeamScores(divisionId, teams); const teamsWithRanks = await rankTeams(teamScores, divisionId); + const innovationProjectWinners = awards['innovation-project'] ?? []; + const robotDesignWinners = awards['robot-design'] ?? []; + const coreValuesWinners = awards['core-values'] ?? []; + const excellenceInEngineeringWinners = teamsWithRanks .filter( t => !Object.values(awards.champions || {}).includes(t.teamId) && - !awards['innovation-project'].includes(t.teamId) && - !awards['robot-design'].includes(t.teamId) && - !awards['core-values'].includes(t.teamId) + !innovationProjectWinners.includes(t.teamId) && + !robotDesignWinners.includes(t.teamId) && + !coreValuesWinners.includes(t.teamId) ) - .sort((a, b) => a.ranks['total'] - b.ranks['total']) + .sort((a, b) => a.averageRank - b.averageRank) .slice(0, excellenceInEngineeringAwards.length) .map(t => t.teamId); @@ -89,7 +93,8 @@ export async function handleCoreAwardsStageCompletion( async function validateCoreAwardsAssignment(awards: FinalDeliberationAwards): Promise { const championsIds = Object.values(awards.champions || {}); for (const category of categories) { - if (awards[category].filter(winnerId => championsIds.includes(winnerId)).length > 0) { + const categoryWinners = awards[category] ?? []; + if (categoryWinners.filter(winnerId => championsIds.includes(winnerId)).length > 0) { throw new MutationError( MutationErrorCode.FORBIDDEN, `Category ${category} has a winner that was already assigned a champions award` @@ -109,7 +114,8 @@ async function assignCoreAwardsToTeams( const categoryAwards = await db.awards.byDivisionId(divisionId).get(category); for (let i = 0; i < categoryAwards.length; i++) { const award = categoryAwards[i]; - const teamId = awards[category][i]; + const categoryWinners = awards[category] ?? []; + const teamId = categoryWinners[i]; if (!teamId) { throw new MutationError( MutationErrorCode.FORBIDDEN, diff --git a/apps/backend/src/lib/graphql/resolvers/mutations/deliberations/handlers/optional-awards.ts b/apps/backend/src/lib/graphql/resolvers/mutations/deliberations/handlers/optional-awards.ts index 179654f18..b44b7c076 100644 --- a/apps/backend/src/lib/graphql/resolvers/mutations/deliberations/handlers/optional-awards.ts +++ b/apps/backend/src/lib/graphql/resolvers/mutations/deliberations/handlers/optional-awards.ts @@ -11,6 +11,7 @@ export function validateOptionalAwardsStage( optionalAwards: FinalDeliberationAwards['optionalAwards'], divisionAwards: Award[] ): Promise { + const resolvedOptionalAwards = optionalAwards ?? {}; const divisionOptionalAwards = divisionAwards.filter( award => (OPTIONAL_AWARDS as readonly string[]) @@ -18,12 +19,13 @@ export function validateOptionalAwardsStage( .includes(award.name) && award.type === 'TEAM' ); for (const award of divisionOptionalAwards) { - if (!optionalAwards[award.name] || optionalAwards[award.name].length === 0) { + if (!resolvedOptionalAwards[award.name] || resolvedOptionalAwards[award.name].length === 0) { return Promise.reject( new Error(`Optional award "${award.name}" must have at least one team assigned.`) ); } } + return Promise.resolve(); } /** @@ -51,9 +53,9 @@ async function validateOptionalAwardsAssignment( const awards = (await db.awards.byDivisionId(divisionId).getAll()).filter( award => award.name !== 'robot-performance' ); - for (const [awardName, winners] of Object.entries(optionalAwards)) { + for (const [awardName, winners] of Object.entries(optionalAwards ?? {})) { for (const award of awards) { - if (winners.includes(award.winner_id)) { + if (award.winner_id && winners.includes(award.winner_id)) { throw new MutationError( MutationErrorCode.FORBIDDEN, `Award ${awardName} has a winner that was already assigned an award` @@ -70,7 +72,7 @@ async function assignOptionalAwardsToTeams( divisionId: string, optionalAwards: FinalDeliberationAwards['optionalAwards'] ): Promise { - for (const [awardName, teamIds] of Object.entries(optionalAwards)) { + for (const [awardName, teamIds] of Object.entries(optionalAwards ?? {})) { const awards = await db.awards.byDivisionId(divisionId).get(awardName); if (awards.length !== teamIds.length) { throw new MutationError( diff --git a/apps/backend/src/lib/graphql/resolvers/mutations/deliberations/handlers/utils.ts b/apps/backend/src/lib/graphql/resolvers/mutations/deliberations/handlers/utils.ts index 669809709..b6479abdf 100644 --- a/apps/backend/src/lib/graphql/resolvers/mutations/deliberations/handlers/utils.ts +++ b/apps/backend/src/lib/graphql/resolvers/mutations/deliberations/handlers/utils.ts @@ -68,7 +68,7 @@ function calculateRubricScores( teamId: string, teamRubrics: Array<{ teamId: string; - data?: { fields?: Record }; + data?: { fields?: Record }; category: JudgingCategory; }> ): Record { diff --git a/apps/backend/src/lib/graphql/resolvers/mutations/deliberations/start-deliberation.ts b/apps/backend/src/lib/graphql/resolvers/mutations/deliberations/start-deliberation.ts index 507a90307..e2a502f6a 100644 --- a/apps/backend/src/lib/graphql/resolvers/mutations/deliberations/start-deliberation.ts +++ b/apps/backend/src/lib/graphql/resolvers/mutations/deliberations/start-deliberation.ts @@ -55,6 +55,13 @@ export const startDeliberationResolver: GraphQLFieldResolver< ); } + if (!updated.start_time) { + throw new MutationError( + MutationErrorCode.INTERNAL_ERROR, + `Deliberation start time missing for division ${divisionId}` + ); + } + const pubSub = getRedisPubSub(); await Promise.all([ pubSub.publish(divisionId, RedisEventTypes.DELIBERATION_UPDATED, { diff --git a/apps/backend/src/lib/graphql/resolvers/mutations/deliberations/update-final-deliberation-awards.ts b/apps/backend/src/lib/graphql/resolvers/mutations/deliberations/update-final-deliberation-awards.ts index 3ff8309ad..b9dcc6a14 100644 --- a/apps/backend/src/lib/graphql/resolvers/mutations/deliberations/update-final-deliberation-awards.ts +++ b/apps/backend/src/lib/graphql/resolvers/mutations/deliberations/update-final-deliberation-awards.ts @@ -2,6 +2,7 @@ import { GraphQLFieldResolver } from 'graphql'; import { RedisEventTypes } from '@lems/types/api/lems/redis'; import { MutationError, MutationErrorCode } from '@lems/types/api/lems'; import { Award, OPTIONAL_AWARDS } from '@lems/shared/awards'; +import { FinalDeliberationAwards } from '@lems/database'; import type { GraphQLContext } from '../../../apollo-server'; import db from '../../../../database'; import { getRedisPubSub } from '../../../../redis/redis-pubsub'; @@ -85,12 +86,12 @@ export const updateFinalDeliberationAwardsResolver: GraphQLFieldResolver< const updatedAwards = { ...deliberation.awards, optionalAwards: { - ...deliberation.awards.optionalAwards, // Preserve existing optional awards - ...optionalAwards // Apply incoming updates + ...deliberation.awards.optionalAwards, + ...optionalAwards }, - ...mandatoryAwards, // Mandatory awards already handle all fields in this update - ...(Object.keys(championsAward).length > 0 && { champions: championsAward }) - }; + ...mandatoryAwards, + ...(Object.keys(championsAward).length > 0 ? { champions: championsAward } : {}) + } as FinalDeliberationAwards; // Update the deliberation const updated = await db.finalDeliberations.byDivision(divisionId).update({ diff --git a/apps/backend/src/lib/graphql/resolvers/mutations/judging-sessions/start-judging-session.ts b/apps/backend/src/lib/graphql/resolvers/mutations/judging-sessions/start-judging-session.ts index fc7ade348..c2cb4136b 100644 --- a/apps/backend/src/lib/graphql/resolvers/mutations/judging-sessions/start-judging-session.ts +++ b/apps/backend/src/lib/graphql/resolvers/mutations/judging-sessions/start-judging-session.ts @@ -32,6 +32,12 @@ export const startJudgingSessionResolver: GraphQLFieldResolver< await checkSessionCanBeStarted(divisionId, session); const division = await db.divisions.byId(divisionId).get(); + if (!division?.schedule_settings) { + throw new MutationError( + MutationErrorCode.INTERNAL_ERROR, + `Division schedule settings not found for division ${divisionId}` + ); + } const scheduledTime = dayjs(session.scheduled_time); const startTime = new Date(); diff --git a/apps/backend/src/lib/graphql/resolvers/mutations/scoresheets/update-mission-clause.ts b/apps/backend/src/lib/graphql/resolvers/mutations/scoresheets/update-mission-clause.ts index 5653920c6..9e09a6bad 100644 --- a/apps/backend/src/lib/graphql/resolvers/mutations/scoresheets/update-mission-clause.ts +++ b/apps/backend/src/lib/graphql/resolvers/mutations/scoresheets/update-mission-clause.ts @@ -62,11 +62,12 @@ export const updateScoresheetMissionClauseResolver: GraphQLFieldResolver< validateClauseValue(clause, value); // Calculate points - const { data = {} } = dbScoresheet; - data['missions'] ??= {}; - data['missions'][missionId] ??= {}; - data['missions'][missionId][clauseIndex] = value; - const points = calculateScore(data['missions']); + type MissionData = Record>; + const data = (dbScoresheet.data ?? {}) as { missions?: MissionData }; + data.missions ??= {}; + data.missions[missionId] ??= {}; + data.missions[missionId][clauseIndex] = value; + const points = calculateScore(data.missions); // Determine new status based on completion criteria // Don't change status if already submitted or in gp status (locked states) diff --git a/apps/backend/src/lib/graphql/utils/rubric-builder.ts b/apps/backend/src/lib/graphql/utils/rubric-builder.ts index 8772c5c83..a16325c4d 100644 --- a/apps/backend/src/lib/graphql/utils/rubric-builder.ts +++ b/apps/backend/src/lib/graphql/utils/rubric-builder.ts @@ -13,7 +13,7 @@ export interface RubricGraphQL { status: string; data?: { awards?: Record; - fields: Record; + fields: Record; feedback?: { greatJob: string; thinkAbout: string }; }; } diff --git a/apps/backend/src/lib/redis/redis-pubsub.ts b/apps/backend/src/lib/redis/redis-pubsub.ts index 8f1b8ba04..db44e10a3 100644 --- a/apps/backend/src/lib/redis/redis-pubsub.ts +++ b/apps/backend/src/lib/redis/redis-pubsub.ts @@ -130,7 +130,8 @@ export class RedisPubSub { isActive = false; if (timeoutHandle) clearTimeout(timeoutHandle); broadcaster.removeListener('event', messageHandler); - if (resolveWait) resolveWait(); + const pendingResolve = resolveWait as (() => void) | null; + if (pendingResolve) pendingResolve(); broadcaster.decrementSubscribers(); } } diff --git a/apps/backend/src/main.ts b/apps/backend/src/main.ts index e6726eb27..c5467932a 100644 --- a/apps/backend/src/main.ts +++ b/apps/backend/src/main.ts @@ -1,6 +1,6 @@ import * as http from 'http'; import * as path from 'path'; -import express from 'express'; +import express, { ErrorRequestHandler, NextFunction, Request, Response } from 'express'; import morgan from 'morgan'; import favicon from 'serve-favicon'; import cookies from 'cookie-parser'; @@ -8,7 +8,7 @@ import cors from 'cors'; import { expressMiddleware } from '@as-integrations/express5'; import timesyncServer from 'timesync/server'; import { WebSocketServer } from 'ws'; -import { useServer } from 'graphql-ws/use/ws'; +import { useServer as createGraphqlWsServer } from 'graphql-ws/use/ws'; import './lib/dayjs'; import './lib/database'; import { logger } from './lib/logger'; @@ -98,12 +98,12 @@ const wsServer = new WebSocketServer({ path: '/lems/graphql' }); -const serverCleanup = useServer( +const serverCleanup = createGraphqlWsServer( { schema, context: async (ctx): Promise => { const user = await authenticateWebsocket(ctx.connectionParams); - return { user }; + return { user: user ?? undefined }; }, onConnect: async () => { logger.info({ component: 'websocket' }, 'Client connected'); @@ -133,7 +133,7 @@ app.use( expressMiddleware(apolloServer, { context: async ({ req }): Promise => { const user = await authenticateHttp(req); - return { user }; + return { user: user ?? undefined }; } }) ); @@ -156,7 +156,7 @@ app.use((req, res) => { // Error handler // eslint-disable-next-line @typescript-eslint/no-unused-vars -app.use((err, req, res, next) => { +app.use(((err: Error, req: Request, res: Response, _next: NextFunction) => { logger.error( { component: 'http', @@ -168,7 +168,7 @@ app.use((err, req, res, next) => { 'Unhandled error' ); res.status(500).json({ error: 'INTERNAL_SERVER_ERROR' }); -}); +}) as ErrorRequestHandler); logger.info('Starting server...'); const port = 3333; diff --git a/apps/backend/src/routers/admin/auth.ts b/apps/backend/src/routers/admin/auth.ts index f2cbb6d72..107769607 100644 --- a/apps/backend/src/routers/admin/auth.ts +++ b/apps/backend/src/routers/admin/auth.ts @@ -8,8 +8,10 @@ import db from '../../lib/database'; import { getRecaptchaResponse } from '../../lib/security/captcha'; import { verifyPassword } from '../../lib/security/credentials'; import { logger } from '../../lib/logger'; +import { asHandler } from '../../types/express-handlers'; import { makeAdminUserResponse } from './users/util'; + const router = express.Router({ mergeParams: true }); const loginRateLimiter = rateLimit({ @@ -34,8 +36,12 @@ router.post('/login', loginRateLimiter, async (req: Request, res: Response, next const { captchaToken, ...loginDetails }: LoginRequest = req.body; if (process.env.RECAPTCHA === 'true') { + if (!captchaToken) { + res.status(400).json({ error: 'CAPTCHA_REQUIRED' }); + return; + } const captcha: RecaptchaResponse = await getRecaptchaResponse(captchaToken); - if (!captcha.success || captcha['error-codes']?.length > 0) { + if (!captcha.success || (captcha['error-codes']?.length ?? 0) > 0) { logger.warn({ component: 'auth', action: 'login', errorCodes: captcha['error-codes'] || [] }, 'Captcha failure'); res.status(500).json({ error: 'CAPTCHA_FAILED' }); return; @@ -115,7 +121,7 @@ router.post('/logout', (req: Request, res: Response) => { res.json({ ok: true }); }); -router.get('/verify', async (req: AdminRequest, res) => { +router.get('/verify', asHandler(async (req, res) => { const user = await db.admins.byId(req.userId!).get(); if (!user) { res.status(401).json({ error: 'UNAUTHORIZED' }); @@ -123,6 +129,6 @@ router.get('/verify', async (req: AdminRequest, res) => { } res.json({ ok: true, user: makeAdminUserResponse(user) }); -}); +})); export default router; diff --git a/apps/backend/src/routers/admin/events/divisions/awards/index.ts b/apps/backend/src/routers/admin/events/divisions/awards/index.ts index fb41087bf..fa65abe89 100644 --- a/apps/backend/src/routers/admin/events/divisions/awards/index.ts +++ b/apps/backend/src/routers/admin/events/divisions/awards/index.ts @@ -2,19 +2,20 @@ import express from 'express'; import db from '../../../../../lib/database'; import { requirePermission } from '../../../middleware/require-permission'; import { AdminDivisionRequest } from '../../../../../types/express'; +import { asHandler } from '../../../../../types/express-handlers'; import { makeAdminAwardResponse } from './utils'; const router = express.Router({ mergeParams: true }); -router.get('/', async (req: AdminDivisionRequest, res) => { +router.get('/', asHandler(async (req, res) => { const awards = await db.awards.byDivisionId(req.divisionId).getAll(); res.status(200).json(awards.map(award => makeAdminAwardResponse(award))); -}); +})); router.post( '/', requirePermission('MANAGE_EVENT_DETAILS'), - async (req: AdminDivisionRequest, res) => { + asHandler(async (req, res) => { const awards = req.body.awards; if (!Array.isArray(awards)) { res.status(400).json({ error: 'Awards array is required' }); @@ -50,17 +51,17 @@ router.post( await db.divisions.byId(req.divisionId).update({ has_awards: true }); res.status(201).end(); - } + }) ); router.delete( '/', requirePermission('MANAGE_EVENT_DETAILS'), - async (req: AdminDivisionRequest, res) => { + asHandler(async (req, res) => { await db.awards.byDivisionId(req.divisionId).deleteAll(); await db.divisions.byId(req.divisionId).update({ has_awards: false }); res.status(200).end(); - } + }) ); export default router; diff --git a/apps/backend/src/routers/admin/events/divisions/awards/utils.ts b/apps/backend/src/routers/admin/events/divisions/awards/utils.ts index ca6b71802..995da11f0 100644 --- a/apps/backend/src/routers/admin/events/divisions/awards/utils.ts +++ b/apps/backend/src/routers/admin/events/divisions/awards/utils.ts @@ -19,6 +19,6 @@ export const makeAdminAwardResponse = (award: DbAward, includeWinner = false): A automaticAssignment: award.automatic_assignment, place: award.place, index: award.index, - ...(includeWinner && { winner }) + ...(includeWinner && winner != null && { winner }) }; }; diff --git a/apps/backend/src/routers/admin/events/divisions/index.ts b/apps/backend/src/routers/admin/events/divisions/index.ts index deb03ce9e..29eb9d7a6 100644 --- a/apps/backend/src/routers/admin/events/divisions/index.ts +++ b/apps/backend/src/routers/admin/events/divisions/index.ts @@ -3,6 +3,7 @@ import db from '../../../../lib/database'; import { AdminDivisionRequest, AdminEventRequest } from '../../../../types/express'; import { attachDivision } from '../../middleware/attach-division'; import { requirePermission } from '../../middleware/require-permission'; +import { asHandler } from '../../../../types/express-handlers'; import { makeAdminDivisionResponse } from './util'; import divisionRoomsRouter from './rooms'; import divisionTablesRouter from './tables'; @@ -13,12 +14,12 @@ import divisionAwardsRouter from './awards'; const router = express.Router({ mergeParams: true }); -router.get('/', async (req: AdminEventRequest, res) => { +router.get('/', asHandler(async (req, res) => { const divisions = await db.divisions.byEventId(req.eventId).getAll(); res.json(divisions.map(division => makeAdminDivisionResponse(division))); -}); +})); -router.post('/', requirePermission('MANAGE_EVENT_DETAILS'), async (req: AdminEventRequest, res) => { +router.post('/', requirePermission('MANAGE_EVENT_DETAILS'), asHandler(async (req, res) => { const { name, color } = req.body; if (!name || !color) { @@ -29,7 +30,7 @@ router.post('/', requirePermission('MANAGE_EVENT_DETAILS'), async (req: AdminEve const division = await db.divisions.create({ name, color, event_id: req.eventId }); res.status(201).json(makeAdminDivisionResponse(division)); -}); +})); router.use('/:divisionId', attachDivision()); @@ -43,7 +44,7 @@ router.use('/:divisionId/schedule', divisionScheduleRouter); router.put( '/:divisionId', requirePermission('MANAGE_EVENT_DETAILS'), - async (req: AdminDivisionRequest, res) => { + asHandler(async (req, res) => { const { name, color } = req.body; // Name can be empty, but has to exist @@ -55,13 +56,13 @@ router.put( await db.divisions.byId(req.divisionId).update({ name, color }); res.status(200).end(); - } + }) ); router.delete( '/:divisionId', requirePermission('MANAGE_EVENT_DETAILS'), - async (req: AdminDivisionRequest, res) => { + asHandler(async (req, res) => { const deleted = await db.divisions.byId(req.divisionId).delete(); if (!deleted) { @@ -70,7 +71,7 @@ router.delete( } res.status(204).end(); - } + }) ); export default router; diff --git a/apps/backend/src/routers/admin/events/divisions/pit-map/index.ts b/apps/backend/src/routers/admin/events/divisions/pit-map/index.ts index 8fcd62494..4c0606cbe 100644 --- a/apps/backend/src/routers/admin/events/divisions/pit-map/index.ts +++ b/apps/backend/src/routers/admin/events/divisions/pit-map/index.ts @@ -1,69 +1,70 @@ -import express from 'express'; -import fileUpload from 'express-fileupload'; -import db from '../../../../../lib/database'; -import { requirePermission } from '../../../middleware/require-permission'; -import { AdminDivisionRequest } from '../../../../../types/express'; +import express from 'express'; +import fileUpload from 'express-fileupload'; +import db from '../../../../../lib/database'; +import { requirePermission } from '../../../middleware/require-permission'; +import { AdminDivisionRequest } from '../../../../../types/express'; import { asHandler } from '../../../../../types/express-handlers'; -const router = express.Router({ mergeParams: true }); - -router.post( - '/', - requirePermission('MANAGE_EVENT_DETAILS'), - fileUpload(), - async (req: AdminDivisionRequest, res) => { - if (!req.files || !req.files.pitMap) { - res.status(400).json({ error: 'No pit map file provided' }); - return; - } - - const pitMapFile = req.files.pitMap as fileUpload.UploadedFile; - - if ( - !pitMapFile.mimetype?.startsWith('image/') || - (!pitMapFile.name.endsWith('.jpg') && - !pitMapFile.name.endsWith('.jpeg') && - !pitMapFile.name.endsWith('.png')) - ) { - res.status(400).json({ error: 'Pit map must be an image file (JPG, JPEG, or PNG)' }); - return; + +const router = express.Router({ mergeParams: true }); + +router.post( + '/', + requirePermission('MANAGE_EVENT_DETAILS'), + fileUpload(), + asHandler(async (req, res) => { + if (!req.files || !req.files.pitMap) { + res.status(400).json({ error: 'No pit map file provided' }); + return; + } + + const pitMapFile = req.files.pitMap as fileUpload.UploadedFile; + + if ( + !pitMapFile.mimetype?.startsWith('image/') || + (!pitMapFile.name.endsWith('.jpg') && + !pitMapFile.name.endsWith('.jpeg') && + !pitMapFile.name.endsWith('.png')) + ) { + res.status(400).json({ error: 'Pit map must be an image file (JPG, JPEG, or PNG)' }); + return; + } + + try { + const updatedDivision = await db.divisions.byId(req.divisionId).updatePitMap(pitMapFile.data); + if (updatedDivision) { + res.status(200).json(updatedDivision); + return; + } else { + res.status(500).json({ error: 'Failed to upload pit map' }); + return; + } + } catch (error) { + console.error('Error uploading pit map:', error); + res.status(500).json({ error: 'Failed to upload pit map' }); + return; } - - try { - const updatedDivision = await db.divisions.byId(req.divisionId).updatePitMap(pitMapFile.data); - if (updatedDivision) { - res.status(200).json(updatedDivision); - return; - } else { - res.status(500).json({ error: 'Failed to upload pit map' }); - return; - } - } catch (error) { - console.error('Error uploading pit map:', error); - res.status(500).json({ error: 'Failed to upload pit map' }); - return; - } - } -); - -router.delete( - '/', - requirePermission('MANAGE_EVENT_DETAILS'), - async (req: AdminDivisionRequest, res) => { - try { - const success = await db.divisions.byId(req.divisionId).update({ pit_map_url: null }); - if (success) { - res.status(200).json({ success: true }); - return; - } else { - res.status(500).json({ error: 'Failed to delete pit map' }); - return; - } - } catch (error) { - console.error('Error deleting pit map:', error); - res.status(500).json({ error: 'Failed to delete pit map' }); - return; + }) +); + +router.delete( + '/', + requirePermission('MANAGE_EVENT_DETAILS'), + asHandler(async (req, res) => { + try { + const success = await db.divisions.byId(req.divisionId).update({ pit_map_url: null }); + if (success) { + res.status(200).json({ success: true }); + return; + } else { + res.status(500).json({ error: 'Failed to delete pit map' }); + return; + } + } catch (error) { + console.error('Error deleting pit map:', error); + res.status(500).json({ error: 'Failed to delete pit map' }); + return; } - } -); - -export default router; + }) +); + +export default router; diff --git a/apps/backend/src/routers/admin/events/divisions/rooms/index.ts b/apps/backend/src/routers/admin/events/divisions/rooms/index.ts index 9ea3c3042..c51c43b32 100644 --- a/apps/backend/src/routers/admin/events/divisions/rooms/index.ts +++ b/apps/backend/src/routers/admin/events/divisions/rooms/index.ts @@ -1,87 +1,92 @@ -import express from 'express'; -import db from '../../../../../lib/database'; -import { requirePermission } from '../../../middleware/require-permission'; -import { AdminDivisionRequest } from '../../../../../types/express'; -import { generateVolunteerPassword } from '../../users/util'; - -const router = express.Router({ mergeParams: true }); - -router.get('/', async (req: AdminDivisionRequest, res) => { - const rooms = await db.rooms.byDivisionId(req.divisionId).getAll(); - res.status(200).json(rooms); -}); - -router.post( - '/', - requirePermission('MANAGE_EVENT_DETAILS'), - async (req: AdminDivisionRequest, res) => { - const { name } = req.body; - - if (!name) { - res.status(400).json({ error: 'Name is required' }); - return; - } - - const room = await db.rooms.create({ division_id: req.divisionId, name }); - const division = await db.divisions.byId(req.divisionId).get(); - - const eventUser = await db.eventUsers.create({ - event_id: division.event_id, - role: 'judge', - role_info: { roomId: room.id }, - identifier: null, - password: generateVolunteerPassword() - }); - - await db.eventUsers.assignUserToDivisions(eventUser.id, [req.divisionId]); - - res.status(201).end(); - } -); - -router.put( - '/:roomId', - requirePermission('MANAGE_EVENT_DETAILS'), - async (req: AdminDivisionRequest, res) => { - const { roomId } = req.params; - if (!roomId || typeof roomId !== 'string') { - res.status(400).json({ error: 'ROOM_ID_REQUIRED' }); - return; - } - - const { name } = req.body; - - if (!name) { - res.status(400).json({ error: 'Name is required' }); - return; - } - - await db.rooms.byId(roomId).update({ name }); - - res.status(200).end(); - } -); - -router.delete( - '/:roomId', - requirePermission('MANAGE_EVENT_DETAILS'), - async (req: AdminDivisionRequest, res) => { - const { roomId } = req.params; - if (!roomId || typeof roomId !== 'string') { - res.status(400).json({ error: 'ROOM_ID_REQUIRED' }); - return; - } - - await db.rooms.byId(roomId).delete(); - - const roomEventUser = await db.eventUsers.byRoleInfo('roomId', roomId).get(); - - if (roomEventUser) { - await db.eventUsers.delete(roomEventUser.id); - } - - res.status(204).end(); - } -); - -export default router; +import express from 'express'; +import db from '../../../../../lib/database'; +import { requirePermission } from '../../../middleware/require-permission'; +import { AdminDivisionRequest } from '../../../../../types/express'; +import { generateVolunteerPassword } from '../../users/util'; import { asHandler } from '../../../../../types/express-handlers'; + + +const router = express.Router({ mergeParams: true }); + +router.get('/', asHandler(async (req, res) => { + const rooms = await db.rooms.byDivisionId(req.divisionId).getAll(); + res.status(200).json(rooms); +})); + +router.post( + '/', + requirePermission('MANAGE_EVENT_DETAILS'), + asHandler(async (req, res) => { + const { name } = req.body; + + if (!name) { + res.status(400).json({ error: 'Name is required' }); + return; + } + + const room = await db.rooms.create({ division_id: req.divisionId, name }); + const division = await db.divisions.byId(req.divisionId).get(); + if (!division) { + res.status(404).json({ error: 'Division not found' }); + return; + } + + const eventUser = await db.eventUsers.create({ + event_id: division.event_id, + role: 'judge', + role_info: { roomId: room.id }, + identifier: null, + password: generateVolunteerPassword() + }); + + await db.eventUsers.assignUserToDivisions(eventUser.id, [req.divisionId]); + + res.status(201).end(); + }) +); + +router.put( + '/:roomId', + requirePermission('MANAGE_EVENT_DETAILS'), + asHandler(async (req, res) => { + const { roomId } = req.params; + if (!roomId || typeof roomId !== 'string') { + res.status(400).json({ error: 'ROOM_ID_REQUIRED' }); + return; + } + + const { name } = req.body; + + if (!name) { + res.status(400).json({ error: 'Name is required' }); + return; + } + + await db.rooms.byId(roomId).update({ name }); + + res.status(200).end(); + }) +); + +router.delete( + '/:roomId', + requirePermission('MANAGE_EVENT_DETAILS'), + asHandler(async (req, res) => { + const { roomId } = req.params; + if (!roomId || typeof roomId !== 'string') { + res.status(400).json({ error: 'ROOM_ID_REQUIRED' }); + return; + } + + await db.rooms.byId(roomId).delete(); + + const roomEventUser = await db.eventUsers.byRoleInfo('roomId', roomId).get(); + + if (roomEventUser) { + await db.eventUsers.delete(roomEventUser.id); + } + + res.status(204).end(); + }) +); + +export default router; diff --git a/apps/backend/src/routers/admin/events/divisions/schedule/agenda.ts b/apps/backend/src/routers/admin/events/divisions/schedule/agenda.ts index de5e9ab0c..4aef9e476 100644 --- a/apps/backend/src/routers/admin/events/divisions/schedule/agenda.ts +++ b/apps/backend/src/routers/admin/events/divisions/schedule/agenda.ts @@ -1,41 +1,42 @@ -import express from 'express'; -import db from '../../../../../lib/database'; -import { AdminDivisionRequest } from '../../../../../types/express'; -import { requirePermission } from '../../../middleware/require-permission'; +import express from 'express'; +import db from '../../../../../lib/database'; +import { AdminDivisionRequest } from '../../../../../types/express'; +import { requirePermission } from '../../../middleware/require-permission'; import { asHandler } from '../../../../../types/express-handlers'; -const router = express.Router({ mergeParams: true }); - -router.post( - '/', - requirePermission('MANAGE_EVENT_DETAILS'), - async (req: AdminDivisionRequest, res) => { - try { - const agendaEvents = req.body; - if (!Array.isArray(agendaEvents)) { - res.status(400).json({ error: 'Invalid agenda events' }); - return; - } - await db.divisions.byId(req.divisionId).agenda().createMany(agendaEvents); - res.status(200).json({ ok: true }); - } catch (error) { - console.error('Error updating agenda events:', error); - res.status(500).json({ error: 'Failed to update agenda events' }); + +const router = express.Router({ mergeParams: true }); + +router.post( + '/', + requirePermission('MANAGE_EVENT_DETAILS'), + asHandler(async (req, res) => { + try { + const agendaEvents = req.body; + if (!Array.isArray(agendaEvents)) { + res.status(400).json({ error: 'Invalid agenda events' }); + return; + } + await db.divisions.byId(req.divisionId).agenda().createMany(agendaEvents); + res.status(200).json({ ok: true }); + } catch (error) { + console.error('Error updating agenda events:', error); + res.status(500).json({ error: 'Failed to update agenda events' }); } - } -); - -router.delete( - '/', - requirePermission('MANAGE_EVENT_DETAILS'), - async (req: AdminDivisionRequest, res) => { - try { - await db.divisions.byId(req.divisionId).agenda().delete(); - res.status(200).json({ ok: true }); - } catch (error) { - console.error('Error deleting agenda event:', error); - res.status(500).json({ error: 'Failed to delete agenda event' }); + }) +); + +router.delete( + '/', + requirePermission('MANAGE_EVENT_DETAILS'), + asHandler(async (req, res) => { + try { + await db.divisions.byId(req.divisionId).agenda().delete(); + res.status(200).json({ ok: true }); + } catch (error) { + console.error('Error deleting agenda event:', error); + res.status(500).json({ error: 'Failed to delete agenda event' }); } - } -); - -export default router; + }) +); + +export default router; diff --git a/apps/backend/src/routers/admin/events/divisions/schedule/index.ts b/apps/backend/src/routers/admin/events/divisions/schedule/index.ts index 532e2c966..c3290ecf4 100644 --- a/apps/backend/src/routers/admin/events/divisions/schedule/index.ts +++ b/apps/backend/src/routers/admin/events/divisions/schedule/index.ts @@ -3,6 +3,7 @@ import { SchedulerRequest } from '@lems/types/api/scheduler'; import db from '../../../../../lib/database'; import { AdminDivisionRequest } from '../../../../../types/express'; import { requirePermission } from '../../../middleware/require-permission'; +import { asHandler } from '../../../../../types/express-handlers'; import { makeAdminJudgingSessionResponse, makeAdminJudgingRoomResponse, @@ -20,7 +21,7 @@ router.use('/agenda', agendaRouter); router.post( '/validate', requirePermission('MANAGE_EVENT_DETAILS'), - async (req: AdminDivisionRequest, res) => { + asHandler(async (req, res) => { try { const settings: SchedulerRequest = req.body; @@ -47,13 +48,13 @@ router.post( console.debug(error); res.status(500).json({ error: 'INTERNAL_SERVER_ERROR' }); } - } + }) ); router.post( '/generate', requirePermission('MANAGE_EVENT_DETAILS'), - async (req: AdminDivisionRequest, res) => { + asHandler(async (req, res) => { try { const settings: SchedulerRequest = req.body; @@ -80,13 +81,13 @@ router.post( console.debug(error); res.status(500).json({ error: 'INTERNAL_SERVER_ERROR' }); } - } + }) ); router.delete( '/', requirePermission('MANAGE_EVENT_DETAILS'), - async (req: AdminDivisionRequest, res) => { + asHandler(async (req, res) => { try { await Promise.all([ db.judgingSessions.byDivision(req.divisionId).deleteAll(), @@ -107,13 +108,13 @@ router.delete( console.error('Error deleting division schedule:', error); res.status(500).json({ error: 'Failed to delete division schedule' }); } - } + }) ); router.get( '/teams/:teamId', requirePermission('MANAGE_EVENT_DETAILS'), - async (req: AdminDivisionRequest, res) => { + asHandler(async (req, res) => { try { const { teamId } = req.params; if (!teamId || typeof teamId !== 'string') { @@ -146,13 +147,13 @@ router.get( console.error('Error fetching team schedule:', error); res.status(500).json({ error: 'Failed to fetch team schedule' }); } - } + }) ); router.get( '/judging-sessions', requirePermission('MANAGE_EVENT_DETAILS'), - async (req: AdminDivisionRequest, res) => { + asHandler(async (req, res) => { try { const sessions = await db.judgingSessions.byDivision(req.divisionId).getAll(); const rooms = await db.rooms.byDivisionId(req.divisionId).getAll(); @@ -165,13 +166,13 @@ router.get( console.error('Error fetching judging sessions:', error); res.status(500).json({ error: 'Failed to fetch judging sessions' }); } - } + }) ); router.put( '/swap', requirePermission('MANAGE_EVENT_DETAILS'), - async (req: AdminDivisionRequest, res) => { + asHandler(async (req, res) => { try { const { teamId1, teamId2 } = req.body; @@ -208,7 +209,7 @@ router.put( console.error('Error swapping team schedules:', error); res.status(500).json({ error: 'Failed to swap team schedules' }); } - } + }) ); export default router; diff --git a/apps/backend/src/routers/admin/events/divisions/tables/index.ts b/apps/backend/src/routers/admin/events/divisions/tables/index.ts index a24ae96e9..426905ecd 100644 --- a/apps/backend/src/routers/admin/events/divisions/tables/index.ts +++ b/apps/backend/src/routers/admin/events/divisions/tables/index.ts @@ -1,87 +1,92 @@ -import express from 'express'; -import db from '../../../../../lib/database'; -import { requirePermission } from '../../../middleware/require-permission'; -import { AdminDivisionRequest } from '../../../../../types/express'; -import { generateVolunteerPassword } from '../../users/util'; - -const router = express.Router({ mergeParams: true }); - -router.get('/', async (req: AdminDivisionRequest, res) => { - const tables = await db.tables.byDivisionId(req.divisionId).getAll(); - res.status(200).json(tables); -}); - -router.post( - '/', - requirePermission('MANAGE_EVENT_DETAILS'), - async (req: AdminDivisionRequest, res) => { - const { name } = req.body; - - if (!name) { - res.status(400).json({ error: 'Name is required' }); - return; - } - - const table = await db.tables.create({ division_id: req.divisionId, name }); - const division = await db.divisions.byId(req.divisionId).get(); - - const eventUser = await db.eventUsers.create({ - event_id: division.event_id, - role: 'referee', - role_info: { tableId: table.id }, - identifier: null, - password: generateVolunteerPassword() - }); - - await db.eventUsers.assignUserToDivisions(eventUser.id, [req.divisionId]); - - res.status(201).end(); - } -); - -router.put( - '/:tableId', - requirePermission('MANAGE_EVENT_DETAILS'), - async (req: AdminDivisionRequest, res) => { - const { tableId } = req.params; - const { name } = req.body; - - if (!tableId || typeof tableId !== 'string') { - res.status(400).json({ error: 'TABLE_ID_REQUIRED' }); - return; - } - - if (!name) { - res.status(400).json({ error: 'Name is required' }); - return; - } - - await db.tables.byId(tableId).update({ name }); - - res.status(200).end(); - } -); - -router.delete( - '/:tableId', - requirePermission('MANAGE_EVENT_DETAILS'), - async (req: AdminDivisionRequest, res) => { - const { tableId } = req.params; - if (!tableId || typeof tableId !== 'string') { - res.status(400).json({ error: 'TABLE_ID_REQUIRED' }); - return; - } - - await db.tables.byId(tableId).delete(); - - const tableEventUser = await db.eventUsers.byRoleInfo('tableId', tableId).get(); - - if (tableEventUser) { - await db.eventUsers.delete(tableEventUser.id); - } - - res.status(204).end(); - } -); - -export default router; +import express from 'express'; +import db from '../../../../../lib/database'; +import { requirePermission } from '../../../middleware/require-permission'; +import { AdminDivisionRequest } from '../../../../../types/express'; +import { generateVolunteerPassword } from '../../users/util'; import { asHandler } from '../../../../../types/express-handlers'; + + +const router = express.Router({ mergeParams: true }); + +router.get('/', asHandler(async (req, res) => { + const tables = await db.tables.byDivisionId(req.divisionId).getAll(); + res.status(200).json(tables); +})); + +router.post( + '/', + requirePermission('MANAGE_EVENT_DETAILS'), + asHandler(async (req, res) => { + const { name } = req.body; + + if (!name) { + res.status(400).json({ error: 'Name is required' }); + return; + } + + const table = await db.tables.create({ division_id: req.divisionId, name }); + const division = await db.divisions.byId(req.divisionId).get(); + if (!division) { + res.status(404).json({ error: 'Division not found' }); + return; + } + + const eventUser = await db.eventUsers.create({ + event_id: division.event_id, + role: 'referee', + role_info: { tableId: table.id }, + identifier: null, + password: generateVolunteerPassword() + }); + + await db.eventUsers.assignUserToDivisions(eventUser.id, [req.divisionId]); + + res.status(201).end(); + }) +); + +router.put( + '/:tableId', + requirePermission('MANAGE_EVENT_DETAILS'), + asHandler(async (req, res) => { + const { tableId } = req.params; + const { name } = req.body; + + if (!tableId || typeof tableId !== 'string') { + res.status(400).json({ error: 'TABLE_ID_REQUIRED' }); + return; + } + + if (!name) { + res.status(400).json({ error: 'Name is required' }); + return; + } + + await db.tables.byId(tableId).update({ name }); + + res.status(200).end(); + }) +); + +router.delete( + '/:tableId', + requirePermission('MANAGE_EVENT_DETAILS'), + asHandler(async (req, res) => { + const { tableId } = req.params; + if (!tableId || typeof tableId !== 'string') { + res.status(400).json({ error: 'TABLE_ID_REQUIRED' }); + return; + } + + await db.tables.byId(tableId).delete(); + + const tableEventUser = await db.eventUsers.byRoleInfo('tableId', tableId).get(); + + if (tableEventUser) { + await db.eventUsers.delete(tableEventUser.id); + } + + res.status(204).end(); + }) +); + +export default router; diff --git a/apps/backend/src/routers/admin/events/divisions/teams/index.ts b/apps/backend/src/routers/admin/events/divisions/teams/index.ts index 743c3f816..4004f1513 100644 --- a/apps/backend/src/routers/admin/events/divisions/teams/index.ts +++ b/apps/backend/src/routers/admin/events/divisions/teams/index.ts @@ -1,13 +1,14 @@ -import express from 'express'; -import db from '../../../../../lib/database'; -import { AdminDivisionRequest } from '../../../../../types/express'; -import { makeAdminTeamResponse } from '../../../teams/util'; +import express from 'express'; +import db from '../../../../../lib/database'; +import { AdminDivisionRequest } from '../../../../../types/express'; +import { makeAdminTeamResponse } from '../../../teams/util'; import { asHandler } from '../../../../../types/express-handlers'; -const router = express.Router({ mergeParams: true }); - -router.get('/', async (req: AdminDivisionRequest, res) => { - const teams = await db.teams.byDivisionId(req.divisionId).getAll(); - res.json(teams.map(team => makeAdminTeamResponse(team))); -}); - -export default router; + +const router = express.Router({ mergeParams: true }); + +router.get('/', asHandler(async (req, res) => { + const teams = await db.teams.byDivisionId(req.divisionId).getAll(); + res.json(teams.map(team => makeAdminTeamResponse(team))); +})); + +export default router; diff --git a/apps/backend/src/routers/admin/events/index.ts b/apps/backend/src/routers/admin/events/index.ts index 9fe080898..6ddefb4e2 100644 --- a/apps/backend/src/routers/admin/events/index.ts +++ b/apps/backend/src/routers/admin/events/index.ts @@ -1,242 +1,247 @@ -import express from 'express'; -import dayjs from 'dayjs'; -import { UpdateableEvent } from '@lems/database'; -import db from '../../../lib/database'; -import { attachEvent } from '../middleware/attach-event'; -import { requirePermission } from '../middleware/require-permission'; -import { AdminEventRequest, AdminRequest } from '../../../types/express'; -import { makeAdminEventResponse, makeAdminEventSummaryResponse } from './util'; -import eventUsersRouter from './users'; -import eventTeamsRouter from './teams'; -import eventDivisionsRouter from './divisions'; -import eventSettingsRouter from './settings'; -import eventIntegrationsRouter from './integrations'; - -const router = express.Router({ mergeParams: true }); - -router.get('/', async (req: AdminEventRequest, res) => { - const events = await db.events.getAll(); - res.json(events.map(event => makeAdminEventResponse(event))); -}); - -router.get('/me', async (req: AdminRequest, res) => { - const events = await db.admins.byId(req.userId).getEvents(); - res.json(events.map(event => makeAdminEventResponse(event))); -}); - -router.get('/slug/:slug', async (req, res) => { - const event = await db.events.bySlug(req.params.slug).get(); - - if (!event) { - res.status(404).json({ error: 'Event not found' }); - return; - } - - res.json(makeAdminEventResponse(event)); -}); - -router.get('/season/:seasonId', async (req, res) => { - const events = await db.events.bySeason(req.params.seasonId).getAll(); - res.json(events); -}); - -router.get('/season/:seasonId/summary', async (req, res) => { - const events = await db.events.bySeason(req.params.seasonId).getAllSummaries(); - res.json(events.map(event => makeAdminEventSummaryResponse(event))); -}); - -router.post('/', requirePermission('MANAGE_EVENTS'), async (req: AdminRequest, res) => { - try { - const { name, slug, date, location, region, timezone, divisions } = req.body; - - if (!name || !slug || !date || !location || !region || !timezone) { - res - .status(400) - .json({ error: 'Name, slug, date, location, region, and timezone are required' }); - return; - } - - if (typeof region !== 'string' || region.length !== 2 || !/^[A-Z]{2}$/.test(region)) { - res.status(400).json({ error: 'Region must be a 2-letter ISO 3166-1 alpha-2 country code' }); - return; - } - - if (typeof timezone !== 'string' || !timezone.trim()) { - res.status(400).json({ error: 'Timezone must be a valid IANA timezone string' }); - return; - } - - if (!Array.isArray(divisions) || divisions.length === 0) { - res.status(400).json({ error: 'At least one division is required' }); - return; - } - - if (divisions.length > 1) { - for (const division of divisions) { - if (!division.name || !division.color) { - res.status(400).json({ error: 'Each division must have a name and color' }); - return; - } - } - } - - const slugPattern = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; - if (!slugPattern.test(slug)) { - res - .status(400) - .json({ error: 'Invalid slug format. Use lowercase letters, numbers, and dashes only' }); - return; - } - - const existingEvent = await db.events.bySlug(slug).get(); - if (existingEvent) { - res.status(409).json({ error: 'Event with this slug already exists' }); - return; - } - - const currentSeason = await db.seasons.getCurrent(); - if (!currentSeason) { - res - .status(400) - .json({ error: 'No current season found. A season must be active to create an event.' }); - return; - } - - // Convert the selected date from 8:00 AM to 23:59 in the event's timezone to UTC range - // Example: April 4 8:00 AM - 23:59 in Europe/Warsaw (UTC+2) becomes April 4 06:00 UTC to April 4 21:59 UTC - let startDate: Date; - let endDate: Date; - - try { - startDate = dayjs.tz(date, 'YYYY-MM-DD', timezone).hour(8).minute(0).second(0).toDate(); - endDate = dayjs.tz(date, 'YYYY-MM-DD', timezone).endOf('day').toDate(); - } catch { - res.status(400).json({ error: 'Invalid date format or timezone' }); - return; - } - - const eventResult = await db.events.create({ - name, - slug, - start_date: startDate, - end_date: endDate, - location, - region, - timezone, - season_id: currentSeason.id - }); - - if (!eventResult) { - res.status(500).json({ error: 'Failed to create event' }); - return; - } - - await db.events.byId(eventResult.id).addAdmin(req.userId); - - const divisionsResult = await db.divisions.createMany( - divisions.map(division => ({ - name: division.name, - color: division.color, - event_id: eventResult.id - })) - ); - - if (!divisionsResult || divisionsResult.length === 0) { - res.status(500).json({ error: 'Failed to create divisions' }); - return; - } - - res.status(201).json({ timezone }); - } catch (error) { - console.error('Error creating event:', error); - res.status(500).json({ error: 'Internal server error' }); - } -}); - -router.use('/:eventId', attachEvent()); - -router.use('/:eventId/divisions', eventDivisionsRouter); -router.use('/:eventId/teams', eventTeamsRouter); -router.use('/:eventId/users', eventUsersRouter); -router.use('/:eventId/settings', eventSettingsRouter); -router.use('/:eventId/integrations', eventIntegrationsRouter); - -router.get('/:eventId', async (req: AdminEventRequest, res) => { - const event = await db.events.byId(req.eventId).get(); - res.json(makeAdminEventResponse(event)); -}); - -router.put('/:eventId', requirePermission('MANAGE_EVENTS'), async (req: AdminRequest, res) => { - try { - const { eventId } = req.params; - const { name, date, location, region } = req.body; - - if (!eventId || typeof eventId !== 'string') { - res.status(400).json({ error: 'EVENT_ID_REQUIRED' }); - return; - } - - const existingEvent = await db.events.byId(eventId).get(); - if (!existingEvent) { - res.status(404).json({ error: 'Event not found' }); - return; - } - - const updateData: Partial = {}; - - if (name !== undefined) { - if (!name.trim()) { - res.status(400).json({ error: 'Name cannot be empty' }); - return; - } - updateData.name = name; - } - - if (date !== undefined) { - const eventDate = new Date(date); - if (isNaN(eventDate.getTime())) { - res.status(400).json({ error: 'Invalid date format' }); - return; - } - updateData.start_date = eventDate; - updateData.end_date = eventDate; // For now, using same date for start and end - } - - if (location !== undefined) { - if (!location.trim()) { - res.status(400).json({ error: 'Location cannot be empty' }); - return; - } - updateData.location = location; - } - - if (region !== undefined) { - if (typeof region !== 'string' || region.length !== 2 || !/^[A-Z]{2}$/.test(region)) { - res - .status(400) - .json({ error: 'Region must be a 2-letter ISO 3166-1 alpha-2 country code' }); - return; - } - updateData.region = region; - } - - if (Object.keys(updateData).length === 0) { - res.status(400).json({ error: 'No valid fields to update' }); - return; - } - - const updatedEvent = await db.events.byId(eventId).update(updateData); - - if (!updatedEvent) { - res.status(500).json({ error: 'Failed to update event' }); - return; - } - - res.status(200).end(); - } catch (error) { - console.error('Error updating event:', error); - res.status(500).json({ error: 'Internal server error' }); - } -}); - -export default router; +import express from 'express'; +import dayjs from 'dayjs'; +import { UpdateableEvent } from '@lems/database'; +import db from '../../../lib/database'; +import { attachEvent } from '../middleware/attach-event'; +import { requirePermission } from '../middleware/require-permission'; +import { AdminEventRequest, AdminRequest } from '../../../types/express'; +import { asHandler } from '../../../types/express-handlers'; +import { makeAdminEventResponse, makeAdminEventSummaryResponse } from './util'; +import eventUsersRouter from './users'; +import eventTeamsRouter from './teams'; +import eventDivisionsRouter from './divisions'; +import eventSettingsRouter from './settings'; +import eventIntegrationsRouter from './integrations'; + +const router = express.Router({ mergeParams: true }); + +router.get('/', asHandler(async (req, res) => { + const events = await db.events.getAll(); + res.json(events.map(event => makeAdminEventResponse(event))); +})); + +router.get('/me', asHandler(async (req, res) => { + const events = await db.admins.byId(req.userId).getEvents(); + res.json(events.map(event => makeAdminEventResponse(event))); +})); + +router.get('/slug/:slug', async (req, res) => { + const event = await db.events.bySlug(req.params.slug).get(); + + if (!event) { + res.status(404).json({ error: 'Event not found' }); + return; + } + + res.json(makeAdminEventResponse(event)); +}); + +router.get('/season/:seasonId', async (req, res) => { + const events = await db.events.bySeason(req.params.seasonId).getAll(); + res.json(events); +}); + +router.get('/season/:seasonId/summary', async (req, res) => { + const events = await db.events.bySeason(req.params.seasonId).getAllSummaries(); + res.json(events.map(event => makeAdminEventSummaryResponse(event))); +}); + +router.post('/', requirePermission('MANAGE_EVENTS'), asHandler(async (req, res) => { + try { + const { name, slug, date, location, region, timezone, divisions } = req.body; + + if (!name || !slug || !date || !location || !region || !timezone) { + res + .status(400) + .json({ error: 'Name, slug, date, location, region, and timezone are required' }); + return; + } + + if (typeof region !== 'string' || region.length !== 2 || !/^[A-Z]{2}$/.test(region)) { + res.status(400).json({ error: 'Region must be a 2-letter ISO 3166-1 alpha-2 country code' }); + return; + } + + if (typeof timezone !== 'string' || !timezone.trim()) { + res.status(400).json({ error: 'Timezone must be a valid IANA timezone string' }); + return; + } + + if (!Array.isArray(divisions) || divisions.length === 0) { + res.status(400).json({ error: 'At least one division is required' }); + return; + } + + if (divisions.length > 1) { + for (const division of divisions) { + if (!division.name || !division.color) { + res.status(400).json({ error: 'Each division must have a name and color' }); + return; + } + } + } + + const slugPattern = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; + if (!slugPattern.test(slug)) { + res + .status(400) + .json({ error: 'Invalid slug format. Use lowercase letters, numbers, and dashes only' }); + return; + } + + const existingEvent = await db.events.bySlug(slug).get(); + if (existingEvent) { + res.status(409).json({ error: 'Event with this slug already exists' }); + return; + } + + const currentSeason = await db.seasons.getCurrent(); + if (!currentSeason) { + res + .status(400) + .json({ error: 'No current season found. A season must be active to create an event.' }); + return; + } + + // Convert the selected date from 8:00 AM to 23:59 in the event's timezone to UTC range + // Example: April 4 8:00 AM - 23:59 in Europe/Warsaw (UTC+2) becomes April 4 06:00 UTC to April 4 21:59 UTC + let startDate: Date; + let endDate: Date; + + try { + startDate = dayjs.tz(date, 'YYYY-MM-DD', timezone).hour(8).minute(0).second(0).toDate(); + endDate = dayjs.tz(date, 'YYYY-MM-DD', timezone).endOf('day').toDate(); + } catch { + res.status(400).json({ error: 'Invalid date format or timezone' }); + return; + } + + const eventResult = await db.events.create({ + name, + slug, + start_date: startDate, + end_date: endDate, + location, + region, + timezone, + season_id: currentSeason.id + }); + + if (!eventResult) { + res.status(500).json({ error: 'Failed to create event' }); + return; + } + + await db.events.byId(eventResult.id).addAdmin(req.userId); + + const divisionsResult = await db.divisions.createMany( + divisions.map(division => ({ + name: division.name, + color: division.color, + event_id: eventResult.id + })) + ); + + if (!divisionsResult || divisionsResult.length === 0) { + res.status(500).json({ error: 'Failed to create divisions' }); + return; + } + + res.status(201).json({ timezone }); + } catch (error) { + console.error('Error creating event:', error); + res.status(500).json({ error: 'Internal server error' }); + } +})); + +router.use('/:eventId', attachEvent()); + +router.use('/:eventId/divisions', eventDivisionsRouter); +router.use('/:eventId/teams', eventTeamsRouter); +router.use('/:eventId/users', eventUsersRouter); +router.use('/:eventId/settings', eventSettingsRouter); +router.use('/:eventId/integrations', eventIntegrationsRouter); + +router.get('/:eventId', asHandler(async (req, res) => { + const event = await db.events.byId(req.eventId).get(); + if (!event) { + res.status(404).json({ error: 'Event not found' }); + return; + } + res.json(makeAdminEventResponse(event)); +})); + +router.put('/:eventId', requirePermission('MANAGE_EVENTS'), asHandler(async (req, res) => { + try { + const { eventId } = req.params; + const { name, date, location, region } = req.body; + + if (!eventId || typeof eventId !== 'string') { + res.status(400).json({ error: 'EVENT_ID_REQUIRED' }); + return; + } + + const existingEvent = await db.events.byId(eventId).get(); + if (!existingEvent) { + res.status(404).json({ error: 'Event not found' }); + return; + } + + const updateData: Partial = {}; + + if (name !== undefined) { + if (!name.trim()) { + res.status(400).json({ error: 'Name cannot be empty' }); + return; + } + updateData.name = name; + } + + if (date !== undefined) { + const eventDate = new Date(date); + if (isNaN(eventDate.getTime())) { + res.status(400).json({ error: 'Invalid date format' }); + return; + } + updateData.start_date = eventDate; + updateData.end_date = eventDate; // For now, using same date for start and end + } + + if (location !== undefined) { + if (!location.trim()) { + res.status(400).json({ error: 'Location cannot be empty' }); + return; + } + updateData.location = location; + } + + if (region !== undefined) { + if (typeof region !== 'string' || region.length !== 2 || !/^[A-Z]{2}$/.test(region)) { + res + .status(400) + .json({ error: 'Region must be a 2-letter ISO 3166-1 alpha-2 country code' }); + return; + } + updateData.region = region; + } + + if (Object.keys(updateData).length === 0) { + res.status(400).json({ error: 'No valid fields to update' }); + return; + } + + const updatedEvent = await db.events.byId(eventId).update(updateData); + + if (!updatedEvent) { + res.status(500).json({ error: 'Failed to update event' }); + return; + } + + res.status(200).end(); + } catch (error) { + console.error('Error updating event:', error); + res.status(500).json({ error: 'Internal server error' }); + } +})); + +export default router; diff --git a/apps/backend/src/routers/admin/events/integrations/index.ts b/apps/backend/src/routers/admin/events/integrations/index.ts index 4a478315f..9a9597ac7 100644 --- a/apps/backend/src/routers/admin/events/integrations/index.ts +++ b/apps/backend/src/routers/admin/events/integrations/index.ts @@ -1,162 +1,163 @@ -import express from 'express'; -import { - validateIntegrationSettings, - getIntegrationConfig, - IntegrationType -} from '@lems/shared/integrations'; -import { AdminEventRequest } from '../../../../types/express'; -import { requirePermission } from '../../middleware/require-permission'; -import db from '../../../../lib/database'; -import { makeAdminIntegrationResponse, validateAndUpdateIntegration } from './util'; - -const router = express.Router({ mergeParams: true }); - -router.get('/', async (req: AdminEventRequest, res) => { - try { - const integrations = await db.integrations.byEventId(req.eventId).getAll(); - res.json(integrations.map(makeAdminIntegrationResponse)); - } catch (error) { - console.error('Error fetching integrations:', error); - res.status(500).json({ error: 'Failed to fetch integrations' }); - } -}); - -router.post('/', requirePermission('MANAGE_EVENT_DETAILS'), async (req: AdminEventRequest, res) => { - try { - const { type, settings, enabled } = req.body; - - if (!type) { - res.status(400).json({ error: 'Integration type is required' }); - return; - } - - // Validate the integration type exists - getIntegrationConfig(type); - - const validatedSettings = validateIntegrationSettings(type, settings || {}); - - const existing = await db.integrations.byType(req.eventId, type).get(); - if (existing) { - res - .status(409) - .json({ error: `Integration of type "${type}" already exists for this event` }); - return; - } - - const integration = await db.integrations.create({ - event_id: req.eventId, - integration_type: type, - enabled: enabled !== false, - settings: validatedSettings - }); - - res.status(201).json(makeAdminIntegrationResponse(integration)); - } catch (error) { - if (error instanceof Error && error.message.includes('Unknown integration type')) { - res.status(400).json({ error: error.message }); - return; - } - - if (error.message.includes('validation')) { - res.status(400).json({ error: `Invalid settings: ${error.message}` }); - return; - } - - console.error('Error creating integration:', error); - res.status(500).json({ error: 'Failed to create integration' }); - } -}); - -router.put( - '/:id', - requirePermission('MANAGE_EVENT_DETAILS'), - async (req: AdminEventRequest, res) => { - const { id: integrationId } = req.params; - if (!integrationId || typeof integrationId !== 'string') { - res.status(400).json({ error: 'INTEGRATION_ID_REQUIRED' }); - return; - } - - const integration = await db.integrations.byId(integrationId).get(); - - if (!integration) { - res.status(404).json({ error: 'Integration not found' }); - return; - } - - if (integration.event_id !== req.eventId) { - res.status(403).json({ error: 'Unauthorized' }); - return; - } - - const { enabled, settings } = req.body; - const updateData: Record = {}; - - if (typeof enabled === 'boolean') { - updateData.enabled = enabled; - } - - try { - if (settings) { - // Validate settings against the integration type's schema - const validatedSettings = validateAndUpdateIntegration( - integration.integration_type as IntegrationType, - integration.settings, - { settings } - ); - updateData.settings = validatedSettings; - } - - const updateDataTyped = updateData as Record; - if (Object.keys(updateDataTyped).length === 0) { - res.status(400).json({ error: 'No fields to update' }); - return; - } - - const updated = await db.integrations.byId(integrationId).update(updateDataTyped); - res.json(makeAdminIntegrationResponse(updated)); - } catch (error) { - if (error instanceof Error && error.message.includes('validation')) { - res.status(400).json({ error: `Invalid settings: ${error.message}` }); - return; - } - - console.error('Error updating integration:', error); - res.status(500).json({ error: 'Failed to update integration' }); - } - } -); - -router.delete( - '/:id', - requirePermission('MANAGE_EVENT_DETAILS'), - async (req: AdminEventRequest, res) => { - const { id: integrationId } = req.params; - if (!integrationId || typeof integrationId !== 'string') { - res.status(400).json({ error: 'INTEGRATION_ID_REQUIRED' }); - return; - } - - const integration = await db.integrations.byId(integrationId).get(); - - if (!integration) { - res.status(404).json({ error: 'Integration not found' }); - return; - } - - if (integration.event_id !== req.eventId) { - res.status(403).json({ error: 'Unauthorized' }); - return; - } - - try { - await db.integrations.byId(integrationId).delete(); - res.status(204).end(); - } catch (error) { - console.error('Error deleting integration:', error); - res.status(500).json({ error: 'Failed to delete integration' }); - } - } -); - -export default router; +import express from 'express'; +import { + validateIntegrationSettings, + getIntegrationConfig, + IntegrationType +} from '@lems/shared/integrations'; +import { AdminEventRequest } from '../../../../types/express'; +import { requirePermission } from '../../middleware/require-permission'; +import db from '../../../../lib/database'; +import { asHandler } from '../../../../types/express-handlers'; +import { makeAdminIntegrationResponse, validateAndUpdateIntegration } from './util'; + +const router = express.Router({ mergeParams: true }); + +router.get('/', asHandler(async (req, res) => { + try { + const integrations = await db.integrations.byEventId(req.eventId).getAll(); + res.json(integrations.map(makeAdminIntegrationResponse)); + } catch (error) { + console.error('Error fetching integrations:', error); + res.status(500).json({ error: 'Failed to fetch integrations' }); + } +})); + +router.post('/', requirePermission('MANAGE_EVENT_DETAILS'), asHandler(async (req, res) => { + try { + const { type, settings, enabled } = req.body; + + if (!type) { + res.status(400).json({ error: 'Integration type is required' }); + return; + } + + // Validate the integration type exists + getIntegrationConfig(type); + + const validatedSettings = validateIntegrationSettings(type, settings || {}); + + const existing = await db.integrations.byType(req.eventId, type).get(); + if (existing) { + res + .status(409) + .json({ error: `Integration of type "${type}" already exists for this event` }); + return; + } + + const integration = await db.integrations.create({ + event_id: req.eventId, + integration_type: type, + enabled: enabled !== false, + settings: validatedSettings + }); + + res.status(201).json(makeAdminIntegrationResponse(integration)); + } catch (error) { + if (error instanceof Error && error.message.includes('Unknown integration type')) { + res.status(400).json({ error: error.message }); + return; + } + + if (error instanceof Error && error.message.includes('validation')) { + res.status(400).json({ error: `Invalid settings: ${error.message}` }); + return; + } + + console.error('Error creating integration:', error); + res.status(500).json({ error: 'Failed to create integration' }); + } +})); + +router.put( + '/:id', + requirePermission('MANAGE_EVENT_DETAILS'), + asHandler(async (req, res) => { + const { id: integrationId } = req.params; + if (!integrationId || typeof integrationId !== 'string') { + res.status(400).json({ error: 'INTEGRATION_ID_REQUIRED' }); + return; + } + + const integration = await db.integrations.byId(integrationId).get(); + + if (!integration) { + res.status(404).json({ error: 'Integration not found' }); + return; + } + + if (integration.event_id !== req.eventId) { + res.status(403).json({ error: 'Unauthorized' }); + return; + } + + const { enabled, settings } = req.body; + const updateData: Record = {}; + + if (typeof enabled === 'boolean') { + updateData.enabled = enabled; + } + + try { + if (settings) { + // Validate settings against the integration type's schema + const validatedSettings = validateAndUpdateIntegration( + integration.integration_type as IntegrationType, + integration.settings, + { settings } + ); + updateData.settings = validatedSettings; + } + + const updateDataTyped = updateData as Record; + if (Object.keys(updateDataTyped).length === 0) { + res.status(400).json({ error: 'No fields to update' }); + return; + } + + const updated = await db.integrations.byId(integrationId).update(updateDataTyped); + res.json(makeAdminIntegrationResponse(updated)); + } catch (error) { + if (error instanceof Error && error.message.includes('validation')) { + res.status(400).json({ error: `Invalid settings: ${error.message}` }); + return; + } + + console.error('Error updating integration:', error); + res.status(500).json({ error: 'Failed to update integration' }); + } + }) +); + +router.delete( + '/:id', + requirePermission('MANAGE_EVENT_DETAILS'), + asHandler(async (req, res) => { + const { id: integrationId } = req.params; + if (!integrationId || typeof integrationId !== 'string') { + res.status(400).json({ error: 'INTEGRATION_ID_REQUIRED' }); + return; + } + + const integration = await db.integrations.byId(integrationId).get(); + + if (!integration) { + res.status(404).json({ error: 'Integration not found' }); + return; + } + + if (integration.event_id !== req.eventId) { + res.status(403).json({ error: 'Unauthorized' }); + return; + } + + try { + await db.integrations.byId(integrationId).delete(); + res.status(204).end(); + } catch (error) { + console.error('Error deleting integration:', error); + res.status(500).json({ error: 'Failed to delete integration' }); + } + }) +); + +export default router; diff --git a/apps/backend/src/routers/admin/events/settings/index.ts b/apps/backend/src/routers/admin/events/settings/index.ts index f3f4de199..a8bc3b6b9 100644 --- a/apps/backend/src/routers/admin/events/settings/index.ts +++ b/apps/backend/src/routers/admin/events/settings/index.ts @@ -11,8 +11,10 @@ import { publishEventResults } from '../../../integrations/sendgrid/publish'; import { generateEventResultsZip } from '../../../../lib/results-download'; import { createSseEmitter } from '../../../../lib/sse'; import { storeTempFile, consumeTempFile } from '../../../../lib/temp-download-store'; +import { asHandler } from '../../../../types/express-handlers'; import { makeAdminSettingsResponse, makeUpdateableEventSettings } from './util'; + const router = express.Router({ mergeParams: true }); const downloadRateLimiter = rateLimit({ @@ -29,7 +31,7 @@ const downloadFileRateLimiter = rateLimit({ legacyHeaders: false, }); -router.get('/', async (req: AdminEventRequest, res) => { +router.get('/', asHandler(async (req, res) => { const settings = await db.events.byId(req.eventId).getSettings(); if (!settings) { res.status(404).json({ error: 'Event not found' }); @@ -37,26 +39,26 @@ router.get('/', async (req: AdminEventRequest, res) => { } res.json(makeAdminSettingsResponse(settings)); -}); +})); -router.put('/', async (req: AdminEventRequest, res) => { +router.put('/', asHandler(async (req, res) => { const updateData = makeUpdateableEventSettings(req.body); const updatedSettings = await db.events.byId(req.eventId).updateSettings(updateData); if (!updatedSettings) { throw new Error('Failed to update event settings'); } res.json({ success: true }); -}); +})); -router.post('/complete', async (req: AdminEventRequest, res) => { +router.post('/complete', asHandler(async (req, res) => { const updatedSettings = await db.events.byId(req.eventId).updateSettings({ completed: true }); if (!updatedSettings) { throw new Error('Failed to complete event'); } res.json({ success: true }); -}); +})); -router.post('/publish', async (req: AdminEventRequest, res) => { +router.post('/publish', asHandler(async (req, res) => { const emitter = createSseEmitter(res); try { @@ -119,9 +121,9 @@ router.post('/publish', async (req: AdminEventRequest, res) => { console.error(`Error publishing event ${req.eventId}: ${message}`, error); emitter.sendFailure(`Failed to publish event: ${message}`); } -}); +})); -router.post('/download', downloadRateLimiter, async (req: AdminEventRequest, res) => { +router.post('/download', downloadRateLimiter, asHandler(async (req, res) => { const emitter = createSseEmitter(res); let tempPath: string | undefined; let fileStream: fs.WriteStream | undefined; @@ -209,9 +211,9 @@ router.post('/download', downloadRateLimiter, async (req: AdminEventRequest, res console.error(`Error downloading event results for event ${req.eventId}: ${message}`, error); emitter.sendFailure(`Failed to download event results: ${message}`); } -}); +})); -router.get('/download/file', downloadFileRateLimiter, (req: AdminEventRequest, res) => { +router.get('/download/file', downloadFileRateLimiter, asHandler((req, res) => { const token = req.query.token as string; if (!token) { res.status(400).json({ error: 'Missing token' }); @@ -227,6 +229,6 @@ router.get('/download/file', downloadFileRateLimiter, (req: AdminEventRequest, r res.download(entry.filePath, entry.fileName, () => { fs.unlink(entry.filePath, () => {}); }); -}); +})); export default router; diff --git a/apps/backend/src/routers/admin/events/teams/index.ts b/apps/backend/src/routers/admin/events/teams/index.ts index a0591060a..219e14d01 100644 --- a/apps/backend/src/routers/admin/events/teams/index.ts +++ b/apps/backend/src/routers/admin/events/teams/index.ts @@ -4,24 +4,25 @@ import db from '../../../../lib/database'; import { requirePermission } from '../../middleware/require-permission'; import { AdminEventRequest } from '../../../../types/express'; import { makeAdminTeamResponse, makeAdminTeamWithDivisionResponse } from '../../teams/util'; +import { asHandler } from '../../../../types/express-handlers'; import { isTeamsRegistration, parseTeamCSVRegistration } from './utils'; const router = express.Router({ mergeParams: true }); -router.get('/', async (req: AdminEventRequest, res) => { +router.get('/', asHandler(async (req, res) => { const teams = await db.events.byId(req.eventId).getRegisteredTeams(); res.json(teams.map(team => makeAdminTeamWithDivisionResponse(team))); -}); +})); -router.get('/available', async (req: AdminEventRequest, res) => { +router.get('/available', asHandler(async (req, res) => { const teams = await db.events.byId(req.eventId).getAvailableTeams(); res.json(teams.map(team => makeAdminTeamResponse(team))); -}); +})); router.post( '/register', requirePermission('MANAGE_EVENT_DETAILS'), - async (req: AdminEventRequest, res) => { + asHandler(async (req, res) => { const registration = req.body; if (!registration || !isTeamsRegistration(registration)) { @@ -32,13 +33,13 @@ router.post( await db.events.byId(req.eventId).registerTeams(registration); res.status(200).end(); - } + }) ); router.delete( '/remove', requirePermission('MANAGE_EVENT_DETAILS'), - async (req: AdminEventRequest, res) => { + asHandler(async (req, res) => { const teamsToRemove = req.body; if (!teamsToRemove || !Array.isArray(teamsToRemove)) { @@ -49,13 +50,13 @@ router.delete( await db.events.byId(req.eventId).removeTeams(teamsToRemove); res.status(200).end(); - } + }) ); router.put( '/:teamId/division', requirePermission('MANAGE_EVENT_DETAILS'), - async (req: AdminEventRequest, res) => { + asHandler(async (req, res) => { const { teamId } = req.params; const { divisionId } = req.body; @@ -76,13 +77,13 @@ router.put( console.error('Error changing team division:', error); res.status(500).json({ error: 'Failed to change team division' }); } - } + }) ); router.post( '/register-from-csv', [requirePermission('MANAGE_EVENT_DETAILS'), fileUpload()], - async (req: AdminEventRequest, res) => { + asHandler(async (req, res) => { if (!req.files || !req.files.file) { res.status(400).json({ error: 'No file uploaded' }); return; @@ -145,7 +146,7 @@ router.post( // TODO: Split DB operations into another function // console.error('Error registering teams from CSV:', error); // res.status(500).json({ error: 'Failed to register teams from CSV' }); - } + }) ); export default router; diff --git a/apps/backend/src/routers/admin/events/users/index.ts b/apps/backend/src/routers/admin/events/users/index.ts index c7df3e472..8336e977a 100644 --- a/apps/backend/src/routers/admin/events/users/index.ts +++ b/apps/backend/src/routers/admin/events/users/index.ts @@ -1,142 +1,142 @@ -import express from 'express'; -import db from '../../../../lib/database'; -import { AdminEventRequest } from '../../../../types/express'; -import { makeAdminUserResponse } from '../../users/util'; -import { requirePermission } from '../../middleware/require-permission'; -import { - makeAdminVolunteerResponse, - generateVolunteerPassword, - getRoleInfoMapping, - formatVolunteerInfo -} from './util'; - -const router = express.Router({ mergeParams: true }); - -router.get('/admins', async (req: AdminEventRequest, res) => { - const admins = await db.admins.byEventId(req.eventId).getAll(); - res.json(admins.map(admin => makeAdminUserResponse(admin))); -}); - -router.post( - '/admins', - requirePermission('MANAGE_EVENT_DETAILS'), - async (req: AdminEventRequest, res) => { - const { adminIds } = req.body; - adminIds.forEach(async id => await db.events.byId(req.eventId).addAdmin(id)); - res.status(204).end(); - } -); - -router.delete( - '/admins/:adminId', - requirePermission('MANAGE_EVENT_DETAILS'), - async (req: AdminEventRequest, res) => { - const { adminId } = req.params; - if (!adminId || typeof adminId !== 'string') { - res.status(400).json({ error: 'Admin ID is required' }); - return; - } - - if (adminId === req.userId) { - res.status(400).json({ error: 'CANNOT_REMOVE_SELF' }); - return; - } - await db.events.byId(req.eventId).removeAdmin(adminId); - res.status(204).end(); - } -); - -router.get('/volunteers', async (req: AdminEventRequest, res) => { - const volunteers = await db.eventUsers.byEventId(req.eventId).getAll(); - res.json(volunteers.map(volunteer => makeAdminVolunteerResponse(volunteer))); -}); - -router.post( - '/volunteers', - requirePermission('MANAGE_EVENT_DETAILS'), - async (req: AdminEventRequest, res) => { - const { volunteers } = req.body; - - if (!Array.isArray(volunteers) || volunteers.length === 0) { - res.status(400).json({ error: 'Volunteers array is required' }); - return; - } - - const existingVolunteers = await db.eventUsers.byEventId(req.eventId).getAll(); - for (const volunteer of existingVolunteers) { - if (volunteer.role === 'judge' || volunteer.role === 'referee') { - // Judge and referee are managed by the system - continue; - } - - await db.eventUsers.delete(volunteer.id); - } - - const volunteersToCreate = volunteers.map(volunteer => ({ - event_id: req.eventId, - role: volunteer.role, - identifier: volunteer.identifier || null, - role_info: volunteer.roleInfo || null, - password: generateVolunteerPassword() - })); - - const createdVolunteers = await db.eventUsers.createMany(volunteersToCreate); - - for (let i = 0; i < createdVolunteers.length; i++) { - const volunteer = createdVolunteers[i]; - const originalData = volunteers[i]; - - if (originalData.divisions && originalData.divisions.length > 0) { - await db.eventUsers.assignUserToDivisions(volunteer.id, originalData.divisions); - } - } - - const allDivisions = await db.divisions.byEventId(req.eventId).getAll(); - const volunteersWithDivisions = await db.eventUsers.byEventId(req.eventId).getAll(); - - const divisionsWithUsers = new Set(); - volunteersWithDivisions.forEach(volunteer => { - volunteer.divisions.forEach(divisionId => divisionsWithUsers.add(divisionId)); - }); - - for (const division of allDivisions) { - const hasUsers = divisionsWithUsers.has(division.id); - await db.divisions.byId(division.id).update({ has_users: hasUsers }); - } - - const finalVolunteersWithDivisions = await db.eventUsers.byEventId(req.eventId).getAll(); - res - .status(201) - .json(finalVolunteersWithDivisions.map(volunteer => makeAdminVolunteerResponse(volunteer))); - } -); - -router.get( - '/volunteers/passwords', - requirePermission('MANAGE_EVENT_DETAILS'), - async (req: AdminEventRequest, res) => { - const volunteers = await db.eventUsers.byEventId(req.eventId).getAll(); - const divisions = await db.divisions.byEventId(req.eventId).getAll(); - - const roleInfoMapping = await getRoleInfoMapping(divisions); - - const csvLines = ['Role,Divisions,Identifier,Role Info,Password']; - - volunteers.forEach(volunteer => { - csvLines.push(formatVolunteerInfo(volunteer, roleInfoMapping)); - }); - - // Add UTF-8 BOM - const BOM = '\ufeff'; - const csvContent = BOM + csvLines.join('\n'); - - res.setHeader( - 'Content-Disposition', - `attachment; filename="volunteer_passwords_${req.eventId}.csv"` - ); - res.setHeader('Content-Type', 'text/csv; charset=utf-8'); - res.send(csvContent); - } -); - -export default router; +import express from 'express'; +import db from '../../../../lib/database'; +import { AdminEventRequest } from '../../../../types/express'; +import { makeAdminUserResponse } from '../../users/util'; +import { requirePermission } from '../../middleware/require-permission'; import { asHandler } from '../../../../types/express-handlers'; +import { + makeAdminVolunteerResponse, + generateVolunteerPassword, + getRoleInfoMapping, + formatVolunteerInfo +} from './util'; + +const router = express.Router({ mergeParams: true }); + +router.get('/admins', asHandler(async (req, res) => { + const admins = await db.admins.byEventId(req.eventId).getAll(); + res.json(admins.map(admin => makeAdminUserResponse(admin))); +})); + +router.post( + '/admins', + requirePermission('MANAGE_EVENT_DETAILS'), + asHandler(async (req, res) => { + const { adminIds } = req.body; + adminIds.forEach(async (id: string) => await db.events.byId(req.eventId).addAdmin(id)); + res.status(204).end(); + }) +); + +router.delete( + '/admins/:adminId', + requirePermission('MANAGE_EVENT_DETAILS'), + asHandler(async (req, res) => { + const { adminId } = req.params; + if (!adminId || typeof adminId !== 'string') { + res.status(400).json({ error: 'Admin ID is required' }); + return; + } + + if (adminId === req.userId) { + res.status(400).json({ error: 'CANNOT_REMOVE_SELF' }); + return; + } + await db.events.byId(req.eventId).removeAdmin(adminId); + res.status(204).end(); + }) +); + +router.get('/volunteers', asHandler(async (req, res) => { + const volunteers = await db.eventUsers.byEventId(req.eventId).getAll(); + res.json(volunteers.map(volunteer => makeAdminVolunteerResponse(volunteer))); +})); + +router.post( + '/volunteers', + requirePermission('MANAGE_EVENT_DETAILS'), + asHandler(async (req, res) => { + const { volunteers } = req.body; + + if (!Array.isArray(volunteers) || volunteers.length === 0) { + res.status(400).json({ error: 'Volunteers array is required' }); + return; + } + + const existingVolunteers = await db.eventUsers.byEventId(req.eventId).getAll(); + for (const volunteer of existingVolunteers) { + if (volunteer.role === 'judge' || volunteer.role === 'referee') { + // Judge and referee are managed by the system + continue; + } + + await db.eventUsers.delete(volunteer.id); + } + + const volunteersToCreate = volunteers.map(volunteer => ({ + event_id: req.eventId, + role: volunteer.role, + identifier: volunteer.identifier || null, + role_info: volunteer.roleInfo || null, + password: generateVolunteerPassword() + })); + + const createdVolunteers = await db.eventUsers.createMany(volunteersToCreate); + + for (let i = 0; i < createdVolunteers.length; i++) { + const volunteer = createdVolunteers[i]; + const originalData = volunteers[i]; + + if (originalData.divisions && originalData.divisions.length > 0) { + await db.eventUsers.assignUserToDivisions(volunteer.id, originalData.divisions); + } + } + + const allDivisions = await db.divisions.byEventId(req.eventId).getAll(); + const volunteersWithDivisions = await db.eventUsers.byEventId(req.eventId).getAll(); + + const divisionsWithUsers = new Set(); + volunteersWithDivisions.forEach(volunteer => { + volunteer.divisions.forEach(divisionId => divisionsWithUsers.add(divisionId)); + }); + + for (const division of allDivisions) { + const hasUsers = divisionsWithUsers.has(division.id); + await db.divisions.byId(division.id).update({ has_users: hasUsers }); + } + + const finalVolunteersWithDivisions = await db.eventUsers.byEventId(req.eventId).getAll(); + res + .status(201) + .json(finalVolunteersWithDivisions.map(volunteer => makeAdminVolunteerResponse(volunteer))); + }) +); + +router.get( + '/volunteers/passwords', + requirePermission('MANAGE_EVENT_DETAILS'), + asHandler(async (req, res) => { + const volunteers = await db.eventUsers.byEventId(req.eventId).getAll(); + const divisions = await db.divisions.byEventId(req.eventId).getAll(); + + const roleInfoMapping = await getRoleInfoMapping(divisions); + + const csvLines = ['Role,Divisions,Identifier,Role Info,Password']; + + volunteers.forEach(volunteer => { + csvLines.push(formatVolunteerInfo(volunteer, roleInfoMapping)); + }); + + // Add UTF-8 BOM + const BOM = '\ufeff'; + const csvContent = BOM + csvLines.join('\n'); + + res.setHeader( + 'Content-Disposition', + `attachment; filename="volunteer_passwords_${req.eventId}.csv"` + ); + res.setHeader('Content-Type', 'text/csv; charset=utf-8'); + res.send(csvContent); + }) +); + +export default router; diff --git a/apps/backend/src/routers/admin/events/users/util.ts b/apps/backend/src/routers/admin/events/users/util.ts index 38d93c479..cd15241fa 100644 --- a/apps/backend/src/routers/admin/events/users/util.ts +++ b/apps/backend/src/routers/admin/events/users/util.ts @@ -65,14 +65,15 @@ export const getDivisionNamesString = (divisionIds: string[], infoMapping: Recor return divisionIds.map(id => infoMapping[id] || id).join('; '); } -const parseRoleInfo = (roleInfo: Record, infoMapping: Record): string => { - if (!roleInfo) return null; - Object.entries(roleInfo).map(([key, value]) => { +const parseRoleInfo = (roleInfo: Record | null, infoMapping: Record): string => { + if (!roleInfo) return ''; + const roleInfoCopy = { ...roleInfo }; + Object.entries(roleInfoCopy).forEach(([key, value]) => { if (typeof value === 'string' && infoMapping[value]) { - roleInfo[key] = infoMapping[value]; + roleInfoCopy[key] = infoMapping[value]; } }); - return JSON.stringify(roleInfo); + return JSON.stringify(roleInfoCopy); } export const formatVolunteerInfo = (user: EventUser & { divisions: string[] }, infoMapping: Record) : string => { diff --git a/apps/backend/src/routers/admin/middleware/attach-division.ts b/apps/backend/src/routers/admin/middleware/attach-division.ts index 9645d0738..b3499097d 100644 --- a/apps/backend/src/routers/admin/middleware/attach-division.ts +++ b/apps/backend/src/routers/admin/middleware/attach-division.ts @@ -1,6 +1,7 @@ -import { NextFunction, Response } from 'express'; import { AdminDivisionRequest, AdminEventRequest } from '../../../types/express'; import database from '../../../lib/database'; +import { asMiddleware } from '../../../types/express-handlers'; + /** * Middleware to attach the division ID to the request. @@ -8,7 +9,7 @@ import database from '../../../lib/database'; * If the division is not bound to the event preceding it, a 400 error is returned. */ export const attachDivision = () => { - return async (req: AdminEventRequest, res: Response, next: NextFunction) => { + return asMiddleware(async (req, res, next) => { try { const divisionId = req.params.divisionId; @@ -36,5 +37,5 @@ export const attachDivision = () => { console.error('Error attaching division:', error); res.status(500).json({ error: 'INTERNAL_SERVER_ERROR' }); } - }; + }); }; diff --git a/apps/backend/src/routers/admin/middleware/attach-event.ts b/apps/backend/src/routers/admin/middleware/attach-event.ts index 02a3586bf..a11e8306e 100644 --- a/apps/backend/src/routers/admin/middleware/attach-event.ts +++ b/apps/backend/src/routers/admin/middleware/attach-event.ts @@ -1,6 +1,7 @@ -import { NextFunction, Response } from 'express'; import { AdminEventRequest, AdminRequest } from '../../../types/express'; import database from '../../../lib/database'; +import { asMiddleware } from '../../../types/express-handlers'; + /** * Middleware to attach the event ID to the request. @@ -8,7 +9,7 @@ import database from '../../../lib/database'; * If the user tries to access an event they are not assigned to, a 403 error is returned. */ export const attachEvent = () => { - return async (req: AdminRequest, res: Response, next: NextFunction) => { + return asMiddleware(async (req, res, next) => { try { const eventId = req.params.eventId; if (!eventId || typeof eventId !== 'string') { @@ -37,5 +38,5 @@ export const attachEvent = () => { console.error('Error attaching event:', error); res.status(500).json({ error: 'INTERNAL_SERVER_ERROR' }); } - }; + }); }; diff --git a/apps/backend/src/routers/admin/middleware/auth.ts b/apps/backend/src/routers/admin/middleware/auth.ts index 3f074b99d..a6ee6e9d8 100644 --- a/apps/backend/src/routers/admin/middleware/auth.ts +++ b/apps/backend/src/routers/admin/middleware/auth.ts @@ -6,6 +6,10 @@ import { extractToken } from '../../../lib/security/auth'; const jwtSecret = process.env.JWT_SECRET; +if (!jwtSecret) { + throw new Error('JWT_SECRET environment variable is required'); +} + const publicPaths = new Set(['/auth/login', '/auth/logout']); export const authMiddleware = (req: Request, res: Response, next: NextFunction) => { @@ -15,7 +19,7 @@ export const authMiddleware = (req: Request, res: Response, next: NextFunction) try { const token = extractToken(req, 'admin-auth-token'); - const tokenData = jwt.verify(token, jwtSecret) as JwtTokenData; + const tokenData = jwt.verify(token, jwtSecret) as unknown as JwtTokenData; if (tokenData.userType !== 'admin') { res.clearCookie('admin-auth-token'); @@ -23,7 +27,7 @@ export const authMiddleware = (req: Request, res: Response, next: NextFunction) return; } - if (tokenData.exp > Date.now() / 1000) { + if (tokenData.exp && tokenData.exp > Date.now() / 1000) { const adminReq = req as AdminRequest; adminReq.userId = tokenData.userId; adminReq.userType = tokenData.userType; diff --git a/apps/backend/src/routers/admin/middleware/require-event-assignment.ts b/apps/backend/src/routers/admin/middleware/require-event-assignment.ts index 563eb8742..b7c896947 100644 --- a/apps/backend/src/routers/admin/middleware/require-event-assignment.ts +++ b/apps/backend/src/routers/admin/middleware/require-event-assignment.ts @@ -1,6 +1,7 @@ -import { NextFunction, Response } from 'express'; import { AdminRequest } from '../../../types/express'; import database from '../../../lib/database'; +import { asMiddleware } from '../../../types/express-handlers'; + /** * Middleware factory that creates a middleware to check if the authenticated admin @@ -14,7 +15,7 @@ export const requireEventAssignment = ( eventIdentifier: string, identifierType: 'id' | 'slug' = 'id' ) => { - return async (req: AdminRequest, res: Response, next: NextFunction) => { + return asMiddleware(async (req, res, next) => { try { let eventId: string; @@ -41,5 +42,5 @@ export const requireEventAssignment = ( console.error('Error checking event assignment:', error); res.status(500).json({ error: 'INTERNAL_SERVER_ERROR' }); } - }; + }); }; diff --git a/apps/backend/src/routers/admin/middleware/require-permission.ts b/apps/backend/src/routers/admin/middleware/require-permission.ts index 54859d1eb..307c16ea6 100644 --- a/apps/backend/src/routers/admin/middleware/require-permission.ts +++ b/apps/backend/src/routers/admin/middleware/require-permission.ts @@ -1,7 +1,8 @@ -import { NextFunction, Response } from 'express'; import { PermissionType } from '@lems/database'; import { AdminRequest } from '../../../types/express'; import db from '../../../lib/database'; +import { asMiddleware } from '../../../types/express-handlers'; + /** * Middleware factory that creates a middleware to check if the authenticated admin @@ -11,7 +12,7 @@ import db from '../../../lib/database'; * @returns Express middleware function */ export const requirePermission = (permission: PermissionType) => { - return async (req: AdminRequest, res: Response, next: NextFunction) => { + return asMiddleware(async (req, res, next) => { try { const hasPermission = await db.admins.byId(req.userId).hasPermission(permission); @@ -25,5 +26,5 @@ export const requirePermission = (permission: PermissionType) => { console.error('Error checking permission:', error); res.status(500).json({ error: 'INTERNAL_SERVER_ERROR' }); } - }; + }); }; diff --git a/apps/backend/src/routers/admin/seasons/index.ts b/apps/backend/src/routers/admin/seasons/index.ts index cece2a6f9..2ed88dcaf 100644 --- a/apps/backend/src/routers/admin/seasons/index.ts +++ b/apps/backend/src/routers/admin/seasons/index.ts @@ -3,6 +3,7 @@ import fileUpload from 'express-fileupload'; import db from '../../../lib/database'; import { requirePermission } from '../middleware/require-permission'; import { AdminRequest } from '../../../types/express'; +import { asHandler } from '../../../types/express-handlers'; import { makeAdminSeasonResponse } from './util'; const router = express.Router({ mergeParams: true }); @@ -11,7 +12,7 @@ router.post( '/', requirePermission('MANAGE_SEASONS'), fileUpload(), - async (req: AdminRequest, res) => { + asHandler(async (req, res) => { const { slug, name, startDate, endDate } = req.body; if (!slug || !name || !startDate || !endDate) { @@ -49,24 +50,24 @@ router.post( } res.status(201).json(makeAdminSeasonResponse(season)); - } + }) ); -router.get('/', async (req: AdminRequest, res) => { +router.get('/', asHandler(async (req, res) => { const seasons = await db.seasons.getAll(); res.status(200).json(seasons.map(makeAdminSeasonResponse)); -}); +})); -router.get('/current', async (req: AdminRequest, res) => { +router.get('/current', asHandler(async (req, res) => { const currentSeason = await db.seasons.getCurrent(); if (!currentSeason) { res.status(404).json({ error: 'No current season found' }); return; } res.status(200).json(makeAdminSeasonResponse(currentSeason)); -}); +})); -router.get('/id/:id', async (req: AdminRequest, res) => { +router.get('/id/:id', asHandler(async (req, res) => { const { id } = req.params; if (!id || typeof id !== 'string') { res.status(400).json({ error: 'Invalid season ID' }); @@ -79,9 +80,9 @@ router.get('/id/:id', async (req: AdminRequest, res) => { return; } res.status(200).json(makeAdminSeasonResponse(season)); -}); +})); -router.get('/:slug', async (req: AdminRequest, res) => { +router.get('/:slug', asHandler(async (req, res) => { const { slug } = req.params; if (!slug || typeof slug !== 'string') { @@ -97,6 +98,6 @@ router.get('/:slug', async (req: AdminRequest, res) => { } res.status(200).json(makeAdminSeasonResponse(season)); -}); +})); export default router; diff --git a/apps/backend/src/routers/admin/teams/index.ts b/apps/backend/src/routers/admin/teams/index.ts index bb2230e4e..f5013dbd9 100644 --- a/apps/backend/src/routers/admin/teams/index.ts +++ b/apps/backend/src/routers/admin/teams/index.ts @@ -4,12 +4,14 @@ import { ensureArray } from '@lems/shared/utils'; import db from '../../../lib/database'; import { AdminRequest } from '../../../types/express'; import { requirePermission } from '../middleware/require-permission'; +import { asHandler } from '../../../types/express-handlers'; import { makeAdminTeamResponse, parseTeamList } from './util'; + const router = express.Router({ mergeParams: true }); const FILE_SIZE_LIMIT = 2 * 1024 * 1024; // 2 MB -router.get('/', async (req: AdminRequest, res) => { +router.get('/', asHandler(async (req, res) => { let teams = await db.teams.getAllWithActiveStatus(); const extraFields = ensureArray(req.query.extraFields).map(field => field.toLowerCase()); @@ -28,9 +30,9 @@ router.get('/', async (req: AdminRequest, res) => { const response = teams.map(team => makeAdminTeamResponse(team)); res.json(response); -}); +})); -router.delete('/:teamId', requirePermission('MANAGE_TEAMS'), async (req: AdminRequest, res) => { +router.delete('/:teamId', requirePermission('MANAGE_TEAMS'), asHandler(async (req, res) => { const teamId = req.params.teamId; if (!teamId || typeof teamId !== 'string') { res.status(400).json({ error: 'Team ID is required' }); @@ -57,9 +59,9 @@ router.delete('/:teamId', requirePermission('MANAGE_TEAMS'), async (req: AdminRe } res.status(200).end(); -}); +})); -router.get('/:teamId', async (req: AdminRequest, res) => { +router.get('/:teamId', asHandler(async (req, res) => { const id = req.params.teamId; if (!id || typeof id !== 'string') { res.status(400).json({ error: 'Team ID is required' }); @@ -72,13 +74,13 @@ router.get('/:teamId', async (req: AdminRequest, res) => { return; } res.json(makeAdminTeamResponse(team)); -}); +})); router.put( '/:teamId', requirePermission('MANAGE_TEAMS'), fileUpload(), - async (req: AdminRequest, res) => { + asHandler(async (req, res) => { const { name, affiliation, city } = req.body; if (!name || !affiliation || !city) { @@ -99,6 +101,11 @@ router.put( city }); + if (!team) { + res.status(404).json({ error: 'Team not found' }); + return; + } + if (req.files && req.files.logo) { const logoFile = req.files.logo as fileUpload.UploadedFile; @@ -118,7 +125,12 @@ router.put( } try { - team = await db.teams.byId(team.id).updateLogo(logoFile.data); + const updatedTeam = await db.teams.byId(team.id).updateLogo(logoFile.data); + if (!updatedTeam) { + res.status(500).json({ error: 'Failed to upload team logo' }); + return; + } + team = updatedTeam; } catch (error) { console.error('Error uploading team logo:', error); res.status(500).json({ error: 'Failed to upload team logo' }); @@ -136,14 +148,14 @@ router.put( console.error('Error updating team:', error); res.status(500).json({ error: 'Failed to update team' }); } - } + }) ); router.post( '/', requirePermission('MANAGE_TEAMS'), fileUpload(), - async (req: AdminRequest, res) => { + asHandler(async (req, res) => { const { name, number, affiliation, city, region } = req.body; if (!name || !number || !affiliation || !city || !region) { @@ -168,6 +180,11 @@ router.post( logo_url: null }); + if (!team) { + res.status(500).json({ error: 'Failed to create team' }); + return; + } + if (req.files && req.files.logo) { const logoFile = req.files.logo as fileUpload.UploadedFile; @@ -187,7 +204,12 @@ router.post( } try { - team = await db.teams.byId(team.id).updateLogo(logoFile.data); + const updatedTeam = await db.teams.byId(team.id).updateLogo(logoFile.data); + if (!updatedTeam) { + res.status(500).json({ error: 'Failed to upload team logo' }); + return; + } + team = updatedTeam; } catch (error) { console.error('Error uploading team logo:', error); res.status(500).json({ error: 'Failed to upload team logo' }); @@ -209,13 +231,13 @@ router.post( res.status(500).json({ error: 'Failed to create team' }); } } - } + }) ); router.post( '/import', [requirePermission('MANAGE_TEAMS'), fileUpload()], - async (req: AdminRequest, res) => { + asHandler(async (req, res) => { if (!req.files || !req.files.file) { res.status(400).json({ error: 'No file uploaded' }); return; @@ -239,7 +261,7 @@ router.post( console.error('Error importing teams:', error); res.status(500).json({ error: 'Failed to import teams' }); } - } + }) ); export default router; diff --git a/apps/backend/src/routers/admin/users/index.ts b/apps/backend/src/routers/admin/users/index.ts index 8c91b8ebb..c712fa7a4 100644 --- a/apps/backend/src/routers/admin/users/index.ts +++ b/apps/backend/src/routers/admin/users/index.ts @@ -3,6 +3,7 @@ import db from '../../../lib/database'; import { requirePermission } from '../middleware/require-permission'; import { hashPassword } from '../../../lib/security/credentials'; import { AdminRequest } from '../../../types/express'; +import { asHandler } from '../../../types/express-handlers'; import { makeAdminUserResponse } from './util'; import registrationRouter from './register'; import permissionsRouter from './permissions'; @@ -12,12 +13,12 @@ const router = express.Router({ mergeParams: true }); router.use('/register', registrationRouter); router.use('/permissions', permissionsRouter); -router.get('/', async (req: AdminRequest, res) => { +router.get('/', asHandler(async (req, res) => { const users = await db.admins.getAll(); res.json(users.map(user => makeAdminUserResponse(user))); -}); +})); -router.get('/me', async (req: AdminRequest, res) => { +router.get('/me', asHandler(async (req, res) => { const user = await db.admins.byId(req.userId).get(); if (!user) { @@ -26,9 +27,9 @@ router.get('/me', async (req: AdminRequest, res) => { } res.json(makeAdminUserResponse(user)); -}); +})); -router.get('/:userId', async (req: AdminRequest, res) => { +router.get('/:userId', asHandler(async (req, res) => { const userId = req.params.userId; if (!userId || typeof userId !== 'string') { res.status(400).json({ error: 'User ID is required' }); @@ -42,9 +43,9 @@ router.get('/:userId', async (req: AdminRequest, res) => { } res.json(makeAdminUserResponse(user)); -}); +})); -router.patch('/:userId', requirePermission('MANAGE_USERS'), async (req: AdminRequest, res) => { +router.patch('/:userId', requirePermission('MANAGE_USERS'), asHandler(async (req, res) => { const userId = req.params.userId; const { firstName, lastName } = req.body; @@ -77,12 +78,12 @@ router.patch('/:userId', requirePermission('MANAGE_USERS'), async (req: AdminReq console.error('Error updating user:', error); res.status(500).json({ error: 'Failed to update user' }); } -}); +})); router.patch( '/:userId/password', requirePermission('MANAGE_USERS'), - async (req: AdminRequest, res) => { + asHandler(async (req, res) => { const userId = req.params.userId; const { password } = req.body; @@ -111,10 +112,10 @@ router.patch( console.error('Error updating password:', error); res.status(500).json({ error: 'Failed to update password' }); } - } + }) ); -router.delete('/:userId', requirePermission('MANAGE_USERS'), async (req: AdminRequest, res) => { +router.delete('/:userId', requirePermission('MANAGE_USERS'), asHandler(async (req, res) => { const userId = req.params.userId; if (!userId || typeof userId !== 'string') { @@ -149,6 +150,6 @@ router.delete('/:userId', requirePermission('MANAGE_USERS'), async (req: AdminRe console.error('Error deleting user:', error); res.status(500).json({ error: 'Failed to delete user' }); } -}); +})); export default router; diff --git a/apps/backend/src/routers/admin/users/permissions.ts b/apps/backend/src/routers/admin/users/permissions.ts index 872467eb9..44ad2a8df 100644 --- a/apps/backend/src/routers/admin/users/permissions.ts +++ b/apps/backend/src/routers/admin/users/permissions.ts @@ -1,73 +1,74 @@ -import express from 'express'; -import { ALL_ADMIN_PERMISSIONS } from '@lems/types/api/admin'; -import db from '../../../lib/database'; -import { AdminRequest } from '../../../types/express'; -import { requirePermission } from '../middleware/require-permission'; +import express from 'express'; +import { ALL_ADMIN_PERMISSIONS } from '@lems/types/api/admin'; +import db from '../../../lib/database'; +import { AdminRequest } from '../../../types/express'; +import { requirePermission } from '../middleware/require-permission'; import { asHandler } from '../../../types/express-handlers'; -const router = express.Router({ mergeParams: true }); - -router.get('/me', async (req: AdminRequest, res) => { - const permissions = await db.admins.byId(req.userId).getPermissions(); - - if (!permissions) { - res.status(404).json({ error: 'Permissions not found' }); - return; - } - - res.json(permissions); -}); - -router.get('/:userId', async (req: AdminRequest, res) => { - const userId = req.params.userId; - if (!userId || typeof userId !== 'string') { - res.status(400).json({ error: 'User ID is required' }); - return; - } - - const permissions = await db.admins.byId(userId).getPermissions(); - if (!permissions) { - res.status(404).json({ error: 'Permissions not found for this user' }); - return; - } - - res.json(permissions); -}); - -router.put('/:userId', requirePermission('MANAGE_USERS'), async (req: AdminRequest, res) => { - const userId = req.params.userId; - if (!userId || typeof userId !== 'string') { - res.status(400).json({ error: 'User ID is required' }); - return; - } - - const { permissions } = req.body; - if (!Array.isArray(permissions)) { - res.status(400).json({ error: 'Permissions must be an array' }); - return; - } - - const invalidPermissions = permissions.filter(p => !ALL_ADMIN_PERMISSIONS.includes(p)); - if (invalidPermissions.length > 0) { - res.status(400).json({ - error: 'Invalid permissions', - invalidPermissions - }); - return; - } - - try { - const user = await db.admins.byId(userId).get(); - if (!user) { - res.status(404).json({ error: 'User not found' }); - return; - } - - const updatedPermissions = await db.admins.byId(userId).updatePermissions(permissions); - res.json(updatedPermissions); - } catch (error) { - console.error('Error updating permissions:', error); - res.status(500).json({ error: 'Internal server error' }); - } -}); - -export default router; + +const router = express.Router({ mergeParams: true }); + +router.get('/me', asHandler(async (req, res) => { + const permissions = await db.admins.byId(req.userId).getPermissions(); + + if (!permissions) { + res.status(404).json({ error: 'Permissions not found' }); + return; + } + + res.json(permissions); +})); + +router.get('/:userId', asHandler(async (req, res) => { + const userId = req.params.userId; + if (!userId || typeof userId !== 'string') { + res.status(400).json({ error: 'User ID is required' }); + return; + } + + const permissions = await db.admins.byId(userId).getPermissions(); + if (!permissions) { + res.status(404).json({ error: 'Permissions not found for this user' }); + return; + } + + res.json(permissions); +})); + +router.put('/:userId', requirePermission('MANAGE_USERS'), asHandler(async (req, res) => { + const userId = req.params.userId; + if (!userId || typeof userId !== 'string') { + res.status(400).json({ error: 'User ID is required' }); + return; + } + + const { permissions } = req.body; + if (!Array.isArray(permissions)) { + res.status(400).json({ error: 'Permissions must be an array' }); + return; + } + + const invalidPermissions = permissions.filter(p => !ALL_ADMIN_PERMISSIONS.includes(p)); + if (invalidPermissions.length > 0) { + res.status(400).json({ + error: 'Invalid permissions', + invalidPermissions + }); + return; + } + + try { + const user = await db.admins.byId(userId).get(); + if (!user) { + res.status(404).json({ error: 'User not found' }); + return; + } + + const updatedPermissions = await db.admins.byId(userId).updatePermissions(permissions); + res.json(updatedPermissions); + } catch (error) { + console.error('Error updating permissions:', error); + res.status(500).json({ error: 'Internal server error' }); + } +})); + +export default router; diff --git a/apps/backend/src/routers/admin/users/register.ts b/apps/backend/src/routers/admin/users/register.ts index 3f44641be..831501647 100644 --- a/apps/backend/src/routers/admin/users/register.ts +++ b/apps/backend/src/routers/admin/users/register.ts @@ -7,8 +7,10 @@ import { } from '../../../lib/security/credentials'; import { AdminRequest } from '../../../types/express'; import { requirePermission } from '../middleware/require-permission'; +import { asHandler } from '../../../types/express-handlers'; import { makeAdminUserResponse } from './util'; + const router = express.Router({ mergeParams: true }); class RegistrationError extends Error { @@ -23,7 +25,7 @@ class RegistrationError extends Error { } } -router.post('/', requirePermission('MANAGE_USERS'), async (req: AdminRequest, res) => { +router.post('/', requirePermission('MANAGE_USERS'), asHandler(async (req, res) => { try { const { username, password, firstName, lastName } = req.body; @@ -33,12 +35,12 @@ router.post('/', requirePermission('MANAGE_USERS'), async (req: AdminRequest, re const usernameValidation = validateUsername(username); if (!usernameValidation.isValid) { - throw new RegistrationError(400, 'Invalid username', usernameValidation.error); + throw new RegistrationError(400, 'Invalid username', usernameValidation.error ?? 'invalid-username'); } const passwordValidation = validatePassword(password); if (!passwordValidation.isValid) { - throw new RegistrationError(400, 'Invalid password', passwordValidation.error); + throw new RegistrationError(400, 'Invalid password', passwordValidation.error ?? 'invalid-password'); } if (firstName.length > 64 || lastName.length > 64) { @@ -77,6 +79,6 @@ router.post('/', requirePermission('MANAGE_USERS'), async (req: AdminRequest, re details: 'An error occurred while creating the user' }); } -}); +})); export default router; diff --git a/apps/backend/src/routers/integrations/first-israel-dashboard/index.ts b/apps/backend/src/routers/integrations/first-israel-dashboard/index.ts index 533be2a5f..124461f99 100644 --- a/apps/backend/src/routers/integrations/first-israel-dashboard/index.ts +++ b/apps/backend/src/routers/integrations/first-israel-dashboard/index.ts @@ -3,6 +3,7 @@ import fileUpload, { UploadedFile } from 'express-fileupload'; import sharp from 'sharp'; import db from '../../../lib/database'; import { FirstIsraelDashboardRequest } from '../../../types/express'; +import { asHandler } from '../../../types/express-handlers'; import { teamLogoFileValidator, firstIsraelDashboardTeamMiddleware } from './middleware'; import eventRouter from './team/event'; @@ -14,7 +15,7 @@ router.post( '/team/logo', fileUpload({ limits: { fileSize: 200 * 1024, files: 1 } }), teamLogoFileValidator, - async (req: FirstIsraelDashboardRequest, res: Response) => { + asHandler(async (req, res: Response) => { if (!req.files || !req.files.file) { res.status(400).json({ error: 'NO_FILE_UPLOADED' }); return; @@ -37,7 +38,7 @@ router.post( } res.status(200).end(); - } + }) ); router.use('/team/event', eventRouter); diff --git a/apps/backend/src/routers/integrations/first-israel-dashboard/middleware/event.ts b/apps/backend/src/routers/integrations/first-israel-dashboard/middleware/event.ts index b77af2fc5..170b673a9 100644 --- a/apps/backend/src/routers/integrations/first-israel-dashboard/middleware/event.ts +++ b/apps/backend/src/routers/integrations/first-israel-dashboard/middleware/event.ts @@ -7,6 +7,10 @@ import { FirstIsraelDashboardEventRequest } from '../../../../types/express'; const firstIsraelDashboardSecret = process.env.FIRST_ISRAEL_DASHBOARD_SECRET; +if (!firstIsraelDashboardSecret) { + throw new Error('FIRST_ISRAEL_DASHBOARD_SECRET environment variable is required'); +} + export const firstIsraelDashboardEventMiddleware = async ( req: Request, res: Response, @@ -17,7 +21,7 @@ export const firstIsraelDashboardEventMiddleware = async ( const tokenData = jwt.verify( token, firstIsraelDashboardSecret - ) as FirstIsraelDashboardTokenDataWithEvent; + ) as unknown as FirstIsraelDashboardTokenDataWithEvent; const eventIntegration = await db.integrations.bySettings({ sfid: tokenData.eventSfid }).get(); if (!eventIntegration) throw new Error('Event integration not found'); diff --git a/apps/backend/src/routers/integrations/first-israel-dashboard/middleware/team.ts b/apps/backend/src/routers/integrations/first-israel-dashboard/middleware/team.ts index 60814bf9f..27c8bb109 100644 --- a/apps/backend/src/routers/integrations/first-israel-dashboard/middleware/team.ts +++ b/apps/backend/src/routers/integrations/first-israel-dashboard/middleware/team.ts @@ -7,6 +7,10 @@ import { FirstIsraelDashboardEventRequest } from '../../../../types/express'; const firstIsraelDashboardSecret = process.env.FIRST_ISRAEL_DASHBOARD_SECRET; +if (!firstIsraelDashboardSecret) { + throw new Error('FIRST_ISRAEL_DASHBOARD_SECRET environment variable is required'); +} + export const firstIsraelDashboardTeamMiddleware = async ( req: Request, res: Response, @@ -17,7 +21,7 @@ export const firstIsraelDashboardTeamMiddleware = async ( const tokenData = jwt.verify( token, firstIsraelDashboardSecret - ) as FirstIsraelDashboardTokenData; + ) as unknown as FirstIsraelDashboardTokenData; const team = await db.teams.bySlug(tokenData.teamSlug).get(); if (!team) throw new Error('Team not found'); diff --git a/apps/backend/src/routers/integrations/first-israel-dashboard/team/event/export/index.ts b/apps/backend/src/routers/integrations/first-israel-dashboard/team/event/export/index.ts index 17d182dcd..8b300d678 100644 --- a/apps/backend/src/routers/integrations/first-israel-dashboard/team/event/export/index.ts +++ b/apps/backend/src/routers/integrations/first-israel-dashboard/team/event/export/index.ts @@ -1,60 +1,61 @@ -import express, { Response } from 'express'; -import { FirstIsraelDashboardEventRequest } from '../../../../../../types/express'; -import { getLemsWebpageAsPdf } from '../../../../export'; +import express, { Response } from 'express'; +import { FirstIsraelDashboardEventRequest } from '../../../../../../types/express'; +import { getLemsWebpageAsPdf } from '../../../../export'; import { asHandler } from '../../../../../../types/express-handlers'; -const router = express.Router({ mergeParams: true }); - -router.get('/rubrics', async (req: FirstIsraelDashboardEventRequest, res: Response) => { - try { - const pdf = await getLemsWebpageAsPdf( - `/he/lems/export/${req.teamSlug}/${req.eventSlug}/rubrics`, - { - teamSlug: req.teamSlug, - divsionId: req.divisionId - } - ); - - res.contentType('application/pdf'); - res.send(pdf); - } catch (error) { - console.error( - `[Export] Failed to generate rubrics PDF for team ${req.teamSlug} event ${req.eventSlug}:`, - error instanceof Error ? error.message : String(error) - ); - res.status(500).json({ - error: 'Failed to generate rubrics export PDF', - details: - error instanceof Error - ? error.message - : 'Unknown error occurred. Check that all rubrics are approved and data is complete.' - }); - } -}); - -router.get('/scoresheets', async (req: FirstIsraelDashboardEventRequest, res: Response) => { - try { - const pdf = await getLemsWebpageAsPdf( - `/he/lems/export/${req.teamSlug}/${req.eventSlug}/scoresheets`, - { - teamSlug: req.teamSlug, - divsionId: req.divisionId - } - ); - - res.contentType('application/pdf'); - res.send(pdf); - } catch (error) { - console.error( - `[Export] Failed to generate scoresheets PDF for team ${req.teamSlug} event ${req.eventSlug}:`, - error instanceof Error ? error.message : String(error) - ); - res.status(500).json({ - error: 'Failed to generate scoresheets export PDF', - details: - error instanceof Error - ? error.message - : 'Unknown error occurred. Check that all scoresheets are submitted and data is complete.' - }); - } -}); -export default router; + +const router = express.Router({ mergeParams: true }); + +router.get('/rubrics', asHandler(async (req, res: Response) => { + try { + const pdf = await getLemsWebpageAsPdf( + `/he/lems/export/${req.teamSlug}/${req.eventSlug}/rubrics`, + { + teamSlug: req.teamSlug, + divsionId: req.divisionId + } + ); + + res.contentType('application/pdf'); + res.send(pdf); + } catch (error) { + console.error( + `[Export] Failed to generate rubrics PDF for team ${req.teamSlug} event ${req.eventSlug}:`, + error instanceof Error ? error.message : String(error) + ); + res.status(500).json({ + error: 'Failed to generate rubrics export PDF', + details: + error instanceof Error + ? error.message + : 'Unknown error occurred. Check that all rubrics are approved and data is complete.' + }); + } +})); + +router.get('/scoresheets', asHandler(async (req, res: Response) => { + try { + const pdf = await getLemsWebpageAsPdf( + `/he/lems/export/${req.teamSlug}/${req.eventSlug}/scoresheets`, + { + teamSlug: req.teamSlug, + divsionId: req.divisionId + } + ); + + res.contentType('application/pdf'); + res.send(pdf); + } catch (error) { + console.error( + `[Export] Failed to generate scoresheets PDF for team ${req.teamSlug} event ${req.eventSlug}:`, + error instanceof Error ? error.message : String(error) + ); + res.status(500).json({ + error: 'Failed to generate scoresheets export PDF', + details: + error instanceof Error + ? error.message + : 'Unknown error occurred. Check that all scoresheets are submitted and data is complete.' + }); + } +})); +export default router; diff --git a/apps/backend/src/routers/integrations/first-israel-dashboard/team/event/index.ts b/apps/backend/src/routers/integrations/first-israel-dashboard/team/event/index.ts index c477343a6..86a1553c1 100644 --- a/apps/backend/src/routers/integrations/first-israel-dashboard/team/event/index.ts +++ b/apps/backend/src/routers/integrations/first-israel-dashboard/team/event/index.ts @@ -1,72 +1,73 @@ -import express, { Response } from 'express'; -import { randomAlphanumericString } from '@lems/shared/utils'; -import fileUpload, { UploadedFile } from 'express-fileupload'; -import { firstIsraelDashboardEventMiddleware, teamDocumentFileValidator } from '../../middleware'; -import db from '../../../../../lib/database'; -import { uploadFile } from '../../../../../lib/blob-storage/upload'; -import { FirstIsraelDashboardEventRequest } from '../../../../../types/express'; -import exportRouter from './export'; - -const router = express.Router({ mergeParams: true }); - -router.use('/', firstIsraelDashboardEventMiddleware); - -router.post( - '/team-info', - fileUpload({ limits: { fileSize: 15 * 1024 * 1024, files: 1 } }), - teamDocumentFileValidator, - async (req: FirstIsraelDashboardEventRequest, res: Response) => { - try { - const team = await db.teams.bySlug(req.teamSlug).get(); - if (!team) { - res.status(400).json({ error: 'TEAM_NOT_FOUND' }); - return; - } - - const teamDivision = await db.raw.sql - .selectFrom('team_divisions') - .select('profile_document_url') - .where('team_id', '=', team.id) - .where('division_id', '=', req.divisionId) - .executeTakeFirst(); - - if (!teamDivision) { - res.status(400).json({ error: 'TEAM_DIVISION_NOT_FOUND' }); - return; - } - - const documentFile = req.files.file as UploadedFile; - if (!documentFile) { - res.status(400).json({ error: 'NO_FILE_UPLOADED' }); - return; - } - - const fileName = teamDivision.profile_document_url - ? teamDivision.profile_document_url.substring( - teamDivision.profile_document_url.lastIndexOf('/') + 1 - ) - : `${req.teamSlug}-${randomAlphanumericString(12)}`; - const key = `events/${req.divisionId}/teams/${req.teamSlug}/${fileName}.pdf`; - const path = await uploadFile(documentFile.data, key); - const url = `https://${process.env.DIGITALOCEAN_SPACE}.${process.env.DIGITALOCEAN_ENDPOINT}/${key}`; - console.log('Successfully uploaded object: ' + path); - - await db.raw.sql - .updateTable('team_divisions') - .set({ profile_document_url: url }) - .where('team_id', '=', team.id) - .where('division_id', '=', req.divisionId) - .execute(); - - res.status(200).end(); - } catch (error) { - console.error('Error updating team document:', error); - res.status(500).json({ error: 'SERVER_ERROR' }); - return; - } - } -); - -router.use('/export', exportRouter); - -export default router; +import express, { Response } from 'express'; +import { randomAlphanumericString } from '@lems/shared/utils'; +import fileUpload, { UploadedFile } from 'express-fileupload'; +import { firstIsraelDashboardEventMiddleware, teamDocumentFileValidator } from '../../middleware'; +import db from '../../../../../lib/database'; +import { uploadFile } from '../../../../../lib/blob-storage/upload'; +import { FirstIsraelDashboardEventRequest } from '../../../../../types/express'; +import { asHandler } from '../../../../../types/express-handlers'; +import exportRouter from './export'; + +const router = express.Router({ mergeParams: true }); + +router.use('/', firstIsraelDashboardEventMiddleware); + +router.post( + '/team-info', + fileUpload({ limits: { fileSize: 15 * 1024 * 1024, files: 1 } }), + teamDocumentFileValidator, + asHandler(async (req, res: Response) => { + try { + const team = await db.teams.bySlug(req.teamSlug).get(); + if (!team) { + res.status(400).json({ error: 'TEAM_NOT_FOUND' }); + return; + } + + const teamDivision = await db.raw.sql + .selectFrom('team_divisions') + .select('profile_document_url') + .where('team_id', '=', team.id) + .where('division_id', '=', req.divisionId) + .executeTakeFirst(); + + if (!teamDivision) { + res.status(400).json({ error: 'TEAM_DIVISION_NOT_FOUND' }); + return; + } + + const documentFile = req.files?.file as UploadedFile | undefined; + if (!documentFile) { + res.status(400).json({ error: 'NO_FILE_UPLOADED' }); + return; + } + + const fileName = teamDivision.profile_document_url + ? teamDivision.profile_document_url.substring( + teamDivision.profile_document_url.lastIndexOf('/') + 1 + ) + : `${req.teamSlug}-${randomAlphanumericString(12)}`; + const key = `events/${req.divisionId}/teams/${req.teamSlug}/${fileName}.pdf`; + const path = await uploadFile(documentFile.data, key); + const url = `https://${process.env.DIGITALOCEAN_SPACE}.${process.env.DIGITALOCEAN_ENDPOINT}/${key}`; + console.log('Successfully uploaded object: ' + path); + + await db.raw.sql + .updateTable('team_divisions') + .set({ profile_document_url: url }) + .where('team_id', '=', team.id) + .where('division_id', '=', req.divisionId) + .execute(); + + res.status(200).end(); + } catch (error) { + console.error('Error updating team document:', error); + res.status(500).json({ error: 'SERVER_ERROR' }); + return; + } + }) +); + +router.use('/export', exportRouter); + +export default router; diff --git a/apps/backend/src/routers/integrations/sendgrid/index.ts b/apps/backend/src/routers/integrations/sendgrid/index.ts index 9c679bfc5..108334783 100644 --- a/apps/backend/src/routers/integrations/sendgrid/index.ts +++ b/apps/backend/src/routers/integrations/sendgrid/index.ts @@ -5,6 +5,7 @@ import { requirePermission } from '../../../routers/admin/middleware/require-per import { attachEvent } from '../../../routers/admin/middleware/attach-event'; import { authMiddleware as adminAuth } from '../../../routers/admin/middleware/auth'; import db from '../../../lib/database'; +import { asHandler } from '../../../types/express-handlers'; import { sendEmailWithSendGrid } from './sendgrid-lib'; import { generatePlaceholderPDF } from './placeholder-generator'; import { CSVRecord } from './types'; @@ -33,7 +34,7 @@ router.use( requirePermission('MANAGE_EVENT_DETAILS') ); -router.post('/:eventId/upload-contacts', async (req: AdminEventRequest, res) => { +router.post('/:eventId/upload-contacts', asHandler(async (req, res) => { try { const { csvContent } = req.body; if (!csvContent) { @@ -106,9 +107,9 @@ router.post('/:eventId/upload-contacts', async (req: AdminEventRequest, res) => console.error('Error uploading contacts:', error); res.status(500).json({ error: 'Failed to process CSV file' }); } -}); +})); -router.delete('/:eventId/contacts/:teamNumber', async (req: AdminEventRequest, res) => { +router.delete('/:eventId/contacts/:teamNumber', asHandler(async (req, res) => { try { const { teamNumber } = req.params; const teamNum = parseInt(String(teamNumber), 10); @@ -142,9 +143,9 @@ router.delete('/:eventId/contacts/:teamNumber', async (req: AdminEventRequest, r console.error('Error deleting contact:', error); res.status(500).json({ error: 'Failed to delete contact' }); } -}); +})); -router.post('/:eventId/send-test', async (req: AdminEventRequest, res) => { +router.post('/:eventId/send-test', asHandler(async (req, res) => { try { const { templateId, fromAddress, testEmailAddress } = req.body; @@ -192,6 +193,6 @@ router.post('/:eventId/send-test', async (req: AdminEventRequest, res) => { .status(500) .json({ error: error instanceof Error ? error.message : 'Failed to send test email' }); } -}); +})); export default router; diff --git a/apps/backend/src/routers/lems/auth/index.ts b/apps/backend/src/routers/lems/auth/index.ts index 5721f1835..4229aac59 100644 --- a/apps/backend/src/routers/lems/auth/index.ts +++ b/apps/backend/src/routers/lems/auth/index.ts @@ -32,8 +32,12 @@ router.post('/login', loginRateLimiter, async (req: Request, res: Response, next const { captchaToken, ...loginDetails }: LoginRequest = req.body; if (process.env.RECAPTCHA === 'true') { + if (!captchaToken) { + res.status(400).json({ error: 'CAPTCHA_REQUIRED' }); + return; + } const captcha: RecaptchaResponse = await getRecaptchaResponse(captchaToken); - if (!captcha.success || captcha['error-codes']?.length > 0) { + if (!captcha.success || (captcha['error-codes']?.length ?? 0) > 0) { logger.warn( { component: 'auth', diff --git a/apps/backend/src/routers/lems/export/index.ts b/apps/backend/src/routers/lems/export/index.ts index 4752fa51c..bb4d05ad3 100644 --- a/apps/backend/src/routers/lems/export/index.ts +++ b/apps/backend/src/routers/lems/export/index.ts @@ -1,220 +1,221 @@ -import express, { NextFunction, Request, Response } from 'express'; -import jwt from 'jsonwebtoken'; -import { ScoresheetClauseValue } from '@lems/shared/scoresheet'; -import { extractToken } from '../../../lib/security/auth'; -import { logger } from '../../../lib/logger'; -import db from '../../../lib/database'; -import { ExportRequest } from '../../../types/express'; - -const router = express.Router({ mergeParams: true }); - -const integrationsJwtSecret = process.env.INTEGRATIONS_LEMS_JWT as string; - -router.use('/:teamSlug/:eventSlug', async (req: Request, res: Response, next: NextFunction) => { - const { teamSlug, eventSlug } = req.params; - - if (!teamSlug || typeof teamSlug !== 'string') { - res.status(400).json({ error: 'Invalid team slug' }); - return; - } - - if (!eventSlug || typeof eventSlug !== 'string') { - res.status(400).json({ error: 'Invalid event slug' }); - return; - } - - try { - const token = extractToken(req); - const tokenData = jwt.verify(token, integrationsJwtSecret) as { - teamSlug: string; - divisionId: string; - }; - - // Validate team and event from token - const team = await db.teams.bySlug(teamSlug).get(); - if (!team) { - throw new Error('Team not found'); - } - if (teamSlug !== tokenData.teamSlug) { - throw new Error('Invalid team slug'); - } - - const event = await db.events.bySlug(eventSlug).get(); - if (!event) { - throw new Error('Event not found'); - } - if (event.slug !== eventSlug) { - throw new Error('Invalid event slug'); - } - - const teamDivision = await db.teams.bySlug(teamSlug).isInEvent(event.id); - if (!teamDivision) { - throw new Error('Team is not part of the event'); - } - - if (teamDivision !== tokenData.divisionId) { - throw new Error('Invalid division ID'); - } - - // Validate that the event is published - const eventSettings = await db.events.bySlug(eventSlug).getSettings(); - if (!eventSettings) { - throw new Error('Event settings not found'); - } - if (!eventSettings.published) { - throw new Error('Event not published'); - } - - // Attach validated data to request for downstream handlers - (req as ExportRequest).team = team; - (req as ExportRequest).event = event; - (req as ExportRequest).divisionId = teamDivision; - - next(); - return; - } catch { - // Invalid token - } - - res.status(401).json({ error: 'UNAUTHORIZED' }); -}); - -router.get('/:teamSlug/:eventSlug/scoresheets', async (req: ExportRequest, res: Response) => { - const { team, event, divisionId } = req; - - const division = await db.divisions.byId(divisionId).get(); - if (!division) { - res.status(404).json({ error: 'Division not found' }); - return; - } - - const season = await db.seasons.byId(event.season_id).get(); - if (!season) { - res.status(404).json({ error: 'Season not found' }); - return; - } - - const scoresheets = ( - await db.scoresheets.byDivision(division.id).byTeamId(team.id).getAll() - ).filter(s => s.stage === 'RANKING' && s.status === 'submitted'); - - res.json({ - teamNumber: team.number, - teamName: team.name, - teamLogoUrl: team.logo_url, - eventName: event.name, - divisionName: division.name, - seasonName: season.name, - scoresheets: scoresheets.map(s => { - // Gracefully handle missing data object - if (!s.data) { - logger.warn( - { scoresheetId: s._id, teamId: team.id, round: s.round }, - 'Scoresheet missing data object during export' - ); - return { - round: s.round, - missions: [], - score: 0 - }; - } - - // Transform missions object to preserve mission IDs and convert clause values - const missions = s.data.missions || {}; - const transformedMissions: Array<{ - id: string; - clauses: Array<{ value: ScoresheetClauseValue }>; - }> = []; - - for (const [missionId, clauses] of Object.entries(missions)) { - const clausesArray: Array<{ value: ScoresheetClauseValue }> = []; - for (const [indexStr, value] of Object.entries(clauses)) { - const index = Number(indexStr); - // Fill any gaps with null placeholders to preserve clause positions - while (clausesArray.length < index) { - clausesArray.push({ value: null }); - } - clausesArray[index] = { value }; - } - transformedMissions.push({ id: missionId, clauses: clausesArray }); - } - - return { - round: s.round, - missions: transformedMissions, - score: s.data.score ?? 0 - }; - }) - }); -}); - -router.get('/:teamSlug/:eventSlug/rubrics', async (req: ExportRequest, res: Response) => { - const { team, event, divisionId } = req; - - const division = await db.divisions.byId(divisionId).get(); - if (!division) { - res.status(404).json({ error: 'Division not found' }); - return; - } - - const season = await db.seasons.byId(event.season_id).get(); - if (!season) { - res.status(404).json({ error: 'Season not found' }); - return; - } - - const allRubrics = await db.rubrics.byDivision(division.id).byTeamId(team.id).getAll(); - const rubrics = allRubrics.filter(r => r.status === 'approved'); - - const optionalAwards = (await db.awards.byDivisionId(division.id).getAll()).filter( - a => a.allow_nominations - ); - const coreValuesRubric = rubrics.find(r => r.category === 'core-values'); - - const awards = optionalAwards.reduce( - (acc, award) => { - acc[award.name] = coreValuesRubric?.data?.awards?.[award.name] ?? false; - return acc; - }, - {} as Record - ); - - // Log if core values rubric is missing or incomplete - if (!coreValuesRubric) { - logger.warn( - { teamId: team.id, divisionId: division.id }, - 'Core values rubric not found during export' - ); - } else if (!coreValuesRubric.data) { - logger.warn( - { rubricId: coreValuesRubric._id, rubricCategory: coreValuesRubric.category }, - 'Core values rubric missing data object during export' - ); - } - - res.json({ - teamNumber: team.number, - teamName: team.name, - teamLogoUrl: team.logo_url, - eventName: event.name, - divisionName: division.name, - seasonName: season.name, - rubrics: rubrics.map(r => { - if (!r.data) { - logger.warn( - { rubricId: r._id, category: r.category }, - 'Rubric missing data object during export' - ); - } - - return { - id: r._id, - category: r.category, - data: r.data - }; - }), - awards: awards - }); -}); - -export default router; +import express, { NextFunction, Request, Response } from 'express'; +import jwt from 'jsonwebtoken'; +import { ScoresheetClauseValue } from '@lems/shared/scoresheet'; +import { extractToken } from '../../../lib/security/auth'; +import { logger } from '../../../lib/logger'; +import db from '../../../lib/database'; +import { ExportRequest } from '../../../types/express'; import { asHandler } from '../../../types/express-handlers'; + + +const router = express.Router({ mergeParams: true }); + +const integrationsJwtSecret = process.env.INTEGRATIONS_LEMS_JWT as string; + +router.use('/:teamSlug/:eventSlug', async (req: Request, res: Response, next: NextFunction) => { + const { teamSlug, eventSlug } = req.params; + + if (!teamSlug || typeof teamSlug !== 'string') { + res.status(400).json({ error: 'Invalid team slug' }); + return; + } + + if (!eventSlug || typeof eventSlug !== 'string') { + res.status(400).json({ error: 'Invalid event slug' }); + return; + } + + try { + const token = extractToken(req); + const tokenData = jwt.verify(token, integrationsJwtSecret) as { + teamSlug: string; + divisionId: string; + }; + + // Validate team and event from token + const team = await db.teams.bySlug(teamSlug).get(); + if (!team) { + throw new Error('Team not found'); + } + if (teamSlug !== tokenData.teamSlug) { + throw new Error('Invalid team slug'); + } + + const event = await db.events.bySlug(eventSlug).get(); + if (!event) { + throw new Error('Event not found'); + } + if (event.slug !== eventSlug) { + throw new Error('Invalid event slug'); + } + + const teamDivision = await db.teams.bySlug(teamSlug).isInEvent(event.id); + if (!teamDivision) { + throw new Error('Team is not part of the event'); + } + + if (teamDivision !== tokenData.divisionId) { + throw new Error('Invalid division ID'); + } + + // Validate that the event is published + const eventSettings = await db.events.bySlug(eventSlug).getSettings(); + if (!eventSettings) { + throw new Error('Event settings not found'); + } + if (!eventSettings.published) { + throw new Error('Event not published'); + } + + // Attach validated data to request for downstream handlers + (req as ExportRequest).team = team; + (req as ExportRequest).event = event; + (req as ExportRequest).divisionId = teamDivision; + + next(); + return; + } catch { + // Invalid token + } + + res.status(401).json({ error: 'UNAUTHORIZED' }); +}); + +router.get('/:teamSlug/:eventSlug/scoresheets', asHandler(async (req, res: Response) => { + const { team, event, divisionId } = req; + + const division = await db.divisions.byId(divisionId).get(); + if (!division) { + res.status(404).json({ error: 'Division not found' }); + return; + } + + const season = await db.seasons.byId(event.season_id).get(); + if (!season) { + res.status(404).json({ error: 'Season not found' }); + return; + } + + const scoresheets = ( + await db.scoresheets.byDivision(division.id).byTeamId(team.id).getAll() + ).filter(s => s.stage === 'RANKING' && s.status === 'submitted'); + + res.json({ + teamNumber: team.number, + teamName: team.name, + teamLogoUrl: team.logo_url, + eventName: event.name, + divisionName: division.name, + seasonName: season.name, + scoresheets: scoresheets.map(s => { + // Gracefully handle missing data object + if (!s.data) { + logger.warn( + { scoresheetId: s._id, teamId: team.id, round: s.round }, + 'Scoresheet missing data object during export' + ); + return { + round: s.round, + missions: [], + score: 0 + }; + } + + // Transform missions object to preserve mission IDs and convert clause values + const missions = s.data.missions || {}; + const transformedMissions: Array<{ + id: string; + clauses: Array<{ value: ScoresheetClauseValue }>; + }> = []; + + for (const [missionId, clauses] of Object.entries(missions)) { + const clausesArray: Array<{ value: ScoresheetClauseValue }> = []; + for (const [indexStr, value] of Object.entries(clauses)) { + const index = Number(indexStr); + // Fill any gaps with null placeholders to preserve clause positions + while (clausesArray.length < index) { + clausesArray.push({ value: null }); + } + clausesArray[index] = { value }; + } + transformedMissions.push({ id: missionId, clauses: clausesArray }); + } + + return { + round: s.round, + missions: transformedMissions, + score: s.data.score ?? 0 + }; + }) + }); +})); + +router.get('/:teamSlug/:eventSlug/rubrics', asHandler(async (req, res: Response) => { + const { team, event, divisionId } = req; + + const division = await db.divisions.byId(divisionId).get(); + if (!division) { + res.status(404).json({ error: 'Division not found' }); + return; + } + + const season = await db.seasons.byId(event.season_id).get(); + if (!season) { + res.status(404).json({ error: 'Season not found' }); + return; + } + + const allRubrics = await db.rubrics.byDivision(division.id).byTeamId(team.id).getAll(); + const rubrics = allRubrics.filter(r => r.status === 'approved'); + + const optionalAwards = (await db.awards.byDivisionId(division.id).getAll()).filter( + a => a.allow_nominations + ); + const coreValuesRubric = rubrics.find(r => r.category === 'core-values'); + + const awards = optionalAwards.reduce( + (acc, award) => { + acc[award.name] = coreValuesRubric?.data?.awards?.[award.name] ?? false; + return acc; + }, + {} as Record + ); + + // Log if core values rubric is missing or incomplete + if (!coreValuesRubric) { + logger.warn( + { teamId: team.id, divisionId: division.id }, + 'Core values rubric not found during export' + ); + } else if (!coreValuesRubric.data) { + logger.warn( + { rubricId: coreValuesRubric._id, rubricCategory: coreValuesRubric.category }, + 'Core values rubric missing data object during export' + ); + } + + res.json({ + teamNumber: team.number, + teamName: team.name, + teamLogoUrl: team.logo_url, + eventName: event.name, + divisionName: division.name, + seasonName: season.name, + rubrics: rubrics.map(r => { + if (!r.data) { + logger.warn( + { rubricId: r._id, category: r.category }, + 'Rubric missing data object during export' + ); + } + + return { + id: r._id, + category: r.category, + data: r.data + }; + }), + awards: awards + }); +})); + +export default router; diff --git a/apps/backend/src/routers/portal/divisions/index.ts b/apps/backend/src/routers/portal/divisions/index.ts index 7e3803a57..dd2af591c 100644 --- a/apps/backend/src/routers/portal/divisions/index.ts +++ b/apps/backend/src/routers/portal/divisions/index.ts @@ -4,6 +4,7 @@ import { PortalDivisionRequest } from '../../../types/express'; import { attachDivision } from '../middleware/attach-division'; import { makePortalTeamResponse } from '../teams/util'; import { calculateRobotGameRankings } from '../utils/ranking-calculator'; +import { asHandler } from '../../../types/express-handlers'; import { makePortalDivisionResponse, makePortalJudgingSessionResponse, @@ -16,18 +17,22 @@ const router = express.Router({ mergeParams: true }); router.use('/:divisionId', attachDivision()); -router.get('/:divisionId', async (req: PortalDivisionRequest, res: Response) => { +router.get('/:divisionId', asHandler(async (req, res: Response) => { const division = await db.divisions.byId(req.divisionId).get(); + if (!division) { + res.status(404).json({ error: 'Division not found' }); + return; + } res.status(200).json(makePortalDivisionResponse(division)); -}); +})); -router.get('/:divisionId/teams', async (req: PortalDivisionRequest, res: Response) => { +router.get('/:divisionId/teams', asHandler(async (req, res: Response) => { const divisionId = req.divisionId; const teams = await db.teams.byDivisionId(divisionId).getAll(); res.status(200).json(teams.map(makePortalTeamResponse)); -}); +})); -router.get('/:divisionId/schedule/judging', async (req: PortalDivisionRequest, res: Response) => { +router.get('/:divisionId/schedule/judging', asHandler(async (req, res: Response) => { const teams = await db.teams.byDivisionId(req.divisionId).getAll(); const rooms = await db.rooms.byDivisionId(req.divisionId).getAll(); const judgingSchedule = await db.judgingSessions.byDivision(req.divisionId).getAll(); @@ -36,18 +41,18 @@ router.get('/:divisionId/schedule/judging', async (req: PortalDivisionRequest, r makePortalJudgingSessionResponse(session, rooms, teams) ); res.status(200).json(sessions); -}); +})); -router.get('/:divisionId/schedule/field', async (req: PortalDivisionRequest, res: Response) => { +router.get('/:divisionId/schedule/field', asHandler(async (req, res: Response) => { const teams = await db.teams.byDivisionId(req.divisionId).getAll(); const tables = await db.tables.byDivisionId(req.divisionId).getAll(); const fieldSchedule = await db.robotGameMatches.byDivision(req.divisionId).getAll(); const matches = fieldSchedule.map(match => makePortalMatchResponse(match, tables, teams)); res.status(200).json(matches); -}); +})); -router.get('/:divisionId/agenda', async (req: PortalDivisionRequest, res: Response) => { +router.get('/:divisionId/agenda', asHandler(async (req, res: Response) => { const agendaPublic = await db.divisions.byId(req.divisionId).agenda().getAll('public'); const agendaTeams = await db.divisions.byId(req.divisionId).agenda().getAll('teams'); const agenda = [...agendaPublic, ...agendaTeams].sort( @@ -55,10 +60,10 @@ router.get('/:divisionId/agenda', async (req: PortalDivisionRequest, res: Respon ); res.status(200).json(agenda.map(makePortalAgendaResponse)); -}); +})); // TODO: Implement this properly -router.get('/:divisionId/scoreboard', async (req: PortalDivisionRequest, res: Response) => { +router.get('/:divisionId/scoreboard', asHandler(async (req, res: Response) => { const teams = await db.teams.byDivisionId(req.divisionId).getAll(); const rankings = await calculateRobotGameRankings(req.divisionId); @@ -86,10 +91,14 @@ router.get('/:divisionId/scoreboard', async (req: PortalDivisionRequest, res: Re }); res.status(200).json(scoreboard); -}); +})); -router.get('/:divisionId/awards', async (req: PortalDivisionRequest, res: Response) => { +router.get('/:divisionId/awards', asHandler(async (req, res: Response) => { const division = await db.divisions.byId(req.divisionId).get(); + if (!division) { + res.status(404).json({ error: 'Division not found' }); + return; + } const eventSettings = await db.events.byId(division.event_id).getSettings(); if (!eventSettings?.published) { @@ -99,6 +108,6 @@ router.get('/:divisionId/awards', async (req: PortalDivisionRequest, res: Respon const awards = await db.awards.byDivisionId(req.divisionId).getAll(); res.status(200).json(awards.map(makePortalAwardsResponse)); -}); +})); export default router; diff --git a/apps/backend/src/routers/portal/divisions/util.ts b/apps/backend/src/routers/portal/divisions/util.ts index e6d51dd56..5007bd844 100644 --- a/apps/backend/src/routers/portal/divisions/util.ts +++ b/apps/backend/src/routers/portal/divisions/util.ts @@ -30,7 +30,7 @@ export const makePortalAwardsResponse = (award: DbAward): Award => ({ name: award.name, type: award.type, showPlaces: award.show_places, - winner: award.type === 'PERSONAL' ? award.winner_name : award.winner_id, + winner: (award.type === 'PERSONAL' ? award.winner_name : award.winner_id) ?? undefined, place: award.place }); diff --git a/apps/backend/src/routers/portal/events/index.ts b/apps/backend/src/routers/portal/events/index.ts index 1ddfad5f3..705d765ed 100644 --- a/apps/backend/src/routers/portal/events/index.ts +++ b/apps/backend/src/routers/portal/events/index.ts @@ -3,9 +3,11 @@ import { EventDetails, EventSummary } from '@lems/database'; import db from '../../../lib/database'; import { attachEvent } from '../middleware/attach-event'; import { PortalEventRequest } from '../../../types/express'; +import { asHandler } from '../../../types/express-handlers'; import eventTeamRouter from './teams'; import { makePortalEventDetailsResponse, makePortalEventSummaryResponse } from './util'; + const router = express.Router({ mergeParams: true }); router.get('/', async (req: Request, res: Response) => { @@ -22,6 +24,10 @@ router.get('/', async (req: Request, res: Response) => { const registeredTeams = await db.events.bySlug(eventSlug).getRegisteredTeams(); const divisions = await db.divisions.byEventId(event.id).getAll(); const settings = await db.events.byId(event.id).getSettings(); + if (!settings) { + res.status(404).json({ error: 'Event settings not found' }); + return; + } const eventSummary: EventSummary = { ...event, @@ -76,11 +82,23 @@ router.get('/', async (req: Request, res: Response) => { router.use('/:slug', attachEvent()); -router.get('/:slug', async (req: PortalEventRequest, res: Response) => { +router.get('/:slug', asHandler(async (req, res: Response) => { const event = await db.events.byId(req.eventId).get(); + if (!event) { + res.status(404).json({ error: 'Event not found' }); + return; + } const divisions = await db.divisions.byEventId(event.id).getAllSummaries(); const season = await db.seasons.byId(event.season_id).get(); + if (!season) { + res.status(404).json({ error: 'Season not found' }); + return; + } const settings = await db.events.byId(event.id).getSettings(); + if (!settings) { + res.status(404).json({ error: 'Event settings not found' }); + return; + } const eventSummary: EventDetails = { ...event, @@ -91,7 +109,7 @@ router.get('/:slug', async (req: PortalEventRequest, res: Response) => { }; res.json(makePortalEventDetailsResponse(eventSummary)); -}); +})); router.use('/:slug/teams', eventTeamRouter); diff --git a/apps/backend/src/routers/portal/events/teams/index.ts b/apps/backend/src/routers/portal/events/teams/index.ts index 8372cd1b6..e387bc441 100644 --- a/apps/backend/src/routers/portal/events/teams/index.ts +++ b/apps/backend/src/routers/portal/events/teams/index.ts @@ -5,6 +5,7 @@ import { attachTeamAtEvent } from '../../middleware/attach-team-at-event'; import { makePortalAwardsResponse, makePortalDivisionResponse } from '../../divisions/util'; import { makePortalTeamResponse } from '../../teams/util'; import { getTeamRankingData } from '../../utils/ranking-calculator'; +import { asHandler } from '../../../../types/express-handlers'; import { makePortalTeamJudgingSessionResponse, makePortalTeamRobotGameMatchResponse, @@ -15,10 +16,22 @@ const router = express.Router({ mergeParams: true }); router.use('/:teamSlug', attachTeamAtEvent()); -router.get('/:teamSlug', async (req: PortalTeamAtEventRequest, res: Response) => { +router.get('/:teamSlug', asHandler(async (req, res: Response) => { const team = await db.teams.byId(req.teamId).get(); + if (!team) { + res.status(404).json({ error: 'Team not found' }); + return; + } const division = await db.divisions.byId(req.divisionId).get(); + if (!division) { + res.status(404).json({ error: 'Division not found' }); + return; + } const event = await db.events.byId(division.event_id).get(); + if (!event) { + res.status(404).json({ error: 'Event not found' }); + return; + } res.json({ team: makePortalTeamResponse(team), @@ -29,9 +42,9 @@ router.get('/:teamSlug', async (req: PortalTeamAtEventRequest, res: Response) => }, division: makePortalDivisionResponse(division) }); -}); +})); -router.get('/:teamSlug/activities', async (req: PortalTeamAtEventRequest, res: Response) => { +router.get('/:teamSlug/activities', asHandler(async (req, res: Response) => { const session = await db.judgingSessions.byDivision(req.divisionId).getByTeam(req.teamId); const rooms = await db.rooms.byDivisionId(req.divisionId).getAll(); const matches = await db.robotGameMatches.byDivision(req.divisionId).getByTeam(req.teamId); @@ -41,26 +54,30 @@ router.get('/:teamSlug/activities', async (req: PortalTeamAtEventRequest, res: R const agenda = [...agendaPublic, ...agendaTeams]; res.json({ - session: makePortalTeamJudgingSessionResponse(req.teamId, session, rooms), + session: session ? makePortalTeamJudgingSessionResponse(req.teamId, session, rooms) : null, matches: matches.map(match => makePortalTeamRobotGameMatchResponse(req.teamId, match, tables)), agenda: agenda.map(a => makeAgendaResponse(a)) }); -}); +})); -router.get('/:teamSlug/awards', async (req: PortalTeamAtEventRequest, res: Response) => { +router.get('/:teamSlug/awards', asHandler(async (req, res: Response) => { const division = await db.divisions.byId(req.divisionId).get(); + if (!division) { + res.status(404).json({ error: 'Division not found' }); + return; + } const eventSettings = await db.events.byId(division.event_id).getSettings(); - if (!eventSettings.published) { + if (!eventSettings?.published) { res.status(200).json([]); return; } const teamAwards = await db.awards.byDivisionId(division.id).getByTeam(req.teamId); res.status(200).json(teamAwards.map(makePortalAwardsResponse)); -}); +})); -router.get('/:teamSlug/robot-performance', async (req: PortalTeamAtEventRequest, res: Response) => { +router.get('/:teamSlug/robot-performance', asHandler(async (req, res: Response) => { const rankingData = await getTeamRankingData(req.divisionId, req.teamId); if (!rankingData) { @@ -81,6 +98,6 @@ router.get('/:teamSlug/robot-performance', async (req: PortalTeamAtEventRequest, highestScore: rankingData.maxScore, robotGameRank: rankingData.rank }); -}); +})); export default router; diff --git a/apps/backend/src/routers/portal/events/util.ts b/apps/backend/src/routers/portal/events/util.ts index d210c382e..83ccb2bb2 100644 --- a/apps/backend/src/routers/portal/events/util.ts +++ b/apps/backend/src/routers/portal/events/util.ts @@ -25,7 +25,7 @@ export const makePortalEventResponse = (event: DbEvent | DbEventSummary): Event startDate, endDate, location: event.location, - coordinates: event.coordinates, + coordinates: event.coordinates ?? undefined, seasonId: event.season_id, region: event.region, timezone: event.timezone diff --git a/apps/backend/src/routers/portal/middleware/attach-division.ts b/apps/backend/src/routers/portal/middleware/attach-division.ts index 8b94c12c9..4f4761d78 100644 --- a/apps/backend/src/routers/portal/middleware/attach-division.ts +++ b/apps/backend/src/routers/portal/middleware/attach-division.ts @@ -24,7 +24,7 @@ export const attachDivision = () => { const eventSettings = await database.events.byId(division.event_id).getSettings(); - if (!eventSettings.visible) { + if (!eventSettings?.visible) { res.status(404).json({ error: 'DIVISION_NOT_FOUND' }); return; } diff --git a/apps/backend/src/routers/portal/middleware/attach-event.ts b/apps/backend/src/routers/portal/middleware/attach-event.ts index d1e85c9d5..11c544739 100644 --- a/apps/backend/src/routers/portal/middleware/attach-event.ts +++ b/apps/backend/src/routers/portal/middleware/attach-event.ts @@ -16,7 +16,7 @@ export const attachEvent = () => { return; } - let settings: EventSettings; + let settings: EventSettings | null = null; try { settings = await database.events.bySlug(eventSlug).getSettings(); @@ -25,6 +25,11 @@ export const attachEvent = () => { return; } + if (!settings) { + res.status(404).json({ error: 'EVENT_NOT_FOUND' }); + return; + } + if (!settings.visible) { res.status(404).json({ error: 'EVENT_NOT_FOUND' }); return; diff --git a/apps/backend/src/routers/portal/seasons/index.ts b/apps/backend/src/routers/portal/seasons/index.ts index a0d66bacd..5b12e73c5 100644 --- a/apps/backend/src/routers/portal/seasons/index.ts +++ b/apps/backend/src/routers/portal/seasons/index.ts @@ -11,7 +11,12 @@ router.get('/latest', async (req: Request, res: Response) => { return; } - const latestSeason = await db.seasons.getAll()[0]; + const allSeasons = await db.seasons.getAll(); + const latestSeason = allSeasons[0]; + if (!latestSeason) { + res.status(404).json({ message: 'No seasons found' }); + return; + } res.status(200).json(makePortalSeasonResponse(latestSeason)); }); diff --git a/apps/backend/src/routers/portal/teams/index.ts b/apps/backend/src/routers/portal/teams/index.ts index 88b9f9622..c41c73a13 100644 --- a/apps/backend/src/routers/portal/teams/index.ts +++ b/apps/backend/src/routers/portal/teams/index.ts @@ -7,8 +7,10 @@ import { makePortalSeasonResponse } from '../seasons/util'; import { attachTeam } from '../middleware/attach-team'; import { PortalTeamRequest } from '../../../types/express'; import { getTeamRankingData } from '../utils/ranking-calculator'; +import { asHandler, asMiddleware } from '../../../types/express-handlers'; import { makePortalTeamResponse, makePortalTeamSummaryResponse } from './util'; + const router = express.Router({ mergeParams: true }); router.get('/', async (req: Request, res: Response) => { @@ -38,8 +40,12 @@ router.use('/:teamSlug', attachTeam()); /** * Returns a team, as well as the latest season they competed at */ -router.get('/:teamSlug/summary', async (req: PortalTeamRequest, res: Response) => { +router.get('/:teamSlug/summary', asHandler(async (req, res: Response) => { const team = await db.teams.byId(req.teamId).get(); + if (!team) { + res.status(404).json({ error: 'Team not found' }); + return; + } const teamEvents = await db.events.byTeam(req.teamId).getAllSummaries(); const seasons = await db.seasons.getAll(); // Sorted by default @@ -54,12 +60,12 @@ router.get('/:teamSlug/summary', async (req: PortalTeamRequest, res: Response) = } res.status(200).json(makePortalTeamSummaryResponse(team, null)); -}); +})); /** * Returns the seasons a team competed at */ -router.get('/:teamSlug/seasons', async (req: PortalTeamRequest, res: Response) => { +router.get('/:teamSlug/seasons', asHandler(async (req, res: Response) => { const seasons = await db.seasons.getAll(); const teamSeasons = new Set(); @@ -75,7 +81,7 @@ router.get('/:teamSlug/seasons', async (req: PortalTeamRequest, res: Response) = const filteredSeasons = seasons.filter(season => teamSeasons.has(season.id)); res.status(200).json(filteredSeasons.map(makePortalSeasonResponse)); -}); +})); type PortalTeamWithSeasonRequest = PortalTeamRequest & { seasonId?: string }; @@ -108,8 +114,8 @@ const seasonFilter = async ( */ router.get( '/:teamSlug/events', - seasonFilter, - async (req: PortalTeamRequest & { seasonId?: string }, res: Response) => { + asMiddleware(seasonFilter), + asHandler(async (req, res) => { let teamEvents = await db.events.byTeam(req.teamId).getAllSummaries(); teamEvents = teamEvents.filter(event => event.visible); @@ -118,7 +124,7 @@ router.get( } res.status(200).json(teamEvents.map(makePortalEventResponse)); - } + }) ); /** @@ -126,8 +132,8 @@ router.get( */ router.get( '/:teamSlug/events/results', - seasonFilter, - async (req: PortalTeamWithSeasonRequest, res: Response) => { + asMiddleware(seasonFilter), + asHandler(async (req, res) => { let teamEvents = await db.events.byTeam(req.teamId).getAllSummaries(); teamEvents = teamEvents.filter(event => event.visible); @@ -154,6 +160,10 @@ router.get( // TODO: This isn't the most efficient way to check division registration, // we should improve this sometime by batch-requesting const teamDivision = await db.teams.byId(req.teamId).isInEvent(event.id); + if (!teamDivision) { + eventResults.push(eventResult); + continue; + } const awards = await db.awards.byDivisionId(teamDivision).getAll(); const teamAwards = awards.filter(award => award.winner_id === req.teamId); @@ -168,7 +178,8 @@ router.get( .getAll(); const submittedScoresheets = scoresheets.filter( - s => s.status === 'submitted' && s.data?.score != null + (s): s is typeof s & { data: { score: number } } => + s.status === 'submitted' && s.data?.score != null ); const scoresByRound = new Map(); @@ -176,13 +187,15 @@ router.get( scoresByRound.set(sheet.round, sheet.data.score); } - const teamMatchResults = rankingMatches.map(match => ({ - number: match.round, - score: scoresByRound.get(match.round) ?? null - })); + const teamMatchResults = rankingMatches + .map(match => ({ + number: match.round, + score: scoresByRound.get(match.round) + })) + .filter((match): match is { number: number; score: number } => match.score != null); const rankingData = await getTeamRankingData(teamDivision, req.teamId); - const robotGameRank = rankingData?.rank ?? null; + const robotGameRank = rankingData?.rank ?? 0; eventResult.results = { awards: teamAwards.map(award => ({ @@ -197,7 +210,7 @@ router.get( } res.status(200).json(eventResults); - } + }) ); export default router; diff --git a/apps/backend/src/routers/portal/teams/util.ts b/apps/backend/src/routers/portal/teams/util.ts index 9eaad8f8d..9a7a0bcd0 100644 --- a/apps/backend/src/routers/portal/teams/util.ts +++ b/apps/backend/src/routers/portal/teams/util.ts @@ -16,7 +16,7 @@ export const makePortalTeamResponse = (team: DbTeam): Team => ({ export const makePortalTeamSummaryResponse = ( team: DbTeam, lastCompetedSeason: Season | null -): TeamSummary => ({ +) => ({ ...makePortalTeamResponse(team), lastCompetedSeason }); diff --git a/apps/backend/src/routers/portal/utils/ranking-calculator.ts b/apps/backend/src/routers/portal/utils/ranking-calculator.ts index fb2f82b21..2ce6624b6 100644 --- a/apps/backend/src/routers/portal/utils/ranking-calculator.ts +++ b/apps/backend/src/routers/portal/utils/ranking-calculator.ts @@ -27,6 +27,10 @@ export async function calculateRobotGameRankings( .collection('division_states') .findOne({ divisionId }); + if (!divisionState?.field?.currentStage) { + return new Map(); + } + const scoresheets = await db.scoresheets .byDivision(divisionId) .byStage(divisionState.field.currentStage) @@ -44,7 +48,7 @@ export async function calculateRobotGameRankings( } teamScores.get(scoresheet.teamId)!.push({ round: scoresheet.round, - score: scoresheet.data.score + score: scoresheet.data!.score }); } diff --git a/apps/backend/src/routers/scheduler/divisions/index.ts b/apps/backend/src/routers/scheduler/divisions/index.ts index 25778d4e8..3da302250 100644 --- a/apps/backend/src/routers/scheduler/divisions/index.ts +++ b/apps/backend/src/routers/scheduler/divisions/index.ts @@ -1,249 +1,258 @@ -import express from 'express'; -import { - InsertableJudgingSession, - InsertableRobotGameMatch, - InsertableRobotGameMatchParticipant, - Rubric, - JudgingCategory -} from '@lems/database'; -import { JUDGING_CATEGORIES } from '@lems/types/judging'; -import db from '../../../lib/database'; -import { attachDivision } from '../middleware/attach-division'; -import { SchedulerRequest } from '../../../types/express'; -import { makeSchedulerLocationResponse, makeSchedulerTeamResponse } from './utils'; - -const router = express.Router({ mergeParams: true }); - -router.use(attachDivision()); - -router.get('/teams', async (req: SchedulerRequest, res) => { - const teams = await db.teams.byDivisionId(req.divisionId).getAll(); - res.status(200).json(teams.map(team => makeSchedulerTeamResponse(team))); -}); - -router.get('/team/:teamSlug', async (req: SchedulerRequest, res) => { - const { teamSlug } = req.params; - if (!teamSlug || typeof teamSlug !== 'string') { - res.status(400).json({ error: 'Team slug is required' }); - return; - } - - const [, teamNumber] = teamSlug.split('-'); - - if (Number.isNaN(Number(teamNumber))) { - res.status(400).json({ error: 'Team number must be a number' }); - return; - } - - const team = await db.teams.bySlug(teamSlug).get(); - if (!team) { - res.status(404).json({ error: 'Team not found' }); - return; - } - - const isInDivision = db.teams.byId(team.id).isInDivision(req.divisionId); - if (!isInDivision) { - res.status(400).json({ error: 'Team not found in this division' }); - return; - } - - res.status(200).json(makeSchedulerTeamResponse(team)); -}); - -router.get('/tables', async (req: SchedulerRequest, res) => { - const tables = await db.tables.byDivisionId(req.divisionId).getAll(); - res.status(200).json(tables.map(table => makeSchedulerLocationResponse(table))); -}); - -router.get('/rooms', async (req: SchedulerRequest, res) => { - const rooms = await db.rooms.byDivisionId(req.divisionId).getAll(); - res.status(200).json(rooms.map(room => makeSchedulerLocationResponse(room))); -}); - -router.post('/sessions', async (req: SchedulerRequest, res) => { - const { sessions }: { sessions: InsertableJudgingSession[] } = req.body; - - if (!sessions || !Array.isArray(sessions)) { - res.status(400).json({ error: 'Sessions are required' }); - return; - } - - const division = await db.divisions.byId(req.divisionId).get(); - if (division.has_schedule) { - res - .status(400) - .json({ error: 'Division already has a schedule. Delete the existing schedule first.' }); - return; - } - - await db.judgingSessions.createMany(sessions); - - await Promise.all( - JUDGING_CATEGORIES.map(category => - db.judgingDeliberations.create({ - division_id: division.id, - category - }) - ) - ); - - await db.finalDeliberations.create(division.id); - - // Create rubrics for each team in the division - const teamIds = [ - ...new Set(sessions.map(s => s.team_id).filter((id): id is string => id !== null)) - ]; - const categories: JudgingCategory[] = ['innovation-project', 'robot-design', 'core-values']; - - const rubrics: Rubric[] = teamIds.flatMap(teamId => - categories.map(category => ({ - divisionId: req.divisionId, - teamId, - category, - status: 'empty' as const - })) - ); - - await db.rubrics.createMany(rubrics); - - res.status(200).json({ ok: true }); -}); - -interface MatchRequest { - number: number; - stage: string; - round: number; - scheduled_time: string; - tables: Record; -} - -router.post('/matches', async (req: SchedulerRequest, res) => { - const { matches }: { matches: MatchRequest[] } = req.body; - - if (!matches || !Array.isArray(matches)) { - res.status(400).json({ error: 'Matches are required' }); - return; - } - - const division = await db.divisions.byId(req.divisionId).get(); - if (division.has_schedule) { - res - .status(400) - .json({ error: 'Division already has a schedule. Delete the existing schedule first.' }); - return; - } - - try { - const matchesWithParticipants = matches.map(match => ({ - match: { - number: match.number, - round: match.round, - stage: match.stage.toUpperCase() as 'PRACTICE' | 'RANKING' | 'TEST', - scheduled_time: new Date(match.scheduled_time), - division_id: req.divisionId - } as InsertableRobotGameMatch, - participants: Object.entries(match.tables).map(([tableId, tableData]) => ({ - team_id: tableData.team_id || null, - table_id: tableId - })) as InsertableRobotGameMatchParticipant[] - })); - - const testMatch = { - match: { - number: 0, - round: 0, - stage: 'TEST' as 'PRACTICE' | 'RANKING' | 'TEST', - scheduled_time: new Date(), - division_id: req.divisionId - } as InsertableRobotGameMatch, - participants: [] as InsertableRobotGameMatchParticipant[] - }; - - const allMatches = [testMatch, ...matchesWithParticipants]; - - await db.robotGameMatches.createMany(allMatches); - - const scoresheets = []; - - for (const { match, participants } of matchesWithParticipants) { - for (const participant of participants) { - if (participant.team_id) { - scoresheets.push({ - divisionId: req.divisionId, - teamId: participant.team_id, - stage: match.stage as 'PRACTICE' | 'RANKING', - round: match.round, - status: 'empty' as const, - escalated: false - }); - } - } - } - - await db.scoresheets.createMany(scoresheets); - - res.status(200).json({ ok: true }); - } catch (error) { - console.error('Error creating matches:', error); - res.status(500).json({ error: 'Failed to create matches' }); - } -}); - -router.put('/settings', async (req: SchedulerRequest, res) => { - try { - const { schedule_settings } = req.body; - - if (schedule_settings !== undefined && schedule_settings !== null) { - const requiredFields = [ - 'match_length', - 'practice_cycle_time', - 'ranking_cycle_time', - 'judging_session_length', - 'judging_session_cycle_time' - ]; - - for (const field of requiredFields) { - if (!(field in schedule_settings) || typeof schedule_settings[field] !== 'number') { - res.status(400).json({ - error: `Invalid schedule_settings: ${field} must be a number` - }); - return; - } - } - } - - const updated = await db.divisions - .byId(req.divisionId) - .update({ has_schedule: true, schedule_settings }); - - if (!updated) { - res.status(404).json({ error: 'Division not found' }); - return; - } - - res.status(200).json({ ok: true }); - } catch (error) { - console.error('Error updating division has_schedule:', error); - res.status(500).json({ error: 'Failed to update division has_schedule' }); - } -}); - -router.delete('/schedule', async (req: SchedulerRequest, res) => { - try { - await Promise.all([ - db.judgingSessions.byDivision(req.divisionId).deleteAll(), - db.robotGameMatches.byDivision(req.divisionId).deleteAll(), - db.scoresheets.byDivision(req.divisionId).deleteAll(), - db.judgingDeliberations.byDivision(req.divisionId).deleteAll(), - db.finalDeliberations.byDivision(req.divisionId).delete(), - db.divisions.byId(req.divisionId).update({ has_schedule: false, schedule_settings: null }) - ]); - - res.status(200).json({ ok: true }); - } catch (error) { - console.error('Error deleting division schedule:', error); - res.status(500).json({ error: 'Failed to delete division schedule' }); - } -}); - -export default router; +import express from 'express'; +import { + InsertableJudgingSession, + InsertableRobotGameMatch, + InsertableRobotGameMatchParticipant, + Rubric, + JudgingCategory +} from '@lems/database'; +import { JUDGING_CATEGORIES } from '@lems/types/judging'; +import db from '../../../lib/database'; +import { attachDivision } from '../middleware/attach-division'; +import { SchedulerRequest } from '../../../types/express'; +import { asHandler } from '../../../types/express-handlers'; +import { makeSchedulerLocationResponse, makeSchedulerTeamResponse } from './utils'; + +const router = express.Router({ mergeParams: true }); + +router.use(attachDivision()); + +router.get('/teams', asHandler(async (req, res) => { + const teams = await db.teams.byDivisionId(req.divisionId).getAll(); + res.status(200).json(teams.map(team => makeSchedulerTeamResponse(team))); +})); + +router.get('/team/:teamSlug', asHandler(async (req, res) => { + const { teamSlug } = req.params; + if (!teamSlug || typeof teamSlug !== 'string') { + res.status(400).json({ error: 'Team slug is required' }); + return; + } + + const [, teamNumber] = teamSlug.split('-'); + + if (Number.isNaN(Number(teamNumber))) { + res.status(400).json({ error: 'Team number must be a number' }); + return; + } + + const team = await db.teams.bySlug(teamSlug).get(); + if (!team) { + res.status(404).json({ error: 'Team not found' }); + return; + } + + const isInDivision = db.teams.byId(team.id).isInDivision(req.divisionId); + if (!isInDivision) { + res.status(400).json({ error: 'Team not found in this division' }); + return; + } + + res.status(200).json(makeSchedulerTeamResponse(team)); +})); + +router.get('/tables', asHandler(async (req, res) => { + const tables = await db.tables.byDivisionId(req.divisionId).getAll(); + res.status(200).json(tables.map(table => makeSchedulerLocationResponse(table))); +})); + +router.get('/rooms', asHandler(async (req, res) => { + const rooms = await db.rooms.byDivisionId(req.divisionId).getAll(); + res.status(200).json(rooms.map(room => makeSchedulerLocationResponse(room))); +})); + +router.post('/sessions', asHandler(async (req, res) => { + const { sessions }: { sessions: InsertableJudgingSession[] } = req.body; + + if (!sessions || !Array.isArray(sessions)) { + res.status(400).json({ error: 'Sessions are required' }); + return; + } + + const division = await db.divisions.byId(req.divisionId).get(); + if (!division) { + res.status(404).json({ error: 'Division not found' }); + return; + } + if (division.has_schedule) { + res + .status(400) + .json({ error: 'Division already has a schedule. Delete the existing schedule first.' }); + return; + } + + await db.judgingSessions.createMany(sessions); + + await Promise.all( + JUDGING_CATEGORIES.map(category => + db.judgingDeliberations.create({ + division_id: division.id, + category + }) + ) + ); + + await db.finalDeliberations.create(division.id); + + // Create rubrics for each team in the division + const teamIds = [ + ...new Set(sessions.map(s => s.team_id).filter((id): id is string => id !== null)) + ]; + const categories: JudgingCategory[] = ['innovation-project', 'robot-design', 'core-values']; + + const rubrics: Rubric[] = teamIds.flatMap(teamId => + categories.map(category => ({ + divisionId: req.divisionId, + teamId, + category, + status: 'empty' as const + })) + ); + + await db.rubrics.createMany(rubrics); + + res.status(200).json({ ok: true }); +})); + +interface MatchRequest { + number: number; + stage: string; + round: number; + scheduled_time: string; + tables: Record; +} + +router.post('/matches', asHandler(async (req, res) => { + const { matches }: { matches: MatchRequest[] } = req.body; + + if (!matches || !Array.isArray(matches)) { + res.status(400).json({ error: 'Matches are required' }); + return; + } + + const division = await db.divisions.byId(req.divisionId).get(); + if (!division) { + res.status(404).json({ error: 'Division not found' }); + return; + } + if (division.has_schedule) { + res + .status(400) + .json({ error: 'Division already has a schedule. Delete the existing schedule first.' }); + return; + } + + try { + const matchesWithParticipants = matches.map(match => ({ + match: { + number: match.number, + round: match.round, + stage: match.stage.toUpperCase() as 'PRACTICE' | 'RANKING' | 'TEST', + scheduled_time: new Date(match.scheduled_time), + division_id: req.divisionId + } as InsertableRobotGameMatch, + participants: Object.entries(match.tables).map(([tableId, tableData]) => ({ + team_id: tableData.team_id || null, + table_id: tableId + })) as InsertableRobotGameMatchParticipant[] + })); + + const testMatch = { + match: { + number: 0, + round: 0, + stage: 'TEST' as 'PRACTICE' | 'RANKING' | 'TEST', + scheduled_time: new Date(), + division_id: req.divisionId + } as InsertableRobotGameMatch, + participants: [] as InsertableRobotGameMatchParticipant[] + }; + + const allMatches = [testMatch, ...matchesWithParticipants]; + + await db.robotGameMatches.createMany(allMatches); + + const scoresheets = []; + + for (const { match, participants } of matchesWithParticipants) { + for (const participant of participants) { + if (participant.team_id) { + scoresheets.push({ + divisionId: req.divisionId, + teamId: participant.team_id, + stage: match.stage as 'PRACTICE' | 'RANKING', + round: match.round, + status: 'empty' as const, + escalated: false + }); + } + } + } + + await db.scoresheets.createMany(scoresheets); + + res.status(200).json({ ok: true }); + } catch (error) { + console.error('Error creating matches:', error); + res.status(500).json({ error: 'Failed to create matches' }); + } +})); + +router.put('/settings', asHandler(async (req, res) => { + try { + const { schedule_settings } = req.body; + + if (schedule_settings !== undefined && schedule_settings !== null) { + const requiredFields = [ + 'match_length', + 'practice_cycle_time', + 'ranking_cycle_time', + 'judging_session_length', + 'judging_session_cycle_time' + ]; + + for (const field of requiredFields) { + if (!(field in schedule_settings) || typeof schedule_settings[field] !== 'number') { + res.status(400).json({ + error: `Invalid schedule_settings: ${field} must be a number` + }); + return; + } + } + } + + const updated = await db.divisions + .byId(req.divisionId) + .update({ has_schedule: true, schedule_settings }); + + if (!updated) { + res.status(404).json({ error: 'Division not found' }); + return; + } + + res.status(200).json({ ok: true }); + } catch (error) { + console.error('Error updating division has_schedule:', error); + res.status(500).json({ error: 'Failed to update division has_schedule' }); + } +})); + +router.delete('/schedule', asHandler(async (req, res) => { + try { + await Promise.all([ + db.judgingSessions.byDivision(req.divisionId).deleteAll(), + db.robotGameMatches.byDivision(req.divisionId).deleteAll(), + db.scoresheets.byDivision(req.divisionId).deleteAll(), + db.judgingDeliberations.byDivision(req.divisionId).deleteAll(), + db.finalDeliberations.byDivision(req.divisionId).delete(), + db.divisions.byId(req.divisionId).update({ has_schedule: false, schedule_settings: null }) + ]); + + res.status(200).json({ ok: true }); + } catch (error) { + console.error('Error deleting division schedule:', error); + res.status(500).json({ error: 'Failed to delete division schedule' }); + } +})); + +export default router; diff --git a/apps/backend/src/routers/scheduler/middleware/auth.ts b/apps/backend/src/routers/scheduler/middleware/auth.ts index 603147b8a..cab790f59 100644 --- a/apps/backend/src/routers/scheduler/middleware/auth.ts +++ b/apps/backend/src/routers/scheduler/middleware/auth.ts @@ -4,6 +4,10 @@ import { extractToken } from '../../../lib/security/auth'; const schedulerJwtSecret = process.env.SCHEDULER_JWT_SECRET; +if (!schedulerJwtSecret) { + throw new Error('SCHEDULER_JWT_SECRET environment variable is required'); +} + export const authMiddleware = async (req: Request, res: Response, next: NextFunction) => { try { const token = extractToken(req); diff --git a/apps/backend/src/types/express-handlers.ts b/apps/backend/src/types/express-handlers.ts new file mode 100644 index 000000000..36b1e4609 --- /dev/null +++ b/apps/backend/src/types/express-handlers.ts @@ -0,0 +1,20 @@ +import { NextFunction, Request, RequestHandler, Response } from 'express'; + +/** + * Casts a route handler with a narrowed request type to Express's RequestHandler. + * Middleware upstream is responsible for populating the narrowed fields. + */ +export function asHandler( + handler: (req: TReq, res: Response, next?: NextFunction) => void | Promise +): RequestHandler { + return handler as RequestHandler; +} + +/** + * Casts middleware with a narrowed request type to Express's RequestHandler. + */ +export function asMiddleware( + middleware: (req: TReq, res: Response, next: NextFunction) => void | Promise +): RequestHandler { + return middleware as RequestHandler; +} diff --git a/apps/backend/tsconfig.app.json b/apps/backend/tsconfig.app.json index 12487b442..6abdbcb48 100644 --- a/apps/backend/tsconfig.app.json +++ b/apps/backend/tsconfig.app.json @@ -4,6 +4,7 @@ "outDir": "../../dist/out-tsc", "target": "es2017", "module": "esnext", + "strict": true, "types": ["node", "express"] }, "exclude": ["src/**/*.spec.ts", "src/**/*.test.ts"], diff --git a/apps/frontend/tsconfig.json b/apps/frontend/tsconfig.json index 536e39367..193e062f6 100644 --- a/apps/frontend/tsconfig.json +++ b/apps/frontend/tsconfig.json @@ -3,6 +3,7 @@ "compilerOptions": { "jsx": "preserve", "strict": true, + "types": ["node", "@types/webpack-env", "@types/grecaptcha"], "noEmit": true, "emitDeclarationOnly": false, "esModuleInterop": true, @@ -12,7 +13,6 @@ "isolatedModules": true, "lib": [ "dom", - "dom.iterable", "esnext" ], "allowJs": true, diff --git a/apps/portal/tsconfig.json b/apps/portal/tsconfig.json index 9a7b281b3..54ea19f7f 100644 --- a/apps/portal/tsconfig.json +++ b/apps/portal/tsconfig.json @@ -3,6 +3,7 @@ "compilerOptions": { "jsx": "preserve", "strict": true, + "types": ["node", "@types/grecaptcha"], "noEmit": true, "emitDeclarationOnly": false, "esModuleInterop": true, @@ -12,7 +13,6 @@ "isolatedModules": true, "lib": [ "dom", - "dom.iterable", "esnext" ], "allowJs": true, diff --git a/libs/localization/tsconfig.json b/libs/localization/tsconfig.json index 95cfeb243..95cac5127 100644 --- a/libs/localization/tsconfig.json +++ b/libs/localization/tsconfig.json @@ -2,7 +2,6 @@ "compilerOptions": { "jsx": "react-jsx", "allowJs": false, - "esModuleInterop": false, "allowSyntheticDefaultImports": true, "strict": true }, diff --git a/libs/presentations/tsconfig.json b/libs/presentations/tsconfig.json index 95cfeb243..95cac5127 100644 --- a/libs/presentations/tsconfig.json +++ b/libs/presentations/tsconfig.json @@ -2,7 +2,6 @@ "compilerOptions": { "jsx": "react-jsx", "allowJs": false, - "esModuleInterop": false, "allowSyntheticDefaultImports": true, "strict": true }, diff --git a/libs/shared/src/lib/components/color-picker.tsx b/libs/shared/src/lib/components/color-picker.tsx index 567cd9abb..9b291f3fd 100644 --- a/libs/shared/src/lib/components/color-picker.tsx +++ b/libs/shared/src/lib/components/color-picker.tsx @@ -1,6 +1,6 @@ 'use client'; -import React, { useState, useEffect, CSSProperties } from 'react'; +import React, { useState, CSSProperties } from 'react'; import { motion } from 'motion/react'; import { Paper, @@ -42,17 +42,21 @@ export const ColorPicker: React.FC = ({ sx = {} }) => { const theme = useTheme(); - const [open, setOpen] = useState(false); + const [open, setOpen] = useState(defaultOpen); const [hexInput, setHexInput] = useState(hsvaToHex(value)); const [anchorEl, setAnchorEl] = useState(null); + const [prevValue, setPrevValue] = useState(value); + const [prevDefaultOpen, setPrevDefaultOpen] = useState(defaultOpen); - useEffect(() => { + if (value !== prevValue) { + setPrevValue(value); setHexInput(hsvaToHex(value)); - }, [value]); + } - useEffect(() => { - if (defaultOpen) setOpen(true); - }, [defaultOpen]); + if (defaultOpen !== prevDefaultOpen) { + setPrevDefaultOpen(defaultOpen); + setOpen(defaultOpen); + } const handleSaturationChange = (newHsva: HsvaColor) => { onChange(newHsva); diff --git a/libs/shared/src/lib/hooks/use-audio-player.ts b/libs/shared/src/lib/hooks/use-audio-player.ts index 1deb27bb5..12514b801 100644 --- a/libs/shared/src/lib/hooks/use-audio-player.ts +++ b/libs/shared/src/lib/hooks/use-audio-player.ts @@ -52,7 +52,7 @@ export const useAudioPlayer = ( audio.preload = preload; soundRefs.current[key as T] = audio; }); - setIsReady(true); + queueMicrotask(() => setIsReady(true)); } catch (error) { console.error('Failed to initialize audio:', error); } @@ -66,7 +66,7 @@ export const useAudioPlayer = ( } }); soundRefs.current = {} as Record; - setIsReady(false); + queueMicrotask(() => setIsReady(false)); }; }, [sounds, preload]); diff --git a/libs/shared/tsconfig.json b/libs/shared/tsconfig.json index 95cfeb243..95cac5127 100644 --- a/libs/shared/tsconfig.json +++ b/libs/shared/tsconfig.json @@ -2,7 +2,6 @@ "compilerOptions": { "jsx": "react-jsx", "allowJs": false, - "esModuleInterop": false, "allowSyntheticDefaultImports": true, "strict": true }, diff --git a/package-lock.json b/package-lock.json index cc5a0cd75..c701304c1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -105,12 +105,14 @@ "@swc/plugin-emotion": "^14.14.1", "@types/archiver": "^8.0.0", "@types/bcrypt": "^6.0.0", + "@types/cookie-parser": "^1.4.10", "@types/cors": "^2.8.19", "@types/express": "^5.0.6", "@types/express-fileupload": "^1.5.1", "@types/grecaptcha": "^3.0.9", "@types/ioredis": "^4.28.10", "@types/jsonwebtoken": "^9.0.10", + "@types/morgan": "^1.9.10", "@types/node": "^24.13.2", "@types/pg": "^8.20.0", "@types/react": "^19.2.17", @@ -134,7 +136,7 @@ "run-script-os": "^1.1.6", "ts-node": "10.9.2", "tsx": "^4.22.4", - "typescript": "^5.9.3", + "typescript": "^6.0.0", "typescript-eslint": "^8.62.1", "webpack": "5.108.3", "webpack-cli": "^7.0.0", @@ -4236,9 +4238,6 @@ "cpu": [ "arm" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -4255,9 +4254,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -4274,9 +4270,6 @@ "cpu": [ "ppc64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -4293,9 +4286,6 @@ "cpu": [ "riscv64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -4312,9 +4302,6 @@ "cpu": [ "s390x" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -4331,9 +4318,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -4350,9 +4334,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -4369,9 +4350,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -4388,9 +4366,6 @@ "cpu": [ "arm" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -4413,9 +4388,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -4438,9 +4410,6 @@ "cpu": [ "ppc64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -4463,9 +4432,6 @@ "cpu": [ "riscv64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -4488,9 +4454,6 @@ "cpu": [ "s390x" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -4513,9 +4476,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -4538,9 +4498,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -4563,9 +4520,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -6620,9 +6574,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -6639,9 +6590,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -6658,9 +6606,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -6677,9 +6622,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -6911,6 +6853,20 @@ "node": ">=10" } }, + "node_modules/@nx/eslint/node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, "node_modules/@nx/express": { "version": "23.0.1", "resolved": "https://registry.npmjs.org/@nx/express/-/express-23.0.1.tgz", @@ -7658,9 +7614,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -7675,9 +7628,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -7692,9 +7642,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -7709,9 +7656,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -10041,9 +9985,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -10062,9 +10003,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -10083,9 +10021,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -10104,9 +10039,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -10125,9 +10057,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -10146,9 +10075,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -10588,9 +10514,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -10605,9 +10528,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -10622,9 +10542,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -10639,9 +10556,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -11492,9 +11406,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -11511,9 +11422,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -11530,9 +11438,6 @@ "cpu": [ "ppc64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -11549,9 +11454,6 @@ "cpu": [ "s390x" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -11568,9 +11470,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -11587,9 +11486,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -11890,6 +11786,16 @@ "@types/node": "*" } }, + "node_modules/@types/cookie-parser": { + "version": "1.4.10", + "resolved": "https://registry.npmjs.org/@types/cookie-parser/-/cookie-parser-1.4.10.tgz", + "integrity": "sha512-B4xqkqfZ8Wek+rCOeRxsjMS9OgvzebEzzLYw7NHYuvzb7IdxOkI0ZHGgeEBX4PUM7QGVvNSK60T3OvWj3YfBRg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/express": "*" + } + }, "node_modules/@types/cors": { "version": "2.8.19", "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", @@ -12190,6 +12096,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/morgan": { + "version": "1.9.10", + "resolved": "https://registry.npmjs.org/@types/morgan/-/morgan-1.9.10.tgz", + "integrity": "sha512-sS4A1zheMvsADRVfT0lYbJ4S9lmsey8Zo2F7cnbYjWHP67Q0AwMYuuzLlkIM2N8gAbb9cubhIVFwcIN2XyYCkA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/ms": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", @@ -23036,9 +22952,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -23061,9 +22974,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -23086,9 +22996,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -23111,9 +23018,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -25026,9 +24930,6 @@ "cpu": [ "arm" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -25045,9 +24946,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -25064,9 +24962,6 @@ "cpu": [ "ppc64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -25083,9 +24978,6 @@ "cpu": [ "riscv64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -25102,9 +24994,6 @@ "cpu": [ "s390x" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -25121,9 +25010,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -25140,9 +25026,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -25159,9 +25042,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -25178,9 +25058,6 @@ "cpu": [ "arm" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -25203,9 +25080,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -25228,9 +25102,6 @@ "cpu": [ "ppc64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -25253,9 +25124,6 @@ "cpu": [ "riscv64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -25278,9 +25146,6 @@ "cpu": [ "s390x" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -25303,9 +25168,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -25328,9 +25190,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -25353,9 +25212,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -29911,7 +29767,6 @@ "arm" ], "dev": true, - "libc": "glibc", "license": "MIT", "optional": true, "os": [ @@ -29929,7 +29784,6 @@ "arm64" ], "dev": true, - "libc": "glibc", "license": "MIT", "optional": true, "os": [ @@ -29947,7 +29801,6 @@ "arm" ], "dev": true, - "libc": "musl", "license": "MIT", "optional": true, "os": [ @@ -29965,7 +29818,6 @@ "arm64" ], "dev": true, - "libc": "musl", "license": "MIT", "optional": true, "os": [ @@ -29983,7 +29835,6 @@ "riscv64" ], "dev": true, - "libc": "musl", "license": "MIT", "optional": true, "os": [ @@ -30001,7 +29852,6 @@ "x64" ], "dev": true, - "libc": "musl", "license": "MIT", "optional": true, "os": [ @@ -30019,7 +29869,6 @@ "riscv64" ], "dev": true, - "libc": "glibc", "license": "MIT", "optional": true, "os": [ @@ -30037,7 +29886,6 @@ "x64" ], "dev": true, - "libc": "glibc", "license": "MIT", "optional": true, "os": [ @@ -32579,9 +32427,9 @@ "license": "MIT" }, "node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", "dev": true, "license": "Apache-2.0", "bin": { diff --git a/package.json b/package.json index 277d2a794..4a7e06565 100644 --- a/package.json +++ b/package.json @@ -111,12 +111,14 @@ "@swc/plugin-emotion": "^14.14.1", "@types/archiver": "^8.0.0", "@types/bcrypt": "^6.0.0", + "@types/cookie-parser": "^1.4.10", "@types/cors": "^2.8.19", "@types/express": "^5.0.6", "@types/express-fileupload": "^1.5.1", "@types/grecaptcha": "^3.0.9", "@types/ioredis": "^4.28.10", "@types/jsonwebtoken": "^9.0.10", + "@types/morgan": "^1.9.10", "@types/node": "^24.13.2", "@types/pg": "^8.20.0", "@types/react": "^19.2.17", @@ -140,7 +142,7 @@ "run-script-os": "^1.1.6", "ts-node": "10.9.2", "tsx": "^4.22.4", - "typescript": "^5.9.3", + "typescript": "^6.0.0", "typescript-eslint": "^8.62.1", "webpack": "5.108.3", "webpack-cli": "^7.0.0", diff --git a/tsconfig.base.json b/tsconfig.base.json index 8fb88bdca..f6ddd2e25 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -11,17 +11,18 @@ "target": "es2024", "module": "es2022", "lib": ["ES2024", "dom"], + "strict": true, + "types": ["node"], "skipLibCheck": true, "skipDefaultLibCheck": true, - "baseUrl": ".", "paths": { - "@lems/database": ["libs/database/src/index.ts"], - "@lems/localization": ["libs/localization/src/index.ts"], - "@lems/presentations": ["libs/presentations/src/index.ts"], - "@lems/shared": ["libs/shared/src/index.ts"], - "@lems/shared/*": ["libs/shared/src/lib/*"], - "@lems/types": ["libs/types/src/index.ts"], - "@lems/types/*": ["libs/types/src/lib/*"], + "@lems/database": ["./libs/database/src/index.ts"], + "@lems/localization": ["./libs/localization/src/index.ts"], + "@lems/presentations": ["./libs/presentations/src/index.ts"], + "@lems/shared": ["./libs/shared/src/index.ts"], + "@lems/shared/*": ["./libs/shared/src/lib/*"], + "@lems/types": ["./libs/types/src/index.ts"], + "@lems/types/*": ["./libs/types/src/lib/*"] } }, "exclude": ["node_modules", "tmp"] From aee7a31c73950d8721fd98f7e4db7bedb4c3e547 Mon Sep 17 00:00:00 2001 From: itamaroryan Date: Sat, 4 Jul 2026 12:49:04 +0300 Subject: [PATCH 2/3] CR fixes --- apps/backend/src/lib/graphql/apollo-server.ts | 2 +- .../lib/graphql/resolvers/events/resolver.ts | 49 ++++--- .../scoresheets/update-mission-clause.ts | 8 +- apps/backend/src/lib/redis/redis-pubsub.ts | 4 +- apps/backend/src/main.ts | 4 +- .../admin/events/divisions/awards/utils.ts | 2 +- .../team/event/export/index.ts | 126 +++++++++--------- .../src/routers/portal/divisions/util.ts | 2 +- .../backend/src/routers/portal/events/util.ts | 2 +- .../backend/src/routers/portal/teams/index.ts | 72 +++++----- libs/types/src/lib/api/admin/awards.ts | 2 +- libs/types/src/lib/api/portal/divisions.ts | 2 +- libs/types/src/lib/api/portal/events.ts | 2 +- 13 files changed, 149 insertions(+), 128 deletions(-) diff --git a/apps/backend/src/lib/graphql/apollo-server.ts b/apps/backend/src/lib/graphql/apollo-server.ts index 2593bd0b5..4f7ef33c2 100644 --- a/apps/backend/src/lib/graphql/apollo-server.ts +++ b/apps/backend/src/lib/graphql/apollo-server.ts @@ -15,7 +15,7 @@ export const schema = makeExecutableSchema({ typeDefs, resolvers }); * Used for dependency injection (auth, dataloaders, etc.) */ export interface GraphQLContext { - user?: VolunteerUser; + user: VolunteerUser | null; } /** diff --git a/apps/backend/src/lib/graphql/resolvers/events/resolver.ts b/apps/backend/src/lib/graphql/resolvers/events/resolver.ts index 0efbcf598..6feb0f201 100644 --- a/apps/backend/src/lib/graphql/resolvers/events/resolver.ts +++ b/apps/backend/src/lib/graphql/resolvers/events/resolver.ts @@ -2,6 +2,7 @@ import { GraphQLFieldResolver } from 'graphql'; import { sql } from 'kysely'; import dayjs from 'dayjs'; import { Event } from '@lems/database'; +import { PortalEventResponseSchema } from '@lems/types/api/portal'; import db from '../../../database'; export interface EventGraphQL { @@ -68,14 +69,31 @@ function buildEventQuery(args: EventsArgs) { .selectFrom('events') .leftJoin('divisions', 'divisions.event_id', 'events.id') .leftJoin('event_settings', 'event_settings.event_id', 'events.id') - .select(['events.id', 'events.slug', 'events.name', 'events.start_date', 'events.end_date', 'events.region', 'events.timezone']) + .select([ + 'events.id', + 'events.slug', + 'events.name', + 'events.start_date', + 'events.end_date', + 'events.region', + 'events.timezone' + ]) .select( sql`COALESCE(BOOL_AND(divisions.has_awards AND divisions.has_users AND divisions.has_schedule), false)`.as( 'is_fully_set_up' ) ) .select('event_settings.official') - .groupBy(['events.id', 'events.slug', 'events.name', 'events.start_date', 'events.end_date', 'events.region', 'events.timezone', 'event_settings.official']); + .groupBy([ + 'events.id', + 'events.slug', + 'events.name', + 'events.start_date', + 'events.end_date', + 'events.region', + 'events.timezone', + 'event_settings.official' + ]); // Apply date filters if (args.startAfter) { @@ -110,26 +128,17 @@ function buildEventQuery(args: EventsArgs) { function buildResult( event: Partial & { is_fully_set_up?: boolean; official?: boolean | null } ): EventGraphQL { - if ( - !event.id || - !event.slug || - !event.name || - !event.start_date || - !event.end_date || - !event.region || - !event.timezone - ) { - throw new Error('Incomplete event data'); - } + // Validate with zod schema + const validatedEvent = PortalEventResponseSchema.parse(event); return { - id: event.id, - slug: event.slug, - name: event.name, - startDate: event.start_date.toISOString(), - endDate: event.end_date.toISOString(), - region: event.region, - timezone: event.timezone, + id: validatedEvent.id, + slug: validatedEvent.slug, + name: validatedEvent.name, + startDate: validatedEvent.startDate.toISOString(), + endDate: validatedEvent.endDate.toISOString(), + region: validatedEvent.region, + timezone: validatedEvent.timezone, isFullySetUp: event.is_fully_set_up, official: event.official ?? true }; diff --git a/apps/backend/src/lib/graphql/resolvers/mutations/scoresheets/update-mission-clause.ts b/apps/backend/src/lib/graphql/resolvers/mutations/scoresheets/update-mission-clause.ts index 9e09a6bad..07741d180 100644 --- a/apps/backend/src/lib/graphql/resolvers/mutations/scoresheets/update-mission-clause.ts +++ b/apps/backend/src/lib/graphql/resolvers/mutations/scoresheets/update-mission-clause.ts @@ -24,6 +24,8 @@ interface UpdateScoresheetMissionClauseArgs { value: ScoresheetClauseValue; } +type MissionData = Record>; + /** * Resolver for Mutation.updateScoresheetMissionClause * Updates a single mission clause value in a scoresheet @@ -62,7 +64,6 @@ export const updateScoresheetMissionClauseResolver: GraphQLFieldResolver< validateClauseValue(clause, value); // Calculate points - type MissionData = Record>; const data = (dbScoresheet.data ?? {}) as { missions?: MissionData }; data.missions ??= {}; data.missions[missionId] ??= {}; @@ -71,9 +72,8 @@ export const updateScoresheetMissionClauseResolver: GraphQLFieldResolver< // Determine new status based on completion criteria // Don't change status if already submitted or in gp status (locked states) - const newStatus = (status === 'submitted' || status === 'gp') - ? status - : determineScoresheetCompletionStatus(data); + const newStatus = + status === 'submitted' || status === 'gp' ? status : determineScoresheetCompletionStatus(data); const updateFields: Record = { [`data.missions.${missionId}.${clauseIndex}`]: value, diff --git a/apps/backend/src/lib/redis/redis-pubsub.ts b/apps/backend/src/lib/redis/redis-pubsub.ts index db44e10a3..4e0a6c0c6 100644 --- a/apps/backend/src/lib/redis/redis-pubsub.ts +++ b/apps/backend/src/lib/redis/redis-pubsub.ts @@ -130,8 +130,8 @@ export class RedisPubSub { isActive = false; if (timeoutHandle) clearTimeout(timeoutHandle); broadcaster.removeListener('event', messageHandler); - const pendingResolve = resolveWait as (() => void) | null; - if (pendingResolve) pendingResolve(); + // Typescript thinks resolveWait is of type never, so we need to cast it to a function + if (resolveWait) (resolveWait as () => void)(); broadcaster.decrementSubscribers(); } } diff --git a/apps/backend/src/main.ts b/apps/backend/src/main.ts index c5467932a..e3c236727 100644 --- a/apps/backend/src/main.ts +++ b/apps/backend/src/main.ts @@ -103,7 +103,7 @@ const serverCleanup = createGraphqlWsServer( schema, context: async (ctx): Promise => { const user = await authenticateWebsocket(ctx.connectionParams); - return { user: user ?? undefined }; + return { user }; }, onConnect: async () => { logger.info({ component: 'websocket' }, 'Client connected'); @@ -133,7 +133,7 @@ app.use( expressMiddleware(apolloServer, { context: async ({ req }): Promise => { const user = await authenticateHttp(req); - return { user: user ?? undefined }; + return { user }; } }) ); diff --git a/apps/backend/src/routers/admin/events/divisions/awards/utils.ts b/apps/backend/src/routers/admin/events/divisions/awards/utils.ts index 995da11f0..ca6b71802 100644 --- a/apps/backend/src/routers/admin/events/divisions/awards/utils.ts +++ b/apps/backend/src/routers/admin/events/divisions/awards/utils.ts @@ -19,6 +19,6 @@ export const makeAdminAwardResponse = (award: DbAward, includeWinner = false): A automaticAssignment: award.automatic_assignment, place: award.place, index: award.index, - ...(includeWinner && winner != null && { winner }) + ...(includeWinner && { winner }) }; }; diff --git a/apps/backend/src/routers/integrations/first-israel-dashboard/team/event/export/index.ts b/apps/backend/src/routers/integrations/first-israel-dashboard/team/event/export/index.ts index 8b300d678..3406b0677 100644 --- a/apps/backend/src/routers/integrations/first-israel-dashboard/team/event/export/index.ts +++ b/apps/backend/src/routers/integrations/first-israel-dashboard/team/event/export/index.ts @@ -1,61 +1,67 @@ -import express, { Response } from 'express'; -import { FirstIsraelDashboardEventRequest } from '../../../../../../types/express'; -import { getLemsWebpageAsPdf } from '../../../../export'; import { asHandler } from '../../../../../../types/express-handlers'; +import express, { Response } from 'express'; +import { FirstIsraelDashboardEventRequest } from '../../../../../../types/express'; +import { getLemsWebpageAsPdf } from '../../../../export'; +import { asHandler } from '../../../../../../types/express-handlers'; - -const router = express.Router({ mergeParams: true }); - -router.get('/rubrics', asHandler(async (req, res: Response) => { - try { - const pdf = await getLemsWebpageAsPdf( - `/he/lems/export/${req.teamSlug}/${req.eventSlug}/rubrics`, - { - teamSlug: req.teamSlug, - divsionId: req.divisionId - } - ); - - res.contentType('application/pdf'); - res.send(pdf); - } catch (error) { - console.error( - `[Export] Failed to generate rubrics PDF for team ${req.teamSlug} event ${req.eventSlug}:`, - error instanceof Error ? error.message : String(error) - ); - res.status(500).json({ - error: 'Failed to generate rubrics export PDF', - details: - error instanceof Error - ? error.message - : 'Unknown error occurred. Check that all rubrics are approved and data is complete.' - }); - } -})); - -router.get('/scoresheets', asHandler(async (req, res: Response) => { - try { - const pdf = await getLemsWebpageAsPdf( - `/he/lems/export/${req.teamSlug}/${req.eventSlug}/scoresheets`, - { - teamSlug: req.teamSlug, - divsionId: req.divisionId - } - ); - - res.contentType('application/pdf'); - res.send(pdf); - } catch (error) { - console.error( - `[Export] Failed to generate scoresheets PDF for team ${req.teamSlug} event ${req.eventSlug}:`, - error instanceof Error ? error.message : String(error) - ); - res.status(500).json({ - error: 'Failed to generate scoresheets export PDF', - details: - error instanceof Error - ? error.message - : 'Unknown error occurred. Check that all scoresheets are submitted and data is complete.' - }); - } -})); -export default router; +const router = express.Router({ mergeParams: true }); + +router.get( + '/rubrics', + asHandler(async (req, res: Response) => { + try { + const pdf = await getLemsWebpageAsPdf( + `/he/lems/export/${req.teamSlug}/${req.eventSlug}/rubrics`, + { + teamSlug: req.teamSlug, + divsionId: req.divisionId + } + ); + + res.contentType('application/pdf'); + res.send(pdf); + } catch (error) { + console.error( + `[Export] Failed to generate rubrics PDF for team ${req.teamSlug} event ${req.eventSlug}:`, + error instanceof Error ? error.message : String(error) + ); + res.status(500).json({ + error: 'Failed to generate rubrics export PDF', + details: + error instanceof Error + ? error.message + : 'Unknown error occurred. Check that all rubrics are approved and data is complete.' + }); + } + }) +); + +router.get( + '/scoresheets', + asHandler(async (req, res: Response) => { + try { + const pdf = await getLemsWebpageAsPdf( + `/he/lems/export/${req.teamSlug}/${req.eventSlug}/scoresheets`, + { + teamSlug: req.teamSlug, + divsionId: req.divisionId + } + ); + + res.contentType('application/pdf'); + res.send(pdf); + } catch (error) { + console.error( + `[Export] Failed to generate scoresheets PDF for team ${req.teamSlug} event ${req.eventSlug}:`, + error instanceof Error ? error.message : String(error) + ); + res.status(500).json({ + error: 'Failed to generate scoresheets export PDF', + details: + error instanceof Error + ? error.message + : 'Unknown error occurred. Check that all scoresheets are submitted and data is complete.' + }); + } + }) +); +export default router; diff --git a/apps/backend/src/routers/portal/divisions/util.ts b/apps/backend/src/routers/portal/divisions/util.ts index 5007bd844..e6d51dd56 100644 --- a/apps/backend/src/routers/portal/divisions/util.ts +++ b/apps/backend/src/routers/portal/divisions/util.ts @@ -30,7 +30,7 @@ export const makePortalAwardsResponse = (award: DbAward): Award => ({ name: award.name, type: award.type, showPlaces: award.show_places, - winner: (award.type === 'PERSONAL' ? award.winner_name : award.winner_id) ?? undefined, + winner: award.type === 'PERSONAL' ? award.winner_name : award.winner_id, place: award.place }); diff --git a/apps/backend/src/routers/portal/events/util.ts b/apps/backend/src/routers/portal/events/util.ts index 83ccb2bb2..d210c382e 100644 --- a/apps/backend/src/routers/portal/events/util.ts +++ b/apps/backend/src/routers/portal/events/util.ts @@ -25,7 +25,7 @@ export const makePortalEventResponse = (event: DbEvent | DbEventSummary): Event startDate, endDate, location: event.location, - coordinates: event.coordinates ?? undefined, + coordinates: event.coordinates, seasonId: event.season_id, region: event.region, timezone: event.timezone diff --git a/apps/backend/src/routers/portal/teams/index.ts b/apps/backend/src/routers/portal/teams/index.ts index c41c73a13..567ae8a70 100644 --- a/apps/backend/src/routers/portal/teams/index.ts +++ b/apps/backend/src/routers/portal/teams/index.ts @@ -10,7 +10,6 @@ import { getTeamRankingData } from '../utils/ranking-calculator'; import { asHandler, asMiddleware } from '../../../types/express-handlers'; import { makePortalTeamResponse, makePortalTeamSummaryResponse } from './util'; - const router = express.Router({ mergeParams: true }); router.get('/', async (req: Request, res: Response) => { @@ -40,48 +39,54 @@ router.use('/:teamSlug', attachTeam()); /** * Returns a team, as well as the latest season they competed at */ -router.get('/:teamSlug/summary', asHandler(async (req, res: Response) => { - const team = await db.teams.byId(req.teamId).get(); - if (!team) { - res.status(404).json({ error: 'Team not found' }); - return; - } +router.get( + '/:teamSlug/summary', + asHandler(async (req, res: Response) => { + const team = await db.teams.byId(req.teamId).get(); + if (!team) { + res.status(404).json({ error: 'Team not found' }); + return; + } - const teamEvents = await db.events.byTeam(req.teamId).getAllSummaries(); - const seasons = await db.seasons.getAll(); // Sorted by default + const teamEvents = await db.events.byTeam(req.teamId).getAllSummaries(); + const seasons = await db.seasons.getAll(); // Sorted by default - for (const season of seasons) { - if (teamEvents.some(event => event.season_id === season.id)) { - const seasonResponse = makePortalSeasonResponse(season); + for (const season of seasons) { + if (teamEvents.some(event => event.season_id === season.id)) { + const seasonResponse = makePortalSeasonResponse(season); - res.status(200).json(makePortalTeamSummaryResponse(team, seasonResponse)); - return; + res.status(200).json(makePortalTeamSummaryResponse(team, seasonResponse)); + return; + } } - } - res.status(200).json(makePortalTeamSummaryResponse(team, null)); -})); + res.status(200).json(makePortalTeamSummaryResponse(team, null)); + }) +); /** * Returns the seasons a team competed at */ -router.get('/:teamSlug/seasons', asHandler(async (req, res: Response) => { - const seasons = await db.seasons.getAll(); - const teamSeasons = new Set(); +router.get( + '/:teamSlug/seasons', + asHandler(async (req, res: Response) => { + const seasons = await db.seasons.getAll(); + const teamSeasons = new Set(); - const teamEvents = await db.events.byTeam(req.teamId).getAllSummaries(); + const teamEvents = await db.events.byTeam(req.teamId).getAllSummaries(); - for (const event of teamEvents) { - if (!event.visible) continue; + for (const event of teamEvents) { + if (!event.visible) continue; - if (!teamSeasons.has(event.season_id)) { - teamSeasons.add(event.season_id); + if (!teamSeasons.has(event.season_id)) { + teamSeasons.add(event.season_id); + } } - } - const filteredSeasons = seasons.filter(season => teamSeasons.has(season.id)); - res.status(200).json(filteredSeasons.map(makePortalSeasonResponse)); -})); + const filteredSeasons = seasons.filter(season => teamSeasons.has(season.id)); + res.status(200).json(filteredSeasons.map(makePortalSeasonResponse)); + }) +); type PortalTeamWithSeasonRequest = PortalTeamRequest & { seasonId?: string }; @@ -178,13 +183,12 @@ router.get( .getAll(); const submittedScoresheets = scoresheets.filter( - (s): s is typeof s & { data: { score: number } } => - s.status === 'submitted' && s.data?.score != null + s => s.status === 'submitted' && s.data?.score != null ); const scoresByRound = new Map(); for (const sheet of submittedScoresheets) { - scoresByRound.set(sheet.round, sheet.data.score); + scoresByRound.set(sheet.round, sheet.data?.score ?? 0); } const teamMatchResults = rankingMatches @@ -194,8 +198,10 @@ router.get( })) .filter((match): match is { number: number; score: number } => match.score != null); + const divisionTeams = await db.teams.byDivisionId(teamDivision).getAll(); + const rankingData = await getTeamRankingData(teamDivision, req.teamId); - const robotGameRank = rankingData?.rank ?? 0; + const robotGameRank = rankingData?.rank ?? divisionTeams.length; eventResult.results = { awards: teamAwards.map(award => ({ diff --git a/libs/types/src/lib/api/admin/awards.ts b/libs/types/src/lib/api/admin/awards.ts index 7337f8f92..b63ba4cd2 100644 --- a/libs/types/src/lib/api/admin/awards.ts +++ b/libs/types/src/lib/api/admin/awards.ts @@ -11,7 +11,7 @@ export const AdminAwardResponseSchema = z.object({ automaticAssignment: z.boolean(), place: z.number(), index: z.number(), - winner: z.string().optional() + winner: z.string().nullable().optional() }); export type Award = z.infer; diff --git a/libs/types/src/lib/api/portal/divisions.ts b/libs/types/src/lib/api/portal/divisions.ts index 4ac80c8a6..01390ceee 100644 --- a/libs/types/src/lib/api/portal/divisions.ts +++ b/libs/types/src/lib/api/portal/divisions.ts @@ -14,7 +14,7 @@ export const PortalAwardResponseSchema = z.object({ type: z.enum(['PERSONAL', 'TEAM']), showPlaces: z.boolean(), place: z.number(), - winner: z.string().optional() + winner: z.string().nullable().optional() }); const Team = z.object({ diff --git a/libs/types/src/lib/api/portal/events.ts b/libs/types/src/lib/api/portal/events.ts index adf93d07f..484552895 100644 --- a/libs/types/src/lib/api/portal/events.ts +++ b/libs/types/src/lib/api/portal/events.ts @@ -9,7 +9,7 @@ export const PortalEventResponseSchema = z.object({ location: z.string(), region: z.string(), timezone: z.string(), - coordinates: z.string().optional(), + coordinates: z.string().nullable().optional(), seasonId: z.string() }); From 2cfdf13c62bf7416ec3c7bdbc78f3a5b3e2599a0 Mon Sep 17 00:00:00 2001 From: itamaroryan Date: Sat, 4 Jul 2026 21:09:45 +0300 Subject: [PATCH 3/3] Run formatting on the repo --- .../components/widgets/current-season.tsx | 35 +- .../components/widgets/number-widget.tsx | 14 +- .../upcoming-events/event-list-item.tsx | 21 +- .../upcoming-events/upcoming-events.tsx | 5 +- .../[locale]/(dashboard)/dev/graphql/page.tsx | 5 +- .../src/app/[locale]/(dashboard)/error.tsx | 9 +- .../[slug]/awards/components/award-item.tsx | 15 +- .../awards/components/awards-header.tsx | 45 +- .../awards/components/awards-validation.tsx | 79 +- .../[slug]/components/division-selector.tsx | 11 +- .../[slug]/components/event-page-title.tsx | 17 +- .../components/create-division-dialog.tsx | 10 +- .../divisions/components/divisions-table.tsx | 41 +- .../edit/components/division-color-editor.tsx | 32 +- .../edit/components/edit-event-card.tsx | 5 +- .../edit/components/editable-event-title.tsx | 25 +- .../edit/components/event-information.tsx | 60 +- .../components/add-integration-card.tsx | 7 +- .../components/add-integration-dialog.tsx | 27 +- .../components/integration-card.tsx | 7 +- .../components/integration-detail-panel.tsx | 18 +- .../sendgrid/components/sendgrid-settings.tsx | 19 +- .../sendgrid/components/settings-section.tsx | 2 +- .../preview-view/contacts-list-section.tsx | 8 +- .../preview-view/preview-view.tsx | 27 +- .../upload-contacts-modal/upload-form.tsx | 9 +- .../upload-instructions.tsx | 20 +- .../view-all-modal/view-all-modal.tsx | 9 +- .../dialog/edit-agenda-dialog.tsx | 9 +- .../calendar/agenda-column/agenda-column.tsx | 13 +- .../agenda-column/use-drag-handlers.ts | 37 +- .../components/calendar/calendar-grid.tsx | 25 +- .../components/calendar/calendar-header.tsx | 10 +- .../components/calendar/calendar-types.ts | 5 +- .../components/calendar/calender-column.tsx | 8 +- .../components/calendar/drag-utils.ts | 4 +- .../[slug]/schedule/components/link-card.tsx | 9 +- .../schedule/components/schedule-exists.tsx | 9 +- .../schedule/components/schedule-manager.tsx | 9 +- .../schedule/components/schedule-settings.tsx | 180 ++- .../team-swapper/judging-session-selector.tsx | 32 +- .../team-swapper/team-schedule-view.tsx | 25 +- .../components/team-swapper/team-selector.tsx | 14 +- .../components/team-swapper/team-swapper.tsx | 14 +- .../components/complete-event-dialog.tsx | 6 +- .../components/danger-zone-section.tsx | 54 +- .../components/download-results-dialog.tsx | 18 +- .../components/event-actions-section.tsx | 60 +- .../components/event-settings-section.tsx | 39 +- .../components/publish-event-dialog.tsx | 20 +- .../events/[slug]/settings/page.tsx | 50 +- .../components/event-teams-split-view.tsx | 9 +- .../register-teams-dialog-content.tsx | 5 +- .../components/register-teams-dialog.tsx | 13 +- .../register-teams-from-csv-button.tsx | 14 +- .../register-teams-from-csv-dialog.tsx | 69 +- .../teams/components/remove-team-button.tsx | 2 +- .../teams/components/schedule-exists.tsx | 16 +- .../users/components/event-admins-section.tsx | 28 +- .../volunteer-roles/managed-roles.tsx | 40 +- .../volunteer-roles/mandatory-roles.tsx | 5 +- .../volunteer-roles/optional-roles.tsx | 5 +- .../volunteer-users-section.tsx | 71 +- .../[slug]/venue/components/asset-cell.tsx | 32 +- .../[slug]/venue/components/asset-manager.tsx | 17 +- .../venue/components/pit-map-manager.tsx | 19 +- .../venue/components/schedule-exists.tsx | 2 +- .../events/components/current-season.tsx | 8 +- .../events/components/event-card.tsx | 32 +- .../events/components/event-grid.tsx | 7 +- .../missing-info/missing-info-dialog.tsx | 14 +- .../events/components/previous-season.tsx | 7 +- .../events/components/season-header.tsx | 26 +- .../create/components/create-event-layout.tsx | 25 +- .../create/components/division-item.tsx | 16 +- .../src/app/[locale]/(dashboard)/layout.tsx | 9 +- .../seasons/components/create-season-card.tsx | 13 +- .../components/create-season-dialog.tsx | 10 +- .../seasons/components/season-card.tsx | 11 +- .../seasons/components/seasons-grid.tsx | 4 +- .../teams/components/import-team-dialog.tsx | 47 +- .../teams/components/logo-upload-dialog.tsx | 11 +- .../teams/components/team-form.tsx | 10 +- .../app/[locale]/(dashboard)/teams/page.tsx | 5 +- .../users/components/create-user-dialog.tsx | 10 +- .../users/components/password-field.tsx | 11 +- .../password-validation-indicator.tsx | 5 +- .../components/permissions-editor-dialog.tsx | 18 +- .../app/[locale]/(dashboard)/users/page.tsx | 5 +- .../src/app/[locale]/login/login-form.tsx | 18 +- apps/admin/src/app/[locale]/not-found.tsx | 5 +- apps/admin/src/app/error.tsx | 15 +- .../divisions/field/audience-display.ts | 8 +- .../judging-open-rubrics-during-session.ts | 6 +- .../judging/judging-session-length.ts | 4 +- .../resolvers/mutations/rubrics/utils.ts | 6 +- apps/backend/src/lib/queues/types.ts | 4 +- apps/backend/src/lib/security/credentials.ts | 5 +- apps/backend/src/routers/admin/auth.ts | 70 +- .../admin/events/divisions/awards/index.ts | 11 +- .../routers/admin/events/divisions/index.ts | 33 +- .../admin/events/divisions/pit-map/index.ts | 130 +- .../admin/events/divisions/rooms/index.ts | 187 +-- .../admin/events/divisions/schedule/agenda.ts | 74 +- .../admin/events/divisions/schedule/util.ts | 14 +- .../admin/events/divisions/tables/index.ts | 187 +-- .../admin/events/divisions/teams/index.ts | 29 +- .../backend/src/routers/admin/events/index.ts | 513 +++---- .../admin/events/integrations/index.ts | 333 ++--- .../routers/admin/events/settings/index.ts | 371 +++--- .../src/routers/admin/events/teams/index.ts | 24 +- .../src/routers/admin/events/users/index.ts | 291 ++-- .../src/routers/admin/events/users/util.ts | 55 +- .../admin/middleware/attach-division.ts | 1 - .../routers/admin/middleware/attach-event.ts | 1 - .../middleware/require-event-assignment.ts | 1 - .../admin/middleware/require-permission.ts | 1 - .../src/routers/admin/seasons/index.ts | 104 +- apps/backend/src/routers/admin/teams/index.ts | 139 +- apps/backend/src/routers/admin/users/index.ts | 213 +-- .../src/routers/admin/users/permissions.ts | 156 ++- .../src/routers/admin/users/register.ts | 117 +- .../team/event/index.ts | 146 +- .../routers/integrations/sendgrid/index.ts | 275 ++-- apps/backend/src/routers/lems/export/index.ts | 448 ++++--- .../src/routers/portal/divisions/index.ts | 201 +-- .../src/routers/portal/events/index.ts | 56 +- .../src/routers/portal/events/teams/index.ts | 160 ++- apps/backend/src/routers/portal/teams/util.ts | 5 +- .../src/routers/scheduler/divisions/index.ts | 540 ++++---- apps/frontend/locale/he.json | 2 +- .../login/components/login-error-boundary.tsx | 10 +- .../[event]/login/components/login-form.tsx | 8 +- .../login/components/next-step-button.tsx | 9 +- .../login/components/step-indicator.tsx | 7 +- .../step-summaries/completed-step-summary.tsx | 15 +- .../login/components/steps/division-step.tsx | 5 +- .../login/components/steps/password-step.tsx | 18 +- .../login/components/steps/role-info-step.tsx | 5 +- .../login/components/steps/user-step.tsx | 5 +- .../components/homepage/event-card.tsx | 68 +- .../app/[locale]/components/homepage/hero.tsx | 50 +- .../homepage/live-events-section.tsx | 35 +- .../homepage/upcoming-events-section.tsx | 35 +- .../components/time-sync-provider.tsx | 10 +- .../frontend/src/app/[locale]/events/page.tsx | 40 +- .../components/desktop/division-switcher.tsx | 10 +- .../components/desktop/navigation-list.tsx | 17 +- .../(dashboard)/components/mobile/app-bar.tsx | 5 +- .../components/mobile/division-switcher.tsx | 10 +- .../components/mobile/navigation-list.tsx | 6 +- .../components/mobile/user-info-section.tsx | 13 +- .../components/sound-test-dialog.tsx | 23 +- .../(dashboard)/components/team-info.tsx | 5 +- .../components/active-match-display.tsx | 73 +- .../components/field-head-queuer-context.tsx | 4 +- .../components/field-schedule.tsx | 58 +- .../components/judging-schedule.tsx | 39 +- .../components/judging-status-timer.tsx | 70 +- .../field-head-queuer/graphql/query.ts | 2 +- .../components/field-schedule-view.tsx | 45 +- .../field-queuer/components/pit-map-view.tsx | 27 +- .../components/team-queue-card.tsx | 71 +- .../(dashboard)/field-queuer/graphql/query.ts | 2 +- .../subscriptions/match-call-updated.ts | 24 +- .../(dashboard)/field-queuer/page.tsx | 24 +- .../components/desktop-schedule/match-row.tsx | 28 +- .../escalated-scoresheets-panel.tsx | 9 +- .../head-referee/components/filters.tsx | 16 +- .../components/mobile-schedule-cards.tsx | 30 +- .../components/round-schedule.tsx | 5 +- .../category-deliberation-card.tsx | 45 +- .../deliberation-status-section.tsx | 7 +- .../deliberation/final-deliberation-card.tsx | 15 +- .../components/personal-awards-section.tsx | 22 +- .../assign-award-confirmation-dialog.tsx | 36 +- .../components/rubric-status-glossary.tsx | 5 +- .../components/rubric-status-summary.tsx | 46 +- .../components/status-filter-selector.tsx | 10 +- .../(dashboard)/judge-advisor/page.tsx | 18 +- .../schedule/room-schedule-table.tsx | 17 +- .../schedule/rubric-status-glossary.tsx | 5 +- .../schedule/session-status-indicator.tsx | 14 +- .../schedule/start-session-button.tsx | 2 +- .../timer/judging-session-context.tsx | 4 +- .../timer/judging-timer-desktop-layout.tsx | 52 +- .../timer/judging-timer-mobile-layout.tsx | 28 +- .../judge/components/timer/stage-timeline.tsx | 24 +- .../components/current-sessions-display.tsx | 64 +- .../components/judging-schedule.tsx | 73 +- .../components/judging-schedule-view.tsx | 63 +- .../components/pit-map-view.tsx | 27 +- .../components/team-queue-card.tsx | 71 +- .../(dashboard)/judging-queuer/page.tsx | 24 +- .../lems/(volunteer)/(dashboard)/layout.tsx | 3 +- .../components/rubric-status-glossary.tsx | 5 +- .../components/rubric-status-summary.tsx | 7 +- .../lead-judge/components/session-filters.tsx | 7 +- .../components/status-filter-selector.tsx | 10 +- .../components/team-session-card.tsx | 9 +- .../lead-judge/components/utils.ts | 1 - .../(dashboard)/mc/components/award-page.tsx | 41 +- .../(dashboard)/mc/components/awards-view.tsx | 18 +- .../mc/components/current-match-hero.tsx | 14 +- .../mc/components/match-schedule-table.tsx | 10 +- .../mc/components/mc-loading-skeleton.tsx | 16 +- .../(dashboard)/mc/components/nav-buttons.tsx | 7 +- .../lems/(volunteer)/(dashboard)/mc/page.tsx | 9 +- .../pit-admin/components/arrivals-stats.tsx | 55 +- .../referee/components/inspection-timer.tsx | 28 +- .../referee/components/match-timer.tsx | 16 +- .../referee/components/no-match.tsx | 18 +- .../referee/components/schedule.tsx | 37 +- .../awards-list/components/award-card.tsx | 8 +- .../awards-list/components/empty-state.tsx | 9 +- .../awards-list/components/error-state.tsx | 9 +- .../awards-list/components/loading-state.tsx | 10 +- .../(dashboard)/reports/awards-list/page.tsx | 9 +- .../components/agenda-event-card.tsx | 56 +- .../event-agenda/components/empty-state.tsx | 9 +- .../event-agenda/components/error-state.tsx | 9 +- .../event-agenda/components/loading-state.tsx | 9 +- .../components/agenda-event-row.tsx | 8 +- .../field-schedule/components/empty-state.tsx | 9 +- .../components/loading-state.tsx | 9 +- .../field-schedule/components/match-row.tsx | 25 +- .../components/round-schedule.tsx | 15 +- .../reports/field-schedule/graphql/query.ts | 31 +- .../components/match-countdown.tsx | 5 +- .../components/next-match-panel.tsx | 57 +- .../components/upcoming-matches.tsx | 9 +- .../field-timer/components/loading-state.tsx | 9 +- .../field-timer/components/match-info.tsx | 3 +- .../components/empty-state.tsx | 9 +- .../components/error-state.tsx | 9 +- .../components/loading-state.tsx | 9 +- .../components/schedule-table.tsx | 37 +- .../components/judging-status-mobile.tsx | 27 +- .../components/judging-status-table.tsx | 27 +- .../components/next-session-row.tsx | 16 +- .../components/session-card.tsx | 39 +- .../judging-status/components/session-row.tsx | 41 +- .../components/status-legend.tsx | 5 +- .../reports/judging-status/page.tsx | 5 +- .../(dashboard)/reports/pit-map/page.tsx | 34 +- .../scoreboard/components/error-state.tsx | 9 +- .../scoreboard/components/loading-state.tsx | 10 +- .../components/mobile-scoreboard.tsx | 67 +- .../components/scoreboard-table.tsx | 11 +- .../team-list/components/arrival-stats.tsx | 37 +- .../components/desktop-team-list-table.tsx | 10 +- .../components/mobile-team-list-table.tsx | 18 +- .../(dashboard)/reports/team-list/page.tsx | 19 +- .../components/audience-display-control.tsx | 10 +- .../awards-presentation-wrapper.tsx | 4 +- .../awards-presentation/display.tsx | 16 +- .../navigation-buttons.tsx | 7 +- .../awards-presentation/slide-display.tsx | 8 +- .../loaded-match/loaded-match-display.tsx | 12 +- .../loaded-match/team-status-legend.tsx | 5 +- .../components/loaded-match/utils.tsx | 8 +- .../scorekeeper-loading-skeleton.tsx | 18 +- .../graphql/subscriptions/match-completed.ts | 4 +- .../(dashboard)/scorekeeper/graphql/types.ts | 7 +- .../(dashboard)/scorekeeper/page.tsx | 52 +- .../components/award-nominations.tsx | 14 +- .../[category]/components/feedback-row.tsx | 10 +- .../components/field-rating-row.tsx | 8 +- .../components/section-title-row.tsx | 15 +- .../components/table-header-row.tsx | 5 +- .../[teamSlug]/rubric/[category]/page.tsx | 5 +- .../rubric/[category]/rubric-validation.ts | 4 +- .../components/gp-selector.tsx | 12 +- .../components/mission-clause.tsx | 18 +- .../participant-not-present-modal.tsx | 10 +- .../components/score-floater.tsx | 18 +- .../components/scoresheet-action-buttons.tsx | 25 +- .../components/scoresheet-alert.tsx | 7 +- .../components/scoresheet-form.tsx | 13 +- .../components/scoresheet-mission.tsx | 84 +- .../components/signature-actions.tsx | 7 +- .../components/signature-canvas.tsx | 2 +- .../scoresheet/[scoresheetSlug]/page.tsx | 7 +- .../components/missing-teams-alert.tsx | 11 +- .../schedule/field-schedule-table.tsx | 30 +- .../schedule/judging-schedule-table.tsx | 19 +- .../field-matches-list.tsx | 20 +- .../judging-sessions-list.tsx | 20 +- .../second-slot-info.tsx | 39 +- .../selected-slot-header.tsx | 35 +- .../team-selection-drawer.tsx | 7 +- .../components/team-slot.tsx | 9 +- .../match-preview/match-participant-card.tsx | 15 +- .../match-preview/match-preview-display.tsx | 42 +- .../components/scoreboard/active-match.tsx | 44 +- .../scoreboard/scoreboard-display.tsx | 9 +- .../components/scoreboard/team-score-card.tsx | 7 +- .../components/settings/settings-modal.tsx | 3 +- .../audience-display/graphql/types.ts | 7 +- .../(volunteer)/audience-display/page.tsx | 4 +- .../components/connection-indicator.tsx | 5 +- .../complete-deliberation-modal.tsx | 9 +- .../components/deliberation-table.tsx | 8 +- .../[category]/components/metrics.tsx | 10 +- .../[category]/deliberation-computation.ts | 5 +- .../compare/components/category-filter.tsx | 18 +- .../compare/components/empty-state.tsx | 15 +- .../compare/components/exceeding-notes.tsx | 19 +- .../compare/components/feedback.tsx | 9 +- .../compare/components/gp-scores.tsx | 14 +- .../compare/components/nominations.tsx | 9 +- .../compare/components/radar-charts.tsx | 7 +- .../compare/components/rubric-scores.tsx | 10 +- .../compare/components/score-summary.tsx | 79 +- .../compare/components/section-score-row.tsx | 7 +- .../compare/components/team-info.tsx | 27 +- .../compare/components/team-selector.tsx | 25 +- .../deliberation/components/room-metrics.tsx | 9 +- .../components/small-screen-block.tsx | 5 +- .../components/champions/champions-podium.tsx | 9 +- .../optional-awards-data-grid.tsx | 8 +- .../final/components/review/review-stage.tsx | 5 +- .../final-deliberation-updated.ts | 5 +- .../lems/(volunteer)/deliberation/utils.ts | 4 +- .../components/combined-feedback-table.tsx | 6 +- .../components/export-rubric-table.tsx | 33 +- .../[teamSlug]/[eventSlug]/rubrics/page.tsx | 8 +- .../components/export-scoresheet-mission.tsx | 34 +- .../[eventSlug]/scoresheets/page.tsx | 7 +- apps/portal/locale/he.json | 2 +- .../src/app/[locale]/components/app-bar.tsx | 27 +- .../components/homepage/events-section.tsx | 22 +- .../app/[locale]/components/homepage/hero.tsx | 25 +- .../components/homepage/live-icon.tsx | 13 +- .../homepage/quick-actions-section.tsx | 42 +- .../homepage/resource-links-section.tsx | 42 +- .../homepage/search/search-section.tsx | 89 +- .../components/homepage/search/utils.tsx | 5 +- .../app/[locale]/components/nav-search.tsx | 105 +- .../[slug]/components/division-selector.tsx | 23 +- .../[slug]/components/division-tab-bar.tsx | 8 +- .../event/[slug]/components/event-header.tsx | 53 +- .../[slug]/components/tabs/agenda-tab.tsx | 60 +- .../components/tabs/awards/award-row.tsx | 6 +- .../components/tabs/awards/awards-tab.tsx | 18 +- .../components/tabs/field-schedule-tab.tsx | 59 +- .../components/tabs/judging-schedule-tab.tsx | 34 +- .../[slug]/components/tabs/loading-tab.tsx | 9 +- .../tabs/scoreboard/desktop-scoreboard.tsx | 11 +- .../tabs/scoreboard/mobile-scoreboard.tsx | 67 +- .../[slug]/components/tabs/teams-tab.tsx | 30 +- .../event-summary/event-summary.tsx | 5 +- .../event-summary/match-results.tsx | 24 +- .../components/event-summary/performance.tsx | 58 +- .../components/team-info-header.tsx | 38 +- .../[teamSlug]/components/team-schedule.tsx | 41 +- .../events/components/event-list-item.tsx | 45 +- .../events/components/event-list-section.tsx | 15 +- .../events/components/event-section.tsx | 5 +- .../events/components/events-page-header.tsx | 5 +- .../components/events-search-section.tsx | 11 +- apps/portal/src/app/[locale]/page.tsx | 10 +- .../[teamSlug]/components/season-selector.tsx | 8 +- .../components/team-event-result-card.tsx | 117 +- .../[teamSlug]/components/team-header.tsx | 59 +- .../components/unpublished-event-card.tsx | 36 +- .../app/[locale]/teams/[teamSlug]/error.tsx | 15 +- .../teams/components/team-list-item.tsx | 11 +- .../[locale]/teams/components/team-list.tsx | 19 +- apps/portal/src/app/[locale]/teams/page.tsx | 5 +- .../components/desktop/feedback-row.tsx | 10 +- .../components/desktop/field-rating-row.tsx | 10 +- .../components/desktop/section-title-row.tsx | 15 +- .../components/desktop/table-header-row.tsx | 5 +- .../rubrics/components/judging-timer.tsx | 49 +- .../components/mobile/mobile-feedback.tsx | 3 +- .../mobile/mobile-field-section.tsx | 9 +- .../components/mobile/mobile-section.tsx | 3 +- .../rubrics/components/rubric-header.tsx | 17 +- .../tools/rubrics/hooks/use-judging-timer.ts | 2 +- .../tools/scorer/components/field-timer.tsx | 27 +- .../scorer/components/mission-clause.tsx | 18 +- .../tools/scorer/components/score-floater.tsx | 12 +- .../scorer/components/scoresheet-form.tsx | 13 +- .../scorer/components/scoresheet-mission.tsx | 86 +- .../src/app/[locale]/tools/scorer/page.tsx | 22 +- ...ot_game_tables_matches_and_participants.ts | 2 +- .../015_add_arrived_at_to_team_divisions.ts | 10 +- .../024_add_location_to_agenda_events.ts | 10 +- .../src/repositories/robot-game-matches.ts | 14 +- libs/database/src/repositories/rooms.ts | 6 +- libs/database/src/repositories/rubrics.ts | 3 +- libs/database/src/repositories/scoresheets.ts | 3 +- libs/database/src/repositories/tables.ts | 4 +- .../src/schema/documents/division-state.ts | 7 +- libs/localization/src/lib/locale/he.json | 2 +- libs/localization/src/lib/locale/pl.json | 3 +- .../src/lib/components/logo-stack.tsx | 7 +- .../slides/advancing-teams-slide.tsx | 12 +- .../slides/award-winner-chroma-slide.tsx | 7 +- .../components/slides/award-winner-slide.tsx | 7 +- .../src/lib/components/slides/title-slide.tsx | 7 +- .../src/lib/components/color-picker.tsx | 33 +- .../shared/src/lib/components/file-upload.tsx | 5 +- libs/shared/src/lib/components/flag.tsx | 6 +- .../formik/formik-conditional-text-field.tsx | 9 +- .../src/lib/components/number-input.tsx | 32 +- .../lib/components/responsive-component.tsx | 20 +- libs/shared/src/lib/consts.ts | 2 +- libs/shared/src/lib/hooks/use-audio-player.ts | 4 +- libs/shared/src/lib/utils/timezones.ts | 1186 ++++++++--------- .../lib/api/lems/graphql/volunteer.graphql | 8 +- libs/types/src/lib/api/scheduler/teams.ts | 2 +- 413 files changed, 7945 insertions(+), 5697 deletions(-) diff --git a/apps/admin/src/app/[locale]/(dashboard)/components/widgets/current-season.tsx b/apps/admin/src/app/[locale]/(dashboard)/components/widgets/current-season.tsx index 63b9da993..98787b5a4 100644 --- a/apps/admin/src/app/[locale]/(dashboard)/components/widgets/current-season.tsx +++ b/apps/admin/src/app/[locale]/(dashboard)/components/widgets/current-season.tsx @@ -19,20 +19,25 @@ export default async function CurrentSeasonWidget() { + alignItems: 'center', + textAlign: 'center' + }} + > + }} + > {t('error')} - + {t('error-description')} @@ -62,11 +67,12 @@ export default async function CurrentSeasonWidget() { + width: '100%' + }} + > + }} + > {t('current-season')} diff --git a/apps/admin/src/app/[locale]/(dashboard)/components/widgets/number-widget.tsx b/apps/admin/src/app/[locale]/(dashboard)/components/widgets/number-widget.tsx index a3e3db3db..a15026c89 100644 --- a/apps/admin/src/app/[locale]/(dashboard)/components/widgets/number-widget.tsx +++ b/apps/admin/src/app/[locale]/(dashboard)/components/widgets/number-widget.tsx @@ -13,11 +13,12 @@ export default function NumberWidget({ value, description, icon }: NumberWidgetP + width: '100%' + }} + > {icon && ( + }} + > {description} diff --git a/apps/admin/src/app/[locale]/(dashboard)/components/widgets/upcoming-events/event-list-item.tsx b/apps/admin/src/app/[locale]/(dashboard)/components/widgets/upcoming-events/event-list-item.tsx index 82a47fb1f..51b974998 100644 --- a/apps/admin/src/app/[locale]/(dashboard)/components/widgets/upcoming-events/event-list-item.tsx +++ b/apps/admin/src/app/[locale]/(dashboard)/components/widgets/upcoming-events/event-list-item.tsx @@ -28,11 +28,12 @@ export default function EventListItem({ event }: EventListItemProps) { > + }} + > + }} + > {event.location} + }} + > + }} + > {t('no-events')} diff --git a/apps/admin/src/app/[locale]/(dashboard)/dev/graphql/page.tsx b/apps/admin/src/app/[locale]/(dashboard)/dev/graphql/page.tsx index 40a195bd2..10e973ce8 100644 --- a/apps/admin/src/app/[locale]/(dashboard)/dev/graphql/page.tsx +++ b/apps/admin/src/app/[locale]/(dashboard)/dev/graphql/page.tsx @@ -38,9 +38,10 @@ export default function GraphQLSchemaPage() { + }} + > Explore and interact with the GraphQL schema for the LEMS application. diff --git a/apps/admin/src/app/[locale]/(dashboard)/error.tsx b/apps/admin/src/app/[locale]/(dashboard)/error.tsx index 30d3f091f..483275bdd 100644 --- a/apps/admin/src/app/[locale]/(dashboard)/error.tsx +++ b/apps/admin/src/app/[locale]/(dashboard)/error.tsx @@ -16,9 +16,12 @@ export default function Error({ return ( - + Something went wrong {error.message || 'Unexpected error'} diff --git a/apps/admin/src/app/[locale]/(dashboard)/events/[slug]/schedule/components/calendar/calendar-types.ts b/apps/admin/src/app/[locale]/(dashboard)/events/[slug]/schedule/components/calendar/calendar-types.ts index ca072e798..cb8dcfd5b 100644 --- a/apps/admin/src/app/[locale]/(dashboard)/events/[slug]/schedule/components/calendar/calendar-types.ts +++ b/apps/admin/src/app/[locale]/(dashboard)/events/[slug]/schedule/components/calendar/calendar-types.ts @@ -1,10 +1,7 @@ import { Dayjs } from 'dayjs'; export type ScheduleBlockType = - | 'practice-round' - | 'ranking-round' - | 'judging-session' - | 'agenda-event'; + 'practice-round' | 'ranking-round' | 'judging-session' | 'agenda-event'; export type ScheduleColumn = 'judging' | 'field' | 'agenda'; export type AgendaBlockVisibility = 'public' | 'field' | 'judging' | 'teams'; diff --git a/apps/admin/src/app/[locale]/(dashboard)/events/[slug]/schedule/components/calendar/calender-column.tsx b/apps/admin/src/app/[locale]/(dashboard)/events/[slug]/schedule/components/calendar/calender-column.tsx index e04c6ed61..9751a00a4 100644 --- a/apps/admin/src/app/[locale]/(dashboard)/events/[slug]/schedule/components/calendar/calender-column.tsx +++ b/apps/admin/src/app/[locale]/(dashboard)/events/[slug]/schedule/components/calendar/calender-column.tsx @@ -19,9 +19,11 @@ export const CalendarColumn: React.FC = ({ name, handleDrag const columnBlocks = blocks[name]; return ( - + = ({ {title} - + {description} diff --git a/apps/admin/src/app/[locale]/(dashboard)/events/[slug]/schedule/components/schedule-exists.tsx b/apps/admin/src/app/[locale]/(dashboard)/events/[slug]/schedule/components/schedule-exists.tsx index 0f83e5be4..11157a7a0 100644 --- a/apps/admin/src/app/[locale]/(dashboard)/events/[slug]/schedule/components/schedule-exists.tsx +++ b/apps/admin/src/app/[locale]/(dashboard)/events/[slug]/schedule/components/schedule-exists.tsx @@ -57,9 +57,12 @@ export const ScheduleExists: React.FC = ({ division }) => { return ( <> - + } diff --git a/apps/admin/src/app/[locale]/(dashboard)/events/[slug]/schedule/components/schedule-manager.tsx b/apps/admin/src/app/[locale]/(dashboard)/events/[slug]/schedule/components/schedule-manager.tsx index 791a8d7cf..e2b67f3db 100644 --- a/apps/admin/src/app/[locale]/(dashboard)/events/[slug]/schedule/components/schedule-manager.tsx +++ b/apps/admin/src/app/[locale]/(dashboard)/events/[slug]/schedule/components/schedule-manager.tsx @@ -27,9 +27,12 @@ const ScheduleManagerContent: React.FC = ({ division }) => if (teamsCount === 0 || roomsCount === 0 || tablesCount === 0) { return ( - + } sx={{ py: 0.5 }}> {t('alerts.missing-details')} diff --git a/apps/admin/src/app/[locale]/(dashboard)/events/[slug]/schedule/components/schedule-settings.tsx b/apps/admin/src/app/[locale]/(dashboard)/events/[slug]/schedule/components/schedule-settings.tsx index 8a8a8f49d..a97ce4ecd 100644 --- a/apps/admin/src/app/[locale]/(dashboard)/events/[slug]/schedule/components/schedule-settings.tsx +++ b/apps/admin/src/app/[locale]/(dashboard)/events/[slug]/schedule/components/schedule-settings.tsx @@ -77,9 +77,13 @@ export const ScheduleSettings: React.FC = () => { }} > - + {t('title')} @@ -87,33 +91,50 @@ export const ScheduleSettings: React.FC = () => { + }} + > {t('information.title')} - - + + {t('information.teams')}: {teamsCount} - + {t('information.rooms')}: {roomsCount} - + {t('information.tables')}: {tablesCount} @@ -123,20 +144,32 @@ export const ScheduleSettings: React.FC = () => { - - + + {t('information.total-matches')}: {totalMatches} - + {t('information.total-sessions')}: {totalSessions} @@ -146,28 +179,44 @@ export const ScheduleSettings: React.FC = () => { - - + + {t('information.practice-rounds')}: {practiceRounds} - + {t('information.ranking-rounds')}: {rankingRounds} - + {t('information.matches-per-round')}: {matchesPerRound} @@ -177,28 +226,44 @@ export const ScheduleSettings: React.FC = () => { - - + + {t('information.judging-start')}: {judgingStart.format('HH:mm')} - + {t('information.field-start')}: {fieldStart.format('HH:mm')} - + {t('settings.timezone')}: {timezone} @@ -213,15 +278,20 @@ export const ScheduleSettings: React.FC = () => { + }} + > {t('settings.title')} - + = ({ + color: 'text.secondary', + textAlign: 'center' + }} + > {t('select-team-to-continue')} @@ -101,17 +102,24 @@ export const JudgingSessionSelector: React.FC = ({ - + justifyContent: 'space-between', + alignItems: 'center' + }} + > + {room.name} - + {isEmpty ? t('empty') : isCurrentTeam diff --git a/apps/admin/src/app/[locale]/(dashboard)/events/[slug]/schedule/components/team-swapper/team-schedule-view.tsx b/apps/admin/src/app/[locale]/(dashboard)/events/[slug]/schedule/components/team-swapper/team-schedule-view.tsx index 37775aa02..26a8927c9 100644 --- a/apps/admin/src/app/[locale]/(dashboard)/events/[slug]/schedule/components/team-swapper/team-schedule-view.tsx +++ b/apps/admin/src/app/[locale]/(dashboard)/events/[slug]/schedule/components/team-swapper/team-schedule-view.tsx @@ -53,9 +53,12 @@ export const TeamScheduleView: React.FC = ({ teamSchedule {teamSchedule.matches.length > 0 && ( <> - + {t('matches')} {teamSchedule.matches.map(match => ( @@ -64,9 +67,10 @@ export const TeamScheduleView: React.FC = ({ teamSchedule + justifyContent: 'space-between', + alignItems: 'center' + }} + > {t('match', { round: match.round, @@ -74,9 +78,12 @@ export const TeamScheduleView: React.FC = ({ teamSchedule stage: getStage(match.stage) })} - + {dayjs(match.scheduledTime).format('HH:mm')} diff --git a/apps/admin/src/app/[locale]/(dashboard)/events/[slug]/schedule/components/team-swapper/team-selector.tsx b/apps/admin/src/app/[locale]/(dashboard)/events/[slug]/schedule/components/team-swapper/team-selector.tsx index 55c1c4b4a..8d6eae20f 100644 --- a/apps/admin/src/app/[locale]/(dashboard)/events/[slug]/schedule/components/team-swapper/team-selector.tsx +++ b/apps/admin/src/app/[locale]/(dashboard)/events/[slug]/schedule/components/team-swapper/team-selector.tsx @@ -37,9 +37,10 @@ export const TeamSelector: React.FC = ({ variant="body2" gutterBottom sx={{ - fontSize: "small", - color: "text.secondary" - }}> + fontSize: 'small', + color: 'text.secondary' + }} + > {t('description')} = ({ + }} + > {searchQuery ? t('no-teams-found') : t('no-teams')} ) : ( diff --git a/apps/admin/src/app/[locale]/(dashboard)/events/[slug]/schedule/components/team-swapper/team-swapper.tsx b/apps/admin/src/app/[locale]/(dashboard)/events/[slug]/schedule/components/team-swapper/team-swapper.tsx index 0e840d0b5..8e7e50261 100644 --- a/apps/admin/src/app/[locale]/(dashboard)/events/[slug]/schedule/components/team-swapper/team-swapper.tsx +++ b/apps/admin/src/app/[locale]/(dashboard)/events/[slug]/schedule/components/team-swapper/team-swapper.tsx @@ -135,9 +135,10 @@ export const TeamSwapper: React.FC = ({ division }) => { + }} + > = ({ division }) => { sx={{ p: 3, flex: 1, - display: "flex", - flexDirection: "column", - overflow: "hidden" - }}> + display: 'flex', + flexDirection: 'column', + overflow: 'hidden' + }} + > = ({ {t('title')} - - {t('message')} - + {t('message')} {t('event-name', { eventName })} @@ -64,4 +62,4 @@ export const CompleteEventDialog: React.FC = ({ ); -}; \ No newline at end of file +}; diff --git a/apps/admin/src/app/[locale]/(dashboard)/events/[slug]/settings/components/danger-zone-section.tsx b/apps/admin/src/app/[locale]/(dashboard)/events/[slug]/settings/components/danger-zone-section.tsx index e1fe18ff3..849688f94 100644 --- a/apps/admin/src/app/[locale]/(dashboard)/events/[slug]/settings/components/danger-zone-section.tsx +++ b/apps/admin/src/app/[locale]/(dashboard)/events/[slug]/settings/components/danger-zone-section.tsx @@ -1,48 +1,40 @@ -"use client"; +'use client'; -import React from "react"; -import { useTranslations } from "next-intl"; -import { - Box, - Card, - CardContent, - Typography, - Button, - Stack, - useTheme, -} from "@mui/material"; +import React from 'react'; +import { useTranslations } from 'next-intl'; +import { Box, Card, CardContent, Typography, Button, Stack, useTheme } from '@mui/material'; export const DangerZoneSection = () => { - const t = useTranslations("pages.events.settings.danger-zone"); + const t = useTranslations('pages.events.settings.danger-zone'); const theme = useTheme(); return ( - {t("title")} + {t('title')} - - - - {t("delete-event-description")} - - - {t("delete-disabled")} + {t('delete-event-description')} + + {t('delete-disabled')} diff --git a/apps/admin/src/app/[locale]/(dashboard)/events/[slug]/settings/components/download-results-dialog.tsx b/apps/admin/src/app/[locale]/(dashboard)/events/[slug]/settings/components/download-results-dialog.tsx index e84859dd8..d517cfbb5 100644 --- a/apps/admin/src/app/[locale]/(dashboard)/events/[slug]/settings/components/download-results-dialog.tsx +++ b/apps/admin/src/app/[locale]/(dashboard)/events/[slug]/settings/components/download-results-dialog.tsx @@ -52,16 +52,22 @@ export const DownloadResultsDialog: React.FC = ({ {t('event-name', { eventName })} - + {t('info')} {progress !== null && ( - + {t('generating')} diff --git a/apps/admin/src/app/[locale]/(dashboard)/events/[slug]/settings/components/event-actions-section.tsx b/apps/admin/src/app/[locale]/(dashboard)/events/[slug]/settings/components/event-actions-section.tsx index 78d1421ed..e6f71c6a4 100644 --- a/apps/admin/src/app/[locale]/(dashboard)/events/[slug]/settings/components/event-actions-section.tsx +++ b/apps/admin/src/app/[locale]/(dashboard)/events/[slug]/settings/components/event-actions-section.tsx @@ -81,8 +81,7 @@ export const EventActionsSection: React.FC = ({ setAlert({ type: 'error', message: t('messages.publish-error') }); } } catch (error) { - const errorMsg = - error instanceof Error ? error.message : t('messages.publish-error'); + const errorMsg = error instanceof Error ? error.message : t('messages.publish-error'); setAlert({ type: 'error', message: errorMsg }); } }; @@ -127,9 +126,13 @@ export const EventActionsSection: React.FC = ({ - + ); diff --git a/apps/admin/src/app/[locale]/(dashboard)/events/[slug]/teams/components/register-teams-from-csv-dialog.tsx b/apps/admin/src/app/[locale]/(dashboard)/events/[slug]/teams/components/register-teams-from-csv-dialog.tsx index e746ce0be..17ea5f7b7 100644 --- a/apps/admin/src/app/[locale]/(dashboard)/events/[slug]/teams/components/register-teams-from-csv-dialog.tsx +++ b/apps/admin/src/app/[locale]/(dashboard)/events/[slug]/teams/components/register-teams-from-csv-dialog.tsx @@ -113,12 +113,15 @@ const RegisterForm: React.FC<{ direction="row" spacing={2} sx={{ - alignItems: "flex-start", + alignItems: 'flex-start', mb: 1 - }}> - + }} + > + @@ -128,9 +131,10 @@ const RegisterForm: React.FC<{ + color: 'text.secondary', + marginBottom: '16px' + }} + > {t('instructions.description')} @@ -234,9 +238,12 @@ const RegisterForm: React.FC<{ placeholder={t('fields.file.placeholder')} /> {error && {t(`errors.${error}`)}} - + diff --git a/apps/admin/src/app/[locale]/(dashboard)/events/components/missing-info/missing-info-dialog.tsx b/apps/admin/src/app/[locale]/(dashboard)/events/components/missing-info/missing-info-dialog.tsx index e022cac49..6fc463745 100644 --- a/apps/admin/src/app/[locale]/(dashboard)/events/components/missing-info/missing-info-dialog.tsx +++ b/apps/admin/src/app/[locale]/(dashboard)/events/components/missing-info/missing-info-dialog.tsx @@ -88,9 +88,12 @@ export const MissingInfoDialog: React.FC = ({ {t('dialog-title')} {!hasDetailedData ? ( - + {tCard('missing-details')} ) : ( @@ -129,9 +132,10 @@ export const MissingInfoDialog: React.FC = ({ + }} + > {t('division-fully-configured')} )} diff --git a/apps/admin/src/app/[locale]/(dashboard)/events/components/previous-season.tsx b/apps/admin/src/app/[locale]/(dashboard)/events/components/previous-season.tsx index dd8e8c6a0..07d31095d 100644 --- a/apps/admin/src/app/[locale]/(dashboard)/events/components/previous-season.tsx +++ b/apps/admin/src/app/[locale]/(dashboard)/events/components/previous-season.tsx @@ -40,9 +40,10 @@ export const PreviousSeason: React.FC = ({ season }) => { + justifyContent: 'space-between', + alignItems: 'center' + }} + > = ({ - + justifyContent: 'space-between', + alignItems: 'center' + }} + > + = ({ {seasonName} {numberOfEvents > 0 && ( - + {numberOfEvents} {numberOfEvents === 1 ? 'event' : 'events'} )} diff --git a/apps/admin/src/app/[locale]/(dashboard)/events/create/components/create-event-layout.tsx b/apps/admin/src/app/[locale]/(dashboard)/events/create/components/create-event-layout.tsx index 19b13713b..a1a17981d 100644 --- a/apps/admin/src/app/[locale]/(dashboard)/events/create/components/create-event-layout.tsx +++ b/apps/admin/src/app/[locale]/(dashboard)/events/create/components/create-event-layout.tsx @@ -192,10 +192,11 @@ export const CreateEventLayout = () => { <> + }} + > {t('sections.event-details')} @@ -286,9 +287,11 @@ export const CreateEventLayout = () => { {isMultipleDivisions && ( - + {t('sections.divisions')} @@ -308,9 +311,13 @@ export const CreateEventLayout = () => { )} - + @@ -56,18 +57,20 @@ export default function BrowseEventsPage() { variant="h3" gutterBottom sx={{ - fontWeight: "700", + fontWeight: '700', fontSize: { xs: '2rem', md: '2.5rem' } - }}> + }} + > {t('title')} + }} + > {tags => t.rich('description', tags)} @@ -75,9 +78,13 @@ export default function BrowseEventsPage() { {/* Events Grid */} {loading ? ( - {t('loading')} + + {t('loading')} + ) : error ? ( @@ -86,17 +93,22 @@ export default function BrowseEventsPage() { ) : allEvents.length === 0 ? ( - + {t('no-events')} + }} + > {t('no-events-description')} {mode === 'judging' && ( - + )} {mode === 'awards' && ( - + diff --git a/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/judge/components/schedule/room-schedule-table.tsx b/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/judge/components/schedule/room-schedule-table.tsx index f02f0bcb2..e5f2728f4 100644 --- a/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/judge/components/schedule/room-schedule-table.tsx +++ b/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/judge/components/schedule/room-schedule-table.tsx @@ -195,17 +195,22 @@ export const RoomScheduleTable: React.FC = ({ direction="row" spacing={1} sx={{ - justifyContent: "center", - alignItems: "center" - }}> + justifyContent: 'center', + alignItems: 'center' + }} + > - + { direction="row" spacing={1.5} sx={{ - alignItems: "flex-start", + alignItems: 'flex-start', py: 0.5 - }}> + }} + > = ({ + }} + > {tSchedule('session-label', { number: sessionNumber })} - + {scheduledTime} = ({ }) => { const t = useTranslations('pages.judge.schedule'); const [loading, setLoading] = useState(false); - const currentTime = useTime({interval: 1000}); + const currentTime = useTime({ interval: 1000 }); const isStartable = useMemo(() => { if (session.status !== 'not-started') return false; diff --git a/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/judge/components/timer/judging-session-context.tsx b/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/judge/components/timer/judging-session-context.tsx index 55e35f8b0..51cab439f 100644 --- a/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/judge/components/timer/judging-session-context.tsx +++ b/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/judge/components/timer/judging-session-context.tsx @@ -23,9 +23,7 @@ export function JudgingSessionProvider({ openRubricsDuringSession?: boolean; }) { return ( - + {children} ); diff --git a/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/judge/components/timer/judging-timer-desktop-layout.tsx b/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/judge/components/timer/judging-timer-desktop-layout.tsx index 1e157778e..b14f771f1 100644 --- a/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/judge/components/timer/judging-timer-desktop-layout.tsx +++ b/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/judge/components/timer/judging-timer-desktop-layout.tsx @@ -84,12 +84,15 @@ export const JudgingTimerDesktopLayout: React.FC - + height: '100%', + justifyContent: 'space-between' + }} + > + direction="row" spacing={2} sx={{ - alignItems: "center", + alignItems: 'center', mt: 1 - }}> + }} + > - + - + direction="row" spacing={2} sx={{ - alignItems: "flex-end", - justifyContent: "space-between" - }}> + alignItems: 'flex-end', + justifyContent: 'space-between' + }} + > direction="row" spacing={1} sx={{ - flexWrap: "wrap", - alignItems: "flex-start", + flexWrap: 'wrap', + alignItems: 'flex-start', mt: 2 - }}> + }} + > = borderRadius: 3 }} > - + = direction="row" spacing={1.5} sx={{ - alignItems: "center", + alignItems: 'center', mt: 1 - }}> + }} + > = - + = direction="row" spacing={1} sx={{ - flexWrap: "wrap", - justifyContent: "center" - }}> + flexWrap: 'wrap', + justifyContent: 'center' + }} + > • diff --git a/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/judge/components/timer/stage-timeline.tsx b/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/judge/components/timer/stage-timeline.tsx index 565fcc15b..426ca35e9 100644 --- a/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/judge/components/timer/stage-timeline.tsx +++ b/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/judge/components/timer/stage-timeline.tsx @@ -47,26 +47,29 @@ export const StageTimeline = ({ timerState }: StageTimelineProps) => { + }} + > + alignItems: 'center', + justifyContent: 'space-between' + }} + > + }} + > { {t('timer.minutes', { count: stage.duration / 60 })} - + - + - + {t('current-sessions')} @@ -126,12 +133,13 @@ export function CurrentSessionsDisplay({ direction="row" spacing={1} sx={{ - alignItems: "center", - flexWrap: "wrap", + alignItems: 'center', + flexWrap: 'wrap', p: 1, borderRadius: 1, bgcolor: 'rgba(255,255,255,0.08)' - }}> + }} + > - + - + {t('upcoming-sessions')} @@ -203,17 +218,19 @@ export function CurrentSessionsDisplay({ direction="row" spacing={1} sx={{ - alignItems: "center", - flexWrap: "wrap", + alignItems: 'center', + flexWrap: 'wrap', justifyContent: 'space-between' - }}> + }} + > + alignItems: 'center', + flexWrap: 'wrap' + }} + > + }} + > - + {t('no-sessions')} + }} + > {t('no-sessions-description')} @@ -128,9 +132,12 @@ export function JudgingSchedule({ divisionId, sessions, rooms, loading }: Judgin {t('title')} - + {t('subtitle')} @@ -146,21 +153,33 @@ export function JudgingSchedule({ divisionId, sessions, rooms, loading }: Judgin - {t('time')} + + {t('time')} + {rooms.map(room => ( - {room.name} + + {room.name} + ))} - {t('actions')} + + {t('actions')} + @@ -175,9 +194,10 @@ export function JudgingSchedule({ divisionId, sessions, rooms, loading }: Judgin + }} + > {currentTime .set('hour', new Date(time).getHours()) .set('minute', new Date(time).getMinutes()) @@ -189,9 +209,13 @@ export function JudgingSchedule({ divisionId, sessions, rooms, loading }: Judgin if (!session) { return ( - - + + - + ); } @@ -218,9 +242,10 @@ export function JudgingSchedule({ divisionId, sessions, rooms, loading }: Judgin ) : ( + }} + > — )} diff --git a/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/judging-queuer/components/judging-schedule-view.tsx b/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/judging-queuer/components/judging-schedule-view.tsx index 95b506393..5a88017b7 100644 --- a/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/judging-queuer/components/judging-schedule-view.tsx +++ b/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/judging-queuer/components/judging-schedule-view.tsx @@ -66,17 +66,21 @@ export function JudgingScheduleView({ data, loading }: JudgingScheduleViewProps) if (timeSlots.length === 0) { return ( - + {t('no-sessions')} + }} + > {t('no-sessions-description')} @@ -87,9 +91,12 @@ export function JudgingScheduleView({ data, loading }: JudgingScheduleViewProps) {t('title')} - + {t('subtitle')} @@ -105,15 +112,23 @@ export function JudgingScheduleView({ data, loading }: JudgingScheduleViewProps) - {t('time')} + + {t('time')} + {rooms.map(room => ( - {room.name} + + {room.name} + ))} @@ -124,9 +139,10 @@ export function JudgingScheduleView({ data, loading }: JudgingScheduleViewProps) + }} + > {currentTime .set('hour', new Date(time).getHours()) .set('minute', new Date(time).getMinutes()) @@ -138,9 +154,13 @@ export function JudgingScheduleView({ data, loading }: JudgingScheduleViewProps) if (!session) { return ( - - + + - + ); } @@ -167,9 +187,10 @@ export function JudgingScheduleView({ data, loading }: JudgingScheduleViewProps) ) : ( + }} + > — )} diff --git a/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/judging-queuer/components/pit-map-view.tsx b/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/judging-queuer/components/pit-map-view.tsx index 9020c3cad..7f8b39b0c 100644 --- a/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/judging-queuer/components/pit-map-view.tsx +++ b/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/judging-queuer/components/pit-map-view.tsx @@ -9,18 +9,27 @@ export function PitMapView() { return ( - + - + {t('title')} - + {t('description')} diff --git a/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/judging-queuer/components/team-queue-card.tsx b/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/judging-queuer/components/team-queue-card.tsx index 5edb2cd73..b9b55d00b 100644 --- a/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/judging-queuer/components/team-queue-card.tsx +++ b/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/judging-queuer/components/team-queue-card.tsx @@ -65,18 +65,29 @@ export function TeamQueueCard({ direction="row" spacing={2} sx={{ - alignItems: "center", - justifyContent: "space-between" - }}> - - - + alignItems: 'center', + justifyContent: 'space-between' + }} + > + + + #{teamNumber} {isInMatch && ( @@ -85,24 +96,31 @@ export function TeamQueueCard({ )} - + {teamName} - + + alignItems: 'center', + flexWrap: 'wrap', + justifyContent: 'flex-end' + }} + > - + {timeInfo.formattedTime} ( {timeInfo.isPast ? t('time-ago', { minutes: timeInfo.diffMinutes }) diff --git a/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/judging-queuer/page.tsx b/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/judging-queuer/page.tsx index 234708b86..b36123dea 100644 --- a/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/judging-queuer/page.tsx +++ b/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/judging-queuer/page.tsx @@ -192,9 +192,13 @@ export default function JudgingQueuerPage() { transformOrigin={{ vertical: 'top', horizontal: 'right' }} > - + {t('filter-by-room')} @@ -225,17 +229,21 @@ export default function JudgingQueuerPage() { {!loading && filteredTeams.length === 0 && ( - + {t('no-teams')} + }} + > {t('no-teams-description')} diff --git a/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/layout.tsx b/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/layout.tsx index 946fc8a91..75b3b4418 100644 --- a/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/layout.tsx +++ b/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/layout.tsx @@ -22,7 +22,8 @@ export default function VolunteerDashboardLayout({ children }: { children: React flexGrow: 1, backgroundColor: '#fafafa', mt: { xs: 8, lg: 0 } - }}> + }} + > { direction="row" spacing={1.5} sx={{ - alignItems: "flex-start", + alignItems: 'flex-start', py: 0.5 - }}> + }} + > { + }} + > {t('status.approved')} diff --git a/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/lead-judge/components/session-filters.tsx b/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/lead-judge/components/session-filters.tsx index 48c0ce61d..f6f6b1e37 100644 --- a/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/lead-judge/components/session-filters.tsx +++ b/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/lead-judge/components/session-filters.tsx @@ -97,9 +97,10 @@ export const SessionFilters: React.FC = () => { + justifyContent: 'space-between', + alignItems: 'center' + }} + > {t('filter.results')}: {filteredSessions.length} diff --git a/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/lead-judge/components/status-filter-selector.tsx b/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/lead-judge/components/status-filter-selector.tsx index 4925e026c..f3f78d735 100644 --- a/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/lead-judge/components/status-filter-selector.tsx +++ b/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/lead-judge/components/status-filter-selector.tsx @@ -79,9 +79,13 @@ export const StatusFilterSelector: React.FC = ({ borderColor: 'rgba(0, 0, 0, 0.25)' }} > - {displayLabel} + + {displayLabel} + = ({ }} > - + session.team.arrived); const statuses = arrivedSessions diff --git a/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/mc/components/award-page.tsx b/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/mc/components/award-page.tsx index a267cd792..3ae031c88 100644 --- a/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/mc/components/award-page.tsx +++ b/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/mc/components/award-page.tsx @@ -37,20 +37,24 @@ export const AwardPage: React.FC = ({ awardGroup }) => { }; return ( - + + }} + > = ({ awardGroup }) => { + }} + > {getDescription(awardGroup.name)} @@ -108,9 +113,10 @@ export const AwardPage: React.FC = ({ awardGroup }) => { direction="row" spacing={3} sx={{ - alignItems: "center", - justifyContent: "flex-start" - }}> + alignItems: 'center', + justifyContent: 'flex-start' + }} + > {/* Place Badge */} {award.place > 0 && awardGroup.showPlaces && ( = ({ awardGroup }) => { minWidth: 0 }} > - + = ({ awardGroup }) => { + }} + > {award.winner.team.affiliation} diff --git a/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/mc/components/awards-view.tsx b/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/mc/components/awards-view.tsx index 08dde710f..902282e2d 100644 --- a/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/mc/components/awards-view.tsx +++ b/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/mc/components/awards-view.tsx @@ -114,9 +114,12 @@ export const AwardsView: React.FC = () => { borderColor: 'error.main' }} > - + @@ -142,9 +145,12 @@ export const AwardsView: React.FC = () => { borderColor: 'divider' }} > - + diff --git a/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/mc/components/current-match-hero.tsx b/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/mc/components/current-match-hero.tsx index 733562059..ef7b962e2 100644 --- a/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/mc/components/current-match-hero.tsx +++ b/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/mc/components/current-match-hero.tsx @@ -81,9 +81,10 @@ export const CurrentMatchHero: React.FC = () => { + justifyContent: 'space-between', + alignItems: 'center' + }} + > { direction="row" spacing={1.5} sx={{ - alignItems: "center", - justifyContent: "space-between" - }}> + alignItems: 'center', + justifyContent: 'space-between' + }} + > { {match.participants.map(participant => ( - + { + justifyContent: 'space-between', + alignItems: 'center' + }} + > @@ -34,9 +35,12 @@ export const McLoadingSkeleton: React.FC = () => { {/* Awards Placeholder Skeleton */} - + diff --git a/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/mc/components/nav-buttons.tsx b/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/mc/components/nav-buttons.tsx index 982c939b7..2e50fe974 100644 --- a/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/mc/components/nav-buttons.tsx +++ b/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/mc/components/nav-buttons.tsx @@ -20,11 +20,12 @@ export const NavButtons: React.FC = ({ current, total, onPrevio direction="row" spacing={2} sx={{ - alignItems: "center", - justifyContent: "center", + alignItems: 'center', + justifyContent: 'center', pt: 2, pb: 1 - }}> + }} + > {mode === 'matches' && ( - + diff --git a/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/pit-admin/components/arrivals-stats.tsx b/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/pit-admin/components/arrivals-stats.tsx index 253ec3981..663dd07ee 100644 --- a/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/pit-admin/components/arrivals-stats.tsx +++ b/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/pit-admin/components/arrivals-stats.tsx @@ -74,13 +74,18 @@ export function ArrivalsStats({ teams, loading = false }: ArrivalsStatsProps) { direction={{ xs: 'column', md: 'row' }} spacing={2} sx={{ - alignItems: "flex-start", - justifyContent: "space-between" - }}> + alignItems: 'flex-start', + justifyContent: 'space-between' + }} + > - + {t('total-teams')} @@ -102,9 +107,13 @@ export function ArrivalsStats({ teams, loading = false }: ArrivalsStatsProps) { - + {t('arrived')} @@ -150,9 +159,12 @@ export function ArrivalsStats({ teams, loading = false }: ArrivalsStatsProps) { }} /> - + {stats.pending > 0 && t('teams-pending', { count: stats.pending })} {stats.pending === 0 && t('all-arrived')} @@ -180,13 +192,20 @@ export function ArrivalsStats({ teams, loading = false }: ArrivalsStatsProps) { border: '1px solid rgba(255, 255, 255, 0.2)' }} > - + - + {t('waiting-for', { count: stats.missingTeams.length })} diff --git a/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/referee/components/inspection-timer.tsx b/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/referee/components/inspection-timer.tsx index dc459778e..2bf447b98 100644 --- a/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/referee/components/inspection-timer.tsx +++ b/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/referee/components/inspection-timer.tsx @@ -44,16 +44,26 @@ export const InspectionTimer: React.FC = ({ inspectionStar borderRadius: 2 }} > - - + + - + {t('inspection-timer')} diff --git a/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/referee/components/match-timer.tsx b/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/referee/components/match-timer.tsx index 522ea1a44..86dcbc510 100644 --- a/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/referee/components/match-timer.tsx +++ b/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/referee/components/match-timer.tsx @@ -142,9 +142,12 @@ export const RefereeMatchTimer = () => { {participant.team.name} #{participant.team.number} - + {participant.team.affiliation}, {participant.team.city} @@ -164,9 +167,10 @@ export const RefereeMatchTimer = () => { + color: 'text.secondary', + textAlign: 'center' + }} + > {t('team-not-available')} )} diff --git a/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/referee/components/no-match.tsx b/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/referee/components/no-match.tsx index 0ad2340af..136dfabeb 100644 --- a/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/referee/components/no-match.tsx +++ b/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/referee/components/no-match.tsx @@ -22,9 +22,12 @@ export function RefereeNoMatch() { - + - + {t('no-match-instructions')} diff --git a/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/referee/components/schedule.tsx b/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/referee/components/schedule.tsx index a04b91a6d..f14957686 100644 --- a/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/referee/components/schedule.tsx +++ b/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/referee/components/schedule.tsx @@ -59,15 +59,21 @@ export function RefereeSchedule() { }} > - + {getStage(match.stage)} #{match.number} - + {t('round')} {match.round} @@ -78,16 +84,23 @@ export function RefereeSchedule() { - + {match.participants.map(p => p.team ? ( ) : ( - + - ) diff --git a/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/reports/awards-list/components/award-card.tsx b/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/reports/awards-list/components/award-card.tsx index 702cf2136..74d7da265 100644 --- a/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/reports/awards-list/components/award-card.tsx +++ b/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/reports/awards-list/components/award-card.tsx @@ -66,7 +66,8 @@ export function AwardCard({ name, awardList }: AwardCardProps) { sx={{ fontWeight: 600, flexGrow: 1 - }}> + }} + > {localizedName} @@ -96,9 +97,10 @@ export function AwardCard({ name, awardList }: AwardCardProps) { + }} + > {description} )} diff --git a/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/reports/awards-list/components/empty-state.tsx b/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/reports/awards-list/components/empty-state.tsx index 5122b11b6..f4c87ff38 100644 --- a/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/reports/awards-list/components/empty-state.tsx +++ b/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/reports/awards-list/components/empty-state.tsx @@ -8,9 +8,12 @@ export function EmptyState() { return ( - + {t('no-awards')} diff --git a/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/reports/awards-list/components/error-state.tsx b/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/reports/awards-list/components/error-state.tsx index 9e4b92b2b..dbd8b03ca 100644 --- a/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/reports/awards-list/components/error-state.tsx +++ b/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/reports/awards-list/components/error-state.tsx @@ -8,9 +8,12 @@ export function ErrorState() { return ( - + {t('error-loading')} diff --git a/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/reports/awards-list/components/loading-state.tsx b/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/reports/awards-list/components/loading-state.tsx index aca4adb82..f40df66f0 100644 --- a/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/reports/awards-list/components/loading-state.tsx +++ b/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/reports/awards-list/components/loading-state.tsx @@ -8,9 +8,13 @@ export function LoadingState() { return ( - {t('loading')} + + {t('loading')} + ); } diff --git a/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/reports/awards-list/page.tsx b/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/reports/awards-list/page.tsx index 9a5d38927..7c1428301 100644 --- a/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/reports/awards-list/page.tsx +++ b/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/reports/awards-list/page.tsx @@ -63,9 +63,12 @@ export default function AwardsListPage() { }} > - + {t('description')} diff --git a/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/reports/event-agenda/components/agenda-event-card.tsx b/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/reports/event-agenda/components/agenda-event-card.tsx index a34f8869f..71ccc0eec 100644 --- a/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/reports/event-agenda/components/agenda-event-card.tsx +++ b/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/reports/event-agenda/components/agenda-event-card.tsx @@ -62,47 +62,65 @@ export function AgendaEventCard({ event }: AgendaEventCardProps) { - + {event.title} - + }} + > + {formattedTime} - + - + {t('duration-minutes', { count: durationMinutes })} {event.location && ( <> - + + }} + > {event.location} diff --git a/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/reports/event-agenda/components/empty-state.tsx b/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/reports/event-agenda/components/empty-state.tsx index 6986628a3..2ef0c1a39 100644 --- a/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/reports/event-agenda/components/empty-state.tsx +++ b/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/reports/event-agenda/components/empty-state.tsx @@ -10,9 +10,12 @@ export function EmptyState() { return ( - + {t('no-events')} diff --git a/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/reports/event-agenda/components/error-state.tsx b/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/reports/event-agenda/components/error-state.tsx index ed8c06b97..2ead51486 100644 --- a/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/reports/event-agenda/components/error-state.tsx +++ b/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/reports/event-agenda/components/error-state.tsx @@ -10,9 +10,12 @@ export function ErrorState() { return ( - + {t('error-loading')} diff --git a/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/reports/event-agenda/components/loading-state.tsx b/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/reports/event-agenda/components/loading-state.tsx index 08ba6c36c..974af31be 100644 --- a/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/reports/event-agenda/components/loading-state.tsx +++ b/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/reports/event-agenda/components/loading-state.tsx @@ -9,9 +9,12 @@ export function LoadingState() { return ( - + {t('loading')} diff --git a/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/reports/field-schedule/components/agenda-event-row.tsx b/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/reports/field-schedule/components/agenda-event-row.tsx index cd6a483d0..fdcdb0c89 100644 --- a/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/reports/field-schedule/components/agenda-event-row.tsx +++ b/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/reports/field-schedule/components/agenda-event-row.tsx @@ -32,10 +32,11 @@ export const AgendaEventRow: React.FC = ({ event, tableCoun + }} + > {startTime.format('HH:mm')} - {endTime.format('HH:mm')} @@ -49,7 +50,8 @@ export const AgendaEventRow: React.FC = ({ event, tableCoun alignItems: 'center', justifyContent: 'center', gap: 1 - }}> + }} + > {event.title} - + {t('empty-state')} diff --git a/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/reports/field-schedule/components/loading-state.tsx b/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/reports/field-schedule/components/loading-state.tsx index f2f14e000..49df68cca 100644 --- a/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/reports/field-schedule/components/loading-state.tsx +++ b/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/reports/field-schedule/components/loading-state.tsx @@ -9,9 +9,12 @@ export function LoadingState() { return ( - + {t('loading')} diff --git a/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/reports/field-schedule/components/match-row.tsx b/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/reports/field-schedule/components/match-row.tsx index e434670a3..3470282c2 100644 --- a/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/reports/field-schedule/components/match-row.tsx +++ b/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/reports/field-schedule/components/match-row.tsx @@ -40,30 +40,33 @@ export const MatchRow: React.FC = ({ match, tables, teams }) => { + }} + > {match.number} + }} + > {startTime.format('HH:mm')} + }} + > {endTime.format('HH:mm')} @@ -102,9 +105,13 @@ export const MatchRow: React.FC = ({ match, tables, teams }) => { ) : ( - - + + - + )} ); diff --git a/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/reports/field-schedule/components/round-schedule.tsx b/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/reports/field-schedule/components/round-schedule.tsx index 0ef794daa..dd2850714 100644 --- a/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/reports/field-schedule/components/round-schedule.tsx +++ b/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/reports/field-schedule/components/round-schedule.tsx @@ -51,7 +51,8 @@ export const RoundSchedule: React.FC = ({ matches, tables, t sx={{ fontWeight: 600, fontSize: isMobile ? '0.875rem' : '1rem' - }}> + }} + > {roundTitle} @@ -62,7 +63,8 @@ export const RoundSchedule: React.FC = ({ matches, tables, t sx={{ fontWeight: 600, fontSize: isMobile ? '0.75rem' : '1rem' - }}> + }} + > {t('columns.match')} @@ -71,7 +73,8 @@ export const RoundSchedule: React.FC = ({ matches, tables, t sx={{ fontWeight: 600, fontSize: isMobile ? '0.75rem' : '1rem' - }}> + }} + > {t('columns.start-time')} @@ -80,7 +83,8 @@ export const RoundSchedule: React.FC = ({ matches, tables, t sx={{ fontWeight: 600, fontSize: isMobile ? '0.75rem' : '1rem' - }}> + }} + > {t('columns.end-time')} @@ -90,7 +94,8 @@ export const RoundSchedule: React.FC = ({ matches, tables, t sx={{ fontWeight: 600, fontSize: isMobile ? '0.75rem' : '1rem' - }}> + }} + > {table.name} diff --git a/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/reports/field-schedule/graphql/query.ts b/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/reports/field-schedule/graphql/query.ts index b813b9891..04d51198c 100644 --- a/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/reports/field-schedule/graphql/query.ts +++ b/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/reports/field-schedule/graphql/query.ts @@ -135,24 +135,31 @@ export function parseFieldScheduleData(data: QueryData) { const roundRows: typeof rows = []; // Get time boundaries for this round - const lastMatchTime = dayjs(currentRoundMatches[currentRoundMatches.length - 1].scheduledTime); - + const lastMatchTime = dayjs( + currentRoundMatches[currentRoundMatches.length - 1].scheduledTime + ); + // Get the start boundary (before first match of this round) - const startBoundary = roundIndex > 0 - ? dayjs(roundMatches[sortedRoundKeys[roundIndex - 1]][roundMatches[sortedRoundKeys[roundIndex - 1]].length - 1].scheduledTime) - : dayjs(0); - + const startBoundary = + roundIndex > 0 + ? dayjs( + roundMatches[sortedRoundKeys[roundIndex - 1]][ + roundMatches[sortedRoundKeys[roundIndex - 1]].length - 1 + ].scheduledTime + ) + : dayjs(0); + // Get the end boundary (before first match of next round, or infinity for last round) - const endBoundary = roundIndex < sortedRoundKeys.length - 1 - ? dayjs(roundMatches[sortedRoundKeys[roundIndex + 1]][0].scheduledTime) - : dayjs('9999-12-31'); + const endBoundary = + roundIndex < sortedRoundKeys.length - 1 + ? dayjs(roundMatches[sortedRoundKeys[roundIndex + 1]][0].scheduledTime) + : dayjs('9999-12-31'); // Add matches and events for this round in chronological order currentRoundMatches.forEach((match, matchIndex) => { const matchTime = dayjs(match.scheduledTime); - const previousMatchTime = matchIndex > 0 - ? dayjs(currentRoundMatches[matchIndex - 1].scheduledTime) - : startBoundary; + const previousMatchTime = + matchIndex > 0 ? dayjs(currentRoundMatches[matchIndex - 1].scheduledTime) : startBoundary; // Add agenda events that fall between previous match and this match agenda.forEach(event => { diff --git a/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/reports/field-status/components/match-countdown.tsx b/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/reports/field-status/components/match-countdown.tsx index 03df5a5e6..0646bc117 100644 --- a/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/reports/field-status/components/match-countdown.tsx +++ b/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/reports/field-status/components/match-countdown.tsx @@ -62,10 +62,11 @@ export function MatchCountdown({ + }} + > {t('countdown.no-match')} diff --git a/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/reports/field-status/components/next-match-panel.tsx b/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/reports/field-status/components/next-match-panel.tsx index 2d7102639..2e005c2a4 100644 --- a/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/reports/field-status/components/next-match-panel.tsx +++ b/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/reports/field-status/components/next-match-panel.tsx @@ -56,14 +56,21 @@ export function NextMatchPanel({ match }: NextMatchPanelProps) { return ( - + {t('next-match.title')} - {t('next-match.no-match')} + + {t('next-match.no-match')} + ); @@ -102,25 +109,28 @@ export function NextMatchPanel({ match }: NextMatchPanelProps) { direction="row" spacing={2} sx={{ - alignItems: "center", - justifyContent: "space-between", - flexWrap: "wrap" - }}> + alignItems: 'center', + justifyContent: 'space-between', + flexWrap: 'wrap' + }} + > + }} + > {getStage(match.stage)} #{match.number} + }} + > {currentTime .set('hour', dayjs(match.scheduledTime).hour()) .set('minute', dayjs(match.scheduledTime).minute()) @@ -128,15 +138,20 @@ export function NextMatchPanel({ match }: NextMatchPanelProps) { - + + }} + > {t('next-match.tables-ready')}: + }} + > + }} + > {participant.table.name}: - + {t('upcoming-matches.title')} diff --git a/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/reports/field-timer/components/loading-state.tsx b/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/reports/field-timer/components/loading-state.tsx index b74c799f8..671e702d0 100644 --- a/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/reports/field-timer/components/loading-state.tsx +++ b/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/reports/field-timer/components/loading-state.tsx @@ -16,9 +16,12 @@ export function LoadingState() { minHeight: 'calc(100vh - 200px)' }} > - + {t('loading')} diff --git a/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/reports/field-timer/components/match-info.tsx b/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/reports/field-timer/components/match-info.tsx index c0d40ab86..d9dff5e14 100644 --- a/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/reports/field-timer/components/match-info.tsx +++ b/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/reports/field-timer/components/match-info.tsx @@ -44,7 +44,8 @@ export function MatchInfo({ match, isDesktop }: MatchInfoProps) { fontWeight: 700, color: theme => theme.palette.text.primary, letterSpacing: '0.5px' - }}> + }} + > {getMatchLabel()} diff --git a/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/reports/judging-schedule/components/empty-state.tsx b/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/reports/judging-schedule/components/empty-state.tsx index 5bdcac2d3..83e09f2dc 100644 --- a/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/reports/judging-schedule/components/empty-state.tsx +++ b/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/reports/judging-schedule/components/empty-state.tsx @@ -10,9 +10,12 @@ export function EmptyState() { return ( - + {t('no-sessions')} diff --git a/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/reports/judging-schedule/components/error-state.tsx b/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/reports/judging-schedule/components/error-state.tsx index 3959c6a4a..4b750b96a 100644 --- a/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/reports/judging-schedule/components/error-state.tsx +++ b/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/reports/judging-schedule/components/error-state.tsx @@ -10,9 +10,12 @@ export function ErrorState() { return ( - + {t('error-loading')} diff --git a/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/reports/judging-schedule/components/loading-state.tsx b/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/reports/judging-schedule/components/loading-state.tsx index d2048d8c9..448a97386 100644 --- a/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/reports/judging-schedule/components/loading-state.tsx +++ b/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/reports/judging-schedule/components/loading-state.tsx @@ -9,9 +9,12 @@ export function LoadingState() { return ( - + {t('loading')} diff --git a/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/reports/judging-schedule/components/schedule-table.tsx b/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/reports/judging-schedule/components/schedule-table.tsx index 2fb4296c8..67f796b2d 100644 --- a/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/reports/judging-schedule/components/schedule-table.tsx +++ b/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/reports/judging-schedule/components/schedule-table.tsx @@ -58,7 +58,8 @@ export function ScheduleTable({ rooms, rows, sessionLength }: ScheduleTableProps sx={{ fontWeight: 600, fontSize: isMobile ? '0.75rem' : '1rem' - }}> + }} + > {t('table.start-time')} @@ -67,7 +68,8 @@ export function ScheduleTable({ rooms, rows, sessionLength }: ScheduleTableProps sx={{ fontWeight: 600, fontSize: isMobile ? '0.75rem' : '1rem' - }}> + }} + > {t('table.end-time')} @@ -77,7 +79,8 @@ export function ScheduleTable({ rooms, rows, sessionLength }: ScheduleTableProps sx={{ fontWeight: 600, fontSize: isMobile ? '0.75rem' : '1rem' - }}> + }} + > {room.name} @@ -104,20 +107,22 @@ export function ScheduleTable({ rooms, rows, sessionLength }: ScheduleTableProps + }} + > {dayjs(row.time).format('HH:mm')} + }} + > {endTime.format('HH:mm')} @@ -131,7 +136,8 @@ export function ScheduleTable({ rooms, rows, sessionLength }: ScheduleTableProps alignItems: 'center', justifyContent: 'center', gap: 1 - }}> + }} + > {event.title} + }} + > {sessionTime.format('HH:mm')} + }} + > {sessionEndTime.format('HH:mm')} @@ -219,9 +227,10 @@ export function ScheduleTable({ rooms, rows, sessionLength }: ScheduleTableProps ) : ( + }} + > - )} diff --git a/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/reports/judging-status/components/judging-status-mobile.tsx b/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/reports/judging-status/components/judging-status-mobile.tsx index 296a0521a..e5b882239 100644 --- a/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/reports/judging-status/components/judging-status-mobile.tsx +++ b/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/reports/judging-status/components/judging-status-mobile.tsx @@ -43,9 +43,12 @@ export const JudgingStatusMobile: React.FC = () => { if (currentSessions.length === 0 && nextSessions.length === 0) { return ( - + {t('empty-state.message')} @@ -61,9 +64,12 @@ export const JudgingStatusMobile: React.FC = () => { {t('table.current-round')} - + {dayjs(currentSessions[0].scheduledTime).format('HH:mm')} @@ -93,9 +99,12 @@ export const JudgingStatusMobile: React.FC = () => { {t('table.next-round')} - + {dayjs(nextSessions[0].scheduledTime).format('HH:mm')} diff --git a/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/reports/judging-status/components/judging-status-table.tsx b/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/reports/judging-status/components/judging-status-table.tsx index 46685f9b4..86d5ec91b 100644 --- a/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/reports/judging-status/components/judging-status-table.tsx +++ b/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/reports/judging-status/components/judging-status-table.tsx @@ -69,9 +69,12 @@ export const JudgingStatusTable: React.FC = () => { if (currentSessions.length === 0 && nextSessions.length === 0) { return ( - + {t('empty-state.message')} @@ -115,9 +118,12 @@ export const JudgingStatusTable: React.FC = () => { {t('table.current-round')} - + {dayjs(currentSessions[0].scheduledTime).format('HH:mm')} @@ -142,9 +148,12 @@ export const JudgingStatusTable: React.FC = () => { {t('table.next-round')} - + {dayjs(nextSessions[0].scheduledTime).format('HH:mm')} diff --git a/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/reports/judging-status/components/next-session-row.tsx b/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/reports/judging-status/components/next-session-row.tsx index 9e8bf696a..c9d385baa 100644 --- a/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/reports/judging-status/components/next-session-row.tsx +++ b/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/reports/judging-status/components/next-session-row.tsx @@ -20,11 +20,12 @@ export const NextSessionRow: React.FC = ({ session }) => { + }} + > {!team.arrived && ( = ({ session }) => { )} ) : ( - + )} diff --git a/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/reports/judging-status/components/session-card.tsx b/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/reports/judging-status/components/session-card.tsx index 013d7cff1..1f075bc58 100644 --- a/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/reports/judging-status/components/session-card.tsx +++ b/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/reports/judging-status/components/session-card.tsx @@ -39,9 +39,10 @@ export const SessionCard: React.FC = ({ + }} + > {t('table.room', { name: room.name })} @@ -55,9 +56,10 @@ export const SessionCard: React.FC = ({ direction="row" spacing={0.5} sx={{ - alignItems: "center", - flexWrap: "wrap" - }}> + alignItems: 'center', + flexWrap: 'wrap' + }} + > = ({ {session.startTime && session.startDelta !== undefined && ( - + {t('table.started-at', { time: dayjs(session.startTime).format('HH:mm') })} @@ -88,9 +93,12 @@ export const SessionCard: React.FC = ({ )} {session.status === 'in-progress' && session.startTime && ( - + {t('table.ends-at', { time: dayjs(session.startTime).add(sessionLength, 'seconds').format('HH:mm') })} @@ -98,9 +106,12 @@ export const SessionCard: React.FC = ({ )} ) : ( - + {t('empty-state.no-team')} )} diff --git a/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/reports/judging-status/components/session-row.tsx b/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/reports/judging-status/components/session-row.tsx index c9a1210c4..97b54b5b8 100644 --- a/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/reports/judging-status/components/session-row.tsx +++ b/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/reports/judging-status/components/session-row.tsx @@ -22,10 +22,11 @@ export const SessionRow: React.FC = ({ session, sessionLength } + }} + > @@ -36,10 +37,11 @@ export const SessionRow: React.FC = ({ session, sessionLength } direction="row" spacing={0.5} sx={{ - alignItems: "center", - flexWrap: "wrap", - justifyContent: "center" - }}> + alignItems: 'center', + flexWrap: 'wrap', + justifyContent: 'center' + }} + > = ({ session, sessionLength } {session.startTime && session.startDelta !== undefined && ( - + {t('table.started-at', { time: dayjs(session.startTime).format('HH:mm') })} @@ -59,9 +64,12 @@ export const SessionRow: React.FC = ({ session, sessionLength } )} {session.status === 'in-progress' && session.startTime && ( - + {t('table.ends-at', { time: dayjs(session.startTime).add(sessionLength, 'seconds').format('HH:mm') })} @@ -69,9 +77,12 @@ export const SessionRow: React.FC = ({ session, sessionLength } )} ) : ( - + )} diff --git a/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/reports/judging-status/components/status-legend.tsx b/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/reports/judging-status/components/status-legend.tsx index 9d13456ef..3694611f0 100644 --- a/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/reports/judging-status/components/status-legend.tsx +++ b/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/reports/judging-status/components/status-legend.tsx @@ -108,9 +108,10 @@ export const StatusLegend: React.FC = ({ open, anchorEl, onCl direction="row" spacing={1.5} sx={{ - alignItems: "flex-start", + alignItems: 'flex-start', py: 0.5 - }}> + }} + > + width: '100%' + }} + > } desktop={} /> setAnchorEl(null)} /> diff --git a/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/reports/pit-map/page.tsx b/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/reports/pit-map/page.tsx index 843d8e3cb..98bbddb90 100644 --- a/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/reports/pit-map/page.tsx +++ b/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/reports/pit-map/page.tsx @@ -31,18 +31,34 @@ export default function PitMapReportPage() { alignItems: 'center' }} > - {loading && {t('loading')}} + {loading && ( + + {t('loading')} + + )} - {error && !loading && {t('error-loading')}} + {error && !loading && ( + + {t('error-loading')} + + )} {!loading && !error && !pitMapUrl && ( - {t('no-map')} + + {t('no-map')} + )} {!loading && !error && pitMapUrl && ( diff --git a/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/reports/scoreboard/components/error-state.tsx b/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/reports/scoreboard/components/error-state.tsx index 8c26f2a49..5adbd7091 100644 --- a/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/reports/scoreboard/components/error-state.tsx +++ b/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/reports/scoreboard/components/error-state.tsx @@ -8,9 +8,12 @@ export function ErrorState() { return ( - + {t('error-loading')} diff --git a/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/reports/scoreboard/components/loading-state.tsx b/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/reports/scoreboard/components/loading-state.tsx index 23dfe2894..6c2cb71aa 100644 --- a/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/reports/scoreboard/components/loading-state.tsx +++ b/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/reports/scoreboard/components/loading-state.tsx @@ -8,9 +8,13 @@ export function LoadingState() { return ( - {t('loading')} + + {t('loading')} + ); } diff --git a/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/reports/scoreboard/components/mobile-scoreboard.tsx b/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/reports/scoreboard/components/mobile-scoreboard.tsx index f03d54c0f..bbe039694 100644 --- a/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/reports/scoreboard/components/mobile-scoreboard.tsx +++ b/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/reports/scoreboard/components/mobile-scoreboard.tsx @@ -19,14 +19,18 @@ export function MobileScoreboard({ data, matchesPerTeam }: MobileScoreboardProps return ( - + }} + > + {t('no-data')} @@ -107,9 +111,12 @@ export function MobileScoreboard({ data, matchesPerTeam }: MobileScoreboardProps > {entry.name} - + #{entry.number} @@ -118,17 +125,19 @@ export function MobileScoreboard({ data, matchesPerTeam }: MobileScoreboardProps + color: 'text.secondary', + fontSize: '0.75rem' + }} + > {t('best-score')} + color: 'primary.main' + }} + > {entry.maxScore ?? '-'} @@ -141,17 +150,19 @@ export function MobileScoreboard({ data, matchesPerTeam }: MobileScoreboardProps + }} + > {t('match-scores')} + }} + > {Array.from({ length: matchesPerTeam }, (_, index) => { const score = entry.scores[index]; return ( @@ -174,14 +185,18 @@ export function MobileScoreboard({ data, matchesPerTeam }: MobileScoreboardProps + display: 'block', + fontSize: '0.7rem' + }} + > {t('match')} {index + 1} - + {score ?? '-'} diff --git a/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/reports/scoreboard/components/scoreboard-table.tsx b/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/reports/scoreboard/components/scoreboard-table.tsx index 55c936601..e6d411088 100644 --- a/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/reports/scoreboard/components/scoreboard-table.tsx +++ b/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/reports/scoreboard/components/scoreboard-table.tsx @@ -61,11 +61,12 @@ export function ScoreboardTable({ data, matchesPerTeam }: ScoreboardTableProps) noRowsOverlay: () => ( + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + height: '100%' + }} + > {t('no-data')} ) diff --git a/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/reports/team-list/components/arrival-stats.tsx b/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/reports/team-list/components/arrival-stats.tsx index 044d36ae4..2831914b0 100644 --- a/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/reports/team-list/components/arrival-stats.tsx +++ b/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/reports/team-list/components/arrival-stats.tsx @@ -15,9 +15,13 @@ export const ArrivalStats: React.FC = ({ teams }) => { const registeredCount = teams.filter(t => t.arrived).length; return ( - + = ({ teams }) => { {teams.length} - + {t('total-teams')} @@ -64,9 +71,12 @@ export const ArrivalStats: React.FC = ({ teams }) => { > {registeredCount} - + {t('arrived-teams')} @@ -96,9 +106,12 @@ export const ArrivalStats: React.FC = ({ teams }) => { > {teams.length - registeredCount} - + {t('pending-teams')} diff --git a/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/reports/team-list/components/desktop-team-list-table.tsx b/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/reports/team-list/components/desktop-team-list-table.tsx index 1d4f9c6a1..b2f5b6b35 100644 --- a/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/reports/team-list/components/desktop-team-list-table.tsx +++ b/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/reports/team-list/components/desktop-team-list-table.tsx @@ -80,9 +80,13 @@ export const DesktopTeamListTable: React.FC = ({ team {sortedTeams.length === 0 ? ( - {t('no-teams')} + + {t('no-teams')} + ) : ( diff --git a/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/reports/team-list/components/mobile-team-list-table.tsx b/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/reports/team-list/components/mobile-team-list-table.tsx index 8b930ebfa..6104362f2 100644 --- a/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/reports/team-list/components/mobile-team-list-table.tsx +++ b/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/reports/team-list/components/mobile-team-list-table.tsx @@ -50,14 +50,20 @@ export const MobileTeamListTable: React.FC = ({ teams variant={team.arrived ? 'filled' : 'outlined'} /> - + {team.affiliation} - + {team.city} diff --git a/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/reports/team-list/page.tsx b/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/reports/team-list/page.tsx index e6e589bfb..e1609c653 100644 --- a/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/reports/team-list/page.tsx +++ b/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/reports/team-list/page.tsx @@ -51,9 +51,12 @@ export default function TeamListPage() { {error && ( - + {t('error-loading')} @@ -68,9 +71,13 @@ export default function TeamListPage() { {loading && ( - {t('loading')} + + {t('loading')} + )} diff --git a/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/scorekeeper/components/audience-display-control.tsx b/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/scorekeeper/components/audience-display-control.tsx index 23c460337..1dfce9443 100644 --- a/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/scorekeeper/components/audience-display-control.tsx +++ b/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/scorekeeper/components/audience-display-control.tsx @@ -52,9 +52,13 @@ export function AudienceDisplayControl() { height: '100%' }} > - + const showPreview = !isOnFinalSlide; return ( - + {/* Display Area - Current and Next Slide */} + }} + > {/* Current Slide */} = ({ deckRef, t direction="row" spacing={1.5} sx={{ - flexWrap: "wrap", - justifyContent: "center" - }}> + flexWrap: 'wrap', + justifyContent: 'center' + }} + > - + {event.name} {division.name && `• ${division.name}`} @@ -52,10 +56,11 @@ export const TeamInfoHeader: React.FC = () => { component={Link} href={`/teams/${team.slug}`} sx={{ - alignItems: "center", + alignItems: 'center', textDecoration: 'none', color: 'inherit' - }}> + }} + > { + }} + > {team.name} #{team.number} @@ -77,13 +83,17 @@ export const TeamInfoHeader: React.FC = () => { direction="row" spacing={1} sx={{ - alignItems: "center", + alignItems: 'center', mt: 1 - }}> + }} + > - + {team.city} • {team.affiliation} diff --git a/apps/portal/src/app/[locale]/event/[slug]/team/[teamSlug]/components/team-schedule.tsx b/apps/portal/src/app/[locale]/event/[slug]/team/[teamSlug]/components/team-schedule.tsx index e0b2981b9..ec0d3f3fe 100644 --- a/apps/portal/src/app/[locale]/event/[slug]/team/[teamSlug]/components/team-schedule.tsx +++ b/apps/portal/src/app/[locale]/event/[slug]/team/[teamSlug]/components/team-schedule.tsx @@ -78,21 +78,28 @@ export const TeamSchedule: React.FC = () => { direction="row" spacing={1} sx={{ - alignItems: "center", + alignItems: 'center', mb: 2 - }}> + }} + > - + {t('schedule.title')} {scheduleEntries.length === 0 && ( - + {t('schedule.no-schedule')} @@ -113,9 +120,12 @@ export const TeamSchedule: React.FC = () => { - + {dayjs(entry.time).format('HH:mm')} { mx: 2 }} /> - + {entry.description} diff --git a/apps/portal/src/app/[locale]/events/components/event-list-item.tsx b/apps/portal/src/app/[locale]/events/components/event-list-item.tsx index 0ac34ba4b..3e51a5eeb 100644 --- a/apps/portal/src/app/[locale]/events/components/event-list-item.tsx +++ b/apps/portal/src/app/[locale]/events/components/event-list-item.tsx @@ -92,23 +92,26 @@ export const EventListItem: React.FC = ({ event, variant = ' direction={{ xs: 'column', sm: 'row' }} spacing={2} sx={{ - justifyContent: "space-between", + justifyContent: 'space-between', alignItems: { xs: 'flex-start', sm: 'center' } - }}> + }} + > + }} + > + }} + > {event.name} {!event.official && ( @@ -128,26 +131,38 @@ export const EventListItem: React.FC = ({ event, variant = ' spacing={{ xs: 1, sm: 3 }} sx={{ color: 'text.secondary' }} > - + {new Date(event.startDate).toLocaleDateString()} - + {event.location} - + {tEvents('teams-registered', { count: event.teamsRegistered })} diff --git a/apps/portal/src/app/[locale]/events/components/event-list-section.tsx b/apps/portal/src/app/[locale]/events/components/event-list-section.tsx index dc4035691..204848b9e 100644 --- a/apps/portal/src/app/[locale]/events/components/event-list-section.tsx +++ b/apps/portal/src/app/[locale]/events/components/event-list-section.tsx @@ -36,17 +36,22 @@ export const EventsListSection: React.FC = ({ return ( - + {t('no-events.title')} + }} + > {searchValue ? t('no-events.search-message') : t('no-events.filter-message')} diff --git a/apps/portal/src/app/[locale]/events/components/event-section.tsx b/apps/portal/src/app/[locale]/events/components/event-section.tsx index 267204ffb..a462b631d 100644 --- a/apps/portal/src/app/[locale]/events/components/event-section.tsx +++ b/apps/portal/src/app/[locale]/events/components/event-section.tsx @@ -21,12 +21,13 @@ export const EventSection: React.FC = ({ events, variant }) = + }} + > {t(`filters.${variant}`, { count: filteredEvents.length })} diff --git a/apps/portal/src/app/[locale]/events/components/events-page-header.tsx b/apps/portal/src/app/[locale]/events/components/events-page-header.tsx index 0c69e2da6..1f54f4588 100644 --- a/apps/portal/src/app/[locale]/events/components/events-page-header.tsx +++ b/apps/portal/src/app/[locale]/events/components/events-page-header.tsx @@ -36,10 +36,11 @@ export const EventsPageHeader: React.FC = ({ variant="h3" component="h1" sx={{ - fontWeight: "bold", + fontWeight: 'bold', mb: 4, fontSize: { xs: '1.75rem', sm: '2.25rem', md: '2.75rem' } - }}> + }} + > {{tags => t.rich('title', tags)}} diff --git a/apps/portal/src/app/[locale]/events/components/events-search-section.tsx b/apps/portal/src/app/[locale]/events/components/events-search-section.tsx index b822770f2..8bbcce3b7 100644 --- a/apps/portal/src/app/[locale]/events/components/events-search-section.tsx +++ b/apps/portal/src/app/[locale]/events/components/events-search-section.tsx @@ -59,9 +59,14 @@ export const EventsSearchSection: React.FC = ({ }} /> - + onFilterChange(EventFilter.ALL)} diff --git a/apps/portal/src/app/[locale]/page.tsx b/apps/portal/src/app/[locale]/page.tsx index 06fa72c5d..a840be8ba 100644 --- a/apps/portal/src/app/[locale]/page.tsx +++ b/apps/portal/src/app/[locale]/page.tsx @@ -23,9 +23,13 @@ export default function HomePage() { - + diff --git a/apps/portal/src/app/[locale]/teams/[teamSlug]/components/season-selector.tsx b/apps/portal/src/app/[locale]/teams/[teamSlug]/components/season-selector.tsx index 4b2959439..ce402d7cb 100644 --- a/apps/portal/src/app/[locale]/teams/[teamSlug]/components/season-selector.tsx +++ b/apps/portal/src/app/[locale]/teams/[teamSlug]/components/season-selector.tsx @@ -31,9 +31,11 @@ export const SeasonSelector: React.FC = ({ currentSeason }) }; return ( - +