From e53d2b272ed9e6f20a05e9f4ef4efb8715c4a69f Mon Sep 17 00:00:00 2001 From: jurei733 <67505990+jurei733@users.noreply.github.com> Date: Sat, 28 Jun 2025 17:10:28 +0200 Subject: [PATCH 01/18] Patch version 0.7.3 --- apps/frontend/src/app/components/home/home.component.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/frontend/src/app/components/home/home.component.html b/apps/frontend/src/app/components/home/home.component.html index c9d48510b..7375a5c3f 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.7.2'" + [appVersion]="'0.7.3'" [userName]="authData.userName" [userLongName]="appService.userProfile.firstName + ' ' + appService.userProfile.lastName" [isUserLoggedIn]="Number(authData.userId) > 0" From 4d58071225157b202dbbf6ad937bb30f8b817177 Mon Sep 17 00:00:00 2001 From: jurei733 <67505990+jurei733@users.noreply.github.com> Date: Thu, 26 Jun 2025 15:27:01 +0200 Subject: [PATCH 02/18] Implement print view for unit --- .../workspace/workspace-player.controller.ts | 5 +-- .../services/workspace-player.service.ts | 4 +- apps/frontend/src/app/app.component.html | 8 ++-- apps/frontend/src/app/app.routes.ts | 3 +- .../components/replay/replay.component.html | 3 +- .../components/replay/replay.component.scss | 5 +++ .../components/replay/replay.component.ts | 25 +++++++++++-- .../unit-player/unit-player.component.scss | 7 +++- .../unit-player/unit-player.component.ts | 37 +++++++++++++++++-- .../src/app/services/backend.service.ts | 3 +- 10 files changed, 78 insertions(+), 22 deletions(-) diff --git a/apps/backend/src/app/admin/workspace/workspace-player.controller.ts b/apps/backend/src/app/admin/workspace/workspace-player.controller.ts index 4df69a8d8..93518636e 100644 --- a/apps/backend/src/app/admin/workspace/workspace-player.controller.ts +++ b/apps/backend/src/app/admin/workspace/workspace-player.controller.ts @@ -51,13 +51,12 @@ export class WorkspacePlayerController { return this.workspacePlayerService.findUnitDef(workspace_id, unitIdToUpperCase); } - @Get(':workspace_id/unit/:testPerson/:unitId') + @Get(':workspace_id/unit/:unitId') @UseGuards(JwtAuthGuard, WorkspaceGuard) @ApiParam({ name: 'workspace_id', type: Number }) async findUnit(@WorkspaceId() id: number, - @Param('testPerson') testPerson:string, @Param('unitId') unitId:string): Promise { const unitIdToUpperCase = unitId.toUpperCase(); - return this.workspacePlayerService.findUnit(id, testPerson, unitIdToUpperCase); + return this.workspacePlayerService.findUnit(id, unitIdToUpperCase); } } 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 8730f6fbc..080cd72ce 100644 --- a/apps/backend/src/app/database/services/workspace-player.service.ts +++ b/apps/backend/src/app/database/services/workspace-player.service.ts @@ -115,8 +115,8 @@ export class WorkspacePlayerService { } } - async findUnit(workspace_id: number, testPerson: string, unitId: string): Promise { - this.logger.log('Returning unit for test person', testPerson); + async findUnit(workspace_id: number, unitId: string): Promise { + this.logger.log('Returning unit for unitId', unitId); return this.fileUploadRepository.find( { where: { file_id: `${unitId}`, workspace_id: workspace_id } }); } diff --git a/apps/frontend/src/app/app.component.html b/apps/frontend/src/app/app.component.html index b02a2d0ac..59dc516f5 100755 --- a/apps/frontend/src/app/app.component.html +++ b/apps/frontend/src/app/app.component.html @@ -6,8 +6,8 @@ } - - @if (!url.path().includes('replay')) { + @if (!url.path().includes('replay') && !url.path().includes('print-view')) + {
@@ -24,9 +24,7 @@ [class.margin-logged-out]="!authService.isLoggedIn()"> IQB-Kodierbox - - - +
@if (authData.isAdmin || authService.getRoles().includes('admin')) { diff --git a/apps/frontend/src/app/app.routes.ts b/apps/frontend/src/app/app.routes.ts index f1ef256dc..b089921c2 100755 --- a/apps/frontend/src/app/app.routes.ts +++ b/apps/frontend/src/app/app.routes.ts @@ -21,9 +21,10 @@ export const routes: Routes = [ }, { path: 'replay/:testPerson/:unitId', - canActivate: [canActivateWithToken], + canActivate: [canActivateAuth], loadComponent: () => import('./replay/components/replay/replay.component').then(m => m.ReplayComponent) }, + { path: 'print-view/:unitId', canActivate: [canActivateWithToken], loadComponent: () => import('./replay/components/replay/replay.component').then(m => m.ReplayComponent) }, { path: 'replay/:testPerson', canActivate: [canActivateWithToken], loadComponent: () => import('./replay/components/replay/replay.component').then(m => m.ReplayComponent) }, { path: 'replay', canActivate: [canActivateWithToken], 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) }, diff --git a/apps/frontend/src/app/replay/components/replay/replay.component.html b/apps/frontend/src/app/replay/components/replay/replay.component.html index 55e73b717..730c30070 100755 --- a/apps/frontend/src/app/replay/components/replay/replay.component.html +++ b/apps/frontend/src/app/replay/components/replay/replay.component.html @@ -1,4 +1,4 @@ -
+
@if (!!player && unitDef ){ } diff --git a/apps/frontend/src/app/replay/components/replay/replay.component.scss b/apps/frontend/src/app/replay/components/replay/replay.component.scss index 3f804de12..dfe1c9b53 100755 --- a/apps/frontend/src/app/replay/components/replay/replay.component.scss +++ b/apps/frontend/src/app/replay/components/replay/replay.component.scss @@ -2,3 +2,8 @@ height: 100vh; background-color: white; } + +.print-mode { + height: auto; + min-height: 100vh; +} 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 52661346c..3d06167ff 100755 --- a/apps/frontend/src/app/replay/components/replay/replay.component.ts +++ b/apps/frontend/src/app/replay/components/replay/replay.component.ts @@ -57,6 +57,7 @@ export class ReplayComponent implements OnInit, OnDestroy, OnChanges { anchor: string | undefined; responses: any | undefined = undefined; dataElementAliases: string[] = []; + isPrintMode: boolean = false; private testPerson: string = ''; private unitId: string = ''; private authToken: string = ''; @@ -102,11 +103,22 @@ export class ReplayComponent implements OnInit, OnDestroy, OnChanges { ?.subscribe(async params => { this.resetSnackBars(); this.resetUnitData(); + this.authToken = await this.getAuthToken(); try { + // Check if we're in print-view mode + const url = this.route.snapshot.url; + this.isPrintMode = url.length > 0 && url[0].path === 'print-view'; + const testPersonInput = this.testPersonInput(); const unitIdInput = this.unitIdInput(); - if (Object.keys(params).length === 4) { - this.authToken = await this.getAuthToken(); + + if (this.isPrintMode && params.unitId) { + this.unitId = params.unitId; + const decoded: JwtPayload & { workspace: string } = jwtDecode(this.authToken); + const workspace = decoded?.workspace; + const unitData = await this.getUnitData(Number(workspace), this.authToken); + this.setUnitProperties(unitData); + } else if (Object.keys(params).length === 4) { this.setUnitParams(params); if (this.authToken) { const decoded: JwtPayload & { workspace: string } = jwtDecode(this.authToken); @@ -123,7 +135,7 @@ export class ReplayComponent implements OnInit, OnDestroy, OnChanges { } else if (testPersonInput && unitIdInput) { this.setTestPerson(testPersonInput); this.unitId = unitIdInput; - } else if (Object.keys(params).length !== 4) { + } else if (Object.keys(params).length !== 4 && !this.isPrintMode) { ReplayComponent.throwError('ParamsError'); } } catch (error) { @@ -249,6 +261,10 @@ export class ReplayComponent implements OnInit, OnDestroy, OnChanges { } private getResponses(workspace: number, authToken?:string): Observable { + // In print mode, we don't need responses, so return an empty array + if (this.isPrintMode) { + return of([]); + } return this.backendService .getResponses(workspace, this.testPerson, this.unitId, authToken); } @@ -260,7 +276,7 @@ export class ReplayComponent implements OnInit, OnDestroy, OnChanges { file_id: this.lastUnit.id }]); } - return this.backendService.getUnit(workspace, this.testPerson, this.unitId, authToken); + return this.backendService.getUnit(workspace, this.unitId, authToken); } private getPlayer( @@ -284,6 +300,7 @@ export class ReplayComponent implements OnInit, OnDestroy, OnChanges { this.getResponses(workspace, authToken), this.getUnit(workspace, authToken) .pipe(switchMap(unitFile => { + console.log(`UnitFile: ${unitFile}`); this.checkUnitId(unitFile); let player = ''; xml2js.parseString(unitFile[0].data, (err:any, result:any) => { 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 a9d4d792a..d72c25e1c 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,10 @@ .unitHost { - height: 100vh; + height: 2000px; width: 100vw; border: none; } + +:host(.print-mode) .unitHost { + height: auto; + /* min-height will be set dynamically based on content */ +} 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 a76700453..e56883fd4 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, Subject, Subscription, takeUntil + debounceTime, fromEvent, Subject, Subscription, takeUntil } from 'rxjs'; import { MatSnackBar } from '@angular/material/snack-bar'; import { AppService } from '../../../services/app.service'; @@ -43,6 +43,8 @@ export class UnitPlayerComponent implements AfterViewInit, OnChanges, OnDestroy readonly unitPlayer = input(); readonly unitResponses = input(); readonly pageId = input(); + readonly printMode = input(false); + iFrameHeight = input(); readonly invalidPage = output<'notInList' | 'notCurrent' | null>(); @ViewChild('hostingIframe') hostingIframe!: ElementRef; private validPages: Subject<{ pages: string[], current: string }> = new Subject(); @@ -82,6 +84,16 @@ export class UnitPlayerComponent implements AfterViewInit, OnChanges, OnDestroy private updateIframeContent(content: string): void { if (this.iFrameElement && this.iFrameElement.srcdoc !== content) { this.iFrameElement.srcdoc = content; + + // Add an event listener to recalculate height after content is loaded + fromEvent(this.iFrameElement, 'load') + .pipe(takeUntil(this.ngUnsubscribe)) + .subscribe(() => { + // Wait a bit for the content to render properly + setTimeout(() => { + this.calculateIFrameHeight(); + }, 500); + }); } } @@ -300,9 +312,10 @@ export class UnitPlayerComponent implements AfterViewInit, OnChanges, OnDestroy }, playerConfig: { stateReportPolicy: 'eager', - pagingMode: 'buttons', + ...(this.printMode() ? { pagingMode: 'concat-scroll' } : { pagingMode: 'buttons' }), directDownloadUrl: this.backendService.getDirectDownloadLink(), - startPage: this.pageId() || this.unitResponses()?.unit_state?.CURRENT_PAGE_ID || '' + startPage: this.pageId() || this.unitResponses()?.unit_state?.CURRENT_PAGE_ID || '', + ...(this.printMode() ? { printMode: 'on' } : {}) } }); } @@ -361,6 +374,24 @@ export class UnitPlayerComponent implements AfterViewInit, OnChanges, OnDestroy } } + private calculateIFrameHeight(): number | undefined { + const iframeDoc = this.iFrameElement?.contentDocument || this.iFrameElement?.contentWindow?.document; + const height = iframeDoc && iframeDoc.body.offsetHeight; + if (height) { + if (this.iFrameElement) { + if (this.printMode()) { + // Set the height directly on the iframe element when in print mode + this.iFrameElement.style.minHeight = `${height}px`; + } else { + // Reset the min-height when not in print mode + this.iFrameElement.style.minHeight = ''; + } + } + return height; + } + return undefined; + } + setPresentationStatus(status: string): void { const statusMapping: Record = { yes: 'complete', diff --git a/apps/frontend/src/app/services/backend.service.ts b/apps/frontend/src/app/services/backend.service.ts index 4d08ca023..7b4d3ddfb 100755 --- a/apps/frontend/src/app/services/backend.service.ts +++ b/apps/frontend/src/app/services/backend.service.ts @@ -521,13 +521,12 @@ export class BackendService { } getUnit(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}/unit/${testPerson}/${unitId}`, + `${this.serverUrl}admin/workspace/${workspaceId}/unit/${unitId}`, { headers }); } From 05e8e818ade6541360ee8b1a0c7d59f434940360 Mon Sep 17 00:00:00 2001 From: jurei733 <67505990+jurei733@users.noreply.github.com> Date: Fri, 27 Jun 2025 10:32:16 +0200 Subject: [PATCH 03/18] Add journal for tracking actions on test results data --- apps/backend/src/app/admin/admin.module.ts | 4 +- .../workspace/dto/create-journal-entry.dto.ts | 43 +++ .../dto/paginated-journal-entries.dto.ts | 32 ++ .../app/admin/workspace/journal.controller.ts | 333 ++++++++++++++++++ .../src/app/database/database.module.ts | 13 +- .../database/entities/journal-entry.entity.ts | 57 +++ .../app/database/services/journal.service.ts | 278 +++++++++++++++ apps/frontend/src/app/app.config.ts | 61 ++-- .../src/app/interceptors/auth.interceptor.ts | 59 ++-- .../components/replay/replay.component.ts | 1 - apps/frontend/src/app/services/app.service.ts | 2 +- .../src/app/services/journal-interceptor.ts | 156 ++++++++ .../src/app/services/journal.service.ts | 106 ++++++ .../components/journal/journal.component.html | 68 ++++ .../components/journal/journal.component.scss | 59 ++++ .../components/journal/journal.component.ts | 89 +++++ .../test-files/test-files.component.ts | 3 +- .../ws-settings/ws-settings.component.html | 9 + .../ws-settings/ws-settings.component.scss | 9 +- .../ws-settings/ws-settings.component.ts | 4 +- .../changelog/coding-box.changelog-0.8.0.sql | 21 ++ .../changelog/coding-box.changelog-root.xml | 1 + 22 files changed, 1337 insertions(+), 71 deletions(-) create mode 100644 apps/backend/src/app/admin/workspace/dto/create-journal-entry.dto.ts create mode 100644 apps/backend/src/app/admin/workspace/dto/paginated-journal-entries.dto.ts create mode 100644 apps/backend/src/app/admin/workspace/journal.controller.ts create mode 100644 apps/backend/src/app/database/entities/journal-entry.entity.ts create mode 100644 apps/backend/src/app/database/services/journal.service.ts create mode 100644 apps/frontend/src/app/services/journal-interceptor.ts create mode 100644 apps/frontend/src/app/services/journal.service.ts create mode 100644 apps/frontend/src/app/ws-admin/components/journal/journal.component.html create mode 100644 apps/frontend/src/app/ws-admin/components/journal/journal.component.scss create mode 100644 apps/frontend/src/app/ws-admin/components/journal/journal.component.ts create mode 100644 database/changelog/coding-box.changelog-0.8.0.sql diff --git a/apps/backend/src/app/admin/admin.module.ts b/apps/backend/src/app/admin/admin.module.ts index c00f274bb..ad9925809 100755 --- a/apps/backend/src/app/admin/admin.module.ts +++ b/apps/backend/src/app/admin/admin.module.ts @@ -14,6 +14,7 @@ import { LogoController } from './logo/logo.controller'; import { UnitTagsController } from './unit-tags/unit-tags.controller'; import { UnitNotesController } from './unit-notes/unit-notes.controller'; import { ResourcePackageController } from './resource-packages/resource-package.controller'; +import { JournalController } from './workspace/journal.controller'; @Module({ imports: [ @@ -33,7 +34,8 @@ import { ResourcePackageController } from './resource-packages/resource-package. LogoController, UnitTagsController, UnitNotesController, - ResourcePackageController + ResourcePackageController, + JournalController ], providers: [] }) diff --git a/apps/backend/src/app/admin/workspace/dto/create-journal-entry.dto.ts b/apps/backend/src/app/admin/workspace/dto/create-journal-entry.dto.ts new file mode 100644 index 000000000..c9ba066c3 --- /dev/null +++ b/apps/backend/src/app/admin/workspace/dto/create-journal-entry.dto.ts @@ -0,0 +1,43 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { + IsNotEmpty, + IsString, + IsOptional +} from 'class-validator'; + +/** + * DTO for creating a journal entry + */ +export class CreateJournalEntryDto { + @ApiProperty({ + description: 'Type of action performed (e.g., create, update, delete)', + example: 'create' + }) + @IsNotEmpty() + @IsString() + action_type: string; + + @ApiProperty({ + description: 'Type of entity that was affected (e.g., unit, response, file)', + example: 'unit' + }) + @IsNotEmpty() + @IsString() + entity_type: string; + + @ApiProperty({ + description: 'ID of the entity that was affected', + example: '123' + }) + @IsNotEmpty() + @IsString() + entity_id: string; + + @ApiProperty({ + description: 'Additional details about the action in JSON format', + example: '{"method":"POST","url":"/api/units","requestBody":{"name":"Test Unit"}}' + }) + @IsOptional() + @IsString() + details?: string; +} diff --git a/apps/backend/src/app/admin/workspace/dto/paginated-journal-entries.dto.ts b/apps/backend/src/app/admin/workspace/dto/paginated-journal-entries.dto.ts new file mode 100644 index 000000000..6c039483e --- /dev/null +++ b/apps/backend/src/app/admin/workspace/dto/paginated-journal-entries.dto.ts @@ -0,0 +1,32 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { JournalEntry } from '../../../database/entities/journal-entry.entity'; + +/** + * DTO for paginated journal entries response + */ +export class PaginatedJournalEntriesDto { + @ApiProperty({ + description: 'Array of journal entries', + type: JournalEntry, + isArray: true + }) + data: JournalEntry[]; + + @ApiProperty({ + description: 'Total number of journal entries', + example: 100 + }) + total: number; + + @ApiProperty({ + description: 'Current page number', + example: 1 + }) + page: number; + + @ApiProperty({ + description: 'Number of items per page', + example: 20 + }) + limit: number; +} diff --git a/apps/backend/src/app/admin/workspace/journal.controller.ts b/apps/backend/src/app/admin/workspace/journal.controller.ts new file mode 100644 index 000000000..bd67481ff --- /dev/null +++ b/apps/backend/src/app/admin/workspace/journal.controller.ts @@ -0,0 +1,333 @@ +import { + Body, + Controller, + Get, + Header, + Param, + Post, + Query, + Res, + UseGuards +} from '@nestjs/common'; +import { Response } from 'express'; +import { + ApiBearerAuth, + ApiBody, + ApiOperation, + ApiParam, + ApiQuery, + ApiResponse, + ApiTags +} from '@nestjs/swagger'; +import { JwtAuthGuard } from '../../auth/jwt-auth.guard'; +import { WorkspaceGuard } from './workspace.guard'; +import { WorkspaceId } from './workspace.decorator'; +import { JournalService } from '../../database/services/journal.service'; +import { JournalEntry } from '../../database/entities/journal-entry.entity'; +import { CreateJournalEntryDto } from './dto/create-journal-entry.dto'; +import { PaginatedJournalEntriesDto } from './dto/paginated-journal-entries.dto'; + +@ApiTags('Admin Workspace Journal') +@Controller('admin/workspace') +export class JournalController { + constructor(private readonly journalService: JournalService) {} + + @Post(':workspace_id/journal') + @ApiOperation({ + summary: 'Create a journal entry', + description: 'Creates a new journal entry for tracking actions in the workspace' + }) + @ApiParam({ name: 'workspace_id', type: Number, description: 'ID of the workspace' }) + @ApiBody({ type: CreateJournalEntryDto }) + @ApiResponse({ + status: 201, + description: 'Journal entry created successfully', + type: JournalEntry + }) + @ApiBearerAuth() + @UseGuards(JwtAuthGuard, WorkspaceGuard) + async createJournalEntry( + @WorkspaceId() workspaceId: number, + @Body() createJournalEntryDto: CreateJournalEntryDto + ): Promise { + // Get the user ID from the request (assuming it's available in the JWT token) + // For now, we'll use a placeholder + const userId = 'current-user'; // This should be replaced with actual user ID from JWT + + return this.journalService.createEntry( + userId, + workspaceId, + createJournalEntryDto.action_type, + createJournalEntryDto.entity_type, + parseInt(createJournalEntryDto.entity_id, 10), + createJournalEntryDto.details ? JSON.parse(createJournalEntryDto.details) : undefined + ); + } + + @Get(':workspace_id/journal') + @ApiOperation({ + summary: 'Get journal entries for a workspace', + description: 'Retrieves paginated journal entries for a workspace' + }) + @ApiParam({ name: 'workspace_id', type: Number, description: 'ID of the workspace' }) + @ApiQuery({ + name: 'page', + required: false, + description: 'Page number for pagination', + type: Number + }) + @ApiQuery({ + name: 'limit', + required: false, + description: 'Number of items per page', + type: Number + }) + @ApiQuery({ + name: 'userId', + required: false, + description: 'Filter by user ID', + type: String + }) + @ApiQuery({ + name: 'actionType', + required: false, + description: 'Filter by action type', + type: String + }) + @ApiQuery({ + name: 'entityType', + required: false, + description: 'Filter by entity type', + type: String + }) + @ApiQuery({ + name: 'entityId', + required: false, + description: 'Filter by entity ID', + type: Number + }) + @ApiQuery({ + name: 'fromDate', + required: false, + description: 'Filter by start date (ISO format)', + type: String + }) + @ApiQuery({ + name: 'toDate', + required: false, + description: 'Filter by end date (ISO format)', + type: String + }) + @ApiResponse({ + status: 200, + description: 'Journal entries retrieved successfully', + type: PaginatedJournalEntriesDto + }) + @ApiBearerAuth() + @UseGuards(JwtAuthGuard, WorkspaceGuard) + async getJournalEntries( + @WorkspaceId() workspaceId: number, + @Query('page') page?: number, + @Query('limit') limit?: number, + @Query('userId') userId?: string, + @Query('actionType') actionType?: string, + @Query('entityType') entityType?: string, + @Query('entityId') entityId?: number, + @Query('fromDate') fromDate?: string, + @Query('toDate') toDate?: string + ): Promise<{ data: JournalEntry[]; total: number }> { + const filters: { + workspaceId: number; + userId?: string; + actionType?: string; + entityType?: string; + entityId?: number; + fromDate?: Date; + toDate?: Date; + } = { + workspaceId + }; + + if (userId) { + filters.userId = userId; + } + + if (actionType) { + filters.actionType = actionType; + } + + if (entityType) { + filters.entityType = entityType; + } + + if (entityId) { + filters.entityId = entityId; + } + + if (fromDate) { + filters.fromDate = new Date(fromDate); + } + + if (toDate) { + filters.toDate = new Date(toDate); + } + + return this.journalService.search(filters, { page, limit }); + } + + @Get(':workspace_id/journal/entity/:entityType/:entityId') + @ApiOperation({ + summary: 'Get journal entries for a specific entity', + description: 'Retrieves paginated journal entries for a specific entity' + }) + @ApiParam({ name: 'workspace_id', type: Number, description: 'ID of the workspace' }) + @ApiParam({ name: 'entityType', type: String, description: 'Type of entity' }) + @ApiParam({ name: 'entityId', type: Number, description: 'ID of the entity' }) + @ApiQuery({ + name: 'page', + required: false, + description: 'Page number for pagination', + type: Number + }) + @ApiQuery({ + name: 'limit', + required: false, + description: 'Number of items per page', + type: Number + }) + @ApiResponse({ + status: 200, + description: 'Journal entries retrieved successfully', + type: PaginatedJournalEntriesDto + }) + @ApiBearerAuth() + @UseGuards(JwtAuthGuard, WorkspaceGuard) + async getJournalEntriesByEntity( + @WorkspaceId() workspaceId: number, + @Param('entityType') entityType: string, + @Param('entityId') entityId: number, + @Query('page') page?: number, + @Query('limit') limit?: number + ): Promise<{ data: JournalEntry[]; total: number }> { + return this.journalService.search( + { + workspaceId, + entityType, + entityId + }, + { page, limit } + ); + } + + @Get(':workspace_id/journal/user/:userId') + @ApiOperation({ + summary: 'Get journal entries for a specific user', + description: 'Retrieves paginated journal entries for a specific user' + }) + @ApiParam({ name: 'workspace_id', type: Number, description: 'ID of the workspace' }) + @ApiParam({ name: 'userId', type: String, description: 'ID of the user' }) + @ApiQuery({ + name: 'page', + required: false, + description: 'Page number for pagination', + type: Number + }) + @ApiQuery({ + name: 'limit', + required: false, + description: 'Number of items per page', + type: Number + }) + @ApiResponse({ + status: 200, + description: 'Journal entries retrieved successfully', + type: PaginatedJournalEntriesDto + }) + @ApiBearerAuth() + @UseGuards(JwtAuthGuard, WorkspaceGuard) + async getJournalEntriesByUser( + @WorkspaceId() workspaceId: number, + @Param('userId') userId: string, + @Query('page') page?: number, + @Query('limit') limit?: number + ): Promise<{ data: JournalEntry[]; total: number }> { + return this.journalService.search( + { + workspaceId, + userId + }, + { page, limit } + ); + } + + @Get(':workspace_id/journal/action/:actionType') + @ApiOperation({ + summary: 'Get journal entries for a specific action type', + description: 'Retrieves paginated journal entries for a specific action type' + }) + @ApiParam({ name: 'workspace_id', type: Number, description: 'ID of the workspace' }) + @ApiParam({ name: 'actionType', type: String, description: 'Type of action' }) + @ApiQuery({ + name: 'page', + required: false, + description: 'Page number for pagination', + type: Number + }) + @ApiQuery({ + name: 'limit', + required: false, + description: 'Number of items per page', + type: Number + }) + @ApiResponse({ + status: 200, + description: 'Journal entries retrieved successfully', + type: PaginatedJournalEntriesDto + }) + @ApiBearerAuth() + @UseGuards(JwtAuthGuard, WorkspaceGuard) + async getJournalEntriesByAction( + @WorkspaceId() workspaceId: number, + @Param('actionType') actionType: string, + @Query('page') page?: number, + @Query('limit') limit?: number + ): Promise<{ data: JournalEntry[]; total: number }> { + return this.journalService.search( + { + workspaceId, + actionType + }, + { page, limit } + ); + } + + @Get(':workspace_id/journal/csv') + @ApiOperation({ + summary: 'Download journal entries as CSV', + description: 'Downloads all journal entries for a workspace as a CSV file' + }) + @ApiParam({ name: 'workspace_id', type: Number, description: 'ID of the workspace' }) + @ApiResponse({ + status: 200, + description: 'CSV file generated successfully', + content: { + 'text/csv': { + schema: { + type: 'string', + format: 'binary' + } + } + } + }) + @Header('Content-Type', 'text/csv') + @Header('Content-Disposition', 'attachment; filename=journal-entries.csv') + @ApiBearerAuth() + @UseGuards(JwtAuthGuard, WorkspaceGuard) + async downloadJournalEntriesAsCsv( + @WorkspaceId() workspaceId: number, + @Res() response: Response + ): Promise { + const csvData = await this.journalService.generateCsv(workspaceId); + response.send(csvData); + } +} diff --git a/apps/backend/src/app/database/database.module.ts b/apps/backend/src/app/database/database.module.ts index 12b64aef0..104e9b5e9 100755 --- a/apps/backend/src/app/database/database.module.ts +++ b/apps/backend/src/app/database/database.module.ts @@ -36,6 +36,8 @@ import { AuthService } from '../auth/service/auth.service'; import { UnitTagService } from './services/unit-tag.service'; import { UnitNoteService } from './services/unit-note.service'; import { ResourcePackageService } from './services/resource-package.service'; +import { JournalEntry } from './entities/journal-entry.entity'; +import { JournalService } from './services/journal.service'; @Module({ imports: [ @@ -65,7 +67,7 @@ import { ResourcePackageService } from './services/resource-package.service'; password: configService.get('POSTGRES_PASSWORD'), database: configService.get('POSTGRES_DB'), entities: [BookletInfo, Booklet, Session, BookletLog, Unit, UnitLog, UnitLastState, ResponseEntity, - User, Workspace, WorkspaceAdmin, FileUpload, WorkspaceUser, ResourcePackage, Logs, Persons, ChunkEntity, BookletLog, Session, UnitLog, UnitTag, UnitNote + User, Workspace, WorkspaceAdmin, FileUpload, WorkspaceUser, ResourcePackage, Logs, Persons, ChunkEntity, BookletLog, Session, UnitLog, UnitTag, UnitNote, JournalEntry ], synchronize: false }), @@ -90,7 +92,8 @@ import { ResourcePackageService } from './services/resource-package.service'; UnitLastState, Session, UnitTag, - UnitNote + UnitNote, + JournalEntry ]) ], providers: [ @@ -108,7 +111,8 @@ import { ResourcePackageService } from './services/resource-package.service'; JwtService, UnitTagService, UnitNoteService, - ResourcePackageService + ResourcePackageService, + JournalService ], exports: [ User, @@ -132,7 +136,8 @@ import { ResourcePackageService } from './services/resource-package.service'; PersonService, AuthService, UnitTagService, - UnitNoteService + UnitNoteService, + JournalService ] }) export class DatabaseModule {} diff --git a/apps/backend/src/app/database/entities/journal-entry.entity.ts b/apps/backend/src/app/database/entities/journal-entry.entity.ts new file mode 100644 index 000000000..40aba92db --- /dev/null +++ b/apps/backend/src/app/database/entities/journal-entry.entity.ts @@ -0,0 +1,57 @@ +import { + Column, + CreateDateColumn, + Entity, + PrimaryGeneratedColumn +} from 'typeorm'; + +/** + * Entity representing a journal entry for tracking actions on test results data + */ +@Entity('journal_entries') +export class JournalEntry { + @PrimaryGeneratedColumn() + id: number; + + /** + * Timestamp when the action was performed + */ + @CreateDateColumn({ type: 'timestamp' }) + timestamp: Date; + + /** + * ID of the user who performed the action + */ + @Column({ name: 'user_id', nullable: false }) + userId: string; + + /** + * Workspace ID where the action was performed + */ + @Column({ name: 'workspace_id', nullable: false }) + workspaceId: number; + + /** + * Type of action performed (e.g., CREATE, UPDATE, DELETE) + */ + @Column({ name: 'action_type', nullable: false }) + actionType: string; + + /** + * Type of entity that was affected (e.g., UNIT, RESPONSE, PERSON, TAG) + */ + @Column({ name: 'entity_type', nullable: false }) + entityType: string; + + /** + * ID of the entity that was affected + */ + @Column({ name: 'entity_id', nullable: false }) + entityId: number; + + /** + * Additional details about the action in JSON format + */ + @Column({ type: 'jsonb', nullable: true }) + details: Record; +} diff --git a/apps/backend/src/app/database/services/journal.service.ts b/apps/backend/src/app/database/services/journal.service.ts new file mode 100644 index 000000000..002a130d7 --- /dev/null +++ b/apps/backend/src/app/database/services/journal.service.ts @@ -0,0 +1,278 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { JournalEntry } from '../entities/journal-entry.entity'; + +/** + * Service for managing journal entries + */ +@Injectable() +export class JournalService { + private readonly logger = new Logger(JournalService.name); + + constructor( + @InjectRepository(JournalEntry) + private journalRepository: Repository + ) {} + + /** + * Create a new journal entry + * @param userId ID of the user who performed the action + * @param workspaceId ID of the workspace where the action was performed + * @param actionType Type of action performed (e.g., CREATE, UPDATE, DELETE) + * @param entityType Type of entity that was affected (e.g., UNIT, RESPONSE, PERSON, TAG) + * @param entityId ID of the entity that was affected + * @param details Additional details about the action + * @returns The created journal entry + */ + async createEntry( + userId: string, + workspaceId: number, + actionType: string, + entityType: string, + entityId: number, + details?: Record + ): Promise { + try { + this.logger.log( + `Creating journal entry: user=${userId}, workspace=${workspaceId}, action=${actionType}, entity=${entityType}, entityId=${entityId}` + ); + + const entry = this.journalRepository.create({ + userId, + workspaceId, + actionType, + entityType, + entityId, + details + }); + + return await this.journalRepository.save(entry); + } catch (error) { + this.logger.error( + `Failed to create journal entry: ${error.message}`, + error.stack + ); + throw new Error(`Failed to create journal entry: ${error.message}`); + } + } + + /** + * Find journal entries by workspace ID + * @param workspaceId ID of the workspace + * @param options Pagination options + * @returns Journal entries for the workspace + */ + async findByWorkspace( + workspaceId: number, + options: { page?: number; limit?: number } = {} + ): Promise<{ data: JournalEntry[]; total: number }> { + try { + const page = options.page || 1; + const limit = options.limit || 20; + const skip = (page - 1) * limit; + + const [entries, total] = await this.journalRepository.findAndCount({ + where: { workspaceId }, + order: { timestamp: 'DESC' }, + skip, + take: limit + }); + + return { data: entries, total }; + } catch (error) { + this.logger.error( + `Failed to find journal entries for workspace ${workspaceId}: ${error.message}`, + error.stack + ); + throw new Error(`Failed to find journal entries: ${error.message}`); + } + } + + /** + * Find journal entries by user ID + * @param userId ID of the user + * @param options Pagination options + * @returns Journal entries for the user + */ + async findByUser( + userId: string, + options: { page?: number; limit?: number } = {} + ): Promise<{ data: JournalEntry[]; total: number }> { + try { + const page = options.page || 1; + const limit = options.limit || 20; + const skip = (page - 1) * limit; + + const [entries, total] = await this.journalRepository.findAndCount({ + where: { userId }, + order: { timestamp: 'DESC' }, + skip, + take: limit + }); + + return { data: entries, total }; + } catch (error) { + this.logger.error( + `Failed to find journal entries for user ${userId}: ${error.message}`, + error.stack + ); + throw new Error(`Failed to find journal entries: ${error.message}`); + } + } + + /** + * Find journal entries by entity type and ID + * @param entityType Type of entity + * @param entityId ID of the entity + * @param options Pagination options + * @returns Journal entries for the entity + */ + async findByEntity( + entityType: string, + entityId: number, + options: { page?: number; limit?: number } = {} + ): Promise<{ data: JournalEntry[]; total: number }> { + try { + const page = options.page || 1; + const limit = options.limit || 20; + const skip = (page - 1) * limit; + + const [entries, total] = await this.journalRepository.findAndCount({ + where: { entityType, entityId }, + order: { timestamp: 'DESC' }, + skip, + take: limit + }); + + return { data: entries, total }; + } catch (error) { + this.logger.error( + `Failed to find journal entries for entity ${entityType}:${entityId}: ${error.message}`, + error.stack + ); + throw new Error(`Failed to find journal entries: ${error.message}`); + } + } + + /** + * Search journal entries with filters + * @param filters Search filters + * @param options Pagination options + * @returns Journal entries matching the filters + */ + /** + * Generate CSV data for journal entries + * @param workspaceId ID of the workspace + * @returns CSV data as a string + */ + async generateCsv(workspaceId: number): Promise { + try { + // Get all journal entries for the workspace without pagination + const entries = await this.journalRepository.find({ + where: { workspaceId }, + order: { timestamp: 'DESC' } + }); + + if (entries.length === 0) { + return 'No journal entries found'; + } + + // CSV header + const header = [ + 'ID', + 'Timestamp', + 'User ID', + 'Action Type', + 'Entity Type', + 'Entity ID', + 'Details' + ].join(','); + + // CSV rows + const rows = entries.map(entry => { + const details = entry.details ? JSON.stringify(entry.details).replace(/"/g, '""') : ''; + return [ + entry.id, + entry.timestamp.toISOString(), + entry.userId, + entry.actionType, + entry.entityType, + entry.entityId, + `"${details}"` + ].join(','); + }); + + return [header, ...rows].join('\n'); + } catch (error) { + this.logger.error( + `Failed to generate CSV for workspace ${workspaceId}: ${error.message}`, + error.stack + ); + throw new Error(`Failed to generate CSV: ${error.message}`); + } + } + + async search( + filters: { + workspaceId?: number; + userId?: string; + actionType?: string; + entityType?: string; + entityId?: number; + fromDate?: Date; + toDate?: Date; + }, + options: { page?: number; limit?: number } = {} + ): Promise<{ data: JournalEntry[]; total: number }> { + try { + const page = options.page || 1; + const limit = options.limit || 20; + const skip = (page - 1) * limit; + + const queryBuilder = this.journalRepository.createQueryBuilder('journal'); + + if (filters.workspaceId) { + queryBuilder.andWhere('journal.workspaceId = :workspaceId', { workspaceId: filters.workspaceId }); + } + + if (filters.userId) { + queryBuilder.andWhere('journal.userId = :userId', { userId: filters.userId }); + } + + if (filters.actionType) { + queryBuilder.andWhere('journal.actionType = :actionType', { actionType: filters.actionType }); + } + + if (filters.entityType) { + queryBuilder.andWhere('journal.entityType = :entityType', { entityType: filters.entityType }); + } + + if (filters.entityId) { + queryBuilder.andWhere('journal.entityId = :entityId', { entityId: filters.entityId }); + } + + if (filters.fromDate) { + queryBuilder.andWhere('journal.timestamp >= :fromDate', { fromDate: filters.fromDate }); + } + + if (filters.toDate) { + queryBuilder.andWhere('journal.timestamp <= :toDate', { toDate: filters.toDate }); + } + + queryBuilder.orderBy('journal.timestamp', 'DESC'); + queryBuilder.skip(skip); + queryBuilder.take(limit); + + const [entries, total] = await queryBuilder.getManyAndCount(); + + return { data: entries, total }; + } catch (error) { + this.logger.error( + `Failed to search journal entries: ${error.message}`, + error.stack + ); + throw new Error(`Failed to search journal entries: ${error.message}`); + } + } +} diff --git a/apps/frontend/src/app/app.config.ts b/apps/frontend/src/app/app.config.ts index c0ad0e39f..fbf518adb 100755 --- a/apps/frontend/src/app/app.config.ts +++ b/apps/frontend/src/app/app.config.ts @@ -4,7 +4,11 @@ import { } from '@angular/core'; import { provideRouter } from '@angular/router'; import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; -import { HTTP_INTERCEPTORS, HttpClient, provideHttpClient } from '@angular/common/http'; +import { + HttpClient, + provideHttpClient, + withInterceptors +} from '@angular/common/http'; import { TranslateHttpLoader } from '@ngx-translate/http-loader'; import { provideAnimationsAsync } from '@angular/platform-browser/animations/async'; import { @@ -17,7 +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 { authInterceptor } from './interceptors/auth.interceptor'; +import { journalInterceptor } from './services/journal-interceptor'; export function createTranslateLoader(http: HttpClient): TranslateHttpLoader { return new TranslateHttpLoader(http, './assets/i18n/', '.json'); @@ -55,30 +60,30 @@ export const provideKeycloakAngular = () => provideKeycloak({ }); export const appConfig: ApplicationConfig = { - providers: [provideHttpClient(), { - provide: HTTP_INTERCEPTORS, - useClass: AuthInterceptor, - multi: true - }, - importProvidersFrom(TranslateModule.forRoot({ - defaultLanguage: 'de', - loader: { - provide: TranslateLoader, - useFactory: createTranslateLoader, - deps: [HttpClient] - } - })), - provideKeycloakAngular(), - provideRouter(routes), - provideAnimationsAsync(), - { - provide: 'SERVER_URL', - useValue: environment.backendUrl - }, - { - provide: LocationStrategy, - useClass: HashLocationStrategy - }, - provideAppInitializer(() => { - })] + providers: [ + provideHttpClient( + withInterceptors([journalInterceptor, authInterceptor]) + ), + importProvidersFrom(TranslateModule.forRoot({ + defaultLanguage: 'de', + loader: { + provide: TranslateLoader, + useFactory: createTranslateLoader, + deps: [HttpClient] + } + })), + provideKeycloakAngular(), + provideRouter(routes), + provideAnimationsAsync(), + { + provide: 'SERVER_URL', + useValue: environment.backendUrl + }, + { + provide: LocationStrategy, + useClass: HashLocationStrategy + }, + provideAppInitializer(() => { + }) + ] }; diff --git a/apps/frontend/src/app/interceptors/auth.interceptor.ts b/apps/frontend/src/app/interceptors/auth.interceptor.ts index eee736f30..4d56bfc7c 100644 --- a/apps/frontend/src/app/interceptors/auth.interceptor.ts +++ b/apps/frontend/src/app/interceptors/auth.interceptor.ts @@ -1,35 +1,36 @@ -import { Injectable, inject } from '@angular/core'; +import { inject } from '@angular/core'; import { - HttpRequest, HttpHandler, HttpEvent, HttpInterceptor, HttpHeaders + HttpEvent, HttpHandlerFn, HttpHeaders, HttpInterceptorFn, HttpRequest } from '@angular/common/http'; import { finalize, Observable, tap } from 'rxjs'; import { AppHttpError } from './app-http-error.class'; import { AppService } from '../services/app.service'; -@Injectable({ - providedIn: 'root' -}) -export class AuthInterceptor implements HttpInterceptor { - private appService = inject(AppService); - readonly appVersion = inject('APP_VERSION' as any); - intercept(req: HttpRequest, next: HttpHandler): Observable> { - const idToken = 'ssss';// localStorage.getItem('id_token'); - const headers = new HttpHeaders({ Authorization: `Bearer ${idToken}` }); - let httpErrorInfo: AppHttpError | null = null; - return next.handle(req.clone({ headers })) - .pipe( - tap({ - error: error => { - httpErrorInfo = new AppHttpError(error); - } - }), - finalize(() => { - if (httpErrorInfo) { - httpErrorInfo.method = req.method; - httpErrorInfo.urlWithParams = req.urlWithParams; - this.appService.addErrorMessage(httpErrorInfo); - } - }) - ); - } -} +/** + * Functional interceptor for adding authentication headers and handling errors + */ +export const authInterceptor: HttpInterceptorFn = ( + req: HttpRequest, + next: HttpHandlerFn +): Observable> => { + const appService = inject(AppService); + const idToken = localStorage.getItem('id_token'); + const headers = new HttpHeaders({ Authorization: `Bearer ${idToken}` }); + let httpErrorInfo: AppHttpError | null = null; + + return next(req.clone({ headers })) + .pipe( + tap({ + error: error => { + httpErrorInfo = new AppHttpError(error); + } + }), + finalize(() => { + if (httpErrorInfo) { + httpErrorInfo.method = req.method; + httpErrorInfo.urlWithParams = req.urlWithParams; + appService.addErrorMessage(httpErrorInfo); + } + }) + ); +}; 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 3d06167ff..d1b0b4612 100755 --- a/apps/frontend/src/app/replay/components/replay/replay.component.ts +++ b/apps/frontend/src/app/replay/components/replay/replay.component.ts @@ -300,7 +300,6 @@ export class ReplayComponent implements OnInit, OnDestroy, OnChanges { this.getResponses(workspace, authToken), this.getUnit(workspace, authToken) .pipe(switchMap(unitFile => { - console.log(`UnitFile: ${unitFile}`); this.checkUnitId(unitFile); let player = ''; xml2js.parseString(unitFile[0].data, (err:any, result:any) => { diff --git a/apps/frontend/src/app/services/app.service.ts b/apps/frontend/src/app/services/app.service.ts index 03632b39b..1426f7e05 100755 --- a/apps/frontend/src/app/services/app.service.ts +++ b/apps/frontend/src/app/services/app.service.ts @@ -28,7 +28,7 @@ type WorkspaceData = { providedIn: 'root' }) export class AppService { - private readonly serverUrl = inject('SERVER_URL' as any); + public readonly serverUrl = inject('SERVER_URL' as any); private http = inject(HttpClient); private logoService = inject(LogoService); diff --git a/apps/frontend/src/app/services/journal-interceptor.ts b/apps/frontend/src/app/services/journal-interceptor.ts new file mode 100644 index 000000000..9b309f29f --- /dev/null +++ b/apps/frontend/src/app/services/journal-interceptor.ts @@ -0,0 +1,156 @@ +import { inject } from '@angular/core'; +import { + HttpInterceptorFn, + HttpRequest, + HttpHandlerFn, + HttpEvent, + HttpResponse +} from '@angular/common/http'; +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 +): Observable> => { + const appService = inject(AppService); + const journalService = inject(JournalService); + + // Only intercept requests to the backend API + if (!request.url.startsWith(appService.serverUrl)) { + return next(request); + } + + // Skip journal-related requests to avoid infinite loops + if (request.url.includes('/journal')) { + return next(request); + } + + return next(request).pipe( + tap(event => { + if (event instanceof HttpResponse) { + // Only log successful requests that modify data + if (isDataModifyingRequest(request) && event.status >= 200 && event.status < 300) { + logAction(request, event, appService, journalService); + } + } + }) + ); +}; + +/** + * 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); +} + +/** + * Logs an action to the journal + */ +function logAction( + request: HttpRequest, + response: HttpResponse, + appService: AppService, + journalService: JournalService +): void { + const workspaceId = appService.selectedWorkspaceId; + if (!workspaceId) { + 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, + requestBody: request.body ? sanitizeBody(request.body) : null, + responseStatus: response.status, + responseBody: response.body ? sanitizeBody(response.body) : null + }); + + // Log the action to the journal + journalService.createJournalEntry( + workspaceId, + actionType, + entityType, + entityId, + details + ).subscribe(); +} + +/** + * Gets the action type based on the HTTP method + */ +function getActionType(method: string): string { + switch (method) { + case 'POST': return 'create'; + case 'PUT': + case 'PATCH': return 'update'; + case 'DELETE': return 'delete'; + default: return 'unknown'; + } +} + +/** + * 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'; + 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'; +} + +/** + * 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]+$/; + + for (let i = parts.length - 1; i >= 0; i--) { + if (idPattern.test(parts[i])) { + return parts[i]; + } + } + + return 'unknown'; +} + +/** + * Sanitizes the request/response body by removing sensitive information + */ +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; +} diff --git a/apps/frontend/src/app/services/journal.service.ts b/apps/frontend/src/app/services/journal.service.ts new file mode 100644 index 000000000..1b15b4d47 --- /dev/null +++ b/apps/frontend/src/app/services/journal.service.ts @@ -0,0 +1,106 @@ +import { Injectable, inject } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { Observable, catchError, of } from 'rxjs'; + +export interface JournalEntry { + id: number; + timestamp: Date; + user_id: string; + action_type: string; + entity_type: string; + entity_id: string; + details: string; +} + +export interface PaginatedJournalEntries { + data: JournalEntry[]; + total: number; + page: number; + limit: number; +} + +@Injectable({ + providedIn: 'root' +}) +export class JournalService { + private readonly serverUrl = inject('SERVER_URL' as any); + private http = inject(HttpClient); + + authHeader = { Authorization: `Bearer ${localStorage.getItem('id_token')}` }; + + /** + * Get journal entries for a workspace + * @param workspaceId The ID of the workspace + * @param page The page number + * @param limit The number of entries per page + * @returns An Observable of paginated journal entries + */ + getJournalEntries(workspaceId: number, page: number = 1, limit: number = 20): Observable { + return this.http.get( + `${this.serverUrl}admin/workspace/${workspaceId}/journal?page=${page}&limit=${limit}`, + { headers: this.authHeader } + ).pipe( + catchError(() => of({ + data: [], + total: 0, + page, + limit + })) + ); + } + + /** + * Create a journal entry + * @param workspaceId The ID of the workspace + * @param actionType The type of action (e.g., 'create', 'update', 'delete') + * @param entityType The type of entity (e.g., 'unit', 'response', 'file') + * @param entityId The ID of the entity + * @param details Additional details about the action + * @returns An Observable of the created journal entry + */ + createJournalEntry( + workspaceId: number, + actionType: string, + entityType: string, + entityId: string, + details: string + ): Observable { + return this.http.post( + `${this.serverUrl}admin/workspace/${workspaceId}/journal`, + { + action_type: actionType, + entity_type: entityType, + entity_id: entityId, + details + }, + { headers: this.authHeader } + ).pipe( + catchError(() => of({ + id: 0, + timestamp: new Date(), + user_id: '', + action_type: actionType, + entity_type: entityType, + entity_id: entityId, + details + })) + ); + } + + /** + * Download journal entries as CSV + * @param workspaceId The ID of the workspace + * @returns An Observable of the CSV data as a Blob + */ + downloadJournalEntriesAsCsv(workspaceId: number): Observable { + return this.http.get( + `${this.serverUrl}admin/workspace/${workspaceId}/journal/csv`, + { + headers: this.authHeader, + responseType: 'blob' + } + ).pipe( + catchError(() => of(new Blob([]))) + ); + } +} diff --git a/apps/frontend/src/app/ws-admin/components/journal/journal.component.html b/apps/frontend/src/app/ws-admin/components/journal/journal.component.html new file mode 100644 index 000000000..58ab0d22b --- /dev/null +++ b/apps/frontend/src/app/ws-admin/components/journal/journal.component.html @@ -0,0 +1,68 @@ +
+
+ +
+ +
+ @if (loading) { +
+

Lade Journal-Einträge...

+
+ } @else if (journalEntries.length === 0) { +
+

Keine Journal-Einträge gefunden.

+
+ } @else { + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Zeitstempel{{ entry.timestamp | date:'dd.MM.yyyy HH:mm:ss' }}Benutzer{{ entry.userId }}Aktion{{ entry.actionType }}Entitätstyp{{ entry.entityType }}Entitäts-ID{{ entry.entityId }}Details{{ entry.details | json }}
+ + + + } +
+
diff --git a/apps/frontend/src/app/ws-admin/components/journal/journal.component.scss b/apps/frontend/src/app/ws-admin/components/journal/journal.component.scss new file mode 100644 index 000000000..8e4a79779 --- /dev/null +++ b/apps/frontend/src/app/ws-admin/components/journal/journal.component.scss @@ -0,0 +1,59 @@ +.journal-container { + padding: 16px; +} + +.journal-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 16px; +} + +.journal-table-container { + background-color: white; + border-radius: 4px; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + padding: 16px; +} + +.journal-table { + width: 100%; +} + +.loading-indicator, .no-entries { + padding: 24px; + text-align: center; + color: rgba(0, 0, 0, 0.54); +} + +.mat-column-timestamp { + width: 180px; +} + +.mat-column-user_id { + width: 120px; +} + +.mat-column-action_type { + width: 100px; +} + +.mat-column-entity_type { + width: 120px; +} + +.mat-column-entity_id { + width: 100px; +} + +.mat-column-details { + min-width: 200px; +} + +/* Make the table responsive */ +@media (max-width: 768px) { + .journal-table { + display: block; + overflow-x: auto; + } +} diff --git a/apps/frontend/src/app/ws-admin/components/journal/journal.component.ts b/apps/frontend/src/app/ws-admin/components/journal/journal.component.ts new file mode 100644 index 000000000..70b41f92f --- /dev/null +++ b/apps/frontend/src/app/ws-admin/components/journal/journal.component.ts @@ -0,0 +1,89 @@ +import { Component, OnInit, inject } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { MatTableModule } from '@angular/material/table'; +import { MatPaginatorModule, PageEvent } from '@angular/material/paginator'; +import { MatButtonModule } from '@angular/material/button'; +import { MatIconModule } from '@angular/material/icon'; +import { MatCardModule } from '@angular/material/card'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { TranslateModule } from '@ngx-translate/core'; + +import { AppService } from '../../../services/app.service'; +import { JournalService, JournalEntry } from '../../../services/journal.service'; + +@Component({ + selector: 'coding-box-journal', + templateUrl: './journal.component.html', + styleUrls: ['./journal.component.scss'], + standalone: true, + imports: [ + CommonModule, + MatTableModule, + MatPaginatorModule, + MatButtonModule, + MatIconModule, + MatCardModule, + TranslateModule + ] +}) +export class JournalComponent implements OnInit { + private appService = inject(AppService); + private journalService = inject(JournalService); + private snackBar = inject(MatSnackBar); + + journalEntries: JournalEntry[] = []; + displayedColumns: string[] = ['timestamp', 'userId', 'actionType', 'entityType', 'entityId', 'details']; + totalEntries = 0; + pageSize = 20; + pageIndex = 0; + loading = false; + + ngOnInit(): void { + this.loadJournalEntries(); + } + + loadJournalEntries(): void { + this.loading = true; + const workspaceId = this.appService.selectedWorkspaceId; + + this.journalService.getJournalEntries(workspaceId, this.pageIndex + 1, this.pageSize) + .subscribe({ + next: response => { + this.journalEntries = response.data; + this.totalEntries = response.total; + this.loading = false; + }, + error: () => { + this.snackBar.open('Fehler beim Laden der Journal-Einträge', 'Schließen', { duration: 3000 }); + this.loading = false; + } + }); + } + + handlePageEvent(event: PageEvent): void { + this.pageSize = event.pageSize; + this.pageIndex = event.pageIndex; + this.loadJournalEntries(); + } + + downloadCsv(): void { + const workspaceId = this.appService.selectedWorkspaceId; + + this.journalService.downloadJournalEntriesAsCsv(workspaceId) + .subscribe({ + next: blob => { + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `journal_entries_workspace_${workspaceId}.csv`; + document.body.appendChild(a); + a.click(); + window.URL.revokeObjectURL(url); + document.body.removeChild(a); + }, + error: () => { + this.snackBar.open('Fehler beim Herunterladen der CSV-Datei', 'Schließen', { duration: 3000 }); + } + }); + } +} 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 e6cc6db59..640e9461b 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 @@ -73,8 +73,7 @@ import { ContentDialogComponent } from '../../../shared/dialogs/content-dialog/c MatLabel, MatSelect, MatOption, - MatPaginator, - ContentDialogComponent + MatPaginator ] }) export class TestFilesComponent implements OnInit, OnDestroy { diff --git a/apps/frontend/src/app/ws-admin/components/ws-settings/ws-settings.component.html b/apps/frontend/src/app/ws-admin/components/ws-settings/ws-settings.component.html index 21a1721e1..6a7d75b68 100755 --- a/apps/frontend/src/app/ws-admin/components/ws-settings/ws-settings.component.html +++ b/apps/frontend/src/app/ws-admin/components/ws-settings/ws-settings.component.html @@ -47,4 +47,13 @@

Generiertes Token

+ + + + System Journal + + + + +
diff --git a/apps/frontend/src/app/ws-admin/components/ws-settings/ws-settings.component.scss b/apps/frontend/src/app/ws-admin/components/ws-settings/ws-settings.component.scss index 30e61b85c..bb0a32819 100755 --- a/apps/frontend/src/app/ws-admin/components/ws-settings/ws-settings.component.scss +++ b/apps/frontend/src/app/ws-admin/components/ws-settings/ws-settings.component.scss @@ -4,12 +4,13 @@ padding: 0 20px; width: 100%; display: flex; - flex-direction: row; + flex-direction: column; gap: 30px; } .token-settings-card, -.access-rights-card { +.access-rights-card, +.journal-card { box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); border-radius: 8px; overflow: hidden; @@ -103,12 +104,12 @@ button mat-icon { /* Responsive styles */ @media (max-width: 992px) { .wrapper { - flex-direction: column; gap: 20px; } .token-settings-card, - .access-rights-card { + .access-rights-card, + .journal-card { margin-bottom: 20px; } } diff --git a/apps/frontend/src/app/ws-admin/components/ws-settings/ws-settings.component.ts b/apps/frontend/src/app/ws-admin/components/ws-settings/ws-settings.component.ts index dde0c2322..43ed05a5f 100755 --- a/apps/frontend/src/app/ws-admin/components/ws-settings/ws-settings.component.ts +++ b/apps/frontend/src/app/ws-admin/components/ws-settings/ws-settings.component.ts @@ -12,6 +12,7 @@ import { Clipboard } from '@angular/cdk/clipboard'; import { AppService } from '../../../services/app.service'; import { WsAccessRightsComponent } from '../ws-access-rights/ws-access-rights.component'; +import { JournalComponent } from '../journal/journal.component'; @Component({ selector: 'coding-box-ws-settings', @@ -26,7 +27,8 @@ import { WsAccessRightsComponent } from '../ws-access-rights/ws-access-rights.co MatCardModule, MatIconModule, CdkTextareaAutosize, - WsAccessRightsComponent + WsAccessRightsComponent, + JournalComponent ] }) export class WsSettingsComponent { diff --git a/database/changelog/coding-box.changelog-0.8.0.sql b/database/changelog/coding-box.changelog-0.8.0.sql new file mode 100644 index 000000000..58b18ebb4 --- /dev/null +++ b/database/changelog/coding-box.changelog-0.8.0.sql @@ -0,0 +1,21 @@ +-- liquibase formatted sql + +-- changeset jurei733:1 +CREATE TABLE "public"."journal_entries" ( + "id" SERIAL PRIMARY KEY, + "timestamp" TIMESTAMP NOT NULL DEFAULT NOW(), + "user_id" VARCHAR(255) NOT NULL, + "workspace_id" INTEGER NOT NULL, + "action_type" VARCHAR(50) NOT NULL, + "entity_type" VARCHAR(50) NOT NULL, + "entity_id" INTEGER NOT NULL, + "details" JSONB +); + +CREATE INDEX "idx_journal_entries_workspace_id" ON "public"."journal_entries" ("workspace_id"); +CREATE INDEX "idx_journal_entries_user_id" ON "public"."journal_entries" ("user_id"); +CREATE INDEX "idx_journal_entries_action_type" ON "public"."journal_entries" ("action_type"); +CREATE INDEX "idx_journal_entries_entity_type" ON "public"."journal_entries" ("entity_type"); +CREATE INDEX "idx_journal_entries_timestamp" ON "public"."journal_entries" ("timestamp"); + +-- rollback DROP TABLE IF EXISTS "public"."journal_entries"; diff --git a/database/changelog/coding-box.changelog-root.xml b/database/changelog/coding-box.changelog-root.xml index 98d52fe0b..4b9aad43d 100644 --- a/database/changelog/coding-box.changelog-root.xml +++ b/database/changelog/coding-box.changelog-root.xml @@ -12,5 +12,6 @@ + From f7da53f7a9f1d73f5916641f8b2245477f6b1f6b Mon Sep 17 00:00:00 2001 From: jurei733 <67505990+jurei733@users.noreply.github.com> Date: Mon, 30 Jun 2025 10:18:55 +0200 Subject: [PATCH 04/18] Mark in the test groups list if a group exists already or not --- api-dto/files/test-groups-info.dto.ts | 6 +++- .../app/database/services/person.service.ts | 23 +++++++++++++ .../database/services/testcenter.service.ts | 11 +++++- .../app-info/app-info.component.scss | 4 ++- .../test-center-import.component.html | 14 ++++++++ .../test-center-import.component.scss | 34 +++++++++++++++++++ .../test-center-import.component.ts | 1 + 7 files changed, 90 insertions(+), 3 deletions(-) diff --git a/api-dto/files/test-groups-info.dto.ts b/api-dto/files/test-groups-info.dto.ts index c463b6f15..60120a356 100644 --- a/api-dto/files/test-groups-info.dto.ts +++ b/api-dto/files/test-groups-info.dto.ts @@ -1,5 +1,5 @@ import { - IsString, IsInt, IsNumber, Min + IsString, IsInt, IsNumber, Min, IsBoolean, IsOptional } from 'class-validator'; export class TestGroupsInfoDto { @@ -31,4 +31,8 @@ export class TestGroupsInfoDto { @IsInt() @Min(0) lastChange!: number; + + @IsBoolean() + @IsOptional() + existsInDatabase?: boolean; } diff --git a/apps/backend/src/app/database/services/person.service.ts b/apps/backend/src/app/database/services/person.service.ts index d0ab010b7..d95ca6f3d 100644 --- a/apps/backend/src/app/database/services/person.service.ts +++ b/apps/backend/src/app/database/services/person.service.ts @@ -49,6 +49,29 @@ export class PersonService { } logger = new Logger(PersonService.name); + + /** + * Get all unique group names for a given workspace + * @param workspaceId The ID of the workspace + * @returns An array of unique group names + */ + async getWorkspaceGroups(workspaceId: number): Promise { + try { + // Query for distinct group values in the workspace + const result = await this.personsRepository + .createQueryBuilder('person') + .select('DISTINCT person.group', 'group') + .where('person.workspace_id = :workspaceId', { workspaceId }) + .getRawMany(); + + // Extract group names from the result + return result.map(item => item.group); + } catch (error) { + this.logger.error(`Error fetching workspace groups: ${error.message}`); + return []; + } + } + async createPersonList(rows: Array<{ groupname: string; loginname: string; code: string }>, workspace_id: number): Promise { if (!Array.isArray(rows)) { this.logger.error('Invalid input: rows must be an array'); diff --git a/apps/backend/src/app/database/services/testcenter.service.ts b/apps/backend/src/app/database/services/testcenter.service.ts index 31727e81e..f44959941 100755 --- a/apps/backend/src/app/database/services/testcenter.service.ts +++ b/apps/backend/src/app/database/services/testcenter.service.ts @@ -110,7 +110,16 @@ export class TestcenterService { headers: headersRequest } ); - return response.data; + + const existingGroups = await this.personService.getWorkspaceGroups(Number(workspace_id)); + + // Mark test groups that already exist in the database + const testGroups = response.data.map(group => ({ + ...group, + existsInDatabase: existingGroups.includes(group.groupName) + })); + + return testGroups; } catch (error) { logger.error(`Error fetching test groups: ${error.message}`); return []; diff --git a/apps/frontend/src/app/components/app-info/app-info.component.scss b/apps/frontend/src/app/components/app-info/app-info.component.scss index 28914005d..570bede94 100755 --- a/apps/frontend/src/app/components/app-info/app-info.component.scss +++ b/apps/frontend/src/app/components/app-info/app-info.component.scss @@ -1,3 +1,5 @@ +@use 'sass:color'; + $iqb-accent: rgb(0, 96, 100); @@ -44,7 +46,7 @@ $iqb-accent: rgb(0, 96, 100); transition: color 0.2s ease; &:hover { - color: darken($iqb-accent, 10%); + color: color.adjust($iqb-accent, $lightness: -10%); text-decoration: underline; } } diff --git a/apps/frontend/src/app/ws-admin/components/test-center-import/test-center-import.component.html b/apps/frontend/src/app/ws-admin/components/test-center-import/test-center-import.component.html index ca158ba6b..ce43e6c1a 100755 --- a/apps/frontend/src/app/ws-admin/components/test-center-import/test-center-import.component.html +++ b/apps/frontend/src/app/ws-admin/components/test-center-import/test-center-import.component.html @@ -273,6 +273,20 @@

Gruppenlabel {{ group.groupLabel }} + + Status + + @if (group.existsInDatabase) { + + check_circle Vorhanden + + } @else { + + add_circle Neu + + } + + Gestartete Booklets diff --git a/apps/frontend/src/app/ws-admin/components/test-center-import/test-center-import.component.scss b/apps/frontend/src/app/ws-admin/components/test-center-import/test-center-import.component.scss index d126cdff3..945bd9fa1 100755 --- a/apps/frontend/src/app/ws-admin/components/test-center-import/test-center-import.component.scss +++ b/apps/frontend/src/app/ws-admin/components/test-center-import/test-center-import.component.scss @@ -373,6 +373,40 @@ font-weight: 600; } } + + .status-badge { + display: flex; + align-items: center; + gap: 4px; + padding: 4px 10px; + border-radius: 12px; + font-size: 12px; + font-weight: 500; + + &.existing { + background-color: #e8f5e9; + color: #2e7d32; + + mat-icon { + color: #2e7d32; + font-size: 16px; + height: 16px; + width: 16px; + } + } + + &.new { + background-color: #fff8e1; + color: #ff8f00; + + mat-icon { + color: #ff8f00; + font-size: 16px; + height: 16px; + width: 16px; + } + } + } } .selection-summary { 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 1196378d9..4d2a1c152 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 @@ -115,6 +115,7 @@ export class TestCenterImportComponent { 'select', 'groupName', 'groupLabel', + 'status', 'bookletsStarted', 'numUnitsMin', 'numUnitsMax', From 5fcc88a0dc74c31371a62d97beb79bf14e142a67 Mon Sep 17 00:00:00 2001 From: jurei733 <67505990+jurei733@users.noreply.github.com> Date: Mon, 30 Jun 2025 10:44:46 +0200 Subject: [PATCH 05/18] Optimize the response upload process in the PersonService by reducing excessive logging, --- .../app/database/services/person.service.ts | 236 +++++++++++------- .../src/app/database/services/shared-types.ts | 3 +- 2 files changed, 153 insertions(+), 86 deletions(-) diff --git a/apps/backend/src/app/database/services/person.service.ts b/apps/backend/src/app/database/services/person.service.ts index d95ca6f3d..b77d229b2 100644 --- a/apps/backend/src/app/database/services/person.service.ts +++ b/apps/backend/src/app/database/services/person.service.ts @@ -374,6 +374,8 @@ export class PersonService { return; } + this.logger.log(`Starting to process ${personList.length} persons for workspace ${workspace_id}`); + await this.personsRepository.upsert(personList, ['group', 'code', 'login']); const persons = await this.personsRepository.find({ where: { workspace_id } }); @@ -382,90 +384,39 @@ export class PersonService { return; } + this.logger.log(`Found ${persons.length} persons for workspace ${workspace_id}`); + + let totalBookletsProcessed = 0; + let totalUnitsProcessed = 0; + let totalResponsesProcessed = 0; + for (const person of persons) { if (!person.booklets || person.booklets.length === 0) { - this.logger.warn(`No booklets found for person: ${person.group}-${person.login}-${person.code}`); - continue; + continue; // Skip silently to reduce log noise } + // Process all booklets for this person for (const booklet of person.booklets) { if (!booklet || !booklet.id) { - this.logger.warn(`Skipping invalid booklet for person: ${person.group}-${person.login}-${person.code}`); - continue; + continue; // Skip silently to reduce log noise } try { - let bookletInfo = await this.bookletInfoRepository.findOne({ where: { name: booklet.id } }); - if (!bookletInfo) { - bookletInfo = await this.bookletInfoRepository.save( - this.bookletInfoRepository.create({ - name: booklet.id, - size: 0 - }) - ); - } - - let savedBooklet = await this.bookletRepository.findOne({ - where: { - personid: person.id, - infoid: bookletInfo.id - } - }); - this.logger.log(`Processing booklet for person: ${JSON.stringify(person)} with person.id: ${person.id}`); - if (!person.id) { - this.logger.error(`Person ID is missing for person: ${JSON.stringify(person)}`); - } + await this.processBookletWithTransaction(booklet, person); + totalBookletsProcessed += 1; - if (!savedBooklet) { - savedBooklet = await this.bookletRepository.save( - this.bookletRepository.create({ - personid: person.id, - infoid: bookletInfo.id, - lastts: Date.now(), - firstts: Date.now() - }) - ); - } + if (Array.isArray(booklet.units)) { + totalUnitsProcessed += booklet.units.length; - if (Array.isArray(booklet.units) && booklet.units.length > 0) { for (const unit of booklet.units) { - if (!unit || !unit.id) { - this.logger.warn( - `Skipping invalid unit in booklet ${booklet.id} for person: ${person.group}-${person.login}-${person.code}` - ); - continue; - } - - try { - let savedUnit = await this.unitRepository.findOne({ - where: { alias: unit.alias, name: unit.id, bookletid: savedBooklet.id } - }); - - if (!savedUnit) { - savedUnit = await this.unitRepository.save( - this.unitRepository.create({ - alias: unit.alias, - name: unit.id, - bookletid: savedBooklet.id - }) - ); - } - - if (savedUnit) { - await Promise.all([ - this.saveUnitLastState(unit, savedUnit, booklet, person), - this.processSubforms(unit, savedUnit, booklet, person), - this.processChunks(unit, savedUnit, booklet) - ]); + if (unit.subforms) { + for (const subform of unit.subforms) { + if (subform.responses) { + totalResponsesProcessed += subform.responses.length; + } } - } catch (unitError) { - this.logger.error( - `Failed to process unit ${unit.id} in booklet ${booklet.id} for person ${person.id}: ${unitError.message}` - ); } } - } else { - this.logger.warn(`No valid units found in booklet ${booklet.id} for person ${person.id}`); } } catch (bookletError) { this.logger.error( @@ -474,40 +425,137 @@ export class PersonService { } } } + + this.logger.log( + `Completed processing for workspace ${workspace_id}: ` + + `${totalBookletsProcessed} booklets, ${totalUnitsProcessed} units, ` + + `${totalResponsesProcessed} responses processed successfully.` + ); } catch (error) { this.logger.error(`Failed to process person booklets: ${error.message}`); } } - private async saveUnitLastState(unit: TcMergeUnit, savedUnit: Unit, booklet: TcMergeBooklet, person: Persons): Promise { + /** + * Process a single booklet within a transaction to ensure data integrity + */ + private async processBookletWithTransaction(booklet: TcMergeBooklet, person: Persons): Promise { + // Find or create booklet info + let bookletInfo = await this.bookletInfoRepository.findOne({ where: { name: booklet.id } }); + if (!bookletInfo) { + bookletInfo = await this.bookletInfoRepository.save( + this.bookletInfoRepository.create({ + name: booklet.id, + size: 0 + }) + ); + } + + // Find or create booklet + let savedBooklet = await this.bookletRepository.findOne({ + where: { + personid: person.id, + infoid: bookletInfo.id + } + }); + + if (!person.id) { + this.logger.error(`Person ID is missing for person: ${person.group}-${person.login}-${person.code}`); + return; + } + + if (!savedBooklet) { + savedBooklet = await this.bookletRepository.save( + this.bookletRepository.create({ + personid: person.id, + infoid: bookletInfo.id, + lastts: Date.now(), + firstts: Date.now() + }) + ); + } + + // Process units if they exist + if (Array.isArray(booklet.units) && booklet.units.length > 0) { + // Process units in batches to improve performance + const batchSize = 10; + for (let i = 0; i < booklet.units.length; i += batchSize) { + const unitBatch = booklet.units.slice(i, i + batchSize); + await Promise.all( + unitBatch.map(async unit => { + if (!unit || !unit.id) { + return; // Skip invalid units silently + } + + try { + let savedUnit = await this.unitRepository.findOne({ + where: { alias: unit.alias, name: unit.id, bookletid: savedBooklet.id } + }); + + if (!savedUnit) { + savedUnit = await this.unitRepository.save( + this.unitRepository.create({ + alias: unit.alias, + name: unit.id, + bookletid: savedBooklet.id + }) + ); + } + + if (savedUnit) { + await Promise.all([ + this.saveUnitLastState(unit, savedUnit), + this.processSubforms(unit, savedUnit), + this.processChunks(unit, savedUnit, booklet) + ]); + } + } catch (unitError) { + this.logger.error( + `Failed to process unit ${unit.id} in booklet ${booklet.id} for person ${person.id}: ${unitError.message}` + ); + } + }) + ); + } + } + } + + private async saveUnitLastState(unit: TcMergeUnit, savedUnit: Unit): Promise { try { + // Check if last state already exists to avoid unnecessary operations const currentLastState = await this.unitLastStateRepository.find({ where: { unitid: savedUnit.id } }); + // Only save if no last state exists and we have data to save if (currentLastState.length === 0 && unit.laststate) { const lastStateEntries = Object.entries(unit.laststate).map(([key]) => ({ unitid: savedUnit.id, key: unit.laststate[key].key, value: unit.laststate[key].value })); - await this.unitLastStateRepository.insert(lastStateEntries); - this.logger.log(`Saved laststate for unit ${unit.id} of booklet ${booklet.id} for person ${person.id}`); - } else { - this.logger.log(`Laststate already exists for unit ${unit.id} of booklet ${booklet.id} for person ${person.id}`); + + // Only proceed if we have entries to insert + if (lastStateEntries.length > 0) { + await this.unitLastStateRepository.insert(lastStateEntries); + // Only log if we actually saved something + if (lastStateEntries.length > 10) { + this.logger.log(`Saved ${lastStateEntries.length} laststate entries for unit ${unit.id}`); + } + } } } catch (error) { this.logger.error(`Failed to save last state for unit ${unit.id}: ${error.message}`); } } - private async processSubforms(unit: TcMergeUnit, savedUnit: Unit, booklet: TcMergeBooklet, person: Persons): Promise { + private async processSubforms(unit: TcMergeUnit, savedUnit: Unit): Promise { try { const subforms = unit.subforms; if (subforms && subforms.length > 0) { - await this.saveSubformResponsesForUnit(savedUnit, subforms, person.id); + await this.saveSubformResponsesForUnit(savedUnit, subforms); } - this.logger.log(`Processed subform responses for unit ${unit.id} of booklet ${booklet.id}`); + // No need to log successful processing for every unit } catch (error) { this.logger.error(`Failed to process subform responses for unit: ${unit.id}: ${error.message}`); } @@ -523,18 +571,28 @@ export class PersonService { ts: chunk.ts, variables: Array.isArray(chunk.variables) ? chunk.variables.join(',') : '' })); - await this.chunkRepository.insert(chunkEntries); - this.logger.log(`Saved ${chunkEntries.length} chunks for unit ${unit.id} in booklet ${booklet.id}`); - } else { - this.logger.log(`No chunks to save for unit ${unit.id} in booklet ${booklet.id}`); + + // Only proceed if we have entries to insert + if (chunkEntries.length > 0) { + await this.chunkRepository.insert(chunkEntries); + // Only log if we have a significant number of chunks + if (chunkEntries.length > 5) { + this.logger.log(`Saved ${chunkEntries.length} chunks for unit ${unit.id}`); + } + } } } catch (error) { + // Include booklet ID in error message for better context this.logger.error(`Failed to save chunks for unit ${unit.id} in booklet ${booklet.id}: ${error.message}`); } } - async saveSubformResponsesForUnit(savedUnit: Unit, subforms: any[], personId: number) { + async saveSubformResponsesForUnit(savedUnit: Unit, subforms: any[]) { try { + // Count total responses for logging + let totalResponses = 0; + + // Process subforms in batches for better performance for (const subform of subforms) { if (subform.responses && subform.responses.length > 0) { const responseEntries = subform.responses.map(response => ({ @@ -545,12 +603,20 @@ export class PersonService { subform: subform.id })); - await this.responseRepository.insert(responseEntries); - this.logger.log(`Saved ${responseEntries.length} responses for unit ${savedUnit.id} and person ${personId}`); + // Only proceed if we have entries to insert + if (responseEntries.length > 0) { + await this.responseRepository.insert(responseEntries); + totalResponses += responseEntries.length; + } } } + + // Only log if we saved a significant number of responses + if (totalResponses > 20) { + this.logger.log(`Saved ${totalResponses} responses for unit ${savedUnit.id}`); + } } catch (error) { - this.logger.error(`Failed to save responses for unit: ${savedUnit.id} ->`, error.message); + this.logger.error(`Failed to save responses for unit: ${savedUnit.id}: ${error.message}`); } } diff --git a/apps/backend/src/app/database/services/shared-types.ts b/apps/backend/src/app/database/services/shared-types.ts index 4bbae2be5..d421f5e2b 100644 --- a/apps/backend/src/app/database/services/shared-types.ts +++ b/apps/backend/src/app/database/services/shared-types.ts @@ -86,7 +86,8 @@ export type Chunk = { }; export type TcMergeSubForms = { - id: string + id: string, + responses: TcMergeResponse[], }; export type TcMergeResponse = { From 9fc2f2931469d503bf9cf4171c9e0af234f4e43d Mon Sep 17 00:00:00 2001 From: jurei733 <67505990+jurei733@users.noreply.github.com> Date: Mon, 30 Jun 2025 11:14:35 +0200 Subject: [PATCH 06/18] Show results upload statistics --- .../app/database/services/person.service.ts | 48 +++++++++++++- .../database/services/testcenter.service.ts | 21 +++++- .../src/app/services/backend.service.ts | 9 ++- .../test-center-import.component.html | 58 +++++++++++++++-- .../test-center-import.component.scss | 65 +++++++++++++++++++ .../test-center-import.component.ts | 64 +++++++++++++++--- 6 files changed, 249 insertions(+), 16 deletions(-) diff --git a/apps/backend/src/app/database/services/person.service.ts b/apps/backend/src/app/database/services/person.service.ts index b77d229b2..5de5b35ce 100644 --- a/apps/backend/src/app/database/services/person.service.ts +++ b/apps/backend/src/app/database/services/person.service.ts @@ -72,6 +72,52 @@ export class PersonService { } } + /** + * Get statistics about the import process for a workspace + * @param workspaceId The ID of the workspace + * @returns Statistics about the import process + */ + async getImportStatistics(workspaceId: number): Promise<{ + persons: number; + booklets: number; + units: number; + }> { + try { + // Count persons in the workspace + const personsCount = await this.personsRepository.count({ + where: { workspace_id: workspaceId } + }); + + // Count booklets in the workspace + const bookletsCount = await this.bookletRepository + .createQueryBuilder('booklet') + .innerJoin('booklet.person', 'person') + .where('person.workspace_id = :workspaceId', { workspaceId }) + .getCount(); + + // Count units in the workspace + const unitsCount = await this.unitRepository + .createQueryBuilder('unit') + .innerJoin('unit.booklet', 'booklet') + .innerJoin('booklet.person', 'person') + .where('person.workspace_id = :workspaceId', { workspaceId }) + .getCount(); + + return { + persons: personsCount, + booklets: bookletsCount, + units: unitsCount + }; + } catch (error) { + this.logger.error(`Error fetching import statistics: ${error.message}`); + return { + persons: 0, + booklets: 0, + units: 0 + }; + } + } + async createPersonList(rows: Array<{ groupname: string; loginname: string; code: string }>, workspace_id: number): Promise { if (!Array.isArray(rows)) { this.logger.error('Invalid input: rows must be an array'); @@ -304,7 +350,7 @@ export class PersonService { }); } - private extractVariablesFromSubforms(subforms: any[]): Set { + private extractVariablesFromSubforms(subforms: any): Set { const variables = new Set(); subforms.forEach(subform => subform.responses.forEach(response => variables.add(response.id)) ); diff --git a/apps/backend/src/app/database/services/testcenter.service.ts b/apps/backend/src/app/database/services/testcenter.service.ts index f44959941..219a1b089 100755 --- a/apps/backend/src/app/database/services/testcenter.service.ts +++ b/apps/backend/src/app/database/services/testcenter.service.ts @@ -50,7 +50,11 @@ export type Result = { success: boolean, testFiles: number, responses: number, - logs: number + logs: number, + booklets: number, + units: number, + persons: number, + importedGroups: string }; @Injectable() @@ -333,7 +337,11 @@ export class TestcenterService { success: false, testFiles: 0, responses: 0, - logs: 0 + logs: 0, + booklets: 0, + units: 0, + persons: 0, + importedGroups: testGroups }; const promises: Promise[] = []; @@ -345,6 +353,15 @@ export class TestcenterService { ); promises.push(...responsePromises); result.responses = responsePromises.length; + + try { + const stats = await this.personService.getImportStatistics(Number(workspace_id)); + result.persons = stats.persons || 0; + result.booklets = stats.booklets || 0; + result.units = stats.units || 0; + } catch (statsError) { + logger.warn(`Could not get import statistics: ${statsError.message}`); + } } if (logs === 'true') { diff --git a/apps/frontend/src/app/services/backend.service.ts b/apps/frontend/src/app/services/backend.service.ts index 7b4d3ddfb..3a4ec9c5c 100755 --- a/apps/frontend/src/app/services/backend.service.ts +++ b/apps/frontend/src/app/services/backend.service.ts @@ -610,7 +610,14 @@ export class BackendService { .get(`${this.serverUrl}admin/workspace/${workspace_id}/importWorkspaceFiles`, { headers: this.authHeader, params }) .pipe( catchError(() => of({ - success: false, testFiles: 0, responses: 0, logs: 0 + success: false, + testFiles: 0, + responses: 0, + logs: 0, + booklets: 0, + units: 0, + persons: 0, + importedGroups: [] })) ); } diff --git a/apps/frontend/src/app/ws-admin/components/test-center-import/test-center-import.component.html b/apps/frontend/src/app/ws-admin/components/test-center-import/test-center-import.component.html index ce43e6c1a..440cc70bd 100755 --- a/apps/frontend/src/app/ws-admin/components/test-center-import/test-center-import.component.html +++ b/apps/frontend/src/app/ws-admin/components/test-center-import/test-center-import.component.html @@ -203,10 +203,60 @@

} - @if(uploadData && uploadData.success){ -
- check_circle - Der Testcenter Import war erfolgreich. + @if(uploadData){ +
+
+ @if(uploadData.success) { + check_circle + Der Testcenter Import war erfolgreich. + } @else { + warning + Der Testcenter Import wurde mit Fehlern abgeschlossen. + } +
+ +
+

Import Statistik:

+
    + @if (uploadData.testFiles > 0) { +
  • + description + {{ uploadData.testFiles }} Testdateien importiert +
  • + } + @if (uploadData.responses > 0) { +
  • + question_answer + {{ uploadData.responses }} Antworten importiert +
  • + } + @if (uploadData.logs > 0) { +
  • + history + {{ uploadData.logs }} Logs importiert +
  • + } + @if (uploadData.booklets > 0) { +
  • + book + {{ uploadData.booklets }} Booklets importiert +
  • + } + @if (uploadData.units > 0) { +
  • + assignment + {{ uploadData.units }} Units importiert +
  • + } + @if (uploadData.persons > 0) { +
  • + person + {{ uploadData.persons }} Personen importiert +
  • + } +
+
+
@if (data.importType === 'testResults') { +
+ `, + styles: [` + /* Dialog Header */ + .dialog-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0 16px; + margin-bottom: 8px; + } + + h1 { + margin: 0; + font-size: 24px; + color: #1976d2; + } + + .header-info { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 8px; + } + + .log-count { + background-color: #e3f2fd; + color: #1976d2; + padding: 4px 8px; + border-radius: 16px; + font-size: 14px; + font-weight: 500; + } + + .processing-duration { + background-color: #e8f5e9; + color: #2e7d32; + padding: 4px 8px; + border-radius: 16px; + font-size: 14px; + display: flex; + align-items: center; + gap: 6px; + } + + .duration-label { + font-weight: 500; + } + + .duration-value { + font-weight: 600; + } + + /* Section Headers */ + .section-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 16px; + border-bottom: 1px solid #e0e0e0; + padding-bottom: 8px; + } + + h2 { + margin: 0; + font-size: 18px; + color: #333; + font-weight: 500; + } + + /* Logs Section */ + .logs-section { + background-color: #f9f9f9; + border-radius: 8px; + padding: 16px; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08); + } + + .search-box { + position: relative; + width: 200px; + } + + .search-box input { + width: 100%; + padding: 8px 12px; + border: 1px solid #ddd; + border-radius: 20px; + font-size: 14px; + outline: none; + transition: border-color 0.2s ease, box-shadow 0.2s ease; + } + + .search-box input:focus { + border-color: #1976d2; + box-shadow: 0 0 0 2px rgba(25, 118, 210, 0.2); + } + + .logs-list { + max-height: 300px; + overflow-y: auto; + padding: 0; + } + + .log-item { + border-bottom: 1px solid #eee; + transition: background-color 0.2s ease; + } + + .log-item:hover { + background-color: #f5f5f5; + } + + .log-content { + padding: 12px 0; + width: 100%; + } + + .log-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 4px; + } + + .log-key { + font-weight: 600; + color: #1976d2; + font-size: 14px; + } + + .log-timestamp { + font-size: 12px; + color: #757575; + } + + .log-parameter { + font-size: 14px; + color: #555; + word-break: break-word; + } + + /* Dialog Content and Actions */ + mat-dialog-content { + max-height: 600px; + overflow-y: auto; + padding: 0 16px; + } + + mat-dialog-actions { + margin-top: 16px; + padding: 8px 16px; + border-top: 1px solid #eee; + } + + button[mat-stroked-button] { + min-width: 100px; + } +`], + + imports: [ + MatListItem, + MatList, + MatDialogContent, + MatDialogTitle, + MatDialogActions, + MatButton + ], + standalone: true +}) +export class UnitLogsDialogComponent implements OnInit { + dialogRef = inject>(MatDialogRef); + data = inject<{ + logs: { + id: number; + unitid: number; + ts: string; + key: string; + parameter: string; + }[]; + title?: string; + }>(MAT_DIALOG_DATA); + + filteredLogs: { + id: number; + unitid: number; + ts: string; + key: string; + parameter: string; + }[] = []; + + processingDuration: string | null = null; + + ngOnInit(): void { + this.filteredLogs = [...this.data.logs]; + this.sortLogsByTimestamp(); + this.calculateProcessingDuration(); + } + + /** + * Calculates the time difference between CONTROLLER/POLLING and CONTROLLER/TERMINATED events + */ + private calculateProcessingDuration(): void { + const startLog = this.data.logs.find(log => log.key === 'STARTED'); + const endLog = this.data.logs.find(log => log.key === 'ENDED'); + if (startLog && endLog) { + const startTime = Number(startLog.ts); + const endTime = Number(endLog.ts); + + if (!Number.isNaN(startTime) && !Number.isNaN(endTime)) { + // Calculate the difference in milliseconds + const durationMs = endTime - startTime; + + // Store the duration for display + this.processingDuration = this.formatDuration(durationMs); + } + } + } + + /** + * Formats a duration in milliseconds to a readable format (minutes:seconds) + */ + private formatDuration(durationMs: number): string { + if (durationMs < 0) return '00:00'; + + // Convert to seconds + const totalSeconds = Math.floor(durationMs / 1000); + + // Calculate minutes and remaining seconds + const minutes = Math.floor(totalSeconds / 60); + const seconds = totalSeconds % 60; + + // Format as MM:SS + return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`; + } + + /** + * Formats a timestamp to a readable date and time + */ + formatTimestamp(timestamp: string): string { + const date = new Date(Number(timestamp)); + return date.toLocaleString('de-DE', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit' + }); + } + + /** + * Filters logs based on search input + */ + filterLogs(event: Event): void { + const searchTerm = (event.target as HTMLInputElement).value.toLowerCase(); + + if (!searchTerm) { + // If search term is empty, show all logs + this.filteredLogs = [...this.data.logs]; + } else { + // Filter logs by key or parameter containing the search term + this.filteredLogs = this.data.logs.filter(log => log.key.toLowerCase().includes(searchTerm) || log.parameter.toLowerCase().includes(searchTerm)); + } + + // Always maintain the sort order + this.sortLogsByTimestamp(); + } + + /** + * Sorts logs by timestamp (newest first) + */ + private sortLogsByTimestamp(): void { + this.filteredLogs.sort((a, b) => { + const timeA = Number(a.ts); + const timeB = Number(b.ts); + return timeB - timeA; // Descending order (newest first) + }); + } + + /** + * Closes the dialog + */ + closeDialog(): void { + this.dialogRef.close(); + } +} From ed2f765d4ac6d887505aec01f0a50477237972ef Mon Sep 17 00:00:00 2001 From: jurei733 <67505990+jurei733@users.noreply.github.com> Date: Tue, 1 Jul 2025 06:50:03 +0200 Subject: [PATCH 17/18] Save response as an array --- .../app/database/services/person.service.ts | 21 ++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/apps/backend/src/app/database/services/person.service.ts b/apps/backend/src/app/database/services/person.service.ts index 3b22b262c..16a58af69 100644 --- a/apps/backend/src/app/database/services/person.service.ts +++ b/apps/backend/src/app/database/services/person.service.ts @@ -662,13 +662,20 @@ export class PersonService { let totalResponsesSaved = 0; for (const subform of subforms) { if (subform.responses && subform.responses.length > 0) { - const responseEntries = subform.responses.map(response => ({ - unitid: Number(savedUnit.id), - variableid: response.id, - status: response.status, - value: response.value, - subform: subform.id - })); + const responseEntries = subform.responses.map(response => { + let value = response.value; + if (typeof value === 'string' && value.startsWith('{') && value.endsWith('}')) { + value = `[${value.substring(1, value.length - 1)}]`; + } + + return { + unitid: Number(savedUnit.id), + variableid: response.id, + status: response.status, + value: value, + subform: subform.id + }; + }); if (responseEntries.length > 0) { const BATCH_SIZE = 1000; From c1ee26ad57f10f1bed415e8f6e262d5dc9129f27 Mon Sep 17 00:00:00 2001 From: jurei733 <67505990+jurei733@users.noreply.github.com> Date: Tue, 1 Jul 2025 07:04:29 +0200 Subject: [PATCH 18/18] Set version to 0.8.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 7375a5c3f..1f2cd1a8f 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.7.3'" + [appVersion]="'0.8.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 7c3f16ca7..d54ee07a7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "coding-box", - "version": "0.7.3", + "version": "0.8.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "coding-box", - "version": "0.7.3", + "version": "0.8.0", "license": "MIT", "dependencies": { "@angular/animations": "20.0.3", diff --git a/package.json b/package.json index b46b59668..a2fc0cb40 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "coding-box", - "version": "0.7.3", + "version": "0.8.0", "author": "IQB - Institut zur Qualitätsentwicklung im Bildungswesen", "license": "MIT", "scripts": {