All files / server/src/features/assignment assignmentService.ts

85.36% Statements 35/41
66.66% Branches 12/18
100% Functions 5/5
85.36% Lines 35/41

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                                                                  42x     62x       62x     62x   62x       62x     57x   62x   66x     66x         42x           62x 62x               62x 62x               62x   62x 4x     4x               61x 4x     4x                       60x 60x 1x 1x 1x                       59x   53x 53x         59x             59x     53x   53x               53x      
import { isBefore } from "date-fns";
import { config } from "shared/config";
import { PostAssignmentResponse } from "shared/types/api/assignment";
import { MongoDbError } from "shared/types/api/errors";
import { ProgramItem } from "shared/types/models/programItem";
import { isLotterySignupProgramItem } from "shared/utils/isLotterySignupProgramItem";
import { Result, makeSuccessResult } from "shared/utils/result";
import {
  getDirectSignupPhaseStarted,
  getLotterySignupEndTime,
} from "shared/utils/signupTimes";
import { runAssignment } from "server/features/assignment/run-assignment/runAssignment";
import { getStartingProgramItems } from "server/features/assignment/utils/getStartingProgramItems";
import { getTimeNow } from "server/features/assignment/utils/getTimeNow";
import { findProgramItems } from "server/features/program-item/programItemRepository";
import {
  acquireAssignmentLock,
  findOrCreateSettings,
  releaseAssignmentLock,
  setAssignmentLastRun,
} from "server/features/settings/settingsRepository";
import { logger } from "server/utils/logger";
 
// A manual run has to land in the gap between the lottery and its direct sign-up phase: before
// it, attendees are still entering the lottery and the run would decide the start time behind
// them; after it, the run competes with the first-come queue and moves attendees out of spots
// they picked themselves. Checked here rather than inside the run because only a manual run can
// be off the mark - the cron derives the start time it targets from the current time.
interface RunWindow {
  stillTakingLotterySignups: ProgramItem[];
  alreadyTakingDirectSignups: ProgramItem[];
}
 
const findRunWindow = async (
  assignmentTime: string,
): Promise<Result<RunWindow, MongoDbError>> => {
  const [timeNowResult, programItemsResult] = await Promise.all([
    getTimeNow(),
    findProgramItems(),
  ]);
  Iif (!timeNowResult.ok) {
    return timeNowResult;
  }
  const timeNow = timeNowResult.value;
 
  Iif (!programItemsResult.ok) {
    return programItemsResult;
  }
 
  const startingProgramItems = getStartingProgramItems(
    programItemsResult.value,
    assignmentTime,
  ).filter((programItem) => isLotterySignupProgramItem(programItem));
 
  return makeSuccessResult({
    stillTakingLotterySignups: startingProgramItems.filter((programItem) =>
      isBefore(timeNow, getLotterySignupEndTime(programItem)),
    ),
    alreadyTakingDirectSignups: startingProgramItems.filter((programItem) =>
      getDirectSignupPhaseStarted(programItem, timeNow),
    ),
  });
};
 
export const storeAssignment = async (
  assignmentTime: string,
): Promise<PostAssignmentResponse> => {
  // Ensure a settings row exists (a fresh one starts with a free lock) so the lock check
  // below fails only when an assignment genuinely ran within the window, not when the row
  // is missing
  const settingsResult = await findOrCreateSettings();
  Iif (!settingsResult.ok) {
    return {
      message: "Assignment failed",
      status: "error",
      errorId: "unknown",
    };
  }
 
  const runWindowResult = await findRunWindow(assignmentTime);
  Iif (!runWindowResult.ok) {
    return {
      message: "Assignment failed",
      status: "error",
      errorId: "unknown",
    };
  }
  const { stillTakingLotterySignups, alreadyTakingDirectSignups } =
    runWindowResult.value;
 
  if (stillTakingLotterySignups.length > 0) {
    logger.warn(
      `Lottery signup still open for ${stillTakingLotterySignups.length} program items starting at ${assignmentTime}, skip manual assignment`,
    );
    return {
      message:
        "Lottery sign-up for this starting time is still open, so its lottery cannot be run yet",
      status: "error",
      errorId: "lotterySignupStillOpen",
    };
  }
 
  if (alreadyTakingDirectSignups.length > 0) {
    logger.warn(
      `Direct signup already open for ${alreadyTakingDirectSignups.length} program items starting at ${assignmentTime}, skip manual assignment`,
    );
    return {
      message:
        "Direct signup for this starting time is already open, so its lottery can no longer be run",
      status: "error",
      errorId: "directSignupAlreadyOpen",
    };
  }
 
  // Hold the same in-progress lock the auto-assign cron uses for the whole run so a manual run
  // can't overlap a cron run (or another manual run) and corrupt results via the non-atomic
  // save. Released in the finally below, so a failed run is immediately retryable and a crash
  // can't hold the lock past the stale timeout.
  const lockResult = await acquireAssignmentLock();
  if (!lockResult.ok) {
    Eif (lockResult.error === MongoDbError.ASSIGNMENT_LOCK_HELD) {
      logger.warn("Assignment already running, skip manual assignment");
      return {
        message: "Assignment already running",
        status: "error",
        errorId: "assignmentInProgress",
      };
    }
    return {
      message: "Assignment failed",
      status: "error",
      errorId: "unknown",
    };
  }
  const lockToken = lockResult.value;
 
  try {
    const assignResultsResult = await runAssignment({
      assignmentAlgorithm: config.event().assignmentAlgorithm,
      assignmentTime,
    });
 
    Iif (!assignResultsResult.ok) {
      return {
        message: "Assignment failed",
        status: "error",
        errorId: "unknown",
      };
    }
    const assignResults = assignResultsResult.value;
 
    // Record the last successful run time
    await setAssignmentLastRun(new Date().toISOString());
 
    return {
      message: "Assignment success",
      status: "success",
      results: assignResults.results,
      resultMessage: assignResults.message,
      assignmentTime,
    };
  } finally {
    await releaseAssignmentLock(lockToken);
  }
};