Bernd-L/exDateMan

View on GitHub
frontend/src/app/services/EventSourcing/event-sourcing.service.ts

Summary

Maintainability
B
6 hrs
Test Coverage
import { Injectable } from "@angular/core";
import { HttpClient } from "@angular/common/http";
import { environment } from "../../../environments/environment";
import { AsyncConstructor } from "../../interfaces/async-constructor";

/**
 * This Service stores & sorts Events and syncs them with the API.
 *
 * This class needs to await async operations before it can be used, but
 * TypeScript does not support await to be used in constructors, so the user
 * has to await the ready promise of this class.
 */
@Injectable({
  providedIn: "root"
})
export class EventSourcingService implements AsyncConstructor {
  constructor(private api: HttpClient) {
    this.prepare();
  }

  /**
   * The events of all inventories cached in memory
   */
  public static events: LocalEventStorage[];

  /**
   * Sneaky stuff
   *
   * Used to get around the "no async constructors" limitation
   */
  public ready: Promise<any>;

  /**
   * The API base url
   */
  private baseUrl: string = environment.baseUrl;

  /**
   * Loads the events from localStorage into memory
   */
  loadEvents(): void {
    // Get the events from LocalStorage
    const events = JSON.parse(window.localStorage.getItem("events"));

    // Prevent undefined array
    if (events == null) {
      EventSourcingService.events = [];
    } else {
      EventSourcingService.events = events;
    }
  }

  /**
   * Stores the events in localStorage
   */
  saveEvents(): void {
    // console.log(
    //   "The events are being saved.\n" +
    //     JSON.stringify(EventSourcingService.events)
    // );

    // Set the localStorage to the new value
    window.localStorage.setItem(
      "events",
      JSON.stringify(EventSourcingService.events)
    );
  }

  /**
   * Prepares this class for operaton and resolves the ready promise when done
   */
  private prepare() {
    // Ready declaration
    this.ready = new Promise(resolve => {
      if (
        EventSourcingService.events == null ||
        EventSourcingService.events.length === 0
      ) {
        this.reFetchAll().then(() => {
          // Mark as ready
          resolve(null);
        });
      } else {
        // Mark as ready
        resolve(null);
      }
    });
  }

  /**
   * Refreshes all Events
   */
  public async reFetchAll(): Promise<void> {
    // Reset all current event logs
    EventSourcingService.events = [];

    try {
      // Try to fetch all events from the API
      await this.fetchAllInventoryEvents();

      // Persist the data offline & refresh everything
      this.saveEvents();
    } catch (err) {
      console.error(err);
      //  Use the offline data instead of the API
      this.loadEvents();
    }
  }

  /**
   * Fetches a list of all accessible inventory uuids via the API
   *
   * Then it iterates over said list and fetches the events of each
   *
   * This method gets called in the EventSourcingService constructor
   */
  private async fetchAllInventoryEvents() {
    // Get a list of all accessible inventory uuids from the API
    const accessibleUuids = await this.api
      .get<string[]>(this.baseUrl + "/authorization/accessibleInventoryUuids")
      .toPromise();

    // Iterate over the list
    for (const inventoryUuid of accessibleUuids) {
      // Fetch the events from the inventory
      await this.fetchSingleInventoryEvents(inventoryUuid);
    }
  }

  /**
   * Fetches an inventories event-log and parses it
   *
   * @param inventoryUuid The uuid of the inventory to be fetched
   */
  private async fetchSingleInventoryEvents(
    inventoryUuid: string
  ): Promise<void> {
    let res: Event[] = await this.api
      .get<Event[]>(this.baseUrl + "/events/" + inventoryUuid)
      .toPromise();

    // Get the event log of the inventory
    let inventoryEvents = EventSourcingService.events.find(
      el => el.uuid === inventoryUuid
    );

    // Check, if the event log doesn't exist yet
    if (inventoryEvents == null) {
      // If so, create it
      const newValue = {
        uuid: inventoryUuid,
        events: []
      };
      EventSourcingService.events.push(newValue);
      inventoryEvents = newValue;
    }

    // Make sure the local events are not null
    if (window.localStorage.getItem("events") != null) {
      // Load the events of the current eventLog from LocalStorage
      const localEventLog = JSON.parse(
        window.localStorage.getItem("events")
      ).find(
        (eventLog: { uuid: string; events: Event[] }) =>
          eventLog.uuid === inventoryUuid
      );

      const localEvents = localEventLog == null ? [] : localEventLog.events;

      // Reload flag
      let needsReload = false;

      // Merge the event streams from the API with the local one
      for (const le of localEvents) {
        // Check for events missing in the API response
        if (!res.some(event => event.date === le.date)) {
          // Upload the missing event to the API
          await this.api
            .put<Event[]>(this.baseUrl + "/events/", le)
            .toPromise();

          // Set the needsReload flag
          needsReload = true;
        }
      }

      if (needsReload) {
        // Reload the events list the API to make sure they are sorted
        res = await this.api
          .get<Event[]>(this.baseUrl + "/events/" + inventoryUuid)
          .toPromise();
      }
    }

    // Write the received events in the event log of the inventory
    inventoryEvents.events = res;
  }

  /**
   * Appends an event to its corresponding event log and sends it to the API
   *
   * @param event The event to be appended to its log
   */
  public async appendEventToInventoryLog(event: Event): Promise<void> {
    try {
      // After a successful transmission, the event gets appended to the local event log
      // EventSourcingService.eventLogs[event.inventoryUuid].push(event);
      let inventoryEvents = EventSourcingService.events.find(
        el => el.uuid === event.inventoryUuid
      );

      if (inventoryEvents == null) {
        const newLog = { events: [], uuid: event.inventoryUuid };
        EventSourcingService.events.push(newLog);
        inventoryEvents = newLog;
      }

      inventoryEvents.events.push(event);

      // Persist the data offline & refresh everything
      this.saveEvents();

      // Transmit the event to the server to be stored in the db
      const res: Event[] = await this.api
        .put<Event[]>(this.baseUrl + "/events/", event)
        .toPromise();
    } catch (err) {
      console.log("Couldn't push event");

      console.error(err);
    }
  }
}

/**
 * The data format of the persistent localStorage data
 */
interface LocalEventStorage {
  /**
   * An array of event streams
   *
   * Every inventory has its own event stream to keep unauthorized users form
   * accessing data for which they do not have permissions for.
   *
   * This works since the user-access-separation is made on the inventory level
   */
  // inventories: {

  /**
   * The uuid of the inventory this event stream is dedicated to
   */
  uuid: string;

  /**
   * The event log of the inventory
   */
  events: Event[];

  // }[];
}

/**
 * The data structure of an event
 */
export interface Event {
  /**,
   * The date of the event
   */
  date: Date;

  /**
   * The uuid of the inventory-event-stream this event belongs to
   */
  inventoryUuid: string;

  /**
   * The data of the event
   */
  data: {
    /**
     * The uuid of the item this event is about
     * This information is redundant (but still required) on inventory events
     */
    uuid: string;

    /**
     * The uuid of the user who issued this event
     */
    userUuid: string;

    /**
     * Defines what type of item is this event about
     */
    itemType: itemType;

    /**
     * Defines what type of operation was performed
     */
    crudType: crudType;

    /**
     * The inventory-specific data (if this event is about an inventory)
     */
    inventoryData?: {
      /**
       * The name of this inventory
       */
      name?: string;

      /**
       * The uuid of the owner of this inventory
       */
      ownerUuid?: string;

      /**
       * An array of users who have the admin privilege for this inventory
       */
      adminsUuids?: string[];

      /**
       * An array of user uuids who have the write privilege for this inventory
       */
      WritablesUuids?: string[];

      /**
       * An array of user uuids who have the read privilege for this inventory
       */
      readablesUuids?: string[];

      /**
       * The date of the creation of this inventory
       *
       * This field may only be set in an inventory-created event
       */
      createdOn?: Date;
    };
    /**
     * The category-specific data (if this event is about a category)
     */
    categoryData?: {
      /**
       * The name of the category
       */
      name?: string;

      /**
       * The parent-category of this category
       *
       * Top-level categories are their own parent
       */
      parentUuid?: string;

      /**
       * The date of the creation of this category
       *
       * This field may only be set in an category-created event
       */
      createdOn?: Date;
    };

    /**
     * The thing-specific data (if this event is about a thing)
     */
    thingData?: {
      /**
       * The name of the thing
       */
      name?: string;

      /**
       * The date of the creation of this category
       *
       * This field may only be set in an category-created event
       */
      createdOn?: Date;

      /**
       * The UUIDs of the categories this thing has
       */
      categoryUuids?: string[];
    };

    /**
     * The stock-specific data (if this event is about a stock)
     */
    stockData?: {
      /**
       * The expiration date of the stock
       */
      exDate?: Date;

      /**
       * How many days after the opening of this stock is it still usable?
       */
      useUpIn?: number;

      /**
       * Text description of the quantity of the stock
       */
      quantity?: string;

      /**
       * When was this stock opened?
       */
      openedOn: Date;

      /**
       * The date of the creation of this category
       *
       * This field may only be set in an category-created event
       */
      createdOn?: Date;

      /**
       * The UUID of the Thing this stock belongs to
       */
      thingUuid: string;

      /**
       * The percentage of how much of the Stock is left
       */
      percentLeft: number;
    };
  };
}

/**
 * Used to describe on which type of item an operation was performed on
 */
export enum itemType {
  INVENTORY = "inventory",
  CATEGORY = "category",
  THING = "thing",
  STOCK = "stock"
}

/**
 * Used to describe which type of operation was performed
 *
 * (read is excluded from this list since it doesn't affect the data)
 */
export enum crudType {
  CREATE = "create",
  UPDATE = "update",
  DELETE = "delete"
}