Skip to content

Instantly share code, notes, and snippets.

@johnmpost
Last active May 12, 2023 21:55
Show Gist options
  • Save johnmpost/0bc78b7f45be688084dc16af0ea836b5 to your computer and use it in GitHub Desktop.
Save johnmpost/0bc78b7f45be688084dc16af0ea836b5 to your computer and use it in GitHub Desktop.
Insight - Individual Portfolio

John Post Individual Portfolio

For each selection, I have pulled out code snippets. The full files that these snippets are from are provided below. All the code in the snippets is code that I claim, whether I wrote it myself or collaborated with someone else.

Selection 1: The Result Module

export type Result<TSuccess> =
  | {
      kind: "success";
      value: TSuccess;
    }
  | {
      kind: "failure";
      msg: string;
    };

export const success = <TSuccess>(value: TSuccess): Result<TSuccess> => ({
  kind: "success",
  value,
});

export const failure = (error: string): Result<never> => ({
  kind: "failure",
  msg: error,
});

export const map =
  <TSuccess, TNewSuccess>(
    fn: (value: TSuccess) => TNewSuccess
  ): ((result: Result<TSuccess>) => Result<TNewSuccess>) =>
  (result) =>
    result.kind === "success" ? success(fn(result.value)) : result;

export const bind =
  <TSuccess, TNewSuccess>(
    fn: (value: TSuccess) => Result<TNewSuccess>
  ): ((result: Result<TSuccess>) => Result<TNewSuccess>) =>
  (result) =>
    result.kind === "success" ? fn(result.value) : result;

Key Features

This code snippet shows the most important parts of the Result module. The Result module consists of the Result type and several utility functions that work with it. The Result type represents the result of some action that can either succeed or fail. Examples of possible failures include a nonexistant database key, a duplicate database key, or a business rule being broken. The Result type is a powerful concept that helps to embrace error handling rather than avoid it, and more accurately describe what is being accepted by and returned from our functions. It is used primarily in our API route handler functions to do "Railway-Oriented Programming." This term was popularized by the blog "F# For Fun and Profit," but is broadly applicable to APIs in any language. It is a way to define API routes solely in terms of the "happy path," and all errors are handled by the underlying Result type and the ROP framework. In addition to our API routes, Result is used in some of our utility functions. In particular, the YAML parser is a great use case for this concept.

The Result type itself is a discriminated union - an either type. It can take on one of two values: a success value with a generic type, or a failure with a string error message. The success and failure functions are convenience functions provided so that creating result values is easier and they are more explicitly readable. The map and bind functions are where it gets fun. These two functions are common concepts within the realm of functional programming. They are functions that transform other functions.

Let's start with this. We can think of Result as a "wrapper" for a normal value. We can imagine that there exist functions that turn normal values into other normal values (e.g. int -> int). We can also imagine that there exist functions that take normal values and return a wrapper (e.g. int -> Result). You would never want to write a function that accepts a Result. Think about it: in every function like this, you'd have to check if the Result is a success and actually has a value, and bypass the rest of the function if that is not the case. So, we have two types of functions: functions that start in "normal world" and move to "normal world," and functions that start in "normal world" and end in "wrapper world." But of course, you have all these functions that don't accept Result values and you want to use them with Result values. That's where map and bind come in.

Both map and bind take in a function as a parameter. Both map and bind transform the input function so that the new function is one that starts in wrapper world and ends in wrapper world. The only difference is this: map transforms normal -> normal functions, and bind transforms normal -> wrapper functions. So, with both map and bind, you can transform any function into one that operates within wrapper world.

SE Principles

The Result module demonstrates good software engineering principles in several ways. First, it embraces error handling by providing a type that can represent both successful and failed results. This allows developers to explicitly handle errors and avoid common pitfalls such as null or undefined values. Second, the Result type is used in conjunction with the “Railway-Oriented Programming” approach to define API routes solely in terms of the “happy path,” with all errors being handled by the underlying Result type and the ROP framework. This approach promotes code readability and maintainability by separating error handling from the main logic of the application. Finally, the map and bind functions provide a way to transform functions so that they can operate within the “wrapper world” of the Result type, promoting code reuse and composability.

Selection 2: The Route Handler Functions

export const createSession =
  (
    crud: AsyncCrudAPI<Session>,
    io: Server,
    codesRepo: AsyncUniqueValueStoreAPI<string>
  ) =>
  async (req: Request, res: Response) => {
    const socketId = getHeader(req, "X-Socket-ID");

    const socket = bind(getSocket(io))(socketId);

    const socketNotInRoom = bind(ensureNotAlreadyInRoom)(socket);

    const newSession = bindAsync<Socket, Session>(async (socket: Socket) => {
      const newSession = await createEmptySession(codesRepo, socket.id);
      const created = await crud.create(newSession.code, newSession);
      return created;
    })(socketNotInRoom);

    const joinedSession = map(([socket, session]: [Socket, Session]) => {
      socket.join(session.code);
      return session;
    })(combine(socketNotInRoom, await newSession));

    return process(
      joinedSession,
      (newSession) => res.status(200).json(sessionToSessionForHost(newSession)),
      (errorMsg) => res.status(400).send(errorMsg)
    );
  };

Key Features

This code snippet is one of our many route handler functions. These functions are what get called when someone hits an API route, and as such, are essential to the codebase.

The example shown is the route handler for creating a new session. There are two things to note about the code. First, createSession is a curried function. At the basic level, it's a function that returns another function. The "outer" function accepts three parameters: crud (the crud accessor functions), io (the socket.io server object), and codesRepo (the repository for managing session codes). This "outer" function returns an "inner" function that becomes the actual route handler function that express requires. It accepts the standard req and res parameters. The benefit of doing it this way is that we can pass in the necessary dependencies (crud, io, and codesRepo) as parameters using dependency injection, and still have a function that works with what express expects. Since we do dependency injection, it allows us to easily create mock versions of the dependencies for testing.

Second, the function is using the functions from the Result module and Railway-Oriented Programming. Each step of the "pipeline" that needs to happen when this route is called is a const variable of the Result type. Each step takes in the things that it depends on. All errors are properly handled by the Result module functions. As you can see, the code is only concerned with the "happy path" until the very end, when we handle any possible error that came up in the pipeline.

SE Principles

This code snippet demonstrates several software engineering principles. It uses currying and dependency injection to make the code more modular and testable. By passing in dependencies as parameters, it allows for easy creation of mock versions for testing. It also uses Railway-Oriented Programming and the Result module to handle errors in a structured and organized way. This makes the code more readable and maintainable by separating the “happy path” from error handling. Overall, this code demonstrates good software engineering practices by being modular, testable, readable, and maintainable.

Selection 3: Unique Value Store

type Store<T> = {
  data: Set<T>;
  mutex: Mutex;
};

export const createStore = <T>(): Store<T> => ({
  data: new Set(),
  mutex: new Mutex(),
});

export const create = async <T>(
  store: Store<T>,
  tryGenValue: () => T
): Promise<T> => {
  const release = await store.mutex.acquire();
  try {
    let value = tryGenValue();
    while (store.data.has(value)) {
      value = tryGenValue();
    }
    store.data.add(value);
    return value;
  } finally {
    release();
  }
};

export const release = async <T>(
  store: Store<T>,
  value: T
): Promise<Result<T>> => {
  const release = await store.mutex.acquire();
  try {
    if (!store.data.has(value)) {
      return failure("Value does not exist");
    }
    store.data.delete(value);
    return success(value);
  } finally {
    release();
  }
};

export const createAPI = <T>(
  store: Store<T>,
  tryGenValue: () => T
): AsyncUniqueValueStoreAPI<T> => ({
  create: () => create(store, tryGenValue),
  release: (value) => release(store, value),
});

Key Features

This is the set store module. At a high level, it is an in-memory data store that stores unique values and has some basic operations on it. You can add new values to the store using the create function. This function also ensures that the new value you add is unique. You can then "release" values from the store, allowing them to be reused, using the release function. This function returns a Result value, which is a failure if the value to be released does not exist.

This set store is also asynchronous and safely handles concurrency (no race conditions). It uses a mutex object that is locked for every operation to ensure safety. This is necessary because this set store object is a shared value, and multiple API calls may attempt to use it simultaneously.

You'll also notice the function createAPI. This function creates a nicer interface to use a set store. Since you'll likely be using a single Store object throughout the app, it would be annoying to have to pass the store parameter every time you wanted to use create or release. Instead, we can pre-apply that store parameter and output two new create and release functions with the store parameter "baked in." This also allows for a type AsyncUniqueValueStoreAPI. This type represents an "interface" that can be implemented in any way. For example, you could implement the same interface by backing it with a database rather than an in-memory store.

In our app, this set store is used to manage session codes. Our session codes consist of 6 digits, where each is a capital letter or a digit. This means there are 36^6 possible codes, and a very low chance of collision. However, we chose to eliminate that possibility entirely. When creating new codes for new sessions, we could have pulled all the sessions from the session store and checked against their codes. However, that would have introduced extra complexity with regard to concurrency. So, we chose to use this independent concurrency-safe unique value store to manage creating and releasing session codes. This leads to a more decoupled and manageable codebase.

SE Principles

This code demonstrates several good software engineering principles. For example, it uses a modular design by separating the set store functionality into its own module with a clear interface. This makes the code easier to understand and maintain. Additionally, the use of a mutex to handle concurrency safely is an example of defensive programming, which helps prevent race conditions and other concurrency-related issues. The createAPI function also demonstrates the principle of abstraction by providing a simpler interface for using the set store. Finally, the use of this set store to manage session codes in a decoupled and manageable way is an example of separation of concerns, which helps keep the codebase organized and maintainable.

Selection 4: Frontend State Management

export type FrontendDataModel =
  | {
      type: "host";
      session: SessionForHost;
      sessionStatus: "inProgress" | "ended";
      timeRemaining: Option<number>;
      questionQueue: Question[];
    }
  | {
      type: "participant";
      participant: Participant;
      sessionCode: string;
      participantState:
        | {
            type: "waitingForHost";
          }
        | {
            type: "acceptingResponses";
            question: Question;
            latestResponse: Option<QuestionResponseForParticipant>;
            timeRemaining: Option<number>;
          }
        | {
            type: "showingCorrect";
            question: Question;
            latestResponse: Option<QuestionResponseForParticipant>;
          }
        | {
            type: "sessionEnded";
          };
    }
  | {
      type: "noneSelected";
    };

// sample of some of the actions
export type Action =
  | {
      type: "createSession";
      newSession: SessionForHost;
    }
  | {
      type: "endSession";
      completedSession: SessionForHost;
    }
  | {
      type: "newResponse";
      response: QuestionResponseForHost;
    }
  | {
      type: "newQuestion";
      question: Question;
    }
  | {
      type: "sentNewResponse";
      response: QuestionResponseForParticipant;
    };

// sample of part of the reducer
export const reducer = (
  state: FrontendDataModel,
  action: Action
): FrontendDataModel => {
  switch (action.type) {
    case "createSession":
      if (state.type !== "noneSelected") {
        logError("cannot create a session if in one already");
        return state;
      }
      return {
        type: "host",
        timeRemaining: null,
        session: action.newSession,
        sessionStatus: "inProgress",
        questionQueue: [],
      };

    case "endSession":
      if (state.type !== "host") {
        logError("cannot end a session if not the host");
        return state;
      }
      if (state.session.activeQuestion !== null) {
        logError("cannot end session if there is an active question");
        return state;
      }
      return {
        ...state,
        session: action.completedSession,
        sessionStatus: "ended",
      };
  }
};
const [dataModel, dispatch] = React.useReducer(reducer, {
  type: "noneSelected",
});

Key Features

For our major frontend state management, we decided to use React's useReducer hook (for smaller state things, we still used component-scoped useStates). This hook is similar to useState, but it is much more specific with how you're allowed to update the state. We first defined a state type, FrontendDataModel, to represent what the state of our application would be. As you can see, the type is a discriminated union type that represents any possible state that the application can be in with regard to the user, session status, etc. Then, we defined the major actions that could be taken in our application as the Action type. The code snippet shows a couple of actions we defined, such as creating a session and sending a new response. Finally, we define a reducer function that accepts the current state and an action to carry out. The reducer function then does the action by transforming the state and returning the updated state.

In this way, we can be very deliberate about what actions will happen in the application and how we handle the state when doing those actions. This makes it much more difficult to write buggy code that could put the app in an invalid state, and makes it much easier to manage the state as a whole.

After defining our state model, actions, and reducer function, we put it all in a React.useReducer hook. We also put this hook into a react context so that it would be accessible from any component in the app. This made building out the frontend much simpler. We could completely decouple the state management from the views. So, once we had the state correct, we could build out any view just by hooking into the current state and building a view around it.

SE Principles

This code demonstrates several software engineering principles. One of the key principles demonstrated is the use of type safety through TypeScript. By defining the FrontendDataModel and Action types, the code ensures that the state and actions are well-defined and that any invalid states or actions are caught at compile-time. This helps to prevent runtime errors and makes the code more maintainable.

Another principle demonstrated is the separation of concerns. The state management logic is separated from the view logic through the use of a reducer function and the useReducer hook. This makes it easier to reason about the state management and view logic independently, leading to more modular and maintainable code.

Finally, the use of a reducer function and useReducer hook also demonstrates the principle of immutability. The state is never mutated directly, but rather a new state is returned by the reducer function based on the current state and action. This helps to prevent unintended side-effects and makes it easier to reason about how the state changes over time.

import { Participant } from "../../../shared/domain/Participant";
import { Question } from "../../../shared/domain/Question";
import { QuestionResponseForHost } from "../../../shared/dtos/QuestionResponseForHost";
import { QuestionResponseForParticipant } from "../../../shared/dtos/QuestionResponseForParticipant";
import { SessionForHost } from "../../../shared/dtos/SessionForHost";
import { deepCopy } from "../../../shared/utils/deepcopy";
import { logError, logWarning } from "../../../shared/utils/log";
import { Option } from "../../../shared/utils/Option";
import { questionHasCorrectAnswer } from "../../../shared/utils/utils";
export type FrontendDataModel =
| {
type: "host";
session: SessionForHost;
sessionStatus: "inProgress" | "ended";
timeRemaining: Option<number>;
questionQueue: Question[];
}
| {
type: "participant";
participant: Participant;
sessionCode: string;
participantState:
| {
type: "waitingForHost";
}
| {
type: "acceptingResponses";
question: Question;
latestResponse: Option<QuestionResponseForParticipant>;
timeRemaining: Option<number>;
}
| {
type: "showingCorrect";
question: Question;
latestResponse: Option<QuestionResponseForParticipant>;
}
| {
type: "sessionEnded";
};
}
| {
type: "noneSelected";
};
export type Action =
| {
type: "createSession";
newSession: SessionForHost;
}
| {
type: "endSession";
completedSession: SessionForHost;
}
| {
type: "newResponse";
response: QuestionResponseForHost;
}
| {
type: "newQuestion";
question: Question;
}
| {
type: "sentNewResponse";
response: QuestionResponseForParticipant;
}
| {
type: "questionClosed";
endedQuestion: Question;
}
| {
type: "doneViewingCorrect";
}
| {
type: "activateQuestion";
newQuestion: Question;
}
| {
type: "closeQuestion";
}
| {
type: "joinSession";
sessionCode: string;
participant: Participant;
}
| {
type: "participantJoined";
participant: Participant;
}
| {
type: "sessionClosedByHost";
}
| {
type: "updateTimer";
timeRemaining: number;
}
| {
type: "addQuestionsToQueue";
questionsToAdd: Question[];
insertIndex: number;
}
| {
type: "removeQuestionFromQueue";
removeIndex: number;
}
| {
type: "moveQuestionUpInQueue";
moveIndex: number;
}
| {
type: "moveQuestionDownInQueue";
moveIndex: number;
}
| {
type: "editQuestionInQueue";
updateIndex: number;
updatedQuestion: Question;
}
| {
type: "reorderQuestionQueue";
newOrder: Question[];
};
export const reducer = (
state: FrontendDataModel,
action: Action
): FrontendDataModel => {
switch (action.type) {
case "createSession":
if (state.type !== "noneSelected") {
logError("cannot create a session if in one already");
return state;
}
return {
type: "host",
timeRemaining: null,
session: action.newSession,
sessionStatus: "inProgress",
questionQueue: [],
};
case "endSession":
if (state.type !== "host") {
logError("cannot end a session if not the host");
return state;
}
if (state.session.activeQuestion !== null) {
logError("cannot end session if there is an active question");
return state;
}
return {
...state,
session: action.completedSession,
sessionStatus: "ended",
};
case "newResponse":
if (state.type !== "host") {
logError("cannot receive a new response if not the host");
return state;
}
const addOrUpdateResponse = (
responses: QuestionResponseForHost[],
newResponse: QuestionResponseForHost
) => {
const indexToUpdate = responses.findIndex(
(response) => response.id === action.response.id
);
if (indexToUpdate === -1) {
return [...responses, newResponse];
} else {
const newResponses = deepCopy(responses);
newResponses[indexToUpdate] = newResponse;
return newResponses;
}
};
const newResponses = addOrUpdateResponse(
state.session.responses,
action.response
);
return {
...state,
session: {
...state.session,
responses: newResponses,
},
};
case "newQuestion":
if (state.type !== "participant") {
logError("cannot receive a new question if not the participant");
return state;
}
if (state.participantState.type === "acceptingResponses") {
logWarning(
"should not have had a new question while there was still an active question"
);
}
return {
...state,
participantState: {
type: "acceptingResponses",
question: action.question,
latestResponse: null,
timeRemaining: null,
},
};
case "sentNewResponse":
if (state.type !== "participant") {
logError("cannot send a new response if not a participant");
return state;
}
if (state.participantState.type !== "acceptingResponses") {
logError("cannot send a new response if not accepting responses");
return state;
}
return {
...state,
participantState: {
...state.participantState,
latestResponse: action.response,
},
};
case "questionClosed":
if (state.type !== "participant") {
logError(
"cannot have a question close on a user that is not a participant"
);
return state;
}
if (state.participantState.type !== "acceptingResponses") {
logError(
"cannot have closed a question when there wasn't an active question"
);
return state;
}
const hasCorrect = questionHasCorrectAnswer(action.endedQuestion);
return hasCorrect
? {
...state,
participantState: {
type: "showingCorrect",
question: action.endedQuestion,
latestResponse: state.participantState.latestResponse,
},
}
: { ...state, participantState: { type: "waitingForHost" } };
case "doneViewingCorrect":
if (state.type !== "participant") {
logError("cannot stop viewing correct if not a participant");
return state;
}
return {
...state,
participantState: {
type: "waitingForHost",
},
};
case "closeQuestion":
if (state.type !== "host") {
logError("cannot close a question if not the host");
return state;
}
if (state.session.activeQuestion === null) {
logError("cannot close a question if there is no active question");
return state;
}
return {
...state,
timeRemaining: null,
session: {
...state.session,
activeQuestion: null,
endedQuestions: [
...state.session.endedQuestions,
state.session.activeQuestion,
],
},
};
case "activateQuestion":
if (state.type !== "host") {
logError("cannot activate a question if not the host");
return state;
}
if (state.session.activeQuestion !== null) {
logError(
"cannot activate a question if there is already an active question"
);
return state;
}
return {
...state,
session: {
...state.session,
activeQuestion: action.newQuestion,
},
};
case "joinSession":
if (state.type !== "noneSelected") {
logError("cannot join a session if already in one");
return state;
}
return {
type: "participant",
participantState: {
type: "waitingForHost",
},
sessionCode: action.sessionCode,
participant: action.participant,
};
case "participantJoined":
if (state.type !== "host") {
logError("cannot receive a participant join msg if not the host");
return state;
}
return {
...state,
session: {
...state.session,
participants: [...state.session.participants, action.participant],
},
};
case "sessionClosedByHost":
if (state.type !== "participant") {
logError(
"cannot receive a session ended by host msg if not a participant"
);
return state;
}
return {
...state,
participantState: {
type: "sessionEnded",
},
};
case "updateTimer":
return state.type === "noneSelected"
? (logError("cannot update timer if not in a session"), state)
: state.type === "host"
? { ...state, timeRemaining: action.timeRemaining }
: state.participantState.type === "acceptingResponses"
? {
...state,
participantState: {
...state.participantState,
timeRemaining: action.timeRemaining,
},
}
: (logError("cannot update timer if not accepting responses"), state);
case "addQuestionsToQueue":
return state.type !== "host"
? (logError("cannot add to queue if not the host"), state)
: {
...state,
questionQueue: [
...state.questionQueue.slice(0, action.insertIndex),
...action.questionsToAdd,
...state.questionQueue.slice(action.insertIndex),
],
};
case "removeQuestionFromQueue":
return state.type !== "host"
? (logError("cannot remove from queue if not the host"), state)
: {
...state,
questionQueue: [
...state.questionQueue.slice(0, action.removeIndex),
...state.questionQueue.slice(action.removeIndex + 1),
],
};
case "editQuestionInQueue":
return state.type !== "host"
? (logError("cannot edit queue if not the host"), state)
: {
...state,
questionQueue: [
...state.questionQueue.slice(0, action.updateIndex),
action.updatedQuestion,
...state.questionQueue.slice(action.updateIndex + 1),
],
};
case "moveQuestionUpInQueue":
return state.type !== "host"
? (logError("cannot move question up in queue if not the host"), state)
: action.moveIndex === 0
? (logError("cannot move first question up in queue"), state)
: {
...state,
questionQueue: [
...state.questionQueue.slice(0, action.moveIndex - 1),
state.questionQueue[action.moveIndex],
state.questionQueue[action.moveIndex - 1],
...state.questionQueue.slice(action.moveIndex + 1),
],
};
case "moveQuestionDownInQueue":
return state.type !== "host"
? (logError("cannot move question down in queue if not the host"),
state)
: action.moveIndex === state.questionQueue.length - 1
? (logError("cannot move last question down in queue"), state)
: {
...state,
questionQueue: [
...state.questionQueue.slice(0, action.moveIndex),
state.questionQueue[action.moveIndex + 1],
state.questionQueue[action.moveIndex],
...state.questionQueue.slice(action.moveIndex + 2),
],
};
case "reorderQuestionQueue":
return state.type !== "host"
? (logError("cannot reorder queue if not the host"), state)
: {
...state,
questionQueue: action.newOrder,
};
}
};
export type Result<TSuccess> =
| {
kind: "success";
value: TSuccess;
}
| {
kind: "failure";
msg: string;
};
export const success = <TSuccess>(value: TSuccess): Result<TSuccess> => ({
kind: "success",
value,
});
export const failure = (error: string): Result<never> => ({
kind: "failure",
msg: error,
});
export const map =
<TSuccess, TNewSuccess>(
fn: (value: TSuccess) => TNewSuccess
): ((result: Result<TSuccess>) => Result<TNewSuccess>) =>
(result) => {
if (result.kind === "success") {
return success(fn(result.value));
} else {
return result;
}
};
export const mapAsync =
<TSuccess, TNewSuccess>(
fn: (value: TSuccess) => Promise<TNewSuccess>
): ((result: Result<TSuccess>) => Promise<Result<TNewSuccess>>) =>
async (result) => {
if (result.kind === "success") {
return success(await fn(result.value));
} else {
return result;
}
};
export const bind =
<TSuccess, TNewSuccess>(
fn: (value: TSuccess) => Result<TNewSuccess>
): ((result: Result<TSuccess>) => Result<TNewSuccess>) =>
(result) => {
if (result.kind === "success") {
return fn(result.value);
} else {
return result;
}
};
export const bindAsync =
<TSuccess, TNewSuccess>(
fn: (value: TSuccess) => Promise<Result<TNewSuccess>>
): ((result: Result<TSuccess>) => Promise<Result<TNewSuccess>>) =>
async (result) => {
if (result.kind === "success") {
return await fn(result.value);
} else {
return result;
}
};
export function combine<T1, T2>(
result1: Result<T1>,
result2: Result<T2>
): Result<[T1, T2]> {
if (result1.kind === "failure") {
return { kind: "failure", msg: result1.msg };
}
if (result2.kind === "failure") {
return { kind: "failure", msg: result2.msg };
}
return { kind: "success", value: [result1.value, result2.value] };
}
export const combine3 = <T1, T2, T3>(
result1: Result<T1>,
result2: Result<T2>,
result3: Result<T3>
): Result<[T1, T2, T3]> => {
if (result1.kind === "failure") {
return { kind: "failure", msg: result1.msg };
}
if (result2.kind === "failure") {
return { kind: "failure", msg: result2.msg };
}
if (result3.kind === "failure") {
return { kind: "failure", msg: result3.msg };
}
return {
kind: "success",
value: [result1.value, result2.value, result3.value],
};
};
export function process<TSuccess, TOnSuccess, TOnFailure>(
result: Result<TSuccess>,
onSuccess: (value: TSuccess) => TOnSuccess,
onFailure: (msg: string) => TOnFailure
) {
if (result.kind === "success") {
return onSuccess(result.value);
} else {
return onFailure(result.msg);
}
}
import { WSEvents } from "../../../shared/domain/WebsocketEvents";
import { v4 as uuidv4 } from "uuid";
import { Request, Response } from "express";
import { JoinSessionRequest } from "../../../shared/dtos/JoinSessionRequest";
import { Question } from "../../../shared/domain/Question";
import { QuestionResponse } from "../../../shared/domain/QuestionResponse";
import { Server, Socket } from "socket.io";
import { Session } from "../../../shared/domain/Session";
import {
Result,
bind,
bindAsync,
combine,
combine3,
failure,
map,
mapAsync,
process,
success,
} from "../../../shared/utils/Result";
import { Participant } from "../../../shared/domain/Participant";
import {
createEmptySession,
createParticipant,
ensureNotAlreadyInRoom,
getHeader,
getSocket,
questionResponseForParticipantToQuestionResponse,
questionResponseToQuestionResponseForHost,
removeAnswersFromQuestion,
sessionToSessionForHost,
} from "../utils/route-utils";
import { QuestionResponseForParticipant } from "../../../shared/dtos/QuestionResponseForParticipant";
import {
AsyncCrdAPI,
AsyncCrudAPI,
AsyncUniqueValueStoreAPI,
} from "../utils/storage-apis";
import { countdownTimer } from "../utils/countdownTimer";
type SessionCodeRouteParam = {
sessionCode: string;
};
export const createSession =
(
crud: AsyncCrudAPI<Session>,
io: Server,
codesRepo: AsyncUniqueValueStoreAPI<string>
) =>
async (req: Request, res: Response) => {
const socketId = getHeader(req, "X-Socket-ID");
const socket = bind(getSocket(io))(socketId);
const socketNotInRoom = bind(ensureNotAlreadyInRoom)(socket);
const newSession = bindAsync<Socket, Session>(async (socket: Socket) => {
const newSession = await createEmptySession(codesRepo, socket.id);
const created = await crud.create(newSession.code, newSession);
return created;
})(socketNotInRoom);
const joinedSession = map(([socket, session]: [Socket, Session]) => {
socket.join(session.code);
return session;
})(combine(socketNotInRoom, await newSession));
return process(
joinedSession,
(newSession) => res.status(200).json(sessionToSessionForHost(newSession)),
(errorMsg) => res.status(400).send(errorMsg)
);
};
export const checkSessionExists =
(crud: AsyncCrdAPI<Session>) =>
async (req: Request<SessionCodeRouteParam>, res: Response) => {
const { sessionCode } = req.params;
const session = await crud.read(sessionCode);
return process(
session,
(exists) => res.status(200).json({ sessionExists: true }),
(notExists) => res.status(200).json({ sessionExists: false })
);
};
export const endSession =
(
crud: AsyncCrudAPI<Session>,
io: Server,
codesRepo: AsyncUniqueValueStoreAPI<string>
) =>
async (req: Request<SessionCodeRouteParam>, res: Response) => {
const { sessionCode } = req.params;
const hostSecret = getHeader(req, "X-Host-Secret");
const session = await crud.read(sessionCode);
const sessionMatchingSecret = bind(
([session, hostSecret]: [Session, string]) =>
session.hostSecret === hostSecret
? success(session)
: failure("Host secret does not match")
)(combine(session, hostSecret));
const sessionEndedQuestion = bind((session: Session) =>
session.activeQuestion === null
? success(session)
: failure("Session still has an active question")
)(sessionMatchingSecret);
const disconnectedSockets = map((session: Session) => {
io.to(session.code).emit(WSEvents.sessionEnded);
io.in(session.code).disconnectSockets(true);
return session;
})(sessionEndedQuestion);
const endedSession = await bindAsync((session: Session) =>
crud.deleteKey(session.code)
)(disconnectedSockets);
const releasedCode = await mapAsync(async (session: Session) => {
await codesRepo.release(session.code);
return session;
})(endedSession);
return process(
releasedCode,
(endedSession) =>
res.status(200).json(sessionToSessionForHost(endedSession)),
(errorMsg) => res.status(400).send(errorMsg)
);
};
export const joinSession =
(crud: AsyncCrudAPI<Session>, io: Server) =>
async (
req: Request<SessionCodeRouteParam, {}, JoinSessionRequest>,
res: Response
) => {
const { sessionCode } = req.params;
const { name } = req.body;
const socketId = getHeader(req, "X-Socket-ID");
const socket = bind(getSocket(io))(socketId);
const socketInNoRooms = bind(ensureNotAlreadyInRoom)(socket);
const session = await crud.read(sessionCode);
const newParticipant = createParticipant(name);
const addedParticipant = await bindAsync(async (session: Session) =>
crud.update(session.code, (toUpdate) => {
toUpdate.participants.push(newParticipant);
return success(toUpdate);
})
)(session);
const joinedSession = map(([socket, session]: [Socket, Session]) => {
socket.join(session.code);
return session;
})(combine(socketInNoRooms, addedParticipant));
const notifiedHost = map((session: Session) => {
io.to(session.hostSocketId).emit(
WSEvents.participantJoined,
newParticipant
);
return undefined;
})(joinedSession);
return process(
notifiedHost,
(_) => res.status(200).json(newParticipant),
(errorMsg) => res.status(400).send(errorMsg)
);
};
export const activateQuestion =
(
crud: AsyncCrudAPI<Session>,
io: Server,
timersRepo: AsyncCrdAPI<NodeJS.Timer>
) =>
async (req: Request<SessionCodeRouteParam, {}, Question>, res: Response) => {
const { sessionCode } = req.params;
const newQuestion = req.body;
const hostSecret = getHeader(req, "X-Host-Secret");
const session = await crud.read(sessionCode);
const sessionMatchingSecret = bind(
([session, hostSecret]: [Session, string]) =>
session.hostSecret === hostSecret
? success(session)
: failure("Host secret does not match")
)(combine(session, hostSecret));
const sessionNoActiveQuestion = bind((session: Session) =>
session.activeQuestion === null
? success(session)
: failure("Session already has an active question")
)(sessionMatchingSecret);
const sessionNewActiveQuestion = await bindAsync((session: Session) =>
crud.update(session.code, (session) => {
session.activeQuestion = newQuestion;
return success(session);
})
)(sessionNoActiveQuestion);
const questionSentToWS = map((session: Session) => {
io.to(session.code).emit(
WSEvents.newQuestion,
removeAnswersFromQuestion(session.activeQuestion as Question)
);
return undefined;
})(sessionNewActiveQuestion);
const createdTimerIfRequired = await mapAsync(async (session: Session) => {
if (session.activeQuestion?.timeLimit) {
const oneSecond = 1000;
const notifyWS = (countRemaining: number) =>
io.to(session.code).emit(WSEvents.timerUpdate, countRemaining);
const closeQuestion = async () => {
await timersRepo.deleteKey(session.code);
await crud.update(session.code, (session) => {
session.endedQuestions.push(session.activeQuestion as Question);
session.activeQuestion = null;
return success(session);
});
io.to(session.code).emit(
WSEvents.questionEnded,
session.activeQuestion
);
};
const timer = countdownTimer(
oneSecond,
session.activeQuestion.timeLimit,
notifyWS,
closeQuestion
);
await timersRepo.create(session.code, timer);
}
})(sessionNewActiveQuestion);
return process(
questionSentToWS,
(_) => res.status(200).send(),
(errorMsg) => res.status(400).send(errorMsg)
);
};
export const closeQuestion =
(
crud: AsyncCrudAPI<Session>,
io: Server,
timersRepo: AsyncCrdAPI<NodeJS.Timer>
) =>
async (req: Request<SessionCodeRouteParam>, res: Response) => {
const { sessionCode } = req.params;
const hostSecret = getHeader(req, "X-Host-Secret");
const session = await crud.read(sessionCode);
const sessionMatchingSecret = bind(
([session, hostSecret]: [Session, string]) =>
session.hostSecret === hostSecret
? success(session)
: failure("Host secret does not match")
)(combine(session, hostSecret));
const sessionWithActiveQuestion = bind((session: Session) =>
session.activeQuestion !== null
? success(session)
: failure("Session does not have an active question")
)(sessionMatchingSecret);
const closedQuestion = map(
(session: Session) => session.activeQuestion as Question
)(sessionWithActiveQuestion);
const sessionNoActiveQuestion = await bindAsync((session: Session) =>
crud.update(session.code, (session) => {
session.endedQuestions.push(session.activeQuestion as Question);
session.activeQuestion = null;
return success(session);
})
)(sessionWithActiveQuestion);
const endedTimer = await mapAsync(async (session: Session) => {
const timer = await timersRepo.deleteKey(session.code);
map(clearInterval)(timer);
return undefined;
})(sessionNoActiveQuestion);
const wsInformedEnded = map(
([session, closedQuestion, _]: [Session, Question, undefined]) => {
io.to(session.code).emit(WSEvents.questionEnded, closedQuestion);
return undefined;
}
)(combine3(sessionNoActiveQuestion, closedQuestion, endedTimer));
return process(
wsInformedEnded,
(_) => res.status(200).send(),
(errorMsg) => res.status(400).send(errorMsg)
);
};
export const respondToQuestion =
(crud: AsyncCrudAPI<Session>, io: Server) =>
async (
req: Request<SessionCodeRouteParam, {}, QuestionResponseForParticipant>,
res: Response
) => {
const { sessionCode } = req.params;
const newResponse = req.body;
const participantSecret = getHeader(req, "X-Participant-Secret");
const session = await crud.read(sessionCode);
const participant = bind((session: Session) => {
const participant = session.participants.find(
(participant) => participant.id === newResponse.participantId
);
return participant === undefined
? failure("No participant exists with that id")
: success(participant);
})(session);
const participantSecretMatches = bind(
([participant, participantSecret]: [Readonly<Participant>, string]) =>
participant.secret === participantSecret
? success(undefined)
: failure("participant secret does not match")
)(combine(participant, participantSecret));
// we do not ensure that the type or responses match with the referenced question
// this is okay for now, may change later depending on implementation
const activeQuestionMatches = bind((session: Session) =>
session.activeQuestion === null
? failure("cannot respond when there is no active question")
: session.activeQuestion.id === newResponse.questionId
? success(session.activeQuestion)
: failure("questionId does not match activeQuestion id")
)(session);
const addedResponse: Result<QuestionResponse> = await mapAsync(
async ([session, _, question]: [Session, undefined, Question]) => {
const indexToUpdate = session.responses.findIndex(
(response) =>
response.participantId === newResponse.participantId &&
response.questionId === newResponse.questionId
);
const responseType = indexToUpdate === -1 ? "new" : "updated";
const newOrUpdatedResponse =
questionResponseForParticipantToQuestionResponse(
question.isAnonymous,
newResponse,
responseType === "new"
? uuidv4()
: session.responses[indexToUpdate].id
);
const updatedResponses =
responseType === "new"
? (session.responses.push(newOrUpdatedResponse), session.responses)
: ((session.responses[indexToUpdate] = newOrUpdatedResponse),
session.responses);
await crud.update(session.code, (session) =>
success({ ...session, responses: updatedResponses })
);
return newOrUpdatedResponse;
}
)(combine3(session, participantSecretMatches, activeQuestionMatches));
const wsInformedResponded = map(
([session, newResponse]: [Session, QuestionResponse]) =>
io
.to(session.hostSocketId)
.emit(
WSEvents.newResponse,
questionResponseToQuestionResponseForHost(newResponse)
)
)(combine(session, addedResponse));
return process(
wsInformedResponded,
(_) => res.status(200).send(),
(errorMsg) => res.status(400).send(errorMsg)
);
};
export const checkActiveQuestion =
(crud: AsyncCrudAPI<Session>, io: Server) =>
async (req: Request<SessionCodeRouteParam>, res: Response) => {
const { sessionCode } = req.params;
const socketId = getHeader(req, "X-Socket-ID");
const socket = bind(getSocket(io))(socketId);
const session = await crud.read(sessionCode);
const notifiedParticipant = map(([socket, session]: [Socket, Session]) => {
const sessionHasActiveQuestion = session.activeQuestion !== null;
if (sessionHasActiveQuestion) {
io.to(socket.id).emit(
WSEvents.newQuestion,
removeAnswersFromQuestion(session.activeQuestion as Question)
);
}
return undefined;
})(combine(socket, session));
return process(
notifiedParticipant,
(_) => res.status(200).send(),
(errorMsg) => res.status(400).send(errorMsg)
);
};
import { Mutex } from "async-mutex";
import { Result, failure, success } from "../../../shared/utils/Result";
import { AsyncUniqueValueStoreAPI } from "./storage-apis";
type Store<T> = {
data: Set<T>;
mutex: Mutex;
};
export function createStore<T>(): Store<T> {
return {
data: new Set(),
mutex: new Mutex(),
};
}
export async function create<T>(
store: Store<T>,
tryGenValue: () => T
): Promise<T> {
const release = await store.mutex.acquire();
try {
let value = tryGenValue();
while (store.data.has(value)) {
value = tryGenValue();
}
store.data.add(value);
return value;
} finally {
release();
}
}
export async function release<T>(
store: Store<T>,
value: T
): Promise<Result<T>> {
const release = await store.mutex.acquire();
try {
if (!store.data.has(value)) {
return failure("Value does not exist");
}
store.data.delete(value);
return success(value);
} finally {
release();
}
}
export function createAPI<T>(
store: Store<T>,
tryGenValue: () => T
): AsyncUniqueValueStoreAPI<T> {
return {
create: () => create(store, tryGenValue),
release: (value) => release(store, value),
};
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment