diff --git a/api-dto/files/test-groups-info.dto.ts b/api-dto/files/test-groups-info.dto.ts index c463b6f15..8f3674b42 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,12 @@ export class TestGroupsInfoDto { @IsInt() @Min(0) lastChange!: number; + + @IsBoolean() + @IsOptional() + existsInDatabase?: boolean; + + @IsBoolean() + @IsOptional() + hasBookletLogs?: boolean; } 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..39b9ab5c1 --- /dev/null +++ b/apps/backend/src/app/admin/workspace/journal.controller.ts @@ -0,0 +1,339 @@ +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 + + const entityId = parseInt(createJournalEntryDto.entity_id, 10); + + if (Number.isNaN(entityId)) { + throw new Error(`Invalid entity_id: "${createJournalEntryDto.entity_id}" is not a valid number`); + } + + return this.journalService.createEntry( + userId, + workspaceId, + createJournalEntryDto.action_type, + createJournalEntryDto.entity_type, + entityId, + 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/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/admin/workspace/workspace-test-center.controller.ts b/apps/backend/src/app/admin/workspace/workspace-test-center.controller.ts index b76a68414..98666cdc0 100644 --- a/apps/backend/src/app/admin/workspace/workspace-test-center.controller.ts +++ b/apps/backend/src/app/admin/workspace/workspace-test-center.controller.ts @@ -46,6 +46,12 @@ export class WorkspaceTestCenterController { @ApiQuery({ name: 'testTakers', required: false, description: 'Include test takers' }) @ApiQuery({ name: 'testGroups', required: false, description: 'Include test groups' }) @ApiQuery({ name: 'booklets', required: false, description: 'Include booklets' }) + @ApiQuery({ + name: 'overwriteExistingLogs', + required: false, + description: 'Whether to overwrite existing logs', + type: Boolean + }) @ApiOkResponse({ description: 'Files imported successfully', type: Object }) @ApiBadRequestResponse({ description: 'Failed to import files' }) async importWorkspaceFiles( @@ -62,7 +68,8 @@ export class WorkspaceTestCenterController { @Query('codings') codings: string, @Query('testTakers') testTakers: string, @Query('testGroups') testGroups: string, - @Query('booklets') booklets: string) + @Query('booklets') booklets: string, + @Query('overwriteExistingLogs') overwriteExistingLogs: string) : Promise { const importOptions:ImportOptions = { definitions: definitions, @@ -75,7 +82,18 @@ export class WorkspaceTestCenterController { testTakers: testTakers }; - return this.testCenterService.importWorkspaceFiles(workspace_id, tc_workspace, server, decodeURIComponent(url), token, importOptions, testGroups); + const overwriteLogs = overwriteExistingLogs === 'true'; + + return this.testCenterService.importWorkspaceFiles( + workspace_id, + tc_workspace, + server, + decodeURIComponent(url), + token, + importOptions, + testGroups, + overwriteLogs + ); } @Get(':workspace_id/importWorkspaceFiles/testGroups') diff --git a/apps/backend/src/app/admin/workspace/workspace-test-results.controller.ts b/apps/backend/src/app/admin/workspace/workspace-test-results.controller.ts index ec5afb1f3..c9b1cf739 100644 --- a/apps/backend/src/app/admin/workspace/workspace-test-results.controller.ts +++ b/apps/backend/src/app/admin/workspace/workspace-test-results.controller.ts @@ -513,8 +513,6 @@ export class WorkspaceTestResultsController { throw new BadRequestException('Invalid workspace_id.'); } - // No longer require at least one parameter to be provided - try { return await this.workspaceTestResultsService.searchResponses( workspace_id, @@ -697,10 +695,17 @@ export class WorkspaceTestResultsController { @ApiBadRequestResponse({ description: 'Invalid request. Please check your input data.' }) + @ApiQuery({ + name: 'overwriteExisting', + type: Boolean, + required: false, + description: 'Whether to overwrite existing logs/responses (default: true)' + }) async addTestResults( @Param('workspace_id') workspace_id: number, @Param('resultType') resultType: 'logs' | 'responses', - @UploadedFiles() files: Express.Multer.File[] + @UploadedFiles() files: Express.Multer.File[], + @Query('overwriteExisting') overwriteExisting?: string ): Promise { if (!workspace_id || Number.isNaN(workspace_id)) { throw new BadRequestException('Invalid workspace_id.'); @@ -710,8 +715,13 @@ export class WorkspaceTestResultsController { throw new BadRequestException('No files were uploaded.'); } + // Convert the query parameter to a boolean + const shouldOverwrite = overwriteExisting !== 'false'; + + logger.log(`Uploading test results with overwriteExisting=${shouldOverwrite}`); + try { - return await this.uploadResults.uploadTestResults(workspace_id, files, resultType); + return await this.uploadResults.uploadTestResults(workspace_id, files, resultType, shouldOverwrite); } catch (error) { logger.error('Error uploading test results!'); throw new BadRequestException('Uploading test results failed. Please try again.'); 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/backend/src/app/database/services/person.service.ts b/apps/backend/src/app/database/services/person.service.ts index d0ab010b7..16a58af69 100644 --- a/apps/backend/src/app/database/services/person.service.ts +++ b/apps/backend/src/app/database/services/person.service.ts @@ -49,6 +49,93 @@ export class PersonService { } logger = new Logger(PersonService.name); + + async getWorkspaceGroups(workspaceId: number): Promise { + try { + const result = await this.personsRepository + .createQueryBuilder('person') + .select('DISTINCT person.group', 'group') + .where('person.workspace_id = :workspaceId', { workspaceId }) + .getRawMany(); + + return result.map(item => item.group); + } catch (error) { + this.logger.error(`Error fetching workspace groups: ${error.message}`); + return []; + } + } + + async hasBookletLogsForGroup(workspaceId: number, groupName: string): Promise { + try { + const count = await this.bookletLogRepository + .createQueryBuilder('bookletlog') + .innerJoin('bookletlog.booklet', 'booklet') + .innerJoin('booklet.person', 'person') + .where('person.workspace_id = :workspaceId', { workspaceId }) + .andWhere('person.group = :groupName', { groupName }) + .getCount(); + + return count > 0; + } catch (error) { + this.logger.error(`Error checking booklet logs for group ${groupName}: ${error.message}`); + return false; + } + } + + async getGroupsWithBookletLogs(workspaceId: number): Promise> { + try { + const groups = await this.getWorkspaceGroups(workspaceId); + const groupsWithLogs = new Map(); + for (const group of groups) { + const hasLogs = await this.hasBookletLogsForGroup(workspaceId, group); + groupsWithLogs.set(group, hasLogs); + } + + return groupsWithLogs; + } catch (error) { + this.logger.error(`Error getting groups with booklet logs: ${error.message}`); + return new Map(); + } + } + + async getImportStatistics(workspaceId: number): Promise<{ + persons: number; + booklets: number; + units: number; + }> { + try { + const personsCount = await this.personsRepository.count({ + where: { workspace_id: workspaceId } + }); + + const bookletsCount = await this.bookletRepository + .createQueryBuilder('booklet') + .innerJoin('booklet.person', 'person') + .where('person.workspace_id = :workspaceId', { workspaceId }) + .getCount(); + + 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'); @@ -340,7 +427,10 @@ export class PersonService { }; } - async processPersonBooklets(personList: Person[], workspace_id: number): Promise { + async processPersonBooklets( + personList: Person[], + workspace_id: number + ): Promise { try { if (!Array.isArray(personList) || personList.length === 0) { this.logger.warn('Person list is empty or invalid'); @@ -351,6 +441,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 } }); @@ -359,90 +451,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; + const totalResponsesSkipped = 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 } - 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 - }) - ); - } + await this.processBookletWithTransaction(booklet, person); + totalBookletsProcessed += 1; - 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)}`); - } + if (Array.isArray(booklet.units)) { + totalUnitsProcessed += booklet.units.length; - 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) && 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) { + // This is just an estimate as we don't have the actual count of saved vs skipped + 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( @@ -451,42 +492,141 @@ export class PersonService { } } } + + this.logger.log( + `Completed processing for workspace ${workspace_id}: ` + + `${totalBookletsProcessed} booklets, ${totalUnitsProcessed} units, ` + + `${totalResponsesProcessed} responses processed, ${totalResponsesSkipped} responses skipped.` + ); } catch (error) { this.logger.error(`Failed to process person booklets: ${error.message}`); } } - private async saveUnitLastState(unit: TcMergeUnit, savedUnit: Unit, booklet: TcMergeBooklet, person: Persons): Promise { + private async processBookletWithTransaction( + booklet: TcMergeBooklet, + person: Persons + ): Promise { + 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 { 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<{ success: boolean; saved: number; skipped: number }> { try { const subforms = unit.subforms; if (subforms && subforms.length > 0) { - await this.saveSubformResponsesForUnit(savedUnit, subforms, person.id); + return await this.saveSubformResponsesForUnit(savedUnit, subforms); } - this.logger.log(`Processed subform responses for unit ${unit.id} of booklet ${booklet.id}`); + return { success: true, saved: 0, skipped: 0 }; } catch (error) { this.logger.error(`Failed to process subform responses for unit: ${unit.id}: ${error.message}`); + return { success: false, saved: 0, skipped: 0 }; } } @@ -500,34 +640,66 @@ 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}`); + + if (chunkEntries.length > 0) { + await this.chunkRepository.insert(chunkEntries); + 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[] + ): Promise<{ success: boolean; saved: number; skipped: number }> { try { + 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 - })); - - await this.responseRepository.insert(responseEntries); - this.logger.log(`Saved ${responseEntries.length} responses for unit ${savedUnit.id} and person ${personId}`); + 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; + for (let i = 0; i < responseEntries.length; i += BATCH_SIZE) { + const batch = responseEntries.slice(i, i + BATCH_SIZE); + await this.responseRepository.save(batch); + } + totalResponsesSaved += responseEntries.length; + } } } + + return { + success: true, + saved: totalResponsesSaved, + skipped: 0 + }; } 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}`); + return { + success: false, + saved: 0, + skipped: 0 + }; } } @@ -593,11 +765,30 @@ export class PersonService { return booklet; } + /** + * Process logs for persons + * @param persons The persons to process logs for + * @param unitLogs The unit logs to process + * @param bookletLogs The booklet logs to process + * @param overwriteExistingLogs Whether to overwrite existing logs + * @returns A summary of the processing results + */ async processPersonLogs( persons: Person[], - unitLogs: Log[], - bookletLogs: Log[] - ): Promise { + unitLogs: any, + bookletLogs: any, + overwriteExistingLogs: boolean = true + ): Promise<{ + success: boolean; + totalBooklets: number; + totalLogsSaved: number; + totalLogsSkipped: number; + }> { + let totalBooklets = 0; + let totalLogsSaved = 0; + let totalLogsSkipped = 0; + let success = true; + try { const keys = persons.map(person => ({ group: person.group, @@ -692,43 +883,100 @@ export class PersonService { } try { - await this.storeBookletLogs(booklet, existingBooklet.id); + totalBooklets += 1; + + // Store booklet logs with overwrite flag + const logsResult = await this.storeBookletLogs( + booklet, + existingBooklet.id, + overwriteExistingLogs + ); + + if (logsResult.success) { + totalLogsSaved += logsResult.saved; + totalLogsSkipped += logsResult.skipped; + } else { + success = false; + } + await this.storeBookletSessions(booklet, existingBooklet); - await this.processUnits(booklet, existingBooklet, enrichedPerson); + await this.processUnits(booklet, existingBooklet, enrichedPerson, overwriteExistingLogs); } catch (error) { + success = false; this.logger.error( `Failed to process booklet ${booklet.id} for person ${originalPerson.code}: ${error.message}` ); } } } + + this.logger.log( + `Processed logs for ${totalBooklets} booklets: ` + + `${totalLogsSaved} logs saved, ${totalLogsSkipped} logs skipped` + ); + + return { + success, + totalBooklets, + totalLogsSaved, + totalLogsSkipped + }; } catch (error) { this.logger.error( `Critical error while processing person logs: ${error.message}` ); + return { + success: false, + totalBooklets, + totalLogsSaved, + totalLogsSkipped + }; } } - private async storeBookletLogs(booklet: TcMergeBooklet, bookletId: number): Promise { + async storeBookletLogs( + booklet: TcMergeBooklet, + bookletId: number, + overwriteExisting: boolean = true + ): Promise<{ success: boolean; saved: number; skipped: number }> { if (!booklet.logs || booklet.logs.length === 0) { - return; + return { success: true, saved: 0, skipped: 0 }; } - const bookletLogEntries = booklet.logs.map(log => ({ - key: log.key, - parameter: log.parameter, - bookletid: bookletId, - ts: Number(log.ts) - })); - try { + // Check if logs already exist for this booklet + const existingLogsCount = await this.bookletLogRepository.count({ + where: { bookletid: bookletId } + }); + + // If logs exist and we're not supposed to overwrite, skip + if (existingLogsCount > 0 && !overwriteExisting) { + this.logger.log(`Skipping ${booklet.logs.length} logs for booklet ${booklet.id} (logs already exist)`); + return { success: true, saved: 0, skipped: booklet.logs.length }; + } + + // If logs exist and we're supposed to overwrite, delete existing logs first + if (existingLogsCount > 0 && overwriteExisting) { + await this.bookletLogRepository.delete({ bookletid: bookletId }); + this.logger.log(`Deleted ${existingLogsCount} existing logs for booklet ${booklet.id}`); + } + + const bookletLogEntries = booklet.logs.map(log => ({ + key: log.key, + parameter: log.parameter, + bookletid: bookletId, + ts: Number(log.ts) + })); + await this.bookletLogRepository.save(bookletLogEntries); this.logger.log(`Saved ${booklet.logs.length} logs for booklet ${booklet.id}`); + + return { success: true, saved: booklet.logs.length, skipped: 0 }; } catch (error) { this.logger.error( `Failed to save logs for booklet ${booklet.id}: ${error.message}` ); - throw error; + return { success: false, saved: 0, skipped: booklet.logs.length }; } } @@ -765,8 +1013,12 @@ export class PersonService { private async processUnits( booklet: TcMergeBooklet, existingBooklet: Booklet, - person: Person + person: Person, + overwriteExistingLogs: boolean = true ): Promise { + let totalLogsSaved = 0; + let totalLogsSkipped = 0; + for (const unit of booklet.units) { if (!unit || !unit.id) { this.logger.warn( @@ -787,34 +1039,67 @@ export class PersonService { this.logger.warn( `Unit not found for alias: ${unit.alias}, name: ${unit.id} ${booklet.id} ${existingBooklet.id} ID${unit.id} ALIAS${unit.alias}` ); + continue; } - // await this.saveUnitLogs(unit, existingUnit); + const result = await this.saveUnitLogs(unit, existingUnit, overwriteExistingLogs); + if (result.success) { + totalLogsSaved += result.saved; + totalLogsSkipped += result.skipped; + } } + + this.logger.log( + `Processed unit logs for booklet ${booklet.id}: ` + + `${totalLogsSaved} logs saved, ${totalLogsSkipped} logs skipped` + ); } - private async saveUnitLogs(unit: TcMergeUnit, existingUnit: Unit): Promise { + private async saveUnitLogs( + unit: TcMergeUnit, + existingUnit: Unit, + overwriteExisting: boolean = true + ): Promise<{ success: boolean; saved: number; skipped: number }> { if (!unit.logs || unit.logs.length === 0) { - return; + return { success: true, saved: 0, skipped: 0 }; } - const unitLogEntries = unit.logs.map(log => ({ - key: log.key, - parameter: log.parameter, - unitid: existingUnit.id, - ts: Number(log.ts) - })); - try { - await this.unitLogRepository.insert(unitLogEntries); - this.logger.log( - `Saved ${unit.logs.length} logs for unit ${unit.id}` - ); + const existingLogsCount = await this.unitLogRepository.count({ + where: { unitid: existingUnit.id } + }); + + if (existingLogsCount > 0 && !overwriteExisting) { + this.logger.log(`Skipping ${unit.logs.length} logs for unit ${unit.id} (logs already exist)`); + return { success: true, saved: 0, skipped: unit.logs.length }; + } + + if (existingLogsCount > 0 && overwriteExisting) { + await this.unitLogRepository.delete({ unitid: existingUnit.id }); + this.logger.log(`Deleted ${existingLogsCount} existing logs for unit ${unit.id}`); + } + + const unitLogEntries = unit.logs.map(log => ({ + key: log.key, + parameter: log.parameter, + unitid: existingUnit.id, + ts: Number(log.ts) + })); + + // Use batch processing for better performance with large datasets + const BATCH_SIZE = 1000; + for (let i = 0; i < unitLogEntries.length; i += BATCH_SIZE) { + const batch = unitLogEntries.slice(i, i + BATCH_SIZE); + await this.unitLogRepository.save(batch); + } + + this.logger.log(`Saved ${unit.logs.length} logs for unit ${unit.id}`); + return { success: true, saved: unit.logs.length, skipped: 0 }; } catch (error) { this.logger.error( `Failed to save logs for unit ${unit.id}: ${error.message}` ); - throw error; + return { success: false, saved: 0, skipped: unit.logs.length }; } } } 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 = { diff --git a/apps/backend/src/app/database/services/testcenter.service.ts b/apps/backend/src/app/database/services/testcenter.service.ts index 31727e81e..8a0df63a0 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() @@ -110,7 +114,16 @@ export class TestcenterService { headers: headersRequest } ); - return response.data; + const existingGroups = await this.personService.getWorkspaceGroups(Number(workspace_id)); + const groupsWithLogs = await this.personService.getGroupsWithBookletLogs(Number(workspace_id)); + + const testGroups = response.data.map(group => ({ + ...group, + existsInDatabase: existingGroups.includes(group.groupName), + hasBookletLogs: groupsWithLogs.get(group.groupName) || false + })); + + return testGroups; } catch (error) { logger.error(`Error fetching test groups: ${error.message}`); return []; @@ -173,12 +186,12 @@ export class TestcenterService { server: string, url: string, authToken: string, - testGroups: string + testGroups: string, + overwriteExistingLogs: boolean = true ): Promise[]> { logger.log('Import logs data from TC'); const headersRequest = this.createHeaders(authToken); const logsChunks = this.createChunks(testGroups.split(','), 2); - const logsPromises = logsChunks.map(async chunk => { const logsUrl = url ? `${url}/api/workspace/${tc_workspace}/report/log?dataIds=${chunk.join(',')}` : @@ -191,10 +204,18 @@ export class TestcenterService { const { bookletLogs, unitLogs } = this.separateLogsByType(logData); const persons = await this.personService.createPersonList(logData, Number(workspace_id)); - // @ts-expect-error - Method signature mismatch between PersonService and expected types - await this.personService.processPersonLogs(persons, unitLogs, bookletLogs); + + // Process logs with overwrite flag + const result = await this.personService.processPersonLogs( + persons, + unitLogs, + bookletLogs, + overwriteExistingLogs + ); + + logger.log(`Logs import result: ${JSON.stringify(result)}`); } catch (error) { - logger.error('Error processing logs:'); + logger.error(`Error processing logs: ${error.message}`); throw error; } }); @@ -284,12 +305,6 @@ export class TestcenterService { return filePromises; } - /** - * Creates database entries from fetched files - * @param fetchedFiles The fetched files - * @param workspace_id The workspace ID - * @returns An array of database entries - */ private createDatabaseEntries( fetchedFiles: Array<{ data: File; @@ -317,14 +332,19 @@ export class TestcenterService { url: string, authToken: string, importOptions: ImportOptions, - testGroups: string + testGroups: string, + overwriteExistingLogs: boolean = true ): Promise { const { responses, logs } = importOptions; const result: Result = { success: false, testFiles: 0, responses: 0, - logs: 0 + logs: 0, + booklets: 0, + units: 0, + persons: 0, + importedGroups: testGroups }; const promises: Promise[] = []; @@ -336,11 +356,20 @@ 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') { const logsPromises = await this.importLogs( - workspace_id, tc_workspace, server, url, authToken, testGroups + workspace_id, tc_workspace, server, url, authToken, testGroups, overwriteExistingLogs ); promises.push(...logsPromises); result.logs = logsPromises.length; diff --git a/apps/backend/src/app/database/services/upload-results.service.ts b/apps/backend/src/app/database/services/upload-results.service.ts index e100a93c7..da957e66b 100644 --- a/apps/backend/src/app/database/services/upload-results.service.ts +++ b/apps/backend/src/app/database/services/upload-results.service.ts @@ -19,8 +19,13 @@ export class UploadResultsService { ) { } - async uploadTestResults(workspace_id: number, originalFiles: FileIo[], resultType:'logs' | 'responses'): Promise { - this.logger.log(`Uploading test results for workspace ${workspace_id}`); + async uploadTestResults( + workspace_id: number, + originalFiles: FileIo[], + resultType:'logs' | 'responses', + overwriteExisting: boolean = true + ): Promise { + this.logger.log(`Uploading test results for workspace ${workspace_id} (overwrite existing: ${overwriteExisting})`); const MAX_FILES_LENGTH = 1000; if (originalFiles.length > MAX_FILES_LENGTH) { this.logger.error(`Too many files to upload: ${originalFiles.length}`); @@ -29,13 +34,18 @@ export class UploadResultsService { const filePromises = []; for (let i = 0; i < originalFiles.length; i++) { const file = originalFiles[i]; - filePromises.push(this.uploadFile(file, workspace_id, resultType)); + filePromises.push(this.uploadFile(file, workspace_id, resultType, overwriteExisting)); } await Promise.all(filePromises); return true; } - async uploadFile(file: FileIo, workspace_id: number, resultType: 'logs' | 'responses'): Promise { + async uploadFile( + file: FileIo, + workspace_id: number, + resultType: 'logs' | 'responses', + overwriteExisting: boolean = true + ): Promise { if (file.mimetype === 'text/csv') { const bufferStream = new Readable(); bufferStream.push(file.buffer); @@ -50,7 +60,7 @@ export class UploadResultsService { { bookletLogs: [], unitLogs: [] } ); const persons = await this.personService.createPersonList(rowData, workspace_id); - await this.personService.processPersonLogs(persons, unitLogs, bookletLogs); + await this.personService.processPersonLogs(persons, unitLogs, bookletLogs, overwriteExisting); }); } else if (resultType === 'responses') { await this.handleCsvStream(bufferStream, resultType, async rowData => { diff --git a/apps/backend/src/app/database/services/workspace-coding.service.ts b/apps/backend/src/app/database/services/workspace-coding.service.ts index 4105d178d..3ad409bdd 100644 --- a/apps/backend/src/app/database/services/workspace-coding.service.ts +++ b/apps/backend/src/app/database/services/workspace-coding.service.ts @@ -31,7 +31,20 @@ export class WorkspaceCodingService { ) {} async codeTestPersons(workspace_id: number, testPersonIds: string): Promise { - const ids = testPersonIds.split(','); + const startTime = Date.now(); + const metrics: { [key: string]: number } = {}; + + if (!workspace_id || !testPersonIds || testPersonIds.trim() === '') { + this.logger.warn('Ungültige Eingabeparameter: workspace_id oder testPersonIds fehlen.'); + return { totalResponses: 0, statusCounts: {} }; + } + + const ids = testPersonIds.split(',').filter(id => id.trim() !== ''); + if (ids.length === 0) { + this.logger.warn('Keine gültigen Personen-IDs angegeben.'); + return { totalResponses: 0, statusCounts: {} }; + } + this.logger.log(`Verarbeite Personen ${testPersonIds} für Workspace ${workspace_id}`); const statistics: CodingStatistics = { @@ -39,179 +52,284 @@ export class WorkspaceCodingService { statusCounts: {} }; + const queryRunner = this.responseRepository.manager.connection.createQueryRunner(); + await queryRunner.connect(); + await queryRunner.startTransaction('READ COMMITTED'); + try { + const personsQueryStart = Date.now(); const persons = await this.personsRepository.find({ - where: { workspace_id, id: In(ids) }, select: ['id', 'group', 'login', 'code', 'uploaded_at'] + where: { workspace_id, id: In(ids) }, + select: ['id', 'group', 'login', 'code', 'uploaded_at'] }); + metrics.personsQuery = Date.now() - personsQueryStart; if (!persons || persons.length === 0) { this.logger.warn('Keine Personen gefunden mit den angegebenen IDs.'); + await queryRunner.release(); return statistics; } const personIds = persons.map(person => person.id); - + const bookletQueryStart = Date.now(); const booklets = await this.bookletRepository.find({ - where: { personid: In(personIds) } + where: { personid: In(personIds) }, + select: ['id', 'personid'] // Only select needed fields }); + metrics.bookletQuery = Date.now() - bookletQueryStart; if (!booklets || booklets.length === 0) { this.logger.log('Keine Booklets für die angegebenen Personen gefunden.'); + await queryRunner.release(); return statistics; } const bookletIds = booklets.map(booklet => booklet.id); - + const unitQueryStart = Date.now(); const units = await this.unitRepository.find({ - where: { bookletid: In(bookletIds) } + where: { bookletid: In(bookletIds) }, + select: ['id', 'bookletid', 'name', 'alias'] // Only select needed fields }); + metrics.unitQuery = Date.now() - unitQueryStart; if (!units || units.length === 0) { this.logger.log('Keine Einheiten für die angegebenen Booklets gefunden.'); + await queryRunner.release(); return statistics; } const bookletToUnitsMap = new Map(); - units.forEach(unit => { + const unitIds = new Set(); + const unitAliasesSet = new Set(); + + for (const unit of units) { if (!bookletToUnitsMap.has(unit.bookletid)) { bookletToUnitsMap.set(unit.bookletid, []); } bookletToUnitsMap.get(unit.bookletid).push(unit); - }); + unitIds.add(unit.id); + unitAliasesSet.add(unit.alias.toUpperCase()); + } - const unitIds = units.map(unit => unit.id); - const unitAliases = units.map(unit => unit.alias.toUpperCase()); + const unitIdsArray = Array.from(unitIds); + const unitAliasesArray = Array.from(unitAliasesSet); + const responseQueryStart = Date.now(); const allResponses = await this.responseRepository.find({ - where: { unitid: In(unitIds), status: In(['VALUE_CHANGED']) } + where: { unitid: In(unitIdsArray), status: In(['VALUE_CHANGED']) }, + select: ['id', 'unitid', 'variableid', 'value', 'status'] // Only select needed fields }); + metrics.responseQuery = Date.now() - responseQueryStart; + + if (!allResponses || allResponses.length === 0) { + this.logger.log('Keine zu kodierenden Antworten gefunden.'); + await queryRunner.release(); + return statistics; + } const unitToResponsesMap = new Map(); - allResponses.forEach(response => { + for (const response of allResponses) { if (!unitToResponsesMap.has(response.unitid)) { unitToResponsesMap.set(response.unitid, []); } unitToResponsesMap.get(response.unitid).push(response); - }); + } + + const fileQueryStart = Date.now(); const testFiles = await this.fileUploadRepository.find({ - where: { workspace_id: workspace_id, file_id: In(unitAliases) } + where: { workspace_id: workspace_id, file_id: In(unitAliasesArray) }, + select: ['file_id', 'data', 'filename'] // Only select needed fields }); + metrics.fileQuery = Date.now() - fileQueryStart; const fileIdToTestFileMap = new Map(); testFiles.forEach(file => { fileIdToTestFileMap.set(file.file_id, file); }); - + const schemeExtractStart = Date.now(); const codingSchemeRefs = new Set(); const unitToCodingSchemeRefMap = new Map(); - for (const unit of units) { - const testFile = fileIdToTestFileMap.get(unit.alias.toUpperCase()); - if (!testFile) continue; + const batchSize = 50; + for (let i = 0; i < units.length; i += batchSize) { + const unitBatch = units.slice(i, i + batchSize); - try { - const $ = cheerio.load(testFile.data); - const codingSchemeRefText = $('codingSchemeRef').text(); - if (codingSchemeRefText) { - codingSchemeRefs.add(codingSchemeRefText.toUpperCase()); - unitToCodingSchemeRefMap.set(unit.id, codingSchemeRefText.toUpperCase()); + for (const unit of unitBatch) { + const testFile = fileIdToTestFileMap.get(unit.alias.toUpperCase()); + if (!testFile) continue; + + try { + const $ = cheerio.load(testFile.data); + const codingSchemeRefText = $('codingSchemeRef').text(); + if (codingSchemeRefText) { + codingSchemeRefs.add(codingSchemeRefText.toUpperCase()); + unitToCodingSchemeRefMap.set(unit.id, codingSchemeRefText.toUpperCase()); + } + } catch (error) { + this.logger.error(`--- Fehler beim Verarbeiten der Datei ${testFile.filename}: ${error.message}`); } - } catch (error) { - this.logger.error(`--- Fehler beim Verarbeiten der Datei ${testFile.filename}: ${error.message}`); } } + metrics.schemeExtract = Date.now() - schemeExtractStart; + + const schemeQueryStart = Date.now(); const codingSchemeFiles = await this.fileUploadRepository.find({ where: { file_id: In([...codingSchemeRefs]) }, select: ['file_id', 'data', 'filename'] }); - + metrics.schemeQuery = Date.now() - schemeQueryStart; + const schemeParsing = Date.now(); const fileIdToCodingSchemeMap = new Map(); + const emptyScheme = new Autocoder.CodingScheme({}); + codingSchemeFiles.forEach(file => { try { - const scheme = new Autocoder.CodingScheme(JSON.parse(JSON.stringify(file.data))); + const data = typeof file.data === 'string' ? JSON.parse(file.data) : file.data; + const scheme = new Autocoder.CodingScheme(data); fileIdToCodingSchemeMap.set(file.file_id, scheme); } catch (error) { this.logger.error(`--- Fehler beim Verarbeiten des Kodierschemas ${file.filename}: ${error.message}`); } }); + metrics.schemeParsing = Date.now() - schemeParsing; - const allCodedResponses = []; - - for (const unit of units) { - const responses = unitToResponsesMap.get(unit.id) || []; - if (responses.length === 0) continue; - - statistics.totalResponses += responses.length; - - let scheme = new Autocoder.CodingScheme({}); - const codingSchemeRef = unitToCodingSchemeRefMap.get(unit.id); - if (codingSchemeRef) { - scheme = fileIdToCodingSchemeMap.get(codingSchemeRef) || scheme; - } + const processingStart = Date.now(); - const codedResponses = responses.map(response => { - const codedResult = scheme.code([{ - id: response.variableid, - value: response.value, - status: response.status as ResponseStatusType - }]); + const allCodedResponses = []; + const estimatedResponseCount = allResponses.length; + allCodedResponses.length = estimatedResponseCount; + let responseIndex = 0; + + for (let i = 0; i < units.length; i += batchSize) { + const unitBatch = units.slice(i, i + batchSize); + + for (const unit of unitBatch) { + const responses = unitToResponsesMap.get(unit.id) || []; + if (responses.length === 0) continue; + + statistics.totalResponses += responses.length; + + const codingSchemeRef = unitToCodingSchemeRefMap.get(unit.id); + const scheme = codingSchemeRef ? + (fileIdToCodingSchemeMap.get(codingSchemeRef) || emptyScheme) : + emptyScheme; + + for (const response of responses) { + const codedResult = scheme.code([{ + id: response.variableid, + value: response.value, + status: response.status as ResponseStatusType + }]); + + const codedStatus = codedResult[0]?.status; + if (!statistics.statusCounts[codedStatus]) { + statistics.statusCounts[codedStatus] = 0; + } + statistics.statusCounts[codedStatus] += 1; - const codedStatus = codedResult[0]?.status; - if (!statistics.statusCounts[codedStatus]) { - statistics.statusCounts[codedStatus] = 0; + allCodedResponses[responseIndex] = { + id: response.id, + code: codedResult[0]?.code, + codedstatus: codedStatus, + score: codedResult[0]?.score + }; + responseIndex += 1; } - statistics.statusCounts[codedStatus] += 1; + } + } - return { - ...response, // Enthält die ursprüngliche 'id' und andere Felder der Response - code: codedResult[0]?.code, - codedstatus: codedStatus, - score: codedResult[0]?.score - }; - }); + allCodedResponses.length = responseIndex; + metrics.processing = Date.now() - processingStart; - allCodedResponses.push(...codedResponses); - } + // Update responses in batches with transaction support if (allCodedResponses.length > 0) { + const updateStart = Date.now(); try { - const batchSize = 10000; + const updateBatchSize = 500; const batches = []; - for (let i = 0; i < allCodedResponses.length; i += batchSize) { - batches.push(allCodedResponses.slice(i, i + batchSize)); + for (let i = 0; i < allCodedResponses.length; i += updateBatchSize) { + batches.push(allCodedResponses.slice(i, i + updateBatchSize)); } - this.logger.log(`Starte die Aktualisierung von ${allCodedResponses.length} Responses in ${batches.length} Batches (concurrent).`); + this.logger.log(`Starte die Aktualisierung von ${allCodedResponses.length} Responses in ${batches.length} Batches (sequential).`); - const updateBatchPromises = batches.map(async (batch, index) => { + for (let index = 0; index < batches.length; index++) { + const batch = batches[index]; this.logger.log(`Starte Aktualisierung für Batch #${index + 1} (Größe: ${batch.length}).`); - const individualUpdatePromises = batch.map(codedResponse => this.responseRepository.update( - codedResponse.id, - { - code: codedResponse.code, - codedstatus: codedResponse.codedstatus, - score: codedResponse.score - } - ) - ); + try { - await Promise.all(individualUpdatePromises); + if (batch.length > 0) { + const updatePromises = batch.map(response => queryRunner.manager.update( + ResponseEntity, + response.id, + { + code: response.code, + codedstatus: response.codedstatus, + score: response.score + } + )); + + await Promise.all(updatePromises); + } + this.logger.log(`Batch #${index + 1} (Größe: ${batch.length}) erfolgreich aktualisiert.`); } catch (error) { this.logger.error(`Fehler beim Aktualisieren von Batch #${index + 1} (Größe: ${batch.length}):`, error.message); + // Rollback transaction on error + await queryRunner.rollbackTransaction(); + await queryRunner.release(); throw error; } - }); - - await Promise.all(updateBatchPromises); + } + // Commit transaction if all updates were successful + await queryRunner.commitTransaction(); this.logger.log(`${allCodedResponses.length} Responses wurden erfolgreich aktualisiert.`); } catch (error) { this.logger.error('Fehler beim Aktualisieren der Responses:', error.message); + // Ensure transaction is rolled back on error + try { + await queryRunner.rollbackTransaction(); + } catch (rollbackError) { + this.logger.error('Fehler beim Rollback der Transaktion:', rollbackError.message); + } + } finally { + // Always release the query runner + await queryRunner.release(); } + metrics.update = Date.now() - updateStart; + } else { + // Release query runner if no updates were performed + await queryRunner.release(); } + // Log performance metrics + const totalTime = Date.now() - startTime; + this.logger.log(`Performance metrics for codeTestPersons (total: ${totalTime}ms): + - Persons query: ${metrics.personsQuery}ms + - Booklet query: ${metrics.bookletQuery}ms + - Unit query: ${metrics.unitQuery}ms + - Response query: ${metrics.responseQuery}ms + - File query: ${metrics.fileQuery}ms + - Scheme extraction: ${metrics.schemeExtract}ms + - Scheme query: ${metrics.schemeQuery}ms + - Scheme parsing: ${metrics.schemeParsing}ms + - Response processing: ${metrics.processing}ms + - Database updates: ${metrics.update || 0}ms`); + return statistics; } catch (error) { this.logger.error('Fehler beim Verarbeiten der Personen:', error); + + // Ensure transaction is rolled back on error + try { + await queryRunner.rollbackTransaction(); + } catch (rollbackError) { + this.logger.error('Fehler beim Rollback der Transaktion:', rollbackError.message); + } finally { + // Always release the query runner + await queryRunner.release(); + } + return statistics; } } @@ -349,7 +467,7 @@ export class WorkspaceCodingService { const bookletInfo = booklet?.bookletinfo; const loginName = person?.login || ''; const loginCode = person?.code || ''; - //const loginGroup = person.group || ''; + // const loginGroup = person.group || ''; const bookletId = bookletInfo?.name || ''; const unitKey = unit?.name || ''; const unitAlias = unit?.alias || ''; @@ -523,7 +641,9 @@ export class WorkspaceCodingService { .getRawMany(); statusCountResults.forEach(result => { - statistics.statusCounts[result.statusValue] = parseInt(result.count, 10); + const count = parseInt(result.count, 10); + // Ensure count is a valid number + statistics.statusCounts[result.statusValue] = Number.isNaN(count) ? 0 : count; }); return statistics; 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/backend/src/app/database/services/workspace-test-results.service.ts b/apps/backend/src/app/database/services/workspace-test-results.service.ts index 9c7eabe46..f9f3c9a92 100644 --- a/apps/backend/src/app/database/services/workspace-test-results.service.ts +++ b/apps/backend/src/app/database/services/workspace-test-results.service.ts @@ -10,6 +10,7 @@ import { BookletLog } from '../entities/bookletLog.entity'; import { UnitLog } from '../entities/unitLog.entity'; import { Session } from '../entities/session.entity'; import { UnitTagService } from './unit-tag.service'; +import { JournalService } from './journal.service'; @Injectable() export class WorkspaceTestResultsService { @@ -33,7 +34,8 @@ export class WorkspaceTestResultsService { @InjectRepository(Session) private sessionRepository: Repository, private readonly connection: Connection, - private readonly unitTagService: UnitTagService + private readonly unitTagService: UnitTagService, + private readonly journalService: JournalService ) {} async findPersonTestResults(personId: number, workspaceId: number): Promise<{ @@ -288,15 +290,38 @@ export class WorkspaceTestResultsService { }); const mappedResponses = unit.responses .filter(response => response.subform === 'elementCodes') - .map(response => ({ - id: response.variableid, - value: response.value, - status: response.status - })); + .map(response => { + let value = response.value; + if (typeof value === 'string') { + if (value.startsWith('[') && value.endsWith(']')) { + try { + value = JSON.parse(value); + } catch (e) { + // If parsing fails, keep the original value + this.logger.warn(`Failed to parse JSON array: ${value}`); + } + } else if (value.startsWith('{') && value.endsWith('}')) { + try { + const jsonArrayString = value.replace(/^\{/, '[').replace(/\}$/, ']'); + value = JSON.parse(jsonArrayString); + } catch (e) { + // If parsing fails, keep the original value + this.logger.warn(`Failed to parse curly brace array: ${value}`); + } + } + } + + return { + id: response.variableid, + value: value, + status: response.status + }; + }); const uniqueResponses = mappedResponses.filter( (response, index, self) => index === self.findIndex(r => r.id === response.id) ); + return { responses: [{ id: 'elementCodes', @@ -367,7 +392,15 @@ export class WorkspaceTestResultsService { const existingPersons = await manager .createQueryBuilder(Persons, 'persons') - .select('persons.id') + .select([ + 'persons.id', + 'persons.login', + 'persons.code', + 'persons.group', + 'persons.workspace_id', + 'persons.uploaded_at', + 'persons.source' + ]) .where('persons.id IN (:...ids)', { ids }) .getMany(); @@ -389,16 +422,34 @@ export class WorkspaceTestResultsService { report.deletedPersons = existingIds; + for (const person of existingPersons) { + try { + await this.journalService.createEntry( + 'system', // userId + workspaceId, + 'delete', + 'test-person', + person.id, + { + personId: person.id, + personLogin: person.login, + personCode: person.code, + personGroup: person.group, + personSource: person.source, + personUploadedAt: person.uploaded_at, + message: 'Test person deleted' + } + ); + } catch (error) { + this.logger.error(`Failed to create journal entry for deleting test person ${person.id}: ${error.message}`); + } + } + return { success: true, report }; }); } - /** - * Delete a unit and all its associated responses - * @param workspaceId The ID of the workspace - * @param unitId The ID of the unit to delete - * @returns A success flag and a report with deleted unit and warnings - */ + async deleteUnit( workspaceId: number, unitId: number @@ -415,7 +466,6 @@ export class WorkspaceTestResultsService { warnings: [] }; - // Check if the unit exists and belongs to the workspace const unit = await manager .createQueryBuilder(Unit, 'unit') .leftJoinAndSelect('unit.booklet', 'booklet') @@ -431,7 +481,6 @@ export class WorkspaceTestResultsService { return { success: false, report }; } - // Delete the unit (cascade will delete associated responses) await manager .createQueryBuilder() .delete() @@ -441,16 +490,35 @@ export class WorkspaceTestResultsService { report.deletedUnit = unitId; + try { + await this.journalService.createEntry( + 'system', // userId + workspaceId, + 'delete', + 'unit', + unitId, + { + unitId, + unitName: unit.name, + unitAlias: unit.alias, + bookletId: unit.booklet?.id, + personId: unit.booklet?.person?.id, + personLogin: unit.booklet?.person?.login, + personCode: unit.booklet?.person?.code, + personGroup: unit.booklet?.person?.group, + personSource: unit.booklet?.person?.source, + personUploadedAt: unit.booklet?.person?.uploaded_at, + message: 'Unit deleted' + } + ); + } catch (error) { + this.logger.error(`Failed to create journal entry for deleting unit ${unitId}: ${error.message}`); + } + return { success: true, report }; }); } - /** - * Delete a response - * @param workspaceId The ID of the workspace - * @param responseId The ID of the response to delete - * @returns A success flag and a report with deleted response and warnings - */ async deleteResponse( workspaceId: number, responseId: number @@ -467,7 +535,6 @@ export class WorkspaceTestResultsService { warnings: [] }; - // Check if the response exists and belongs to the workspace const response = await manager .createQueryBuilder(ResponseEntity, 'response') .leftJoinAndSelect('response.unit', 'unit') @@ -484,7 +551,6 @@ export class WorkspaceTestResultsService { return { success: false, report }; } - // Delete the response await manager .createQueryBuilder() .delete() @@ -494,16 +560,37 @@ export class WorkspaceTestResultsService { report.deletedResponse = responseId; + try { + await this.journalService.createEntry( + 'system', // userId + workspaceId, + 'delete', + 'response', + responseId, + { + responseId, + unitId: response.unit.id, + unitName: response.unit.name, + variableId: response.variableid, + value: response.value, + bookletId: response.unit.booklet?.id, + personId: response.unit.booklet?.person?.id, + personLogin: response.unit.booklet?.person?.login, + personCode: response.unit.booklet?.person?.code, + personGroup: response.unit.booklet?.person?.group, + personSource: response.unit.booklet?.person?.source, + personUploadedAt: response.unit.booklet?.person?.uploaded_at, + message: 'Response deleted' + } + ); + } catch (error) { + this.logger.error(`Failed to create journal entry for deleting response ${responseId}: ${error.message}`); + } + return { success: true, report }; }); } - /** - * Delete a booklet and all its associated units and responses - * @param workspaceId The ID of the workspace - * @param bookletId The ID of the booklet to delete - * @returns A success flag and a report with deleted booklet and warnings - */ async deleteBooklet( workspaceId: number, bookletId: number @@ -520,7 +607,6 @@ export class WorkspaceTestResultsService { warnings: [] }; - // Check if the booklet exists and belongs to the workspace const booklet = await manager .createQueryBuilder(Booklet, 'booklet') .leftJoinAndSelect('booklet.person', 'person') @@ -545,17 +631,32 @@ export class WorkspaceTestResultsService { report.deletedBooklet = bookletId; + try { + await this.journalService.createEntry( + 'system', // userId + workspaceId, + 'delete', + 'booklet', + bookletId, + { + bookletId, + personId: booklet.personid, + personLogin: booklet.person?.login || 'Unknown', + personCode: booklet.person?.code, + personGroup: booklet.person?.group, + personSource: booklet.person?.source, + personUploadedAt: booklet.person?.uploaded_at, + message: 'Booklet deleted' + } + ); + } catch (error) { + this.logger.error(`Failed to create journal entry for deleting booklet ${bookletId}: ${error.message}`); + } + return { success: true, report }; }); } - /** - * Search for responses across all test persons in a workspace - * @param workspaceId The ID of the workspace - * @param searchParams Search parameters (value, variableId, unitName) - * @param options Pagination options - * @returns An array of responses matching the search criteria and total count - */ async searchResponses( workspaceId: number, searchParams: { value?: string; variableId?: string; unitName?: string; status?: string; codedStatus?: string; group?: string; code?: string }, @@ -594,7 +695,6 @@ export class WorkspaceTestResultsService { `Searching for responses in workspace: ${workspaceId} with params: ${JSON.stringify(searchParams)} (page: ${page}, limit: ${limit})` ); - // Create a query to find responses matching the search criteria const query = this.responseRepository.createQueryBuilder('response') .innerJoinAndSelect('response.unit', 'unit') .innerJoinAndSelect('unit.booklet', 'booklet') @@ -602,7 +702,6 @@ export class WorkspaceTestResultsService { .innerJoinAndSelect('booklet.bookletinfo', 'bookletinfo') .where('person.workspace_id = :workspaceId', { workspaceId }); - // Add search conditions based on provided parameters if (searchParams.value) { query.andWhere('response.value ILIKE :value', { value: `%${searchParams.value}%` }); } @@ -631,7 +730,6 @@ export class WorkspaceTestResultsService { query.andWhere('person.code = :code', { code: searchParams.code }); } - // Get total count const total = await query.getCount(); if (total === 0) { @@ -639,14 +737,12 @@ export class WorkspaceTestResultsService { return { data: [], total: 0 }; } - // Apply pagination query.skip(skip).take(limit); const responses = await query.getMany(); this.logger.log(`Found ${total} responses matching the criteria in workspace: ${workspaceId}, returning ${responses.length} for page ${page}`); - // Map the results to the desired format const data = responses.map(response => ({ responseId: response.id, variableId: response.variableid, @@ -676,13 +772,6 @@ export class WorkspaceTestResultsService { } } - /** - * Find units by name across all test persons in a workspace - * @param workspaceId The ID of the workspace - * @param unitName The name of the unit to search for - * @param options Pagination options - * @returns An array of units with the same name across different test persons and total count - */ async findUnitsByName( workspaceId: number, unitName: string, @@ -725,7 +814,6 @@ export class WorkspaceTestResultsService { .where('unit.name = :unitName', { unitName }) .andWhere('person.workspace_id = :workspaceId', { workspaceId }); - // Get total count const total = await query.getCount(); if (total === 0) { @@ -733,27 +821,23 @@ export class WorkspaceTestResultsService { return { data: [], total: 0 }; } - // Apply pagination query.skip(skip).take(limit); const units = await query.getMany(); this.logger.log(`Found ${total} units with name: ${unitName} in workspace: ${workspaceId}, returning ${units.length} for page ${page}`); - // Get tags for all units const unitIds = units.map(unit => unit.id); const allUnitTags = await Promise.all( unitIds.map(unitId => this.unitTagService.findAllByUnitId(unitId)) ); - // Create a map of unit ID to tags const unitTagsMap = new Map(); unitIds.forEach((unitId, index) => { unitTagsMap.set(unitId, allUnitTags[index]); }); - // Map the results to the desired format - const data = units.map(unit => ({ + let data = units.map(unit => ({ unitId: unit.id, unitName: unit.name, unitAlias: unit.alias, @@ -774,7 +858,16 @@ export class WorkspaceTestResultsService { })) : [] })); - return { data, total }; + const uniqueMap = new Map(); + data.forEach(item => { + const uniqueKey = `${item.personGroup}|${item.personCode}|${item.personLogin}|${item.bookletName}|${item.unitName}`; + if (!uniqueMap.has(uniqueKey)) { + uniqueMap.set(uniqueKey, item); + } + }); + + data = Array.from(uniqueMap.values()); + return { data, total: data.length }; } catch (error) { this.logger.error( `Failed to search for units with name: ${unitName} in workspace: ${workspaceId}`, 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.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/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/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/components/home/home.component.html b/apps/frontend/src/app/components/home/home.component.html index c9d48510b..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.2'" + [appVersion]="'0.8.0'" [userName]="authData.userName" [userLongName]="appService.userProfile.firstName + ' ' + appService.userProfile.lastName" [isUserLoggedIn]="Number(authData.userId) > 0" 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.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..d1b0b4612 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( 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/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/backend.service.ts b/apps/frontend/src/app/services/backend.service.ts index 4d08ca023..51aba487f 100755 --- a/apps/frontend/src/app/services/backend.service.ts +++ b/apps/frontend/src/app/services/backend.service.ts @@ -385,14 +385,20 @@ export class BackendService { }); } - uploadTestResults(workspaceId: number, files: FileList | null, resultType: 'logs' | 'responses'): Observable { + uploadTestResults( + workspaceId: number, + files: FileList | null, + resultType: 'logs' | 'responses', + overwriteExisting: boolean = true + ): Observable { const formData = new FormData(); if (files) { for (let i = 0; i < files.length; i++) { formData.append('files', files[i]); } } - return this.http.post(`${this.serverUrl}admin/workspace/${workspaceId}/upload/results/${resultType}`, formData, { + const url = `${this.serverUrl}admin/workspace/${workspaceId}/upload/results/${resultType}?overwriteExisting=${overwriteExisting}`; + return this.http.post(url, formData, { headers: this.authHeader }); } @@ -521,13 +527,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 }); } @@ -586,7 +591,8 @@ export class BackendService { url:string, token:string, importOptions:ImportOptions, - testGroups: string[] + testGroups: string[], + overwriteExistingLogs:boolean = false ): Observable { const { units, responses, definitions, player, codings, logs, testTakers, booklets @@ -605,13 +611,21 @@ export class BackendService { .set('token', token) .set('testTakers', String(testTakers)) .set('booklets', String(booklets)) - .set('testGroups', String(testGroups.join(','))); + .set('testGroups', String(testGroups.join(','))) + .set('overwriteExistingLogs', String(overwriteExistingLogs)); return this.http .get(`${this.serverUrl}admin/workspace/${workspace_id}/importWorkspaceFiles`, { headers: this.authHeader, params }) .pipe( catchError(() => of({ - success: false, testFiles: 0, responses: 0, logs: 0 + success: false, + testFiles: 0, + responses: 0, + logs: 0, + booklets: 0, + units: 0, + persons: 0, + importedGroups: [] })) ); } 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..99b6bafb0 --- /dev/null +++ b/apps/frontend/src/app/services/journal-interceptor.ts @@ -0,0 +1,164 @@ +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 and are related to test results + if (isDataModifyingRequest(request) && event.status >= 200 && event.status < 300 && + isTestResultsRequest(request)) { + // 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); +} + +/** + * Checks if the request is related to test results + */ +function isTestResultsRequest(request: HttpRequest): boolean { + // Check if the request URL contains test-results related paths + return request.url.includes('/test-results') || + request.url.includes('/responses') || + request.url.includes('/units') || + request.url.includes('/booklets'); +} + +/** + * Logs an action to the journal + */ +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 '0'; +} + +function sanitizeBody(body: unknown): unknown { + // Remove sensitive information from the body + if (!body) return null; + if (typeof body !== 'object') return body; + + const sanitized = { ...body as Record }; + + // Remove sensitive fields + if (sanitized.password) sanitized.password = '***'; + if (sanitized.token) sanitized.token = '***'; + if (sanitized.authToken) sanitized.authToken = '***'; + + return sanitized; +} 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-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..7aae21177 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(); + } +} diff --git a/apps/frontend/src/app/ws-admin/components/unit-search-dialog/unit-search-dialog.component.ts b/apps/frontend/src/app/ws-admin/components/unit-search-dialog/unit-search-dialog.component.ts index 8675b6ea2..e100bfec8 100644 --- a/apps/frontend/src/app/ws-admin/components/unit-search-dialog/unit-search-dialog.component.ts +++ b/apps/frontend/src/app/ws-admin/components/unit-search-dialog/unit-search-dialog.component.ts @@ -234,10 +234,6 @@ export class UnitSearchDialogComponent implements OnInit { this.dialogRef.close(); } - /** - * Replays a unit in a new tab - * @param item The unit or response to replay - */ replayUnit(item: UnitSearchResult | ResponseSearchResult): void { this.appService .createToken(this.appService.selectedWorkspaceId, this.appService.loggedUser?.sub || '', 1) @@ -248,17 +244,13 @@ export class UnitSearchDialogComponent implements OnInit { const url = this.router .serializeUrl( this.router.createUrlTree( - [`replay/${item.personLogin}@${item.personCode}@${item.bookletId}/${item.unitAlias}/0/0`], + [`replay/${item.personLogin}@${item.personCode}@${item.bookletName}/${item.unitAlias}/0/0`], { queryParams: queryParams }) ); window.open(`#/${url}`, '_blank'); }); } - /** - * Deletes a unit and all its associated responses - * @param unit The unit to delete - */ deleteUnit(unit: UnitSearchResult): void { const dialogRef = this.dialog.open(ConfirmDialogComponent, { width: '400px', @@ -308,10 +300,6 @@ export class UnitSearchDialogComponent implements OnInit { }); } - /** - * Deletes a response - * @param response The response to delete - */ deleteResponse(response: ResponseSearchResult): void { const dialogRef = this.dialog.open(ConfirmDialogComponent, { width: '400px', @@ -333,11 +321,8 @@ export class UnitSearchDialogComponent implements OnInit { next: apiResponse => { this.isLoading = false; if (apiResponse.success) { - // Remove the response from the results this.responseSearchResults = this.responseSearchResults.filter(r => r.responseId !== response.responseId); - // Update total count this.totalItems -= 1; - // Show success message this.snackBar.open( `Antwort erfolgreich gelöscht. Antwort ID: ${apiResponse.report.deletedResponse}`, 'Schließen', @@ -365,9 +350,6 @@ export class UnitSearchDialogComponent implements OnInit { }); } - /** - * Deletes all filtered units - */ deleteAllUnits(): void { if (this.unitSearchResults.length === 0) { this.snackBar.open( @@ -401,19 +383,14 @@ export class UnitSearchDialogComponent implements OnInit { this.isLoading = false; if (response.success) { const deletedCount = response.report.deletedUnits.length; - - // Clear the search results this.unitSearchResults = []; this.totalItems = 0; - - // Show success message this.snackBar.open( `${deletedCount} Aufgaben erfolgreich gelöscht.`, 'Schließen', { duration: 3000 } ); } else { - // Show error message this.snackBar.open( `Fehler beim Löschen der Aufgaben: ${response.report.warnings.join(', ')}`, 'Fehler', @@ -434,9 +411,6 @@ export class UnitSearchDialogComponent implements OnInit { }); } - /** - * Deletes all filtered responses - */ deleteAllResponses(): void { if (this.responseSearchResults.length === 0) { this.snackBar.open( 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 @@ + 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": {