diff --git a/backend/src/database/state-migrations/migrations.ts b/backend/src/database/state-migrations/migrations.ts index 2e42610a7..fed87d1fa 100644 --- a/backend/src/database/state-migrations/migrations.ts +++ b/backend/src/database/state-migrations/migrations.ts @@ -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; +export type DbMigrationFunction = ( + entityManager: EntityManager, + exerciseId: UUID +) => Promise; +/** + * 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; + +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 { + 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 { + 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; +} diff --git a/backend/src/exercise/exercise-wrapper.ts b/backend/src/exercise/exercise-wrapper.ts index a68627c57..7e32ed7f5 100644 --- a/backend/src/exercise/exercise-wrapper.ts +++ b/backend/src/exercise/exercise-wrapper.ts @@ -3,6 +3,8 @@ import type { ExerciseTimeline, Role, UUID, + StateExport, + ExerciseIds, } from 'digital-fuesim-manv-shared'; import { ExerciseState, @@ -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'; @@ -54,6 +60,23 @@ export class ExerciseWrapper extends NormalType< public markAsModified() { this._changedSinceSave = true; } + + async saveActions( + entityManager: EntityManager, + exerciseEntity: ExerciseWrapperEntity + ): Promise { + 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 @@ -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) { @@ -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) { @@ -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 { + 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, @@ -250,7 +350,7 @@ export class ExerciseWrapper extends NormalType< return exercise; } - private async restore(): Promise { + 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})`, @@ -258,10 +358,10 @@ export class ExerciseWrapper extends NormalType< ); } this.validateInitialState(); - await this.restoreState(); + this.restoreState(); } - private async restoreState() { + private restoreState() { this.currentState = this.initialState; this.temporaryActionHistory.forEach((action) => { this.validateAction(action.action); @@ -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( @@ -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, @@ -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 ); }); diff --git a/backend/src/exercise/http-handler/api/exercise.ts b/backend/src/exercise/http-handler/api/exercise.ts index 83fec4eb9..a86c47daa 100644 --- a/backend/src/exercise/http-handler/api/exercise.ts +++ b/backend/src/exercise/http-handler/api/exercise.ts @@ -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 { - let newParticipantId: string | undefined; - let newTrainerId: string | undefined; +export async function postExercise( + databaseService: DatabaseService, + importObject: StateExport +): Promise> { 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, diff --git a/backend/src/exercise/http-server.ts b/backend/src/exercise/http-server.ts index 3bcfa9f88..3d35fe9bd 100644 --- a/backend/src/exercise/http-server.ts +++ b/backend/src/exercise/http-server.ts @@ -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, @@ -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. @@ -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); }); diff --git a/backend/src/exercise/patient-ticking.ts b/backend/src/exercise/patient-ticking.ts index f3dbea849..ab85f1eec 100644 --- a/backend/src/exercise/patient-ticking.ts +++ b/backend/src/exercise/patient-ticking.ts @@ -29,6 +29,10 @@ interface PatientTickResult { * The new state time of the patient */ nextStateTime: number; + /** + * The time a patient was treated overall + */ + treatmentTime: number; } /** @@ -46,6 +50,10 @@ export function patientTick( // Only look at patients that are alive and have a position, i.e. are not in a vehicle .filter((patient) => isAlive(patient.health) && patient.position) .map((patient) => { + // update the time a patient is being treated, to check for pretriage later + const treatmentTime = patient.isBeingTreated + ? patient.treatmentTime + patientTickInterval + : patient.treatmentTime; const nextHealthPoints = getNextPatientHealthPoints( patient, getDedicatedResources(state, patient), @@ -62,6 +70,7 @@ export function patientTick( nextHealthPoints, nextStateId, nextStateTime, + treatmentTime, }; }) ); diff --git a/backend/src/utils/import-exercise.ts b/backend/src/utils/import-exercise.ts new file mode 100644 index 000000000..7fe5b9e08 --- /dev/null +++ b/backend/src/utils/import-exercise.ts @@ -0,0 +1,59 @@ +import { plainToInstance } from 'class-transformer'; +import type { DatabaseService } from 'database/services/database-service'; +import type { ExerciseIds } from 'digital-fuesim-manv-shared'; +import { + ReducerError, + StateExport, + validateExerciseExport, +} from 'digital-fuesim-manv-shared'; +import { ExerciseWrapper } from '../exercise/exercise-wrapper'; +import type { HttpResponse } from '../exercise/http-handler/utils'; + +export async function importExercise( + importObject: StateExport, + ids: ExerciseIds, + databaseService: DatabaseService +): Promise> { + // console.log( + // inspect(importObject.history, { depth: 2, colors: true }) + // ); + const importInstance = plainToInstance( + StateExport, + importObject + // TODO: verify that this is indeed not required + // // Workaround for https://github.com/typestack/class-transformer/issues/876 + // { enableImplicitConversion: true } + ); + // console.log( + // inspect(importInstance.history, { depth: 2, colors: true }) + // ); + const validationErrors = validateExerciseExport(importObject); + if (validationErrors.length > 0) { + return { + statusCode: 400, + body: { + message: `The validation of the import failed: ${validationErrors}`, + }, + }; + } + try { + return await ExerciseWrapper.importFromFile( + databaseService, + importInstance, + { + participantId: ids.participantId, + trainerId: ids.trainerId, + } + ); + } catch (e: unknown) { + if (e instanceof ReducerError) { + return { + statusCode: 400, + body: { + message: `Error importing exercise: ${e.message}`, + }, + }; + } + throw e; + } +} diff --git a/docker/nginx/default-conf b/docker/nginx/default-conf index 269635fb2..ffc357d11 100644 --- a/docker/nginx/default-conf +++ b/docker/nginx/default-conf @@ -13,8 +13,13 @@ server { location /api { proxy_pass http://localhost:3201; proxy_http_version 1.1; - proxy_set_header Host $http_host; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + } # Websocket @@ -23,7 +28,10 @@ server { proxy_set_header Connection "Upgrade"; proxy_pass http://localhost:3200; proxy_http_version 1.1; - proxy_set_header Host $http_host; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; } } diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 5057f5003..2413e9d4b 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -22,6 +22,7 @@ "bootstrap": "^5.1.3", "bootstrap-icons": "^1.8.1", "chart.js": "^3.7.1", + "class-transformer": "^0.5.1", "class-validator": "^0.13.2", "digital-fuesim-manv-shared": "file:../shared", "immer": "^9.0.12", @@ -6595,6 +6596,11 @@ "integrity": "sha512-cOU9usZw8/dXIXKtwa8pM0OTJQuJkxMN6w30csNRUerHfeQ5R6U3kkU/FtJeIf3M202OHfY2U8ccInBG7/xogA==", "dev": true }, + "node_modules/class-transformer": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/class-transformer/-/class-transformer-0.5.1.tgz", + "integrity": "sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==" + }, "node_modules/class-validator": { "version": "0.13.2", "resolved": "https://registry.npmjs.org/class-validator/-/class-validator-0.13.2.tgz", @@ -24015,6 +24021,11 @@ "integrity": "sha512-cOU9usZw8/dXIXKtwa8pM0OTJQuJkxMN6w30csNRUerHfeQ5R6U3kkU/FtJeIf3M202OHfY2U8ccInBG7/xogA==", "dev": true }, + "class-transformer": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/class-transformer/-/class-transformer-0.5.1.tgz", + "integrity": "sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==" + }, "class-validator": { "version": "0.13.2", "resolved": "https://registry.npmjs.org/class-validator/-/class-validator-0.13.2.tgz", diff --git a/frontend/package.json b/frontend/package.json index 3a32e84a1..62daef659 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -38,6 +38,7 @@ "bootstrap": "^5.1.3", "bootstrap-icons": "^1.8.1", "chart.js": "^3.7.1", + "class-transformer": "^0.5.1", "class-validator": "^0.13.2", "digital-fuesim-manv-shared": "file:../shared", "immer": "^9.0.12", diff --git a/frontend/src/app/core/api.service.ts b/frontend/src/app/core/api.service.ts index afa797498..93aba49d4 100644 --- a/frontend/src/app/core/api.service.ts +++ b/frontend/src/app/core/api.service.ts @@ -7,6 +7,7 @@ import type { ExerciseIds, ExerciseState, ExerciseTimeline, + StateExport, } from 'digital-fuesim-manv-shared'; import { reduceExerciseState } from 'digital-fuesim-manv-shared'; import type { Observable } from 'rxjs'; @@ -197,6 +198,23 @@ export class ApiService { ); } + public async importExercise(exportedState: StateExport) { + return lastValueFrom( + this.httpClient.post( + `${httpOrigin}/api/exercise`, + exportedState + ) + ); + } + + public async exerciseHistory() { + return lastValueFrom( + this.httpClient.get( + `${httpOrigin}/api/exercise/${this.exerciseId}/history` + ) + ); + } + public async deleteExercise(trainerId: string) { return lastValueFrom( this.httpClient.delete( diff --git a/frontend/src/app/pages/exercises/exercise/exercise.module.ts b/frontend/src/app/pages/exercises/exercise/exercise.module.ts index 2c44bd81f..f7fb22d45 100644 --- a/frontend/src/app/pages/exercises/exercise/exercise.module.ts +++ b/frontend/src/app/pages/exercises/exercise/exercise.module.ts @@ -1,24 +1,25 @@ -import { NgModule } from '@angular/core'; import { CommonModule } from '@angular/common'; -import { FormsModule } from '@angular/forms'; import { HttpClientModule } from '@angular/common/http'; +import { NgModule } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap'; import { SharedModule } from 'src/app/shared/shared.module'; import { ExerciseComponent } from './exercise/exercise.component'; -import { ClientOverviewModule } from './shared/client-overview/client-overview.module'; -import { ExerciseMapModule } from './shared/exercise-map/exercise-map.module'; -import { TrainerMapEditorComponent } from './shared/trainer-map-editor/trainer-map-editor.component'; -import { TrainerToolbarComponent } from './shared/trainer-toolbar/trainer-toolbar.component'; -import { ExerciseStateBadgeComponent } from './shared/exercise-state-badge/exercise-state-badge.component'; -import { TransferOverviewModule } from './shared/transfer-overview/transfer-overview.module'; -import { ExerciseSettingsModalComponent } from './shared/exercise-settings/exercise-settings-modal/exercise-settings-modal.component'; -import { TimeTravelComponent } from './shared/time-travel/time-travel.component'; import { AlarmGroupOverviewModule } from './shared/alarm-group-overview/alarm-group-overview.module'; -import { EmergencyOperationsCenterModule } from './shared/emergency-operations-center/emergency-operations-center.module'; -import { ExerciseStatisticsModule } from './shared/exercise-statistics/exercise-statistics.module'; +import { ClientOverviewModule } from './shared/client-overview/client-overview.module'; import { CreateImageTemplateModalComponent } from './shared/editor-panel/create-image-template-modal/create-image-template-modal.component'; import { EditImageTemplateModalComponent } from './shared/editor-panel/edit-image-template-modal/edit-image-template-modal.component'; import { ImageTemplateFormComponent } from './shared/editor-panel/image-template-form/image-template-form.component'; +import { EmergencyOperationsCenterModule } from './shared/emergency-operations-center/emergency-operations-center.module'; +import { ExerciseMapModule } from './shared/exercise-map/exercise-map.module'; +import { ExerciseSettingsModalComponent } from './shared/exercise-settings/exercise-settings-modal/exercise-settings-modal.component'; +import { ExerciseStateBadgeComponent } from './shared/exercise-state-badge/exercise-state-badge.component'; +import { ExerciseStatisticsModule } from './shared/exercise-statistics/exercise-statistics.module'; import { HospitalEditorModule } from './shared/hospital-editor/hospital-editor.module'; +import { TimeTravelComponent } from './shared/time-travel/time-travel.component'; +import { TrainerMapEditorComponent } from './shared/trainer-map-editor/trainer-map-editor.component'; +import { TrainerToolbarComponent } from './shared/trainer-toolbar/trainer-toolbar.component'; +import { TransferOverviewModule } from './shared/transfer-overview/transfer-overview.module'; @NgModule({ declarations: [ @@ -35,6 +36,7 @@ import { HospitalEditorModule } from './shared/hospital-editor/hospital-editor.m imports: [ CommonModule, SharedModule, + NgbDropdownModule, FormsModule, HttpClientModule, ClientOverviewModule, diff --git a/frontend/src/app/pages/exercises/exercise/exercise/exercise.component.html b/frontend/src/app/pages/exercises/exercise/exercise/exercise.component.html index 9a38e95f6..18f2899db 100644 --- a/frontend/src/app/pages/exercises/exercise/exercise/exercise.component.html +++ b/frontend/src/app/pages/exercises/exercise/exercise/exercise.component.html @@ -37,6 +37,39 @@

Aufzeichnung verlassen +
+ + +
+ + +
+

diff --git a/frontend/src/app/pages/exercises/exercise/exercise/exercise.component.ts b/frontend/src/app/pages/exercises/exercise/exercise/exercise.component.ts index 81ba2351f..eaf7eca94 100644 --- a/frontend/src/app/pages/exercises/exercise/exercise/exercise.component.ts +++ b/frontend/src/app/pages/exercises/exercise/exercise/exercise.component.ts @@ -1,11 +1,14 @@ import type { OnDestroy } from '@angular/core'; import { Component } from '@angular/core'; import { Store } from '@ngrx/store'; +import { StateExport, StateHistoryCompound } from 'digital-fuesim-manv-shared'; import { Subject } from 'rxjs'; import { ApiService } from 'src/app/core/api.service'; import { MessageService } from 'src/app/core/messages/message.service'; +import { saveBlob } from 'src/app/shared/functions/save-blob'; import type { AppState } from 'src/app/state/app.state'; import { selectParticipantId } from 'src/app/state/exercise/exercise.selectors'; +import { getStateSnapshot } from 'src/app/state/get-state-snapshot'; @Component({ selector: 'app-exercise', @@ -54,6 +57,31 @@ export class ExerciseComponent implements OnDestroy { }); } + public async exportExerciseWithHistory() { + const history = await this.apiService.exerciseHistory(); + const currentState = getStateSnapshot(this.store).exercise; + const blob = new Blob([ + JSON.stringify( + new StateExport( + currentState, + new StateHistoryCompound( + history.actionsWrappers.map( + (actionWrapper) => actionWrapper.action + ), + history.initialState + ) + ) + ), + ]); + saveBlob(blob, `exercise-state-${currentState.participantId}.json`); + } + + public exportExerciseState() { + const currentState = getStateSnapshot(this.store).exercise; + const blob = new Blob([JSON.stringify(new StateExport(currentState))]); + saveBlob(blob, `exercise-state-${currentState.participantId}.json`); + } + ngOnDestroy(): void { this.destroy.next(); } diff --git a/frontend/src/app/pages/exercises/exercise/shared/core/drag-element.service.ts b/frontend/src/app/pages/exercises/exercise/shared/core/drag-element.service.ts index ec7ad15b8..d8f0ad575 100644 --- a/frontend/src/app/pages/exercises/exercise/shared/core/drag-element.service.ts +++ b/frontend/src/app/pages/exercises/exercise/shared/core/drag-element.service.ts @@ -14,6 +14,7 @@ import { PatientTemplate, MapImage, } from 'digital-fuesim-manv-shared'; +import type { PatientCategory } from 'digital-fuesim-manv-shared/dist/models/patient-category'; @Injectable({ providedIn: 'root', @@ -141,7 +142,14 @@ export class DragElementService { case 'patient': { const patient = PatientTemplate.generatePatient( - this.transferringTemplate.template + this.transferringTemplate.template.patientTemplates[ + Math.floor( + Math.random() * + this.transferringTemplate.template + .patientTemplates.length + ) + ], + this.transferringTemplate.template.name ); this.apiService.proposeAction( { @@ -232,7 +240,7 @@ type TransferTemplate = } | { type: 'patient'; - template: PatientTemplate; + template: PatientCategory; } | { type: 'transferPoint'; diff --git a/frontend/src/app/pages/exercises/exercise/shared/exercise-map/shared/patient-popup/patient-popup.component.html b/frontend/src/app/pages/exercises/exercise/shared/exercise-map/shared/patient-popup/patient-popup.component.html index 52070fac3..6fd203922 100644 --- a/frontend/src/app/pages/exercises/exercise/shared/exercise-map/shared/patient-popup/patient-popup.component.html +++ b/frontend/src/app/pages/exercises/exercise/shared/exercise-map/shared/patient-popup/patient-popup.component.html @@ -2,18 +2,21 @@
{{ patient.personalInformation.name }} {{ (apiService.currentRole$ | async) !== 'participant' ? (patient.health / healthPointsDefaults.max | percent) - : statusNames[patient.visibleStatus || 'white'] + : statusNames[displayedStatus] }}