diff --git a/apps/backend/src/app/admin/admin.module.ts b/apps/backend/src/app/admin/admin.module.ts index ad9925809..6b5aa060d 100755 --- a/apps/backend/src/app/admin/admin.module.ts +++ b/apps/backend/src/app/admin/admin.module.ts @@ -15,6 +15,8 @@ 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'; +import { VariableAnalysisController } from './variable-analysis/variable-analysis.controller'; +import { JobsController } from './jobs/jobs.controller'; @Module({ imports: [ @@ -35,7 +37,9 @@ import { JournalController } from './workspace/journal.controller'; UnitTagsController, UnitNotesController, ResourcePackageController, - JournalController + JournalController, + VariableAnalysisController, + JobsController ], providers: [] }) diff --git a/apps/backend/src/app/admin/jobs/dto/job.dto.ts b/apps/backend/src/app/admin/jobs/dto/job.dto.ts new file mode 100644 index 000000000..1eb50ad27 --- /dev/null +++ b/apps/backend/src/app/admin/jobs/dto/job.dto.ts @@ -0,0 +1,31 @@ +import { Job } from '../../../database/entities/job.entity'; + +export class JobDto { + id: number; + workspace_id: number; + type: string; + status: string; + progress?: number; + error?: string; + result?: string; + created_at: Date; + updated_at: Date; + + /** + * Static method to create a DTO from an entity + */ + static fromEntity(entity: Job): JobDto { + const dto = new JobDto(); + dto.id = entity.id; + dto.workspace_id = entity.workspace_id; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + dto.type = (entity as any).type; // Type is added by TypeORM for inheritance + dto.status = entity.status; + dto.progress = entity.progress; + dto.error = entity.error; + dto.result = entity.result; + dto.created_at = entity.created_at; + dto.updated_at = entity.updated_at; + return dto; + } +} diff --git a/apps/backend/src/app/admin/jobs/jobs.controller.ts b/apps/backend/src/app/admin/jobs/jobs.controller.ts new file mode 100644 index 000000000..fce90e58c --- /dev/null +++ b/apps/backend/src/app/admin/jobs/jobs.controller.ts @@ -0,0 +1,152 @@ +import { + BadRequestException, + Controller, + Get, + NotFoundException, + Param, + Post, + UseGuards +} from '@nestjs/common'; +import { + ApiBadRequestResponse, + ApiBearerAuth, + ApiNotFoundResponse, + ApiOkResponse, + ApiOperation, + ApiParam, + ApiTags +} from '@nestjs/swagger'; +import { JwtAuthGuard } from '../../auth/jwt-auth.guard'; +import { WorkspaceGuard } from '../workspace/workspace.guard'; +import { WorkspaceId } from '../workspace/workspace.decorator'; +import { JobService } from '../../database/services/job.service'; +import { JobDto } from './dto/job.dto'; + +@ApiTags('Jobs') +@Controller('admin/workspace/:workspace_id/jobs') +export class JobsController { + constructor(private readonly jobService: JobService) {} + + @Get() + @UseGuards(JwtAuthGuard, WorkspaceGuard) + @ApiBearerAuth() + @ApiOperation({ + summary: 'Get all jobs for a workspace', + description: 'Retrieves all jobs for a workspace' + }) + @ApiParam({ + name: 'workspace_id', + type: Number, + required: true, + description: 'The ID of the workspace' + }) + @ApiOkResponse({ + description: 'The jobs have been successfully retrieved.', + type: [JobDto] + }) + @ApiBadRequestResponse({ + description: 'Invalid input data.' + }) + @ApiNotFoundResponse({ + description: 'Workspace not found.' + }) + async getJobs(@WorkspaceId() workspaceId: number): Promise { + try { + const jobs = await this.jobService.getJobs(workspaceId); + return jobs.map(job => JobDto.fromEntity(job)); + } catch (error) { + if (error instanceof NotFoundException) { + throw error; + } + throw new BadRequestException(`Failed to retrieve jobs: ${error.message}`); + } + } + + @Get(':job_id') + @UseGuards(JwtAuthGuard, WorkspaceGuard) + @ApiBearerAuth() + @ApiOperation({ + summary: 'Get a job by ID', + description: 'Retrieves a job by ID' + }) + @ApiParam({ + name: 'workspace_id', + type: Number, + required: true, + description: 'The ID of the workspace' + }) + @ApiParam({ + name: 'job_id', + type: Number, + required: true, + description: 'The ID of the job' + }) + @ApiOkResponse({ + description: 'The job has been successfully retrieved.', + type: JobDto + }) + @ApiNotFoundResponse({ + description: 'Job not found.' + }) + async getJob( + @WorkspaceId() workspaceId: number, + @Param('job_id') jobId: number + ): Promise { + try { + const job = await this.jobService.getJob(jobId, workspaceId); + return JobDto.fromEntity(job); + } catch (error) { + if (error instanceof NotFoundException) { + throw error; + } + throw new BadRequestException(`Failed to retrieve job: ${error.message}`); + } + } + + @Post(':job_id/cancel') + @UseGuards(JwtAuthGuard, WorkspaceGuard) + @ApiBearerAuth() + @ApiOperation({ + summary: 'Cancel a job', + description: 'Cancels a job by ID' + }) + @ApiParam({ + name: 'workspace_id', + type: Number, + required: true, + description: 'The ID of the workspace' + }) + @ApiParam({ + name: 'job_id', + type: Number, + required: true, + description: 'The ID of the job' + }) + @ApiOkResponse({ + description: 'The job has been successfully cancelled.', + schema: { + type: 'object', + properties: { + success: { type: 'boolean' }, + message: { type: 'string' } + } + } + }) + @ApiNotFoundResponse({ + description: 'Job not found.' + }) + async cancelJob( + @WorkspaceId() workspaceId: number, + @Param('job_id') jobId: number + ): Promise<{ success: boolean; message: string }> { + try { + await this.jobService.getJob(jobId, workspaceId); + return await this.jobService.cancelJob(jobId); + } catch (error) { + if (error instanceof NotFoundException) { + throw error; + } + throw new BadRequestException(`Failed to cancel job: ${error.message}`); + } + } +} diff --git a/apps/backend/src/app/admin/variable-analysis/dto/variable-analysis-job.dto.ts b/apps/backend/src/app/admin/variable-analysis/dto/variable-analysis-job.dto.ts new file mode 100644 index 000000000..a308bb637 --- /dev/null +++ b/apps/backend/src/app/admin/variable-analysis/dto/variable-analysis-job.dto.ts @@ -0,0 +1,46 @@ +import { VariableAnalysisJob } from '../../../database/entities/variable-analysis-job.entity'; + +export class VariableAnalysisJobDto { + id: number; + workspace_id: number; + + /** + * Optional unit ID to filter by + */ + unit_id?: number; + + /** + * Optional variable ID to filter by + */ + variable_id?: string; + + /** + * Status of the job: 'pending', 'processing', 'completed', 'failed' + */ + status: string; + error?: string; + created_at: Date; + updated_at: Date; + + /** + * Type of the job, used for inheritance discrimination + */ + type?: string; + + /** + * Static method to create a DTO from an entity + */ + static fromEntity(entity: VariableAnalysisJob): VariableAnalysisJobDto { + const dto = new VariableAnalysisJobDto(); + dto.id = entity.id; + dto.workspace_id = entity.workspace_id; + dto.unit_id = entity.unit_id; + dto.variable_id = entity.variable_id; + dto.status = entity.status; + dto.error = entity.error; + dto.created_at = entity.created_at; + dto.updated_at = entity.updated_at; + dto.type = entity.type; + return dto; + } +} diff --git a/apps/backend/src/app/admin/variable-analysis/dto/variable-analysis-result.dto.ts b/apps/backend/src/app/admin/variable-analysis/dto/variable-analysis-result.dto.ts new file mode 100644 index 000000000..32868ad6c --- /dev/null +++ b/apps/backend/src/app/admin/variable-analysis/dto/variable-analysis-result.dto.ts @@ -0,0 +1,12 @@ +import { VariableFrequencyDto } from './variable-frequency.dto'; + +export interface VariableCombo { + unitName: string; + variableId: string; +} + +export class VariableAnalysisResultDto { + variableCombos: VariableCombo[]; + frequencies: { [key: string]: VariableFrequencyDto[] }; + total: number; +} diff --git a/apps/backend/src/app/admin/variable-analysis/dto/variable-analysis.dto.ts b/apps/backend/src/app/admin/variable-analysis/dto/variable-analysis.dto.ts new file mode 100644 index 000000000..ee3d83412 --- /dev/null +++ b/apps/backend/src/app/admin/variable-analysis/dto/variable-analysis.dto.ts @@ -0,0 +1,40 @@ +/** + * DTO for variable frequency data + */ +export class VariableFrequencyDto { + /** + * The ID of the variable + */ + variableId: string; + + /** + * The value of the variable + */ + value: string; + + /** + * The count of occurrences of this value + */ + count: number; + + /** + * The percentage of occurrences of this value + */ + percentage: number; +} + +/** + * DTO for variable analysis result + */ +export class VariableAnalysisResultDto { + /** + * List of variable IDs + */ + variables: string[]; + + /** + * Map of variable ID to frequency data + */ + frequencies: { [key: string]: VariableFrequencyDto[] }; + total: number; +} diff --git a/apps/backend/src/app/admin/variable-analysis/dto/variable-frequency.dto.ts b/apps/backend/src/app/admin/variable-analysis/dto/variable-frequency.dto.ts new file mode 100644 index 000000000..16b047983 --- /dev/null +++ b/apps/backend/src/app/admin/variable-analysis/dto/variable-frequency.dto.ts @@ -0,0 +1,7 @@ +export class VariableFrequencyDto { + unitName?: string; + variableId: string; + value: string; + count: number; + percentage: number; +} diff --git a/apps/backend/src/app/admin/variable-analysis/variable-analysis.controller.ts b/apps/backend/src/app/admin/variable-analysis/variable-analysis.controller.ts new file mode 100644 index 000000000..0455b268f --- /dev/null +++ b/apps/backend/src/app/admin/variable-analysis/variable-analysis.controller.ts @@ -0,0 +1,280 @@ +import { + BadRequestException, + Controller, + Get, + NotFoundException, + Param, + Post, + Query, + UseGuards +} from '@nestjs/common'; +import { + ApiBadRequestResponse, + ApiBearerAuth, + ApiCreatedResponse, + ApiNotFoundResponse, + ApiOkResponse, + ApiOperation, + ApiParam, + ApiQuery, + ApiTags +} from '@nestjs/swagger'; +import { JwtAuthGuard } from '../../auth/jwt-auth.guard'; +import { WorkspaceGuard } from '../workspace/workspace.guard'; +import { WorkspaceId } from '../workspace/workspace.decorator'; +import { VariableAnalysisService } from '../../database/services/variable-analysis.service'; +import { VariableAnalysisResultDto } from './dto/variable-analysis-result.dto'; +import { VariableAnalysisJobDto } from './dto/variable-analysis-job.dto'; + +@ApiTags('Variable Analysis') +@Controller('admin/workspace/:workspace_id/variable-analysis') +export class VariableAnalysisController { + constructor(private readonly variableAnalysisService: VariableAnalysisService) {} + + @Get() + @UseGuards(JwtAuthGuard, WorkspaceGuard) + @ApiBearerAuth() + @ApiOperation({ + summary: 'Get variable frequencies synchronously', + description: 'Retrieves frequency analysis for variables in a workspace (synchronous operation)' + }) + @ApiParam({ + name: 'workspace_id', + type: Number, + required: true, + description: 'The ID of the workspace' + }) + @ApiQuery({ + name: 'unitId', + type: Number, + required: false, + description: 'Optional unit ID to filter by' + }) + @ApiQuery({ + name: 'variableId', + type: String, + required: false, + description: 'Optional variable ID to filter by' + }) + @ApiQuery({ + name: 'page', + type: Number, + required: false, + description: 'Page number for pagination' + }) + @ApiQuery({ + name: 'limit', + type: Number, + required: false, + description: 'Number of items per page' + }) + @ApiOkResponse({ + description: 'The variable frequencies have been successfully retrieved.', + type: VariableAnalysisResultDto + }) + @ApiBadRequestResponse({ + description: 'Invalid input data.' + }) + @ApiNotFoundResponse({ + description: 'Workspace not found.' + }) + async getVariableFrequencies(@WorkspaceId() workspaceId: number, @Query('unitId') unitId?: number, @Query('variableId') variableId?: string): Promise { + try { + return await this.variableAnalysisService.getVariableFrequencies( + workspaceId, + unitId, + variableId + ); + } catch (error) { + if (error instanceof NotFoundException) { + throw error; + } + throw new BadRequestException(`Failed to retrieve variable frequencies: ${error.message}`); + } + } + + @Post('jobs') + @UseGuards(JwtAuthGuard, WorkspaceGuard) + @ApiBearerAuth() + @ApiOperation({ + summary: 'Create a new variable analysis job', + description: 'Initiates an asynchronous variable analysis job' + }) + @ApiParam({ + name: 'workspace_id', + type: Number, + required: true, + description: 'The ID of the workspace' + }) + @ApiQuery({ + name: 'unitId', + type: Number, + required: false, + description: 'Optional unit ID to filter by' + }) + @ApiQuery({ + name: 'variableId', + type: String, + required: false, + description: 'Optional variable ID to filter by' + }) + @ApiCreatedResponse({ + description: 'The variable analysis job has been successfully created.', + type: VariableAnalysisJobDto + }) + @ApiBadRequestResponse({ + description: 'Invalid input data.' + }) + @ApiNotFoundResponse({ + description: 'Workspace not found.' + }) + async createAnalysisJob( + @WorkspaceId() workspaceId: number, + @Query('unitId') unitId?: number, + @Query('variableId') variableId?: string + ): Promise { + try { + const job = await this.variableAnalysisService.createAnalysisJob( + workspaceId, + unitId, + variableId + ); + return VariableAnalysisJobDto.fromEntity(job); + } catch (error) { + if (error instanceof NotFoundException) { + throw error; + } + throw new BadRequestException(`Failed to create variable analysis job: ${error.message}`); + } + } + + @Get('jobs') + @UseGuards(JwtAuthGuard, WorkspaceGuard) + @ApiBearerAuth() + @ApiOperation({ + summary: 'Get all variable analysis jobs for a workspace', + description: 'Retrieves all variable analysis jobs for a workspace' + }) + @ApiParam({ + name: 'workspace_id', + type: Number, + required: true, + description: 'The ID of the workspace' + }) + @ApiOkResponse({ + description: 'The variable analysis jobs have been successfully retrieved.', + type: [VariableAnalysisJobDto] + }) + @ApiBadRequestResponse({ + description: 'Invalid input data.' + }) + @ApiNotFoundResponse({ + description: 'Workspace not found.' + }) + async getAnalysisJobs( + @WorkspaceId() workspaceId: number + ): Promise { + try { + const jobs = await this.variableAnalysisService.getAnalysisJobs(workspaceId); + return jobs.map(job => VariableAnalysisJobDto.fromEntity(job)); + } catch (error) { + if (error instanceof NotFoundException) { + throw error; + } + throw new BadRequestException(`Failed to retrieve variable analysis jobs: ${error.message}`); + } + } + + @Get('jobs/:job_id') + @UseGuards(JwtAuthGuard, WorkspaceGuard) + @ApiBearerAuth() + @ApiOperation({ + summary: 'Get a variable analysis job by ID', + description: 'Retrieves a variable analysis job by ID' + }) + @ApiParam({ + name: 'workspace_id', + type: Number, + required: true, + description: 'The ID of the workspace' + }) + @ApiParam({ + name: 'job_id', + type: Number, + required: true, + description: 'The ID of the job' + }) + @ApiOkResponse({ + description: 'The variable analysis job has been successfully retrieved.', + type: VariableAnalysisJobDto + }) + @ApiBadRequestResponse({ + description: 'Invalid input data.' + }) + @ApiNotFoundResponse({ + description: 'Job not found.' + }) + async getAnalysisJob( + @WorkspaceId() workspaceId: number, + @Param('job_id') jobId: number + ): Promise { + try { + const job = await this.variableAnalysisService.getAnalysisJob(jobId, workspaceId); + return VariableAnalysisJobDto.fromEntity(job); + } catch (error) { + if (error instanceof NotFoundException) { + throw error; + } + if (error.message && error.message.includes('not found in workspace')) { + throw new NotFoundException(error.message); + } + throw new BadRequestException(`Failed to retrieve variable analysis job: ${error.message}`); + } + } + + @Get('jobs/:job_id/results') + @UseGuards(JwtAuthGuard, WorkspaceGuard) + @ApiBearerAuth() + @ApiOperation({ + summary: 'Get the results of a variable analysis job', + description: 'Retrieves the results of a completed variable analysis job' + }) + @ApiParam({ + name: 'workspace_id', + type: Number, + required: true, + description: 'The ID of the workspace' + }) + @ApiParam({ + name: 'job_id', + type: Number, + required: true, + description: 'The ID of the job' + }) + @ApiOkResponse({ + description: 'The variable analysis results have been successfully retrieved.', + type: VariableAnalysisResultDto + }) + @ApiBadRequestResponse({ + description: 'Invalid input data or job not completed.' + }) + @ApiNotFoundResponse({ + description: 'Job not found.' + }) + async getAnalysisResults( + @WorkspaceId() workspaceId: number, + @Param('job_id') jobId: number + ): Promise { + try { + return await this.variableAnalysisService.getAnalysisResults(jobId, workspaceId); + } catch (error) { + if (error instanceof NotFoundException) { + throw error; + } + if (error.message && error.message.includes('not found in workspace')) { + throw new NotFoundException(error.message); + } + throw new BadRequestException(`Failed to retrieve variable analysis results: ${error.message}`); + } + } +} diff --git a/apps/backend/src/app/admin/workspace/workspace-coding.controller.ts b/apps/backend/src/app/admin/workspace/workspace-coding.controller.ts index 3bfbd2a16..b9216ec46 100644 --- a/apps/backend/src/app/admin/workspace/workspace-coding.controller.ts +++ b/apps/backend/src/app/admin/workspace/workspace-coding.controller.ts @@ -12,12 +12,14 @@ import { JwtAuthGuard } from '../../auth/jwt-auth.guard'; import { WorkspaceGuard } from './workspace.guard'; import { WorkspaceId } from './workspace.decorator'; import { WorkspaceCodingService } from '../../database/services/workspace-coding.service'; +import { PersonService } from '../../database/services/person.service'; @ApiTags('Admin Workspace Coding') @Controller('admin/workspace') export class WorkspaceCodingController { constructor( - private workspaceCodingService: WorkspaceCodingService + private workspaceCodingService: WorkspaceCodingService, + private personService: PersonService ) {} @Get(':workspace_id/coding') @@ -186,7 +188,7 @@ export class WorkspaceCodingController { properties: { status: { type: 'string', - enum: ['pending', 'processing', 'completed', 'failed', 'cancelled'], + enum: ['pending', 'processing', 'completed', 'failed', 'cancelled', 'paused'], description: 'Current status of the job' }, progress: { @@ -261,7 +263,7 @@ export class WorkspaceCodingController { }, status: { type: 'string', - enum: ['pending', 'processing', 'completed', 'failed', 'cancelled'], + enum: ['pending', 'processing', 'completed', 'failed', 'cancelled', 'paused'], description: 'Current status of the job' }, progress: { @@ -298,7 +300,7 @@ export class WorkspaceCodingController { }) async getAllJobs(@WorkspaceId() workspace_id: number): Promise<{ jobId: string; - status: 'pending' | 'processing' | 'completed' | 'failed' | 'cancelled'; + status: 'pending' | 'processing' | 'completed' | 'failed' | 'cancelled' | 'paused'; progress: number; result?: CodingStatistics; error?: string; @@ -307,4 +309,72 @@ export class WorkspaceCodingController { }[]> { return this.workspaceCodingService.getAllJobs(workspace_id); } + + @Get(':workspace_id/coding/groups') + @UseGuards(JwtAuthGuard, WorkspaceGuard) + @ApiTags('coding') + @ApiParam({ name: 'workspace_id', type: Number }) + @ApiOkResponse({ + description: 'List of all test person groups in the workspace retrieved successfully.', + schema: { + type: 'array', + items: { + type: 'string', + description: 'Group name' + } + } + }) + async getWorkspaceGroups(@WorkspaceId() workspace_id: number): Promise { + return this.personService.getWorkspaceGroups(workspace_id); + } + + @Get(':workspace_id/coding/job/:jobId/pause') + @UseGuards(JwtAuthGuard, WorkspaceGuard) + @ApiTags('coding') + @ApiParam({ name: 'workspace_id', type: Number }) + @ApiParam({ name: 'jobId', type: String, description: 'ID of the background job to pause' }) + @ApiOkResponse({ + description: 'Job pause request processed.', + schema: { + type: 'object', + properties: { + success: { + type: 'boolean', + description: 'Whether the pause request was successful' + }, + message: { + type: 'string', + description: 'Message describing the result of the pause request' + } + } + } + }) + async pauseJob(@Param('jobId') jobId: string): Promise<{ success: boolean; message: string }> { + return this.workspaceCodingService.pauseJob(jobId); + } + + @Get(':workspace_id/coding/job/:jobId/resume') + @UseGuards(JwtAuthGuard, WorkspaceGuard) + @ApiTags('coding') + @ApiParam({ name: 'workspace_id', type: Number }) + @ApiParam({ name: 'jobId', type: String, description: 'ID of the background job to resume' }) + @ApiOkResponse({ + description: 'Job resume request processed.', + schema: { + type: 'object', + properties: { + success: { + type: 'boolean', + description: 'Whether the resume request was successful' + }, + message: { + type: 'string', + description: 'Message describing the result of the resume request' + } + } + } + }) + async resumeJob(@Param('jobId') jobId: string): Promise<{ success: boolean; message: string }> { + return this.workspaceCodingService.resumeJob(jobId); + } } diff --git a/apps/backend/src/app/admin/workspace/workspace-files.controller.ts b/apps/backend/src/app/admin/workspace/workspace-files.controller.ts index 43bdac1c5..3383789a4 100644 --- a/apps/backend/src/app/admin/workspace/workspace-files.controller.ts +++ b/apps/backend/src/app/admin/workspace/workspace-files.controller.ts @@ -512,4 +512,24 @@ export class WorkspaceFilesController { const ids = responseIds.split(',').map(id => parseInt(id, 10)); return this.workspaceFilesService.deleteInvalidResponses(workspace_id, ids); } + + @Delete(':workspace_id/files/all-invalid-responses') + @ApiTags('test files validation') + @UseGuards(JwtAuthGuard, WorkspaceGuard) + @ApiOperation({ summary: 'Delete all invalid responses', description: 'Deletes all invalid responses of a specific type from the database' }) + @ApiParam({ name: 'workspace_id', type: Number, description: 'ID of the workspace' }) + @ApiQuery({ + name: 'validationType', + enum: ['variables', 'variableTypes', 'responseStatus'], + description: 'Type of validation to use for identifying invalid responses' + }) + @ApiOkResponse({ + description: 'Number of deleted responses', + type: Number + }) + async deleteAllInvalidResponses( + @Param('workspace_id') workspace_id: number, + @Query('validationType') validationType: 'variables' | 'variableTypes' | 'responseStatus'): Promise { + return this.workspaceFilesService.deleteAllInvalidResponses(workspace_id, validationType); + } } diff --git a/apps/backend/src/app/database/database.module.ts b/apps/backend/src/app/database/database.module.ts index 104e9b5e9..4559abf54 100755 --- a/apps/backend/src/app/database/database.module.ts +++ b/apps/backend/src/app/database/database.module.ts @@ -38,6 +38,11 @@ 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'; +import { VariableAnalysisService } from './services/variable-analysis.service'; +import { JobService } from './services/job.service'; +import { Job } from './entities/job.entity'; +import { VariableAnalysisJob } from './entities/variable-analysis-job.entity'; +import { TestPersonCodingJob } from './entities/test-person-coding-job.entity'; @Module({ imports: [ @@ -67,7 +72,7 @@ import { JournalService } from './services/journal.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, JournalEntry + User, Workspace, WorkspaceAdmin, FileUpload, WorkspaceUser, ResourcePackage, Logs, Persons, ChunkEntity, BookletLog, Session, UnitLog, UnitTag, UnitNote, JournalEntry, Job, VariableAnalysisJob, TestPersonCodingJob ], synchronize: false }), @@ -93,7 +98,10 @@ import { JournalService } from './services/journal.service'; Session, UnitTag, UnitNote, - JournalEntry + JournalEntry, + Job, + VariableAnalysisJob, + TestPersonCodingJob ]) ], providers: [ @@ -112,7 +120,9 @@ import { JournalService } from './services/journal.service'; UnitTagService, UnitNoteService, ResourcePackageService, - JournalService + JournalService, + VariableAnalysisService, + JobService ], exports: [ User, @@ -137,7 +147,9 @@ import { JournalService } from './services/journal.service'; AuthService, UnitTagService, UnitNoteService, - JournalService + JournalService, + VariableAnalysisService, + JobService ] }) export class DatabaseModule {} diff --git a/apps/backend/src/app/database/entities/job.entity.ts b/apps/backend/src/app/database/entities/job.entity.ts new file mode 100644 index 000000000..698e30e98 --- /dev/null +++ b/apps/backend/src/app/database/entities/job.entity.ts @@ -0,0 +1,48 @@ +import { + Entity, + Column, + PrimaryGeneratedColumn, + CreateDateColumn, + UpdateDateColumn, + TableInheritance +} from 'typeorm'; + +/** + * Base entity for all job types + */ +@Entity() +@TableInheritance({ column: { type: 'varchar', name: 'type' } }) +export class Job { + @PrimaryGeneratedColumn() + id: number; + + @Column() + workspace_id: number; + + /** + * Status of the job: 'pending', 'processing', 'completed', 'failed', 'cancelled', 'paused' + */ + @Column() + status: string; + + /** + * Progress of the job (0-100) + */ + @Column({ nullable: true }) + progress?: number; + + @Column({ nullable: true }) + error?: string; + + @Column({ type: 'text', nullable: true }) + result?: string; + + @Column({ type: 'text', nullable: true }) + type?: string; + + @CreateDateColumn() + created_at: Date; + + @UpdateDateColumn() + updated_at: Date; +} diff --git a/apps/backend/src/app/database/entities/test-person-coding-job.entity.ts b/apps/backend/src/app/database/entities/test-person-coding-job.entity.ts new file mode 100644 index 000000000..095c0722e --- /dev/null +++ b/apps/backend/src/app/database/entities/test-person-coding-job.entity.ts @@ -0,0 +1,30 @@ +import { + Column, + ChildEntity +} from 'typeorm'; +import { Job } from './job.entity'; + +/** + * Entity for test person coding jobs + */ +@ChildEntity('test-person-coding') +export class TestPersonCodingJob extends Job { + /** + * Comma-separated list of person IDs to code + */ + @Column({ type: 'text', nullable: true }) + person_ids?: string; + + /** + * Comma-separated list of group names that were coded + */ + @Column({ type: 'text', nullable: true }) + group_names?: string; + + /** + * Time in milliseconds that the job took to complete + * Only set when the job is completed + */ + @Column({ type: 'bigint', nullable: true }) + duration_ms?: number; +} diff --git a/apps/backend/src/app/database/entities/variable-analysis-job.entity.ts b/apps/backend/src/app/database/entities/variable-analysis-job.entity.ts new file mode 100644 index 000000000..22b5ab4a0 --- /dev/null +++ b/apps/backend/src/app/database/entities/variable-analysis-job.entity.ts @@ -0,0 +1,23 @@ +import { + Column, + ChildEntity +} from 'typeorm'; +import { Job } from './job.entity'; + +/** + * Entity for variable analysis jobs + */ +@ChildEntity('variable-analysis') +export class VariableAnalysisJob extends Job { + /** + * Optional unit ID to filter by + */ + @Column({ nullable: true }) + unit_id?: number; + + /** + * Optional variable ID to filter by + */ + @Column({ nullable: true }) + variable_id?: string; +} diff --git a/apps/backend/src/app/database/services/job.service.ts b/apps/backend/src/app/database/services/job.service.ts new file mode 100644 index 000000000..0644a3671 --- /dev/null +++ b/apps/backend/src/app/database/services/job.service.ts @@ -0,0 +1,122 @@ +import { Injectable, Logger, NotFoundException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { Job } from '../entities/job.entity'; + +/** + * Service for managing jobs + */ +@Injectable() +export class JobService { + private readonly logger = new Logger(JobService.name); + + constructor( + @InjectRepository(Job) + private jobRepository: Repository + ) {} + + /** + * Get a job by ID + * @param jobId The ID of the job + * @param workspaceId Optional workspace ID to filter by + * @returns The job + * @throws NotFoundException if the job is not found + */ + async getJob(jobId: number, workspaceId?: number): Promise { + const whereClause: { id: number; workspace_id?: number } = { id: jobId }; + + if (workspaceId !== undefined) { + whereClause.workspace_id = workspaceId; + } + + const job = await this.jobRepository.findOne({ where: whereClause }); + if (!job) { + if (workspaceId !== undefined) { + throw new NotFoundException(`Job with ID ${jobId} not found in workspace ${workspaceId}`); + } else { + throw new NotFoundException(`Job with ID ${jobId} not found`); + } + } + return job; + } + + /** + * Get all jobs for a workspace + * @param workspaceId The ID of the workspace + * @param type Optional job type to filter by + * @returns Array of jobs + */ + async getJobs(workspaceId: number, type?: string): Promise { + const whereClause: { workspace_id: number; type?: string } = { workspace_id: workspaceId }; + + if (type) { + whereClause.type = type; + } + + return this.jobRepository.find({ + where: whereClause, + order: { created_at: 'DESC' } + }); + } + + /** + * Update a job + * @param jobId The ID of the job + * @param updates The updates to apply + * @returns The updated job + * @throws NotFoundException if the job is not found + */ + async updateJob(jobId: number, updates: Partial): Promise { + const job = await this.getJob(jobId); + + // Apply updates + Object.assign(job, updates); + + // Save the job + return this.jobRepository.save(job); + } + + /** + * Cancel a job + * @param jobId The ID of the job + * @returns Object with success flag and message + */ + async cancelJob(jobId: number): Promise<{ success: boolean; message: string }> { + try { + const job = await this.getJob(jobId); + + // Only pending or processing jobs can be cancelled + if (job.status !== 'pending' && job.status !== 'processing') { + return { + success: false, + message: `Job with ID ${jobId} cannot be cancelled because it is already ${job.status}` + }; + } + + // Update job status to cancelled + job.status = 'cancelled'; + await this.jobRepository.save(job); + this.logger.log(`Job ${jobId} has been cancelled`); + + return { success: true, message: `Job ${jobId} has been cancelled successfully` }; + } catch (error) { + this.logger.error(`Error cancelling job: ${error.message}`, error.stack); + return { success: false, message: `Error cancelling job: ${error.message}` }; + } + } + + /** + * Check if a job has been cancelled + * @param jobId The ID of the job + * @returns True if the job has been cancelled, false otherwise + */ + async isJobCancelled(jobId: number): Promise { + try { + const job = await this.getJob(jobId); + return job.status === 'cancelled'; + } catch (error) { + this.logger.error(`Error checking job cancellation: ${error.message}`, error.stack); + return false; // Assume not cancelled on error + } + } +} diff --git a/apps/backend/src/app/database/services/variable-analysis.service.ts b/apps/backend/src/app/database/services/variable-analysis.service.ts new file mode 100644 index 000000000..fbf6225e6 --- /dev/null +++ b/apps/backend/src/app/database/services/variable-analysis.service.ts @@ -0,0 +1,221 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { ResponseEntity } from '../entities/response.entity'; +import { Unit } from '../entities/unit.entity'; +import { VariableFrequencyDto } from '../../admin/variable-analysis/dto/variable-frequency.dto'; +import { VariableAnalysisResultDto } from '../../admin/variable-analysis/dto/variable-analysis-result.dto'; +import { VariableAnalysisJob } from '../entities/variable-analysis-job.entity'; + +@Injectable() +export class VariableAnalysisService { + private readonly logger = new Logger(VariableAnalysisService.name); + + constructor( + @InjectRepository(ResponseEntity) + private responseRepository: Repository, + @InjectRepository(Unit) + private unitRepository: Repository, + @InjectRepository(VariableAnalysisJob) + private jobRepository: Repository + ) {} + + /** + * Create a new variable analysis job + * @param workspaceId The ID of the workspace + * @param unitId Optional unit ID to filter by + * @param variableId Optional variable ID to filter by + * @returns The created job + */ + async createAnalysisJob( + workspaceId: number, + unitId?: number, + variableId?: string + ): Promise { + const job = this.jobRepository.create({ + workspace_id: workspaceId, + unit_id: unitId, + variable_id: variableId, + status: 'pending' + }); + + const savedJob = await this.jobRepository.save(job); + this.logger.log(`Created variable analysis job with ID ${savedJob.id}`); + + this.processAnalysisJob(savedJob.id).catch(error => { + this.logger.error(`Error processing job ${savedJob.id}: ${error.message}`, error.stack); + }); + + return savedJob; + } + + async getAnalysisJob(jobId: number, workspaceId?: number): Promise { + const whereClause: { id: number; workspace_id?: number } = { id: jobId }; + + if (workspaceId !== undefined) { + whereClause.workspace_id = workspaceId; + } + + const job = await this.jobRepository.findOne({ where: whereClause }); + if (!job) { + if (workspaceId !== undefined) { + throw new Error(`Job with ID ${jobId} not found in workspace ${workspaceId}`); + } else { + throw new Error(`Job with ID ${jobId} not found`); + } + } + return job; + } + + async getAnalysisResults(jobId: number, workspaceId?: number): Promise { + const job = await this.getAnalysisJob(jobId, workspaceId); + + if (job.status !== 'completed') { + throw new Error(`Job with ID ${jobId} is not completed (status: ${job.status})`); + } + + if (!job.result) { + throw new Error(`Job with ID ${jobId} has no results`); + } + + try { + return JSON.parse(job.result) as VariableAnalysisResultDto; + } catch (error) { + this.logger.error(`Error parsing results for job ${jobId}: ${error.message}`, error.stack); + throw new Error(`Error parsing results for job ${jobId}`); + } + } + + async getAnalysisJobs(workspaceId: number): Promise { + return this.jobRepository.find({ + where: { workspace_id: workspaceId }, + order: { created_at: 'DESC' } + }); + } + + private async processAnalysisJob(jobId: number): Promise { + try { + // Get the job without workspace filtering since this is an internal method + const job = await this.getAnalysisJob(jobId); + + job.status = 'processing'; + await this.jobRepository.save(job); + + const result = await this.getVariableFrequencies( + job.workspace_id, + job.unit_id, + job.variable_id + ); + + // Update job with result + job.result = JSON.stringify(result); + job.status = 'completed'; + await this.jobRepository.save(job); + + this.logger.log(`Completed variable analysis job with ID ${jobId}`); + } catch (error) { + try { + // Try to get the job again in case it was deleted + const job = await this.getAnalysisJob(jobId); + job.error = error.message; + job.status = 'failed'; + await this.jobRepository.save(job); + } catch (innerError) { + // If we can't get the job, just log the error + this.logger.error(`Failed to update job ${jobId} with error: ${innerError.message}`, innerError.stack); + } + + this.logger.error(`Failed to process job ${jobId}: ${error.message}`, error.stack); + } + } + + async getVariableFrequencies( + workspaceId: number, + unitId?: number, + variableId?: string, + ): Promise { + // Build the query + const query = this.responseRepository + .createQueryBuilder('response') + .innerJoin('response.unit', 'unit') + .innerJoin('unit.booklet', 'booklet') + .innerJoin('booklet.person', 'person') + .innerJoin('booklet.bookletinfo', 'bookletinfo') + .where('person.workspace_id = :workspaceId', { workspaceId }); + + // Add filters + if (unitId) { + query.andWhere('unit.id = :unitId', { unitId }); + } + + if (variableId) { + query.andWhere('response.variableId LIKE :variableId', { variableId: `%${variableId}%` }); + } + + // Get distinct combinations of unit name and variable ID + const variableCombosQuery = query.clone() + .select('DISTINCT unit.name', 'unitName') + .addSelect('response.variableId', 'variableId') + .orderBy('unit.name', 'ASC') + .addOrderBy('response.variableId', 'ASC'); + + // Execute the query to get variable combinations + const variableCombosResult = await variableCombosQuery.getRawMany(); + const variableCombos = variableCombosResult.map(result => ({ + unitName: result.unitName, + variableId: result.variableId + })); + + // If no variable combinations found, return empty result + if (variableCombos.length === 0) { + return { + variableCombos: [], + frequencies: {}, + total: 0 + }; + } + + // Get total count of distinct variable combinations + const totalQuery = query.clone() + .select('COUNT(DISTINCT CONCAT(unit.name, response.variableId))', 'count'); + const totalResult = await totalQuery.getRawOne(); + const total = parseInt(totalResult.count, 10); + + // Get frequencies for each variable combination + const frequencies: { [key: string]: VariableFrequencyDto[] } = {}; + + // Process each variable combination + for (const combo of variableCombos) { + // Create a unique key for this combination + const comboKey = `${combo.unitName}:${combo.variableId}`; + + // Get all values for this variable combination + const valuesQuery = query.clone() + .select('response.value', 'value') + .addSelect('COUNT(*)', 'count') + .where('unit.name = :unitName', { unitName: combo.unitName }) + .andWhere('response.variableId = :varId', { varId: combo.variableId }) + .groupBy('response.value') + .orderBy('count', 'DESC'); + + const valuesResult = await valuesQuery.getRawMany(); + + const totalResponses = valuesResult.reduce((sum, result) => sum + parseInt(result.count, 10), 0); + + // Map to DTOs + frequencies[comboKey] = valuesResult.map(result => ({ + unitName: combo.unitName, + variableId: combo.variableId, + value: result.value || '', + count: parseInt(result.count, 10), + percentage: (parseInt(result.count, 10) / totalResponses) * 100 + })); + } + + return { + variableCombos, + frequencies, + total + }; + } +} 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 8eebcc1ed..59941a0ee 100644 --- a/apps/backend/src/app/database/services/workspace-coding.service.ts +++ b/apps/backend/src/app/database/services/workspace-coding.service.ts @@ -10,6 +10,7 @@ import Persons from '../entities/persons.entity'; import { Unit } from '../entities/unit.entity'; import { Booklet } from '../entities/booklet.entity'; import { ResponseEntity } from '../entities/response.entity'; +import { TestPersonCodingJob } from '../entities/test-person-coding-job.entity'; import { CodingStatistics, CodingStatisticsWithJob } from './shared-types'; import { extractVariableLocation } from '../../utils/voud/extractVariableLocation'; @@ -27,7 +28,9 @@ export class WorkspaceCodingService { @InjectRepository(Booklet) private bookletRepository: Repository, @InjectRepository(ResponseEntity) - private responseRepository: Repository + private responseRepository: Repository, + @InjectRepository(TestPersonCodingJob) + private jobRepository: Repository ) {} // Cache for coding schemes with TTL @@ -174,16 +177,34 @@ export class WorkspaceCodingService { } // Job status tracking - private jobStatus: Map = new Map(); - - /** - * Get all jobs - * @param workspaceId Optional workspace ID to filter jobs - * @returns Array of job status objects with job IDs - */ - getAllJobs(workspaceId?: number): { jobId: string; status: 'pending' | 'processing' | 'completed' | 'failed' | 'cancelled'; progress: number; result?: CodingStatistics; error?: string; workspaceId?: number; createdAt?: Date }[] { - const jobs: { jobId: string; status: 'pending' | 'processing' | 'completed' | 'failed' | 'cancelled'; progress: number; result?: CodingStatistics; error?: string; workspaceId?: number; createdAt?: Date }[] = []; - + private jobStatus: Map = new Map(); + + async getAllJobs(workspaceId?: number): Promise<{ + jobId: string; + status: 'pending' | 'processing' | 'completed' | 'failed' | 'cancelled' | 'paused'; + progress: number; + result?: CodingStatistics; + error?: string; + workspaceId?: number; + createdAt?: Date; + groupNames?: string; + durationMs?: number; + completedAt?: Date; + }[]> { + const jobs: { + jobId: string; + status: 'pending' | 'processing' | 'completed' | 'failed' | 'cancelled' | 'paused'; + progress: number; + result?: CodingStatistics; + error?: string; + workspaceId?: number; + createdAt?: Date; + groupNames?: string; + durationMs?: number; + completedAt?: Date; + }[] = []; + + // First get jobs from the in-memory map for backward compatibility this.jobStatus.forEach((status, jobId) => { // If workspaceId is provided, filter jobs by workspace if (workspaceId !== undefined && status.workspaceId !== workspaceId) { @@ -196,6 +217,43 @@ export class WorkspaceCodingService { }); }); + try { + const whereClause = workspaceId !== undefined ? { workspace_id: workspaceId } : {}; + const dbJobs = await this.jobRepository.find({ + where: whereClause, + order: { created_at: 'DESC' } + }); + + for (const job of dbJobs) { + let result: CodingStatistics | undefined; + if (job.result) { + try { + result = JSON.parse(job.result) as CodingStatistics; + } catch (error) { + this.logger.error(`Error parsing job result: ${error.message}`, error.stack); + } + } + + // Check if this is a TestPersonCodingJob to get additional fields + const isTestPersonCodingJob = job.type === 'test-person-coding'; + + jobs.push({ + jobId: job.id.toString(), + status: job.status as 'pending' | 'processing' | 'completed' | 'failed' | 'cancelled' | 'paused', + progress: job.progress || 0, + result, + error: job.error, + workspaceId: job.workspace_id, + createdAt: job.created_at, + groupNames: isTestPersonCodingJob ? (job as TestPersonCodingJob).group_names : undefined, + durationMs: isTestPersonCodingJob ? (job as TestPersonCodingJob).duration_ms : undefined, + completedAt: job.status === 'completed' ? job.updated_at : undefined + }); + } + } catch (error) { + this.logger.error(`Error getting jobs from database: ${error.message}`, error.stack); + } + // Sort jobs by creation date (newest first) return jobs.sort((a, b) => { if (!a.createdAt) return 1; @@ -204,123 +262,198 @@ export class WorkspaceCodingService { }); } - /** - * Process test persons in the background - * @param jobId Unique job ID - * @param workspace_id Workspace ID - * @param personIds Array of person IDs to process - */ - private async processTestPersonsInBackground(jobId: string, workspace_id: number, personIds: string[]): Promise { - // Update job status to processing - this.jobStatus.set(jobId, { - status: 'processing', - progress: 0, - workspaceId: workspace_id, - createdAt: new Date() - }); - + private async processTestPersonsInBackground(jobId: number, workspace_id: number, personIds: string[]): Promise { try { + // Get the job from the database + const job = await this.jobRepository.findOne({ where: { id: jobId } }); + if (!job) { + this.logger.error(`Job with ID ${jobId} not found`); + return; + } + + // Update job status to processing + job.status = 'processing'; + job.progress = 0; + await this.jobRepository.save(job); + // Clone the implementation of codeTestPersons but with progress tracking - const result = await this.processTestPersonsBatch(workspace_id, personIds, progress => { + const result = await this.processTestPersonsBatch(workspace_id, personIds, async progress => { // Update job progress - const currentStatus = this.jobStatus.get(jobId); - if (currentStatus) { - // Don't update if job has been cancelled - if (currentStatus.status === 'cancelled') { + try { + // Get the latest job status + const currentJob = await this.jobRepository.findOne({ where: { id: jobId } }); + if (!currentJob) { + this.logger.error(`Job with ID ${jobId} not found when updating progress`); + return; + } + + // Don't update if job has been cancelled or paused + if (currentJob.status === 'cancelled' || currentJob.status === 'paused') { return; } - this.jobStatus.set(jobId, { ...currentStatus, progress }); + + // Update progress + currentJob.progress = progress; + await this.jobRepository.save(currentJob); + } catch (error) { + this.logger.error(`Error updating job progress: ${error.message}`, error.stack); } - }, jobId); + }, jobId.toString()); // Check if job was cancelled during processing - const currentStatus = this.jobStatus.get(jobId); - if (currentStatus && currentStatus.status === 'cancelled') { - this.logger.log(`Background job ${jobId} was cancelled`); + const currentJob = await this.jobRepository.findOne({ where: { id: jobId } }); + if (!currentJob) { + this.logger.error(`Job with ID ${jobId} not found when checking cancellation`); + return; + } + + if (currentJob.status === 'cancelled' || currentJob.status === 'paused') { + this.logger.log(`Background job ${jobId} was ${currentJob.status}`); return; } // Update job status to completed with result - const currentJob = this.jobStatus.get(jobId); - this.jobStatus.set(jobId, { - status: 'completed', - progress: 100, - result, - workspaceId: currentJob?.workspaceId, - createdAt: currentJob?.createdAt - }); + currentJob.status = 'completed'; + currentJob.progress = 100; + currentJob.result = JSON.stringify(result); + + // Calculate and store job duration if it's a TestPersonCodingJob + if (currentJob.type === 'test-person-coding' && currentJob.created_at) { + const durationMs = Date.now() - currentJob.created_at.getTime(); + (currentJob as TestPersonCodingJob).duration_ms = durationMs; + this.logger.log(`Job ${jobId} completed in ${durationMs}ms`); + } + + await this.jobRepository.save(currentJob); + this.statisticsCache.delete(workspace_id); + this.logger.log(`Invalidated coding statistics cache for workspace ${workspace_id}`); + this.logger.log(`Background job ${jobId} completed successfully`); } catch (error) { - // Check if job was cancelled during processing - const currentStatus = this.jobStatus.get(jobId); - if (currentStatus && currentStatus.status === 'cancelled') { - this.logger.log(`Background job ${jobId} was cancelled`); - return; + try { + // Get the job from the database + const job = await this.jobRepository.findOne({ where: { id: jobId } }); + if (!job) { + this.logger.error(`Job with ID ${jobId} not found when handling error`); + return; + } + + // Don't update if job has been cancelled or paused + if (job.status === 'cancelled' || job.status === 'paused') { + this.logger.log(`Background job ${jobId} was ${job.status}`); + return; + } + + // Update job status to failed with error + job.status = 'failed'; + job.progress = 0; + job.error = error.message; + await this.jobRepository.save(job); + } catch (innerError) { + this.logger.error(`Error updating job status: ${innerError.message}`, innerError.stack); } - // Update job status to failed with error - const currentJob = this.jobStatus.get(jobId); - this.jobStatus.set(jobId, { - status: 'failed', - progress: 0, - error: error.message, - workspaceId: currentJob?.workspaceId, - createdAt: currentJob?.createdAt - }); this.logger.error(`Background job ${jobId} failed: ${error.message}`, error.stack); } - - // Remove job status after 1 hour to prevent memory leaks - setTimeout(() => { - this.jobStatus.delete(jobId); - this.logger.log(`Removed job status for job ${jobId}`); - }, 60 * 60 * 1000); } - /** - * Get the status of a background job - * @param jobId Job ID - * @returns Job status or null if job not found - */ - getJobStatus(jobId: string): { status: 'pending' | 'processing' | 'completed' | 'failed' | 'cancelled'; progress: number; result?: CodingStatistics; error?: string } | null { - return this.jobStatus.get(jobId) || null; - } + async getJobStatus(jobId: string): Promise<{ status: 'pending' | 'processing' | 'completed' | 'failed' | 'cancelled' | 'paused'; progress: number; result?: CodingStatistics; error?: string } | null> { + try { + // First check the in-memory job status map for backward compatibility + const inMemoryStatus = this.jobStatus.get(jobId); + if (inMemoryStatus) { + return inMemoryStatus; + } - /** - * Cancel a running job - * @param jobId Job ID to cancel - * @returns Object with success flag and message - */ - cancelJob(jobId: string): { success: boolean; message: string } { - const job = this.jobStatus.get(jobId); + // If not found in memory, check the database + const job = await this.jobRepository.findOne({ where: { id: parseInt(jobId, 10) } }); + if (!job) { + return null; + } - if (!job) { - return { success: false, message: `Job with ID ${jobId} not found` }; - } + // Parse the result if it exists + let result: CodingStatistics | undefined; + if (job.result) { + try { + result = JSON.parse(job.result) as CodingStatistics; + } catch (error) { + this.logger.error(`Error parsing job result: ${error.message}`, error.stack); + } + } - // Only pending or processing jobs can be cancelled - if (job.status !== 'pending' && job.status !== 'processing') { return { - success: false, - message: `Job with ID ${jobId} cannot be cancelled because it is already ${job.status}` + status: job.status as 'pending' | 'processing' | 'completed' | 'failed' | 'cancelled' | 'paused', + progress: job.progress || 0, + result, + error: job.error }; + } catch (error) { + this.logger.error(`Error getting job status: ${error.message}`, error.stack); + return null; } + } + + async cancelJob(jobId: string): Promise<{ success: boolean; message: string }> { + try { + // First check the in-memory job status map for backward compatibility + const inMemoryJob = this.jobStatus.get(jobId); + if (inMemoryJob) { + // Only pending or processing jobs can be cancelled + if (inMemoryJob.status !== 'pending' && inMemoryJob.status !== 'processing') { + return { + success: false, + message: `Job with ID ${jobId} cannot be cancelled because it is already ${inMemoryJob.status}` + }; + } + + // Update job status to cancelled + this.jobStatus.set(jobId, { ...inMemoryJob, status: 'cancelled' }); + this.logger.log(`In-memory job ${jobId} has been cancelled`); + + return { success: true, message: `Job ${jobId} has been cancelled successfully` }; + } - // Update job status to cancelled - this.jobStatus.set(jobId, { ...job, status: 'cancelled' }); - this.logger.log(`Job ${jobId} has been cancelled`); + // If not found in memory, check the database + const job = await this.jobRepository.findOne({ where: { id: parseInt(jobId, 10) } }); + if (!job) { + return { success: false, message: `Job with ID ${jobId} not found` }; + } + + // Only pending or processing jobs can be cancelled + if (job.status !== 'pending' && job.status !== 'processing') { + return { + success: false, + message: `Job with ID ${jobId} cannot be cancelled because it is already ${job.status}` + }; + } + + // Update job status to cancelled + job.status = 'cancelled'; + await this.jobRepository.save(job); + this.logger.log(`Job ${jobId} has been cancelled`); - return { success: true, message: `Job ${jobId} has been cancelled successfully` }; + return { success: true, message: `Job ${jobId} has been cancelled successfully` }; + } catch (error) { + this.logger.error(`Error cancelling job: ${error.message}`, error.stack); + return { success: false, message: `Error cancelling job: ${error.message}` }; + } + } + + private async isJobCancelled(jobId: string | number): Promise { + try { + const inMemoryStatus = this.jobStatus.get(jobId.toString()); + if (inMemoryStatus && (inMemoryStatus.status === 'cancelled' || inMemoryStatus.status === 'paused')) { + return true; + } + + const job = await this.jobRepository.findOne({ where: { id: Number(jobId) } }); + return job && (job.status === 'cancelled' || job.status === 'paused'); + } catch (error) { + this.logger.error(`Error checking job cancellation or pause: ${error.message}`, error.stack); + return false; // Assume not cancelled or paused on error + } } - /** - * Process a batch of test persons with progress tracking - * @param workspace_id Workspace ID - * @param personIds Array of person IDs to process - * @param progressCallback Callback function to report progress (0-100) - * @param jobId Optional job ID for cancellation checking - * @returns Coding statistics - */ private async processTestPersonsBatch( workspace_id: number, personIds: string[], @@ -344,13 +477,10 @@ export class WorkspaceCodingService { progressCallback(0); } - // Check for cancellation before starting work - if (jobId) { - const jobStatus = this.jobStatus.get(jobId); - if (jobStatus && jobStatus.status === 'cancelled') { - this.logger.log(`Job ${jobId} was cancelled before processing started`); - return statistics; - } + // Check for cancellation or pause before starting work + if (jobId && await this.isJobCancelled(jobId)) { + this.logger.log(`Job ${jobId} was cancelled or paused before processing started`); + return statistics; } const queryRunner = this.responseRepository.manager.connection.createQueryRunner(); @@ -377,14 +507,11 @@ export class WorkspaceCodingService { progressCallback(10); } - // Check for cancellation after step 1 - if (jobId) { - const jobStatus = this.jobStatus.get(jobId); - if (jobStatus && jobStatus.status === 'cancelled') { - this.logger.log(`Job ${jobId} was cancelled after getting persons`); - await queryRunner.release(); - return statistics; - } + // Check for cancellation or pause after step 1 + if (jobId && await this.isJobCancelled(jobId)) { + this.logger.log(`Job ${jobId} was cancelled or paused after getting persons`); + await queryRunner.release(); + return statistics; } // Step 2: Get booklets - 20% progress @@ -407,14 +534,11 @@ export class WorkspaceCodingService { progressCallback(20); } - // Check for cancellation after step 2 - if (jobId) { - const jobStatus = this.jobStatus.get(jobId); - if (jobStatus && jobStatus.status === 'cancelled') { - this.logger.log(`Job ${jobId} was cancelled after getting booklets`); - await queryRunner.release(); - return statistics; - } + // Check for cancellation or pause after step 2 + if (jobId && await this.isJobCancelled(jobId)) { + this.logger.log(`Job ${jobId} was cancelled or paused after getting booklets`); + await queryRunner.release(); + return statistics; } // Step 3: Get units - 30% progress @@ -437,14 +561,11 @@ export class WorkspaceCodingService { progressCallback(30); } - // Check for cancellation after step 3 - if (jobId) { - const jobStatus = this.jobStatus.get(jobId); - if (jobStatus && jobStatus.status === 'cancelled') { - this.logger.log(`Job ${jobId} was cancelled after getting units`); - await queryRunner.release(); - return statistics; - } + // Check for cancellation or pause after step 3 + if (jobId && await this.isJobCancelled(jobId)) { + this.logger.log(`Job ${jobId} was cancelled or paused after getting units`); + await queryRunner.release(); + return statistics; } // Step 4: Process units and build maps - 40% progress @@ -469,14 +590,11 @@ export class WorkspaceCodingService { progressCallback(40); } - // Check for cancellation after step 4 - if (jobId) { - const jobStatus = this.jobStatus.get(jobId); - if (jobStatus && jobStatus.status === 'cancelled') { - this.logger.log(`Job ${jobId} was cancelled after processing units`); - await queryRunner.release(); - return statistics; - } + // Check for cancellation or pause after step 4 + if (jobId && await this.isJobCancelled(jobId)) { + this.logger.log(`Job ${jobId} was cancelled or paused after processing units`); + await queryRunner.release(); + return statistics; } // Step 5: Get responses - 50% progress @@ -498,14 +616,11 @@ export class WorkspaceCodingService { progressCallback(50); } - // Check for cancellation after step 5 - if (jobId) { - const jobStatus = this.jobStatus.get(jobId); - if (jobStatus && jobStatus.status === 'cancelled') { - this.logger.log(`Job ${jobId} was cancelled after getting responses`); - await queryRunner.release(); - return statistics; - } + // Check for cancellation or pause after step 5 + if (jobId && await this.isJobCancelled(jobId)) { + this.logger.log(`Job ${jobId} was cancelled or paused after getting responses`); + await queryRunner.release(); + return statistics; } // Step 6: Process responses and build maps - 60% progress @@ -522,14 +637,11 @@ export class WorkspaceCodingService { progressCallback(60); } - // Check for cancellation after step 6 - if (jobId) { - const jobStatus = this.jobStatus.get(jobId); - if (jobStatus && jobStatus.status === 'cancelled') { - this.logger.log(`Job ${jobId} was cancelled after processing responses`); - await queryRunner.release(); - return statistics; - } + // Check for cancellation or pause after step 6 + if (jobId && await this.isJobCancelled(jobId)) { + this.logger.log(`Job ${jobId} was cancelled or paused after processing responses`); + await queryRunner.release(); + return statistics; } // Step 7: Get test files - 70% progress @@ -543,14 +655,11 @@ export class WorkspaceCodingService { progressCallback(70); } - // Check for cancellation after step 7 - if (jobId) { - const jobStatus = this.jobStatus.get(jobId); - if (jobStatus && jobStatus.status === 'cancelled') { - this.logger.log(`Job ${jobId} was cancelled after getting test files`); - await queryRunner.release(); - return statistics; - } + // Check for cancellation or pause after step 7 + if (jobId && await this.isJobCancelled(jobId)) { + this.logger.log(`Job ${jobId} was cancelled or paused after getting test files`); + await queryRunner.release(); + return statistics; } // Step 8: Extract coding scheme references - 80% progress @@ -577,14 +686,11 @@ export class WorkspaceCodingService { } } - // Check for cancellation during scheme extraction - if (jobId) { - const jobStatus = this.jobStatus.get(jobId); - if (jobStatus && jobStatus.status === 'cancelled') { - this.logger.log(`Job ${jobId} was cancelled during scheme extraction`); - await queryRunner.release(); - return statistics; - } + // Check for cancellation or pause during scheme extraction + if (jobId && await this.isJobCancelled(jobId)) { + this.logger.log(`Job ${jobId} was cancelled or paused during scheme extraction`); + await queryRunner.release(); + return statistics; } } metrics.schemeExtract = Date.now() - schemeExtractStart; @@ -594,14 +700,11 @@ export class WorkspaceCodingService { progressCallback(80); } - // Check for cancellation after step 8 - if (jobId) { - const jobStatus = this.jobStatus.get(jobId); - if (jobStatus && jobStatus.status === 'cancelled') { - this.logger.log(`Job ${jobId} was cancelled after extracting scheme references`); - await queryRunner.release(); - return statistics; - } + // Check for cancellation or pause after step 8 + if (jobId && await this.isJobCancelled(jobId)) { + this.logger.log(`Job ${jobId} was cancelled or paused after extracting scheme references`); + await queryRunner.release(); + return statistics; } // Step 9: Get coding scheme files - 85% progress @@ -617,14 +720,11 @@ export class WorkspaceCodingService { progressCallback(85); } - // Check for cancellation after step 9 - if (jobId) { - const jobStatus = this.jobStatus.get(jobId); - if (jobStatus && jobStatus.status === 'cancelled') { - this.logger.log(`Job ${jobId} was cancelled after getting coding scheme files`); - await queryRunner.release(); - return statistics; - } + // Check for cancellation or pause after step 9 + if (jobId && await this.isJobCancelled(jobId)) { + this.logger.log(`Job ${jobId} was cancelled or paused after getting coding scheme files`); + await queryRunner.release(); + return statistics; } // Skip to step 11 (step 10 is now part of getCodingSchemesWithCache) @@ -635,14 +735,11 @@ export class WorkspaceCodingService { progressCallback(90); } - // Check for cancellation after step 10 - if (jobId) { - const jobStatus = this.jobStatus.get(jobId); - if (jobStatus && jobStatus.status === 'cancelled') { - this.logger.log(`Job ${jobId} was cancelled after parsing coding schemes`); - await queryRunner.release(); - return statistics; - } + // Check for cancellation or pause after step 10 + if (jobId && await this.isJobCancelled(jobId)) { + this.logger.log(`Job ${jobId} was cancelled or paused after parsing coding schemes`); + await queryRunner.release(); + return statistics; } // Step 11: Process and code responses - 95% progress @@ -690,14 +787,11 @@ export class WorkspaceCodingService { } } - // Check for cancellation during response processing - if (jobId) { - const jobStatus = this.jobStatus.get(jobId); - if (jobStatus && jobStatus.status === 'cancelled') { - this.logger.log(`Job ${jobId} was cancelled during response processing`); - await queryRunner.release(); - return statistics; - } + // Check for cancellation or pause during response processing + if (jobId && await this.isJobCancelled(jobId)) { + this.logger.log(`Job ${jobId} was cancelled or paused during response processing`); + await queryRunner.release(); + return statistics; } } @@ -709,14 +803,11 @@ export class WorkspaceCodingService { progressCallback(95); } - // Check for cancellation after step 11 - if (jobId) { - const jobStatus = this.jobStatus.get(jobId); - if (jobStatus && jobStatus.status === 'cancelled') { - this.logger.log(`Job ${jobId} was cancelled after processing responses`); - await queryRunner.release(); - return statistics; - } + // Check for cancellation or pause after step 11 + if (jobId && await this.isJobCancelled(jobId)) { + this.logger.log(`Job ${jobId} was cancelled or paused after processing responses`); + await queryRunner.release(); + return statistics; } // Step 12: Update responses in database - 100% progress @@ -735,15 +826,12 @@ export class WorkspaceCodingService { const batch = batches[index]; this.logger.log(`Starte Aktualisierung für Batch #${index + 1} (Größe: ${batch.length}).`); - // Check for cancellation before updating batch - if (jobId) { - const jobStatus = this.jobStatus.get(jobId); - if (jobStatus && jobStatus.status === 'cancelled') { - this.logger.log(`Job ${jobId} was cancelled before updating batch #${index + 1}`); - await queryRunner.rollbackTransaction(); - await queryRunner.release(); - return statistics; - } + // Check for cancellation or pause before updating batch + if (jobId && await this.isJobCancelled(jobId)) { + this.logger.log(`Job ${jobId} was cancelled or paused before updating batch #${index + 1}`); + await queryRunner.rollbackTransaction(); + await queryRunner.release(); + return statistics; } try { @@ -766,7 +854,7 @@ export class WorkspaceCodingService { // Update progress during batch updates if (progressCallback) { const batchProgress = 95 + (5 * ((index + 1) / batches.length)); - progressCallback(Math.min(batchProgress, 99)); // Cap at 99% until fully complete + progressCallback(Math.round(Math.min(batchProgress, 99))); // Cap at 99% until fully complete and round to integer } } catch (error) { this.logger.error(`Fehler beim Aktualisieren von Batch #${index + 1} (Größe: ${batch.length}):`, error.message); @@ -827,7 +915,6 @@ export class WorkspaceCodingService { } catch (rollbackError) { this.logger.error('Fehler beim Rollback der Transaktion:', rollbackError.message); } finally { - // Always release the query runner await queryRunner.release(); } @@ -835,316 +922,87 @@ export class WorkspaceCodingService { } } - async codeTestPersons(workspace_id: number, testPersonIds: string): Promise { - // Clean up expired cache entries + async codeTestPersons(workspace_id: number, testPersonIdsOrGroups: string): Promise { this.cleanupCaches(); - 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.'); + if (!workspace_id || !testPersonIdsOrGroups || testPersonIdsOrGroups.trim() === '') { + this.logger.warn('Ungültige Eingabeparameter: workspace_id oder testPersonIdsOrGroups 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.'); + const groupsOrIds = testPersonIdsOrGroups.split(',').filter(item => item.trim() !== ''); + if (groupsOrIds.length === 0) { + this.logger.warn('Keine gültigen Gruppen oder Personen-IDs angegeben.'); return { totalResponses: 0, statusCounts: {} }; } - // If there are more than 100 test persons, process in the background - if (ids.length > 100) { - const jobId = `${workspace_id}-${Date.now()}`; - this.logger.log(`Starting background job ${jobId} for ${ids.length} test persons in workspace ${workspace_id}`); - - // Set initial job status - this.jobStatus.set(jobId, { - status: 'pending', - progress: 0, - workspaceId: workspace_id, - createdAt: new Date() - }); - - // Process in the background - this.processTestPersonsInBackground(jobId, workspace_id, ids); - - // Return a response indicating the job has been scheduled - return { - totalResponses: 0, - statusCounts: {}, - jobId, - message: `Processing ${ids.length} test persons in the background. Check job status with jobId: ${jobId}` - }; - } - - this.logger.log(`Verarbeite Personen ${testPersonIds} für Workspace ${workspace_id}`); - - const statistics: CodingStatistics = { - totalResponses: 0, - 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'] - }); - 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; - } + // Check if the input contains groups or person IDs + // If all items can be parsed as numbers, they are person IDs + // Otherwise, they are group names + const areAllNumbers = groupsOrIds.every(item => !Number.isNaN(Number(item))); - const personIds = persons.map(person => person.id); - const bookletQueryStart = Date.now(); - const booklets = await this.bookletRepository.find({ - where: { personid: In(personIds) }, - select: ['id', 'personid'] // Only select needed fields - }); - metrics.bookletQuery = Date.now() - bookletQueryStart; + let personIds: string[] = []; - if (!booklets || booklets.length === 0) { - this.logger.log('Keine Booklets für die angegebenen Personen gefunden.'); - await queryRunner.release(); - return statistics; - } + if (areAllNumbers) { + personIds = groupsOrIds; + this.logger.log(`Using provided person IDs: ${personIds.length} persons`); + } else { + // Input contains group names, fetch all persons in these groups + this.logger.log(`Fetching persons for groups: ${groupsOrIds.join(', ')}`); - const bookletIds = booklets.map(booklet => booklet.id); - const unitQueryStart = Date.now(); - const units = await this.unitRepository.find({ - where: { bookletid: In(bookletIds) }, - select: ['id', 'bookletid', 'name', 'alias'] // Only select needed fields - }); - metrics.unitQuery = Date.now() - unitQueryStart; - - if (!units || units.length === 0) { - this.logger.log('Keine Einheiten für die angegebenen Booklets gefunden.'); - await queryRunner.release(); - return statistics; - } - - const bookletToUnitsMap = new Map(); - const unitIds = new Set(); - const unitAliasesSet = new Set(); - - for (const unit of units) { - if (!bookletToUnitsMap.has(unit.bookletid)) { - bookletToUnitsMap.set(unit.bookletid, []); - } - bookletToUnitsMap.get(unit.bookletid).push(unit); - unitIds.add(unit.id); - unitAliasesSet.add(unit.alias.toUpperCase()); - } - - const unitIdsArray = Array.from(unitIds); - const unitAliasesArray = Array.from(unitAliasesSet); - - const responseQueryStart = Date.now(); - const allResponses = await this.responseRepository.find({ - where: { unitid: In(unitIdsArray), status: In(['VALUE_CHANGED']) }, - select: ['id', 'unitid', 'variableid', 'value', 'status'] // Only select needed fields - }); - metrics.responseQuery = Date.now() - responseQueryStart; - - if (!allResponses || allResponses.length === 0) { - this.logger.log('Keine zu kodierenden Antworten gefunden.'); - await queryRunner.release(); - return statistics; - } - - const unitToResponsesMap = new Map(); - for (const response of allResponses) { - if (!unitToResponsesMap.has(response.unitid)) { - unitToResponsesMap.set(response.unitid, []); - } - unitToResponsesMap.get(response.unitid).push(response); - } - - const fileQueryStart = Date.now(); - // Use cache for test files - const fileIdToTestFileMap = await this.getTestFilesWithCache(workspace_id, unitAliasesArray); - metrics.fileQuery = Date.now() - fileQueryStart; - const schemeExtractStart = Date.now(); - const codingSchemeRefs = new Set(); - const unitToCodingSchemeRefMap = new Map(); - const batchSize = 50; - for (let i = 0; i < units.length; i += batchSize) { - const unitBatch = units.slice(i, i + batchSize); - - for (const unit of unitBatch) { - const testFile = fileIdToTestFileMap.get(unit.alias.toUpperCase()); - if (!testFile) continue; - - try { - const $ = cheerio.load(testFile.data); - const codingSchemeRefText = $('codingSchemeRef').text(); - if (codingSchemeRefText) { - codingSchemeRefs.add(codingSchemeRefText.toUpperCase()); - unitToCodingSchemeRefMap.set(unit.id, codingSchemeRefText.toUpperCase()); - } - } catch (error) { - this.logger.error(`--- Fehler beim Verarbeiten der Datei ${testFile.filename}: ${error.message}`); - } - } - } - metrics.schemeExtract = Date.now() - schemeExtractStart; - - const schemeQueryStart = Date.now(); - // Use cache for coding schemes - const fileIdToCodingSchemeMap = await this.getCodingSchemesWithCache([...codingSchemeRefs]); - metrics.schemeQuery = Date.now() - schemeQueryStart; - // No separate parsing step needed as it's handled by the cache helper - metrics.schemeParsing = 0; - const emptyScheme = new Autocoder.CodingScheme({}); - - const processingStart = Date.now(); - - const allCodedResponses = []; - const estimatedResponseCount = allResponses.length; - allCodedResponses.length = estimatedResponseCount; - let responseIndex = 0; - - for (let i = 0; i < units.length; i += batchSize) { - const unitBatch = units.slice(i, i + batchSize); - - for (const unit of unitBatch) { - const responses = unitToResponsesMap.get(unit.id) || []; - if (responses.length === 0) continue; - - statistics.totalResponses += responses.length; - - const codingSchemeRef = unitToCodingSchemeRefMap.get(unit.id); - const scheme = codingSchemeRef ? - (fileIdToCodingSchemeMap.get(codingSchemeRef) || emptyScheme) : - emptyScheme; - - for (const response of responses) { - const codedResult = scheme.code([{ - id: response.variableid, - value: response.value, - status: response.status as ResponseStatusType - }]); + try { + const persons = await this.personsRepository.find({ + where: { + workspace_id, + group: In(groupsOrIds) + }, + select: ['id'] + }); - const codedStatus = codedResult[0]?.status; - if (!statistics.statusCounts[codedStatus]) { - statistics.statusCounts[codedStatus] = 0; - } - statistics.statusCounts[codedStatus] += 1; + personIds = persons.map(person => person.id.toString()); + this.logger.log(`Found ${personIds.length} persons in the specified groups`); - allCodedResponses[responseIndex] = { - id: response.id, - code: codedResult[0]?.code, - codedstatus: codedStatus, - score: codedResult[0]?.score - }; - responseIndex += 1; - } + if (personIds.length === 0) { + this.logger.warn(`No persons found in groups: ${groupsOrIds.join(', ')}`); + return { + totalResponses: 0, + statusCounts: {}, + message: `No persons found in the selected groups: ${groupsOrIds.join(', ')}` + }; } + } catch (error) { + this.logger.error(`Error fetching persons for groups: ${error.message}`, error.stack); + return { + totalResponses: 0, + statusCounts: {}, + message: `Error fetching persons for groups: ${error.message}` + }; } + } - allCodedResponses.length = responseIndex; - metrics.processing = Date.now() - processingStart; - - // Update responses in batches with transaction support - if (allCodedResponses.length > 0) { - const updateStart = Date.now(); - try { - const updateBatchSize = 500; - const batches = []; - for (let i = 0; i < allCodedResponses.length; i += updateBatchSize) { - batches.push(allCodedResponses.slice(i, i + updateBatchSize)); - } - - this.logger.log(`Starte die Aktualisierung von ${allCodedResponses.length} Responses in ${batches.length} Batches (sequential).`); - - for (let index = 0; index < batches.length; index++) { - const batch = batches[index]; - this.logger.log(`Starte Aktualisierung für Batch #${index + 1} (Größe: ${batch.length}).`); - - try { - if (batch.length > 0) { - const updatePromises = batch.map(response => queryRunner.manager.update( - ResponseEntity, - response.id, - { - code: response.code, - codedstatus: response.codedstatus, - score: response.score - } - )); - - await Promise.all(updatePromises); - } - - this.logger.log(`Batch #${index + 1} (Größe: ${batch.length}) erfolgreich aktualisiert.`); - } 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; - } - } + // Always process as a job, regardless of the number of test persons + this.logger.log(`Starting job for ${personIds.length} test persons in workspace ${workspace_id}`); - // 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`); + const job = this.jobRepository.create({ + workspace_id, + person_ids: personIds.join(','), + status: 'pending', + progress: 0, + // Store group names if groups were provided (not person IDs) + group_names: !areAllNumbers ? groupsOrIds.join(',') : undefined + }); - return statistics; - } catch (error) { - this.logger.error('Fehler beim Verarbeiten der Personen:', error); + const savedJob = await this.jobRepository.save(job); + this.logger.log(`Created test person coding job with ID ${savedJob.id}`); - // 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(); - } + this.processTestPersonsInBackground(savedJob.id, workspace_id, personIds); - return statistics; - } + return { + totalResponses: 0, + statusCounts: {}, + jobId: savedJob.id.toString(), + message: `Processing ${personIds.length} test persons in the background. Check job status with jobId: ${savedJob.id}` + }; } async getManualTestPersons(workspace_id: number, personIds?: string): Promise { @@ -1536,4 +1394,95 @@ export class WorkspaceCodingService { this.logger.log(`Generating Excel export for workspace ${workspace_id}`); return this.getCodingListAsCsv(workspace_id); } + + /** + * Pause a running job + * @param jobId Job ID to pause + * @returns Object with success flag and message + */ + async pauseJob(jobId: string): Promise<{ success: boolean; message: string }> { + try { + // First check the in-memory job status map for backward compatibility + const inMemoryJob = this.jobStatus.get(jobId); + if (inMemoryJob) { + // Only processing jobs can be paused + if (inMemoryJob.status !== 'processing') { + return { + success: false, + message: `Job with ID ${jobId} cannot be paused because it is ${inMemoryJob.status}` + }; + } + + // Update job status to paused + this.jobStatus.set(jobId, { ...inMemoryJob, status: 'paused' }); + this.logger.log(`In-memory job ${jobId} has been paused`); + + return { success: true, message: `Job ${jobId} has been paused successfully` }; + } + + // If not found in memory, check the database + const job = await this.jobRepository.findOne({ where: { id: parseInt(jobId, 10) } }); + if (!job) { + return { success: false, message: `Job with ID ${jobId} not found` }; + } + + // Only processing jobs can be paused + if (job.status !== 'processing') { + return { + success: false, + message: `Job with ID ${jobId} cannot be paused because it is ${job.status}` + }; + } + + // Update job status to paused + job.status = 'paused'; + await this.jobRepository.save(job); + this.logger.log(`Job ${jobId} has been paused`); + + return { success: true, message: `Job ${jobId} has been paused successfully` }; + } catch (error) { + this.logger.error(`Error pausing job: ${error.message}`, error.stack); + return { success: false, message: `Error pausing job: ${error.message}` }; + } + } + + async resumeJob(jobId: string): Promise<{ success: boolean; message: string }> { + try { + const inMemoryJob = this.jobStatus.get(jobId); + if (inMemoryJob) { + if (inMemoryJob.status !== 'paused') { + return { + success: false, + message: `Job with ID ${jobId} cannot be resumed because it is ${inMemoryJob.status}` + }; + } + + this.jobStatus.set(jobId, { ...inMemoryJob, status: 'processing' }); + this.logger.log(`In-memory job ${jobId} has been resumed`); + + return { success: true, message: `Job ${jobId} has been resumed successfully` }; + } + + const job = await this.jobRepository.findOne({ where: { id: parseInt(jobId, 10) } }); + if (!job) { + return { success: false, message: `Job with ID ${jobId} not found` }; + } + + if (job.status !== 'paused') { + return { + success: false, + message: `Job with ID ${jobId} cannot be resumed because it is ${job.status}` + }; + } + + job.status = 'processing'; + await this.jobRepository.save(job); + this.logger.log(`Job ${jobId} has been resumed`); + + return { success: true, message: `Job ${jobId} has been resumed successfully` }; + } catch (error) { + this.logger.error(`Error resuming job: ${error.message}`, error.stack); + return { success: false, message: `Error resuming job: ${error.message}` }; + } + } } diff --git a/apps/backend/src/app/database/services/workspace-files.service.ts b/apps/backend/src/app/database/services/workspace-files.service.ts index b04d2df6b..fffcbd0da 100644 --- a/apps/backend/src/app/database/services/workspace-files.service.ts +++ b/apps/backend/src/app/database/services/workspace-files.service.ts @@ -1104,7 +1104,8 @@ export class WorkspaceFilesService { } const validPage = Math.max(1, page); - const validLimit = Math.min(Math.max(1, limit), 1000); + // Remove the 1000 item limit when limit is set to Number.MAX_SAFE_INTEGER + const validLimit = limit === Number.MAX_SAFE_INTEGER ? limit : Math.min(Math.max(1, limit), 1000); const startIndex = (validPage - 1) * validLimit; const endIndex = startIndex + validLimit; const paginatedData = invalidVariables.slice(startIndex, endIndex); @@ -1132,7 +1133,7 @@ export class WorkspaceFilesService { where: { workspace_id: workspaceId, file_type: 'Unit' } }); - const unitVariableTypes = new Map>(); + const unitVariableTypes = new Map>(); for (const unitFile of unitFiles) { try { @@ -1140,7 +1141,7 @@ export class WorkspaceFilesService { const parsedXml = await parseStringPromise(xmlContent, { explicitArray: false }); if (parsedXml.Unit && parsedXml.Unit.Metadata && parsedXml.Unit.Metadata.Id) { const unitName = parsedXml.Unit.Metadata.Id; - const variableTypes = new Map(); + const variableTypes = new Map(); if (parsedXml.Unit.BaseVariables && parsedXml.Unit.BaseVariables.Variable) { const baseVariables = Array.isArray(parsedXml.Unit.BaseVariables.Variable) ? @@ -1149,7 +1150,13 @@ export class WorkspaceFilesService { for (const variable of baseVariables) { if (variable.$.alias && variable.$.type) { - variableTypes.set(variable.$.alias, variable.$.type); + const multiple = variable.$.multiple === 'true' || variable.$.multiple === true; + const nullable = variable.$.nullable === 'true' || variable.$.nullable === true; + variableTypes.set(variable.$.alias, { + type: variable.$.type, + multiple: multiple || undefined, + nullable: nullable || undefined + }); } } } @@ -1272,7 +1279,51 @@ export class WorkspaceFilesService { continue; } - const expectedType = variableTypes.get(variableId); + const variableInfo = variableTypes.get(variableId); + const expectedType = variableInfo.type; + const isMultiple = variableInfo.multiple === true; + const isNullable = variableInfo.nullable !== false; // If nullable is undefined or true, treat as nullable + + // Check if multiple is true and value is not an array + if (isMultiple) { + try { + const parsedValue = JSON.parse(value); + if (!Array.isArray(parsedValue)) { + invalidVariables.push({ + fileName: `${unitName}`, + variableId: variableId, + value: value, + responseId: response.id, + expectedType: `${expectedType} (array)`, + errorReason: 'Variable has multiple=true but value is not an array' + }); + continue; + } + } catch (e) { + invalidVariables.push({ + fileName: `${unitName}`, + variableId: variableId, + value: value, + responseId: response.id, + expectedType: `${expectedType} (array)`, + errorReason: 'Variable has multiple=true but value is not a valid JSON array' + }); + continue; + } + } + + // Check if nullable is false and value is null or empty + if (!isNullable && (!value || value.trim() === '')) { + invalidVariables.push({ + fileName: `${unitName}`, + variableId: variableId, + value: value, + responseId: response.id, + expectedType: expectedType, + errorReason: 'Variable has nullable=false but value is null or empty' + }); + continue; + } if (!this.isValidValueForType(value, expectedType)) { invalidVariables.push({ @@ -1287,7 +1338,8 @@ export class WorkspaceFilesService { } const validPage = Math.max(1, page); - const validLimit = Math.min(Math.max(1, limit), 1000); + // Remove the 1000 item limit when limit is set to Number.MAX_SAFE_INTEGER + const validLimit = limit === Number.MAX_SAFE_INTEGER ? limit : Math.min(Math.max(1, limit), 1000); const startIndex = (validPage - 1) * validLimit; const endIndex = startIndex + validLimit; const paginatedData = invalidVariables.slice(startIndex, endIndex); @@ -1573,7 +1625,8 @@ export class WorkspaceFilesService { } const validPage = Math.max(1, page); - const validLimit = Math.min(Math.max(1, limit), 1000); + // Remove the 1000 item limit when limit is set to Number.MAX_SAFE_INTEGER + const validLimit = limit === Number.MAX_SAFE_INTEGER ? limit : Math.min(Math.max(1, limit), 1000); const startIndex = (validPage - 1) * validLimit; const endIndex = startIndex + validLimit; const paginatedData = invalidVariables.slice(startIndex, endIndex); @@ -1858,4 +1911,50 @@ export class WorkspaceFilesService { throw error; } } + + async deleteAllInvalidResponses(workspaceId: number, validationType: 'variables' | 'variableTypes' | 'responseStatus'): Promise { + try { + if (!workspaceId) { + this.logger.error('Workspace ID is required'); + return 0; + } + + this.logger.log(`Deleting all invalid responses for workspace ${workspaceId} of type ${validationType}`); + + // Get all invalid responses based on the validation type + let invalidResponses: InvalidVariableDto[] = []; + + if (validationType === 'variables') { + const result = await this.validateVariables(workspaceId, 1, Number.MAX_SAFE_INTEGER); + invalidResponses = result.data; + } else if (validationType === 'variableTypes') { + const result = await this.validateVariableTypes(workspaceId, 1, Number.MAX_SAFE_INTEGER); + invalidResponses = result.data; + } else if (validationType === 'responseStatus') { + const result = await this.validateResponseStatus(workspaceId, 1, Number.MAX_SAFE_INTEGER); + invalidResponses = result.data; + } + + if (invalidResponses.length === 0) { + this.logger.warn(`No invalid responses found for workspace ${workspaceId} of type ${validationType}`); + return 0; + } + + // Extract response IDs + const responseIds = invalidResponses + .filter(variable => variable.responseId !== undefined) + .map(variable => variable.responseId as number); + + if (responseIds.length === 0) { + this.logger.warn(`No response IDs found for invalid responses in workspace ${workspaceId} of type ${validationType}`); + return 0; + } + + // Delete the invalid responses + return await this.deleteInvalidResponses(workspaceId, responseIds); + } catch (error) { + this.logger.error(`Error deleting all invalid responses: ${error.message}`, error.stack); + throw error; + } + } } diff --git a/apps/frontend/src/app/coding/components/test-person-coding-dialog/test-person-coding-dialog.component.html b/apps/frontend/src/app/coding/components/test-person-coding-dialog/test-person-coding-dialog.component.html index 8b34bc318..ca7aab1ae 100644 --- a/apps/frontend/src/app/coding/components/test-person-coding-dialog/test-person-coding-dialog.component.html +++ b/apps/frontend/src/app/coding/components/test-person-coding-dialog/test-person-coding-dialog.component.html @@ -1,6 +1,6 @@
-

Test Person Coding

+

Testpersonen Kodierung

diff --git a/apps/frontend/src/app/coding/components/test-person-coding/test-person-coding.component.html b/apps/frontend/src/app/coding/components/test-person-coding/test-person-coding.component.html index 4cd4cb964..508ed9ca4 100644 --- a/apps/frontend/src/app/coding/components/test-person-coding/test-person-coding.component.html +++ b/apps/frontend/src/app/coding/components/test-person-coding/test-person-coding.component.html @@ -1,104 +1,59 @@
-
-

Test Person Coding

-

Manage and process test person coding for workspace.

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

Status Counts:

-
-
- {{ status.key || 'Unknown' }}: - {{ status.value }} -
-
-
-
-
-
- - - Code Test Persons + Testpersonen Kodieren
- - Test Person IDs (comma-separated) - - Enter comma-separated list of test person IDs to code - - -
- - Group Size - - Number of test persons per job +
+ + Testpersonengruppen auswählen + + Gruppen werden geladen... + Keine Gruppen verfügbar + {{ group }} + + Wählen Sie eine oder mehrere Gruppen zum Kodieren aus - -
- Run jobs sequentially -
When enabled, jobs will run one after another
-
-
- -
- - Running Jobs + Laufende Aufträge
- Loading jobs... + Aufträge werden geladen...
- - + - - - + - - + - + + + + + + + + + + - + - - + +
Job IDAuftrags-ID {{ job.jobId }} Status @@ -106,9 +61,8 @@

Status Counts:

ProgressFortschritt
{{ job.progress }}% @@ -117,49 +71,54 @@

Status Counts:

CreatedErstellt {{ job.createdAt | date:'medium' }} Gruppen + + {{ truncateText(job.groupNames, 30) }} + + - + Dauer + {{ formatDuration(job.durationMs) }} + Unbekannt + - + ActionsAktionen
- - - - - - - - - + person {{ job.testPersonId }} @@ -167,50 +126,25 @@

Status Counts:

work_off -

No jobs found.

+

Keine Aufträge gefunden.

- - - - Sequential Processing Status - Processing chunks sequentially - - -
-
- Current Chunk: - {{ currentJobIndex + 1 }} of {{ totalJobs }} -
-
- Progress: - {{ Math.round((currentJobIndex / totalJobs) * 100) }}% -
- -
- Chunk Size: - {{ processingQueue[currentJobIndex]?.length || 0 }} test persons -
-
-
-
- - Current Job Status - Job ID: {{ activeJobId }} + Aktueller Auftragsstatus + Auftrags-ID: {{ activeJobId }}
@@ -219,18 +153,28 @@

Status Counts:

{{ jobStatus.status | titlecase }}
- Progress: + Fortschritt: {{ jobStatus.progress }}%
+
+ Gruppen: + + {{ truncateText(jobStatus.groupNames, 50) }} + +
+
+ Dauer: + {{ formatDuration(jobStatus.durationMs) }} +
- Error: + Fehler: {{ jobStatus.error }}
diff --git a/apps/frontend/src/app/coding/components/test-person-coding/test-person-coding.component.scss b/apps/frontend/src/app/coding/components/test-person-coding/test-person-coding.component.scss index 21db7708e..624394e09 100644 --- a/apps/frontend/src/app/coding/components/test-person-coding/test-person-coding.component.scss +++ b/apps/frontend/src/app/coding/components/test-person-coding/test-person-coding.component.scss @@ -1,283 +1,183 @@ -.test-person-coding-container { - padding: 20px; - max-width: 1200px; - margin: 0 auto; -} - -.header-section { - margin-bottom: 20px; - - .page-title { - font-size: 24px; - margin-bottom: 8px; - } - - .page-description { - color: rgba(0, 0, 0, 0.6); - font-size: 16px; - } -} - -mat-card { - margin-bottom: 20px; -} +// All styles consolidated into a single .test-person-coding-container -.statistics-content { - display: flex; - flex-direction: column; - gap: 16px; +.test-person-coding-container { + padding: 16px; - .statistic-item { - display: flex; - align-items: center; - gap: 8px; + .header-section { + margin-bottom: 16px; - .statistic-label { + .page-title { + margin: 0; + font-size: 24px; font-weight: 500; } - .statistic-value { - font-size: 18px; - font-weight: bold; + .page-description { + margin: 8px 0 0; + color: rgba(0, 0, 0, 0.6); } } - .status-counts { - h3 { - margin-bottom: 8px; - } - - .status-grid { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); - gap: 8px; - - .status-item { - display: flex; - justify-content: space-between; - padding: 8px; - background-color: rgba(0, 0, 0, 0.04); - border-radius: 4px; - - .status-label { - font-weight: 500; - } - - .status-value { - font-weight: bold; - } - } - } - } -} - -.form-container { - display: flex; - flex-direction: column; - gap: 16px; - - .full-width { - width: 100%; + .statistics-card, + .code-test-persons-card, + .jobs-card, + .job-status-card { + margin-bottom: 16px; } - .settings-container { + .form-container { display: flex; - flex-wrap: wrap; - gap: 20px; - align-items: flex-start; - margin-bottom: 8px; + flex-direction: column; + gap: 16px; - .group-size-field { - width: 150px; + .group-selection-container { + width: 100%; + + .full-width { + width: 100%; + } } - .sequential-checkbox { + .button-container { display: flex; - flex-direction: column; + flex-wrap: wrap; + gap: 8px; margin-top: 8px; - - .hint-text { - font-size: 12px; - color: rgba(0, 0, 0, 0.6); - margin-top: 4px; - } } } - .button-container { + .loading-indicator { display: flex; - justify-content: flex-end; - gap: 10px; - } -} + flex-direction: column; + align-items: center; + padding: 16px; -.job-status-content { - display: flex; - flex-direction: column; - gap: 16px; + span { + margin-top: 8px; + } + } - .status-row { - display: flex; - align-items: center; - gap: 8px; + .table-container { + margin-top: 16px; + overflow-x: auto; - .status-label { - font-weight: 500; - min-width: 80px; + table { + width: 100%; } - .status-value { - &.status-completed { - color: green; - } + .no-data { + color: rgba(0, 0, 0, 0.38); + font-style: italic; + } - &.status-failed { - color: red; - } + .progress-container { + display: flex; + flex-direction: column; + width: 150px; - &.status-cancelled { - color: orange; + .progress-value { + margin-bottom: 4px; + font-size: 12px; } + } - &.status-paused { - color: purple; - } + .job-actions { + display: flex; + gap: 4px; + } - &.error { - color: red; + .test-person-indicator { + display: flex; + align-items: center; + font-size: 12px; + + mat-icon { + font-size: 16px; + height: 16px; + width: 16px; + margin-right: 4px; } } } - .button-container { + .no-data { display: flex; - justify-content: flex-end; - margin-top: 8px; - } -} - -.sequential-status-card { - background-color: #f0f7ff; - border-left: 4px solid #2196F3; - - mat-card-title { - color: #2196F3; - } + flex-direction: column; + align-items: center; + padding: 32px; + color: rgba(0, 0, 0, 0.5); - .status-value { - font-weight: bold; - color: #2196F3; + mat-icon { + font-size: 48px; + height: 48px; + width: 48px; + margin-bottom: 8px; + } } - mat-progress-bar { - height: 8px; - border-radius: 4px; + .truncated-text { + display: inline-block; + max-width: 100%; overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + cursor: help; } -} - -.coding-list-actions, .jobs-actions { - display: flex; - gap: 8px; - margin-bottom: 16px; -} -.loading-indicator { - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - padding: 20px; - gap: 8px; -} - -.table-container { - overflow-x: auto; - - table { - width: 100%; - } - - mat-paginator { - margin-top: 16px; - } -} + .job-status-content { + .status-row { + display: flex; + margin-bottom: 8px; -.no-data { - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - padding: 40px; - color: rgba(0, 0, 0, 0.6); - - mat-icon { - font-size: 48px; - height: 48px; - width: 48px; - margin-bottom: 16px; - } -} + .status-label { + font-weight: 500; + margin-right: 8px; + min-width: 80px; + } -.progress-container { - display: flex; - flex-direction: column; - gap: 4px; - width: 100%; - max-width: 150px; + .status-value { + &.error { + color: #f44336; + } - .progress-value { - font-weight: 500; - font-size: 14px; - } -} + &.truncated-text { + max-width: calc(100% - 90px); // Subtract the width of the label + } + } + } -.job-actions { - display: flex; - align-items: center; - gap: 4px; - flex-wrap: wrap; -} + mat-progress-bar { + margin-bottom: 16px; + } -.test-person-indicator { - display: flex; - align-items: center; - gap: 4px; - font-size: 12px; - color: rgba(0, 0, 0, 0.6); - margin-left: 8px; - padding: 2px 6px; - background-color: rgba(0, 0, 0, 0.05); - border-radius: 12px; - - mat-icon { - font-size: 14px; - height: 14px; - width: 14px; - line-height: 14px; + .button-container { + margin-top: 16px; + } } -} -.status-processing { - color: #2196F3; // Blue -} + .status-value { + &.status-pending { + color: #ff9800; + } -.status-pending { - color: #FF9800; // Orange -} + &.status-processing { + color: #2196f3; + } -.status-completed { - color: #4CAF50; // Green -} + &.status-completed { + color: #4caf50; + } -.status-failed { - color: #F44336; // Red -} + &.status-failed { + color: #f44336; + } -.status-cancelled { - color: #9E9E9E; // Grey -} + &.status-cancelled { + color: #9e9e9e; + } -.status-paused { - color: #9C27B0; // Purple + &.status-paused { + color: #9c27b0; + } + } } diff --git a/apps/frontend/src/app/coding/components/test-person-coding/test-person-coding.component.ts b/apps/frontend/src/app/coding/components/test-person-coding/test-person-coding.component.ts index 33a662c28..43d8acdb0 100644 --- a/apps/frontend/src/app/coding/components/test-person-coding/test-person-coding.component.ts +++ b/apps/frontend/src/app/coding/components/test-person-coding/test-person-coding.component.ts @@ -67,30 +67,13 @@ export class TestPersonCodingComponent implements OnInit { private snackBar = inject(MatSnackBar); private appService = inject(AppService); private backendService = inject(BackendService); - - // Make Math available to the template Math = Math; - - // Configurable group size for batch processing - groupSize = 5; - - // Flag to track if jobs should run sequentially - runSequentially = true; - - // Track the current job being processed in sequential mode - currentJobIndex = 0; - totalJobs = 0; - processingQueue: number[][] = []; - - // Workspace ID from app service get workspaceId(): number { return this.appService.selectedWorkspaceId; } - // Coding statistics statistics$: Observable | null = null; - // Coding list codingList$ = new BehaviorSubject({ data: [], total: 0, @@ -102,26 +85,41 @@ export class TestPersonCodingComponent implements OnInit { isLoading = false; - // Pagination currentPage = 1; pageSize = 20; - // Job status activeJobId: string | null = null; jobStatus: JobStatus | null = null; jobStatusInterval: number | null = null; - // All jobs allJobs: JobInfo[] = []; jobsLoading = false; jobsRefreshInterval: number | null = null; + availableGroups: string[] = []; + selectedGroups: string[] = []; + groupsLoading = false; + ngOnInit(): void { - // Load data using workspace ID from app service - this.loadStatistics(); - this.loadCodingList(); + // this.loadStatistics(); + // this.loadCodingList(); this.loadAllJobs(); this.startJobsRefreshInterval(); + this.loadWorkspaceGroups(); + } + + loadWorkspaceGroups(): void { + this.groupsLoading = true; + this.testPersonCodingService.getWorkspaceGroups(this.workspaceId) + .pipe( + tap(groups => { + this.availableGroups = groups; + }), + finalize(() => { + this.groupsLoading = false; + }) + ) + .subscribe(); } ngOnDestroy(): void { @@ -129,9 +127,6 @@ export class TestPersonCodingComponent implements OnInit { this.stopJobsRefreshInterval(); } - /** - * Load all jobs for the current workspace - */ loadAllJobs(): void { this.jobsLoading = true; this.testPersonCodingService.getAllJobs(this.workspaceId) @@ -145,8 +140,8 @@ export class TestPersonCodingComponent implements OnInit { if (activeJob) { this.jobStatus = activeJob; - // If job is completed, failed, or cancelled, stop polling - if (['completed', 'failed', 'cancelled'].includes(activeJob.status)) { + // If job is completed, failed, cancelled, or paused, stop polling + if (['completed', 'failed', 'cancelled', 'paused'].includes(activeJob.status)) { this.stopJobStatusPolling(); if (activeJob.status === 'completed') { @@ -164,9 +159,6 @@ export class TestPersonCodingComponent implements OnInit { .subscribe(); } - /** - * Start automatic refresh of jobs list - */ startJobsRefreshInterval(): void { // Clear any existing interval this.stopJobsRefreshInterval(); @@ -177,9 +169,6 @@ export class TestPersonCodingComponent implements OnInit { }, 5000); } - /** - * Stop automatic refresh of jobs list - */ stopJobsRefreshInterval(): void { if (this.jobsRefreshInterval) { clearInterval(this.jobsRefreshInterval); @@ -217,7 +206,7 @@ export class TestPersonCodingComponent implements OnInit { codeTestPersons(testPersonIds: string): void { if (!testPersonIds) { - this.snackBar.open('Please enter test person IDs', 'Close', { duration: 3000 }); + this.snackBar.open('Bitte geben Sie Testpersonen-IDs ein', 'Schließen', { duration: 3000 }); return; } @@ -229,16 +218,16 @@ export class TestPersonCodingComponent implements OnInit { // Background job started this.activeJobId = result.jobId; this.startJobStatusPolling(result.jobId); - this.snackBar.open(result.message || 'Background job started', 'Close', { duration: 5000 }); + this.snackBar.open(result.message || 'Hintergrundauftrag gestartet', 'Schließen', { duration: 5000 }); } else { // Immediate result - this.snackBar.open(`Coded ${result.totalResponses} responses`, 'Close', { duration: 3000 }); + this.snackBar.open(`${result.totalResponses} Antworten kodiert`, 'Schließen', { duration: 3000 }); this.loadStatistics(); this.loadCodingList(this.currentPage, this.pageSize); } }), catchError(error => { - this.snackBar.open(`Error: ${error.message || 'Failed to code test persons'}`, 'Close', { duration: 5000 }); + this.snackBar.open(`Fehler: ${error.message || 'Kodierung der Testpersonen fehlgeschlagen'}`, 'Schließen', { duration: 5000 }); return of(null); }), finalize(() => { @@ -249,35 +238,34 @@ export class TestPersonCodingComponent implements OnInit { } startJobStatusPolling(jobId: string): void { - // Clear any existing interval if (this.jobStatusInterval) { clearInterval(this.jobStatusInterval); } - // Poll job status every 2 seconds this.jobStatusInterval = window.setInterval(() => { this.testPersonCodingService.getJobStatus(this.workspaceId, jobId) .subscribe(status => { if ('error' in status) { - this.snackBar.open(`Error: ${status.error}`, 'Close', { duration: 5000 }); + this.snackBar.open(`Fehler: ${status.error}`, 'Schließen', { duration: 5000 }); this.stopJobStatusPolling(); return; } this.jobStatus = status; - // If job is completed, failed, or cancelled, stop polling - if (['completed', 'failed', 'cancelled'].includes(status.status)) { + if (['completed', 'failed', 'cancelled', 'paused'].includes(status.status)) { this.stopJobStatusPolling(); if (status.status === 'completed') { - this.snackBar.open('Coding job completed successfully', 'Close', { duration: 3000 }); + this.snackBar.open('Kodierungsauftrag erfolgreich abgeschlossen', 'Schließen', { duration: 3000 }); this.loadStatistics(); this.loadCodingList(this.currentPage, this.pageSize); } else if (status.status === 'failed') { - this.snackBar.open(`Coding job failed: ${status.error || 'Unknown error'}`, 'Close', { duration: 5000 }); + this.snackBar.open(`Kodierungsauftrag fehlgeschlagen: ${status.error || 'Unbekannter Fehler'}`, 'Schließen', { duration: 5000 }); } else if (status.status === 'cancelled') { - this.snackBar.open('Coding job was cancelled', 'Close', { duration: 3000 }); + this.snackBar.open('Kodierungsauftrag wurde abgebrochen', 'Schließen', { duration: 3000 }); + } else if (status.status === 'paused') { + this.snackBar.open('Kodierungsauftrag wurde pausiert', 'Schließen', { duration: 3000 }); } } }); @@ -293,10 +281,6 @@ export class TestPersonCodingComponent implements OnInit { this.jobStatus = null; } - /** - * Cancel a job - * @param jobId Optional job ID to cancel. If not provided, cancels the active job. - */ cancelJob(jobId?: string): void { const idToCancel = jobId || this.activeJobId; if (!idToCancel) return; @@ -304,126 +288,54 @@ export class TestPersonCodingComponent implements OnInit { this.testPersonCodingService.cancelJob(this.workspaceId, idToCancel) .subscribe(result => { if (result.success) { - this.snackBar.open(result.message, 'Close', { duration: 3000 }); + this.snackBar.open(result.message, 'Schließen', { duration: 3000 }); // Refresh the jobs list this.loadAllJobs(); } else { - this.snackBar.open(`Failed to cancel job: ${result.message}`, 'Close', { duration: 5000 }); + this.snackBar.open(`Fehler beim Abbrechen des Auftrags: ${result.message}`, 'Schließen', { duration: 5000 }); } }); } - /** - * Pause a job - * @param jobId Optional job ID to pause. If not provided, pauses the active job. - */ - pauseJob(jobId?: string): void { - const idToPause = jobId || this.activeJobId; - if (!idToPause) return; - - this.testPersonCodingService.pauseJob(this.workspaceId, idToPause) - .subscribe(result => { - if (result.success) { - this.snackBar.open(result.message, 'Close', { duration: 3000 }); - // Refresh the jobs list - this.loadAllJobs(); - } else { - this.snackBar.open(`Failed to pause job: ${result.message}`, 'Close', { duration: 5000 }); - } - }); - } - - /** - * Resume a job - * @param jobId Optional job ID to resume. If not provided, resumes the active job. - */ - resumeJob(jobId?: string): void { - const idToResume = jobId || this.activeJobId; - if (!idToResume) return; - - this.testPersonCodingService.resumeJob(this.workspaceId, idToResume) - .subscribe(result => { - if (result.success) { - this.snackBar.open(result.message, 'Close', { duration: 3000 }); - // Refresh the jobs list - this.loadAllJobs(); - } else { - this.snackBar.open(`Failed to resume job: ${result.message}`, 'Close', { duration: 5000 }); - } - }); - } - - /** - * Show job result in a dialog - * @param job The job to show results for - */ showJobResult(job: JobInfo): void { if (!job.result) { - this.snackBar.open('No results available for this job', 'Close', { duration: 3000 }); + this.snackBar.open('Keine Ergebnisse für diesen Auftrag verfügbar', 'Schließen', { duration: 3000 }); return; } - // Create a formatted message with the job results - let message = `Job ID: ${job.jobId}\n\n`; - message += `Total Responses: ${job.result.totalResponses}\n\n`; - message += 'Status Counts:\n'; + let message = `Auftrags-ID: ${job.jobId}\n\n`; - for (const [status, count] of Object.entries(job.result.statusCounts)) { - message += `${status || 'Unknown'}: ${count}\n`; + if (job.groupNames) { + message += `Kodierte Gruppen: ${job.groupNames}\n\n`; } - // Show the message in a snackbar - this.snackBar.open(message, 'Close', { duration: 10000 }); - } - - /** - * Code exactly five test persons - */ - codeFiveTestPersons(): void { - this.isLoading = true; - this.backendService.getTestPersons(this.workspaceId) - .pipe( - catchError(error => { - this.snackBar.open(`Error getting test persons: ${error.message || 'Unknown error'}`, 'Close', { duration: 5000 }); - return of([]); - }), - finalize(() => { - this.isLoading = false; - }) - ) - .subscribe(testPersonIds => { - if (testPersonIds.length === 0) { - this.snackBar.open('No test persons found for this workspace', 'Close', { duration: 3000 }); - return; - } + if (job.durationMs) { + message += `Dauer: ${this.formatDuration(job.durationMs)}\n\n`; + } - // Take only the first 5 test persons (or fewer if there are less than 5) - const limitedTestPersonIds = testPersonIds.slice(0, 5); + message += `Gesamtantworten: ${job.result.totalResponses}\n\n`; + message += 'Statuszähler:\n'; - // Process the chunk of 5 test persons - if (this.runSequentially) { - this.processChunksSequentially([limitedTestPersonIds]); - } else { - this.processTestPersonChunk(limitedTestPersonIds, 0, 1) - .catch(error => { - this.snackBar.open(`Error processing test persons: ${error.message || 'Unknown error'}`, 'Close', { duration: 5000 }); - }); - } + for (const [status, count] of Object.entries(job.result.statusCounts)) { + message += `${status || 'Unbekannt'}: ${count}\n`; + } - // Show a message about how many test persons are being coded - this.snackBar.open(`Coding ${limitedTestPersonIds.length} test persons`, 'Close', { duration: 3000 }); - }); + this.snackBar.open(message, 'Schließen', { duration: 10000 }); } - /** - * Code all test persons in the workspace, split into groups of the configured size - */ codeAllTestPersons(): void { + if (this.availableGroups.length > 0) { + this.selectedGroups = [...this.availableGroups]; + this.codeTestPersons(this.selectedGroups.join(',')); + this.snackBar.open(`Kodiere Testpersonen aus allen ${this.selectedGroups.length} Gruppen`, 'Schließen', { duration: 3000 }); + return; + } + this.isLoading = true; this.backendService.getTestPersons(this.workspaceId) .pipe( catchError(error => { - this.snackBar.open(`Error getting test persons: ${error.message || 'Unknown error'}`, 'Close', { duration: 5000 }); + this.snackBar.open(`Fehler beim Abrufen der Testpersonen: ${error.message || 'Unbekannter Fehler'}`, 'Schließen', { duration: 5000 }); return of([]); }), finalize(() => { @@ -432,178 +344,42 @@ export class TestPersonCodingComponent implements OnInit { ) .subscribe(testPersonIds => { if (testPersonIds.length === 0) { - this.snackBar.open('No test persons found for this workspace', 'Close', { duration: 3000 }); + this.snackBar.open('Keine Testpersonen für diesen Arbeitsbereich gefunden', 'Schließen', { duration: 3000 }); return; } - // Split test persons into groups of the configured size - const chunks = this.chunkArray(testPersonIds, this.groupSize); - - // Show message about how many chunks will be processed - this.snackBar.open(`Processing ${testPersonIds.length} test persons in ${chunks.length} groups of ${this.groupSize}`, 'Close', { duration: 5000 }); + const testPersonIdsString = testPersonIds.join(','); + this.codeTestPersons(testPersonIdsString); - if (this.runSequentially) { - // Process chunks sequentially - this.processChunksSequentially(chunks) - .catch(error => { - this.snackBar.open(`Error processing test persons: ${error.message || 'Unknown error'}`, 'Close', { duration: 5000 }); - }); - } else { - // Process each chunk with a small delay between them to avoid overwhelming the server - chunks.forEach((chunk, index) => { - setTimeout(() => { - this.processTestPersonChunk(chunk, index, chunks.length) - .catch(error => { - this.snackBar.open(`Error processing chunk ${index + 1}/${chunks.length}: ${error.message || 'Unknown error'}`, 'Close', { duration: 5000 }); - }); - }, index * 500); // 500ms delay between chunks - }); - } + // Show message about how many test persons are being coded + this.snackBar.open(`Kodiere ${testPersonIds.length} Testpersonen`, 'Schließen', { duration: 5000 }); }); } - exportAsCsv(): void { - this.isLoading = true; - this.testPersonCodingService.exportCodingListAsCsv(this.workspaceId) - .pipe( - finalize(() => { - this.isLoading = false; - }) - ) - .subscribe(blob => { - this.downloadFile(blob, `coding-list-${new Date().toISOString().slice(0, 10)}.csv`); - }); - } - - exportAsExcel(): void { - this.isLoading = true; - this.testPersonCodingService.exportCodingListAsExcel(this.workspaceId) - .pipe( - finalize(() => { - this.isLoading = false; - }) - ) - .subscribe(blob => { - this.downloadFile(blob, `coding-list-${new Date().toISOString().slice(0, 10)}.xlsx`); - }); - } + formatDuration(durationMs: number): string { + if (!durationMs) return '-'; - /** - * Split an array into chunks of the specified size - * @param array The array to split - * @param chunkSize The size of each chunk - * @returns An array of chunks - */ - private chunkArray(array: T[], chunkSize: number): T[][] { - const chunks: T[][] = []; - for (let i = 0; i < array.length; i += chunkSize) { - chunks.push(array.slice(i, i + chunkSize)); - } - return chunks; - } + const seconds = Math.floor((durationMs / 1000) % 60); + const minutes = Math.floor((durationMs / (1000 * 60)) % 60); + const hours = Math.floor((durationMs / (1000 * 60 * 60))); - /** - * Process a chunk of test person IDs - * @param chunk Array of test person IDs to process - * @param chunkIndex Index of the current chunk - * @param totalChunks Total number of chunks - * @returns Promise that resolves when the chunk is processed - */ - private processTestPersonChunk(chunk: number[], chunkIndex: number, totalChunks: number): Promise { - return new Promise((resolve, reject) => { - // Convert the chunk to a comma-separated string - const testPersonIdsString = chunk.join(','); - - // Show message about which chunk is being processed - this.snackBar.open(`Processing chunk ${chunkIndex + 1}/${totalChunks} with ${chunk.length} test persons`, 'Close', { duration: 3000 }); - - // Call the existing codeTestPersons method with the IDs string - this.testPersonCodingService.codeTestPersons(this.workspaceId, testPersonIdsString) - .pipe( - catchError(error => { - this.snackBar.open(`Error coding chunk ${chunkIndex + 1}/${totalChunks}: ${error.message || 'Unknown error'}`, 'Close', { duration: 5000 }); - reject(error); - return of(null); - }) - ) - .subscribe(result => { - if (result && result.jobId) { - // If a job was created, we need to wait for it to complete before resolving - const checkJobInterval = setInterval(() => { - this.testPersonCodingService.getJobStatus(this.workspaceId, result.jobId!) - .subscribe(status => { - if ('error' in status) { - clearInterval(checkJobInterval); - this.snackBar.open(`Error checking job status: ${status.error}`, 'Close', { duration: 5000 }); - reject(new Error(status.error)); - return; - } - - // If job is completed, failed, or cancelled, resolve or reject the promise - if (['completed', 'failed', 'cancelled'].includes(status.status)) { - clearInterval(checkJobInterval); - - if (status.status === 'completed') { - this.snackBar.open(`Completed chunk ${chunkIndex + 1}/${totalChunks}`, 'Close', { duration: 3000 }); - resolve(); - } else if (status.status === 'failed') { - this.snackBar.open(`Failed to process chunk ${chunkIndex + 1}/${totalChunks}: ${status.error || 'Unknown error'}`, 'Close', { duration: 5000 }); - reject(new Error(status.error || 'Job failed')); - } else if (status.status === 'cancelled') { - this.snackBar.open(`Chunk ${chunkIndex + 1}/${totalChunks} was cancelled`, 'Close', { duration: 3000 }); - reject(new Error('Job was cancelled')); - } - } - }); - }, 2000); - } else if (result) { - // If no job was created (immediate result), resolve immediately - this.snackBar.open(`Processed chunk ${chunkIndex + 1}/${totalChunks} with ${chunk.length} test persons`, 'Close', { duration: 3000 }); - resolve(); - } else { - // If no result, reject - reject(new Error('No result returned')); - } + const parts: string[] = []; - // Refresh the jobs list after each chunk is processed - this.loadAllJobs(); - }); - }); - } + if (hours > 0) { + parts.push(`${hours}h`); + } - /** - * Process all chunks sequentially - * @param chunks Array of chunks to process - * @returns Promise that resolves when all chunks are processed - */ - private async processChunksSequentially(chunks: number[][]): Promise { - this.currentJobIndex = 0; - this.totalJobs = chunks.length; - this.processingQueue = chunks; - - for (let i = 0; i < chunks.length; i++) { - this.currentJobIndex = i; - try { - await this.processTestPersonChunk(chunks[i], i, chunks.length); - } catch (error) { - // @ts-ignore - this.snackBar.open(`Error processing chunk ${i + 1}/${chunks.length}: ${error.message || 'Unknown error'}`, 'Close', { duration: 5000 }); - // Continue with the next chunk even if this one failed - } + if (minutes > 0 || hours > 0) { + parts.push(`${minutes}m`); } - // Refresh statistics and coding list after all chunks are processed - this.loadStatistics(); - this.loadCodingList(this.currentPage, this.pageSize); - this.snackBar.open(`Completed processing all ${chunks.length} chunks`, 'Close', { duration: 5000 }); + parts.push(`${seconds}s`); + + return parts.join(' '); } - private downloadFile(blob: Blob, filename: string): void { - const url = window.URL.createObjectURL(blob); - const link = document.createElement('a'); - link.href = url; - link.download = filename; - link.click(); - window.URL.revokeObjectURL(url); + truncateText(text: string, maxLength: number): string { + if (!text) return ''; + return text.length > maxLength ? `${text.substring(0, maxLength)}...` : text; } } diff --git a/apps/frontend/src/app/coding/services/test-person-coding.service.ts b/apps/frontend/src/app/coding/services/test-person-coding.service.ts index 6ba827014..78d1b2daf 100644 --- a/apps/frontend/src/app/coding/services/test-person-coding.service.ts +++ b/apps/frontend/src/app/coding/services/test-person-coding.service.ts @@ -46,6 +46,9 @@ export interface JobStatus { workspaceId?: number; createdAt?: Date; testPersonId?: string; + groupNames?: string; + durationMs?: number; + completedAt?: Date; } export interface JobInfo extends JobStatus { @@ -63,11 +66,6 @@ export class TestPersonCodingService { return { Authorization: `Bearer ${localStorage.getItem('id_token')}` }; } - /** - * Code test persons - * @param workspaceId Workspace ID - * @param testPersonIds Comma-separated list of test person IDs - */ codeTestPersons(workspaceId: number, testPersonIds: string): Observable { return this.http .get( @@ -183,38 +181,6 @@ export class TestPersonCodingService { ); } - /** - * Pause job - * @param workspaceId Workspace ID - * @param jobId Job ID - */ - pauseJob(workspaceId: number, jobId: string): Observable<{ success: boolean; message: string }> { - return this.http - .get<{ success: boolean; message: string }>( - `${this.serverUrl}admin/workspace/${workspaceId}/coding/job/${jobId}/pause`, - { headers: this.authHeader } - ) - .pipe( - catchError(() => of({ success: false, message: `Failed to pause job ${jobId}` })) - ); - } - - /** - * Resume job - * @param workspaceId Workspace ID - * @param jobId Job ID - */ - resumeJob(workspaceId: number, jobId: string): Observable<{ success: boolean; message: string }> { - return this.http - .get<{ success: boolean; message: string }>( - `${this.serverUrl}admin/workspace/${workspaceId}/coding/job/${jobId}/resume`, - { headers: this.authHeader } - ) - .pipe( - catchError(() => of({ success: false, message: `Failed to resume job ${jobId}` })) - ); - } - /** * Export coding list as CSV * @param workspaceId Workspace ID @@ -266,4 +232,20 @@ export class TestPersonCodingService { catchError(() => of([])) ); } + + /** + * Get all test person groups for a workspace + * @param workspaceId Workspace ID + * @returns Observable of an array of group names + */ + getWorkspaceGroups(workspaceId: number): Observable { + return this.http + .get( + `${this.serverUrl}admin/workspace/${workspaceId}/coding/groups`, + { headers: this.authHeader } + ) + .pipe( + catchError(() => of([])) + ); + } } diff --git a/apps/frontend/src/app/components/home/home.component.html b/apps/frontend/src/app/components/home/home.component.html index 4a6baa817..0ad745cb7 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.9.0'" + [appVersion]="'0.9.1'" [userName]="authData.userName" [userLongName]="appService.userProfile.firstName + ' ' + appService.userProfile.lastName" [isUserLoggedIn]="Number(authData.userId) > 0" diff --git a/apps/frontend/src/app/models/variable-analysis-job.dto.ts b/apps/frontend/src/app/models/variable-analysis-job.dto.ts new file mode 100644 index 000000000..5e29b3460 --- /dev/null +++ b/apps/frontend/src/app/models/variable-analysis-job.dto.ts @@ -0,0 +1,23 @@ +export interface VariableAnalysisJobDto { + id: number; + workspace_id: number; + + /** + * Optional unit ID to filter by + */ + unit_id?: number; + + /** + * Optional variable ID to filter by + */ + variable_id?: string; + status: 'pending' | 'processing' | 'completed' | 'failed'; + error?: string; + created_at: Date; + updated_at: Date; + + /** + * Type of the job, used for inheritance discrimination + */ + type?: string; +} 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 a6611daec..a53e3c4df 100755 --- a/apps/frontend/src/app/replay/components/replay/replay.component.ts +++ b/apps/frontend/src/app/replay/components/replay/replay.component.ts @@ -134,7 +134,7 @@ export class ReplayComponent implements OnInit, OnDestroy, OnChanges { scrollToElementByAlias(this.unitPlayerComponent.hostingIframe.nativeElement, this.anchor); } else { // When no anchor is provided, scroll to the top of the content - this.scrollToTop(); + // this.scrollToTop(); } } }, 1000); @@ -341,7 +341,7 @@ export class ReplayComponent implements OnInit, OnDestroy, OnChanges { UnitIdError: 'Unbekannte Unit-ID', TestPersonError: 'Ungültige ID für Testperson', PlayerError: 'Ungültiger Player-Name', - ResponsesError: `Keine Antworten für Aufgabe "${this.unitId}" von Testperson "${this.testPerson}" gefunden`, + ResponsesError: `Fehler beim Laden der Antworten für Aufgabe "${this.unitId}" von Testperson "${this.testPerson}"`, notInList: `Keine valide Seite mit ID "${this.page}" gefunden`, notCurrent: `Seite mit ID "${this.page}" kann nicht ausgewählt werden`, tokenExpired: 'Das Authentisierungs-Token ist abgelaufen', @@ -351,7 +351,17 @@ export class ReplayComponent implements OnInit, OnDestroy, OnChanges { } private catchError(error: HttpErrorResponse): void { - const messageKey = error.status === 401 ? '401' : error.message as keyof ErrorMessages; + let messageKey: keyof ErrorMessages; + + if (error.status === 401) { + messageKey = '401' as keyof ErrorMessages; + } else if (error.status === 404 && this.unitId && this.testPerson) { + // If it's a 404 error and we have unitId and testPerson, it's likely a ResponsesError + messageKey = 'ResponsesError' as keyof ErrorMessages; + } else { + messageKey = error.message as keyof ErrorMessages; + } + const message = this.getErrorMessages()[messageKey] || this.getErrorMessages().unknown; this.openErrorSnackBar(message, 'Schließen'); } @@ -378,9 +388,6 @@ export class ReplayComponent implements OnInit, OnDestroy, OnChanges { this.responses = undefined; } - /** - * Scrolls the iframe content to the top - */ private scrollToTop(): void { try { if (this.unitPlayerComponent?.hostingIframe?.nativeElement?.contentWindow) { diff --git a/apps/frontend/src/app/services/backend.service.ts b/apps/frontend/src/app/services/backend.service.ts index eee711f66..fb744fd6c 100755 --- a/apps/frontend/src/app/services/backend.service.ts +++ b/apps/frontend/src/app/services/backend.service.ts @@ -24,6 +24,8 @@ import { UnitService } from './unit.service'; // eslint-disable-next-line import/no-cycle import { ImportService } from './import.service'; import { AuthenticationService } from './authentication.service'; +import { VariableAnalysisService, VariableAnalysisResultDto } from './variable-analysis.service'; +import { VariableAnalysisJobDto } from '../models/variable-analysis-job.dto'; import { FilesDto } from '../../../../../api-dto/files/files.dto'; import { CreateUnitNoteDto } from '../../../../../api-dto/unit-notes/create-unit-note.dto'; import { WorkspaceFullDto } from '../../../../../api-dto/workspaces/workspace-full-dto'; @@ -107,6 +109,7 @@ export class BackendService { private unitService = inject(UnitService); private importService = inject(ImportService); private authenticationService = inject(AuthenticationService); + private variableAnalysisService = inject(VariableAnalysisService); authHeader = { Authorization: `Bearer ${localStorage.getItem('id_token')}` }; @@ -179,10 +182,50 @@ export class BackendService { statusCounts: { [key: string]: number; }; + jobId?: string; + message?: string; }> { return this.codingService.codeTestPersons(workspace_id, testPersonIds); } + getCodingJobStatus(workspace_id: number, jobId: string): Observable<{ + status: 'pending' | 'processing' | 'completed' | 'failed' | 'cancelled'; + progress: number; + result?: { + totalResponses: number; + statusCounts: { + [key: string]: number; + }; + }; + error?: string; + }> { + return this.codingService.getCodingJobStatus(workspace_id, jobId); + } + + cancelCodingJob(workspace_id: number, jobId: string): Observable<{ + success: boolean; + message: string; + }> { + return this.codingService.cancelCodingJob(workspace_id, jobId); + } + + getAllCodingJobs(workspace_id: number): Observable<{ + jobId: string; + status: 'pending' | 'processing' | 'completed' | 'failed' | 'cancelled'; + progress: number; + result?: { + totalResponses: number; + statusCounts: { + [key: string]: number; + }; + }; + error?: string; + workspaceId?: number; + createdAt?: Date; + }[]> { + return this.codingService.getAllCodingJobs(workspace_id); + } + getCodingList(workspace_id: number, page: number = 1, limit: number = 100): Observable> { return this.codingService.getCodingList(workspace_id, page, limit); } @@ -499,4 +542,42 @@ export class BackendService { deleteInvalidResponses(workspaceId: number, responseIds: number[]): Observable { return this.validationService.deleteInvalidResponses(workspaceId, responseIds); } + + deleteAllInvalidResponses(workspaceId: number, validationType: 'variables' | 'variableTypes' | 'responseStatus'): Observable { + return this.validationService.deleteAllInvalidResponses(workspaceId, validationType); + } + + createVariableAnalysisJob( + workspaceId: number, + unitId?: number, + variableId?: string + ): Observable { + return this.variableAnalysisService.createAnalysisJob( + workspaceId, + unitId, + variableId + ); + } + + getVariableAnalysisJob( + workspaceId: number, + jobId: number + ): Observable { + return this.variableAnalysisService.getAnalysisJob(workspaceId, jobId); + } + + getVariableAnalysisResults( + workspaceId: number, + jobId: number + ): Observable { + return this.variableAnalysisService.getAnalysisResults(workspaceId, jobId); + } + + getAllVariableAnalysisJobs(workspaceId: number): Observable { + return this.variableAnalysisService.getAllJobs(workspaceId); + } + + cancelVariableAnalysisJob(workspaceId: number, jobId: number): Observable<{ success: boolean; message: string }> { + return this.variableAnalysisService.cancelJob(workspaceId, jobId); + } } diff --git a/apps/frontend/src/app/services/coding.service.ts b/apps/frontend/src/app/services/coding.service.ts index dd191b53c..920f81348 100644 --- a/apps/frontend/src/app/services/coding.service.ts +++ b/apps/frontend/src/app/services/coding.service.ts @@ -71,6 +71,8 @@ export class CodingService { statusCounts: { [key: string]: number; }; + jobId?: string; + message?: string; }> { const params = new HttpParams().set('testPersons', testPersonIds.join(',')); return this.http @@ -79,6 +81,8 @@ export class CodingService { statusCounts: { [key: string]: number; }; + jobId?: string; + message?: string; }>( `${this.serverUrl}admin/workspace/${workspace_id}/coding`, { headers: this.authHeader, params }) @@ -87,6 +91,107 @@ export class CodingService { ); } + getCodingJobStatus(workspace_id: number, jobId: string): Observable<{ + status: 'pending' | 'processing' | 'completed' | 'failed' | 'cancelled'; + progress: number; + result?: { + totalResponses: number; + statusCounts: { + [key: string]: number; + }; + }; + error?: string; + }> { + return this.http + .get<{ + status: 'pending' | 'processing' | 'completed' | 'failed' | 'cancelled'; + progress: number; + result?: { + totalResponses: number; + statusCounts: { + [key: string]: number; + }; + }; + error?: string; + }>( + `${this.serverUrl}admin/workspace/${workspace_id}/coding/job/${jobId}`, + { headers: this.authHeader } + ) + .pipe( + catchError(error => { + console.error('Error getting job status:', error); + return of({ + status: 'failed' as const, + progress: 0, + error: 'Failed to get job status' + }); + }) + ); + } + + cancelCodingJob(workspace_id: number, jobId: string): Observable<{ + success: boolean; + message: string; + }> { + return this.http + .get<{ + success: boolean; + message: string; + }>( + `${this.serverUrl}admin/workspace/${workspace_id}/coding/job/${jobId}/cancel`, + { headers: this.authHeader } + ) + .pipe( + catchError(error => { + console.error('Error cancelling job:', error); + return of({ + success: false, + message: 'Failed to cancel job' + }); + }) + ); + } + + getAllCodingJobs(workspace_id: number): Observable<{ + jobId: string; + status: 'pending' | 'processing' | 'completed' | 'failed' | 'cancelled'; + progress: number; + result?: { + totalResponses: number; + statusCounts: { + [key: string]: number; + }; + }; + error?: string; + workspaceId?: number; + createdAt?: Date; + }[]> { + return this.http + .get<{ + jobId: string; + status: 'pending' | 'processing' | 'completed' | 'failed' | 'cancelled'; + progress: number; + result?: { + totalResponses: number; + statusCounts: { + [key: string]: number; + }; + }; + error?: string; + workspaceId?: number; + createdAt?: Date; + }[]>( + `${this.serverUrl}admin/workspace/${workspace_id}/coding/jobs`, + { headers: this.authHeader } + ) + .pipe( + catchError(error => { + console.error('Error getting all jobs:', error); + return of([]); + }) + ); + } + getCodingList(workspace_id: number, page: number = 1, limit: number = 100): Observable> { const identity = this.appService.loggedUser?.sub || ''; return this.appService.createToken(workspace_id, identity, 60).pipe( diff --git a/apps/frontend/src/app/services/validation.service.ts b/apps/frontend/src/app/services/validation.service.ts index 004206193..d61ce64fb 100644 --- a/apps/frontend/src/app/services/validation.service.ts +++ b/apps/frontend/src/app/services/validation.service.ts @@ -139,4 +139,14 @@ export class ValidationService { catchError(() => of(0)) ); } + + deleteAllInvalidResponses(workspaceId: number, validationType: 'variables' | 'variableTypes' | 'responseStatus'): Observable { + const params = new HttpParams().set('validationType', validationType); + return this.http.delete( + `${this.serverUrl}admin/workspace/${workspaceId}/files/all-invalid-responses`, + { headers: this.authHeader, params } + ).pipe( + catchError(() => of(0)) + ); + } } diff --git a/apps/frontend/src/app/services/variable-analysis.service.ts b/apps/frontend/src/app/services/variable-analysis.service.ts new file mode 100644 index 000000000..c3dd44ff4 --- /dev/null +++ b/apps/frontend/src/app/services/variable-analysis.service.ts @@ -0,0 +1,127 @@ +import { Injectable, inject } from '@angular/core'; +import { HttpClient, HttpParams } from '@angular/common/http'; +import { + catchError, + Observable +} from 'rxjs'; +import { logger } from 'nx/src/utils/logger'; +import { SERVER_URL } from '../injection-tokens'; +import { VariableAnalysisJobDto } from '../models/variable-analysis-job.dto'; + +export interface JobCancelResult { + success: boolean; + message: string; +} + +export interface VariableFrequencyDto { + unitName?: string; + variableId: string; + value: string; + count: number; + percentage: number; +} + +export interface VariableCombo { + unitName: string; + variableId: string; +} + +export interface VariableAnalysisResultDto { + variableCombos: VariableCombo[]; + frequencies: { [key: string]: VariableFrequencyDto[] }; + total: number; +} + +@Injectable({ + providedIn: 'root' +}) +export class VariableAnalysisService { + readonly serverUrl = inject(SERVER_URL); + private http = inject(HttpClient); + + get authHeader() { + return { Authorization: `Bearer ${localStorage.getItem('id_token')}` }; + } + + createAnalysisJob( + workspaceId: number, + unitId?: number, + variableId?: string + ): Observable { + let params = new HttpParams(); + + if (unitId) { + params = params.set('unitId', unitId.toString()); + } + + if (variableId) { + params = params.set('variableId', variableId); + } + + return this.http.post( + `${this.serverUrl}admin/workspace/${workspaceId}/variable-analysis/jobs`, + null, + { headers: this.authHeader, params } + ).pipe( + catchError(error => { + logger.error(`Error creating variable analysis job: ${error.message}`); + throw error; + }) + ); + } + + getAnalysisJob( + workspaceId: number, + jobId: number + ): Observable { + return this.http.get( + `${this.serverUrl}admin/workspace/${workspaceId}/variable-analysis/jobs/${jobId}`, + { headers: this.authHeader } + ).pipe( + catchError(error => { + logger.error(`Error getting variable analysis job: ${error.message}`); + throw error; + }) + ); + } + + getAnalysisResults( + workspaceId: number, + jobId: number + ): Observable { + return this.http.get( + `${this.serverUrl}admin/workspace/${workspaceId}/variable-analysis/jobs/${jobId}/results`, + { headers: this.authHeader } + ).pipe( + catchError(error => { + logger.error(`Error getting variable analysis results: ${error.message}`); + throw error; + }) + ); + } + + getAllJobs(workspaceId: number): Observable { + return this.http.get( + `${this.serverUrl}admin/workspace/${workspaceId}/jobs`, + { headers: this.authHeader } + ).pipe( + catchError(error => { + logger.error(`Error getting all variable analysis jobs: ${error.message}`); + throw error; + }) + ); + } + + cancelJob(workspaceId: number, jobId: number): Observable { + return this.http.post( + `${this.serverUrl}admin/workspace/${workspaceId}/jobs/${jobId}/cancel`, + null, + { headers: this.authHeader } + ).pipe( + catchError(error => { + logger.error(`Error cancelling variable analysis job: ${error.message}`); + throw error; + }) + ); + } +} diff --git a/apps/frontend/src/app/ws-admin/components/test-results/test-results.component.html b/apps/frontend/src/app/ws-admin/components/test-results/test-results.component.html index c121b6332..dddb33c82 100755 --- a/apps/frontend/src/app/ws-admin/components/test-results/test-results.component.html +++ b/apps/frontend/src/app/ws-admin/components/test-results/test-results.component.html @@ -29,6 +29,10 @@ rule Validieren + + analytics + Item/Variablen Analyse +
Keine Ergebnisse gefunden
-

Booklets

-

Wählen Sie ein Booklet aus, um dessen Aufgaben anzuzeigen

+

Testhefte

+

Wählen Sie ein Testheft aus, um dessen Aufgaben anzuzeigen

- +
+ +

Booklets werden geladen...

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

Booklets

@if (hasShortProcessingTime(booklet) || !isBookletComplete(booklet)) { diff --git a/apps/frontend/src/app/ws-admin/components/test-results/test-results.component.ts b/apps/frontend/src/app/ws-admin/components/test-results/test-results.component.ts index 295aa3372..6c8c93780 100755 --- a/apps/frontend/src/app/ws-admin/components/test-results/test-results.component.ts +++ b/apps/frontend/src/app/ws-admin/components/test-results/test-results.component.ts @@ -52,6 +52,7 @@ import { UpdateUnitTagDto } from '../../../../../../../api-dto/unit-tags/update- import { UnitNoteDto } from '../../../../../../../api-dto/unit-notes/unit-note.dto'; import { ValidationDialogComponent } from '../validation-dialog/validation-dialog.component'; import { VariableValidationDto } from '../../../../../../../api-dto/files/variable-validation.dto'; +import { VariableAnalysisDialogComponent } from '../variable-analysis-dialog/variable-analysis-dialog.component'; interface BookletLog { id: number; @@ -200,6 +201,7 @@ export class TestResultsComponent implements OnInit, OnDestroy { isLoading: boolean = true; isUploadingResults: boolean = false; isSearching: boolean = false; + isLoadingBooklets: boolean = false; unitTags: UnitTagDto[] = []; newTagText: string = ''; unitTagsMap: Map = new Map(); @@ -237,14 +239,21 @@ export class TestResultsComponent implements OnInit, OnDestroy { this.bookletLogs = []; this.selectedUnit = undefined; this.unitTagsMap.clear(); + this.isLoadingBooklets = true; this.backendService.getPersonTestResults(this.appService.selectedWorkspaceId, row.id) - .subscribe(booklets => { - this.selectedBooklet = row.group; - const uniqueBooklets = this.filterUniqueBooklets(booklets); - this.booklets = uniqueBooklets; - this.sortBooklets(); - this.sortBookletUnits(); - this.loadAllUnitTags(); + .subscribe({ + next: booklets => { + this.selectedBooklet = row.group; + const uniqueBooklets = this.filterUniqueBooklets(booklets); + this.booklets = uniqueBooklets; + this.sortBooklets(); + this.sortBookletUnits(); + this.loadAllUnitTags(); + this.isLoadingBooklets = false; + }, + error: () => { + this.isLoadingBooklets = false; + } }); } @@ -885,32 +894,132 @@ export class TestResultsComponent implements OnInit, OnDestroy { codeSelectedPersons(): void { this.isLoading = true; const selectedTestPersons = this.selection.selected; + const loadingSnackBar = this.snackBar.open( + 'Starte Kodierung...', + '', + { duration: 3000 } + ); + this.backendService.codeTestPersons( this.appService.selectedWorkspaceId, selectedTestPersons.map(person => person.id) - ).subscribe(respOk => { - if (respOk) { - this.snackBar.open( - this.translateService.instant('ws-admin.test-group-coded'), - '', - { duration: 1000 } - ); - this.createTestResultsList(this.pageIndex, this.pageSize, this.getCurrentSearchText()); - } else { + ).subscribe({ + next: result => { + loadingSnackBar.dismiss(); + this.isLoading = false; + this.selection.clear(); + + if (result.jobId) { + this.snackBar.open( + `Kodierung gestartet (Job ID: ${result.jobId}). Sie werden benachrichtigt, wenn die Kodierung abgeschlossen ist.`, + 'OK', + { duration: 5000 } + ); + + this.pollCodingJobStatus(result.jobId); + } else if (result.totalResponses > 0) { // Handle synchronous result (backward compatibility) + this.snackBar.open( + this.translateService.instant('ws-admin.test-group-coded'), + '', + { duration: 1000 } + ); + this.createTestResultsList(this.pageIndex, this.pageSize, this.getCurrentSearchText()); + } else { + this.snackBar.open( + this.translateService.instant('ws-admin.test-group-not-coded'), + this.translateService.instant('error'), + { duration: 1000 } + ); + } + }, + error: () => { + loadingSnackBar.dismiss(); + this.isLoading = false; + this.selection.clear(); + this.snackBar.open( - this.translateService.instant('ws-admin.test-group-not-coded'), - this.translateService.instant('error'), - { duration: 1000 } + 'Fehler beim Starten der Kodierung', + 'Fehler', + { duration: 3000 } ); } - this.isLoading = false; - this.selection.clear(); }); } - /** - * Opens a dialog to search for units by name across all test persons - */ + private pollCodingJobStatus(jobId: string): void { + const pollingInterval = 5000; + + // Set up a timer to check job status + const timer = setInterval(() => { + this.backendService.getCodingJobStatus( + this.appService.selectedWorkspaceId, + jobId + ).subscribe({ + next: job => { + // Check if the job is completed or failed + if (job.status === 'completed') { + // Stop polling + clearInterval(timer); + + // Show success notification + const snackBarRef = this.snackBar.open( + 'Kodierung abgeschlossen', + 'Ergebnisse anzeigen', + { duration: 10000 } + ); + + // Handle click on action button + snackBarRef.onAction().subscribe(() => { + this.showCodingResults(job.result); + this.createTestResultsList(this.pageIndex, this.pageSize, this.getCurrentSearchText()); + }); + } else if (job.status === 'failed') { + // Stop polling + clearInterval(timer); + + this.snackBar.open( + `Fehler bei der Kodierung: ${job.error || 'Unbekannter Fehler'}`, + 'Fehler', + { duration: 5000 } + ); + } + // If status is 'pending' or 'processing', continue polling + }, + error: () => { + // Stop polling on error + clearInterval(timer); + + this.snackBar.open( + 'Fehler beim Abrufen des Kodierungs-Status', + 'Fehler', + { duration: 3000 } + ); + } + }); + }, pollingInterval); + } + + private showCodingResults(result?: { totalResponses: number; statusCounts: { [key: string]: number } }): void { + if (!result) { + this.snackBar.open( + 'Keine Kodierungsergebnisse verfügbar', + 'Info', + { duration: 3000 } + ); + return; + } + + const statusMessages = Object.entries(result.statusCounts) + .map(([status, count]) => `${status}: ${count}`) + .join(', '); + + this.snackBar.open( + `Kodierung abgeschlossen: ${result.totalResponses} Antworten verarbeitet (${statusMessages})`, + 'OK', + { duration: 5000 } + ); + } + openUnitSearchDialog(): void { this.dialog.open(UnitSearchDialogComponent, { width: '1200px', @@ -920,11 +1029,6 @@ export class TestResultsComponent implements OnInit, OnDestroy { }); } - /** - * Deletes a unit after confirmation - * @param unit The unit to delete - * @param booklet The booklet containing the unit - */ deleteUnit(unit: Unit, booklet: Booklet): void { if (!unit.id) { this.snackBar.open( @@ -991,10 +1095,6 @@ export class TestResultsComponent implements OnInit, OnDestroy { }); } - /** - * Deletes a response after confirmation - * @param response The response to delete - */ deleteResponse(response: Response): void { if (!response.id) { this.snackBar.open( @@ -1066,4 +1166,40 @@ export class TestResultsComponent implements OnInit, OnDestroy { } }); } + + openVariableAnalysisDialog(): void { + const loadingSnackBar = this.snackBar.open( + 'Lade Analyse-Aufträge...', + '', + { duration: 3000 } + ); + + this.backendService.getAllVariableAnalysisJobs( + this.appService.selectedWorkspaceId + ).subscribe({ + next: jobs => { + loadingSnackBar.dismiss(); + + const variableAnalysisJobs = jobs.filter(job => job.type === 'variable-analysis'); + + this.dialog.open(VariableAnalysisDialogComponent, { + width: '900px', + data: { + unitId: this.selectedUnit?.id, // Optional unit ID, may be undefined + title: 'Item/Variablen Analyse', + workspaceId: this.appService.selectedWorkspaceId, + jobs: variableAnalysisJobs + } + }); + }, + error: () => { + loadingSnackBar.dismiss(); + this.snackBar.open( + 'Fehler beim Laden der Analyse-Aufträge', + 'Fehler', + { duration: 3000 } + ); + } + }); + } } diff --git a/apps/frontend/src/app/ws-admin/components/validation-dialog/validation-dialog.component.ts b/apps/frontend/src/app/ws-admin/components/validation-dialog/validation-dialog.component.ts index dcb934a54..2cdda90b0 100644 --- a/apps/frontend/src/app/ws-admin/components/validation-dialog/validation-dialog.component.ts +++ b/apps/frontend/src/app/ws-admin/components/validation-dialog/validation-dialog.component.ts @@ -337,7 +337,7 @@ export class ValidationDialogComponent implements AfterViewInit, OnInit { } deleteAllResponses(): void { - if (this.invalidVariables.length === 0) { + if (this.totalInvalidVariables === 0) { this.snackBar.open('Keine ungültigen Variablen vorhanden', 'Schließen', { duration: 3000 }); return; } @@ -356,11 +356,8 @@ export class ValidationDialogComponent implements AfterViewInit, OnInit { dialogRef.afterClosed().subscribe(deleteFromDb => { if (deleteFromDb) { this.isDeletingResponses = true; - const responseIds = this.invalidVariables - .filter(variable => variable.responseId !== undefined) - .map(variable => variable.responseId as number); - this.backendService.deleteInvalidResponses(this.appService.selectedWorkspaceId, responseIds) + this.backendService.deleteAllInvalidResponses(this.appService.selectedWorkspaceId, 'variables') .subscribe(deletedCount => { this.isDeletingResponses = false; this.snackBar.open(`${deletedCount} Antworten gelöscht`, 'Schließen', { duration: 3000 }); @@ -476,7 +473,7 @@ export class ValidationDialogComponent implements AfterViewInit, OnInit { } deleteAllTypeResponses(): void { - if (this.invalidTypeVariables.length === 0) { + if (this.totalInvalidTypeVariables === 0) { this.snackBar.open('Keine ungültigen Variablentypen vorhanden', 'Schließen', { duration: 3000 }); return; } @@ -495,11 +492,8 @@ export class ValidationDialogComponent implements AfterViewInit, OnInit { dialogRef.afterClosed().subscribe(deleteFromDb => { if (deleteFromDb) { this.isDeletingResponses = true; - const responseIds = this.invalidTypeVariables - .filter(variable => variable.responseId !== undefined) - .map(variable => variable.responseId as number); - this.backendService.deleteInvalidResponses(this.appService.selectedWorkspaceId, responseIds) + this.backendService.deleteAllInvalidResponses(this.appService.selectedWorkspaceId, 'variableTypes') .subscribe(deletedCount => { this.isDeletingResponses = false; this.snackBar.open(`${deletedCount} Antworten gelöscht`, 'Schließen', { duration: 3000 }); @@ -561,7 +555,7 @@ export class ValidationDialogComponent implements AfterViewInit, OnInit { } deleteAllStatusResponses(): void { - if (this.invalidStatusVariables.length === 0) { + if (this.totalInvalidStatusVariables === 0) { this.snackBar.open('Keine ungültigen Antwortstatus vorhanden', 'Schließen', { duration: 3000 }); return; } @@ -580,11 +574,8 @@ export class ValidationDialogComponent implements AfterViewInit, OnInit { dialogRef.afterClosed().subscribe(deleteFromDb => { if (deleteFromDb) { this.isDeletingResponses = true; - const responseIds = this.invalidStatusVariables - .filter(variable => variable.responseId !== undefined) - .map(variable => variable.responseId as number); - this.backendService.deleteInvalidResponses(this.appService.selectedWorkspaceId, responseIds) + this.backendService.deleteAllInvalidResponses(this.appService.selectedWorkspaceId, 'responseStatus') .subscribe(deletedCount => { this.isDeletingResponses = false; this.snackBar.open(`${deletedCount} Antworten gelöscht`, 'Schließen', { duration: 3000 }); diff --git a/apps/frontend/src/app/ws-admin/components/variable-analysis-dialog/variable-analysis-dialog.component.html b/apps/frontend/src/app/ws-admin/components/variable-analysis-dialog/variable-analysis-dialog.component.html new file mode 100644 index 000000000..9d3cedb88 --- /dev/null +++ b/apps/frontend/src/app/ws-admin/components/variable-analysis-dialog/variable-analysis-dialog.component.html @@ -0,0 +1,192 @@ +
+

{{ data.title }}

+ + + + + +
+ @if (isLoading) { +
+ +

Analysiere Variablen...

+
+ } @else { + +
+ + Variablen suchen + + search + @if (searchText) { + + } + +
+ + @if (variableCombos.length === 0) { +
+ info +

Keine Variablen gefunden.

+
+ } @else { +
+
+ info + Es werden maximal die {{ MAX_VALUES_PER_VARIABLE }} häufigsten Werte pro Variable angezeigt. +
+ + @for (combo of variableCombos; track combo) { +
+

{{ combo.unitName }}: {{ combo.variableId }}

+ +
+ + + + + + + + + + + + + + + + + + + + + +
Wert +
+ @if (item.value === '') { + [Leer] + } @else { + {{ item.value | slice:0:100 }}{{ item.value.length > 100 ? '...' : '' }} + } +
+
Anzahl{{ item.count }}Prozent{{ item.percentage | number:'1.1-1' }}%
+
+
+ } + + + +
+ } + } +
+
+ + + +
+
+ + +
+ + @if (isJobsLoading) { +
+ +

Lade Aufträge...

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

Keine Variablen-Analyse Aufträge gefunden.

+
+ } @else { +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ID{{ job.id }}Status + {{ job.status }} + Erstellt am{{ formatDate(job.created_at) }}Unit ID{{ job.unit_id || 'Alle' }}Variable ID{{ job.variable_id || 'Alle' }}Aktionen + + @if (job.status === 'completed') { + + } + + + @if (job.status === 'pending' || job.status === 'processing') { + + } + + + @if (job.status === 'failed') { + + } +
+
+ } +
+
+
+ +
+ +
+
diff --git a/apps/frontend/src/app/ws-admin/components/variable-analysis-dialog/variable-analysis-dialog.component.scss b/apps/frontend/src/app/ws-admin/components/variable-analysis-dialog/variable-analysis-dialog.component.scss new file mode 100644 index 000000000..b256a7832 --- /dev/null +++ b/apps/frontend/src/app/ws-admin/components/variable-analysis-dialog/variable-analysis-dialog.component.scss @@ -0,0 +1,142 @@ +.dialog-container { + display: flex; + flex-direction: column; + padding: 16px; + max-height: 80vh; + min-width: 600px; +} + +.dialog-title { + margin-top: 0; + margin-bottom: 16px; + font-size: 20px; + font-weight: 500; +} + +.dialog-content { + flex: 1; + overflow-y: auto; + margin: 16px 0; +} + +.loading-container { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 32px; + + p { + margin-top: 16px; + font-size: 16px; + } +} + +.empty-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 32px; + + .empty-icon { + font-size: 48px; + height: 48px; + width: 48px; + margin-bottom: 16px; + color: #888; + } + + p { + font-size: 16px; + color: #666; + } +} + +.search-container { + margin-bottom: 16px; + + .search-field { + width: 100%; + } +} + +.variables-container { + display: flex; + flex-direction: column; + gap: 24px; + + .info-message { + display: flex; + align-items: center; + background-color: #e3f2fd; + padding: 8px 16px; + border-radius: 4px; + margin-bottom: 16px; + + mat-icon { + color: #2196f3; + margin-right: 8px; + } + + span { + font-size: 14px; + color: #333; + } + } + + mat-paginator { + margin-top: 16px; + } +} + +.variable-section { + background-color: #f5f5f5; + border-radius: 4px; + padding: 16px; + + .variable-title { + margin-top: 0; + margin-bottom: 16px; + font-size: 18px; + font-weight: 500; + color: #333; + } +} + +.table-container { + overflow-x: auto; + + .frequency-table { + width: 100%; + + .mat-mdc-header-cell { + font-weight: 500; + color: #333; + } + + .mat-mdc-row:nth-child(even) { + background-color: rgba(0, 0, 0, 0.02); + } + + .mat-mdc-row:hover { + background-color: rgba(0, 0, 0, 0.04); + } + } +} + +.value-cell { + max-width: 400px; + word-break: break-word; + + .empty-value { + font-style: italic; + color: #888; + } +} + +.dialog-actions { + display: flex; + justify-content: flex-end; + margin-top: 16px; +} diff --git a/apps/frontend/src/app/ws-admin/components/variable-analysis-dialog/variable-analysis-dialog.component.ts b/apps/frontend/src/app/ws-admin/components/variable-analysis-dialog/variable-analysis-dialog.component.ts new file mode 100644 index 000000000..7ed2fe63d --- /dev/null +++ b/apps/frontend/src/app/ws-admin/components/variable-analysis-dialog/variable-analysis-dialog.component.ts @@ -0,0 +1,383 @@ +import { Component, Inject, OnInit } from '@angular/core'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; +import { CommonModule } from '@angular/common'; +import { MatButtonModule } from '@angular/material/button'; +import { MatIconModule } from '@angular/material/icon'; +import { MatDividerModule } from '@angular/material/divider'; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; +import { MatTableModule } from '@angular/material/table'; +import { MatSortModule } from '@angular/material/sort'; +import { MatPaginatorModule, PageEvent } from '@angular/material/paginator'; +import { MatInputModule } from '@angular/material/input'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { FormsModule } from '@angular/forms'; +import { Subject } from 'rxjs'; +import { debounceTime, distinctUntilChanged } from 'rxjs/operators'; +import { MatTabsModule } from '@angular/material/tabs'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { BackendService } from '../../../services/backend.service'; +import { VariableAnalysisJobDto } from '../../../models/variable-analysis-job.dto'; + +export interface VariableAnalysisData { + unitId: number; + title: string; + workspaceId: number; + responses?: { + id: number; + unitid: number; + variableid: string; + status: string; + value: string; + subform: string; + code?: number; + score?: number; + codedstatus?: string; + expanded?: boolean; + }[]; + analysisResults?: { + variableCombos: { + unitName: string; + variableId: string; + }[]; + frequencies: { [key: string]: { + unitName?: string; + variableId: string; + value: string; + count: number; + percentage: number; + }[] }; + total: number; + }; + jobs?: VariableAnalysisJobDto[]; +} + +export interface VariableFrequency { + unitName?: string; + variableid: string; + value: string; + count: number; + percentage: number; +} + +export interface VariableCombo { + unitName: string; + variableId: string; +} + +@Component({ + selector: 'coding-box-variable-analysis-dialog', + templateUrl: './variable-analysis-dialog.component.html', + styleUrls: ['./variable-analysis-dialog.component.scss'], + standalone: true, + imports: [ + CommonModule, + FormsModule, + MatButtonModule, + MatIconModule, + MatDividerModule, + MatProgressSpinnerModule, + MatTableModule, + MatSortModule, + MatPaginatorModule, + MatInputModule, + MatFormFieldModule, + MatTabsModule, + MatTooltipModule + ] +}) +export class VariableAnalysisDialogComponent implements OnInit { + isLoading = false; + variableFrequencies: { [key: string]: VariableFrequency[] } = {}; + displayedColumns: string[] = ['value', 'count', 'percentage']; + + allVariableCombos: VariableCombo[] = []; + + // Filtered and paginated variable combinations + variableCombos: VariableCombo[] = []; + + searchText = ''; + private searchSubject = new Subject(); + currentPage = 0; + pageSize = 10; + pageSizeOptions = [5, 10, 25, 50]; + + readonly MAX_VALUES_PER_VARIABLE = 20; + + isJobsLoading = false; + jobs: VariableAnalysisJobDto[] = []; + jobsDisplayedColumns: string[] = ['id', 'status', 'createdAt', 'unitId', 'variableId', 'actions']; + + constructor( + public dialogRef: MatDialogRef, + @Inject(MAT_DIALOG_DATA) public data: VariableAnalysisData, + private backendService: BackendService, + private snackBar: MatSnackBar + ) {} + + ngOnInit(): void { + this.searchSubject.pipe( + debounceTime(300), + distinctUntilChanged() + ).subscribe(searchText => { + this.searchText = searchText; + this.filterVariables(); + }); + + this.analyzeVariables(); + + if (this.data.jobs) { + this.jobs = this.data.jobs; + } else { + this.refreshJobs(); + } + } + + analyzeVariables(): void { + this.isLoading = true; + + // Check if we have pre-calculated analysis results + if (this.data.analysisResults) { + // Use the pre-calculated results + this.allVariableCombos = this.data.analysisResults!.variableCombos; + + // Convert the frequencies to our internal format + Object.keys(this.data.analysisResults!.frequencies).forEach(comboKey => { + // Get the first frequency item to extract unitName and variableId + const firstFreq = this.data.analysisResults!.frequencies[comboKey][0]; + if (firstFreq) { + // Create a key that matches what we use in the template + const newComboKey = `${firstFreq.unitName || 'Unknown'}:${firstFreq.variableId}`; + + this.variableFrequencies[newComboKey] = this.data.analysisResults!.frequencies[comboKey].map(freq => ({ + unitName: freq.unitName, + variableid: freq.variableId, + value: freq.value, + count: freq.count, + percentage: freq.percentage + })); + } + }); + } else if (this.data.responses && this.data.responses.length > 0) { + // Fall back to the old behavior of analyzing responses + // Group responses by variableid (without unitName since we don't have that info) + const responsesByVariable: { [key: string]: { [key: string]: number } } = {}; + + // Initialize the variables array with just variableId (no unitName) + const variableIds = Array.from(new Set(this.data.responses.map(r => r.variableid))); + this.allVariableCombos = variableIds.map(variableId => ({ + unitName: 'Unknown', // We don't have unitName in the old format + variableId + })); + + // Count occurrences of each value for each variable + this.data.responses.forEach(response => { + if (!responsesByVariable[response.variableid]) { + responsesByVariable[response.variableid] = {}; + } + + const value = response.value || ''; + if (!responsesByVariable[response.variableid][value]) { + responsesByVariable[response.variableid][value] = 0; + } + + responsesByVariable[response.variableid][value] += 1; + }); + + // Calculate frequencies and percentages + Object.keys(responsesByVariable).forEach(variableid => { + const valueMap = responsesByVariable[variableid]; + const totalResponses = Object.values(valueMap).reduce((sum, count) => sum + count, 0); + + // Create a key that matches what we use in the template + const comboKey = `Unknown:${variableid}`; + + // Sort by count in descending order and limit to MAX_VALUES_PER_VARIABLE + this.variableFrequencies[comboKey] = Object.keys(valueMap) + .map(value => { + const count = valueMap[value]; + return { + unitName: 'Unknown', + variableid, + value, + count, + percentage: (count / totalResponses) * 100 + }; + }) + .sort((a, b) => b.count - a.count) + .slice(0, this.MAX_VALUES_PER_VARIABLE); // Limit the number of values shown + }); + } else { + this.allVariableCombos = []; + } + + // Sort by unitName and then by variableId + this.allVariableCombos.sort((a, b) => { + if (a.unitName !== b.unitName) { + return a.unitName.localeCompare(b.unitName); + } + return a.variableId.localeCompare(b.variableId); + }); + + // Initialize the filtered and paginated variables + this.filterVariables(); + + this.isLoading = false; + } + + filterVariables(): void { + const filteredCombos = this.searchText ? + this.allVariableCombos.filter(combo => combo.unitName.toLowerCase().includes(this.searchText.toLowerCase()) || + combo.variableId.toLowerCase().includes(this.searchText.toLowerCase())) : + this.allVariableCombos; + + const startIndex = this.currentPage * this.pageSize; + const endIndex = startIndex + this.pageSize; + this.variableCombos = filteredCombos.slice(startIndex, endIndex); + } + + onSearchChange(event: Event): void { + const value = (event.target as HTMLInputElement).value; + this.searchSubject.next(value); + } + + onPageChange(event: PageEvent): void { + this.currentPage = event.pageIndex; + this.pageSize = event.pageSize; + this.filterVariables(); + } + + getTotalFilteredVariables(): number { + return this.searchText ? + this.allVariableCombos.filter(combo => combo.unitName.toLowerCase().includes(this.searchText.toLowerCase()) || + combo.variableId.toLowerCase().includes(this.searchText.toLowerCase())).length : + this.allVariableCombos.length; + } + + onClose(): void { + this.dialogRef.close(); + } + + refreshJobs(): void { + this.isJobsLoading = true; + this.backendService.getAllVariableAnalysisJobs(this.data.workspaceId) + .subscribe({ + next: jobs => { + this.jobs = jobs.filter(job => job.type === 'variable-analysis'); + this.isJobsLoading = false; + }, + error: () => { + this.snackBar.open( + 'Fehler beim Laden der Analyse-Aufträge', + 'Fehler', + { duration: 3000 } + ); + this.isJobsLoading = false; + } + }); + } + + startNewAnalysis(): void { + this.isJobsLoading = true; + const loadingSnackBar = this.snackBar.open( + 'Starte Analyse...', + '', + { duration: 3000 } + ); + + this.backendService.createVariableAnalysisJob( + this.data.workspaceId, + this.data.unitId // Optional unit ID, may be undefined + ).subscribe({ + next: job => { + loadingSnackBar.dismiss(); + this.snackBar.open( + `Analyse gestartet (Job ID: ${job.id}). Sie werden benachrichtigt, wenn die Analyse abgeschlossen ist.`, + 'OK', + { duration: 5000 } + ); + this.refreshJobs(); + }, + error: () => { + loadingSnackBar.dismiss(); + this.snackBar.open( + 'Fehler beim Starten der Analyse', + 'Fehler', + { duration: 3000 } + ); + this.isJobsLoading = false; + } + }); + } + + cancelJob(jobId: number): void { + this.isJobsLoading = true; + this.backendService.cancelVariableAnalysisJob(this.data.workspaceId, jobId) + .subscribe({ + next: result => { + if (result.success) { + this.snackBar.open( + result.message || 'Analyse-Auftrag erfolgreich abgebrochen', + 'OK', + { duration: 3000 } + ); + this.refreshJobs(); + } else { + this.snackBar.open( + result.message || 'Fehler beim Abbrechen des Analyse-Auftrags', + 'Fehler', + { duration: 3000 } + ); + this.isJobsLoading = false; + } + }, + error: () => { + this.snackBar.open( + 'Fehler beim Abbrechen des Analyse-Auftrags', + 'Fehler', + { duration: 3000 } + ); + this.isJobsLoading = false; + } + }); + } + + viewJobResults(jobId: number): void { + this.isLoading = true; + const loadingSnackBar = this.snackBar.open( + 'Lade Analyse-Ergebnisse...', + '', + { duration: undefined } + ); + + this.backendService.getVariableAnalysisResults( + this.data.workspaceId, + jobId + ).subscribe({ + next: results => { + loadingSnackBar.dismiss(); + this.isLoading = false; + + // Update the data with the new results + this.data.analysisResults = results; + + // Re-analyze variables with the new results + this.analyzeVariables(); + }, + error: () => { + loadingSnackBar.dismiss(); + this.isLoading = false; + this.snackBar.open( + 'Fehler beim Laden der Analyse-Ergebnisse', + 'Fehler', + { duration: 3000 } + ); + } + }); + } + + formatDate(date: Date): string { + if (!date) return ''; + return new Date(date).toLocaleString(); + } +} diff --git a/apps/frontend/src/app/ws-admin/components/variable-analysis-jobs-dialog/variable-analysis-jobs-dialog.component.html b/apps/frontend/src/app/ws-admin/components/variable-analysis-jobs-dialog/variable-analysis-jobs-dialog.component.html new file mode 100644 index 000000000..5cfa0d09e --- /dev/null +++ b/apps/frontend/src/app/ws-admin/components/variable-analysis-jobs-dialog/variable-analysis-jobs-dialog.component.html @@ -0,0 +1,95 @@ +
+

Variablen-Analyse Aufträge

+ + +
+
+ +
+ + @if (isLoading) { +
+ +

Lade Aufträge...

+
+ } @else if (data.jobs.length === 0) { +
+ info +

Keine Variablen-Analyse Aufträge gefunden.

+
+ } @else { +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ID{{ job.id }}Status + {{ job.status }} + Erstellt am{{ formatDate(job.created_at) }}Unit ID{{ job.unit_id || 'Alle' }}Variable ID{{ job.variable_id || 'Alle' }}Aktionen + + @if (job.status === 'completed') { + + } + + + @if (job.status === 'pending' || job.status === 'processing') { + + } + + + @if (job.status === 'failed') { + + } +
+
+ } +
+ +
+ +
+
diff --git a/apps/frontend/src/app/ws-admin/components/variable-analysis-jobs-dialog/variable-analysis-jobs-dialog.component.scss b/apps/frontend/src/app/ws-admin/components/variable-analysis-jobs-dialog/variable-analysis-jobs-dialog.component.scss new file mode 100644 index 000000000..7a6fc08ff --- /dev/null +++ b/apps/frontend/src/app/ws-admin/components/variable-analysis-jobs-dialog/variable-analysis-jobs-dialog.component.scss @@ -0,0 +1,96 @@ +.dialog-container { + display: flex; + flex-direction: column; + min-height: 400px; + max-height: 80vh; +} + +.dialog-title { + margin: 0; + padding: 16px; + font-size: 20px; + font-weight: 500; +} + +.dialog-content { + flex: 1; + padding: 16px; + overflow-y: auto; +} + +.dialog-actions { + display: flex; + justify-content: flex-end; + padding: 8px 16px; +} + +.actions-container { + display: flex; + justify-content: flex-end; + margin-bottom: 16px; +} + +.loading-container { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 32px; +} + +.empty-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 32px; + color: #666; +} + +.empty-icon { + font-size: 48px; + width: 48px; + height: 48px; + margin-bottom: 16px; +} + +.table-container { + overflow-x: auto; +} + +.jobs-table { + width: 100%; +} + +.status-badge { + padding: 4px 8px; + border-radius: 4px; + font-size: 12px; + font-weight: 500; + text-transform: uppercase; +} + +.status-pending { + background-color: #e3f2fd; + color: #0d47a1; +} + +.status-processing { + background-color: #e8f5e9; + color: #1b5e20; +} + +.status-completed { + background-color: #e8f5e9; + color: #1b5e20; +} + +.status-failed { + background-color: #ffebee; + color: #b71c1c; +} + +.status-cancelled { + background-color: #fafafa; + color: #616161; +} diff --git a/apps/frontend/src/app/ws-admin/components/variable-analysis-jobs-dialog/variable-analysis-jobs-dialog.component.ts b/apps/frontend/src/app/ws-admin/components/variable-analysis-jobs-dialog/variable-analysis-jobs-dialog.component.ts new file mode 100644 index 000000000..0de9840a7 --- /dev/null +++ b/apps/frontend/src/app/ws-admin/components/variable-analysis-jobs-dialog/variable-analysis-jobs-dialog.component.ts @@ -0,0 +1,122 @@ +import { Component, Inject, OnInit } from '@angular/core'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; +import { CommonModule } from '@angular/common'; +import { MatButtonModule } from '@angular/material/button'; +import { MatIconModule } from '@angular/material/icon'; +import { MatDividerModule } from '@angular/material/divider'; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; +import { MatTableModule } from '@angular/material/table'; +import { MatSortModule } from '@angular/material/sort'; +import { MatPaginatorModule } from '@angular/material/paginator'; +import { MatInputModule } from '@angular/material/input'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import { FormsModule } from '@angular/forms'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { VariableAnalysisJobDto } from '../../../models/variable-analysis-job.dto'; +import { BackendService } from '../../../services/backend.service'; + +export interface VariableAnalysisJobsDialogData { + jobs: VariableAnalysisJobDto[]; + workspaceId: number; +} + +@Component({ + selector: 'coding-box-variable-analysis-jobs-dialog', + templateUrl: './variable-analysis-jobs-dialog.component.html', + styleUrls: ['./variable-analysis-jobs-dialog.component.scss'], + standalone: true, + imports: [ + CommonModule, + FormsModule, + MatButtonModule, + MatIconModule, + MatDividerModule, + MatProgressSpinnerModule, + MatTableModule, + MatSortModule, + MatPaginatorModule, + MatInputModule, + MatFormFieldModule, + MatTooltipModule + ] +}) +export class VariableAnalysisJobsDialogComponent implements OnInit { + displayedColumns: string[] = ['id', 'status', 'createdAt', 'unitId', 'variableId', 'actions']; + isLoading = false; + + constructor( + public dialogRef: MatDialogRef, + @Inject(MAT_DIALOG_DATA) public data: VariableAnalysisJobsDialogData, + private backendService: BackendService, + private snackBar: MatSnackBar + ) {} + + ngOnInit(): void { + this.refreshJobs(); + } + + refreshJobs(): void { + this.isLoading = true; + this.backendService.getAllVariableAnalysisJobs(this.data.workspaceId) + .subscribe({ + next: jobs => { + this.data.jobs = jobs.filter(job => job.type === 'variable-analysis'); + this.isLoading = false; + }, + error: () => { + this.snackBar.open( + 'Fehler beim Laden der Analyse-Aufträge', + 'Fehler', + { duration: 3000 } + ); + this.isLoading = false; + } + }); + } + + cancelJob(jobId: number): void { + this.isLoading = true; + this.backendService.cancelVariableAnalysisJob(this.data.workspaceId, jobId) + .subscribe({ + next: result => { + if (result.success) { + this.snackBar.open( + result.message || 'Analyse-Auftrag erfolgreich abgebrochen', + 'OK', + { duration: 3000 } + ); + this.refreshJobs(); + } else { + this.snackBar.open( + result.message || 'Fehler beim Abbrechen des Analyse-Auftrags', + 'Fehler', + { duration: 3000 } + ); + this.isLoading = false; + } + }, + error: () => { + this.snackBar.open( + 'Fehler beim Abbrechen des Analyse-Auftrags', + 'Fehler', + { duration: 3000 } + ); + this.isLoading = false; + } + }); + } + + viewResults(jobId: number): void { + this.dialogRef.close({ jobId }); + } + + onClose(): void { + this.dialogRef.close(); + } + + formatDate(date: Date): string { + if (!date) return ''; + return new Date(date).toLocaleString(); + } +} diff --git a/database/changelog/coding-box.changelog-0.9.1.sql b/database/changelog/coding-box.changelog-0.9.1.sql new file mode 100644 index 000000000..f405def74 --- /dev/null +++ b/database/changelog/coding-box.changelog-0.9.1.sql @@ -0,0 +1,77 @@ +-- liquibase formatted sql + +-- changeset jurei733:1 +CREATE TABLE "public"."variable_analysis_job" ( + "id" SERIAL PRIMARY KEY, + "workspace_id" INTEGER NOT NULL, + "unit_id" INTEGER, + "variable_id" VARCHAR(255), + "status" VARCHAR(50) NOT NULL, + "error" TEXT, + "result" TEXT, + "created_at" TIMESTAMP NOT NULL DEFAULT NOW(), + "updated_at" TIMESTAMP NOT NULL DEFAULT NOW() +); + +CREATE INDEX "idx_variable_analysis_job_workspace_id" ON "public"."variable_analysis_job" ("workspace_id"); +CREATE INDEX "idx_variable_analysis_job_status" ON "public"."variable_analysis_job" ("status"); + +-- rollback DROP TABLE IF EXISTS "public"."variable_analysis_job"; + +-- changeset jurei733:2 +CREATE TABLE "public"."test_person_coding_job" ( + "id" SERIAL PRIMARY KEY, + "workspace_id" INTEGER NOT NULL, + "person_ids" TEXT, + "status" VARCHAR(50) NOT NULL, + "progress" INTEGER, + "error" TEXT, + "result" TEXT, + "created_at" TIMESTAMP NOT NULL DEFAULT NOW(), + "updated_at" TIMESTAMP NOT NULL DEFAULT NOW() +); + +CREATE INDEX "idx_test_person_coding_job_workspace_id" ON "public"."test_person_coding_job" ("workspace_id"); +CREATE INDEX "idx_test_person_coding_job_status" ON "public"."test_person_coding_job" ("status"); + +-- rollback DROP TABLE IF EXISTS "public"."test_person_coding_job"; + +-- changeset jurei733:3 +CREATE TABLE "public"."job" ( + "id" SERIAL PRIMARY KEY, + "workspace_id" INTEGER NOT NULL, + "type" VARCHAR(50) NOT NULL, + "status" VARCHAR(50) NOT NULL, + "progress" INTEGER, + "error" TEXT, + "result" TEXT, + "created_at" TIMESTAMP NOT NULL DEFAULT NOW(), + "updated_at" TIMESTAMP NOT NULL DEFAULT NOW() +); + +CREATE INDEX "idx_job_workspace_id" ON "public"."job" ("workspace_id"); +CREATE INDEX "idx_job_type" ON "public"."job" ("type"); +CREATE INDEX "idx_job_status" ON "public"."job" ("status"); +-- rollback DROP TABLE IF EXISTS "public"."job"; + +-- changeset jurei733:4 + +ALTER TABLE "public"."job" ADD COLUMN "unit_id" INTEGER; +ALTER TABLE "public"."job" ADD COLUMN "variable_id" VARCHAR(255); +-- rollback ALTER TABLE "public"."job" DROP COLUMN "unit_id"; ALTER TABLE "public"."job" DROP COLUMN "variable_id"; + +-- changeset jurei733:5 + +ALTER TABLE "public"."job" ADD COLUMN "person_ids" TEXT; +-- rollback ALTER TABLE "public"."job" DROP COLUMN "person_ids"; + +-- changeset jurei733:6 + +ALTER TABLE "public"."job" ADD COLUMN "group_names" TEXT; +-- rollback ALTER TABLE "public"."job" DROP COLUMN "group_names"; + +-- changeset jurei733:7 + +ALTER TABLE "public"."job" ADD COLUMN "duration_ms" BIGINT; +-- rollback ALTER TABLE "public"."job" DROP COLUMN "duration_ms"; + diff --git a/database/changelog/coding-box.changelog-root.xml b/database/changelog/coding-box.changelog-root.xml index 83064345e..94b76ef44 100644 --- a/database/changelog/coding-box.changelog-root.xml +++ b/database/changelog/coding-box.changelog-root.xml @@ -15,4 +15,5 @@ + diff --git a/package-lock.json b/package-lock.json index 8ccfee75e..7622ad095 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "coding-box", - "version": "0.9.0", + "version": "0.9.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "coding-box", - "version": "0.9.0", + "version": "0.9.1", "license": "MIT", "dependencies": { "@angular/animations": "20.0.3", diff --git a/package.json b/package.json index 1fc832732..2d47e689f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "coding-box", - "version": "0.9.0", + "version": "0.9.1", "author": "IQB - Institut zur Qualitätsentwicklung im Bildungswesen", "license": "MIT", "scripts": {