diff --git a/backend/typescript/graphql/index.ts b/backend/typescript/graphql/index.ts index 39887ea..06621ab 100644 --- a/backend/typescript/graphql/index.ts +++ b/backend/typescript/graphql/index.ts @@ -33,6 +33,8 @@ import interviewPageResolvers from "./resolvers/interviewPageResolvers"; import interviewPageType from "./types/interviewPageTypes"; import interviewGroupTypes from "./types/interviewGroupTypes"; import interviewGroupResolvers from "./resolvers/interviewGroupResolvers"; +import interviewDashboardTypes from "./types/interviewDashboardTypes"; +import interviewDashboardResolvers from "./resolvers/interviewDashboardResolvers"; const query = gql` type Query { @@ -60,6 +62,7 @@ const executableSchema = makeExecutableSchema({ applicantRecordType, reviewPageType, interviewDelegationsTypes, + interviewDashboardTypes, reviewedApplicantRecordTypes, interviewedApplicantRecordsTypes, interviewPageType, @@ -76,6 +79,7 @@ const executableSchema = makeExecutableSchema({ applicantRecordResolvers, reviewPageResolvers, interviewDelegationsResolvers, + interviewDashboardResolvers, reviewedApplicantRecordResolvers, interviewedApplicantRecordsResolvers, interviewPageResolvers, @@ -133,7 +137,10 @@ const graphQLMiddlewares = { deleteInterviewDelegation: authorizedByAllRoles(), bulkCreateInterviewDelegations: authorizedByAllRoles(), bulkDeleteInterviewDelegations: authorizedByAllRoles(), + delegateInterviewers: authorizedByAllRoles(), createInterviewGroup: authorizedByAllRoles(), + bulkCreateInterviewGroups: authorizedByAllRoles(), + bulkDeleteInterviewGroupsByIds: authorizedByAllRoles(), deleteInterviewGroupById: authorizedByAllRoles(), updateInterviewGroup: authorizedByAllRoles(), }, diff --git a/backend/typescript/graphql/resolvers/interviewDashboardResolvers.ts b/backend/typescript/graphql/resolvers/interviewDashboardResolvers.ts new file mode 100644 index 0000000..a5d6661 --- /dev/null +++ b/backend/typescript/graphql/resolvers/interviewDashboardResolvers.ts @@ -0,0 +1,26 @@ +import InterviewDashboardService from "../../services/implementations/interviewDashboardService"; +import IInterviewDashboardService from "../../services/interfaces/IInterviewDasboardService"; +import { InterviewDelegationDTO } from "../../types"; +import { getErrorMessage } from "../../utilities/errorUtils"; + +const interviewDashboardService: IInterviewDashboardService = + new InterviewDashboardService(); + +const interviewDashboardResolvers = { + Mutation: { + delegateInterviewers: async ( + _parent: undefined, + args: { positions: string[] }, + ): Promise => { + try { + return await interviewDashboardService.delegateInterviewers( + args.positions, + ); + } catch (error) { + throw new Error(getErrorMessage(error)); + } + }, + }, +}; + +export default interviewDashboardResolvers; diff --git a/backend/typescript/graphql/resolvers/interviewGroupResolvers.ts b/backend/typescript/graphql/resolvers/interviewGroupResolvers.ts index 0182820..e76e024 100644 --- a/backend/typescript/graphql/resolvers/interviewGroupResolvers.ts +++ b/backend/typescript/graphql/resolvers/interviewGroupResolvers.ts @@ -67,6 +67,32 @@ const interviewGroupResolvers = { throw new Error(getErrorMessage(error)); } }, + + bulkCreateInterviewGroups: async ( + _parent: undefined, + args: { interviewGroups: CreateInterviewGroupDTO[] }, + ): Promise => { + try { + return await interviewGroupService.bulkCreateInterviewGroups( + args.interviewGroups, + ); + } catch (error) { + throw new Error(getErrorMessage(error)); + } + }, + + bulkDeleteInterviewGroupsByIds: async ( + _parent: undefined, + args: { interviewGroupIds: string[] }, + ): Promise => { + try { + return await interviewGroupService.bulkDeleteInterviewGroupsByIds( + args.interviewGroupIds, + ); + } catch (error) { + throw new Error(getErrorMessage(error)); + } + }, }, }; diff --git a/backend/typescript/graphql/types/interviewDashboardTypes.ts b/backend/typescript/graphql/types/interviewDashboardTypes.ts new file mode 100644 index 0000000..c6410c7 --- /dev/null +++ b/backend/typescript/graphql/types/interviewDashboardTypes.ts @@ -0,0 +1,9 @@ +import { gql } from "apollo-server-express"; + +const interviewDashboardTypes = gql` + extend type Mutation { + delegateInterviewers(positions: [String!]!): [InterviewDelegation!]! + } +`; + +export default interviewDashboardTypes; diff --git a/backend/typescript/graphql/types/interviewGroupTypes.ts b/backend/typescript/graphql/types/interviewGroupTypes.ts index 54ae7e3..4bba5b7 100644 --- a/backend/typescript/graphql/types/interviewGroupTypes.ts +++ b/backend/typescript/graphql/types/interviewGroupTypes.ts @@ -32,6 +32,14 @@ const interviewGroupTypes = gql` ): InterviewGroupDTO! deleteInterviewGroupById(id: ID!): InterviewGroupDTO! + + bulkCreateInterviewGroups( + interviewGroups: [CreateInterviewGroupDTO]! + ): [InterviewGroupDTO]! + + bulkDeleteInterviewGroupsByIds( + interviewGroupIds: [ID]! + ): [InterviewGroupDTO]! } `; diff --git a/backend/typescript/services/implementations/interviewDashboardService.ts b/backend/typescript/services/implementations/interviewDashboardService.ts new file mode 100644 index 0000000..8631ab3 --- /dev/null +++ b/backend/typescript/services/implementations/interviewDashboardService.ts @@ -0,0 +1,194 @@ +import { Op } from "sequelize"; +import User from "../../models/user.model"; +import InterviewedApplicantRecord from "../../models/interviewedApplicantRecord.model"; +import { + CreateInterviewDelegationDTO, + InterviewDelegationDTO, + InterviewGroupStatusEnum, +} from "../../types"; +import { getErrorMessage } from "../../utilities/errorUtils"; +import logger from "../../utilities/logger"; +import IInterviewDashboardService from "../interfaces/IInterviewDasboardService"; +import InterviewDelegationsService from "./interviewDelegationsService"; +import IInterviewDelegationsService from "../interfaces/IInterviewDelegationsService"; +import InterviewGroupService from "./interviewGroupService"; +import IInterviewGroupService from "../interfaces/IInterviewGroupService"; + +const Logger = logger(__filename); + +const interviewDelegationsService: IInterviewDelegationsService = + new InterviewDelegationsService(); + +const interviewGroupService: IInterviewGroupService = + new InterviewGroupService(); + +type InterviewerAssignment = { + interviewedApplicantRecordId: string; + interviewer1?: number; + interviewer2?: number; +}; + +/** Assign a unique key to each interviewer group - this should be indifferent to ordering of the interviewers. */ +function interviewerTeamKey(a: InterviewerAssignment): string { + const ids = [a.interviewer1, a.interviewer2].filter( + (x): x is number => x !== undefined, + ); + if (ids.length === 0) { + throw new Error( + "No interviewers assigned to this interviewed applicant record.", + ); + } + if (ids.length === 1) { + return `solo:${ids[0]}`; + } + + // Order the interviewers so that the smaller ID is first. + const [x, y] = ids[0] <= ids[1] ? [ids[0], ids[1]] : [ids[1], ids[0]]; + return `pair:${x}|${y}`; +} + +class InterviewDashboardServices implements IInterviewDashboardService { + /* eslint-disable class-methods-use-this */ + /** + * Assignment logic matches {@link ReviewDashboardService.delegateReviewers}: + * per position, circular user list with `undefined` sentinel when count is odd; + * each record consumes two consecutive FSM slots for its two interviewers. + * Interview groups are deduped by interviewer team so identical pairs share `groupId`. + */ + async delegateInterviewers( + positions: string[], + ): Promise { + try { + // Get all users by position. + const groups = ( + await User.findAll({ + attributes: { exclude: ["createdAt", "updatedAt"] }, + where: { position: { [Op.in]: positions } }, + }) + ).reduce((map, user) => { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const pos = user.position!; + const arr = map.get(pos) ?? []; + arr.push(user.id); + map.set(pos, arr); + return map; + }, new Map()); + + // Build the FSM. + const FSM = new Map( + positions.map((title) => [title, [0, groups.get(title) ?? []]]), + ); + + Array.from(FSM.entries()).forEach(([title, [, userIds]]) => { + if (userIds.length === 0) { + throw new Error(`Invalid amount of users with position ${title}.`); + } + if (userIds.length % 2 !== 0) { + userIds.push(undefined); + } + }); + + // Get all interviewer assignments for the given positions. + const interviewedApplicantRecords = + await InterviewedApplicantRecord.findAll({ + attributes: ["id"], + include: [ + { + association: "applicantRecord", + attributes: ["position"], + where: { position: { [Op.in]: positions } }, + }, + ], + }); + + // Round robin the interviewers for each interviewed applicant record. + const assignments: InterviewerAssignment[] = []; + + interviewedApplicantRecords.forEach((record) => { + const position = record.applicantRecord?.position; + if (!position || !FSM.has(position)) { + return; + } + + /* eslint-disable @typescript-eslint/no-non-null-assertion */ + const [count, userIds] = FSM.get(position)!; + let newCount = count; + const assigned1 = FSM.get(position)![1][newCount]; + newCount++; + newCount %= FSM.get(position)![1].length; + const assigned2 = FSM.get(position)![1][newCount]; + newCount++; + newCount %= FSM.get(position)![1].length; + FSM.set(position, [newCount, userIds]); + + if (assigned1 === undefined && assigned2 === undefined) { + return; + } + + assignments.push({ + interviewedApplicantRecordId: record.id, + interviewer1: assigned1, + interviewer2: assigned2, + }); + }); + + if (assignments.length === 0) { + return []; + } + + // Dedupe the team keys to avoid creating duplicate interview groups. + const dedupedTeamKeys = [...new Set(assignments.map(interviewerTeamKey))]; + + // Create the interview groups. + const createdGroups = + await interviewGroupService.bulkCreateInterviewGroups( + dedupedTeamKeys.map(() => ({ + status: InterviewGroupStatusEnum.AVAILABILITY_PENDING, + })), + ); + + // Map delegated assignments to a singular group ID. + const groupIdByTeamKey = dedupedTeamKeys.reduce>( + (acc, teamKey, i) => { + acc[teamKey] = createdGroups[i].id; + return acc; + }, + {}, + ); + + // Create the interview delegations. + const delegations: CreateInterviewDelegationDTO[] = assignments.flatMap( + (a) => { + const groupId = groupIdByTeamKey[interviewerTeamKey(a)]; + const row: CreateInterviewDelegationDTO[] = []; + if (a.interviewer1 !== undefined) { + row.push({ + interviewedApplicantRecordId: a.interviewedApplicantRecordId, + interviewerId: a.interviewer1, + groupId, + }); + } + if (a.interviewer2 !== undefined) { + row.push({ + interviewedApplicantRecordId: a.interviewedApplicantRecordId, + interviewerId: a.interviewer2, + groupId, + }); + } + return row; + }, + ); + + return await interviewDelegationsService.bulkCreateInterviewDelegations( + delegations, + ); + } catch (error: unknown) { + Logger.error( + `Failed to delegate interviewers. Reason = ${getErrorMessage(error)}`, + ); + throw error; + } + } +} + +export default InterviewDashboardServices; diff --git a/backend/typescript/services/implementations/interviewGroupService.ts b/backend/typescript/services/implementations/interviewGroupService.ts index 41a24f1..09cb1f3 100644 --- a/backend/typescript/services/implementations/interviewGroupService.ts +++ b/backend/typescript/services/implementations/interviewGroupService.ts @@ -1,3 +1,5 @@ +import { sequelize } from "../../models"; +import InterviewDelegation from "../../models/interviewDelegation.model"; import InterviewGroup from "../../models/interviewGroup.model"; import { CreateInterviewGroupDTO, @@ -60,13 +62,14 @@ class InterviewGroupService implements IInterviewGroupService { interviewGroup: UpdateInterviewGroupDTO, ): Promise { try { + const { schedulingLink, status } = interviewGroup; const existing = await InterviewGroup.findByPk(id); if (!existing) { throw new Error(`No interview group found for id: ${id}`); } - await existing.update(interviewGroup); - return toInterviewGroupDTO(existing); + const updatedGroup = await existing.update({ schedulingLink, status }); + return toInterviewGroupDTO(updatedGroup); } catch (error: unknown) { Logger.error( `Failed to update interview group. Reason = ${getErrorMessage(error)}`, @@ -90,6 +93,70 @@ class InterviewGroupService implements IInterviewGroupService { throw error; } } + + async bulkCreateInterviewGroups( + groups: CreateInterviewGroupDTO[], + ): Promise { + const t = await sequelize.transaction(); + try { + const createdGroups = await InterviewGroup.bulkCreate(groups, { + transaction: t, + }); + + await t.commit(); + return createdGroups.map((group) => toInterviewGroupDTO(group)); + } catch (error: unknown) { + Logger.error( + `Failed to bulk create interview groups. Reason = ${getErrorMessage( + error, + )}`, + ); + await t.rollback(); + throw error; + } + } + + async bulkDeleteInterviewGroupsByIds( + ids: string[], + ): Promise { + const t = await sequelize.transaction(); + try { + if (ids.length === 0) { + return []; + } + const foundGroups = await InterviewGroup.findAll({ + where: { id: ids }, + transaction: t, + }); + if (foundGroups.length !== ids.length) { + throw new Error( + "Not all interview groups were found, bulk delete failed", + ); + } + + // Delete all interview delegations for the found groups + await InterviewDelegation.destroy({ + where: { groupId: foundGroups.map((group) => group.id) }, + transaction: t, + }); + // Delete all interview groups + await InterviewGroup.destroy({ + where: { id: foundGroups.map((group) => group.id) }, + transaction: t, + }); + + await t.commit(); + return foundGroups.map((group) => toInterviewGroupDTO(group)); + } catch (error: unknown) { + Logger.error( + `Failed to bulk delete interview groups. Reason = ${getErrorMessage( + error, + )}`, + ); + await t.rollback(); + throw error; + } + } } export default InterviewGroupService; diff --git a/backend/typescript/services/interfaces/IInterviewDasboardService.ts b/backend/typescript/services/interfaces/IInterviewDasboardService.ts new file mode 100644 index 0000000..400cd38 --- /dev/null +++ b/backend/typescript/services/interfaces/IInterviewDasboardService.ts @@ -0,0 +1,10 @@ +import { InterviewDelegationDTO } from "../../types"; + +interface IInterviewDashboardService { + /** + * Delegates interviewers to interview applicants. + */ + delegateInterviewers(positions: string[]): Promise; +} + +export default IInterviewDashboardService; diff --git a/backend/typescript/services/interfaces/IInterviewGroupService.ts b/backend/typescript/services/interfaces/IInterviewGroupService.ts index 7053496..d837e5c 100644 --- a/backend/typescript/services/interfaces/IInterviewGroupService.ts +++ b/backend/typescript/services/interfaces/IInterviewGroupService.ts @@ -13,8 +13,7 @@ interface IInterviewGroupService { /** * Creates a new interview group. - * @param status initial status - * @param schedulingLink scheduling link, or undefined if not yet set + * @param interviewGroup initial interview group */ createInterviewGroup( interviewGroup: CreateInterviewGroupDTO, @@ -23,8 +22,7 @@ interface IInterviewGroupService { /** * Updates an existing interview group. * @param id PK of the interview group to update - * @param status updated status - * @param schedulingLink updated scheduling link, or undefined to clear it + * @param interviewGroup updated interview group */ updateInterviewGroup( id: string, @@ -36,6 +34,22 @@ interface IInterviewGroupService { * @param id PK of the interview group to delete */ deleteInterviewGroupById(id: string): Promise; + + /** + * Bulk creates interview groups. + * @param interviewGroups list of interview groups to create + */ + bulkCreateInterviewGroups( + interviewGroups: CreateInterviewGroupDTO[], + ): Promise; + + /** + * Bulk deletes interview groups by ids. + * @param interviewGroupIds list of interview group ids to delete + */ + bulkDeleteInterviewGroupsByIds( + interviewGroupIds: string[], + ): Promise; } export default IInterviewGroupService;