diff --git a/.gitignore b/.gitignore index 4955598ee..86dc633af 100755 --- a/.gitignore +++ b/.gitignore @@ -68,3 +68,5 @@ testem.log /typings /packages +.cursor/rules/nx-rules.mdc +.github/instructions/nx.instructions.md diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 2334dc07c..87e7d4912 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -36,7 +36,7 @@ variables: DOCKER_DAEMON_OPTIONS: "--mtu=${DOCKER_SERVICE_MTU}" DOCKER_IMAGE: ${DOCKER_HUB_PROXY}docker:27.0 DOCKER_SERVICE: ${DOCKER_HUB_PROXY}docker:27.0-dind - DOCKER_SERVICE_MTU: 1442 + DOCKER_SERVICE_MTU: 1392 DOCKER_TLS_CERTDIR: '' TRIVY_IMAGE: ${DOCKER_HUB_PROXY}aquasec/trivy:latest diff --git a/.gitlab-ci/Branch&PreRelease-Pipelines.gitlab-ci.yml b/.gitlab-ci/Branch&PreRelease-Pipelines.gitlab-ci.yml index f89ee1a1b..e4f2bcda7 100644 --- a/.gitlab-ci/Branch&PreRelease-Pipelines.gitlab-ci.yml +++ b/.gitlab-ci/Branch&PreRelease-Pipelines.gitlab-ci.yml @@ -233,6 +233,8 @@ build-cicd-base-image: image: $DOCKER_IMAGE services: - name: $DOCKER_SERVICE + variables: + HEALTHCHECK_TCP_PORT: "2375" entrypoint: [ "sh", "-c", "dockerd-entrypoint.sh $DOCKER_DAEMON_OPTIONS" ] alias: docker before_script: @@ -269,6 +271,8 @@ build-cicd-base-image: # image: $DOCKER_IMAGE # services: # - name: $DOCKER_SERVICE +# variables: +# HEALTHCHECK_TCP_PORT: "2375" # entrypoint: [ "sh", "-c", "dockerd-entrypoint.sh $DOCKER_DAEMON_OPTIONS" ] # alias: docker # before_script: @@ -311,6 +315,8 @@ build-db-image: image: $DOCKER_IMAGE services: - name: $DOCKER_SERVICE + variables: + HEALTHCHECK_TCP_PORT: "2375" entrypoint: [ "sh", "-c", "dockerd-entrypoint.sh $DOCKER_DAEMON_OPTIONS" ] alias: docker variables: @@ -344,6 +350,8 @@ build-liquibase-image: image: $DOCKER_IMAGE services: - name: $DOCKER_SERVICE + variables: + HEALTHCHECK_TCP_PORT: "2375" entrypoint: [ "sh", "-c", "dockerd-entrypoint.sh $DOCKER_DAEMON_OPTIONS" ] alias: docker variables: @@ -393,6 +401,8 @@ test-db: image: $DOCKER_IMAGE services: - name: $DOCKER_SERVICE + variables: + HEALTHCHECK_TCP_PORT: "2375" entrypoint: [ "sh", "-c", "dockerd-entrypoint.sh $DOCKER_DAEMON_OPTIONS" ] alias: docker before_script: @@ -494,6 +504,8 @@ test-app: # image: ${CI_REGISTRY_IMAGE}/${E2E_BASE_IMAGE} # services: # - name: $DOCKER_SERVICE +# variables: +# HEALTHCHECK_TCP_PORT: "2375" # entrypoint: [ "sh", "-c", "dockerd-entrypoint.sh $DOCKER_DAEMON_OPTIONS" ] # alias: docker # before_script: @@ -536,6 +548,8 @@ test-app: # image: ${CI_REGISTRY_IMAGE}/${E2E_BASE_IMAGE} # services: # - name: $DOCKER_SERVICE +# variables: +# HEALTHCHECK_TCP_PORT: "2375" # entrypoint: [ "sh", "-c", "dockerd-entrypoint.sh $DOCKER_DAEMON_OPTIONS" ] # alias: docker # before_script: @@ -571,6 +585,8 @@ test-app: # image: ${CI_REGISTRY_IMAGE}/${E2E_BASE_IMAGE} # services: # - name: $DOCKER_SERVICE +# variables: +# HEALTHCHECK_TCP_PORT: "2375" # entrypoint: [ "sh", "-c", "dockerd-entrypoint.sh $DOCKER_DAEMON_OPTIONS" ] # alias: docker # before_script: @@ -606,6 +622,8 @@ test-app: # image: ${CI_REGISTRY_IMAGE}/${E2E_BASE_IMAGE} # services: # - name: $DOCKER_SERVICE +# variables: +# HEALTHCHECK_TCP_PORT: "2375" # entrypoint: [ "sh", "-c", "dockerd-entrypoint.sh $DOCKER_DAEMON_OPTIONS" ] # alias: docker # before_script: @@ -642,6 +660,8 @@ test-app: # image: ${CI_REGISTRY_IMAGE}/${E2E_BASE_IMAGE} # services: # - name: $DOCKER_SERVICE +# variables: +# HEALTHCHECK_TCP_PORT: "2375" # entrypoint: [ "sh", "-c", "dockerd-entrypoint.sh $DOCKER_DAEMON_OPTIONS" ] # alias: docker # before_script: @@ -677,6 +697,8 @@ test-app: # image: ${CI_REGISTRY_IMAGE}/${E2E_BASE_IMAGE} # services: # - name: $DOCKER_SERVICE +# variables: +# HEALTHCHECK_TCP_PORT: "2375" # entrypoint: [ "sh", "-c", "dockerd-entrypoint.sh $DOCKER_DAEMON_OPTIONS" ] # alias: docker # before_script: @@ -713,6 +735,8 @@ test-app: # image: ${CI_REGISTRY_IMAGE}/${E2E_BASE_IMAGE} # services: # - name: $DOCKER_SERVICE +# variables: +# HEALTHCHECK_TCP_PORT: "2375" # entrypoint: [ "sh", "-c", "dockerd-entrypoint.sh $DOCKER_DAEMON_OPTIONS" ] # alias: docker # before_script: @@ -748,6 +772,8 @@ test-app: # image: ${CI_REGISTRY_IMAGE}/${E2E_BASE_IMAGE} # services: # - name: $DOCKER_SERVICE +# variables: +# HEALTHCHECK_TCP_PORT: "2375" # entrypoint: [ "sh", "-c", "dockerd-entrypoint.sh $DOCKER_DAEMON_OPTIONS" ] # alias: docker # before_script: @@ -803,6 +829,8 @@ build-develop-commit-db-image: image: $DOCKER_IMAGE services: - name: $DOCKER_SERVICE + variables: + HEALTHCHECK_TCP_PORT: "2375" entrypoint: [ "sh", "-c", "dockerd-entrypoint.sh $DOCKER_DAEMON_OPTIONS" ] alias: docker variables: @@ -835,6 +863,8 @@ build-develop-commit-liquibase-image: image: $DOCKER_IMAGE services: - name: $DOCKER_SERVICE + variables: + HEALTHCHECK_TCP_PORT: "2375" entrypoint: [ "sh", "-c", "dockerd-entrypoint.sh $DOCKER_DAEMON_OPTIONS" ] alias: docker variables: @@ -867,6 +897,8 @@ build-develop-commit-base-image: image: $DOCKER_IMAGE services: - name: $DOCKER_SERVICE + variables: + HEALTHCHECK_TCP_PORT: "2375" entrypoint: [ "sh", "-c", "dockerd-entrypoint.sh $DOCKER_DAEMON_OPTIONS" ] alias: docker variables: @@ -898,6 +930,8 @@ build-develop-commit-backend-image: image: $DOCKER_IMAGE services: - name: $DOCKER_SERVICE + variables: + HEALTHCHECK_TCP_PORT: "2375" entrypoint: [ "sh", "-c", "dockerd-entrypoint.sh $DOCKER_DAEMON_OPTIONS" ] alias: docker variables: @@ -936,6 +970,8 @@ build-develop-commit-frontend-image: image: $DOCKER_IMAGE services: - name: $DOCKER_SERVICE + variables: + HEALTHCHECK_TCP_PORT: "2375" entrypoint: [ "sh", "-c", "dockerd-entrypoint.sh $DOCKER_DAEMON_OPTIONS" ] alias: docker variables: @@ -1204,6 +1240,8 @@ build-pre-release: image: $DOCKER_IMAGE services: - name: $DOCKER_SERVICE + variables: + HEALTHCHECK_TCP_PORT: "2375" entrypoint: [ "sh", "-c", "dockerd-entrypoint.sh $DOCKER_DAEMON_OPTIONS" ] alias: docker variables: diff --git a/.gitlab-ci/Release-Pipelines.gitlab-ci.yml b/.gitlab-ci/Release-Pipelines.gitlab-ci.yml index 54e132deb..52e73d872 100644 --- a/.gitlab-ci/Release-Pipelines.gitlab-ci.yml +++ b/.gitlab-ci/Release-Pipelines.gitlab-ci.yml @@ -110,6 +110,8 @@ build-main-pr-base-image: image: $DOCKER_IMAGE services: - name: $DOCKER_SERVICE + variables: + HEALTHCHECK_TCP_PORT: "2375" entrypoint: [ "sh", "-c", "dockerd-entrypoint.sh $DOCKER_DAEMON_OPTIONS" ] alias: docker variables: @@ -142,6 +144,8 @@ build-main-pr-backend-test-image: image: $DOCKER_IMAGE services: - name: $DOCKER_SERVICE + variables: + HEALTHCHECK_TCP_PORT: "2375" entrypoint: [ "sh", "-c", "dockerd-entrypoint.sh $DOCKER_DAEMON_OPTIONS" ] alias: docker variables: @@ -180,6 +184,8 @@ build-main-pr-frontend-test-image: image: $DOCKER_IMAGE services: - name: $DOCKER_SERVICE + variables: + HEALTHCHECK_TCP_PORT: "2375" entrypoint: [ "sh", "-c", "dockerd-entrypoint.sh $DOCKER_DAEMON_OPTIONS" ] alias: docker variables: @@ -220,6 +226,8 @@ build-main-pr-frontend-test-image: # image: $DOCKER_IMAGE # services: # - name: $DOCKER_SERVICE +# variables: +# HEALTHCHECK_TCP_PORT: "2375" # entrypoint: [ "sh", "-c", "dockerd-entrypoint.sh $DOCKER_DAEMON_OPTIONS" ] # alias: docker # variables: @@ -255,6 +263,8 @@ build-main-pr-db-image: image: $DOCKER_IMAGE services: - name: $DOCKER_SERVICE + variables: + HEALTHCHECK_TCP_PORT: "2375" entrypoint: [ "sh", "-c", "dockerd-entrypoint.sh $DOCKER_DAEMON_OPTIONS" ] alias: docker variables: @@ -288,6 +298,8 @@ build-main-pr-liquibase-image: image: $DOCKER_IMAGE services: - name: $DOCKER_SERVICE + variables: + HEALTHCHECK_TCP_PORT: "2375" entrypoint: [ "sh", "-c", "dockerd-entrypoint.sh $DOCKER_DAEMON_OPTIONS" ] alias: docker variables: @@ -324,6 +336,8 @@ build-main-pr-backend-image: image: $DOCKER_IMAGE services: - name: $DOCKER_SERVICE + variables: + HEALTHCHECK_TCP_PORT: "2375" entrypoint: [ "sh", "-c", "dockerd-entrypoint.sh $DOCKER_DAEMON_OPTIONS" ] alias: docker variables: @@ -364,6 +378,8 @@ build-main-pr-frontend-image: image: $DOCKER_IMAGE services: - name: $DOCKER_SERVICE + variables: + HEALTHCHECK_TCP_PORT: "2375" entrypoint: [ "sh", "-c", "dockerd-entrypoint.sh $DOCKER_DAEMON_OPTIONS" ] alias: docker variables: @@ -404,6 +420,8 @@ test-main-pr-db: image: $DOCKER_IMAGE services: - name: $DOCKER_SERVICE + variables: + HEALTHCHECK_TCP_PORT: "2375" entrypoint: [ "sh", "-c", "dockerd-entrypoint.sh $DOCKER_DAEMON_OPTIONS" ] alias: docker before_script: @@ -497,6 +515,8 @@ test-main-pr-backend: image: $DOCKER_IMAGE services: - name: $DOCKER_SERVICE + variables: + HEALTHCHECK_TCP_PORT: "2375" entrypoint: [ "sh", "-c", "dockerd-entrypoint.sh $DOCKER_DAEMON_OPTIONS" ] alias: docker variables: @@ -520,6 +540,8 @@ test-main-pr-frontend: image: $DOCKER_IMAGE services: - name: $DOCKER_SERVICE + variables: + HEALTHCHECK_TCP_PORT: "2375" entrypoint: [ "sh", "-c", "dockerd-entrypoint.sh $DOCKER_DAEMON_OPTIONS" ] alias: docker variables: @@ -546,6 +568,8 @@ test-main-pr-frontend: # image: $DOCKER_IMAGE # services: # - name: $DOCKER_SERVICE +# variables: +# HEALTHCHECK_TCP_PORT: "2375" # entrypoint: [ "sh", "-c", "dockerd-entrypoint.sh $DOCKER_DAEMON_OPTIONS" ] # alias: docker # before_script: @@ -588,6 +612,8 @@ test-main-pr-frontend: # image: $DOCKER_IMAGE # services: # - name: $DOCKER_SERVICE +# variables: +# HEALTHCHECK_TCP_PORT: "2375" # entrypoint: [ "sh", "-c", "dockerd-entrypoint.sh $DOCKER_DAEMON_OPTIONS" ] # alias: docker # before_script: @@ -629,6 +655,8 @@ test-main-pr-frontend: # image: $DOCKER_IMAGE # services: # - name: $DOCKER_SERVICE +# variables: +# HEALTHCHECK_TCP_PORT: "2375" # entrypoint: [ "sh", "-c", "dockerd-entrypoint.sh $DOCKER_DAEMON_OPTIONS" ] # alias: docker # before_script: @@ -671,6 +699,8 @@ test-main-pr-frontend: # image: $DOCKER_IMAGE # services: # - name: $DOCKER_SERVICE +# variables: +# HEALTHCHECK_TCP_PORT: "2375" # entrypoint: [ "sh", "-c", "dockerd-entrypoint.sh $DOCKER_DAEMON_OPTIONS" ] # alias: docker # before_script: @@ -714,6 +744,8 @@ test-main-pr-frontend: # image: $DOCKER_IMAGE # services: # - name: $DOCKER_SERVICE +# variables: +# HEALTHCHECK_TCP_PORT: "2375" # entrypoint: [ "sh", "-c", "dockerd-entrypoint.sh $DOCKER_DAEMON_OPTIONS" ] # alias: docker # before_script: @@ -756,6 +788,8 @@ test-main-pr-frontend: # image: $DOCKER_IMAGE # services: # - name: $DOCKER_SERVICE +# variables: +# HEALTHCHECK_TCP_PORT: "2375" # entrypoint: [ "sh", "-c", "dockerd-entrypoint.sh $DOCKER_DAEMON_OPTIONS" ] # alias: docker # before_script: @@ -799,6 +833,8 @@ test-main-pr-frontend: # image: $DOCKER_IMAGE # services: # - name: $DOCKER_SERVICE +# variables: +# HEALTHCHECK_TCP_PORT: "2375" # entrypoint: [ "sh", "-c", "dockerd-entrypoint.sh $DOCKER_DAEMON_OPTIONS" ] # alias: docker # before_script: @@ -841,6 +877,8 @@ test-main-pr-frontend: # image: $DOCKER_IMAGE # services: # - name: $DOCKER_SERVICE +# variables: +# HEALTHCHECK_TCP_PORT: "2375" # entrypoint: [ "sh", "-c", "dockerd-entrypoint.sh $DOCKER_DAEMON_OPTIONS" ] # alias: docker # before_script: @@ -881,6 +919,8 @@ lint-main-pr-backend: image: $DOCKER_IMAGE services: - name: $DOCKER_SERVICE + variables: + HEALTHCHECK_TCP_PORT: "2375" entrypoint: [ "sh", "-c", "dockerd-entrypoint.sh $DOCKER_DAEMON_OPTIONS" ] alias: docker variables: @@ -904,6 +944,8 @@ lint-main-pr-frontend: image: $DOCKER_IMAGE services: - name: $DOCKER_SERVICE + variables: + HEALTHCHECK_TCP_PORT: "2375" entrypoint: [ "sh", "-c", "dockerd-entrypoint.sh $DOCKER_DAEMON_OPTIONS" ] alias: docker variables: @@ -927,6 +969,8 @@ lint-main-pr-frontend: # image: $DOCKER_IMAGE # services: # - name: $DOCKER_SERVICE +# variables: +# HEALTHCHECK_TCP_PORT: "2375" # entrypoint: [ "sh", "-c", "dockerd-entrypoint.sh $DOCKER_DAEMON_OPTIONS" ] # alias: docker # variables: @@ -946,6 +990,8 @@ audit-main-pr-backend: image: $DOCKER_IMAGE services: - name: $DOCKER_SERVICE + variables: + HEALTHCHECK_TCP_PORT: "2375" entrypoint: [ "sh", "-c", "dockerd-entrypoint.sh $DOCKER_DAEMON_OPTIONS" ] alias: docker variables: @@ -968,6 +1014,8 @@ audit-main-pr-frontend: image: $DOCKER_IMAGE services: - name: $DOCKER_SERVICE + variables: + HEALTHCHECK_TCP_PORT: "2375" entrypoint: [ "sh", "-c", "dockerd-entrypoint.sh $DOCKER_DAEMON_OPTIONS" ] alias: docker variables: @@ -987,6 +1035,8 @@ build-main-commit-db-image: image: $DOCKER_IMAGE services: - name: $DOCKER_SERVICE + variables: + HEALTHCHECK_TCP_PORT: "2375" entrypoint: [ "sh", "-c", "dockerd-entrypoint.sh $DOCKER_DAEMON_OPTIONS" ] alias: docker variables: @@ -1018,6 +1068,8 @@ build-main-commit-liquibase-image: image: $DOCKER_IMAGE services: - name: $DOCKER_SERVICE + variables: + HEALTHCHECK_TCP_PORT: "2375" entrypoint: [ "sh", "-c", "dockerd-entrypoint.sh $DOCKER_DAEMON_OPTIONS" ] alias: docker variables: @@ -1049,6 +1101,8 @@ build-main-commit-base-image: image: $DOCKER_IMAGE services: - name: $DOCKER_SERVICE + variables: + HEALTHCHECK_TCP_PORT: "2375" entrypoint: [ "sh", "-c", "dockerd-entrypoint.sh $DOCKER_DAEMON_OPTIONS" ] alias: docker variables: @@ -1082,6 +1136,8 @@ build-main-commit-backend-image: image: $DOCKER_IMAGE services: - name: $DOCKER_SERVICE + variables: + HEALTHCHECK_TCP_PORT: "2375" entrypoint: [ "sh", "-c", "dockerd-entrypoint.sh $DOCKER_DAEMON_OPTIONS" ] alias: docker variables: @@ -1119,6 +1175,8 @@ build-main-commit-frontend-image: image: $DOCKER_IMAGE services: - name: $DOCKER_SERVICE + variables: + HEALTHCHECK_TCP_PORT: "2375" entrypoint: [ "sh", "-c", "dockerd-entrypoint.sh $DOCKER_DAEMON_OPTIONS" ] alias: docker variables: @@ -1386,6 +1444,8 @@ build-release: image: $DOCKER_IMAGE services: - name: $DOCKER_SERVICE + variables: + HEALTHCHECK_TCP_PORT: "2375" entrypoint: [ "sh", "-c", "dockerd-entrypoint.sh $DOCKER_DAEMON_OPTIONS" ] alias: docker variables: diff --git a/README.md b/README.md index 181345df6..2ddd70db1 100755 --- a/README.md +++ b/README.md @@ -1,17 +1,100 @@ # CodingBox -## Development +## Project Structure -Run `npm run start-app` for a dev server. Navigate to `http://localhost:4200/`. The application will automatically reload if you change any of the source files. +The project structure is organized in a way that ensures easy development and maintenance. The main folders and files include: + +- **apps/**: Contains the `frontend` and `backend` applications. +- **config/**: Configuration files for various services. +- **database/**: Database configuration and related resources. +- **dist/**: Generated build artifacts. +- **environments/**: Environment variables. +- **node_modules/**: Dependencies from npm. +- **packages/**: Additional packages to extend the project. +- **scripts/**: Scripts for build and deployment processes. +- Unified configuration files (.prettierrc, .eslintrc, etc.) for ensuring code quality. + +--- + +## Requirements + +### Dependencies +The project is built using the following main technologies: + +- **Frontend**: Angular +- **Backend**: NestJS +- **Testing**: Jest +- **Linting**: ESLint + +### System Requirements +- Node.js: Version 16 or higher +- NPM: Version 7 or higher +- Docker (optional for containerization) + +--- + +## Development Process + +### Local Development +1. **Start the Development Environment** + Run the following command to start the development environment: + ```bash + npm run start-app + ``` + By default, the application is available at `http://localhost:4200/`. Changes in the source code will trigger an automatic reload of the application. + +2. **Start the Backend (Optional)** + To run the backend, use: + ```bash + npm run start-backend + ``` + +--- ## Build -Run `npm run build-app` to build the project. The build artifacts will be stored in the `dist/` directory. +Run the following command to build the project: +The build artifacts will be stored in the `dist/` folder. + +--- + +## Testing + +### Unit Tests +Run unit tests with Jest: + +### Linting +Perform linting with ESLint: + +--- + +## Containerization (Docker) + +### Start with Docker-Compose +The project provides a Docker-Compose configuration to run the applications in containers with predefined environments. Use the following command: + +### Building and Running +For production environments, you can use the file `docker-compose.coding-box.prod.yaml`: + +--- + +## Key Features + + +--- + +## Useful Scripts -## Running unit tests +- `npm run start-app`: Starts the app. +- `npm run start-backend`: Starts the backend server. +- `npm run build-app`: Builds the project for production. +- `npm run test-app`: Executes unit tests. +- `npm run lint-app`: Performs linting with ESLint. -Run `npm run test-app` to execute the unit tests via [Jest](https://jestjs.io/). +--- -## Running linting +## Additional Information -Run `npm run lint-app` to execute the linting via [Eslint](https://eslint.org/). +- **Contributing**: Contributions are welcome. Submit a pull request with detailed changes. +- **License**: This project is licensed under the **MIT License**. +- **Support**: For questions or issues, please contact the project maintainer. diff --git a/api-dto/coding/coding-statistics.ts b/api-dto/coding/coding-statistics.ts new file mode 100644 index 000000000..9984c853a --- /dev/null +++ b/api-dto/coding/coding-statistics.ts @@ -0,0 +1,6 @@ +export interface CodingStatistics { + totalResponses: number; + statusCounts: { + [key: string]: number; + }; +} diff --git a/api-dto/files/file-download.dto.ts b/api-dto/files/file-download.dto.ts new file mode 100644 index 000000000..61879d330 --- /dev/null +++ b/api-dto/files/file-download.dto.ts @@ -0,0 +1,12 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class FileDownloadDto { + @ApiProperty() + filename!: string; + + @ApiProperty() + base64Data!: string; + + @ApiProperty() + mimeType!: string; +} diff --git a/api-dto/files/file-validation-result.dto.ts b/api-dto/files/file-validation-result.dto.ts new file mode 100644 index 000000000..ef262238b --- /dev/null +++ b/api-dto/files/file-validation-result.dto.ts @@ -0,0 +1,27 @@ +import { ApiProperty } from '@nestjs/swagger'; + +type FileStatus = { + filename: string; + exists: boolean; +}; + +type DataValidation = { + complete: boolean; + missing: string[]; + files: FileStatus[]; +}; + +export class FileValidationResultDto { + @ApiProperty({ type: Boolean, description: 'Indicates whether test takers were found' }) + testTakersFound!: boolean; + + @ApiProperty({ type: [Object], description: 'Array of validation results for each test taker' }) + validationResults!: { + testTaker: string; + booklets: DataValidation; + units: DataValidation; + schemes: DataValidation; + definitions: DataValidation; + player: DataValidation; + }[]; +} diff --git a/api-dto/files/files-in-list.dto.ts b/api-dto/files/files-in-list.dto.ts index dfe35d792..3524e8ab6 100644 --- a/api-dto/files/files-in-list.dto.ts +++ b/api-dto/files/files-in-list.dto.ts @@ -2,7 +2,7 @@ import { ApiProperty } from '@nestjs/swagger'; export class FilesInListDto { @ApiProperty() - id!: number; + id!: number; @ApiProperty() filename!: string; diff --git a/api-dto/files/files-validation.dto.ts b/api-dto/files/files-validation.dto.ts new file mode 100644 index 000000000..eb6259ff8 --- /dev/null +++ b/api-dto/files/files-validation.dto.ts @@ -0,0 +1,20 @@ +import { ApiProperty } from '@nestjs/swagger'; + +type DataValidation = { + complete: boolean; + missing: string[]; +}; + +export class FilesValidationDto { + @ApiProperty() + booklets!: DataValidation; + + @ApiProperty() + units!: DataValidation; + + @ApiProperty() + schemes!: DataValidation; + + @ApiProperty() + definitions!: DataValidation; +} diff --git a/api-dto/files/import-workspace-files.dto.ts b/api-dto/files/import-workspace-files.dto.ts new file mode 100644 index 000000000..e216d7287 --- /dev/null +++ b/api-dto/files/import-workspace-files.dto.ts @@ -0,0 +1,51 @@ +import { IsOptional, IsString } from 'class-validator'; + +export class ImportWorkspaceFilesDto { + @IsOptional() + @IsString() + server?: string; + + @IsOptional() + @IsString() + url?: string; + + @IsOptional() + @IsString() + tc_workspace?: string; + + @IsOptional() + @IsString() + token?: string; + + @IsOptional() + @IsString() + definitions?: string; + + @IsOptional() + @IsString() + responses?: string; + + @IsOptional() + @IsString() + logs?: string; + + @IsOptional() + @IsString() + player?: string; + + @IsOptional() + @IsString() + units?: string; + + @IsOptional() + @IsString() + codings?: string; + + @IsOptional() + @IsString() + testTakers?: string; + + @IsOptional() + @IsString() + booklets?: string; +} diff --git a/api-dto/files/test-groups-info.dto.ts b/api-dto/files/test-groups-info.dto.ts new file mode 100644 index 000000000..c463b6f15 --- /dev/null +++ b/api-dto/files/test-groups-info.dto.ts @@ -0,0 +1,34 @@ +import { + IsString, IsInt, IsNumber, Min +} from 'class-validator'; + +export class TestGroupsInfoDto { + @IsString() + groupName!: string; + + @IsString() + groupLabel!: string; + + @IsInt() + bookletsStarted!: number; + + @IsInt() + @Min(0) + numUnitsMin!: number; + + @IsInt() + @Min(0) + numUnitsMax!: number; + + @IsInt() + @Min(0) + numUnitsTotal!: number; + + @IsNumber() + @Min(0) + numUnitsAvg!: number; + + @IsInt() + @Min(0) + lastChange!: number; +} diff --git a/api-dto/resource-package/resource-package-dto.ts b/api-dto/resource-package/resource-package-dto.ts index d03e2a49e..456f69c83 100644 --- a/api-dto/resource-package/resource-package-dto.ts +++ b/api-dto/resource-package/resource-package-dto.ts @@ -13,6 +13,9 @@ export class ResourcePackageDto { @IsNotEmpty() elements!: string[]; + @ApiProperty() + packageSize?: number; + @ApiProperty() createdAt?: Date; } diff --git a/api-dto/unit-notes/create-unit-note.dto.ts b/api-dto/unit-notes/create-unit-note.dto.ts new file mode 100644 index 000000000..39ab59646 --- /dev/null +++ b/api-dto/unit-notes/create-unit-note.dto.ts @@ -0,0 +1,9 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class CreateUnitNoteDto { + @ApiProperty({ description: 'The ID of the unit this note belongs to' }) + unitId!: number; + + @ApiProperty({ description: 'The note text' }) + note!: string; +} diff --git a/api-dto/unit-notes/unit-note.dto.ts b/api-dto/unit-notes/unit-note.dto.ts new file mode 100644 index 000000000..e8542b367 --- /dev/null +++ b/api-dto/unit-notes/unit-note.dto.ts @@ -0,0 +1,18 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class UnitNoteDto { + @ApiProperty({ description: 'The unique identifier of the unit note' }) + id!: number; + + @ApiProperty({ description: 'The ID of the unit this note belongs to' }) + unitId!: number; + + @ApiProperty({ description: 'The note text' }) + note!: string; + + @ApiProperty({ description: 'The date and time when the note was created' }) + createdAt!: Date; + + @ApiProperty({ description: 'The date and time when the note was last updated' }) + updatedAt!: Date; +} diff --git a/api-dto/unit-notes/update-unit-note.dto.ts b/api-dto/unit-notes/update-unit-note.dto.ts new file mode 100644 index 000000000..cf8785e94 --- /dev/null +++ b/api-dto/unit-notes/update-unit-note.dto.ts @@ -0,0 +1,6 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class UpdateUnitNoteDto { + @ApiProperty({ description: 'The note text' }) + note!: string; +} diff --git a/api-dto/unit-tags/create-unit-tag.dto.ts b/api-dto/unit-tags/create-unit-tag.dto.ts new file mode 100644 index 000000000..6984ceb20 --- /dev/null +++ b/api-dto/unit-tags/create-unit-tag.dto.ts @@ -0,0 +1,12 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class CreateUnitTagDto { + @ApiProperty({ description: 'The ID of the unit this tag belongs to' }) + unitId!: number; + + @ApiProperty({ description: 'The tag text' }) + tag!: string; + + @ApiProperty({ description: 'The color of the tag', required: false }) + color?: string; +} diff --git a/api-dto/unit-tags/unit-tag.dto.ts b/api-dto/unit-tags/unit-tag.dto.ts new file mode 100644 index 000000000..0f2b6c61c --- /dev/null +++ b/api-dto/unit-tags/unit-tag.dto.ts @@ -0,0 +1,18 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class UnitTagDto { + @ApiProperty({ description: 'The unique identifier of the unit tag' }) + id!: number; + + @ApiProperty({ description: 'The ID of the unit this tag belongs to' }) + unitId!: number; + + @ApiProperty({ description: 'The tag text' }) + tag!: string; + + @ApiProperty({ description: 'The color of the tag', required: false }) + color?: string; + + @ApiProperty({ description: 'The date and time when the tag was created' }) + createdAt!: Date; +} diff --git a/api-dto/unit-tags/update-unit-tag.dto.ts b/api-dto/unit-tags/update-unit-tag.dto.ts new file mode 100644 index 000000000..656251830 --- /dev/null +++ b/api-dto/unit-tags/update-unit-tag.dto.ts @@ -0,0 +1,9 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class UpdateUnitTagDto { + @ApiProperty({ description: 'The tag text' }) + tag!: string; + + @ApiProperty({ description: 'The color of the tag', required: false }) + color?: string; +} diff --git a/api-dto/workspaces/paginated-workspace-user-dto.ts b/api-dto/workspaces/paginated-workspace-user-dto.ts new file mode 100644 index 000000000..d7968bc0a --- /dev/null +++ b/api-dto/workspaces/paginated-workspace-user-dto.ts @@ -0,0 +1,16 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { WorkspaceUserDto } from './workspace-user-dto'; + +export class PaginatedWorkspaceUserDto { + @ApiProperty({ type: [WorkspaceUserDto] }) + data!: WorkspaceUserDto[]; + + @ApiProperty() + total!: number; + + @ApiProperty() + page!: number; + + @ApiProperty() + limit!: number; +} diff --git a/api-dto/workspaces/paginated-workspaces-dto.ts b/api-dto/workspaces/paginated-workspaces-dto.ts new file mode 100644 index 000000000..0665b1205 --- /dev/null +++ b/api-dto/workspaces/paginated-workspaces-dto.ts @@ -0,0 +1,16 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { WorkspaceInListDto } from './workspace-in-list-dto'; + +export class PaginatedWorkspacesDto { + @ApiProperty({ type: () => [WorkspaceInListDto] }) + data!: WorkspaceInListDto[]; + + @ApiProperty({ example: 3 }) + total!: number; + + @ApiProperty({ example: 1 }) + page!: number; + + @ApiProperty({ example: 20 }) + limit!: number; +} diff --git a/api-dto/workspaces/workspace-user-dto.ts b/api-dto/workspaces/workspace-user-dto.ts new file mode 100644 index 000000000..a307f4c4b --- /dev/null +++ b/api-dto/workspaces/workspace-user-dto.ts @@ -0,0 +1,12 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class WorkspaceUserDto { + @ApiProperty() + workspaceId!: number; + + @ApiProperty() + userId!: number; + + @ApiProperty({ nullable: true }) + accessLevel!: string | null; +} diff --git a/apps/backend/project.json b/apps/backend/project.json index 971b2dd6b..7c1ecada1 100755 --- a/apps/backend/project.json +++ b/apps/backend/project.json @@ -10,6 +10,14 @@ "{options.outputPath}" ], "options": { + "assets": [ + { + "glob": "**/*", + "input": "schemas", + "output": "schemas" + } + ], + "webpackConfig": "apps/backend/webpack.config.js", "outputPath": "dist/apps/backend", "main": "apps/backend/src/main.ts", diff --git a/apps/backend/src/app/admin/admin.guard.ts b/apps/backend/src/app/admin/admin.guard.ts new file mode 100644 index 000000000..f5e6317ba --- /dev/null +++ b/apps/backend/src/app/admin/admin.guard.ts @@ -0,0 +1,25 @@ +import { + CanActivate, ExecutionContext, Injectable, UnauthorizedException +} from '@nestjs/common'; +import { AuthService } from '../auth/service/auth.service'; + +@Injectable() +export class AdminGuard implements CanActivate { + constructor( + private authService: AuthService + ) {} + + async canActivate( + context: ExecutionContext + ): Promise { + const req = context.switchToHttp().getRequest(); + const userId = req.user.id; + + const isAdmin = await this.authService.isAdminUser(userId); + if (!isAdmin) { + throw new UnauthorizedException('Admin privileges required'); + } + + return true; + } +} diff --git a/apps/backend/src/app/admin/admin.module.ts b/apps/backend/src/app/admin/admin.module.ts index b12454959..c00f274bb 100755 --- a/apps/backend/src/app/admin/admin.module.ts +++ b/apps/backend/src/app/admin/admin.module.ts @@ -4,6 +4,16 @@ import { UsersController } from './users/users.controller'; import { DatabaseModule } from '../database/database.module'; import { AuthModule } from '../auth/auth.module'; import { WorkspaceController } from './workspace/workspace.controller'; +import { WorkspaceFilesController } from './workspace/workspace-files.controller'; +import { WorkspaceTestResultsController } from './workspace/workspace-test-results.controller'; +import { WorkspaceUsersController } from './workspace/workspace-users.controller'; +import { WorkspaceCodingController } from './workspace/workspace-coding.controller'; +import { WorkspaceTestCenterController } from './workspace/workspace-test-center.controller'; +import { WorkspacePlayerController } from './workspace/workspace-player.controller'; +import { LogoController } from './logo/logo.controller'; +import { UnitTagsController } from './unit-tags/unit-tags.controller'; +import { UnitNotesController } from './unit-notes/unit-notes.controller'; +import { ResourcePackageController } from './resource-packages/resource-package.controller'; @Module({ imports: [ @@ -12,7 +22,18 @@ import { WorkspaceController } from './workspace/workspace.controller'; HttpModule ], controllers: [ - UsersController, WorkspaceController + UsersController, + WorkspaceController, + WorkspaceFilesController, + WorkspaceTestResultsController, + WorkspaceUsersController, + WorkspaceCodingController, + WorkspaceTestCenterController, + WorkspacePlayerController, + LogoController, + UnitTagsController, + UnitNotesController, + ResourcePackageController ], providers: [] }) diff --git a/apps/backend/src/app/admin/logo/logo.controller.ts b/apps/backend/src/app/admin/logo/logo.controller.ts new file mode 100644 index 000000000..e0531f3fe --- /dev/null +++ b/apps/backend/src/app/admin/logo/logo.controller.ts @@ -0,0 +1,179 @@ +import { + BadRequestException, + Body, + Controller, + Delete, + Get, + InternalServerErrorException, + Post, + Put, + UploadedFile, + UseGuards, + UseInterceptors +} from '@nestjs/common'; +import { + ApiBearerAuth, + ApiBody, + ApiConsumes, + ApiOkResponse, + ApiOperation, + ApiTags +} from '@nestjs/swagger'; +import { FileInterceptor } from '@nestjs/platform-express'; +import { diskStorage } from 'multer'; +import * as fs from 'fs'; +import * as path from 'path'; +import { JwtAuthGuard } from '../../auth/jwt-auth.guard'; +import { AdminGuard } from '../admin.guard'; +import { AppLogoDto } from '../../../../../../api-dto/app-logo-dto'; + +@Controller('admin/logo') +@ApiTags('admin') +export class LogoController { + LOGO_PATH = path.join(process.cwd(), 'apps', 'frontend', 'src', 'assets', 'logo'); + ALLOWED_MIME_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/svg+xml', 'image/webp']; + MAX_FILE_SIZE = 4 * 1024 * 1024; // 4MB + + @Post('upload') + @UseGuards(JwtAuthGuard) + @ApiBearerAuth() + @ApiOperation({ summary: 'Upload logo', description: 'Uploads a new logo to replace the default one' }) + @UseInterceptors( + FileInterceptor('logo', { + storage: diskStorage({ + destination: (req, file, cb) => { + const uploadPath = path.join(process.cwd(), 'apps', 'frontend', 'src', 'assets'); + if (!fs.existsSync(uploadPath)) { + fs.mkdirSync(uploadPath, { recursive: true }); + } + cb(null, uploadPath); + }, + filename: (req, file, cb) => { + // Use the extension from the original file + const ext = path.extname(file.originalname); + cb(null, `logo${ext}`); + } + }), + limits: { + fileSize: 4 * 1024 * 1024 // 4MB + }, + fileFilter: (req, file, cb) => { + if (!['image/jpeg', 'image/png', 'image/gif', 'image/svg+xml', 'image/webp'].includes(file.mimetype)) { + return cb(new BadRequestException('Only image files are allowed'), false); + } + + // @ts-expect-error content-length might not be defined in headers type + if (parseInt(req.headers['content-length'], 10) > this.MAX_FILE_SIZE) { + return cb(new BadRequestException('File size exceeds the limit (4MB)'), false); + } + + return cb(null, true); + } + }) + ) + @ApiConsumes('multipart/form-data') + @ApiBody({ + schema: { + type: 'object', + properties: { + logo: { + type: 'string', + format: 'binary', + description: 'Logo file to upload (max 4MB, allowed types: JPEG, PNG, GIF, SVG, WebP)' + } + } + } + }) + @ApiOkResponse({ description: 'Logo uploaded successfully', type: String }) + async uploadLogo(@UploadedFile() file: Express.Multer.File): Promise<{ path: string }> { + if (!file) { + throw new BadRequestException('No file uploaded'); + } + + try { + // Always return the consistent path to the uploaded file + // This ensures the path matches the actual saved filename (logo + extension) + return { path: `assets/logo${path.extname(file.originalname)}` }; + } catch (error) { + throw new InternalServerErrorException('Failed to upload logo'); + } + } + + @Delete() + @UseGuards(JwtAuthGuard, AdminGuard) + @ApiBearerAuth() + @ApiOperation({ summary: 'Delete logo', description: 'Deletes the custom logo and reverts to the default one' }) + @ApiOkResponse({ description: 'Logo deleted successfully', type: Boolean }) + async deleteLogo(): Promise<{ success: boolean }> { + try { + // Find all files starting with 'logo' in the assets directory + const assetsDir = path.join(process.cwd(), 'apps', 'frontend', 'src', 'assets'); + const files = fs.readdirSync(assetsDir); + + let deleted = false; + for (const file of files) { + if (file.startsWith('logo')) { + fs.unlinkSync(path.join(assetsDir, file)); + deleted = true; + } + } + + // Delete logo settings file if it exists + const settingsPath = path.join(process.cwd(), 'apps', 'frontend', 'src', 'assets', 'logo-settings.json'); + if (fs.existsSync(settingsPath)) { + fs.unlinkSync(settingsPath); + } + + return { success: deleted }; + } catch (error) { + throw new InternalServerErrorException('Failed to delete logo'); + } + } + + @Put('settings') + @UseGuards(JwtAuthGuard, AdminGuard) + @ApiBearerAuth() + @ApiOperation({ summary: 'Save logo settings', description: 'Saves logo settings like background color' }) + @ApiBody({ type: AppLogoDto }) + @ApiOkResponse({ description: 'Logo settings saved successfully', type: Boolean }) + async saveLogoSettings(@Body() logoSettings: AppLogoDto): Promise<{ success: boolean }> { + try { + const settingsPath = path.join(process.cwd(), 'apps', 'frontend', 'src', 'assets', 'logo-settings.json'); + + // Save the settings to a file + fs.writeFileSync(settingsPath, JSON.stringify(logoSettings, null, 2)); + + return { success: true }; + } catch (error) { + throw new InternalServerErrorException('Failed to save logo settings'); + } + } + + @Get('settings') + @UseGuards(JwtAuthGuard) + @ApiBearerAuth() + @ApiOperation({ summary: 'Get logo settings', description: 'Gets logo settings like background color' }) + @ApiOkResponse({ description: 'Logo settings retrieved successfully', type: AppLogoDto }) + async getLogoSettings(): Promise { + try { + const settingsPath = path.join(process.cwd(), 'apps', 'frontend', 'src', 'assets', 'logo-settings.json'); + + // Check if settings file exists + if (fs.existsSync(settingsPath)) { + // Read the settings from the file + const settingsJson = fs.readFileSync(settingsPath, 'utf8'); + return JSON.parse(settingsJson); + } + + // Return default settings if file doesn't exist + return { + data: 'assets/IQB-LogoA.png', + alt: 'Zur Startseite', + bodyBackground: 'linear-gradient(180deg, rgba(7,70,94,1) 0%, rgba(6,112,123,1) 24%, rgba(1,192,229,1) 85%)', + boxBackground: 'lightgray' + }; + } catch (error) { + throw new InternalServerErrorException('Failed to get logo settings'); + } + } +} diff --git a/apps/backend/src/app/admin/resource-packages/resource-package.controller.ts b/apps/backend/src/app/admin/resource-packages/resource-package.controller.ts index 6bcbbd34e..10d88adb0 100644 --- a/apps/backend/src/app/admin/resource-packages/resource-package.controller.ts +++ b/apps/backend/src/app/admin/resource-packages/resource-package.controller.ts @@ -1,7 +1,7 @@ import { Controller, Delete, - Get, Header, + Get, Header, NotFoundException, Param, ParseArrayPipe, ParseIntPipe, @@ -12,7 +12,8 @@ import { UseGuards } from '@nestjs/common'; import { - ApiBearerAuth, ApiCreatedResponse, ApiNotFoundResponse, ApiOkResponse, ApiQuery, ApiTags + ApiBadRequestResponse, ApiBearerAuth, ApiCreatedResponse, ApiNotFoundResponse, + ApiOkResponse, ApiOperation, ApiParam, ApiQuery, ApiTags } from '@nestjs/swagger'; import { Express } from 'express'; import 'multer'; @@ -23,33 +24,81 @@ import { fileMimetypeFilter } from './file-mimetype-filter'; import { ParseFile } from './parse-file-pipe'; import { ResourcePackageDto } from '../../../../../../api-dto/resource-package/resource-package-dto'; -@Controller('admin/resource-packages') +@ApiTags('Admin Resource Packages') +@Controller('admin/workspace/:workspaceId/resource-packages') export class ResourcePackageController { constructor( private resourcePackageService: ResourcePackageService ) {} @Get() - @ApiOkResponse({ description: 'Resource Packages retrieved successfully.' }) // TODO Exception - @ApiTags('admin resource-packages') - async findResourcePackages(): Promise { - return this.resourcePackageService.findResourcePackages(); + @UseGuards(JwtAuthGuard) + @ApiBearerAuth() + @ApiOperation({ + summary: 'Get all resource packages for a workspace', + description: 'Retrieves a list of all resource packages for the specified workspace' + }) + @ApiParam({ + name: 'workspaceId', + type: Number, + description: 'The ID of the workspace', + required: true + }) + @ApiOkResponse({ + description: 'Resource Packages retrieved successfully.', + type: [ResourcePackageDto] + }) + @ApiNotFoundResponse({ + description: 'No resource packages found.' + }) + @ApiBadRequestResponse({ description: 'Failed to retrieve resource packages' }) + async findResourcePackages( + @Param('workspaceId', ParseIntPipe) workspaceId: number + ): Promise { + const resourcePackages = await this.resourcePackageService.findResourcePackages(workspaceId); + + if (!resourcePackages || resourcePackages.length === 0) { + throw new NotFoundException(`No resource packages found for workspace ${workspaceId}.`); + } + + return resourcePackages; } @Delete(':id') @UseGuards(JwtAuthGuard) @ApiBearerAuth() + @ApiParam({ + name: 'workspaceId', + type: Number, + description: 'The ID of the workspace', + required: true + }) + @ApiParam({ + name: 'id', + type: Number, + description: 'The ID of the resource package', + required: true + }) @ApiOkResponse({ description: 'Resource Package deleted successfully.' }) - @ApiNotFoundResponse({ description: 'Comment not found.' }) + @ApiNotFoundResponse({ description: 'Resource package not found.' }) @ApiTags('admin resource-packages') - async removeResourcePackage(@Param('id', ParseIntPipe) id: number): Promise { - return this.resourcePackageService.removeResourcePackage(id); + async removeResourcePackage( + @Param('workspaceId', ParseIntPipe) workspaceId: number, + @Param('id', ParseIntPipe) id: number + ): Promise { + return this.resourcePackageService.removeResourcePackage(workspaceId, id); } @Delete() @UseGuards(JwtAuthGuard) @ApiBearerAuth() @ApiTags('admin resource-packages') + @ApiParam({ + name: 'workspaceId', + type: Number, + description: 'The ID of the workspace', + required: true + }) @ApiQuery({ name: 'id', type: Number, @@ -58,9 +107,10 @@ export class ResourcePackageController { }) @ApiOkResponse({ description: 'Admin resource-packages deleted successfully.' }) async removeIds( - @Query('id', new ParseArrayPipe({ items: Number, separator: ',' })) id: number[] + @Param('workspaceId', ParseIntPipe) workspaceId: number, + @Query('id', new ParseArrayPipe({ items: Number, separator: ',' })) id: number[] ) : Promise { - return this.resourcePackageService.removeResourcePackages(id); + return this.resourcePackageService.removeResourcePackages(workspaceId, id); } @Get(':name') @@ -70,14 +120,35 @@ export class ResourcePackageController { @UseGuards(JwtAuthGuard) @ApiBearerAuth() @ApiTags('admin resource-packages') - async getZippedResourcePackage(@Param('name') name: string): Promise { - const file = this.resourcePackageService.getZippedResourcePackage(name); + @ApiParam({ + name: 'workspaceId', + type: Number, + description: 'The ID of the workspace', + required: true + }) + @ApiParam({ + name: 'name', + type: String, + description: 'The name of the resource package', + required: true + }) + async getZippedResourcePackage( + @Param('workspaceId', ParseIntPipe) workspaceId: number, + @Param('name') name: string + ): Promise { + const file = await this.resourcePackageService.getZippedResourcePackage(workspaceId, name); return new StreamableFile(file); } @Post() @UseGuards(JwtAuthGuard) @ApiBearerAuth() + @ApiParam({ + name: 'workspaceId', + type: Number, + description: 'The ID of the workspace', + required: true + }) @ApiFile('resourcePackage', true, { fileFilter: fileMimetypeFilter('application/zip') }) @@ -86,7 +157,10 @@ export class ResourcePackageController { type: Number }) @ApiTags('admin resource-packages') - async create(@UploadedFile(ParseFile) zippedResourcePackage: Express.Multer.File): Promise { - return this.resourcePackageService.create(zippedResourcePackage); + async create( + @Param('workspaceId', ParseIntPipe) workspaceId: number, + @UploadedFile(ParseFile) zippedResourcePackage: Express.Multer.File + ): Promise { + return this.resourcePackageService.create(workspaceId, zippedResourcePackage); } } diff --git a/apps/backend/src/app/admin/unit-notes/unit-notes.controller.ts b/apps/backend/src/app/admin/unit-notes/unit-notes.controller.ts new file mode 100644 index 000000000..d0ea46e13 --- /dev/null +++ b/apps/backend/src/app/admin/unit-notes/unit-notes.controller.ts @@ -0,0 +1,236 @@ +import { + BadRequestException, + Body, + Controller, + Delete, + Get, + NotFoundException, + Param, + Patch, + Post, + UseGuards +} from '@nestjs/common'; +import { + ApiBadRequestResponse, + ApiBearerAuth, + ApiCreatedResponse, + 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 { UnitNoteService } from '../../database/services/unit-note.service'; +import { UnitNoteDto } from '../../../../../../api-dto/unit-notes/unit-note.dto'; +import { CreateUnitNoteDto } from '../../../../../../api-dto/unit-notes/create-unit-note.dto'; +import { UpdateUnitNoteDto } from '../../../../../../api-dto/unit-notes/update-unit-note.dto'; + +@ApiTags('Unit Notes') +@Controller('admin/workspace/:workspace_id/unit-notes') +export class UnitNotesController { + constructor(private readonly unitNoteService: UnitNoteService) {} + + @Post() + @UseGuards(JwtAuthGuard, WorkspaceGuard) + @ApiBearerAuth() + @ApiOperation({ + summary: 'Create a new unit note', + description: 'Creates a new note for a unit' + }) + @ApiParam({ + name: 'workspace_id', + type: Number, + required: true, + description: 'The ID of the workspace' + }) + @ApiCreatedResponse({ + description: 'The note has been successfully created.', + type: UnitNoteDto + }) + @ApiBadRequestResponse({ + description: 'Invalid input data.' + }) + @ApiNotFoundResponse({ + description: 'Unit not found.' + }) + async create( + @WorkspaceId() workspaceId: number, + @Body() createUnitNoteDto: CreateUnitNoteDto + ): Promise { + try { + return await this.unitNoteService.create(createUnitNoteDto); + } catch (error) { + if (error instanceof NotFoundException) { + throw error; + } + throw new BadRequestException(`Failed to create note: ${error.message}`); + } + } + + @Get('unit/:unitId') + @UseGuards(JwtAuthGuard, WorkspaceGuard) + @ApiBearerAuth() + @ApiOperation({ + summary: 'Get all notes for a unit', + description: 'Retrieves all notes for a specific unit' + }) + @ApiParam({ + name: 'workspace_id', + type: Number, + required: true, + description: 'The ID of the workspace' + }) + @ApiParam({ + name: 'unitId', + type: Number, + required: true, + description: 'The ID of the unit' + }) + @ApiOkResponse({ + description: 'The notes have been successfully retrieved.', + type: [UnitNoteDto] + }) + @ApiNotFoundResponse({ + description: 'Unit not found.' + }) + async findAllByUnitId( + @WorkspaceId() workspaceId: number, + @Param('unitId') unitId: number + ): Promise { + try { + return await this.unitNoteService.findAllByUnitId(unitId); + } catch (error) { + if (error instanceof NotFoundException) { + throw error; + } + throw new BadRequestException(`Failed to retrieve notes: ${error.message}`); + } + } + + @Get(':id') + @UseGuards(JwtAuthGuard, WorkspaceGuard) + @ApiBearerAuth() + @ApiOperation({ + summary: 'Get a note by ID', + description: 'Retrieves a note by its ID' + }) + @ApiParam({ + name: 'workspace_id', + type: Number, + required: true, + description: 'The ID of the workspace' + }) + @ApiParam({ + name: 'id', + type: Number, + required: true, + description: 'The ID of the note' + }) + @ApiOkResponse({ + description: 'The note has been successfully retrieved.', + type: UnitNoteDto + }) + @ApiNotFoundResponse({ + description: 'Note not found.' + }) + async findOne( + @WorkspaceId() workspaceId: number, + @Param('id') id: number + ): Promise { + try { + return await this.unitNoteService.findOne(id); + } catch (error) { + if (error instanceof NotFoundException) { + throw error; + } + throw new BadRequestException(`Failed to retrieve note: ${error.message}`); + } + } + + @Patch(':id') + @UseGuards(JwtAuthGuard, WorkspaceGuard) + @ApiBearerAuth() + @ApiOperation({ + summary: 'Update a note', + description: 'Updates a note by its ID' + }) + @ApiParam({ + name: 'workspace_id', + type: Number, + required: true, + description: 'The ID of the workspace' + }) + @ApiParam({ + name: 'id', + type: Number, + required: true, + description: 'The ID of the note' + }) + @ApiOkResponse({ + description: 'The note has been successfully updated.', + type: UnitNoteDto + }) + @ApiNotFoundResponse({ + description: 'Note not found.' + }) + @ApiBadRequestResponse({ + description: 'Invalid input data.' + }) + async update( + @WorkspaceId() workspaceId: number, + @Param('id') id: number, + @Body() updateUnitNoteDto: UpdateUnitNoteDto + ): Promise { + try { + return await this.unitNoteService.update(id, updateUnitNoteDto); + } catch (error) { + if (error instanceof NotFoundException) { + throw error; + } + throw new BadRequestException(`Failed to update note: ${error.message}`); + } + } + + @Delete(':id') + @UseGuards(JwtAuthGuard, WorkspaceGuard) + @ApiBearerAuth() + @ApiOperation({ + summary: 'Delete a note', + description: 'Deletes a note by its ID' + }) + @ApiParam({ + name: 'workspace_id', + type: Number, + required: true, + description: 'The ID of the workspace' + }) + @ApiParam({ + name: 'id', + type: Number, + required: true, + description: 'The ID of the note' + }) + @ApiOkResponse({ + description: 'The note has been successfully deleted.', + type: Boolean + }) + @ApiNotFoundResponse({ + description: 'Note not found.' + }) + async remove( + @WorkspaceId() workspaceId: number, + @Param('id') id: number + ): Promise { + try { + return await this.unitNoteService.remove(id); + } catch (error) { + if (error instanceof NotFoundException) { + throw error; + } + throw new BadRequestException(`Failed to delete note: ${error.message}`); + } + } +} diff --git a/apps/backend/src/app/admin/unit-tags/unit-tags.controller.ts b/apps/backend/src/app/admin/unit-tags/unit-tags.controller.ts new file mode 100644 index 000000000..b2357a9e7 --- /dev/null +++ b/apps/backend/src/app/admin/unit-tags/unit-tags.controller.ts @@ -0,0 +1,236 @@ +import { + BadRequestException, + Body, + Controller, + Delete, + Get, + NotFoundException, + Param, + Patch, + Post, + UseGuards +} from '@nestjs/common'; +import { + ApiBadRequestResponse, + ApiBearerAuth, + ApiCreatedResponse, + 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 { UnitTagService } from '../../database/services/unit-tag.service'; +import { UnitTagDto } from '../../../../../../api-dto/unit-tags/unit-tag.dto'; +import { CreateUnitTagDto } from '../../../../../../api-dto/unit-tags/create-unit-tag.dto'; +import { UpdateUnitTagDto } from '../../../../../../api-dto/unit-tags/update-unit-tag.dto'; + +@ApiTags('Unit Tags') +@Controller('admin/workspace/:workspace_id/unit-tags') +export class UnitTagsController { + constructor(private readonly unitTagService: UnitTagService) {} + + @Post() + @UseGuards(JwtAuthGuard, WorkspaceGuard) + @ApiBearerAuth() + @ApiOperation({ + summary: 'Create a new unit tag', + description: 'Creates a new tag for a unit' + }) + @ApiParam({ + name: 'workspace_id', + type: Number, + required: true, + description: 'The ID of the workspace' + }) + @ApiCreatedResponse({ + description: 'The tag has been successfully created.', + type: UnitTagDto + }) + @ApiBadRequestResponse({ + description: 'Invalid input data.' + }) + @ApiNotFoundResponse({ + description: 'Unit not found.' + }) + async create( + @WorkspaceId() workspaceId: number, + @Body() createUnitTagDto: CreateUnitTagDto + ): Promise { + try { + return await this.unitTagService.create(createUnitTagDto); + } catch (error) { + if (error instanceof NotFoundException) { + throw error; + } + throw new BadRequestException(`Failed to create tag: ${error.message}`); + } + } + + @Get('unit/:unitId') + @UseGuards(JwtAuthGuard, WorkspaceGuard) + @ApiBearerAuth() + @ApiOperation({ + summary: 'Get all tags for a unit', + description: 'Retrieves all tags for a specific unit' + }) + @ApiParam({ + name: 'workspace_id', + type: Number, + required: true, + description: 'The ID of the workspace' + }) + @ApiParam({ + name: 'unitId', + type: Number, + required: true, + description: 'The ID of the unit' + }) + @ApiOkResponse({ + description: 'The tags have been successfully retrieved.', + type: [UnitTagDto] + }) + @ApiNotFoundResponse({ + description: 'Unit not found.' + }) + async findAllByUnitId( + @WorkspaceId() workspaceId: number, + @Param('unitId') unitId: number + ): Promise { + try { + return await this.unitTagService.findAllByUnitId(unitId); + } catch (error) { + if (error instanceof NotFoundException) { + throw error; + } + throw new BadRequestException(`Failed to retrieve tags: ${error.message}`); + } + } + + @Get(':id') + @UseGuards(JwtAuthGuard, WorkspaceGuard) + @ApiBearerAuth() + @ApiOperation({ + summary: 'Get a tag by ID', + description: 'Retrieves a tag by its ID' + }) + @ApiParam({ + name: 'workspace_id', + type: Number, + required: true, + description: 'The ID of the workspace' + }) + @ApiParam({ + name: 'id', + type: Number, + required: true, + description: 'The ID of the tag' + }) + @ApiOkResponse({ + description: 'The tag has been successfully retrieved.', + type: UnitTagDto + }) + @ApiNotFoundResponse({ + description: 'Tag not found.' + }) + async findOne( + @WorkspaceId() workspaceId: number, + @Param('id') id: number + ): Promise { + try { + return await this.unitTagService.findOne(id); + } catch (error) { + if (error instanceof NotFoundException) { + throw error; + } + throw new BadRequestException(`Failed to retrieve tag: ${error.message}`); + } + } + + @Patch(':id') + @UseGuards(JwtAuthGuard, WorkspaceGuard) + @ApiBearerAuth() + @ApiOperation({ + summary: 'Update a tag', + description: 'Updates a tag by its ID' + }) + @ApiParam({ + name: 'workspace_id', + type: Number, + required: true, + description: 'The ID of the workspace' + }) + @ApiParam({ + name: 'id', + type: Number, + required: true, + description: 'The ID of the tag' + }) + @ApiOkResponse({ + description: 'The tag has been successfully updated.', + type: UnitTagDto + }) + @ApiNotFoundResponse({ + description: 'Tag not found.' + }) + @ApiBadRequestResponse({ + description: 'Invalid input data.' + }) + async update( + @WorkspaceId() workspaceId: number, + @Param('id') id: number, + @Body() updateUnitTagDto: UpdateUnitTagDto + ): Promise { + try { + return await this.unitTagService.update(id, updateUnitTagDto); + } catch (error) { + if (error instanceof NotFoundException) { + throw error; + } + throw new BadRequestException(`Failed to update tag: ${error.message}`); + } + } + + @Delete(':id') + @UseGuards(JwtAuthGuard, WorkspaceGuard) + @ApiBearerAuth() + @ApiOperation({ + summary: 'Delete a tag', + description: 'Deletes a tag by its ID' + }) + @ApiParam({ + name: 'workspace_id', + type: Number, + required: true, + description: 'The ID of the workspace' + }) + @ApiParam({ + name: 'id', + type: Number, + required: true, + description: 'The ID of the tag' + }) + @ApiOkResponse({ + description: 'The tag has been successfully deleted.', + type: Boolean + }) + @ApiNotFoundResponse({ + description: 'Tag not found.' + }) + async remove( + @WorkspaceId() workspaceId: number, + @Param('id') id: number + ): Promise { + try { + return await this.unitTagService.remove(id); + } catch (error) { + if (error instanceof NotFoundException) { + throw error; + } + throw new BadRequestException(`Failed to delete tag: ${error.message}`); + } + } +} diff --git a/apps/backend/src/app/admin/users/users.controller.ts b/apps/backend/src/app/admin/users/users.controller.ts index 0e97b84b9..f9bcbe6cb 100755 --- a/apps/backend/src/app/admin/users/users.controller.ts +++ b/apps/backend/src/app/admin/users/users.controller.ts @@ -3,7 +3,8 @@ import { Controller, Delete, Get, Param, Patch, Post, UseGuards } from '@nestjs/common'; import { - ApiBearerAuth, ApiCreatedResponse, ApiMethodNotAllowedResponse, ApiOkResponse, ApiQuery, ApiTags + ApiBadRequestResponse, ApiBearerAuth, ApiBody, ApiCreatedResponse, ApiMethodNotAllowedResponse, + ApiNotFoundResponse, ApiOkResponse, ApiOperation, ApiParam, ApiQuery, ApiTags } from '@nestjs/swagger'; import { UsersService } from '../../database/services/users.service'; import { UserFullDto } from '../../../../../../api-dto/user/user-full-dto'; @@ -12,6 +13,7 @@ import { JwtAuthGuard } from '../../auth/jwt-auth.guard'; import { WorkspaceUserInListDto } from '../../../../../../api-dto/user/workspace-user-in-list-dto'; import { UserInListDto } from '../../../../../../api-dto/user/user-in-list-dto'; +@ApiTags('Admin Users') @Controller('admin/users') export class UsersController { constructor( @@ -21,7 +23,14 @@ export class UsersController { @Get('access/:workspaceId') @UseGuards(JwtAuthGuard) @ApiBearerAuth() - @ApiOkResponse({ description: 'Users with access level.' }) + @ApiOperation({ summary: 'Get users with access to workspace', description: 'Retrieves all users with their access level for a specific workspace' }) + @ApiParam({ name: 'workspaceId', type: Number, description: 'ID of the workspace' }) + @ApiOkResponse({ + description: 'Users with access level retrieved successfully.', + type: [WorkspaceUserInListDto] + }) + @ApiBadRequestResponse({ description: 'Invalid workspace ID' }) + @ApiNotFoundResponse({ description: 'Workspace not found' }) @ApiTags('users access') async findAll(@Param('workspaceId') workspaceId:number): Promise { return this.usersService.findAllUsers(workspaceId); @@ -30,7 +39,15 @@ export class UsersController { @Patch('access/:workspaceId') @UseGuards(JwtAuthGuard) @ApiBearerAuth() - @ApiOkResponse({ description: 'Group admin users retrieved successfully.' }) + @ApiOperation({ summary: 'Update users access', description: 'Updates access levels for users in a specific workspace' }) + @ApiParam({ name: 'workspaceId', type: Number, description: 'ID of the workspace' }) + @ApiBody({ + type: [UserInListDto], + description: 'Array of users with updated access levels' + }) + @ApiOkResponse({ description: 'Users access levels updated successfully.', type: Boolean }) + @ApiBadRequestResponse({ description: 'Invalid workspace ID or user data' }) + @ApiNotFoundResponse({ description: 'Workspace or users not found' }) @ApiTags('users access') async patchAll(@Param('workspaceId') workspaceId:number, @Body() users: UserInListDto[]): Promise { return this.usersService.patchAllUsers(workspaceId, users); @@ -39,9 +56,12 @@ export class UsersController { @Get('full') @UseGuards(JwtAuthGuard) @ApiBearerAuth() - @ApiCreatedResponse({ + @ApiOperation({ summary: 'Get all users with full details', description: 'Retrieves all users with their complete details' }) + @ApiOkResponse({ + description: 'Users retrieved successfully', type: [UserFullDto] }) + @ApiBadRequestResponse({ description: 'Failed to retrieve users' }) @ApiTags('admin users') async findAllFull(): Promise { return this.usersService.findAllFull(); @@ -50,17 +70,34 @@ export class UsersController { @Patch(':userId') @UseGuards(JwtAuthGuard) @ApiBearerAuth() + @ApiOperation({ summary: 'Update user', description: 'Updates a user\'s details' }) + @ApiParam({ name: 'userId', type: Number, description: 'ID of the user to update' }) + @ApiBody({ type: UserFullDto, description: 'Updated user data' }) + @ApiOkResponse({ description: 'User updated successfully', type: UserFullDto }) + @ApiBadRequestResponse({ description: 'Invalid user ID or data' }) + @ApiNotFoundResponse({ description: 'User not found' }) @ApiTags('admin users') - async editUser(@Param('userId') userId:number, @Body() change: UserFullDto): Promise { + async editUser(@Param('userId') userId:number, @Body() change: UserFullDto): Promise { return this.usersService.editUser(userId, change); } @Get(':userId/workspaces') @UseGuards(JwtAuthGuard) @ApiBearerAuth() - @ApiCreatedResponse({ - type: [UserFullDto] + @ApiOperation({ summary: 'Get user workspaces', description: 'Retrieves all workspaces associated with a user' }) + @ApiParam({ name: 'userId', type: Number, description: 'ID of the user' }) + @ApiOkResponse({ + description: 'User workspaces retrieved successfully', + schema: { + type: 'array', + items: { + type: 'number' + }, + description: 'Array of workspace IDs' + } }) + @ApiBadRequestResponse({ description: 'Invalid user ID' }) + @ApiNotFoundResponse({ description: 'User not found' }) @ApiTags('admin users') async findUserWorkspaces(@Param('userId') userId:number): Promise { return this.usersService.findUserWorkspaceIds(userId); @@ -69,8 +106,20 @@ export class UsersController { @Delete(':ids') @UseGuards(JwtAuthGuard) @ApiBearerAuth() + @ApiOperation({ + summary: 'Delete users', + description: 'Deletes one or more users by their IDs (separated by semicolons)' + }) + @ApiParam({ + name: 'ids', + description: 'Semicolon-separated list of user IDs to delete', + example: '1;2;3', + type: String + }) + @ApiOkResponse({ description: 'Users deleted successfully' }) + @ApiBadRequestResponse({ description: 'Invalid user IDs' }) + @ApiNotFoundResponse({ description: 'One or more users not found' }) @ApiTags('admin users') - @ApiOkResponse({ description: 'Admin users deleted successfully.' }) async remove(@Param('ids') ids: string): Promise { const idsAsNumberArray: number[] = []; ids.split(';').forEach(s => idsAsNumberArray.push(parseInt(s, 10))); @@ -79,25 +128,53 @@ export class UsersController { @Delete() @ApiBearerAuth() + @ApiOperation({ + summary: 'Delete users by query', + description: 'Deletes users by their IDs provided as query parameters' + }) @ApiTags('admin users') @ApiQuery({ name: 'id', type: Number, isArray: true, - required: false + required: false, + description: 'IDs of users to delete' }) - @ApiOkResponse({ description: 'Admin users deleted successfully.' }) - @ApiMethodNotAllowedResponse({ description: 'Active admin user must not be deleted.' }) + @ApiOkResponse({ description: 'Users deleted successfully' }) + @ApiBadRequestResponse({ description: 'Invalid user IDs' }) + @ApiNotFoundResponse({ description: 'One or more users not found' }) + @ApiMethodNotAllowedResponse({ description: 'Active admin user must not be deleted' }) async removeIds(ids: number[]): Promise { return this.usersService.removeIds(ids); } @Post(':userId/workspaces') + @UseGuards(JwtAuthGuard) @ApiBearerAuth() + @ApiOperation({ + summary: 'Set user workspaces', + description: 'Assigns workspaces to a user' + }) + @ApiParam({ + name: 'userId', + type: Number, + description: 'ID of the user' + }) + @ApiBody({ + schema: { + type: 'array', + items: { + type: 'number' + }, + description: 'Array of workspace IDs to assign to the user' + } + }) @ApiCreatedResponse({ - description: 'Sends back the id of the new user in database', + description: 'Workspaces assigned successfully', type: Number }) + @ApiBadRequestResponse({ description: 'Invalid user ID or workspace IDs' }) + @ApiNotFoundResponse({ description: 'User or workspaces not found' }) @ApiTags('admin users') async setUserWorkspaces(@Body() workspaceIds: number[], @Param('userId') userId: number) { @@ -105,11 +182,21 @@ export class UsersController { } @Post() + @UseGuards(JwtAuthGuard) @ApiBearerAuth() + @ApiOperation({ + summary: 'Create a new user', + description: 'Creates a new user with the provided data' + }) + @ApiBody({ + type: CreateUserDto, + description: 'User data to create' + }) @ApiCreatedResponse({ - description: 'Sends back the id of the new user in database', + description: 'User created successfully. Returns the ID of the new user.', type: Number }) + @ApiBadRequestResponse({ description: 'Invalid user data' }) @ApiTags('admin users') async create(@Body() createUserDto: CreateUserDto) { return this.usersService.create(createUserDto); diff --git a/apps/backend/src/app/admin/workspace/workspace-coding.controller.ts b/apps/backend/src/app/admin/workspace/workspace-coding.controller.ts new file mode 100644 index 000000000..d5d8d45cc --- /dev/null +++ b/apps/backend/src/app/admin/workspace/workspace-coding.controller.ts @@ -0,0 +1,176 @@ +import { + Controller, + Get, Query, Res, UseGuards +} from '@nestjs/common'; +import { + ApiOkResponse, + ApiParam, ApiQuery, ApiTags +} from '@nestjs/swagger'; +import { Response } from 'express'; +import { CodingStatistics } from '../../database/services/shared-types'; +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'; + +@ApiTags('Admin Workspace Coding') +@Controller('admin/workspace') +export class WorkspaceCodingController { + constructor( + private workspaceCodingService: WorkspaceCodingService + ) {} + + @Get(':workspace_id/coding') + @UseGuards(JwtAuthGuard, WorkspaceGuard) + @ApiParam({ name: 'workspace_id', type: Number }) + @ApiOkResponse({ + description: 'Coding statistics retrieved successfully.' + }) + async codeTestPersons(@Query('testPersons') testPersons: string, @WorkspaceId() workspace_id: number): Promise { + return this.workspaceCodingService.codeTestPersons(workspace_id, testPersons); + } + + @Get(':workspace_id/coding/manual') + @UseGuards(JwtAuthGuard, WorkspaceGuard) + @ApiParam({ name: 'workspace_id', type: Number }) + async getManualTestPersons(@Query('testPersons') testPersons: string, @WorkspaceId() workspace_id: number): Promise { + return this.workspaceCodingService.getManualTestPersons(workspace_id, testPersons); + } + + @Get(':workspace_id/coding/coding-list') + @UseGuards(JwtAuthGuard, WorkspaceGuard) + @ApiTags('coding') + @ApiQuery({ + name: 'identity', + required: false, + description: 'User identity for token generation', + type: String + }) + @ApiQuery({ + name: 'serverUrl', + required: false, + description: 'Server URL to use for generating links', + type: String + }) + @ApiQuery({ + name: 'page', + required: false, + description: 'Page number for pagination', + type: Number + }) + @ApiQuery({ + name: 'limit', + required: false, + description: 'Number of items per page', + type: Number + }) + @ApiOkResponse({ + description: 'List of incomplete coding items retrieved successfully.', + schema: { + type: 'object', + properties: { + data: { + type: 'array', + items: { + type: 'object', + properties: { + unit_key: { type: 'string' }, + unit_alias: { type: 'string' }, + login_name: { type: 'string' }, + login_code: { type: 'string' }, + booklet_id: { type: 'string' }, + variable_id: { type: 'string' }, + variable_page: { type: 'string' }, + variable_anchor: { type: 'string' }, + url: { type: 'string' } + } + } + }, + total: { type: 'number' }, + page: { type: 'number' }, + limit: { type: 'number' } + } + } + }) + async getCodingList(@WorkspaceId() workspace_id: number, @Query('authToken') authToken: string, @Query('serverUrl') serverUrl: string, @Query('page') page: number = 1, @Query('limit') limit: number = 20): Promise<{ + data: { + unit_key: string; + unit_alias: string; + login_name: string; + login_code: string; + booklet_id: string; + variable_id: string; + variable_page: string; + variable_anchor: string; + url: string; + }[]; + total: number; + page: number; + limit: number; + }> { + const [items, total] = await this.workspaceCodingService.getCodingList(workspace_id, authToken, serverUrl, { page, limit }); + return { + data: items, + total, + page, + limit + }; + } + + @Get(':workspace_id/coding/coding-list/csv') + @UseGuards(JwtAuthGuard, WorkspaceGuard) + @ApiTags('coding') + @ApiParam({ name: 'workspace_id', type: Number }) + @ApiOkResponse({ + description: 'Coding list exported as CSV', + content: { + 'text/csv': { + schema: { + type: 'string', + format: 'binary' + } + } + } + }) + async getCodingListAsCsv(@WorkspaceId() workspace_id: number, @Res() res: Response): Promise { + const csvData = await this.workspaceCodingService.getCodingListAsCsv(workspace_id); + + res.setHeader('Content-Type', 'text/csv'); + res.setHeader('Content-Disposition', `attachment; filename="coding-list-${new Date().toISOString().slice(0, 10)}.csv"`); + res.send(csvData); + } + + @Get(':workspace_id/coding/coding-list/excel') + @UseGuards(JwtAuthGuard, WorkspaceGuard) + @ApiTags('coding') + @ApiParam({ name: 'workspace_id', type: Number }) + @ApiOkResponse({ + description: 'Coding list exported as Excel', + content: { + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': { + schema: { + type: 'string', + format: 'binary' + } + } + } + }) + async getCodingListAsExcel(@WorkspaceId() workspace_id: number, @Res() res: Response): Promise { + const excelData = await this.workspaceCodingService.getCodingListAsExcel(workspace_id); + + res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'); + res.setHeader('Content-Disposition', `attachment; filename="coding-list-${new Date().toISOString().slice(0, 10)}.xlsx"`); + res.send(excelData); + } + + @Get(':workspace_id/coding/statistics') + @UseGuards(JwtAuthGuard, WorkspaceGuard) + @ApiTags('coding') + @ApiParam({ name: 'workspace_id', type: Number }) + @ApiOkResponse({ + description: 'Coding statistics retrieved successfully.' + }) + async getCodingStatistics(@WorkspaceId() workspace_id: number): Promise { + return this.workspaceCodingService.getCodingStatistics(workspace_id); + } +} diff --git a/apps/backend/src/app/admin/workspace/workspace-files.controller.ts b/apps/backend/src/app/admin/workspace/workspace-files.controller.ts new file mode 100644 index 000000000..3f076489d --- /dev/null +++ b/apps/backend/src/app/admin/workspace/workspace-files.controller.ts @@ -0,0 +1,187 @@ +import { + BadRequestException, + Controller, + Delete, + Get, InternalServerErrorException, Param, Post, Query, UseGuards, UseInterceptors, UploadedFiles +} from '@nestjs/common'; +import { + ApiBadRequestResponse, + ApiBearerAuth, ApiBody, ApiConsumes, ApiNotFoundResponse, ApiOkResponse, ApiOperation, + ApiParam, ApiQuery, ApiTags +} from '@nestjs/swagger'; +import { FilesInterceptor } from '@nestjs/platform-express'; +import { logger } from 'nx/src/utils/logger'; +import { FilesDto } from '../../../../../../api-dto/files/files.dto'; +import { JwtAuthGuard } from '../../auth/jwt-auth.guard'; +import { WorkspaceGuard } from './workspace.guard'; +import { FileDownloadDto } from '../../../../../../api-dto/files/file-download.dto'; +import { FileValidationResultDto } from '../../../../../../api-dto/files/file-validation-result.dto'; +import { WorkspaceFilesService } from '../../database/services/workspace-files.service'; + +@ApiTags('Admin Workspace Files') +@Controller('admin/workspace') +export class WorkspaceFilesController { + constructor( + private workspaceFilesService: WorkspaceFilesService + ) {} + + @Get(':workspace_id/files') + @ApiTags('admin workspace') + @ApiBearerAuth() + @ApiOperation({ summary: 'Get workspace files', description: 'Retrieves paginated files associated with a workspace' }) + @ApiParam({ + name: 'workspace_id', + type: Number, + required: true, + description: 'The unique ID of the workspace for which the files should be retrieved.' + }) + @ApiQuery({ + name: 'page', + required: false, + description: 'Page number for pagination', + type: Number + }) + @ApiQuery({ + name: 'limit', + required: false, + description: 'Number of items per page', + type: Number + }) + @ApiOkResponse({ + description: 'Files retrieved successfully.', + schema: { + type: 'object', + properties: { + data: { type: 'array', items: { $ref: '#/components/schemas/FilesDto' } }, + total: { type: 'number' }, + page: { type: 'number' }, + limit: { type: 'number' } + } + } + }) + @ApiNotFoundResponse({ + description: 'The requested workspace could not be found.' + }) + @ApiBadRequestResponse({ + description: 'Invalid workspace ID or error fetching files.' + }) + @UseGuards(JwtAuthGuard, WorkspaceGuard) + async findFiles( + @Param('workspace_id') workspace_id: number, + @Query('page') page: number = 1, + @Query('limit') limit: number = 20 + ): Promise<{ data: FilesDto[]; total: number; page: number; limit: number }> { + if (!workspace_id || workspace_id <= 0) { + throw new BadRequestException( + 'Invalid workspace ID. Please provide a valid ID.' + ); + } + try { + const [files, total] = await this.workspaceFilesService.findFiles(workspace_id, { page, limit }); + return { + data: files, + total, + page, + limit + }; + } catch (error) { + throw new BadRequestException( + `An error occurred while fetching files for workspace ${workspace_id}: ${error.message}` + ); + } + } + + @Delete(':workspace_id/files') + @ApiTags('ws admin test-files') + @UseGuards(JwtAuthGuard, WorkspaceGuard) + async deleteTestFiles(@Query() query: { fileIds: string }, + @Param('workspace_id') workspace_id: number) { + return this.workspaceFilesService.deleteTestFiles(workspace_id, query.fileIds.split(';')); + } + + @Get(':workspace_id/files/validation') + @ApiTags('test files validation') + @UseGuards(JwtAuthGuard, WorkspaceGuard) + @ApiOperation({ summary: 'Validate test files', description: 'Validates test files and returns a hierarchical view of expected files and their status' }) + @ApiParam({ name: 'workspace_id', type: Number, description: 'ID of the workspace' }) + @ApiOkResponse({ + description: 'Files validation result', + type: FileValidationResultDto + }) + async validateTestFiles( + @Param('workspace_id') workspace_id: number): Promise { + return this.workspaceFilesService.validateTestFiles(workspace_id); + } + + @Post(':workspace_id/upload') + @UseGuards(JwtAuthGuard, WorkspaceGuard) + @ApiBearerAuth() + @ApiOperation({ summary: 'Upload test files', description: 'Uploads test files to a workspace' }) + @ApiParam({ name: 'workspace_id', type: Number, description: 'ID of the workspace' }) + @UseInterceptors(FilesInterceptor('files')) + @ApiConsumes('multipart/form-data') + @ApiBody({ + schema: { + type: 'object', + properties: { + files: { + type: 'array', + items: { + type: 'string', + format: 'binary' + } + } + } + } + }) + @ApiOkResponse({ description: 'Files uploaded successfully', type: Boolean }) + @ApiBadRequestResponse({ description: 'Invalid workspace ID or no files uploaded' }) + @ApiTags('workspace') + async addTestFiles( + @Param('workspace_id') workspaceId: number, + @UploadedFiles() files: Express.Multer.File[] + ): Promise { + if (!workspaceId) { + throw new BadRequestException('Workspace ID is required.'); + } + + if (!files || files.length === 0) { + throw new BadRequestException('At least one file must be uploaded.'); + } + + try { + return await this.workspaceFilesService.uploadTestFiles(workspaceId, files); + } catch (error) { + logger.error('Error uploading test files:'); + return false; + } + } + + @Get(':workspace_id/files/:fileId/download') + @UseGuards(JwtAuthGuard, WorkspaceGuard) + @ApiBearerAuth() + @ApiOperation({ summary: 'Download a file', description: 'Downloads a specific file from a workspace' }) + @ApiParam({ name: 'workspace_id', type: Number, description: 'ID of the workspace' }) + @ApiParam({ name: 'fileId', type: Number, description: 'ID of the file to download' }) + @ApiOkResponse({ + description: 'File downloaded successfully', + type: FileDownloadDto + }) + @ApiBadRequestResponse({ description: 'Invalid workspace ID or file ID' }) + @ApiNotFoundResponse({ description: 'File not found' }) + @ApiTags('workspace') + async downloadFile( + @Param('workspace_id') workspaceId: number, @Param('fileId') fileId: number + ): Promise { + if (!workspaceId) { + logger.error('Workspace ID is required.'); + throw new BadRequestException('Workspace ID is required.'); + } + try { + return await this.workspaceFilesService.downloadTestFile(workspaceId, fileId); + } catch (error) { + logger.error(`'Error downloading test file:' ${error}`); + throw new InternalServerErrorException('Unable to download the file. Please try again later.'); + } + } +} diff --git a/apps/backend/src/app/admin/workspace/workspace-player.controller.ts b/apps/backend/src/app/admin/workspace/workspace-player.controller.ts new file mode 100644 index 000000000..4df69a8d8 --- /dev/null +++ b/apps/backend/src/app/admin/workspace/workspace-player.controller.ts @@ -0,0 +1,63 @@ +import { + Controller, + Get, Param, UseGuards +} from '@nestjs/common'; +import { + ApiParam, ApiTags +} from '@nestjs/swagger'; +import { JwtAuthGuard } from '../../auth/jwt-auth.guard'; +import { WorkspaceGuard } from './workspace.guard'; +import { WorkspaceId } from './workspace.decorator'; +import { FilesDto } from '../../../../../../api-dto/files/files.dto'; +import FileUpload from '../../database/entities/file_upload.entity'; +import { WorkspacePlayerService } from '../../database/services/workspace-player.service'; +import { ResponseEntity } from '../../database/entities/response.entity'; + +@ApiTags('Admin Workspace Player') +@Controller('admin/workspace') +export class WorkspacePlayerController { + constructor( + private workspacePlayerService: WorkspacePlayerService + ) {} + + @Get(':workspace_id/player/:playerName') + @ApiParam({ name: 'workspace_id', type: Number }) + @UseGuards(JwtAuthGuard, WorkspaceGuard) + async findPlayer(@Param('workspace_id') workspace_id: number, + @Param('playerName') playerName:string): Promise { + return this.workspacePlayerService.findPlayer(Number(workspace_id), playerName); + } + + @Get(':workspace_id/units/:testPerson') + @UseGuards(JwtAuthGuard, WorkspaceGuard) + @ApiParam({ name: 'workspace_id', type: Number }) + async findTestPersonUnits(@WorkspaceId() id: number, @Param('testPerson') testPerson:string): Promise { + return this.workspacePlayerService.findTestPersonUnits(id, testPerson); + } + + @Get(':workspace_id/test-groups') + @UseGuards(JwtAuthGuard, WorkspaceGuard) + @ApiParam({ name: 'workspace_id', type: Number }) + async findTestPersons(@WorkspaceId() id: number): Promise { + return this.workspacePlayerService.findTestPersons(id); + } + + @Get(':workspace_id/:unit/unitDef') + @UseGuards(JwtAuthGuard, WorkspaceGuard) + @ApiParam({ name: 'workspace_id', type: Number }) + async findUnitDef(@Param('workspace_id') workspace_id:number, + @Param('unit') unit:string): Promise { + const unitIdToUpperCase = unit.toUpperCase(); + return this.workspacePlayerService.findUnitDef(workspace_id, unitIdToUpperCase); + } + + @Get(':workspace_id/unit/:testPerson/:unitId') + @UseGuards(JwtAuthGuard, WorkspaceGuard) + @ApiParam({ name: 'workspace_id', type: Number }) + async findUnit(@WorkspaceId() id: number, + @Param('testPerson') testPerson:string, + @Param('unitId') unitId:string): Promise { + const unitIdToUpperCase = unitId.toUpperCase(); + return this.workspacePlayerService.findUnit(id, testPerson, unitIdToUpperCase); + } +} diff --git a/apps/backend/src/app/admin/workspace/workspace-test-center.controller.ts b/apps/backend/src/app/admin/workspace/workspace-test-center.controller.ts new file mode 100644 index 000000000..b76a68414 --- /dev/null +++ b/apps/backend/src/app/admin/workspace/workspace-test-center.controller.ts @@ -0,0 +1,100 @@ +import { + Controller, + Get, Param, Query, UseGuards +} from '@nestjs/common'; +import { + ApiBadRequestResponse, + ApiOkResponse, ApiOperation, + ApiParam, ApiQuery, ApiTags +} from '@nestjs/swagger'; +import { TestcenterService } from '../../database/services/testcenter.service'; +import { JwtAuthGuard } from '../../auth/jwt-auth.guard'; +import { WorkspaceGuard } from './workspace.guard'; +import { TestGroupsInfoDto } from '../../../../../../api-dto/files/test-groups-info.dto'; +import { + ImportOptions +} from '../../../../../frontend/src/app/ws-admin/components/test-center-import/test-center-import.component'; + +export type Result = { + success: boolean, + testFiles: number, + responses: number, + logs: number +}; + +@ApiTags('Admin Workspace Test Center') +@Controller('admin/workspace') +export class WorkspaceTestCenterController { + constructor( + private testCenterService: TestcenterService + ) {} + + @Get(':workspace_id/importWorkspaceFiles') + @UseGuards(JwtAuthGuard, WorkspaceGuard) + @ApiOperation({ summary: 'Import workspace files', description: 'Imports files from a test center into the workspace' }) + @ApiParam({ name: 'workspace_id', required: true, description: 'ID of the workspace' }) + @ApiQuery({ name: 'server', required: true, description: 'Server address' }) + @ApiQuery({ name: 'url', required: true, description: 'URL of the test center' }) + @ApiQuery({ name: 'tc_workspace', required: true, description: 'Test center workspace ID' }) + @ApiQuery({ name: 'token', required: true, description: 'Authentication token' }) + @ApiQuery({ name: 'definitions', required: false, description: 'Include definitions' }) + @ApiQuery({ name: 'responses', required: false, description: 'Include responses' }) + @ApiQuery({ name: 'logs', required: false, description: 'Include logs' }) + @ApiQuery({ name: 'player', required: false, description: 'Include player' }) + @ApiQuery({ name: 'units', required: false, description: 'Include units' }) + @ApiQuery({ name: 'codings', required: false, description: 'Include codings' }) + @ApiQuery({ name: 'testTakers', required: false, description: 'Include test takers' }) + @ApiQuery({ name: 'testGroups', required: false, description: 'Include test groups' }) + @ApiQuery({ name: 'booklets', required: false, description: 'Include booklets' }) + @ApiOkResponse({ description: 'Files imported successfully', type: Object }) + @ApiBadRequestResponse({ description: 'Failed to import files' }) + async importWorkspaceFiles( + @Param('workspace_id') workspace_id: string, + @Query('server') server: string, + @Query('url') url: string, + @Query('tc_workspace') tc_workspace: string, + @Query('token') token: string, + @Query('definitions') definitions: string, + @Query('responses') responses: string, + @Query('logs') logs: string, + @Query('player') player: string, + @Query('units') units: string, + @Query('codings') codings: string, + @Query('testTakers') testTakers: string, + @Query('testGroups') testGroups: string, + @Query('booklets') booklets: string) + : Promise { + const importOptions:ImportOptions = { + definitions: definitions, + responses: responses, + units: units, + player: player, + codings: codings, + logs: logs, + booklets: booklets, + testTakers: testTakers + }; + + return this.testCenterService.importWorkspaceFiles(workspace_id, tc_workspace, server, decodeURIComponent(url), token, importOptions, testGroups); + } + + @Get(':workspace_id/importWorkspaceFiles/testGroups') + @UseGuards(JwtAuthGuard, WorkspaceGuard) + @ApiOperation({ summary: 'Get test groups for import', description: 'Retrieves test groups from a test center for import' }) + @ApiParam({ name: 'workspace_id', required: true, description: 'ID of the workspace' }) + @ApiQuery({ name: 'server', required: true, description: 'Server address' }) + @ApiQuery({ name: 'url', required: true, description: 'URL of the test center' }) + @ApiQuery({ name: 'tc_workspace', required: true, description: 'Test center workspace ID' }) + @ApiQuery({ name: 'token', required: true, description: 'Authentication token' }) + @ApiOkResponse({ description: 'Test groups retrieved successfully', type: [TestGroupsInfoDto] }) + @ApiBadRequestResponse({ description: 'Failed to retrieve test groups' }) + async getImportTestcenterGroups( + @Param('workspace_id') workspace_id: string, + @Query('server') server: string, + @Query('url') url: string, + @Query('tc_workspace') tc_workspace: string, + @Query('token') token: string) + : Promise { + return this.testCenterService.getTestgroups(workspace_id, tc_workspace, server, decodeURIComponent(url), token); + } +} diff --git a/apps/backend/src/app/admin/workspace/workspace-test-results.controller.ts b/apps/backend/src/app/admin/workspace/workspace-test-results.controller.ts new file mode 100644 index 000000000..e5ff55365 --- /dev/null +++ b/apps/backend/src/app/admin/workspace/workspace-test-results.controller.ts @@ -0,0 +1,328 @@ +import { + BadRequestException, + Controller, + Delete, + Get, Param, Post, Query, UseGuards, UseInterceptors, UploadedFiles +} from '@nestjs/common'; +import { + ApiBadRequestResponse, + ApiBearerAuth, ApiBody, ApiConsumes, ApiOkResponse, ApiOperation, + ApiParam, ApiQuery, ApiTags +} from '@nestjs/swagger'; +import { FilesInterceptor } from '@nestjs/platform-express'; +import { logger } from 'nx/src/utils/logger'; +import { JwtAuthGuard } from '../../auth/jwt-auth.guard'; +import { WorkspaceGuard } from './workspace.guard'; +import { WorkspaceId } from './workspace.decorator'; +import { UploadResultsService } from '../../database/services/upload-results.service'; +import Persons from '../../database/entities/persons.entity'; +import { ResponseEntity } from '../../database/entities/response.entity'; +import { WorkspaceTestResultsService } from '../../database/services/workspace-test-results.service'; + +@ApiTags('Admin Workspace Test Results') +@Controller('admin/workspace') +export class WorkspaceTestResultsController { + constructor( + private workspaceTestResultsService: WorkspaceTestResultsService, + private uploadResults: UploadResultsService + ) {} + + @Get(':workspace_id/test-results') + @ApiOperation({ summary: 'Get test results', description: 'Retrieves paginated test results for a workspace' }) + @ApiParam({ name: 'workspace_id', type: Number, description: 'ID of the workspace' }) + @ApiQuery({ + name: 'page', + required: false, + description: 'Page number for pagination', + type: Number + }) + @ApiQuery({ + name: 'limit', + required: false, + description: 'Number of items per page', + type: Number + }) + @ApiOkResponse({ + description: 'Test results retrieved successfully.', + schema: { + type: 'object', + properties: { + data: { type: 'array', items: { type: 'object' } }, + total: { type: 'number' }, + page: { type: 'number' }, + limit: { type: 'number' } + } + } + }) + @ApiBadRequestResponse({ description: 'Failed to retrieve test results' }) + @UseGuards(JwtAuthGuard, WorkspaceGuard) + async findTestResults( + @Param('workspace_id') workspace_id: number, + @Query('page') page: number = 1, + @Query('limit') limit: number = 20 + ): Promise<{ data: Persons[]; total: number; page: number; limit: number }> { + const [data, total] = await this.workspaceTestResultsService.findTestResults(workspace_id, { page, limit }); + return { + data, total, page, limit + }; + } + + @Get(':workspace_id/test-results/:personId') + @ApiOperation({ + summary: 'Get test results for a specific person', + description: 'Retrieves detailed test results for a specific person in a workspace' + }) + @ApiParam({ name: 'workspace_id', type: Number, description: 'ID of the workspace' }) + @ApiParam({ name: 'personId', type: Number, description: 'ID of the person' }) + @ApiOkResponse({ + description: 'Test results retrieved successfully.', + schema: { + type: 'array', + items: { + type: 'object', + properties: { + id: { type: 'number', description: 'ID of the test result' }, + personid: { type: 'number', description: 'ID of the person' }, + name: { type: 'string', description: 'Name of the person' }, + size: { type: 'number', description: 'Size of the test results' }, + logs: { + type: 'array', + description: 'Logs associated with the test', + items: { + type: 'object', + properties: { + id: { type: 'number' }, + bookletid: { type: 'number' }, + ts: { type: 'string', description: 'Timestamp' }, + parameter: { type: 'string' }, + key: { type: 'string' } + } + } + }, + units: { + type: 'array', + description: 'Units associated with the test', + items: { + type: 'object', + properties: { + id: { type: 'number' }, + bookletid: { type: 'number' }, + name: { type: 'string' }, + alias: { type: 'string', nullable: true }, + results: { + type: 'array', + items: { + type: 'object', + properties: { + id: { type: 'number' }, + unitid: { type: 'number' } + } + } + }, + logs: { + type: 'array', + items: { + type: 'object', + properties: { + id: { type: 'number' }, + unitid: { type: 'number' }, + ts: { type: 'string', description: 'Timestamp' }, + key: { type: 'string' }, + parameter: { type: 'string' } + } + } + } + } + } + } + } + } + } + }) + @ApiBadRequestResponse({ description: 'Failed to retrieve test results' }) + @UseGuards(JwtAuthGuard, WorkspaceGuard) + async findPersonTestResults( + @Param('workspace_id') workspace_id: number, + @Param('personId') personId: number + ): Promise<{ + id: number; + personid: number; + name: string; + size: number; + logs: { id: number; bookletid: number; ts: string; parameter: string, key: string }[]; + units: { + id: number; + bookletid: number; + name: string; + alias: string | null; + results: { id: number; unitid: number }[]; + logs: { id: number; unitid: number; ts: string; key: string; parameter: string }[]; + }[]; + }[]> { + return this.workspaceTestResultsService.findPersonTestResults(personId, workspace_id); + } + + @Delete(':workspace_id/test-results') + @UseGuards(JwtAuthGuard, WorkspaceGuard) + async deleteTestGroups( + @Query('testPersons')testPersonIds:string, + @Param('workspace_id')workspaceId:string): Promise<{ + success: boolean; + report: { + deletedPersons: string[]; + warnings: string[]; + }; + }> { + return this.workspaceTestResultsService.deleteTestPersons(Number(workspaceId), testPersonIds); + } + + @Get(':workspace_id/responses') + @UseGuards(JwtAuthGuard, WorkspaceGuard) + @ApiParam({ name: 'workspace_id', type: Number }) + @ApiQuery({ + name: 'page', + required: false, + description: 'Page number for pagination', + type: Number + }) + @ApiQuery({ + name: 'limit', + required: false, + description: 'Number of items per page', + type: Number + }) + @ApiOkResponse({ + description: 'Responses retrieved successfully.', + schema: { + type: 'object', + properties: { + data: { type: 'array', items: { $ref: '#/components/schemas/ResponseDto' } }, + total: { type: 'number' }, + page: { type: 'number' }, + limit: { type: 'number' } + } + } + }) + async findWorkspaceResponse(@WorkspaceId() id: number, @Query('page') page: number = 1, @Query('limit') limit: number = 20): Promise<{ data: ResponseEntity[]; total: number; page: number; limit: number }> { + const [responses, total] = await this.workspaceTestResultsService.findWorkspaceResponses(id, { page, limit }); + return { + data: responses, + total, + page, + limit + }; + } + + @Get(':workspace_id/responses/:testPerson/:unitId') + @UseGuards(JwtAuthGuard, WorkspaceGuard) + @ApiParam({ name: 'workspace_id', type: Number }) + async findResponse(@WorkspaceId() id: number, + @Param('testPerson') testPerson:string, + @Param('unitId') unitId:string): Promise<{ responses: { id: string, content: { id: string; value: string; status: string }[] }[] }> { + return this.workspaceTestResultsService.findUnitResponse(id, testPerson, unitId); + } + + @Get(':workspace_id/coding/responses/:status') + @UseGuards(JwtAuthGuard, WorkspaceGuard) + @ApiTags('coding') + @ApiParam({ name: 'workspace_id', type: Number }) + @ApiParam({ name: 'status', type: String }) + @ApiQuery({ + name: 'page', + required: false, + description: 'Page number for pagination', + type: Number + }) + @ApiQuery({ + name: 'limit', + required: false, + description: 'Number of items per page', + type: Number + }) + @ApiOkResponse({ + description: 'Responses with the specified status retrieved successfully.', + schema: { + type: 'object', + properties: { + data: { type: 'array', items: { $ref: '#/components/schemas/ResponseEntity' } }, + total: { type: 'number' }, + page: { type: 'number' }, + limit: { type: 'number' } + } + } + }) + async getResponsesByStatus(@WorkspaceId() workspace_id: number, @Param('status') status: string, @Query('page') page: number = 1, @Query('limit') limit: number = 20): Promise<{ data: ResponseEntity[]; total: number; page: number; limit: number }> { + const [responses, total] = await this.workspaceTestResultsService.getResponsesByStatus(workspace_id, status, { page, limit }); + return { + data: responses, + total, + page, + limit + }; + } + + @Post(':workspace_id/upload/results/:resultType') + @UseGuards(JwtAuthGuard, WorkspaceGuard) + @ApiBearerAuth() + @ApiOperation({ + summary: 'Upload test results', + description: 'Uploads test results (logs or responses) to a workspace' + }) + @ApiParam({ + name: 'workspace_id', + type: Number, + required: true, + description: 'The ID of the workspace to which test results should be uploaded.' + }) + @ApiParam({ + name: 'resultType', + enum: ['logs', 'responses'], + required: true, + description: 'Type of results to upload (logs or responses)' + }) + @UseInterceptors(FilesInterceptor('files')) + @ApiConsumes('multipart/form-data') + @ApiBody({ + schema: { + type: 'object', + properties: { + files: { + type: 'array', + items: { + type: 'string', + format: 'binary' + }, + description: 'Result files to upload' + } + } + } + }) + @ApiTags('workspace') + @ApiOkResponse({ + description: 'Test results successfully uploaded.', + type: Boolean + }) + @ApiBadRequestResponse({ + description: 'Invalid request. Please check your input data.' + }) + async addTestResults( + @Param('workspace_id') workspace_id: number, + @Param('resultType') resultType: 'logs' | 'responses', + @UploadedFiles() files: Express.Multer.File[] + ): Promise { + if (!workspace_id || Number.isNaN(workspace_id)) { + throw new BadRequestException('Invalid workspace_id.'); + } + + if (!files || files.length === 0) { + throw new BadRequestException('No files were uploaded.'); + } + + try { + return await this.uploadResults.uploadTestResults(workspace_id, files, resultType); + } catch (error) { + logger.error('Error uploading test results!'); + throw new BadRequestException('Uploading test results failed. Please try again.'); + } + } +} diff --git a/apps/backend/src/app/admin/workspace/workspace-users.controller.ts b/apps/backend/src/app/admin/workspace/workspace-users.controller.ts new file mode 100644 index 000000000..9089a5d24 --- /dev/null +++ b/apps/backend/src/app/admin/workspace/workspace-users.controller.ts @@ -0,0 +1,135 @@ +import { + BadRequestException, + Body, + Controller, + Get, Param, Post, Query, UseGuards +} from '@nestjs/common'; +import { + ApiBadRequestResponse, + ApiBearerAuth, ApiBody, ApiCreatedResponse, ApiNotFoundResponse, ApiOkResponse, ApiOperation, + ApiParam, ApiQuery, ApiTags +} from '@nestjs/swagger'; +import { logger } from 'nx/src/utils/logger'; +import { JwtAuthGuard } from '../../auth/jwt-auth.guard'; +import { WorkspaceGuard } from './workspace.guard'; +import { AuthService } from '../../auth/service/auth.service'; +import WorkspaceUser from '../../database/entities/workspace_user.entity'; +import { WorkspaceUsersService } from '../../database/services/workspace-users.service'; + +@ApiTags('Admin Workspace Users') +@Controller('admin/workspace') +export class WorkspaceUsersController { + constructor( + private workspaceUsersService: WorkspaceUsersService, + private authService: AuthService + ) {} + + @Get(':workspace_id/:user_id/token/:duration') + @ApiBearerAuth() + @ApiTags('admin workspace') + @ApiOperation({ summary: 'Create authentication token', description: 'Creates a JWT token for a user in a specific workspace with a specified duration' }) + @ApiParam({ name: 'workspace_id', required: true, description: 'ID of the workspace' }) + @ApiParam({ name: 'user_id', required: true, description: 'ID of the user' }) + @ApiParam({ name: 'duration', required: true, description: 'Duration of the token in seconds' }) + @ApiOkResponse({ description: 'Token created successfully', type: String }) + @ApiBadRequestResponse({ description: 'Invalid input parameters' }) + @UseGuards(JwtAuthGuard, WorkspaceGuard) + async createToken( + @Param('user_id') userId: string, + @Param('workspace_id') workspaceId: number, + @Param('duration') duration: number + ): Promise { + if (!userId || !workspaceId || !duration) { + throw new BadRequestException('Invalid input parameters'); + } + logger.log(`Generating token for user ${userId} in workspace ${workspaceId} with duration ${duration}s`); + + return this.authService.createToken(userId, workspaceId, duration); + } + + @Get(':workspace_id/users') + @ApiTags('admin workspace users') + @ApiBearerAuth() + @ApiParam({ + name: 'workspace_id', + type: Number, + required: true, + description: 'Unique identifier for the workspace' + }) + @ApiQuery({ + name: 'page', + required: false, + description: 'Page number for pagination', + type: Number + }) + @ApiQuery({ + name: 'limit', + required: false, + description: 'Number of items per page', + type: Number + }) + @ApiOkResponse({ + description: 'List of users retrieved successfully', + schema: { + type: 'object', + properties: { + data: { type: 'array', items: { $ref: '#/components/schemas/WorkspaceUser' } }, + total: { type: 'number' }, + page: { type: 'number' }, + limit: { type: 'number' } + } + } + }) + @ApiNotFoundResponse({ + description: 'Workspace not found or no users available' + }) + @UseGuards(JwtAuthGuard, WorkspaceGuard) + async findUsers( + @Param('workspace_id') workspaceId: number, + @Query('page') page: number = 1, + @Query('limit') limit: number = 20 + ): Promise<{ data: WorkspaceUser[]; total: number; page: number; limit: number }> { + try { + const [users, total] = await this.workspaceUsersService.findUsers(workspaceId, { page, limit }); + return { + data: users, + total, + page, + limit + }; + } catch (error) { + logger.error(`Error retrieving users for workspace ${workspaceId}`); + return { + data: [], + total: 0, + page, + limit + }; + } + } + + @Post(':workspaceId/users') + @ApiBearerAuth() + @UseGuards(JwtAuthGuard, WorkspaceGuard) + @ApiOperation({ summary: 'Set workspace users', description: 'Assigns users to a workspace' }) + @ApiParam({ name: 'workspaceId', type: Number, description: 'ID of the workspace' }) + @ApiBody({ + schema: { + type: 'array', + items: { + type: 'number' + }, + description: 'Array of user IDs to assign to the workspace' + } + }) + @ApiCreatedResponse({ + description: 'Sends back the id of the new user in database', + type: Number + }) + @ApiBadRequestResponse({ description: 'Invalid user IDs or workspace ID' }) + @ApiTags('admin users') + async setWorkspaceUsers(@Body() userIds: number[], + @Param('workspaceId') workspaceId: number) { + return this.workspaceUsersService.setWorkspaceUsers(workspaceId, userIds); + } +} diff --git a/apps/backend/src/app/admin/workspace/workspace.controller.spec.ts b/apps/backend/src/app/admin/workspace/workspace.controller.spec.ts index afef8dd07..8ed177d04 100755 --- a/apps/backend/src/app/admin/workspace/workspace.controller.spec.ts +++ b/apps/backend/src/app/admin/workspace/workspace.controller.spec.ts @@ -2,9 +2,9 @@ import { Test, TestingModule } from '@nestjs/testing'; import { createMock } from '@golevelup/ts-jest'; import { WorkspaceController } from './workspace.controller'; import { AuthService } from '../../auth/service/auth.service'; -import { WorkspaceService } from '../../database/services/workspace.service'; import { UsersService } from '../../database/services/users.service'; import { TestcenterService } from '../../database/services/testcenter.service'; +import { UploadResultsService } from '../../database/services/upload-results.service'; // ggf. anpassen, falls anderer Pfad describe('WorkspaceController', () => { let controller: WorkspaceController; @@ -19,15 +19,15 @@ describe('WorkspaceController', () => { }, { provide: TestcenterService, - useValue: createMock() - }, - { - provide: WorkspaceService, - useValue: createMock() + useValue: createMock() }, { provide: UsersService, useValue: createMock() + }, + { + provide: UploadResultsService, + useValue: createMock() // Mock-Implementierung für UploadResultsService } ] }).compile(); diff --git a/apps/backend/src/app/admin/workspace/workspace.controller.ts b/apps/backend/src/app/admin/workspace/workspace.controller.ts index cb270199f..5f7f237ba 100755 --- a/apps/backend/src/app/admin/workspace/workspace.controller.ts +++ b/apps/backend/src/app/admin/workspace/workspace.controller.ts @@ -1,215 +1,107 @@ import { + BadRequestException, Body, Controller, Delete, Get, Param, Patch, - Post, Query, UploadedFiles, UseGuards, UseInterceptors + Post, Query, UseGuards } from '@nestjs/common'; import { - ApiBearerAuth, ApiCreatedResponse, ApiNotFoundResponse, ApiOkResponse, ApiParam, ApiTags + ApiBadRequestResponse, + ApiBearerAuth, ApiBody, ApiCreatedResponse, ApiNotFoundResponse, ApiOkResponse, ApiOperation, + ApiParam, ApiQuery, ApiTags } from '@nestjs/swagger'; -import { FilesInterceptor } from '@nestjs/platform-express'; +import { logger } from 'nx/src/utils/logger'; import { WorkspaceInListDto } from '../../../../../../api-dto/workspaces/workspace-in-list-dto'; import { WorkspaceFullDto } from '../../../../../../api-dto/workspaces/workspace-full-dto'; import { CreateWorkspaceDto } from '../../../../../../api-dto/workspaces/create-workspace-dto'; -import { WorkspaceService } from '../../database/services/workspace.service'; +import { WorkspaceCoreService } from '../../database/services/workspace-core.service'; import { WorkspaceId } from './workspace.decorator'; -import { FilesDto } from '../../../../../../api-dto/files/files.dto'; -import { TestcenterService } from '../../database/services/testcenter.service'; -import { - ImportOptions -} from '../../../../../frontend/src/app/ws-admin/components/test-center-import/test-center-import.component'; import { JwtAuthGuard } from '../../auth/jwt-auth.guard'; import { WorkspaceGuard } from './workspace.guard'; -import { AuthService } from '../../auth/service/auth.service'; -import { TestGroupsInListDto } from '../../../../../../api-dto/test-groups/testgroups-in-list.dto'; -import FileUpload from '../../database/entities/file_upload.entity'; -import { ResponseDto } from '../../../../../../api-dto/responses/response-dto'; -import WorkspaceUser from '../../database/entities/workspace_user.entity'; - -export type Result = { - success: boolean, - testFiles: number, - responses: number, - logs: number -}; +@ApiTags('Admin Workspace') @Controller('admin/workspace') export class WorkspaceController { constructor( - private workspaceService: WorkspaceService, - private testCenterService: TestcenterService, - private authService: AuthService + private workspaceCoreService: WorkspaceCoreService ) {} @Get() @ApiBearerAuth() @UseGuards(JwtAuthGuard) - @ApiOkResponse({ description: 'Admin workspace retrieved successfully.' }) @ApiTags('admin workspaces') - async findAll(): Promise { - return this.workspaceService.findAll(); - } - - @Get(':workspace_id/:user_id/token/:duration') // TODO push - @UseGuards(JwtAuthGuard) - async createToken( - @Param('user_id') - user_id:string, - @Param('workspace_id') workspace_id: number, - @Param('duration') duration: number - ):Promise { - return this.authService.createToken(user_id, workspace_id, duration); - } - - // TODO Don't use boolean query params as strings - @Get(':workspace_id/importWorkspaceFiles') - @UseGuards(JwtAuthGuard, WorkspaceGuard) - async importWorkspaceFiles( - @Param('workspace_id') workspace_id: string, - @Query('server') server: string, - @Query('url') url: string, - @Query('tc_workspace') tc_workspace: string, - @Query('token') token: string, - @Query('definitions') definitions: string, - @Query('responses') responses: string, - @Query('logs') logs: string, - @Query('player') player: string, - @Query('units') units: string, - @Query('codings') codings: string, - @Query('testTakers') testTakers: string, - @Query('booklets') booklets: string) - : Promise { - const importOptions:ImportOptions = { - definitions: definitions, - responses: responses, - units: units, - player: player, - codings: codings, - logs: logs, - booklets: booklets, - testTakers: testTakers - }; - return this.testCenterService.importWorkspaceFiles(workspace_id, tc_workspace, server, url, token, importOptions); + @ApiOperation({ summary: 'Get all workspaces', description: 'Retrieves a paginated list of all admin workspaces' }) + @ApiQuery({ + name: 'page', + required: false, + description: 'Page number for pagination', + type: Number + }) + @ApiQuery({ + name: 'limit', + required: false, + description: 'Number of items per page', + type: Number + }) + @ApiOkResponse({ + description: 'List of admin workspaces retrieved successfully.', + schema: { + type: 'object', + properties: { + data: { type: 'array', items: { $ref: '#/components/schemas/WorkspaceInListDto' } }, + total: { type: 'number' }, + page: { type: 'number' }, + limit: { type: 'number' } + } + } + }) + @ApiBadRequestResponse({ description: 'Failed to retrieve admin workspaces' }) + async findAll( + @Query('page') page: number = 1, + @Query('limit') limit: number = 20 + ): Promise<{ data: WorkspaceInListDto[]; total: number; page: number; limit: number }> { + try { + const [workspaces, total] = await this.workspaceCoreService.findAll({ page, limit }); + return { + data: workspaces, + total, + page, + limit + }; + } catch (error) { + throw new BadRequestException('Failed to retrieve admin workspaces. Please try again later.'); + } } @Get(':workspace_id') @ApiBearerAuth() - @ApiOkResponse({ description: 'Admin workspace-group retrieved successfully.' }) - @ApiNotFoundResponse({ description: 'Admin workspace not found.' }) - @ApiParam({ name: 'workspace_id', type: Number }) @ApiTags('admin workspaces') + @ApiOkResponse({ + description: 'Admin workspace retrieved successfully.', + type: WorkspaceFullDto + }) + @ApiNotFoundResponse({ description: 'Admin workspace not found.' }) + @ApiBadRequestResponse({ description: 'Invalid workspace ID.' }) + @ApiParam({ + name: 'workspace_id', + type: Number, + description: 'Unique identifier of the workspace' + }) @UseGuards(JwtAuthGuard, WorkspaceGuard) async findOne(@WorkspaceId() id: number): Promise { - return this.workspaceService.findOne(id); - } - - @Get(':workspace_id/files') - @ApiParam({ name: 'workspace_id', type: Number }) - @UseGuards(JwtAuthGuard, WorkspaceGuard) - async findFiles(@Param('workspace_id') workspace_id: number): Promise { - return this.workspaceService.findFiles(workspace_id); - } - - @Get(':workspace_id/users') - @ApiParam({ name: 'workspace_id', type: Number }) - @UseGuards(JwtAuthGuard, WorkspaceGuard) - async findUsers(@Param('workspace_id') workspace_id: number): Promise { - return this.workspaceService.findUsers(workspace_id); - } - - // Todo: use query params - @Delete(':workspace_id/files/:ids') - @ApiTags('ws admin test-files') - @UseGuards(JwtAuthGuard, WorkspaceGuard) - async deleteTestFiles( - @Param('workspace_id') workspace_id: number, - @Param('ids')ids : string) { - return this.workspaceService.deleteTestFiles(workspace_id, ids.split(';')); - } - - @Get(':workspace_id/player/:playerName') - @ApiParam({ name: 'workspace_id', type: Number }) - @UseGuards(JwtAuthGuard, WorkspaceGuard) - async findPlayer(@Param('workspace_id') workspace_id: number, - @Param('playerName') playerName:string): Promise { - return this.workspaceService.findPlayer(workspace_id, playerName); - } - - @Get(':workspace_id/units/:testPerson') - @UseGuards(JwtAuthGuard, WorkspaceGuard) - @ApiParam({ name: 'workspace_id', type: Number }) - async findTestPersonUnits(@WorkspaceId() id: number, @Param('testPerson') testPerson:string): Promise { - return this.workspaceService.findTestPersonUnits(id, testPerson); - } - - @Get(':workspace_id/test-groups') - @UseGuards(JwtAuthGuard, WorkspaceGuard) - @ApiParam({ name: 'workspace_id', type: Number }) - async findTestGroups(@Param('workspace_id') workspace_id:number): Promise { - return this.workspaceService.findTestGroups(workspace_id); - } - - // Todo: use query params - @Delete(':workspace_id/test-groups/:testGroupNames') - @UseGuards(JwtAuthGuard, WorkspaceGuard) - async deleteTestGroups( - @Param('testGroupNames')testGroupNames:string, - @Param('workspace_id')workspaceId:string): Promise { - const splittedTestGroupNames = testGroupNames.split(';'); - return this.workspaceService.deleteTestGroups(workspaceId, splittedTestGroupNames); - } - - @Get(':workspace_id/test-groups/:testGroup') - @UseGuards(JwtAuthGuard, WorkspaceGuard) - @ApiParam({ name: 'workspace_id', type: Number }) - async findTestPersons(@WorkspaceId() id: number, @Param('testGroup') testGroup:string): Promise { - return this.workspaceService.findTestPersons(id, testGroup); - } - - @Get(':workspace_id/:unit/unitDef') - @UseGuards(JwtAuthGuard, WorkspaceGuard) - @ApiParam({ name: 'workspace_id', type: Number }) - async findUnitDef(@Param('workspace_id') workspace_id:number, - @Param('unit') unit:string): Promise { - const unitIdToUpperCase = unit.toUpperCase(); - return this.workspaceService.findUnitDef(workspace_id, unitIdToUpperCase); - } - - @Get(':workspace_id/unit/:testPerson/:unitId') - @UseGuards(JwtAuthGuard, WorkspaceGuard) - @ApiParam({ name: 'workspace_id', type: Number }) - async findUnit(@WorkspaceId() id: number, - @Param('testPerson') testPerson:string, - @Param('unitId') unitId:string): Promise { - const unitIdToUpperCase = unitId.toUpperCase(); - return this.workspaceService.findUnit(id, testPerson, unitIdToUpperCase); - } - - @Get(':workspace_id/responses/:testPerson/:unitId') - @UseGuards(JwtAuthGuard, WorkspaceGuard) - @ApiParam({ name: 'workspace_id', type: Number }) - async findResponse(@WorkspaceId() id: number, - @Param('testPerson') testPerson:string, - @Param('unitId') unitId:string): Promise { - return this.workspaceService.findResponse(id, testPerson, unitId); - } - - @Get(':workspace_id/responses') - @UseGuards(JwtAuthGuard, WorkspaceGuard) - @ApiParam({ name: 'workspace_id', type: Number }) - async findWorkspaceResponse(@WorkspaceId() id: number): Promise { - return this.workspaceService.findWorkspaceResponses(id); - } - - @Post(':workspace_id/upload') - @UseGuards(JwtAuthGuard, WorkspaceGuard) - @ApiBearerAuth() - @ApiParam({ name: 'workspace_id', type: Number }) - @UseInterceptors(FilesInterceptor('files')) - @ApiTags('workspace') - async addTestFiles(@Param('workspace_id') workspace_id:number, @UploadedFiles() files): Promise { - return this.workspaceService.uploadTestFiles(workspace_id, files); + if (!id || id <= 0) { + throw new BadRequestException('Invalid workspace ID.'); + } + try { + const workspace = await this.workspaceCoreService.findOne(id); + if (!workspace) { + logger.error('Admin workspace not found.'); + } + return workspace; + } catch (error) { + throw new BadRequestException(`Failed to retrieve workspace: ${error.message}`); + } } // TODO: use query params @@ -217,44 +109,59 @@ export class WorkspaceController { @Delete(':ids') @UseGuards(JwtAuthGuard) @ApiBearerAuth() + @ApiOperation({ + summary: 'Delete workspaces', + description: 'Deletes one or more workspaces by their IDs (separated by semicolons)' + }) + @ApiParam({ + name: 'ids', + description: 'Semicolon-separated list of workspace IDs to delete', + example: '1;2;3', + type: String + }) @ApiOkResponse({ description: 'Admin workspaces deleted successfully.' }) - @ApiNotFoundResponse({ description: 'Admin workspace not found.' }) // TODO: not implemented + @ApiNotFoundResponse({ description: 'Admin workspace not found.' }) + @ApiBadRequestResponse({ description: 'Invalid workspace IDs' }) @ApiTags('admin workspaces') async remove(@Param('ids') ids: string): Promise { const idsAsNumberArray: number[] = ids.split(';').map(idString => parseInt(idString, 10)); - return this.workspaceService.remove(idsAsNumberArray); + return this.workspaceCoreService.remove(idsAsNumberArray); } @Patch() @ApiBearerAuth() @UseGuards(JwtAuthGuard) + @ApiOperation({ + summary: 'Update workspace', + description: 'Updates an existing workspace with the provided data' + }) + @ApiBody({ + type: WorkspaceFullDto, + description: 'Updated workspace data' + }) + @ApiOkResponse({ description: 'Workspace updated successfully' }) + @ApiBadRequestResponse({ description: 'Invalid workspace data' }) + @ApiNotFoundResponse({ description: 'Workspace not found' }) @ApiTags('admin workspaces') async patch(@Body() workspaces: WorkspaceFullDto) { - return this.workspaceService.patch(workspaces); + return this.workspaceCoreService.patch(workspaces); } @Post() @UseGuards(JwtAuthGuard) @ApiBearerAuth() + @ApiOperation({ summary: 'Create a new workspace', description: 'Creates a new workspace with the provided data' }) + @ApiBody({ + type: CreateWorkspaceDto, + description: 'Workspace data to create' + }) @ApiCreatedResponse({ description: 'Sends back the id of the new workspace in database', type: Number }) + @ApiBadRequestResponse({ description: 'Invalid workspace data' }) @ApiTags('admin workspaces') async create(@Body() createWorkspaceDto: CreateWorkspaceDto) { - return this.workspaceService.create(createWorkspaceDto); - } - - @Post(':workspaceId/users') - @ApiBearerAuth() - @UseGuards(JwtAuthGuard, WorkspaceGuard) - @ApiCreatedResponse({ - description: 'Sends back the id of the new user in database', - type: Number - }) - @ApiTags('admin users') - async setWorkspaceUsers(@Body() userIds: number[], - @Param('workspaceId') workspaceId: number) { - return this.workspaceService.setWorkspaceUsers(workspaceId, userIds); + return this.workspaceCoreService.create(createWorkspaceDto); } } diff --git a/apps/backend/src/app/app.controller.spec.ts b/apps/backend/src/app/app.controller.spec.ts index 693870ede..3a6b00cde 100755 --- a/apps/backend/src/app/app.controller.spec.ts +++ b/apps/backend/src/app/app.controller.spec.ts @@ -1,7 +1,6 @@ import { Test } from '@nestjs/testing'; import { createMock } from '@golevelup/ts-jest'; import { AppController } from './app.controller'; -import { WorkspaceService } from './database/services/workspace.service'; import { AuthService } from './auth/service/auth.service'; import { UsersService } from './database/services/users.service'; import { TestcenterService } from './database/services/testcenter.service'; @@ -22,9 +21,6 @@ describe('AppController', () => { { provide: TestcenterService, useValue: createMock() - }, { - provide: WorkspaceService, - useValue: createMock() } ] }).compile(); diff --git a/apps/backend/src/app/app.controller.ts b/apps/backend/src/app/app.controller.ts index ba1464b63..9bfa71049 100755 --- a/apps/backend/src/app/app.controller.ts +++ b/apps/backend/src/app/app.controller.ts @@ -8,16 +8,16 @@ import { AuthService } from './auth/service/auth.service'; import { CreateUserDto } from '../../../../api-dto/user/create-user-dto'; import { AuthDataDto } from '../../../../api-dto/auth-data-dto'; import { UsersService } from './database/services/users.service'; -import { WorkspaceService } from './database/services/workspace.service'; import { JwtAuthGuard } from './auth/jwt-auth.guard'; import { TestcenterService } from './database/services/testcenter.service'; +import { WorkspaceUsersService } from './database/services/workspace-users.service'; @Controller() export class AppController { constructor(private authService:AuthService, private usersService: UsersService, private testCenterService: TestcenterService, - private workspaceService:WorkspaceService) {} + private workspaceUsersService: WorkspaceUsersService) {} @Get('auth-data') @UseGuards(JwtAuthGuard) @@ -25,7 +25,7 @@ export class AppController { @ApiTags('auth') async findCanDos(@Query('identity')identity:string): Promise { const user = await this.usersService.findUserByIdentity(identity); - const workspaces = await this.workspaceService.findAllUserWorkspaces(identity); + const workspaces = await this.workspaceUsersService.findAllUserWorkspaces(identity); return { userId: user.id, userName: user.username, @@ -45,7 +45,7 @@ export class AppController { @Post('tc_authentication') async authenticate( @Body() credentials: { username: string, password: string, server:string, url:string } - ): Promise { + ): Promise> { return this.testCenterService.authenticate(credentials); } } diff --git a/apps/backend/src/app/database/database.module.ts b/apps/backend/src/app/database/database.module.ts index caefb2c94..12b64aef0 100755 --- a/apps/backend/src/app/database/database.module.ts +++ b/apps/backend/src/app/database/database.module.ts @@ -2,17 +2,40 @@ import { Module } from '@nestjs/common'; import { ConfigModule, ConfigService } from '@nestjs/config'; import { TypeOrmModule } from '@nestjs/typeorm'; import { HttpModule } from '@nestjs/axios'; +import { JwtService } from '@nestjs/jwt'; import User from './entities/user.entity'; import { UsersService } from './services/users.service'; -import { WorkspaceService } from './services/workspace.service'; +import { WorkspaceCoreService } from './services/workspace-core.service'; +import { WorkspaceFilesService } from './services/workspace-files.service'; +import { WorkspaceTestResultsService } from './services/workspace-test-results.service'; +import { WorkspaceUsersService } from './services/workspace-users.service'; +import { WorkspaceCodingService } from './services/workspace-coding.service'; +import { WorkspacePlayerService } from './services/workspace-player.service'; import Workspace from './entities/workspace.entity'; import WorkspaceAdmin from './entities/workspace-admin.entity'; import FileUpload from './entities/file_upload.entity'; -import Responses from './entities/responses.entity'; import WorkspaceUser from './entities/workspace_user.entity'; import { TestcenterService } from './services/testcenter.service'; import ResourcePackage from './entities/resource-package.entity'; import Logs from './entities/logs.entity'; +import Persons from './entities/persons.entity'; +import { UploadResultsService } from './services/upload-results.service'; +import { BookletLog } from './entities/bookletLog.entity'; +import { Unit } from './entities/unit.entity'; +import { Booklet } from './entities/booklet.entity'; +import { BookletInfo } from './entities/bookletInfo.entity'; +import { UnitLog } from './entities/unitLog.entity'; +import { UnitLastState } from './entities/unitLastState.entity'; +import { ChunkEntity } from './entities/chunk.entity'; +import { ResponseEntity } from './entities/response.entity'; +import { Session } from './entities/session.entity'; +import { UnitTag } from './entities/unitTag.entity'; +import { UnitNote } from './entities/unitNote.entity'; +import { PersonService } from './services/person.service'; +import { AuthService } from '../auth/service/auth.service'; +import { UnitTagService } from './services/unit-tag.service'; +import { UnitNoteService } from './services/unit-note.service'; +import { ResourcePackageService } from './services/resource-package.service'; @Module({ imports: [ @@ -21,7 +44,14 @@ import Logs from './entities/logs.entity'; Workspace, WorkspaceAdmin, FileUpload, - Responses, + Persons, + Unit, + BookletLog, + Session, + UnitLastState, + UnitLog, + ResponseEntity, + ChunkEntity, ResourcePackage, WorkspaceUser, HttpModule, @@ -34,8 +64,8 @@ import Logs from './entities/logs.entity'; username: configService.get('POSTGRES_USER'), password: configService.get('POSTGRES_PASSWORD'), database: configService.get('POSTGRES_DB'), - entities: [ - User, Workspace, WorkspaceAdmin, FileUpload, Responses, WorkspaceUser, ResourcePackage, Logs + entities: [BookletInfo, Booklet, Session, BookletLog, Unit, UnitLog, UnitLastState, ResponseEntity, + User, Workspace, WorkspaceAdmin, FileUpload, WorkspaceUser, ResourcePackage, Logs, Persons, ChunkEntity, BookletLog, Session, UnitLog, UnitTag, UnitNote ], synchronize: false }), @@ -47,24 +77,62 @@ import Logs from './entities/logs.entity'; WorkspaceAdmin, FileUpload, Logs, - Responses, + ResponseEntity, WorkspaceUser, - ResourcePackage + ResourcePackage, + Persons, + Booklet, + BookletInfo, + Unit, + ChunkEntity, + BookletLog, + UnitLog, + UnitLastState, + Session, + UnitTag, + UnitNote ]) ], - providers: [UsersService, WorkspaceService, TestcenterService], + providers: [ + UsersService, + WorkspaceCoreService, + WorkspaceFilesService, + WorkspaceTestResultsService, + WorkspaceUsersService, + WorkspaceCodingService, + WorkspacePlayerService, + TestcenterService, + UploadResultsService, + PersonService, + AuthService, + JwtService, + UnitTagService, + UnitNoteService, + ResourcePackageService + ], exports: [ User, FileUpload, Logs, - Responses, + Persons, Workspace, WorkspaceAdmin, - WorkspaceService, + WorkspaceCoreService, + WorkspaceFilesService, + WorkspaceTestResultsService, + WorkspaceUsersService, + WorkspaceCodingService, + WorkspacePlayerService, UsersService, WorkspaceUser, TestcenterService, - ResourcePackage + UploadResultsService, + ResourcePackageService, + ResourcePackage, + PersonService, + AuthService, + UnitTagService, + UnitNoteService ] }) export class DatabaseModule {} diff --git a/apps/backend/src/app/database/entities/booklet.entity.ts b/apps/backend/src/app/database/entities/booklet.entity.ts new file mode 100644 index 000000000..0a5085f3d --- /dev/null +++ b/apps/backend/src/app/database/entities/booklet.entity.ts @@ -0,0 +1,68 @@ +import { + Entity, + Column, + PrimaryGeneratedColumn, + ManyToOne, + JoinColumn, + OneToMany, + Index +} from 'typeorm'; + +import { BookletInfo } from './bookletInfo.entity'; +// eslint-disable-next-line import/no-cycle +import { BookletLog } from './bookletLog.entity'; +// eslint-disable-next-line import/no-cycle +import { Unit } from './unit.entity'; +// eslint-disable-next-line import/no-cycle +import { Session } from './session.entity'; +// eslint-disable-next-line import/no-cycle +import Persons from './persons.entity'; + +@Entity('booklet') +@Index(['personid', 'infoid']) // Composite index for common query patterns +export class Booklet { + @PrimaryGeneratedColumn() + id: number; + + @Index() + @Column({ type: 'bigint' }) + infoid: number; + + @Index() + @Column({ type: 'bigint' }) + personid: number; + + @Column({ type: 'bigint', default: 0 }) + lastts: number; + + @Column({ type: 'bigint', default: 0 }) + firstts: number; + + @ManyToOne(() => Persons, person => person.booklets, { + onDelete: 'CASCADE', + // Eager loading for person as it's frequently accessed with booklet + eager: true + }) + @JoinColumn({ name: 'personid' }) + person: Persons; + + @ManyToOne(() => BookletInfo, { + onDelete: 'CASCADE', + // Eager loading for bookletinfo as it's frequently accessed with booklet + eager: true + }) + @JoinColumn({ name: 'infoid' }) + bookletinfo: BookletInfo; + + @OneToMany(() => Session, session => session.booklet) + sessions: Session[]; + + @OneToMany(() => BookletLog, bookletLog => bookletLog.booklet) + bookletLogs: BookletLog[]; + + @OneToMany(() => Unit, unit => unit.booklet, { + // Cascade operations to units when booklet is modified + cascade: true + }) + units: Unit[]; +} diff --git a/apps/backend/src/app/database/entities/booklet.interface.ts b/apps/backend/src/app/database/entities/booklet.interface.ts new file mode 100644 index 000000000..43740f2c6 --- /dev/null +++ b/apps/backend/src/app/database/entities/booklet.interface.ts @@ -0,0 +1,12 @@ +// common/interfaces/booklet.interface.ts +export interface IBooklet { + id: number; + title: string; + sessions?: ISession[]; +} + +export interface ISession { + id: number; + ts: number; + booklet?: IBooklet; +} diff --git a/apps/backend/src/app/database/entities/bookletInfo.entity.ts b/apps/backend/src/app/database/entities/bookletInfo.entity.ts new file mode 100644 index 000000000..0cb0c65e4 --- /dev/null +++ b/apps/backend/src/app/database/entities/bookletInfo.entity.ts @@ -0,0 +1,17 @@ +import { + Entity, Column, PrimaryGeneratedColumn, Unique +} from 'typeorm'; + +@Entity('bookletinfo') +@Unique('bookletinfo_pk', ['name']) + +export class BookletInfo { + @PrimaryGeneratedColumn() + id: number; + + @Column({ type: 'text' }) + name: string; + + @Column({ type: 'bigint', default: 0 }) + size: number; +} diff --git a/apps/backend/src/app/database/entities/bookletLog.entity.ts b/apps/backend/src/app/database/entities/bookletLog.entity.ts new file mode 100644 index 000000000..8ba3393e3 --- /dev/null +++ b/apps/backend/src/app/database/entities/bookletLog.entity.ts @@ -0,0 +1,29 @@ +import { + Entity, Column, PrimaryGeneratedColumn, ManyToOne, JoinColumn +} from 'typeorm'; +// eslint-disable-next-line import/no-cycle +import { Booklet } from './booklet.entity'; + +@Entity('bookletlog') +export class BookletLog { + @PrimaryGeneratedColumn() + id: number; + + @Column({ type: 'bigint' }) + bookletid: number; + + @Column({ type: 'text' }) + key: string; + + @Column({ type: 'text', nullable: true }) + parameter: string; + + @Column({ type: 'bigint', nullable: true }) + ts: number; + + @ManyToOne(() => Booklet, booklet => booklet.bookletLogs, { + onDelete: 'CASCADE' + }) + @JoinColumn({ name: 'bookletid' }) + booklet: Booklet; +} diff --git a/apps/backend/src/app/database/entities/chunk.entity.ts b/apps/backend/src/app/database/entities/chunk.entity.ts new file mode 100644 index 000000000..b55555f13 --- /dev/null +++ b/apps/backend/src/app/database/entities/chunk.entity.ts @@ -0,0 +1,29 @@ +import { + Entity, Column, ManyToOne, JoinColumn, PrimaryColumn +} from 'typeorm'; +// eslint-disable-next-line import/no-cycle +import { Unit } from './unit.entity'; + +@Entity('chunk') +export class ChunkEntity { + @PrimaryColumn({ type: 'bigint' }) + unitid: number; + + @Column({ type: 'text' }) + key: string; + + @Column({ type: 'text', nullable: true }) + type: string; + + @Column({ type: 'text', nullable: true }) + variables: string; + + @Column({ type: 'bigint', nullable: true }) + ts: number; + + @ManyToOne(() => Unit, unit => unit.chunks, { + onDelete: 'CASCADE' + }) + @JoinColumn({ name: 'unitid' }) + unit: Unit; +} diff --git a/apps/backend/src/app/database/entities/file_upload.entity.ts b/apps/backend/src/app/database/entities/file_upload.entity.ts index e98f30b31..4141a625f 100755 --- a/apps/backend/src/app/database/entities/file_upload.entity.ts +++ b/apps/backend/src/app/database/entities/file_upload.entity.ts @@ -1,5 +1,5 @@ import { - Column, Entity, PrimaryColumn, Unique + Column, Entity, Index, PrimaryColumn, Unique } from 'typeorm'; @Entity() @@ -11,12 +11,14 @@ class FileUpload { @Column({ type: 'varchar' }) filename: string; + @Index() @Column({ type: 'integer' }) workspace_id: number; @Column({ type: 'integer' }) file_size: number; + @Index() @Column({ type: 'varchar' }) file_type: string; diff --git a/apps/backend/src/app/database/entities/persons.entity.ts b/apps/backend/src/app/database/entities/persons.entity.ts new file mode 100755 index 000000000..685ff91ba --- /dev/null +++ b/apps/backend/src/app/database/entities/persons.entity.ts @@ -0,0 +1,50 @@ +import { + Column, Entity, Index, PrimaryGeneratedColumn, Unique, OneToMany +} from 'typeorm'; +import { TcMergeBooklet } from '../services/shared-types'; +// eslint-disable-next-line import/no-cycle +import { Booklet } from './booklet.entity'; + +@Entity() +@Unique('persons_pk', ['code', 'group', 'login']) +@Index(['workspace_id', 'code']) // Composite index for common query patterns +@Index(['workspace_id', 'group']) // Composite index for filtering by group within workspace + +class Persons { + @PrimaryGeneratedColumn() + id!: number; + + @Index() + @Column({ type: 'varchar' }) + login!: string; + + @Index() + @Column({ type: 'varchar' }) + code!: string; + + @Index() + @Column({ type: 'varchar' }) + group!: string; + + @Index() + @Column({ type: 'integer' }) + workspace_id!: number; + + @Column({ type: 'timestamp' }) + uploaded_at!: Date; + + @Column({ type: 'jsonb' }) + booklets: TcMergeBooklet[]; + + @Column({ type: 'varchar' }) + source!: string; + + // Add explicit relationship to Booklet entity + @OneToMany(() => Booklet, booklet => booklet.person, { + // Cascade operations to booklets when person is modified + cascade: true + }) + booklets_relation!: Booklet[]; +} + +export default Persons; diff --git a/apps/backend/src/app/database/entities/resource-package.entity.ts b/apps/backend/src/app/database/entities/resource-package.entity.ts index 5f964f7e6..83b88ebe4 100644 --- a/apps/backend/src/app/database/entities/resource-package.entity.ts +++ b/apps/backend/src/app/database/entities/resource-package.entity.ts @@ -5,12 +5,22 @@ class ResourcePackage { @PrimaryGeneratedColumn() id: number; + @Column() + workspaceId: number; + @Column() name: string; @Column('text', { array: true }) elements; + @Column({ + type: 'bigint', + name: 'package_size', + default: 0 + }) + packageSize: number; + @Column({ type: 'timestamp with time zone', name: 'created_at' diff --git a/apps/backend/src/app/database/entities/response.entity.ts b/apps/backend/src/app/database/entities/response.entity.ts new file mode 100644 index 000000000..f2b245d0d --- /dev/null +++ b/apps/backend/src/app/database/entities/response.entity.ts @@ -0,0 +1,48 @@ +import { + Entity, Column, ManyToOne, JoinColumn, PrimaryGeneratedColumn, Index +} from 'typeorm'; + +// eslint-disable-next-line import/no-cycle +import { Unit } from './unit.entity'; + +@Entity('response') +@Index(['unitid', 'variableid']) // Composite index for common query patterns +@Index(['unitid', 'status']) // Composite index for filtering by status +@Index(['codedstatus']) // Index for filtering by coded status +export class ResponseEntity { + @PrimaryGeneratedColumn() + id: number; + + @Index() + @Column({ type: 'bigint' }) + unitid: number; + + @Index() + @Column({ type: 'text' }) + variableid: string; + + @Column({ type: 'text' }) + status: string; + + @Column({ type: 'text', nullable: true }) + value: string; + + @Column({ type: 'text', nullable: true }) + subform: string; + + @Column({ type: 'bigint', nullable: true }) + code: number | null; + + @Column({ type: 'bigint', nullable: true }) + score: number | null; + + @Column({ type: 'text' }) + codedstatus: string; + + @ManyToOne(() => Unit, unit => unit.responses, { + onDelete: 'CASCADE' + // Not using eager loading here to avoid performance issues with large result sets + }) + @JoinColumn({ name: 'unitid' }) + unit: Unit; +} diff --git a/apps/backend/src/app/database/entities/responses.entity.ts b/apps/backend/src/app/database/entities/responses.entity.ts deleted file mode 100755 index 4122dc1ba..000000000 --- a/apps/backend/src/app/database/entities/responses.entity.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { - Column, Entity, PrimaryGeneratedColumn, Unique -} from 'typeorm'; - -@Entity() -@Unique('response_id', ['test_person', 'unit_id']) -class Responses { - @PrimaryGeneratedColumn('increment') - id: number; - - @Column({ type: 'varchar' }) - test_person!: string; - - @Column({ type: 'varchar' }) - unit_id!: string; - - @Column({ type: 'varchar' }) - test_group!: string; - - @Column({ type: 'integer' }) - workspace_id!: number; - - @Column({ type: 'timestamp' }) - created_at: Date; - - @Column({ type: 'jsonb' }) - responses: Array<{ id: string; content: string; ts: number; responseType: string }> | undefined; - - @Column({ type: 'jsonb' }) - unit_state: unknown | undefined; - - @Column({ type: 'varchar' }) - booklet_id: string; -} - -export default Responses; diff --git a/apps/backend/src/app/database/entities/session.entity.ts b/apps/backend/src/app/database/entities/session.entity.ts new file mode 100644 index 000000000..e4a0c2296 --- /dev/null +++ b/apps/backend/src/app/database/entities/session.entity.ts @@ -0,0 +1,34 @@ +import { + Entity, Column, PrimaryGeneratedColumn, ManyToOne, JoinColumn, Index +} from 'typeorm'; + +// eslint-disable-next-line import/no-cycle +import { Booklet } from './booklet.entity'; + +@Entity('session') +export class Session { + @PrimaryGeneratedColumn() + id: number; + + @Column({ type: 'text', nullable: true }) + browser: string; + + @Column({ type: 'text', nullable: true }) + os: string; + + @Column({ type: 'text', nullable: true }) + screen: string; + + @Column({ type: 'bigint', nullable: true }) + ts: number; + + @Column({ type: 'bigint', nullable: true }) + loadcompletems: number; + + @Index() + @ManyToOne(() => Booklet, booklet => booklet.sessions, { + onDelete: 'CASCADE' + }) + @JoinColumn({ name: 'bookletid' }) + booklet: Booklet; +} diff --git a/apps/backend/src/app/database/entities/unit.entity.ts b/apps/backend/src/app/database/entities/unit.entity.ts new file mode 100644 index 000000000..9290c18ff --- /dev/null +++ b/apps/backend/src/app/database/entities/unit.entity.ts @@ -0,0 +1,85 @@ +import { + Entity, + Column, + PrimaryGeneratedColumn, + ManyToOne, + JoinColumn, + OneToMany, + Index +} from 'typeorm'; +// eslint-disable-next-line import/no-cycle +import { Booklet } from './booklet.entity'; +// eslint-disable-next-line import/no-cycle +import { UnitLog } from './unitLog.entity'; +// eslint-disable-next-line import/no-cycle +import { UnitLastState } from './unitLastState.entity'; +// eslint-disable-next-line import/no-cycle +import { ChunkEntity } from './chunk.entity'; +// eslint-disable-next-line import/no-cycle +import { ResponseEntity } from './response.entity'; +// eslint-disable-next-line import/no-cycle +import { UnitTag } from './unitTag.entity'; +// eslint-disable-next-line import/no-cycle +import { UnitNote } from './unitNote.entity'; + +@Entity('unit') +@Index(['bookletid', 'alias']) // Composite index for common query patterns +export class Unit { + @PrimaryGeneratedColumn() + id: number; + + @Index() + @Column({ type: 'bigint' }) + bookletid: number; + + @Index() + @Column({ type: 'text' }) + name: string; + + @Index() + @Column({ type: 'text', nullable: true }) + alias: string; + + @ManyToOne(() => Booklet, booklet => booklet.units, { + onDelete: 'CASCADE' + // Not using eager loading here to avoid circular eager loading with Booklet + }) + @JoinColumn({ name: 'bookletid' }) + booklet: Booklet; + + @OneToMany(() => UnitLog, unitLog => unitLog.unit, { + // Cascade operations to unit logs when unit is modified + cascade: true + }) + unitLogs: UnitLog[]; + + @OneToMany(() => UnitLastState, unitLastState => unitLastState.unit, { + // Cascade operations to unit last states when unit is modified + cascade: true + }) + unitLastStates: UnitLastState[]; + + @OneToMany(() => ChunkEntity, chunk => chunk.unit, { + // Cascade operations to chunks when unit is modified + cascade: true + }) + chunks: ChunkEntity[]; + + @OneToMany(() => ResponseEntity, response => response.unit, { + // Cascade operations to responses when unit is modified + cascade: true + }) + responses: ResponseEntity[]; + + @OneToMany(() => UnitTag, unitTag => unitTag.unit, { + // Cascade operations to unit tags when unit is modified + cascade: true + }) + tags: UnitTag[]; + + @OneToMany(() => UnitNote, unitNote => unitNote.unit, { + // Cascade operations to unit notes when unit is modified + cascade: true + }) + notes: UnitNote[]; +} diff --git a/apps/backend/src/app/database/entities/unitLastState.entity.ts b/apps/backend/src/app/database/entities/unitLastState.entity.ts new file mode 100644 index 000000000..3a3a363b8 --- /dev/null +++ b/apps/backend/src/app/database/entities/unitLastState.entity.ts @@ -0,0 +1,23 @@ +import { + Entity, Column, ManyToOne, JoinColumn, PrimaryColumn +} from 'typeorm'; +// eslint-disable-next-line import/no-cycle +import { Unit } from './unit.entity'; + +@Entity('unitlaststate') +export class UnitLastState { + @PrimaryColumn({ type: 'bigint' }) + unitid: number; + + @Column({ type: 'text' }) + key: string; + + @Column({ type: 'text', nullable: true }) + value: string; + + @ManyToOne(() => Unit, unit => unit.unitLastStates, { + onDelete: 'CASCADE' + }) + @JoinColumn({ name: 'unitid' }) + unit: Unit; +} diff --git a/apps/backend/src/app/database/entities/unitLog.entity.ts b/apps/backend/src/app/database/entities/unitLog.entity.ts new file mode 100644 index 000000000..0c28e78ab --- /dev/null +++ b/apps/backend/src/app/database/entities/unitLog.entity.ts @@ -0,0 +1,29 @@ +import { + Entity, Column, PrimaryGeneratedColumn, ManyToOne, JoinColumn +} from 'typeorm'; +// eslint-disable-next-line import/no-cycle +import { Unit } from './unit.entity'; + +@Entity('unitlog') +export class UnitLog { + @PrimaryGeneratedColumn() + id: number; + + @Column({ type: 'bigint' }) + unitid: number; + + @Column({ type: 'text' }) + key: string; + + @Column({ type: 'text', nullable: true }) + parameter: string; + + @Column({ type: 'bigint', nullable: true }) + ts: number; + + @ManyToOne(() => Unit, unit => unit.unitLogs, { + onDelete: 'CASCADE' + }) + @JoinColumn({ name: 'unitid' }) + unit: Unit; +} diff --git a/apps/backend/src/app/database/entities/unitNote.entity.ts b/apps/backend/src/app/database/entities/unitNote.entity.ts new file mode 100644 index 000000000..4f846d6ee --- /dev/null +++ b/apps/backend/src/app/database/entities/unitNote.entity.ts @@ -0,0 +1,39 @@ +import { + Entity, + Column, + PrimaryGeneratedColumn, + ManyToOne, + JoinColumn, + Index, + CreateDateColumn, + UpdateDateColumn +} from 'typeorm'; +// eslint-disable-next-line import/no-cycle +import { Unit } from './unit.entity'; + +@Entity('unit_note') +@Index(['unitId', 'note']) // Composite index for common query patterns +export class UnitNote { + @PrimaryGeneratedColumn() + id: number; + + @Index() + @Column({ type: 'bigint' }) + unitId: number; + + @Index() + @Column({ type: 'text' }) + note: string; + + @CreateDateColumn({ type: 'timestamp' }) + createdAt: Date; + + @UpdateDateColumn({ type: 'timestamp' }) + updatedAt: Date; + + @ManyToOne(() => Unit, unit => unit.notes, { + onDelete: 'CASCADE' + }) + @JoinColumn({ name: 'unitId' }) + unit: Unit; +} diff --git a/apps/backend/src/app/database/entities/unitTag.entity.ts b/apps/backend/src/app/database/entities/unitTag.entity.ts new file mode 100644 index 000000000..ee8056612 --- /dev/null +++ b/apps/backend/src/app/database/entities/unitTag.entity.ts @@ -0,0 +1,38 @@ +import { + Entity, + Column, + PrimaryGeneratedColumn, + ManyToOne, + JoinColumn, + Index, + CreateDateColumn +} from 'typeorm'; +// eslint-disable-next-line import/no-cycle +import { Unit } from './unit.entity'; + +@Entity('unit_tag') +@Index(['unitId', 'tag']) // Composite index for common query patterns +export class UnitTag { + @PrimaryGeneratedColumn() + id: number; + + @Index() + @Column({ type: 'bigint' }) + unitId: number; + + @Index() + @Column({ type: 'text' }) + tag: string; + + @Column({ type: 'text', nullable: true }) + color: string; + + @CreateDateColumn({ type: 'timestamp' }) + createdAt: Date; + + @ManyToOne(() => Unit, unit => unit.tags, { + onDelete: 'CASCADE' + }) + @JoinColumn({ name: 'unitId' }) + unit: Unit; +} diff --git a/apps/backend/src/app/database/entities/user.entity.ts b/apps/backend/src/app/database/entities/user.entity.ts index d315bd394..c0c735760 100755 --- a/apps/backend/src/app/database/entities/user.entity.ts +++ b/apps/backend/src/app/database/entities/user.entity.ts @@ -1,10 +1,16 @@ -import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm'; +import { + Column, + Entity, + Index, + PrimaryGeneratedColumn +} from 'typeorm'; @Entity() class User { @PrimaryGeneratedColumn({ type: 'int' }) id: number; + @Index() @Column({ type: 'varchar' }) identity: string; diff --git a/apps/backend/src/app/database/entities/workspace.entity.ts b/apps/backend/src/app/database/entities/workspace.entity.ts index 60821f21b..f2c8a43c0 100755 --- a/apps/backend/src/app/database/entities/workspace.entity.ts +++ b/apps/backend/src/app/database/entities/workspace.entity.ts @@ -1,10 +1,16 @@ -import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm'; +import { + Column, + Entity, + Index, + PrimaryGeneratedColumn +} from 'typeorm'; @Entity() class Workspace { @PrimaryGeneratedColumn({ type: 'int' }) id: number = 0; + @Index() @Column({ type: 'varchar' }) name: string = ''; diff --git a/apps/backend/src/app/database/services/person.service.ts b/apps/backend/src/app/database/services/person.service.ts new file mode 100644 index 000000000..d0ab010b7 --- /dev/null +++ b/apps/backend/src/app/database/services/person.service.ts @@ -0,0 +1,820 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import Persons from '../entities/persons.entity'; +import { Booklet } from '../entities/booklet.entity'; +import { Unit } from '../entities/unit.entity'; +import { UnitLastState } from '../entities/unitLastState.entity'; +import { BookletInfo } from '../entities/bookletInfo.entity'; +import { ResponseEntity } from '../entities/response.entity'; +import { ChunkEntity } from '../entities/chunk.entity'; +import { BookletLog } from '../entities/bookletLog.entity'; +import { Session } from '../entities/session.entity'; +import { UnitLog } from '../entities/unitLog.entity'; +import { + Chunk, + Log, + Person, + TcMergeBooklet, + TcMergeLastState, + TcMergeResponse, + TcMergeSubForms, + TcMergeUnit, Response +} from './shared-types'; + +@Injectable() +export class PersonService { + constructor( + @InjectRepository(Persons) + private personsRepository: Repository, + @InjectRepository(Booklet) + private bookletRepository: Repository, + @InjectRepository(Unit) + private unitRepository: Repository, + @InjectRepository(UnitLastState) + private unitLastStateRepository: Repository, + @InjectRepository(BookletInfo) + private bookletInfoRepository: Repository, + @InjectRepository(ResponseEntity) + private responseRepository: Repository, + @InjectRepository(ChunkEntity) + private chunkRepository: Repository, + @InjectRepository(BookletLog) + private bookletLogRepository: Repository, + @InjectRepository(Session) + private bookletSessionRepository: Repository, + @InjectRepository(UnitLog) + private unitLogRepository: Repository + ) { + } + + logger = new Logger(PersonService.name); + async createPersonList(rows: Array<{ groupname: string; loginname: string; code: string }>, workspace_id: number): Promise { + if (!Array.isArray(rows)) { + this.logger.error('Invalid input: rows must be an array'); + } + + if (typeof workspace_id !== 'number' || workspace_id <= 0) { + this.logger.error('Invalid input: workspace_id must be a positive number'); + } + const personMap = new Map(); + rows.forEach((row, index) => { + try { + if (!row.groupname || !row.loginname || !row.code) { + this.logger.warn(`Skipping incomplete row at index ${index}: ${JSON.stringify(row)}`); + return; + } + + const mapKey = `${row.groupname}-${row.loginname}-${row.code}`; + if (!personMap.has(mapKey)) { + personMap.set(mapKey, { + workspace_id, + group: row.groupname, + login: row.loginname, + code: row.code, + booklets: [] + }); + } + } catch (error) { + this.logger.error(`Error processing row at index ${index}: ${error.message}`); + } + }); + + if (personMap.size === 0) { + this.logger.warn('No valid persons were created from the input rows'); + } + + return Array.from(personMap.values()); + } + + async assignBookletsToPerson(person: Person, rows: Response[]): Promise { + const logger = new Logger('assignBookletsToPerson'); + const bookletIds = new Set(); // To avoid duplicate booklets + const booklets: TcMergeBooklet[] = []; // List of booklets to be assigned + + for (const row of rows) { + try { + if (row.groupname === person.group && row.loginname === person.login && row.code === person.code) { + if (!row.bookletname) { + logger.warn(`Missing booklet name in row: ${JSON.stringify(row)}`); + continue; + } + if (!bookletIds.has(row.bookletname)) { + bookletIds.add(row.bookletname); + booklets.push({ + id: row.bookletname, + logs: [], + units: [], + sessions: [] + }); + } + } + } catch (error) { + logger.error( + `Error processing a row [Group: ${row.groupname}, Login: ${row.loginname}, Code: ${row.code}]: ${error.message}` + ); + } + } + person.booklets = booklets; + logger.log(`Successfully assigned ${booklets.length} booklets to person ${person.login}.`); + return person; + } + + assignBookletLogsToPerson(person: Person, rows: Log[]): Person { + const booklets: TcMergeBooklet[] = []; + const bookletMap = new Map(); + + rows.forEach((row, index) => { + try { + if ( + row.groupname === person.group && + row.loginname === person.login && + row.code === person.code + ) { + const { bookletname, timestamp, logentry } = row; + + if (!bookletname || !logentry) { + this.logger.warn( + `Skipping incomplete log entry at index ${index} for person: ${person.login}` + ); + return; + } + + const [logEntryKey, logEntryValueRaw] = logentry.split(' : '); + const logEntryKeyTrimmed = logEntryKey?.trim(); + const logEntryValue = logEntryValueRaw?.trim()?.replace(/"/g, ''); + + if (!logEntryKeyTrimmed) { + this.logger.warn( + `Invalid log key detected at index ${index} for person: ${person.login}` + ); + return; + } + + let booklet = bookletMap.get(bookletname); + if (!booklet) { + booklet = { + id: bookletname, + logs: [], + units: [], + sessions: [] + }; + booklets.push(booklet); + bookletMap.set(bookletname, booklet); + } + + if (logEntryKeyTrimmed === 'LOADCOMPLETE' && logEntryValue) { + const parsedResult = this.parseLoadCompleteLog(logEntryValue); + if (parsedResult) { + const { + browserVersion = 'Unknown', + browserName = 'Unknown', + osName = 'Unknown', + screenSizeWidth = '0', + screenSizeHeight = '0', + loadTime = '0' + } = parsedResult; + + booklet.sessions.push({ + browser: `${browserName} ${browserVersion}`.trim(), + os: osName.toString(), + screen: `${screenSizeWidth} x ${screenSizeHeight}`, + ts: timestamp, + loadCompleteMS: Number(loadTime) || 0 + }); + } else { + this.logger.warn( + `Failed to parse LOADCOMPLETE entry at index ${index} for person: ${person.login}` + ); + } + } + if (logEntryKeyTrimmed !== 'LOADCOMPLETE') { + booklet.logs.push({ + ts: timestamp, + key: logEntryKeyTrimmed || 'UNKNOWN', + parameter: logEntryValue || '' + }); + } + } + } catch (error) { + this.logger.error( + `Error processing log row at index ${index} for person: ${person.login}. Data: ${JSON.stringify( + row + )}. Error: ${error.message}` + ); + } + }); + + person.booklets = booklets; + return person; + } + + private parseLoadCompleteLog(logEntry: string): { [key: string]: string | number | undefined } | null { + try { + const keyValues = logEntry.slice(1, -1).split(','); + const parsedResult: { [key: string]: string | number | undefined } = {}; + + keyValues.forEach(pair => { + const [key, value] = pair.split(':', 2).map(part => part.trim()); + parsedResult[key] = !Number.isNaN(Number(value)) ? Number(value) : value || undefined; + }); + + return parsedResult; + } catch (error) { + this.logger.error(`Failed to parse LOADCOMPLETE log entry: ${logEntry} - ${error.message}`); + return null; + } + } + + async assignUnitsToBookletAndPerson(person: Person, rows: Response[]): Promise { + for (const row of rows) { + try { + if (!this.doesRowMatchPerson(row, person)) continue; + + const booklet = person.booklets.find(b => b.id === row.bookletname); + if (!booklet) continue; + + const parsedResponses = this.parseResponses(row.responses); + const subforms = this.extractSubforms(parsedResponses); + const variables = this.extractVariablesFromSubforms(subforms); + const laststate = this.parseLastState(row.laststate); + + person.booklets = person.booklets.map(b => (b.id === booklet.id ? + { ...b, units: [...b.units, this.createUnit(row, laststate, subforms, variables, parsedResponses)] } : + b) + ); + } catch (error) { + this.logger.error(`Error processing row for person ${person.login}: ${error.message}`, error.stack); + } + } + return person; + } + + private doesRowMatchPerson(row: Response, person: Person): boolean { + return row.groupname === person.group && + row.loginname === person.login && + row.code === person.code; + } + + private parseResponses(responses: string | Chunk[]): Chunk[] { + if (Array.isArray(responses)) return responses; + + try { + return JSON.parse(responses); + } catch (error) { + this.logger.error(`Error parsing responses: ${error.message}`); + return []; + } + } + + private extractSubforms(parsedResponses: Chunk[]): TcMergeSubForms[] { + return parsedResponses + .filter(chunk => chunk?.id === 'elementCodes') + .map(chunk => { + try { + const chunkContent: TcMergeResponse[] = JSON.parse(chunk.content); + return { id: chunk.id, responses: chunkContent }; + } catch (error) { + this.logger.error(`Error parsing chunk content for chunk ID ${chunk.id}: ${error.message}`); + return { id: chunk.id, responses: [] }; + } + }); + } + + private extractVariablesFromSubforms(subforms: any[]): Set { + const variables = new Set(); + subforms.forEach(subform => subform.responses.forEach(response => variables.add(response.id)) + ); + return variables; + } + + private parseLastState(laststate: string): TcMergeLastState[] { + try { + if (!laststate || typeof laststate !== 'string' || laststate.trim() === '') { + this.logger.warn('Last state is empty or invalid.'); + return []; + } + + const parsed = JSON.parse(laststate); + + if ( + typeof parsed !== 'object' || + Array.isArray(parsed) || + parsed === null + ) { + this.logger.error('Parsed last state is not a valid object.'); + return []; + } + + return Object.entries(parsed).map(([key, value]) => ({ + key, + value: String(value) + })); + } catch (error) { + this.logger.error(`Error parsing last state: ${error.message}`); + return []; + } + } + + private createUnit( + row: Response, + laststate: TcMergeLastState[], + subforms: TcMergeSubForms[], + variables: Set, + parsedResponses: Chunk[] + ): TcMergeUnit { + return { + id: row.unitname, + alias: row.unitname, + laststate, + subforms, + chunks: [ + { + id: 'elementCodes', + type: parsedResponses[0]?.responseType || '', + ts: parsedResponses[0]?.ts || 0, + variables: Array.from(variables) + } + ], + logs: [] + }; + } + + async processPersonBooklets(personList: Person[], workspace_id: number): Promise { + try { + if (!Array.isArray(personList) || personList.length === 0) { + this.logger.warn('Person list is empty or invalid'); + return; + } + if (!workspace_id || workspace_id <= 0) { + this.logger.error('Invalid workspace ID provided'); + return; + } + + await this.personsRepository.upsert(personList, ['group', 'code', 'login']); + const persons = await this.personsRepository.find({ where: { workspace_id } }); + + if (!persons || persons.length === 0) { + this.logger.warn(`No persons found for workspace_id: ${workspace_id}`); + return; + } + + for (const person of persons) { + if (!person.booklets || person.booklets.length === 0) { + this.logger.warn(`No booklets found for person: ${person.group}-${person.login}-${person.code}`); + continue; + } + + for (const booklet of person.booklets) { + if (!booklet || !booklet.id) { + this.logger.warn(`Skipping invalid booklet for person: ${person.group}-${person.login}-${person.code}`); + continue; + } + + try { + let bookletInfo = await this.bookletInfoRepository.findOne({ where: { name: booklet.id } }); + if (!bookletInfo) { + bookletInfo = await this.bookletInfoRepository.save( + this.bookletInfoRepository.create({ + name: booklet.id, + size: 0 + }) + ); + } + + let savedBooklet = await this.bookletRepository.findOne({ + where: { + personid: person.id, + infoid: bookletInfo.id + } + }); + this.logger.log(`Processing booklet for person: ${JSON.stringify(person)} with person.id: ${person.id}`); + if (!person.id) { + this.logger.error(`Person ID is missing for person: ${JSON.stringify(person)}`); + } + + if (!savedBooklet) { + savedBooklet = await this.bookletRepository.save( + this.bookletRepository.create({ + personid: person.id, + infoid: bookletInfo.id, + lastts: Date.now(), + firstts: Date.now() + }) + ); + } + + if (Array.isArray(booklet.units) && booklet.units.length > 0) { + for (const unit of booklet.units) { + if (!unit || !unit.id) { + this.logger.warn( + `Skipping invalid unit in booklet ${booklet.id} for person: ${person.group}-${person.login}-${person.code}` + ); + continue; + } + + try { + let savedUnit = await this.unitRepository.findOne({ + where: { alias: unit.alias, name: unit.id, bookletid: savedBooklet.id } + }); + + if (!savedUnit) { + savedUnit = await this.unitRepository.save( + this.unitRepository.create({ + alias: unit.alias, + name: unit.id, + bookletid: savedBooklet.id + }) + ); + } + + if (savedUnit) { + await Promise.all([ + this.saveUnitLastState(unit, savedUnit, booklet, person), + this.processSubforms(unit, savedUnit, booklet, person), + this.processChunks(unit, savedUnit, booklet) + ]); + } + } catch (unitError) { + this.logger.error( + `Failed to process unit ${unit.id} in booklet ${booklet.id} for person ${person.id}: ${unitError.message}` + ); + } + } + } else { + this.logger.warn(`No valid units found in booklet ${booklet.id} for person ${person.id}`); + } + } catch (bookletError) { + this.logger.error( + `Failed to process booklet ${booklet.id} for person ${person.id}: ${bookletError.message}` + ); + } + } + } + } catch (error) { + this.logger.error(`Failed to process person booklets: ${error.message}`); + } + } + + private async saveUnitLastState(unit: TcMergeUnit, savedUnit: Unit, booklet: TcMergeBooklet, person: Persons): Promise { + try { + const currentLastState = await this.unitLastStateRepository.find({ + where: { unitid: savedUnit.id } + }); + + if (currentLastState.length === 0 && unit.laststate) { + const lastStateEntries = Object.entries(unit.laststate).map(([key]) => ({ + unitid: savedUnit.id, + key: unit.laststate[key].key, + value: unit.laststate[key].value + })); + await this.unitLastStateRepository.insert(lastStateEntries); + this.logger.log(`Saved laststate for unit ${unit.id} of booklet ${booklet.id} for person ${person.id}`); + } else { + this.logger.log(`Laststate already exists for unit ${unit.id} of booklet ${booklet.id} for person ${person.id}`); + } + } catch (error) { + this.logger.error(`Failed to save last state for unit ${unit.id}: ${error.message}`); + } + } + + private async processSubforms(unit: TcMergeUnit, savedUnit: Unit, booklet: TcMergeBooklet, person: Persons): Promise { + try { + const subforms = unit.subforms; + if (subforms && subforms.length > 0) { + await this.saveSubformResponsesForUnit(savedUnit, subforms, person.id); + } + this.logger.log(`Processed subform responses for unit ${unit.id} of booklet ${booklet.id}`); + } catch (error) { + this.logger.error(`Failed to process subform responses for unit: ${unit.id}: ${error.message}`); + } + } + + private async processChunks(unit: TcMergeUnit, savedUnit: Unit, booklet: TcMergeBooklet): Promise { + try { + if (unit.chunks && unit.chunks.length > 0) { + const chunkEntries = unit.chunks.map(chunk => ({ + unitid: savedUnit.id, + key: chunk.id, + type: chunk.type, + ts: chunk.ts, + variables: Array.isArray(chunk.variables) ? chunk.variables.join(',') : '' + })); + await this.chunkRepository.insert(chunkEntries); + this.logger.log(`Saved ${chunkEntries.length} chunks for unit ${unit.id} in booklet ${booklet.id}`); + } else { + this.logger.log(`No chunks to save for unit ${unit.id} in booklet ${booklet.id}`); + } + } catch (error) { + this.logger.error(`Failed to save chunks for unit ${unit.id} in booklet ${booklet.id}: ${error.message}`); + } + } + + async saveSubformResponsesForUnit(savedUnit: Unit, subforms: any[], personId: number) { + try { + for (const subform of subforms) { + if (subform.responses && subform.responses.length > 0) { + const responseEntries = subform.responses.map(response => ({ + unitid: Number(savedUnit.id), + variableid: response.id, + status: response.status, + value: response.value, + subform: subform.id + })); + + await this.responseRepository.insert(responseEntries); + this.logger.log(`Saved ${responseEntries.length} responses for unit ${savedUnit.id} and person ${personId}`); + } + } + } catch (error) { + this.logger.error(`Failed to save responses for unit: ${savedUnit.id} ->`, error.message); + } + } + + assignUnitLogsToBooklet(booklet: TcMergeBooklet, rows: Log[]): TcMergeBooklet { + if (!booklet || !Array.isArray(booklet.units)) { + this.logger.error("Invalid booklet provided. Booklet must contain a valid 'units' array."); + } + + if (!Array.isArray(rows)) { + this.logger.error('Invalid rows provided. Expecting an array of Log items.'); + } + + const unitMap = new Map(); + booklet.units.forEach(unit => { + if (unit && unit.id) { + unitMap.set(unit.id, { ...unit, logs: Array.isArray(unit.logs) ? [...unit.logs] : [] }); + } else { + this.logger.warn("Skipping invalid unit without 'id' in booklet units."); + } + }); + + rows.forEach((row, index) => { + try { + if (!row || typeof row.bookletname !== 'string' || typeof row.unitname !== 'string') { + this.logger.warn(`Skipping invalid row at index ${index}. Row must contain 'bookletname' and 'unitname'.`); + return; + } + + if (booklet.id !== row.bookletname) return; + + const logEntryParts = row.logentry?.split('='); + if (!logEntryParts || logEntryParts.length < 2) { + this.logger.warn(`Skipping invalid log entry in row at index ${index}: ${row.logentry}`); + return; + } + + const log = { + ts: row.timestamp.toString(), + key: logEntryParts[0]?.trim() || 'UNKNOWN', + parameter: logEntryParts[1]?.trim()?.replace(/"/g, '') || '' + }; + + const existingUnit = unitMap.get(row.unitname); + if (existingUnit) { + existingUnit.logs.push(log); + } else { + const newUnit: TcMergeUnit = { + id: row.unitname, + alias: '', + laststate: [], + subforms: [], + chunks: [], + logs: [log] + }; + unitMap.set(row.unitname, newUnit); + } + } catch (error) { + this.logger.error(`Error processing row at index ${index}: ${error.message}`, row); + } + }); + + booklet.units = Array.from(unitMap.values()); + return booklet; + } + + async processPersonLogs( + persons: Person[], + unitLogs: Log[], + bookletLogs: Log[] + ): Promise { + try { + const keys = persons.map(person => ({ + group: person.group, + code: person.code, + login: person.login, + workspace_id: person.workspace_id + })); + + const existingPersons = await this.personsRepository.find({ + where: keys, + select: ['group', 'code', 'login', 'booklets'] + }); + const enrichedPersons = await Promise.all( + existingPersons.map(async person => { + const updatedPerson = this.assignBookletLogsToPerson(person, bookletLogs); + + if (updatedPerson.booklets?.length) { + await Promise.all(updatedPerson.booklets.map(async booklet => { + this.assignUnitLogsToBooklet(booklet, unitLogs); + })); + } + return updatedPerson; + }) + ); + + for (const enrichedPerson of enrichedPersons) { + const originalPerson = persons.find( + p => p.group === enrichedPerson.group && + p.code === enrichedPerson.code && + p.login === enrichedPerson.login + ); + + if (!originalPerson) { + this.logger.warn( + `Original person matching enriched person not found: ${JSON.stringify( + enrichedPerson + )}` + ); + continue; + } + + if (!enrichedPerson.booklets || enrichedPerson.booklets.length === 0) { + this.logger.warn( + `No booklets found for person ${originalPerson.group}-${originalPerson.login}-${originalPerson.code}` + ); + continue; + } + + for (const booklet of enrichedPerson.booklets) { + if (!booklet || !booklet.id) { + this.logger.warn( + `Skipping invalid booklet for person: ${originalPerson.group}-${originalPerson.login}-${originalPerson.code}` + ); + continue; + } + const existingPerson = await this.personsRepository.findOne({ + where: { + group: originalPerson.group, + login: originalPerson.login, + code: originalPerson.code + } + }); + + if (!existingPerson) { + this.logger.error( + `Person not found in database: ${originalPerson.group}-${originalPerson.login}-${originalPerson.code}` + ); + continue; + } + + const bookletInfo = await this.bookletInfoRepository.findOne({ + where: { name: booklet.id } + }); + + if (!bookletInfo) { + this.logger.warn(`BookletInfo not found for booklet ID: ${booklet.id}`); + continue; + } + + const existingBooklet = await this.bookletRepository.findOne({ + where: { + personid: existingPerson.id, + infoid: bookletInfo.id + } + }); + + if (!existingBooklet) { + this.logger.warn( + `Booklet not found in the repository: ${booklet.id}` + ); + continue; + } + + try { + await this.storeBookletLogs(booklet, existingBooklet.id); + await this.storeBookletSessions(booklet, existingBooklet); + await this.processUnits(booklet, existingBooklet, enrichedPerson); + } catch (error) { + this.logger.error( + `Failed to process booklet ${booklet.id} for person ${originalPerson.code}: ${error.message}` + ); + } + } + } + } catch (error) { + this.logger.error( + `Critical error while processing person logs: ${error.message}` + ); + } + } + + private async storeBookletLogs(booklet: TcMergeBooklet, bookletId: number): Promise { + if (!booklet.logs || booklet.logs.length === 0) { + return; + } + + const bookletLogEntries = booklet.logs.map(log => ({ + key: log.key, + parameter: log.parameter, + bookletid: bookletId, + ts: Number(log.ts) + })); + + try { + await this.bookletLogRepository.save(bookletLogEntries); + this.logger.log(`Saved ${booklet.logs.length} logs for booklet ${booklet.id}`); + } catch (error) { + this.logger.error( + `Failed to save logs for booklet ${booklet.id}: ${error.message}` + ); + throw error; + } + } + + private async storeBookletSessions( + booklet: TcMergeBooklet, + existingBooklet: Booklet + ): Promise { + if (!booklet.sessions || booklet.sessions.length === 0) { + return; + } + + const sessionEntries = booklet.sessions.map(session => ({ + browser: session.browser, + os: session.os, + screen: session.screen, + loadcompletems: session.loadCompleteMS, + ts: Number(session.ts), + booklet: existingBooklet + })); + + try { + await this.bookletSessionRepository.save(sessionEntries); + this.logger.log( + `Saved ${sessionEntries.length} sessions for booklet ${booklet.id}` + ); + } catch (error) { + this.logger.error( + `Failed to save sessions for booklet ${booklet.id}: ${error.message}` + ); + throw error; + } + } + + private async processUnits( + booklet: TcMergeBooklet, + existingBooklet: Booklet, + person: Person + ): Promise { + for (const unit of booklet.units) { + if (!unit || !unit.id) { + this.logger.warn( + `Skipping invalid unit in booklet ${booklet.id} for person ${person.group}-${person.login}-${person.code}` + ); + continue; + } + + const existingUnit = await this.unitRepository.findOne({ + where: { + alias: unit.id, + name: unit.id, + bookletid: existingBooklet.id + } + }); + + if (!existingUnit) { + this.logger.warn( + `Unit not found for alias: ${unit.alias}, name: ${unit.id} ${booklet.id} ${existingBooklet.id} ID${unit.id} ALIAS${unit.alias}` + ); + } + + // await this.saveUnitLogs(unit, existingUnit); + } + } + + private async saveUnitLogs(unit: TcMergeUnit, existingUnit: Unit): Promise { + if (!unit.logs || unit.logs.length === 0) { + return; + } + + const unitLogEntries = unit.logs.map(log => ({ + key: log.key, + parameter: log.parameter, + unitid: existingUnit.id, + ts: Number(log.ts) + })); + + try { + await this.unitLogRepository.insert(unitLogEntries); + this.logger.log( + `Saved ${unit.logs.length} logs for unit ${unit.id}` + ); + } catch (error) { + this.logger.error( + `Failed to save logs for unit ${unit.id}: ${error.message}` + ); + throw error; + } + } +} diff --git a/apps/backend/src/app/database/services/resource-package.service.ts b/apps/backend/src/app/database/services/resource-package.service.ts index 66b1eef9a..c6f232a25 100644 --- a/apps/backend/src/app/database/services/resource-package.service.ts +++ b/apps/backend/src/app/database/services/resource-package.service.ts @@ -22,23 +22,24 @@ export class ResourcePackageService { ) { } - async findResourcePackages(): Promise { - this.logger.log('Returning resource packages.'); + async findResourcePackages(workspaceId: number): Promise { + this.logger.log(`Returning resource packages for workspace ${workspaceId}.`); return this.resourcePackageRepository .find({ + where: { workspaceId }, order: { createdAt: 'DESC' } }); } - async removeResourcePackages(ids: number[]): Promise { - await Promise.all(ids.map(async id => this.removeResourcePackage(id))); + async removeResourcePackages(workspaceId: number, ids: number[]): Promise { + await Promise.all(ids.map(async id => this.removeResourcePackage(workspaceId, id))); } - async removeResourcePackage(id: number): Promise { - this.logger.log(`Deleting resource package with id ${id}.`); + async removeResourcePackage(workspaceId: number, id: number): Promise { + this.logger.log(`Deleting resource package with id ${id} from workspace ${workspaceId}.`); const resourcePackage = await this.resourcePackageRepository .findOne({ - where: { id: id } + where: { id: id, workspaceId: workspaceId } }); if (resourcePackage) { const elementPath = `${this.resourcePackagesPath}/${resourcePackage.name}`; @@ -51,15 +52,15 @@ export class ResourcePackageService { } } - async create(zippedResourcePackage: Express.Multer.File): Promise { - this.logger.log('Creating resource package.'); + async create(workspaceId: number, zippedResourcePackage: Express.Multer.File): Promise { + this.logger.log(`Creating resource package for workspace ${workspaceId}.`); const zip = new AdmZip(zippedResourcePackage.buffer); const packageNameArray = zippedResourcePackage.originalname.split('.itcr.zip'); if (packageNameArray.length === 2) { const packageName = packageNameArray[0]; const resourcePackage = await this.resourcePackageRepository .findOne({ - where: { name: packageName } + where: { name: packageName, workspaceId } }); if (!resourcePackage) { const packageFiles = zip.getEntries() @@ -67,9 +68,12 @@ export class ResourcePackageService { const zipExtractAllToAsync = util.promisify(zip.extractAllToAsync); return zipExtractAllToAsync(`${this.resourcePackagesPath}/${packageName}`, true, true) .then(async () => { + const packageSize = zippedResourcePackage.buffer.length; const newResourcePackage = this.resourcePackageRepository.create({ + workspaceId, name: packageName, elements: packageFiles, + packageSize, createdAt: new Date() }); await this.resourcePackageRepository.save(newResourcePackage); @@ -88,8 +92,18 @@ export class ResourcePackageService { throw new Error('No Resource Package'); } - getZippedResourcePackage(name: string): Buffer { - this.logger.log('Returning zipped resource package.'); - return fs.readFileSync(`${this.resourcePackagesPath}/${name}/${name}.itcr.zip`); + async getZippedResourcePackage(workspaceId: number, name: string): Promise { + this.logger.log(`Returning zipped resource package ${name} for workspace ${workspaceId}.`); + + // Check if the resource package exists for the given workspace + const resourcePackage = await this.resourcePackageRepository.findOne({ + where: { name, workspaceId } + }); + + if (!resourcePackage) { + throw new ResourcePackageNotFoundException(0, 'GET', `Resource package ${name} not found in workspace ${workspaceId}`); + } + + return fs.readFileSync(`${this.resourcePackagesPath}/${name}/${name}.itcs.zip`); } } diff --git a/apps/backend/src/app/database/services/shared-types.ts b/apps/backend/src/app/database/services/shared-types.ts new file mode 100644 index 000000000..4bbae2be5 --- /dev/null +++ b/apps/backend/src/app/database/services/shared-types.ts @@ -0,0 +1,109 @@ +// This file contains shared types used across multiple services +// to prevent circular dependencies + +export type Response = { + groupname: string, + loginname: string, + code: string, + bookletname: string, + unitname: string, + originalUnitId: string + // eslint-disable-next-line @typescript-eslint/no-explicit-any + responses: any, + laststate: string, +}; + +export type Log = { + groupname: string, + loginname: string, + code: string, + bookletname: string, + unitname: string, + originalUnitId: string, + timestamp: string, + logentry: string, +}; + +export type File = { + filename: string, + file_id: string, + file_type: string, + file_size: number, + workspace_id: string, + data: string +}; + +export type Person = { + workspace_id: number, + group: string, + login: string, + code: string, + booklets: TcMergeBooklet[], +}; + +export type TcMergeBooklet = { + id: string, + logs: TcMergeLog[], + units: TcMergeUnit[], + sessions: TcMergeSession[] +}; + +export type TcMergeLog = { + ts: string, + key: string, + parameter: string +}; + +export type TcMergeSession = { + browser: string, + os: string, + screen: string, + ts: string, + loadCompleteMS: number, +}; + +export type TcMergeUnit = { + id: string, + alias: string, + laststate: TcMergeLastState[], + subforms: TcMergeSubForms[], + chunks: TcMergeChunk[], + logs: TcMergeLog[], +}; + +export type TcMergeChunk = { + id: string, + type: string, + ts: number, + variables: string[] +}; + +export type Chunk = { + id: string, + content: string, + ts: number, + responseType: string +}; + +export type TcMergeSubForms = { + id: string +}; + +export type TcMergeResponse = { + id: string, + ts: number, + content: string, + responseType: string +}; + +export type TcMergeLastState = { + key: string, + value: string +}; + +export interface CodingStatistics { + totalResponses: number; + statusCounts: { + [key: string]: number; + }; +} diff --git a/apps/backend/src/app/database/services/testcenter.service.spec.ts b/apps/backend/src/app/database/services/testcenter.service.spec.ts index e7d10f8ef..2c15a0370 100755 --- a/apps/backend/src/app/database/services/testcenter.service.spec.ts +++ b/apps/backend/src/app/database/services/testcenter.service.spec.ts @@ -2,12 +2,8 @@ import { Test, TestingModule } from '@nestjs/testing'; import { createMock } from '@golevelup/ts-jest'; import { JwtService } from '@nestjs/jwt'; import { HttpService } from '@nestjs/axios'; -import { getRepositoryToken } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; import { TestcenterService } from './testcenter.service'; import { UsersService } from './users.service'; -import { WorkspaceService } from './workspace.service'; -import Responses from '../entities/responses.entity'; describe('TestCenterService', () => { let service: TestcenterService; @@ -20,10 +16,6 @@ describe('TestCenterService', () => { provide: HttpService, useValue: createMock() }, - { - provide: WorkspaceService, - useValue: createMock() - }, { provide: TestcenterService, useValue: createMock() @@ -35,10 +27,6 @@ describe('TestCenterService', () => { { provide: JwtService, useValue: createMock() - }, - { - provide: getRepositoryToken(Responses), - useValue: createMock>() } ] }).compile(); diff --git a/apps/backend/src/app/database/services/testcenter.service.ts b/apps/backend/src/app/database/services/testcenter.service.ts index 9d1c8cacf..31727e81e 100755 --- a/apps/backend/src/app/database/services/testcenter.service.ts +++ b/apps/backend/src/app/database/services/testcenter.service.ts @@ -2,17 +2,14 @@ import { Injectable } from '@nestjs/common'; import { HttpService } from '@nestjs/axios'; import * as https from 'https'; import { catchError, firstValueFrom } from 'rxjs'; -import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; -import { WorkspaceService } from './workspace.service'; -import Responses from '../entities/responses.entity'; +import { logger } from 'nx/src/utils/logger'; +import { Person, Response } from './shared-types'; import { ImportOptions } from '../../../../../frontend/src/app/ws-admin/components/test-center-import/test-center-import.component'; -import FileUpload from '../entities/file_upload.entity'; -import { ResponseDto } from '../../../../../../api-dto/responses/response-dto'; -import { LogsDto } from '../../../../../../api-dto/logs/logs-dto'; -import Logs from '../entities/logs.entity'; +import { TestGroupsInfoDto } from '../../../../../../api-dto/files/test-groups-info.dto'; +import { PersonService } from './person.service'; +import { WorkspaceFilesService } from './workspace-files.service'; const agent = new https.Agent({ rejectUnauthorized: false @@ -25,17 +22,6 @@ type ServerFilesResponse = { Testtakers:[], }; -type TestserverResponse = { - groupName: string, - groupLabel: string, - bookletsStarted: number, - numUnitsMin: number, - numUnitsMax: number, - numUnitsTotal: number, - numUnitsAvg: number, - lastChange: number, -}; - type File = { name: string, size: number, @@ -50,16 +36,6 @@ type File = { data: string }; -export type UnitResponse = { - groupname:string, - loginname : string, - code : string, - bookletname : string, - unitname : string, - responses : Array<{ id: string; content: string; ts: number; responseType: string }>, - laststate : string, -}; - export type Log = { groupname:string, loginname : string, @@ -80,294 +56,367 @@ export type Result = { @Injectable() export class TestcenterService { constructor( + private readonly personService: PersonService, private readonly httpService: HttpService, - private workspaceService: WorkspaceService, - @InjectRepository(Responses) - private responsesRepository:Repository, - @InjectRepository(Logs) - private logsRepository:Repository - + private workspaceFilesService: WorkspaceFilesService ) { } - async authenticate(credentials: { username: string, password: string, server:string, url:string }): Promise { - if (!credentials.server && credentials.url !== '') { + persons: Person[] = []; + + async authenticate(credentials: { username: string; password: string; server: string; url: string }): Promise> { + const endpoint = credentials.url && !credentials.server ? + `${credentials.url}/api/session/admin` : + `http://iqb-testcenter${credentials.server}.de/api/session/admin`; + + try { const { data } = await firstValueFrom( - this.httpService.put(`${credentials.url}/api/session/admin`, { - name: credentials.username, - password: credentials.password - }, { - httpsAgent: agent - }).pipe( + this.httpService.put(endpoint, + { + name: credentials.username, + password: credentials.password + }, + { + httpsAgent: agent + }).pipe( catchError(error => { - throw new Error(error); + throw new Error(`Authentication failed: ${error?.message || error}`); }) ) ); return data; + } catch (error) { + throw new Error(`Authentication error: ${error.message || 'Unknown error'}`); } - - const { data } = await firstValueFrom( - this.httpService.put(`http://iqb-testcenter${credentials.server}.de/api/session/admin`, { - name: credentials.username, - password: credentials.password - }, { - httpsAgent: agent - }).pipe( - catchError(error => { - throw new Error(error); - }) - ) - ); - return data; } - async importWorkspaceFiles( - workspace_id:string, - tc_workspace:string, - server:string, - url:string, - authToken:string, - importOptions:ImportOptions - ): Promise { - const { - units, responses, definitions, player, codings, logs, testTakers, booklets - } = importOptions; - + async getTestgroups( + workspace_id: string, + tc_workspace: string, + server: string, + url: string, + authToken: string + ): Promise { const headersRequest = { Authtoken: authToken }; + try { + const response = await this.httpService.axiosRef.get( + url ? + `${url}/api/workspace/${tc_workspace}/results` : + `https://iqb-testcenter${server}.de/api/workspace/${tc_workspace}/results`, + { + httpsAgent: agent, + headers: headersRequest + } + ); + return response.data; + } catch (error) { + logger.error(`Error fetching test groups: ${error.message}`); + return []; + } + } - const result: Result = { - success: false, testFiles: 0, responses: 0, logs: 0 - }; + private createChunks(array: T[], size: number): T[][] { + return Array.from( + { length: Math.ceil(array.length / size) }, + (_, i) => array.slice(i * size, i * size + size) + ); + } - if (responses === 'true') { - const resultsPromise = this.httpService.axiosRef - .get(url ? `${url}/api/workspace/${tc_workspace}/results` : - `http://iqb-testcenter${server}.de/api/workspace/${tc_workspace}/results`, { - httpsAgent: agent, - headers: headersRequest - }); - const report = await resultsPromise.then(res => res); - if (!report) { - throw new Error('could not obtain information about groups from TC'); - } - const resultGroupNames = report.data.map(group => group.groupName); - const createChunks = (a, size) => Array.from( - new Array(Math.ceil(a.length / size)), - (_, i) => a.slice(i * size, i * size + size) - ); + private createHeaders(authToken: string): { Authtoken: string } { + return { Authtoken: authToken }; + } - const chunks = createChunks(resultGroupNames, 2); - const unitResponsesPromises = chunks.map(chunk => { - const unitResponsesPromise = this.httpService.axiosRef - .get(url ? `${url}/api/workspace/${tc_workspace}/report/response?dataIds=${chunk.join(',')}` : - `http://iqb-testcenter${server}.de/api/workspace/${tc_workspace}/report/response?dataIds=${chunk.join(',')}`, - { + private async importResponses( + workspace_id: string, + tc_workspace: string, + server: string, + url: string, + authToken: string, + testGroups: string + ): Promise[]> { + logger.log('Import response data from TC'); + const headersRequest = this.createHeaders(authToken); + const chunks = this.createChunks(testGroups.split(','), 2); + + return chunks.map(async chunk => { + const endpoint = url ? + `${url}/api/workspace/${tc_workspace}/report/response?dataIds=${chunk.join(',')}` : + `https://www.iqb-testcenter${server}.de/api/workspace/${tc_workspace}/report/response?dataIds=${chunk.join(',')}`; + + try { + const { data: rawResponses } = await this.httpService.axiosRef.get(endpoint, { httpsAgent: agent, headers: headersRequest }); - return unitResponsesPromise - .then(callResponse => { - const rows: ResponseDto[] = callResponse.data - .map((unitResponse: UnitResponse) => ({ - test_person: TestcenterService.getTestPersonName(unitResponse), - unit_id: unitResponse.unitname, - responses: unitResponse.responses, - test_group: unitResponse.groupname, - workspace_id: Number(workspace_id), - unit_state: JSON.parse(unitResponse.laststate), - booklet_id: unitResponse.bookletname, - id: undefined, - created_at: undefined - })); - const cleanedRows = WorkspaceService.cleanResponses(rows); - this.responsesRepository.upsert(cleanedRows, ['test_person', 'unit_id']); - }); - }); - await Promise.all(unitResponsesPromises).then(() => { - result.success = true; - result.responses = report.data.length; - }).catch(() => { - result.success = false; - }); - } - if (logs === 'true') { - const resultsPromise = this.httpService.axiosRef - .get(url ? `${url}api/workspace/${tc_workspace}/results` : - `http://iqb-testcenter${server}.de/api/workspace/${tc_workspace}/results`, { - httpsAgent: agent, - headers: headersRequest - }); - const report = await resultsPromise.then(res => res); - if (!report) { - throw new Error('could not obtain information about groups from TC'); + this.persons = await this.personService.createPersonList(rawResponses, Number(workspace_id)); + + const personList = await Promise.all( + this.persons.map(async person => { + const personWithBooklets = await this.personService.assignBookletsToPerson(person, rawResponses); + return this.personService.assignUnitsToBookletAndPerson(personWithBooklets, rawResponses); + }) + ); + await this.personService.processPersonBooklets(personList, Number(workspace_id)); + } catch (error) { + logger.error('Error processing response chunk:'); + throw error; } - const resultGroupNames = report.data.map(group => group.groupName); - const createChunks = (a, size) => Array.from( - new Array(Math.ceil(a.length / size)), - (_, i) => a.slice(i * size, i * size + size) - ); + }); + } - const chunks = createChunks(resultGroupNames, 2); - const logsPromises = chunks.map(chunk => { - const logsPromise = this.httpService.axiosRef - .get(url ? `${url}/api/workspace/${tc_workspace}/report/log?dataIds=${chunk.join(',')}` : - `http://iqb-testcenter${server}.de/api/workspace/${tc_workspace}/report/log?dataIds=${chunk.join(',')}`, - { + private async importLogs( + workspace_id: string, + tc_workspace: string, + server: string, + url: string, + authToken: string, + testGroups: string + ): Promise[]> { + logger.log('Import logs data from TC'); + const headersRequest = this.createHeaders(authToken); + const logsChunks = this.createChunks(testGroups.split(','), 2); + + const logsPromises = logsChunks.map(async chunk => { + const logsUrl = url ? + `${url}/api/workspace/${tc_workspace}/report/log?dataIds=${chunk.join(',')}` : + `https://iqb-testcenter${server}.de/api/workspace/${tc_workspace}/report/log?dataIds=${chunk.join(',')}`; + try { + const { data: logData } = await this.httpService.axiosRef.get(logsUrl, { httpsAgent: agent, headers: headersRequest }); - return logsPromise - .then(callResponse => { - const rows:LogsDto[] = callResponse.data - .map((log: Log) => ({ - unit_id: log.unitname, - timestamp: log.timestamp, - test_group: log.groupname, - workspace_id: Number(workspace_id), - log_entry: log.logentry, - booklet_id: log.bookletname, - id: undefined - })); - this.logsRepository.save(rows, { chunk: 50000 }); - }); - }); - await Promise.all(logsPromises).then(() => { - result.success = true; - result.logs = report.data.length; - }).catch(() => { - result.success = false; + const { bookletLogs, unitLogs } = this.separateLogsByType(logData); + + const persons = await this.personService.createPersonList(logData, Number(workspace_id)); + // @ts-expect-error - Method signature mismatch between PersonService and expected types + await this.personService.processPersonLogs(persons, unitLogs, bookletLogs); + } catch (error) { + logger.error('Error processing logs:'); + throw error; + } + }); + + return logsPromises; + } + + private separateLogsByType(logData: Log[]): { bookletLogs: Log[], unitLogs: Log[] } { + return logData.reduce( + (acc, row) => { + row.unitname === '' ? acc.bookletLogs.push(row) : acc.unitLogs.push(row); + return acc; + }, + { bookletLogs: [] as Log[], unitLogs: [] as Log[] } + ); + } + + private async importFiles( + workspace_id: string, + tc_workspace: string, + server: string, + url: string, + authToken: string, + importOptions: ImportOptions + ): Promise<{ success: boolean, testFiles: number }> { + const headersRequest = this.createHeaders(authToken); + const filesEndpoint = url ? + `${url}/api/workspace/${tc_workspace}/files` : + `http://iqb-testcenter${server}.de/api/workspace/${tc_workspace}/files`; + + try { + const { data: files } = await this.httpService.axiosRef.get(filesEndpoint, { + httpsAgent: agent, + headers: headersRequest }); + + const filePromises = this.createFilePromises(files, importOptions); + + const fetchedFiles = await Promise.all(filePromises.map(async filePromise => { + const file = await filePromise; + return this.getFile(file, server, tc_workspace, authToken, url); + })); + + const dbEntries = this.createDatabaseEntries(fetchedFiles, workspace_id); + + await this.workspaceFilesService.testCenterImport(dbEntries); + return { + success: fetchedFiles.length > 0, + testFiles: fetchedFiles.length + }; + } catch (error) { + logger.error('Error fetching files:'); + return { success: false, testFiles: 0 }; } + } - if (definitions === 'true' || - player === 'true' || - units === 'true' || - codings === 'true' || - testTakers === 'true' || - booklets === 'true' - ) { - const filesPromise = this.httpService.axiosRef - .get( - url ? `${url}/api/workspace/${tc_workspace}/files` : - `http://iqb-testcenter${server}.de/api/workspace/${tc_workspace}/files`, - { - httpsAgent: agent, - headers: headersRequest - }); - const files = await filesPromise.then(res => res.data); - if (files) { - // const zipFiles = files.Resource.filter(file => file.name.includes('.zip')); - const unitDefFiles = files.Resource.filter(file => file.name.includes('.voud')); - const playerFiles = files.Resource.filter(file => file.name.includes('.html')); - const codingSchemeFiles = files.Resource.filter(file => file.name.includes('.vocs')); - const unitFiles = files.Unit; - const bookletFiles = files.Booklet; - const testTakerFiles = files.Testtakers; - let promises = []; - // const zipPromises = zipFiles - // .map(file => this.getPackage(file, server, tc_workspace, authToken)); - // promises = [...promises, ...packagesPromises]; - - // TODO: Chunks! - if (player === 'true' && playerFiles.length > 0) { - const playerPromises = playerFiles - .map(file => this.getFile(file, server, tc_workspace, authToken, url)); - promises = [...promises, ...playerPromises]; - } - if (units === 'true' && unitFiles.length > 0) { - const unitFilesPromises = unitFiles - .map(file => this.getFile(file, server, tc_workspace, authToken, url)); - promises = [...promises, ...unitFilesPromises]; - } - if (definitions === 'true' && unitDefFiles.length > 0) { - const unitDefPromises = unitDefFiles - .map(file => this.getFile(file, server, tc_workspace, authToken, url)); - promises = [...promises, ...unitDefPromises]; - } - if (codings === 'true' && codingSchemeFiles.length > 0) { - const codingSchemePromises = codingSchemeFiles - .map(file => this.getFile(file, server, tc_workspace, authToken, url)); - promises = [...promises, ...codingSchemePromises]; - } - if (booklets === 'true' && bookletFiles.length > 0) { - const bookletPromises = bookletFiles - .map(file => this.getFile(file, server, tc_workspace, authToken, url)); - promises = [...promises, ...bookletPromises]; - } - if (testTakers === 'true' && testTakerFiles.length > 0) { - const testTakersPromises = testTakerFiles - .map(file => this.getFile(file, server, tc_workspace, authToken, url)); - promises = [...promises, ...testTakersPromises]; - } - const results :File[] = await Promise.all(promises); - if (results.length > 0) { - const dbEntries: unknown = results.map(res => ({ - filename: res.name || '', - file_id: res.id, - file_type: res.type, - file_size: res.size, - workspace_id: workspace_id, - data: res.data - })); - await this.workspaceService.testCenterImport(dbEntries as FileUpload[]); - result.success = true; - result.testFiles = results.length; - return result; - } - result.success = false; - return result; + private createFilePromises(files: ServerFilesResponse, importOptions: ImportOptions): Promise[] { + const { + units, + definitions, + player, + codings, + testTakers, + booklets + } = importOptions; + const filePromises: Promise[] = []; + + if (player === 'true') { + filePromises.push(...files.Resource.filter(f => f.name.includes('.html')).map(file => Promise.resolve(file))); + } + if (units === 'true') { + filePromises.push(...files.Unit.map(file => Promise.resolve(file))); + } + if (definitions === 'true') { + filePromises.push(...files.Resource.filter(f => f.name.includes('.voud')).map(file => Promise.resolve(file))); + } + if (codings === 'true') { + filePromises.push(...files.Resource.filter(f => f.name.includes('.vocs')).map(file => Promise.resolve(file))); + } + if (booklets === 'true') { + filePromises.push(...files.Booklet.map(file => Promise.resolve(file))); + } + if (testTakers === 'true') { + filePromises.push(...files.Testtakers.map(file => Promise.resolve(file))); + } + + return filePromises; + } + + /** + * Creates database entries from fetched files + * @param fetchedFiles The fetched files + * @param workspace_id The workspace ID + * @returns An array of database entries + */ + private createDatabaseEntries( + fetchedFiles: Array<{ + data: File; + name: string; + type: string; + size: number; + id: string; + }>, + workspace_id: string + ): Record[] { + return fetchedFiles.map(res => ({ + filename: res.name, + file_id: res.id, + file_type: res.type, + file_size: res.size, + workspace_id: workspace_id, + data: res.data + })); + } + + async importWorkspaceFiles( + workspace_id: string, + tc_workspace: string, + server: string, + url: string, + authToken: string, + importOptions: ImportOptions, + testGroups: string + ): Promise { + const { responses, logs } = importOptions; + const result: Result = { + success: false, + testFiles: 0, + responses: 0, + logs: 0 + }; + + const promises: Promise[] = []; + + try { + if (responses === 'true') { + const responsePromises = await this.importResponses( + workspace_id, tc_workspace, server, url, authToken, testGroups + ); + promises.push(...responsePromises); + result.responses = responsePromises.length; + } + + if (logs === 'true') { + const logsPromises = await this.importLogs( + workspace_id, tc_workspace, server, url, authToken, testGroups + ); + promises.push(...logsPromises); + result.logs = logsPromises.length; + } + + const shouldImportFiles = this.shouldImportFiles(importOptions); + if (shouldImportFiles) { + const filesResult = await this.importFiles( + workspace_id, tc_workspace, server, url, authToken, importOptions + ); + result.testFiles = filesResult.testFiles; + result.success = filesResult.success; } + + // Wait for all promises to complete + await Promise.all(promises); + result.success = true; + return result; + } catch (error) { + logger.error('Error during importWorkspaceFiles:'); result.success = false; return result; } - result.success = true; - return result; } - private static getTestPersonName(unitResponse: UnitResponse): string { - return `${unitResponse.loginname}@${unitResponse.code}@${unitResponse.bookletname}`; + private shouldImportFiles(importOptions: ImportOptions): boolean { + const { + definitions, + player, + units, + codings, + testTakers, + booklets + } = importOptions; + return definitions === 'true' || player === 'true' || units === 'true' || + codings === 'true' || testTakers === 'true' || booklets === 'true'; } - async getFile(file:File, server:string, tc_workspace:string, authToken:string, url:string): - Promise<{ - data: File, name: string, type: string, size: number, id: string - }> { - const headersRequest = { - Authtoken: authToken - }; - const filePromise = this.httpService.axiosRef - .get(url ? `${url}/api/workspace/${tc_workspace}/file/${file.type}/${file.name}` : - `http://iqb-testcenter${server}.de/api/workspace/${tc_workspace}/file/${file.type}/${file.name}`, - { - httpsAgent: agent, - headers: headersRequest - }); - const fileData = await filePromise.then(res => res.data); - return { - data: fileData, name: file.name, type: file.type, size: file.size, id: file.id - }; + async getFile( + file: File, + server: string, + tcWorkspace: string, + authToken: string, + url?: string + ): Promise<{ + data: File; + name: string; + type: string; + size: number; + id: string; + }> { + const headersRequest = this.createHeaders(authToken); + const requestUrl = this.buildFileRequestUrl(file, server, tcWorkspace, url); + + try { + const response = await this.httpService.axiosRef.get(requestUrl, { + httpsAgent: agent, // Disable SSL validation for HTTPS requests + headers: headersRequest + }); + + return { + data: response.data, + name: file.name, + type: file.type, + size: file.size, + id: file.id + }; + } catch (error) { + logger.error(`Failed to fetch file: ${file.name} ${error}`); + throw new Error('Unable to fetch the file from server.'); + } } - // async getPackage(res:File, server:string, tc_workspace:string, authToken:string): Promise { - // const headersRequest = { - // Authtoken: authToken - // }; - // const filePromise = this.httpService.axiosRef - // .get(`http://iqb-testcenter${server}.de/api/workspace/${tc_workspace}/file/${res.type}/${res.name}`, - // { - // httpsAgent: agent, - // headers: headersRequest - // }); - // //const fileData = await filePromise.then(res => res.data); - // //const zip = new AdmZip(Buffer.from(fileData)); - // //const packageFiles = zip.getEntries().map(entry => entry.entryName); - // - // // return { - // // data: fileData, name: res.name, type: res.type, size: res.size, id: res.id - // // }; - // } + private buildFileRequestUrl(file: File, server: string, tcWorkspace: string, url?: string): string { + return url ? + `${url}/api/workspace/${tcWorkspace}/file/${file.type}/${file.name}` : + `http://iqb-testcenter${server}.de/api/workspace/${tcWorkspace}/file/${file.type}/${file.name}`; + } } diff --git a/apps/backend/src/app/database/services/unit-note.service.ts b/apps/backend/src/app/database/services/unit-note.service.ts new file mode 100644 index 000000000..10f475150 --- /dev/null +++ b/apps/backend/src/app/database/services/unit-note.service.ts @@ -0,0 +1,155 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { UnitNote } from '../entities/unitNote.entity'; +import { Unit } from '../entities/unit.entity'; +import { CreateUnitNoteDto } from '../../../../../../api-dto/unit-notes/create-unit-note.dto'; +import { UpdateUnitNoteDto } from '../../../../../../api-dto/unit-notes/update-unit-note.dto'; +import { UnitNoteDto } from '../../../../../../api-dto/unit-notes/unit-note.dto'; + +@Injectable() +export class UnitNoteService { + constructor( + @InjectRepository(UnitNote) + private unitNoteRepository: Repository, + @InjectRepository(Unit) + private unitRepository: Repository + ) {} + + /** + * Create a new unit note + * @param createUnitNoteDto The data to create the note with + * @returns The created note + */ + async create(createUnitNoteDto: CreateUnitNoteDto): Promise { + // Check if the unit exists + const unit = await this.unitRepository.findOne({ + where: { id: createUnitNoteDto.unitId } + }); + + if (!unit) { + throw new NotFoundException(`Unit with ID ${createUnitNoteDto.unitId} not found`); + } + + // Create the note + const unitNote = this.unitNoteRepository.create({ + unitId: createUnitNoteDto.unitId, + note: createUnitNoteDto.note + }); + + // Save the note + const savedNote = await this.unitNoteRepository.save(unitNote); + + // Return the DTO + return { + id: savedNote.id, + unitId: savedNote.unitId, + note: savedNote.note, + createdAt: savedNote.createdAt, + updatedAt: savedNote.updatedAt + }; + } + + /** + * Find all notes for a unit + * @param unitId The ID of the unit + * @returns An array of notes + */ + async findAllByUnitId(unitId: number): Promise { + // Check if the unit exists + const unit = await this.unitRepository.findOne({ + where: { id: unitId } + }); + + if (!unit) { + throw new NotFoundException(`Unit with ID ${unitId} not found`); + } + + // Find all notes for the unit + const notes = await this.unitNoteRepository.find({ + where: { unitId }, + order: { createdAt: 'DESC' } + }); + + // Return the DTOs + return notes.map(note => ({ + id: note.id, + unitId: note.unitId, + note: note.note, + createdAt: note.createdAt, + updatedAt: note.updatedAt + })); + } + + /** + * Find a note by ID + * @param id The ID of the note + * @returns The note + */ + async findOne(id: number): Promise { + const note = await this.unitNoteRepository.findOne({ + where: { id } + }); + + if (!note) { + throw new NotFoundException(`Note with ID ${id} not found`); + } + + return { + id: note.id, + unitId: note.unitId, + note: note.note, + createdAt: note.createdAt, + updatedAt: note.updatedAt + }; + } + + /** + * Update a note + * @param id The ID of the note + * @param updateUnitNoteDto The data to update the note with + * @returns The updated note + */ + async update(id: number, updateUnitNoteDto: UpdateUnitNoteDto): Promise { + const note = await this.unitNoteRepository.findOne({ + where: { id } + }); + + if (!note) { + throw new NotFoundException(`Note with ID ${id} not found`); + } + + // Update the note + note.note = updateUnitNoteDto.note; + + // Save the note + const updatedNote = await this.unitNoteRepository.save(note); + + // Return the DTO + return { + id: updatedNote.id, + unitId: updatedNote.unitId, + note: updatedNote.note, + createdAt: updatedNote.createdAt, + updatedAt: updatedNote.updatedAt + }; + } + + /** + * Delete a note + * @param id The ID of the note + * @returns True if the note was deleted + */ + async remove(id: number): Promise { + const note = await this.unitNoteRepository.findOne({ + where: { id } + }); + + if (!note) { + throw new NotFoundException(`Note with ID ${id} not found`); + } + + const result = await this.unitNoteRepository.delete(id); + return result.affected > 0; + } +} diff --git a/apps/backend/src/app/database/services/unit-tag.service.ts b/apps/backend/src/app/database/services/unit-tag.service.ts new file mode 100644 index 000000000..c8bfd9980 --- /dev/null +++ b/apps/backend/src/app/database/services/unit-tag.service.ts @@ -0,0 +1,159 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { UnitTag } from '../entities/unitTag.entity'; +import { Unit } from '../entities/unit.entity'; +import { CreateUnitTagDto } from '../../../../../../api-dto/unit-tags/create-unit-tag.dto'; +import { UpdateUnitTagDto } from '../../../../../../api-dto/unit-tags/update-unit-tag.dto'; +import { UnitTagDto } from '../../../../../../api-dto/unit-tags/unit-tag.dto'; + +@Injectable() +export class UnitTagService { + constructor( + @InjectRepository(UnitTag) + private unitTagRepository: Repository, + @InjectRepository(Unit) + private unitRepository: Repository + ) {} + + /** + * Create a new unit tag + * @param createUnitTagDto The data to create the tag with + * @returns The created tag + */ + async create(createUnitTagDto: CreateUnitTagDto): Promise { + // Check if the unit exists + const unit = await this.unitRepository.findOne({ + where: { id: createUnitTagDto.unitId } + }); + + if (!unit) { + throw new NotFoundException(`Unit with ID ${createUnitTagDto.unitId} not found`); + } + + // Create the tag + const unitTag = this.unitTagRepository.create({ + unitId: createUnitTagDto.unitId, + tag: createUnitTagDto.tag, + color: createUnitTagDto.color + }); + + // Save the tag + const savedTag = await this.unitTagRepository.save(unitTag); + + // Return the DTO + return { + id: savedTag.id, + unitId: savedTag.unitId, + tag: savedTag.tag, + color: savedTag.color, + createdAt: savedTag.createdAt + }; + } + + /** + * Find all tags for a unit + * @param unitId The ID of the unit + * @returns An array of tags + */ + async findAllByUnitId(unitId: number): Promise { + // Check if the unit exists + const unit = await this.unitRepository.findOne({ + where: { id: unitId } + }); + + if (!unit) { + throw new NotFoundException(`Unit with ID ${unitId} not found`); + } + + // Find all tags for the unit + const tags = await this.unitTagRepository.find({ + where: { unitId }, + order: { createdAt: 'DESC' } + }); + + // Return the DTOs + return tags.map(tag => ({ + id: tag.id, + unitId: tag.unitId, + tag: tag.tag, + color: tag.color, + createdAt: tag.createdAt + })); + } + + /** + * Find a tag by ID + * @param id The ID of the tag + * @returns The tag + */ + async findOne(id: number): Promise { + const tag = await this.unitTagRepository.findOne({ + where: { id } + }); + + if (!tag) { + throw new NotFoundException(`Tag with ID ${id} not found`); + } + + return { + id: tag.id, + unitId: tag.unitId, + tag: tag.tag, + color: tag.color, + createdAt: tag.createdAt + }; + } + + /** + * Update a tag + * @param id The ID of the tag + * @param updateUnitTagDto The data to update the tag with + * @returns The updated tag + */ + async update(id: number, updateUnitTagDto: UpdateUnitTagDto): Promise { + const tag = await this.unitTagRepository.findOne({ + where: { id } + }); + + if (!tag) { + throw new NotFoundException(`Tag with ID ${id} not found`); + } + + // Update the tag + tag.tag = updateUnitTagDto.tag; + if (updateUnitTagDto.color !== undefined) { + tag.color = updateUnitTagDto.color; + } + + // Save the tag + const updatedTag = await this.unitTagRepository.save(tag); + + // Return the DTO + return { + id: updatedTag.id, + unitId: updatedTag.unitId, + tag: updatedTag.tag, + color: updatedTag.color, + createdAt: updatedTag.createdAt + }; + } + + /** + * Delete a tag + * @param id The ID of the tag + * @returns True if the tag was deleted + */ + async remove(id: number): Promise { + const tag = await this.unitTagRepository.findOne({ + where: { id } + }); + + if (!tag) { + throw new NotFoundException(`Tag with ID ${id} not found`); + } + + const result = await this.unitTagRepository.delete(id); + return result.affected > 0; + } +} diff --git a/apps/backend/src/app/database/services/workspace.service.spec.ts b/apps/backend/src/app/database/services/upload-results.service.spec.ts old mode 100755 new mode 100644 similarity index 61% rename from apps/backend/src/app/database/services/workspace.service.spec.ts rename to apps/backend/src/app/database/services/upload-results.service.spec.ts index 3bf3090b9..e9b4e48a5 --- a/apps/backend/src/app/database/services/workspace.service.spec.ts +++ b/apps/backend/src/app/database/services/upload-results.service.spec.ts @@ -1,14 +1,14 @@ import { Test, TestingModule } from '@nestjs/testing'; -import { HttpService } from '@nestjs/axios'; -import { createMock } from '@golevelup/ts-jest'; import { getRepositoryToken } from '@nestjs/typeorm'; +import { createMock } from '@golevelup/ts-jest'; import { Repository } from 'typeorm'; +import { HttpService } from '@nestjs/axios'; +import { UsersService } from './users.service'; import User from '../entities/user.entity'; -import { WorkspaceService } from './workspace.service'; -import FileUpload from '../entities/file_upload.entity'; +import WorkspaceUser from '../entities/workspace_user.entity'; -describe('WorkspaceService', () => { - let service: WorkspaceService; +describe('UploadResultsService', () => { + let service: UsersService; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ @@ -17,23 +17,19 @@ describe('WorkspaceService', () => { provide: HttpService, useValue: createMock() }, - { - provide: WorkspaceService, - useValue: createMock() - }, - { - provide: getRepositoryToken(FileUpload), - useValue: createMock>() - }, + UsersService, { provide: getRepositoryToken(User), useValue: createMock>() + }, + { + provide: getRepositoryToken(WorkspaceUser), + useValue: createMock>() } - ] }).compile(); - service = module.get(WorkspaceService); + service = module.get(UsersService); }); it('should be defined', () => { diff --git a/apps/backend/src/app/database/services/upload-results.service.ts b/apps/backend/src/app/database/services/upload-results.service.ts new file mode 100644 index 000000000..e100a93c7 --- /dev/null +++ b/apps/backend/src/app/database/services/upload-results.service.ts @@ -0,0 +1,105 @@ +import { + Injectable, Logger +} from '@nestjs/common'; +import 'multer'; +import * as csv from 'fast-csv'; +import { Readable } from 'stream'; +import { FileIo } from '../../admin/workspace/file-io.interface'; +import { Log, Person, Response } from './shared-types'; +import { PersonService } from './person.service'; + +type PersonWithoutBooklets = Omit; + +@Injectable() +export class UploadResultsService { + private readonly logger = new Logger(UploadResultsService.name); + person: PersonWithoutBooklets[] = []; + constructor( + private readonly personService: PersonService + ) { + } + + async uploadTestResults(workspace_id: number, originalFiles: FileIo[], resultType:'logs' | 'responses'): Promise { + this.logger.log(`Uploading test results for workspace ${workspace_id}`); + const MAX_FILES_LENGTH = 1000; + if (originalFiles.length > MAX_FILES_LENGTH) { + this.logger.error(`Too many files to upload: ${originalFiles.length}`); + return false; + } + const filePromises = []; + for (let i = 0; i < originalFiles.length; i++) { + const file = originalFiles[i]; + filePromises.push(this.uploadFile(file, workspace_id, resultType)); + } + await Promise.all(filePromises); + return true; + } + + async uploadFile(file: FileIo, workspace_id: number, resultType: 'logs' | 'responses'): Promise { + if (file.mimetype === 'text/csv') { + const bufferStream = new Readable(); + bufferStream.push(file.buffer); + bufferStream.push(null); + if (resultType === 'logs') { + await this.handleCsvStream(bufferStream, resultType, async rowData => { + const { bookletLogs, unitLogs } = rowData.reduce( + (acc, row) => { + row.unitname === '' ? acc.bookletLogs.push(row) : acc.unitLogs.push(row); + return acc; + }, + { bookletLogs: [], unitLogs: [] } + ); + const persons = await this.personService.createPersonList(rowData, workspace_id); + await this.personService.processPersonLogs(persons, unitLogs, bookletLogs); + }); + } else if (resultType === 'responses') { + await this.handleCsvStream(bufferStream, resultType, async rowData => { + const persons = await this.personService.createPersonList(rowData, workspace_id); + const personList = await Promise.all( + persons.map(async person => { + const personWithBooklets = await this.personService.assignBookletsToPerson(person, rowData); + return this.personService.assignUnitsToBookletAndPerson(personWithBooklets, rowData); + }) + ); + await this.personService.processPersonBooklets(personList, workspace_id); + }); + } + } + } + + private handleCsvStream( + bufferStream: Readable, + resultType: 'logs' | 'responses', + onDataProcessed: (rowData: T[]) => Promise + ): Promise { + return new Promise((resolve, reject) => { + const rowData: T[] = []; + this.logger.log(`Processing CSV stream for ${resultType}`); + + csv.parseStream(bufferStream, { headers: true, delimiter: ';', quote: resultType === 'logs' ? null : '"' }) + .transform((row: T) => { + if (resultType === 'logs') { + Object.keys(row).forEach(key => { + if (typeof row[key] === 'string') { + row[key] = row[key].replace(/"/g, ''); + } + }); + } + return row; + }) + .on('data', (row: T) => { rowData.push(row); }) + .on('error', error => { + this.logger.error(`CSV Parsing Error: ${error.message}`); + reject(error); + }) + .on('end', async () => { + try { + await onDataProcessed(rowData); + resolve(); + } catch (processError) { + reject(processError); + } + }); + }); + } +} diff --git a/apps/backend/src/app/database/services/users.service.ts b/apps/backend/src/app/database/services/users.service.ts index ef6477e9e..de4263cad 100755 --- a/apps/backend/src/app/database/services/users.service.ts +++ b/apps/backend/src/app/database/services/users.service.ts @@ -1,20 +1,17 @@ import { Injectable, Logger, MethodNotAllowedException } from '@nestjs/common'; import { Repository } from 'typeorm'; import { InjectRepository } from '@nestjs/typeorm'; -import { HttpService } from '@nestjs/axios'; import User from '../entities/user.entity'; import { UserFullDto } from '../../../../../../api-dto/user/user-full-dto'; import { CreateUserDto } from '../../../../../../api-dto/user/create-user-dto'; import WorkspaceUser from '../entities/workspace_user.entity'; import { WorkspaceUserInListDto } from '../../../../../../api-dto/user/workspace-user-in-list-dto'; -import { UserWorkspaceAccessDto } from '../../../../../../api-dto/workspaces/user-workspace-access-dto'; import { UserInListDto } from '../../../../../../api-dto/user/user-in-list-dto'; @Injectable() export class UsersService { private readonly logger = new Logger(UsersService.name); constructor( - private httpService: HttpService, @InjectRepository(User) private usersRepository: Repository, @InjectRepository(WorkspaceUser) @@ -23,49 +20,42 @@ export class UsersService { } async findAllFull(workspaceId?: number): Promise { - const validUsers: number[] = []; + const validUsers = new Set(); + if (workspaceId) { + const workspaceUsers = await this.workspaceUserRepository.find({ + where: { workspaceId }, + select: ['userId'] + }); + workspaceUsers.forEach(wsUser => validUsers.add(wsUser.userId)); + } const users: User[] = await this.usersRepository.find({ order: { username: 'ASC' } }); - const returnUsers: UserFullDto[] = []; - users.forEach(user => { - if (!workspaceId || (validUsers.indexOf(user.id) > -1)) { - returnUsers.push({ - id: user.id, - username: user.username, - isAdmin: user.isAdmin - }); - } - }); - return returnUsers; + return users + .filter(user => !workspaceId || validUsers.has(user.id)) + .map(user => ({ + id: user.id, + username: user.username, + isAdmin: user.isAdmin + })); } async findAllUsers(workspaceId?: number): Promise { this.logger.log(`Returning users${workspaceId ? ` for workspaceId: ${workspaceId}` : '.'}`); - const validUsers: UserWorkspaceAccessDto[] = []; - if (workspaceId) { - const workspaceUsers: WorkspaceUser[] = await this.workspaceUserRepository - .find({ where: { workspaceId: workspaceId } }); - - workspaceUsers.forEach(wsU => validUsers.push( - { id: wsU.userId, accessLevel: wsU.accessLevel } - )); - } - const users: User[] = await this.usersRepository - .find({ }); - const returnUsers: WorkspaceUserInListDto[] = []; - users.forEach(user => { - if (!workspaceId || - (validUsers.find(validUser => validUser.id === user.id))) { - returnUsers.push({ - id: user.id, - name: user.username, - username: user.username, - accessLevel: validUsers - .find(validUser => validUser.id === user.id)?.accessLevel || 0, - isAdmin: user.isAdmin - }); - } - }); - return returnUsers; + const validUsers = workspaceId ? + await this.workspaceUserRepository.find({ where: { workspaceId } }) : + []; + const validUserMap = new Map( + validUsers.map(wsUser => [wsUser.userId, wsUser.accessLevel]) + ); + const users = await this.usersRepository.find(); + return users + .filter(user => !workspaceId || validUserMap.has(user.id)) + .map(user => ({ + id: user.id, + name: user.username, // Assuming "name" is the same as "username" + username: user.username, + accessLevel: validUserMap.get(user.id) || 0, // Default accessLevel is 0 if not found + isAdmin: user.isAdmin + })); } async patchAllUsers(workspaceId: number, users: UserInListDto[]): Promise { @@ -90,68 +80,98 @@ export class UsersService { } async findUserWorkspaceIds(userId: number): Promise { - this.logger.log(`Returning workspaces for user with id: ${userId}`); - const workspaces = await this.workspaceUserRepository.find({ where: { userId: userId } }); - const workspaceIds = workspaces.map(workspace => workspace.workspaceId); - if (workspaceIds) { - return workspaceIds; - } - return []; + this.logger.log(`Retrieving workspace IDs for user with ID: ${userId}`); + const workspaces = await this.workspaceUserRepository + .find({ where: { userId: userId } }); + const workspaceIds = workspaces + .map(workspace => workspace.workspaceId); + return workspaceIds || []; } - async findUserByIdentity(id: string): Promise { - this.logger.log(`Returning user with id: ${id}`); - const user = await this.usersRepository.findOne({ - where: { identity: id } - }); - if (user) { - return { - id: user.id, - username: user.username, - isAdmin: user.isAdmin - }; + async findUserByIdentity(id: string): Promise { + this.logger.log(`Searching for user with identity: ${id}`); + const user = await this.usersRepository.findOne({ where: { identity: id } }); + + if (!user) { + this.logger.warn(`User with identity ${id} not found.`); + return null; } - return user; + this.logger.log(`Returning user with id: ${user.id}`); + + return { + id: user.id, + username: user.username, + isAdmin: user.isAdmin + } as UserFullDto; } - async editUser(userId:number, change:UserFullDto): Promise { + async editUser(userId: number, change: UserFullDto): Promise { this.logger.log(`Editing user with id: ${userId}`); - await this.usersRepository.save({ id: userId, ...change }); - return []; + const existingUser = await this.usersRepository.findOne({ where: { id: userId } }); + if (!existingUser) { + this.logger.warn(`User with id: ${userId} not found.`); + throw new Error(`User with id: ${userId} not found.`); + } + const updatedUser = await this.usersRepository.save({ id: userId, ...change }); + return updatedUser; } async setUserWorkspaces(userId: number, workspaceIds: number[]): Promise { - this.logger.log(`Setting workspaces for user with id: ${userId}`); - const entries = workspaceIds.map(workspace => ({ userId: userId, workspaceId: workspace })); - const hasRights = this.workspaceUserRepository.find({ where: { userId: userId } }); - if (hasRights) { - await this.workspaceUserRepository.delete({ userId: userId }); + this.logger.log(`Setting workspaces for user with ID: ${userId}`); + const entries = workspaceIds.map(workspaceId => ({ userId, workspaceId })); + try { + const hasRights = await this.workspaceUserRepository.findOne({ where: { userId } }); + if (hasRights) { + this.logger.log(`Existing workspaces found for user ${userId}, deleting...`); + await this.workspaceUserRepository.delete({ userId }); + } + const savedEntries = await this.workspaceUserRepository.save(entries); + + this.logger.log(`Workspaces successfully set for user with ID: ${userId}`); + // Return true if at least one entry was saved + return savedEntries.length > 0; + } catch (error) { + this.logger.error( + `Error setting workspaces for user with ID: ${userId}. Details: ${error.message}`, + error.stack + ); + throw new Error('Failed to set user workspaces'); } - const saved = await this.workspaceUserRepository.save(entries); - return !!saved; } async create(user: CreateUserDto): Promise { - const newUser = this.usersRepository.create(user); - await this.usersRepository.save(newUser); - return newUser.id; + try { + this.logger.log('Creating a new user'); + + const newUser = this.usersRepository.create(user); + const savedUser = await this.usersRepository.save(newUser); + + this.logger.log(`User created successfully with ID: ${savedUser.id}`); + return savedUser.id; + } catch (error) { + this.logger.error('Error creating a new user', error.stack); + throw new Error('Failed to create user'); + } } async createUser(user: CreateUserDto): Promise { - const existingUser: User = await this.usersRepository.findOne({ + const existingUser: User | null = await this.usersRepository.findOne({ where: { username: user.username }, - select: { - username: true, - id: true - } + select: ['id', 'username'] // Fetch only the needed fields for validation }); - this.logger.log(`Creating user with username: ${JSON.stringify(user)}`); + + this.logger.log(`Attempting to create user with username: ${user.username}`); + if (existingUser) { - this.logger.log(`User with username ${user.username} already exists`); + this.logger.warn(`User with username '${user.username}' already exists with ID: ${existingUser.id}`); return existingUser.id; } + const newUser = this.usersRepository.create(user); + await this.usersRepository.save(newUser); + + this.logger.log(`Successfully created user with ID: ${newUser.id}`); return newUser.id; } @@ -179,50 +199,37 @@ export class UsersService { } async createKeycloakUser(keycloakUser: CreateUserDto): Promise { - const existingUser: User = await this.usersRepository.findOne({ - where: { username: keycloakUser.username }, + const { username, identity, issuer } = keycloakUser; + + // Search for an existing user by either username or a combination of identity and issuer + const existingUser = await this.usersRepository.findOne({ + where: [ + { username }, + { identity, issuer } + ], select: { - username: true, - id: true - } - }); - const existingKeycloakUser: User = await this.usersRepository.findOne({ - where: { identity: keycloakUser.identity, issuer: keycloakUser.issuer }, - select: { - username: true, - id: true + id: true, username: true, identity: true, issuer: true } }); + if (existingUser) { - if (keycloakUser.issuer) existingUser.issuer = keycloakUser?.issuer; - if (keycloakUser.identity) existingUser.identity = keycloakUser?.identity; - await this.usersRepository.update( - { id: existingUser.id }, - { - identity: keycloakUser.identity, - issuer: keycloakUser.issuer - } - ); - this.logger.log(`Updating keycloak user with username: ${JSON.stringify(keycloakUser)}`); - return existingKeycloakUser.id; - } - if (existingKeycloakUser) { - if (keycloakUser.issuer) existingKeycloakUser.issuer = keycloakUser?.issuer; - if (keycloakUser.identity) existingKeycloakUser.identity = keycloakUser?.identity; - await this.usersRepository.update( - { id: existingKeycloakUser.id }, - { - identity: keycloakUser.identity, - issuer: keycloakUser.issuer - } - ); - this.logger.log(`Updating keycloak user with username: ${JSON.stringify(keycloakUser)}`); - return existingKeycloakUser.id; - } + // Prepare fields to update if the provided identity or issuer has changed + const updatedFields: Partial = {}; + if (identity && existingUser.identity !== identity) updatedFields.identity = identity; + if (issuer && existingUser.issuer !== issuer) updatedFields.issuer = issuer; + + // Only update the database if there are fields to update + if (Object.keys(updatedFields).length > 0) { + await this.usersRepository.update({ id: existingUser.id }, updatedFields); + this.logger.log(`Updating existing user: ${JSON.stringify({ ...existingUser, ...updatedFields })}`); + } - this.logger.log(`Creating keycloak user with username: ${JSON.stringify(keycloakUser)}`); + return existingUser.id; + } + this.logger.log(`Creating new Keycloak user: ${JSON.stringify(keycloakUser)}`); const newUser = this.usersRepository.create(keycloakUser); await this.usersRepository.save(newUser); + return newUser.id; } } diff --git a/apps/backend/src/app/database/services/workspace-coding.service.ts b/apps/backend/src/app/database/services/workspace-coding.service.ts new file mode 100644 index 000000000..0c610ad0a --- /dev/null +++ b/apps/backend/src/app/database/services/workspace-coding.service.ts @@ -0,0 +1,573 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { In, Like, Repository } from 'typeorm'; +import * as Autocoder from '@iqb/responses'; +import * as cheerio from 'cheerio'; +import * as fastCsv from 'fast-csv'; +import { ResponseStatusType } from '@iqb/responses'; +import FileUpload from '../entities/file_upload.entity'; +import Persons from '../entities/persons.entity'; +import { Unit } from '../entities/unit.entity'; +import { Booklet } from '../entities/booklet.entity'; +import { ResponseEntity } from '../entities/response.entity'; +import { CodingStatistics } from './shared-types'; +import { prepareDefinition } from '../../utils/voud/transform'; + +@Injectable() +export class WorkspaceCodingService { + private readonly logger = new Logger(WorkspaceCodingService.name); + + constructor( + @InjectRepository(FileUpload) + private fileUploadRepository: Repository, + @InjectRepository(Persons) + private personsRepository: Repository, + @InjectRepository(Unit) + private unitRepository: Repository, + @InjectRepository(Booklet) + private bookletRepository: Repository, + @InjectRepository(ResponseEntity) + private responseRepository: Repository + ) {} + + async codeTestPersons(workspace_id: number, testPersonIds: string): Promise { + const ids = testPersonIds.split(','); + this.logger.log(`Verarbeite Personen ${testPersonIds} für Workspace ${workspace_id}`); + + const statistics: CodingStatistics = { + totalResponses: 0, + statusCounts: {} + }; + + try { + const persons = await this.personsRepository.find({ + where: { workspace_id, id: In(ids) }, select: ['id', 'group', 'login', 'code', 'uploaded_at'] + }); + + if (!persons || persons.length === 0) { + this.logger.warn('Keine Personen gefunden mit den angegebenen IDs.'); + return statistics; + } + + const personIds = persons.map(person => person.id); + + const booklets = await this.bookletRepository.find({ + where: { personid: In(personIds) } + }); + + if (!booklets || booklets.length === 0) { + this.logger.log('Keine Booklets für die angegebenen Personen gefunden.'); + return statistics; + } + + const bookletIds = booklets.map(booklet => booklet.id); + + const units = await this.unitRepository.find({ + where: { bookletid: In(bookletIds) } + }); + + if (!units || units.length === 0) { + this.logger.log('Keine Einheiten für die angegebenen Booklets gefunden.'); + return statistics; + } + + const bookletToUnitsMap = new Map(); + units.forEach(unit => { + if (!bookletToUnitsMap.has(unit.bookletid)) { + bookletToUnitsMap.set(unit.bookletid, []); + } + bookletToUnitsMap.get(unit.bookletid).push(unit); + }); + + const unitIds = units.map(unit => unit.id); + const unitAliases = units.map(unit => unit.alias.toUpperCase()); + + const allResponses = await this.responseRepository.find({ + where: { unitid: In(unitIds), status: In(['VALUE_CHANGED']) } + }); + + const unitToResponsesMap = new Map(); + allResponses.forEach(response => { + if (!unitToResponsesMap.has(response.unitid)) { + unitToResponsesMap.set(response.unitid, []); + } + unitToResponsesMap.get(response.unitid).push(response); + }); + const testFiles = await this.fileUploadRepository.find({ + where: { workspace_id: workspace_id, file_id: In(unitAliases) } + }); + + const fileIdToTestFileMap = new Map(); + testFiles.forEach(file => { + fileIdToTestFileMap.set(file.file_id, file); + }); + + const codingSchemeRefs = new Set(); + const unitToCodingSchemeRefMap = new Map(); + for (const unit of units) { + 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}`); + } + } + const codingSchemeFiles = await this.fileUploadRepository.find({ + where: { file_id: In([...codingSchemeRefs]) }, + select: ['file_id', 'data', 'filename'] + }); + + const fileIdToCodingSchemeMap = new Map(); + codingSchemeFiles.forEach(file => { + try { + const scheme = new Autocoder.CodingScheme(JSON.parse(JSON.stringify(file.data))); + fileIdToCodingSchemeMap.set(file.file_id, scheme); + } catch (error) { + this.logger.error(`--- Fehler beim Verarbeiten des Kodierschemas ${file.filename}: ${error.message}`); + } + }); + + const allCodedResponses = []; + + for (const unit of units) { + const responses = unitToResponsesMap.get(unit.id) || []; + if (responses.length === 0) continue; + + statistics.totalResponses += responses.length; + + let scheme = new Autocoder.CodingScheme({}); + const codingSchemeRef = unitToCodingSchemeRefMap.get(unit.id); + if (codingSchemeRef) { + scheme = fileIdToCodingSchemeMap.get(codingSchemeRef) || scheme; + } + + const codedResponses = responses.map(response => { + const codedResult = scheme.code([{ + id: response.variableid, + value: response.value, + status: response.status as ResponseStatusType + }]); + + const codedStatus = codedResult[0]?.status; + if (!statistics.statusCounts[codedStatus]) { + statistics.statusCounts[codedStatus] = 0; + } + statistics.statusCounts[codedStatus] += 1; + + return { + ...response, // Enthält die ursprüngliche 'id' und andere Felder der Response + code: codedResult[0]?.code, + codedstatus: codedStatus, + score: codedResult[0]?.score + }; + }); + + allCodedResponses.push(...codedResponses); + } + if (allCodedResponses.length > 0) { + try { + const batchSize = 10000; + const batches = []; + for (let i = 0; i < allCodedResponses.length; i += batchSize) { + batches.push(allCodedResponses.slice(i, i + batchSize)); + } + + this.logger.log(`Starte die Aktualisierung von ${allCodedResponses.length} Responses in ${batches.length} Batches (concurrent).`); + + const updateBatchPromises = batches.map(async (batch, index) => { + this.logger.log(`Starte Aktualisierung für Batch #${index + 1} (Größe: ${batch.length}).`); + const individualUpdatePromises = batch.map(codedResponse => this.responseRepository.update( + codedResponse.id, + { + code: codedResponse.code, + codedstatus: codedResponse.codedstatus, + score: codedResponse.score + } + ) + ); + try { + await Promise.all(individualUpdatePromises); + 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); + throw error; + } + }); + + await Promise.all(updateBatchPromises); + + this.logger.log(`${allCodedResponses.length} Responses wurden erfolgreich aktualisiert.`); + } catch (error) { + this.logger.error('Fehler beim Aktualisieren der Responses:', error.message); + } + } + + return statistics; + } catch (error) { + this.logger.error('Fehler beim Verarbeiten der Personen:', error); + return statistics; + } + } + + async getManualTestPersons(workspace_id: number, personIds?: string): Promise { + this.logger.log( + `Fetching responses for workspace_id = ${workspace_id} ${ + personIds ? `and personIds = ${personIds}` : '' + }.` + ); + + try { + const persons = await this.personsRepository.find({ + where: { workspace_id: workspace_id } + }); + + if (!persons.length) { + this.logger.log(`No persons found for workspace_id = ${workspace_id}.`); + return []; + } + + const filteredPersons = personIds ? + persons.filter(person => personIds.split(',').includes(String(person.id))) : + persons; + + if (!filteredPersons.length) { + this.logger.log(`No persons match the personIds in workspace_id = ${workspace_id}.`); + return []; + } + + const personIdsArray = filteredPersons.map(person => person.id); + + const booklets = await this.bookletRepository.find({ + where: { personid: In(personIdsArray) }, + select: ['id'] + }); + + const bookletIds = booklets.map(booklet => booklet.id); + + if (!bookletIds.length) { + this.logger.log( + `No booklets found for persons = [${personIdsArray.join(', ')}] in workspace_id = ${workspace_id}.` + ); + return []; + } + + const units = await this.unitRepository.find({ + where: { bookletid: In(bookletIds) }, + select: ['id', 'name'] + }); + + const unitIdToNameMap = new Map(units.map(unit => [unit.id, unit.name])); + const unitIds = Array.from(unitIdToNameMap.keys()); + + if (!unitIds.length) { + this.logger.log( + `No units found for booklets = [${bookletIds.join(', ')}] in workspace_id = ${workspace_id}.` + ); + return []; + } + + const responses = await this.responseRepository.find({ + where: { + unitid: In(unitIds), + codedstatus: In(['CODING_INCOMPLETE', 'INTENDED_INCOMPLETE', 'CODE_SELECTION_PENDING']) + } + }); + + const enrichedResponses = responses.map(response => ({ + ...response, + unitname: unitIdToNameMap.get(response.unitid) || 'Unknown Unit' + })); + + this.logger.log( + `Fetched ${responses.length} responses for the given criteria in workspace_id = ${workspace_id}.` + ); + + return enrichedResponses; + } catch (error) { + this.logger.error(`Failed to fetch responses: ${error.message}`, error.stack); + throw new Error('Could not retrieve responses. Please check the database connection or query.'); + } + } + + async getCodingList(workspace_id: number, authToken: string, serverUrl?: string, options?: { page: number; limit: number }): Promise<[{ + unit_key: string; + unit_alias: string; + login_name: string; + login_code: string; + booklet_id: string; + variable_id: string; + variable_page: string; + variable_anchor: string; + url: string; + }[], number]> { + try { + const server = serverUrl; + + const voudFiles = await this.fileUploadRepository.find({ + where: { + workspace_id: workspace_id, + file_type: 'Resource', + filename: Like('%.voud') + } + }); + + this.logger.log(`Found ${voudFiles.length} VOUD files for workspace ${workspace_id}`); + + const voudFileMap = new Map(); + voudFiles.forEach(file => { + voudFileMap.set(file.file_id, file); + }); + if (options) { + const { page, limit } = options; + const MAX_LIMIT = 10000000; + const validPage = Math.max(1, page); + const validLimit = Math.min(Math.max(1, limit), MAX_LIMIT); + const queryBuilder = this.responseRepository.createQueryBuilder('response') + .leftJoinAndSelect('response.unit', 'unit') + .leftJoinAndSelect('unit.booklet', 'booklet') + .leftJoinAndSelect('booklet.person', 'person') + .leftJoinAndSelect('booklet.bookletinfo', 'bookletinfo') + .where('response.codedStatus = :status', { status: 'CODING_INCOMPLETE' }) + .andWhere('person.workspace_id = :workspace_id', { workspace_id }) + .skip((validPage - 1) * validLimit) + .take(MAX_LIMIT) // Set a very high limit to fetch all items + .orderBy('response.id', 'ASC'); + + const [responses, total] = await queryBuilder.getManyAndCount(); + + const result = await Promise.all(responses.map(async response => { + const unit = response.unit; + const booklet = unit?.booklet; + const person = booklet?.person; + const bookletInfo = booklet?.bookletinfo; + const loginName = person?.login || ''; + const loginCode = person?.code || ''; + //const loginGroup = person?.group || ''; + const bookletId = bookletInfo?.name || ''; + const unitKey = unit?.name || ''; + const unitAlias = unit?.alias || ''; + let variablePage = '0'; + const variableAnchor = response.variableid || 0; + const voudFile = voudFileMap.get(`${unitKey}.VOUD`); + if (voudFile) { + try { + const respDefinition = { + definition: voudFile.data + }; + const transformResult = prepareDefinition(respDefinition); + const variablePageInfo = transformResult.variablePages.find( + pageInfo => pageInfo.variable_ref === response.variableid + ); + + if (variablePageInfo) { + variablePage = variablePageInfo.variable_page.toString(); + } + + this.logger.log(`Processed VOUD file for unit ${unitKey}, variable ${response.variableid}, page ${variablePage}`); + } catch (error) { + this.logger.error(`Error processing VOUD file for unit ${unitKey}: ${error.message}`); + } + } else { + this.logger.warn(`VOUD file not found for unit ${unitKey}`); + } + + const url = `${server}/#/replay/${loginName}@${loginCode}@${bookletId}/${unitKey}/${variablePage}/${variableAnchor}?auth=${authToken}`; + + return { + unit_key: unitKey, + unit_alias: unitAlias, + login_name: loginName, + login_code: loginCode, + booklet_id: bookletId, + variable_id: response.variableid || '', + variable_page: variablePage, + variable_anchor: response.variableid || '', + url + }; + })); + + const sortedResult = result.sort((a, b) => { + const unitKeyComparison = a.unit_key.localeCompare(b.unit_key); + if (unitKeyComparison !== 0) { + return unitKeyComparison; + } + return a.variable_id.localeCompare(b.variable_id); + }); + + this.logger.log(`Found ${sortedResult.length} coding items (page ${validPage}, limit ${validLimit}, total ${total})`); + return [sortedResult, total]; + } + + const queryBuilder = this.responseRepository.createQueryBuilder('response') + .leftJoinAndSelect('response.unit', 'unit') + .leftJoinAndSelect('unit.booklet', 'booklet') + .leftJoinAndSelect('booklet.person', 'person') + .leftJoinAndSelect('booklet.bookletinfo', 'bookletinfo') + .where('response.codedStatus = :status', { status: 'CODING_INCOMPLETE' }) + .andWhere('person.workspace_id = :workspace_id', { workspace_id }) + .orderBy('response.id', 'ASC'); + + const responses = await queryBuilder.getMany(); + + const result = await Promise.all(responses.map(async response => { + const unit = response.unit; + const booklet = unit?.booklet; + const person = booklet?.person; + const bookletInfo = booklet?.bookletinfo; + const loginName = person?.login || ''; + const loginCode = person?.code || ''; + //const loginGroup = person?.group || ''; + const bookletId = bookletInfo?.name || ''; + const unitKey = unit?.name || ''; + const unitAlias = unit?.alias || ''; + let variablePage = '0'; + const variableAnchor = response.variableid || 0; + const voudFile = voudFileMap.get(`${unitKey}.VOUD`); + + if (voudFile) { + try { + const respDefinition = { + definition: voudFile.data + }; + const transformResult = prepareDefinition(respDefinition); + + const variablePageInfo = transformResult.variablePages.find( + pageInfo => pageInfo.variable_ref === response.variableid + ); + + if (variablePageInfo) { + variablePage = variablePageInfo.variable_page.toString(); + } + + this.logger.log(`Processed VOUD file for unit ${unitKey}, variable ${response.variableid}, page ${variablePage}`); + } catch (error) { + this.logger.error(`Error processing VOUD file for unit ${unitKey}: ${error.message}`); + } + } else { + this.logger.warn(`VOUD file not found for unit ${unitKey}`); + } + + const url = `${server}/#/replay/${loginName}@${loginCode}@${bookletId}/${unitKey}/${variablePage}/${variableAnchor}?auth=${authToken}`; + return { + unit_key: unitKey, + unit_alias: unitAlias, + login_name: loginName, + login_code: loginCode, + booklet_id: bookletId, + variable_id: response.variableid || '', + variable_page: variablePage, + variable_anchor: response.variableid || '', + url + }; + })); + + const sortedResult = result.sort((a, b) => { + const unitKeyComparison = a.unit_key.localeCompare(b.unit_key); + if (unitKeyComparison !== 0) { + return unitKeyComparison; + } + // If unit_key is the same, sort by variable_id + return a.variable_id.localeCompare(b.variable_id); + }); + + this.logger.log(`Found ${sortedResult.length} coding items`); + return [sortedResult, sortedResult.length]; + } catch (error) { + this.logger.error(`Error fetching coding list: ${error.message}`); + return [[], 0]; + } + } + + async getCodingStatistics(workspace_id: number): Promise { + this.logger.log(`Getting coding statistics for workspace ${workspace_id}`); + + const statistics: CodingStatistics = { + totalResponses: 0, + statusCounts: {} + }; + + try { + const queryBuilder = this.responseRepository.createQueryBuilder('response') + .innerJoin('response.unit', 'unit') + .innerJoin('unit.booklet', 'booklet') + .innerJoin('booklet.person', 'person') + .where('response.status = :status', { status: 'VALUE_CHANGED' }) + .andWhere('person.workspace_id = :workspace_id', { workspace_id }); + + statistics.totalResponses = await queryBuilder.getCount(); + + const statusCountResults = await queryBuilder + .select('COALESCE(response.codedstatus, null)', 'statusValue') + .addSelect('COUNT(response.id)', 'count') + .groupBy('COALESCE(response.codedstatus, null)') + .getRawMany(); + + statusCountResults.forEach(result => { + statistics.statusCounts[result.statusValue] = parseInt(result.count, 10); + }); + + return statistics; + } catch (error) { + this.logger.error(`Error getting coding statistics: ${error.message}`); + + return statistics; + } + } + + async getCodingListAsCsv(workspace_id: number): Promise { + this.logger.log(`Generating CSV export for workspace ${workspace_id}`); + const [items] = await this.getCodingList(workspace_id, '', ''); + + if (!items || items.length === 0) { + this.logger.warn('No coding list items found for CSV export'); + return Buffer.from('No data available'); + } + + const csvStream = fastCsv.format({ headers: true }); + const chunks: Buffer[] = []; + + return new Promise((resolve, reject) => { + csvStream.on('data', chunk => { + chunks.push(Buffer.from(chunk)); + }); + + csvStream.on('end', () => { + const csvBuffer = Buffer.concat(chunks); + this.logger.log(`CSV export generated successfully with ${items.length} items`); + resolve(csvBuffer); + }); + + csvStream.on('error', error => { + this.logger.error(`Error generating CSV export: ${error.message}`); + reject(error); + }); + + items.forEach(item => { + csvStream.write({ + unit_key: item.unit_key, + unit_alias: item.unit_alias, + login_name: item.login_name, + login_code: item.login_code, + booklet_id: item.booklet_id, + variable_id: item.variable_id, + variable_page: item.variable_page, + variable_anchor: item.variable_anchor + }); + }); + + csvStream.end(); + }); + } + + async getCodingListAsExcel(workspace_id: number): Promise { + this.logger.log(`Generating Excel export for workspace ${workspace_id}`); + return this.getCodingListAsCsv(workspace_id); + } +} diff --git a/apps/backend/src/app/database/services/workspace-core.service.ts b/apps/backend/src/app/database/services/workspace-core.service.ts new file mode 100644 index 000000000..b19517984 --- /dev/null +++ b/apps/backend/src/app/database/services/workspace-core.service.ts @@ -0,0 +1,107 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import Workspace from '../entities/workspace.entity'; +import { WorkspaceInListDto } from '../../../../../../api-dto/workspaces/workspace-in-list-dto'; +import { WorkspaceFullDto } from '../../../../../../api-dto/workspaces/workspace-full-dto'; +import { CreateWorkspaceDto } from '../../../../../../api-dto/workspaces/create-workspace-dto'; +import { AdminWorkspaceNotFoundException } from '../../exceptions/admin-workspace-not-found.exception'; + +@Injectable() +export class WorkspaceCoreService { + private readonly logger = new Logger(WorkspaceCoreService.name); + + constructor( + @InjectRepository(Workspace) + private workspaceRepository: Repository + ) {} + + async findAll(options?: { page: number; limit: number }): Promise<[WorkspaceInListDto[], number]> { + this.logger.log('Fetching all workspaces from the repository.'); + + if (options) { + const { page, limit } = options; + const MAX_LIMIT = 10000; + const validPage = Math.max(1, page); + const validLimit = Math.min(Math.max(1, limit), MAX_LIMIT); + const [workspaces, total] = await this.workspaceRepository.findAndCount({ + select: ['id', 'name'], + skip: (validPage - 1) * validLimit, + take: validLimit + }); + + this.logger.log(`Found ${workspaces.length} workspaces (page ${validPage}, limit ${validLimit}, total ${total}).`); + return [workspaces.map(({ id, name }) => ({ id, name })), total]; + } + + const workspaces = await this.workspaceRepository.find({ + select: ['id', 'name'] + }); + this.logger.log(`Found ${workspaces.length} workspaces.`); + return [workspaces.map(({ id, name }) => ({ id, name })), workspaces.length]; + } + + async findOne(id: number): Promise { + this.logger.log(`Returning workspace with id: ${id}`); + const workspace = await this.workspaceRepository.findOne({ + where: { id: id }, + select: { id: true, name: true, settings: true } + }); + if (workspace) { + return { + id: workspace.id, + name: workspace.name, + settings: workspace.settings + }; + } + throw new AdminWorkspaceNotFoundException(id, 'GET'); + } + + async create(workspace: CreateWorkspaceDto): Promise { + this.logger.log(`Creating workspace with name: ${workspace.name}`); + const newWorkspace = this.workspaceRepository.create({ ...workspace }); + try { + const savedWorkspace = await this.workspaceRepository.save(newWorkspace); + this.logger.log(`Workspace created successfully with ID: ${savedWorkspace.id}`); + return savedWorkspace.id; + } catch (error) { + this.logger.error( + `Failed to create workspace with name: ${workspace.name}`, + error.stack + ); + throw new Error('Workspace creation failed'); + } + } + + async patch(workspaceData: WorkspaceFullDto): Promise { + this.logger.log(`Updating workspace with id: ${workspaceData.id}`); + if (workspaceData.id) { + const workspaceGroupToUpdate = await this.workspaceRepository.findOne({ + where: { id: workspaceData.id } + }); + if (workspaceData.name) workspaceGroupToUpdate.name = workspaceData.name; + if (workspaceData.settings) workspaceGroupToUpdate.settings = workspaceData.settings; + await this.workspaceRepository.save(workspaceGroupToUpdate); + } + } + + async remove(ids: number[]): Promise { + if (!ids || ids.length === 0) { + this.logger.warn('No IDs provided for workspace deletion.'); + return; + } + this.logger.log(`Attempting to delete workspaces with IDs: ${ids.join(', ')}`); + try { + const result = await this.workspaceRepository.delete(ids); + + if (result.affected && result.affected > 0) { + this.logger.log(`Successfully deleted ${result.affected} workspace(s) with IDs: ${ids.join(', ')}`); + } else { + this.logger.warn(`No workspaces found with the specified IDs: ${ids.join(', ')}`); + } + } catch (error) { + this.logger.error(`Failed to delete workspaces with IDs: ${ids.join(', ')}. Error: ${error.message}`, error.stack); + throw error; + } + } +} diff --git a/apps/backend/src/app/database/services/workspace-files.service.ts b/apps/backend/src/app/database/services/workspace-files.service.ts new file mode 100644 index 000000000..0ac98e57b --- /dev/null +++ b/apps/backend/src/app/database/services/workspace-files.service.ts @@ -0,0 +1,835 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { In, Repository } from 'typeorm'; +import * as cheerio from 'cheerio'; +import AdmZip = require('adm-zip'); +import * as fs from 'fs'; +import * as path from 'path'; +import * as libxmljs from 'libxmljs2'; +import FileUpload from '../entities/file_upload.entity'; +import { FilesDto } from '../../../../../../api-dto/files/files.dto'; +import { FileIo } from '../../admin/workspace/file-io.interface'; +import { FileDownloadDto } from '../../../../../../api-dto/files/file-download.dto'; +import { FileValidationResultDto } from '../../../../../../api-dto/files/file-validation-result.dto'; +import { ResponseDto } from '../../../../../../api-dto/responses/response-dto'; + +function sanitizePath(filePath: string): string { + const normalizedPath = path.normalize(filePath); + if (normalizedPath.startsWith('..')) { + throw new Error('Invalid file path: Path cannot navigate outside root.'); + } + return normalizedPath.replace(/\\/g, '/'); +} + +type FileStatus = { + filename: string; + exists: boolean; +}; + +type DataValidation = { + complete: boolean; + missing: string[]; + files: FileStatus[]; +}; + +type ValidationData = { + testTaker: string; + booklets: DataValidation; + units: DataValidation; + schemes: DataValidation; + definitions: DataValidation; + player: DataValidation; +}; + +export type ValidationResult = { + allUnitsExist: boolean; + missingUnits: string[]; + unitFiles: FileStatus[]; + allCodingSchemesExist: boolean; + allCodingDefinitionsExist: boolean; + missingCodingSchemeRefs: string[]; + missingDefinitionRefs: string[]; + schemeFiles: FileStatus[]; + definitionFiles: FileStatus[]; + allPlayerRefsExist: boolean; + missingPlayerRefs: string[]; + playerFiles: FileStatus[]; +}; + +@Injectable() +export class WorkspaceFilesService { + private readonly logger = new Logger(WorkspaceFilesService.name); + + constructor( + @InjectRepository(FileUpload) + private fileUploadRepository: Repository + ) {} + + async findFiles(workspaceId: number, options?: { page: number; limit: number }): Promise<[FilesDto[], number]> { + this.logger.log(`Fetching test files for workspace: ${workspaceId}`); + + if (options) { + const { page, limit } = options; + const MAX_LIMIT = 10000; + const validPage = Math.max(1, page); + const validLimit = Math.min(Math.max(1, limit), MAX_LIMIT); + + const [files, total] = await this.fileUploadRepository.findAndCount({ + where: { workspace_id: workspaceId }, + select: ['id', 'filename', 'file_id', 'file_size', 'file_type', 'created_at'], + skip: (validPage - 1) * validLimit, + take: validLimit, + order: { created_at: 'DESC' } + }); + + this.logger.log(`Found ${files.length} files (page ${validPage}, limit ${validLimit}, total ${total}).`); + return [files, total]; + } + + const files = await this.fileUploadRepository.find({ + where: { workspace_id: workspaceId }, + select: ['id', 'filename', 'file_id', 'file_size', 'file_type', 'created_at'], + order: { created_at: 'DESC' } + }); + + this.logger.log(`Found ${files.length} files.`); + return [files, files.length]; + } + + async deleteTestFiles(workspace_id: number, fileIds: string[]): Promise { + this.logger.log(`Delete test files for workspace ${workspace_id}`); + const res = await this.fileUploadRepository.delete(fileIds); + return !!res; + } + + async validateTestFiles(workspaceId: number): Promise { + try { + const testTakers = await this.fileUploadRepository.find({ + where: { workspace_id: workspaceId, file_type: In(['TestTakers', 'Testtakers']) } + }); + + if (!testTakers || testTakers.length === 0) { + this.logger.warn(`No TestTakers found in workspace with ID ${workspaceId}.`); + return { + testTakersFound: false, + validationResults: this.createEmptyValidationData() + }; + } + + const validationResultsPromises = testTakers.map(testTaker => this.processTestTaker(testTaker)); + const validationResults = (await Promise.all(validationResultsPromises)).filter(Boolean); + + if (validationResults.length > 0) { + return { + testTakersFound: true, + validationResults + }; + } + + const booklets = await this.fileUploadRepository.find({ + where: { workspace_id: workspaceId, file_type: 'Booklet' } + }); + + if (!booklets || booklets.length === 0) { + this.logger.warn(`No booklets found in workspace with ID ${workspaceId}.`); + return { + testTakersFound: true, + validationResults: this.createEmptyValidationData() + }; + } + + return { + testTakersFound: true, + validationResults: this.createEmptyValidationData() + }; + } catch (error) { + this.logger.error(`Error during test file validation for workspace ID ${workspaceId}: ${error.message}`, error.stack); + throw error; + } + } + + private createEmptyValidationData(): ValidationData[] { + return [{ + testTaker: '', + booklets: { complete: false, missing: [], files: [] }, + units: { complete: false, missing: [], files: [] }, + schemes: { complete: false, missing: [], files: [] }, + definitions: { complete: false, missing: [], files: [] }, + player: { complete: false, missing: [], files: [] } + }]; + } + + private async processTestTaker(testTaker: FileUpload): Promise { + const xmlDocument = cheerio.load(testTaker.data, { xmlMode: true, recognizeSelfClosing: true }); + const bookletTags = xmlDocument('Booklet'); + const unitTags = xmlDocument('Unit'); + + if (bookletTags.length === 0) { + this.logger.warn('No elements found in the XML document.'); + return null; + } + + this.logger.log(`Found ${bookletTags.length} elements.`); + + const { + uniqueBooklets + } = this.extractXmlData(bookletTags, unitTags); + + const { allBookletsExist, missingBooklets, bookletFiles } = await this.checkMissingBooklets(Array.from(uniqueBooklets)); + const { + allUnitsExist, + missingUnits, + unitFiles, + missingCodingSchemeRefs, + missingDefinitionRefs, + schemeFiles, + definitionFiles, + allCodingSchemesExist, + allCodingDefinitionsExist, + allPlayerRefsExist, + missingPlayerRefs, + playerFiles + } = await this.checkMissingUnits(Array.from(uniqueBooklets)); + + // If booklets are incomplete, all other categories should also be marked as incomplete + const bookletComplete = allBookletsExist; + + // If units are incomplete, coding schemes, definitions, and player should also be marked as incomplete + const unitComplete = bookletComplete && allUnitsExist; + + return { + testTaker: testTaker.file_id, + booklets: { + complete: bookletComplete, + missing: missingBooklets, + files: bookletFiles + }, + units: { + complete: bookletComplete ? allUnitsExist : false, + missing: missingUnits, + files: unitFiles + }, + schemes: { + complete: unitComplete ? allCodingSchemesExist : false, + missing: missingCodingSchemeRefs, + files: schemeFiles + }, + definitions: { + complete: unitComplete ? allCodingDefinitionsExist : false, + missing: missingDefinitionRefs, + files: definitionFiles + }, + player: { + complete: unitComplete ? allPlayerRefsExist : false, + missing: missingPlayerRefs, + files: playerFiles + } + }; + } + + private extractXmlData( + bookletTags: cheerio.Cheerio, + unitTags: cheerio.Cheerio + ): { + uniqueBooklets: Set; + uniqueUnits: Set; + codingSchemeRefs: string[]; + definitionRefs: string[]; + } { + const uniqueBooklets = new Set(); + const uniqueUnits = new Set(); + const codingSchemeRefs: string[] = []; + const definitionRefs: string[] = []; + + bookletTags.each((_, booklet) => { + const bookletValue = cheerio.load(booklet).text().trim(); + uniqueBooklets.add(bookletValue); + }); + + unitTags.each((_, unit) => { + const $ = cheerio.load(unit); + + $('unit').each((__, codingScheme) => { + const value = $(codingScheme).text().trim(); + if (value) codingSchemeRefs.push(value); + }); + + $('DefinitionRef').each((__, definition) => { + const value = $(definition).text().trim(); + if (value) definitionRefs.push(value); + }); + + const unitId = $('unit').attr('id'); + if (unitId) { + uniqueUnits.add(unitId.trim()); + } + }); + + return { + uniqueBooklets, uniqueUnits, codingSchemeRefs, definitionRefs + }; + } + + async uploadTestFiles(workspace_id: number, originalFiles: FileIo[]): Promise { + this.logger.log(`Uploading test files for workspace ${workspace_id}`); + + const MAX_CONCURRENT_UPLOADS = 5; + const processInBatches = async (files: FileIo[], batchSize: number): Promise[]> => { + const results: PromiseSettledResult[] = []; + const batches = []; + for (let i = 0; i < files.length; i += batchSize) { + const batch = files.slice(i, i + batchSize); + batches.push( + Promise.allSettled(batch.flatMap(file => this.handleFile(workspace_id, file))) + ); + } + const batchResults = await Promise.all(batches); + batchResults.forEach(batch => results.push(...batch as PromiseSettledResult[])); + return results; + }; + + try { + const results = await processInBatches(originalFiles, MAX_CONCURRENT_UPLOADS); + const failedFiles = results + .filter(result => result.status === 'rejected') + .map((result, index) => ({ + file: originalFiles[index], + reason: (result as PromiseRejectedResult).reason + })); + + if (failedFiles.length > 0) { + this.logger.warn(`Some files failed to upload for workspace ${workspace_id}:`); + failedFiles.forEach(({ file, reason }) => this.logger.warn(`File: ${JSON.stringify(file)}, Reason: ${reason}`) + ); + } + return failedFiles.length === 0; + } catch (error) { + this.logger.error(`Unexpected error while uploading files for workspace ${workspace_id}:`, error); + return false; + } + } + + async downloadTestFile(workspace_id: number, fileId: number): Promise { + this.logger.log(`Downloading file with ID ${fileId} for workspace ${workspace_id}`); + + const file = await this.fileUploadRepository.findOne({ + where: { id: fileId, workspace_id: workspace_id } + }); + + if (!file) { + this.logger.warn(`File with ID ${fileId} not found in workspace ${workspace_id}`); + throw new Error('File not found'); + } + this.logger.log(`File ${file.filename} found. Preparing to convert to Base64.`); + const base64Data = Buffer.from(file.data, 'binary').toString('base64'); + this.logger.log(`File ${file.filename} successfully converted to Base64.`); + + return { + filename: file.filename, + base64Data, + mimeType: 'application/xml' + }; + } + + handleFile(workspaceId: number, file: FileIo): Array> { + const filePromises: Array> = []; + + switch (file.mimetype) { + case 'text/xml': + filePromises.push(this.handleXmlFile(workspaceId, file)); + break; + case 'text/html': + filePromises.push(this.handleHtmlFile(workspaceId, file)); + break; + case 'application/octet-stream': + filePromises.push(this.handleOctetStreamFile(workspaceId, file)); + break; + case 'application/zip': + case 'application/x-zip-compressed': + case 'application/x-zip': + filePromises.push(...this.handleZipFile(workspaceId, file)); + break; + default: + this.logger.warn(`Unsupported file type: ${file.mimetype}`); + filePromises.push(Promise.reject(this.unsupportedFile(`Unsupported file type: ${file.mimetype}`))); + } + + return filePromises; + } + + private unsupportedFile(message: string): Error { + return new Error(message); + } + + private async validateXmlAgainstSchema(xml: string, xsdPath: string): Promise { + try { + const xsdContent = fs.readFileSync(xsdPath, 'utf8'); + const xsdDoc = libxmljs.parseXml(xsdContent); + const xmlDoc = libxmljs.parseXml(xml); + return xmlDoc.validate(xsdDoc); + } catch (err) { + this.logger.error(`XML validation error: ${err.message}`); + return false; + } + } + + private async handleXmlFile(workspaceId: number, file: FileIo): Promise { + try { + if (!file.buffer || !file.buffer.length) { + this.logger.warn('Empty file buffer'); + return await Promise.resolve(); + } + + const xmlContent = file.buffer.toString('utf8'); + const xmlDocument = cheerio.load(file.buffer.toString('utf8'), { xmlMode: true, recognizeSelfClosing: true }); + const firstChild = xmlDocument.root().children().first(); + const rootTagName = firstChild ? firstChild.prop('tagName') : null; + + if (!rootTagName) { + return this.unsupportedFile('Invalid XML: No root tag found'); + } + + const fileTypeMapping: Record = { + UNIT: 'Unit', + BOOKLET: 'Booklet', + TESTTAKERS: 'TestTakers' + }; + + const fileType = fileTypeMapping[rootTagName]; + if (!fileType) { + return this.unsupportedFile(`Unsupported root tag: ${rootTagName}`); + } + + const schemaPaths: Record = { + UNIT: path.resolve(__dirname, 'schemas/unit.xsd'), + BOOKLET: path.resolve(__dirname, 'schemas/booklet.xsd'), + TESTTAKERS: path.resolve(__dirname, 'schemas/testtakers.xsd') + }; + const xsdPath = schemaPaths[rootTagName]; + if (!xsdPath || !fs.existsSync(xsdPath)) { + return this.unsupportedFile(`No XSD schema found for root tag: ${rootTagName}`); + } + + await this.validateXmlAgainstSchema(xmlContent, xsdPath); + + const metadata = xmlDocument('Metadata'); + const idElement = metadata.find('Id'); + const fileId = idElement.length ? idElement.text().toUpperCase().trim() : null; + const resolvedFileId = fileType === 'TestTakers' ? fileId || file.originalname : fileId; + + const existingFile = await this.fileUploadRepository.findOne({ + where: { file_id: resolvedFileId, workspace_id: workspaceId } + }); + if (existingFile) { + this.logger.warn( + `File with ID ${resolvedFileId} in Workspace ${workspaceId} already exists.` + ); + return { + message: `File with ID ${resolvedFileId} already exists`, + fileId: resolvedFileId, + filename: file.originalname + }; + } + + return await this.fileUploadRepository.upsert({ + workspace_id: workspaceId, + filename: file.originalname, + file_type: fileType, + file_size: file.size, + data: file.buffer.toString(), + file_id: resolvedFileId + }, ['file_id']); + } catch (error) { + this.logger.error(`Error processing XML file: ${error.message}`); + throw error; + } + } + + private async handleHtmlFile(workspaceId: number, file: FileIo): Promise { + const resourceFileId = WorkspaceFilesService.getPlayerId(file); + + return this.fileUploadRepository.upsert({ + filename: file.originalname, + workspace_id: workspaceId, + file_type: 'Resource', + file_size: file.size, + file_id: resourceFileId, + data: file.buffer.toString() + }, ['file_id']); + } + + private async handleOctetStreamFile(workspaceId: number, file: FileIo): Promise { + this.logger.log(`Processing octet-stream file: ${file.originalname} for workspace ${workspaceId}`); + try { + const fileExtension = path.extname(file.originalname).toLowerCase(); + let fileType = 'Resource'; + let fileContent: string | Buffer = file.buffer; + + if (['.xml', '.html', '.htm', '.xhtml', '.txt', '.json', '.csv'].includes(fileExtension)) { + fileContent = file.buffer.toString('utf8'); + } + + if (fileExtension === '.xml') { + try { + const $ = cheerio.load(fileContent as string, { xmlMode: true }); + if ($('Testtakers').length > 0) { + fileType = 'TestTakers'; + } else if ($('Booklet').length > 0) { + fileType = 'Booklet'; + } else if ($('Unit').length > 0) { + fileType = 'Unit'; + } else if ($('SysCheck').length > 0) { + fileType = 'SysCheck'; + } + } catch (error) { + this.logger.warn(`Could not parse XML content for ${file.originalname}: ${error.message}`); + } + } + + // @ts-expect-error + const fileUpload = this.fileUploadRepository.create({ + workspace_id: workspaceId, + filename: file.originalname, + file_id: file.originalname.toUpperCase(), + file_type: fileType, + file_size: file.size, + data: fileContent + }); + + await this.fileUploadRepository.save(fileUpload); + this.logger.log(`Successfully processed octet-stream file: ${file.originalname} as ${fileType}`); + } catch (error) { + this.logger.error(`Error processing octet-stream file ${file.originalname}: ${error.message}`, error.stack); + throw error; + } + } + + private handleZipFile(workspaceId: number, file: FileIo): Array> { + this.logger.log(`Processing ZIP file: ${file.originalname} for workspace ${workspaceId}`); + const promises: Array> = []; + + try { + const zip = new AdmZip(file.buffer); + const zipEntries = zip.getEntries(); + + if (zipEntries.length === 0) { + this.logger.warn(`ZIP file ${file.originalname} is empty.`); + return [Promise.reject(new Error(`ZIP file ${file.originalname} is empty.`))]; + } + + this.logger.log(`Found ${zipEntries.length} entries in ZIP file ${file.originalname}`); + + zipEntries.forEach(zipEntry => { + if (zipEntry.isDirectory) { + return; + } + + const entryName = zipEntry.entryName; + const sanitizedEntryName = sanitizePath(entryName); + const entryData = zipEntry.getData(); + + const mimeType = this.getMimeType(sanitizedEntryName); + const fileIo: FileIo = { + originalname: path.basename(sanitizedEntryName), + buffer: entryData, + mimetype: mimeType, + size: entryData.length, + fieldname: '', + encoding: '' + }; + + promises.push(...this.handleFile(workspaceId, fileIo)); + }); + + return promises; + } catch (error) { + this.logger.error(`Error processing ZIP file ${file.originalname}: ${error.message}`, error.stack); + return [Promise.reject(error)]; + } + } + + private async checkMissingBooklets(uniqueBookletsArray: string[]): Promise<{ + allBookletsExist: boolean; + missingBooklets: string[]; + bookletFiles: FileStatus[]; + }> { + this.logger.log(`Checking for missing booklets among ${uniqueBookletsArray.length} unique booklet IDs`); + + const bookletFiles: FileStatus[] = []; + const missingBooklets: string[] = []; + + for (const booklet of uniqueBookletsArray) { + const bookletId = booklet.trim(); + if (!bookletId) continue; + + const existingBooklet = await this.fileUploadRepository.findOne({ + where: { file_id: bookletId.toUpperCase(), file_type: 'Booklet' } + }); + + const fileStatus: FileStatus = { + filename: bookletId, + exists: !!existingBooklet + }; + + bookletFiles.push(fileStatus); + + if (!existingBooklet) { + missingBooklets.push(bookletId); + } + } + + const allBookletsExist = missingBooklets.length === 0; + this.logger.log(`Found ${missingBooklets.length} missing booklets out of ${uniqueBookletsArray.length} total`); + + return { allBookletsExist, missingBooklets, bookletFiles }; + } + + async checkMissingUnits(bookletNames:string[]): Promise { + try { + const existingBooklets = await this.fileUploadRepository.findBy({ + file_type: 'Booklet', + file_id: In(bookletNames.map(b => b.toUpperCase())) + }); + + const unitIdsPromises = existingBooklets.map(async booklet => { + try { + const fileData = booklet.data; + const $ = cheerio.load(fileData, { xmlMode: true }); + const unitIds: string[] = []; + + $('Unit').each((_, element) => { + const unitId = $(element).attr('id'); + if (unitId) { + unitIds.push(unitId.toUpperCase()); + } + }); + + return unitIds; + } catch (error) { + this.logger.error(`Fehler beim Verarbeiten von Unit ${booklet.file_id}:`, error); + return []; + } + }); + + const allUnitIdsArrays = await Promise.all(unitIdsPromises); + const allUnitIds = Array.from(new Set(allUnitIdsArrays.flat())); + const chunkSize = 50; + const unitBatches = []; + + for (let i = 0; i < allUnitIds.length; i += chunkSize) { + const chunk = allUnitIds.slice(i, i + chunkSize); + unitBatches.push(chunk); + } + + const unitBatchPromises = unitBatches.map(batch => this.fileUploadRepository.find({ + where: { file_id: In(batch) } + })); + + const unitBatchResults = await Promise.all(unitBatchPromises); + const existingUnits = unitBatchResults.flat(); + + const refsPromises = existingUnits.map(async unit => { + try { + const fileData = unit.data; + const $ = cheerio.load(fileData, { xmlMode: true }); + const refs = { + codingSchemeRefs: [] as string[], + definitionRefs: [] as string[], + playerRefs: [] as string[] + }; + + $('Unit').each((_, element) => { + const codingSchemeRef = $(element).find('CodingSchemeRef').text(); + const definitionRef = $(element).find('DefinitionRef').text(); + const playerRefAttr = $(element).find('DefinitionRef').attr('player'); + const playerRef = playerRefAttr ? playerRefAttr.replace('@', '-') : ''; + + if (codingSchemeRef) { + refs.codingSchemeRefs.push(codingSchemeRef.toUpperCase()); + } + + if (definitionRef) { + refs.definitionRefs.push(definitionRef.toUpperCase()); + } + + if (playerRef) { + refs.playerRefs.push(playerRef.toUpperCase()); + } + }); + + return refs; + } catch (error) { + this.logger.error(`Fehler beim Verarbeiten von Unit ${unit.file_id}:`, error); + return { codingSchemeRefs: [], definitionRefs: [], playerRefs: [] }; + } + }); + + const allRefs = await Promise.all(refsPromises); + + // Combine all references using Sets to remove duplicates + const allCodingSchemeRefs = Array.from(new Set(allRefs.flatMap(ref => ref.codingSchemeRefs))); + const allDefinitionRefs = Array.from(new Set(allRefs.flatMap(ref => ref.definitionRefs))); + const allPlayerRefs = Array.from(new Set(allRefs.flatMap(ref => ref.playerRefs))); + + // Get all resources in a single query + const existingResources = await this.fileUploadRepository.findBy({ + file_type: 'Resource' + }); + + const allResourceIds = existingResources.map(resource => resource.file_id); + + // Find missing references + const missingCodingSchemeRefs = allCodingSchemeRefs.filter(ref => !allResourceIds.includes(ref)); + const missingDefinitionRefs = allDefinitionRefs.filter(ref => !allResourceIds.includes(ref)); + const missingPlayerRefs = allPlayerRefs.filter(ref => !allResourceIds.includes(ref)); + + // Check if all references exist + const allCodingSchemesExist = missingCodingSchemeRefs.length === 0; + const allCodingDefinitionsExist = missingDefinitionRefs.length === 0; + const allPlayerRefsExist = missingPlayerRefs.length === 0; + + // Find missing units + const foundUnitIds = existingUnits.map(unit => unit.file_id.toUpperCase()); + const missingUnits = allUnitIds.filter(unitId => !foundUnitIds.includes(unitId)); + const uniqueUnits = Array.from(new Set(missingUnits)); + + const allUnitsExist = missingUnits.length === 0; + + // Create lists of all files with their match status + const unitFiles: FileStatus[] = allUnitIds.map(unitId => ({ + filename: unitId, + exists: foundUnitIds.includes(unitId) + })); + + const schemeFiles: FileStatus[] = allCodingSchemeRefs.map(ref => ({ + filename: ref, + exists: allResourceIds.includes(ref) + })); + + const definitionFiles: FileStatus[] = allDefinitionRefs.map(ref => ({ + filename: ref, + exists: allResourceIds.includes(ref) + })); + + const playerFiles: FileStatus[] = allPlayerRefs.map(ref => ({ + filename: ref, + exists: allResourceIds.includes(ref) + })); + + return { + allUnitsExist, + missingUnits: uniqueUnits, + unitFiles, + allCodingSchemesExist, + allCodingDefinitionsExist, + missingCodingSchemeRefs, + missingDefinitionRefs, + schemeFiles, + definitionFiles, + allPlayerRefsExist, + missingPlayerRefs, + playerFiles + }; + } catch (error) { + this.logger.error('Error validating units', error); + throw error; + } + } + + private getMimeType(fileName: string): string { + const extension = path.extname(fileName).toLowerCase(); + const mimeTypes: Record = { + '.xml': 'text/xml', + '.html': 'text/html', + '.htm': 'text/html', + '.zip': 'application/zip' + }; + return mimeTypes[extension] || 'application/octet-stream'; + } + + static cleanResponses(rows: ResponseDto[]): ResponseDto[] { + return Object.values(rows.reduce((agg, response) => { + const key = [response.test_person, response.unit_id].join('@@@@@@'); + if (agg[key]) { + if (!(agg[key].responses.length) && response.responses.length) { + agg[key].responses = response.responses; + } + if ( + !(Object.keys(agg[key].unit_state || {}).length) && + (Object.keys(response.unit_state || {}).length) + ) { + agg[key].unit_state = response.unit_state; + } + } else { + agg[key] = response; + } + return agg; + }, <{ [key: string]: ResponseDto }>{})); + } + + async testCenterImport(entries: Record[]): Promise { + try { + const registry = this.fileUploadRepository.create(entries); + await this.fileUploadRepository.upsert(registry, ['file_id']); + return true; + } catch (error) { + this.logger.error('Error during test center import', error); + return false; + } + } + + private static getPlayerId(file: FileIo): string { + try { + const playerCode = file.buffer.toString(); + + const playerContent = cheerio.load(playerCode); + + // Search for JSON+LD