From 5ee9a82f5a60e0a48af55f0bab89706ed494cf34 Mon Sep 17 00:00:00 2001 From: jurei733 <67505990+jurei733@users.noreply.github.com> Date: Fri, 1 Aug 2025 18:34:18 +0200 Subject: [PATCH 1/6] Remove postgres conf --- docker-compose.override.yaml | 4 +--- docker-compose.yaml | 1 - 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/docker-compose.override.yaml b/docker-compose.override.yaml index bc272e812..d3c1be40a 100644 --- a/docker-compose.override.yaml +++ b/docker-compose.override.yaml @@ -7,9 +7,7 @@ services: REGISTRY_PATH: ${REGISTRY_PATH} ports: - "${POSTGRES_PORT}:5432" - volumes: - - "./database/config/postgresql.conf:/etc/postgresql/postgresql.conf" - command: ["postgres", "-c", "config_file=/etc/postgresql/postgresql.conf"] + liquibase: build: diff --git a/docker-compose.yaml b/docker-compose.yaml index 41e4714ec..5cc186e67 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -4,7 +4,6 @@ x-env-postgres: &env-postgres POSTGRES_USER: ${POSTGRES_USER} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} POSTGRES_DB: ${POSTGRES_DB} - POSTGRES_SHARED_BUFFERS: ${POSTGRES_SHARED_BUFFERS} services: redis: image: redis:alpine From d86ccbb4a6a28205dafb32a54303eac3ec9aad9f Mon Sep 17 00:00:00 2001 From: jurei733 <67505990+jurei733@users.noreply.github.com> Date: Fri, 1 Aug 2025 18:35:22 +0200 Subject: [PATCH 2/6] Fix the volume path to have changes in code in container --- apps/frontend/Dockerfile | 2 +- docker-compose.override.yaml | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/frontend/Dockerfile b/apps/frontend/Dockerfile index 8050c4e75..0c47a220e 100644 --- a/apps/frontend/Dockerfile +++ b/apps/frontend/Dockerfile @@ -12,7 +12,7 @@ ARG PROJECT RUN sed -i "s/localhost.*$/backend:3333\",/" apps/${PROJECT}/proxy.conf.json -EXPOSE 80 +EXPOSE 8080 ENTRYPOINT ["npx", "nx"] diff --git a/docker-compose.override.yaml b/docker-compose.override.yaml index d3c1be40a..fa3cd01eb 100644 --- a/docker-compose.override.yaml +++ b/docker-compose.override.yaml @@ -39,7 +39,7 @@ services: - "${API_PORT}:3333" # backend - "9229:9229" # default debug port volumes: - - "./apps/backend/src:/usr/src/coding-box/apps/backend/src" + - "./apps/backend/src:/usr/src/coding-box-base/apps/backend/src" - "backend_vol:/usr/src/coding-box/packages" command: - "serve" @@ -64,7 +64,7 @@ services: ports: - "${HTTP_PORT}:8080" volumes: - - "./apps/frontend/src:/coding-box/apps/frontend/src" + - "./apps/frontend/src:/usr/src/coding-box-base/apps/frontend/src" command: - "serve" - "frontend" From e214b3018c0295f4ed92241b4f36a0bc6b9c7650 Mon Sep 17 00:00:00 2001 From: jurei733 <67505990+jurei733@users.noreply.github.com> Date: Fri, 1 Aug 2025 20:44:27 +0200 Subject: [PATCH 3/6] Add Redis cache module and service --- apps/backend/src/app/app.module.ts | 3 +- apps/backend/src/app/cache/cache.module.ts | 35 +++ apps/backend/src/app/cache/cache.service.ts | 90 +++++++ .../src/app/database/database.module.ts | 13 +- package-lock.json | 250 +++++++++++++++++- package.json | 7 +- 6 files changed, 381 insertions(+), 17 deletions(-) create mode 100644 apps/backend/src/app/cache/cache.module.ts create mode 100644 apps/backend/src/app/cache/cache.service.ts diff --git a/apps/backend/src/app/app.module.ts b/apps/backend/src/app/app.module.ts index 29446b601..104bcbc2a 100755 --- a/apps/backend/src/app/app.module.ts +++ b/apps/backend/src/app/app.module.ts @@ -7,13 +7,14 @@ import { DatabaseModule } from './database/database.module'; import { AdminModule } from './admin/admin.module'; import { JobQueueModule } from './job-queue/job-queue.module'; import { HealthModule } from './health/health.module'; +import { CacheModule } from './cache/cache.module'; @Module({ imports: [ConfigModule.forRoot({ isGlobal: true, envFilePath: '.env.dev', cache: true - }), AuthModule, DatabaseModule, AdminModule, HttpModule, JobQueueModule, HealthModule], + }), AuthModule, DatabaseModule, AdminModule, HttpModule, JobQueueModule, HealthModule, CacheModule], controllers: [AppController] }) export class AppModule {} diff --git a/apps/backend/src/app/cache/cache.module.ts b/apps/backend/src/app/cache/cache.module.ts new file mode 100644 index 000000000..a1ce1966e --- /dev/null +++ b/apps/backend/src/app/cache/cache.module.ts @@ -0,0 +1,35 @@ +import { Module, forwardRef } from '@nestjs/common'; +import { ConfigModule, ConfigService } from '@nestjs/config'; +import { RedisModule } from '@nestjs-modules/ioredis'; +import { ScheduleModule } from '@nestjs/schedule'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { CacheService } from './cache.service'; +import { ResponseCacheSchedulerService } from './response-cache-scheduler.service'; +import { BookletCacheSchedulerService } from './booklet-cache-scheduler.service'; +import Persons from '../database/entities/persons.entity'; +import { Unit } from '../database/entities/unit.entity'; +// eslint-disable-next-line import/no-cycle +import { DatabaseModule } from '../database/database.module'; + +@Module({ + imports: [ + RedisModule.forRootAsync({ + imports: [ConfigModule], + inject: [ConfigService], + useFactory: (configService: ConfigService) => ({ + type: 'single', + options: { + host: configService.get('REDIS_HOST', 'redis'), + port: parseInt(configService.get('REDIS_PORT', '6379'), 10), + keyPrefix: `${configService.get('REDIS_PREFIX', 'coding-box')}:cache:` + } + }) + }), + ScheduleModule.forRoot(), + TypeOrmModule.forFeature([Persons, Unit]), + forwardRef(() => DatabaseModule) + ], + providers: [CacheService, ResponseCacheSchedulerService, BookletCacheSchedulerService], + exports: [CacheService] +}) +export class CacheModule {} diff --git a/apps/backend/src/app/cache/cache.service.ts b/apps/backend/src/app/cache/cache.service.ts new file mode 100644 index 000000000..f8bf632c2 --- /dev/null +++ b/apps/backend/src/app/cache/cache.service.ts @@ -0,0 +1,90 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { InjectRedis } from '@nestjs-modules/ioredis'; +import Redis from 'ioredis'; + +@Injectable() +export class CacheService { + private readonly logger = new Logger(CacheService.name); + private readonly DEFAULT_TTL = 3600; // 1 hour in seconds + + constructor( + @InjectRedis() private readonly redis: Redis + ) {} + + /** + * Get a value from the cache + * @param key The cache key + * @returns The cached value or null if not found + */ + async get(key: string): Promise { + try { + const cachedValue = await this.redis.get(key); + if (!cachedValue) { + return null; + } + return JSON.parse(cachedValue) as T; + } catch (error) { + this.logger.error(`Error getting value from cache: ${error.message}`, error.stack); + return null; + } + } + + /** + * Set a value in the cache + * @param key The cache key + * @param value The value to cache + * @param ttl Time to live in seconds (optional, defaults to 1 hour) + * @returns True if the value was set, false otherwise + */ + async set(key: string, value: T, ttl: number = this.DEFAULT_TTL): Promise { + try { + const serializedValue = JSON.stringify(value); + await this.redis.set(key, serializedValue, 'EX', ttl); + return true; + } catch (error) { + this.logger.error(`Error setting value in cache: ${error.message}`, error.stack); + return false; + } + } + + /** + * Delete a value from the cache + * @param key The cache key + * @returns True if the value was deleted, false otherwise + */ + async delete(key: string): Promise { + try { + await this.redis.del(key); + return true; + } catch (error) { + this.logger.error(`Error deleting value from cache: ${error.message}`, error.stack); + return false; + } + } + + /** + * Check if a key exists in the cache + * @param key The cache key + * @returns True if the key exists, false otherwise + */ + async exists(key: string): Promise { + try { + const exists = await this.redis.exists(key); + return exists === 1; + } catch (error) { + this.logger.error(`Error checking if key exists in cache: ${error.message}`, error.stack); + return false; + } + } + + /** + * Generate a cache key for unit responses + * @param workspaceId The workspace ID + * @param testPerson The test person ID + * @param unitId The unit ID + * @returns The cache key + */ + generateUnitResponseCacheKey(workspaceId: number, testPerson: string, unitId: string): string { + return `responses:${workspaceId}:${testPerson}:${unitId}`; + } +} diff --git a/apps/backend/src/app/database/database.module.ts b/apps/backend/src/app/database/database.module.ts index 73347c992..c011c947b 100755 --- a/apps/backend/src/app/database/database.module.ts +++ b/apps/backend/src/app/database/database.module.ts @@ -49,17 +49,9 @@ import { ReplayStatistics } from './entities/replay-statistics.entity'; import { ReplayStatisticsService } from './services/replay-statistics.service'; // eslint-disable-next-line import/no-cycle import { JobQueueModule } from '../job-queue/job-queue.module'; +// eslint-disable-next-line import/no-cycle +import { CacheModule } from '../cache/cache.module'; -/** - * DatabaseModule provides database access and services for the application. - * - * Note: This module has a circular dependency with JobQueueModule because: - * - DatabaseModule exports WorkspaceCodingService which is used by JobQueueModule - * - DatabaseModule imports JobQueueModule for job queue functionality - * - * This circular dependency is resolved using forwardRef() both at the module level - * and at the injection point in the TestPersonCodingProcessor. - */ @Module({ imports: [ User, @@ -79,6 +71,7 @@ import { JobQueueModule } from '../job-queue/job-queue.module'; WorkspaceUser, HttpModule, forwardRef(() => JobQueueModule), + forwardRef(() => CacheModule), TypeOrmModule.forRootAsync({ imports: [ConfigModule], useFactory: (configService: ConfigService) => ({ diff --git a/package-lock.json b/package-lock.json index f67e8def9..1889d8bab 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,6 +22,7 @@ "@iqb/responses": "^3.6.0", "@iqbspecs/response": "1.4.0", "@iqbspecs/variable-info": "1.3.0", + "@nestjs-modules/ioredis": "^2.0.2", "@nestjs/axios": "^4.0.1", "@nestjs/bull": "^11.0.3", "@nestjs/common": "^11.1.5", @@ -30,6 +31,7 @@ "@nestjs/jwt": "^11.0.0", "@nestjs/passport": "^11.0.5", "@nestjs/platform-express": "^11.1.5", + "@nestjs/schedule": "^6.0.0", "@nestjs/swagger": "^11.1.5", "@nestjs/testing": "^11.1.5", "@nestjs/typeorm": "^11.0.0", @@ -49,6 +51,7 @@ "docx": "^9.5.1", "fast-csv": "^5.0.1", "file-saver-es": "^2.0.5", + "ioredis": "^5.7.0", "jwt-decode": "^4.0.0", "keycloak-angular": "20.0.0", "keycloak-js": "^23.0.6", @@ -7545,6 +7548,91 @@ "@tybys/wasm-util": "^0.9.0" } }, + "node_modules/@nestjs-modules/ioredis": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@nestjs-modules/ioredis/-/ioredis-2.0.2.tgz", + "integrity": "sha512-8pzSvT8R3XP6p8ZzQmEN8OnY0yWrJ/elFhwQK+PID2zf1SLBkAZ18bDcx3SKQ2atledt0gd9kBeP5xT4MlyS7Q==", + "license": "MIT", + "optionalDependencies": { + "@nestjs/terminus": "10.2.0" + }, + "peerDependencies": { + "@nestjs/common": ">=6.7.0", + "@nestjs/core": ">=6.7.0", + "ioredis": ">=5.0.0" + } + }, + "node_modules/@nestjs-modules/ioredis/node_modules/@nestjs/terminus": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/@nestjs/terminus/-/terminus-10.2.0.tgz", + "integrity": "sha512-zPs98xvJ4ogEimRQOz8eU90mb7z+W/kd/mL4peOgrJ/VqER+ibN2Cboj65uJZW3XuNhpOqaeYOJte86InJd44A==", + "license": "MIT", + "optional": true, + "dependencies": { + "boxen": "5.1.2", + "check-disk-space": "3.4.0" + }, + "peerDependencies": { + "@grpc/grpc-js": "*", + "@grpc/proto-loader": "*", + "@mikro-orm/core": "*", + "@mikro-orm/nestjs": "*", + "@nestjs/axios": "^1.0.0 || ^2.0.0 || ^3.0.0", + "@nestjs/common": "^9.0.0 || ^10.0.0", + "@nestjs/core": "^9.0.0 || ^10.0.0", + "@nestjs/microservices": "^9.0.0 || ^10.0.0", + "@nestjs/mongoose": "^9.0.0 || ^10.0.0", + "@nestjs/sequelize": "^9.0.0 || ^10.0.0", + "@nestjs/typeorm": "^9.0.0 || ^10.0.0", + "@prisma/client": "*", + "mongoose": "*", + "reflect-metadata": "0.1.x", + "rxjs": "7.x", + "sequelize": "*", + "typeorm": "*" + }, + "peerDependenciesMeta": { + "@grpc/grpc-js": { + "optional": true + }, + "@grpc/proto-loader": { + "optional": true + }, + "@mikro-orm/core": { + "optional": true + }, + "@mikro-orm/nestjs": { + "optional": true + }, + "@nestjs/axios": { + "optional": true + }, + "@nestjs/microservices": { + "optional": true + }, + "@nestjs/mongoose": { + "optional": true + }, + "@nestjs/sequelize": { + "optional": true + }, + "@nestjs/typeorm": { + "optional": true + }, + "@prisma/client": { + "optional": true + }, + "mongoose": { + "optional": true + }, + "sequelize": { + "optional": true + }, + "typeorm": { + "optional": true + } + } + }, "node_modules/@nestjs/axios": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/@nestjs/axios/-/axios-4.0.1.tgz", @@ -8033,6 +8121,19 @@ "node": ">= 0.6" } }, + "node_modules/@nestjs/schedule": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@nestjs/schedule/-/schedule-6.0.0.tgz", + "integrity": "sha512-aQySMw6tw2nhitELXd3EiRacQRgzUKD9mFcUZVOJ7jPLqIBvXOyvRWLsK9SdurGA+jjziAlMef7iB5ZEFFoQpw==", + "license": "MIT", + "dependencies": { + "cron": "4.3.0" + }, + "peerDependencies": { + "@nestjs/common": "^10.0.0 || ^11.0.0", + "@nestjs/core": "^10.0.0 || ^11.0.0" + } + }, "node_modules/@nestjs/schematics": { "version": "11.0.5", "resolved": "https://registry.npmjs.org/@nestjs/schematics/-/schematics-11.0.5.tgz", @@ -13745,6 +13846,12 @@ "@types/node": "*" } }, + "node_modules/@types/luxon": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.6.2.tgz", + "integrity": "sha512-R/BdP7OxEMc44l2Ex5lSXHoIXTB2JLNa3y2QISIbr58U/YcsffyQrYW//hZSdrfxrjRZj3GcUoxMPGdO8gSYuw==", + "license": "MIT" + }, "node_modules/@types/mime": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", @@ -14583,6 +14690,16 @@ "ajv": "^8.8.2" } }, + "node_modules/ansi-align": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ansi-align/-/ansi-align-3.0.1.tgz", + "integrity": "sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w==", + "license": "ISC", + "optional": true, + "dependencies": { + "string-width": "^4.1.0" + } + }, "node_modules/ansi-colors": { "version": "4.1.3", "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", @@ -15327,6 +15444,73 @@ "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==" }, + "node_modules/boxen": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/boxen/-/boxen-5.1.2.tgz", + "integrity": "sha512-9gYgQKXx+1nP8mP7CzFyaUARhg7D3n1dF/FnErWmu9l6JvGpNUN278h0aSb+QjoiKSWG+iZ3uHrcqk0qrY9RQQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "ansi-align": "^3.0.0", + "camelcase": "^6.2.0", + "chalk": "^4.1.0", + "cli-boxes": "^2.2.1", + "string-width": "^4.2.2", + "type-fest": "^0.20.2", + "widest-line": "^3.1.0", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/boxen/node_modules/camelcase": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", + "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/boxen/node_modules/type-fest": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", + "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", + "license": "(MIT OR CC0-1.0)", + "optional": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/boxen/node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "optional": true, + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, "node_modules/brace-expansion": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", @@ -15776,6 +15960,16 @@ "dev": true, "license": "MIT" }, + "node_modules/check-disk-space": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/check-disk-space/-/check-disk-space-3.4.0.tgz", + "integrity": "sha512-drVkSqfwA+TvuEhFipiR1OC9boEGZL5RrWvVsOthdcvQNXyCCuKkEiTOTXZ7qxSf/GLwq4GvzfrQD/Wz325hgw==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=16" + } + }, "node_modules/cheerio": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.1.0.tgz", @@ -15897,6 +16091,19 @@ "validator": "^13.9.0" } }, + "node_modules/cli-boxes": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/cli-boxes/-/cli-boxes-2.2.1.tgz", + "integrity": "sha512-y4coMcylgSCdVinjiDBuR8PCC2bLjyGTwEmPb9NHR/QaNU6EUOXcTY/s6VjGMD6ENSEaeQYHCY0GNGS5jfMwPw==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/cli-cursor": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz", @@ -16598,6 +16805,19 @@ "devOptional": true, "license": "MIT" }, + "node_modules/cron": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/cron/-/cron-4.3.0.tgz", + "integrity": "sha512-ciiYNLfSlF9MrDqnbMdRWFiA6oizSF7kA1osPP9lRzNu0Uu+AWog1UKy7SkckiDY2irrNjeO6qLyKnXC8oxmrw==", + "license": "MIT", + "dependencies": { + "@types/luxon": "~3.6.0", + "luxon": "~3.6.0" + }, + "engines": { + "node": ">=18.x" + } + }, "node_modules/cron-parser": { "version": "4.9.0", "resolved": "https://registry.npmjs.org/cron-parser/-/cron-parser-4.9.0.tgz", @@ -16610,6 +16830,15 @@ "node": ">=12.0.0" } }, + "node_modules/cron/node_modules/luxon": { + "version": "3.6.1", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.6.1.tgz", + "integrity": "sha512-tJLxrKJhO2ukZ5z0gyjY1zPh3Rh88Ej9P7jNrZiHMUXHae1yvI2imgOZtL1TO8TW6biMMKfTtAOoEJANgtWBMQ==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -20781,12 +21010,12 @@ } }, "node_modules/ioredis": { - "version": "5.6.1", - "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.6.1.tgz", - "integrity": "sha512-UxC0Yv1Y4WRJiGQxQkP0hfdL0/5/6YvdfOOClRgJ0qppSarkhneSa6UvkMkms0AkdGimSH3Ikqm+6mkMmX7vGA==", + "version": "5.7.0", + "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.7.0.tgz", + "integrity": "sha512-NUcA93i1lukyXU+riqEyPtSEkyFq8tX90uL659J+qpCZ3rEdViB/APC58oAhIh3+bJln2hzdlZbBZsGNrlsR8g==", "license": "MIT", "dependencies": { - "@ioredis/commands": "^1.1.1", + "@ioredis/commands": "^1.3.0", "cluster-key-slot": "^1.1.0", "debug": "^4.3.4", "denque": "^2.1.0", @@ -31106,6 +31335,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/widest-line": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/widest-line/-/widest-line-3.1.0.tgz", + "integrity": "sha512-NsmoXalsWVDMGupxZ5R08ka9flZjjiLvHVAWYOKtiKM8ujtZWr9cRffak+uSE48+Ob8ObalXpwyeUiyDD6QFgg==", + "license": "MIT", + "optional": true, + "dependencies": { + "string-width": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/wildcard": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/wildcard/-/wildcard-2.0.1.tgz", diff --git a/package.json b/package.json index b2a4c9364..01e5ae620 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "@iqb/responses": "^3.6.0", "@iqbspecs/response": "1.4.0", "@iqbspecs/variable-info": "1.3.0", + "@nestjs-modules/ioredis": "^2.0.2", "@nestjs/axios": "^4.0.1", "@nestjs/bull": "^11.0.3", "@nestjs/common": "^11.1.5", @@ -41,12 +42,15 @@ "@nestjs/jwt": "^11.0.0", "@nestjs/passport": "^11.0.5", "@nestjs/platform-express": "^11.1.5", + "@nestjs/schedule": "^6.0.0", "@nestjs/swagger": "^11.1.5", "@nestjs/testing": "^11.1.5", "@nestjs/typeorm": "^11.0.0", "@ngx-translate/core": "^15.0.0", "@ngx-translate/http-loader": "^8.0.0", + "@swimlane/ngx-charts": "^23.0.0-alpha.0", "@types/adm-zip": "^0.5.0", + "@types/file-saver-es": "^2.0.1", "adm-zip": "^0.5.9", "ajv": "^8.17.1", "ajv-keywords": "^5.1.0", @@ -58,7 +62,7 @@ "docx": "^9.5.1", "fast-csv": "^5.0.1", "file-saver-es": "^2.0.5", - "@types/file-saver-es": "^2.0.1", + "ioredis": "^5.7.0", "jwt-decode": "^4.0.0", "keycloak-angular": "20.0.0", "keycloak-js": "^23.0.6", @@ -70,7 +74,6 @@ "reflect-metadata": "^0.2.0", "rxjs": "~7.8.0", "stream": "^0.0.2", - "@swimlane/ngx-charts": "^23.0.0-alpha.0", "timers": "^0.1.1", "tslib": "^2.3.0", "typeorm": "^0.3.20", From 8c7741914aa738dbc993d1aa09ac72f1549c22d0 Mon Sep 17 00:00:00 2001 From: jurei733 <67505990+jurei733@users.noreply.github.com> Date: Fri, 1 Aug 2025 20:46:44 +0200 Subject: [PATCH 4/6] Add booklet and response cache scheduler services --- .../cache/booklet-cache-scheduler.service.ts | 99 ++++++++++++++ .../cache/response-cache-scheduler.service.ts | 121 ++++++++++++++++++ 2 files changed, 220 insertions(+) create mode 100644 apps/backend/src/app/cache/booklet-cache-scheduler.service.ts create mode 100644 apps/backend/src/app/cache/response-cache-scheduler.service.ts diff --git a/apps/backend/src/app/cache/booklet-cache-scheduler.service.ts b/apps/backend/src/app/cache/booklet-cache-scheduler.service.ts new file mode 100644 index 000000000..8a2a80c27 --- /dev/null +++ b/apps/backend/src/app/cache/booklet-cache-scheduler.service.ts @@ -0,0 +1,99 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { Cron, CronExpression } from '@nestjs/schedule'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { CacheService } from './cache.service'; +import Persons from '../database/entities/persons.entity'; +import { WorkspaceTestResultsService } from '../database/services/workspace-test-results.service'; + +@Injectable() +export class BookletCacheSchedulerService { + private readonly logger = new Logger(BookletCacheSchedulerService.name); + private readonly BOOKLET_CACHE_TTL = 24 * 60 * 60; // 24 hours in seconds + + constructor( + private readonly cacheService: CacheService, + private readonly workspaceTestResultsService: WorkspaceTestResultsService, + @InjectRepository(Persons) + private readonly personsRepository: Repository + ) {} + + /** + * Scheduled task to cache all test person booklets + * Runs every night at 3:00 AM (after the response cache scheduler) + */ + @Cron(CronExpression.EVERY_DAY_AT_3AM) + async cacheAllBooklets() { + this.logger.log('Starting nightly task to cache all test person booklets'); + + try { + // Get all workspaces with persons + const workspaces = await this.getWorkspacesWithPersons(); + + for (const workspace of workspaces) { + const workspaceId = workspace.workspace_id; + this.logger.log(`Caching booklets for workspace ${workspaceId}`); + + // Get all test persons in this workspace + const persons = await this.personsRepository.find({ + where: { workspace_id: workspaceId, consider: true } + }); + + for (const person of persons) { + try { + // Cache the booklet data for this person + await this.cachePersonBooklets(person.id, workspaceId); + } catch (error) { + this.logger.error(`Error caching booklets for person ID ${person.id} in workspace ${workspaceId}: ${error.message}`, error.stack); + } + } + } + + this.logger.log('Finished nightly caching of all test person booklets'); + } catch (error) { + this.logger.error(`Error in cacheAllBooklets: ${error.message}`, error.stack); + } + } + + /** + * Get all workspaces that have persons + */ + private async getWorkspacesWithPersons(): Promise<{ workspace_id: number }[]> { + return this.personsRepository + .createQueryBuilder('person') + .select('DISTINCT person.workspace_id', 'workspace_id') + .where('person.consider = :consider', { consider: true }) + .getRawMany(); + } + + /** + * Cache booklet data for a specific person + */ + private async cachePersonBooklets(personId: number, workspaceId: number): Promise { + const cacheKey = this.generateBookletCacheKey(workspaceId, personId); + + // Check if already in cache + const exists = await this.cacheService.exists(cacheKey); + if (exists) { + this.logger.debug(`Booklet data already in cache for person ID ${personId} in workspace ${workspaceId}`); + return; + } + + // Fetch and cache the booklet data + try { + const bookletData = await this.workspaceTestResultsService.findPersonTestResults(personId, workspaceId); + await this.cacheService.set(cacheKey, bookletData, this.BOOKLET_CACHE_TTL); + this.logger.debug(`Cached booklet data for person ID ${personId} in workspace ${workspaceId}`); + } catch (error) { + this.logger.error(`Error fetching booklet data for caching: ${error.message}`, error.stack); + throw error; + } + } + + /** + * Generate a cache key for booklet data + */ + private generateBookletCacheKey(workspaceId: number, personId: number): string { + return `booklets:${workspaceId}:${personId}`; + } +} diff --git a/apps/backend/src/app/cache/response-cache-scheduler.service.ts b/apps/backend/src/app/cache/response-cache-scheduler.service.ts new file mode 100644 index 000000000..551c7bf5a --- /dev/null +++ b/apps/backend/src/app/cache/response-cache-scheduler.service.ts @@ -0,0 +1,121 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { Cron, CronExpression } from '@nestjs/schedule'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { CacheService } from './cache.service'; +import Persons from '../database/entities/persons.entity'; +import { Unit } from '../database/entities/unit.entity'; +import { WorkspaceTestResultsService } from '../database/services/workspace-test-results.service'; + +@Injectable() +export class ResponseCacheSchedulerService { + private readonly logger = new Logger(ResponseCacheSchedulerService.name); + + constructor( + private readonly cacheService: CacheService, + private readonly workspaceTestResultsService: WorkspaceTestResultsService, + @InjectRepository(Persons) + private readonly personsRepository: Repository, + @InjectRepository(Unit) + private readonly unitRepository: Repository + ) {} + + /** + * Scheduled task to cache all possible replay URLs and their responses + * Runs every night at 2:00 AM + */ + @Cron(CronExpression.EVERY_DAY_AT_1AM) + async cacheAllResponses() { + this.logger.log('Starting nightly task to cache all responses'); + + try { + // Get all workspaces with persons + const workspaces = await this.getWorkspacesWithPersons(); + + for (const workspace of workspaces) { + const workspaceId = workspace.workspace_id; + this.logger.log(`Caching responses for workspace ${workspaceId}`); + + // Get all test persons in this workspace + const persons = await this.personsRepository.find({ + where: { workspace_id: workspaceId, consider: true } + }); + + for (const person of persons) { + // Get all units for this person + const units = await this.getUnitsForPerson(person.id); + + for (const unit of units) { + // Create the connector string (login@code@bookletId) + const connector = this.createConnector(person, unit.booklet.bookletinfo.name); + + try { + // Cache the response + await this.cacheResponse(workspaceId, connector, unit.alias); + } catch (error) { + this.logger.error(`Error caching response for workspace=${workspaceId}, testPerson=${connector}, unitId=${unit.alias}: ${error.message}`, error.stack); + } + } + } + } + + this.logger.log('Finished nightly caching of all responses'); + } catch (error) { + this.logger.error(`Error in cacheAllResponses: ${error.message}`, error.stack); + } + } + + /** + * Get all workspaces that have persons + */ + private async getWorkspacesWithPersons(): Promise<{ workspace_id: number }[]> { + return this.personsRepository + .createQueryBuilder('person') + .select('DISTINCT person.workspace_id', 'workspace_id') + .where('person.consider = :consider', { consider: true }) + .getRawMany(); + } + + /** + * Get all units for a person + */ + private async getUnitsForPerson(personId: number): Promise { + return this.unitRepository + .createQueryBuilder('unit') + .leftJoinAndSelect('unit.booklet', 'booklet') + .leftJoinAndSelect('booklet.bookletinfo', 'bookletInfo') + .where('booklet.personid = :personId', { personId }) + .getMany(); + } + + /** + * Create a connector string for a person and booklet + */ + private createConnector(person: Persons, bookletId: string): string { + return `${person.login}@${person.code}@${bookletId}`; + } + + /** + * Cache a response for a specific workspace, test person, and unit + */ + private async cacheResponse(workspaceId: number, connector: string, unitId: string): Promise { + const cacheKey = this.cacheService.generateUnitResponseCacheKey(workspaceId, connector, unitId); + + // Check if already in cache + const exists = await this.cacheService.exists(cacheKey); + if (exists) { + this.logger.debug(`Response already in cache: workspace=${workspaceId}, testPerson=${connector}, unitId=${unitId}`); + return; + } + + // Fetch and cache the response + try { + const response = await this.workspaceTestResultsService.findUnitResponse(workspaceId, connector, unitId); + await this.cacheService.set(cacheKey, response); + this.logger.debug(`Cached response: workspace=${workspaceId}, testPerson=${connector}, unitId=${unitId}`); + } catch (error) { + this.logger.error(`Error fetching response for caching: ${error.message}`, error.stack); + throw error; + } + } +} From 1bfa336ef77793672a2982a5f43df534b3cf1447 Mon Sep 17 00:00:00 2001 From: jurei733 <67505990+jurei733@users.noreply.github.com> Date: Fri, 1 Aug 2025 20:47:12 +0200 Subject: [PATCH 5/6] Integrate caching into the workspace test results service --- .../workspace-test-results.service.ts | 212 ++++++++++++++++-- 1 file changed, 189 insertions(+), 23 deletions(-) diff --git a/apps/backend/src/app/database/services/workspace-test-results.service.ts b/apps/backend/src/app/database/services/workspace-test-results.service.ts index 3b948fd91..53f02ca21 100644 --- a/apps/backend/src/app/database/services/workspace-test-results.service.ts +++ b/apps/backend/src/app/database/services/workspace-test-results.service.ts @@ -11,6 +11,7 @@ import { UnitLog } from '../entities/unitLog.entity'; import { Session } from '../entities/session.entity'; import { UnitTagService } from './unit-tag.service'; import { JournalService } from './journal.service'; +import { CacheService } from '../../cache/cache.service'; @Injectable() export class WorkspaceTestResultsService { @@ -35,7 +36,8 @@ export class WorkspaceTestResultsService { private sessionRepository: Repository, private readonly connection: Connection, private readonly unitTagService: UnitTagService, - private readonly journalService: JournalService + private readonly journalService: JournalService, + private readonly cacheService: CacheService ) {} async findPersonTestResults(personId: number, workspaceId: number): Promise<{ @@ -59,6 +61,35 @@ export class WorkspaceTestResultsService { throw new Error('Both personId and workspaceId are required.'); } + // Generate a cache key for booklet data + const cacheKey = `booklets:${workspaceId}:${personId}`; + + // Check if data is in Redis cache + const cachedResult = await this.cacheService.get<{ + id: number; + personid: number; + name: string; + size: number; + logs: { id: number; bookletid: number; ts: string; parameter: string, key: string }[]; + sessions: { id: number; browser: string; os: string; screen: string; ts: 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 }[]; + tags: { id: number; unitId: number; tag: string; color?: string; createdAt: Date }[]; + }[]; + }[]>(cacheKey); + + if (cachedResult) { + this.logger.log(`Cache hit: Returning cached booklet data for person ${personId} in workspace ${workspaceId}`); + return cachedResult; + } + + this.logger.log(`Cache miss for booklet data for person ${personId} in workspace ${workspaceId}`); + try { this.logger.log( `Fetching booklets, bookletInfo data, units, and test results for personId: ${personId} and workspaceId: ${workspaceId}` @@ -203,7 +234,7 @@ export class WorkspaceTestResultsService { unitsMap.get(unit.bookletid)?.push(unit); }); - return booklets.map(booklet => ({ + const result = booklets.map(booklet => ({ id: booklet.id, personid: booklet.personid, name: booklet.bookletinfo.name, @@ -220,6 +251,13 @@ export class WorkspaceTestResultsService { tags: unitTagsMap.get(unit.id) || [] })) })); + + // Store the result in Redis cache (24 hours TTL for booklet data) + const ONE_DAY_SECONDS = 24 * 60 * 60; + await this.cacheService.set(cacheKey, result, ONE_DAY_SECONDS); + this.logger.log(`Cached booklet data for person ${personId} in workspace ${workspaceId}`); + + return result; } catch (error) { this.logger.error( `Failed to fetch booklets, bookletInfo, units, and results for personId: ${personId} and workspaceId: ${workspaceId}`, @@ -240,6 +278,18 @@ export class WorkspaceTestResultsService { const validPage = Math.max(1, page); // minimum 1 const validLimit = Math.min(Math.max(1, limit), MAX_LIMIT); // Between 1 and MAX_LIMIT + // Generate a cache key based on the parameters + const cacheKey = `test-results:${workspace_id}:${validPage}:${validLimit}:${searchText || ''}`; + + // Check if data is in Redis cache + const cachedResult = await this.cacheService.get<[Persons[], number]>(cacheKey); + if (cachedResult) { + this.logger.log(`Cache hit: Returning cached test results for workspace ${workspace_id} (page ${validPage}, limit ${validLimit})`); + return cachedResult; + } + + this.logger.log(`Cache miss for test results in workspace ${workspace_id} (page ${validPage}, limit ${validLimit})`); + try { const queryBuilder = this.personsRepository.createQueryBuilder('person') .where('person.workspace_id = :workspace_id', { workspace_id }) @@ -267,7 +317,12 @@ export class WorkspaceTestResultsService { const [results, total] = await queryBuilder.getManyAndCount(); - return [results, total]; + // Store the result in Redis cache (30 seconds TTL for test results) + const result: [Persons[], number] = [results, total]; + await this.cacheService.set(cacheKey, result, 30); + this.logger.log(`Cached test results for workspace ${workspace_id} (page ${validPage}, limit ${validLimit})`); + + return result; } catch (error) { this.logger.error(`Failed to fetch test results for workspace_id ${workspace_id}: ${error.message}`, error.stack); throw new Error('An error occurred while fetching test results'); @@ -277,6 +332,20 @@ export class WorkspaceTestResultsService { async findWorkspaceResponses(workspace_id: number, options?: { page: number; limit: number }): Promise<[ResponseEntity[], number]> { this.logger.log('Returning responses for workspace', workspace_id); + // Generate a cache key based on the parameters + const cacheKey = `workspace-responses:${workspace_id}:${options?.page || 0}:${options?.limit || 0}`; + + // Check if data is in Redis cache + const cachedResult = await this.cacheService.get<[ResponseEntity[], number]>(cacheKey); + if (cachedResult) { + this.logger.log(`Cache hit: Returning cached workspace responses for workspace ${workspace_id}`); + return cachedResult; + } + + this.logger.log(`Cache miss for workspace responses in workspace ${workspace_id}`); + + let result: [ResponseEntity[], number]; + if (options) { const { page, limit } = options; const MAX_LIMIT = 500; @@ -290,18 +359,35 @@ export class WorkspaceTestResultsService { }); this.logger.log(`Found ${responses.length} responses (page ${validPage}, limit ${validLimit}, total ${total}) for workspace ${workspace_id}`); - return [responses, total]; + result = [responses, total]; + } else { + const responses = await this.responseRepository.find({ + order: { id: 'ASC' } + }); + + this.logger.log(`Found ${responses.length} responses for workspace ${workspace_id}`); + result = [responses, responses.length]; } - const responses = await this.responseRepository.find({ - order: { id: 'ASC' } - }); + // Store the result in Redis cache (45 seconds TTL for workspace responses) + await this.cacheService.set(cacheKey, result, 45); + this.logger.log(`Cached workspace responses for workspace ${workspace_id}`); - this.logger.log(`Found ${responses.length} responses for workspace ${workspace_id}`); - return [responses, responses.length]; + return result; } async findUnitResponse(workspaceId: number, connector: string, unitId: string): Promise<{ responses: { id: string, content: { id: string; value: string; status: string }[] }[] }> { + const cacheKey = this.cacheService.generateUnitResponseCacheKey(workspaceId, connector, unitId); + const cachedResponse = await this.cacheService.get<{ responses: { id: string, content: { id: string; value: string; status: string }[] }[] }>(cacheKey); + + if (cachedResponse) { + this.logger.log(`Cache hit for responses: workspace=${workspaceId}, testPerson=${connector}, unitId=${unitId}`); + return cachedResponse; + } + + this.logger.log(`Cache miss for responses: workspace=${workspaceId}, testPerson=${connector}, unitId=${unitId}`); + + // If not in cache, fetch from database const [login, code, bookletId] = connector.split('@'); const person = await this.personsRepository.findOne({ where: { @@ -389,25 +475,33 @@ export class WorkspaceTestResultsService { }; }); - return { + const result = { responses: responsesArray }; + + // Cache the result + await this.cacheService.set(cacheKey, result); + this.logger.log(`Cached responses for: workspace=${workspaceId}, testPerson=${connector}, unitId=${unitId}`); + + return result; } - private responsesByStatusCache: Map = new Map(); - private readonly RESPONSES_CACHE_TTL_MS = 60 * 1000; // 1 minute cache TTL + private readonly RESPONSES_CACHE_TTL_SECONDS = 60; // 1 minute cache TTL async getResponsesByStatus(workspace_id: number, status: string, options?: { page: number; limit: number }): Promise<[ResponseEntity[], number]> { this.logger.log(`Getting responses with status ${status} for workspace ${workspace_id}`); - const cacheKey = `${workspace_id}-${status}-${options?.page || 0}-${options?.limit || 0}`; + const cacheKey = `responses:status:${workspace_id}:${status}:${options?.page || 0}:${options?.limit || 0}`; - const cachedResult = this.responsesByStatusCache.get(cacheKey); - if (cachedResult && (Date.now() - cachedResult.timestamp) < this.RESPONSES_CACHE_TTL_MS) { - this.logger.log(`Returning cached responses for status ${status} (workspace ${workspace_id})`); - return cachedResult.data; + // Check if data is in Redis cache + const cachedResult = await this.cacheService.get<[ResponseEntity[], number]>(cacheKey); + if (cachedResult) { + this.logger.log(`Cache hit: Returning cached responses for status ${status} (workspace ${workspace_id})`); + return cachedResult; } + this.logger.log(`Cache miss for responses with status ${status} (workspace ${workspace_id})`); + try { const queryBuilder = this.responseRepository.createQueryBuilder('response') .leftJoinAndSelect('response.unit', 'unit') @@ -444,10 +538,9 @@ export class WorkspaceTestResultsService { this.logger.log(`Found ${result[0].length} responses with status ${status} for workspace ${workspace_id}`); } - this.responsesByStatusCache.set(cacheKey, { - data: result, - timestamp: Date.now() - }); + // Store the result in Redis cache + await this.cacheService.set(cacheKey, result, this.RESPONSES_CACHE_TTL_SECONDS); + this.logger.log(`Cached responses with status ${status} for workspace ${workspace_id}`); return result; } catch (error) { @@ -772,6 +865,39 @@ export class WorkspaceTestResultsService { const limit = options.limit || 10; const skip = (page - 1) * limit; + // Generate a cache key based on the search parameters and pagination + const cacheKey = `search-responses:${workspaceId}:${JSON.stringify(searchParams)}:${page}:${limit}`; + + // Check if data is in Redis cache + const cachedResult = await this.cacheService.get<{ + data: { + responseId: number; + variableId: string; + value: string; + status: string; + code?: number; + score?: number; + codedStatus?: string; + unitId: number; + unitName: string; + unitAlias: string | null; + bookletId: number; + bookletName: string; + personId: number; + personLogin: string; + personCode: string; + personGroup: string; + }[]; + total: number; + }>(cacheKey); + + if (cachedResult) { + this.logger.log(`Cache hit: Returning cached search results for workspace ${workspaceId}`); + return cachedResult; + } + + this.logger.log(`Cache miss for search results in workspace ${workspaceId}`); + try { this.logger.log( `Searching for responses in workspace: ${workspaceId} with params: ${JSON.stringify(searchParams)} (page: ${page}, limit: ${limit})` @@ -844,7 +970,13 @@ export class WorkspaceTestResultsService { personGroup: response.unit.booklet.person.group })); - return { data, total }; + const result = { data, total }; + + const ONE_DAY_SECONDS = 24 * 60 * 60; + await this.cacheService.set(cacheKey, result, ONE_DAY_SECONDS); + this.logger.log(`Cached search results for workspace ${workspaceId}`); + + return result; } catch (error) { this.logger.error( `Failed to search for responses in workspace: ${workspaceId}`, @@ -882,6 +1014,34 @@ export class WorkspaceTestResultsService { const limit = options.limit || 10; const skip = (page - 1) * limit; + // Generate a cache key based on the parameters + const cacheKey = `units-by-name:${workspaceId}:${unitName}:${page}:${limit}`; + + // Check if data is in Redis cache + const cachedResult = await this.cacheService.get<{ + data: { + unitId: number; + unitName: string; + unitAlias: string | null; + bookletId: number; + bookletName: string; + personId: number; + personLogin: string; + personCode: string; + personGroup: string; + tags: { id: number; unitId: number; tag: string; color?: string; createdAt: Date }[]; + responses: { variableId: string; value: string; status: string; code?: number; score?: number; codedStatus?: string }[]; + }[]; + total: number; + }>(cacheKey); + + if (cachedResult) { + this.logger.log(`Cache hit: Returning cached units by name for workspace ${workspaceId}, unitName: ${unitName}`); + return cachedResult; + } + + this.logger.log(`Cache miss for units by name in workspace ${workspaceId}, unitName: ${unitName}`); + try { this.logger.log( `Searching for units with name: ${unitName} in workspace: ${workspaceId} (page: ${page}, limit: ${limit})` @@ -949,7 +1109,13 @@ export class WorkspaceTestResultsService { }); data = Array.from(uniqueMap.values()); - return { data, total: data.length }; + const result = { data, total: data.length }; + + // Store the result in Redis cache (60 seconds TTL for units by name) + await this.cacheService.set(cacheKey, result, 60); + this.logger.log(`Cached units by name for workspace ${workspaceId}, unitName: ${unitName}`); + + return result; } catch (error) { this.logger.error( `Failed to search for units with name: ${unitName} in workspace: ${workspaceId}`, From 603db649e0cf691c4ba4f7f8fd943af2146a8a19 Mon Sep 17 00:00:00 2001 From: jurei733 <67505990+jurei733@users.noreply.github.com> Date: Fri, 1 Aug 2025 20:59:29 +0200 Subject: [PATCH 6/6] Set version to 0.11.1 --- apps/frontend/src/app/components/home/home.component.html | 2 +- package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/frontend/src/app/components/home/home.component.html b/apps/frontend/src/app/components/home/home.component.html index e15d176e8..86e314cc7 100755 --- a/apps/frontend/src/app/components/home/home.component.html +++ b/apps/frontend/src/app/components/home/home.component.html @@ -9,7 +9,7 @@ [appTitle]="'Web application for coding'" [introHtml]="'appService.appConfig.introHtml'" [appName]="'IQB-Kodierbox'" - [appVersion]="'0.11.0'" + [appVersion]="'0.11.1'" [userName]="authData.userName" [userLongName]="appService.userProfile.firstName + ' ' + appService.userProfile.lastName" [isUserLoggedIn]="Number(authData.userId) > 0" diff --git a/package-lock.json b/package-lock.json index 1889d8bab..0601473a4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "coding-box", - "version": "0.11.0", + "version": "0.11.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "coding-box", - "version": "0.11.0", + "version": "0.11.1", "license": "MIT", "dependencies": { "@angular/animations": "20.0.3", diff --git a/package.json b/package.json index 01e5ae620..320567170 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "coding-box", - "version": "0.11.0", + "version": "0.11.1", "author": "IQB - Institut zur Qualitätsentwicklung im Bildungswesen", "license": "MIT", "scripts": {