From e1b8ae2e28306472cc1c0d5803e426207b9b00ab Mon Sep 17 00:00:00 2001 From: jurei733 <67505990+jurei733@users.noreply.github.com> Date: Thu, 3 Jul 2025 12:42:59 +0200 Subject: [PATCH 01/36] Add ts-node to run tests --- package-lock.json | 150 ++++++++++++++++++++++++++++++++++++++++++++-- package.json | 1 + 2 files changed, 145 insertions(+), 6 deletions(-) diff --git a/package-lock.json b/package-lock.json index 4afdefb32..3fd19d3e3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -95,6 +95,7 @@ "nx": "21.2.0", "prisma": "^5.10.2", "ts-jest": "29.1.1", + "ts-node": "^10.9.2", "typescript": "5.8.3" } }, @@ -3553,6 +3554,30 @@ "node": ">=0.1.90" } }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@cspotcode/source-map-support/node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, "node_modules/@discoveryjs/json-ext": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/@discoveryjs/json-ext/-/json-ext-0.6.3.tgz", @@ -5046,7 +5071,7 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "dev": true, + "devOptional": true, "engines": { "node": ">=6.0.0" } @@ -5075,7 +5100,7 @@ "version": "1.5.0", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { @@ -10215,6 +10240,34 @@ "node": ">=10.13.0" } }, + "node_modules/@tsconfig/node10": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.11.tgz", + "integrity": "sha512-DcRjDCujK/kCk/cUe8Xz8ZSpm8mS3mNNpta+jGCA6USEDfktlNvm1+IuZ9eTcDbNk41BHwpHHeW+N1lKCz4zOw==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node12": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", + "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node14": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", + "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node16": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz", + "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", + "devOptional": true, + "license": "MIT" + }, "node_modules/@tufjs/canonical-json": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@tufjs/canonical-json/-/canonical-json-2.0.0.tgz", @@ -11210,7 +11263,7 @@ "version": "8.14.1", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.1.tgz", "integrity": "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==", - "dev": true, + "devOptional": true, "license": "MIT", "bin": { "acorn": "bin/acorn" @@ -11242,7 +11295,7 @@ "version": "8.3.2", "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.2.tgz", "integrity": "sha512-cjkyv4OtNCIeqhHrfS81QWXoCBPExR/J62oyEqepVw8WaQeSqpW2uhuLPh1m9eWhDuOo/jUXVTlifvesOWp/4A==", - "dev": true, + "devOptional": true, "engines": { "node": ">=0.4.0" } @@ -11447,6 +11500,13 @@ "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz", "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==" }, + "node_modules/arg": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", + "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", + "devOptional": true, + "license": "MIT" + }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -13380,6 +13440,13 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/create-require": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", + "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", + "devOptional": true, + "license": "MIT" + }, "node_modules/cron-parser": { "version": "4.9.0", "resolved": "https://registry.npmjs.org/cron-parser/-/cron-parser-4.9.0.tgz", @@ -14013,6 +14080,16 @@ "integrity": "sha512-uJaamHkagcZtHPqCIHZxnFrXlunQXgBOsZSUOWwFw31QJCAbyTBoHMW75YOTur5ZNx8pIeAKgf6GWIgaqqiLhA==", "dev": true }, + "node_modules/diff": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", + "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==", + "devOptional": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, "node_modules/diff-sequences": { "version": "29.6.3", "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz", @@ -20241,7 +20318,7 @@ "version": "1.3.6", "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", - "dev": true + "devOptional": true }, "node_modules/make-fetch-happen": { "version": "14.0.3", @@ -26394,6 +26471,50 @@ "webpack": "^5.0.0" } }, + "node_modules/ts-node": { + "version": "10.9.2", + "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", + "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@cspotcode/source-map-support": "^0.8.0", + "@tsconfig/node10": "^1.0.7", + "@tsconfig/node12": "^1.0.7", + "@tsconfig/node14": "^1.0.0", + "@tsconfig/node16": "^1.0.2", + "acorn": "^8.4.1", + "acorn-walk": "^8.1.1", + "arg": "^4.1.0", + "create-require": "^1.1.0", + "diff": "^4.0.1", + "make-error": "^1.1.1", + "v8-compile-cache-lib": "^3.0.1", + "yn": "3.1.1" + }, + "bin": { + "ts-node": "dist/bin.js", + "ts-node-cwd": "dist/bin-cwd.js", + "ts-node-esm": "dist/bin-esm.js", + "ts-node-script": "dist/bin-script.js", + "ts-node-transpile-only": "dist/bin-transpile.js", + "ts-script": "dist/bin-script-deprecated.js" + }, + "peerDependencies": { + "@swc/core": ">=1.2.50", + "@swc/wasm": ">=1.2.50", + "@types/node": "*", + "typescript": ">=2.7" + }, + "peerDependenciesMeta": { + "@swc/core": { + "optional": true + }, + "@swc/wasm": { + "optional": true + } + } + }, "node_modules/tsconfig-paths": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-4.2.0.tgz", @@ -26819,7 +26940,7 @@ "version": "5.8.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -27096,6 +27217,13 @@ "uuid": "dist/bin/uuid" } }, + "node_modules/v8-compile-cache-lib": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", + "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", + "devOptional": true, + "license": "MIT" + }, "node_modules/v8-to-istanbul": { "version": "9.2.0", "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.2.0.tgz", @@ -27971,6 +28099,16 @@ "node": ">= 4.0.0" } }, + "node_modules/yn": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", + "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/package.json b/package.json index 9a3610680..47ea29532 100644 --- a/package.json +++ b/package.json @@ -106,6 +106,7 @@ "nx": "21.2.0", "prisma": "^5.10.2", "ts-jest": "29.1.1", + "ts-node": "^10.9.2", "typescript": "5.8.3" }, "eslintConfig": { From 8febdd0f3515719bb66ddcd5142bf0da8b6de370 Mon Sep 17 00:00:00 2001 From: jurei733 <67505990+jurei733@users.noreply.github.com> Date: Thu, 3 Jul 2025 12:43:14 +0200 Subject: [PATCH 02/36] Fix frontend tests --- .../src/app/admin/workspace/workspace.controller.spec.ts | 5 +++++ apps/backend/src/app/app.controller.spec.ts | 5 +++++ 2 files changed, 10 insertions(+) diff --git a/apps/backend/src/app/admin/workspace/workspace.controller.spec.ts b/apps/backend/src/app/admin/workspace/workspace.controller.spec.ts index 8ed177d04..12e02670e 100755 --- a/apps/backend/src/app/admin/workspace/workspace.controller.spec.ts +++ b/apps/backend/src/app/admin/workspace/workspace.controller.spec.ts @@ -5,6 +5,7 @@ import { AuthService } from '../../auth/service/auth.service'; import { UsersService } from '../../database/services/users.service'; import { TestcenterService } from '../../database/services/testcenter.service'; import { UploadResultsService } from '../../database/services/upload-results.service'; // ggf. anpassen, falls anderer Pfad +import { WorkspaceCoreService } from '../../database/services/workspace-core.service'; describe('WorkspaceController', () => { let controller: WorkspaceController; @@ -28,6 +29,10 @@ describe('WorkspaceController', () => { { provide: UploadResultsService, useValue: createMock() // Mock-Implementierung für UploadResultsService + }, + { + provide: WorkspaceCoreService, + useValue: createMock() } ] }).compile(); diff --git a/apps/backend/src/app/app.controller.spec.ts b/apps/backend/src/app/app.controller.spec.ts index 3a6b00cde..8967fc71a 100755 --- a/apps/backend/src/app/app.controller.spec.ts +++ b/apps/backend/src/app/app.controller.spec.ts @@ -4,6 +4,7 @@ import { AppController } from './app.controller'; import { AuthService } from './auth/service/auth.service'; import { UsersService } from './database/services/users.service'; import { TestcenterService } from './database/services/testcenter.service'; +import { WorkspaceUsersService } from './database/services/workspace-users.service'; describe('AppController', () => { beforeEach(async () => { @@ -21,6 +22,10 @@ describe('AppController', () => { { provide: TestcenterService, useValue: createMock() + }, + { + provide: WorkspaceUsersService, + useValue: createMock() } ] }).compile(); From 5be0134ee1a5c842ddcea0b6457dca36634a36f0 Mon Sep 17 00:00:00 2001 From: jurei733 <67505990+jurei733@users.noreply.github.com> Date: Sat, 5 Jul 2025 16:48:35 +0200 Subject: [PATCH 03/36] Fix frontend test --- apps/frontend/src/app/app.component.spec.ts | 41 ------------ .../app-info/app-info.component.spec.ts | 23 ++++--- .../components/home/home.component.spec.ts | 18 +++++- .../src/app/components/home/home.component.ts | 9 +-- .../spinner/spinner.component.spec.ts | 3 +- .../search-filter.component.spec.ts | 14 ++--- .../search-filter/search-filter.component.ts | 12 +++- .../wrapped-icon.component.spec.ts | 6 +- .../account-action.component.spec.ts | 2 + .../user-menu/user-menu.component.spec.ts | 22 ++++--- .../users-menu/users-menu.component.spec.ts | 3 + .../workspaces-menu.component.spec.ts | 62 +++++++++++++------ .../user-workspaces-area.component.spec.ts | 19 +++--- .../user-workspaces.component.spec.ts | 21 ++++++- 14 files changed, 140 insertions(+), 115 deletions(-) delete mode 100755 apps/frontend/src/app/app.component.spec.ts diff --git a/apps/frontend/src/app/app.component.spec.ts b/apps/frontend/src/app/app.component.spec.ts deleted file mode 100755 index 2fcc690b6..000000000 --- a/apps/frontend/src/app/app.component.spec.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { TestBed } from '@angular/core/testing'; -import { provideHttpClient } from '@angular/common/http'; -import { InjectionToken } from '@angular/core'; -import { KeycloakService } from 'keycloak-angular'; -import { AppComponent } from './app.component'; -import { environment } from '../environments/environment'; -import { AuthService } from './auth/service/auth.service'; - -export const AUTH_TOKEN = new InjectionToken('AUTH_TOKEN'); -const mockAuthService = { - isLoggedIn: jest.fn(() => true) -}; - -const mockKeycloakService = { - isLoggedIn: () => true, - getToken: () => 'mocked-jwt-token', - login: jest.fn(), - logout: jest.fn() -}; - -describe('AppComponent', () => { - beforeEach(async () => { - await TestBed.configureTestingModule({ - providers: [provideHttpClient(), { provide: AUTH_TOKEN, useValue: 'dummy-auth-token' }, - { provide: AuthService, useValue: mockAuthService }, - { provide: KeycloakService, useValue: mockKeycloakService }, - - { - provide: 'SERVER_URL', - useValue: environment.backendUrl - }], - imports: [AppComponent] - }).compileComponents(); - }); - - it('should create the app', () => { - const fixture = TestBed.createComponent(AppComponent); - const app = fixture.componentInstance; - expect(app).toBeTruthy(); - }); -}); diff --git a/apps/frontend/src/app/components/app-info/app-info.component.spec.ts b/apps/frontend/src/app/components/app-info/app-info.component.spec.ts index 68145e73f..c6c00f3ea 100755 --- a/apps/frontend/src/app/components/app-info/app-info.component.spec.ts +++ b/apps/frontend/src/app/components/app-info/app-info.component.spec.ts @@ -1,30 +1,33 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { MatDialogModule } from '@angular/material/dialog'; import { TranslateModule } from '@ngx-translate/core'; -import { ActivatedRoute } from '@angular/router'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { AppInfoComponent } from './app-info.component'; describe('AppInfoComponent', () => { let component: AppInfoComponent; let fixture: ComponentFixture; - const fakeActivatedRoute = { - snapshot: { data: { } } - } as ActivatedRoute; - beforeEach(async () => { await TestBed.configureTestingModule({ - providers: [ - { - provide: ActivatedRoute, - useValue: fakeActivatedRoute - }], imports: [ + AppInfoComponent, + MatDialogModule, + NoopAnimationsModule, TranslateModule.forRoot() ] }).compileComponents(); fixture = TestBed.createComponent(AppInfoComponent); component = fixture.componentInstance; + fixture.componentRef.setInput('appTitle', 'Test Title'); + fixture.componentRef.setInput('introHtml', undefined); + fixture.componentRef.setInput('appName', 'Test App'); + fixture.componentRef.setInput('appVersion', '1.0'); + fixture.componentRef.setInput('userName', 'testuser'); + fixture.componentRef.setInput('userLongName', 'Test User'); + fixture.componentRef.setInput('isUserLoggedIn', true); + fixture.componentRef.setInput('isAdmin', false); fixture.detectChanges(); }); diff --git a/apps/frontend/src/app/components/home/home.component.spec.ts b/apps/frontend/src/app/components/home/home.component.spec.ts index 4571a6a4f..2b6097f9f 100755 --- a/apps/frontend/src/app/components/home/home.component.spec.ts +++ b/apps/frontend/src/app/components/home/home.component.spec.ts @@ -2,9 +2,11 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { TranslateModule } from '@ngx-translate/core'; import { provideHttpClient } from '@angular/common/http'; import { ActivatedRoute } from '@angular/router'; +import { of } from 'rxjs'; import { HomeComponent } from './home.component'; import { AuthService } from '../../auth/service/auth.service'; import { environment } from '../../../environments/environment'; +import { AppService } from '../../services/app.service'; const mockAuthService = { isLoggedIn: jest.fn(() => true) @@ -15,31 +17,41 @@ const mockActivatedRoute = { data: { someData: 'test-data' } + }, + queryParams: of({}) +}; + +const mockAppService = { + refreshAuthData: jest.fn(), + authData$: of({ + workspaces: [] + }), + userProfile: { + firstName: '', + lastName: '' } }; describe('HomeComponent', () => { let component: HomeComponent; let fixture: ComponentFixture; - beforeEach(async () => { await TestBed.configureTestingModule({ imports: [HomeComponent, TranslateModule.forRoot()], providers: [ { provide: ActivatedRoute, useValue: mockActivatedRoute }, { provide: AuthService, useValue: mockAuthService }, + { provide: AppService, useValue: mockAppService }, { provide: 'SERVER_URL', useValue: environment.backendUrl }, provideHttpClient()] }) .compileComponents(); - fixture = TestBed.createComponent(HomeComponent); component = fixture.componentInstance; fixture.detectChanges(); }); - it('should create', () => { expect(component).toBeTruthy(); }); diff --git a/apps/frontend/src/app/components/home/home.component.ts b/apps/frontend/src/app/components/home/home.component.ts index 207f16d89..2e5618d5e 100755 --- a/apps/frontend/src/app/components/home/home.component.ts +++ b/apps/frontend/src/app/components/home/home.component.ts @@ -11,7 +11,6 @@ import { ActivatedRoute } from '@angular/router'; import { MatSnackBar } from '@angular/material/snack-bar'; import { AppService } from '../../services/app.service'; import { AppInfoComponent } from '../app-info/app-info.component'; -// eslint-disable-next-line max-len import { UserWorkspacesAreaComponent } from '../../workspace/components/user-workspaces-area/user-workspaces-area.component'; import { WorkspaceFullDto } from '../../../../../../api-dto/workspaces/workspace-full-dto'; @@ -43,8 +42,10 @@ export class HomeComponent implements OnInit, OnDestroy { ngOnInit(): void { this.appService.refreshAuthData(); this.authSubscription = this.appService.authData$.subscribe(authData => { - this.authData = authData; - this.workspaces = authData.workspaces; + if (authData) { + this.authData = authData; + this.workspaces = authData.workspaces; + } }); this.route.queryParams.subscribe(params => { @@ -59,7 +60,7 @@ export class HomeComponent implements OnInit, OnDestroy { * @param errorCode The error code from the query parameters */ private showErrorMessage(errorCode: string): void { - let message = 'Ein Fehler ist aufgetreten'; + let message; switch (errorCode) { case 'token_missing': diff --git a/apps/frontend/src/app/replay/components/spinner/spinner.component.spec.ts b/apps/frontend/src/app/replay/components/spinner/spinner.component.spec.ts index e8ee7ac8b..89e6e828e 100644 --- a/apps/frontend/src/app/replay/components/spinner/spinner.component.spec.ts +++ b/apps/frontend/src/app/replay/components/spinner/spinner.component.spec.ts @@ -15,7 +15,8 @@ describe('SpinnerComponent', () => { fixture = TestBed.createComponent(SpinnerComponent); component = fixture.componentInstance; - component.isLoaded = new Subject(); + fixture.componentRef.setInput('isLoaded', new Subject()); + fixture.detectChanges(); }); diff --git a/apps/frontend/src/app/shared/search-filter/search-filter.component.spec.ts b/apps/frontend/src/app/shared/search-filter/search-filter.component.spec.ts index 9d42cacb4..79cb68aac 100755 --- a/apps/frontend/src/app/shared/search-filter/search-filter.component.spec.ts +++ b/apps/frontend/src/app/shared/search-filter/search-filter.component.spec.ts @@ -1,10 +1,6 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { TranslateModule } from '@ngx-translate/core'; -import { MatFormFieldModule } from '@angular/material/form-field'; -import { MatInputModule } from '@angular/material/input'; -import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; -import { MatIconModule } from '@angular/material/icon'; -import { MatTooltipModule } from '@angular/material/tooltip'; import { SearchFilterComponent } from './search-filter.component'; describe('SearchFilterComponent', () => { @@ -14,17 +10,15 @@ describe('SearchFilterComponent', () => { beforeEach(async () => { await TestBed.configureTestingModule({ imports: [ - MatTooltipModule, - MatIconModule, - BrowserAnimationsModule, - MatInputModule, - MatFormFieldModule, + SearchFilterComponent, + NoopAnimationsModule, TranslateModule.forRoot() ] }).compileComponents(); fixture = TestBed.createComponent(SearchFilterComponent); component = fixture.componentInstance; + fixture.componentRef.setInput('title', 'test search'); fixture.detectChanges(); }); diff --git a/apps/frontend/src/app/shared/search-filter/search-filter.component.ts b/apps/frontend/src/app/shared/search-filter/search-filter.component.ts index a8b0f625d..a0fb18ce9 100755 --- a/apps/frontend/src/app/shared/search-filter/search-filter.component.ts +++ b/apps/frontend/src/app/shared/search-filter/search-filter.component.ts @@ -14,8 +14,16 @@ import { WrappedIconComponent } from '../wrapped-icon/wrapped-icon.component'; selector: 'coding-box-search-filter', templateUrl: './search-filter.component.html', styleUrls: ['./search-filter.component.scss'], - // eslint-disable-next-line max-len - imports: [MatFormField, MatLabel, MatInput, MatIconButton, MatSuffix, MatTooltip, WrappedIconComponent, TranslateModule] + imports: [ + MatFormField, + MatLabel, + MatInput, + MatIconButton, + MatSuffix, + MatTooltip, + WrappedIconComponent, + TranslateModule + ] }) export class SearchFilterComponent { value: string = ''; diff --git a/apps/frontend/src/app/shared/wrapped-icon/wrapped-icon.component.spec.ts b/apps/frontend/src/app/shared/wrapped-icon/wrapped-icon.component.spec.ts index 9e71439f2..6a0d7a547 100755 --- a/apps/frontend/src/app/shared/wrapped-icon/wrapped-icon.component.spec.ts +++ b/apps/frontend/src/app/shared/wrapped-icon/wrapped-icon.component.spec.ts @@ -1,5 +1,4 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { MatIconModule } from '@angular/material/icon'; import { WrappedIconComponent } from './wrapped-icon.component'; describe('WrappedIconComponent', () => { @@ -8,13 +7,12 @@ describe('WrappedIconComponent', () => { beforeEach(async () => { await TestBed.configureTestingModule({ - imports: [ - MatIconModule - ] + imports: [WrappedIconComponent] }).compileComponents(); fixture = TestBed.createComponent(WrappedIconComponent); component = fixture.componentInstance; + fixture.componentRef.setInput('icon', 'home'); fixture.detectChanges(); }); diff --git a/apps/frontend/src/app/sys-admin/components/account-action/account-action.component.spec.ts b/apps/frontend/src/app/sys-admin/components/account-action/account-action.component.spec.ts index ff0b535c5..3c3bfda5b 100755 --- a/apps/frontend/src/app/sys-admin/components/account-action/account-action.component.spec.ts +++ b/apps/frontend/src/app/sys-admin/components/account-action/account-action.component.spec.ts @@ -18,6 +18,8 @@ describe('AccountActionComponent', () => { fixture = TestBed.createComponent(AccountActionComponent); component = fixture.componentInstance; + fixture.componentRef.setInput('type', 'logout'); + fixture.componentRef.setInput('iconName', 'exit_to_app'); fixture.detectChanges(); }); diff --git a/apps/frontend/src/app/sys-admin/components/user-menu/user-menu.component.spec.ts b/apps/frontend/src/app/sys-admin/components/user-menu/user-menu.component.spec.ts index 41bb6d399..4dcb021cd 100755 --- a/apps/frontend/src/app/sys-admin/components/user-menu/user-menu.component.spec.ts +++ b/apps/frontend/src/app/sys-admin/components/user-menu/user-menu.component.spec.ts @@ -1,10 +1,13 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { TranslateModule } from '@ngx-translate/core'; -import { KeycloakService } from 'keycloak-angular'; -import { HttpClientModule } from '@angular/common/http'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { UserMenuComponent } from './user-menu.component'; import { AuthService } from '../../../auth/service/auth.service'; -import { environment } from '../../../../environments/environment'; + +const mockAuthService = { + logout: jest.fn().mockResolvedValue(undefined), + redirectToProfile: jest.fn().mockResolvedValue(undefined) +}; describe('UserMenuComponent', () => { let component: UserMenuComponent; @@ -12,17 +15,16 @@ describe('UserMenuComponent', () => { beforeEach(async () => { await TestBed.configureTestingModule({ - providers: [AuthService, KeycloakService, { - provide: 'SERVER_URL', - useValue: environment.backendUrl - }], imports: [ - HttpClientModule, UserMenuComponent, + NoopAnimationsModule, TranslateModule.forRoot() + ], + providers: [ + { provide: AuthService, useValue: mockAuthService } ] - }) - .compileComponents(); + }).compileComponents(); + fixture = TestBed.createComponent(UserMenuComponent); component = fixture.componentInstance; fixture.detectChanges(); diff --git a/apps/frontend/src/app/sys-admin/components/users-menu/users-menu.component.spec.ts b/apps/frontend/src/app/sys-admin/components/users-menu/users-menu.component.spec.ts index 689d18d51..2c51d10af 100755 --- a/apps/frontend/src/app/sys-admin/components/users-menu/users-menu.component.spec.ts +++ b/apps/frontend/src/app/sys-admin/components/users-menu/users-menu.component.spec.ts @@ -21,6 +21,9 @@ describe('UsersMenuComponent', () => { fixture = TestBed.createComponent(UsersMenuComponent); component = fixture.componentInstance; + fixture.componentRef.setInput('selectedUser', []); + fixture.componentRef.setInput('checkedRows', []); + fixture.componentRef.setInput('selectedRows', []); fixture.detectChanges(); }); diff --git a/apps/frontend/src/app/sys-admin/components/workspaces-menu/workspaces-menu.component.spec.ts b/apps/frontend/src/app/sys-admin/components/workspaces-menu/workspaces-menu.component.spec.ts index c2da21ab2..99987dd65 100755 --- a/apps/frontend/src/app/sys-admin/components/workspaces-menu/workspaces-menu.component.spec.ts +++ b/apps/frontend/src/app/sys-admin/components/workspaces-menu/workspaces-menu.component.spec.ts @@ -1,32 +1,56 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { MatDialogModule } from '@angular/material/dialog'; -import { MatIconModule } from '@angular/material/icon'; -import { MatTooltipModule } from '@angular/material/tooltip'; +import { ActivatedRoute } from '@angular/router'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { of } from 'rxjs'; import { TranslateModule } from '@ngx-translate/core'; -import { HttpClientModule } from '@angular/common/http'; -import { WorkspacesMenuComponent } from './workspaces-menu.component'; -import { environment } from '../../../../environments/environment'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { AppService } from '../../../services/app.service'; +import { HomeComponent } from '../../../components/home/home.component'; +import { AuthService } from '../../../auth/service/auth.service'; -describe('WorkspacesMenuComponent', () => { - let component: WorkspacesMenuComponent; - let fixture: ComponentFixture; +describe('HomeComponent', () => { + let component: HomeComponent; + let fixture: ComponentFixture; + + const mockAppService = { + authData$: of(AppService.defaultAuthData), + refreshAuthData: jest.fn(), + userProfile: { + firstName: '', + lastName: '' + } + }; + + const mockSnackBar = { + open: jest.fn() + }; + + const mockActivatedRoute = { + queryParams: of({}) + }; + const mockAuthService = { + getAuthData: jest.fn(), + isLoggedIn: jest.fn(() => true), + login: jest.fn() + }; beforeEach(async () => { await TestBed.configureTestingModule({ - providers: [{ - provide: 'SERVER_URL', - useValue: environment.backendUrl - }], imports: [ - HttpClientModule, - MatDialogModule, - MatIconModule, - MatTooltipModule, - TranslateModule.forRoot() + HomeComponent, + TranslateModule.forRoot(), + NoopAnimationsModule + ], + providers: [ + { provide: AppService, useValue: mockAppService }, + { provide: MatSnackBar, useValue: mockSnackBar }, + { provide: ActivatedRoute, useValue: mockActivatedRoute }, + { provide: AuthService, useValue: mockAuthService } + ] }).compileComponents(); - fixture = TestBed.createComponent(WorkspacesMenuComponent); + fixture = TestBed.createComponent(HomeComponent); component = fixture.componentInstance; fixture.detectChanges(); }); diff --git a/apps/frontend/src/app/workspace/components/user-workspaces-area/user-workspaces-area.component.spec.ts b/apps/frontend/src/app/workspace/components/user-workspaces-area/user-workspaces-area.component.spec.ts index 4dd66a587..6a3bfbf93 100755 --- a/apps/frontend/src/app/workspace/components/user-workspaces-area/user-workspaces-area.component.spec.ts +++ b/apps/frontend/src/app/workspace/components/user-workspaces-area/user-workspaces-area.component.spec.ts @@ -1,29 +1,28 @@ -// eslint-disable-next-line max-classes-per-file import { ComponentFixture, TestBed } from '@angular/core/testing'; import { TranslateModule } from '@ngx-translate/core'; import { UserWorkspacesAreaComponent } from './user-workspaces-area.component'; -import { environment } from '../../../../environments/environment'; import { AuthService } from '../../../auth/service/auth.service'; +const mockAuthService = { + getLoggedUser: jest.fn(), + isLoggedIn: jest.fn().mockReturnValue(true) + +}; describe('UserWorkspacesAreaComponent', () => { let component: UserWorkspacesAreaComponent; let fixture: ComponentFixture; beforeEach(async () => { await TestBed.configureTestingModule({ - imports: [ - TranslateModule.forRoot() - ], + imports: [UserWorkspacesAreaComponent, TranslateModule.forRoot()], providers: [ - AuthService, - { - provide: 'SERVER_URL', - useValue: environment.backendUrl - }] + { provide: AuthService, useValue: mockAuthService } + ] }).compileComponents(); fixture = TestBed.createComponent(UserWorkspacesAreaComponent); component = fixture.componentInstance; + fixture.componentRef.setInput('workspaces', []); fixture.detectChanges(); }); diff --git a/apps/frontend/src/app/workspace/components/user-workspaces/user-workspaces.component.spec.ts b/apps/frontend/src/app/workspace/components/user-workspaces/user-workspaces.component.spec.ts index b459d3b91..d73638e07 100755 --- a/apps/frontend/src/app/workspace/components/user-workspaces/user-workspaces.component.spec.ts +++ b/apps/frontend/src/app/workspace/components/user-workspaces/user-workspaces.component.spec.ts @@ -3,17 +3,36 @@ import { TranslateModule } from '@ngx-translate/core'; import { UserWorkspacesComponent } from './user-workspaces.component'; import { AuthService } from '../../../auth/service/auth.service'; +const mockKeycloak = { + idTokenParsed: { sub: 'test-user-id', preferred_username: 'test-user' }, + token: 'mock-token', + authenticated: true, + loadUserProfile: jest.fn().mockResolvedValue({ username: 'test-user' }), + login: jest.fn(), + logout: jest.fn(), + accountManagement: jest.fn(), + realmAccess: { roles: ['user'] } +}; + +const mockAuthService = { + isLoggedIn: jest.fn().mockReturnValue(true) +}; + describe('UserWorkspacesComponent', () => { let component: UserWorkspacesComponent; let fixture: ComponentFixture; beforeEach(async () => { await TestBed.configureTestingModule({ - providers: [AuthService], + providers: [ + { provide: AuthService, useValue: mockAuthService }, + { provide: 'Keycloak', useValue: mockKeycloak } + ], imports: [TranslateModule.forRoot()] }).compileComponents(); fixture = TestBed.createComponent(UserWorkspacesComponent); component = fixture.componentInstance; + component.workspaces = []; fixture.detectChanges(); }); From 29a4511b5d1bc55d6aa0e7cb755cff46cb264da2 Mon Sep 17 00:00:00 2001 From: jurei733 <67505990+jurei733@users.noreply.github.com> Date: Sun, 6 Jul 2025 16:31:20 +0200 Subject: [PATCH 04/36] Fix frontend linting --- apps/backend/project.json | 2 +- apps/frontend/project.json | 2 +- .../coding/coder-list/coder-list.component.ts | 13 ++---- ...coding-management-manual.component.spec.ts | 1 - .../coding-management.component.spec.ts | 2 +- .../export-dialog/export-dialog.component.ts | 3 +- .../components/replay/replay.component.ts | 3 ++ .../src/app/services/journal-interceptor.ts | 26 +---------- .../sys-admin-settings.component.ts | 45 +++---------------- .../files-validation.component.ts | 4 +- .../test-files/test-files.component.ts | 41 +++++------------ 11 files changed, 30 insertions(+), 112 deletions(-) diff --git a/apps/backend/project.json b/apps/backend/project.json index 7c1ecada1..30e319796 100755 --- a/apps/backend/project.json +++ b/apps/backend/project.json @@ -47,7 +47,7 @@ } }, "lint": { - "executor": "@nx/linter:eslint", + "executor": "@nx/eslint:lint", "outputs": [ "{options.outputFile}" ], diff --git a/apps/frontend/project.json b/apps/frontend/project.json index 5d62765fb..b54a4e33f 100755 --- a/apps/frontend/project.json +++ b/apps/frontend/project.json @@ -87,7 +87,7 @@ } }, "lint": { - "executor": "@nx/linter:eslint", + "executor": "@nx/eslint:lint", "options": { "lintFilePatterns": [ "apps/frontend/src/**/*.ts", diff --git a/apps/frontend/src/app/coding/coder-list/coder-list.component.ts b/apps/frontend/src/app/coding/coder-list/coder-list.component.ts index 184d67537..8d35bf859 100755 --- a/apps/frontend/src/app/coding/coder-list/coder-list.component.ts +++ b/apps/frontend/src/app/coding/coder-list/coder-list.component.ts @@ -93,8 +93,7 @@ export class CoderListComponent implements OnInit, AfterViewInit { this.dataSource.data = coders; this.isLoading = false; }, - error: error => { - console.error('Error loading coders:', error); + error: () => { this.snackBar.open('Fehler beim Laden der Kodierer', 'Schließen', { duration: 3000 }); this.isLoading = false; } @@ -141,8 +140,7 @@ export class CoderListComponent implements OnInit, AfterViewInit { this.loadCoders(); this.coderForm.reset(); }, - error: error => { - console.error('Error creating coder:', error); + error: () => { this.snackBar.open('Fehler beim Erstellen des Kodierers', 'Schließen', { duration: 3000 }); } }); @@ -180,8 +178,7 @@ export class CoderListComponent implements OnInit, AfterViewInit { this.editingCoderId = null; this.coderForm.reset(); }, - error: error => { - console.error('Error updating coder:', error); + error: () => { this.snackBar.open('Fehler beim Aktualisieren des Kodierers', 'Schließen', { duration: 3000 }); } }); @@ -195,13 +192,11 @@ export class CoderListComponent implements OnInit, AfterViewInit { const deletePromises = this.selection.selected.map(coder => this.coderService.deleteCoder(coder.id)); - // Using Promise.all to wait for all delete operations to complete Promise.all(deletePromises).then(() => { this.snackBar.open('Kodierer erfolgreich gelöscht', 'Schließen', { duration: 3000 }); this.loadCoders(); this.selection.clear(); - }).catch(error => { - console.error('Error deleting coders:', error); + }).catch(() => { this.snackBar.open('Fehler beim Löschen der Kodierer', 'Schließen', { duration: 3000 }); }); } diff --git a/apps/frontend/src/app/coding/coding-management-manual/coding-management-manual.component.spec.ts b/apps/frontend/src/app/coding/coding-management-manual/coding-management-manual.component.spec.ts index 7719cee8e..ff28f1095 100755 --- a/apps/frontend/src/app/coding/coding-management-manual/coding-management-manual.component.spec.ts +++ b/apps/frontend/src/app/coding/coding-management-manual/coding-management-manual.component.spec.ts @@ -1,4 +1,3 @@ - import { ComponentFixture, TestBed } from '@angular/core/testing'; import { TranslateModule } from '@ngx-translate/core'; import { ActivatedRoute } from '@angular/router'; diff --git a/apps/frontend/src/app/coding/coding-managment/coding-management.component.spec.ts b/apps/frontend/src/app/coding/coding-managment/coding-management.component.spec.ts index ef4a078d5..4eab02e30 100755 --- a/apps/frontend/src/app/coding/coding-managment/coding-management.component.spec.ts +++ b/apps/frontend/src/app/coding/coding-managment/coding-management.component.spec.ts @@ -1,9 +1,9 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { TranslateModule } from '@ngx-translate/core'; import { ActivatedRoute } from '@angular/router'; +import { provideHttpClient } from '@angular/common/http'; import { CodingManagementComponent } from './coding-management.component'; import { environment } from '../../../environments/environment'; -import { provideHttpClient } from '@angular/common/http'; describe('CodingManagementComponent', () => { let component: CodingManagementComponent; diff --git a/apps/frontend/src/app/coding/export-dialog/export-dialog.component.ts b/apps/frontend/src/app/coding/export-dialog/export-dialog.component.ts index 0690e56a2..f1fca56d2 100644 --- a/apps/frontend/src/app/coding/export-dialog/export-dialog.component.ts +++ b/apps/frontend/src/app/coding/export-dialog/export-dialog.component.ts @@ -4,7 +4,6 @@ import { MatButtonModule } from '@angular/material/button'; import { MatRadioModule } from '@angular/material/radio'; import { FormsModule } from '@angular/forms'; - export type ExportFormat = 'json' | 'csv' | 'excel'; @Component({ @@ -17,7 +16,7 @@ export type ExportFormat = 'json' | 'csv' | 'excel'; MatDialogModule, MatButtonModule, MatRadioModule -] + ] }) export class ExportDialogComponent { dialogRef = inject>(MatDialogRef); diff --git a/apps/frontend/src/app/replay/components/replay/replay.component.ts b/apps/frontend/src/app/replay/components/replay/replay.component.ts index b2ad9204f..ff3d5cd41 100755 --- a/apps/frontend/src/app/replay/components/replay/replay.component.ts +++ b/apps/frontend/src/app/replay/components/replay/replay.component.ts @@ -444,6 +444,7 @@ export class ReplayComponent implements OnInit, OnDestroy, OnChanges { } } } catch (error) { + // eslint-disable-next-line no-console console.error('Error searching for elements with data-element-alias:', error); } @@ -473,6 +474,7 @@ export class ReplayComponent implements OnInit, OnDestroy, OnChanges { } } } catch (error) { + // eslint-disable-next-line no-console console.error('Error getting data-element-alias values:', error); } @@ -496,6 +498,7 @@ export class ReplayComponent implements OnInit, OnDestroy, OnChanges { return true; } } catch (error) { + // eslint-disable-next-line no-console console.error(`Error scrolling to element with alias "${alias}":`, error); } diff --git a/apps/frontend/src/app/services/journal-interceptor.ts b/apps/frontend/src/app/services/journal-interceptor.ts index 99b6bafb0..8f4f80051 100644 --- a/apps/frontend/src/app/services/journal-interceptor.ts +++ b/apps/frontend/src/app/services/journal-interceptor.ts @@ -20,7 +20,6 @@ export const journalInterceptor: HttpInterceptorFn = ( const appService = inject(AppService); // const journalService = inject(JournalService); - // Only intercept requests to the backend API if (!request.url.startsWith(appService.serverUrl)) { return next(request); } @@ -43,28 +42,18 @@ export const journalInterceptor: HttpInterceptorFn = ( ); }; -/** - * Checks if the request method indicates data modification - */ function isDataModifyingRequest(request: HttpRequest): boolean { - // Check if the request method indicates data modification return ['POST', 'PUT', 'PATCH', 'DELETE'].includes(request.method); } -/** - * Checks if the request is related to test results - */ function isTestResultsRequest(request: HttpRequest): boolean { - // Check if the request URL contains test-results related paths return request.url.includes('/test-results') || request.url.includes('/responses') || request.url.includes('/units') || request.url.includes('/booklets'); } -/** - * Logs an action to the journal - */ +// eslint-disable-next-line @typescript-eslint/no-unused-vars function logAction( request: HttpRequest, response: HttpResponse, @@ -76,14 +65,12 @@ function logAction( return; } - // Extract information from the request const url = request.url; const method = request.method; const actionType = getActionType(method); const entityType = getEntityType(url); const entityId = getEntityId(url); - // Create details from the request body and response const details = JSON.stringify({ method, url, @@ -92,7 +79,6 @@ function logAction( responseBody: response.body ? sanitizeBody(response.body) : null }); - // Log the action to the journal journalService.createJournalEntry( workspaceId, actionType, @@ -102,9 +88,6 @@ function logAction( ).subscribe(); } -/** - * Gets the action type based on the HTTP method - */ function getActionType(method: string): string { switch (method) { case 'POST': return 'create'; @@ -115,9 +98,6 @@ function getActionType(method: string): string { } } -/** - * Gets the entity type based on the URL - */ function getEntityType(url: string): string { // Extract entity type from URL if (url.includes('/test-results')) return 'test-results'; @@ -132,11 +112,7 @@ function getEntityType(url: string): string { return 'unknown'; } -/** - * Gets the entity ID from the URL - */ function getEntityId(url: string): string { - // Try to extract an ID from the URL const parts = url.split('/'); const idPattern = /^[0-9]+$/; diff --git a/apps/frontend/src/app/sys-admin/components/sys-admin-settings/sys-admin-settings.component.ts b/apps/frontend/src/app/sys-admin/components/sys-admin-settings/sys-admin-settings.component.ts index 8e18b4c56..70f925ebd 100755 --- a/apps/frontend/src/app/sys-admin/components/sys-admin-settings/sys-admin-settings.component.ts +++ b/apps/frontend/src/app/sys-admin/components/sys-admin-settings/sys-admin-settings.component.ts @@ -95,9 +95,6 @@ export class SysAdminSettingsComponent { } } - /** - * Uploads the selected logo - */ uploadLogo(): void { if (!this.selectedFile || !this.previewUrl) return; @@ -110,11 +107,8 @@ export class SysAdminSettingsComponent { boxBackground: this.appService.appLogo.boxBackground }; - // Update the appLogo property in the AppService this.appService.appLogo = newLogo; this.isDefaultLogo = false; - - // Save the logo settings to the server this.logoService.saveLogoSettings(newLogo).subscribe({ next: settingsResponse => { if (settingsResponse.success) { @@ -124,23 +118,18 @@ export class SysAdminSettingsComponent { } this.resetFileInput(); }, - error: settingsError => { - console.error('Error saving logo settings:', settingsError); + error: () => { this.snackBar.open('Logo aktualisiert, aber Fehler beim Speichern der Einstellungen', 'Schließen', { duration: 3000 }); this.resetFileInput(); } }); }, - error: error => { - console.error('Error uploading logo:', error); + error: () => { this.snackBar.open('Fehler beim Hochladen des Logos', 'Schließen', { duration: 3000 }); } }); } - /** - * Resets to the default logo - */ resetToDefaultLogo(): void { this.logoService.deleteLogo().subscribe({ next: response => { @@ -155,26 +144,20 @@ export class SysAdminSettingsComponent { } this.resetFileInput(); }, - error: error => { - console.error('Error resetting logo:', error); + error: () => { this.snackBar.open('Fehler beim Zurücksetzen des Logos', 'Schließen', { duration: 3000 }); } }); } - /** - * Saves the alternative text for the logo - */ saveAltText(): void { const updatedLogo = { ...this.appService.appLogo, alt: this.logoAltText }; - // Update the appLogo property in the AppService this.appService.appLogo = updatedLogo; - // Save the logo settings to the server this.logoService.saveLogoSettings(updatedLogo).subscribe({ next: response => { if (response.success) { @@ -183,16 +166,12 @@ export class SysAdminSettingsComponent { this.snackBar.open('Fehler beim Speichern des Alternativtexts', 'Schließen', { duration: 3000 }); } }, - error: error => { - console.error('Error saving alt text:', error); + error: () => { this.snackBar.open('Fehler beim Speichern des Alternativtexts', 'Schließen', { duration: 3000 }); } }); } - /** - * Updates the background color preview when the input changes - */ updateBackgroundPreview(): void { // The preview is automatically updated through data binding // This method is called when the input changes @@ -207,10 +186,7 @@ export class SysAdminSettingsComponent { bodyBackground: this.backgroundColorValue }; - // Update the appLogo property in the AppService this.appService.appLogo = updatedLogo; - - // Save the logo settings to the server this.logoService.saveLogoSettings(updatedLogo).subscribe({ next: response => { if (response.success) { @@ -219,8 +195,7 @@ export class SysAdminSettingsComponent { this.snackBar.open('Fehler beim Speichern der Hintergrundfarbe', 'Schließen', { duration: 3000 }); } }, - error: error => { - console.error('Error saving background color:', error); + error: () => { this.snackBar.open('Fehler beim Speichern der Hintergrundfarbe', 'Schließen', { duration: 3000 }); } }); @@ -230,19 +205,12 @@ export class SysAdminSettingsComponent { * Resets the background color to the standard linear gradient */ resetToDefaultBackground(): void { - // Set the background color value to the standard gradient from standardLogo this.backgroundColorValue = standardLogo.bodyBackground || ''; - - // Create updated logo object with default background const updatedLogo = { ...this.appService.appLogo, bodyBackground: this.backgroundColorValue }; - - // Update the appLogo property in the AppService this.appService.appLogo = updatedLogo; - - // Save the logo settings to the server this.logoService.saveLogoSettings(updatedLogo).subscribe({ next: response => { if (response.success) { @@ -251,8 +219,7 @@ export class SysAdminSettingsComponent { this.snackBar.open('Fehler beim Zurücksetzen der Hintergrundfarbe', 'Schließen', { duration: 3000 }); } }, - error: error => { - console.error('Error resetting background color:', error); + error: () => { this.snackBar.open('Fehler beim Zurücksetzen der Hintergrundfarbe', 'Schließen', { duration: 3000 }); } }); diff --git a/apps/frontend/src/app/ws-admin/components/files-validation-result/files-validation.component.ts b/apps/frontend/src/app/ws-admin/components/files-validation-result/files-validation.component.ts index 5aa88ebc7..1090c7699 100644 --- a/apps/frontend/src/app/ws-admin/components/files-validation-result/files-validation.component.ts +++ b/apps/frontend/src/app/ws-admin/components/files-validation-result/files-validation.component.ts @@ -56,13 +56,13 @@ interface ExpandedFilesLists { }) export class FilesValidationDialogComponent { dialogRef = inject>(MatDialogRef); - data = inject(MAT_DIALOG_DATA); + data = inject(MAT_DIALOG_DATA); expandedFilesLists: Map = new Map(); constructor() { const data = this.data; if (data) { - data.forEach((val:any) => { + data.forEach((val: FilesValidation) => { this.expandedFilesLists.set(val.testTaker, { booklets: false, units: false, diff --git a/apps/frontend/src/app/ws-admin/components/test-files/test-files.component.ts b/apps/frontend/src/app/ws-admin/components/test-files/test-files.component.ts index 640e9461b..400373658 100755 --- a/apps/frontend/src/app/ws-admin/components/test-files/test-files.component.ts +++ b/apps/frontend/src/app/ws-admin/components/test-files/test-files.component.ts @@ -25,7 +25,7 @@ import { MatFormField, MatLabel } from '@angular/material/form-field'; import { MatOption, MatSelect } from '@angular/material/select'; import { Subject, Subscription } from 'rxjs'; import { debounceTime } from 'rxjs/operators'; -import { MatPaginator } from '@angular/material/paginator'; +import { MatPaginator, PageEvent } from '@angular/material/paginator'; import { FilesValidationDialogComponent } from '../files-validation-result/files-validation.component'; import { TestCenterImportComponent } from '../test-center-import/test-center-import.component'; import { ResourcePackagesDialogComponent } from '../resource-packages-dialog/resource-packages-dialog.component'; @@ -100,7 +100,6 @@ export class TestFilesComponent implements OnInit, OnDestroy { { value: '10MB+', display: '> 10MB' } ]; - // Flag to track if resource packages have been modified resourcePackagesModified = false; textFilterValue: string = ''; @@ -109,13 +108,12 @@ export class TestFilesComponent implements OnInit, OnDestroy { private textFilterChanged: Subject = new Subject(); private textFilterSubscription: Subscription | undefined; - // Pagination variables page: number = 1; limit: number = 100; total: number = 0; ngOnInit(): void { - this.loadTestFiles(false); + this.loadTestFiles(); this.textFilterSubscription = this.textFilterChanged .pipe(debounceTime(300)) // Debounce für 300ms .subscribe(() => { @@ -137,22 +135,19 @@ export class TestFilesComponent implements OnInit, OnDestroy { return this.sort; } - /** Checks if all rows are selected */ private isAllSelected(): boolean { const numSelected = this.tableCheckboxSelection.selected.length; const numRows = this.dataSource?.data.length || 0; return numSelected === numRows; } - /** Toggles the selection of all rows */ masterToggle(): void { this.isAllSelected() ? this.tableCheckboxSelection.clear() : this.dataSource?.data.forEach(row => this.tableCheckboxSelection.select(row)); } - /** Loads test files and updates the data source */ - loadTestFiles(forceReload: boolean): void { + loadTestFiles(): void { this.isLoading = true; this.isValidating = false; this.backendService.getFilesList( @@ -170,26 +165,22 @@ export class TestFilesComponent implements OnInit, OnDestroy { }); } - /** Updates the table data source and stops spinner */ private updateTable(files: { data: FilesInListDto[], fileTypes: string[] }): void { this.dataSource = new MatTableDataSource(files.data); this.fileTypes = files.fileTypes; this.isLoading = false; } - /** Applies all filters */ applyFilters(): void { this.page = 1; - this.loadTestFiles(true); + this.loadTestFiles(); } - /** Handles text filter changes */ onTextFilterChange(value: string): void { this.textFilterValue = value.trim(); this.textFilterChanged.next(this.textFilterValue); } - /** Clears all filters */ clearFilters(): void { this.textFilterValue = ''; this.selectedFileType = ''; @@ -197,7 +188,6 @@ export class TestFilesComponent implements OnInit, OnDestroy { this.applyFilters(); } - /** Handles file selection for upload */ onFileSelected(target: EventTarget | null): void { if (!target) return; const inputElement = target as HTMLInputElement; @@ -213,7 +203,7 @@ export class TestFilesComponent implements OnInit, OnDestroy { private onUploadSuccess(): void { setTimeout(() => { - this.loadTestFiles(true); + this.loadTestFiles(); }, 1000); // Optional timeout to simulate processing delay this.isLoading = false; this.isValidating = false; @@ -228,9 +218,8 @@ export class TestFilesComponent implements OnInit, OnDestroy { } }); dialogRef.afterClosed().subscribe((result: boolean | UntypedFormGroup) => { - // Reload files if dialog returns a positive result if (result instanceof UntypedFormGroup || result) { - this.loadTestFiles(true); + this.loadTestFiles(); } }); } @@ -276,7 +265,7 @@ export class TestFilesComponent implements OnInit, OnDestroy { { duration: 1000 } ); if (success) { - this.loadTestFiles(true); + this.loadTestFiles(); } } @@ -305,9 +294,6 @@ export class TestFilesComponent implements OnInit, OnDestroy { } } - /** - * Returns the appropriate icon based on file type - */ getFileIcon(fileType: string): string { const type = fileType.toLowerCase(); if (type.includes('xml')) { @@ -328,11 +314,6 @@ export class TestFilesComponent implements OnInit, OnDestroy { return 'insert_drive_file'; } - // Resource Packages methods - - /** - * Opens the resource packages dialog - */ openResourcePackagesDialog(): void { const dialogRef = this.dialog.open(ResourcePackagesDialogComponent, { width: '90%', @@ -343,20 +324,18 @@ export class TestFilesComponent implements OnInit, OnDestroy { }); dialogRef.afterClosed().subscribe(result => { - // If result is true, resource packages were modified (uploaded or deleted) if (result === true) { this.resourcePackagesModified = true; // Optionally reload test files if they include resource packages - // this.loadTestFiles(true); + // this.loadTestFiles(); } }); } - /** Wird vom MatPaginator aufgerufen */ - onPageChange(event: any): void { + onPageChange(event: PageEvent): void { this.page = event.pageIndex + 1; this.limit = event.pageSize; - this.loadTestFiles(true); + this.loadTestFiles(); } showFileContent(file: FilesInListDto): void { From 2a846cba207590883ce5f6b9170a01138054e3ab Mon Sep 17 00:00:00 2001 From: jurei733 <67505990+jurei733@users.noreply.github.com> Date: Sun, 6 Jul 2025 17:34:49 +0200 Subject: [PATCH 05/36] Fix frontend linting --- apps/frontend/src/app/app.config.ts | 3 ++- .../coding-jobs/coding-jobs.component.spec.ts | 3 ++- ...coding-management-manual.component.spec.ts | 3 ++- .../coding-management.component.spec.ts | 3 ++- .../coding-manual.component.spec.ts | 3 ++- .../components/home/home.component.spec.ts | 3 ++- apps/frontend/src/app/injection-tokens.ts | 3 +++ .../replay/replay.component.spec.ts | 3 ++- .../unit-player/unit-player.component.spec.ts | 3 ++- apps/frontend/src/app/services/app.service.ts | 19 ++----------------- .../src/app/services/backend.service.ts | 3 ++- .../src/app/services/journal-interceptor.ts | 3 --- .../src/app/services/journal.service.ts | 3 ++- .../frontend/src/app/services/logo.service.ts | 3 ++- .../sys-admin-settings.component.spec.ts | 3 ++- ...ser-access-rights-dialog.component.spec.ts | 9 +++++---- .../users-selection.component.spec.ts | 3 ++- ...ace-access-rights-dialog.component.spec.ts | 3 ++- .../workspaces-selection.component.spec.ts | 3 ++- .../workspaces/workspaces.component.spec.ts | 3 ++- .../test-center-import.component.spec.ts | 9 +++++---- .../test-files/test-files.component.spec.ts | 3 ++- .../test-groups/test-groups.component.spec.ts | 9 +++++---- .../test-results.component.spec.ts | 3 ++- .../ws-access-rights.component.spec.ts | 3 ++- .../ws-admin/ws-admin.component.spec.ts | 9 +++++---- .../ws-settings/ws-settings.component.spec.ts | 3 ++- .../ws-users/ws-users.component.spec.ts | 3 ++- 28 files changed, 67 insertions(+), 57 deletions(-) create mode 100644 apps/frontend/src/app/injection-tokens.ts diff --git a/apps/frontend/src/app/app.config.ts b/apps/frontend/src/app/app.config.ts index fbf518adb..0dbe3657f 100755 --- a/apps/frontend/src/app/app.config.ts +++ b/apps/frontend/src/app/app.config.ts @@ -23,6 +23,7 @@ import { routes } from './app.routes'; import { environment } from '../environments/environment'; import { authInterceptor } from './interceptors/auth.interceptor'; import { journalInterceptor } from './services/journal-interceptor'; +import { SERVER_URL } from './injection-tokens'; export function createTranslateLoader(http: HttpClient): TranslateHttpLoader { return new TranslateHttpLoader(http, './assets/i18n/', '.json'); @@ -76,7 +77,7 @@ export const appConfig: ApplicationConfig = { provideRouter(routes), provideAnimationsAsync(), { - provide: 'SERVER_URL', + provide: SERVER_URL, useValue: environment.backendUrl }, { diff --git a/apps/frontend/src/app/coding/coding-jobs/coding-jobs.component.spec.ts b/apps/frontend/src/app/coding/coding-jobs/coding-jobs.component.spec.ts index c5e1612ea..b68d1c44a 100755 --- a/apps/frontend/src/app/coding/coding-jobs/coding-jobs.component.spec.ts +++ b/apps/frontend/src/app/coding/coding-jobs/coding-jobs.component.spec.ts @@ -4,6 +4,7 @@ import { ActivatedRoute } from '@angular/router'; import { provideHttpClient } from '@angular/common/http'; import { CodingJobsComponent } from './coding-jobs.component'; import { environment } from '../../../environments/environment'; +import { SERVER_URL } from '../../injection-tokens'; describe('CodingJobsComponent', () => { let component: CodingJobsComponent; @@ -15,7 +16,7 @@ describe('CodingJobsComponent', () => { beforeEach(async () => { await TestBed.configureTestingModule({ providers: [ - { provide: 'SERVER_URL', useValue: environment.backendUrl }, + { provide: SERVER_URL, useValue: environment.backendUrl }, { provide: ActivatedRoute, diff --git a/apps/frontend/src/app/coding/coding-management-manual/coding-management-manual.component.spec.ts b/apps/frontend/src/app/coding/coding-management-manual/coding-management-manual.component.spec.ts index ff28f1095..5cb69356b 100755 --- a/apps/frontend/src/app/coding/coding-management-manual/coding-management-manual.component.spec.ts +++ b/apps/frontend/src/app/coding/coding-management-manual/coding-management-manual.component.spec.ts @@ -5,6 +5,7 @@ import { provideHttpClient } from '@angular/common/http'; import { provideNoopAnimations } from '@angular/platform-browser/animations'; import { CodingManagementManualComponent } from './coding-management-manual.component'; import { environment } from '../../../environments/environment'; +import { SERVER_URL } from '../../injection-tokens'; describe('CodingManagementManualComponent', () => { let component: CodingManagementManualComponent; @@ -22,7 +23,7 @@ describe('CodingManagementManualComponent', () => { provide: ActivatedRoute, useValue: fakeActivatedRoute }, { - provide: 'SERVER_URL', + provide: SERVER_URL, useValue: environment.backendUrl }, provideHttpClient()], imports: [ diff --git a/apps/frontend/src/app/coding/coding-managment/coding-management.component.spec.ts b/apps/frontend/src/app/coding/coding-managment/coding-management.component.spec.ts index 4eab02e30..3294f548d 100755 --- a/apps/frontend/src/app/coding/coding-managment/coding-management.component.spec.ts +++ b/apps/frontend/src/app/coding/coding-managment/coding-management.component.spec.ts @@ -4,6 +4,7 @@ import { ActivatedRoute } from '@angular/router'; import { provideHttpClient } from '@angular/common/http'; import { CodingManagementComponent } from './coding-management.component'; import { environment } from '../../../environments/environment'; +import { SERVER_URL } from '../../injection-tokens'; describe('CodingManagementComponent', () => { let component: CodingManagementComponent; @@ -18,7 +19,7 @@ describe('CodingManagementComponent', () => { providers: [ provideHttpClient(), { - provide: 'SERVER_URL', + provide: SERVER_URL, useValue: environment.backendUrl }, { diff --git a/apps/frontend/src/app/coding/coding-manual/coding-manual.component.spec.ts b/apps/frontend/src/app/coding/coding-manual/coding-manual.component.spec.ts index 96d96cf0f..9c7dad15f 100755 --- a/apps/frontend/src/app/coding/coding-manual/coding-manual.component.spec.ts +++ b/apps/frontend/src/app/coding/coding-manual/coding-manual.component.spec.ts @@ -4,6 +4,7 @@ import { provideHttpClient } from '@angular/common/http'; import { ActivatedRoute } from '@angular/router'; import { CodingManualComponent } from './coding-manual.component'; import { environment } from '../../../environments/environment'; +import { SERVER_URL } from '../../injection-tokens'; describe('CodingManualComponent', () => { let component: CodingManualComponent; @@ -16,7 +17,7 @@ describe('CodingManualComponent', () => { beforeEach(async () => { await TestBed.configureTestingModule({ providers: [{ - provide: 'SERVER_URL', + provide: SERVER_URL, useValue: environment.backendUrl }, provideHttpClient(), { provide: ActivatedRoute, diff --git a/apps/frontend/src/app/components/home/home.component.spec.ts b/apps/frontend/src/app/components/home/home.component.spec.ts index 2b6097f9f..6f3d4ccbf 100755 --- a/apps/frontend/src/app/components/home/home.component.spec.ts +++ b/apps/frontend/src/app/components/home/home.component.spec.ts @@ -7,6 +7,7 @@ import { HomeComponent } from './home.component'; import { AuthService } from '../../auth/service/auth.service'; import { environment } from '../../../environments/environment'; import { AppService } from '../../services/app.service'; +import { SERVER_URL } from '../../injection-tokens'; const mockAuthService = { isLoggedIn: jest.fn(() => true) @@ -43,7 +44,7 @@ describe('HomeComponent', () => { { provide: AuthService, useValue: mockAuthService }, { provide: AppService, useValue: mockAppService }, { - provide: 'SERVER_URL', + provide: SERVER_URL, useValue: environment.backendUrl }, provideHttpClient()] }) diff --git a/apps/frontend/src/app/injection-tokens.ts b/apps/frontend/src/app/injection-tokens.ts new file mode 100644 index 000000000..720fc7284 --- /dev/null +++ b/apps/frontend/src/app/injection-tokens.ts @@ -0,0 +1,3 @@ +import { InjectionToken } from '@angular/core'; + +export const SERVER_URL = new InjectionToken('server.url'); diff --git a/apps/frontend/src/app/replay/components/replay/replay.component.spec.ts b/apps/frontend/src/app/replay/components/replay/replay.component.spec.ts index 486b92006..4e96d6560 100755 --- a/apps/frontend/src/app/replay/components/replay/replay.component.spec.ts +++ b/apps/frontend/src/app/replay/components/replay/replay.component.spec.ts @@ -4,6 +4,7 @@ import { ActivatedRoute } from '@angular/router'; import { TranslateModule } from '@ngx-translate/core'; import { ReplayComponent } from './replay.component'; import { environment } from '../../../../environments/environment'; +import { SERVER_URL } from '../../../injection-tokens'; describe('ReplayComponent', () => { let component: ReplayComponent; @@ -19,7 +20,7 @@ describe('ReplayComponent', () => { useValue: fakeActivatedRoute }, { - provide: 'SERVER_URL', + provide: SERVER_URL, useValue: environment.backendUrl }], imports: [ReplayComponent, HttpClientModule, TranslateModule.forRoot()] diff --git a/apps/frontend/src/app/replay/components/unit-player/unit-player.component.spec.ts b/apps/frontend/src/app/replay/components/unit-player/unit-player.component.spec.ts index 729f87d0e..a14d68577 100755 --- a/apps/frontend/src/app/replay/components/unit-player/unit-player.component.spec.ts +++ b/apps/frontend/src/app/replay/components/unit-player/unit-player.component.spec.ts @@ -3,6 +3,7 @@ import { TranslateModule } from '@ngx-translate/core'; import { HttpClientModule } from '@angular/common/http'; import { UnitPlayerComponent } from './unit-player.component'; import { environment } from '../../../../environments/environment'; +import { SERVER_URL } from '../../../injection-tokens'; describe('UnitPlayerComponent', () => { let component: UnitPlayerComponent; @@ -11,7 +12,7 @@ describe('UnitPlayerComponent', () => { beforeEach(async () => { await TestBed.configureTestingModule({ providers: [{ - provide: 'SERVER_URL', + provide: SERVER_URL, useValue: environment.backendUrl }], imports: [ diff --git a/apps/frontend/src/app/services/app.service.ts b/apps/frontend/src/app/services/app.service.ts index 1426f7e05..4228ad064 100755 --- a/apps/frontend/src/app/services/app.service.ts +++ b/apps/frontend/src/app/services/app.service.ts @@ -17,6 +17,7 @@ import { TestGroupsInListDto } from '../../../../../api-dto/test-groups/testgrou import { FilesInListDto } from '../../../../../api-dto/files/files-in-list.dto'; import { CreateUserDto } from '../../../../../api-dto/user/create-user-dto'; import { LogoService } from './logo.service'; +import { SERVER_URL } from '../injection-tokens'; type WorkspaceData = { testGroups: TestGroupsInListDto[]; @@ -28,7 +29,7 @@ type WorkspaceData = { providedIn: 'root' }) export class AppService { - public readonly serverUrl = inject('SERVER_URL' as any); + readonly serverUrl = inject(SERVER_URL); private http = inject(HttpClient); private logoService = inject(LogoService); @@ -78,11 +79,6 @@ export class AppService { ); } - /** - * Logs in using Keycloak - * @param user The user to log in - * @returns An Observable of whether the login was successful - */ keycloakLogin(user: CreateUserDto): Observable { return this.http.post(`${this.serverUrl}keycloak-login`, user) .pipe( @@ -111,11 +107,6 @@ export class AppService { ); } - /** - * Gets authentication data for the specified identity - * @param id The identity to get auth data for - * @returns An Observable of the auth data - */ getAuthData(id: string): Observable { return this.http.get( `${this.serverUrl}auth-data?identity=${id}`, @@ -123,9 +114,6 @@ export class AppService { ); } - /** - * Refreshes the auth data by fetching it from the backend - */ refreshAuthData(): void { if (this.loggedUser?.sub) { this.getAuthData(this.loggedUser.sub).subscribe(authData => { @@ -134,9 +122,6 @@ export class AppService { } } - /** - * Loads saved logo settings from the server - */ private loadLogoSettings(): void { this.logoService.getLogoSettings().subscribe({ next: settings => { diff --git a/apps/frontend/src/app/services/backend.service.ts b/apps/frontend/src/app/services/backend.service.ts index 15f6b0487..51ffa75e7 100755 --- a/apps/frontend/src/app/services/backend.service.ts +++ b/apps/frontend/src/app/services/backend.service.ts @@ -36,6 +36,7 @@ import { ResourcePackageDto } from '../../../../../api-dto/resource-package/reso import { PaginatedWorkspaceUserDto } from '../../../../../api-dto/workspaces/paginated-workspace-user-dto'; import { InvalidVariableDto } from '../../../../../api-dto/files/variable-validation.dto'; import { TestTakersValidationDto } from '../../../../../api-dto/files/testtakers-validation.dto'; +import { SERVER_URL } from '../injection-tokens'; interface PaginatedResponse { data: T[]; @@ -85,7 +86,7 @@ interface ResponseEntity { providedIn: 'root' }) export class BackendService { - private readonly serverUrl = inject('SERVER_URL' as any); + readonly serverUrl = inject(SERVER_URL); private http = inject(HttpClient); appService = inject(AppService); diff --git a/apps/frontend/src/app/services/journal-interceptor.ts b/apps/frontend/src/app/services/journal-interceptor.ts index 8f4f80051..c1d904013 100644 --- a/apps/frontend/src/app/services/journal-interceptor.ts +++ b/apps/frontend/src/app/services/journal-interceptor.ts @@ -10,9 +10,6 @@ import { Observable, tap } from 'rxjs'; import { AppService } from './app.service'; import { JournalService } from './journal.service'; -/** - * Functional interceptor for logging HTTP requests to the journal - */ export const journalInterceptor: HttpInterceptorFn = ( request: HttpRequest, next: HttpHandlerFn diff --git a/apps/frontend/src/app/services/journal.service.ts b/apps/frontend/src/app/services/journal.service.ts index 1b15b4d47..780310717 100644 --- a/apps/frontend/src/app/services/journal.service.ts +++ b/apps/frontend/src/app/services/journal.service.ts @@ -1,6 +1,7 @@ import { Injectable, inject } from '@angular/core'; import { HttpClient } from '@angular/common/http'; import { Observable, catchError, of } from 'rxjs'; +import { SERVER_URL } from '../injection-tokens'; export interface JournalEntry { id: number; @@ -23,7 +24,7 @@ export interface PaginatedJournalEntries { providedIn: 'root' }) export class JournalService { - private readonly serverUrl = inject('SERVER_URL' as any); + readonly serverUrl = inject(SERVER_URL); private http = inject(HttpClient); authHeader = { Authorization: `Bearer ${localStorage.getItem('id_token')}` }; diff --git a/apps/frontend/src/app/services/logo.service.ts b/apps/frontend/src/app/services/logo.service.ts index 3cd8b29b0..d98e48eac 100644 --- a/apps/frontend/src/app/services/logo.service.ts +++ b/apps/frontend/src/app/services/logo.service.ts @@ -2,13 +2,14 @@ import { Injectable, inject } from '@angular/core'; import { HttpClient } from '@angular/common/http'; import { Observable } from 'rxjs'; import { AppLogoDto } from '../../../../../api-dto/app-logo-dto'; +import { SERVER_URL } from '../injection-tokens'; @Injectable({ providedIn: 'root' }) export class LogoService { private http = inject(HttpClient); - private readonly serverUrl = inject('SERVER_URL' as any); + readonly serverUrl = inject(SERVER_URL); authHeader = { Authorization: `Bearer ${localStorage.getItem('id_token')}` }; diff --git a/apps/frontend/src/app/sys-admin/components/sys-admin-settings/sys-admin-settings.component.spec.ts b/apps/frontend/src/app/sys-admin/components/sys-admin-settings/sys-admin-settings.component.spec.ts index 7b0f6fc41..701b6a3b7 100755 --- a/apps/frontend/src/app/sys-admin/components/sys-admin-settings/sys-admin-settings.component.spec.ts +++ b/apps/frontend/src/app/sys-admin/components/sys-admin-settings/sys-admin-settings.component.spec.ts @@ -4,6 +4,7 @@ import { provideHttpClient } from '@angular/common/http'; import { provideNoopAnimations } from '@angular/platform-browser/animations'; // Importieren import { SysAdminSettingsComponent } from './sys-admin-settings.component'; import { environment } from '../../../../environments/environment'; +import { SERVER_URL } from '../../../injection-tokens'; describe('SysAdminSettingsComponent', () => { let component: SysAdminSettingsComponent; @@ -14,7 +15,7 @@ describe('SysAdminSettingsComponent', () => { providers: [ provideHttpClient(), { - provide: 'SERVER_URL', + provide: SERVER_URL, useValue: environment.backendUrl }, provideNoopAnimations() // Hier hinzufügen diff --git a/apps/frontend/src/app/sys-admin/components/user-access-rights-dialog/user-access-rights-dialog.component.spec.ts b/apps/frontend/src/app/sys-admin/components/user-access-rights-dialog/user-access-rights-dialog.component.spec.ts index 4ab9568c7..2d49368bd 100755 --- a/apps/frontend/src/app/sys-admin/components/user-access-rights-dialog/user-access-rights-dialog.component.spec.ts +++ b/apps/frontend/src/app/sys-admin/components/user-access-rights-dialog/user-access-rights-dialog.component.spec.ts @@ -1,10 +1,11 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { HttpClientModule } from '@angular/common/http'; +import { provideHttpClient } from '@angular/common/http'; import { MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog'; import { TranslateModule } from '@ngx-translate/core'; import { MatIconModule } from '@angular/material/icon'; import { UserAccessRightsDialogComponent } from './user-access-rights-dialog.component'; import { environment } from '../../../../environments/environment'; +import { SERVER_URL } from '../../../injection-tokens'; describe('UserAccessRightsDialogComponent', () => { let component: UserAccessRightsDialogComponent; @@ -17,12 +18,12 @@ describe('UserAccessRightsDialogComponent', () => { useValue: {} }, { - provide: 'SERVER_URL', + provide: SERVER_URL, useValue: environment.backendUrl - } + }, + provideHttpClient() ], imports: [ - HttpClientModule, TranslateModule.forRoot(), MatDialogModule, MatIconModule diff --git a/apps/frontend/src/app/sys-admin/components/users-selection/users-selection.component.spec.ts b/apps/frontend/src/app/sys-admin/components/users-selection/users-selection.component.spec.ts index 783b5f6e0..eee3341a8 100755 --- a/apps/frontend/src/app/sys-admin/components/users-selection/users-selection.component.spec.ts +++ b/apps/frontend/src/app/sys-admin/components/users-selection/users-selection.component.spec.ts @@ -10,6 +10,7 @@ import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { provideHttpClient } from '@angular/common/http'; import { UsersSelectionComponent } from './users-selection.component'; import { environment } from '../../../../environments/environment'; +import { SERVER_URL } from '../../../injection-tokens'; describe('UsersSelectionComponent', () => { let component: UsersSelectionComponent; @@ -29,7 +30,7 @@ describe('UsersSelectionComponent', () => { providers: [ provideHttpClient(), { - provide: 'SERVER_URL', + provide: SERVER_URL, useValue: environment.backendUrl } ] diff --git a/apps/frontend/src/app/sys-admin/components/workspace-access-rights-dialog/workspace-access-rights-dialog.component.spec.ts b/apps/frontend/src/app/sys-admin/components/workspace-access-rights-dialog/workspace-access-rights-dialog.component.spec.ts index 2b335f7be..6f1508ad4 100755 --- a/apps/frontend/src/app/sys-admin/components/workspace-access-rights-dialog/workspace-access-rights-dialog.component.spec.ts +++ b/apps/frontend/src/app/sys-admin/components/workspace-access-rights-dialog/workspace-access-rights-dialog.component.spec.ts @@ -5,6 +5,7 @@ import { TranslateModule } from '@ngx-translate/core'; import { MatIconModule } from '@angular/material/icon'; import { WorkspaceAccessRightsDialogComponent } from './workspace-access-rights-dialog.component'; import { environment } from '../../../../environments/environment'; +import { SERVER_URL } from '../../../injection-tokens'; describe('WorkspaceAccessRightsDialogComponent', () => { let component: WorkspaceAccessRightsDialogComponent; @@ -17,7 +18,7 @@ describe('WorkspaceAccessRightsDialogComponent', () => { useValue: {} }, { - provide: 'SERVER_URL', + provide: SERVER_URL, useValue: environment.backendUrl } ], diff --git a/apps/frontend/src/app/sys-admin/components/workspaces-selection/workspaces-selection.component.spec.ts b/apps/frontend/src/app/sys-admin/components/workspaces-selection/workspaces-selection.component.spec.ts index 0ad71789d..5e052b3e7 100755 --- a/apps/frontend/src/app/sys-admin/components/workspaces-selection/workspaces-selection.component.spec.ts +++ b/apps/frontend/src/app/sys-admin/components/workspaces-selection/workspaces-selection.component.spec.ts @@ -11,6 +11,7 @@ import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { provideHttpClient } from '@angular/common/http'; import { WorkspacesSelectionComponent } from './workspaces-selection.component'; import { environment } from '../../../../environments/environment'; +import { SERVER_URL } from '../../../injection-tokens'; describe('WorkspacesSelectionComponent', () => { let component: WorkspacesSelectionComponent; @@ -31,7 +32,7 @@ describe('WorkspacesSelectionComponent', () => { providers: [ provideHttpClient(), { - provide: 'SERVER_URL', + provide: SERVER_URL, useValue: environment.backendUrl } ] diff --git a/apps/frontend/src/app/sys-admin/components/workspaces/workspaces.component.spec.ts b/apps/frontend/src/app/sys-admin/components/workspaces/workspaces.component.spec.ts index d868aa3b5..5f27861c5 100755 --- a/apps/frontend/src/app/sys-admin/components/workspaces/workspaces.component.spec.ts +++ b/apps/frontend/src/app/sys-admin/components/workspaces/workspaces.component.spec.ts @@ -12,6 +12,7 @@ import { MatDialogModule } from '@angular/material/dialog'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { WorkspacesComponent } from './workspaces.component'; import { environment } from '../../../../environments/environment'; +import { SERVER_URL } from '../../../injection-tokens'; describe('WorkspaceGroupsComponent', () => { let component: WorkspacesComponent; @@ -32,7 +33,7 @@ describe('WorkspaceGroupsComponent', () => { providers: [ provideHttpClient(), { - provide: 'SERVER_URL', + provide: SERVER_URL, useValue: environment.backendUrl } ] diff --git a/apps/frontend/src/app/ws-admin/components/test-center-import/test-center-import.component.spec.ts b/apps/frontend/src/app/ws-admin/components/test-center-import/test-center-import.component.spec.ts index 1a1cfe0b1..303812edc 100755 --- a/apps/frontend/src/app/ws-admin/components/test-center-import/test-center-import.component.spec.ts +++ b/apps/frontend/src/app/ws-admin/components/test-center-import/test-center-import.component.spec.ts @@ -1,10 +1,11 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { HttpClientModule } from '@angular/common/http'; +import { provideHttpClient } from '@angular/common/http'; import { MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog'; import { TranslateModule } from '@ngx-translate/core'; import { MatIconModule } from '@angular/material/icon'; import { TestCenterImportComponent } from './test-center-import.component'; import { environment } from '../../../../environments/environment'; +import { SERVER_URL } from '../../../injection-tokens'; describe('TestCenterImportComponent', () => { let component: TestCenterImportComponent; @@ -13,15 +14,15 @@ describe('TestCenterImportComponent', () => { beforeEach(async () => { await TestBed.configureTestingModule({ providers: [{ - provide: 'SERVER_URL', + provide: SERVER_URL, useValue: environment.backendUrl }, { provide: MAT_DIALOG_DATA, useValue: {} - } + }, + provideHttpClient() ], imports: [ - HttpClientModule, TranslateModule.forRoot(), MatDialogModule, MatIconModule diff --git a/apps/frontend/src/app/ws-admin/components/test-files/test-files.component.spec.ts b/apps/frontend/src/app/ws-admin/components/test-files/test-files.component.spec.ts index 3fab95677..eaf77116a 100755 --- a/apps/frontend/src/app/ws-admin/components/test-files/test-files.component.spec.ts +++ b/apps/frontend/src/app/ws-admin/components/test-files/test-files.component.spec.ts @@ -4,6 +4,7 @@ import { HttpClientModule } from '@angular/common/http'; import { ActivatedRoute } from '@angular/router'; import { TestFilesComponent } from './test-files.component'; import { environment } from '../../../../environments/environment'; +import { SERVER_URL } from '../../../injection-tokens'; describe('TestFilesComponent', () => { let component: TestFilesComponent; @@ -19,7 +20,7 @@ describe('TestFilesComponent', () => { useValue: fakeActivatedRoute }, { - provide: 'SERVER_URL', + provide: SERVER_URL, useValue: environment.backendUrl }], imports: [ diff --git a/apps/frontend/src/app/ws-admin/components/test-groups/test-groups.component.spec.ts b/apps/frontend/src/app/ws-admin/components/test-groups/test-groups.component.spec.ts index 9995ae993..423defcdd 100755 --- a/apps/frontend/src/app/ws-admin/components/test-groups/test-groups.component.spec.ts +++ b/apps/frontend/src/app/ws-admin/components/test-groups/test-groups.component.spec.ts @@ -4,12 +4,13 @@ import { TranslateModule } from '@ngx-translate/core'; import { MatIconModule } from '@angular/material/icon'; import { MatTooltipModule } from '@angular/material/tooltip'; import { MatSnackBarModule } from '@angular/material/snack-bar'; -import { HttpClientModule } from '@angular/common/http'; +import { provideHttpClient } from '@angular/common/http'; import { MatTableModule } from '@angular/material/table'; import { MatCheckboxModule } from '@angular/material/checkbox'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { environment } from '../../../../environments/environment'; import { TestGroupsComponent } from './test-groups.component'; +import { SERVER_URL } from '../../../injection-tokens'; describe('UsersComponent', () => { let component: TestGroupsComponent; @@ -24,15 +25,15 @@ describe('UsersComponent', () => { MatTooltipModule, MatIconModule, MatTableModule, - HttpClientModule, NoopAnimationsModule, TranslateModule.forRoot() ], providers: [ { - provide: 'SERVER_URL', + provide: SERVER_URL, useValue: environment.backendUrl - } + }, + provideHttpClient() ] }).compileComponents(); diff --git a/apps/frontend/src/app/ws-admin/components/test-results/test-results.component.spec.ts b/apps/frontend/src/app/ws-admin/components/test-results/test-results.component.spec.ts index 58b946340..f5c21f326 100755 --- a/apps/frontend/src/app/ws-admin/components/test-results/test-results.component.spec.ts +++ b/apps/frontend/src/app/ws-admin/components/test-results/test-results.component.spec.ts @@ -10,6 +10,7 @@ import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { provideHttpClient } from '@angular/common/http'; import { environment } from '../../../../environments/environment'; import { TestResultsComponent } from './test-results.component'; +import { SERVER_URL } from '../../../injection-tokens'; describe('TestResultsComponent', () => { let component: TestResultsComponent; @@ -29,7 +30,7 @@ describe('TestResultsComponent', () => { providers: [ provideHttpClient(), { - provide: 'SERVER_URL', + provide: SERVER_URL, useValue: environment.backendUrl } ] diff --git a/apps/frontend/src/app/ws-admin/components/ws-access-rights/ws-access-rights.component.spec.ts b/apps/frontend/src/app/ws-admin/components/ws-access-rights/ws-access-rights.component.spec.ts index d79975239..70b248696 100755 --- a/apps/frontend/src/app/ws-admin/components/ws-access-rights/ws-access-rights.component.spec.ts +++ b/apps/frontend/src/app/ws-admin/components/ws-access-rights/ws-access-rights.component.spec.ts @@ -10,6 +10,7 @@ import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { provideHttpClient } from '@angular/common/http'; import { WsAccessRightsComponent } from './ws-access-rights.component'; import { environment } from '../../../../environments/environment'; +import { SERVER_URL } from '../../../injection-tokens'; describe('WsAccessRightsComponent', () => { let component: WsAccessRightsComponent; @@ -18,7 +19,7 @@ describe('WsAccessRightsComponent', () => { beforeEach(async () => { await TestBed.configureTestingModule({ providers: [provideHttpClient(), { - provide: 'SERVER_URL', + provide: SERVER_URL, useValue: environment.backendUrl }], imports: [ diff --git a/apps/frontend/src/app/ws-admin/components/ws-admin/ws-admin.component.spec.ts b/apps/frontend/src/app/ws-admin/components/ws-admin/ws-admin.component.spec.ts index 2f1f9f762..5eb18bac7 100755 --- a/apps/frontend/src/app/ws-admin/components/ws-admin/ws-admin.component.spec.ts +++ b/apps/frontend/src/app/ws-admin/components/ws-admin/ws-admin.component.spec.ts @@ -1,10 +1,11 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { HttpClientModule } from '@angular/common/http'; +import { provideHttpClient } from '@angular/common/http'; import { TranslateModule } from '@ngx-translate/core'; import { MatTabsModule } from '@angular/material/tabs'; import { ActivatedRoute } from '@angular/router'; import { WsAdminComponent } from './ws-admin.component'; import { environment } from '../../../../environments/environment'; +import { SERVER_URL } from '../../../injection-tokens'; describe('WsAdminComponent', () => { let component: WsAdminComponent; @@ -20,12 +21,12 @@ describe('WsAdminComponent', () => { provide: ActivatedRoute, useValue: fakeActivatedRoute }, { - provide: 'SERVER_URL', + provide: SERVER_URL, useValue: environment.backendUrl - }], + }, + provideHttpClient()], imports: [ MatTabsModule, - HttpClientModule, TranslateModule.forRoot() ] }) diff --git a/apps/frontend/src/app/ws-admin/components/ws-settings/ws-settings.component.spec.ts b/apps/frontend/src/app/ws-admin/components/ws-settings/ws-settings.component.spec.ts index 9d9f483dd..497cdfe51 100755 --- a/apps/frontend/src/app/ws-admin/components/ws-settings/ws-settings.component.spec.ts +++ b/apps/frontend/src/app/ws-admin/components/ws-settings/ws-settings.component.spec.ts @@ -10,6 +10,7 @@ import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { provideHttpClient } from '@angular/common/http'; import { WsSettingsComponent } from './ws-settings.component'; import { environment } from '../../../../environments/environment'; +import { SERVER_URL } from '../../../injection-tokens'; describe('WsSettingsComponent', () => { let component: WsSettingsComponent; @@ -19,7 +20,7 @@ describe('WsSettingsComponent', () => { await TestBed.configureTestingModule({ providers: [provideHttpClient(), { - provide: 'SERVER_URL', + provide: SERVER_URL, useValue: environment.backendUrl }], imports: [ diff --git a/apps/frontend/src/app/ws-admin/components/ws-users/ws-users.component.spec.ts b/apps/frontend/src/app/ws-admin/components/ws-users/ws-users.component.spec.ts index d60c22f44..c6de4f0d6 100755 --- a/apps/frontend/src/app/ws-admin/components/ws-users/ws-users.component.spec.ts +++ b/apps/frontend/src/app/ws-admin/components/ws-users/ws-users.component.spec.ts @@ -10,6 +10,7 @@ import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { provideHttpClient } from '@angular/common/http'; import { WsUsersComponent } from './ws-users.component'; import { environment } from '../../../../environments/environment'; +import { SERVER_URL } from '../../../injection-tokens'; describe('WsUsersComponent', () => { let component: WsUsersComponent; @@ -29,7 +30,7 @@ describe('WsUsersComponent', () => { providers: [ provideHttpClient(), { - provide: 'SERVER_URL', + provide: SERVER_URL, useValue: environment.backendUrl } ] From 88c98e2c6d203d9769ece69958aaa1cac47aeb82 Mon Sep 17 00:00:00 2001 From: jurei733 <67505990+jurei733@users.noreply.github.com> Date: Sun, 6 Jul 2025 17:57:29 +0200 Subject: [PATCH 06/36] Fix backend linting --- .../app/database/services/person.service.ts | 4 +++ .../services/workspace-files.service.ts | 12 +++---- .../services/workspace-player.service.ts | 2 +- .../app/utils/voud/extractVariableLocation.ts | 34 +++++++++++++++---- 4 files changed, 37 insertions(+), 15 deletions(-) diff --git a/apps/backend/src/app/database/services/person.service.ts b/apps/backend/src/app/database/services/person.service.ts index 6d85b323f..b3fd653fd 100644 --- a/apps/backend/src/app/database/services/person.service.ts +++ b/apps/backend/src/app/database/services/person.service.ts @@ -367,6 +367,7 @@ export class PersonService { }); } + // eslint-disable-next-line @typescript-eslint/no-explicit-any private extractVariablesFromSubforms(subforms: any[]): Set { const variables = new Set(); subforms.forEach(subform => subform.responses.forEach(response => variables.add(response.id)) @@ -654,6 +655,7 @@ export class PersonService { async saveSubformResponsesForUnit( savedUnit: Unit, + // eslint-disable-next-line @typescript-eslint/no-explicit-any subforms: any[] ): Promise<{ success: boolean; saved: number; skipped: number }> { try { @@ -765,7 +767,9 @@ export class PersonService { async processPersonLogs( persons: Person[], + // eslint-disable-next-line @typescript-eslint/no-explicit-any unitLogs: any, + // eslint-disable-next-line @typescript-eslint/no-explicit-any bookletLogs: any, overwriteExistingLogs: boolean = true ): Promise<{ diff --git a/apps/backend/src/app/database/services/workspace-files.service.ts b/apps/backend/src/app/database/services/workspace-files.service.ts index 87a8c27d2..e5988937a 100644 --- a/apps/backend/src/app/database/services/workspace-files.service.ts +++ b/apps/backend/src/app/database/services/workspace-files.service.ts @@ -300,7 +300,9 @@ export class WorkspaceFilesService { } private extractXmlData( + // eslint-disable-next-line @typescript-eslint/no-explicit-any bookletTags: cheerio.Cheerio, + // eslint-disable-next-line @typescript-eslint/no-explicit-any unitTags: cheerio.Cheerio ): { uniqueBooklets: Set; @@ -558,7 +560,7 @@ export class WorkspaceFilesService { } } - // @ts-expect-error + // @ts-expect-error: not exact match const fileUpload = this.fileUploadRepository.create({ workspace_id: workspaceId, filename: file.originalname, @@ -1004,9 +1006,7 @@ export class WorkspaceFilesService { } unitVariables.set(unitName, variables); } - } catch (e) { - console.error(`Could not parse Unit file ${unitFile.filename}: ${e.message}`); - } + } catch (e) { /* empty */ } } const invalidVariables: InvalidVariableDto[] = []; @@ -1142,9 +1142,7 @@ export class WorkspaceFilesService { unitVariableTypes.set(unitName, variableTypes); } - } catch (e) { - console.error(`Could not parse Unit file ${unitFile.filename}: ${e.message}`); - } + } catch (e) { /* empty */ } } const invalidVariables: InvalidVariableDto[] = []; diff --git a/apps/backend/src/app/database/services/workspace-player.service.ts b/apps/backend/src/app/database/services/workspace-player.service.ts index 080cd72ce..fad51e92d 100644 --- a/apps/backend/src/app/database/services/workspace-player.service.ts +++ b/apps/backend/src/app/database/services/workspace-player.service.ts @@ -138,7 +138,7 @@ export class WorkspacePlayerService { const res = this.responseRepository .find({ select: ['unitid'], - //where: { testPerson: testPerson }, + // where: { testPerson: testPerson }, order: { unitid: 'ASC' } }); if (res) { diff --git a/apps/backend/src/app/utils/voud/extractVariableLocation.ts b/apps/backend/src/app/utils/voud/extractVariableLocation.ts index 0925ec6d5..48191e125 100644 --- a/apps/backend/src/app/utils/voud/extractVariableLocation.ts +++ b/apps/backend/src/app/utils/voud/extractVariableLocation.ts @@ -6,7 +6,7 @@ function collectIdsWithKeyedPaths( skipCollect = false ) { if (Array.isArray(node)) { - node.forEach((child, index) => { + node.forEach(child => { collectIdsWithKeyedPaths(child, path, collected, visibility, skipCollect); }); return collected; @@ -14,8 +14,9 @@ function collectIdsWithKeyedPaths( if (typeof node === 'object' && node !== null) { // If at a 'page' level object, update visibility + let currentVisibility = visibility; if ('alwaysVisible' in node && 'sections' in node) { - visibility = node.alwaysVisible; + currentVisibility = node.alwaysVisible; } // Only collect if not under a skipped key @@ -24,7 +25,7 @@ function collectIdsWithKeyedPaths( id: node.id, markingPanels: node.markingPanels, connectedTo: node.connectedTo, - alwaysVisible: visibility, + alwaysVisible: currentVisibility, path: { ...path } }); } @@ -42,7 +43,7 @@ function collectIdsWithKeyedPaths( child, newPath, collected, - visibility, + currentVisibility, shouldSkip ); }); @@ -52,7 +53,7 @@ function collectIdsWithKeyedPaths( value, newPath, collected, - visibility, + currentVisibility, shouldSkip ); } @@ -84,8 +85,27 @@ function findDependencies(data) { }); } -export const extractVariableLocation = function (definitions:{ definition: string }[]): any { - return definitions.map((unit:any) => { +interface UnitWithDefinition { + definition: string; + variable_pages?: VariablePage[]; + [key: string]: unknown; +} + +interface VariablePage { + variable_ref: string; + variable_path: Record; + variable_page_always_visible: boolean | null; + variable_dependencies: VariableDependency[]; +} + +interface VariableDependency { + variable_dependency_ref: string; + variable_dependency_path: Record; + variable_dependency_page_always_visible: boolean | null; +} + +export const extractVariableLocation = function extractVariableLocations(definitions: UnitWithDefinition[]): UnitWithDefinition[] { + return definitions.map((unit: UnitWithDefinition) => { const definitionParsed = JSON.parse(unit.definition); const data = collectIdsWithKeyedPaths(definitionParsed); From e4647b16636d87911ae49894a1db6c1a0f88cbfd Mon Sep 17 00:00:00 2001 From: jurei733 <67505990+jurei733@users.noreply.github.com> Date: Sun, 6 Jul 2025 20:13:42 +0200 Subject: [PATCH 07/36] Refactor backend Service into specialized services --- .../app/services/authentication.service.ts | 29 + .../src/app/services/backend.service.ts | 966 +++--------------- .../src/app/services/coding.service.ts | 167 +++ .../frontend/src/app/services/file.service.ts | 168 +++ .../src/app/services/import.service.ts | 90 ++ .../app/services/resource-package.service.ts | 66 ++ .../src/app/services/response.service.ts | 227 ++++ .../src/app/services/test-result.service.ts | 201 ++++ .../src/app/services/unit-note.service.ts | 53 + .../src/app/services/unit-tag.service.ts | 53 + .../frontend/src/app/services/unit.service.ts | 82 ++ .../frontend/src/app/services/user.service.ts | 98 ++ .../src/app/services/validation.service.ts | 142 +++ .../src/app/services/workspace.service.ts | 93 ++ .../test-center-import.component.ts | 14 - 15 files changed, 1634 insertions(+), 815 deletions(-) create mode 100644 apps/frontend/src/app/services/authentication.service.ts create mode 100644 apps/frontend/src/app/services/coding.service.ts create mode 100644 apps/frontend/src/app/services/file.service.ts create mode 100644 apps/frontend/src/app/services/import.service.ts create mode 100644 apps/frontend/src/app/services/resource-package.service.ts create mode 100644 apps/frontend/src/app/services/response.service.ts create mode 100644 apps/frontend/src/app/services/test-result.service.ts create mode 100644 apps/frontend/src/app/services/unit-note.service.ts create mode 100644 apps/frontend/src/app/services/unit-tag.service.ts create mode 100644 apps/frontend/src/app/services/unit.service.ts create mode 100644 apps/frontend/src/app/services/user.service.ts create mode 100644 apps/frontend/src/app/services/validation.service.ts create mode 100644 apps/frontend/src/app/services/workspace.service.ts diff --git a/apps/frontend/src/app/services/authentication.service.ts b/apps/frontend/src/app/services/authentication.service.ts new file mode 100644 index 000000000..f89f0c3a2 --- /dev/null +++ b/apps/frontend/src/app/services/authentication.service.ts @@ -0,0 +1,29 @@ +import { Injectable, inject } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { Observable } from 'rxjs'; +import { SERVER_URL } from '../injection-tokens'; + +export interface ServerResponse { + success: boolean; + token?: string; + message?: string; +} + +@Injectable({ + providedIn: 'root' +}) +export class AuthenticationService { + readonly serverUrl = inject(SERVER_URL); + private http = inject(HttpClient); + + get authHeader() { + return { Authorization: `Bearer ${localStorage.getItem('id_token')}` }; + } + + authenticate(username: string, password: string, server: string, url: string): Observable { + return this.http + .post(`${this.serverUrl}tc_authentication`, { + username, password, server, url + }); + } +} diff --git a/apps/frontend/src/app/services/backend.service.ts b/apps/frontend/src/app/services/backend.service.ts index 51ffa75e7..4a55e3ec0 100755 --- a/apps/frontend/src/app/services/backend.service.ts +++ b/apps/frontend/src/app/services/backend.service.ts @@ -1,42 +1,47 @@ import { Injectable, inject } from '@angular/core'; -import { HttpClient, HttpParams } from '@angular/common/http'; -import { - catchError, forkJoin, map, Observable, of, switchMap -} from 'rxjs'; -import { logger } from 'nx/src/utils/logger'; -import { CreateUserDto } from '../../../../../api-dto/user/create-user-dto'; -// eslint-disable-next-line import/no-cycle +import { HttpClient } from '@angular/common/http'; +import { Observable } from 'rxjs'; +import { FilesInListDto } from 'api-dto/files/files-in-list.dto'; +import { UnitNoteDto } from 'api-dto/unit-notes/unit-note.dto'; +import { UpdateUnitTagDto } from 'api-dto/unit-tags/update-unit-tag.dto'; +import { UnitTagDto } from 'api-dto/unit-tags/unit-tag.dto'; +import { CreateUnitTagDto } from 'api-dto/unit-tags/create-unit-tag.dto'; +import { CreateWorkspaceDto } from 'api-dto/workspaces/create-workspace-dto'; +import { PaginatedWorkspacesDto } from 'api-dto/workspaces/paginated-workspaces-dto'; import { AppService } from './app.service'; -import { UserFullDto } from '../../../../../api-dto/user/user-full-dto'; -import { WorkspaceFullDto } from '../../../../../api-dto/workspaces/workspace-full-dto'; -import { CreateWorkspaceDto } from '../../../../../api-dto/workspaces/create-workspace-dto'; +import { TestGroupsInfoDto } from '../../../../../api-dto/files/test-groups-info.dto'; +import { SERVER_URL } from '../injection-tokens'; +import { UserService } from './user.service'; +import { WorkspaceService } from './workspace.service'; +import { FileService } from './file.service'; +import { CodingService } from './coding.service'; +import { UnitTagService } from './unit-tag.service'; +import { UnitNoteService } from './unit-note.service'; +import { ResponseService } from './response.service'; +import { TestResultService } from './test-result.service'; +import { ResourcePackageService } from './resource-package.service'; +import { ValidationService } from './validation.service'; +import { UnitService } from './unit.service'; // eslint-disable-next-line import/no-cycle -import { - ImportOptions, - Result, - ServerResponse -} from '../ws-admin/components/test-center-import/test-center-import.component'; -import { FilesInListDto } from '../../../../../api-dto/files/files-in-list.dto'; -import { ResponseDto } from '../../../../../api-dto/responses/response-dto'; +import { ImportService } from './import.service'; +import { AuthenticationService } from './authentication.service'; import { FilesDto } from '../../../../../api-dto/files/files.dto'; -import { UserInListDto } from '../../../../../api-dto/user/user-in-list-dto'; -import { UserWorkspaceAccessDto } from '../../../../../api-dto/workspaces/user-workspace-access-dto'; +import { CreateUnitNoteDto } from '../../../../../api-dto/unit-notes/create-unit-note.dto'; +import { WorkspaceFullDto } from '../../../../../api-dto/workspaces/workspace-full-dto'; +import { CodingStatistics } from '../../../../../api-dto/coding/coding-statistics'; import { FileValidationResultDto } from '../../../../../api-dto/files/file-validation-result.dto'; import { FileDownloadDto } from '../../../../../api-dto/files/file-download.dto'; -import { TestGroupsInfoDto } from '../../../../../api-dto/files/test-groups-info.dto'; -import { CodingStatistics } from '../../../../../api-dto/coding/coding-statistics'; -import { PaginatedWorkspacesDto } from '../../../../../api-dto/workspaces/paginated-workspaces-dto'; -import { UnitTagDto } from '../../../../../api-dto/unit-tags/unit-tag.dto'; -import { CreateUnitTagDto } from '../../../../../api-dto/unit-tags/create-unit-tag.dto'; -import { UpdateUnitTagDto } from '../../../../../api-dto/unit-tags/update-unit-tag.dto'; -import { UnitNoteDto } from '../../../../../api-dto/unit-notes/unit-note.dto'; -import { CreateUnitNoteDto } from '../../../../../api-dto/unit-notes/create-unit-note.dto'; -import { UpdateUnitNoteDto } from '../../../../../api-dto/unit-notes/update-unit-note.dto'; -import { ResourcePackageDto } from '../../../../../api-dto/resource-package/resource-package-dto'; import { PaginatedWorkspaceUserDto } from '../../../../../api-dto/workspaces/paginated-workspace-user-dto'; -import { InvalidVariableDto } from '../../../../../api-dto/files/variable-validation.dto'; +import { UserFullDto } from '../../../../../api-dto/user/user-full-dto'; +import { CreateUserDto } from '../../../../../api-dto/user/create-user-dto'; +import { UserWorkspaceAccessDto } from '../../../../../api-dto/workspaces/user-workspace-access-dto'; +import { UserInListDto } from '../../../../../api-dto/user/user-in-list-dto'; +import { ResourcePackageDto } from '../../../../../api-dto/resource-package/resource-package-dto'; import { TestTakersValidationDto } from '../../../../../api-dto/files/testtakers-validation.dto'; -import { SERVER_URL } from '../injection-tokens'; +import { ImportOptions, Result } from '../ws-admin/components/test-center-import/test-center-import.component'; +import { UpdateUnitNoteDto } from '../../../../../api-dto/unit-notes/update-unit-note.dto'; +import { ResponseDto } from '../../../../../api-dto/responses/response-dto'; +import { InvalidVariableDto } from '../../../../../api-dto/files/variable-validation.dto'; interface PaginatedResponse { data: T[]; @@ -90,302 +95,122 @@ export class BackendService { private http = inject(HttpClient); appService = inject(AppService); + // Inject specialized services + private userService = inject(UserService); + private workspaceService = inject(WorkspaceService); + private fileService = inject(FileService); + private codingService = inject(CodingService); + private unitTagService = inject(UnitTagService); + private unitNoteService = inject(UnitNoteService); + private responseService = inject(ResponseService); + private testResultService = inject(TestResultService); + private resourcePackageService = inject(ResourcePackageService); + private validationService = inject(ValidationService); + private unitService = inject(UnitService); + private importService = inject(ImportService); + private authenticationService = inject(AuthenticationService); + authHeader = { Authorization: `Bearer ${localStorage.getItem('id_token')}` }; getDirectDownloadLink(): string { - return `${this.serverUrl}packages/`; + return this.fileService.getDirectDownloadLink(); } - getUsers(workspaceId:number): Observable { - return this.http - .get(`${this.serverUrl}admin/users/access/${workspaceId}`, { headers: this.authHeader }); + getUsers(workspaceId: number): Observable { + return this.userService.getUsers(workspaceId); } - saveUsers(workspaceId:number, users:UserWorkspaceAccessDto[]): Observable { - return this.http - .patch(`${this.serverUrl}admin/users/access/${workspaceId}`, - users, - { headers: this.authHeader }); + saveUsers(workspaceId: number, users: UserWorkspaceAccessDto[]): Observable { + return this.userService.saveUsers(workspaceId, users); } getUsersFull(): Observable { - return this.http - .get( - `${this.serverUrl}admin/users/full`, - { headers: this.authHeader }) - .pipe( - catchError(() => of([])) - ); + return this.userService.getUsersFull(); } addUser(newUser: CreateUserDto): Observable { - return this.http - .post( - `${this.serverUrl}admin/users`, - newUser, - { headers: this.authHeader } - ) - .pipe( - catchError(() => of(false)), - map(() => true) - ); - } - - changeUserData(userId:number, newData: UserFullDto): Observable { - return this.http - .patch( - `${this.serverUrl}admin/users/${userId}`, - newData, - { headers: this.authHeader }) - .pipe( - catchError(() => of(false)), - map(() => true) - ); + return this.userService.addUser(newUser); + } + + changeUserData(userId: number, newData: UserFullDto): Observable { + return this.userService.changeUserData(userId, newData); } deleteUsers(users: number[]): Observable { - return this.http - .delete(`${this.serverUrl}admin/users/${users.join(';')}`, - { headers: this.authHeader }) - .pipe( - catchError(() => of(false)), - map(() => true) - ); + return this.userService.deleteUsers(users); } getAllWorkspacesList(): Observable { - return this.http - .get(`${this.serverUrl}admin/workspace`, - { headers: this.authHeader }) - .pipe( - catchError(() => { - const defaultResponse: PaginatedWorkspacesDto = { - data: [], - total: 0, - page: 0, - limit: 0 - }; - return of(defaultResponse); - }) - ); - } - - getWorkspacesByUserList(userId:number): Observable { - return this.http - .get(`${this.serverUrl}admin/users/${userId}/workspaces`, - { headers: this.authHeader }) - .pipe( - catchError(() => of([])) - ); - } - - getWorkspaceUsers(workspaceId:number): Observable { - return this.http - .get(`${this.serverUrl}admin/workspace/${workspaceId}/users`, - { headers: this.authHeader }) - .pipe( - catchError(() => of({ - data: [], - total: 0, - page: 0, - limit: 0 - })) - ); + return this.workspaceService.getAllWorkspacesList(); + } + + getWorkspacesByUserList(userId: number): Observable { + return this.userService.getWorkspacesByUserList(userId); + } + + getWorkspaceUsers(workspaceId: number): Observable { + return this.workspaceService.getWorkspaceUsers(workspaceId); } addWorkspace(workspaceData: CreateWorkspaceDto): Observable { - return this.http - .post(`${this.serverUrl}admin/workspace`, workspaceData, { headers: this.authHeader }) - .pipe( - catchError(() => of(false)) - ); + return this.workspaceService.addWorkspace(workspaceData); } deleteWorkspace(ids: number[]): Observable { - const params = new HttpParams().set('ids', ids.join(';')); - return this.http - .delete(`${this.serverUrl}admin/workspace`, { - headers: this.authHeader, - params - }) - .pipe( - catchError(() => of(false)), - map(() => true) - ); + return this.workspaceService.deleteWorkspace(ids); } deleteFiles(workspaceId: number, fileIds: number[]): Observable { - const batchSize = 100; - const batches = []; - - for (let i = 0; i < fileIds.length; i += batchSize) { - batches.push(fileIds.slice(i, i + batchSize)); - } - - return batches.reduce>((acc, batch) => acc.pipe( - switchMap(() => this.http - .delete(`${this.serverUrl}admin/workspace/${workspaceId}/files`, { - headers: this.authHeader, - params: { fileIds: batch.join(';') } - }) - .pipe( - map(() => true), - catchError(() => of(false)) - ) - ) - ), of(true)); + return this.fileService.deleteFiles(workspaceId, fileIds); } downloadFile(workspaceId: number, fileId: number): Observable { - const url = `${this.serverUrl}admin/workspace/${workspaceId}/files/${fileId}/download`; - return this.http.get(url, { headers: this.authHeader }); - } - - validateFiles(workspace_id:number): Observable { - return this.http - .get( - `${this.serverUrl}admin/workspace/${workspace_id}/files/validation`, - { headers: this.authHeader }) - .pipe( - catchError(() => of(false)), - map(res => res) - ); - } - - deleteTestPersons(workspace_id:number, testPersonIds: number[]): Observable { - const params = new HttpParams().set('testPersons', testPersonIds.join(',')); - return this.http - .delete( - `${this.serverUrl}admin/workspace/${workspace_id}/test-results`, - { headers: this.authHeader, params }) - .pipe( - catchError(() => of(false)), - map(() => true) - ); - } - - codeTestPersons(workspace_id:number, testPersonIds: number[]): Observable<{ + return this.fileService.downloadFile(workspaceId, fileId); + } + + validateFiles(workspace_id: number): Observable { + return this.fileService.validateFiles(workspace_id); + } + + deleteTestPersons(workspace_id: number, testPersonIds: number[]): Observable { + return this.responseService.deleteTestPersons(workspace_id, testPersonIds); + } + + codeTestPersons(workspace_id: number, testPersonIds: number[]): Observable<{ totalResponses: number; statusCounts: { [key: string]: number; }; }> { - const params = new HttpParams().set('testPersons', testPersonIds.join(',')); - return this.http - .get<{ - totalResponses: number; - statusCounts: { - [key: string]: number; - }; - }>( - `${this.serverUrl}admin/workspace/${workspace_id}/coding`, - { headers: this.authHeader, params }) - .pipe( - catchError(() => of({ totalResponses: 0, statusCounts: {} })), - map(res => res) - ); - } - - getCodingList(workspace_id:number, page: number = 1, limit: number = 100): Observable> { - const identity = this.appService.loggedUser?.sub || ''; - return this.appService.createToken(workspace_id, identity, 60).pipe( - catchError(() => of('')), - switchMap(token => { - const params = new HttpParams() - .set('page', page.toString()) - .set('limit', limit.toString()) - .set('identity', identity) - .set('authToken', token) - .set('serverUrl', window.location.origin); - return this.http - .get>( - `${this.serverUrl}admin/workspace/${workspace_id}/coding/coding-list`, - { headers: this.authHeader, params } - ) - .pipe( - catchError(() => of({ - data: [], - total: 0, - page, - limit - })), - map(res => res) - ); - }) - ); + return this.codingService.codeTestPersons(workspace_id, testPersonIds); + } + + getCodingList(workspace_id: number, page: number = 1, limit: number = 100): Observable> { + return this.codingService.getCodingList(workspace_id, page, limit); } getCodingListAsCsv(workspace_id: number): Observable { - return this.http.get( - `${this.serverUrl}admin/workspace/${workspace_id}/coding/coding-list/csv`, - { - headers: this.authHeader, - responseType: 'arraybuffer' - } - ); + return this.codingService.getCodingListAsCsv(workspace_id); } getCodingListAsExcel(workspace_id: number): Observable { - return this.http.get( - `${this.serverUrl}admin/workspace/${workspace_id}/coding/coding-list/excel`, - { - headers: this.authHeader, - responseType: 'arraybuffer' - } - ); + return this.codingService.getCodingListAsExcel(workspace_id); } - getCodingStatistics(workspace_id:number): Observable { - return this.http - .get( - `${this.serverUrl}admin/workspace/${workspace_id}/coding/statistics`, - { headers: this.authHeader }) - .pipe( - catchError(() => of({ totalResponses: 0, statusCounts: {} })), - map(res => res) - ); - } - - getResponsesByStatus(workspace_id:number, status: string, page: number = 1, limit: number = 100): Observable> { - const params = new HttpParams() - .set('page', page.toString()) - .set('limit', limit.toString()); - - return this.http - .get>( - `${this.serverUrl}admin/workspace/${workspace_id}/coding/responses/${status}`, - { headers: this.authHeader, params } - ) - .pipe( - catchError(() => of({ - data: [], - total: 0, - page, - limit - })), - map(res => res) - ); + getCodingStatistics(workspace_id: number): Observable { + return this.codingService.getCodingStatistics(workspace_id); + } + + getResponsesByStatus(workspace_id: number, status: string, page: number = 1, limit: number = 100): Observable> { + return this.codingService.getResponsesByStatus(workspace_id, status, page, limit); } changeWorkspace(workspaceData: WorkspaceFullDto): Observable { - return this.http - .patch(`${this.serverUrl}admin/workspace`, workspaceData, { headers: this.authHeader }) - .pipe( - catchError(() => of(false)), - map(() => true) - ); + return this.workspaceService.changeWorkspace(workspaceData); } uploadTestFiles(workspaceId: number, files: FileList | null): Observable { - const formData = new FormData(); - if (files) { - for (let i = 0; i < files.length; i++) { - formData.append('files', files[i]); - } - } - return this.http.post(`${this.serverUrl}admin/workspace/${workspaceId}/upload`, formData, { - headers: this.authHeader - }); + return this.fileService.uploadTestFiles(workspaceId, files); } uploadTestResults( @@ -394,96 +219,57 @@ export class BackendService { resultType: 'logs' | 'responses', overwriteExisting: boolean = true ): Observable { - const formData = new FormData(); - if (files) { - for (let i = 0; i < files.length; i++) { - formData.append('files', files[i]); - } - } - const url = `${this.serverUrl}admin/workspace/${workspaceId}/upload/results/${resultType}?overwriteExisting=${overwriteExisting}`; - return this.http.post(url, formData, { - headers: this.authHeader - }); + return this.fileService.uploadTestResults(workspaceId, files, resultType, overwriteExisting); } setUserWorkspaceAccessRight(userId: number, workspaceIds: number[]): Observable { - return this.http.post( - `${this.serverUrl}admin/users/${userId}/workspaces/`, - workspaceIds, - { headers: this.authHeader }); + return this.userService.setUserWorkspaceAccessRight(userId, workspaceIds); } // Unit Tags API methods createUnitTag(workspaceId: number, createUnitTagDto: CreateUnitTagDto): Observable { - return this.http.post( - `${this.serverUrl}admin/workspace/${workspaceId}/unit-tags`, - createUnitTagDto, - { headers: this.authHeader }); + return this.unitTagService.createUnitTag(workspaceId, createUnitTagDto); } getUnitTags(workspaceId: number, unitId: number): Observable { - return this.http.get( - `${this.serverUrl}admin/workspace/${workspaceId}/unit-tags/unit/${unitId}`, - { headers: this.authHeader }); + return this.unitTagService.getUnitTags(workspaceId, unitId); } getUnitTag(workspaceId: number, tagId: number): Observable { - return this.http.get( - `${this.serverUrl}admin/workspace/${workspaceId}/unit-tags/${tagId}`, - { headers: this.authHeader }); + return this.unitTagService.getUnitTag(workspaceId, tagId); } updateUnitTag(workspaceId: number, tagId: number, updateUnitTagDto: UpdateUnitTagDto): Observable { - return this.http.patch( - `${this.serverUrl}admin/workspace/${workspaceId}/unit-tags/${tagId}`, - updateUnitTagDto, - { headers: this.authHeader }); + return this.unitTagService.updateUnitTag(workspaceId, tagId, updateUnitTagDto); } deleteUnitTag(workspaceId: number, tagId: number): Observable { - return this.http.delete( - `${this.serverUrl}admin/workspace/${workspaceId}/unit-tags/${tagId}`, - { headers: this.authHeader }); + return this.unitTagService.deleteUnitTag(workspaceId, tagId); } createUnitNote(workspaceId: number, createUnitNoteDto: CreateUnitNoteDto): Observable { - return this.http.post( - `${this.serverUrl}admin/workspace/${workspaceId}/unit-notes`, - createUnitNoteDto, - { headers: this.authHeader }); + return this.unitNoteService.createUnitNote(workspaceId, createUnitNoteDto); } getUnitNotes(workspaceId: number, unitId: number): Observable { - return this.http.get( - `${this.serverUrl}admin/workspace/${workspaceId}/unit-notes/unit/${unitId}`, - { headers: this.authHeader }); + return this.unitNoteService.getUnitNotes(workspaceId, unitId); } getUnitNote(workspaceId: number, noteId: number): Observable { - return this.http.get( - `${this.serverUrl}admin/workspace/${workspaceId}/unit-notes/${noteId}`, - { headers: this.authHeader }); + return this.unitNoteService.getUnitNote(workspaceId, noteId); } updateUnitNote(workspaceId: number, noteId: number, updateUnitNoteDto: UpdateUnitNoteDto): Observable { - return this.http.patch( - `${this.serverUrl}admin/workspace/${workspaceId}/unit-notes/${noteId}`, - updateUnitNoteDto, - { headers: this.authHeader }); + return this.unitNoteService.updateUnitNote(workspaceId, noteId, updateUnitNoteDto); } deleteUnitNote(workspaceId: number, noteId: number): Observable { - return this.http.delete( - `${this.serverUrl}admin/workspace/${workspaceId}/unit-notes/${noteId}`, - { headers: this.authHeader }); + return this.unitNoteService.deleteUnitNote(workspaceId, noteId); } setWorkspaceUsersAccessRight(workspaceId: number, userIds: number[]): Observable { - return this.http.post( - `${this.serverUrl}admin/workspace/${workspaceId}/users/`, - userIds, - { headers: this.authHeader }); + return this.workspaceService.setWorkspaceUsersAccessRight(workspaceId, userIds); } getFilesList( @@ -494,98 +280,42 @@ export class BackendService { fileSize?: string, searchText?: string ): Observable & { fileTypes: string[] }> { - let params = new HttpParams() - .set('page', page.toString()) - .set('limit', limit.toString()); - if (fileType) params = params.set('fileType', fileType); - if (fileSize) params = params.set('fileSize', fileSize); - if (searchText) params = params.set('searchText', searchText); - - return this.http.get & { fileTypes: string[] }>( - `${this.serverUrl}admin/workspace/${workspaceId}/files`, - { headers: this.authHeader, params } - ); + return this.fileService.getFilesList(workspaceId, page, limit, fileType, fileSize, searchText); } - getUnitDef(workspaceId: number, unit: string, authToken?:string): Observable { - const headers = authToken ? { Authorization: `Bearer ${authToken}` } : this.authHeader; - return this.http.get( - `${this.serverUrl}admin/workspace/${workspaceId}/${unit}/unitDef`, - { headers }); + getUnitDef(workspaceId: number, unit: string, authToken?: string): Observable { + return this.fileService.getUnitDef(workspaceId, unit, authToken); } - getPlayer(workspaceId: number, player:string, authToken?:string): Observable { - const headers = authToken ? { Authorization: `Bearer ${authToken}` } : this.authHeader; - return this.http.get( - `${this.serverUrl}admin/workspace/${workspaceId}/player/${player}`, - { headers }); + getPlayer(workspaceId: number, player: string, authToken?: string): Observable { + return this.fileService.getPlayer(workspaceId, player, authToken); } - getResponses(workspaceId: number, testPerson: string, unitId:string, authToken?:string - ): Observable { - const headers = authToken ? { Authorization: `Bearer ${authToken}` } : this.authHeader; - return this.http.get( - `${this.serverUrl}admin/workspace/${workspaceId}/responses/${testPerson}/${unitId}`, - { headers }); + getResponses(workspaceId: number, testPerson: string, unitId: string, authToken?: string): Observable { + return this.responseService.getResponses(workspaceId, testPerson, unitId, authToken); } - getUnit(workspaceId: number, - unitId:string, - authToken?:string - ): Observable { - const headers = authToken ? { Authorization: `Bearer ${authToken}` } : this.authHeader; - return this.http.get( - `${this.serverUrl}admin/workspace/${workspaceId}/unit/${unitId}`, - { headers }); + getUnit(workspaceId: number, unitId: string, authToken?: string): Observable { + return this.fileService.getUnit(workspaceId, unitId, authToken); } getTestPersons(workspaceId: number): Observable { - return this.http.get( - `${this.serverUrl}admin/workspace/${workspaceId}/test-groups`, - { headers: this.authHeader }); + return this.testResultService.getTestPersons(workspaceId); } // eslint-disable-next-line @typescript-eslint/no-explicit-any getTestResults(workspaceId: number, page: number, limit: number, searchText?: string): Observable { - const params: { [key: string]: string } = { - page: page.toString(), - limit: limit.toString() - }; - - if (searchText && searchText.trim() !== '') { - params.searchText = searchText.trim(); - } - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return this.http.get( - `${this.serverUrl}admin/workspace/${workspaceId}/test-results/`, - { - headers: this.authHeader, - params: params - } - ).pipe( - catchError(() => { - logger.error('Error fetching test data'); - return of({ results: [], total: 0 }); - }), - map(result => result || { results: [], total: 0 }) - ); + return this.testResultService.getTestResults(workspaceId, page, limit, searchText); } // eslint-disable-next-line @typescript-eslint/no-explicit-any getPersonTestResults(workspaceId: number, personId: number): Observable { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return this.http.get( - `${this.serverUrl}admin/workspace/${workspaceId}/test-results/${personId}`, - { headers: this.authHeader } - ); + return this.testResultService.getPersonTestResults(workspaceId, personId); } - authenticate(username:string, password:string, server:string, url:string): Observable { - return this.http - .post(`${this.serverUrl}tc_authentication`, { - username, password, server, url - }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + authenticate(username:string, password:string, server:string, url:string): Observable { + return this.authenticationService.authenticate(username, password, server, url); } importWorkspaceFiles(workspace_id: number, @@ -597,40 +327,16 @@ export class BackendService { testGroups: string[], overwriteExistingLogs:boolean = false ): Observable { - const { - units, responses, definitions, player, codings, logs, testTakers, booklets - } = importOptions; - - const params = new HttpParams() - .set('tc_workspace', testCenterWorkspace) - .set('server', server) - .set('url', encodeURIComponent(url)) - .set('responses', String(responses)) - .set('logs', String(logs)) - .set('definitions', String(definitions)) - .set('units', String(units)) - .set('codings', String(codings)) - .set('player', String(player)) - .set('token', token) - .set('testTakers', String(testTakers)) - .set('booklets', String(booklets)) - .set('testGroups', String(testGroups.join(','))) - .set('overwriteExistingLogs', String(overwriteExistingLogs)); - - return this.http - .get(`${this.serverUrl}admin/workspace/${workspace_id}/importWorkspaceFiles`, { headers: this.authHeader, params }) - .pipe( - catchError(() => of({ - success: false, - testFiles: 0, - responses: 0, - logs: 0, - booklets: 0, - units: 0, - persons: 0, - importedGroups: [] - })) - ); + return this.importService.importWorkspaceFiles( + workspace_id, + testCenterWorkspace, + server, + url, + token, + importOptions, + testGroups, + overwriteExistingLogs + ); } importTestcenterGroups(workspace_id: number, @@ -639,80 +345,37 @@ export class BackendService { url:string, authToken:string ): Observable { - const params = new HttpParams() - .set('tc_workspace', testCenterWorkspace) - .set('server', server) - .set('url', encodeURIComponent(url)) - .set('token', authToken); - - return this.http - .get(`${this.serverUrl}admin/workspace/${workspace_id}/importWorkspaceFiles/testGroups`, { headers: this.authHeader, params }) - .pipe( - catchError(() => of([])) - ); + return this.importService.importTestcenterGroups( + workspace_id, + testCenterWorkspace, + server, + url, + authToken + ); } getResourcePackages(workspaceId:number): Observable { - return this.http.get( - `${this.serverUrl}admin/workspace/${workspaceId}/resource-packages`, - { headers: this.authHeader } - ).pipe( - catchError(() => of([])) - ); + return this.resourcePackageService.getResourcePackages(workspaceId); } deleteResourcePackages(workspaceId:number, ids: number[]): Observable { - const params = new HttpParams() - .set('id', ids.join(',')) - .set('workspaceId', workspaceId); - return this.http.delete( - `${this.serverUrl}admin/workspace/${workspaceId}/resource-packages`, - { headers: this.authHeader, params } - ).pipe( - catchError(() => of(false)), - map(() => true) - ); + return this.resourcePackageService.deleteResourcePackages(workspaceId, ids); } downloadResourcePackage(workspaceId:number, name: string): Observable { - return this.http.get( - `${this.serverUrl}admin/workspace/${workspaceId}/resource-packages/${name}`, - { headers: this.authHeader, responseType: 'blob' } - ).pipe( - catchError(() => of(new Blob([]))) - ); + return this.resourcePackageService.downloadResourcePackage(workspaceId, name); } uploadResourcePackage(workspaceId:number, file: File): Observable { - const formData = new FormData(); - formData.append('resourcePackage', file); - - return this.http.post( - `${this.serverUrl}admin/workspace/${workspaceId}/resource-packages`, - formData, - { headers: this.authHeader } - ).pipe( - catchError(() => of(-1)) - ); + return this.resourcePackageService.uploadResourcePackage(workspaceId, file); } getCodingSchemeFile(workspaceId: number, codingSchemeRef: string): Observable { - return this.http.get( - `${this.serverUrl}admin/workspace/${workspaceId}/files/coding-scheme/${codingSchemeRef}`, - { headers: this.authHeader } - ).pipe( - catchError(() => of(null)) - ); + return this.fileService.getCodingSchemeFile(workspaceId, codingSchemeRef); } getUnitContentXml(workspaceId: number, unitId: number): Observable { - return this.http.get<{ content: string }>( - `${this.serverUrl}admin/workspace/${workspaceId}/unit/${unitId}/content`, - { headers: this.authHeader } - ).pipe( - map(response => response.content), - catchError(() => of(null)) - ); + return this.fileService.getUnitContentXml(workspaceId, unitId); } searchResponses( @@ -741,73 +404,7 @@ export class BackendService { }[]; total: number; }> { - let params = new HttpParams(); - - if (searchParams.value) { - params = params.set('value', searchParams.value); - } - - if (searchParams.variableId) { - params = params.set('variableId', searchParams.variableId); - } - - if (searchParams.unitName) { - params = params.set('unitName', searchParams.unitName); - } - - if (searchParams.status) { - params = params.set('status', searchParams.status); - } - - if (searchParams.codedStatus) { - params = params.set('codedStatus', searchParams.codedStatus); - } - - if (searchParams.group) { - params = params.set('group', searchParams.group); - } - - if (searchParams.code) { - params = params.set('code', searchParams.code); - } - - if (page !== undefined) { - params = params.set('page', page.toString()); - } - - if (limit !== undefined) { - params = params.set('limit', limit.toString()); - } - - return this.http.get<{ - data: { - responseId: number; - variableId: string; - value: string; - status: string; - code?: number; - score?: number; - codedStatus?: string; - unitId: number; - unitName: string; - unitAlias: string | null; - bookletId: number; - bookletName: string; - personId: number; - personLogin: string; - personCode: string; - personGroup: string; - }[]; - total: number; - }>( - `${this.serverUrl}admin/workspace/${workspaceId}/responses/search`, - { headers: this.authHeader, params } - ).pipe( - catchError(() => { - logger.error(`Error searching for responses with params: ${JSON.stringify(searchParams)}`); - return of({ data: [], total: 0 }); - }) - ); + return this.responseService.searchResponses(workspaceId, searchParams, page, limit); } searchUnitsByName( @@ -831,48 +428,9 @@ export class BackendService { }[]; total: number; }> { - let params = new HttpParams().set('unitName', unitName); - - if (page !== undefined) { - params = params.set('page', page.toString()); - } - - if (limit !== undefined) { - params = params.set('limit', limit.toString()); - } - - return this.http.get<{ - data: { - unitId: number; - unitName: string; - unitAlias: string | null; - bookletId: number; - bookletName: string; - personId: number; - personLogin: string; - personCode: string; - personGroup: string; - tags: { id: number; unitId: number; tag: string; color?: string; createdAt: Date }[]; - responses: { variableId: string; value: string; status: string; code?: number; score?: number; codedStatus?: string }[]; - }[]; - total: number; - }>( - `${this.serverUrl}admin/workspace/${workspaceId}/units/search`, - { headers: this.authHeader, params } - ).pipe( - catchError(() => { - logger.error(`Error searching for units with name: ${unitName}`); - return of({ data: [], total: 0 }); - }) - ); + return this.testResultService.searchUnitsByName(workspaceId, unitName, page, limit); } - /** - * Delete a unit and all its associated responses - * @param workspaceId The ID of the workspace - * @param unitId The ID of the unit to delete - * @returns An Observable of the deletion result - */ deleteUnit(workspaceId: number, unitId: number): Observable<{ success: boolean; report: { @@ -880,29 +438,9 @@ export class BackendService { warnings: string[]; }; }> { - return this.http.delete<{ - success: boolean; - report: { - deletedUnit: number | null; - warnings: string[]; - }; - }>( - `${this.serverUrl}admin/workspace/${workspaceId}/units/${unitId}`, - { headers: this.authHeader } - ).pipe( - catchError(() => { - logger.error(`Error deleting unit with ID: ${unitId}`); - return of({ success: false, report: { deletedUnit: null, warnings: ['Failed to delete unit'] } }); - }) - ); + return this.unitService.deleteUnit(workspaceId, unitId); } - /** - * Delete multiple units and all their associated responses - * @param workspaceId The ID of the workspace - * @param unitIds Array of unit IDs to delete - * @returns An Observable of the deletion result - */ deleteMultipleUnits(workspaceId: number, unitIds: number[]): Observable<{ success: boolean; report: { @@ -910,48 +448,9 @@ export class BackendService { warnings: string[]; }; }> { - // Create a series of delete requests for each unit - const deleteRequests = unitIds.map(unitId => this.deleteUnit(workspaceId, unitId)); - - // Combine all requests and aggregate the results - return forkJoin(deleteRequests).pipe( - map(results => { - const successfulDeletes = results.filter(result => result.success); - const deletedUnits = successfulDeletes - .map(result => result.report.deletedUnit) - .filter(id => id !== null) as number[]; - - const warnings = results - .filter(result => !result.success || result.report.warnings.length > 0) - .flatMap(result => result.report.warnings); - - return { - success: deletedUnits.length > 0, - report: { - deletedUnits, - warnings - } - }; - }), - catchError(() => { - logger.error('Error deleting multiple units'); - return of({ - success: false, - report: { - deletedUnits: [], - warnings: ['Failed to delete units'] - } - }); - }) - ); + return this.unitService.deleteMultipleUnits(workspaceId, unitIds); } - /** - * Delete a response - * @param workspaceId The ID of the workspace - * @param responseId The ID of the response to delete - * @returns An Observable of the deletion result - */ deleteResponse(workspaceId: number, responseId: number): Observable<{ success: boolean; report: { @@ -959,29 +458,9 @@ export class BackendService { warnings: string[]; }; }> { - return this.http.delete<{ - success: boolean; - report: { - deletedResponse: number | null; - warnings: string[]; - }; - }>( - `${this.serverUrl}admin/workspace/${workspaceId}/responses/${responseId}`, - { headers: this.authHeader } - ).pipe( - catchError(() => { - logger.error(`Error deleting response with ID: ${responseId}`); - return of({ success: false, report: { deletedResponse: null, warnings: ['Failed to delete response'] } }); - }) - ); + return this.responseService.deleteResponse(workspaceId, responseId); } - /** - * Delete multiple responses - * @param workspaceId The ID of the workspace - * @param responseIds Array of response IDs to delete - * @returns An Observable of the deletion result - */ deleteMultipleResponses(workspaceId: number, responseIds: number[]): Observable<{ success: boolean; report: { @@ -989,109 +468,23 @@ export class BackendService { warnings: string[]; }; }> { - // Create a series of delete requests for each response - const deleteRequests = responseIds.map(responseId => this.deleteResponse(workspaceId, responseId)); - - // Combine all requests and aggregate the results - return forkJoin(deleteRequests).pipe( - map(results => { - const successfulDeletes = results.filter(result => result.success); - const deletedResponses = successfulDeletes - .map(result => result.report.deletedResponse) - .filter(id => id !== null) as number[]; - - const warnings = results - .filter(result => !result.success || result.report.warnings.length > 0) - .flatMap(result => result.report.warnings); - - return { - success: deletedResponses.length > 0, - report: { - deletedResponses, - warnings - } - }; - }), - catchError(() => { - logger.error('Error deleting multiple responses'); - return of({ - success: false, - report: { - deletedResponses: [], - warnings: ['Failed to delete responses'] - } - }); - }) - ); + return this.responseService.deleteMultipleResponses(workspaceId, responseIds); } validateVariables(workspaceId: number, page: number = 1, limit: number = 10): Observable> { - const params = new HttpParams() - .set('page', page.toString()) - .set('limit', limit.toString()); - - return this.http.get>( - `${this.serverUrl}admin/workspace/${workspaceId}/files/validate-variables`, - { headers: this.authHeader, params } - ).pipe( - catchError(() => of({ - data: [], - total: 0, - page, - limit - })) - ); + return this.validationService.validateVariables(workspaceId, page, limit); } validateVariableTypes(workspaceId: number, page: number = 1, limit: number = 10): Observable> { - const params = new HttpParams() - .set('page', page.toString()) - .set('limit', limit.toString()); - - return this.http.get>( - `${this.serverUrl}admin/workspace/${workspaceId}/files/validate-variable-types`, - { headers: this.authHeader, params } - ).pipe( - catchError(() => of({ - data: [], - total: 0, - page, - limit - })) - ); + return this.validationService.validateVariableTypes(workspaceId, page, limit); } validateResponseStatus(workspaceId: number, page: number = 1, limit: number = 10): Observable> { - const params = new HttpParams() - .set('page', page.toString()) - .set('limit', limit.toString()); - - return this.http.get>( - `${this.serverUrl}admin/workspace/${workspaceId}/files/validate-response-status`, - { headers: this.authHeader, params } - ).pipe( - catchError(() => of({ - data: [], - total: 0, - page, - limit - })) - ); + return this.validationService.validateResponseStatus(workspaceId, page, limit); } validateTestTakers(workspaceId: number): Observable { - return this.http.get( - `${this.serverUrl}admin/workspace/${workspaceId}/files/validate-testtakers`, - { headers: this.authHeader } - ).pipe( - catchError(() => of({ - testTakersFound: false, - totalGroups: 0, - totalLogins: 0, - totalBookletCodes: 0, - missingPersons: [] - })) - ); + return this.validationService.validateTestTakers(workspaceId); } validateGroupResponses(workspaceId: number, page: number = 1, limit: number = 10): Observable<{ @@ -1102,39 +495,10 @@ export class BackendService { page: number; limit: number; }> { - const params = new HttpParams() - .set('page', page.toString()) - .set('limit', limit.toString()); - - return this.http.get<{ - testTakersFound: boolean; - groupsWithResponses: { group: string; hasResponse: boolean }[]; - allGroupsHaveResponses: boolean; - total: number; - page: number; - limit: number; - }>( - `${this.serverUrl}admin/workspace/${workspaceId}/files/validate-group-responses`, - { headers: this.authHeader, params } - ).pipe( - catchError(() => of({ - testTakersFound: false, - groupsWithResponses: [], - allGroupsHaveResponses: false, - total: 0, - page, - limit - })) - ); + return this.validationService.validateGroupResponses(workspaceId, page, limit); } deleteInvalidResponses(workspaceId: number, responseIds: number[]): Observable { - const params = new HttpParams().set('responseIds', responseIds.join(',')); - return this.http.delete( - `${this.serverUrl}admin/workspace/${workspaceId}/files/invalid-responses`, - { headers: this.authHeader, params } - ).pipe( - catchError(() => of(0)) - ); + return this.validationService.deleteInvalidResponses(workspaceId, responseIds); } } diff --git a/apps/frontend/src/app/services/coding.service.ts b/apps/frontend/src/app/services/coding.service.ts new file mode 100644 index 000000000..dd191b53c --- /dev/null +++ b/apps/frontend/src/app/services/coding.service.ts @@ -0,0 +1,167 @@ +import { Injectable, inject } from '@angular/core'; +import { HttpClient, HttpParams } from '@angular/common/http'; +import { + catchError, + Observable, + of, + switchMap +} from 'rxjs'; +import { CodingStatistics } from '../../../../../api-dto/coding/coding-statistics'; +import { SERVER_URL } from '../injection-tokens'; +import { AppService } from './app.service'; + +interface PaginatedResponse { + data: T[]; + total: number; + page: number; + limit: number; +} + +export interface CodingListItem { + unit_key: string; + unit_alias: string; + login_name: string; + login_code: string; + booklet_id: string; + variable_id: string; + variable_page: string; + variable_anchor: string; + url: string; +} + +interface ResponseEntity { + id: number; + unitId: number; + variableId: string; + status: string; + value: string; + subform: string; + code: number; + score: number; + codedStatus: string; + unit?: { + name: string; + alias: string; + booklet?: { + person?: { + login: string; + code: string; + }; + bookletinfo?: { + name: string; + }; + }; + }; +} + +@Injectable({ + providedIn: 'root' +}) +export class CodingService { + readonly serverUrl = inject(SERVER_URL); + private http = inject(HttpClient); + private appService = inject(AppService); + + get authHeader() { + return { Authorization: `Bearer ${localStorage.getItem('id_token')}` }; + } + + codeTestPersons(workspace_id: number, testPersonIds: number[]): Observable<{ + totalResponses: number; + statusCounts: { + [key: string]: number; + }; + }> { + const params = new HttpParams().set('testPersons', testPersonIds.join(',')); + return this.http + .get<{ + totalResponses: number; + statusCounts: { + [key: string]: number; + }; + }>( + `${this.serverUrl}admin/workspace/${workspace_id}/coding`, + { headers: this.authHeader, params }) + .pipe( + catchError(() => of({ totalResponses: 0, statusCounts: {} })) + ); + } + + getCodingList(workspace_id: number, page: number = 1, limit: number = 100): Observable> { + const identity = this.appService.loggedUser?.sub || ''; + return this.appService.createToken(workspace_id, identity, 60).pipe( + catchError(() => of('')), + switchMap(token => { + const params = new HttpParams() + .set('page', page.toString()) + .set('limit', limit.toString()) + .set('identity', identity) + .set('authToken', token) + .set('serverUrl', window.location.origin); + return this.http + .get>( + `${this.serverUrl}admin/workspace/${workspace_id}/coding/coding-list`, + { headers: this.authHeader, params } + ) + .pipe( + catchError(() => of({ + data: [], + total: 0, + page, + limit + })) + ); + }) + ); + } + + getCodingListAsCsv(workspace_id: number): Observable { + return this.http.get( + `${this.serverUrl}admin/workspace/${workspace_id}/coding/coding-list/csv`, + { + headers: this.authHeader, + responseType: 'arraybuffer' + } + ); + } + + getCodingListAsExcel(workspace_id: number): Observable { + return this.http.get( + `${this.serverUrl}admin/workspace/${workspace_id}/coding/coding-list/excel`, + { + headers: this.authHeader, + responseType: 'arraybuffer' + } + ); + } + + getCodingStatistics(workspace_id: number): Observable { + return this.http + .get( + `${this.serverUrl}admin/workspace/${workspace_id}/coding/statistics`, + { headers: this.authHeader }) + .pipe( + catchError(() => of({ totalResponses: 0, statusCounts: {} })) + ); + } + + getResponsesByStatus(workspace_id: number, status: string, page: number = 1, limit: number = 100): Observable> { + const params = new HttpParams() + .set('page', page.toString()) + .set('limit', limit.toString()); + + return this.http + .get>( + `${this.serverUrl}admin/workspace/${workspace_id}/coding/responses/${status}`, + { headers: this.authHeader, params } + ) + .pipe( + catchError(() => of({ + data: [], + total: 0, + page, + limit + })) + ); + } +} diff --git a/apps/frontend/src/app/services/file.service.ts b/apps/frontend/src/app/services/file.service.ts new file mode 100644 index 000000000..0c1c85758 --- /dev/null +++ b/apps/frontend/src/app/services/file.service.ts @@ -0,0 +1,168 @@ +import { Injectable, inject } from '@angular/core'; +import { HttpClient, HttpParams } from '@angular/common/http'; +import { + catchError, + map, + Observable, + of, + switchMap +} from 'rxjs'; +import { FilesInListDto } from '../../../../../api-dto/files/files-in-list.dto'; +import { FilesDto } from '../../../../../api-dto/files/files.dto'; +import { FileValidationResultDto } from '../../../../../api-dto/files/file-validation-result.dto'; +import { FileDownloadDto } from '../../../../../api-dto/files/file-download.dto'; +import { SERVER_URL } from '../injection-tokens'; + +interface PaginatedResponse { + data: T[]; + total: number; + page: number; + limit: number; +} + +@Injectable({ + providedIn: 'root' +}) +export class FileService { + readonly serverUrl = inject(SERVER_URL); + private http = inject(HttpClient); + + get authHeader() { + return { Authorization: `Bearer ${localStorage.getItem('id_token')}` }; + } + + getDirectDownloadLink(): string { + return `${this.serverUrl}packages/`; + } + + getFilesList( + workspaceId: number, + page: number = 1, + limit: number = 10000, + fileType?: string, + fileSize?: string, + searchText?: string + ): Observable & { fileTypes: string[] }> { + let params = new HttpParams() + .set('page', page.toString()) + .set('limit', limit.toString()); + if (fileType) params = params.set('fileType', fileType); + if (fileSize) params = params.set('fileSize', fileSize); + if (searchText) params = params.set('searchText', searchText); + + return this.http.get & { fileTypes: string[] }>( + `${this.serverUrl}admin/workspace/${workspaceId}/files`, + { headers: this.authHeader, params } + ); + } + + deleteFiles(workspaceId: number, fileIds: number[]): Observable { + const batchSize = 100; + const batches = []; + + for (let i = 0; i < fileIds.length; i += batchSize) { + batches.push(fileIds.slice(i, i + batchSize)); + } + + return batches.reduce>((acc, batch) => acc.pipe( + switchMap(() => this.http + .delete(`${this.serverUrl}admin/workspace/${workspaceId}/files`, { + headers: this.authHeader, + params: { fileIds: batch.join(';') } + }) + .pipe( + map(() => true), + catchError(() => of(false)) + ) + ) + ), of(true)); + } + + downloadFile(workspaceId: number, fileId: number): Observable { + const url = `${this.serverUrl}admin/workspace/${workspaceId}/files/${fileId}/download`; + return this.http.get(url, { headers: this.authHeader }); + } + + validateFiles(workspace_id: number): Observable { + return this.http + .get( + `${this.serverUrl}admin/workspace/${workspace_id}/files/validation`, + { headers: this.authHeader }) + .pipe( + catchError(() => of(false)) + ); + } + + uploadTestFiles(workspaceId: number, files: FileList | null): Observable { + const formData = new FormData(); + if (files) { + for (let i = 0; i < files.length; i++) { + formData.append('files', files[i]); + } + } + return this.http.post(`${this.serverUrl}admin/workspace/${workspaceId}/upload`, formData, { + headers: this.authHeader + }); + } + + uploadTestResults( + workspaceId: number, + files: FileList | null, + resultType: 'logs' | 'responses', + overwriteExisting: boolean = true + ): Observable { + const formData = new FormData(); + if (files) { + for (let i = 0; i < files.length; i++) { + formData.append('files', files[i]); + } + } + const url = `${this.serverUrl}admin/workspace/${workspaceId}/upload/results/${resultType}?overwriteExisting=${overwriteExisting}`; + return this.http.post(url, formData, { + headers: this.authHeader + }); + } + + getUnitDef(workspaceId: number, unit: string, authToken?: string): Observable { + const headers = authToken ? { Authorization: `Bearer ${authToken}` } : this.authHeader; + return this.http.get( + `${this.serverUrl}admin/workspace/${workspaceId}/${unit}/unitDef`, + { headers }); + } + + getPlayer(workspaceId: number, player: string, authToken?: string): Observable { + const headers = authToken ? { Authorization: `Bearer ${authToken}` } : this.authHeader; + return this.http.get( + `${this.serverUrl}admin/workspace/${workspaceId}/player/${player}`, + { headers }); + } + + getUnit(workspaceId: number, + unitId: string, + authToken?: string + ): Observable { + const headers = authToken ? { Authorization: `Bearer ${authToken}` } : this.authHeader; + return this.http.get( + `${this.serverUrl}admin/workspace/${workspaceId}/unit/${unitId}`, + { headers }); + } + + getUnitContentXml(workspaceId: number, unitId: number): Observable { + return this.http.get<{ content: string }>( + `${this.serverUrl}admin/workspace/${workspaceId}/unit/${unitId}/content`, + { headers: this.authHeader } + ).pipe( + map(response => response.content), + catchError(() => of(null)) + ); + } + + getCodingSchemeFile(workspaceId: number, codingSchemeRef: string): Observable { + return this.http.get( + `${this.serverUrl}admin/workspace/${workspaceId}/files/coding-scheme/${codingSchemeRef}`, + { headers: this.authHeader } + ).pipe( + catchError(() => of(null)) + ); + } +} diff --git a/apps/frontend/src/app/services/import.service.ts b/apps/frontend/src/app/services/import.service.ts new file mode 100644 index 000000000..c58eb2bfe --- /dev/null +++ b/apps/frontend/src/app/services/import.service.ts @@ -0,0 +1,90 @@ +import { Injectable, inject } from '@angular/core'; +import { HttpClient, HttpParams } from '@angular/common/http'; +import { + catchError, + Observable, + of +} from 'rxjs'; +import { SERVER_URL } from '../injection-tokens'; +// eslint-disable-next-line import/no-cycle +import { + ImportOptions, + Result +} from '../ws-admin/components/test-center-import/test-center-import.component'; +import { TestGroupsInfoDto } from '../../../../../api-dto/files/test-groups-info.dto'; + +@Injectable({ + providedIn: 'root' +}) +export class ImportService { + readonly serverUrl = inject(SERVER_URL); + private http = inject(HttpClient); + + get authHeader() { + return { Authorization: `Bearer ${localStorage.getItem('id_token')}` }; + } + + importWorkspaceFiles(workspace_id: number, + testCenterWorkspace: string, + server: string, + url: string, + token: string, + importOptions: ImportOptions, + testGroups: string[], + overwriteExistingLogs: boolean = false + ): Observable { + const { + units, responses, definitions, player, codings, logs, testTakers, booklets + } = importOptions; + + const params = new HttpParams() + .set('tc_workspace', testCenterWorkspace) + .set('server', server) + .set('url', encodeURIComponent(url)) + .set('responses', String(responses)) + .set('logs', String(logs)) + .set('definitions', String(definitions)) + .set('units', String(units)) + .set('codings', String(codings)) + .set('player', String(player)) + .set('token', token) + .set('testTakers', String(testTakers)) + .set('booklets', String(booklets)) + .set('testGroups', String(testGroups.join(','))) + .set('overwriteExistingLogs', String(overwriteExistingLogs)); + + return this.http + .get(`${this.serverUrl}admin/workspace/${workspace_id}/importWorkspaceFiles`, { headers: this.authHeader, params }) + .pipe( + catchError(() => of({ + success: false, + testFiles: 0, + responses: 0, + logs: 0, + booklets: 0, + units: 0, + persons: 0, + importedGroups: [] + })) + ); + } + + importTestcenterGroups(workspace_id: number, + testCenterWorkspace: string, + server: string, + url: string, + authToken: string + ): Observable { + const params = new HttpParams() + .set('tc_workspace', testCenterWorkspace) + .set('server', server) + .set('url', encodeURIComponent(url)) + .set('token', authToken); + + return this.http + .get(`${this.serverUrl}admin/workspace/${workspace_id}/importWorkspaceFiles/testGroups`, { headers: this.authHeader, params }) + .pipe( + catchError(() => of([])) + ); + } +} diff --git a/apps/frontend/src/app/services/resource-package.service.ts b/apps/frontend/src/app/services/resource-package.service.ts new file mode 100644 index 000000000..ffd3a3704 --- /dev/null +++ b/apps/frontend/src/app/services/resource-package.service.ts @@ -0,0 +1,66 @@ +import { Injectable, inject } from '@angular/core'; +import { HttpClient, HttpParams } from '@angular/common/http'; +import { + catchError, + map, + Observable, + of +} from 'rxjs'; +import { ResourcePackageDto } from '../../../../../api-dto/resource-package/resource-package-dto'; +import { SERVER_URL } from '../injection-tokens'; + +@Injectable({ + providedIn: 'root' +}) +export class ResourcePackageService { + readonly serverUrl = inject(SERVER_URL); + private http = inject(HttpClient); + + get authHeader() { + return { Authorization: `Bearer ${localStorage.getItem('id_token')}` }; + } + + getResourcePackages(workspaceId: number): Observable { + return this.http.get( + `${this.serverUrl}admin/workspace/${workspaceId}/resource-packages`, + { headers: this.authHeader } + ).pipe( + catchError(() => of([])) + ); + } + + deleteResourcePackages(workspaceId: number, ids: number[]): Observable { + const params = new HttpParams() + .set('id', ids.join(',')) + .set('workspaceId', workspaceId); + return this.http.delete( + `${this.serverUrl}admin/workspace/${workspaceId}/resource-packages`, + { headers: this.authHeader, params } + ).pipe( + catchError(() => of(false)), + map(() => true) + ); + } + + downloadResourcePackage(workspaceId: number, name: string): Observable { + return this.http.get( + `${this.serverUrl}admin/workspace/${workspaceId}/resource-packages/${name}`, + { headers: this.authHeader, responseType: 'blob' } + ).pipe( + catchError(() => of(new Blob([]))) + ); + } + + uploadResourcePackage(workspaceId: number, file: File): Observable { + const formData = new FormData(); + formData.append('resourcePackage', file); + + return this.http.post( + `${this.serverUrl}admin/workspace/${workspaceId}/resource-packages`, + formData, + { headers: this.authHeader } + ).pipe( + catchError(() => of(-1)) + ); + } +} diff --git a/apps/frontend/src/app/services/response.service.ts b/apps/frontend/src/app/services/response.service.ts new file mode 100644 index 000000000..91a3208e2 --- /dev/null +++ b/apps/frontend/src/app/services/response.service.ts @@ -0,0 +1,227 @@ +import { Injectable, inject } from '@angular/core'; +import { HttpClient, HttpParams } from '@angular/common/http'; +import { + catchError, + map, + Observable, + of, + forkJoin +} from 'rxjs'; +import { logger } from 'nx/src/utils/logger'; +import { ResponseDto } from '../../../../../api-dto/responses/response-dto'; +import { SERVER_URL } from '../injection-tokens'; + +@Injectable({ + providedIn: 'root' +}) +export class ResponseService { + readonly serverUrl = inject(SERVER_URL); + private http = inject(HttpClient); + + get authHeader() { + return { Authorization: `Bearer ${localStorage.getItem('id_token')}` }; + } + + getResponses(workspaceId: number, testPerson: string, unitId: string, authToken?: string): Observable { + const headers = authToken ? { Authorization: `Bearer ${authToken}` } : this.authHeader; + return this.http.get( + `${this.serverUrl}admin/workspace/${workspaceId}/responses/${testPerson}/${unitId}`, + { headers }); + } + + deleteTestPersons(workspace_id: number, testPersonIds: number[]): Observable { + const params = new HttpParams().set('testPersons', testPersonIds.join(',')); + return this.http + .delete( + `${this.serverUrl}admin/workspace/${workspace_id}/test-results`, + { headers: this.authHeader, params }) + .pipe( + catchError(() => of(false)), + map(() => true) + ); + } + + /** + * Delete a response + * @param workspaceId The ID of the workspace + * @param responseId The ID of the response to delete + * @returns An Observable of the deletion result + */ + deleteResponse(workspaceId: number, responseId: number): Observable<{ + success: boolean; + report: { + deletedResponse: number | null; + warnings: string[]; + }; + }> { + return this.http.delete<{ + success: boolean; + report: { + deletedResponse: number | null; + warnings: string[]; + }; + }>( + `${this.serverUrl}admin/workspace/${workspaceId}/responses/${responseId}`, + { headers: this.authHeader } + ).pipe( + catchError(() => { + logger.error(`Error deleting response with ID: ${responseId}`); + return of({ success: false, report: { deletedResponse: null, warnings: ['Failed to delete response'] } }); + }) + ); + } + + /** + * Delete multiple responses + * @param workspaceId The ID of the workspace + * @param responseIds Array of response IDs to delete + * @returns An Observable of the deletion result + */ + deleteMultipleResponses(workspaceId: number, responseIds: number[]): Observable<{ + success: boolean; + report: { + deletedResponses: number[]; + warnings: string[]; + }; + }> { + // Create a series of delete requests for each response + const deleteRequests = responseIds.map(responseId => this.deleteResponse(workspaceId, responseId)); + + // Combine all requests and aggregate the results + return forkJoin(deleteRequests).pipe( + map(results => { + const successfulDeletes = results.filter(result => result.success); + const deletedResponses = successfulDeletes + .map(result => result.report.deletedResponse) + .filter(id => id !== null) as number[]; + + const warnings = results + .filter(result => !result.success || result.report.warnings.length > 0) + .flatMap(result => result.report.warnings); + + return { + success: deletedResponses.length > 0, + report: { + deletedResponses, + warnings + } + }; + }), + catchError(() => { + logger.error('Error deleting multiple responses'); + return of({ + success: false, + report: { + deletedResponses: [], + warnings: ['Failed to delete responses'] + } + }); + }) + ); + } + + searchResponses( + workspaceId: number, + searchParams: { value?: string; variableId?: string; unitName?: string; status?: string; codedStatus?: string; group?: string; code?: string }, + page?: number, + limit?: number + ): Observable<{ + data: { + responseId: number; + variableId: string; + value: string; + status: string; + code?: number; + score?: number; + codedStatus?: string; + unitId: number; + unitName: string; + unitAlias: string | null; + bookletId: number; + bookletName: string; + personId: number; + personLogin: string; + personCode: string; + personGroup: string; + }[]; + total: number; + }> { + let params = new HttpParams(); + + if (searchParams.value) { + params = params.set('value', searchParams.value); + } + + if (searchParams.variableId) { + params = params.set('variableId', searchParams.variableId); + } + + if (searchParams.unitName) { + params = params.set('unitName', searchParams.unitName); + } + + if (searchParams.status) { + params = params.set('status', searchParams.status); + } + + if (searchParams.codedStatus) { + params = params.set('codedStatus', searchParams.codedStatus); + } + + if (searchParams.group) { + params = params.set('group', searchParams.group); + } + + if (searchParams.code) { + params = params.set('code', searchParams.code); + } + + if (page !== undefined) { + params = params.set('page', page.toString()); + } + + if (limit !== undefined) { + params = params.set('limit', limit.toString()); + } + + return this.http.get<{ + data: { + responseId: number; + variableId: string; + value: string; + status: string; + code?: number; + score?: number; + codedStatus?: string; + unitId: number; + unitName: string; + unitAlias: string | null; + bookletId: number; + bookletName: string; + personId: number; + personLogin: string; + personCode: string; + personGroup: string; + }[]; + total: number; + }>( + `${this.serverUrl}admin/workspace/${workspaceId}/responses/search`, + { headers: this.authHeader, params } + ).pipe( + catchError(() => { + logger.error(`Error searching for responses with params: ${JSON.stringify(searchParams)}`); + return of({ data: [], total: 0 }); + }) + ); + } + + deleteInvalidResponses(workspaceId: number, responseIds: number[]): Observable { + const params = new HttpParams().set('responseIds', responseIds.join(',')); + return this.http.delete( + `${this.serverUrl}admin/workspace/${workspaceId}/files/invalid-responses`, + { headers: this.authHeader, params } + ).pipe( + catchError(() => of(0)) + ); + } +} diff --git a/apps/frontend/src/app/services/test-result.service.ts b/apps/frontend/src/app/services/test-result.service.ts new file mode 100644 index 000000000..8e1c7b709 --- /dev/null +++ b/apps/frontend/src/app/services/test-result.service.ts @@ -0,0 +1,201 @@ +import { Injectable, inject } from '@angular/core'; +import { HttpClient, HttpParams } from '@angular/common/http'; +import { + catchError, + forkJoin, + map, + Observable, + of +} from 'rxjs'; +import { logger } from 'nx/src/utils/logger'; +import { SERVER_URL } from '../injection-tokens'; + +@Injectable({ + providedIn: 'root' +}) +export class TestResultService { + readonly serverUrl = inject(SERVER_URL); + private http = inject(HttpClient); + + get authHeader() { + return { Authorization: `Bearer ${localStorage.getItem('id_token')}` }; + } + + getTestPersons(workspaceId: number): Observable { + return this.http.get( + `${this.serverUrl}admin/workspace/${workspaceId}/test-groups`, + { headers: this.authHeader }); + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + getTestResults(workspaceId: number, page: number, limit: number, searchText?: string): Observable { + const params: { [key: string]: string } = { + page: page.toString(), + limit: limit.toString() + }; + + if (searchText && searchText.trim() !== '') { + params.searchText = searchText.trim(); + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return this.http.get( + `${this.serverUrl}admin/workspace/${workspaceId}/test-results/`, + { + headers: this.authHeader, + params: params + } + ).pipe( + catchError(() => { + logger.error('Error fetching test data'); + return of({ results: [], total: 0 }); + }), + map(result => result || { results: [], total: 0 }) + ); + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + getPersonTestResults(workspaceId: number, personId: number): Observable { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return this.http.get( + `${this.serverUrl}admin/workspace/${workspaceId}/test-results/${personId}`, + { headers: this.authHeader } + ); + } + + searchUnitsByName( + workspaceId: number, + unitName: string, + page?: number, + limit?: number + ): Observable<{ + data: { + unitId: number; + unitName: string; + unitAlias: string | null; + bookletId: number; + bookletName: string; + personId: number; + personLogin: string; + personCode: string; + personGroup: string; + tags: { id: number; unitId: number; tag: string; color?: string; createdAt: Date }[]; + responses: { variableId: string; value: string; status: string; code?: number; score?: number; codedStatus?: string }[]; + }[]; + total: number; + }> { + let params = new HttpParams().set('unitName', unitName); + + if (page !== undefined) { + params = params.set('page', page.toString()); + } + + if (limit !== undefined) { + params = params.set('limit', limit.toString()); + } + + return this.http.get<{ + data: { + unitId: number; + unitName: string; + unitAlias: string | null; + bookletId: number; + bookletName: string; + personId: number; + personLogin: string; + personCode: string; + personGroup: string; + tags: { id: number; unitId: number; tag: string; color?: string; createdAt: Date }[]; + responses: { variableId: string; value: string; status: string; code?: number; score?: number; codedStatus?: string }[]; + }[]; + total: number; + }>( + `${this.serverUrl}admin/workspace/${workspaceId}/units/search`, + { headers: this.authHeader, params } + ).pipe( + catchError(() => { + logger.error(`Error searching for units with name: ${unitName}`); + return of({ data: [], total: 0 }); + }) + ); + } + + /** + * Delete a unit and all its associated responses + * @param workspaceId The ID of the workspace + * @param unitId The ID of the unit to delete + * @returns An Observable of the deletion result + */ + deleteUnit(workspaceId: number, unitId: number): Observable<{ + success: boolean; + report: { + deletedUnit: number | null; + warnings: string[]; + }; + }> { + return this.http.delete<{ + success: boolean; + report: { + deletedUnit: number | null; + warnings: string[]; + }; + }>( + `${this.serverUrl}admin/workspace/${workspaceId}/units/${unitId}`, + { headers: this.authHeader } + ).pipe( + catchError(() => { + logger.error(`Error deleting unit with ID: ${unitId}`); + return of({ success: false, report: { deletedUnit: null, warnings: ['Failed to delete unit'] } }); + }) + ); + } + + /** + * Delete multiple units and all their associated responses + * @param workspaceId The ID of the workspace + * @param unitIds Array of unit IDs to delete + * @returns An Observable of the deletion result + */ + deleteMultipleUnits(workspaceId: number, unitIds: number[]): Observable<{ + success: boolean; + report: { + deletedUnits: number[]; + warnings: string[]; + }; + }> { + // Create a series of delete requests for each unit + const deleteRequests = unitIds.map(unitId => this.deleteUnit(workspaceId, unitId)); + + // Combine all requests and aggregate the results + return forkJoin(deleteRequests).pipe( + map(results => { + const successfulDeletes = results.filter(result => result.success); + const deletedUnits = successfulDeletes + .map(result => result.report.deletedUnit) + .filter(id => id !== null) as number[]; + + const warnings = results + .filter(result => !result.success || result.report.warnings.length > 0) + .flatMap(result => result.report.warnings); + + return { + success: deletedUnits.length > 0, + report: { + deletedUnits, + warnings + } + }; + }), + catchError(() => { + logger.error('Error deleting multiple units'); + return of({ + success: false, + report: { + deletedUnits: [], + warnings: ['Failed to delete units'] + } + }); + }) + ); + } +} diff --git a/apps/frontend/src/app/services/unit-note.service.ts b/apps/frontend/src/app/services/unit-note.service.ts new file mode 100644 index 000000000..d93fe0127 --- /dev/null +++ b/apps/frontend/src/app/services/unit-note.service.ts @@ -0,0 +1,53 @@ +import { Injectable, inject } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { + Observable +} from 'rxjs'; +import { UnitNoteDto } from '../../../../../api-dto/unit-notes/unit-note.dto'; +import { CreateUnitNoteDto } from '../../../../../api-dto/unit-notes/create-unit-note.dto'; +import { UpdateUnitNoteDto } from '../../../../../api-dto/unit-notes/update-unit-note.dto'; +import { SERVER_URL } from '../injection-tokens'; + +@Injectable({ + providedIn: 'root' +}) +export class UnitNoteService { + readonly serverUrl = inject(SERVER_URL); + private http = inject(HttpClient); + + get authHeader() { + return { Authorization: `Bearer ${localStorage.getItem('id_token')}` }; + } + + createUnitNote(workspaceId: number, createUnitNoteDto: CreateUnitNoteDto): Observable { + return this.http.post( + `${this.serverUrl}admin/workspace/${workspaceId}/unit-notes`, + createUnitNoteDto, + { headers: this.authHeader }); + } + + getUnitNotes(workspaceId: number, unitId: number): Observable { + return this.http.get( + `${this.serverUrl}admin/workspace/${workspaceId}/unit-notes/unit/${unitId}`, + { headers: this.authHeader }); + } + + getUnitNote(workspaceId: number, noteId: number): Observable { + return this.http.get( + `${this.serverUrl}admin/workspace/${workspaceId}/unit-notes/${noteId}`, + { headers: this.authHeader }); + } + + updateUnitNote(workspaceId: number, noteId: number, updateUnitNoteDto: UpdateUnitNoteDto): Observable { + return this.http.patch( + `${this.serverUrl}admin/workspace/${workspaceId}/unit-notes/${noteId}`, + updateUnitNoteDto, + { headers: this.authHeader }); + } + + deleteUnitNote(workspaceId: number, noteId: number): Observable { + return this.http.delete( + `${this.serverUrl}admin/workspace/${workspaceId}/unit-notes/${noteId}`, + { headers: this.authHeader }); + } +} diff --git a/apps/frontend/src/app/services/unit-tag.service.ts b/apps/frontend/src/app/services/unit-tag.service.ts new file mode 100644 index 000000000..34db4eddd --- /dev/null +++ b/apps/frontend/src/app/services/unit-tag.service.ts @@ -0,0 +1,53 @@ +import { Injectable, inject } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { + Observable +} from 'rxjs'; +import { UnitTagDto } from '../../../../../api-dto/unit-tags/unit-tag.dto'; +import { CreateUnitTagDto } from '../../../../../api-dto/unit-tags/create-unit-tag.dto'; +import { UpdateUnitTagDto } from '../../../../../api-dto/unit-tags/update-unit-tag.dto'; +import { SERVER_URL } from '../injection-tokens'; + +@Injectable({ + providedIn: 'root' +}) +export class UnitTagService { + readonly serverUrl = inject(SERVER_URL); + private http = inject(HttpClient); + + get authHeader() { + return { Authorization: `Bearer ${localStorage.getItem('id_token')}` }; + } + + createUnitTag(workspaceId: number, createUnitTagDto: CreateUnitTagDto): Observable { + return this.http.post( + `${this.serverUrl}admin/workspace/${workspaceId}/unit-tags`, + createUnitTagDto, + { headers: this.authHeader }); + } + + getUnitTags(workspaceId: number, unitId: number): Observable { + return this.http.get( + `${this.serverUrl}admin/workspace/${workspaceId}/unit-tags/unit/${unitId}`, + { headers: this.authHeader }); + } + + getUnitTag(workspaceId: number, tagId: number): Observable { + return this.http.get( + `${this.serverUrl}admin/workspace/${workspaceId}/unit-tags/${tagId}`, + { headers: this.authHeader }); + } + + updateUnitTag(workspaceId: number, tagId: number, updateUnitTagDto: UpdateUnitTagDto): Observable { + return this.http.patch( + `${this.serverUrl}admin/workspace/${workspaceId}/unit-tags/${tagId}`, + updateUnitTagDto, + { headers: this.authHeader }); + } + + deleteUnitTag(workspaceId: number, tagId: number): Observable { + return this.http.delete( + `${this.serverUrl}admin/workspace/${workspaceId}/unit-tags/${tagId}`, + { headers: this.authHeader }); + } +} diff --git a/apps/frontend/src/app/services/unit.service.ts b/apps/frontend/src/app/services/unit.service.ts new file mode 100644 index 000000000..964f7e5f9 --- /dev/null +++ b/apps/frontend/src/app/services/unit.service.ts @@ -0,0 +1,82 @@ +import { Injectable, inject } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { + catchError, + forkJoin, + map, + Observable, + of +} from 'rxjs'; +import { SERVER_URL } from '../injection-tokens'; + +@Injectable({ + providedIn: 'root' +}) +export class UnitService { + readonly serverUrl = inject(SERVER_URL); + private http = inject(HttpClient); + + get authHeader() { + return { Authorization: `Bearer ${localStorage.getItem('id_token')}` }; + } + + deleteUnit(workspaceId: number, unitId: number): Observable<{ + success: boolean; + report: { + deletedUnit: number | null; + warnings: string[]; + }; + }> { + return this.http.delete<{ + success: boolean; + report: { + deletedUnit: number | null; + warnings: string[]; + }; + }>( + `${this.serverUrl}admin/workspace/${workspaceId}/units/${unitId}`, + { headers: this.authHeader } + ).pipe( + catchError(() => of({ success: false, report: { deletedUnit: null, warnings: ['Failed to delete unit'] } })) + ); + } + + deleteMultipleUnits(workspaceId: number, unitIds: number[]): Observable<{ + success: boolean; + report: { + deletedUnits: number[]; + warnings: string[]; + }; + }> { + const deleteRequests = unitIds.map(unitId => this.deleteUnit(workspaceId, unitId)); + + // Combine all requests and aggregate the results + return forkJoin(deleteRequests).pipe( + map(results => { + const successfulDeletes = results.filter(result => result.success); + const deletedUnits = successfulDeletes + .map(result => result.report.deletedUnit) + .filter(id => id !== null) as number[]; + + const warnings = results + .filter(result => !result.success || result.report.warnings.length > 0) + .flatMap(result => result.report.warnings); + + return { + success: deletedUnits.length > 0, + report: { + deletedUnits, + warnings + } + }; + }), + catchError(() => of({ + success: false, + report: { + deletedUnits: [], + warnings: ['Failed to delete units'] + } + })) + ); + } +} diff --git a/apps/frontend/src/app/services/user.service.ts b/apps/frontend/src/app/services/user.service.ts new file mode 100644 index 000000000..5bbb18863 --- /dev/null +++ b/apps/frontend/src/app/services/user.service.ts @@ -0,0 +1,98 @@ +import { Injectable, inject } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { + catchError, + map, + Observable, + of +} from 'rxjs'; +import { CreateUserDto } from '../../../../../api-dto/user/create-user-dto'; +import { UserFullDto } from '../../../../../api-dto/user/user-full-dto'; +import { UserInListDto } from '../../../../../api-dto/user/user-in-list-dto'; +import { UserWorkspaceAccessDto } from '../../../../../api-dto/workspaces/user-workspace-access-dto'; +import { SERVER_URL } from '../injection-tokens'; + +@Injectable({ + providedIn: 'root' +}) +export class UserService { + readonly serverUrl = inject(SERVER_URL); + private http = inject(HttpClient); + + get authHeader() { + return { Authorization: `Bearer ${localStorage.getItem('id_token')}` }; + } + + getUsers(workspaceId: number): Observable { + return this.http + .get(`${this.serverUrl}admin/users/access/${workspaceId}`, { headers: this.authHeader }); + } + + saveUsers(workspaceId: number, users: UserWorkspaceAccessDto[]): Observable { + return this.http + .patch(`${this.serverUrl}admin/users/access/${workspaceId}`, + users, + { headers: this.authHeader }); + } + + getUsersFull(): Observable { + return this.http + .get( + `${this.serverUrl}admin/users/full`, + { headers: this.authHeader }) + .pipe( + catchError(() => of([])) + ); + } + + addUser(newUser: CreateUserDto): Observable { + return this.http + .post( + `${this.serverUrl}admin/users`, + newUser, + { headers: this.authHeader } + ) + .pipe( + catchError(() => of(false)), + map(() => true) + ); + } + + changeUserData(userId: number, newData: UserFullDto): Observable { + return this.http + .patch( + `${this.serverUrl}admin/users/${userId}`, + newData, + { headers: this.authHeader }) + .pipe( + catchError(() => of(false)), + map(() => true) + ); + } + + deleteUsers(users: number[]): Observable { + return this.http + .delete(`${this.serverUrl}admin/users/${users.join(';')}`, + { headers: this.authHeader }) + .pipe( + catchError(() => of(false)), + map(() => true) + ); + } + + getWorkspacesByUserList(userId: number): Observable { + return this.http + .get(`${this.serverUrl}admin/users/${userId}/workspaces`, + { headers: this.authHeader }) + .pipe( + catchError(() => of([])) + ); + } + + setUserWorkspaceAccessRight(userId: number, workspaceIds: number[]): Observable { + return this.http.post( + `${this.serverUrl}admin/users/${userId}/workspaces/`, + workspaceIds, + { headers: this.authHeader }); + } +} diff --git a/apps/frontend/src/app/services/validation.service.ts b/apps/frontend/src/app/services/validation.service.ts new file mode 100644 index 000000000..004206193 --- /dev/null +++ b/apps/frontend/src/app/services/validation.service.ts @@ -0,0 +1,142 @@ +import { Injectable, inject } from '@angular/core'; +import { HttpClient, HttpParams } from '@angular/common/http'; +import { + catchError, + Observable, + of +} from 'rxjs'; +import { InvalidVariableDto } from '../../../../../api-dto/files/variable-validation.dto'; +import { TestTakersValidationDto } from '../../../../../api-dto/files/testtakers-validation.dto'; +import { SERVER_URL } from '../injection-tokens'; + +interface PaginatedResponse { + data: T[]; + total: number; + page: number; + limit: number; +} + +@Injectable({ + providedIn: 'root' +}) +export class ValidationService { + readonly serverUrl = inject(SERVER_URL); + private http = inject(HttpClient); + + get authHeader() { + return { Authorization: `Bearer ${localStorage.getItem('id_token')}` }; + } + + validateVariables(workspaceId: number, page: number = 1, limit: number = 10): Observable> { + const params = new HttpParams() + .set('page', page.toString()) + .set('limit', limit.toString()); + + return this.http.get>( + `${this.serverUrl}admin/workspace/${workspaceId}/files/validate-variables`, + { headers: this.authHeader, params } + ).pipe( + catchError(() => of({ + data: [], + total: 0, + page, + limit + })) + ); + } + + validateVariableTypes(workspaceId: number, page: number = 1, limit: number = 10): Observable> { + const params = new HttpParams() + .set('page', page.toString()) + .set('limit', limit.toString()); + + return this.http.get>( + `${this.serverUrl}admin/workspace/${workspaceId}/files/validate-variable-types`, + { headers: this.authHeader, params } + ).pipe( + catchError(() => of({ + data: [], + total: 0, + page, + limit + })) + ); + } + + validateResponseStatus(workspaceId: number, page: number = 1, limit: number = 10): Observable> { + const params = new HttpParams() + .set('page', page.toString()) + .set('limit', limit.toString()); + + return this.http.get>( + `${this.serverUrl}admin/workspace/${workspaceId}/files/validate-response-status`, + { headers: this.authHeader, params } + ).pipe( + catchError(() => of({ + data: [], + total: 0, + page, + limit + })) + ); + } + + validateTestTakers(workspaceId: number): Observable { + return this.http.get( + `${this.serverUrl}admin/workspace/${workspaceId}/files/validate-testtakers`, + { headers: this.authHeader } + ).pipe( + catchError(() => of({ + testTakersFound: false, + totalGroups: 0, + totalLogins: 0, + totalBookletCodes: 0, + missingPersons: [] + })) + ); + } + + validateGroupResponses(workspaceId: number, page: number = 1, limit: number = 10): Observable<{ + testTakersFound: boolean; + groupsWithResponses: { group: string; hasResponse: boolean }[]; + allGroupsHaveResponses: boolean; + total: number; + page: number; + limit: number; + }> { + const params = new HttpParams() + .set('page', page.toString()) + .set('limit', limit.toString()); + + return this.http.get<{ + testTakersFound: boolean; + groupsWithResponses: { group: string; hasResponse: boolean }[]; + allGroupsHaveResponses: boolean; + total: number; + page: number; + limit: number; + }>( + `${this.serverUrl}admin/workspace/${workspaceId}/files/validate-group-responses`, + { headers: this.authHeader, params } + ).pipe( + catchError(() => of({ + testTakersFound: false, + groupsWithResponses: [], + allGroupsHaveResponses: false, + total: 0, + page, + limit + })) + ); + } + + deleteInvalidResponses(workspaceId: number, responseIds: number[]): Observable { + const params = new HttpParams().set('responseIds', responseIds.join(',')); + return this.http.delete( + `${this.serverUrl}admin/workspace/${workspaceId}/files/invalid-responses`, + { headers: this.authHeader, params } + ).pipe( + catchError(() => of(0)) + ); + } +} diff --git a/apps/frontend/src/app/services/workspace.service.ts b/apps/frontend/src/app/services/workspace.service.ts new file mode 100644 index 000000000..2b9cdd3dc --- /dev/null +++ b/apps/frontend/src/app/services/workspace.service.ts @@ -0,0 +1,93 @@ +import { Injectable, inject } from '@angular/core'; +import { HttpClient, HttpParams } from '@angular/common/http'; +import { + catchError, + map, + Observable, + of +} from 'rxjs'; +import { WorkspaceFullDto } from '../../../../../api-dto/workspaces/workspace-full-dto'; +import { CreateWorkspaceDto } from '../../../../../api-dto/workspaces/create-workspace-dto'; +import { PaginatedWorkspacesDto } from '../../../../../api-dto/workspaces/paginated-workspaces-dto'; +import { PaginatedWorkspaceUserDto } from '../../../../../api-dto/workspaces/paginated-workspace-user-dto'; +import { SERVER_URL } from '../injection-tokens'; + +@Injectable({ + providedIn: 'root' +}) +export class WorkspaceService { + readonly serverUrl = inject(SERVER_URL); + private http = inject(HttpClient); + + get authHeader() { + return { Authorization: `Bearer ${localStorage.getItem('id_token')}` }; + } + + getAllWorkspacesList(): Observable { + return this.http + .get(`${this.serverUrl}admin/workspace`, + { headers: this.authHeader }) + .pipe( + catchError(() => { + const defaultResponse: PaginatedWorkspacesDto = { + data: [], + total: 0, + page: 0, + limit: 0 + }; + return of(defaultResponse); + }) + ); + } + + getWorkspaceUsers(workspaceId: number): Observable { + return this.http + .get(`${this.serverUrl}admin/workspace/${workspaceId}/users`, + { headers: this.authHeader }) + .pipe( + catchError(() => of({ + data: [], + total: 0, + page: 0, + limit: 0 + })) + ); + } + + addWorkspace(workspaceData: CreateWorkspaceDto): Observable { + return this.http + .post(`${this.serverUrl}admin/workspace`, workspaceData, { headers: this.authHeader }) + .pipe( + catchError(() => of(false)) + ); + } + + deleteWorkspace(ids: number[]): Observable { + const params = new HttpParams().set('ids', ids.join(';')); + return this.http + .delete(`${this.serverUrl}admin/workspace`, { + headers: this.authHeader, + params + }) + .pipe( + catchError(() => of(false)), + map(() => true) + ); + } + + changeWorkspace(workspaceData: WorkspaceFullDto): Observable { + return this.http + .patch(`${this.serverUrl}admin/workspace`, workspaceData, { headers: this.authHeader }) + .pipe( + catchError(() => of(false)), + map(() => true) + ); + } + + setWorkspaceUsersAccessRight(workspaceId: number, userIds: number[]): Observable { + return this.http.post( + `${this.serverUrl}admin/workspace/${workspaceId}/users/`, + userIds, + { headers: this.authHeader }); + } +} diff --git a/apps/frontend/src/app/ws-admin/components/test-center-import/test-center-import.component.ts b/apps/frontend/src/app/ws-admin/components/test-center-import/test-center-import.component.ts index 268289905..ae301b25a 100755 --- a/apps/frontend/src/app/ws-admin/components/test-center-import/test-center-import.component.ts +++ b/apps/frontend/src/app/ws-admin/components/test-center-import/test-center-import.component.ts @@ -30,20 +30,6 @@ import { WorkspaceAdminService } from '../../services/workspace-admin.service'; import { TestGroupsInfoDto } from '../../../../../../../api-dto/files/test-groups-info.dto'; import { ConfirmDialogComponent, ConfirmDialogData } from '../../../shared/dialogs/confirm-dialog.component'; -export type ServerResponse = { - token: string, - displayName: string, - customTexts: unknown, - flags: [], - claims: { - workspaceAdmin: WorkspaceAdmin[], - }, - groupToken: null, - access: { - workspaceAdmin: string[], - } -}; - export type WorkspaceAdmin = { label: string, id: string, From d453ca8b3bf6795a13097db15673b2e693d79f37 Mon Sep 17 00:00:00 2001 From: jurei733 <67505990+jurei733@users.noreply.github.com> Date: Tue, 8 Jul 2025 09:40:03 +0200 Subject: [PATCH 08/36] Improve test results data rendering --- .../src/app/services/backend.service.ts | 2 -- .../test-results/test-results.component.html | 27 +++---------------- .../test-results/test-results.component.ts | 15 ++++++++++- 3 files changed, 18 insertions(+), 26 deletions(-) diff --git a/apps/frontend/src/app/services/backend.service.ts b/apps/frontend/src/app/services/backend.service.ts index 4a55e3ec0..967715e6b 100755 --- a/apps/frontend/src/app/services/backend.service.ts +++ b/apps/frontend/src/app/services/backend.service.ts @@ -1,5 +1,4 @@ import { Injectable, inject } from '@angular/core'; -import { HttpClient } from '@angular/common/http'; import { Observable } from 'rxjs'; import { FilesInListDto } from 'api-dto/files/files-in-list.dto'; import { UnitNoteDto } from 'api-dto/unit-notes/unit-note.dto'; @@ -92,7 +91,6 @@ interface ResponseEntity { }) export class BackendService { readonly serverUrl = inject(SERVER_URL); - private http = inject(HttpClient); appService = inject(AppService); // Inject specialized services diff --git a/apps/frontend/src/app/ws-admin/components/test-results/test-results.component.html b/apps/frontend/src/app/ws-admin/components/test-results/test-results.component.html index 84b5c2e93..f90829648 100755 --- a/apps/frontend/src/app/ws-admin/components/test-results/test-results.component.html +++ b/apps/frontend/src/app/ws-admin/components/test-results/test-results.component.html @@ -164,7 +164,7 @@

Booklets

- @for (booklet of booklets; track booklet) { + @for (booklet of booklets; track booklet.id) { @@ -202,7 +202,7 @@

Booklets

Aufgaben

- @for (unit of booklet.units; track unit) { + @for (unit of booklet.units; track unit.id) { @@ -213,7 +213,7 @@

Aufgaben

} @if (unit.id) {
- @for (tag of getUnitTags(unit.id); track tag) { + @for (tag of getUnitTags(unit.id); track tag.id) {
{{ tag.tag }}
@@ -259,7 +259,7 @@

Antworten

- @for (response of this.responses; track response) { + @for (response of this.responses; track response.id) {
@@ -314,25 +314,6 @@

Antworten

} - - @if (logs.length > 0) { -
-

Logs

-

Protokolleinträge für die ausgewählte Unit

- -
- @for (log of this.logs; track log;) { -
-
- {{ log.key }} - {{ formatTimestamp(log.ts) }} -
-
{{ log.parameter }}
-
- } -
-
- } diff --git a/apps/frontend/src/app/ws-admin/components/test-results/test-results.component.ts b/apps/frontend/src/app/ws-admin/components/test-results/test-results.component.ts index a109173f0..295aa3372 100755 --- a/apps/frontend/src/app/ws-admin/components/test-results/test-results.component.ts +++ b/apps/frontend/src/app/ws-admin/components/test-results/test-results.component.ts @@ -430,10 +430,23 @@ export class TestResultsComponent implements OnInit, OnDestroy { } onUnitClick(unit: Unit, booklet: Booklet): void { - this.responses = unit.results.map((response: UnitResult) => ({ + const mappedResponses = unit.results.map((response: UnitResult) => ({ ...response, expanded: false })); + const getUniqueKey = (r: Response) => `${r.variableid}|${r.unitid}|${r.value}`; + + const uniqueMap = new Map(); + mappedResponses.forEach(response => { + const key = getUniqueKey(response); + if (!uniqueMap.has(key)) { + uniqueMap.set(key, response); + } + }); + + const uniqueResponses = Array.from(uniqueMap.values()); + + this.responses = uniqueResponses; this.selectedBooklet = booklet.name; this.responses.sort((a: Response, b: Response) => { From 7bc1914fdd1ba47b7d44e33297e56fb60c3c7000 Mon Sep 17 00:00:00 2001 From: jurei733 <67505990+jurei733@users.noreply.github.com> Date: Tue, 8 Jul 2025 09:49:39 +0200 Subject: [PATCH 09/36] Improve test results data gathering performance --- .../app/database/services/unit-tag.service.ts | 28 ++- .../workspace-test-results.service.ts | 221 +++++++++++------- .../test-results/test-results.component.html | 2 +- 3 files changed, 171 insertions(+), 80 deletions(-) diff --git a/apps/backend/src/app/database/services/unit-tag.service.ts b/apps/backend/src/app/database/services/unit-tag.service.ts index c8bfd9980..6b7dd8e65 100644 --- a/apps/backend/src/app/database/services/unit-tag.service.ts +++ b/apps/backend/src/app/database/services/unit-tag.service.ts @@ -1,6 +1,6 @@ import { Injectable, NotFoundException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; +import { Repository, In } from 'typeorm'; import { UnitTag } from '../entities/unitTag.entity'; import { Unit } from '../entities/unit.entity'; import { CreateUnitTagDto } from '../../../../../../api-dto/unit-tags/create-unit-tag.dto'; @@ -82,6 +82,32 @@ export class UnitTagService { })); } + /** + * Find all tags for multiple units in a single query + * @param unitIds Array of unit IDs + * @returns An array of tags for all specified units + */ + async findAllByUnitIds(unitIds: number[]): Promise { + if (!unitIds || unitIds.length === 0) { + return []; + } + + // Find all tags for the units in a single query + const tags = await this.unitTagRepository.find({ + where: { unitId: In(unitIds) }, + order: { createdAt: 'DESC' } + }); + + // Return the DTOs + return tags.map(tag => ({ + id: tag.id, + unitId: tag.unitId, + tag: tag.tag, + color: tag.color, + createdAt: tag.createdAt + })); + } + /** * Find a tag by ID * @param id The ID of the tag diff --git a/apps/backend/src/app/database/services/workspace-test-results.service.ts b/apps/backend/src/app/database/services/workspace-test-results.service.ts index e50cca701..1fa00b194 100644 --- a/apps/backend/src/app/database/services/workspace-test-results.service.ts +++ b/apps/backend/src/app/database/services/workspace-test-results.service.ts @@ -64,110 +64,175 @@ export class WorkspaceTestResultsService { `Fetching booklets, bookletInfo data, units, and test results for personId: ${personId} and workspaceId: ${workspaceId}` ); - const booklets = await this.bookletRepository.find({ - where: { personid: personId }, - select: ['id', 'personid', 'infoid'] - }); + // Get booklets with bookletInfo in a single query using join + const booklets = await this.bookletRepository + .createQueryBuilder('booklet') + .innerJoinAndSelect('booklet.bookletinfo', 'bookletinfo') + .where('booklet.personid = :personId', { personId }) + .select([ + 'booklet.id', + 'booklet.personid', + 'bookletinfo.id', + 'bookletinfo.name', + 'bookletinfo.size' + ]) + .getMany(); + if (!booklets || booklets.length === 0) { this.logger.log(`No booklets found for personId: ${personId}`); return []; } const bookletIds = booklets.map(booklet => booklet.id); - const bookletInfoIds = booklets.map(booklet => booklet.infoid); - const bookletInfoData = await this.bookletInfoRepository.find({ - where: { id: In(bookletInfoIds) }, - select: ['id', 'name', 'size'] - }); - const units = await this.unitRepository.find({ - where: { bookletid: In(bookletIds) }, - select: ['id', 'name', 'alias', 'bookletid'] - }); + // Get units with responses in a single query using join + const units = await this.unitRepository + .createQueryBuilder('unit') + .leftJoinAndSelect('unit.responses', 'response') + .where('unit.bookletid IN (:...bookletIds)', { bookletIds }) + .select([ + 'unit.id', + 'unit.name', + 'unit.alias', + 'unit.bookletid', + 'response.id', + 'response.unitid', + 'response.variableid', + 'response.status', + 'response.value', + 'response.subform', + 'response.code', + 'response.score', + 'response.codedstatus' + ]) + .getMany(); const unitIds = units.map(unit => unit.id); - const responses = await this.responseRepository.find({ - where: { unitid: In(unitIds) } + // Create a map of unit ID to responses + const unitResultMap = new Map(); + units.forEach(unit => { + if (unit.responses) { + // Remove duplicate responses + const uniqueResponses = Array.from( + new Map(unit.responses.map(response => [response.id, response])).values() + ); + unitResultMap.set(unit.id, uniqueResponses); + } }); - const uniqueResponses = Array.from( - new Map(responses.map(response => [response.id, response])).values() - ); + // Get booklet logs in a single query + const bookletLogs = await this.bookletLogRepository + .createQueryBuilder('bookletLog') + .where('bookletLog.bookletid IN (:...bookletIds)', { bookletIds }) + .select(['bookletLog.id', 'bookletLog.bookletid', 'bookletLog.ts', 'bookletLog.parameter', 'bookletLog.key']) + .getMany(); - const unitResultMap = new Map(); - for (const response of uniqueResponses) { - if (!unitResultMap.has(response.unitid)) { - unitResultMap.set(response.unitid, []); - } - unitResultMap.get(response.unitid)?.push(response); - } + // Get sessions in a single query + const sessions = await this.sessionRepository + .createQueryBuilder('session') + .innerJoin('session.booklet', 'booklet') + .where('booklet.id IN (:...bookletIds)', { bookletIds }) + .select(['session.id', 'session.browser', 'session.os', 'session.screen', 'session.ts', 'booklet.id']) + .getMany(); - const bookletLogs = await this.bookletLogRepository.find({ - where: { bookletid: In(bookletIds) }, - select: ['id', 'bookletid', 'ts', 'parameter', 'key'] - }); + // Get unit logs in a single query + const unitLogs = await this.unitLogRepository + .createQueryBuilder('unitLog') + .where('unitLog.unitid IN (:...unitIds)', { unitIds }) + .select(['unitLog.id', 'unitLog.unitid', 'unitLog.ts', 'unitLog.key', 'unitLog.parameter']) + .getMany(); - const sessions = await this.sessionRepository.find({ - where: { booklet: { id: In(bookletIds) } }, - relations: ['booklet'], - select: ['id', 'browser', 'os', 'screen', 'ts'] + // Group logs by unit ID for faster lookup + const unitLogsMap = new Map(); + unitLogs.forEach(log => { + if (!unitLogsMap.has(log.unitid)) { + unitLogsMap.set(log.unitid, []); + } + unitLogsMap.get(log.unitid)?.push({ + id: log.id, + unitid: log.unitid, + ts: log.ts.toString(), + key: log.key, + parameter: log.parameter + }); }); - const unitLogs = await this.unitLogRepository.find({ - where: { unitid: In(unitIds) }, - select: ['id', 'unitid', 'ts', 'key', 'parameter'] - }); + // Get unit tags in a single batch query instead of multiple individual queries + const unitTagsMap = new Map(); - const allUnitTags = await Promise.all( - unitIds.map(unitId => this.unitTagService.findAllByUnitId(unitId)) - ); + // Only fetch tags if there are units + if (unitIds.length > 0) { + const allTags = await this.unitTagService.findAllByUnitIds(unitIds); - const unitTagsMap = new Map(); - unitIds.forEach((unitId, index) => { - unitTagsMap.set(unitId, allUnitTags[index]); - }); + // Group tags by unit ID + allTags.forEach(tag => { + if (!unitTagsMap.has(tag.unitId)) { + unitTagsMap.set(tag.unitId, []); + } + unitTagsMap.get(tag.unitId)?.push(tag); + }); + } - return booklets.map(booklet => { - const bookletInfo = bookletInfoData.find(info => info.id === booklet.infoid); - return { - id: booklet.id, - personid: booklet.personid, - name: bookletInfo.name, - size: bookletInfo.size, - logs: bookletLogs.filter(log => log.bookletid === booklet.id).map(log => ({ - id: log.id, - bookletid: log.bookletid, - ts: log.ts.toString(), - key: log.key, - parameter: log.parameter - })), - sessions: sessions.filter(session => session.booklet?.id === booklet.id).map(session => ({ + // Group sessions by booklet ID for faster lookup + const sessionsMap = new Map(); + sessions.forEach(session => { + const bookletId = session.booklet?.id; + if (bookletId && !sessionsMap.has(bookletId)) { + sessionsMap.set(bookletId, []); + } + if (bookletId) { + sessionsMap.get(bookletId)?.push({ id: session.id, browser: session.browser, os: session.os, screen: session.screen, ts: session.ts?.toString() - })), - units: units - .filter(unit => unit.bookletid === booklet.id) - .map(unit => ({ - id: unit.id, - bookletid: unit.bookletid, - name: unit.name, - alias: unit.alias, - results: unitResultMap.get(unit.id) || [], - logs: unitLogs.filter(log => log.unitid === unit.id).map(log => ({ - id: log.id, - unitid: log.unitid, - ts: log.ts.toString(), - key: log.key, - parameter: log.parameter - })), - tags: unitTagsMap.get(unit.id) || [] - })) - }; + }); + } + }); + + // Group booklet logs by booklet ID for faster lookup + const bookletLogsMap = new Map(); + bookletLogs.forEach(log => { + if (!bookletLogsMap.has(log.bookletid)) { + bookletLogsMap.set(log.bookletid, []); + } + bookletLogsMap.get(log.bookletid)?.push({ + id: log.id, + bookletid: log.bookletid, + ts: log.ts.toString(), + key: log.key, + parameter: log.parameter + }); }); + + // Group units by booklet ID for faster lookup + const unitsMap = new Map(); + units.forEach(unit => { + if (!unitsMap.has(unit.bookletid)) { + unitsMap.set(unit.bookletid, []); + } + unitsMap.get(unit.bookletid)?.push(unit); + }); + + return booklets.map(booklet => ({ + id: booklet.id, + personid: booklet.personid, + name: booklet.bookletinfo.name, + size: booklet.bookletinfo.size, + logs: bookletLogsMap.get(booklet.id) || [], + sessions: sessionsMap.get(booklet.id) || [], + units: (unitsMap.get(booklet.id) || []).map(unit => ({ + id: unit.id, + bookletid: unit.bookletid, + name: unit.name, + alias: unit.alias, + results: unitResultMap.get(unit.id) || [], + logs: unitLogsMap.get(unit.id) || [], + tags: unitTagsMap.get(unit.id) || [] + })) + })); } catch (error) { this.logger.error( `Failed to fetch booklets, bookletInfo, units, and results for personId: ${personId} and workspaceId: ${workspaceId}`, diff --git a/apps/frontend/src/app/ws-admin/components/test-results/test-results.component.html b/apps/frontend/src/app/ws-admin/components/test-results/test-results.component.html index f90829648..c121b6332 100755 --- a/apps/frontend/src/app/ws-admin/components/test-results/test-results.component.html +++ b/apps/frontend/src/app/ws-admin/components/test-results/test-results.component.html @@ -305,7 +305,7 @@

Antworten

}
Value: - {{ response.value | slice:0:1000 }}{{ response.value.length > 1000 ? '...' : '' }} + {{ response.value ? (response.value | slice:0:1000) : '' }}{{ response.value && response.value.length > 1000 ? '...' : '' }}
} From ced933807278a9ddf2f48e9356109f5a30ed6034 Mon Sep 17 00:00:00 2001 From: jurei733 <67505990+jurei733@users.noreply.github.com> Date: Tue, 8 Jul 2025 10:09:24 +0200 Subject: [PATCH 10/36] Scroll a block to 'start' in replay --- .../components/replay/replay.component.ts | 50 +------------------ 1 file changed, 1 insertion(+), 49 deletions(-) diff --git a/apps/frontend/src/app/replay/components/replay/replay.component.ts b/apps/frontend/src/app/replay/components/replay/replay.component.ts index ff3d5cd41..760fabca1 100755 --- a/apps/frontend/src/app/replay/components/replay/replay.component.ts +++ b/apps/frontend/src/app/replay/components/replay/replay.component.ts @@ -451,36 +451,6 @@ export class ReplayComponent implements OnInit, OnDestroy, OnChanges { return result; } - /** - * Returns the values of the data-element-alias attributes found in the player's HTML. - * - * @returns {string[]} An array of data-element-alias values - */ - getDataElementAliases(): string[] { - try { - // Access the iframe's content document through the UnitPlayerComponent - if (this.unitPlayerComponent && this.unitPlayerComponent.hostingIframe) { - const iframe = this.unitPlayerComponent.hostingIframe.nativeElement as HTMLIFrameElement; - - // Check if the iframe has loaded content - if (iframe.contentDocument) { - // Query for all div elements with data-element-alias attribute - const elements = iframe.contentDocument.querySelectorAll('div[data-element-alias]'); - - // Extract and return the alias values - return Array.from(elements) - .map(element => element.getAttribute('data-element-alias')) - .filter((alias): alias is string => alias !== null); - } - } - } catch (error) { - // eslint-disable-next-line no-console - console.error('Error getting data-element-alias values:', error); - } - - return []; - } - /** * Scrolls to a div element with the specified data-element-alias in the player's HTML. * @@ -494,7 +464,7 @@ export class ReplayComponent implements OnInit, OnDestroy, OnChanges { const element = elements[alias]; if (element) { // Use scrollIntoView with smooth behavior by default - element.scrollIntoView(options || { behavior: 'smooth', block: 'center' }); + element.scrollIntoView(options || { behavior: 'smooth', block: 'start' }); return true; } } catch (error) { @@ -504,22 +474,4 @@ export class ReplayComponent implements OnInit, OnDestroy, OnChanges { return false; } - - /** - * Updates the dataElementAliases array with the values of data-element-alias attributes - * found in the player's HTML and automatically scrolls to each element. - */ - updateDataElementAliases(): void { - this.dataElementAliases = this.getDataElementAliases(); - - // Automatically scroll to each element with data-element-alias - if (this.dataElementAliases.length > 0) { - // Scroll to each element with a small delay between each scroll - this.dataElementAliases.forEach((alias, index) => { - setTimeout(() => { - this.scrollToElementByAlias(alias, { behavior: 'smooth', block: 'center' }); - }, index); // 1 second delay between each scroll - }); - } - } } From bc30f6daec721a6aaf46f61c4b936b92c684b7b8 Mon Sep 17 00:00:00 2001 From: jurei733 <67505990+jurei733@users.noreply.github.com> Date: Tue, 8 Jul 2025 10:14:35 +0200 Subject: [PATCH 11/36] Allow no anchor element replay --- .../src/app/replay/components/replay/replay.component.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/apps/frontend/src/app/replay/components/replay/replay.component.ts b/apps/frontend/src/app/replay/components/replay/replay.component.ts index 760fabca1..dceb0759d 100755 --- a/apps/frontend/src/app/replay/components/replay/replay.component.ts +++ b/apps/frontend/src/app/replay/components/replay/replay.component.ts @@ -153,7 +153,7 @@ export class ReplayComponent implements OnInit, OnDestroy, OnChanges { const workspace = decoded?.workspace; const unitData = await this.getUnitData(Number(workspace), this.authToken); this.setUnitProperties(unitData); - } else if (Object.keys(params).length === 4) { + } else if (Object.keys(params).length >= 3 && Object.keys(params).length <= 4) { this.setUnitParams(params); if (this.authToken) { const decoded: JwtPayload & { workspace: string } = jwtDecode(this.authToken); @@ -161,8 +161,9 @@ export class ReplayComponent implements OnInit, OnDestroy, OnChanges { if (workspace) { const unitData = await this.getUnitData(Number(workspace), this.authToken); this.setUnitProperties(unitData); - setTimeout(() => this.scrollToElementByAlias(this.anchor || ''), 1000 - ); + if (this.anchor) { + setTimeout(() => this.scrollToElementByAlias(this.anchor || ''), 1000); + } } } else { ReplayComponent.throwError('QueryError'); From 8c36ab4aef4e1f9f117f8af4a9d9f0a4ce7bd903 Mon Sep 17 00:00:00 2001 From: jurei733 <67505990+jurei733@users.noreply.github.com> Date: Tue, 8 Jul 2025 10:31:31 +0200 Subject: [PATCH 12/36] Update docker image for postgres --- database/Postgres.Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/database/Postgres.Dockerfile b/database/Postgres.Dockerfile index f7da330e0..8e1a41819 100644 --- a/database/Postgres.Dockerfile +++ b/database/Postgres.Dockerfile @@ -3,7 +3,7 @@ ARG REGISTRY_PATH="" -FROM ${REGISTRY_PATH}postgres:14.12-alpine3.18 +FROM ${REGISTRY_PATH}postgres:14.12-alpine3.19 RUN --mount=type=cache,target=/var/cache/apk \ apk add --update musl musl-utils musl-locales tzdata From 18f5dc6b771565368053fed4f1742f7ff59ce637 Mon Sep 17 00:00:00 2001 From: jurei733 <67505990+jurei733@users.noreply.github.com> Date: Tue, 8 Jul 2025 16:58:37 +0200 Subject: [PATCH 13/36] Standardize feature-based organization with clear directory structures for each feature module --- apps/frontend/src/app/app.component.spec.ts | 41 +++++++++++++++ apps/frontend/src/app/app.component.ts | 2 +- apps/frontend/src/app/app.routes.ts | 51 ++++--------------- .../src/app/auth/{ => guards}/auth.guard.ts | 0 .../src/app/auth/{ => guards}/token.guard.ts | 0 .../auth.service.spec.ts | 0 .../{service => services}/auth.service.ts | 0 apps/frontend/src/app/coding/coding.routes.ts | 10 ++++ .../components/home/home.component.spec.ts | 2 +- apps/frontend/src/app/replay/replay.routes.ts | 28 ++++++++++ .../user-menu/user-menu.component.ts | 2 +- .../src/app/sys-admin/sys-admin.routes.ts | 18 +++++++ .../user-workspaces-area.component.spec.ts | 2 + .../user-workspaces.component.spec.ts | 2 +- .../user-workspaces.component.ts | 2 +- .../src/app/ws-admin/ws-admin.routes.ts | 19 +++++++ 16 files changed, 132 insertions(+), 47 deletions(-) create mode 100755 apps/frontend/src/app/app.component.spec.ts mode change 100755 => 100644 apps/frontend/src/app/app.routes.ts rename apps/frontend/src/app/auth/{ => guards}/auth.guard.ts (100%) rename apps/frontend/src/app/auth/{ => guards}/token.guard.ts (100%) rename apps/frontend/src/app/auth/{service => services}/auth.service.spec.ts (100%) rename apps/frontend/src/app/auth/{service => services}/auth.service.ts (100%) create mode 100644 apps/frontend/src/app/coding/coding.routes.ts create mode 100644 apps/frontend/src/app/replay/replay.routes.ts create mode 100644 apps/frontend/src/app/sys-admin/sys-admin.routes.ts create mode 100644 apps/frontend/src/app/ws-admin/ws-admin.routes.ts diff --git a/apps/frontend/src/app/app.component.spec.ts b/apps/frontend/src/app/app.component.spec.ts new file mode 100755 index 000000000..2fcc690b6 --- /dev/null +++ b/apps/frontend/src/app/app.component.spec.ts @@ -0,0 +1,41 @@ +import { TestBed } from '@angular/core/testing'; +import { provideHttpClient } from '@angular/common/http'; +import { InjectionToken } from '@angular/core'; +import { KeycloakService } from 'keycloak-angular'; +import { AppComponent } from './app.component'; +import { environment } from '../environments/environment'; +import { AuthService } from './auth/service/auth.service'; + +export const AUTH_TOKEN = new InjectionToken('AUTH_TOKEN'); +const mockAuthService = { + isLoggedIn: jest.fn(() => true) +}; + +const mockKeycloakService = { + isLoggedIn: () => true, + getToken: () => 'mocked-jwt-token', + login: jest.fn(), + logout: jest.fn() +}; + +describe('AppComponent', () => { + beforeEach(async () => { + await TestBed.configureTestingModule({ + providers: [provideHttpClient(), { provide: AUTH_TOKEN, useValue: 'dummy-auth-token' }, + { provide: AuthService, useValue: mockAuthService }, + { provide: KeycloakService, useValue: mockKeycloakService }, + + { + provide: 'SERVER_URL', + useValue: environment.backendUrl + }], + imports: [AppComponent] + }).compileComponents(); + }); + + it('should create the app', () => { + const fixture = TestBed.createComponent(AppComponent); + const app = fixture.componentInstance; + expect(app).toBeTruthy(); + }); +}); diff --git a/apps/frontend/src/app/app.component.ts b/apps/frontend/src/app/app.component.ts index c507ba7d1..cd5c384c5 100755 --- a/apps/frontend/src/app/app.component.ts +++ b/apps/frontend/src/app/app.component.ts @@ -8,7 +8,7 @@ import { MatButton } from '@angular/material/button'; import { LocationStrategy } from '@angular/common'; import { KeycloakProfile } from 'keycloak-js'; import { AppService } from './services/app.service'; -import { AuthService } from './auth/service/auth.service'; +import { AuthService } from './auth/services/auth.service'; import { CreateUserDto } from '../../../../api-dto/user/create-user-dto'; import { BackendService } from './services/backend.service'; import { WrappedIconComponent } from './shared/wrapped-icon/wrapped-icon.component'; diff --git a/apps/frontend/src/app/app.routes.ts b/apps/frontend/src/app/app.routes.ts old mode 100755 new mode 100644 index eb8ce3f83..1ea3dfbf4 --- a/apps/frontend/src/app/app.routes.ts +++ b/apps/frontend/src/app/app.routes.ts @@ -1,6 +1,10 @@ import { Routes } from '@angular/router'; -import { canActivateAuth } from './auth/auth.guard'; +import { canActivateAuth } from './auth/guards/auth.guard'; +import { replayRoutes } from './replay/replay.routes'; +import { sysAdminRoutes } from './sys-admin/sys-admin.routes'; +import { wsAdminRoutes } from './ws-admin/ws-admin.routes'; +import { codingRoutes } from './coding/coding.routes'; export const routes: Routes = [ { path: '', redirectTo: 'home', pathMatch: 'full' }, @@ -8,46 +12,9 @@ export const routes: Routes = [ path: 'home', loadComponent: () => import('./components/home/home.component').then(m => m.HomeComponent) }, - { - path: 'replay/:testPerson/:unitId/:page/:anchor', - loadComponent: () => import('./replay/components/replay/replay.component').then(m => m.ReplayComponent) - }, - { - path: 'replay/:testPerson/:unitId/:page', - loadComponent: () => import('./replay/components/replay/replay.component').then(m => m.ReplayComponent) - }, - { - path: 'replay/:testPerson/:unitId', - loadComponent: () => import('./replay/components/replay/replay.component').then(m => m.ReplayComponent) - }, - { path: 'print-view/:unitId', loadComponent: () => import('./replay/components/replay/replay.component').then(m => m.ReplayComponent) }, - { path: 'replay/:testPerson', loadComponent: () => import('./replay/components/replay/replay.component').then(m => m.ReplayComponent) }, - { path: 'replay', loadComponent: () => import('./replay/components/replay/replay.component').then(m => m.ReplayComponent) }, - { path: 'coding-manual', canActivate: [canActivateAuth], loadComponent: () => import('./coding/coding-management-manual/coding-management-manual.component').then(m => m.CodingManagementManualComponent) }, - { - path: 'admin', - canActivate: [canActivateAuth], - loadComponent: () => import('./sys-admin/components/admin/admin.component').then(m => m.AdminComponent), - children: [ - { path: '', redirectTo: 'users', pathMatch: 'full' }, - { path: 'users', loadComponent: () => import('./sys-admin/components/users/users.component').then(m => m.UsersComponent) }, - { path: 'settings', loadComponent: () => import('./sys-admin/components/sys-admin-settings/sys-admin-settings.component').then(m => m.SysAdminSettingsComponent) }, - { path: 'workspaces', loadComponent: () => import('./sys-admin/components/workspaces/workspaces.component').then(m => m.WorkspacesComponent) }, - { path: 'workspace/:ws', loadComponent: () => import('./sys-admin/components/workspaces/workspaces.component').then(m => m.WorkspacesComponent) }, - { path: '**', loadComponent: () => import('./sys-admin/components/users/users.component').then(m => m.UsersComponent) }] - }, { - path: 'workspace-admin/:ws', - canActivate: [canActivateAuth], - loadComponent: () => import('./ws-admin/components/ws-admin/ws-admin.component').then(m => m.WsAdminComponent), - children: [ - { path: '', redirectTo: 'test-files', pathMatch: 'full' }, - { path: 'test-files', loadComponent: () => import('./ws-admin/components/test-files/test-files.component').then(m => m.TestFilesComponent) }, - { path: 'test-results', loadComponent: () => import('./ws-admin/components/test-groups/test-groups.component').then(m => m.TestGroupsComponent) }, - { path: 'users', loadComponent: () => import('./ws-admin/components/ws-users/ws-users.component').then(m => m.WsUsersComponent) }, - { path: 'coding', loadComponent: () => import('./coding/coding-managment/coding-management.component').then(m => m.CodingManagementComponent) }, - { path: 'settings', loadComponent: () => import('./ws-admin/components/ws-settings/ws-settings.component').then(m => m.WsSettingsComponent) }, - { path: '**', loadComponent: () => import('./ws-admin/components/test-files/test-files.component').then(m => m.TestFilesComponent) } - ] - }, + ...replayRoutes, + ...codingRoutes, + ...sysAdminRoutes, + ...wsAdminRoutes, { path: '**', loadComponent: () => import('./components/home/home.component').then(m => m.HomeComponent) } ]; diff --git a/apps/frontend/src/app/auth/auth.guard.ts b/apps/frontend/src/app/auth/guards/auth.guard.ts similarity index 100% rename from apps/frontend/src/app/auth/auth.guard.ts rename to apps/frontend/src/app/auth/guards/auth.guard.ts diff --git a/apps/frontend/src/app/auth/token.guard.ts b/apps/frontend/src/app/auth/guards/token.guard.ts similarity index 100% rename from apps/frontend/src/app/auth/token.guard.ts rename to apps/frontend/src/app/auth/guards/token.guard.ts diff --git a/apps/frontend/src/app/auth/service/auth.service.spec.ts b/apps/frontend/src/app/auth/services/auth.service.spec.ts similarity index 100% rename from apps/frontend/src/app/auth/service/auth.service.spec.ts rename to apps/frontend/src/app/auth/services/auth.service.spec.ts diff --git a/apps/frontend/src/app/auth/service/auth.service.ts b/apps/frontend/src/app/auth/services/auth.service.ts similarity index 100% rename from apps/frontend/src/app/auth/service/auth.service.ts rename to apps/frontend/src/app/auth/services/auth.service.ts diff --git a/apps/frontend/src/app/coding/coding.routes.ts b/apps/frontend/src/app/coding/coding.routes.ts new file mode 100644 index 000000000..06a3a9bfe --- /dev/null +++ b/apps/frontend/src/app/coding/coding.routes.ts @@ -0,0 +1,10 @@ +import { Routes } from '@angular/router'; +import { canActivateAuth } from '../auth/guards/auth.guard'; + +export const codingRoutes: Routes = [ + { + path: 'coding-manual', + canActivate: [canActivateAuth], + loadComponent: () => import('./coding-management-manual/coding-management-manual.component').then(m => m.CodingManagementManualComponent) + } +]; diff --git a/apps/frontend/src/app/components/home/home.component.spec.ts b/apps/frontend/src/app/components/home/home.component.spec.ts index 6f3d4ccbf..ad1c2bb4c 100755 --- a/apps/frontend/src/app/components/home/home.component.spec.ts +++ b/apps/frontend/src/app/components/home/home.component.spec.ts @@ -4,7 +4,7 @@ import { provideHttpClient } from '@angular/common/http'; import { ActivatedRoute } from '@angular/router'; import { of } from 'rxjs'; import { HomeComponent } from './home.component'; -import { AuthService } from '../../auth/service/auth.service'; +import { AuthService } from '../../auth/services/auth.service'; import { environment } from '../../../environments/environment'; import { AppService } from '../../services/app.service'; import { SERVER_URL } from '../../injection-tokens'; diff --git a/apps/frontend/src/app/replay/replay.routes.ts b/apps/frontend/src/app/replay/replay.routes.ts new file mode 100644 index 000000000..3b06a2ef7 --- /dev/null +++ b/apps/frontend/src/app/replay/replay.routes.ts @@ -0,0 +1,28 @@ +import { Routes } from '@angular/router'; + +export const replayRoutes: Routes = [ + { + path: 'replay/:testPerson/:unitId/:page/:anchor', + loadComponent: () => import('./components/replay/replay.component').then(m => m.ReplayComponent) + }, + { + path: 'replay/:testPerson/:unitId/:page', + loadComponent: () => import('./components/replay/replay.component').then(m => m.ReplayComponent) + }, + { + path: 'replay/:testPerson/:unitId', + loadComponent: () => import('./components/replay/replay.component').then(m => m.ReplayComponent) + }, + { + path: 'print-view/:unitId', + loadComponent: () => import('./components/replay/replay.component').then(m => m.ReplayComponent) + }, + { + path: 'replay/:testPerson', + loadComponent: () => import('./components/replay/replay.component').then(m => m.ReplayComponent) + }, + { + path: 'replay', + loadComponent: () => import('./components/replay/replay.component').then(m => m.ReplayComponent) + } +]; diff --git a/apps/frontend/src/app/sys-admin/components/user-menu/user-menu.component.ts b/apps/frontend/src/app/sys-admin/components/user-menu/user-menu.component.ts index 2e1a55251..d244e7721 100755 --- a/apps/frontend/src/app/sys-admin/components/user-menu/user-menu.component.ts +++ b/apps/frontend/src/app/sys-admin/components/user-menu/user-menu.component.ts @@ -5,7 +5,7 @@ import { MatMenu, MatMenuTrigger } from '@angular/material/menu'; import { MatButton } from '@angular/material/button'; import { WrappedIconComponent } from '../../../shared/wrapped-icon/wrapped-icon.component'; import { AccountActionComponent } from '../account-action/account-action.component'; -import { AuthService } from '../../../auth/service/auth.service'; +import { AuthService } from '../../../auth/services/auth.service'; @Component({ selector: 'coding-box-user-menu', diff --git a/apps/frontend/src/app/sys-admin/sys-admin.routes.ts b/apps/frontend/src/app/sys-admin/sys-admin.routes.ts new file mode 100644 index 000000000..a016a4c63 --- /dev/null +++ b/apps/frontend/src/app/sys-admin/sys-admin.routes.ts @@ -0,0 +1,18 @@ +import { Routes } from '@angular/router'; +import { canActivateAuth } from '../auth/guards/auth.guard'; + +export const sysAdminRoutes: Routes = [ + { + path: 'admin', + canActivate: [canActivateAuth], + loadComponent: () => import('./components/admin/admin.component').then(m => m.AdminComponent), + children: [ + { path: '', redirectTo: 'users', pathMatch: 'full' }, + { path: 'users', loadComponent: () => import('./components/users/users.component').then(m => m.UsersComponent) }, + { path: 'settings', loadComponent: () => import('./components/sys-admin-settings/sys-admin-settings.component').then(m => m.SysAdminSettingsComponent) }, + { path: 'workspaces', loadComponent: () => import('./components/workspaces/workspaces.component').then(m => m.WorkspacesComponent) }, + { path: 'workspace/:ws', loadComponent: () => import('./components/workspaces/workspaces.component').then(m => m.WorkspacesComponent) }, + { path: '**', loadComponent: () => import('./components/users/users.component').then(m => m.UsersComponent) } + ] + } +]; diff --git a/apps/frontend/src/app/workspace/components/user-workspaces-area/user-workspaces-area.component.spec.ts b/apps/frontend/src/app/workspace/components/user-workspaces-area/user-workspaces-area.component.spec.ts index 6a3bfbf93..addbf4b00 100755 --- a/apps/frontend/src/app/workspace/components/user-workspaces-area/user-workspaces-area.component.spec.ts +++ b/apps/frontend/src/app/workspace/components/user-workspaces-area/user-workspaces-area.component.spec.ts @@ -2,6 +2,8 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { TranslateModule } from '@ngx-translate/core'; import { UserWorkspacesAreaComponent } from './user-workspaces-area.component'; import { AuthService } from '../../../auth/service/auth.service'; +import { environment } from '../../../../environments/environment'; +import { AuthService } from '../../../auth/services/auth.service'; const mockAuthService = { getLoggedUser: jest.fn(), diff --git a/apps/frontend/src/app/workspace/components/user-workspaces/user-workspaces.component.spec.ts b/apps/frontend/src/app/workspace/components/user-workspaces/user-workspaces.component.spec.ts index d73638e07..d839c9c38 100755 --- a/apps/frontend/src/app/workspace/components/user-workspaces/user-workspaces.component.spec.ts +++ b/apps/frontend/src/app/workspace/components/user-workspaces/user-workspaces.component.spec.ts @@ -1,7 +1,7 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { TranslateModule } from '@ngx-translate/core'; import { UserWorkspacesComponent } from './user-workspaces.component'; -import { AuthService } from '../../../auth/service/auth.service'; +import { AuthService } from '../../../auth/services/auth.service'; const mockKeycloak = { idTokenParsed: { sub: 'test-user-id', preferred_username: 'test-user' }, diff --git a/apps/frontend/src/app/workspace/components/user-workspaces/user-workspaces.component.ts b/apps/frontend/src/app/workspace/components/user-workspaces/user-workspaces.component.ts index f164767c2..9b70b6694 100755 --- a/apps/frontend/src/app/workspace/components/user-workspaces/user-workspaces.component.ts +++ b/apps/frontend/src/app/workspace/components/user-workspaces/user-workspaces.component.ts @@ -3,7 +3,7 @@ import { TranslateModule } from '@ngx-translate/core'; import { RouterLink } from '@angular/router'; import { MatAnchor, MatButton } from '@angular/material/button'; import { WorkspaceFullDto } from '../../../../../../../api-dto/workspaces/workspace-full-dto'; -import { AuthService } from '../../../auth/service/auth.service'; +import { AuthService } from '../../../auth/services/auth.service'; @Component({ selector: 'coding-book-user-workspaces', diff --git a/apps/frontend/src/app/ws-admin/ws-admin.routes.ts b/apps/frontend/src/app/ws-admin/ws-admin.routes.ts new file mode 100644 index 000000000..5853b844e --- /dev/null +++ b/apps/frontend/src/app/ws-admin/ws-admin.routes.ts @@ -0,0 +1,19 @@ +import { Routes } from '@angular/router'; +import { canActivateAuth } from '../auth/guards/auth.guard'; + +export const wsAdminRoutes: Routes = [ + { + path: 'workspace-admin/:ws', + canActivate: [canActivateAuth], + loadComponent: () => import('./components/ws-admin/ws-admin.component').then(m => m.WsAdminComponent), + children: [ + { path: '', redirectTo: 'test-files', pathMatch: 'full' }, + { path: 'test-files', loadComponent: () => import('./components/test-files/test-files.component').then(m => m.TestFilesComponent) }, + { path: 'test-results', loadComponent: () => import('./components/test-groups/test-groups.component').then(m => m.TestGroupsComponent) }, + { path: 'users', loadComponent: () => import('./components/ws-users/ws-users.component').then(m => m.WsUsersComponent) }, + { path: 'coding', loadComponent: () => import('../coding/coding-managment/coding-management.component').then(m => m.CodingManagementComponent) }, + { path: 'settings', loadComponent: () => import('./components/ws-settings/ws-settings.component').then(m => m.WsSettingsComponent) }, + { path: '**', loadComponent: () => import('./components/test-files/test-files.component').then(m => m.TestFilesComponent) } + ] + } +]; From 6814f54cb0604f18c15f9b528545aa5bd16ab63f Mon Sep 17 00:00:00 2001 From: jurei733 <67505990+jurei733@users.noreply.github.com> Date: Tue, 8 Jul 2025 17:34:08 +0200 Subject: [PATCH 14/36] Make component models --- .../src/app/auth/models/auth-state.model.ts | 8 ++++++ .../src/app/auth/models/user.model.ts | 9 +++++++ .../coding-jobs/coding-jobs.component.ts | 27 ++++++++----------- .../coding-management.component.ts | 22 +++------------ .../coding/models/coding-category.model.ts | 7 +++++ .../src/app/coding/models/coding-job.model.ts | 9 +++++++ .../coding/models/coding-list-item.model.ts | 11 ++++++++ .../app/coding/models/coding-scheme.model.ts | 8 ++++++ .../src/app/coding/models/success.model.ts | 17 ++++++++++++ .../src/app/core/models/app-config.model.ts | 8 ++++++ .../app/core/models/error-response.model.ts | 5 ++++ .../components/replay/replay.component.ts | 16 +---------- .../unit-player/unit-player.component.ts | 8 +----- .../app/replay/models/error-messages.model.ts | 14 ++++++++++ .../src/app/replay/models/page-data.model.ts | 6 +++++ .../app/replay/models/replay-session.model.ts | 6 +++++ .../app/replay/models/response-data.model.ts | 6 +++++ .../src/app/replay/models/unit-data.model.ts | 9 +++++++ .../models/confirm-dialog-data.model.ts | 6 +++++ .../app/shared/models/dialog-data.model.ts | 5 ++++ .../app/shared/models/journal-entry.model.ts | 7 +++++ .../models/message-dialog-data.model.ts | 6 +++++ .../models/paginated-journal-entries.model.ts | 6 +++++ .../shared/models/paginated-response.model.ts | 6 +++++ .../shared/models/response-entity.model.ts | 24 +++++++++++++++++ .../app/sys-admin/models/admin-user.model.ts | 8 ++++++ .../sys-admin/models/system-settings.model.ts | 6 +++++ .../workspace/models/workspace-user.model.ts | 7 +++++ .../app/workspace/models/workspace.model.ts | 8 ++++++ .../app/ws-admin/models/test-file.model.ts | 8 ++++++ .../app/ws-admin/models/test-group.model.ts | 6 +++++ .../models/workspace-settings.model.ts | 6 +++++ 32 files changed, 248 insertions(+), 57 deletions(-) create mode 100644 apps/frontend/src/app/auth/models/auth-state.model.ts create mode 100644 apps/frontend/src/app/auth/models/user.model.ts create mode 100644 apps/frontend/src/app/coding/models/coding-category.model.ts create mode 100644 apps/frontend/src/app/coding/models/coding-job.model.ts create mode 100644 apps/frontend/src/app/coding/models/coding-list-item.model.ts create mode 100644 apps/frontend/src/app/coding/models/coding-scheme.model.ts create mode 100644 apps/frontend/src/app/coding/models/success.model.ts create mode 100644 apps/frontend/src/app/core/models/app-config.model.ts create mode 100644 apps/frontend/src/app/core/models/error-response.model.ts create mode 100644 apps/frontend/src/app/replay/models/error-messages.model.ts create mode 100644 apps/frontend/src/app/replay/models/page-data.model.ts create mode 100644 apps/frontend/src/app/replay/models/replay-session.model.ts create mode 100644 apps/frontend/src/app/replay/models/response-data.model.ts create mode 100644 apps/frontend/src/app/replay/models/unit-data.model.ts create mode 100644 apps/frontend/src/app/shared/models/confirm-dialog-data.model.ts create mode 100644 apps/frontend/src/app/shared/models/dialog-data.model.ts create mode 100644 apps/frontend/src/app/shared/models/journal-entry.model.ts create mode 100644 apps/frontend/src/app/shared/models/message-dialog-data.model.ts create mode 100644 apps/frontend/src/app/shared/models/paginated-journal-entries.model.ts create mode 100644 apps/frontend/src/app/shared/models/paginated-response.model.ts create mode 100644 apps/frontend/src/app/shared/models/response-entity.model.ts create mode 100644 apps/frontend/src/app/sys-admin/models/admin-user.model.ts create mode 100644 apps/frontend/src/app/sys-admin/models/system-settings.model.ts create mode 100644 apps/frontend/src/app/workspace/models/workspace-user.model.ts create mode 100644 apps/frontend/src/app/workspace/models/workspace.model.ts create mode 100644 apps/frontend/src/app/ws-admin/models/test-file.model.ts create mode 100644 apps/frontend/src/app/ws-admin/models/test-group.model.ts create mode 100644 apps/frontend/src/app/ws-admin/models/workspace-settings.model.ts diff --git a/apps/frontend/src/app/auth/models/auth-state.model.ts b/apps/frontend/src/app/auth/models/auth-state.model.ts new file mode 100644 index 000000000..9cad81a32 --- /dev/null +++ b/apps/frontend/src/app/auth/models/auth-state.model.ts @@ -0,0 +1,8 @@ +import { User } from './user.model'; + +export interface AuthState { + isAuthenticated: boolean; + user: User | null; + token: string | null; + expiresAt: number | null; +} diff --git a/apps/frontend/src/app/auth/models/user.model.ts b/apps/frontend/src/app/auth/models/user.model.ts new file mode 100644 index 000000000..a52d5c9e8 --- /dev/null +++ b/apps/frontend/src/app/auth/models/user.model.ts @@ -0,0 +1,9 @@ +export interface User { + id: string; + username: string; + firstName?: string; + lastName?: string; + email?: string; + roles: string[]; + issuer?: string; +} diff --git a/apps/frontend/src/app/coding/coding-jobs/coding-jobs.component.ts b/apps/frontend/src/app/coding/coding-jobs/coding-jobs.component.ts index 4877368eb..a628a3f94 100755 --- a/apps/frontend/src/app/coding/coding-jobs/coding-jobs.component.ts +++ b/apps/frontend/src/app/coding/coding-jobs/coding-jobs.component.ts @@ -22,15 +22,7 @@ import { DatePipe, NgClass } from '@angular/common'; import { AppService } from '../../services/app.service'; import { BackendService } from '../../services/backend.service'; import { SearchFilterComponent } from '../../shared/search-filter/search-filter.component'; - -interface CodingJob { - id: number; - name: string; - description: string; - status: string; - created_at: Date; - updated_at: Date; -} +import { CodingJob } from '../models/coding-job.model'; @Component({ selector: 'coding-box-coding-jobs', @@ -65,7 +57,7 @@ export class CodingJobsComponent implements OnInit, AfterViewInit { backendService = inject(BackendService); private snackBar = inject(MatSnackBar); - displayedColumns: string[] = ['selectCheckbox', 'name', 'description', 'status', 'created_at', 'updated_at']; + displayedColumns: string[] = ['selectCheckbox', 'name', 'description', 'status', 'createdAt', 'updatedAt']; dataSource = new MatTableDataSource([]); selection = new SelectionModel(true, []); isLoading = false; @@ -77,24 +69,27 @@ export class CodingJobsComponent implements OnInit, AfterViewInit { name: 'Kodierjob 1', description: 'Beschreibung für Kodierjob 1', status: 'active', - created_at: new Date('2023-01-01'), - updated_at: new Date('2023-01-15') + createdAt: new Date('2023-01-01'), + updatedAt: new Date('2023-01-15'), + assignedCoders: [1, 2] }, { id: 2, name: 'Kodierjob 2', description: 'Beschreibung für Kodierjob 2', status: 'completed', - created_at: new Date('2023-02-01'), - updated_at: new Date('2023-02-15') + createdAt: new Date('2023-02-01'), + updatedAt: new Date('2023-02-15'), + assignedCoders: [3] }, { id: 3, name: 'Kodierjob 3', description: 'Beschreibung für Kodierjob 3', status: 'pending', - created_at: new Date('2023-03-01'), - updated_at: new Date('2023-03-15') + createdAt: new Date('2023-03-01'), + updatedAt: new Date('2023-03-15'), + assignedCoders: [] } ]; diff --git a/apps/frontend/src/app/coding/coding-managment/coding-management.component.ts b/apps/frontend/src/app/coding/coding-managment/coding-management.component.ts index b7c5f4ec0..73d634d9c 100755 --- a/apps/frontend/src/app/coding/coding-managment/coding-management.component.ts +++ b/apps/frontend/src/app/coding/coding-managment/coding-management.component.ts @@ -31,28 +31,12 @@ import { ScrollingModule } from '@angular/cdk/scrolling'; import { MatDivider } from '@angular/material/divider'; import { MatDialog } from '@angular/material/dialog'; import { ContentDialogComponent } from '../../shared/dialogs/content-dialog/content-dialog.component'; -import { BackendService, CodingListItem } from '../../services/backend.service'; +import { BackendService } from '../../services/backend.service'; import { AppService } from '../../services/app.service'; import { CodingStatistics } from '../../../../../../api-dto/coding/coding-statistics'; import { ExportDialogComponent, ExportFormat } from '../export-dialog/export-dialog.component'; - -interface Success { - id: number; - unitid: number; - variableid: string; - status: string; - value: string; - subform: string; - code: string | null; - score: string | null; - codedstatus: string; - unitname: string; - login_name?: string; - login_code?: string; - login_group?: string; - booklet_id?: string; - codingSchemeRef?: string; -} +import { Success } from '../models/success.model'; +import { CodingListItem } from '../models/coding-list-item.model'; @Component({ selector: 'app-coding-management', diff --git a/apps/frontend/src/app/coding/models/coding-category.model.ts b/apps/frontend/src/app/coding/models/coding-category.model.ts new file mode 100644 index 000000000..08fbfb4dd --- /dev/null +++ b/apps/frontend/src/app/coding/models/coding-category.model.ts @@ -0,0 +1,7 @@ +export interface CodingCategory { + id: number; + name: string; + description?: string; + code: string; + subcategories?: CodingCategory[]; +} diff --git a/apps/frontend/src/app/coding/models/coding-job.model.ts b/apps/frontend/src/app/coding/models/coding-job.model.ts new file mode 100644 index 000000000..d53264549 --- /dev/null +++ b/apps/frontend/src/app/coding/models/coding-job.model.ts @@ -0,0 +1,9 @@ +export interface CodingJob { + id: number; + name: string; + description?: string; + status: string; + createdAt: Date; + updatedAt: Date; + assignedCoders: number[]; +} diff --git a/apps/frontend/src/app/coding/models/coding-list-item.model.ts b/apps/frontend/src/app/coding/models/coding-list-item.model.ts new file mode 100644 index 000000000..8d87ed4b9 --- /dev/null +++ b/apps/frontend/src/app/coding/models/coding-list-item.model.ts @@ -0,0 +1,11 @@ +export interface CodingListItem { + unit_key: string; + unit_alias: string; + login_name: string; + login_code: string; + booklet_id: string; + variable_id: string; + variable_page: string; + variable_anchor: string; + url: string; +} diff --git a/apps/frontend/src/app/coding/models/coding-scheme.model.ts b/apps/frontend/src/app/coding/models/coding-scheme.model.ts new file mode 100644 index 000000000..64eef0b1a --- /dev/null +++ b/apps/frontend/src/app/coding/models/coding-scheme.model.ts @@ -0,0 +1,8 @@ +import { CodingCategory } from './coding-category.model'; + +export interface CodingScheme { + id: number; + name: string; + description?: string; + categories: CodingCategory[]; +} diff --git a/apps/frontend/src/app/coding/models/success.model.ts b/apps/frontend/src/app/coding/models/success.model.ts new file mode 100644 index 000000000..d01b33414 --- /dev/null +++ b/apps/frontend/src/app/coding/models/success.model.ts @@ -0,0 +1,17 @@ +export interface Success { + id: number; + unitid: number; + variableid: string; + status: string; + value: string; + subform: string; + code: string | null; + score: string | null; + codedstatus: string; + unitname: string; + login_name?: string; + login_code?: string; + login_group?: string; + booklet_id?: string; + codingSchemeRef?: string; +} diff --git a/apps/frontend/src/app/core/models/app-config.model.ts b/apps/frontend/src/app/core/models/app-config.model.ts new file mode 100644 index 000000000..892431b4b --- /dev/null +++ b/apps/frontend/src/app/core/models/app-config.model.ts @@ -0,0 +1,8 @@ +export interface AppConfig { + apiUrl: string; + version: string; + environment: 'development' | 'production'; + features: { + [key: string]: boolean; + }; +} diff --git a/apps/frontend/src/app/core/models/error-response.model.ts b/apps/frontend/src/app/core/models/error-response.model.ts new file mode 100644 index 000000000..4d98fa392 --- /dev/null +++ b/apps/frontend/src/app/core/models/error-response.model.ts @@ -0,0 +1,5 @@ +export interface ErrorResponse { + status: number; + message: string; + details?: unknown; +} diff --git a/apps/frontend/src/app/replay/components/replay/replay.component.ts b/apps/frontend/src/app/replay/components/replay/replay.component.ts index dceb0759d..3aa62f013 100755 --- a/apps/frontend/src/app/replay/components/replay/replay.component.ts +++ b/apps/frontend/src/app/replay/components/replay/replay.component.ts @@ -23,21 +23,7 @@ import { AppService } from '../../../services/app.service'; import { ResponseDto } from '../../../../../../../api-dto/responses/response-dto'; import { SpinnerComponent } from '../spinner/spinner.component'; import { FilesDto } from '../../../../../../../api-dto/files/files.dto'; - -interface ErrorMessages { - QueryError: string; - ParamsError: string; - 401: string; - UnitIdError: string; - TestPersonError: string; - PlayerError: string; - ResponsesError: string; - notInList: string; - notCurrent: string; - unknown: string; - tokenExpired: string; - tokenInvalid: string; -} +import { ErrorMessages } from '../../models/error-messages.model'; @Component({ selector: 'coding-box-replay', diff --git a/apps/frontend/src/app/replay/components/unit-player/unit-player.component.ts b/apps/frontend/src/app/replay/components/unit-player/unit-player.component.ts index 49382344b..d1f232399 100755 --- a/apps/frontend/src/app/replay/components/unit-player/unit-player.component.ts +++ b/apps/frontend/src/app/replay/components/unit-player/unit-player.component.ts @@ -16,13 +16,7 @@ import { AppService } from '../../../services/app.service'; import { BackendService } from '../../../services/backend.service'; import { ResponseDto } from '../../../../../../../api-dto/responses/response-dto'; import { SpinnerComponent } from '../spinner/spinner.component'; - -export interface PageData { - index: number; - id: string; - type: '#next' | '#previous' | '#goto'; - disabled: boolean; -} +import { PageData } from '../../models/page-data.model'; export type Progress = 'none' | 'some' | 'complete'; diff --git a/apps/frontend/src/app/replay/models/error-messages.model.ts b/apps/frontend/src/app/replay/models/error-messages.model.ts new file mode 100644 index 000000000..a10fa25ce --- /dev/null +++ b/apps/frontend/src/app/replay/models/error-messages.model.ts @@ -0,0 +1,14 @@ +export interface ErrorMessages { + QueryError: string; + ParamsError: string; + 401: string; + UnitIdError: string; + TestPersonError: string; + PlayerError: string; + ResponsesError: string; + notInList: string; + notCurrent: string; + unknown: string; + tokenExpired: string; + tokenInvalid: string; +} diff --git a/apps/frontend/src/app/replay/models/page-data.model.ts b/apps/frontend/src/app/replay/models/page-data.model.ts new file mode 100644 index 000000000..dce26d93d --- /dev/null +++ b/apps/frontend/src/app/replay/models/page-data.model.ts @@ -0,0 +1,6 @@ +export interface PageData { + index: number; + id: string; + type: '#next' | '#previous' | '#goto'; + disabled: boolean; +} diff --git a/apps/frontend/src/app/replay/models/replay-session.model.ts b/apps/frontend/src/app/replay/models/replay-session.model.ts new file mode 100644 index 000000000..8326caa82 --- /dev/null +++ b/apps/frontend/src/app/replay/models/replay-session.model.ts @@ -0,0 +1,6 @@ +export interface ReplaySession { + testPerson: string; + unitId: string; + page?: string; + anchor?: string; +} diff --git a/apps/frontend/src/app/replay/models/response-data.model.ts b/apps/frontend/src/app/replay/models/response-data.model.ts new file mode 100644 index 000000000..429acb062 --- /dev/null +++ b/apps/frontend/src/app/replay/models/response-data.model.ts @@ -0,0 +1,6 @@ +export interface ResponseData { + id: string; + value: unknown; + timeStamp: Date; + pageId: string; +} diff --git a/apps/frontend/src/app/replay/models/unit-data.model.ts b/apps/frontend/src/app/replay/models/unit-data.model.ts new file mode 100644 index 000000000..980d3e99b --- /dev/null +++ b/apps/frontend/src/app/replay/models/unit-data.model.ts @@ -0,0 +1,9 @@ +import { PageData } from './page-data.model'; +import { ResponseData } from './response-data.model'; + +export interface UnitData { + id: string; + title: string; + pages: PageData[]; + responses: ResponseData[]; +} diff --git a/apps/frontend/src/app/shared/models/confirm-dialog-data.model.ts b/apps/frontend/src/app/shared/models/confirm-dialog-data.model.ts new file mode 100644 index 000000000..65c2a4aea --- /dev/null +++ b/apps/frontend/src/app/shared/models/confirm-dialog-data.model.ts @@ -0,0 +1,6 @@ +export interface ConfirmDialogData { + title: string; + content: string; + confirmButtonLabel: string; + cancelButtonLabel: string; +} diff --git a/apps/frontend/src/app/shared/models/dialog-data.model.ts b/apps/frontend/src/app/shared/models/dialog-data.model.ts new file mode 100644 index 000000000..48e995631 --- /dev/null +++ b/apps/frontend/src/app/shared/models/dialog-data.model.ts @@ -0,0 +1,5 @@ +export interface DialogData { + title: string; + content: string; + closeButtonLabel: string; +} diff --git a/apps/frontend/src/app/shared/models/journal-entry.model.ts b/apps/frontend/src/app/shared/models/journal-entry.model.ts new file mode 100644 index 000000000..0187b7493 --- /dev/null +++ b/apps/frontend/src/app/shared/models/journal-entry.model.ts @@ -0,0 +1,7 @@ +export interface JournalEntry { + id: number; + timestamp: Date; + level: string; + message: string; + details?: unknown; +} diff --git a/apps/frontend/src/app/shared/models/message-dialog-data.model.ts b/apps/frontend/src/app/shared/models/message-dialog-data.model.ts new file mode 100644 index 000000000..877618e45 --- /dev/null +++ b/apps/frontend/src/app/shared/models/message-dialog-data.model.ts @@ -0,0 +1,6 @@ +export interface MessageDialogData { + title: string; + content: string; + type: 'info' | 'warning' | 'error'; + closeButtonLabel: string; +} diff --git a/apps/frontend/src/app/shared/models/paginated-journal-entries.model.ts b/apps/frontend/src/app/shared/models/paginated-journal-entries.model.ts new file mode 100644 index 000000000..276e13f58 --- /dev/null +++ b/apps/frontend/src/app/shared/models/paginated-journal-entries.model.ts @@ -0,0 +1,6 @@ +import { JournalEntry } from './journal-entry.model'; + +export interface PaginatedJournalEntries { + entries: JournalEntry[]; + total: number; +} diff --git a/apps/frontend/src/app/shared/models/paginated-response.model.ts b/apps/frontend/src/app/shared/models/paginated-response.model.ts new file mode 100644 index 000000000..92adcc8f1 --- /dev/null +++ b/apps/frontend/src/app/shared/models/paginated-response.model.ts @@ -0,0 +1,6 @@ +export interface PaginatedResponse { + items: T[]; + total: number; + page: number; + pageSize: number; +} diff --git a/apps/frontend/src/app/shared/models/response-entity.model.ts b/apps/frontend/src/app/shared/models/response-entity.model.ts new file mode 100644 index 000000000..ff9d4c8c1 --- /dev/null +++ b/apps/frontend/src/app/shared/models/response-entity.model.ts @@ -0,0 +1,24 @@ +export interface ResponseEntity { + id: number; + unitId: number; + variableId: string; + status: string; + value: string; + subform: string; + code: number; + score: number; + codedStatus: string; + unit?: { + name: string; + alias: string; + booklet?: { + person?: { + login: string; + code: string; + }; + bookletinfo?: { + name: string; + }; + }; + }; +} diff --git a/apps/frontend/src/app/sys-admin/models/admin-user.model.ts b/apps/frontend/src/app/sys-admin/models/admin-user.model.ts new file mode 100644 index 000000000..a75a69e7c --- /dev/null +++ b/apps/frontend/src/app/sys-admin/models/admin-user.model.ts @@ -0,0 +1,8 @@ +export interface AdminUser { + id: number; + username: string; + displayName?: string; + email?: string; + isAdmin: boolean; + lastLogin?: Date; +} diff --git a/apps/frontend/src/app/sys-admin/models/system-settings.model.ts b/apps/frontend/src/app/sys-admin/models/system-settings.model.ts new file mode 100644 index 000000000..f530adfd2 --- /dev/null +++ b/apps/frontend/src/app/sys-admin/models/system-settings.model.ts @@ -0,0 +1,6 @@ +export interface SystemSettings { + id: number; + key: string; + value: string; + description?: string; +} diff --git a/apps/frontend/src/app/workspace/models/workspace-user.model.ts b/apps/frontend/src/app/workspace/models/workspace-user.model.ts new file mode 100644 index 000000000..14a13d5ef --- /dev/null +++ b/apps/frontend/src/app/workspace/models/workspace-user.model.ts @@ -0,0 +1,7 @@ +export interface WorkspaceUser { + id: number; + workspaceId: number; + userId: number; + accessLevel: number; + addedAt: Date; +} diff --git a/apps/frontend/src/app/workspace/models/workspace.model.ts b/apps/frontend/src/app/workspace/models/workspace.model.ts new file mode 100644 index 000000000..e790b25ab --- /dev/null +++ b/apps/frontend/src/app/workspace/models/workspace.model.ts @@ -0,0 +1,8 @@ +export interface Workspace { + id: number; + name: string; + description?: string; + createdAt: Date; + updatedAt: Date; + ownerId: number; +} diff --git a/apps/frontend/src/app/ws-admin/models/test-file.model.ts b/apps/frontend/src/app/ws-admin/models/test-file.model.ts new file mode 100644 index 000000000..b5ae60d38 --- /dev/null +++ b/apps/frontend/src/app/ws-admin/models/test-file.model.ts @@ -0,0 +1,8 @@ +export interface TestFile { + id: string; + name: string; + type: string; + size: number; + uploadedAt: Date; + description?: string; +} diff --git a/apps/frontend/src/app/ws-admin/models/test-group.model.ts b/apps/frontend/src/app/ws-admin/models/test-group.model.ts new file mode 100644 index 000000000..8f4af00dc --- /dev/null +++ b/apps/frontend/src/app/ws-admin/models/test-group.model.ts @@ -0,0 +1,6 @@ +export interface TestGroup { + id: string; + name: string; + description?: string; + testPersons: string[]; +} diff --git a/apps/frontend/src/app/ws-admin/models/workspace-settings.model.ts b/apps/frontend/src/app/ws-admin/models/workspace-settings.model.ts new file mode 100644 index 000000000..62142a1c9 --- /dev/null +++ b/apps/frontend/src/app/ws-admin/models/workspace-settings.model.ts @@ -0,0 +1,6 @@ +export interface WorkspaceSettings { + id: number; + key: string; + value: string; + description?: string; +} From f3d195bb4d9ce5b783a856d0a63b0bbf1711bd2f Mon Sep 17 00:00:00 2001 From: jurei733 <67505990+jurei733@users.noreply.github.com> Date: Tue, 8 Jul 2025 17:44:52 +0200 Subject: [PATCH 15/36] Move coding components in the components folder --- apps/frontend/src/app/coding/coding.routes.ts | 2 +- .../coder-list/coder-list.component.html | 0 .../coder-list/coder-list.component.scss | 0 .../coder-list/coder-list.component.spec.ts | 0 .../coder-list/coder-list.component.ts | 6 +++--- .../coding-jobs/coding-jobs.component.html | 0 .../coding-jobs/coding-jobs.component.scss | 0 .../coding-jobs/coding-jobs.component.spec.ts | 0 .../coding-jobs/coding-jobs.component.ts | 8 ++++---- .../coding-management-manual.component.html | 0 .../coding-management-manual.component.scss | 0 .../coding-management-manual.component.spec.ts | 0 .../coding-management-manual.component.ts | 4 ++-- .../coding-management.component.html | 0 .../coding-management.component.scss | 0 .../coding-management.component.spec.ts | 0 .../coding-managment/coding-management.component.ts | 12 ++++++------ .../coding-manual-navigation.component.html | 0 .../coding-manual-navigation.component.scss | 0 .../coding-manual-navigation.component.spec.ts | 0 .../coding-manual-navigation.component.ts | 0 .../coding-manual/coding-manual.component.html | 0 .../coding-manual/coding-manual.component.scss | 0 .../coding-manual/coding-manual.component.spec.ts | 2 +- .../coding-manual/coding-manual.component.ts | 2 +- .../export-dialog/export-dialog.component.html | 0 .../export-dialog/export-dialog.component.scss | 0 .../export-dialog/export-dialog.component.ts | 0 .../components/ws-admin/ws-admin.component.ts | 4 ++-- apps/frontend/src/app/ws-admin/ws-admin.routes.ts | 2 +- 30 files changed, 21 insertions(+), 21 deletions(-) rename apps/frontend/src/app/coding/{ => components}/coder-list/coder-list.component.html (100%) rename apps/frontend/src/app/coding/{ => components}/coder-list/coder-list.component.scss (100%) rename apps/frontend/src/app/coding/{ => components}/coder-list/coder-list.component.spec.ts (100%) rename apps/frontend/src/app/coding/{ => components}/coder-list/coder-list.component.ts (96%) rename apps/frontend/src/app/coding/{ => components}/coding-jobs/coding-jobs.component.html (100%) rename apps/frontend/src/app/coding/{ => components}/coding-jobs/coding-jobs.component.scss (100%) rename apps/frontend/src/app/coding/{ => components}/coding-jobs/coding-jobs.component.spec.ts (100%) rename apps/frontend/src/app/coding/{ => components}/coding-jobs/coding-jobs.component.ts (94%) rename apps/frontend/src/app/coding/{ => components}/coding-management-manual/coding-management-manual.component.html (100%) rename apps/frontend/src/app/coding/{ => components}/coding-management-manual/coding-management-manual.component.scss (100%) rename apps/frontend/src/app/coding/{ => components}/coding-management-manual/coding-management-manual.component.spec.ts (100%) rename apps/frontend/src/app/coding/{ => components}/coding-management-manual/coding-management-manual.component.ts (89%) rename apps/frontend/src/app/coding/{ => components}/coding-managment/coding-management.component.html (100%) rename apps/frontend/src/app/coding/{ => components}/coding-managment/coding-management.component.scss (100%) rename apps/frontend/src/app/coding/{ => components}/coding-managment/coding-management.component.spec.ts (100%) rename apps/frontend/src/app/coding/{ => components}/coding-managment/coding-management.component.ts (97%) rename apps/frontend/src/app/coding/{ => components}/coding-manual-navigation/coding-manual-navigation.component.html (100%) rename apps/frontend/src/app/coding/{ => components}/coding-manual-navigation/coding-manual-navigation.component.scss (100%) rename apps/frontend/src/app/coding/{ => components}/coding-manual-navigation/coding-manual-navigation.component.spec.ts (100%) rename apps/frontend/src/app/coding/{ => components}/coding-manual-navigation/coding-manual-navigation.component.ts (100%) rename apps/frontend/src/app/coding/{ => components}/coding-manual/coding-manual.component.html (100%) rename apps/frontend/src/app/coding/{ => components}/coding-manual/coding-manual.component.scss (100%) rename apps/frontend/src/app/coding/{ => components}/coding-manual/coding-manual.component.spec.ts (94%) rename apps/frontend/src/app/coding/{ => components}/coding-manual/coding-manual.component.ts (87%) rename apps/frontend/src/app/coding/{ => components}/export-dialog/export-dialog.component.html (100%) rename apps/frontend/src/app/coding/{ => components}/export-dialog/export-dialog.component.scss (100%) rename apps/frontend/src/app/coding/{ => components}/export-dialog/export-dialog.component.ts (100%) diff --git a/apps/frontend/src/app/coding/coding.routes.ts b/apps/frontend/src/app/coding/coding.routes.ts index 06a3a9bfe..0cc02cdb5 100644 --- a/apps/frontend/src/app/coding/coding.routes.ts +++ b/apps/frontend/src/app/coding/coding.routes.ts @@ -5,6 +5,6 @@ export const codingRoutes: Routes = [ { path: 'coding-manual', canActivate: [canActivateAuth], - loadComponent: () => import('./coding-management-manual/coding-management-manual.component').then(m => m.CodingManagementManualComponent) + loadComponent: () => import('./components/coding-management-manual/coding-management-manual.component').then(m => m.CodingManagementManualComponent) } ]; diff --git a/apps/frontend/src/app/coding/coder-list/coder-list.component.html b/apps/frontend/src/app/coding/components/coder-list/coder-list.component.html similarity index 100% rename from apps/frontend/src/app/coding/coder-list/coder-list.component.html rename to apps/frontend/src/app/coding/components/coder-list/coder-list.component.html diff --git a/apps/frontend/src/app/coding/coder-list/coder-list.component.scss b/apps/frontend/src/app/coding/components/coder-list/coder-list.component.scss similarity index 100% rename from apps/frontend/src/app/coding/coder-list/coder-list.component.scss rename to apps/frontend/src/app/coding/components/coder-list/coder-list.component.scss diff --git a/apps/frontend/src/app/coding/coder-list/coder-list.component.spec.ts b/apps/frontend/src/app/coding/components/coder-list/coder-list.component.spec.ts similarity index 100% rename from apps/frontend/src/app/coding/coder-list/coder-list.component.spec.ts rename to apps/frontend/src/app/coding/components/coder-list/coder-list.component.spec.ts diff --git a/apps/frontend/src/app/coding/coder-list/coder-list.component.ts b/apps/frontend/src/app/coding/components/coder-list/coder-list.component.ts similarity index 96% rename from apps/frontend/src/app/coding/coder-list/coder-list.component.ts rename to apps/frontend/src/app/coding/components/coder-list/coder-list.component.ts index 8d35bf859..9fe93ca28 100755 --- a/apps/frontend/src/app/coding/coder-list/coder-list.component.ts +++ b/apps/frontend/src/app/coding/components/coder-list/coder-list.component.ts @@ -24,9 +24,9 @@ import { ReactiveFormsModule, Validators } from '@angular/forms'; -import { SearchFilterComponent } from '../../shared/search-filter/search-filter.component'; -import { CoderService } from '../services/coder.service'; -import { Coder } from '../models/coder.model'; +import { SearchFilterComponent } from '../../../shared/search-filter/search-filter.component'; +import { CoderService } from '../../services/coder.service'; +import { Coder } from '../../models/coder.model'; @Component({ selector: 'coding-box-coder-list', diff --git a/apps/frontend/src/app/coding/coding-jobs/coding-jobs.component.html b/apps/frontend/src/app/coding/components/coding-jobs/coding-jobs.component.html similarity index 100% rename from apps/frontend/src/app/coding/coding-jobs/coding-jobs.component.html rename to apps/frontend/src/app/coding/components/coding-jobs/coding-jobs.component.html diff --git a/apps/frontend/src/app/coding/coding-jobs/coding-jobs.component.scss b/apps/frontend/src/app/coding/components/coding-jobs/coding-jobs.component.scss similarity index 100% rename from apps/frontend/src/app/coding/coding-jobs/coding-jobs.component.scss rename to apps/frontend/src/app/coding/components/coding-jobs/coding-jobs.component.scss diff --git a/apps/frontend/src/app/coding/coding-jobs/coding-jobs.component.spec.ts b/apps/frontend/src/app/coding/components/coding-jobs/coding-jobs.component.spec.ts similarity index 100% rename from apps/frontend/src/app/coding/coding-jobs/coding-jobs.component.spec.ts rename to apps/frontend/src/app/coding/components/coding-jobs/coding-jobs.component.spec.ts diff --git a/apps/frontend/src/app/coding/coding-jobs/coding-jobs.component.ts b/apps/frontend/src/app/coding/components/coding-jobs/coding-jobs.component.ts similarity index 94% rename from apps/frontend/src/app/coding/coding-jobs/coding-jobs.component.ts rename to apps/frontend/src/app/coding/components/coding-jobs/coding-jobs.component.ts index a628a3f94..563c33879 100755 --- a/apps/frontend/src/app/coding/coding-jobs/coding-jobs.component.ts +++ b/apps/frontend/src/app/coding/components/coding-jobs/coding-jobs.component.ts @@ -19,10 +19,10 @@ import { MatProgressSpinner } from '@angular/material/progress-spinner'; import { MatCheckbox } from '@angular/material/checkbox'; import { MatAnchor, MatButton } from '@angular/material/button'; import { DatePipe, NgClass } from '@angular/common'; -import { AppService } from '../../services/app.service'; -import { BackendService } from '../../services/backend.service'; -import { SearchFilterComponent } from '../../shared/search-filter/search-filter.component'; -import { CodingJob } from '../models/coding-job.model'; +import { AppService } from '../../../services/app.service'; +import { BackendService } from '../../../services/backend.service'; +import { SearchFilterComponent } from '../../../shared/search-filter/search-filter.component'; +import { CodingJob } from '../../models/coding-job.model'; @Component({ selector: 'coding-box-coding-jobs', diff --git a/apps/frontend/src/app/coding/coding-management-manual/coding-management-manual.component.html b/apps/frontend/src/app/coding/components/coding-management-manual/coding-management-manual.component.html similarity index 100% rename from apps/frontend/src/app/coding/coding-management-manual/coding-management-manual.component.html rename to apps/frontend/src/app/coding/components/coding-management-manual/coding-management-manual.component.html diff --git a/apps/frontend/src/app/coding/coding-management-manual/coding-management-manual.component.scss b/apps/frontend/src/app/coding/components/coding-management-manual/coding-management-manual.component.scss similarity index 100% rename from apps/frontend/src/app/coding/coding-management-manual/coding-management-manual.component.scss rename to apps/frontend/src/app/coding/components/coding-management-manual/coding-management-manual.component.scss diff --git a/apps/frontend/src/app/coding/coding-management-manual/coding-management-manual.component.spec.ts b/apps/frontend/src/app/coding/components/coding-management-manual/coding-management-manual.component.spec.ts similarity index 100% rename from apps/frontend/src/app/coding/coding-management-manual/coding-management-manual.component.spec.ts rename to apps/frontend/src/app/coding/components/coding-management-manual/coding-management-manual.component.spec.ts diff --git a/apps/frontend/src/app/coding/coding-management-manual/coding-management-manual.component.ts b/apps/frontend/src/app/coding/components/coding-management-manual/coding-management-manual.component.ts similarity index 89% rename from apps/frontend/src/app/coding/coding-management-manual/coding-management-manual.component.ts rename to apps/frontend/src/app/coding/components/coding-management-manual/coding-management-manual.component.ts index 711fe1d1b..558f24256 100755 --- a/apps/frontend/src/app/coding/coding-management-manual/coding-management-manual.component.ts +++ b/apps/frontend/src/app/coding/components/coding-management-manual/coding-management-manual.component.ts @@ -1,6 +1,6 @@ import { Component } from '@angular/core'; import { TranslateModule } from '@ngx-translate/core'; -import { MatAnchor } from '@angular/material/button'; +import { MatAnchor, MatButton } from '@angular/material/button'; import { MatCard, MatCardContent, MatCardHeader, MatCardTitle } from '@angular/material/card'; @@ -12,7 +12,7 @@ import { CodingJobsComponent } from '../coding-jobs/coding-jobs.component'; selector: 'coding-box-coding-management-manual', templateUrl: './coding-management-manual.component.html', styleUrls: ['./coding-management-manual.component.scss'], - imports: [TranslateModule, CoderListComponent, MatAnchor, CodingJobsComponent, MatCardContent, MatCardTitle, MatCardHeader, MatCard, MatIcon] + imports: [TranslateModule, CoderListComponent, MatAnchor, CodingJobsComponent, MatCardContent, MatCardTitle, MatCardHeader, MatCard, MatIcon, MatButton] }) export class CodingManagementManualComponent { } diff --git a/apps/frontend/src/app/coding/coding-managment/coding-management.component.html b/apps/frontend/src/app/coding/components/coding-managment/coding-management.component.html similarity index 100% rename from apps/frontend/src/app/coding/coding-managment/coding-management.component.html rename to apps/frontend/src/app/coding/components/coding-managment/coding-management.component.html diff --git a/apps/frontend/src/app/coding/coding-managment/coding-management.component.scss b/apps/frontend/src/app/coding/components/coding-managment/coding-management.component.scss similarity index 100% rename from apps/frontend/src/app/coding/coding-managment/coding-management.component.scss rename to apps/frontend/src/app/coding/components/coding-managment/coding-management.component.scss diff --git a/apps/frontend/src/app/coding/coding-managment/coding-management.component.spec.ts b/apps/frontend/src/app/coding/components/coding-managment/coding-management.component.spec.ts similarity index 100% rename from apps/frontend/src/app/coding/coding-managment/coding-management.component.spec.ts rename to apps/frontend/src/app/coding/components/coding-managment/coding-management.component.spec.ts diff --git a/apps/frontend/src/app/coding/coding-managment/coding-management.component.ts b/apps/frontend/src/app/coding/components/coding-managment/coding-management.component.ts similarity index 97% rename from apps/frontend/src/app/coding/coding-managment/coding-management.component.ts rename to apps/frontend/src/app/coding/components/coding-managment/coding-management.component.ts index 73d634d9c..a9dee05e6 100755 --- a/apps/frontend/src/app/coding/coding-managment/coding-management.component.ts +++ b/apps/frontend/src/app/coding/components/coding-managment/coding-management.component.ts @@ -30,13 +30,13 @@ import { MatTooltipModule } from '@angular/material/tooltip'; import { ScrollingModule } from '@angular/cdk/scrolling'; import { MatDivider } from '@angular/material/divider'; import { MatDialog } from '@angular/material/dialog'; -import { ContentDialogComponent } from '../../shared/dialogs/content-dialog/content-dialog.component'; -import { BackendService } from '../../services/backend.service'; -import { AppService } from '../../services/app.service'; -import { CodingStatistics } from '../../../../../../api-dto/coding/coding-statistics'; +import { ContentDialogComponent } from '../../../shared/dialogs/content-dialog/content-dialog.component'; +import { BackendService } from '../../../services/backend.service'; +import { AppService } from '../../../services/app.service'; +import { CodingStatistics } from '../../../../../../../api-dto/coding/coding-statistics'; import { ExportDialogComponent, ExportFormat } from '../export-dialog/export-dialog.component'; -import { Success } from '../models/success.model'; -import { CodingListItem } from '../models/coding-list-item.model'; +import { Success } from '../../models/success.model'; +import { CodingListItem } from '../../models/coding-list-item.model'; @Component({ selector: 'app-coding-management', diff --git a/apps/frontend/src/app/coding/coding-manual-navigation/coding-manual-navigation.component.html b/apps/frontend/src/app/coding/components/coding-manual-navigation/coding-manual-navigation.component.html similarity index 100% rename from apps/frontend/src/app/coding/coding-manual-navigation/coding-manual-navigation.component.html rename to apps/frontend/src/app/coding/components/coding-manual-navigation/coding-manual-navigation.component.html diff --git a/apps/frontend/src/app/coding/coding-manual-navigation/coding-manual-navigation.component.scss b/apps/frontend/src/app/coding/components/coding-manual-navigation/coding-manual-navigation.component.scss similarity index 100% rename from apps/frontend/src/app/coding/coding-manual-navigation/coding-manual-navigation.component.scss rename to apps/frontend/src/app/coding/components/coding-manual-navigation/coding-manual-navigation.component.scss diff --git a/apps/frontend/src/app/coding/coding-manual-navigation/coding-manual-navigation.component.spec.ts b/apps/frontend/src/app/coding/components/coding-manual-navigation/coding-manual-navigation.component.spec.ts similarity index 100% rename from apps/frontend/src/app/coding/coding-manual-navigation/coding-manual-navigation.component.spec.ts rename to apps/frontend/src/app/coding/components/coding-manual-navigation/coding-manual-navigation.component.spec.ts diff --git a/apps/frontend/src/app/coding/coding-manual-navigation/coding-manual-navigation.component.ts b/apps/frontend/src/app/coding/components/coding-manual-navigation/coding-manual-navigation.component.ts similarity index 100% rename from apps/frontend/src/app/coding/coding-manual-navigation/coding-manual-navigation.component.ts rename to apps/frontend/src/app/coding/components/coding-manual-navigation/coding-manual-navigation.component.ts diff --git a/apps/frontend/src/app/coding/coding-manual/coding-manual.component.html b/apps/frontend/src/app/coding/components/coding-manual/coding-manual.component.html similarity index 100% rename from apps/frontend/src/app/coding/coding-manual/coding-manual.component.html rename to apps/frontend/src/app/coding/components/coding-manual/coding-manual.component.html diff --git a/apps/frontend/src/app/coding/coding-manual/coding-manual.component.scss b/apps/frontend/src/app/coding/components/coding-manual/coding-manual.component.scss similarity index 100% rename from apps/frontend/src/app/coding/coding-manual/coding-manual.component.scss rename to apps/frontend/src/app/coding/components/coding-manual/coding-manual.component.scss diff --git a/apps/frontend/src/app/coding/coding-manual/coding-manual.component.spec.ts b/apps/frontend/src/app/coding/components/coding-manual/coding-manual.component.spec.ts similarity index 94% rename from apps/frontend/src/app/coding/coding-manual/coding-manual.component.spec.ts rename to apps/frontend/src/app/coding/components/coding-manual/coding-manual.component.spec.ts index 9c7dad15f..619131c72 100755 --- a/apps/frontend/src/app/coding/coding-manual/coding-manual.component.spec.ts +++ b/apps/frontend/src/app/coding/components/coding-manual/coding-manual.component.spec.ts @@ -3,8 +3,8 @@ import { TranslateModule } from '@ngx-translate/core'; import { provideHttpClient } from '@angular/common/http'; import { ActivatedRoute } from '@angular/router'; import { CodingManualComponent } from './coding-manual.component'; -import { environment } from '../../../environments/environment'; import { SERVER_URL } from '../../injection-tokens'; +import { environment } from '../../../../environments/environment'; describe('CodingManualComponent', () => { let component: CodingManualComponent; diff --git a/apps/frontend/src/app/coding/coding-manual/coding-manual.component.ts b/apps/frontend/src/app/coding/components/coding-manual/coding-manual.component.ts similarity index 87% rename from apps/frontend/src/app/coding/coding-manual/coding-manual.component.ts rename to apps/frontend/src/app/coding/components/coding-manual/coding-manual.component.ts index 1fd85b909..83fd5f9e0 100755 --- a/apps/frontend/src/app/coding/coding-manual/coding-manual.component.ts +++ b/apps/frontend/src/app/coding/components/coding-manual/coding-manual.component.ts @@ -1,7 +1,7 @@ import { Component } from '@angular/core'; import { TranslateModule } from '@ngx-translate/core'; import { CodingManualNavigationComponent } from '../coding-manual-navigation/coding-manual-navigation.component'; -import { ReplayComponent } from '../../replay/components/replay/replay.component'; +import { ReplayComponent } from '../../../replay/components/replay/replay.component'; import { CodingJobsComponent } from '../coding-jobs/coding-jobs.component'; @Component({ diff --git a/apps/frontend/src/app/coding/export-dialog/export-dialog.component.html b/apps/frontend/src/app/coding/components/export-dialog/export-dialog.component.html similarity index 100% rename from apps/frontend/src/app/coding/export-dialog/export-dialog.component.html rename to apps/frontend/src/app/coding/components/export-dialog/export-dialog.component.html diff --git a/apps/frontend/src/app/coding/export-dialog/export-dialog.component.scss b/apps/frontend/src/app/coding/components/export-dialog/export-dialog.component.scss similarity index 100% rename from apps/frontend/src/app/coding/export-dialog/export-dialog.component.scss rename to apps/frontend/src/app/coding/components/export-dialog/export-dialog.component.scss diff --git a/apps/frontend/src/app/coding/export-dialog/export-dialog.component.ts b/apps/frontend/src/app/coding/components/export-dialog/export-dialog.component.ts similarity index 100% rename from apps/frontend/src/app/coding/export-dialog/export-dialog.component.ts rename to apps/frontend/src/app/coding/components/export-dialog/export-dialog.component.ts diff --git a/apps/frontend/src/app/ws-admin/components/ws-admin/ws-admin.component.ts b/apps/frontend/src/app/ws-admin/components/ws-admin/ws-admin.component.ts index 9416f7963..c3288f769 100755 --- a/apps/frontend/src/app/ws-admin/components/ws-admin/ws-admin.component.ts +++ b/apps/frontend/src/app/ws-admin/components/ws-admin/ws-admin.component.ts @@ -5,11 +5,11 @@ import { import { TranslateModule } from '@ngx-translate/core'; import { MatTabLink, MatTabNav, MatTabNavPanel } from '@angular/material/tabs'; import { AppService } from '../../../services/app.service'; -import { CodingJobsComponent } from '../../../coding/coding-jobs/coding-jobs.component'; +import { CodingJobsComponent } from '../../../coding/components/coding-jobs/coding-jobs.component'; import { BackendService } from '../../../services/backend.service'; import { CodingManagementManualComponent -} from '../../../coding/coding-management-manual/coding-management-manual.component'; +} from '../../../coding/components/coding-management-manual/coding-management-manual.component'; @Component({ selector: 'coding-box-ws-admin', diff --git a/apps/frontend/src/app/ws-admin/ws-admin.routes.ts b/apps/frontend/src/app/ws-admin/ws-admin.routes.ts index 5853b844e..843bb9c57 100644 --- a/apps/frontend/src/app/ws-admin/ws-admin.routes.ts +++ b/apps/frontend/src/app/ws-admin/ws-admin.routes.ts @@ -11,7 +11,7 @@ export const wsAdminRoutes: Routes = [ { path: 'test-files', loadComponent: () => import('./components/test-files/test-files.component').then(m => m.TestFilesComponent) }, { path: 'test-results', loadComponent: () => import('./components/test-groups/test-groups.component').then(m => m.TestGroupsComponent) }, { path: 'users', loadComponent: () => import('./components/ws-users/ws-users.component').then(m => m.WsUsersComponent) }, - { path: 'coding', loadComponent: () => import('../coding/coding-managment/coding-management.component').then(m => m.CodingManagementComponent) }, + { path: 'coding', loadComponent: () => import('../coding/components/coding-managment/coding-management.component').then(m => m.CodingManagementComponent) }, { path: 'settings', loadComponent: () => import('./components/ws-settings/ws-settings.component').then(m => m.WsSettingsComponent) }, { path: '**', loadComponent: () => import('./components/test-files/test-files.component').then(m => m.TestFilesComponent) } ] From b7c6139fcb591a4107c22718b837c7c2d7bfb651 Mon Sep 17 00:00:00 2001 From: jurei733 <67505990+jurei733@users.noreply.github.com> Date: Tue, 8 Jul 2025 18:06:49 +0200 Subject: [PATCH 16/36] Add utils to feature modules --- .../components/replay/replay.component.ts | 105 ++---------------- .../src/app/replay/utils/dom-utils.ts | 87 +++++++++++++++ .../src/app/replay/utils/token-utils.ts | 38 +++++++ .../src/app/shared/utils/common-utils.ts | 86 ++++++++++++++ .../test-files/test-files.component.ts | 25 +---- .../src/app/ws-admin/utils/file-utils.ts | 39 +++++++ 6 files changed, 265 insertions(+), 115 deletions(-) create mode 100644 apps/frontend/src/app/replay/utils/dom-utils.ts create mode 100644 apps/frontend/src/app/replay/utils/token-utils.ts create mode 100644 apps/frontend/src/app/shared/utils/common-utils.ts create mode 100644 apps/frontend/src/app/ws-admin/utils/file-utils.ts diff --git a/apps/frontend/src/app/replay/components/replay/replay.component.ts b/apps/frontend/src/app/replay/components/replay/replay.component.ts index 3aa62f013..6f12b9a98 100755 --- a/apps/frontend/src/app/replay/components/replay/replay.component.ts +++ b/apps/frontend/src/app/replay/components/replay/replay.component.ts @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ import { Component, OnChanges, OnDestroy, OnInit, SimpleChanges, ViewChild, inject, input @@ -24,6 +23,8 @@ import { ResponseDto } from '../../../../../../../api-dto/responses/response-dto import { SpinnerComponent } from '../spinner/spinner.component'; import { FilesDto } from '../../../../../../../api-dto/files/files.dto'; import { ErrorMessages } from '../../models/error-messages.model'; +import { validateToken, isTestperson } from '../../utils/token-utils'; +import { scrollToElementByAlias } from '../../utils/dom-utils'; @Component({ selector: 'coding-box-replay', @@ -43,8 +44,8 @@ export class ReplayComponent implements OnInit, OnDestroy, OnChanges { isLoaded: Subject = new Subject(); page: string | undefined; anchor: string | undefined; + /* eslint-disable @typescript-eslint/no-explicit-any */ responses: any | undefined = undefined; - dataElementAliases: string[] = []; isPrintMode: boolean = false; private testPerson: string = ''; private unitId: string = ''; @@ -86,26 +87,6 @@ export class ReplayComponent implements OnInit, OnDestroy, OnChanges { return auth; } - private validateToken(token: string): { isValid: boolean; errorType?: 'token_expired' | 'token_invalid' } { - if (!token) { - return { isValid: false, errorType: 'token_invalid' }; - } - - try { - const decoded: JwtPayload & { workspace: string } = jwtDecode(token); - const currentTime = Math.floor(Date.now() / 1000); - if (decoded.exp && decoded.exp < currentTime) { - return { isValid: false, errorType: 'token_expired' }; - } - if (!decoded.workspace) { - return { isValid: false, errorType: 'token_invalid' }; - } - return { isValid: true }; - } catch (error) { - return { isValid: false, errorType: 'token_invalid' }; - } - } - private subscribeRouter(): void { this.routerSubscription = this.route.params ?.subscribe(async params => { @@ -114,7 +95,7 @@ export class ReplayComponent implements OnInit, OnDestroy, OnChanges { this.authToken = await this.getAuthToken(); if (this.authToken) { - const tokenValidation = this.validateToken(this.authToken); + const tokenValidation = validateToken(this.authToken); if (!tokenValidation.isValid) { this.setIsLoaded(); if (tokenValidation.errorType === 'token_expired') { @@ -147,9 +128,11 @@ export class ReplayComponent implements OnInit, OnDestroy, OnChanges { if (workspace) { const unitData = await this.getUnitData(Number(workspace), this.authToken); this.setUnitProperties(unitData); - if (this.anchor) { - setTimeout(() => this.scrollToElementByAlias(this.anchor || ''), 1000); - } + setTimeout(() => { + if (this.unitPlayerComponent?.hostingIframe?.nativeElement) { + scrollToElementByAlias(this.unitPlayerComponent.hostingIframe.nativeElement, this.anchor || ''); + } + }, 1000); } } else { ReplayComponent.throwError('QueryError'); @@ -186,19 +169,13 @@ export class ReplayComponent implements OnInit, OnDestroy, OnChanges { } private setTestPerson(testPerson: string): void { - if (!ReplayComponent.isTestperson(testPerson)) { + if (!isTestperson(testPerson)) { ReplayComponent.throwError('TestPersonError'); } else { this.testPerson = testPerson; } } - private static isTestperson(testperson: string): boolean { - if (testperson.split('@').length !== 3) return false; - const reg = /^.+(@.+){2}$/; - return reg.test(testperson); - } - private checkUnitId(unitFile: FilesDto[]): void { if (!unitFile || !unitFile[0]) { ReplayComponent.throwError('UnitIdError'); @@ -218,7 +195,7 @@ export class ReplayComponent implements OnInit, OnDestroy, OnChanges { this.resetSnackBars(); if (this.authToken) { - const tokenValidation = this.validateToken(this.authToken); + const tokenValidation = validateToken(this.authToken); if (!tokenValidation.isValid) { this.setIsLoaded(); if (tokenValidation.errorType === 'token_expired') { @@ -401,64 +378,4 @@ export class ReplayComponent implements OnInit, OnDestroy, OnChanges { this.routerSubscription = null; this.resetSnackBars(); } - - /** - * Searches for div elements with data-element-alias attribute in the player's HTML - * and returns an object mapping the aliases to their corresponding elements. - * - * @returns {Record} An object mapping data-element-alias values to their HTML elements - */ - findElementsByDataAlias(): Record { - const result: Record = {}; - - try { - // Access the iframe's content document through the UnitPlayerComponent - if (this.unitPlayerComponent && this.unitPlayerComponent.hostingIframe) { - const iframe = this.unitPlayerComponent.hostingIframe.nativeElement as HTMLIFrameElement; - - // Check if the iframe has loaded content - if (iframe.contentDocument) { - // Query for all div elements with data-element-alias attribute - const elements = iframe.contentDocument.querySelectorAll('div[data-element-alias]'); - - // Create a mapping of aliases to elements - elements.forEach((element: Element) => { - const alias = element.getAttribute('data-element-alias'); - if (alias) { - result[alias] = element as HTMLElement; - } - }); - } - } - } catch (error) { - // eslint-disable-next-line no-console - console.error('Error searching for elements with data-element-alias:', error); - } - - return result; - } - - /** - * Scrolls to a div element with the specified data-element-alias in the player's HTML. - * - * @param {string} alias - The data-element-alias value of the element to scroll to - * @param {ScrollIntoViewOptions} [options] - Optional scroll behavior options - * @returns {boolean} True if the element was found and scrolled to, false otherwise - */ - scrollToElementByAlias(alias: string, options?: ScrollIntoViewOptions): boolean { - try { - const elements = this.findElementsByDataAlias(); - const element = elements[alias]; - if (element) { - // Use scrollIntoView with smooth behavior by default - element.scrollIntoView(options || { behavior: 'smooth', block: 'start' }); - return true; - } - } catch (error) { - // eslint-disable-next-line no-console - console.error(`Error scrolling to element with alias "${alias}":`, error); - } - - return false; - } } diff --git a/apps/frontend/src/app/replay/utils/dom-utils.ts b/apps/frontend/src/app/replay/utils/dom-utils.ts new file mode 100644 index 000000000..8b6fcd545 --- /dev/null +++ b/apps/frontend/src/app/replay/utils/dom-utils.ts @@ -0,0 +1,87 @@ +/** + * Utility functions for DOM manipulation in the replay module + */ + +/** + * Searches for div elements with data-element-alias attribute in the player's HTML + * and returns an object mapping the aliases to their corresponding elements. + * + * @param iframe The iframe element containing the player's HTML + * @returns An object mapping data-element-alias values to their HTML elements + */ +export function findElementsByDataAlias(iframe: HTMLIFrameElement): Record { + const result: Record = {}; + + try { + // Check if the iframe has loaded content + if (iframe.contentDocument) { + // Query for all div elements with data-element-alias attribute + const elements = iframe.contentDocument.querySelectorAll('div[data-element-alias]'); + + // Create a mapping of aliases to elements + elements.forEach((element: Element) => { + const alias = element.getAttribute('data-element-alias'); + if (alias) { + result[alias] = element as HTMLElement; + } + }); + } + } catch (error) { + console.error('Error searching for elements with data-element-alias:', error); + } + + return result; +} + +/** + * Returns the values of the data-element-alias attributes found in the player's HTML. + * + * @param iframe The iframe element containing the player's HTML + * @returns An array of data-element-alias values + */ +export function getDataElementAliases(iframe: HTMLIFrameElement): string[] { + try { + // Check if the iframe has loaded content + if (iframe.contentDocument) { + // Query for all div elements with data-element-alias attribute + const elements = iframe.contentDocument.querySelectorAll('div[data-element-alias]'); + + // Extract and return the alias values + return Array.from(elements) + .map(element => element.getAttribute('data-element-alias')) + .filter((alias): alias is string => alias !== null); + } + } catch (error) { + console.error('Error getting data-element-alias values:', error); + } + + return []; +} + +/** + * Scrolls to a div element with the specified data-element-alias in the player's HTML. + * + * @param iframe The iframe element containing the player's HTML + * @param alias The data-element-alias value of the element to scroll to + * @param options Optional scroll behavior options + * @returns True if the element was found and scrolled to, false otherwise + */ +export function scrollToElementByAlias( + iframe: HTMLIFrameElement, + alias: string, + options?: ScrollIntoViewOptions +): boolean { + try { + const elements = findElementsByDataAlias(iframe); + const element = elements[alias]; + if (element) { + // Use scrollIntoView with smooth behavior by default + element.scrollIntoView(options || { behavior: 'smooth', block: 'center' }); + return true; + } + } catch (error) { + console.error(`Error scrolling to element with alias "${alias}":`, error); + } + + return false; +} diff --git a/apps/frontend/src/app/replay/utils/token-utils.ts b/apps/frontend/src/app/replay/utils/token-utils.ts new file mode 100644 index 000000000..7760a648a --- /dev/null +++ b/apps/frontend/src/app/replay/utils/token-utils.ts @@ -0,0 +1,38 @@ +import { JwtPayload } from 'jwt-decode'; +import * as jwt from 'jwt-decode'; + +/** + * Validates a JWT token + * @param token The token to validate + * @returns An object indicating if the token is valid and the error type if it's not + */ +export function validateToken(token: string): { isValid: boolean; errorType?: 'token_expired' | 'token_invalid' } { + if (!token) { + return { isValid: false, errorType: 'token_invalid' }; + } + + try { + const decoded: JwtPayload & { workspace: string } = jwt.jwtDecode(token); + const currentTime = Math.floor(Date.now() / 1000); + if (decoded.exp && decoded.exp < currentTime) { + return { isValid: false, errorType: 'token_expired' }; + } + if (!decoded.workspace) { + return { isValid: false, errorType: 'token_invalid' }; + } + return { isValid: true }; + } catch (error) { + return { isValid: false, errorType: 'token_invalid' }; + } +} + +/** + * Validates if a string is a valid test person identifier + * @param testperson The test person identifier to validate + * @returns True if the test person identifier is valid, false otherwise + */ +export function isTestperson(testperson: string): boolean { + if (testperson.split('@').length !== 3) return false; + const reg = /^.+(@.+){2}$/; + return reg.test(testperson); +} diff --git a/apps/frontend/src/app/shared/utils/common-utils.ts b/apps/frontend/src/app/shared/utils/common-utils.ts new file mode 100644 index 000000000..4cd61b378 --- /dev/null +++ b/apps/frontend/src/app/shared/utils/common-utils.ts @@ -0,0 +1,86 @@ +/** + * Common utility functions that can be used across the application + */ + +/** + * Formats a date to a string in the format "DD.MM.YYYY HH:MM:SS" + * @param date The date to format + * @returns The formatted date string + */ +export function formatDate(date: Date): string { + if (!date) return ''; + + const day = date.getDate().toString().padStart(2, '0'); + const month = (date.getMonth() + 1).toString().padStart(2, '0'); + const year = date.getFullYear(); + const hours = date.getHours().toString().padStart(2, '0'); + const minutes = date.getMinutes().toString().padStart(2, '0'); + const seconds = date.getSeconds().toString().padStart(2, '0'); + + return `${day}.${month}.${year} ${hours}:${minutes}:${seconds}`; +} + +/** + * Truncates a string to a specified length and adds an ellipsis if truncated + * @param str The string to truncate + * @param maxLength The maximum length of the string + * @returns The truncated string + */ +export function truncateString(str: string, maxLength: number): string { + if (!str) return ''; + if (str.length <= maxLength) return str; + + return `${str.substring(0, maxLength)}...`; +} + +/** + * Generates a random string of a specified length + * @param length The length of the random string + * @returns The random string + */ +export function generateRandomString(length: number): string { + const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; + let result = ''; + + for (let i = 0; i < length; i++) { + const randomIndex = Math.floor(Math.random() * characters.length); + result += characters.charAt(randomIndex); + } + + return result; +} + +/** + * Debounces a function to prevent it from being called too frequently + * @param func The function to debounce + * @param wait The time to wait before calling the function + * @returns The debounced function + */ +export function debounce unknown>( + func: T, + wait: number +): (...args: Parameters) => void { + let timeout: number | null = null; + + return function (...args: Parameters): void { + const later = () => { + timeout = null; + func(...args); + }; + + if (timeout !== null) { + clearTimeout(timeout); + } + + timeout = window.setTimeout(later, wait) as unknown as number; + }; +} + +/** + * Checks if a value is null or undefined + * @param value The value to check + * @returns True if the value is null or undefined, false otherwise + */ +export function isNullOrUndefined(value: unknown): value is null | undefined { + return value === null || value === undefined; +} diff --git a/apps/frontend/src/app/ws-admin/components/test-files/test-files.component.ts b/apps/frontend/src/app/ws-admin/components/test-files/test-files.component.ts index 400373658..6e4b8b9f8 100755 --- a/apps/frontend/src/app/ws-admin/components/test-files/test-files.component.ts +++ b/apps/frontend/src/app/ws-admin/components/test-files/test-files.component.ts @@ -40,6 +40,7 @@ import { FilesInListDto } from '../../../../../../../api-dto/files/files-in-list import { FileValidationResultDto } from '../../../../../../../api-dto/files/file-validation-result.dto'; import { FileDownloadDto } from '../../../../../../../api-dto/files/file-download.dto'; import { ContentDialogComponent } from '../../../shared/dialogs/content-dialog/content-dialog.component'; +import { getFileIcon } from '../../utils/file-utils'; @Component({ selector: 'coding-box-test-files', @@ -115,7 +116,7 @@ export class TestFilesComponent implements OnInit, OnDestroy { ngOnInit(): void { this.loadTestFiles(); this.textFilterSubscription = this.textFilterChanged - .pipe(debounceTime(300)) // Debounce für 300ms + .pipe(debounceTime(300)) .subscribe(() => { this.applyFilters(); }); @@ -294,26 +295,6 @@ export class TestFilesComponent implements OnInit, OnDestroy { } } - getFileIcon(fileType: string): string { - const type = fileType.toLowerCase(); - if (type.includes('xml')) { - return 'code'; - } - if (type.includes('zip')) { - return 'folder_zip'; - } - if (type.includes('html')) { - return 'html'; - } - if (type.includes('csv')) { - return 'table_chart'; - } - if (type.includes('voud') || type.includes('vocs')) { - return 'description'; - } - return 'insert_drive_file'; - } - openResourcePackagesDialog(): void { const dialogRef = this.dialog.open(ResourcePackagesDialogComponent, { width: '90%', @@ -351,4 +332,6 @@ export class TestFilesComponent implements OnInit, OnDestroy { }); }); } + + protected readonly getFileIcon = getFileIcon; } diff --git a/apps/frontend/src/app/ws-admin/utils/file-utils.ts b/apps/frontend/src/app/ws-admin/utils/file-utils.ts new file mode 100644 index 000000000..baef2761f --- /dev/null +++ b/apps/frontend/src/app/ws-admin/utils/file-utils.ts @@ -0,0 +1,39 @@ +/** + * Utility functions for file operations in the ws-admin module + */ + +/** + * Returns the appropriate icon based on file type + * @param fileType The file type to get an icon for + * @returns The name of the Material icon to use + */ +export function getFileIcon(fileType: string): string { + const type = fileType.toLowerCase(); + if (type.includes('xml')) { + return 'code'; + } + if (type.includes('zip')) { + return 'folder_zip'; + } + if (type.includes('html')) { + return 'html'; + } + if (type.includes('csv')) { + return 'table_chart'; + } + if (type.includes('voud') || type.includes('vocs')) { + return 'description'; + } + return 'insert_drive_file'; +} + +/** + * Extracts the unit name from a file name + * @param fileName The fileName in the format "Unit unitName" + * @returns The unit name + */ +export function extractUnitName(fileName: string): string { + // The fileName is in the format "Unit unitName" + const match = fileName.match(/^Unit\s+(.+)$/); + return match ? match[1] : fileName; +} From 7afe4ef494ab871cd95373da85db466f293a33f8 Mon Sep 17 00:00:00 2001 From: jurei733 <67505990+jurei733@users.noreply.github.com> Date: Tue, 8 Jul 2025 18:18:33 +0200 Subject: [PATCH 17/36] Move files to core --- apps/frontend/src/app/app.component.spec.ts | 2 +- apps/frontend/src/app/app.component.ts | 2 +- apps/frontend/src/app/app.config.ts | 5 ++--- apps/frontend/src/app/app.routes.ts | 2 -- apps/frontend/src/app/auth/models/auth-state.model.ts | 8 -------- apps/frontend/src/app/auth/models/user.model.ts | 9 --------- apps/frontend/src/app/coding/coding.routes.ts | 2 +- .../src/app/components/home/home.component.spec.ts | 2 +- .../frontend/src/app/{auth => core}/guards/auth.guard.ts | 0 .../src/app/{auth => core}/guards/token.guard.ts | 0 .../app/{ => core}/interceptors/app-http-error.class.ts | 0 .../src/app/{ => core}/interceptors/auth.interceptor.ts | 2 +- .../interceptors}/journal-interceptor.ts | 4 ++-- .../src/app/{auth => core}/services/auth.service.spec.ts | 0 .../src/app/{auth => core}/services/auth.service.ts | 0 apps/frontend/src/app/services/app.service.ts | 2 +- .../components/user-menu/user-menu.component.spec.ts | 4 ++-- .../components/user-menu/user-menu.component.ts | 2 +- apps/frontend/src/app/sys-admin/sys-admin.routes.ts | 2 +- .../user-workspaces-area.component.spec.ts | 2 +- .../user-workspaces/user-workspaces.component.spec.ts | 2 +- .../user-workspaces/user-workspaces.component.ts | 2 +- apps/frontend/src/app/ws-admin/ws-admin.routes.ts | 2 +- 23 files changed, 18 insertions(+), 38 deletions(-) delete mode 100644 apps/frontend/src/app/auth/models/auth-state.model.ts delete mode 100644 apps/frontend/src/app/auth/models/user.model.ts rename apps/frontend/src/app/{auth => core}/guards/auth.guard.ts (100%) rename apps/frontend/src/app/{auth => core}/guards/token.guard.ts (100%) rename apps/frontend/src/app/{ => core}/interceptors/app-http-error.class.ts (100%) rename apps/frontend/src/app/{ => core}/interceptors/auth.interceptor.ts (95%) rename apps/frontend/src/app/{services => core/interceptors}/journal-interceptor.ts (96%) rename apps/frontend/src/app/{auth => core}/services/auth.service.spec.ts (100%) rename apps/frontend/src/app/{auth => core}/services/auth.service.ts (100%) diff --git a/apps/frontend/src/app/app.component.spec.ts b/apps/frontend/src/app/app.component.spec.ts index 2fcc690b6..787dd9946 100755 --- a/apps/frontend/src/app/app.component.spec.ts +++ b/apps/frontend/src/app/app.component.spec.ts @@ -4,7 +4,7 @@ import { InjectionToken } from '@angular/core'; import { KeycloakService } from 'keycloak-angular'; import { AppComponent } from './app.component'; import { environment } from '../environments/environment'; -import { AuthService } from './auth/service/auth.service'; +import { AuthService } from './core/services/auth.service'; export const AUTH_TOKEN = new InjectionToken('AUTH_TOKEN'); const mockAuthService = { diff --git a/apps/frontend/src/app/app.component.ts b/apps/frontend/src/app/app.component.ts index cd5c384c5..b1d710eef 100755 --- a/apps/frontend/src/app/app.component.ts +++ b/apps/frontend/src/app/app.component.ts @@ -8,7 +8,7 @@ import { MatButton } from '@angular/material/button'; import { LocationStrategy } from '@angular/common'; import { KeycloakProfile } from 'keycloak-js'; import { AppService } from './services/app.service'; -import { AuthService } from './auth/services/auth.service'; +import { AuthService } from './core/services/auth.service'; import { CreateUserDto } from '../../../../api-dto/user/create-user-dto'; import { BackendService } from './services/backend.service'; import { WrappedIconComponent } from './shared/wrapped-icon/wrapped-icon.component'; diff --git a/apps/frontend/src/app/app.config.ts b/apps/frontend/src/app/app.config.ts index 0dbe3657f..8c7a5fb4a 100755 --- a/apps/frontend/src/app/app.config.ts +++ b/apps/frontend/src/app/app.config.ts @@ -21,9 +21,8 @@ import { import { HashLocationStrategy, LocationStrategy } from '@angular/common'; import { routes } from './app.routes'; import { environment } from '../environments/environment'; -import { authInterceptor } from './interceptors/auth.interceptor'; -import { journalInterceptor } from './services/journal-interceptor'; -import { SERVER_URL } from './injection-tokens'; +import { authInterceptor } from './core/interceptors/auth.interceptor'; +import { journalInterceptor } from './core/interceptors/journal-interceptor'; export function createTranslateLoader(http: HttpClient): TranslateHttpLoader { return new TranslateHttpLoader(http, './assets/i18n/', '.json'); diff --git a/apps/frontend/src/app/app.routes.ts b/apps/frontend/src/app/app.routes.ts index 1ea3dfbf4..99999c32d 100644 --- a/apps/frontend/src/app/app.routes.ts +++ b/apps/frontend/src/app/app.routes.ts @@ -1,6 +1,4 @@ import { Routes } from '@angular/router'; - -import { canActivateAuth } from './auth/guards/auth.guard'; import { replayRoutes } from './replay/replay.routes'; import { sysAdminRoutes } from './sys-admin/sys-admin.routes'; import { wsAdminRoutes } from './ws-admin/ws-admin.routes'; diff --git a/apps/frontend/src/app/auth/models/auth-state.model.ts b/apps/frontend/src/app/auth/models/auth-state.model.ts deleted file mode 100644 index 9cad81a32..000000000 --- a/apps/frontend/src/app/auth/models/auth-state.model.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { User } from './user.model'; - -export interface AuthState { - isAuthenticated: boolean; - user: User | null; - token: string | null; - expiresAt: number | null; -} diff --git a/apps/frontend/src/app/auth/models/user.model.ts b/apps/frontend/src/app/auth/models/user.model.ts deleted file mode 100644 index a52d5c9e8..000000000 --- a/apps/frontend/src/app/auth/models/user.model.ts +++ /dev/null @@ -1,9 +0,0 @@ -export interface User { - id: string; - username: string; - firstName?: string; - lastName?: string; - email?: string; - roles: string[]; - issuer?: string; -} diff --git a/apps/frontend/src/app/coding/coding.routes.ts b/apps/frontend/src/app/coding/coding.routes.ts index 0cc02cdb5..46e3420bf 100644 --- a/apps/frontend/src/app/coding/coding.routes.ts +++ b/apps/frontend/src/app/coding/coding.routes.ts @@ -1,5 +1,5 @@ import { Routes } from '@angular/router'; -import { canActivateAuth } from '../auth/guards/auth.guard'; +import { canActivateAuth } from '../core/guards/auth.guard'; export const codingRoutes: Routes = [ { diff --git a/apps/frontend/src/app/components/home/home.component.spec.ts b/apps/frontend/src/app/components/home/home.component.spec.ts index ad1c2bb4c..7fa84c7f9 100755 --- a/apps/frontend/src/app/components/home/home.component.spec.ts +++ b/apps/frontend/src/app/components/home/home.component.spec.ts @@ -4,7 +4,7 @@ import { provideHttpClient } from '@angular/common/http'; import { ActivatedRoute } from '@angular/router'; import { of } from 'rxjs'; import { HomeComponent } from './home.component'; -import { AuthService } from '../../auth/services/auth.service'; +import { AuthService } from '../../core/services/auth.service'; import { environment } from '../../../environments/environment'; import { AppService } from '../../services/app.service'; import { SERVER_URL } from '../../injection-tokens'; diff --git a/apps/frontend/src/app/auth/guards/auth.guard.ts b/apps/frontend/src/app/core/guards/auth.guard.ts similarity index 100% rename from apps/frontend/src/app/auth/guards/auth.guard.ts rename to apps/frontend/src/app/core/guards/auth.guard.ts diff --git a/apps/frontend/src/app/auth/guards/token.guard.ts b/apps/frontend/src/app/core/guards/token.guard.ts similarity index 100% rename from apps/frontend/src/app/auth/guards/token.guard.ts rename to apps/frontend/src/app/core/guards/token.guard.ts diff --git a/apps/frontend/src/app/interceptors/app-http-error.class.ts b/apps/frontend/src/app/core/interceptors/app-http-error.class.ts similarity index 100% rename from apps/frontend/src/app/interceptors/app-http-error.class.ts rename to apps/frontend/src/app/core/interceptors/app-http-error.class.ts diff --git a/apps/frontend/src/app/interceptors/auth.interceptor.ts b/apps/frontend/src/app/core/interceptors/auth.interceptor.ts similarity index 95% rename from apps/frontend/src/app/interceptors/auth.interceptor.ts rename to apps/frontend/src/app/core/interceptors/auth.interceptor.ts index 52d12c670..78328b00b 100644 --- a/apps/frontend/src/app/interceptors/auth.interceptor.ts +++ b/apps/frontend/src/app/core/interceptors/auth.interceptor.ts @@ -4,7 +4,7 @@ import { } from '@angular/common/http'; import { finalize, Observable, tap } from 'rxjs'; import { AppHttpError } from './app-http-error.class'; -import { AppService } from '../services/app.service'; +import { AppService } from '../../services/app.service'; /** * Functional interceptor for adding authentication headers and handling errors diff --git a/apps/frontend/src/app/services/journal-interceptor.ts b/apps/frontend/src/app/core/interceptors/journal-interceptor.ts similarity index 96% rename from apps/frontend/src/app/services/journal-interceptor.ts rename to apps/frontend/src/app/core/interceptors/journal-interceptor.ts index c1d904013..215930cf7 100644 --- a/apps/frontend/src/app/services/journal-interceptor.ts +++ b/apps/frontend/src/app/core/interceptors/journal-interceptor.ts @@ -7,8 +7,8 @@ import { HttpResponse } from '@angular/common/http'; import { Observable, tap } from 'rxjs'; -import { AppService } from './app.service'; -import { JournalService } from './journal.service'; +import { AppService } from '../../services/app.service'; +import { JournalService } from '../../services/journal.service'; export const journalInterceptor: HttpInterceptorFn = ( request: HttpRequest, diff --git a/apps/frontend/src/app/auth/services/auth.service.spec.ts b/apps/frontend/src/app/core/services/auth.service.spec.ts similarity index 100% rename from apps/frontend/src/app/auth/services/auth.service.spec.ts rename to apps/frontend/src/app/core/services/auth.service.spec.ts diff --git a/apps/frontend/src/app/auth/services/auth.service.ts b/apps/frontend/src/app/core/services/auth.service.ts similarity index 100% rename from apps/frontend/src/app/auth/services/auth.service.ts rename to apps/frontend/src/app/core/services/auth.service.ts diff --git a/apps/frontend/src/app/services/app.service.ts b/apps/frontend/src/app/services/app.service.ts index 4228ad064..0f97c9c16 100755 --- a/apps/frontend/src/app/services/app.service.ts +++ b/apps/frontend/src/app/services/app.service.ts @@ -12,7 +12,7 @@ import { import { KeycloakProfile, KeycloakTokenParsed } from 'keycloak-js'; import { AppLogoDto } from '../../../../../api-dto/app-logo-dto'; import { AuthDataDto } from '../../../../../api-dto/auth-data-dto'; -import { AppHttpError } from '../interceptors/app-http-error.class'; +import { AppHttpError } from '../core/interceptors/app-http-error.class'; import { TestGroupsInListDto } from '../../../../../api-dto/test-groups/testgroups-in-list.dto'; import { FilesInListDto } from '../../../../../api-dto/files/files-in-list.dto'; import { CreateUserDto } from '../../../../../api-dto/user/create-user-dto'; diff --git a/apps/frontend/src/app/sys-admin/components/user-menu/user-menu.component.spec.ts b/apps/frontend/src/app/sys-admin/components/user-menu/user-menu.component.spec.ts index 4dcb021cd..d579a3194 100755 --- a/apps/frontend/src/app/sys-admin/components/user-menu/user-menu.component.spec.ts +++ b/apps/frontend/src/app/sys-admin/components/user-menu/user-menu.component.spec.ts @@ -2,12 +2,12 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { TranslateModule } from '@ngx-translate/core'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { UserMenuComponent } from './user-menu.component'; -import { AuthService } from '../../../auth/service/auth.service'; - const mockAuthService = { logout: jest.fn().mockResolvedValue(undefined), redirectToProfile: jest.fn().mockResolvedValue(undefined) }; +import { AuthService } from '../../../core/services/auth.service'; +import { environment } from '../../../../environments/environment'; describe('UserMenuComponent', () => { let component: UserMenuComponent; diff --git a/apps/frontend/src/app/sys-admin/components/user-menu/user-menu.component.ts b/apps/frontend/src/app/sys-admin/components/user-menu/user-menu.component.ts index d244e7721..72703f039 100755 --- a/apps/frontend/src/app/sys-admin/components/user-menu/user-menu.component.ts +++ b/apps/frontend/src/app/sys-admin/components/user-menu/user-menu.component.ts @@ -5,7 +5,7 @@ import { MatMenu, MatMenuTrigger } from '@angular/material/menu'; import { MatButton } from '@angular/material/button'; import { WrappedIconComponent } from '../../../shared/wrapped-icon/wrapped-icon.component'; import { AccountActionComponent } from '../account-action/account-action.component'; -import { AuthService } from '../../../auth/services/auth.service'; +import { AuthService } from '../../../core/services/auth.service'; @Component({ selector: 'coding-box-user-menu', diff --git a/apps/frontend/src/app/sys-admin/sys-admin.routes.ts b/apps/frontend/src/app/sys-admin/sys-admin.routes.ts index a016a4c63..b430360c3 100644 --- a/apps/frontend/src/app/sys-admin/sys-admin.routes.ts +++ b/apps/frontend/src/app/sys-admin/sys-admin.routes.ts @@ -1,5 +1,5 @@ import { Routes } from '@angular/router'; -import { canActivateAuth } from '../auth/guards/auth.guard'; +import { canActivateAuth } from '../core/guards/auth.guard'; export const sysAdminRoutes: Routes = [ { diff --git a/apps/frontend/src/app/workspace/components/user-workspaces-area/user-workspaces-area.component.spec.ts b/apps/frontend/src/app/workspace/components/user-workspaces-area/user-workspaces-area.component.spec.ts index addbf4b00..ee268268b 100755 --- a/apps/frontend/src/app/workspace/components/user-workspaces-area/user-workspaces-area.component.spec.ts +++ b/apps/frontend/src/app/workspace/components/user-workspaces-area/user-workspaces-area.component.spec.ts @@ -3,7 +3,7 @@ import { TranslateModule } from '@ngx-translate/core'; import { UserWorkspacesAreaComponent } from './user-workspaces-area.component'; import { AuthService } from '../../../auth/service/auth.service'; import { environment } from '../../../../environments/environment'; -import { AuthService } from '../../../auth/services/auth.service'; +import { AuthService } from '../../../core/services/auth.service'; const mockAuthService = { getLoggedUser: jest.fn(), diff --git a/apps/frontend/src/app/workspace/components/user-workspaces/user-workspaces.component.spec.ts b/apps/frontend/src/app/workspace/components/user-workspaces/user-workspaces.component.spec.ts index d839c9c38..bcae6aeed 100755 --- a/apps/frontend/src/app/workspace/components/user-workspaces/user-workspaces.component.spec.ts +++ b/apps/frontend/src/app/workspace/components/user-workspaces/user-workspaces.component.spec.ts @@ -1,7 +1,7 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { TranslateModule } from '@ngx-translate/core'; import { UserWorkspacesComponent } from './user-workspaces.component'; -import { AuthService } from '../../../auth/services/auth.service'; +import { AuthService } from '../../../core/services/auth.service'; const mockKeycloak = { idTokenParsed: { sub: 'test-user-id', preferred_username: 'test-user' }, diff --git a/apps/frontend/src/app/workspace/components/user-workspaces/user-workspaces.component.ts b/apps/frontend/src/app/workspace/components/user-workspaces/user-workspaces.component.ts index 9b70b6694..54140b758 100755 --- a/apps/frontend/src/app/workspace/components/user-workspaces/user-workspaces.component.ts +++ b/apps/frontend/src/app/workspace/components/user-workspaces/user-workspaces.component.ts @@ -3,7 +3,7 @@ import { TranslateModule } from '@ngx-translate/core'; import { RouterLink } from '@angular/router'; import { MatAnchor, MatButton } from '@angular/material/button'; import { WorkspaceFullDto } from '../../../../../../../api-dto/workspaces/workspace-full-dto'; -import { AuthService } from '../../../auth/services/auth.service'; +import { AuthService } from '../../../core/services/auth.service'; @Component({ selector: 'coding-book-user-workspaces', diff --git a/apps/frontend/src/app/ws-admin/ws-admin.routes.ts b/apps/frontend/src/app/ws-admin/ws-admin.routes.ts index 843bb9c57..18346d3c9 100644 --- a/apps/frontend/src/app/ws-admin/ws-admin.routes.ts +++ b/apps/frontend/src/app/ws-admin/ws-admin.routes.ts @@ -1,5 +1,5 @@ import { Routes } from '@angular/router'; -import { canActivateAuth } from '../auth/guards/auth.guard'; +import { canActivateAuth } from '../core/guards/auth.guard'; export const wsAdminRoutes: Routes = [ { From be3689db0aeabddf1905ee5e32e20e4295d332d5 Mon Sep 17 00:00:00 2001 From: jurei733 <67505990+jurei733@users.noreply.github.com> Date: Tue, 8 Jul 2025 18:19:46 +0200 Subject: [PATCH 18/36] Move files to core --- .../core/interceptors/journal-interceptor.ts | 87 ------------------- 1 file changed, 87 deletions(-) diff --git a/apps/frontend/src/app/core/interceptors/journal-interceptor.ts b/apps/frontend/src/app/core/interceptors/journal-interceptor.ts index 215930cf7..8a5ca3398 100644 --- a/apps/frontend/src/app/core/interceptors/journal-interceptor.ts +++ b/apps/frontend/src/app/core/interceptors/journal-interceptor.ts @@ -8,7 +8,6 @@ import { } from '@angular/common/http'; import { Observable, tap } from 'rxjs'; import { AppService } from '../../services/app.service'; -import { JournalService } from '../../services/journal.service'; export const journalInterceptor: HttpInterceptorFn = ( request: HttpRequest, @@ -49,89 +48,3 @@ function isTestResultsRequest(request: HttpRequest): boolean { request.url.includes('/units') || request.url.includes('/booklets'); } - -// eslint-disable-next-line @typescript-eslint/no-unused-vars -function logAction( - request: HttpRequest, - response: HttpResponse, - appService: AppService, - journalService: JournalService -): void { - const workspaceId = appService.selectedWorkspaceId; - if (!workspaceId) { - return; - } - - const url = request.url; - const method = request.method; - const actionType = getActionType(method); - const entityType = getEntityType(url); - const entityId = getEntityId(url); - - const details = JSON.stringify({ - method, - url, - requestBody: request.body ? sanitizeBody(request.body) : null, - responseStatus: response.status, - responseBody: response.body ? sanitizeBody(response.body) : null - }); - - journalService.createJournalEntry( - workspaceId, - actionType, - entityType, - entityId, - details - ).subscribe(); -} - -function getActionType(method: string): string { - switch (method) { - case 'POST': return 'create'; - case 'PUT': - case 'PATCH': return 'update'; - case 'DELETE': return 'delete'; - default: return 'unknown'; - } -} - -function getEntityType(url: string): string { - // Extract entity type from URL - if (url.includes('/test-results')) return 'test-results'; - if (url.includes('/coding')) return 'coding'; - if (url.includes('/files')) return 'files'; - if (url.includes('/unit-tags')) return 'unit-tags'; - if (url.includes('/unit-notes')) return 'unit-notes'; - if (url.includes('/resource-packages')) return 'resource-packages'; - if (url.includes('/units')) return 'units'; - if (url.includes('/responses')) return 'responses'; - - return 'unknown'; -} - -function getEntityId(url: string): string { - const parts = url.split('/'); - const idPattern = /^[0-9]+$/; - - for (let i = parts.length - 1; i >= 0; i--) { - if (idPattern.test(parts[i])) { - return parts[i]; - } - } - return '0'; -} - -function sanitizeBody(body: unknown): unknown { - // Remove sensitive information from the body - if (!body) return null; - if (typeof body !== 'object') return body; - - const sanitized = { ...body as Record }; - - // Remove sensitive fields - if (sanitized.password) sanitized.password = '***'; - if (sanitized.token) sanitized.token = '***'; - if (sanitized.authToken) sanitized.authToken = '***'; - - return sanitized; -} From a1452ea7d568f0f7d405c528fce88453d6cd0b1a Mon Sep 17 00:00:00 2001 From: jurei733 <67505990+jurei733@users.noreply.github.com> Date: Tue, 8 Jul 2025 18:25:38 +0200 Subject: [PATCH 19/36] Organize assets more systematically --- .../src/app/admin/logo/logo.controller.ts | 24 +++++++++++------- apps/frontend/src/app/services/app.service.ts | 2 +- .../src/assets/{ => icons}/favicon.ico | Bin .../src/assets/{ => images}/IQB-LogoA.png | Bin apps/frontend/src/index.html | 2 +- 5 files changed, 17 insertions(+), 11 deletions(-) rename apps/frontend/src/assets/{ => icons}/favicon.ico (100%) rename apps/frontend/src/assets/{ => images}/IQB-LogoA.png (100%) diff --git a/apps/backend/src/app/admin/logo/logo.controller.ts b/apps/backend/src/app/admin/logo/logo.controller.ts index e0531f3fe..059f67057 100644 --- a/apps/backend/src/app/admin/logo/logo.controller.ts +++ b/apps/backend/src/app/admin/logo/logo.controller.ts @@ -30,7 +30,7 @@ import { AppLogoDto } from '../../../../../../api-dto/app-logo-dto'; @Controller('admin/logo') @ApiTags('admin') export class LogoController { - LOGO_PATH = path.join(process.cwd(), 'apps', 'frontend', 'src', 'assets', 'logo'); + LOGO_PATH = path.join(process.cwd(), 'apps', 'frontend', 'src', 'assets', 'images'); ALLOWED_MIME_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/svg+xml', 'image/webp']; MAX_FILE_SIZE = 4 * 1024 * 1024; // 4MB @@ -42,7 +42,7 @@ export class LogoController { FileInterceptor('logo', { storage: diskStorage({ destination: (req, file, cb) => { - const uploadPath = path.join(process.cwd(), 'apps', 'frontend', 'src', 'assets'); + const uploadPath = path.join(process.cwd(), 'apps', 'frontend', 'src', 'assets', 'images'); if (!fs.existsSync(uploadPath)) { fs.mkdirSync(uploadPath, { recursive: true }); } @@ -93,7 +93,7 @@ export class LogoController { try { // Always return the consistent path to the uploaded file // This ensures the path matches the actual saved filename (logo + extension) - return { path: `assets/logo${path.extname(file.originalname)}` }; + return { path: `assets/images/logo${path.extname(file.originalname)}` }; } catch (error) { throw new InternalServerErrorException('Failed to upload logo'); } @@ -106,8 +106,8 @@ export class LogoController { @ApiOkResponse({ description: 'Logo deleted successfully', type: Boolean }) async deleteLogo(): Promise<{ success: boolean }> { try { - // Find all files starting with 'logo' in the assets directory - const assetsDir = path.join(process.cwd(), 'apps', 'frontend', 'src', 'assets'); + // Find all files starting with 'logo' in the images directory + const assetsDir = path.join(process.cwd(), 'apps', 'frontend', 'src', 'assets', 'images'); const files = fs.readdirSync(assetsDir); let deleted = false; @@ -119,7 +119,7 @@ export class LogoController { } // Delete logo settings file if it exists - const settingsPath = path.join(process.cwd(), 'apps', 'frontend', 'src', 'assets', 'logo-settings.json'); + const settingsPath = path.join(process.cwd(), 'apps', 'frontend', 'src', 'assets', 'data', 'logo-settings.json'); if (fs.existsSync(settingsPath)) { fs.unlinkSync(settingsPath); } @@ -138,7 +138,13 @@ export class LogoController { @ApiOkResponse({ description: 'Logo settings saved successfully', type: Boolean }) async saveLogoSettings(@Body() logoSettings: AppLogoDto): Promise<{ success: boolean }> { try { - const settingsPath = path.join(process.cwd(), 'apps', 'frontend', 'src', 'assets', 'logo-settings.json'); + const dataDir = path.join(process.cwd(), 'apps', 'frontend', 'src', 'assets', 'data'); + const settingsPath = path.join(dataDir, 'logo-settings.json'); + + // Ensure data directory exists + if (!fs.existsSync(dataDir)) { + fs.mkdirSync(dataDir, { recursive: true }); + } // Save the settings to a file fs.writeFileSync(settingsPath, JSON.stringify(logoSettings, null, 2)); @@ -156,7 +162,7 @@ export class LogoController { @ApiOkResponse({ description: 'Logo settings retrieved successfully', type: AppLogoDto }) async getLogoSettings(): Promise { try { - const settingsPath = path.join(process.cwd(), 'apps', 'frontend', 'src', 'assets', 'logo-settings.json'); + const settingsPath = path.join(process.cwd(), 'apps', 'frontend', 'src', 'assets', 'data', 'logo-settings.json'); // Check if settings file exists if (fs.existsSync(settingsPath)) { @@ -167,7 +173,7 @@ export class LogoController { // Return default settings if file doesn't exist return { - data: 'assets/IQB-LogoA.png', + data: 'assets/images/IQB-LogoA.png', alt: 'Zur Startseite', bodyBackground: 'linear-gradient(180deg, rgba(7,70,94,1) 0%, rgba(6,112,123,1) 24%, rgba(1,192,229,1) 85%)', boxBackground: 'lightgray' diff --git a/apps/frontend/src/app/services/app.service.ts b/apps/frontend/src/app/services/app.service.ts index 0f97c9c16..bc9a1d76a 100755 --- a/apps/frontend/src/app/services/app.service.ts +++ b/apps/frontend/src/app/services/app.service.ts @@ -168,7 +168,7 @@ export class AppService { } export const standardLogo: AppLogoDto = { - data: 'assets/IQB-LogoA.png', + data: 'assets/images/IQB-LogoA.png', alt: 'Zur Startseite', bodyBackground: 'linear-gradient(180deg, rgba(7,70,94,1) 0%, rgba(6,112,123,1) 24%, rgba(1,192,229,1) 85%)', boxBackground: 'lightgray' diff --git a/apps/frontend/src/assets/favicon.ico b/apps/frontend/src/assets/icons/favicon.ico similarity index 100% rename from apps/frontend/src/assets/favicon.ico rename to apps/frontend/src/assets/icons/favicon.ico diff --git a/apps/frontend/src/assets/IQB-LogoA.png b/apps/frontend/src/assets/images/IQB-LogoA.png similarity index 100% rename from apps/frontend/src/assets/IQB-LogoA.png rename to apps/frontend/src/assets/images/IQB-LogoA.png diff --git a/apps/frontend/src/index.html b/apps/frontend/src/index.html index d4e1ea7b6..154bc8a6f 100755 --- a/apps/frontend/src/index.html +++ b/apps/frontend/src/index.html @@ -5,7 +5,7 @@ IQB-Kodierbox - + From 94bd57600f8693cde1e4f348c573508adc47df09 Mon Sep 17 00:00:00 2001 From: jurei733 <67505990+jurei733@users.noreply.github.com> Date: Tue, 8 Jul 2025 21:51:06 +0200 Subject: [PATCH 20/36] Fix tests after merge --- apps/frontend/src/app/README.md | 62 +++++++++++++++++++ apps/frontend/src/app/app.config.ts | 1 + .../coding-jobs/coding-jobs.component.spec.ts | 4 +- ...coding-management-manual.component.spec.ts | 4 +- .../coding-management.component.spec.ts | 4 +- .../coding-manual.component.spec.ts | 2 +- apps/frontend/src/app/core/README.md | 32 ++++++++++ .../user-menu/user-menu.component.spec.ts | 4 +- .../workspaces-menu.component.spec.ts | 2 +- .../user-workspaces-area.component.spec.ts | 2 - 10 files changed, 105 insertions(+), 12 deletions(-) create mode 100644 apps/frontend/src/app/README.md create mode 100644 apps/frontend/src/app/core/README.md diff --git a/apps/frontend/src/app/README.md b/apps/frontend/src/app/README.md new file mode 100644 index 000000000..26dcc24f2 --- /dev/null +++ b/apps/frontend/src/app/README.md @@ -0,0 +1,62 @@ +# Frontend Application Structure + +This document outlines the structure of the frontend application and provides guidance for future development. + +## Directory Structure + +The application follows a feature-based organization with shared components and core services: + +``` +src/ + app/ + core/ # Singleton services and application-wide providers + shared/ # Reusable components, directives, pipes, and utilities + [feature-modules]/ # Various feature modules (auth, coding, replay, etc.) + app.component.* # Root component files + app.config.ts # Application configuration + app.routes.ts # Root routing configuration +``` + +## Feature Module Structure + +Each feature module follows a consistent directory structure: + +``` +feature/ + components/ # UI components specific to this feature + services/ # Services specific to this feature + models/ # Data models and interfaces + utils/ # Utility functions + guards/ # Route guards + feature.routes.ts # Feature-specific routes +``` + +## Routing + +The application uses a modular routing approach: + +1. Each feature module has its own routes file (e.g., `replay.routes.ts`, `sys-admin.routes.ts`) +2. The root routes file (`app.routes.ts`) imports and combines all feature routes +3. Routes are lazy-loaded to improve performance + +## Development Guidelines + +1. **Components**: + - Place components in the appropriate feature module's `components` directory + - Use smart/dumb component pattern (containers vs. presentational components) + +2. **Services**: + - Place global services in the `core/services` directory + - Place feature-specific services in the feature module's `services` directory + +3. **Models**: + - Define interfaces and types in the appropriate feature module's `models` directory + - Place shared models in the `shared/models` directory + +4. **Guards**: + - Place global guards in the `core/guards` directory + - Place feature-specific guards in the feature module's `guards` directory + +5. **Utils**: + - Place utility functions in the appropriate feature module's `utils` directory + - Place shared utilities in the `shared/utils` directory diff --git a/apps/frontend/src/app/app.config.ts b/apps/frontend/src/app/app.config.ts index 8c7a5fb4a..5641e4988 100755 --- a/apps/frontend/src/app/app.config.ts +++ b/apps/frontend/src/app/app.config.ts @@ -23,6 +23,7 @@ import { routes } from './app.routes'; import { environment } from '../environments/environment'; import { authInterceptor } from './core/interceptors/auth.interceptor'; import { journalInterceptor } from './core/interceptors/journal-interceptor'; +import { SERVER_URL } from './injection-tokens'; export function createTranslateLoader(http: HttpClient): TranslateHttpLoader { return new TranslateHttpLoader(http, './assets/i18n/', '.json'); diff --git a/apps/frontend/src/app/coding/components/coding-jobs/coding-jobs.component.spec.ts b/apps/frontend/src/app/coding/components/coding-jobs/coding-jobs.component.spec.ts index b68d1c44a..cc3e3c9b0 100755 --- a/apps/frontend/src/app/coding/components/coding-jobs/coding-jobs.component.spec.ts +++ b/apps/frontend/src/app/coding/components/coding-jobs/coding-jobs.component.spec.ts @@ -3,8 +3,8 @@ import { TranslateModule } from '@ngx-translate/core'; import { ActivatedRoute } from '@angular/router'; import { provideHttpClient } from '@angular/common/http'; import { CodingJobsComponent } from './coding-jobs.component'; -import { environment } from '../../../environments/environment'; -import { SERVER_URL } from '../../injection-tokens'; +import { SERVER_URL } from '../../../injection-tokens'; +import { environment } from '../../../../environments/environment'; describe('CodingJobsComponent', () => { let component: CodingJobsComponent; diff --git a/apps/frontend/src/app/coding/components/coding-management-manual/coding-management-manual.component.spec.ts b/apps/frontend/src/app/coding/components/coding-management-manual/coding-management-manual.component.spec.ts index 5cb69356b..6e3d8bb3b 100755 --- a/apps/frontend/src/app/coding/components/coding-management-manual/coding-management-manual.component.spec.ts +++ b/apps/frontend/src/app/coding/components/coding-management-manual/coding-management-manual.component.spec.ts @@ -4,8 +4,8 @@ import { ActivatedRoute } from '@angular/router'; import { provideHttpClient } from '@angular/common/http'; import { provideNoopAnimations } from '@angular/platform-browser/animations'; import { CodingManagementManualComponent } from './coding-management-manual.component'; -import { environment } from '../../../environments/environment'; -import { SERVER_URL } from '../../injection-tokens'; +import { SERVER_URL } from '../../../injection-tokens'; +import { environment } from '../../../../environments/environment'; describe('CodingManagementManualComponent', () => { let component: CodingManagementManualComponent; diff --git a/apps/frontend/src/app/coding/components/coding-managment/coding-management.component.spec.ts b/apps/frontend/src/app/coding/components/coding-managment/coding-management.component.spec.ts index 3294f548d..c391f1927 100755 --- a/apps/frontend/src/app/coding/components/coding-managment/coding-management.component.spec.ts +++ b/apps/frontend/src/app/coding/components/coding-managment/coding-management.component.spec.ts @@ -3,8 +3,8 @@ import { TranslateModule } from '@ngx-translate/core'; import { ActivatedRoute } from '@angular/router'; import { provideHttpClient } from '@angular/common/http'; import { CodingManagementComponent } from './coding-management.component'; -import { environment } from '../../../environments/environment'; -import { SERVER_URL } from '../../injection-tokens'; +import { SERVER_URL } from '../../../injection-tokens'; +import { environment } from '../../../../environments/environment'; describe('CodingManagementComponent', () => { let component: CodingManagementComponent; diff --git a/apps/frontend/src/app/coding/components/coding-manual/coding-manual.component.spec.ts b/apps/frontend/src/app/coding/components/coding-manual/coding-manual.component.spec.ts index 619131c72..7263a8703 100755 --- a/apps/frontend/src/app/coding/components/coding-manual/coding-manual.component.spec.ts +++ b/apps/frontend/src/app/coding/components/coding-manual/coding-manual.component.spec.ts @@ -3,8 +3,8 @@ import { TranslateModule } from '@ngx-translate/core'; import { provideHttpClient } from '@angular/common/http'; import { ActivatedRoute } from '@angular/router'; import { CodingManualComponent } from './coding-manual.component'; -import { SERVER_URL } from '../../injection-tokens'; import { environment } from '../../../../environments/environment'; +import { SERVER_URL } from '../../../injection-tokens'; describe('CodingManualComponent', () => { let component: CodingManualComponent; diff --git a/apps/frontend/src/app/core/README.md b/apps/frontend/src/app/core/README.md new file mode 100644 index 000000000..d14a4586c --- /dev/null +++ b/apps/frontend/src/app/core/README.md @@ -0,0 +1,32 @@ +# Core Module + +This directory contains singleton services and application-wide providers that should be instantiated only once in the application lifecycle. + +## Structure + +- **services/**: Singleton services that should have only one instance throughout the application + - Authentication services + - Logging services + - Global state services + +- **interceptors/**: HTTP interceptors for handling cross-cutting concerns + - Authentication interceptors + - Error handling interceptors + - Logging interceptors + +- **guards/**: Global route guards + - Authentication guards + - Permission guards + +- **config/**: Application configuration + - Environment-specific configuration + - Feature flags + - Global constants + +## Usage Guidelines + +1. Services in the core module should be provided at the root level in `app.config.ts` +2. Do not import the core module in feature modules to avoid circular dependencies +3. Core services should be stateless or manage global application state +4. Interceptors should handle cross-cutting concerns like authentication, error handling, and logging +5. Guards should protect routes based on authentication, permissions, or other global conditions diff --git a/apps/frontend/src/app/sys-admin/components/user-menu/user-menu.component.spec.ts b/apps/frontend/src/app/sys-admin/components/user-menu/user-menu.component.spec.ts index d579a3194..d2ec8b29b 100755 --- a/apps/frontend/src/app/sys-admin/components/user-menu/user-menu.component.spec.ts +++ b/apps/frontend/src/app/sys-admin/components/user-menu/user-menu.component.spec.ts @@ -2,12 +2,12 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { TranslateModule } from '@ngx-translate/core'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { UserMenuComponent } from './user-menu.component'; +import { AuthService } from '../../../core/services/auth.service'; + const mockAuthService = { logout: jest.fn().mockResolvedValue(undefined), redirectToProfile: jest.fn().mockResolvedValue(undefined) }; -import { AuthService } from '../../../core/services/auth.service'; -import { environment } from '../../../../environments/environment'; describe('UserMenuComponent', () => { let component: UserMenuComponent; diff --git a/apps/frontend/src/app/sys-admin/components/workspaces-menu/workspaces-menu.component.spec.ts b/apps/frontend/src/app/sys-admin/components/workspaces-menu/workspaces-menu.component.spec.ts index 99987dd65..b90604fbd 100755 --- a/apps/frontend/src/app/sys-admin/components/workspaces-menu/workspaces-menu.component.spec.ts +++ b/apps/frontend/src/app/sys-admin/components/workspaces-menu/workspaces-menu.component.spec.ts @@ -6,7 +6,7 @@ import { TranslateModule } from '@ngx-translate/core'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { AppService } from '../../../services/app.service'; import { HomeComponent } from '../../../components/home/home.component'; -import { AuthService } from '../../../auth/service/auth.service'; +import { AuthService } from '../../../core/services/auth.service'; describe('HomeComponent', () => { let component: HomeComponent; diff --git a/apps/frontend/src/app/workspace/components/user-workspaces-area/user-workspaces-area.component.spec.ts b/apps/frontend/src/app/workspace/components/user-workspaces-area/user-workspaces-area.component.spec.ts index ee268268b..fb3a2b12f 100755 --- a/apps/frontend/src/app/workspace/components/user-workspaces-area/user-workspaces-area.component.spec.ts +++ b/apps/frontend/src/app/workspace/components/user-workspaces-area/user-workspaces-area.component.spec.ts @@ -1,8 +1,6 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { TranslateModule } from '@ngx-translate/core'; import { UserWorkspacesAreaComponent } from './user-workspaces-area.component'; -import { AuthService } from '../../../auth/service/auth.service'; -import { environment } from '../../../../environments/environment'; import { AuthService } from '../../../core/services/auth.service'; const mockAuthService = { From a2d1e619205d6335ae1f1ea2f56f6a190b9deb66 Mon Sep 17 00:00:00 2001 From: jurei733 <67505990+jurei733@users.noreply.github.com> Date: Tue, 8 Jul 2025 22:09:35 +0200 Subject: [PATCH 21/36] Scroll to the top if no anchor element is provided in the URL --- README.md | 4 ---- .../components/replay/replay.component.ts | 24 ++++++++++++++++++- 2 files changed, 23 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 2ddd70db1..084bf1a73 100755 --- a/README.md +++ b/README.md @@ -78,10 +78,6 @@ For production environments, you can use the file `docker-compose.coding-box.pro --- -## Key Features - - ---- ## Useful Scripts diff --git a/apps/frontend/src/app/replay/components/replay/replay.component.ts b/apps/frontend/src/app/replay/components/replay/replay.component.ts index 6f12b9a98..1edf75b5a 100755 --- a/apps/frontend/src/app/replay/components/replay/replay.component.ts +++ b/apps/frontend/src/app/replay/components/replay/replay.component.ts @@ -130,7 +130,12 @@ export class ReplayComponent implements OnInit, OnDestroy, OnChanges { this.setUnitProperties(unitData); setTimeout(() => { if (this.unitPlayerComponent?.hostingIframe?.nativeElement) { - scrollToElementByAlias(this.unitPlayerComponent.hostingIframe.nativeElement, this.anchor || ''); + if (this.anchor) { + scrollToElementByAlias(this.unitPlayerComponent.hostingIframe.nativeElement, this.anchor); + } else { + // When no anchor is provided, scroll to the top of the content + this.scrollToTop(); + } } }, 1000); } @@ -373,6 +378,23 @@ export class ReplayComponent implements OnInit, OnDestroy, OnChanges { this.responses = undefined; } + /** + * Scrolls the iframe content to the top + */ + private scrollToTop(): void { + try { + if (this.unitPlayerComponent?.hostingIframe?.nativeElement?.contentWindow) { + this.unitPlayerComponent.hostingIframe.nativeElement.contentWindow.scrollTo({ + top: 0, + left: 0, + behavior: 'smooth' + }); + } + } catch (error) { + console.error('Error scrolling to top:', error); + } + } + ngOnDestroy(): void { this.routerSubscription?.unsubscribe(); this.routerSubscription = null; From c88abff0e02a5459fae0417d9470ccf12e364d69 Mon Sep 17 00:00:00 2001 From: jurei733 <67505990+jurei733@users.noreply.github.com> Date: Tue, 8 Jul 2025 22:26:51 +0200 Subject: [PATCH 22/36] Ensure validation occurs only when both the page ID and valid pages are available. --- .../replay/replay.component.spec.ts | 156 ++++++++++++++++-- .../components/replay/replay.component.ts | 12 +- .../unit-player/unit-player.component.ts | 61 ++++--- 3 files changed, 186 insertions(+), 43 deletions(-) diff --git a/apps/frontend/src/app/replay/components/replay/replay.component.spec.ts b/apps/frontend/src/app/replay/components/replay/replay.component.spec.ts index 4e96d6560..f7c160962 100755 --- a/apps/frontend/src/app/replay/components/replay/replay.component.spec.ts +++ b/apps/frontend/src/app/replay/components/replay/replay.component.spec.ts @@ -1,38 +1,162 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { HttpClientModule } from '@angular/common/http'; +// eslint-disable-next-line max-classes-per-file import { ActivatedRoute } from '@angular/router'; import { TranslateModule } from '@ngx-translate/core'; +import { of } from 'rxjs'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { provideHttpClient } from '@angular/common/http'; +import { MatSnackBar } from '@angular/material/snack-bar'; import { ReplayComponent } from './replay.component'; import { environment } from '../../../../environments/environment'; import { SERVER_URL } from '../../../injection-tokens'; +import { BackendService } from '../../../services/backend.service'; +import { AppService } from '../../../services/app.service'; +import * as tokenUtils from '../../utils/token-utils'; +import * as domUtils from '../../utils/dom-utils'; + +// Beispielhafte Mocks für Services, die im Component per inject() genutzt werden +class BackendServiceMock { + getUnitDef = jest.fn().mockReturnValue(of([{ data: 'unitDef data', file_id: 'UNIT-123.VOUD' }])); + getResponses = jest.fn().mockReturnValue(of([{ id: 1, data: 'response data' }])); + getUnit = jest.fn().mockReturnValue(of([{ data: '', file_id: 'UNIT-123' }])); + getPlayer = jest.fn().mockReturnValue(of([{ data: 'player data', file_id: 'PLAYER-1.0' }])); +} + +class AppServiceMock { + selectedWorkspaceId = 42; +} + +class MatSnackBarMock { + open = jest.fn().mockReturnValue({ + afterDismissed: () => of({}) + }); + + dismiss = jest.fn(); +} + +// Konfiguration der Aktivierten Route, inklusive Parameter und Query Params +const fakeActivatedRoute = { + snapshot: { data: {}, url: [{ path: '' }] }, + params: of({ + page: 'page-1', testPerson: 'valid@test@person', unitId: 'unit-123', anchor: undefined + }), + queryParams: of({ auth: 'valid-token' }) +} as unknown as ActivatedRoute; describe('ReplayComponent', () => { let component: ReplayComponent; let fixture: ComponentFixture; - const fakeActivatedRoute = { - snapshot: { data: { } } - } as ActivatedRoute; + let backendService: BackendServiceMock; + let snackBar: MatSnackBarMock; beforeEach(async () => { + // Spy on token validation + jest.spyOn(tokenUtils, 'validateToken').mockReturnValue({ isValid: true }); + jest.spyOn(tokenUtils, 'isTestperson').mockImplementation(testperson => testperson === 'valid@test@person'); + + // Spy on DOM utils + jest.spyOn(domUtils, 'scrollToElementByAlias').mockReturnValue(true); + await TestBed.configureTestingModule({ - providers: [{ - provide: ActivatedRoute, - useValue: fakeActivatedRoute - }, - { - provide: SERVER_URL, - useValue: environment.backendUrl - }], - imports: [ReplayComponent, HttpClientModule, TranslateModule.forRoot()] - }) - .compileComponents(); + providers: [ + provideHttpClient(), + { provide: ActivatedRoute, useValue: fakeActivatedRoute }, + { provide: SERVER_URL, useValue: environment.backendUrl }, + { provide: BackendService, useClass: BackendServiceMock }, + { provide: AppService, useClass: AppServiceMock }, + { provide: MatSnackBar, useClass: MatSnackBarMock } + ], + imports: [ReplayComponent, TranslateModule.forRoot()] + }).compileComponents(); fixture = TestBed.createComponent(ReplayComponent); component = fixture.componentInstance; + backendService = TestBed.inject(BackendService) as unknown as BackendServiceMock; + snackBar = TestBed.inject(MatSnackBar) as unknown as MatSnackBarMock; fixture.detectChanges(); }); + afterEach(() => { + jest.restoreAllMocks(); + }); + it('should create', () => { expect(component).toBeTruthy(); }); + + it('should initialise observables and default properties', () => { + expect(component.isLoaded).toBeDefined(); + expect(component.player).toBe(''); + expect(component.unitDef).toBe(''); + expect(component.responses).toBeUndefined(); + }); + + it('should handle invalid testPerson in setTestPerson', () => { + // Testperson, die absichtlich ungültig ist + expect(() => component.setTestPerson('')).toThrowError('TestPersonError'); + }); + + it('should set test person correctly when valid', () => { + component.setTestPerson('valid@test@person'); + expect(component.testPerson).toBe('valid@test@person'); + }); + + it('should handle page errors correctly', () => { + component.checkPageError('notInList'); + expect(snackBar.open).toHaveBeenCalledWith( + expect.stringContaining('Keine valide Seite mit ID'), + 'Schließen', + expect.any(Object) + ); + }); + + it('should dismiss page error when null is passed', () => { + // First create an error + component.checkPageError('notInList'); + + // Then dismiss it + component.checkPageError(null); + + // Check if dismiss was called + expect(snackBar.dismiss).toHaveBeenCalled(); + }); + + it('should set unit parameters correctly', () => { + const params = { + page: 'test-page', + testPerson: 'valid@test@person', + unitId: 'test-unit', + anchor: 'test-anchor' + }; + + component.setUnitParams(params); + + expect(component.page).toBe('test-page'); + expect(component.anchor).toBe('test-anchor'); + expect(component.unitId).toBe('test-unit'); + expect(component.testPerson).toBe('valid@test@person'); + }); + + it('should normalize player ID correctly', () => { + const normalizedId = ReplayComponent.getNormalizedPlayerId('player-1.2.3-beta.js'); + expect(normalizedId).toBe('PLAYER-1.2'); + }); + + it('should reset unit data correctly', () => { + // Set some data first + component.unitId = 'test-unit'; + component.player = 'test-player'; + component.unitDef = 'test-unitDef'; + component.page = 'test-page'; + component.responses = [{ id: 1 }]; + + // Call the private method using type assertion + (component as unknown as { resetUnitData: () => void }).resetUnitData(); + + // Check if data was reset + expect(component.unitId).toBe(''); + expect(component.player).toBe(''); + expect(component.unitDef).toBe(''); + expect(component.page).toBeUndefined(); + expect(component.responses).toBeUndefined(); + }); }); diff --git a/apps/frontend/src/app/replay/components/replay/replay.component.ts b/apps/frontend/src/app/replay/components/replay/replay.component.ts index 1edf75b5a..a6611daec 100755 --- a/apps/frontend/src/app/replay/components/replay/replay.component.ts +++ b/apps/frontend/src/app/replay/components/replay/replay.component.ts @@ -47,8 +47,8 @@ export class ReplayComponent implements OnInit, OnDestroy, OnChanges { /* eslint-disable @typescript-eslint/no-explicit-any */ responses: any | undefined = undefined; isPrintMode: boolean = false; - private testPerson: string = ''; - private unitId: string = ''; + testPerson: string = ''; + unitId: string = ''; private authToken: string = ''; private errorSnackbarRef: MatSnackBarRef | null = null; private pageErrorSnackbarRef: MatSnackBarRef | null = null; @@ -87,7 +87,7 @@ export class ReplayComponent implements OnInit, OnDestroy, OnChanges { return auth; } - private subscribeRouter(): void { + subscribeRouter(): void { this.routerSubscription = this.route.params ?.subscribe(async params => { this.resetSnackBars(); @@ -163,7 +163,7 @@ export class ReplayComponent implements OnInit, OnDestroy, OnChanges { setTimeout(() => this.isLoaded.next(true)); } - private setUnitParams(params: Params): void { + setUnitParams(params: Params): void { const { page, testPerson, unitId, anchor } = params; @@ -173,7 +173,7 @@ export class ReplayComponent implements OnInit, OnDestroy, OnChanges { this.setTestPerson(testPerson); } - private setTestPerson(testPerson: string): void { + setTestPerson(testPerson: string): void { if (!isTestperson(testPerson)) { ReplayComponent.throwError('TestPersonError'); } else { @@ -250,7 +250,7 @@ export class ReplayComponent implements OnInit, OnDestroy, OnChanges { this.lastPlayer.id = playerData.file_id; } - private static getNormalizedPlayerId(name: string): string { + static getNormalizedPlayerId(name: string): string { const reg = /^(\D+?)[@V-]?((\d+)(\.\d+)?(\.\d+)?(-\S+?)?)?(.\D{3,4})?$/; const matches = name.match(reg); if (matches) { diff --git a/apps/frontend/src/app/replay/components/unit-player/unit-player.component.ts b/apps/frontend/src/app/replay/components/unit-player/unit-player.component.ts index d1f232399..937ceb54e 100755 --- a/apps/frontend/src/app/replay/components/unit-player/unit-player.component.ts +++ b/apps/frontend/src/app/replay/components/unit-player/unit-player.component.ts @@ -9,7 +9,7 @@ import { MatButtonModule } from '@angular/material/button'; import { ReactiveFormsModule } from '@angular/forms'; import { TranslateModule, TranslateService } from '@ngx-translate/core'; import { - debounceTime, fromEvent, Subject, Subscription, takeUntil + combineLatest, debounceTime, fromEvent, Observable, Subject, Subscription, takeUntil } from 'rxjs'; import { MatSnackBar } from '@angular/material/snack-bar'; import { AppService } from '../../../services/app.service'; @@ -139,29 +139,48 @@ export class UnitPlayerComponent implements AfterViewInit, OnChanges, OnDestroy } private subscribeForValidPages(): void { - this.validPagesSubscription = this.validPages - .pipe(debounceTime(2000)) - .subscribe({ - next: validPages => { - const pageId = this.pageId(); - if (!pageId) { - this.invalidPage.emit('notInList'); - return; - } + // Create an Observable that emits the current pageId whenever it changes + const pageId$ = new Observable(observer => { + // Initial value + observer.next(this.pageId() || ''); + + // Set up a MutationObserver to watch for changes to the pageId input + const callback = () => { + observer.next(this.pageId() || ''); + }; + + const interval = setInterval(callback, 500); + + // Cleanup function + return () => { + clearInterval(interval); + }; + }); + + // Use combineLatest to wait for both pageId and validPages to be available + this.validPagesSubscription = combineLatest([ + pageId$, + this.validPages.pipe(debounceTime(2000)) + ]).subscribe({ + next: ([pageId, validPages]) => { + if (!pageId) { + this.invalidPage.emit('notInList'); + return; + } - if (!validPages.pages.includes(pageId)) { - this.invalidPage.emit('notInList'); - } else if (validPages.current !== pageId) { - this.invalidPage.emit('notCurrent'); - } else { - this.invalidPage.emit(null); - this.cleanupValidPagesSubscription(); - } - }, - error: () => { + if (!validPages.pages.includes(pageId)) { this.invalidPage.emit('notInList'); + } else if (validPages.current !== pageId) { + this.invalidPage.emit('notCurrent'); + } else { + this.invalidPage.emit(null); + this.cleanupValidPagesSubscription(); } - }); + }, + error: () => { + this.invalidPage.emit('notInList'); + } + }); } private cleanupValidPagesSubscription(): void { From 73bae9a1898421dfbf3622fdaa1c5cbb51768d54 Mon Sep 17 00:00:00 2001 From: jurei733 <67505990+jurei733@users.noreply.github.com> Date: Tue, 8 Jul 2025 23:29:54 +0200 Subject: [PATCH 23/36] Optimize the retrieval of coding statistics --- .../services/workspace-coding.service.ts | 36 ++++++++++++++----- .../workspace-test-results.service.ts | 33 +++++++++++++---- 2 files changed, 54 insertions(+), 15 deletions(-) diff --git a/apps/backend/src/app/database/services/workspace-coding.service.ts b/apps/backend/src/app/database/services/workspace-coding.service.ts index 3ad409bdd..bc74b4a79 100644 --- a/apps/backend/src/app/database/services/workspace-coding.service.ts +++ b/apps/backend/src/app/database/services/workspace-coding.service.ts @@ -616,40 +616,60 @@ export class WorkspaceCodingService { } } + // Cache for statistics with TTL + private statisticsCache: Map = new Map(); + private readonly CACHE_TTL_MS = 1 * 60 * 1000; // 1 minute cache TTL + async getCodingStatistics(workspace_id: number): Promise { this.logger.log(`Getting coding statistics for workspace ${workspace_id}`); + // Check if we have a valid cached result + const cachedResult = this.statisticsCache.get(workspace_id); + if (cachedResult && (Date.now() - cachedResult.timestamp) < this.CACHE_TTL_MS) { + this.logger.log(`Returning cached statistics for workspace ${workspace_id}`); + return cachedResult.data; + } + const statistics: CodingStatistics = { totalResponses: 0, statusCounts: {} }; try { - const queryBuilder = this.responseRepository.createQueryBuilder('response') + // Optimized query: get total count and status counts in a single query + const statusCountResults = await this.responseRepository.createQueryBuilder('response') .innerJoin('response.unit', 'unit') .innerJoin('unit.booklet', 'booklet') .innerJoin('booklet.person', 'person') .where('response.status = :status', { status: 'VALUE_CHANGED' }) - .andWhere('person.workspace_id = :workspace_id', { workspace_id }); - - statistics.totalResponses = await queryBuilder.getCount(); - - const statusCountResults = await queryBuilder + .andWhere('person.workspace_id = :workspace_id', { workspace_id }) .select('COALESCE(response.codedstatus, null)', 'statusValue') .addSelect('COUNT(response.id)', 'count') .groupBy('COALESCE(response.codedstatus, null)') .getRawMany(); + // Calculate total from the sum of all status counts + let totalResponses = 0; + statusCountResults.forEach(result => { const count = parseInt(result.count, 10); // Ensure count is a valid number - statistics.statusCounts[result.statusValue] = Number.isNaN(count) ? 0 : count; + const validCount = Number.isNaN(count) ? 0 : count; + statistics.statusCounts[result.statusValue] = validCount; + totalResponses += validCount; + }); + + statistics.totalResponses = totalResponses; + + // Cache the result + this.statisticsCache.set(workspace_id, { + data: statistics, + timestamp: Date.now() }); return statistics; } catch (error) { this.logger.error(`Error getting coding statistics: ${error.message}`); - return statistics; } } diff --git a/apps/backend/src/app/database/services/workspace-test-results.service.ts b/apps/backend/src/app/database/services/workspace-test-results.service.ts index 1fa00b194..b00fa6632 100644 --- a/apps/backend/src/app/database/services/workspace-test-results.service.ts +++ b/apps/backend/src/app/database/services/workspace-test-results.service.ts @@ -409,8 +409,20 @@ export class WorkspaceTestResultsService { }; } + private responsesByStatusCache: Map = new Map(); + private readonly RESPONSES_CACHE_TTL_MS = 1 * 60 * 1000; // 1 minute cache TTL + async getResponsesByStatus(workspace_id: number, status: string, options?: { page: number; limit: number }): Promise<[ResponseEntity[], number]> { this.logger.log(`Getting responses with status ${status} for workspace ${workspace_id}`); + + const cacheKey = `${workspace_id}-${status}-${options?.page || 0}-${options?.limit || 0}`; + + const cachedResult = this.responsesByStatusCache.get(cacheKey); + if (cachedResult && (Date.now() - cachedResult.timestamp) < this.RESPONSES_CACHE_TTL_MS) { + this.logger.log(`Returning cached responses for status ${status} (workspace ${workspace_id})`); + return cachedResult.data; + } + try { const queryBuilder = this.responseRepository.createQueryBuilder('response') .leftJoinAndSelect('response.unit', 'unit') @@ -427,6 +439,8 @@ export class WorkspaceTestResultsService { queryBuilder.andWhere('response.codedStatus = :statusParam', { statusParam: status }); } + let result: [ResponseEntity[], number]; + if (options) { const { page, limit } = options; const MAX_LIMIT = 500; @@ -437,15 +451,20 @@ export class WorkspaceTestResultsService { .skip((validPage - 1) * validLimit) .take(validLimit); - const [responses, total] = await queryBuilder.getManyAndCount(); - this.logger.log(`Found ${responses.length} responses with status ${status} (page ${validPage}, limit ${validLimit}, total ${total}) for workspace ${workspace_id}`); - return [responses, total]; + result = await queryBuilder.getManyAndCount(); + this.logger.log(`Found ${result[0].length} responses with status ${status} (page ${validPage}, limit ${validLimit}, total ${result[1]}) for workspace ${workspace_id}`); + } else { + // For non-paginated queries, still use getManyAndCount to avoid multiple queries + result = await queryBuilder.getManyAndCount(); + this.logger.log(`Found ${result[0].length} responses with status ${status} for workspace ${workspace_id}`); } - const responses = await queryBuilder.getMany(); - const total = await queryBuilder.getCount(); - this.logger.log(`Found ${responses.length} responses with status ${status} for workspace ${workspace_id}`); - return [responses, total]; + this.responsesByStatusCache.set(cacheKey, { + data: result, + timestamp: Date.now() + }); + + return result; } catch (error) { this.logger.error(`Error getting responses by status: ${error.message}`); return [[], 0]; From 9fd1be67474e4cbe7b1f61cf504b343446276127 Mon Sep 17 00:00:00 2001 From: jurei733 <67505990+jurei733@users.noreply.github.com> Date: Wed, 9 Jul 2025 15:03:56 +0200 Subject: [PATCH 24/36] Coding test persons in jobs --- .../workspace/workspace-coding.controller.ts | 136 +++- .../src/app/database/services/shared-types.ts | 5 + .../services/workspace-coding.service.ts | 716 +++++++++++++++++- apps/frontend/src/app/coding/coding.routes.ts | 5 + .../coding-management.component.scss | 21 + .../coding-management.component.ts | 68 +- .../test-person-coding-dialog.component.html | 12 + .../test-person-coding-dialog.component.scss | 41 + .../test-person-coding-dialog.component.ts | 22 + .../test-person-coding.component.html | 239 ++++++ .../test-person-coding.component.scss | 283 +++++++ .../test-person-coding.component.ts | 609 +++++++++++++++ .../services/test-person-coding.service.ts | 269 +++++++ 13 files changed, 2369 insertions(+), 57 deletions(-) create mode 100644 apps/frontend/src/app/coding/components/test-person-coding-dialog/test-person-coding-dialog.component.html create mode 100644 apps/frontend/src/app/coding/components/test-person-coding-dialog/test-person-coding-dialog.component.scss create mode 100644 apps/frontend/src/app/coding/components/test-person-coding-dialog/test-person-coding-dialog.component.ts create mode 100644 apps/frontend/src/app/coding/components/test-person-coding/test-person-coding.component.html create mode 100644 apps/frontend/src/app/coding/components/test-person-coding/test-person-coding.component.scss create mode 100644 apps/frontend/src/app/coding/components/test-person-coding/test-person-coding.component.ts create mode 100644 apps/frontend/src/app/coding/services/test-person-coding.service.ts diff --git a/apps/backend/src/app/admin/workspace/workspace-coding.controller.ts b/apps/backend/src/app/admin/workspace/workspace-coding.controller.ts index d5d8d45cc..3bfbd2a16 100644 --- a/apps/backend/src/app/admin/workspace/workspace-coding.controller.ts +++ b/apps/backend/src/app/admin/workspace/workspace-coding.controller.ts @@ -1,6 +1,6 @@ import { Controller, - Get, Query, Res, UseGuards + Get, Param, Query, Res, UseGuards } from '@nestjs/common'; import { ApiOkResponse, @@ -173,4 +173,138 @@ export class WorkspaceCodingController { async getCodingStatistics(@WorkspaceId() workspace_id: number): Promise { return this.workspaceCodingService.getCodingStatistics(workspace_id); } + + @Get(':workspace_id/coding/job/:jobId') + @UseGuards(JwtAuthGuard, WorkspaceGuard) + @ApiTags('coding') + @ApiParam({ name: 'workspace_id', type: Number }) + @ApiParam({ name: 'jobId', type: String, description: 'ID of the background job' }) + @ApiOkResponse({ + description: 'Job status retrieved successfully.', + schema: { + type: 'object', + properties: { + status: { + type: 'string', + enum: ['pending', 'processing', 'completed', 'failed', 'cancelled'], + description: 'Current status of the job' + }, + progress: { + type: 'number', + description: 'Progress percentage (0-100)' + }, + result: { + type: 'object', + description: 'Result of the job (only available when status is completed)', + properties: { + totalResponses: { type: 'number' }, + statusCounts: { + type: 'object', + additionalProperties: { type: 'number' } + } + } + }, + error: { + type: 'string', + description: 'Error message (only available when status is failed)' + } + } + } + }) + async getJobStatus(@Param('jobId') jobId: string): Promise<{ status: string; progress: number; result?: CodingStatistics; error?: string } | { error: string }> { + const status = this.workspaceCodingService.getJobStatus(jobId); + if (!status) { + return { error: `Job with ID ${jobId} not found` }; + } + return status; + } + + @Get(':workspace_id/coding/job/:jobId/cancel') + @UseGuards(JwtAuthGuard, WorkspaceGuard) + @ApiTags('coding') + @ApiParam({ name: 'workspace_id', type: Number }) + @ApiParam({ name: 'jobId', type: String, description: 'ID of the background job to cancel' }) + @ApiOkResponse({ + description: 'Job cancellation request processed.', + schema: { + type: 'object', + properties: { + success: { + type: 'boolean', + description: 'Whether the cancellation request was successful' + }, + message: { + type: 'string', + description: 'Message describing the result of the cancellation request' + } + } + } + }) + async cancelJob(@Param('jobId') jobId: string): Promise<{ success: boolean; message: string }> { + return this.workspaceCodingService.cancelJob(jobId); + } + + @Get(':workspace_id/coding/jobs') + @UseGuards(JwtAuthGuard, WorkspaceGuard) + @ApiTags('coding') + @ApiParam({ name: 'workspace_id', type: Number }) + @ApiOkResponse({ + description: 'List of all jobs retrieved successfully.', + schema: { + type: 'array', + items: { + type: 'object', + properties: { + jobId: { + type: 'string', + description: 'Unique identifier for the job' + }, + status: { + type: 'string', + enum: ['pending', 'processing', 'completed', 'failed', 'cancelled'], + description: 'Current status of the job' + }, + progress: { + type: 'number', + description: 'Progress percentage (0-100)' + }, + result: { + type: 'object', + description: 'Result of the job (only available when status is completed)', + properties: { + totalResponses: { type: 'number' }, + statusCounts: { + type: 'object', + additionalProperties: { type: 'number' } + } + } + }, + error: { + type: 'string', + description: 'Error message (only available when status is failed)' + }, + workspaceId: { + type: 'number', + description: 'ID of the workspace the job belongs to' + }, + createdAt: { + type: 'string', + format: 'date-time', + description: 'Date and time when the job was created' + } + } + } + } + }) + async getAllJobs(@WorkspaceId() workspace_id: number): Promise<{ + jobId: string; + status: 'pending' | 'processing' | 'completed' | 'failed' | 'cancelled'; + progress: number; + result?: CodingStatistics; + error?: string; + workspaceId?: number; + createdAt?: Date; + }[]> { + return this.workspaceCodingService.getAllJobs(workspace_id); + } } diff --git a/apps/backend/src/app/database/services/shared-types.ts b/apps/backend/src/app/database/services/shared-types.ts index d421f5e2b..da7b2e314 100644 --- a/apps/backend/src/app/database/services/shared-types.ts +++ b/apps/backend/src/app/database/services/shared-types.ts @@ -108,3 +108,8 @@ export interface CodingStatistics { [key: string]: number; }; } + +export interface CodingStatisticsWithJob extends CodingStatistics { + jobId?: string; + message?: string; +} diff --git a/apps/backend/src/app/database/services/workspace-coding.service.ts b/apps/backend/src/app/database/services/workspace-coding.service.ts index bc74b4a79..f68ad5965 100644 --- a/apps/backend/src/app/database/services/workspace-coding.service.ts +++ b/apps/backend/src/app/database/services/workspace-coding.service.ts @@ -10,7 +10,7 @@ import Persons from '../entities/persons.entity'; import { Unit } from '../entities/unit.entity'; import { Booklet } from '../entities/booklet.entity'; import { ResponseEntity } from '../entities/response.entity'; -import { CodingStatistics } from './shared-types'; +import { CodingStatistics, CodingStatisticsWithJob } from './shared-types'; import { extractVariableLocation } from '../../utils/voud/extractVariableLocation'; @Injectable() @@ -30,7 +30,694 @@ export class WorkspaceCodingService { private responseRepository: Repository ) {} - async codeTestPersons(workspace_id: number, testPersonIds: string): Promise { + // Cache for coding schemes with TTL + private codingSchemeCache: Map = new Map(); + private readonly SCHEME_CACHE_TTL_MS = 30 * 60 * 1000; // 30 minutes cache TTL + + // Cache for test files with TTL + private testFileCache: Map; timestamp: number }> = new Map(); + private readonly TEST_FILE_CACHE_TTL_MS = 15 * 60 * 1000; // 15 minutes cache TTL + + // Job status tracking + private jobStatus: Map = new Map(); + + /** + * Get all jobs + * @param workspaceId Optional workspace ID to filter jobs + * @returns Array of job status objects with job IDs + */ + getAllJobs(workspaceId?: number): { jobId: string; status: 'pending' | 'processing' | 'completed' | 'failed' | 'cancelled'; progress: number; result?: CodingStatistics; error?: string; workspaceId?: number; createdAt?: Date }[] { + const jobs: { jobId: string; status: 'pending' | 'processing' | 'completed' | 'failed' | 'cancelled'; progress: number; result?: CodingStatistics; error?: string; workspaceId?: number; createdAt?: Date }[] = []; + + this.jobStatus.forEach((status, jobId) => { + // If workspaceId is provided, filter jobs by workspace + if (workspaceId !== undefined && status.workspaceId !== workspaceId) { + return; + } + + jobs.push({ + jobId, + ...status + }); + }); + + // Sort jobs by creation date (newest first) + return jobs.sort((a, b) => { + if (!a.createdAt) return 1; + if (!b.createdAt) return -1; + return b.createdAt.getTime() - a.createdAt.getTime(); + }); + } + + /** + * Process test persons in the background + * @param jobId Unique job ID + * @param workspace_id Workspace ID + * @param personIds Array of person IDs to process + */ + private async processTestPersonsInBackground(jobId: string, workspace_id: number, personIds: string[]): Promise { + // Update job status to processing + this.jobStatus.set(jobId, { + status: 'processing', + progress: 0, + workspaceId: workspace_id, + createdAt: new Date() + }); + + try { + // Clone the implementation of codeTestPersons but with progress tracking + const result = await this.processTestPersonsBatch(workspace_id, personIds, progress => { + // Update job progress + const currentStatus = this.jobStatus.get(jobId); + if (currentStatus) { + // Don't update if job has been cancelled + if (currentStatus.status === 'cancelled') { + return; + } + this.jobStatus.set(jobId, { ...currentStatus, progress }); + } + }, jobId); + + // Check if job was cancelled during processing + const currentStatus = this.jobStatus.get(jobId); + if (currentStatus && currentStatus.status === 'cancelled') { + this.logger.log(`Background job ${jobId} was cancelled`); + return; + } + + // Update job status to completed with result + const currentJob = this.jobStatus.get(jobId); + this.jobStatus.set(jobId, { + status: 'completed', + progress: 100, + result, + workspaceId: currentJob?.workspaceId, + createdAt: currentJob?.createdAt + }); + this.logger.log(`Background job ${jobId} completed successfully`); + } catch (error) { + // Check if job was cancelled during processing + const currentStatus = this.jobStatus.get(jobId); + if (currentStatus && currentStatus.status === 'cancelled') { + this.logger.log(`Background job ${jobId} was cancelled`); + return; + } + + // Update job status to failed with error + const currentJob = this.jobStatus.get(jobId); + this.jobStatus.set(jobId, { + status: 'failed', + progress: 0, + error: error.message, + workspaceId: currentJob?.workspaceId, + createdAt: currentJob?.createdAt + }); + this.logger.error(`Background job ${jobId} failed: ${error.message}`, error.stack); + } + + // Remove job status after 1 hour to prevent memory leaks + setTimeout(() => { + this.jobStatus.delete(jobId); + this.logger.log(`Removed job status for job ${jobId}`); + }, 60 * 60 * 1000); + } + + /** + * Get the status of a background job + * @param jobId Job ID + * @returns Job status or null if job not found + */ + getJobStatus(jobId: string): { status: 'pending' | 'processing' | 'completed' | 'failed' | 'cancelled'; progress: number; result?: CodingStatistics; error?: string } | null { + return this.jobStatus.get(jobId) || null; + } + + /** + * Cancel a running job + * @param jobId Job ID to cancel + * @returns Object with success flag and message + */ + cancelJob(jobId: string): { success: boolean; message: string } { + const job = this.jobStatus.get(jobId); + + if (!job) { + return { success: false, message: `Job with ID ${jobId} not found` }; + } + + // Only pending or processing jobs can be cancelled + if (job.status !== 'pending' && job.status !== 'processing') { + return { + success: false, + message: `Job with ID ${jobId} cannot be cancelled because it is already ${job.status}` + }; + } + + // Update job status to cancelled + this.jobStatus.set(jobId, { ...job, status: 'cancelled' }); + this.logger.log(`Job ${jobId} has been cancelled`); + + return { success: true, message: `Job ${jobId} has been cancelled successfully` }; + } + + /** + * Process a batch of test persons with progress tracking + * @param workspace_id Workspace ID + * @param personIds Array of person IDs to process + * @param progressCallback Callback function to report progress (0-100) + * @param jobId Optional job ID for cancellation checking + * @returns Coding statistics + */ + private async processTestPersonsBatch( + workspace_id: number, + personIds: string[], + progressCallback?: (progress: number) => void, + jobId?: string + ): Promise { + const startTime = Date.now(); + const metrics: { [key: string]: number } = {}; + + // Initialize statistics + const statistics: CodingStatistics = { + totalResponses: 0, + statusCounts: {} + }; + + // Report initial progress + if (progressCallback) { + progressCallback(0); + } + + // Check for cancellation before starting work + if (jobId) { + const jobStatus = this.jobStatus.get(jobId); + if (jobStatus && jobStatus.status === 'cancelled') { + this.logger.log(`Job ${jobId} was cancelled before processing started`); + return statistics; + } + } + + const queryRunner = this.responseRepository.manager.connection.createQueryRunner(); + await queryRunner.connect(); + await queryRunner.startTransaction('READ COMMITTED'); + + try { + // Step 1: Get persons - 10% progress + const personsQueryStart = Date.now(); + const persons = await this.personsRepository.find({ + where: { workspace_id, id: In(personIds) }, + select: ['id', 'group', 'login', 'code', 'uploaded_at'] + }); + metrics.personsQuery = Date.now() - personsQueryStart; + + if (!persons || persons.length === 0) { + this.logger.warn('Keine Personen gefunden mit den angegebenen IDs.'); + await queryRunner.release(); + return statistics; + } + + // Report progress after step 1 + if (progressCallback) { + progressCallback(10); + } + + // Check for cancellation after step 1 + if (jobId) { + const jobStatus = this.jobStatus.get(jobId); + if (jobStatus && jobStatus.status === 'cancelled') { + this.logger.log(`Job ${jobId} was cancelled after getting persons`); + await queryRunner.release(); + return statistics; + } + } + + // Step 2: Get booklets - 20% progress + const personIdsArray = persons.map(person => person.id); + const bookletQueryStart = Date.now(); + const booklets = await this.bookletRepository.find({ + where: { personid: In(personIdsArray) }, + select: ['id', 'personid'] // Only select needed fields + }); + metrics.bookletQuery = Date.now() - bookletQueryStart; + + if (!booklets || booklets.length === 0) { + this.logger.log('Keine Booklets für die angegebenen Personen gefunden.'); + await queryRunner.release(); + return statistics; + } + + // Report progress after step 2 + if (progressCallback) { + progressCallback(20); + } + + // Check for cancellation after step 2 + if (jobId) { + const jobStatus = this.jobStatus.get(jobId); + if (jobStatus && jobStatus.status === 'cancelled') { + this.logger.log(`Job ${jobId} was cancelled after getting booklets`); + await queryRunner.release(); + return statistics; + } + } + + // Step 3: Get units - 30% progress + const bookletIds = booklets.map(booklet => booklet.id); + const unitQueryStart = Date.now(); + const units = await this.unitRepository.find({ + where: { bookletid: In(bookletIds) }, + select: ['id', 'bookletid', 'name', 'alias'] // Only select needed fields + }); + metrics.unitQuery = Date.now() - unitQueryStart; + + if (!units || units.length === 0) { + this.logger.log('Keine Einheiten für die angegebenen Booklets gefunden.'); + await queryRunner.release(); + return statistics; + } + + // Report progress after step 3 + if (progressCallback) { + progressCallback(30); + } + + // Check for cancellation after step 3 + if (jobId) { + const jobStatus = this.jobStatus.get(jobId); + if (jobStatus && jobStatus.status === 'cancelled') { + this.logger.log(`Job ${jobId} was cancelled after getting units`); + await queryRunner.release(); + return statistics; + } + } + + // Step 4: Process units and build maps - 40% progress + const bookletToUnitsMap = new Map(); + const unitIds = new Set(); + const unitAliasesSet = new Set(); + + for (const unit of units) { + if (!bookletToUnitsMap.has(unit.bookletid)) { + bookletToUnitsMap.set(unit.bookletid, []); + } + bookletToUnitsMap.get(unit.bookletid).push(unit); + unitIds.add(unit.id); + unitAliasesSet.add(unit.alias.toUpperCase()); + } + + const unitIdsArray = Array.from(unitIds); + const unitAliasesArray = Array.from(unitAliasesSet); + + // Report progress after step 4 + if (progressCallback) { + progressCallback(40); + } + + // Check for cancellation after step 4 + if (jobId) { + const jobStatus = this.jobStatus.get(jobId); + if (jobStatus && jobStatus.status === 'cancelled') { + this.logger.log(`Job ${jobId} was cancelled after processing units`); + await queryRunner.release(); + return statistics; + } + } + + // Step 5: Get responses - 50% progress + const responseQueryStart = Date.now(); + const allResponses = await this.responseRepository.find({ + where: { unitid: In(unitIdsArray), status: In(['VALUE_CHANGED']) }, + select: ['id', 'unitid', 'variableid', 'value', 'status'] // Only select needed fields + }); + metrics.responseQuery = Date.now() - responseQueryStart; + + if (!allResponses || allResponses.length === 0) { + this.logger.log('Keine zu kodierenden Antworten gefunden.'); + await queryRunner.release(); + return statistics; + } + + // Report progress after step 5 + if (progressCallback) { + progressCallback(50); + } + + // Check for cancellation after step 5 + if (jobId) { + const jobStatus = this.jobStatus.get(jobId); + if (jobStatus && jobStatus.status === 'cancelled') { + this.logger.log(`Job ${jobId} was cancelled after getting responses`); + await queryRunner.release(); + return statistics; + } + } + + // Step 6: Process responses and build maps - 60% progress + const unitToResponsesMap = new Map(); + for (const response of allResponses) { + if (!unitToResponsesMap.has(response.unitid)) { + unitToResponsesMap.set(response.unitid, []); + } + unitToResponsesMap.get(response.unitid).push(response); + } + + // Report progress after step 6 + if (progressCallback) { + progressCallback(60); + } + + // Check for cancellation after step 6 + if (jobId) { + const jobStatus = this.jobStatus.get(jobId); + if (jobStatus && jobStatus.status === 'cancelled') { + this.logger.log(`Job ${jobId} was cancelled after processing responses`); + await queryRunner.release(); + return statistics; + } + } + + // Step 7: Get test files - 70% progress + const fileQueryStart = Date.now(); + const testFiles = await this.fileUploadRepository.find({ + where: { workspace_id: workspace_id, file_id: In(unitAliasesArray) }, + select: ['file_id', 'data', 'filename'] // Only select needed fields + }); + metrics.fileQuery = Date.now() - fileQueryStart; + + const fileIdToTestFileMap = new Map(); + testFiles.forEach(file => { + fileIdToTestFileMap.set(file.file_id, file); + }); + + // Report progress after step 7 + if (progressCallback) { + progressCallback(70); + } + + // Check for cancellation after step 7 + if (jobId) { + const jobStatus = this.jobStatus.get(jobId); + if (jobStatus && jobStatus.status === 'cancelled') { + this.logger.log(`Job ${jobId} was cancelled after getting test files`); + await queryRunner.release(); + return statistics; + } + } + + // Step 8: Extract coding scheme references - 80% progress + const schemeExtractStart = Date.now(); + const codingSchemeRefs = new Set(); + const unitToCodingSchemeRefMap = new Map(); + const batchSize = 50; + for (let i = 0; i < units.length; i += batchSize) { + const unitBatch = units.slice(i, i + batchSize); + + for (const unit of unitBatch) { + const testFile = fileIdToTestFileMap.get(unit.alias.toUpperCase()); + if (!testFile) continue; + + try { + const $ = cheerio.load(testFile.data); + const codingSchemeRefText = $('codingSchemeRef').text(); + if (codingSchemeRefText) { + codingSchemeRefs.add(codingSchemeRefText.toUpperCase()); + unitToCodingSchemeRefMap.set(unit.id, codingSchemeRefText.toUpperCase()); + } + } catch (error) { + this.logger.error(`--- Fehler beim Verarbeiten der Datei ${testFile.filename}: ${error.message}`); + } + } + + // Check for cancellation during scheme extraction + if (jobId) { + const jobStatus = this.jobStatus.get(jobId); + if (jobStatus && jobStatus.status === 'cancelled') { + this.logger.log(`Job ${jobId} was cancelled during scheme extraction`); + await queryRunner.release(); + return statistics; + } + } + } + metrics.schemeExtract = Date.now() - schemeExtractStart; + + // Report progress after step 8 + if (progressCallback) { + progressCallback(80); + } + + // Check for cancellation after step 8 + if (jobId) { + const jobStatus = this.jobStatus.get(jobId); + if (jobStatus && jobStatus.status === 'cancelled') { + this.logger.log(`Job ${jobId} was cancelled after extracting scheme references`); + await queryRunner.release(); + return statistics; + } + } + + // Step 9: Get coding scheme files - 85% progress + const schemeQueryStart = Date.now(); + const codingSchemeFiles = await this.fileUploadRepository.find({ + where: { file_id: In([...codingSchemeRefs]) }, + select: ['file_id', 'data', 'filename'] + }); + metrics.schemeQuery = Date.now() - schemeQueryStart; + + // Report progress after step 9 + if (progressCallback) { + progressCallback(85); + } + + // Check for cancellation after step 9 + if (jobId) { + const jobStatus = this.jobStatus.get(jobId); + if (jobStatus && jobStatus.status === 'cancelled') { + this.logger.log(`Job ${jobId} was cancelled after getting coding scheme files`); + await queryRunner.release(); + return statistics; + } + } + + // Step 10: Parse coding schemes - 90% progress + const schemeParsing = Date.now(); + const fileIdToCodingSchemeMap = new Map(); + const emptyScheme = new Autocoder.CodingScheme({}); + + codingSchemeFiles.forEach(file => { + try { + const data = typeof file.data === 'string' ? JSON.parse(file.data) : file.data; + const scheme = new Autocoder.CodingScheme(data); + fileIdToCodingSchemeMap.set(file.file_id, scheme); + } catch (error) { + this.logger.error(`--- Fehler beim Verarbeiten des Kodierschemas ${file.filename}: ${error.message}`); + } + }); + metrics.schemeParsing = Date.now() - schemeParsing; + + // Report progress after step 10 + if (progressCallback) { + progressCallback(90); + } + + // Check for cancellation after step 10 + if (jobId) { + const jobStatus = this.jobStatus.get(jobId); + if (jobStatus && jobStatus.status === 'cancelled') { + this.logger.log(`Job ${jobId} was cancelled after parsing coding schemes`); + await queryRunner.release(); + return statistics; + } + } + + // Step 11: Process and code responses - 95% progress + const processingStart = Date.now(); + + const allCodedResponses = []; + const estimatedResponseCount = allResponses.length; + allCodedResponses.length = estimatedResponseCount; + let responseIndex = 0; + + for (let i = 0; i < units.length; i += batchSize) { + const unitBatch = units.slice(i, i + batchSize); + + for (const unit of unitBatch) { + const responses = unitToResponsesMap.get(unit.id) || []; + if (responses.length === 0) continue; + + statistics.totalResponses += responses.length; + + const codingSchemeRef = unitToCodingSchemeRefMap.get(unit.id); + const scheme = codingSchemeRef ? + (fileIdToCodingSchemeMap.get(codingSchemeRef) || emptyScheme) : + emptyScheme; + + for (const response of responses) { + const codedResult = scheme.code([{ + id: response.variableid, + value: response.value, + status: response.status as ResponseStatusType + }]); + + const codedStatus = codedResult[0]?.status; + if (!statistics.statusCounts[codedStatus]) { + statistics.statusCounts[codedStatus] = 0; + } + statistics.statusCounts[codedStatus] += 1; + + allCodedResponses[responseIndex] = { + id: response.id, + code: codedResult[0]?.code, + codedstatus: codedStatus, + score: codedResult[0]?.score + }; + responseIndex += 1; + } + } + + // Check for cancellation during response processing + if (jobId) { + const jobStatus = this.jobStatus.get(jobId); + if (jobStatus && jobStatus.status === 'cancelled') { + this.logger.log(`Job ${jobId} was cancelled during response processing`); + await queryRunner.release(); + return statistics; + } + } + } + + allCodedResponses.length = responseIndex; + metrics.processing = Date.now() - processingStart; + + // Report progress after step 11 + if (progressCallback) { + progressCallback(95); + } + + // Check for cancellation after step 11 + if (jobId) { + const jobStatus = this.jobStatus.get(jobId); + if (jobStatus && jobStatus.status === 'cancelled') { + this.logger.log(`Job ${jobId} was cancelled after processing responses`); + await queryRunner.release(); + return statistics; + } + } + + // Step 12: Update responses in database - 100% progress + if (allCodedResponses.length > 0) { + const updateStart = Date.now(); + try { + const updateBatchSize = 500; + const batches = []; + for (let i = 0; i < allCodedResponses.length; i += updateBatchSize) { + batches.push(allCodedResponses.slice(i, i + updateBatchSize)); + } + + this.logger.log(`Starte die Aktualisierung von ${allCodedResponses.length} Responses in ${batches.length} Batches (sequential).`); + + for (let index = 0; index < batches.length; index++) { + const batch = batches[index]; + this.logger.log(`Starte Aktualisierung für Batch #${index + 1} (Größe: ${batch.length}).`); + + // Check for cancellation before updating batch + if (jobId) { + const jobStatus = this.jobStatus.get(jobId); + if (jobStatus && jobStatus.status === 'cancelled') { + this.logger.log(`Job ${jobId} was cancelled before updating batch #${index + 1}`); + await queryRunner.rollbackTransaction(); + await queryRunner.release(); + return statistics; + } + } + + try { + if (batch.length > 0) { + const updatePromises = batch.map(response => queryRunner.manager.update( + ResponseEntity, + response.id, + { + code: response.code, + codedstatus: response.codedstatus, + score: response.score + } + )); + + await Promise.all(updatePromises); + } + + this.logger.log(`Batch #${index + 1} (Größe: ${batch.length}) erfolgreich aktualisiert.`); + + // Update progress during batch updates + if (progressCallback) { + const batchProgress = 95 + (5 * ((index + 1) / batches.length)); + progressCallback(Math.min(batchProgress, 99)); // Cap at 99% until fully complete + } + } catch (error) { + this.logger.error(`Fehler beim Aktualisieren von Batch #${index + 1} (Größe: ${batch.length}):`, error.message); + // Rollback transaction on error + await queryRunner.rollbackTransaction(); + await queryRunner.release(); + throw error; + } + } + + // Commit transaction if all updates were successful + await queryRunner.commitTransaction(); + this.logger.log(`${allCodedResponses.length} Responses wurden erfolgreich aktualisiert.`); + } catch (error) { + this.logger.error('Fehler beim Aktualisieren der Responses:', error.message); + // Ensure transaction is rolled back on error + try { + await queryRunner.rollbackTransaction(); + } catch (rollbackError) { + this.logger.error('Fehler beim Rollback der Transaktion:', rollbackError.message); + } + } finally { + // Always release the query runner + await queryRunner.release(); + } + metrics.update = Date.now() - updateStart; + } else { + // Release query runner if no updates were performed + await queryRunner.release(); + } + + // Report completion + if (progressCallback) { + progressCallback(100); + } + + // Log performance metrics + const totalTime = Date.now() - startTime; + this.logger.log(`Performance metrics for processTestPersonsBatch (total: ${totalTime}ms): + - Persons query: ${metrics.personsQuery}ms + - Booklet query: ${metrics.bookletQuery}ms + - Unit query: ${metrics.unitQuery}ms + - Response query: ${metrics.responseQuery}ms + - File query: ${metrics.fileQuery}ms + - Scheme extraction: ${metrics.schemeExtract}ms + - Scheme query: ${metrics.schemeQuery}ms + - Scheme parsing: ${metrics.schemeParsing}ms + - Response processing: ${metrics.processing}ms + - Database updates: ${metrics.update || 0}ms`); + + return statistics; + } catch (error) { + this.logger.error('Fehler beim Verarbeiten der Personen:', error); + + // Ensure transaction is rolled back on error + try { + await queryRunner.rollbackTransaction(); + } catch (rollbackError) { + this.logger.error('Fehler beim Rollback der Transaktion:', rollbackError.message); + } finally { + // Always release the query runner + await queryRunner.release(); + } + + return statistics; + } + } + + async codeTestPersons(workspace_id: number, testPersonIds: string): Promise { const startTime = Date.now(); const metrics: { [key: string]: number } = {}; @@ -45,6 +732,31 @@ export class WorkspaceCodingService { return { totalResponses: 0, statusCounts: {} }; } + // If there are more than 100 test persons, process in the background + if (ids.length > 100) { + const jobId = `${workspace_id}-${Date.now()}`; + this.logger.log(`Starting background job ${jobId} for ${ids.length} test persons in workspace ${workspace_id}`); + + // Set initial job status + this.jobStatus.set(jobId, { + status: 'pending', + progress: 0, + workspaceId: workspace_id, + createdAt: new Date() + }); + + // Process in the background + this.processTestPersonsInBackground(jobId, workspace_id, ids); + + // Return a response indicating the job has been scheduled + return { + totalResponses: 0, + statusCounts: {}, + jobId, + message: `Processing ${ids.length} test persons in the background. Check job status with jobId: ${jobId}` + }; + } + this.logger.log(`Verarbeite Personen ${testPersonIds} für Workspace ${workspace_id}`); const statistics: CodingStatistics = { diff --git a/apps/frontend/src/app/coding/coding.routes.ts b/apps/frontend/src/app/coding/coding.routes.ts index 46e3420bf..bd4f51578 100644 --- a/apps/frontend/src/app/coding/coding.routes.ts +++ b/apps/frontend/src/app/coding/coding.routes.ts @@ -6,5 +6,10 @@ export const codingRoutes: Routes = [ path: 'coding-manual', canActivate: [canActivateAuth], loadComponent: () => import('./components/coding-management-manual/coding-management-manual.component').then(m => m.CodingManagementManualComponent) + }, + { + path: 'test-person-coding/:workspace_id', + canActivate: [canActivateAuth], + loadComponent: () => import('./components/test-person-coding/test-person-coding.component').then(m => m.TestPersonCodingComponent) } ]; diff --git a/apps/frontend/src/app/coding/components/coding-managment/coding-management.component.scss b/apps/frontend/src/app/coding/components/coding-managment/coding-management.component.scss index 7ca61668e..e89fa5955 100755 --- a/apps/frontend/src/app/coding/components/coding-managment/coding-management.component.scss +++ b/apps/frontend/src/app/coding/components/coding-managment/coding-management.component.scss @@ -715,3 +715,24 @@ color: #283593; /* Dunklere Farbe beim Hover */ } } + +// Dialog styles for test person coding +::ng-deep .full-screen-dialog { + .mat-mdc-dialog-container { + padding: 0 !important; + } + + .mat-mdc-dialog-surface { + border-radius: 12px; + overflow: hidden !important; + } + + .mdc-dialog__container { + height: 100%; + } + + .mdc-dialog__surface { + max-width: 100vw !important; + max-height: 100vh !important; + } +} diff --git a/apps/frontend/src/app/coding/components/coding-managment/coding-management.component.ts b/apps/frontend/src/app/coding/components/coding-managment/coding-management.component.ts index a9dee05e6..77c66b536 100755 --- a/apps/frontend/src/app/coding/components/coding-managment/coding-management.component.ts +++ b/apps/frontend/src/app/coding/components/coding-managment/coding-management.component.ts @@ -37,6 +37,7 @@ import { CodingStatistics } from '../../../../../../../api-dto/coding/coding-sta import { ExportDialogComponent, ExportFormat } from '../export-dialog/export-dialog.component'; import { Success } from '../../models/success.model'; import { CodingListItem } from '../../models/coding-list-item.model'; +import { TestPersonCodingDialogComponent } from '../test-person-coding-dialog/test-person-coding-dialog.component'; @Component({ selector: 'app-coding-management', @@ -305,61 +306,20 @@ export class CodingManagementComponent implements AfterViewInit, OnInit, OnDestr } onAutoCode(): void { - const workspaceId = this.appService.selectedWorkspaceId; - this.isAutoCoding = true; - - this.backendService.getTestPersons(workspaceId) - .pipe( - catchError(() => { - this.isAutoCoding = false; - this.snackBar.open('Fehler beim Abrufen der Testgruppen', 'Schließen', { - duration: 5000, - panelClass: ['error-snackbar'] - }); - return of([]); - }) - ) - .subscribe(testPersons => { - if (testPersons.length === 0) { - this.isAutoCoding = false; - return; - } - - this.backendService.codeTestPersons(workspaceId, testPersons) - .pipe( - catchError(() => { - this.isAutoCoding = false; - this.snackBar.open('Fehler beim Kodieren der Testpersonen', 'Schließen', { - duration: 5000, - panelClass: ['error-snackbar'] - }); - return of({ - totalResponses: 0, - statusCounts: {} - }); - }), - finalize(async () => { - this.isAutoCoding = false; - this.fetchCodingStatistics(); - }) - ) - .subscribe(stats => { - // Create a report message - let reportMessage = `Insgesamt wurden ${stats.totalResponses} Antworten verarbeitet.\n\n`; - reportMessage += 'Verteilung der Kodier-Status:\n'; - for (const status in stats.statusCounts) { - if (Object.prototype.hasOwnProperty.call(stats.statusCounts, status)) { - // @ts-expect-error - Index access on statusCounts object - reportMessage += `${status}: ${stats.statusCounts[status]}\n`; - } - } + // Open the test person coding dialog + const dialogRef = this.dialog.open(TestPersonCodingDialogComponent, { + width: '90vw', + height: '90vh', + maxWidth: '100vw', + maxHeight: '100vh', + panelClass: 'full-screen-dialog' + }); - this.snackBar.open(reportMessage, 'Schließen', { - duration: 10000, - panelClass: ['success-snackbar'] - }); - }); - }); + // Handle dialog close event if needed + dialogRef.afterClosed().subscribe(() => { + // Refresh statistics after dialog is closed + this.fetchCodingStatistics(); + }); } fetchCodingList(page: number = 1, limit: number = this.pageSize): void { diff --git a/apps/frontend/src/app/coding/components/test-person-coding-dialog/test-person-coding-dialog.component.html b/apps/frontend/src/app/coding/components/test-person-coding-dialog/test-person-coding-dialog.component.html new file mode 100644 index 000000000..8b34bc318 --- /dev/null +++ b/apps/frontend/src/app/coding/components/test-person-coding-dialog/test-person-coding-dialog.component.html @@ -0,0 +1,12 @@ +
+
+

Test Person Coding

+ +
+ +
+ +
+
diff --git a/apps/frontend/src/app/coding/components/test-person-coding-dialog/test-person-coding-dialog.component.scss b/apps/frontend/src/app/coding/components/test-person-coding-dialog/test-person-coding-dialog.component.scss new file mode 100644 index 000000000..49a95ce38 --- /dev/null +++ b/apps/frontend/src/app/coding/components/test-person-coding-dialog/test-person-coding-dialog.component.scss @@ -0,0 +1,41 @@ +.dialog-container { + display: flex; + flex-direction: column; + height: 100%; + width: 100%; + max-width: 1200px; + max-height: 90vh; + overflow: hidden; +} + +.dialog-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 16px 24px; + border-bottom: 1px solid rgba(0, 0, 0, 0.12); +} + +.dialog-title { + margin: 0; + font-size: 20px; + font-weight: 500; +} + +.close-button { + margin-left: 16px; +} + +.dialog-content { + flex: 1; + overflow: auto; + padding: 0; +} + +::ng-deep .mat-mdc-dialog-container { + padding: 0 !important; +} + +::ng-deep .mat-mdc-dialog-surface { + overflow: hidden !important; +} diff --git a/apps/frontend/src/app/coding/components/test-person-coding-dialog/test-person-coding-dialog.component.ts b/apps/frontend/src/app/coding/components/test-person-coding-dialog/test-person-coding-dialog.component.ts new file mode 100644 index 000000000..720c1e20d --- /dev/null +++ b/apps/frontend/src/app/coding/components/test-person-coding-dialog/test-person-coding-dialog.component.ts @@ -0,0 +1,22 @@ +import { Component } from '@angular/core'; +import { MatDialogRef } from '@angular/material/dialog'; +import { MatIconModule } from '@angular/material/icon'; +import { MatButtonModule } from '@angular/material/button'; +import { TestPersonCodingComponent } from '../test-person-coding/test-person-coding.component'; + +@Component({ + selector: 'coding-box-test-person-coding-dialog', + templateUrl: './test-person-coding-dialog.component.html', + styleUrls: ['./test-person-coding-dialog.component.scss'], + standalone: true, + imports: [TestPersonCodingComponent, MatIconModule, MatButtonModule] +}) +export class TestPersonCodingDialogComponent { + constructor( + public dialogRef: MatDialogRef + ) {} + + closeDialog(): void { + this.dialogRef.close(); + } +} diff --git a/apps/frontend/src/app/coding/components/test-person-coding/test-person-coding.component.html b/apps/frontend/src/app/coding/components/test-person-coding/test-person-coding.component.html new file mode 100644 index 000000000..4cd4cb964 --- /dev/null +++ b/apps/frontend/src/app/coding/components/test-person-coding/test-person-coding.component.html @@ -0,0 +1,239 @@ +
+
+

Test Person Coding

+

Manage and process test person coding for workspace.

+
+ + + + + Coding Statistics + + +
+
+ Total Responses: + {{ statistics.totalResponses }} +
+
+

Status Counts:

+
+
+ {{ status.key || 'Unknown' }}: + {{ status.value }} +
+
+
+
+
+
+ + + + + Code Test Persons + + +
+ + Test Person IDs (comma-separated) + + Enter comma-separated list of test person IDs to code + + +
+ + Group Size + + Number of test persons per job + + +
+ Run jobs sequentially +
When enabled, jobs will run one after another
+
+
+ +
+ + + +
+
+
+
+ + + + + Running Jobs + + +
+ +
+ +
+ + Loading jobs... +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Job ID{{ job.jobId }}Status + {{ job.status | titlecase }} + Progress +
+ {{ job.progress }}% + +
+
Created{{ job.createdAt | date:'medium' }}Actions +
+ + + + + + + + + + + + + + + + + + person + {{ job.testPersonId }} + +
+
+
+ + +
+ work_off +

No jobs found.

+
+
+
+
+ + + + + Sequential Processing Status + Processing chunks sequentially + + +
+
+ Current Chunk: + {{ currentJobIndex + 1 }} of {{ totalJobs }} +
+
+ Progress: + {{ Math.round((currentJobIndex / totalJobs) * 100) }}% +
+ +
+ Chunk Size: + {{ processingQueue[currentJobIndex]?.length || 0 }} test persons +
+
+
+
+ + + + + Current Job Status + Job ID: {{ activeJobId }} + + +
+
+ Status: + {{ jobStatus.status | titlecase }} +
+
+ Progress: + {{ jobStatus.progress }}% +
+ +
+ Error: + {{ jobStatus.error }} +
+
+ +
+
+
+
+
diff --git a/apps/frontend/src/app/coding/components/test-person-coding/test-person-coding.component.scss b/apps/frontend/src/app/coding/components/test-person-coding/test-person-coding.component.scss new file mode 100644 index 000000000..21db7708e --- /dev/null +++ b/apps/frontend/src/app/coding/components/test-person-coding/test-person-coding.component.scss @@ -0,0 +1,283 @@ +.test-person-coding-container { + padding: 20px; + max-width: 1200px; + margin: 0 auto; +} + +.header-section { + margin-bottom: 20px; + + .page-title { + font-size: 24px; + margin-bottom: 8px; + } + + .page-description { + color: rgba(0, 0, 0, 0.6); + font-size: 16px; + } +} + +mat-card { + margin-bottom: 20px; +} + +.statistics-content { + display: flex; + flex-direction: column; + gap: 16px; + + .statistic-item { + display: flex; + align-items: center; + gap: 8px; + + .statistic-label { + font-weight: 500; + } + + .statistic-value { + font-size: 18px; + font-weight: bold; + } + } + + .status-counts { + h3 { + margin-bottom: 8px; + } + + .status-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + gap: 8px; + + .status-item { + display: flex; + justify-content: space-between; + padding: 8px; + background-color: rgba(0, 0, 0, 0.04); + border-radius: 4px; + + .status-label { + font-weight: 500; + } + + .status-value { + font-weight: bold; + } + } + } + } +} + +.form-container { + display: flex; + flex-direction: column; + gap: 16px; + + .full-width { + width: 100%; + } + + .settings-container { + display: flex; + flex-wrap: wrap; + gap: 20px; + align-items: flex-start; + margin-bottom: 8px; + + .group-size-field { + width: 150px; + } + + .sequential-checkbox { + display: flex; + flex-direction: column; + margin-top: 8px; + + .hint-text { + font-size: 12px; + color: rgba(0, 0, 0, 0.6); + margin-top: 4px; + } + } + } + + .button-container { + display: flex; + justify-content: flex-end; + gap: 10px; + } +} + +.job-status-content { + display: flex; + flex-direction: column; + gap: 16px; + + .status-row { + display: flex; + align-items: center; + gap: 8px; + + .status-label { + font-weight: 500; + min-width: 80px; + } + + .status-value { + &.status-completed { + color: green; + } + + &.status-failed { + color: red; + } + + &.status-cancelled { + color: orange; + } + + &.status-paused { + color: purple; + } + + &.error { + color: red; + } + } + } + + .button-container { + display: flex; + justify-content: flex-end; + margin-top: 8px; + } +} + +.sequential-status-card { + background-color: #f0f7ff; + border-left: 4px solid #2196F3; + + mat-card-title { + color: #2196F3; + } + + .status-value { + font-weight: bold; + color: #2196F3; + } + + mat-progress-bar { + height: 8px; + border-radius: 4px; + overflow: hidden; + } +} + +.coding-list-actions, .jobs-actions { + display: flex; + gap: 8px; + margin-bottom: 16px; +} + +.loading-indicator { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 20px; + gap: 8px; +} + +.table-container { + overflow-x: auto; + + table { + width: 100%; + } + + mat-paginator { + margin-top: 16px; + } +} + +.no-data { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 40px; + color: rgba(0, 0, 0, 0.6); + + mat-icon { + font-size: 48px; + height: 48px; + width: 48px; + margin-bottom: 16px; + } +} + +.progress-container { + display: flex; + flex-direction: column; + gap: 4px; + width: 100%; + max-width: 150px; + + .progress-value { + font-weight: 500; + font-size: 14px; + } +} + +.job-actions { + display: flex; + align-items: center; + gap: 4px; + flex-wrap: wrap; +} + +.test-person-indicator { + display: flex; + align-items: center; + gap: 4px; + font-size: 12px; + color: rgba(0, 0, 0, 0.6); + margin-left: 8px; + padding: 2px 6px; + background-color: rgba(0, 0, 0, 0.05); + border-radius: 12px; + + mat-icon { + font-size: 14px; + height: 14px; + width: 14px; + line-height: 14px; + } +} + +.status-processing { + color: #2196F3; // Blue +} + +.status-pending { + color: #FF9800; // Orange +} + +.status-completed { + color: #4CAF50; // Green +} + +.status-failed { + color: #F44336; // Red +} + +.status-cancelled { + color: #9E9E9E; // Grey +} + +.status-paused { + color: #9C27B0; // Purple +} diff --git a/apps/frontend/src/app/coding/components/test-person-coding/test-person-coding.component.ts b/apps/frontend/src/app/coding/components/test-person-coding/test-person-coding.component.ts new file mode 100644 index 000000000..33a662c28 --- /dev/null +++ b/apps/frontend/src/app/coding/components/test-person-coding/test-person-coding.component.ts @@ -0,0 +1,609 @@ +import { Component, OnInit, inject } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule, ReactiveFormsModule } from '@angular/forms'; +import { MatButtonModule } from '@angular/material/button'; +import { MatCardModule } from '@angular/material/card'; +import { MatCheckboxModule } from '@angular/material/checkbox'; +import { MatChipsModule } from '@angular/material/chips'; +import { MatDialogModule } from '@angular/material/dialog'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatIconModule } from '@angular/material/icon'; +import { MatInputModule } from '@angular/material/input'; +import { MatPaginatorModule, PageEvent } from '@angular/material/paginator'; +import { MatProgressBarModule } from '@angular/material/progress-bar'; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; +import { MatSelectModule } from '@angular/material/select'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { MatTableModule } from '@angular/material/table'; +import { MatTabsModule } from '@angular/material/tabs'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import { TranslateModule } from '@ngx-translate/core'; +import { + BehaviorSubject, + Observable, + catchError, + finalize, + of, + tap +} from 'rxjs'; +import { + CodingStatistics, JobInfo, + JobStatus, + PaginatedCodingList, + TestPersonCodingService +} from '../../services/test-person-coding.service'; +import { AppService } from '../../../services/app.service'; +import { BackendService } from '../../../services/backend.service'; + +@Component({ + selector: 'coding-box-test-person-coding', + templateUrl: './test-person-coding.component.html', + styleUrls: ['./test-person-coding.component.scss'], + standalone: true, + imports: [ + CommonModule, + FormsModule, + ReactiveFormsModule, + MatButtonModule, + MatCardModule, + MatCheckboxModule, + MatChipsModule, + MatDialogModule, + MatFormFieldModule, + MatIconModule, + MatInputModule, + MatPaginatorModule, + MatProgressBarModule, + MatProgressSpinnerModule, + MatSelectModule, + MatTableModule, + MatTabsModule, + MatTooltipModule, + TranslateModule + ] +}) +export class TestPersonCodingComponent implements OnInit { + private testPersonCodingService = inject(TestPersonCodingService); + private snackBar = inject(MatSnackBar); + private appService = inject(AppService); + private backendService = inject(BackendService); + + // Make Math available to the template + Math = Math; + + // Configurable group size for batch processing + groupSize = 5; + + // Flag to track if jobs should run sequentially + runSequentially = true; + + // Track the current job being processed in sequential mode + currentJobIndex = 0; + totalJobs = 0; + processingQueue: number[][] = []; + + // Workspace ID from app service + get workspaceId(): number { + return this.appService.selectedWorkspaceId; + } + + // Coding statistics + statistics$: Observable | null = null; + + // Coding list + codingList$ = new BehaviorSubject({ + data: [], + total: 0, + page: 1, + limit: 20 + }); + + displayedColumns: string[] = ['unit_key', 'unit_alias', 'login_name', 'booklet_id', 'variable_id', 'actions']; + + isLoading = false; + + // Pagination + currentPage = 1; + pageSize = 20; + + // Job status + activeJobId: string | null = null; + jobStatus: JobStatus | null = null; + jobStatusInterval: number | null = null; + + // All jobs + allJobs: JobInfo[] = []; + jobsLoading = false; + jobsRefreshInterval: number | null = null; + + ngOnInit(): void { + // Load data using workspace ID from app service + this.loadStatistics(); + this.loadCodingList(); + this.loadAllJobs(); + this.startJobsRefreshInterval(); + } + + ngOnDestroy(): void { + this.stopJobStatusPolling(); + this.stopJobsRefreshInterval(); + } + + /** + * Load all jobs for the current workspace + */ + loadAllJobs(): void { + this.jobsLoading = true; + this.testPersonCodingService.getAllJobs(this.workspaceId) + .pipe( + tap(jobs => { + this.allJobs = jobs; + + // If we have an active job, update its status from the list + if (this.activeJobId) { + const activeJob = jobs.find(job => job.jobId === this.activeJobId); + if (activeJob) { + this.jobStatus = activeJob; + + // If job is completed, failed, or cancelled, stop polling + if (['completed', 'failed', 'cancelled'].includes(activeJob.status)) { + this.stopJobStatusPolling(); + + if (activeJob.status === 'completed') { + this.loadStatistics(); + this.loadCodingList(this.currentPage, this.pageSize); + } + } + } + } + }), + finalize(() => { + this.jobsLoading = false; + }) + ) + .subscribe(); + } + + /** + * Start automatic refresh of jobs list + */ + startJobsRefreshInterval(): void { + // Clear any existing interval + this.stopJobsRefreshInterval(); + + // Refresh jobs list every 5 seconds + this.jobsRefreshInterval = window.setInterval(() => { + this.loadAllJobs(); + }, 5000); + } + + /** + * Stop automatic refresh of jobs list + */ + stopJobsRefreshInterval(): void { + if (this.jobsRefreshInterval) { + clearInterval(this.jobsRefreshInterval); + this.jobsRefreshInterval = null; + } + } + + loadStatistics(): void { + this.statistics$ = this.testPersonCodingService.getCodingStatistics(this.workspaceId); + } + + loadCodingList(page = 1, limit = 20): void { + this.isLoading = true; + this.currentPage = page; + this.pageSize = limit; + + // Get current auth token + const authToken = localStorage.getItem('id_token') || ''; + // Get server URL for generating links + const serverUrl = window.location.origin; + + this.testPersonCodingService.getCodingList(this.workspaceId, authToken, serverUrl, page, limit) + .pipe( + tap(result => this.codingList$.next(result)), + finalize(() => { + this.isLoading = false; + }) + ) + .subscribe(); + } + + handlePageEvent(event: PageEvent): void { + this.loadCodingList(event.pageIndex + 1, event.pageSize); + } + + codeTestPersons(testPersonIds: string): void { + if (!testPersonIds) { + this.snackBar.open('Please enter test person IDs', 'Close', { duration: 3000 }); + return; + } + + this.isLoading = true; + this.testPersonCodingService.codeTestPersons(this.workspaceId, testPersonIds) + .pipe( + tap(result => { + if (result.jobId) { + // Background job started + this.activeJobId = result.jobId; + this.startJobStatusPolling(result.jobId); + this.snackBar.open(result.message || 'Background job started', 'Close', { duration: 5000 }); + } else { + // Immediate result + this.snackBar.open(`Coded ${result.totalResponses} responses`, 'Close', { duration: 3000 }); + this.loadStatistics(); + this.loadCodingList(this.currentPage, this.pageSize); + } + }), + catchError(error => { + this.snackBar.open(`Error: ${error.message || 'Failed to code test persons'}`, 'Close', { duration: 5000 }); + return of(null); + }), + finalize(() => { + this.isLoading = false; + }) + ) + .subscribe(); + } + + startJobStatusPolling(jobId: string): void { + // Clear any existing interval + if (this.jobStatusInterval) { + clearInterval(this.jobStatusInterval); + } + + // Poll job status every 2 seconds + this.jobStatusInterval = window.setInterval(() => { + this.testPersonCodingService.getJobStatus(this.workspaceId, jobId) + .subscribe(status => { + if ('error' in status) { + this.snackBar.open(`Error: ${status.error}`, 'Close', { duration: 5000 }); + this.stopJobStatusPolling(); + return; + } + + this.jobStatus = status; + + // If job is completed, failed, or cancelled, stop polling + if (['completed', 'failed', 'cancelled'].includes(status.status)) { + this.stopJobStatusPolling(); + + if (status.status === 'completed') { + this.snackBar.open('Coding job completed successfully', 'Close', { duration: 3000 }); + this.loadStatistics(); + this.loadCodingList(this.currentPage, this.pageSize); + } else if (status.status === 'failed') { + this.snackBar.open(`Coding job failed: ${status.error || 'Unknown error'}`, 'Close', { duration: 5000 }); + } else if (status.status === 'cancelled') { + this.snackBar.open('Coding job was cancelled', 'Close', { duration: 3000 }); + } + } + }); + }, 2000); + } + + stopJobStatusPolling(): void { + if (this.jobStatusInterval) { + clearInterval(this.jobStatusInterval); + this.jobStatusInterval = null; + } + this.activeJobId = null; + this.jobStatus = null; + } + + /** + * Cancel a job + * @param jobId Optional job ID to cancel. If not provided, cancels the active job. + */ + cancelJob(jobId?: string): void { + const idToCancel = jobId || this.activeJobId; + if (!idToCancel) return; + + this.testPersonCodingService.cancelJob(this.workspaceId, idToCancel) + .subscribe(result => { + if (result.success) { + this.snackBar.open(result.message, 'Close', { duration: 3000 }); + // Refresh the jobs list + this.loadAllJobs(); + } else { + this.snackBar.open(`Failed to cancel job: ${result.message}`, 'Close', { duration: 5000 }); + } + }); + } + + /** + * Pause a job + * @param jobId Optional job ID to pause. If not provided, pauses the active job. + */ + pauseJob(jobId?: string): void { + const idToPause = jobId || this.activeJobId; + if (!idToPause) return; + + this.testPersonCodingService.pauseJob(this.workspaceId, idToPause) + .subscribe(result => { + if (result.success) { + this.snackBar.open(result.message, 'Close', { duration: 3000 }); + // Refresh the jobs list + this.loadAllJobs(); + } else { + this.snackBar.open(`Failed to pause job: ${result.message}`, 'Close', { duration: 5000 }); + } + }); + } + + /** + * Resume a job + * @param jobId Optional job ID to resume. If not provided, resumes the active job. + */ + resumeJob(jobId?: string): void { + const idToResume = jobId || this.activeJobId; + if (!idToResume) return; + + this.testPersonCodingService.resumeJob(this.workspaceId, idToResume) + .subscribe(result => { + if (result.success) { + this.snackBar.open(result.message, 'Close', { duration: 3000 }); + // Refresh the jobs list + this.loadAllJobs(); + } else { + this.snackBar.open(`Failed to resume job: ${result.message}`, 'Close', { duration: 5000 }); + } + }); + } + + /** + * Show job result in a dialog + * @param job The job to show results for + */ + showJobResult(job: JobInfo): void { + if (!job.result) { + this.snackBar.open('No results available for this job', 'Close', { duration: 3000 }); + return; + } + + // Create a formatted message with the job results + let message = `Job ID: ${job.jobId}\n\n`; + message += `Total Responses: ${job.result.totalResponses}\n\n`; + message += 'Status Counts:\n'; + + for (const [status, count] of Object.entries(job.result.statusCounts)) { + message += `${status || 'Unknown'}: ${count}\n`; + } + + // Show the message in a snackbar + this.snackBar.open(message, 'Close', { duration: 10000 }); + } + + /** + * Code exactly five test persons + */ + codeFiveTestPersons(): void { + this.isLoading = true; + this.backendService.getTestPersons(this.workspaceId) + .pipe( + catchError(error => { + this.snackBar.open(`Error getting test persons: ${error.message || 'Unknown error'}`, 'Close', { duration: 5000 }); + return of([]); + }), + finalize(() => { + this.isLoading = false; + }) + ) + .subscribe(testPersonIds => { + if (testPersonIds.length === 0) { + this.snackBar.open('No test persons found for this workspace', 'Close', { duration: 3000 }); + return; + } + + // Take only the first 5 test persons (or fewer if there are less than 5) + const limitedTestPersonIds = testPersonIds.slice(0, 5); + + // Process the chunk of 5 test persons + if (this.runSequentially) { + this.processChunksSequentially([limitedTestPersonIds]); + } else { + this.processTestPersonChunk(limitedTestPersonIds, 0, 1) + .catch(error => { + this.snackBar.open(`Error processing test persons: ${error.message || 'Unknown error'}`, 'Close', { duration: 5000 }); + }); + } + + // Show a message about how many test persons are being coded + this.snackBar.open(`Coding ${limitedTestPersonIds.length} test persons`, 'Close', { duration: 3000 }); + }); + } + + /** + * Code all test persons in the workspace, split into groups of the configured size + */ + codeAllTestPersons(): void { + this.isLoading = true; + this.backendService.getTestPersons(this.workspaceId) + .pipe( + catchError(error => { + this.snackBar.open(`Error getting test persons: ${error.message || 'Unknown error'}`, 'Close', { duration: 5000 }); + return of([]); + }), + finalize(() => { + this.isLoading = false; + }) + ) + .subscribe(testPersonIds => { + if (testPersonIds.length === 0) { + this.snackBar.open('No test persons found for this workspace', 'Close', { duration: 3000 }); + return; + } + + // Split test persons into groups of the configured size + const chunks = this.chunkArray(testPersonIds, this.groupSize); + + // Show message about how many chunks will be processed + this.snackBar.open(`Processing ${testPersonIds.length} test persons in ${chunks.length} groups of ${this.groupSize}`, 'Close', { duration: 5000 }); + + if (this.runSequentially) { + // Process chunks sequentially + this.processChunksSequentially(chunks) + .catch(error => { + this.snackBar.open(`Error processing test persons: ${error.message || 'Unknown error'}`, 'Close', { duration: 5000 }); + }); + } else { + // Process each chunk with a small delay between them to avoid overwhelming the server + chunks.forEach((chunk, index) => { + setTimeout(() => { + this.processTestPersonChunk(chunk, index, chunks.length) + .catch(error => { + this.snackBar.open(`Error processing chunk ${index + 1}/${chunks.length}: ${error.message || 'Unknown error'}`, 'Close', { duration: 5000 }); + }); + }, index * 500); // 500ms delay between chunks + }); + } + }); + } + + exportAsCsv(): void { + this.isLoading = true; + this.testPersonCodingService.exportCodingListAsCsv(this.workspaceId) + .pipe( + finalize(() => { + this.isLoading = false; + }) + ) + .subscribe(blob => { + this.downloadFile(blob, `coding-list-${new Date().toISOString().slice(0, 10)}.csv`); + }); + } + + exportAsExcel(): void { + this.isLoading = true; + this.testPersonCodingService.exportCodingListAsExcel(this.workspaceId) + .pipe( + finalize(() => { + this.isLoading = false; + }) + ) + .subscribe(blob => { + this.downloadFile(blob, `coding-list-${new Date().toISOString().slice(0, 10)}.xlsx`); + }); + } + + /** + * Split an array into chunks of the specified size + * @param array The array to split + * @param chunkSize The size of each chunk + * @returns An array of chunks + */ + private chunkArray(array: T[], chunkSize: number): T[][] { + const chunks: T[][] = []; + for (let i = 0; i < array.length; i += chunkSize) { + chunks.push(array.slice(i, i + chunkSize)); + } + return chunks; + } + + /** + * Process a chunk of test person IDs + * @param chunk Array of test person IDs to process + * @param chunkIndex Index of the current chunk + * @param totalChunks Total number of chunks + * @returns Promise that resolves when the chunk is processed + */ + private processTestPersonChunk(chunk: number[], chunkIndex: number, totalChunks: number): Promise { + return new Promise((resolve, reject) => { + // Convert the chunk to a comma-separated string + const testPersonIdsString = chunk.join(','); + + // Show message about which chunk is being processed + this.snackBar.open(`Processing chunk ${chunkIndex + 1}/${totalChunks} with ${chunk.length} test persons`, 'Close', { duration: 3000 }); + + // Call the existing codeTestPersons method with the IDs string + this.testPersonCodingService.codeTestPersons(this.workspaceId, testPersonIdsString) + .pipe( + catchError(error => { + this.snackBar.open(`Error coding chunk ${chunkIndex + 1}/${totalChunks}: ${error.message || 'Unknown error'}`, 'Close', { duration: 5000 }); + reject(error); + return of(null); + }) + ) + .subscribe(result => { + if (result && result.jobId) { + // If a job was created, we need to wait for it to complete before resolving + const checkJobInterval = setInterval(() => { + this.testPersonCodingService.getJobStatus(this.workspaceId, result.jobId!) + .subscribe(status => { + if ('error' in status) { + clearInterval(checkJobInterval); + this.snackBar.open(`Error checking job status: ${status.error}`, 'Close', { duration: 5000 }); + reject(new Error(status.error)); + return; + } + + // If job is completed, failed, or cancelled, resolve or reject the promise + if (['completed', 'failed', 'cancelled'].includes(status.status)) { + clearInterval(checkJobInterval); + + if (status.status === 'completed') { + this.snackBar.open(`Completed chunk ${chunkIndex + 1}/${totalChunks}`, 'Close', { duration: 3000 }); + resolve(); + } else if (status.status === 'failed') { + this.snackBar.open(`Failed to process chunk ${chunkIndex + 1}/${totalChunks}: ${status.error || 'Unknown error'}`, 'Close', { duration: 5000 }); + reject(new Error(status.error || 'Job failed')); + } else if (status.status === 'cancelled') { + this.snackBar.open(`Chunk ${chunkIndex + 1}/${totalChunks} was cancelled`, 'Close', { duration: 3000 }); + reject(new Error('Job was cancelled')); + } + } + }); + }, 2000); + } else if (result) { + // If no job was created (immediate result), resolve immediately + this.snackBar.open(`Processed chunk ${chunkIndex + 1}/${totalChunks} with ${chunk.length} test persons`, 'Close', { duration: 3000 }); + resolve(); + } else { + // If no result, reject + reject(new Error('No result returned')); + } + + // Refresh the jobs list after each chunk is processed + this.loadAllJobs(); + }); + }); + } + + /** + * Process all chunks sequentially + * @param chunks Array of chunks to process + * @returns Promise that resolves when all chunks are processed + */ + private async processChunksSequentially(chunks: number[][]): Promise { + this.currentJobIndex = 0; + this.totalJobs = chunks.length; + this.processingQueue = chunks; + + for (let i = 0; i < chunks.length; i++) { + this.currentJobIndex = i; + try { + await this.processTestPersonChunk(chunks[i], i, chunks.length); + } catch (error) { + // @ts-ignore + this.snackBar.open(`Error processing chunk ${i + 1}/${chunks.length}: ${error.message || 'Unknown error'}`, 'Close', { duration: 5000 }); + // Continue with the next chunk even if this one failed + } + } + + // Refresh statistics and coding list after all chunks are processed + this.loadStatistics(); + this.loadCodingList(this.currentPage, this.pageSize); + this.snackBar.open(`Completed processing all ${chunks.length} chunks`, 'Close', { duration: 5000 }); + } + + private downloadFile(blob: Blob, filename: string): void { + const url = window.URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = filename; + link.click(); + window.URL.revokeObjectURL(url); + } +} diff --git a/apps/frontend/src/app/coding/services/test-person-coding.service.ts b/apps/frontend/src/app/coding/services/test-person-coding.service.ts new file mode 100644 index 000000000..6ba827014 --- /dev/null +++ b/apps/frontend/src/app/coding/services/test-person-coding.service.ts @@ -0,0 +1,269 @@ +import { Injectable, inject } from '@angular/core'; +import { HttpClient, HttpParams } from '@angular/common/http'; +import { + Observable, + catchError, + of +} from 'rxjs'; +import { SERVER_URL } from '../../injection-tokens'; + +export interface CodingStatistics { + totalResponses: number; + statusCounts: { + [key: string]: number; + }; +} + +export interface CodingStatisticsWithJob extends CodingStatistics { + jobId?: string; + message?: string; +} + +export interface CodingListItem { + unit_key: string; + unit_alias: string; + login_name: string; + login_code: string; + booklet_id: string; + variable_id: string; + variable_page: string; + variable_anchor: string; + url: string; +} + +export interface PaginatedCodingList { + data: CodingListItem[]; + total: number; + page: number; + limit: number; +} + +export interface JobStatus { + status: 'pending' | 'processing' | 'completed' | 'failed' | 'cancelled' | 'paused'; + progress: number; + result?: CodingStatistics; + error?: string; + workspaceId?: number; + createdAt?: Date; + testPersonId?: string; +} + +export interface JobInfo extends JobStatus { + jobId: string; +} + +@Injectable({ + providedIn: 'root' +}) +export class TestPersonCodingService { + readonly serverUrl = inject(SERVER_URL); + private http = inject(HttpClient); + + get authHeader() { + return { Authorization: `Bearer ${localStorage.getItem('id_token')}` }; + } + + /** + * Code test persons + * @param workspaceId Workspace ID + * @param testPersonIds Comma-separated list of test person IDs + */ + codeTestPersons(workspaceId: number, testPersonIds: string): Observable { + return this.http + .get( + `${this.serverUrl}admin/workspace/${workspaceId}/coding?testPersons=${testPersonIds}`, + { headers: this.authHeader } + ) + .pipe( + catchError(() => of({ totalResponses: 0, statusCounts: {} })) + ); + } + + /** + * Get manual test persons + * @param workspaceId Workspace ID + * @param testPersonIds Optional comma-separated list of test person IDs + */ + getManualTestPersons(workspaceId: number, testPersonIds?: string): Observable { + let url = `${this.serverUrl}admin/workspace/${workspaceId}/coding/manual`; + if (testPersonIds) { + url += `?testPersons=${testPersonIds}`; + } + + return this.http + .get(url, { headers: this.authHeader }) + .pipe( + catchError(() => of([])) + ); + } + + /** + * Get coding list + * @param workspaceId Workspace ID + * @param authToken Authentication token + * @param serverUrl Server URL + * @param page Page number + * @param limit Items per page + */ + getCodingList( + workspaceId: number, + authToken: string, + serverUrl?: string, + page = 1, + limit = 20 + ): Observable { + let params = new HttpParams() + .set('authToken', authToken) + .set('page', page.toString()) + .set('limit', limit.toString()); + + if (serverUrl) { + params = params.set('serverUrl', serverUrl); + } + + return this.http + .get( + `${this.serverUrl}admin/workspace/${workspaceId}/coding/coding-list`, + { headers: this.authHeader, params } + ) + .pipe( + catchError(() => of({ + data: [], + total: 0, + page, + limit + })) + ); + } + + /** + * Get coding statistics + * @param workspaceId Workspace ID + */ + getCodingStatistics(workspaceId: number): Observable { + return this.http + .get( + `${this.serverUrl}admin/workspace/${workspaceId}/coding/statistics`, + { headers: this.authHeader } + ) + .pipe( + catchError(() => of({ totalResponses: 0, statusCounts: {} })) + ); + } + + /** + * Get job status + * @param workspaceId Workspace ID + * @param jobId Job ID + */ + getJobStatus(workspaceId: number, jobId: string): Observable { + return this.http + .get( + `${this.serverUrl}admin/workspace/${workspaceId}/coding/job/${jobId}`, + { headers: this.authHeader } + ) + .pipe( + catchError(() => of({ error: `Failed to get status for job ${jobId}` })) + ); + } + + /** + * Cancel job + * @param workspaceId Workspace ID + * @param jobId Job ID + */ + cancelJob(workspaceId: number, jobId: string): Observable<{ success: boolean; message: string }> { + return this.http + .get<{ success: boolean; message: string }>( + `${this.serverUrl}admin/workspace/${workspaceId}/coding/job/${jobId}/cancel`, + { headers: this.authHeader } + ) + .pipe( + catchError(() => of({ success: false, message: `Failed to cancel job ${jobId}` })) + ); + } + + /** + * Pause job + * @param workspaceId Workspace ID + * @param jobId Job ID + */ + pauseJob(workspaceId: number, jobId: string): Observable<{ success: boolean; message: string }> { + return this.http + .get<{ success: boolean; message: string }>( + `${this.serverUrl}admin/workspace/${workspaceId}/coding/job/${jobId}/pause`, + { headers: this.authHeader } + ) + .pipe( + catchError(() => of({ success: false, message: `Failed to pause job ${jobId}` })) + ); + } + + /** + * Resume job + * @param workspaceId Workspace ID + * @param jobId Job ID + */ + resumeJob(workspaceId: number, jobId: string): Observable<{ success: boolean; message: string }> { + return this.http + .get<{ success: boolean; message: string }>( + `${this.serverUrl}admin/workspace/${workspaceId}/coding/job/${jobId}/resume`, + { headers: this.authHeader } + ) + .pipe( + catchError(() => of({ success: false, message: `Failed to resume job ${jobId}` })) + ); + } + + /** + * Export coding list as CSV + * @param workspaceId Workspace ID + */ + exportCodingListAsCsv(workspaceId: number): Observable { + return this.http + .get( + `${this.serverUrl}admin/workspace/${workspaceId}/coding/coding-list/csv`, + { + headers: this.authHeader, + responseType: 'blob' + } + ) + .pipe( + catchError(() => of(new Blob(['No data available'], { type: 'text/csv' }))) + ); + } + + /** + * Export coding list as Excel + * @param workspaceId Workspace ID + */ + exportCodingListAsExcel(workspaceId: number): Observable { + return this.http + .get( + `${this.serverUrl}admin/workspace/${workspaceId}/coding/coding-list/excel`, + { + headers: this.authHeader, + responseType: 'blob' + } + ) + .pipe( + catchError(() => of(new Blob(['No data available'], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }))) + ); + } + + /** + * Get all jobs for a workspace + * @param workspaceId Workspace ID + * @returns Observable of an array of job information + */ + getAllJobs(workspaceId: number): Observable { + return this.http + .get( + `${this.serverUrl}admin/workspace/${workspaceId}/coding/jobs`, + { headers: this.authHeader } + ) + .pipe( + catchError(() => of([])) + ); + } +} From 6209a16cf1162f602bc13d94e8a8f58413e41dcd Mon Sep 17 00:00:00 2001 From: jurei733 <67505990+jurei733@users.noreply.github.com> Date: Wed, 9 Jul 2025 15:16:12 +0200 Subject: [PATCH 25/36] Implement Caching mechanisms for coding schemes and test files in the `WorkspaceCodingService --- .../services/workspace-coding.service.ts | 207 +++++++++++++----- 1 file changed, 154 insertions(+), 53 deletions(-) diff --git a/apps/backend/src/app/database/services/workspace-coding.service.ts b/apps/backend/src/app/database/services/workspace-coding.service.ts index f68ad5965..8eebcc1ed 100644 --- a/apps/backend/src/app/database/services/workspace-coding.service.ts +++ b/apps/backend/src/app/database/services/workspace-coding.service.ts @@ -38,6 +38,141 @@ export class WorkspaceCodingService { private testFileCache: Map; timestamp: number }> = new Map(); private readonly TEST_FILE_CACHE_TTL_MS = 15 * 60 * 1000; // 15 minutes cache TTL + /** + * Get test files from cache or fetch them from the database + * @param workspace_id Workspace ID + * @param unitAliasesArray Array of unit aliases to fetch + * @returns Map of file ID to file + */ + private async getTestFilesWithCache(workspace_id: number, unitAliasesArray: string[]): Promise> { + // Check if we have a valid cache entry + const cacheEntry = this.testFileCache.get(workspace_id); + const now = Date.now(); + + if (cacheEntry && (now - cacheEntry.timestamp) < this.TEST_FILE_CACHE_TTL_MS) { + this.logger.log(`Using cached test files for workspace ${workspace_id}`); + + // Check if all requested unit aliases are in the cache + const missingAliases = unitAliasesArray.filter(alias => !cacheEntry.files.has(alias)); + + if (missingAliases.length === 0) { + // All files are in the cache, return the cached files + return cacheEntry.files; + } + + // Some files are missing, fetch only the missing ones + this.logger.log(`Fetching ${missingAliases.length} missing test files for workspace ${workspace_id}`); + const missingFiles = await this.fileUploadRepository.find({ + where: { workspace_id, file_id: In(missingAliases) }, + select: ['file_id', 'data', 'filename'] + }); + + // Add the missing files to the cache + missingFiles.forEach(file => { + cacheEntry.files.set(file.file_id, file); + }); + + // Update the timestamp + cacheEntry.timestamp = now; + + return cacheEntry.files; + } + + // No valid cache entry, fetch all files + this.logger.log(`Fetching all test files for workspace ${workspace_id}`); + const testFiles = await this.fileUploadRepository.find({ + where: { workspace_id, file_id: In(unitAliasesArray) }, + select: ['file_id', 'data', 'filename'] + }); + + // Create a new cache entry + const fileMap = new Map(); + testFiles.forEach(file => { + fileMap.set(file.file_id, file); + }); + + // Store in cache + this.testFileCache.set(workspace_id, { files: fileMap, timestamp: now }); + + return fileMap; + } + + /** + * Get coding schemes from cache or fetch them from the database + * @param codingSchemeRefs Array of coding scheme references to fetch + * @returns Map of scheme ID to parsed coding scheme + */ + private async getCodingSchemesWithCache(codingSchemeRefs: string[]): Promise> { + const now = Date.now(); + const result = new Map(); + const emptyScheme = new Autocoder.CodingScheme({}); + + // Check which schemes are in the cache and still valid + const missingSchemeRefs = codingSchemeRefs.filter(ref => { + const cacheEntry = this.codingSchemeCache.get(ref); + if (cacheEntry && (now - cacheEntry.timestamp) < this.SCHEME_CACHE_TTL_MS) { + // Scheme is in cache and still valid + result.set(ref, cacheEntry.scheme); + return false; + } + return true; + }); + + if (missingSchemeRefs.length === 0) { + // All schemes are in the cache + this.logger.log('Using all cached coding schemes'); + return result; + } + + // Fetch missing schemes + this.logger.log(`Fetching ${missingSchemeRefs.length} missing coding schemes`); + const codingSchemeFiles = await this.fileUploadRepository.find({ + where: { file_id: In(missingSchemeRefs) }, + select: ['file_id', 'data', 'filename'] + }); + + // Parse and cache the schemes + codingSchemeFiles.forEach(file => { + try { + const data = typeof file.data === 'string' ? JSON.parse(file.data) : file.data; + const scheme = new Autocoder.CodingScheme(data); + + // Store in result map + result.set(file.file_id, scheme); + + // Store in cache + this.codingSchemeCache.set(file.file_id, { scheme, timestamp: now }); + } catch (error) { + this.logger.error(`--- Fehler beim Verarbeiten des Kodierschemas ${file.filename}: ${error.message}`); + // Use empty scheme for invalid schemes + result.set(file.file_id, emptyScheme); + } + }); + + return result; + } + + /** + * Clean up expired items from caches + */ + private cleanupCaches(): void { + const now = Date.now(); + + // Clean up coding scheme cache + for (const [key, entry] of this.codingSchemeCache.entries()) { + if (now - entry.timestamp > this.SCHEME_CACHE_TTL_MS) { + this.codingSchemeCache.delete(key); + } + } + + // Clean up test file cache + for (const [key, entry] of this.testFileCache.entries()) { + if (now - entry.timestamp > this.TEST_FILE_CACHE_TTL_MS) { + this.testFileCache.delete(key); + } + } + } + // Job status tracking private jobStatus: Map = new Map(); @@ -192,6 +327,9 @@ export class WorkspaceCodingService { progressCallback?: (progress: number) => void, jobId?: string ): Promise { + // Clean up expired cache entries + this.cleanupCaches(); + const startTime = Date.now(); const metrics: { [key: string]: number } = {}; @@ -396,17 +534,10 @@ export class WorkspaceCodingService { // Step 7: Get test files - 70% progress const fileQueryStart = Date.now(); - const testFiles = await this.fileUploadRepository.find({ - where: { workspace_id: workspace_id, file_id: In(unitAliasesArray) }, - select: ['file_id', 'data', 'filename'] // Only select needed fields - }); + // Use cache for test files + const fileIdToTestFileMap = await this.getTestFilesWithCache(workspace_id, unitAliasesArray); metrics.fileQuery = Date.now() - fileQueryStart; - const fileIdToTestFileMap = new Map(); - testFiles.forEach(file => { - fileIdToTestFileMap.set(file.file_id, file); - }); - // Report progress after step 7 if (progressCallback) { progressCallback(70); @@ -475,11 +606,11 @@ export class WorkspaceCodingService { // Step 9: Get coding scheme files - 85% progress const schemeQueryStart = Date.now(); - const codingSchemeFiles = await this.fileUploadRepository.find({ - where: { file_id: In([...codingSchemeRefs]) }, - select: ['file_id', 'data', 'filename'] - }); + // Use cache for coding schemes + const fileIdToCodingSchemeMap = await this.getCodingSchemesWithCache([...codingSchemeRefs]); metrics.schemeQuery = Date.now() - schemeQueryStart; + // No separate parsing step needed as it's handled by the cache helper + metrics.schemeParsing = 0; // Report progress after step 9 if (progressCallback) { @@ -496,22 +627,9 @@ export class WorkspaceCodingService { } } - // Step 10: Parse coding schemes - 90% progress - const schemeParsing = Date.now(); - const fileIdToCodingSchemeMap = new Map(); + // Skip to step 11 (step 10 is now part of getCodingSchemesWithCache) const emptyScheme = new Autocoder.CodingScheme({}); - codingSchemeFiles.forEach(file => { - try { - const data = typeof file.data === 'string' ? JSON.parse(file.data) : file.data; - const scheme = new Autocoder.CodingScheme(data); - fileIdToCodingSchemeMap.set(file.file_id, scheme); - } catch (error) { - this.logger.error(`--- Fehler beim Verarbeiten des Kodierschemas ${file.filename}: ${error.message}`); - } - }); - metrics.schemeParsing = Date.now() - schemeParsing; - // Report progress after step 10 if (progressCallback) { progressCallback(90); @@ -718,6 +836,9 @@ export class WorkspaceCodingService { } async codeTestPersons(workspace_id: number, testPersonIds: string): Promise { + // Clean up expired cache entries + this.cleanupCaches(); + const startTime = Date.now(); const metrics: { [key: string]: number } = {}; @@ -848,16 +969,9 @@ export class WorkspaceCodingService { } const fileQueryStart = Date.now(); - const testFiles = await this.fileUploadRepository.find({ - where: { workspace_id: workspace_id, file_id: In(unitAliasesArray) }, - select: ['file_id', 'data', 'filename'] // Only select needed fields - }); + // Use cache for test files + const fileIdToTestFileMap = await this.getTestFilesWithCache(workspace_id, unitAliasesArray); metrics.fileQuery = Date.now() - fileQueryStart; - - const fileIdToTestFileMap = new Map(); - testFiles.forEach(file => { - fileIdToTestFileMap.set(file.file_id, file); - }); const schemeExtractStart = Date.now(); const codingSchemeRefs = new Set(); const unitToCodingSchemeRefMap = new Map(); @@ -884,26 +998,13 @@ export class WorkspaceCodingService { metrics.schemeExtract = Date.now() - schemeExtractStart; const schemeQueryStart = Date.now(); - const codingSchemeFiles = await this.fileUploadRepository.find({ - where: { file_id: In([...codingSchemeRefs]) }, - select: ['file_id', 'data', 'filename'] - }); + // Use cache for coding schemes + const fileIdToCodingSchemeMap = await this.getCodingSchemesWithCache([...codingSchemeRefs]); metrics.schemeQuery = Date.now() - schemeQueryStart; - const schemeParsing = Date.now(); - const fileIdToCodingSchemeMap = new Map(); + // No separate parsing step needed as it's handled by the cache helper + metrics.schemeParsing = 0; const emptyScheme = new Autocoder.CodingScheme({}); - codingSchemeFiles.forEach(file => { - try { - const data = typeof file.data === 'string' ? JSON.parse(file.data) : file.data; - const scheme = new Autocoder.CodingScheme(data); - fileIdToCodingSchemeMap.set(file.file_id, scheme); - } catch (error) { - this.logger.error(`--- Fehler beim Verarbeiten des Kodierschemas ${file.filename}: ${error.message}`); - } - }); - metrics.schemeParsing = Date.now() - schemeParsing; - const processingStart = Date.now(); const allCodedResponses = []; From 5b4519c2b12b55d1ddd49cd6abccb846a24c7af7 Mon Sep 17 00:00:00 2001 From: jurei733 <67505990+jurei733@users.noreply.github.com> Date: Wed, 9 Jul 2025 15:16:44 +0200 Subject: [PATCH 26/36] Use more height for unit-player --- .../unit-player/unit-player.component.scss | 2 +- docs/performance-optimizations.md | 137 ++++++++++++++++++ 2 files changed, 138 insertions(+), 1 deletion(-) create mode 100644 docs/performance-optimizations.md diff --git a/apps/frontend/src/app/replay/components/unit-player/unit-player.component.scss b/apps/frontend/src/app/replay/components/unit-player/unit-player.component.scss index d72c25e1c..426a46647 100755 --- a/apps/frontend/src/app/replay/components/unit-player/unit-player.component.scss +++ b/apps/frontend/src/app/replay/components/unit-player/unit-player.component.scss @@ -1,5 +1,5 @@ .unitHost { - height: 2000px; + height: 4000px; width: 100vw; border: none; } diff --git a/docs/performance-optimizations.md b/docs/performance-optimizations.md new file mode 100644 index 000000000..6ab672dd3 --- /dev/null +++ b/docs/performance-optimizations.md @@ -0,0 +1,137 @@ +# Performance Optimizations + +## Coding Statistics Retrieval Optimization + +### Issue +The retrieval of coding statistics from the NestJS backend was taking a very long time, especially for workspaces with a large number of responses. + +### Changes Made +The following optimizations were implemented in the `getCodingStatistics` method in `apps/backend/src/app/database/services/workspace-coding.service.ts`: + +1. **Query Optimization**: + - Combined two separate database queries (one for total count, one for status counts) into a single query + - Calculated the total count by summing individual status counts instead of running a separate count query + +2. **Caching Implementation**: + - Added an in-memory cache with a 5-minute time-to-live (TTL) + - Cache is keyed by workspace ID to ensure workspace-specific statistics + - Added logging to indicate when cached results are being returned + +### Expected Benefits +- **Reduced Database Load**: Fewer queries mean less load on the database +- **Faster Response Times**: Cached results are returned immediately without database queries +- **Improved Scalability**: Better handling of workspaces with large numbers of responses + +### Implementation Details +- Cache is implemented as a Map with workspace ID as the key +- Each cache entry includes both the data and a timestamp for TTL calculation +- Cache invalidation happens automatically after 5 minutes +- No additional dependencies were required for this implementation + +## Responses by Status Retrieval Optimization + +### Issue +Retrieving responses by status from the NestJS backend was taking a very long time, especially for workspaces with a large number of responses. + +### Changes Made +The following optimizations were implemented in the `getResponsesByStatus` method in `apps/backend/src/app/database/services/workspace-test-results.service.ts`: + +1. **Query Optimization**: + - Used a single query with `getManyAndCount()` to retrieve both data and count in one database call + - Optimized the query structure to ensure efficient execution + +2. **Caching Implementation**: + - Added an in-memory cache with a 2-minute time-to-live (TTL) + - Cache is keyed by a combination of workspace ID, status, and pagination parameters + - Added logging to indicate when cached results are being returned + +### Expected Benefits +- **Reduced Database Load**: Using a single query reduces database load +- **Faster Response Times**: Cached results are returned immediately without database queries +- **Improved User Experience**: Faster loading of responses filtered by status + +### Implementation Details +- Cache is implemented as a Map with a composite key (workspace_id-status-page-limit) +- Each cache entry includes both the data and a timestamp for TTL calculation +- Cache invalidation happens automatically after 2 minutes +- No additional dependencies were required for this implementation + +### Future Considerations +- If the application scales to multiple instances, consider implementing a distributed cache +- Add cache invalidation when response data is updated to ensure fresh results +- Consider adding configuration options for cache TTL + +## Test Person Coding Optimization + +### Issue +The `codeTestPersons` method in the NestJS backend was taking too much time when processing thousands of test persons, causing timeouts and poor user experience. + +### Changes Made +The following optimizations were implemented in the `codeTestPersons` method in `apps/backend/src/app/database/services/workspace-coding.service.ts`: + +1. **Multi-level Caching Implementation**: + - Added caching for coding schemes with a 30-minute TTL + - Added caching for test files with a 15-minute TTL + - Reduced redundant database queries and file parsing operations + +2. **Background Processing**: + - Implemented a threshold-based approach (>100 test persons) to automatically process large batches in the background + - Added job status tracking with progress reporting + - Created a new API endpoint to check job status + +3. **Batch Processing Improvements**: + - Optimized database queries to select only needed fields + - Implemented more efficient data structures for lookups + - Added progress tracking for better user feedback + +### Expected Benefits +- **Immediate Response**: Users get immediate feedback even for large batches +- **Reduced Timeouts**: Background processing prevents request timeouts +- **Better User Experience**: Progress tracking allows users to monitor long-running operations +- **Reduced Database Load**: Caching and optimized queries reduce database pressure + +### Implementation Details +- Background processing is implemented using Node.js asynchronous capabilities without external dependencies +- Job status is tracked in-memory with automatic cleanup after 1 hour +- Progress reporting is implemented at key points in the processing pipeline +- The API returns a job ID that can be used to check status when processing in the background + +### Future Considerations +- Consider implementing a proper job queue system with Redis for better scalability +- Add WebSocket support for real-time progress updates +- Implement more granular progress reporting + +## Job Cancellation Implementation + +### Issue +Long-running background jobs for coding test persons could not be cancelled, forcing users to wait for completion or restart the server. + +### Changes Made +The following features were implemented to allow cancellation of background jobs: + +1. **Job Status Enhancement**: + - Added a 'cancelled' status to the job status tracking system + - Modified the background processing to check for cancellation at multiple points + - Implemented early termination of processing when cancellation is detected + +2. **Cancellation API**: + - Added a new `cancelJob` method to the `WorkspaceCodingService` + - Created a new API endpoint at `/admin/workspace/:workspace_id/coding/job/:jobId/cancel` + - Enhanced the job status API to include the 'cancelled' status in responses + +3. **Graceful Termination**: + - Implemented checks for cancellation at strategic points in the processing pipeline + - Added proper cleanup of resources when a job is cancelled + - Ensured consistent job status reporting for cancelled jobs + +### Expected Benefits +- **Improved User Control**: Users can cancel long-running jobs that are no longer needed +- **Resource Efficiency**: System resources are freed up when jobs are cancelled +- **Better User Experience**: No need to wait for unnecessary processing to complete +- **Reduced Server Load**: Prevents accumulation of unwanted background processes + +### Implementation Details +- Cancellation checks are performed at multiple stages of processing +- The job status is immediately updated when cancellation is requested +- Cancelled jobs are properly cleaned up to prevent memory leaks +- The API provides clear feedback about the success or failure of cancellation requests From e4e191a982fa61d13d366bc82aad4c429dd46052 Mon Sep 17 00:00:00 2001 From: jurei733 <67505990+jurei733@users.noreply.github.com> Date: Thu, 10 Jul 2025 11:29:56 +0200 Subject: [PATCH 27/36] Check for parameters, limit IDs in IN statements to 1000, and verify non-empty arrays. --- .../services/workspace-files.service.ts | 502 ++++++++++++------ 1 file changed, 340 insertions(+), 162 deletions(-) diff --git a/apps/backend/src/app/database/services/workspace-files.service.ts b/apps/backend/src/app/database/services/workspace-files.service.ts index e5988937a..5fd2f755b 100644 --- a/apps/backend/src/app/database/services/workspace-files.service.ts +++ b/apps/backend/src/app/database/services/workspace-files.service.ts @@ -896,65 +896,24 @@ export class WorkspaceFilesService { return `${module}-${majorVersion}.${minorVersion}`.toUpperCase(); } - /** - * Retrieves the XML content of a unit file - * @param workspaceId The ID of the workspace - * @param unitId The ID of the unit - * @returns The XML content of the unit file - */ async getUnitContent(workspaceId: number, unitId: number): Promise { - try { - const unitFile = await this.fileUploadRepository.findOne({ - where: { workspace_id: workspaceId, file_id: `${unitId}` } - }); - - if (!unitFile) { - this.logger.error(`Unit file with ID ${unitId} not found in workspace ${workspaceId}`); - throw new Error(`Unit file with ID ${unitId} not found`); - } + const unitFile = await this.fileUploadRepository.findOne({ + where: { workspace_id: workspaceId, file_id: `${unitId}` } + }); - if (unitFile.data) { - return unitFile.data.toString(); - } + if (!unitFile) { + this.logger.error(`Unit file with ID ${unitId} not found in workspace ${workspaceId}`); + throw new Error(`Unit file with ID ${unitId} not found`); + } + if (!unitFile.data) { + this.logger.error(`Unit file with ID ${unitId} has no data content`); throw new Error('Unit file has no data content'); - } catch (error) { - this.logger.error(`Error retrieving unit content: ${error.message}`, error.stack); - throw error; } - } - - /** - * Extracts the CodingSchemeRef from an XML string - * @param xmlContent The XML content to parse - * @returns The coding scheme reference name or null if not found - */ - extractCodingSchemeRef(xmlContent: string): string | null { - try { - // Verwende cheerio, um das XML zu parsen - const $ = cheerio.load(xmlContent, { xml: true }); - - // Suche nach dem CodingSchemeRef-Tag - const codingSchemeRefTag = $('CodingSchemeRef'); - - if (codingSchemeRefTag.length > 0) { - // Hole den Text-Inhalt des Tags - return codingSchemeRefTag.text().trim(); - } - return null; - } catch (error) { - this.logger.error(`Error extracting CodingSchemeRef: ${error.message}`, error.stack); - return null; - } + return unitFile.data.toString(); } - /** - * Finds a coding scheme file by its reference name - * @param workspaceId The ID of the workspace - * @param codingSchemeRef The reference name of the coding scheme - * @returns The coding scheme file data - */ async getCodingSchemeByRef(workspaceId: number, codingSchemeRef: string): Promise { try { const codingSchemeFile = await this.fileUploadRepository.findOne({ @@ -983,6 +942,16 @@ export class WorkspaceFilesService { } async validateVariables(workspaceId: number, page: number = 1, limit: number = 10): Promise<{ data: InvalidVariableDto[]; total: number; page: number; limit: number }> { + if (!workspaceId) { + this.logger.error('Workspace ID is required'); + return { + data: [], + total: 0, + page, + limit + }; + } + const unitFiles = await this.filesRepository.find({ where: { workspace_id: workspaceId, file_type: 'Unit' } }); @@ -1011,7 +980,6 @@ export class WorkspaceFilesService { const invalidVariables: InvalidVariableDto[] = []; - // Find all persons with the given workspace_id const persons = await this.personsRepository.find({ where: { workspace_id: workspaceId } }); @@ -1026,16 +994,33 @@ export class WorkspaceFilesService { }; } - // Get all person IDs const personIds = persons.map(person => person.id); - // Find all units that belong to booklets that belong to these persons - const units = await this.unitRepository.createQueryBuilder('unit') - .innerJoin('unit.booklet', 'booklet') - .where('booklet.personid IN (:...personIds)', { personIds }) - .getMany(); + if (personIds.length === 0) { + this.logger.warn(`No person IDs found for workspace ${workspaceId}`); + return { + data: [], + total: 0, + page, + limit + }; + } + + const batchSize = 1000; + let allUnits: Unit[] = []; - if (units.length === 0) { + for (let i = 0; i < personIds.length; i += batchSize) { + const personIdsBatch = personIds.slice(i, i + batchSize); + + const unitsBatch = await this.unitRepository.createQueryBuilder('unit') + .innerJoin('unit.booklet', 'booklet') + .where('booklet.personid IN (:...personIdsBatch)', { personIdsBatch }) + .getMany(); + + allUnits = [...allUnits, ...unitsBatch]; + } + + if (allUnits.length === 0) { this.logger.warn(`No units found for persons in workspace ${workspaceId}`); return { data: [], @@ -1045,16 +1030,42 @@ export class WorkspaceFilesService { }; } - const unitIds = units.map(unit => unit.id); + const unitIds = allUnits.map(unit => unit.id); - // Find all responses that belong to these units - const responses = await this.responseRepository.find({ - where: { unitid: In(unitIds) }, - relations: ['unit'] // Include unit relation to access unit.name - }); + if (unitIds.length === 0) { + this.logger.warn(`No unit IDs found for persons in workspace ${workspaceId}`); + return { + data: [], + total: 0, + page, + limit + }; + } - // Check each response - for (const response of responses) { + let allResponses: ResponseEntity[] = []; + + for (let i = 0; i < unitIds.length; i += batchSize) { + const unitIdsBatch = unitIds.slice(i, i + batchSize); + + const responsesBatch = await this.responseRepository.find({ + where: { unitid: In(unitIdsBatch) }, + relations: ['unit'] + }); + + allResponses = [...allResponses, ...responsesBatch]; + } + + if (allResponses.length === 0) { + this.logger.warn(`No responses found for units in workspace ${workspaceId}`); + return { + data: [], + total: 0, + page, + limit + }; + } + + for (const response of allResponses) { const unit = response.unit; if (!unit) { this.logger.warn(`Response ${response.id} has no associated unit`); @@ -1064,7 +1075,11 @@ export class WorkspaceFilesService { const unitName = unit.name; const variableId = response.variableid; - // Check if the unit name exists in unitVariables + if (!variableId) { + this.logger.warn(`Response ${response.id} has no variable ID`); + continue; + } + if (!unitVariables.has(unitName)) { invalidVariables.push({ fileName: `Unit ${unitName}`, @@ -1076,7 +1091,6 @@ export class WorkspaceFilesService { continue; } - // Check if the variable ID exists in the unit's variables const unitVars = unitVariables.get(unitName); if (!unitVars || !unitVars.has(variableId)) { invalidVariables.push({ @@ -1089,7 +1103,6 @@ export class WorkspaceFilesService { } } - // Apply pagination const validPage = Math.max(1, page); const validLimit = Math.min(Math.max(1, limit), 1000); const startIndex = (validPage - 1) * validLimit; @@ -1104,20 +1117,21 @@ export class WorkspaceFilesService { }; } - /** - * Validates if variable values match their defined types - * @param workspaceId The ID of the workspace - * @param page Page number for pagination - * @param limit Number of items per page - * @returns Paginated validation result with invalid variables - */ async validateVariableTypes(workspaceId: number, page: number = 1, limit: number = 10): Promise<{ data: InvalidVariableDto[]; total: number; page: number; limit: number }> { + if (!workspaceId) { + this.logger.error('Workspace ID is required'); + return { + data: [], + total: 0, + page, + limit + }; + } + const unitFiles = await this.filesRepository.find({ where: { workspace_id: workspaceId, file_type: 'Unit' } }); - // Map to store unit variables with their types - // Key: unitName, Value: Map of variableId to type const unitVariableTypes = new Map>(); for (const unitFile of unitFiles) { @@ -1163,12 +1177,31 @@ export class WorkspaceFilesService { const personIds = persons.map(person => person.id); - const units = await this.unitRepository.createQueryBuilder('unit') - .innerJoin('unit.booklet', 'booklet') - .where('booklet.personid IN (:...personIds)', { personIds }) - .getMany(); + if (personIds.length === 0) { + this.logger.warn(`No person IDs found for workspace ${workspaceId}`); + return { + data: [], + total: 0, + page, + limit + }; + } + + const batchSize = 1000; + let allUnits: Unit[] = []; + + for (let i = 0; i < personIds.length; i += batchSize) { + const personIdsBatch = personIds.slice(i, i + batchSize); - if (units.length === 0) { + const unitsBatch = await this.unitRepository.createQueryBuilder('unit') + .innerJoin('unit.booklet', 'booklet') + .where('booklet.personid IN (:...personIdsBatch)', { personIdsBatch }) + .getMany(); + + allUnits = [...allUnits, ...unitsBatch]; + } + + if (allUnits.length === 0) { this.logger.warn(`No units found for persons in workspace ${workspaceId}`); return { data: [], @@ -1178,14 +1211,42 @@ export class WorkspaceFilesService { }; } - const unitIds = units.map(unit => unit.id); + const unitIds = allUnits.map(unit => unit.id); - const responses = await this.responseRepository.find({ - where: { unitid: In(unitIds) }, - relations: ['unit'] // Include unit relation to access unit.name - }); + if (unitIds.length === 0) { + this.logger.warn(`No unit IDs found for persons in workspace ${workspaceId}`); + return { + data: [], + total: 0, + page, + limit + }; + } + + let allResponses: ResponseEntity[] = []; + + for (let i = 0; i < unitIds.length; i += batchSize) { + const unitIdsBatch = unitIds.slice(i, i + batchSize); - for (const response of responses) { + const responsesBatch = await this.responseRepository.find({ + where: { unitid: In(unitIdsBatch) }, + relations: ['unit'] + }); + + allResponses = [...allResponses, ...responsesBatch]; + } + + if (allResponses.length === 0) { + this.logger.warn(`No responses found for units in workspace ${workspaceId}`); + return { + data: [], + total: 0, + page, + limit + }; + } + + for (const response of allResponses) { const unit = response.unit; if (!unit) { this.logger.warn(`Response ${response.id} has no associated unit`); @@ -1194,6 +1255,12 @@ export class WorkspaceFilesService { const unitName = unit.name; const variableId = response.variableid; + + if (!variableId) { + this.logger.warn(`Response ${response.id} has no variable ID`); + continue; + } + const value = response.value || ''; if (!unitVariableTypes.has(unitName)) { @@ -1219,7 +1286,6 @@ export class WorkspaceFilesService { } } - // Apply pagination const validPage = Math.max(1, page); const validLimit = Math.min(Math.max(1, limit), 1000); const startIndex = (validPage - 1) * validLimit; @@ -1234,12 +1300,6 @@ export class WorkspaceFilesService { }; } - /** - * Checks if a value is valid for a given type - * @param value The value to check - * @param type The expected type (string, integer, number, boolean, json) - * @returns True if the value is valid for the type, false otherwise - */ private isValidValueForType(value: string, type: string): boolean { if (!value && type !== 'string') { return false; @@ -1264,7 +1324,6 @@ export class WorkspaceFilesService { } case 'json': - // Check if the value is valid JSON try { JSON.parse(value); return true; @@ -1277,21 +1336,8 @@ export class WorkspaceFilesService { } } - /** - * Validates if response status is one of the valid values - * @param workspaceId The ID of the workspace - * @param page Page number for pagination - * @param limit Number of items per page - * @returns Paginated validation result with invalid responses - */ - /** - * Validates TestTakers XML files and checks if each person from the persons table is found - * @param workspaceId The ID of the workspace - * @returns Validation results - */ async validateTestTakers(workspaceId: number): Promise { try { - // Find TestTakers files in the workspace const testTakers = await this.fileUploadRepository.find({ where: { workspace_id: workspaceId, file_type: In(['TestTakers', 'Testtakers']) } }); @@ -1307,13 +1353,11 @@ export class WorkspaceFilesService { }; } - // Parse XML to extract Groups, Logins, and Booklet codes const testTakerLogins: TestTakerLoginDto[] = []; let totalGroups = 0; let totalLogins = 0; let totalBookletCodes = 0; - // Process all test takers for (const testTaker of testTakers) { const xmlDocument = cheerio.load(testTaker.data, { xml: true }); const groupElements = xmlDocument('Group'); @@ -1365,12 +1409,10 @@ export class WorkspaceFilesService { } } - // Find all persons in the workspace const persons = await this.personsRepository.find({ where: { workspace_id: workspaceId } }); - // Check if each person from the persons table is found in the extracted data const missingPersons: MissingPersonDto[] = []; for (const person of persons) { @@ -1400,6 +1442,16 @@ export class WorkspaceFilesService { } async validateResponseStatus(workspaceId: number, page: number = 1, limit: number = 10): Promise<{ data: InvalidVariableDto[]; total: number; page: number; limit: number }> { + if (!workspaceId) { + this.logger.error('Workspace ID is required'); + return { + data: [], + total: 0, + page, + limit + }; + } + const validStatusValues = ['VALUE_CHANGED', 'NOT_REACHED', 'DISPLAYED', 'UNSET', 'PARTLY_DISPLAYED']; const persons = await this.personsRepository.find({ @@ -1418,12 +1470,31 @@ export class WorkspaceFilesService { const personIds = persons.map(person => person.id); - const units = await this.unitRepository.createQueryBuilder('unit') - .innerJoin('unit.booklet', 'booklet') - .where('booklet.personid IN (:...personIds)', { personIds }) - .getMany(); + if (personIds.length === 0) { + this.logger.warn(`No person IDs found for workspace ${workspaceId}`); + return { + data: [], + total: 0, + page, + limit + }; + } + + const batchSize = 1000; + let allUnits: Unit[] = []; + + for (let i = 0; i < personIds.length; i += batchSize) { + const personIdsBatch = personIds.slice(i, i + batchSize); + + const unitsBatch = await this.unitRepository.createQueryBuilder('unit') + .innerJoin('unit.booklet', 'booklet') + .where('booklet.personid IN (:...personIdsBatch)', { personIdsBatch }) + .getMany(); + + allUnits = [...allUnits, ...unitsBatch]; + } - if (units.length === 0) { + if (allUnits.length === 0) { this.logger.warn(`No units found for persons in workspace ${workspaceId}`); return { data: [], @@ -1433,16 +1504,44 @@ export class WorkspaceFilesService { }; } - const unitIds = units.map(unit => unit.id); + const unitIds = allUnits.map(unit => unit.id); - const responses = await this.responseRepository.find({ - where: { unitid: In(unitIds) }, - relations: ['unit'] // Include unit relation to access unit.name - }); + if (unitIds.length === 0) { + this.logger.warn(`No unit IDs found for persons in workspace ${workspaceId}`); + return { + data: [], + total: 0, + page, + limit + }; + } + + let allResponses: ResponseEntity[] = []; + + for (let i = 0; i < unitIds.length; i += batchSize) { + const unitIdsBatch = unitIds.slice(i, i + batchSize); + + const responsesBatch = await this.responseRepository.find({ + where: { unitid: In(unitIdsBatch) }, + relations: ['unit'] // Include unit relation to access unit.name + }); + + allResponses = [...allResponses, ...responsesBatch]; + } + + if (allResponses.length === 0) { + this.logger.warn(`No responses found for units in workspace ${workspaceId}`); + return { + data: [], + total: 0, + page, + limit + }; + } const invalidVariables: InvalidVariableDto[] = []; - for (const response of responses) { + for (const response of allResponses) { const unit = response.unit; if (!unit) { this.logger.warn(`Response ${response.id} has no associated unit`); @@ -1451,9 +1550,14 @@ export class WorkspaceFilesService { const unitName = unit.name; const variableId = response.variableid; + + if (!variableId) { + this.logger.warn(`Response ${response.id} has no variable ID`); + continue; + } + const status = response.status; - // Check if the response status is one of the valid values if (!validStatusValues.includes(status)) { invalidVariables.push({ fileName: `Unit ${unitName}`, @@ -1465,7 +1569,6 @@ export class WorkspaceFilesService { } } - // Apply pagination const validPage = Math.max(1, page); const validLimit = Math.min(Math.max(1, limit), 1000); const startIndex = (validPage - 1) * validLimit; @@ -1493,7 +1596,17 @@ export class WorkspaceFilesService { limit: number; }> { try { - // Find TestTakers files in the workspace + if (!workspaceId) { + this.logger.error('Workspace ID is required'); + return { + testTakersFound: false, + groupsWithResponses: [], + allGroupsHaveResponses: false, + total: 0, + page, + limit + }; + } const testTakers = await this.fileUploadRepository.find({ where: { workspace_id: workspaceId, file_type: In(['TestTakers', 'Testtakers']) } }); @@ -1510,10 +1623,8 @@ export class WorkspaceFilesService { }; } - // Extract groups from TestTakers XML files const groups: Set = new Set(); - // Process all test takers for (const testTaker of testTakers) { const xmlDocument = cheerio.load(testTaker.data, { xml: true }); const groupElements = xmlDocument('Group'); @@ -1580,13 +1691,29 @@ export class WorkspaceFilesService { // Get all person IDs const personIds = persons.map(person => person.id); - // Find all units that belong to booklets that belong to these persons - const units = await this.unitRepository.createQueryBuilder('unit') - .innerJoin('unit.booklet', 'booklet') - .where('booklet.personid IN (:...personIds)', { personIds }) - .getMany(); + if (personIds.length === 0) { + this.logger.warn(`No person IDs found for group ${group} in workspace ${workspaceId}`); + groupsWithResponses.push({ group, hasResponse: false }); + allGroupsHaveResponses = false; + continue; + } + + const batchSize = 1000; + let allUnits: Unit[] = []; + + for (let i = 0; i < personIds.length; i += batchSize) { + const personIdsBatch = personIds.slice(i, i + batchSize); + + // Find units for this batch of person IDs + const unitsBatch = await this.unitRepository.createQueryBuilder('unit') + .innerJoin('unit.booklet', 'booklet') + .where('booklet.personid IN (:...personIdsBatch)', { personIdsBatch }) + .getMany(); - if (units.length === 0) { + allUnits = [...allUnits, ...unitsBatch]; + } + + if (allUnits.length === 0) { // No units found for persons in this group groupsWithResponses.push({ group, hasResponse: false }); allGroupsHaveResponses = false; @@ -1594,14 +1721,28 @@ export class WorkspaceFilesService { } // Get all unit IDs - const unitIds = units.map(unit => unit.id); + const unitIds = allUnits.map(unit => unit.id); - // Check if there's at least one response for these units - const responseCount = await this.responseRepository.count({ - where: { unitid: In(unitIds) } - }); + if (unitIds.length === 0) { + this.logger.warn(`No unit IDs found for group ${group} in workspace ${workspaceId}`); + groupsWithResponses.push({ group, hasResponse: false }); + allGroupsHaveResponses = false; + continue; + } - const hasResponse = responseCount > 0; + let totalResponseCount = 0; + + for (let i = 0; i < unitIds.length; i += batchSize) { + const unitIdsBatch = unitIds.slice(i, i + batchSize); + + const responseCountBatch = await this.responseRepository.count({ + where: { unitid: In(unitIdsBatch) } + }); + + totalResponseCount += responseCountBatch; + } + + const hasResponse = totalResponseCount > 0; groupsWithResponses.push({ group, hasResponse }); if (!hasResponse) { @@ -1630,14 +1771,18 @@ export class WorkspaceFilesService { } } - /** - * Deletes invalid responses from the database - * @param workspaceId The ID of the workspace - * @param responseIds Array of response IDs to delete - * @returns Number of deleted responses - */ async deleteInvalidResponses(workspaceId: number, responseIds: number[]): Promise { try { + if (!workspaceId) { + this.logger.error('Workspace ID is required'); + return 0; + } + + if (!responseIds || responseIds.length === 0) { + this.logger.warn('No response IDs provided for deletion'); + return 0; + } + this.logger.log(`Deleting invalid responses for workspace ${workspaceId}: ${responseIds.join(', ')}`); // Verify that the responses belong to units that belong to persons in the workspace @@ -1650,28 +1795,61 @@ export class WorkspaceFilesService { return 0; } + // Get all person IDs const personIds = persons.map(person => person.id); - const units = await this.unitRepository.createQueryBuilder('unit') - .innerJoin('unit.booklet', 'booklet') - .where('booklet.personid IN (:...personIds)', { personIds }) - .getMany(); + // Check if personIds array is empty + if (personIds.length === 0) { + this.logger.warn(`No person IDs found for workspace ${workspaceId}`); + return 0; + } + + const batchSize = 1000; + let allUnits: Unit[] = []; + + for (let i = 0; i < personIds.length; i += batchSize) { + const personIdsBatch = personIds.slice(i, i + batchSize); + + const unitsBatch = await this.unitRepository.createQueryBuilder('unit') + .innerJoin('unit.booklet', 'booklet') + .where('booklet.personid IN (:...personIdsBatch)', { personIdsBatch }) + .getMany(); - if (units.length === 0) { + allUnits = [...allUnits, ...unitsBatch]; + } + + if (allUnits.length === 0) { this.logger.warn(`No units found for persons in workspace ${workspaceId}`); return 0; } - const unitIds = units.map(unit => unit.id); + const unitIds = allUnits.map(unit => unit.id); - // Delete responses that match the given IDs and belong to the units in the workspace - const deleteResult = await this.responseRepository.delete({ - id: In(responseIds), - unitid: In(unitIds) - }); + if (unitIds.length === 0) { + this.logger.warn(`No unit IDs found for persons in workspace ${workspaceId}`); + return 0; + } + + let totalDeleted = 0; + + for (let i = 0; i < responseIds.length; i += batchSize) { + const responseIdsBatch = responseIds.slice(i, i + batchSize); + + for (let j = 0; j < unitIds.length; j += batchSize) { + const unitIdsBatch = unitIds.slice(j, j + batchSize); + + // Delete responses that match the given IDs and belong to the units in the workspace + const deleteResult = await this.responseRepository.delete({ + id: In(responseIdsBatch), + unitid: In(unitIdsBatch) + }); + + totalDeleted += deleteResult.affected || 0; + } + } - this.logger.log(`Deleted ${deleteResult.affected} invalid responses`); - return deleteResult.affected || 0; + this.logger.log(`Deleted ${totalDeleted} invalid responses`); + return totalDeleted; } catch (error) { this.logger.error(`Error deleting invalid responses: ${error.message}`, error.stack); throw error; From 3d28bea89c0d9dc95799e5d7654cd0e68975179b Mon Sep 17 00:00:00 2001 From: jurei733 <67505990+jurei733@users.noreply.github.com> Date: Thu, 10 Jul 2025 15:32:45 +0200 Subject: [PATCH 28/36] Improve results validation --- .../services/workspace-files.service.ts | 15 ++++--- .../coding-management.component.ts | 4 +- .../src/app/services/backend.service.ts | 2 +- .../frontend/src/app/services/file.service.ts | 2 +- .../validation-dialog.component.ts | 39 ++----------------- 5 files changed, 16 insertions(+), 46 deletions(-) diff --git a/apps/backend/src/app/database/services/workspace-files.service.ts b/apps/backend/src/app/database/services/workspace-files.service.ts index 5fd2f755b..b04d2df6b 100644 --- a/apps/backend/src/app/database/services/workspace-files.service.ts +++ b/apps/backend/src/app/database/services/workspace-files.service.ts @@ -1082,7 +1082,7 @@ export class WorkspaceFilesService { if (!unitVariables.has(unitName)) { invalidVariables.push({ - fileName: `Unit ${unitName}`, + fileName: `${unitName}`, variableId: variableId, value: response.value || '', responseId: response.id, @@ -1094,7 +1094,7 @@ export class WorkspaceFilesService { const unitVars = unitVariables.get(unitName); if (!unitVars || !unitVars.has(variableId)) { invalidVariables.push({ - fileName: `Unit ${unitName}`, + fileName: `${unitName}`, variableId: variableId, value: response.value || '', responseId: response.id, @@ -1276,7 +1276,7 @@ export class WorkspaceFilesService { if (!this.isValidValueForType(value, expectedType)) { invalidVariables.push({ - fileName: `Unit ${unitName}`, + fileName: `${unitName}`, variableId: variableId, value: value, responseId: response.id, @@ -1301,14 +1301,17 @@ export class WorkspaceFilesService { } private isValidValueForType(value: string, type: string): boolean { - if (!value && type !== 'string') { - return false; + if (!value) { + return true; // Skip validation for empty values } switch (type.toLowerCase()) { case 'string': return true; // All values are valid strings + case 'no-value': + return true; // Ignore validation for no-value type + case 'integer': // Check if the value is an integer return /^-?\d+$/.test(value); @@ -1560,7 +1563,7 @@ export class WorkspaceFilesService { if (!validStatusValues.includes(status)) { invalidVariables.push({ - fileName: `Unit ${unitName}`, + fileName: `${unitName}`, variableId: variableId, value: response.value || '', responseId: response.id, diff --git a/apps/frontend/src/app/coding/components/coding-managment/coding-management.component.ts b/apps/frontend/src/app/coding/components/coding-managment/coding-management.component.ts index 77c66b536..b1eb0c930 100755 --- a/apps/frontend/src/app/coding/components/coding-managment/coding-management.component.ts +++ b/apps/frontend/src/app/coding/components/coding-managment/coding-management.component.ts @@ -500,7 +500,7 @@ export class CodingManagementComponent implements AfterViewInit, OnInit, OnDestr const workspaceId = this.appService.selectedWorkspaceId; this.isLoading = true; - this.backendService.getUnitContentXml(workspaceId, unitId) + this.backendService.getUnitContentXml(workspaceId, unitId.toString()) .pipe( catchError(() => { this.isLoading = false; @@ -593,7 +593,7 @@ export class CodingManagementComponent implements AfterViewInit, OnInit, OnDestr const workspaceId = this.appService.selectedWorkspaceId; this.isLoading = true; - this.backendService.getUnitContentXml(workspaceId, unitId) + this.backendService.getUnitContentXml(workspaceId, unitId.toString()) .pipe( catchError(() => { this.isLoading = false; diff --git a/apps/frontend/src/app/services/backend.service.ts b/apps/frontend/src/app/services/backend.service.ts index 967715e6b..eee711f66 100755 --- a/apps/frontend/src/app/services/backend.service.ts +++ b/apps/frontend/src/app/services/backend.service.ts @@ -372,7 +372,7 @@ export class BackendService { return this.fileService.getCodingSchemeFile(workspaceId, codingSchemeRef); } - getUnitContentXml(workspaceId: number, unitId: number): Observable { + getUnitContentXml(workspaceId: number, unitId: string): Observable { return this.fileService.getUnitContentXml(workspaceId, unitId); } diff --git a/apps/frontend/src/app/services/file.service.ts b/apps/frontend/src/app/services/file.service.ts index 0c1c85758..57bb99217 100644 --- a/apps/frontend/src/app/services/file.service.ts +++ b/apps/frontend/src/app/services/file.service.ts @@ -147,7 +147,7 @@ export class FileService { { headers }); } - getUnitContentXml(workspaceId: number, unitId: number): Observable { + getUnitContentXml(workspaceId: number, unitId: string): Observable { return this.http.get<{ content: string }>( `${this.serverUrl}admin/workspace/${workspaceId}/unit/${unitId}/content`, { headers: this.authHeader } diff --git a/apps/frontend/src/app/ws-admin/components/validation-dialog/validation-dialog.component.ts b/apps/frontend/src/app/ws-admin/components/validation-dialog/validation-dialog.component.ts index 51c7e2cb7..dcb934a54 100644 --- a/apps/frontend/src/app/ws-admin/components/validation-dialog/validation-dialog.component.ts +++ b/apps/frontend/src/app/ws-admin/components/validation-dialog/validation-dialog.component.ts @@ -158,10 +158,8 @@ export class ValidationDialogComponent implements AfterViewInit, OnInit { selectedTypeResponses: Set = new Set(); selectedStatusResponses: Set = new Set(); - // Pagination properties - pageSizeOptions = [5, 10, 25, 50]; + pageSizeOptions = [25, 50, 100, 200]; - // Paginated data paginatedVariables = new MatTableDataSource([]); paginatedTypeVariables = new MatTableDataSource([]); paginatedStatusVariables = new MatTableDataSource([]); @@ -174,7 +172,6 @@ export class ValidationDialogComponent implements AfterViewInit, OnInit { ) {} ngOnInit(): void { - // Initialize component } ngAfterViewInit(): void { @@ -334,7 +331,6 @@ export class ValidationDialogComponent implements AfterViewInit, OnInit { this.isDeletingResponses = false; this.snackBar.open(`${deletedCount} Antworten gelöscht`, 'Schließen', { duration: 3000 }); - // Refresh the data after deletion this.validateVariables(); this.selectedResponses.clear(); }); @@ -346,7 +342,6 @@ export class ValidationDialogComponent implements AfterViewInit, OnInit { return; } - // Create confirmation dialog const dialogRef = this.dialog.open(ContentDialogComponent, { width: '400px', data: { @@ -361,7 +356,6 @@ export class ValidationDialogComponent implements AfterViewInit, OnInit { dialogRef.afterClosed().subscribe(deleteFromDb => { if (deleteFromDb) { this.isDeletingResponses = true; - // Get all response IDs const responseIds = this.invalidVariables .filter(variable => variable.responseId !== undefined) .map(variable => variable.responseId as number); @@ -371,7 +365,6 @@ export class ValidationDialogComponent implements AfterViewInit, OnInit { this.isDeletingResponses = false; this.snackBar.open(`${deletedCount} Antworten gelöscht`, 'Schließen', { duration: 3000 }); - // Refresh the data after deletion this.validateVariables(); this.selectedResponses.clear(); }); @@ -477,7 +470,6 @@ export class ValidationDialogComponent implements AfterViewInit, OnInit { this.isDeletingResponses = false; this.snackBar.open(`${deletedCount} Antworten gelöscht`, 'Schließen', { duration: 3000 }); - // Refresh the data after deletion this.validateVariableTypes(); this.selectedTypeResponses.clear(); }); @@ -489,7 +481,6 @@ export class ValidationDialogComponent implements AfterViewInit, OnInit { return; } - // Create confirmation dialog const dialogRef = this.dialog.open(ContentDialogComponent, { width: '400px', data: { @@ -504,7 +495,6 @@ export class ValidationDialogComponent implements AfterViewInit, OnInit { dialogRef.afterClosed().subscribe(deleteFromDb => { if (deleteFromDb) { this.isDeletingResponses = true; - // Get all response IDs const responseIds = this.invalidTypeVariables .filter(variable => variable.responseId !== undefined) .map(variable => variable.responseId as number); @@ -514,7 +504,6 @@ export class ValidationDialogComponent implements AfterViewInit, OnInit { this.isDeletingResponses = false; this.snackBar.open(`${deletedCount} Antworten gelöscht`, 'Schließen', { duration: 3000 }); - // Refresh the data after deletion this.validateVariableTypes(); this.selectedTypeResponses.clear(); }); @@ -566,7 +555,6 @@ export class ValidationDialogComponent implements AfterViewInit, OnInit { this.isDeletingResponses = false; this.snackBar.open(`${deletedCount} Antworten gelöscht`, 'Schließen', { duration: 3000 }); - // Refresh the data after deletion this.validateResponseStatus(); this.selectedStatusResponses.clear(); }); @@ -578,7 +566,6 @@ export class ValidationDialogComponent implements AfterViewInit, OnInit { return; } - // Create confirmation dialog const dialogRef = this.dialog.open(ContentDialogComponent, { width: '400px', data: { @@ -593,7 +580,6 @@ export class ValidationDialogComponent implements AfterViewInit, OnInit { dialogRef.afterClosed().subscribe(deleteFromDb => { if (deleteFromDb) { this.isDeletingResponses = true; - // Get all response IDs const responseIds = this.invalidStatusVariables .filter(variable => variable.responseId !== undefined) .map(variable => variable.responseId as number); @@ -602,8 +588,6 @@ export class ValidationDialogComponent implements AfterViewInit, OnInit { .subscribe(deletedCount => { this.isDeletingResponses = false; this.snackBar.open(`${deletedCount} Antworten gelöscht`, 'Schließen', { duration: 3000 }); - - // Refresh the data after deletion this.validateResponseStatus(); this.selectedStatusResponses.clear(); }); @@ -626,25 +610,8 @@ export class ValidationDialogComponent implements AfterViewInit, OnInit { }); } - /** - * Extracts the unit ID from the fileName - * @param fileName The fileName in the format "Unit unitName" - * @returns The unit name - */ - extractUnitName(fileName: string): string { - // The fileName is in the format "Unit unitName" - const match = fileName.match(/^Unit\s+(.+)$/); - return match ? match[1] : fileName; - } - - /** - * Shows the unit XML content in a dialog - * @param fileName The fileName in the format "Unit unitName" - */ - showUnitXml(fileName: string): void { - const unitName = this.extractUnitName(fileName); - - this.backendService.getUnitContentXml(this.appService.selectedWorkspaceId, Number(unitName)) + showUnitXml(unitName: string): void { + this.backendService.getUnitContentXml(this.appService.selectedWorkspaceId, unitName) .subscribe(xmlContent => { if (xmlContent) { this.dialog.open(ContentDialogComponent, { From 4868c166a4e8171e02e3ddc068a4f3dfc46c296f Mon Sep 17 00:00:00 2001 From: jurei733 <67505990+jurei733@users.noreply.github.com> Date: Thu, 10 Jul 2025 16:19:07 +0200 Subject: [PATCH 29/36] Give player fixed height --- .../replay/components/unit-player/unit-player.component.scss | 2 +- .../replay/components/unit-player/unit-player.component.ts | 5 ----- 2 files changed, 1 insertion(+), 6 deletions(-) diff --git a/apps/frontend/src/app/replay/components/unit-player/unit-player.component.scss b/apps/frontend/src/app/replay/components/unit-player/unit-player.component.scss index 426a46647..5026b008f 100755 --- a/apps/frontend/src/app/replay/components/unit-player/unit-player.component.scss +++ b/apps/frontend/src/app/replay/components/unit-player/unit-player.component.scss @@ -1,5 +1,5 @@ .unitHost { - height: 4000px; + height: 100vh; width: 100vw; border: none; } diff --git a/apps/frontend/src/app/replay/components/unit-player/unit-player.component.ts b/apps/frontend/src/app/replay/components/unit-player/unit-player.component.ts index 937ceb54e..53ac63edc 100755 --- a/apps/frontend/src/app/replay/components/unit-player/unit-player.component.ts +++ b/apps/frontend/src/app/replay/components/unit-player/unit-player.component.ts @@ -139,25 +139,20 @@ export class UnitPlayerComponent implements AfterViewInit, OnChanges, OnDestroy } private subscribeForValidPages(): void { - // Create an Observable that emits the current pageId whenever it changes const pageId$ = new Observable(observer => { - // Initial value observer.next(this.pageId() || ''); - // Set up a MutationObserver to watch for changes to the pageId input const callback = () => { observer.next(this.pageId() || ''); }; const interval = setInterval(callback, 500); - // Cleanup function return () => { clearInterval(interval); }; }); - // Use combineLatest to wait for both pageId and validPages to be available this.validPagesSubscription = combineLatest([ pageId$, this.validPages.pipe(debounceTime(2000)) From aea781976eeb0c8fdde796cd9ddfd4bcbcca60a7 Mon Sep 17 00:00:00 2001 From: jurei733 <67505990+jurei733@users.noreply.github.com> Date: Fri, 11 Jul 2025 18:17:09 +0200 Subject: [PATCH 30/36] Edit keycloak settings on server app --- apps/frontend/Dockerfile | 4 +++ apps/frontend/src/app/app.config.ts | 10 +++--- .../src/environments/environment.prod.ts | 34 +++++++++++++++++-- apps/frontend/src/environments/environment.ts | 34 +++++++++++++++++-- apps/frontend/src/index.html | 2 ++ config/frontend/runtime-config.sh | 19 +++++++++++ docker-compose.coding-box.prod.yaml | 5 +++ docker-compose.yaml | 5 +++ environments/environment.prod.ts | 4 --- environments/environment.ts | 4 --- 10 files changed, 103 insertions(+), 18 deletions(-) create mode 100644 config/frontend/runtime-config.sh delete mode 100755 environments/environment.prod.ts delete mode 100755 environments/environment.ts diff --git a/apps/frontend/Dockerfile b/apps/frontend/Dockerfile index cdae6499c..8050c4e75 100644 --- a/apps/frontend/Dockerfile +++ b/apps/frontend/Dockerfile @@ -23,6 +23,10 @@ ARG PROJECT USER root RUN chown -R nginx:root /usr/share/nginx/html +# Kopieren und ausführbar machen des Konfigurationsscripts +COPY --chown=nginx:root config/frontend/runtime-config.sh /docker-entrypoint.d/ +RUN chmod +x /docker-entrypoint.d/runtime-config.sh + USER nginx COPY --chown=nginx:root config/frontend/default.conf.http-template /etc/nginx/templates/default.conf.template diff --git a/apps/frontend/src/app/app.config.ts b/apps/frontend/src/app/app.config.ts index 5641e4988..dcc8c74d2 100755 --- a/apps/frontend/src/app/app.config.ts +++ b/apps/frontend/src/app/app.config.ts @@ -35,20 +35,18 @@ const allUrlsCondition = createInterceptorCondition export const provideKeycloakAngular = () => provideKeycloak({ config: { - url: 'https://www.iqb-login.de', - realm: 'iqb', - clientId: 'coding-box' + url: environment.keycloak.url, + realm: environment.keycloak.realm, + clientId: environment.keycloak.clientId }, initOptions: { onLoad: 'check-sso', - // redirectUri: 'https://iqb-kodierbox.de', - // onLoad: 'login-required', checkLoginIframe: false }, features: [ withAutoRefreshToken({ onInactivityTimeout: 'logout', - sessionTimeout: 60000 + sessionTimeout: 300000 }) ], diff --git a/apps/frontend/src/environments/environment.prod.ts b/apps/frontend/src/environments/environment.prod.ts index 832967390..0b26c41f9 100755 --- a/apps/frontend/src/environments/environment.prod.ts +++ b/apps/frontend/src/environments/environment.prod.ts @@ -1,4 +1,34 @@ -export const environment = { +declare global { + interface Window { + RUNTIME_CONFIG?: { + keycloak?: { + url: string; + realm: string; + clientId: string; + }; + backendUrl?: string; + }; + } +} + +// Standardkonfiguration, die durch Laufzeitkonfiguration überschrieben werden kann +const defaultConfig = { production: true, - backendUrl: 'api/' + backendUrl: 'api/', + keycloak: { + url: 'https://keycloak.kodierbox.iqb.hu-berlin.de/', + realm: 'iqb', + clientId: 'coding-box' + } +}; + +// Überschreiben der Standardkonfiguration mit Laufzeitkonfiguration, falls vorhanden +export const environment = { + ...defaultConfig, + backendUrl: window.RUNTIME_CONFIG?.backendUrl || defaultConfig.backendUrl, + keycloak: { + url: window.RUNTIME_CONFIG?.keycloak?.url || defaultConfig.keycloak.url, + realm: window.RUNTIME_CONFIG?.keycloak?.realm || defaultConfig.keycloak.realm, + clientId: window.RUNTIME_CONFIG?.keycloak?.clientId || defaultConfig.keycloak.clientId + } }; diff --git a/apps/frontend/src/environments/environment.ts b/apps/frontend/src/environments/environment.ts index e83eb82e9..77d3432c1 100755 --- a/apps/frontend/src/environments/environment.ts +++ b/apps/frontend/src/environments/environment.ts @@ -1,4 +1,34 @@ -export const environment = { +declare global { + interface Window { + RUNTIME_CONFIG?: { + keycloak?: { + url: string; + realm: string; + clientId: string; + }; + backendUrl?: string; + }; + } +} + +// Standardkonfiguration, die durch Laufzeitkonfiguration überschrieben werden kann +const defaultConfig = { production: false, - backendUrl: 'api/' + backendUrl: 'api/', + keycloak: { + url: 'https://keycloak.kodierbox.iqb.hu-berlin.de/', + realm: 'iqb', + clientId: 'coding-box' + } +}; + +// Überschreiben der Standardkonfiguration mit Laufzeitkonfiguration, falls vorhanden +export const environment = { + ...defaultConfig, + backendUrl: window.RUNTIME_CONFIG?.backendUrl || defaultConfig.backendUrl, + keycloak: { + url: window.RUNTIME_CONFIG?.keycloak?.url || defaultConfig.keycloak.url, + realm: window.RUNTIME_CONFIG?.keycloak?.realm || defaultConfig.keycloak.realm, + clientId: window.RUNTIME_CONFIG?.keycloak?.clientId || defaultConfig.keycloak.clientId + } }; diff --git a/apps/frontend/src/index.html b/apps/frontend/src/index.html index 154bc8a6f..970be8591 100755 --- a/apps/frontend/src/index.html +++ b/apps/frontend/src/index.html @@ -8,6 +8,8 @@ + + diff --git a/config/frontend/runtime-config.sh b/config/frontend/runtime-config.sh new file mode 100644 index 000000000..a44422744 --- /dev/null +++ b/config/frontend/runtime-config.sh @@ -0,0 +1,19 @@ +#!/bin/sh + +# Erstellen des Verzeichnisses für die Konfigurationsdatei +mkdir -p /usr/share/nginx/html/assets/config + +# Generieren der Konfigurationsdatei mit Umgebungsvariablen +cat > /usr/share/nginx/html/assets/config/runtime-config.js < Date: Sun, 13 Jul 2025 09:57:31 +0200 Subject: [PATCH 31/36] Customize database config --- database/Postgres.Dockerfile | 7 +++++++ database/config/postgresql.conf | 31 +++++++++++++++++++++++++++++++ 2 files changed, 38 insertions(+) create mode 100644 database/config/postgresql.conf diff --git a/database/Postgres.Dockerfile b/database/Postgres.Dockerfile index 8e1a41819..71aed9b6d 100644 --- a/database/Postgres.Dockerfile +++ b/database/Postgres.Dockerfile @@ -12,6 +12,10 @@ RUN --mount=type=cache,target=/var/cache/apk \ ENV LANG=de_DE.utf8 ENV TZ=Europe/Berlin +# Copy custom PostgreSQL configuration +COPY database/config/postgresql.conf /etc/postgresql/postgresql.conf + +# Copy healthcheck script COPY database/healthcheck/postgres-healthcheck /usr/local/bin/ HEALTHCHECK \ --interval=10s \ @@ -22,3 +26,6 @@ HEALTHCHECK \ CMD ["postgres-healthcheck"] EXPOSE 5432 + +# Use custom configuration file +CMD ["postgres", "-c", "config_file=/etc/postgresql/postgresql.conf"] diff --git a/database/config/postgresql.conf b/database/config/postgresql.conf new file mode 100644 index 000000000..d2c6faa60 --- /dev/null +++ b/database/config/postgresql.conf @@ -0,0 +1,31 @@ + +# Memory settings +shared_buffers = '2GB' # Increase shared buffer size for better caching +work_mem = '64MB' # Increase work memory for complex operations +maintenance_work_mem = '512MB' # Increase memory for maintenance operations like bulk loading + +# Write-Ahead Log (WAL) settings +wal_buffers = '16MB' # Increase WAL buffer size +synchronous_commit = 'off' # Disable synchronous commit for faster inserts (use with caution in production) +wal_writer_delay = '200ms' # Increase WAL writer delay to batch more WAL writes + +# Background writer settings +bgwriter_delay = '200ms' # Increase background writer delay +bgwriter_lru_maxpages = 100 # Increase max pages per round + +# Checkpoint settings +checkpoint_timeout = '15min' # Increase checkpoint timeout +max_wal_size = '2GB' # Increase max WAL size +checkpoint_completion_target = 0.9 # Spread checkpoint completion + +# Autovacuum settings +autovacuum = on # Keep autovacuum enabled +autovacuum_max_workers = 3 # Increase number of autovacuum workers +autovacuum_naptime = '1min' # Decrease autovacuum naptime for more frequent cleanup + +# Query planner settings +effective_cache_size = '2GB' # Estimate of how much memory is available for disk caching +random_page_cost = 1.1 # Lower value makes index scans more attractive + +# Logging settings +log_min_duration_statement = 1000 # Log statements that take more than 1 second From 2e98c71e74ef21869b791944c7abac0412d403ad Mon Sep 17 00:00:00 2001 From: jurei733 <67505990+jurei733@users.noreply.github.com> Date: Sun, 13 Jul 2025 10:52:48 +0200 Subject: [PATCH 32/36] Add indexes to db for faster bulk inserts --- .../changelog/coding-box.changelog-0.9.0.sql | 42 +++++++++++++++++++ .../changelog/coding-box.changelog-root.xml | 2 +- 2 files changed, 43 insertions(+), 1 deletion(-) create mode 100644 database/changelog/coding-box.changelog-0.9.0.sql diff --git a/database/changelog/coding-box.changelog-0.9.0.sql b/database/changelog/coding-box.changelog-0.9.0.sql new file mode 100644 index 000000000..14d83b7cb --- /dev/null +++ b/database/changelog/coding-box.changelog-0.9.0.sql @@ -0,0 +1,42 @@ +-- liquibase formatted sql + +-- changeset jurei733:1 + +CREATE INDEX IF NOT EXISTS idx_booklet_person ON booklet(personId); +CREATE INDEX IF NOT EXISTS idx_session_booklet ON session(bookletId); +CREATE INDEX IF NOT EXISTS idx_unit_booklet ON unit(bookletId); +CREATE INDEX IF NOT EXISTS idx_unitLog_unit ON unitLog(unitId); +CREATE INDEX IF NOT EXISTS idx_chunk_unit ON chunk(unitId); +CREATE INDEX IF NOT EXISTS idx_unitLastState_unit ON unitLastState(unitId); +CREATE INDEX IF NOT EXISTS idx_response_unit ON response(unitId); + +-- rollback DROP INDEX IF EXISTS idx_booklet_person; +-- rollback DROP INDEX IF EXISTS idx_session_booklet; +-- rollback DROP INDEX IF EXISTS idx_unit_booklet; +-- rollback DROP INDEX IF EXISTS idx_unitLog_unit; +-- rollback DROP INDEX IF EXISTS idx_chunk_unit; +-- rollback DROP INDEX IF EXISTS idx_unitLastState_unit; +-- rollback DROP INDEX IF EXISTS idx_response_unit; + + +-- changeset jurei733:2 + +CREATE INDEX IF NOT EXISTS idx_response_subform ON response(subform); + +-- Add index on the combination of unitId, variableId, and subform for better performance with bulk inserts +CREATE INDEX IF NOT EXISTS idx_response_unit_var_subform ON response(unitId, variableId, subform); + +-- Add index on the combination of status and codedStatus for better performance when filtering responses +CREATE INDEX IF NOT EXISTS idx_response_status_coded ON response(status, codedStatus); + +-- Add index on uploaded_at column in persons table for better performance when querying by upload date +CREATE INDEX IF NOT EXISTS idx_persons_uploaded_at ON persons(uploaded_at); + +-- Add index on the combination of workspace_id and uploaded_at for better performance when filtering by workspace and upload date +CREATE INDEX IF NOT EXISTS idx_persons_workspace_uploaded ON persons(workspace_id, uploaded_at); + +-- rollback DROP INDEX IF EXISTS idx_response_subform; +-- rollback DROP INDEX IF EXISTS idx_response_unit_var_subform; +-- rollback DROP INDEX IF EXISTS idx_response_status_coded; +-- rollback DROP INDEX IF EXISTS idx_persons_uploaded_at; +-- rollback DROP INDEX IF EXISTS idx_persons_workspace_uploaded; diff --git a/database/changelog/coding-box.changelog-root.xml b/database/changelog/coding-box.changelog-root.xml index 61e669159..83064345e 100644 --- a/database/changelog/coding-box.changelog-root.xml +++ b/database/changelog/coding-box.changelog-root.xml @@ -14,5 +14,5 @@ - + From a2a570aa74b9cbab421432097d8ca697ec9ca32f Mon Sep 17 00:00:00 2001 From: jurei733 <67505990+jurei733@users.noreply.github.com> Date: Sun, 13 Jul 2025 11:09:09 +0200 Subject: [PATCH 33/36] Set version to 0.9.0 --- apps/frontend/src/app/components/home/home.component.html | 2 +- package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/frontend/src/app/components/home/home.component.html b/apps/frontend/src/app/components/home/home.component.html index a2a6a6042..4a6baa817 100755 --- a/apps/frontend/src/app/components/home/home.component.html +++ b/apps/frontend/src/app/components/home/home.component.html @@ -9,7 +9,7 @@ [appTitle]="'Web application for coding'" [introHtml]="'appService.appConfig.introHtml'" [appName]="'IQB-Kodierbox'" - [appVersion]="'0.8.4'" + [appVersion]="'0.9.0'" [userName]="authData.userName" [userLongName]="appService.userProfile.firstName + ' ' + appService.userProfile.lastName" [isUserLoggedIn]="Number(authData.userId) > 0" diff --git a/package-lock.json b/package-lock.json index 3fd19d3e3..8ccfee75e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "coding-box", - "version": "0.8.4", + "version": "0.9.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "coding-box", - "version": "0.8.4", + "version": "0.9.0", "license": "MIT", "dependencies": { "@angular/animations": "20.0.3", diff --git a/package.json b/package.json index 47ea29532..1fc832732 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "coding-box", - "version": "0.8.4", + "version": "0.9.0", "author": "IQB - Institut zur Qualitätsentwicklung im Bildungswesen", "license": "MIT", "scripts": { From bba134210eda5ac044c87e08930ef26598497e70 Mon Sep 17 00:00:00 2001 From: jurei733 <67505990+jurei733@users.noreply.github.com> Date: Sun, 13 Jul 2025 11:58:17 +0200 Subject: [PATCH 34/36] Lower postgres performance settings --- database/config/postgresql.conf | 26 ++++---------------------- 1 file changed, 4 insertions(+), 22 deletions(-) diff --git a/database/config/postgresql.conf b/database/config/postgresql.conf index d2c6faa60..6c785cd0b 100644 --- a/database/config/postgresql.conf +++ b/database/config/postgresql.conf @@ -1,31 +1,13 @@ - # Memory settings -shared_buffers = '2GB' # Increase shared buffer size for better caching -work_mem = '64MB' # Increase work memory for complex operations -maintenance_work_mem = '512MB' # Increase memory for maintenance operations like bulk loading +shared_buffers = '1GB' # Increase shared buffer size for better caching +work_mem = '16MB' # Increase work memory for complex operations +maintenance_work_mem = '256MB' # Increase memory for maintenance operations like bulk loading # Write-Ahead Log (WAL) settings -wal_buffers = '16MB' # Increase WAL buffer size +wal_buffers = '8MB' # Increase WAL buffer size synchronous_commit = 'off' # Disable synchronous commit for faster inserts (use with caution in production) -wal_writer_delay = '200ms' # Increase WAL writer delay to batch more WAL writes - -# Background writer settings -bgwriter_delay = '200ms' # Increase background writer delay -bgwriter_lru_maxpages = 100 # Increase max pages per round # Checkpoint settings checkpoint_timeout = '15min' # Increase checkpoint timeout max_wal_size = '2GB' # Increase max WAL size checkpoint_completion_target = 0.9 # Spread checkpoint completion - -# Autovacuum settings -autovacuum = on # Keep autovacuum enabled -autovacuum_max_workers = 3 # Increase number of autovacuum workers -autovacuum_naptime = '1min' # Decrease autovacuum naptime for more frequent cleanup - -# Query planner settings -effective_cache_size = '2GB' # Estimate of how much memory is available for disk caching -random_page_cost = 1.1 # Lower value makes index scans more attractive - -# Logging settings -log_min_duration_statement = 1000 # Log statements that take more than 1 second From f1b77f3547c3a3846c53634e7b868538b6492a3a Mon Sep 17 00:00:00 2001 From: jurei733 <67505990+jurei733@users.noreply.github.com> Date: Sun, 13 Jul 2025 12:57:50 +0200 Subject: [PATCH 35/36] Include standard postgres config --- database/config/postgresql.conf | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/database/config/postgresql.conf b/database/config/postgresql.conf index 6c785cd0b..e93dad44e 100644 --- a/database/config/postgresql.conf +++ b/database/config/postgresql.conf @@ -1,3 +1,7 @@ +# custom.conf +include '/usr/share/postgresql/14/postgresql.conf.sample' # Standard-Konfig einbinden + + # Memory settings shared_buffers = '1GB' # Increase shared buffer size for better caching work_mem = '16MB' # Increase work memory for complex operations From f90d908526ae15919f2460083173c1f21b03c708 Mon Sep 17 00:00:00 2001 From: jurei733 <67505990+jurei733@users.noreply.github.com> Date: Sun, 13 Jul 2025 14:14:24 +0200 Subject: [PATCH 36/36] Fix postgres dockerfile --- database/Postgres.Dockerfile | 9 +++++++-- database/config/postgresql.conf | 3 --- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/database/Postgres.Dockerfile b/database/Postgres.Dockerfile index 71aed9b6d..07edc46ce 100644 --- a/database/Postgres.Dockerfile +++ b/database/Postgres.Dockerfile @@ -17,6 +17,11 @@ COPY database/config/postgresql.conf /etc/postgresql/postgresql.conf # Copy healthcheck script COPY database/healthcheck/postgres-healthcheck /usr/local/bin/ + +RUN chown postgres:postgres /etc/postgresql/postgresql.conf + +CMD ["postgres", "-c", "config_file=/etc/postgresql/postgresql.conf"] + HEALTHCHECK \ --interval=10s \ --timeout=3s \ @@ -27,5 +32,5 @@ HEALTHCHECK \ EXPOSE 5432 -# Use custom configuration file -CMD ["postgres", "-c", "config_file=/etc/postgresql/postgresql.conf"] + + diff --git a/database/config/postgresql.conf b/database/config/postgresql.conf index e93dad44e..7c7549cf0 100644 --- a/database/config/postgresql.conf +++ b/database/config/postgresql.conf @@ -1,6 +1,3 @@ -# custom.conf -include '/usr/share/postgresql/14/postgresql.conf.sample' # Standard-Konfig einbinden - # Memory settings shared_buffers = '1GB' # Increase shared buffer size for better caching