Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { GraphQLFieldResolver } from 'graphql';
import type { GraphQLContext } from '../../apollo-server';
import db from '../../../database';

interface DivisionWithId {
Expand All @@ -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<AwardGraphQL[]>
> = async (division: DivisionWithId, args: AwardsArgs) => {
Expand All @@ -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<
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice resolver :)

DivisionWithId,
GraphQLContext,
unknown,
Promise<AwardGraphQL[]>
> = 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;
}
};
Original file line number Diff line number Diff line change
@@ -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;
Expand Down
32 changes: 28 additions & 4 deletions apps/backend/src/lib/graphql/resolvers/divisions/resolver.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down
38 changes: 37 additions & 1 deletion apps/backend/src/lib/graphql/resolvers/events/resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down Expand Up @@ -54,7 +64,33 @@ export const eventResolvers = {
console.error('Error fetching event:', error);
throw error;
}
}) as GraphQLFieldResolver<unknown, unknown, EventArgs, Promise<EventGraphQL | null>>
}) as GraphQLFieldResolver<unknown, unknown, EventArgs, Promise<EventGraphQL | null>>,

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<unknown, unknown, DivisionVenueArgs, Promise<DivisionVenue>>
}
};

Expand Down
1 change: 1 addition & 0 deletions apps/backend/src/lib/graphql/resolvers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ export const resolvers = {
Query: {
events: eventResolvers.Query.events,
event: eventResolvers.Query.event,
divisionVenue: eventResolvers.Query.divisionVenue,
division: divisionResolver
},
Mutation: mutationResolvers,
Expand Down
16 changes: 14 additions & 2 deletions apps/backend/src/lib/graphql/resolvers/judging/rubric.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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;
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 }[];
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
21 changes: 21 additions & 0 deletions libs/types/src/lib/api/lems/graphql-errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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<string, unknown>
});
this.code = code;
Object.setPrototypeOf(this, ResolverError.prototype);
}
}
20 changes: 20 additions & 0 deletions libs/types/src/lib/api/lems/graphql/event.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -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!]!
}