All files / server/src/features/assignment/utils saveUserSignupResults.ts

92.3% Statements 60/65
72.41% Branches 21/29
100% Functions 22/22
92.18% Lines 59/64

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283                                                  48x                         126x 1943x           126x 322x     322x     322x                     126x 334x     126x       126x       126x 473x     126x                 126x   315x                             126x 126x     126x     126x 315x               126x             126x                             48x                   126x 71x     279x     17x     17x               49x               126x 126x                                         48x                   126x 126x 322x   322x 322x 322x     126x         26x       70x       126x 1943x                     126x 322x 322x         126x 273x   319x     273x 273x     273x 5x   7x     5x     268x     268x         268x      
import { countBy, groupBy, unique } from "remeda";
import { MongoDbError } from "shared/types/api/errors";
import { ProgramItem } from "shared/types/models/programItem";
import { UserAssignmentResult } from "shared/types/models/result";
import { User } from "shared/types/models/user";
import { Result, makeSuccessResult } from "shared/utils/result";
import { isSameTime } from "shared/utils/timeComparison";
import {
  delDirectSignups,
  findDirectSignupsByStartTimes,
  saveDirectSignups,
} from "server/features/direct-signup/directSignupRepository";
import { SignupRepositoryAddSignup } from "server/features/direct-signup/directSignupTypes";
import { logger } from "server/utils/logger";
 
interface SaveUserSignupResultsParams {
  assignmentTime: string;
  results: readonly UserAssignmentResult[];
  users: User[];
  programItems: ProgramItem[];
}
 
// The one write in a lottery run that anybody depends on: everything after it is bookkeeping
// and messages. One update per program item, so a failure part way through leaves the ones it
// reached whole and the rest untouched, and the next run skips whatever landed.
export const saveUserSignupResults = async ({
  assignmentTime,
  results,
  users,
  programItems,
  // Returns the results that actually landed: saveDirectSignups can drop a sign-up that no
  // longer fits, and the caller must not record those attendees as placed
}: SaveUserSignupResultsParams): Promise<
  Result<readonly UserAssignmentResult[], MongoDbError>
> => {
  // Where each program item starts now, which is what the run picked its preferences by. A
  // sign-up's own stored time is not rewritten when a program item moves, so reading the won
  // hour off it would record the spot at an hour the program item has left.
  const startTimeByProgramItemId = new Map(
    programItems.map((programItem) => [
      programItem.programItemId,
      programItem.startTime,
    ]),
  );
 
  const wonResults = results.map((result) => {
    const startTime = startTimeByProgramItemId.get(
      result.assignmentSignup.programItemId,
    );
    Iif (startTime === undefined) {
      return result;
    }
    return {
      ...result,
      assignmentSignup: {
        ...result.assignmentSignup,
        signedToStartTime: startTime,
      },
    };
  });
 
  // The hours the lottery placed people at, which for a batched program item are not the hour
  // its lottery ran: a won spot only displaces what the attendee holds at that same hour
  const wonStartTimes = unique(
    wonResults.map((result) => result.assignmentSignup.signedToStartTime),
  );
 
  const directSignupsByStartTimeResult = await findDirectSignupsByStartTimes(
    wonStartTimes,
    programItems,
  );
  Iif (!directSignupsByStartTimeResult.ok) {
    return directSignupsByStartTimeResult;
  }
 
  const groupCodeByUsername = new Map(
    users.map((user) => [user.username, user.groupCode]),
  );
 
  const resultsToSave = dropResultsThatDoNotFit({
    results: wonResults,
    assignmentTime,
    existingSignups: directSignupsByStartTimeResult.value,
    programItems,
    groupCodeByUsername,
  });
 
  // Save new assignment results
  const newSignups: SignupRepositoryAddSignup[] = resultsToSave.map(
    (result) => {
      return {
        username: result.username,
        directSignupProgramItemId: result.assignmentSignup.programItemId,
        // The hour of the slot they won, which for a batched program item is not the hour its
        // lottery ran: a spot belongs to when the attendee turns up
        signedToStartTime: result.assignmentSignup.signedToStartTime,
        signupTime: new Date().toISOString(),
        // Sign-ups received from assignment don't have sign-up messages
        message: "",
        priority: result.assignmentSignup.priority,
      };
    },
  );
 
  // This might drop some sign-ups if by some error too many sign-ups are passed for a program item
  const saveSignupsResult = await saveDirectSignups(newSignups, programItems);
  Iif (!saveSignupsResult.ok) {
    return saveSignupsResult;
  }
  const { droppedSignups } = saveSignupsResult.value;
 
  // Filter out possible dropped results
  const finalResults = resultsToSave.filter((result) => {
    return droppedSignups.every(
      (signup) =>
        signup.directSignupProgramItemId !==
          result.assignmentSignup.programItemId ||
        signup.username !== result.username,
    );
  });
 
  await removeReplacedSignups({
    assignmentTime,
    finalResults,
    existingSignups: directSignupsByStartTimeResult.value,
    startTimeByProgramItemId,
  });
 
  return makeSuccessResult(finalResults);
};
 
interface RemoveReplacedSignupsParams {
  assignmentTime: string;
  finalResults: readonly UserAssignmentResult[];
  existingSignups: readonly { username: string; programItemId: string }[];
  startTimeByProgramItemId: ReadonlyMap<string, string>;
}
 
// A winner's own sign-ups for the hour they won give way to that spot - they can't attend both.
// Several are possible at one hour (an always-open program item plus a moved-in one), so remove
// every one of theirs rather than just the first. Runs on the spots that actually landed, and
// after they have: removing one for a replacement that then doesn't land would leave the
// attendee with neither, the worst outcome available.
const removeReplacedSignups = async ({
  assignmentTime,
  finalResults,
  existingSignups,
  // Where each program item starts now, since a sign-up's stored time is not rewritten when
  // one moves
  startTimeByProgramItemId,
}: RemoveReplacedSignupsParams): Promise<void> => {
  // A Map rather than a keyed object: a username is unrestricted input, and one that names an
  // Object.prototype member would read back as an inherited function past the ?? below
  const existingSignupsByUsername = new Map(
    Object.entries(groupBy(existingSignups, (signup) => signup.username)),
  );
 
  const signupsToDelete = finalResults.flatMap((result) =>
    (existingSignupsByUsername.get(result.username) ?? [])
      .filter((signup) => {
        const heldStartTime = startTimeByProgramItemId.get(
          signup.programItemId,
        );
        return (
          // The spot they won is written over their own entry, so deleting it here would take
          // back what the lottery just gave them
          signup.programItemId !== result.assignmentSignup.programItemId &&
          heldStartTime !== undefined &&
          isSameTime(heldStartTime, result.assignmentSignup.signedToStartTime)
        );
      })
      .map((signup) => ({
        username: signup.username,
        directSignupProgramItemId: signup.programItemId,
      })),
  );
 
  // The spots are saved by now, so a failure here costs nobody a place - it leaves an attendee
  // holding a sign-up they have been lotteried out of, which an admin can remove
  const delDirectSignupsResult = await delDirectSignups(signupsToDelete);
  Iif (!delDirectSignupsResult.ok) {
    logger.error(
      new Error(
        `Assignment ${assignmentTime}: failed to remove ${signupsToDelete.length} sign-up(s) replaced by a lottery win: ${delDirectSignupsResult.error}`,
      ),
    );
  }
};
 
interface DropResultsThatDoNotFitParams {
  results: readonly UserAssignmentResult[];
  assignmentTime: string;
  existingSignups: readonly { username: string; programItemId: string }[];
  programItems: readonly ProgramItem[];
  groupCodeByUsername: ReadonlyMap<string, string>;
}
 
// The algorithm already respects the attendance limits, so this should never drop anything - it
// guards against the sign-ups moving under the run. Runs before any deletion, so a sign-up is
// never removed to make room for a replacement that then doesn't land, and drops a whole group
// at a time because a group lands in one program item or none.
const dropResultsThatDoNotFit = ({
  results,
  assignmentTime,
  existingSignups,
  programItems,
  groupCodeByUsername,
}: DropResultsThatDoNotFitParams): readonly UserAssignmentResult[] => {
  // Keyed per program item rather than by username alone: the write rewrites a winner's entry in
  // the program item it places them into, so only that one entry is free. What they hold
  // elsewhere is still occupying a spot, because the deletion runs after the write.
  const placedByProgramItemId = new Map<string, Set<string>>();
  for (const result of results) {
    const { programItemId } = result.assignmentSignup;
    const placedUsernames =
      placedByProgramItemId.get(programItemId) ?? new Set<string>();
    placedUsernames.add(result.username);
    placedByProgramItemId.set(programItemId, placedUsernames);
  }
 
  const stayingPutByProgramItemId = new Map(
    Object.entries(
      countBy(
        existingSignups.filter(
          (signup) =>
            !placedByProgramItemId
              .get(signup.programItemId)
              ?.has(signup.username),
        ),
        (signup) => signup.programItemId,
      ),
    ),
  );
  const remainingByProgramItemId = new Map(
    programItems.map((programItem) => [
      programItem.programItemId,
      Math.max(
        programItem.maxAttendance -
          (stayingPutByProgramItemId.get(programItem.programItemId) ?? 0),
        0,
      ),
    ]),
  );
 
  // A group is placed as a whole or not at all, so it has to fit as a whole
  const resultsByGroup = groupBy(results, (result) => {
    const groupCode = groupCodeByUsername.get(result.username);
    return groupCode === undefined || groupCode === "0"
      ? `individual-${result.username}`
      : `group-${groupCode}`;
  });
 
  return Object.values(resultsByGroup).flatMap((groupResults) => {
    const neededByProgramItemId = countBy(
      groupResults,
      (result) => result.assignmentSignup.programItemId,
    );
 
    const fits = Object.entries(neededByProgramItemId).every(
      ([programItemId, needed]) =>
        (remainingByProgramItemId.get(programItemId) ?? 0) >= needed,
    );
    if (!fits) {
      logger.error(
        new Error(
          `Assignment ${assignmentTime}: dropping ${groupResults.length} result(s) that no longer fit, leaving the attendees' existing sign-ups in place: ${groupResults.map((result) => result.username).join(", ")}`,
        ),
      );
      return [];
    }
 
    for (const [programItemId, needed] of Object.entries(
      neededByProgramItemId,
    )) {
      remainingByProgramItemId.set(
        programItemId,
        (remainingByProgramItemId.get(programItemId) ?? 0) - needed,
      );
    }
    return groupResults;
  });
};