From 461d517175182b12a70e0530f240437381204954 Mon Sep 17 00:00:00 2001 From: johnmeshulam <55348702+johnmeshulam@users.noreply.github.com> Date: Thu, 1 Jan 2026 01:04:22 +0200 Subject: [PATCH] Copilot draft --- .../graphql/resolvers/awards/award-winner.ts | 96 +++++++++++++++++++ .../resolvers/divisions/division-awards.ts | 6 +- .../resolvers/divisions/public-venue.ts | 46 +++++++++ .../graphql/resolvers/divisions/resolver.ts | 13 ++- .../lib/graphql/resolvers/field/scoresheet.ts | 13 ++- .../src/lib/graphql/resolvers/index.ts | 11 ++- .../lib/graphql/resolvers/judging/rubric.ts | 13 ++- .../judging-session-started.ts | 6 +- .../rubrics/rubric-status-changed.ts | 13 ++- .../subscriptions/rubrics/rubric-updated.ts | 13 ++- .../scoresheet/scoresheet-status-changed.ts | 13 ++- .../scoresheet/scoresheet-updated.ts | 13 ++- .../resolvers/subscriptions/team-arrived.ts | 7 +- .../src/lib/graphql/utils/auth-helpers.ts | 67 +++++++++++++ .../[event]/login/graphql/venue/query.ts | 2 +- .../[event]/login/graphql/venue/types.ts | 2 +- .../login/hooks/use-role-info-options.ts | 6 +- .../src/lib/api/lems/graphql/award.graphql | 51 ++++++---- .../src/lib/api/lems/graphql/public.graphql | 25 +++++ 19 files changed, 380 insertions(+), 36 deletions(-) create mode 100644 apps/backend/src/lib/graphql/resolvers/awards/award-winner.ts create mode 100644 apps/backend/src/lib/graphql/resolvers/divisions/public-venue.ts create mode 100644 apps/backend/src/lib/graphql/utils/auth-helpers.ts create mode 100644 libs/types/src/lib/api/lems/graphql/public.graphql diff --git a/apps/backend/src/lib/graphql/resolvers/awards/award-winner.ts b/apps/backend/src/lib/graphql/resolvers/awards/award-winner.ts new file mode 100644 index 000000000..aaa994906 --- /dev/null +++ b/apps/backend/src/lib/graphql/resolvers/awards/award-winner.ts @@ -0,0 +1,96 @@ +import { GraphQLFieldResolver } from 'graphql'; +import db from '../../../database'; +import { buildTeamGraphQL, TeamGraphQL } from '../../utils/team-builder'; +import type { GraphQLContext } from '../../apollo-server'; +import { requireAuthDivisionAndRole, requireAuthAndDivisionAccess } from '../../utils/auth-helpers'; + +// TODO: Replace with actual value from division/event settings +const AWARDS_ANNOUNCED = false; + +// Allowed roles before awards are announced +const PRE_ANNOUNCEMENT_ROLES = ['judge-advisor']; + +export interface AwardGraphQL { + id: string; + divisionId?: string; + name: string; + index: number; + place: number; + type: string; + isOptional: boolean; + allowNominations: boolean; + automaticAssignment: boolean; + description?: string; +} + +export interface AwardWinnerGraphQL { + teamId: string; + divisionId: string; // Added to track division +} + +/** + * Resolver for Award.winner field. + * Returns the computed winner for an award. + * + * Permission rules: + * - Before awards are announced: Requires judge-advisor role + * - After awards are announced: Accessible to anyone with division access + * + * @throws GraphQLError if user doesn't have appropriate permissions + */ +export const awardWinnerResolver: GraphQLFieldResolver< + AwardGraphQL, + GraphQLContext, + unknown, + Promise +> = async (award: AwardGraphQL, _args: unknown, context: GraphQLContext) => { + if (!award.divisionId) { + throw new Error('Award must have divisionId to resolve winner'); + } + + // If awards haven't been announced yet, require judge-advisor role + if (!AWARDS_ANNOUNCED) { + requireAuthDivisionAndRole(context.user, award.divisionId, PRE_ANNOUNCEMENT_ROLES); + } else { + // After announcement, only require basic division access + requireAuthAndDivisionAccess(context.user, award.divisionId); + } + + try { + // TODO: Implement actual database query for award winners + // For now, return null as award winners aren't implemented yet + // When implemented, query should look like: + // const awardWinner = await db.awardWinners.byAwardId(award.id).get(); + + // Placeholder return + return null; + } catch (error) { + console.error('Error fetching award winner:', error); + throw error; + } +}; + +/** + * Resolver for AwardWinner.team field. + * Fetches the team that won the award. + */ +export const awardWinnerTeamResolver: GraphQLFieldResolver< + AwardWinnerGraphQL, + unknown, + unknown, + Promise +> = async (awardWinner: AwardWinnerGraphQL) => { + try { + const team = await db.teams.byId(awardWinner.teamId).get(); + + if (!team) { + throw new Error(`Team not found: ${awardWinner.teamId}`); + } + + // Use the divisionId from the awardWinner object + return buildTeamGraphQL(team, awardWinner.divisionId); + } catch (error) { + console.error('Error fetching team for award winner:', error); + throw error; + } +}; diff --git a/apps/backend/src/lib/graphql/resolvers/divisions/division-awards.ts b/apps/backend/src/lib/graphql/resolvers/divisions/division-awards.ts index 96e60f751..7407d8f5f 100644 --- a/apps/backend/src/lib/graphql/resolvers/divisions/division-awards.ts +++ b/apps/backend/src/lib/graphql/resolvers/divisions/division-awards.ts @@ -11,6 +11,7 @@ interface AwardsArgs { export interface AwardGraphQL { id: string; + divisionId: string; // Added for award winner resolver name: string; index: number; place: number; @@ -18,6 +19,7 @@ export interface AwardGraphQL { isOptional: boolean; allowNominations: boolean; automaticAssignment: boolean; + description?: string; // Added description field } /** @@ -43,13 +45,15 @@ export const divisionAwardsResolver: GraphQLFieldResolver< return awards.map(award => ({ id: award.id, + divisionId: division.id, name: award.name, index: award.index, place: award.place, type: award.type, isOptional: award.is_optional, allowNominations: award.allow_nominations, - automaticAssignment: award.automatic_assignment + automaticAssignment: award.automatic_assignment, + description: award.description })); } catch (error) { console.error('Error fetching awards for division:', division.id, error); diff --git a/apps/backend/src/lib/graphql/resolvers/divisions/public-venue.ts b/apps/backend/src/lib/graphql/resolvers/divisions/public-venue.ts new file mode 100644 index 000000000..7004b0dab --- /dev/null +++ b/apps/backend/src/lib/graphql/resolvers/divisions/public-venue.ts @@ -0,0 +1,46 @@ +import { GraphQLFieldResolver } from 'graphql'; +import db from '../../../database'; + +interface PublicDivisionVenueGraphQL { + id: string; + tables: Array<{ id: string; name: string }>; + rooms: Array<{ id: string; name: string }>; +} + +/** + * Query resolver for fetching public venue information (tables and rooms) for a division. + * This resolver does not require authentication and is used during the login flow. + * @throws Error if division ID is not provided or division not found + */ +export const publicDivisionVenueResolver: GraphQLFieldResolver< + unknown, + unknown, + { id: string }, + Promise +> = async (_parent, args) => { + if (!args.id) { + throw new Error('Division ID is required'); + } + + try { + // Verify division exists + const division = await db.divisions.byId(args.id).get(); + + if (!division) { + throw new Error(`Division with ID ${args.id} not found`); + } + + // Fetch tables and rooms for this division + const tables = await db.tables.byDivisionId(args.id).getAll(); + const rooms = await db.rooms.byDivisionId(args.id).getAll(); + + return { + id: args.id, + tables: tables.map(t => ({ id: t.id, name: t.name })), + rooms: rooms.map(r => ({ id: r.id, name: r.name })) + }; + } catch (error) { + console.error('Error fetching public division venue:', error); + throw error; + } +}; diff --git a/apps/backend/src/lib/graphql/resolvers/divisions/resolver.ts b/apps/backend/src/lib/graphql/resolvers/divisions/resolver.ts index e57f5e2d2..d845704d9 100644 --- a/apps/backend/src/lib/graphql/resolvers/divisions/resolver.ts +++ b/apps/backend/src/lib/graphql/resolvers/divisions/resolver.ts @@ -1,14 +1,25 @@ import db from '../../../database'; +import type { GraphQLContext } from '../../apollo-server'; +import { requireAuthAndDivisionAccess } from '../../utils/auth-helpers'; /** * Query resolver for fetching a single division by ID. + * Requires user to be authenticated and assigned to the division. * @throws Error if division ID is not provided or division not found + * @throws GraphQLError if user is not authenticated or doesn't have access */ -export const divisionResolver = async (_parent: unknown, args: { id: string }) => { +export const divisionResolver = async ( + _parent: unknown, + args: { id: string }, + context: GraphQLContext +) => { if (!args.id) { throw new Error('Division ID is required'); } + // Require authentication and division access + requireAuthAndDivisionAccess(context.user, args.id); + try { const division = await db.divisions.byId(args.id).get(); diff --git a/apps/backend/src/lib/graphql/resolvers/field/scoresheet.ts b/apps/backend/src/lib/graphql/resolvers/field/scoresheet.ts index 541f1f3df..253d5b48f 100644 --- a/apps/backend/src/lib/graphql/resolvers/field/scoresheet.ts +++ b/apps/backend/src/lib/graphql/resolvers/field/scoresheet.ts @@ -3,6 +3,11 @@ import db from '../../../database'; import { buildTeamGraphQL, TeamGraphQL } from '../../utils/team-builder'; import { toGraphQLId } from '../../utils/object-id-transformer'; import { ScoresheetGraphQL } from '../../utils/scoresheet-builder'; +import type { GraphQLContext } from '../../apollo-server'; +import { requireAuthDivisionAndRole } from '../../utils/auth-helpers'; + +// Allowed roles for accessing scoresheet data +const SCORESHEET_ALLOWED_ROLES = ['referee', 'head-referee']; /** * Resolver for Scoresheet.team field. @@ -24,13 +29,17 @@ export const scoresheetTeamResolver: GraphQLFieldResolver< /** * Resolver for Scoresheet.data field. * Returns the scoresheet data if it exists. + * Requires user to be a referee or head-referee in the division. */ export const scoresheetDataResolver: GraphQLFieldResolver< ScoresheetGraphQL, - unknown, + GraphQLContext, unknown, ScoresheetGraphQL['data'] | null -> = (scoresheet: ScoresheetGraphQL) => { +> = (scoresheet: ScoresheetGraphQL, _args: unknown, context: GraphQLContext) => { + // Check authentication, division access, and role permissions + requireAuthDivisionAndRole(context.user, scoresheet.divisionId, SCORESHEET_ALLOWED_ROLES); + return scoresheet.data || null; }; diff --git a/apps/backend/src/lib/graphql/resolvers/index.ts b/apps/backend/src/lib/graphql/resolvers/index.ts index 921eea697..75a7cc739 100644 --- a/apps/backend/src/lib/graphql/resolvers/index.ts +++ b/apps/backend/src/lib/graphql/resolvers/index.ts @@ -1,6 +1,7 @@ import { GraphQLScalarType, Kind } from 'graphql'; import { eventResolvers } from './events/resolver'; import { divisionResolver } from './divisions/resolver'; +import { publicDivisionVenueResolver } from './divisions/public-venue'; import { isFullySetUpResolver } from './events/is-fully-set-up'; import { eventDivisionsResolver } from './events/event-divisions'; import { @@ -47,6 +48,7 @@ import { fieldScoresheetsResolver } from './divisions/field/scoresheets'; import { RubricUpdatedEventResolver } from './subscriptions/rubrics/rubric-updated'; import { ScoresheetUpdatedEventResolver } from './subscriptions/scoresheet/scoresheet-updated'; import { DeliberationUpdatedEventResolver } from './subscriptions/deliberations/deliberation-updated'; +import { awardWinnerResolver, awardWinnerTeamResolver } from './awards/award-winner'; // JSON scalar resolver - passes through any valid JSON value const JSONScalar = new GraphQLScalarType({ @@ -81,7 +83,8 @@ export const resolvers = { Query: { events: eventResolvers.Query.events, event: eventResolvers.Query.event, - division: divisionResolver + division: divisionResolver, + publicDivisionVenue: publicDivisionVenueResolver }, Mutation: mutationResolvers, Subscription: subscriptionResolvers, @@ -142,6 +145,12 @@ export const resolvers = { team: scoresheetTeamResolver, data: scoresheetDataResolver }, + Award: { + winner: awardWinnerResolver + }, + AwardWinner: { + team: awardWinnerTeamResolver + }, Volunteer: { divisions: volunteerDivisionsResolver }, diff --git a/apps/backend/src/lib/graphql/resolvers/judging/rubric.ts b/apps/backend/src/lib/graphql/resolvers/judging/rubric.ts index 6198c1a6c..d6718600b 100644 --- a/apps/backend/src/lib/graphql/resolvers/judging/rubric.ts +++ b/apps/backend/src/lib/graphql/resolvers/judging/rubric.ts @@ -4,6 +4,11 @@ import db from '../../../database'; import { toGraphQLId } from '../../utils/object-id-transformer'; import { buildTeamGraphQL, TeamGraphQL } from '../../utils/team-builder'; import { RubricGraphQL } from '../../utils/rubric-builder'; +import type { GraphQLContext } from '../../apollo-server'; +import { requireAuthDivisionAndRole } from '../../utils/auth-helpers'; + +// Allowed roles for accessing rubric data +const RUBRIC_ALLOWED_ROLES = ['judge', 'lead-judge', 'judge-advisor']; /** * Resolver for Rubric.team field. @@ -25,13 +30,17 @@ export const rubricTeamResolver: GraphQLFieldResolver< /** * Resolver for Rubric.data field. * Returns the rubric data if it exists. + * Requires user to be a judge, lead-judge, or judge-advisor in the division. */ export const rubricDataResolver: GraphQLFieldResolver< RubricGraphQL, - unknown, + GraphQLContext, unknown, RubricGraphQL['data'] | null -> = (rubric: RubricGraphQL) => { +> = (rubric: RubricGraphQL, _args: unknown, context: GraphQLContext) => { + // Check authentication, division access, and role permissions + requireAuthDivisionAndRole(context.user, rubric.divisionId, RUBRIC_ALLOWED_ROLES); + return rubric.data || null; }; diff --git a/apps/backend/src/lib/graphql/resolvers/subscriptions/judging-sessions/judging-session-started.ts b/apps/backend/src/lib/graphql/resolvers/subscriptions/judging-sessions/judging-session-started.ts index bcf5a75ab..c45bce7fa 100644 --- a/apps/backend/src/lib/graphql/resolvers/subscriptions/judging-sessions/judging-session-started.ts +++ b/apps/backend/src/lib/graphql/resolvers/subscriptions/judging-sessions/judging-session-started.ts @@ -1,5 +1,7 @@ import { RedisEventTypes } from '@lems/types/api/lems/redis'; import { getRedisPubSub } from '../../../../redis/redis-pubsub'; +import type { GraphQLContext } from '../../../apollo-server'; +import { requireAuthAndDivisionAccess } from '../../../utils/auth-helpers'; interface JudgingSessionStartedSubscribeArgs { divisionId: string; @@ -34,9 +36,11 @@ const processJudgingSessionStartedEvent = async ( const judgingSessionStartedSubscribe = ( _root: unknown, - { divisionId }: JudgingSessionStartedSubscribeArgs + { divisionId }: JudgingSessionStartedSubscribeArgs, + context: GraphQLContext ) => { if (!divisionId) throw new Error('divisionId is required'); + requireAuthAndDivisionAccess(context.user, divisionId); const pubSub = getRedisPubSub(); return pubSub.asyncIterator(divisionId, RedisEventTypes.JUDGING_SESSION_STARTED); }; diff --git a/apps/backend/src/lib/graphql/resolvers/subscriptions/rubrics/rubric-status-changed.ts b/apps/backend/src/lib/graphql/resolvers/subscriptions/rubrics/rubric-status-changed.ts index 5622e5bbc..ba83dc7f0 100644 --- a/apps/backend/src/lib/graphql/resolvers/subscriptions/rubrics/rubric-status-changed.ts +++ b/apps/backend/src/lib/graphql/resolvers/subscriptions/rubrics/rubric-status-changed.ts @@ -1,5 +1,10 @@ import { RedisEventTypes } from '@lems/types/api/lems/redis'; import { getRedisPubSub } from '../../../../redis/redis-pubsub'; +import type { GraphQLContext } from '../../../apollo-server'; +import { requireAuthDivisionAndRole } from '../../../utils/auth-helpers'; + +// Allowed roles for accessing rubric data +const RUBRIC_ALLOWED_ROLES = ['judge', 'lead-judge', 'judge-advisor']; interface RubricStatusChangedSubscribeArgs { divisionId: string; @@ -21,8 +26,14 @@ async function processRubricStatusChangedEvent( } export const rubricStatusChangedResolver = { - subscribe: (_root: unknown, { divisionId }: RubricStatusChangedSubscribeArgs) => { + subscribe: ( + _root: unknown, + { divisionId }: RubricStatusChangedSubscribeArgs, + context: GraphQLContext + ) => { if (!divisionId) throw new Error('divisionId is required'); + // Require authentication, division access, and appropriate role for rubric data + requireAuthDivisionAndRole(context.user, divisionId, RUBRIC_ALLOWED_ROLES); const pubSub = getRedisPubSub(); return pubSub.asyncIterator(divisionId, RedisEventTypes.RUBRIC_STATUS_CHANGED); }, diff --git a/apps/backend/src/lib/graphql/resolvers/subscriptions/rubrics/rubric-updated.ts b/apps/backend/src/lib/graphql/resolvers/subscriptions/rubrics/rubric-updated.ts index d19342c86..bda8ba29b 100644 --- a/apps/backend/src/lib/graphql/resolvers/subscriptions/rubrics/rubric-updated.ts +++ b/apps/backend/src/lib/graphql/resolvers/subscriptions/rubrics/rubric-updated.ts @@ -1,5 +1,10 @@ import { RedisEventTypes } from '@lems/types/api/lems/redis'; import { getRedisPubSub } from '../../../../redis/redis-pubsub'; +import type { GraphQLContext } from '../../../apollo-server'; +import { requireAuthDivisionAndRole } from '../../../utils/auth-helpers'; + +// Allowed roles for accessing rubric data +const RUBRIC_ALLOWED_ROLES = ['judge', 'lead-judge', 'judge-advisor']; interface RubricUpdatedSubscribeArgs { divisionId: string; @@ -110,8 +115,14 @@ async function processRubricUpdatedEvent( } export const rubricUpdatedResolver = { - subscribe: (_root: unknown, { divisionId }: RubricUpdatedSubscribeArgs) => { + subscribe: ( + _root: unknown, + { divisionId }: RubricUpdatedSubscribeArgs, + context: GraphQLContext + ) => { if (!divisionId) throw new Error('divisionId is required'); + // Require authentication, division access, and appropriate role for rubric data + requireAuthDivisionAndRole(context.user, divisionId, RUBRIC_ALLOWED_ROLES); const pubSub = getRedisPubSub(); return pubSub.asyncIterator(divisionId, RedisEventTypes.RUBRIC_UPDATED); }, diff --git a/apps/backend/src/lib/graphql/resolvers/subscriptions/scoresheet/scoresheet-status-changed.ts b/apps/backend/src/lib/graphql/resolvers/subscriptions/scoresheet/scoresheet-status-changed.ts index 523dfc373..aff97a9b6 100644 --- a/apps/backend/src/lib/graphql/resolvers/subscriptions/scoresheet/scoresheet-status-changed.ts +++ b/apps/backend/src/lib/graphql/resolvers/subscriptions/scoresheet/scoresheet-status-changed.ts @@ -1,5 +1,10 @@ import { RedisEventTypes } from '@lems/types/api/lems/redis'; import { getRedisPubSub } from '../../../../redis/redis-pubsub'; +import type { GraphQLContext } from '../../../apollo-server'; +import { requireAuthDivisionAndRole } from '../../../utils/auth-helpers'; + +// Allowed roles for accessing scoresheet data +const SCORESHEET_ALLOWED_ROLES = ['referee', 'head-referee']; interface ScoresheetStatusChangedSubscribeArgs { divisionId: string; @@ -20,8 +25,14 @@ async function processScoresheetStatusChangedEvent( } export const scoresheetStatusChangedResolver = { - subscribe: (_root: unknown, { divisionId }: ScoresheetStatusChangedSubscribeArgs) => { + subscribe: ( + _root: unknown, + { divisionId }: ScoresheetStatusChangedSubscribeArgs, + context: GraphQLContext + ) => { if (!divisionId) throw new Error('divisionId is required'); + // Require authentication, division access, and appropriate role for scoresheet data + requireAuthDivisionAndRole(context.user, divisionId, SCORESHEET_ALLOWED_ROLES); const pubSub = getRedisPubSub(); return pubSub.asyncIterator(divisionId, RedisEventTypes.SCORESHEET_STATUS_CHANGED); }, diff --git a/apps/backend/src/lib/graphql/resolvers/subscriptions/scoresheet/scoresheet-updated.ts b/apps/backend/src/lib/graphql/resolvers/subscriptions/scoresheet/scoresheet-updated.ts index ff18c576c..db9845f09 100644 --- a/apps/backend/src/lib/graphql/resolvers/subscriptions/scoresheet/scoresheet-updated.ts +++ b/apps/backend/src/lib/graphql/resolvers/subscriptions/scoresheet/scoresheet-updated.ts @@ -1,6 +1,11 @@ import { RedisEventTypes } from '@lems/types/api/lems/redis'; import { ScoresheetClauseValue } from '@lems/shared/scoresheet'; import { getRedisPubSub } from '../../../../redis/redis-pubsub'; +import type { GraphQLContext } from '../../../apollo-server'; +import { requireAuthDivisionAndRole } from '../../../utils/auth-helpers'; + +// Allowed roles for accessing scoresheet data +const SCORESHEET_ALLOWED_ROLES = ['referee', 'head-referee']; interface ScoresheetUpdatedSubscribeArgs { divisionId: string; @@ -107,8 +112,14 @@ async function processScoresheetUpdatedEvent( } export const scoresheetUpdatedResolver = { - subscribe: (_root: unknown, { divisionId }: ScoresheetUpdatedSubscribeArgs) => { + subscribe: ( + _root: unknown, + { divisionId }: ScoresheetUpdatedSubscribeArgs, + context: GraphQLContext + ) => { if (!divisionId) throw new Error('divisionId is required'); + // Require authentication, division access, and appropriate role for scoresheet data + requireAuthDivisionAndRole(context.user, divisionId, SCORESHEET_ALLOWED_ROLES); const pubSub = getRedisPubSub(); return pubSub.asyncIterator(divisionId, RedisEventTypes.SCORESHEET_UPDATED); }, diff --git a/apps/backend/src/lib/graphql/resolvers/subscriptions/team-arrived.ts b/apps/backend/src/lib/graphql/resolvers/subscriptions/team-arrived.ts index 9baecf837..d5a2fd782 100644 --- a/apps/backend/src/lib/graphql/resolvers/subscriptions/team-arrived.ts +++ b/apps/backend/src/lib/graphql/resolvers/subscriptions/team-arrived.ts @@ -1,5 +1,7 @@ import { RedisEventTypes } from '@lems/types/api/lems/redis'; import { getRedisPubSub } from '../../../redis/redis-pubsub'; +import type { GraphQLContext } from '../../apollo-server'; +import { requireAuthAndDivisionAccess } from '../../utils/auth-helpers'; interface TeamEvent { teamId: string; @@ -14,9 +16,12 @@ interface TeamArrivalUpdatedSubscribeArgs { */ const teamArrivalUpdatedSubscribe = ( _root: unknown, - { divisionId }: TeamArrivalUpdatedSubscribeArgs + { divisionId }: TeamArrivalUpdatedSubscribeArgs, + context: GraphQLContext ) => { if (!divisionId) throw new Error('divisionId is required'); + // Require authentication and division access (any role) + requireAuthAndDivisionAccess(context.user, divisionId); const pubSub = getRedisPubSub(); return pubSub.asyncIterator(divisionId, RedisEventTypes.TEAM_ARRIVED); }; diff --git a/apps/backend/src/lib/graphql/utils/auth-helpers.ts b/apps/backend/src/lib/graphql/utils/auth-helpers.ts new file mode 100644 index 000000000..d93b7f838 --- /dev/null +++ b/apps/backend/src/lib/graphql/utils/auth-helpers.ts @@ -0,0 +1,67 @@ +import { GraphQLError } from 'graphql'; +import type { VolunteerUser } from '../auth-context'; + +/** + * Checks if a user is authenticated + * @throws GraphQLError if user is not authenticated + */ +export function requireAuth(user?: VolunteerUser): asserts user is VolunteerUser { + if (!user) { + throw new GraphQLError('Authentication required', { + extensions: { code: 'UNAUTHENTICATED' } + }); + } +} + +/** + * Checks if a user has access to a specific division + * @throws GraphQLError if user doesn't have access to the division + */ +export function requireDivisionAccess(user: VolunteerUser, divisionId: string): void { + if (!user.divisions.includes(divisionId)) { + throw new GraphQLError('Access denied: user not assigned to this division', { + extensions: { code: 'FORBIDDEN' } + }); + } +} + +/** + * Checks if a user is authenticated and has access to a specific division + * @throws GraphQLError if user is not authenticated or doesn't have access + */ +export function requireAuthAndDivisionAccess( + user: VolunteerUser | undefined, + divisionId: string +): asserts user is VolunteerUser { + requireAuth(user); + requireDivisionAccess(user, divisionId); +} + +/** + * Checks if a user has one of the required roles + * @throws GraphQLError if user doesn't have any of the required roles + */ +export function requireRole(user: VolunteerUser, allowedRoles: string[]): void { + if (!allowedRoles.includes(user.role)) { + throw new GraphQLError( + `Access denied: requires one of these roles: ${allowedRoles.join(', ')}`, + { + extensions: { code: 'FORBIDDEN' } + } + ); + } +} + +/** + * Checks if a user is authenticated, has access to a division, and has one of the required roles + * @throws GraphQLError if any check fails + */ +export function requireAuthDivisionAndRole( + user: VolunteerUser | undefined, + divisionId: string, + allowedRoles: string[] +): asserts user is VolunteerUser { + requireAuth(user); + requireDivisionAccess(user, divisionId); + requireRole(user, allowedRoles); +} diff --git a/apps/frontend/src/app/[locale]/[event]/login/graphql/venue/query.ts b/apps/frontend/src/app/[locale]/[event]/login/graphql/venue/query.ts index 8b01aa98d..c659a068c 100644 --- a/apps/frontend/src/app/[locale]/[event]/login/graphql/venue/query.ts +++ b/apps/frontend/src/app/[locale]/[event]/login/graphql/venue/query.ts @@ -6,7 +6,7 @@ export const GET_DIVISION_VENUE_QUERY: TypedDocumentNode< GetDivisionVenueQueryVariables > = gql` query GetDivisionVenue($id: String!) { - division(id: $id) { + publicDivisionVenue(id: $id) { id tables { id diff --git a/apps/frontend/src/app/[locale]/[event]/login/graphql/venue/types.ts b/apps/frontend/src/app/[locale]/[event]/login/graphql/venue/types.ts index ee33c84f9..b3edcc64b 100644 --- a/apps/frontend/src/app/[locale]/[event]/login/graphql/venue/types.ts +++ b/apps/frontend/src/app/[locale]/[event]/login/graphql/venue/types.ts @@ -1,5 +1,5 @@ export type GetDivisionVenueQuery = { - division: { + publicDivisionVenue: { id: string; tables: { id: string; name: string }[]; rooms: { id: string; name: string }[]; diff --git a/apps/frontend/src/app/[locale]/[event]/login/hooks/use-role-info-options.ts b/apps/frontend/src/app/[locale]/[event]/login/hooks/use-role-info-options.ts index 805a6b946..9805414f1 100644 --- a/apps/frontend/src/app/[locale]/[event]/login/hooks/use-role-info-options.ts +++ b/apps/frontend/src/app/[locale]/[event]/login/hooks/use-role-info-options.ts @@ -53,9 +53,11 @@ export const useRoleInfoOptions = (divisionId: string | undefined): RoleInfoOpti let allOptions: RoleInfoOption[] = []; if (roleInfoType === 'table' || roleInfoType === 'room') { - if (divisionData?.division) { + if (divisionData?.publicDivisionVenue) { const items = - roleInfoType === 'table' ? divisionData.division.tables : divisionData.division.rooms; + roleInfoType === 'table' + ? divisionData.publicDivisionVenue.tables + : divisionData.publicDivisionVenue.rooms; allOptions = items.map((item: { id: string; name: string }) => ({ id: item.id, name: item.name diff --git a/libs/types/src/lib/api/lems/graphql/award.graphql b/libs/types/src/lib/api/lems/graphql/award.graphql index fb031fbcc..8088041a5 100644 --- a/libs/types/src/lib/api/lems/graphql/award.graphql +++ b/libs/types/src/lib/api/lems/graphql/award.graphql @@ -1,32 +1,45 @@ - """ An award configured for a division """ type Award { - "Unique identifier for the award" - id: String! + "Unique identifier for the award" + id: String! + + "Display name of the award" + name: String! - "Display name of the award" - name: String! + "Index/order of this award within the division" + index: Int! - "Index/order of this award within the division" - index: Int! + "Place/position number for this award" + place: Int! - "Place/position number for this award" - place: Int! + "Type of award (PERSONAL for individuals, TEAM for team-based)" + type: String! - "Type of award (PERSONAL for individuals, TEAM for team-based)" - type: String! + "Whether this award is optional" + isOptional: Boolean! - "Whether this award is optional" - isOptional: Boolean! + "Whether this award allows nominations" + allowNominations: Boolean! - "Whether this award allows nominations" - allowNominations: Boolean! + "Whether this award is assigned automatically" + automaticAssignment: Boolean! - "Whether this award is assigned automatically" - automaticAssignment: Boolean! + "Optional description for this award" + description: String + + "Computed winner for this award (requires judge-advisor role until awards are announced)" + winner: AwardWinner +} + +""" +Winner information for an award +""" +type AwardWinner { + "Team ID of the winner" + teamId: String! - "Optional description for this award" - description: String + "Team information" + team: Team! } diff --git a/libs/types/src/lib/api/lems/graphql/public.graphql b/libs/types/src/lib/api/lems/graphql/public.graphql new file mode 100644 index 000000000..0250a2919 --- /dev/null +++ b/libs/types/src/lib/api/lems/graphql/public.graphql @@ -0,0 +1,25 @@ +""" +Public venue information for a division, accessible without authentication. +Used during login to provide table and room options. +""" +type PublicDivisionVenue { + "Division ID" + id: String! + + "Competition tables in this division" + tables: [Table!]! + + "Judging rooms in this division" + rooms: [Room!]! +} + +extend type Query { + """ + Get public venue information for a division (tables and rooms only). + This query does not require authentication and is used during the login flow. + """ + publicDivisionVenue( + "Division ID to retrieve venue information for" + id: String! + ): PublicDivisionVenue +}