ahbeng/NUSMods

View on GitHub
website/src/serverless/mpe.ts

Summary

Maintainability
A
0 mins
Test Coverage
import axios, { AxiosHeaders } from 'axios';
import { enableCPEx } from '../featureFlags';
import { MpeSubmission, MpePreference, MODULE_TYPES } from '../types/mpe';
import type { Handler } from './handler';

const vfsEndpoint = process.env.NUS_VFS_MPE_ENDPOINT;
const defaultHeaders = new AxiosHeaders({
  'X-API-KEY': process.env.NUS_VFS_MPE_API_KEY ?? null,
  'X-FileUpload-API': process.env.NUS_VFS_MPE_FILEUPLOAD_API ?? null,
  'X-APP-API': process.env.NUS_VFS_MPE_APP_API ?? null,
});

const vfs = axios.create({
  baseURL: vfsEndpoint,
  headers: defaultHeaders,
});

export const getSubmissionById = async (userId: string): Promise<MpeSubmission> => {
  const resp = await vfs.get<MpeSubmission>(`${userId}.json`);
  return resp.data;
};

export const createSubmission = async (userId: unknown, data: unknown): Promise<unknown> => {
  if (typeof userId !== 'string' || isEmptyString(userId) || !isBetweenRangeString(userId, 1, 30)) {
    throw new Error('User ID needs to be between 1 to 30 characters long');
  }

  const submission = convertToMpeSubmission(data);
  if (!submission) {
    throw new Error('Data argument does not conform to the MpeSubmission type');
  }

  if (submission.intendedMCs < 0) {
    throw new Error('Intended MCs must be at least 0 and above');
  }

  if (!validatePreferences(submission.preferences)) {
    throw new Error('Submission preferences failed to meet validation requirements');
  }

  const validatedSubmission: MpeSubmission = {
    nusExchangeId: userId,
    intendedMCs: submission.intendedMCs,
    preferences: submission.preferences.map(({ moduleCode, moduleType }, index) => ({
      rank: index + 1,
      moduleCode,
      moduleType,
    })),
  };

  const resp = await vfs.post(`${userId}.json`, validatedSubmission);
  return resp.data;
};

const isEmptyString = (str: string): boolean => str.trim() === '';

const isBetweenRangeString = (str: string, start: number, end: number): boolean => {
  if (str.length < start || str.length > end) return false;
  return true;
};

// Refactor all code from here on when TS supports the `in` operator for the
// narrowing of unknown types.
// https://github.com/microsoft/TypeScript/issues/25720#issuecomment-533438205
const isUnknownObject = (x: unknown): x is { [key in PropertyKey]: unknown } =>
  x !== null && typeof x === 'object';

const isMpePreference = (preference: unknown): boolean => {
  if (
    isUnknownObject(preference) &&
    typeof preference.moduleCode === 'string' &&
    Object.keys(MODULE_TYPES).find((type) => type === preference.moduleType)
  ) {
    return true;
  }
  return false;
};

const isMpeSubmission = (submission: unknown): boolean => {
  if (
    isUnknownObject(submission) &&
    typeof submission.intendedMCs === 'number' &&
    Array.isArray(submission.preferences) &&
    !submission.preferences.find((preference) => !isMpePreference(preference))
  ) {
    return true;
  }
  return false;
};

const convertToMpeSubmission = (submission: unknown): MpeSubmission | undefined => {
  if (isMpeSubmission(submission)) {
    return submission as MpeSubmission;
  }
  return undefined;
};

const validatePreferences = (preferences: MpePreference[]): boolean =>
  preferences.every(
    (preference) =>
      !isEmptyString(preference.moduleCode) && isBetweenRangeString(preference.moduleCode, 1, 18),
  );

export const featureFlagEnablerMiddleware =
  (next: Handler): Handler =>
  async (req, res): Promise<void> => {
    if (!enableCPEx) {
      res.status(404).end();
      return;
    }
    next(req, res);
  };