All files / server/src/features/program-item-popularity updateProgramItemPopularity.ts

84% Statements 42/50
50% Branches 7/14
100% Functions 12/12
84% Lines 42/50

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                                                          49x     28x   28x 28x       28x 28x       28x 28x         28x         28x       28x 28x         28x 474x           28x                 28x       86x 86x             28x 6x               6x     28x 6x               6x     28x 6x 4x 28x   4x     28x 205x   28x   1492x     28x     3x 4x   3x       3x                     28x     28x       28x   28x    
import { isBefore } from "date-fns";
import { countBy, first, groupBy } from "remeda";
import { config } from "shared/config";
import { MongoDbError } from "shared/types/api/errors";
import {
  Result,
  makeErrorResult,
  makeSuccessResult,
} from "shared/utils/result";
import {
  getLotteryRunTime,
  getLotterySignupEndTime,
  willNotBeLotteried,
} from "shared/utils/signupTimes";
import { getTimeNow } from "server/features/assignment/utils/getTimeNow";
import {
  getValidLotterySignupProgramItems,
  prepareAssignmentParams,
} from "server/features/assignment/utils/prepareAssignmentParams";
import { runAssignmentAlgorithm } from "server/features/assignment/utils/runAssignmentAlgorithm";
import { findDirectSignups } from "server/features/direct-signup/directSignupRepository";
import { getPopularity } from "server/features/program-item-popularity/getPopularity";
import {
  findProgramItems,
  saveProgramItemPopularity,
} from "server/features/program-item/programItemRepository";
import { findUsers } from "server/features/user/userRepository";
import { logger } from "server/utils/logger";
 
export const updateProgramItemPopularity = async (): Promise<
  Result<void, MongoDbError>
> => {
  logger.info("Calculate program item popularity");
 
  const usersResult = await findUsers();
  Iif (!usersResult.ok) {
    return usersResult;
  }
 
  const programItemsResult = await findProgramItems();
  Iif (!programItemsResult.ok) {
    return programItemsResult;
  }
 
  const directSignupsResult = await findDirectSignups();
  Iif (!directSignupsResult.ok) {
    return directSignupsResult;
  }
 
  const { validLotterySignupsUsers, lotteryParticipantDirectSignups } =
    prepareAssignmentParams(
      usersResult.value,
      programItemsResult.value,
      directSignupsResult.value,
    );
  const validLotterySignupProgramItems = getValidLotterySignupProgramItems(
    programItemsResult.value,
  );
 
  const timeNowResult = await getTimeNow();
  Iif (!timeNowResult.ok) {
    return timeNowResult;
  }
  // Simulating a program item no lottery will take would spend attendees' placements on it
  // rather than on the ones a lottery is still coming for. Its stored figure stays as it is.
  const lotteryProgramItems = validLotterySignupProgramItems.filter(
    (programItem) => !willNotBeLotteried(programItem),
  );
 
  // Grouped by the lottery each program item belongs to rather than by its resolved start time
  // as written: the simulation is handed these keys and matches items to the minute, so two
  // groups inside one minute would simulate the same items twice and double their counts.
  const programItemsByStartTimes = groupBy(
    lotteryProgramItems,
    getLotteryRunTime,
  );
 
  // Only start times whose lottery sign-up is still open. Popularity measures demand for the
  // lottery, and once spots have been handed out the simulation no longer measures that -
  // reduced capacity with every attendee still competing understates some items and inflates
  // others. A start time is never simulated again, so a figure written then is kept for good.
  const openLotteryStartTimes = Object.keys(programItemsByStartTimes).filter(
    (startTime) => {
      // Every program item in the group shares the start time the window is derived from,
      // and a group is never empty, so first() gives one without an absent case to handle
      const programItem = first(programItemsByStartTimes[startTime]);
      return isBefore(
        timeNowResult.value,
        getLotterySignupEndTime(programItem),
      );
    },
  );
 
  const assignmentResults = openLotteryStartTimes.map((startTime) => {
    const result = runAssignmentAlgorithm(
      config.event().assignmentAlgorithm,
      validLotterySignupsUsers,
      lotteryProgramItems,
      startTime,
      lotteryParticipantDirectSignups,
      programItemsResult.value,
    );
    return { result, startTime };
  });
 
  const successResults = assignmentResults.flatMap((assignmentResult) => {
    Iif (!assignmentResult.result.ok) {
      logger.error(
        new Error(
          `Popularity update: assignment for start time ${assignmentResult.startTime} failed: ${assignmentResult.result.error}`,
        ),
      );
      return [];
    }
    return assignmentResult.result.value;
  });
 
  const userAssignmentProgramIds = successResults
    .flatMap((result) => result.results)
    .map((result) => result.assignmentSignup.programItemId);
  const programItemSignupsCounts = countBy(
    userAssignmentProgramIds,
    (id) => id,
  );
 
  const lotterySignups = validLotterySignupsUsers.flatMap(
    (user) => user.lotterySignups,
  );
  const groupedLotterySignups = groupBy(
    lotterySignups,
    (signup) => signup.programItemId,
  );
 
  const programItemPopularityUpdates = Object.entries(
    programItemSignupsCounts,
  ).flatMap(([programItemId, assignmentSignupCount]) => {
    const programItem = lotteryProgramItems.find(
      (item) => item.programItemId === programItemId,
    );
    Iif (!programItem) {
      return [];
    }
 
    return {
      programItemId,
      popularity: getPopularity({
        minAttendance: programItem.minAttendance,
        maxAttendance: programItem.maxAttendance,
        assignmentSignupCount,
        lotterySignups: groupedLotterySignups[programItemId],
      }),
    };
  });
 
  const saveProgramItemPopularityResult = await saveProgramItemPopularity(
    programItemPopularityUpdates,
  );
  Iif (!saveProgramItemPopularityResult.ok) {
    return makeErrorResult(MongoDbError.UNKNOWN_ERROR);
  }
 
  logger.info("Program item popularity updated");
 
  return makeSuccessResult();
};