Skip to content
Merged
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
7 changes: 7 additions & 0 deletions backend/typescript/graphql/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -60,6 +62,7 @@ const executableSchema = makeExecutableSchema({
applicantRecordType,
reviewPageType,
interviewDelegationsTypes,
interviewDashboardTypes,
reviewedApplicantRecordTypes,
interviewedApplicantRecordsTypes,
interviewPageType,
Expand All @@ -76,6 +79,7 @@ const executableSchema = makeExecutableSchema({
applicantRecordResolvers,
reviewPageResolvers,
interviewDelegationsResolvers,
interviewDashboardResolvers,
reviewedApplicantRecordResolvers,
interviewedApplicantRecordsResolvers,
interviewPageResolvers,
Expand Down Expand Up @@ -133,7 +137,10 @@ const graphQLMiddlewares = {
deleteInterviewDelegation: authorizedByAllRoles(),
bulkCreateInterviewDelegations: authorizedByAllRoles(),
bulkDeleteInterviewDelegations: authorizedByAllRoles(),
delegateInterviewers: authorizedByAllRoles(),
createInterviewGroup: authorizedByAllRoles(),
bulkCreateInterviewGroups: authorizedByAllRoles(),
bulkDeleteInterviewGroupsByIds: authorizedByAllRoles(),
deleteInterviewGroupById: authorizedByAllRoles(),
updateInterviewGroup: authorizedByAllRoles(),
},
Expand Down
Original file line number Diff line number Diff line change
@@ -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<InterviewDelegationDTO[]> => {
try {
return await interviewDashboardService.delegateInterviewers(
args.positions,
);
} catch (error) {
throw new Error(getErrorMessage(error));
}
},
},
};

export default interviewDashboardResolvers;
26 changes: 26 additions & 0 deletions backend/typescript/graphql/resolvers/interviewGroupResolvers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,32 @@ const interviewGroupResolvers = {
throw new Error(getErrorMessage(error));
}
},

bulkCreateInterviewGroups: async (
_parent: undefined,
args: { interviewGroups: CreateInterviewGroupDTO[] },
): Promise<InterviewGroupDTO[]> => {
try {
return await interviewGroupService.bulkCreateInterviewGroups(
args.interviewGroups,
);
} catch (error) {
throw new Error(getErrorMessage(error));
}
},

bulkDeleteInterviewGroupsByIds: async (
_parent: undefined,
args: { interviewGroupIds: string[] },
): Promise<InterviewGroupDTO[]> => {
try {
return await interviewGroupService.bulkDeleteInterviewGroupsByIds(
args.interviewGroupIds,
);
} catch (error) {
throw new Error(getErrorMessage(error));
}
},
},
};

Expand Down
9 changes: 9 additions & 0 deletions backend/typescript/graphql/types/interviewDashboardTypes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { gql } from "apollo-server-express";

const interviewDashboardTypes = gql`
extend type Mutation {
delegateInterviewers(positions: [String!]!): [InterviewDelegation!]!
}
`;

export default interviewDashboardTypes;
8 changes: 8 additions & 0 deletions backend/typescript/graphql/types/interviewGroupTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,14 @@ const interviewGroupTypes = gql`
): InterviewGroupDTO!

deleteInterviewGroupById(id: ID!): InterviewGroupDTO!

bulkCreateInterviewGroups(
interviewGroups: [CreateInterviewGroupDTO]!
): [InterviewGroupDTO]!

bulkDeleteInterviewGroupsByIds(
interviewGroupIds: [ID]!
): [InterviewGroupDTO]!
}
`;

Expand Down
Original file line number Diff line number Diff line change
@@ -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<InterviewDelegationDTO[]> {
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<string, number[]>());

// Build the FSM.
const FSM = new Map<string, [number, (number | undefined)[]]>(
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<Record<string, string>>(
(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;
Loading
Loading