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 6438bb989..a043d068b 100644 --- a/apps/backend/src/lib/graphql/resolvers/divisions/division-awards.ts +++ b/apps/backend/src/lib/graphql/resolvers/divisions/division-awards.ts @@ -1,4 +1,5 @@ import { GraphQLFieldResolver } from 'graphql'; +import type { GraphQLContext } from '../../apollo-server'; import db from '../../../database'; interface DivisionWithId { @@ -17,18 +18,24 @@ export interface AwardGraphQL { type: 'PERSONAL' | 'TEAM'; isOptional: boolean; allowNominations: boolean; + winnerName?: string; + winnerId?: string; } +const allowedRoles = new Set(['judge-advisor', 'lead-judge']); + /** * Resolver for Division.awards field. * Fetches all awards configured for a division. + * Requires user authentication and division assignment. * @param division - The division object containing the id * @param args - Optional arguments to filter results * @param args.allowNominations - Filter by allowNominations + * @param context - GraphQL context containing user information */ export const divisionAwardsResolver: GraphQLFieldResolver< DivisionWithId, - unknown, + null, AwardsArgs, Promise > = async (division: DivisionWithId, args: AwardsArgs) => { @@ -54,3 +61,42 @@ export const divisionAwardsResolver: GraphQLFieldResolver< throw error; } }; + +/** + * Resolver for Division.awards.winners field. + * Fetches all awards with their winners for a division. + * Requires user authentication and division assignment. + * @param division - The division object containing the id + * @param _args - Unused arguments + * @param context - GraphQL context containing user information + */ +export const divisionAwardsWinnersResolver: GraphQLFieldResolver< + DivisionWithId, + GraphQLContext, + unknown, + Promise +> = async (division: DivisionWithId, _args: unknown, context: GraphQLContext) => { + try { + const awards = await db.awards.byDivisionId(division.id).getAll(); + + const areAwardsClosed = false; // Placeholder for actual check + const canViewWinners = areAwardsClosed || allowedRoles.has(context.user.role); + + return awards.map(award => ({ + id: award.id, + name: award.name, + index: award.index, + place: award.place, + type: award.type, + isOptional: award.is_optional, + allowNominations: award.allow_nominations, + ...(canViewWinners && { + winnerName: award.winner_name, + winnerId: award.winner_id + }) + })); + } catch (error) { + console.error('Error fetching awards with winners for division:', division.id, error); + throw error; + } +}; diff --git a/apps/backend/src/lib/graphql/resolvers/divisions/judging/judging-rubrics.ts b/apps/backend/src/lib/graphql/resolvers/divisions/judging/judging-rubrics.ts index 49b69530b..82ef05f52 100644 --- a/apps/backend/src/lib/graphql/resolvers/divisions/judging/judging-rubrics.ts +++ b/apps/backend/src/lib/graphql/resolvers/divisions/judging/judging-rubrics.ts @@ -1,7 +1,7 @@ import { GraphQLFieldResolver } from 'graphql'; import { JudgingCategory } from '@lems/database'; -import db from '../../../../database'; import { buildRubricResult, RubricGraphQL } from '../../../utils/rubric-builder'; +import db from '../../../../database'; interface JudgingWithDivisionId { divisionId: string; diff --git a/apps/backend/src/lib/graphql/resolvers/divisions/resolver.ts b/apps/backend/src/lib/graphql/resolvers/divisions/resolver.ts index e57f5e2d2..b58f210c1 100644 --- a/apps/backend/src/lib/graphql/resolvers/divisions/resolver.ts +++ b/apps/backend/src/lib/graphql/resolvers/divisions/resolver.ts @@ -1,19 +1,43 @@ +import { ResolverError, ResolverErrorCode } from '@lems/types/api/lems'; +import type { GraphQLContext } from '../../apollo-server'; import db from '../../../database'; /** * Query resolver for fetching a single division by ID. - * @throws Error if division ID is not provided or division not found + * Requires user authentication and verified division assignment. + * @throws Error if division ID is not provided, division not found, or user not authorized */ -export const divisionResolver = async (_parent: unknown, args: { id: string }) => { +export const divisionResolver = async ( + _parent: unknown, + args: { id: string }, + context: GraphQLContext +) => { + // Check authentication + if (!context.user) { + throw new ResolverError(ResolverErrorCode.UNAUTHORIZED, 'Authentication required'); + } + + // Check division assignment if (!args.id) { - throw new Error('Division ID is required'); + throw new ResolverError(ResolverErrorCode.UNAUTHORIZED, 'Division ID is required'); + } + + // Check if user is assigned to the requested division + if (!context.user.divisions.includes(args.id)) { + throw new ResolverError( + ResolverErrorCode.FORBIDDEN, + 'User is not assigned to this division' + ); } try { const division = await db.divisions.byId(args.id).get(); if (!division) { - throw new Error(`Division with ID ${args.id} not found`); + throw new ResolverError( + ResolverErrorCode.UNAUTHORIZED, + `Division with ID ${args.id} not found` + ); } return { diff --git a/apps/backend/src/lib/graphql/resolvers/events/resolver.ts b/apps/backend/src/lib/graphql/resolvers/events/resolver.ts index bd336d018..c7f4adb0f 100644 --- a/apps/backend/src/lib/graphql/resolvers/events/resolver.ts +++ b/apps/backend/src/lib/graphql/resolvers/events/resolver.ts @@ -27,6 +27,16 @@ interface EventsArgs { endBefore?: string; } +interface DivisionVenueArgs { + id: string; +} + +export interface DivisionVenue { + id: string; + tables: { id: string; name: string }[]; + rooms: { id: string; name: string }[]; +} + export const eventResolvers = { Query: { events: (async (_parent, args: EventsArgs) => { @@ -54,7 +64,33 @@ export const eventResolvers = { console.error('Error fetching event:', error); throw error; } - }) as GraphQLFieldResolver> + }) as GraphQLFieldResolver>, + + divisionVenue: (async (_parent, args: DivisionVenueArgs) => { + if (!args.id) { + throw new Error('Division ID is required'); + } + + try { + const division = await db.divisions.byId(args.id).get(); + + if (!division) { + throw new Error(`Division with ID ${args.id} not found`); + } + + const tables = await db.tables.byDivisionId(args.id).getAll(); + const rooms = await db.rooms.byDivisionId(args.id).getAll(); + + return { + id: division.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 division venue:', error); + throw error; + } + }) as GraphQLFieldResolver> } }; diff --git a/apps/backend/src/lib/graphql/resolvers/index.ts b/apps/backend/src/lib/graphql/resolvers/index.ts index 5b1db4d22..9b51d77bf 100644 --- a/apps/backend/src/lib/graphql/resolvers/index.ts +++ b/apps/backend/src/lib/graphql/resolvers/index.ts @@ -62,6 +62,7 @@ export const resolvers = { Query: { events: eventResolvers.Query.events, event: eventResolvers.Query.event, + divisionVenue: eventResolvers.Query.divisionVenue, division: divisionResolver }, Mutation: mutationResolvers, diff --git a/apps/backend/src/lib/graphql/resolvers/judging/rubric.ts b/apps/backend/src/lib/graphql/resolvers/judging/rubric.ts index 6198c1a6c..31af8ee2a 100644 --- a/apps/backend/src/lib/graphql/resolvers/judging/rubric.ts +++ b/apps/backend/src/lib/graphql/resolvers/judging/rubric.ts @@ -1,9 +1,11 @@ import { GraphQLFieldResolver } from 'graphql'; import { hyphensToUnderscores } from '@lems/shared/utils'; +import { ResolverError, ResolverErrorCode } from '@lems/types/api/lems'; 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 { GraphQLContext } from '../../apollo-server'; /** * Resolver for Rubric.team field. @@ -22,16 +24,26 @@ export const rubricTeamResolver: GraphQLFieldResolver< return buildTeamGraphQL(team, rubric.divisionId); }; +const allowedRubricDataRoles = new Set(['judge', 'lead-judge', 'judge-advisor']); + /** * Resolver for Rubric.data field. * Returns the rubric data if it exists. + * Only accessible to users with specific roles. */ export const rubricDataResolver: GraphQLFieldResolver< RubricGraphQL, - unknown, + GraphQLContext, unknown, RubricGraphQL['data'] | null -> = (rubric: RubricGraphQL) => { +> = (rubric: RubricGraphQL, context: GraphQLContext) => { + if (!allowedRubricDataRoles.has(context.user.role)) { + throw new ResolverError( + ResolverErrorCode.FORBIDDEN, + 'User does not have permission to view rubrics data.' + ); + } + return rubric.data || null; }; diff --git a/apps/frontend/src/app/[locale]/[event]/login/graphql/role-info-step.graphql.ts b/apps/frontend/src/app/[locale]/[event]/login/graphql/role-info-step.graphql.ts index dfb9580ee..4f848d8fb 100644 --- a/apps/frontend/src/app/[locale]/[event]/login/graphql/role-info-step.graphql.ts +++ b/apps/frontend/src/app/[locale]/[event]/login/graphql/role-info-step.graphql.ts @@ -2,7 +2,7 @@ import { gql } from '@apollo/client'; import type { TypedDocumentNode } from '@apollo/client'; type GetDivisionVenueQuery = { - division: { + divisionVenue: { id: string; tables: { id: string; name: string }[]; rooms: { id: string; name: string }[]; @@ -18,7 +18,7 @@ export const GET_DIVISION_VENUE_QUERY: TypedDocumentNode< GetDivisionVenueQueryVariables > = gql` query GetDivisionVenue($id: String!) { - division(id: $id) { + divisionVenue(id: $id) { id tables { id 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 dc9bcde7a..b9eb4eb68 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 @@ -48,14 +48,14 @@ export const useRoleInfoOptions = (divisionId: string | undefined): RoleInfoOpti ); return useMemo(() => { - if (!divisionData?.division || !roleInfoType || !volunteerData?.volunteers) return []; + if (!divisionData?.divisionVenue || !roleInfoType || !volunteerData?.volunteers) return []; let allOptions: RoleInfoOption[] = []; if (roleInfoType === 'table' || roleInfoType === 'room') { - if (divisionData?.division) { + if (divisionData?.divisionVenue) { const items = - roleInfoType === 'table' ? divisionData.division.tables : divisionData.division.rooms; + roleInfoType === 'table' ? divisionData.divisionVenue.tables : divisionData.divisionVenue.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-errors.ts b/libs/types/src/lib/api/lems/graphql-errors.ts index 778abe88b..60a055bac 100644 --- a/libs/types/src/lib/api/lems/graphql-errors.ts +++ b/libs/types/src/lib/api/lems/graphql-errors.ts @@ -9,6 +9,12 @@ export enum MutationErrorCode { INTERNAL_ERROR = 'INTERNAL_ERROR' } +export enum ResolverErrorCode { + UNAUTHORIZED = 'UNAUTHORIZED', + FORBIDDEN = 'FORBIDDEN', + INTERNAL_ERROR = 'INTERNAL_ERROR' +} + /** * GraphQL mutation error with typed error code and message. * Extends GraphQLError to provide better TypeScript support and consistency. @@ -24,3 +30,18 @@ export class MutationError extends GraphQLError { Object.setPrototypeOf(this, MutationError.prototype); } } + +/** + * GraphQL resolver error with typed error code and message. + * Extends GraphQLError to provide better TypeScript support and consistency. + */ +export class ResolverError extends GraphQLError { + readonly code: ResolverErrorCode; + constructor(code: ResolverErrorCode, message: string) { + super(message, { + extensions: { code, message } as unknown as Record + }); + this.code = code; + Object.setPrototypeOf(this, ResolverError.prototype); + } +} \ No newline at end of file diff --git a/libs/types/src/lib/api/lems/graphql/event.graphql b/libs/types/src/lib/api/lems/graphql/event.graphql index 9721adb96..f72fff9bd 100644 --- a/libs/types/src/lib/api/lems/graphql/event.graphql +++ b/libs/types/src/lib/api/lems/graphql/event.graphql @@ -70,4 +70,24 @@ extend type Query { "Event slug to retrieve (alternative to ID)" slug: String ): Event + + """ + Get division venue information (tables and rooms) by division ID. + This query does not require authentication and is used during login. + """ + divisionVenue("Division ID to retrieve venue information for" id: String!): DivisionVenue +} + +""" +Division venue information (tables and rooms) +""" +type DivisionVenue { + "Unique identifier for the division" + id: String! + + "Competition tables assigned to this division" + tables: [Table!]! + + "Judging rooms assigned to this division" + rooms: [Room!]! }