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

93.05% Statements 67/72
83.87% Branches 26/31
100% Functions 20/20
93.05% Lines 67/72

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 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333                                                                                50x           123x   123x 123x 123x 123x                       123x         392x                 123x 123x             123x       123x 200x   123x 2911x     123x   982x                 123x     123x       1x           123x     208x   343x   334x           123x 323x     123x   413x         123x 323x               123x 1x               123x         123x     323x                             123x       39x         39x 111x               39x 1x               39x         39x     111x                                                 50x           162x 1x         1x       161x 161x 1x         1x     160x                                   50x       51x 110x       51x           51x 106x   51x 43x       17x 13x   15x           15x   23x     8x            
import { firstBy, unique } from "remeda";
import { DIRECT_SIGNUP_PRIORITY } from "shared/constants/signups";
import { EmailNotificationTrigger } from "shared/types/emailNotification";
import { EventLogAction } from "shared/types/models/eventLog";
import {
  ProgramItem,
  ProgramType,
  State,
} from "shared/types/models/programItem";
import { UserAssignmentResult } from "shared/types/models/result";
import { Settings } from "shared/types/models/settings";
import { User } from "shared/types/models/user";
import { isLotterySignupProgramItem } from "shared/utils/isLotterySignupProgramItem";
import { hasLotteryAlreadyRun } from "shared/utils/signupTimes";
import { isSameTime } from "shared/utils/timeComparison";
import { getGroupCreators } from "server/features/assignment/utils/getGroupCreators";
import { getGroupMembersWithCreatorLotterySignups } from "server/features/assignment/utils/getGroupMembers";
import { getLotterySignups } from "server/features/assignment/utils/getLotterySignups";
import { getStartingProgramItems } from "server/features/assignment/utils/getStartingProgramItems";
import { findDirectSignupsByProgramItemIds } from "server/features/direct-signup/directSignupRepository";
import { findOrCreateSettings } from "server/features/settings/settingsRepository";
import { addEventLogItems } from "server/features/user/event-log/eventLogRepository";
import { logger } from "server/utils/logger";
import {
  NotificationQueueService,
  NotificationTask,
  NotificationTaskType,
  getGlobalNotificationQueueService,
} from "server/utils/notificationQueue";
 
interface AddAssignmentNotificationsParams {
  assignmentTime: string;
  finalResults: readonly UserAssignmentResult[];
  users: User[];
  programItems: ProgramItem[];
}
 
// The spots are already saved and the start time already closed when this runs, so failures
// are only logged and never returned: an error escaping to the caller would fail the run and
// skip the overlap lottery sign-up cleanup
export const addAssignmentNotifications = async ({
  assignmentTime,
  finalResults,
  users,
  programItems,
}: AddAssignmentNotificationsParams): Promise<void> => {
  const queueService = getGlobalNotificationQueueService();
 
  const settingsResult = await findOrCreateSettings();
  let settings: Settings | null = null;
  if (settingsResult.ok) {
    settings = settingsResult.value;
  } else E{
    logger.error(
      new Error(
        `Assignment ${assignmentTime}: failed to find settings, skip queueing emails`,
      ),
    );
  }
 
  // Get users who didn't get a spot in lottery. The lottery program items at this starting
  // time, whatever the run went on to do with each of them: this one set decides both who is
  // rejected and what the rejection names, so a rejected attendee's slot is always inside it.
  const startingProgramItems = getStartingProgramItems(
    programItems,
    assignmentTime,
  ).filter(
    (programItem) =>
      isLotterySignupProgramItem(programItem) &&
      // A cancelled one was never in the run - the same rule the algorithm's own input follows -
      // so it neither rejects anybody nor stretches the span the rejection names
      programItem.state === State.ACCEPTED &&
      // Lotteried at a slot it no longer starts at, so this run neither considered nor
      // rejected anybody over it: the spot it brought with it says nothing about this hour,
      // and the span it covers is not part of what was lotteried here
      !hasLotteryAlreadyRun(programItem),
  );
  const groupCreators = getGroupCreators(users, startingProgramItems);
  const groupMembers = getGroupMembersWithCreatorLotterySignups(
    groupCreators,
    users,
  );
  // Everyone with a live lottery sign-up for this start time took part, so everyone hears the
  // outcome - including an attendee who already holds a spot at that hour. The lottery really
  // did consider them and really did not place them.
  const lotterySignups = getLotterySignups([...groupCreators, ...groupMembers]);
 
  // A sign-up naming a program item that does not start at this time was not part of this run,
  // so its holder is neither placed nor rejected by it
  const startingProgramItemIds = new Set(
    startingProgramItems.map((programItem) => programItem.programItemId),
  );
  const lotterySignupsForStartingTime = lotterySignups.filter((lotterySignup) =>
    startingProgramItemIds.has(lotterySignup.programItemId),
  );
 
  const lotterySignupUsernames = unique(
    lotterySignupsForStartingTime.map(
      (lotterySignup) => lotterySignup.username,
    ),
  );
 
  // An attendee holding a lottery-placed spot in one of this run's own program items was placed
  // by a lottery - this one, or an earlier attempt that saved its spots and failed before saying
  // so. Saying they got nothing would be false, and neither the event log item nor the email can
  // be taken back. Asked of those program items rather than of the hour, so another lottery's win
  // cannot silence this one's rejection.
  const placedByLotteryResult = await findDirectSignupsByProgramItemIds([
    ...startingProgramItemIds,
  ]);
  if (!placedByLotteryResult.ok) {
    // The rejections still go out, keyed on this run's own results alone: silence for everyone
    // who lost is a certain harm, where telling a retry's already-placed attendee they got
    // nothing is a rare one, and only this hour's second attempt can produce it
    logger.error(
      new Error(
        `Assignment ${assignmentTime}: failed to read the spots already placed, rejecting on this run's results alone`,
      ),
    );
  }
  const placedByLotteryUsernames = new Set(
    placedByLotteryResult.ok
      ? placedByLotteryResult.value.flatMap((directSignup) =>
          directSignup.userSignups
            .filter(
              (userSignup) => userSignup.priority !== DIRECT_SIGNUP_PRIORITY,
            )
            .map((userSignup) => userSignup.username),
        )
      : [],
  );
 
  // Use finalResults so users whose sign-up was dropped are treated as not assigned
  const placedNowUsernames = new Set(
    finalResults.map((result) => result.username),
  );
 
  const rejectedUsernames = lotterySignupUsernames.filter(
    (lotterySignupUsername) =>
      !placedNowUsernames.has(lotterySignupUsername) &&
      !placedByLotteryUsernames.has(lotterySignupUsername),
  );
 
  // Add NEW_ASSIGNMENT to user event logs
  const newAssignmentEventLogItemsResult = await addEventLogItems(
    finalResults.map((result) => ({
      username: result.username,
      programItemId: result.assignmentSignup.programItemId,
      programItemStartTime: result.assignmentSignup.signedToStartTime,
      createdAt: new Date().toISOString(),
      action: EventLogAction.NEW_ASSIGNMENT,
    })),
  );
  if (!newAssignmentEventLogItemsResult.ok) {
    logger.error(
      new Error(
        `Assignment ${assignmentTime}: failed to add NEW_ASSIGNMENT event log items: ${newAssignmentEventLogItemsResult.error}`,
      ),
    );
  }
 
  // Add SEND_EMAIL_ACCEPTED to notification queue
  if (
    settings?.emailNotificationTrigger.includes(
      EmailNotificationTrigger.ACCEPTED,
    )
  ) {
    queueAssignmentEmails({
      queueService,
      assignmentTime,
      notifications: finalResults.map((result) => ({
        type: NotificationTaskType.SEND_EMAIL_ACCEPTED,
        username: result.username,
        programItemId: result.assignmentSignup.programItemId,
        programItemStartTime: result.assignmentSignup.signedToStartTime,
      })),
      emailKind: EmailNotificationTrigger.ACCEPTED,
    });
  } else E{
    logger.info(
      `Assignment ${assignmentTime}: ${EmailNotificationTrigger.ACCEPTED} emails not enabled in settings, none queued`,
    );
  }
 
  // Add NO_ASSIGNMENT to user event logs
  if (rejectedUsernames.length > 0) {
    // A batched lottery decides several starting times at once, so a rejection names the span
    // it took in rather than the hour the run was scheduled at, which for a batch is the
    // parent's
    const lotteriedSpan = getLotteriedSpan(
      startingProgramItems,
      assignmentTime,
    );
 
    const noAssignmentEventLogItemsResult = await addEventLogItems(
      rejectedUsernames.map((rejectedUsername) => ({
        username: rejectedUsername,
        programItemId: "",
        ...lotteriedSpan,
        createdAt: new Date().toISOString(),
        action: EventLogAction.NO_ASSIGNMENT,
      })),
    );
    if (!noAssignmentEventLogItemsResult.ok) {
      logger.error(
        new Error(
          `Assignment ${assignmentTime}: failed to add NO_ASSIGNMENT event log items: ${noAssignmentEventLogItemsResult.error}`,
        ),
      );
    }
 
    // Add SEND_EMAIL_REJECTED to notification queue
    if (
      settings?.emailNotificationTrigger.includes(
        EmailNotificationTrigger.REJECTED,
      )
    ) {
      queueAssignmentEmails({
        queueService,
        assignmentTime,
        notifications: rejectedUsernames.map((rejectedUsername) => ({
          type: NotificationTaskType.SEND_EMAIL_REJECTED,
          username: rejectedUsername,
          programItemId: "",
          ...lotteriedSpan,
        })),
        emailKind: EmailNotificationTrigger.REJECTED,
      });
    } else E{
      logger.info(
        `Assignment ${assignmentTime}: ${EmailNotificationTrigger.REJECTED} emails not enabled in settings, none queued`,
      );
    }
  }
};
 
interface QueueAssignmentEmailsParams {
  queueService: NotificationQueueService | null;
  assignmentTime: string;
  notifications: NotificationTask[];
  emailKind:
    | EmailNotificationTrigger.ACCEPTED
    | EmailNotificationTrigger.REJECTED;
}
 
const queueAssignmentEmails = ({
  queueService,
  assignmentTime,
  notifications,
  emailKind,
}: QueueAssignmentEmailsParams): void => {
  if (queueService === null) {
    logger.error(
      new Error(
        `Assignment ${assignmentTime}: notification queue not initialized, skip queueing ${emailKind} emails`,
      ),
    );
    return;
  }
 
  const queueNotificationsResult =
    queueService.addNotificationsBulk(notifications);
  if (!queueNotificationsResult.ok) {
    logger.error(
      new Error(
        `Assignment ${assignmentTime}: failed to queue ${emailKind} emails: ${queueNotificationsResult.error}`,
      ),
    );
    return;
  }
 
  logger.info(
    `Assignment ${assignmentTime}: queued ${notifications.length} ${emailKind} emails`,
  );
};
 
// Spread into the event log item and the email task, so the two span fields are either both
// written or both absent
type LotteriedSpan =
  | { programItemStartTime: string }
  | {
      programItemStartTime: string;
      lastProgramItemEndTime: string;
      programType: ProgramType;
    };
 
// Measured over the program items handed to it rather than over the run's own hour, which for
// a batch is the parent's and matches nothing the attendee saw. Exported so the admin's test
// email is built the same way a real rejection is, rather than from its own idea of a span
export const getLotteriedSpan = (
  spanProgramItems: readonly ProgramItem[],
  assignmentTime: string,
): LotteriedSpan => {
  const firstProgramItem = firstBy(spanProgramItems, (programItem) =>
    new Date(programItem.startTime).getTime(),
  );
  // A run with no lottery program items at this starting time rejects nobody, so it never asks
  // for a span - the run's own hour is a fallback nothing reaches
  Iif (!firstProgramItem) {
    return { programItemStartTime: assignmentTime };
  }
 
  // To the minute, like every other start time comparison, so one instant written two ways
  // cannot read as a span
  const coversOneStartTime = spanProgramItems.every((programItem) =>
    isSameTime(programItem.startTime, firstProgramItem.startTime),
  );
  if (coversOneStartTime) {
    return { programItemStartTime: firstProgramItem.startTime };
  }
 
  // One program type names the whole span, so a run mixing them names none of them
  const coversOneProgramType = spanProgramItems.every(
    (programItem) => programItem.programType === firstProgramItem.programType,
  );
  Iif (!coversOneProgramType) {
    return { programItemStartTime: firstProgramItem.startTime };
  }
 
  // Seeded with the first program item so the list is non-empty by construction, which is what
  // makes the result a program item rather than a maybe
  const lastProgramItem = firstBy(
    [firstProgramItem, ...spanProgramItems],
    [(programItem) => new Date(programItem.endTime).getTime(), "desc"],
  );
 
  return {
    programItemStartTime: firstProgramItem.startTime,
    lastProgramItemEndTime: lastProgramItem.endTime,
    programType: firstProgramItem.programType,
  };
};