Skip to content

Commit

Permalink
Merge remote-tracking branch 'origin/dev' into feature/398-hospital-s…
Browse files Browse the repository at this point in the history
…tatistics
  • Loading branch information
ClFeSc committed May 30, 2022
2 parents 7d06f0c + 97935a3 commit 0310226
Show file tree
Hide file tree
Showing 66 changed files with 2,529 additions and 436 deletions.
69 changes: 66 additions & 3 deletions backend/src/database/state-migrations/migrations.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,79 @@
import type { UUID } from 'digital-fuesim-manv-shared';
import type { EntityManager } from 'typeorm';
import type { ExerciseWrapper } from '../../exercise/exercise-wrapper';
import { RestoreError } from '../../utils/restore-error';

/**
* Such a function MUST update the initial state of the exercise with the provided {@link exerciseId} as well as every action associated with it from its current state version to the next version in a way that they are valid states/actions.
* It MAY throw a {@link RestoreError} in a case where upgrading is impossible and a terminal incompatibility with older exercises is necessary.
* It MUST update the respective updates to the exercise and its associated objects in the database.
* All database interaction MUST use the provided {@link EntityManager}.
*/
export type MigrationFunction = (exerciseId: UUID) => Promise<void>;
export type DbMigrationFunction = (
entityManager: EntityManager,
exerciseId: UUID
) => Promise<void>;

/**
* Such a function MUST update the initial state of the provided {@link exerciseWrapper} as well as every action associated with it from its current state version to the next version in a way that they are valid states/actions.
* It MAY throw a {@link RestoreError} in a case where upgrading is impossible and a terminal incompatibility with older exercises is necessary.
* It MUST NOT use the database.
*/
export type InMemoryMigrationFunction = (
exerciseWrapper: ExerciseWrapper
) => Promise<ExerciseWrapper>;

export interface MigrationFunctions {
database: DbMigrationFunction;
inMemory: InMemoryMigrationFunction;
}

// TODO: It'd probably be better not to export this
/**
* This object MUST provide entries for every positive integer greater than 1 and less than or equal to ExerciseState.currentStateVersion.
* A function with key `k` MUST be able to transform a valid exercise of state version `k-1` to a valid exercise of state version `k`.
*/
export const migrations: {
[key: number]: MigrationFunction;
} = {};
[key: number]: MigrationFunctions;
} = {
2: {
database: (_entityManager: EntityManager, exerciseId: UUID) => {
throw new RestoreError('The migration is not possible', exerciseId);
},
inMemory: (exerciseWrapper: ExerciseWrapper) => {
throw new RestoreError(
'The migration is not possible',
exerciseWrapper.id ?? 'unknown id'
);
},
},
};

export async function migrateInDatabaseTo(
targetStateVersion: number,
currentStateVersion: number,
exerciseId: UUID,
entityManager: EntityManager
): Promise<void> {
let currentVersion = currentStateVersion;
while (++currentVersion <= targetStateVersion) {
// eslint-disable-next-line no-await-in-loop
await migrations[currentVersion].database(entityManager, exerciseId);
}
}

export async function migrateInMemoryTo(
targetStateVersion: number,
currentStateVersion: number,
exercise: ExerciseWrapper
): Promise<ExerciseWrapper> {
let currentVersion = currentStateVersion;
let currentExercise = exercise;
while (++currentVersion <= targetStateVersion) {
// eslint-disable-next-line no-await-in-loop
currentExercise = await migrations[currentVersion].inMemory(
currentExercise
);
}
return currentExercise;
}
132 changes: 117 additions & 15 deletions backend/src/exercise/exercise-wrapper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import type {
ExerciseTimeline,
Role,
UUID,
StateExport,
ExerciseIds,
} from 'digital-fuesim-manv-shared';
import {
ExerciseState,
Expand All @@ -18,9 +20,13 @@ import { ExerciseWrapperEntity } from '../database/entities/exercise-wrapper.ent
import { NormalType } from '../database/normal-type';
import type { DatabaseService } from '../database/services/database-service';
import { Config } from '../config';
import { migrations } from '../database/state-migrations/migrations';
import { RestoreError } from '../utils/restore-error';
import { UserReadableIdGenerator } from '../utils/user-readable-id-generator';
import type { ActionWrapperEntity } from '../database/entities/action-wrapper.entity';
import {
migrateInDatabaseTo,
migrateInMemoryTo,
} from '../database/state-migrations/migrations';
import { ActionWrapper } from './action-wrapper';
import type { ClientWrapper } from './client-wrapper';
import { exerciseMap } from './exercise-map';
Expand Down Expand Up @@ -54,6 +60,23 @@ export class ExerciseWrapper extends NormalType<
public markAsModified() {
this._changedSinceSave = true;
}

async saveActions(
entityManager: EntityManager,
exerciseEntity: ExerciseWrapperEntity
): Promise<ActionWrapperEntity[]> {
const entities = await Promise.all(
this.temporaryActionHistory.map(async (action) =>
action.asEntity(true, entityManager, exerciseEntity)
)
);
this.temporaryActionHistory.splice(
0,
this.temporaryActionHistory.length
);
return entities;
}

async asEntity(
save: boolean,
entityManager?: EntityManager
Expand Down Expand Up @@ -97,6 +120,7 @@ export class ExerciseWrapper extends NormalType<
getDto(entity)
)(manager);
this.id = savedEntity.id;
await this.saveActions(manager, savedEntity);
this.markAsSaved();
return savedEntity;
} else if (save && !existed) {
Expand All @@ -108,6 +132,7 @@ export class ExerciseWrapper extends NormalType<
getDto(entity)
)(manager);
this.id = savedEntity.id;
await this.saveActions(manager, savedEntity);
this.markAsSaved();
return savedEntity;
} else if (!save && existed) {
Expand Down Expand Up @@ -224,6 +249,81 @@ export class ExerciseWrapper extends NormalType<
super(databaseService);
}

/**
* @param file A **valid** import file
*/
static async importFromFile(
databaseService: DatabaseService,
file: StateExport,
exerciseIds: ExerciseIds
): Promise<ExerciseWrapper> {
const importOperations = async (manager: EntityManager | undefined) => {
let exercise = new ExerciseWrapper(
exerciseIds.participantId,
exerciseIds.trainerId,
[],
databaseService,
ExerciseState.currentStateVersion,
file.history?.initialState ?? file.currentState,
file.currentState
);
const actions = (file.history?.actionHistory ?? []).map(
(action) =>
new ActionWrapper(
databaseService,
action,
exercise.emitterId,
exercise
)
);
exercise.temporaryActionHistory.push(...actions);
if (manager === undefined) {
// eslint-disable-next-line require-atomic-updates
exercise = await migrateInMemoryTo(
ExerciseState.currentStateVersion,
exercise.stateVersion,
exercise
);
} else {
const exerciseEntity = await exercise.save(manager);
await migrateInDatabaseTo(
ExerciseState.currentStateVersion,
exercise.stateVersion,
exerciseEntity.id,
manager
);
// eslint-disable-next-line require-atomic-updates
exercise = ExerciseWrapper.createFromEntity(
await databaseService.exerciseWrapperService.getFindById(
exerciseEntity.id
)(manager),
databaseService
);
// Reset actions to apply them (they are removed when saving the entity to the database)
exercise.temporaryActionHistory.push(...actions);
}
exercise.restore();
exercise.applyAction(
{
type: '[Exercise] Set Participant Id',
participantId: exerciseIds.participantId,
},
exercise.emitterId,
undefined
);
exercise.tickCounter = actions.filter(
(action) => action.action.type === '[Exercise] Tick'
).length;
if (manager !== undefined) {
await exercise.save(manager);
}
return exercise;
};
return Config.useDb
? databaseService.transaction(importOperations)
: importOperations(undefined);
}

static create(
participantId: string,
trainerId: string,
Expand All @@ -250,18 +350,18 @@ export class ExerciseWrapper extends NormalType<
return exercise;
}

private async restore(): Promise<void> {
private restore(): void {
if (this.stateVersion !== ExerciseState.currentStateVersion) {
throw new RestoreError(
`The exercise was created with an incompatible version of the state (got version ${this.stateVersion}, required version ${ExerciseState.currentStateVersion})`,
this.id!
);
}
this.validateInitialState();
await this.restoreState();
this.restoreState();
}

private async restoreState() {
private restoreState() {
this.currentState = this.initialState;
this.temporaryActionHistory.forEach((action) => {
this.validateAction(action.action);
Expand All @@ -271,11 +371,13 @@ export class ExerciseWrapper extends NormalType<
this.incrementIdGenerator.setCurrent(
this.temporaryActionHistory.length
);
// Remove all actions to not save them again
this.temporaryActionHistory.splice(
0,
this.temporaryActionHistory.length
);
// Remove all actions to not save them again (if database is active)
if (Config.useDb) {
this.temporaryActionHistory.splice(
0,
this.temporaryActionHistory.length
);
}
// Pause exercise
if (this.currentState.statusHistory.at(-1)?.status === 'running')
this.reduce(
Expand All @@ -286,7 +388,7 @@ export class ExerciseWrapper extends NormalType<
this.emitterId
);
// Remove all clients from state
Object.values(this.currentState.clients).forEach(async (client) => {
Object.values(this.currentState.clients).forEach((client) => {
const removeClientAction: ExerciseAction = {
type: '[Client] Remove client',
clientId: client.id,
Expand All @@ -308,11 +410,11 @@ export class ExerciseWrapper extends NormalType<
},
})(manager);
outdatedExercises.forEach(async (exercise) => {
do {
// eslint-disable-next-line no-await-in-loop
await migrations[++exercise.stateVersion](exercise.id);
} while (
exercise.stateVersion !== ExerciseState.currentStateVersion
await migrateInDatabaseTo(
ExerciseState.currentStateVersion,
exercise.stateVersion,
exercise.id,
manager
);
});

Expand Down
52 changes: 33 additions & 19 deletions backend/src/exercise/http-handler/api/exercise.ts
Original file line number Diff line number Diff line change
@@ -1,35 +1,49 @@
import type { ExerciseIds, ExerciseTimeline } from 'digital-fuesim-manv-shared';
import type {
ExerciseIds,
ExerciseTimeline,
StateExport,
} from 'digital-fuesim-manv-shared';
import { ExerciseState } from 'digital-fuesim-manv-shared';
import { isEmpty } from 'lodash-es';
import { importExercise } from '../../../utils/import-exercise';
import type { DatabaseService } from '../../../database/services/database-service';
import { UserReadableIdGenerator } from '../../../utils/user-readable-id-generator';
import { exerciseMap } from '../../exercise-map';
import { ExerciseWrapper } from '../../exercise-wrapper';
import type { HttpResponse } from '../utils';

export function postExercise(
databaseService: DatabaseService
): HttpResponse<ExerciseIds> {
let newParticipantId: string | undefined;
let newTrainerId: string | undefined;
export async function postExercise(
databaseService: DatabaseService,
importObject: StateExport
): Promise<HttpResponse<ExerciseIds>> {
try {
newParticipantId = UserReadableIdGenerator.generateId();
newTrainerId = UserReadableIdGenerator.generateId(8);
const newExercise = ExerciseWrapper.create(
newParticipantId,
newTrainerId,
databaseService,
ExerciseState.create()
);
exerciseMap.set(newParticipantId, newExercise);
exerciseMap.set(newTrainerId, newExercise);
const participantId = UserReadableIdGenerator.generateId();
const trainerId = UserReadableIdGenerator.generateId(8);
const newExerciseOrError = isEmpty(importObject)
? ExerciseWrapper.create(
participantId,
trainerId,
databaseService,
ExerciseState.create()
)
: await importExercise(
importObject,
{ participantId, trainerId },
databaseService
);
if (!(newExerciseOrError instanceof ExerciseWrapper)) {
return newExerciseOrError;
}
exerciseMap.set(participantId, newExerciseOrError);
exerciseMap.set(trainerId, newExerciseOrError);
return {
statusCode: 201,
body: {
participantId: newParticipantId,
trainerId: newTrainerId,
participantId,
trainerId,
},
};
} catch (error: any) {
} catch (error: unknown) {
if (error instanceof RangeError) {
return {
statusCode: 503,
Expand Down
7 changes: 5 additions & 2 deletions backend/src/exercise/http-server.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { Server as HttpServer } from 'node:http';
import cors from 'cors';
import type * as core from 'express-serve-static-core';
import express from 'express';
import type { DatabaseService } from '../database/services/database-service';
import {
deleteExercise,
Expand All @@ -20,6 +21,8 @@ export class ExerciseHttpServer {
// TODO: Temporary allow all
app.use(cors());

app.use(express.json());

// This endpoint is used to determine whether the API itself is running.
// It should be independent from any other services that may or may not be running.
// This is used for the Cypress CI.
Expand All @@ -29,8 +32,8 @@ export class ExerciseHttpServer {
res.send(response.body);
});

app.post('/api/exercise', (req, res) => {
const response = postExercise(databaseService);
app.post('/api/exercise', async (req, res) => {
const response = await postExercise(databaseService, req.body);
res.statusCode = response.statusCode;
res.send(response.body);
});
Expand Down
Loading

0 comments on commit 0310226

Please sign in to comment.