diff --git a/package-lock.json b/package-lock.json index 2380f03..a96df4a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.0.1", "license": "UNLICENSED", "dependencies": { + "@nestjs/bullmq": "^11.0.3", "@nestjs/common": "^11.1.3", "@nestjs/config": "^4.0.2", "@nestjs/core": "^11.1.3", @@ -16,6 +17,7 @@ "@nestjs/mongoose": "^11.0.3", "@nestjs/platform-express": "^11.1.3", "@nestjs/typeorm": "^11.0.0", + "bullmq": "^5.56.7", "class-transformer": "^0.5.1", "class-validator": "^0.14.2", "glob": "^11.0.3", @@ -1380,6 +1382,11 @@ } } }, + "node_modules/@ioredis/commands": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.2.0.tgz", + "integrity": "sha512-Sx1pU8EM64o2BrqNpEO1CNLtKQwyhuXuqyfH7oGKCk+1a33d2r5saW8zNwm3j6BTExtjrv2BxTgzzkMwts6vGg==" + }, "node_modules/@isaacs/balanced-match": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz", @@ -2043,6 +2050,78 @@ "sparse-bitfield": "^3.0.3" } }, + "node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.3.tgz", + "integrity": "sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.3.tgz", + "integrity": "sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.3.tgz", + "integrity": "sha512-fg0uy/dG/nZEXfYilKoRe7yALaNmHoYeIoJuJ7KJ+YyU2bvY8vPv27f7UKhGRpY6euFYqEVhxCFZgAUNQBM3nw==", + "cpu": [ + "arm" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.3.tgz", + "integrity": "sha512-YxQL+ax0XqBJDZiKimS2XQaf+2wDGVa1enVRGzEvLLVFeqa5kx2bWbtcSXgsxjQB7nRqqIGFIcLteF/sHeVtQg==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.3.tgz", + "integrity": "sha512-cvwNfbP07pKUfq1uH+S6KJ7dT9K8WOE4ZiAcsrSes+UY55E/0jLYc+vq+DO7jlmqRb5zAggExKm0H7O/CBaesg==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.3.tgz", + "integrity": "sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@napi-rs/nice": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/@napi-rs/nice/-/nice-1.0.4.tgz", @@ -2343,6 +2422,32 @@ "@tybys/wasm-util": "^0.10.0" } }, + "node_modules/@nestjs/bull-shared": { + "version": "11.0.3", + "resolved": "https://registry.npmjs.org/@nestjs/bull-shared/-/bull-shared-11.0.3.tgz", + "integrity": "sha512-CaHniPkLAxis6fAB1DB8WZELQv8VPCLedbj7iP0VQ1pz74i6NSzG9mBg6tOomXq/WW4la4P4OMGEQ48UAJh20A==", + "dependencies": { + "tslib": "2.8.1" + }, + "peerDependencies": { + "@nestjs/common": "^10.0.0 || ^11.0.0", + "@nestjs/core": "^10.0.0 || ^11.0.0" + } + }, + "node_modules/@nestjs/bullmq": { + "version": "11.0.3", + "resolved": "https://registry.npmjs.org/@nestjs/bullmq/-/bullmq-11.0.3.tgz", + "integrity": "sha512-0Qr7Fk3Ir3V2OBIKJk+ArEM0AesGjKaNZA8QQ4fH3qGogudYADSjaNe910/OAfmX8q+cjCRorvwTLdcShwWEMw==", + "dependencies": { + "@nestjs/bull-shared": "^11.0.3", + "tslib": "2.8.1" + }, + "peerDependencies": { + "@nestjs/common": "^10.0.0 || ^11.0.0", + "@nestjs/core": "^10.0.0 || ^11.0.0", + "bullmq": "^3.0.0 || ^4.0.0 || ^5.0.0" + } + }, "node_modules/@nestjs/cli": { "version": "11.0.7", "resolved": "https://registry.npmjs.org/@nestjs/cli/-/cli-11.0.7.tgz", @@ -5500,6 +5605,32 @@ "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==" }, + "node_modules/bullmq": { + "version": "5.56.7", + "resolved": "https://registry.npmjs.org/bullmq/-/bullmq-5.56.7.tgz", + "integrity": "sha512-Aa4Y7rmkuZOuyHvitGd2rSETgQ0SMawPD0XVsU9gAUg9Q4bwNpSD9ZDVcWGhfAQAUZFJH82JUmLsm0B59Prmtg==", + "dependencies": { + "cron-parser": "^4.9.0", + "ioredis": "^5.4.1", + "msgpackr": "^1.11.2", + "node-abort-controller": "^3.1.1", + "semver": "^7.5.4", + "tslib": "^2.0.0", + "uuid": "^9.0.0" + } + }, + "node_modules/bullmq/node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/busboy": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", @@ -5825,6 +5956,14 @@ "node": ">=0.8" } }, + "node_modules/cluster-key-slot": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz", + "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/co": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", @@ -6029,6 +6168,17 @@ "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", "devOptional": true }, + "node_modules/cron-parser": { + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/cron-parser/-/cron-parser-4.9.0.tgz", + "integrity": "sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q==", + "dependencies": { + "luxon": "^3.2.1" + }, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -6166,6 +6316,14 @@ "node": ">=0.4.0" } }, + "node_modules/denque": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", + "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", + "engines": { + "node": ">=0.10" + } + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -6183,6 +6341,15 @@ "node": ">=6" } }, + "node_modules/detect-libc": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz", + "integrity": "sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==", + "optional": true, + "engines": { + "node": ">=8" + } + }, "node_modules/detect-newline": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", @@ -7784,6 +7951,29 @@ "kind-of": "^6.0.2" } }, + "node_modules/ioredis": { + "version": "5.6.1", + "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.6.1.tgz", + "integrity": "sha512-UxC0Yv1Y4WRJiGQxQkP0hfdL0/5/6YvdfOOClRgJ0qppSarkhneSa6UvkMkms0AkdGimSH3Ikqm+6mkMmX7vGA==", + "dependencies": { + "@ioredis/commands": "^1.1.1", + "cluster-key-slot": "^1.1.0", + "debug": "^4.3.4", + "denque": "^2.1.0", + "lodash.defaults": "^4.2.0", + "lodash.isarguments": "^3.1.0", + "redis-errors": "^1.2.0", + "redis-parser": "^3.0.0", + "standard-as-callback": "^2.1.0" + }, + "engines": { + "node": ">=12.22.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/ioredis" + } + }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -9064,11 +9254,21 @@ "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" }, + "node_modules/lodash.defaults": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", + "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==" + }, "node_modules/lodash.includes": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==" }, + "node_modules/lodash.isarguments": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz", + "integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==" + }, "node_modules/lodash.isboolean": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", @@ -9148,6 +9348,14 @@ "yallist": "^3.0.2" } }, + "node_modules/luxon": { + "version": "3.7.1", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.1.tgz", + "integrity": "sha512-RkRWjA926cTvz5rAb1BqyWkKbbjzCGchDUIKMCUvNi17j6f6j8uHGDV82Aqcqtzd+icoYpELmG3ksgGiFNNcNg==", + "engines": { + "node": ">=12" + } + }, "node_modules/magic-string": { "version": "0.30.17", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz", @@ -9503,6 +9711,35 @@ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" }, + "node_modules/msgpackr": { + "version": "1.11.5", + "resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-1.11.5.tgz", + "integrity": "sha512-UjkUHN0yqp9RWKy0Lplhh+wlpdt9oQBYgULZOiFhV3VclSF1JnSQWZ5r9gORQlNYaUKQoR8itv7g7z1xDDuACA==", + "optionalDependencies": { + "msgpackr-extract": "^3.0.2" + } + }, + "node_modules/msgpackr-extract": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.3.tgz", + "integrity": "sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA==", + "hasInstallScript": true, + "optional": true, + "dependencies": { + "node-gyp-build-optional-packages": "5.2.2" + }, + "bin": { + "download-msgpackr-prebuilds": "bin/download-prebuilds.js" + }, + "optionalDependencies": { + "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.3", + "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.3", + "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.3", + "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.3", + "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.3", + "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.3" + } + }, "node_modules/multer": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/multer/-/multer-2.0.2.tgz", @@ -9621,8 +9858,7 @@ "node_modules/node-abort-controller": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/node-abort-controller/-/node-abort-controller-3.1.1.tgz", - "integrity": "sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==", - "dev": true + "integrity": "sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==" }, "node_modules/node-emoji": { "version": "1.11.0", @@ -9633,6 +9869,20 @@ "lodash": "^4.17.21" } }, + "node_modules/node-gyp-build-optional-packages": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz", + "integrity": "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==", + "optional": true, + "dependencies": { + "detect-libc": "^2.0.1" + }, + "bin": { + "node-gyp-build-optional-packages": "bin.js", + "node-gyp-build-optional-packages-optional": "optional.js", + "node-gyp-build-optional-packages-test": "build-test.js" + } + }, "node_modules/node-int64": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", @@ -10495,6 +10745,25 @@ "node": ">= 12.13.0" } }, + "node_modules/redis-errors": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz", + "integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==", + "engines": { + "node": ">=4" + } + }, + "node_modules/redis-parser": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz", + "integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==", + "dependencies": { + "redis-errors": "^1.0.0" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/reflect-metadata": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", @@ -11135,6 +11404,11 @@ "node": ">=8" } }, + "node_modules/standard-as-callback": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz", + "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==" + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", diff --git a/package.json b/package.json index 38ada62..647a454 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "test:e2e": "jest --config ./test/jest-e2e.json" }, "dependencies": { + "@nestjs/bullmq": "^11.0.3", "@nestjs/common": "^11.1.3", "@nestjs/config": "^4.0.2", "@nestjs/core": "^11.1.3", @@ -27,6 +28,7 @@ "@nestjs/mongoose": "^11.0.3", "@nestjs/platform-express": "^11.1.3", "@nestjs/typeorm": "^11.0.0", + "bullmq": "^5.56.7", "class-transformer": "^0.5.1", "class-validator": "^0.14.2", "glob": "^11.0.3", diff --git a/src/app.module.ts b/src/app.module.ts index eb69048..5706b2c 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -15,6 +15,7 @@ import { ParticipanGrouptModule } from './participantGroup/participantGroup.modu import { RealmModule } from './realm/realm.module'; import { TypeOrmModule } from '@nestjs/typeorm'; import { typeOrmConfig } from './config/database.config'; +import { FormResultWorkerModule } from './formResult/formResultWorker.module'; @Module({ imports: [ @@ -32,7 +33,8 @@ import { typeOrmConfig } from './config/database.config'; FormSectionModule, OptionModule, ParticipanGrouptModule, - RealmModule + RealmModule, + FormResultWorkerModule, ], controllers: [AppController], providers: [AppService], diff --git a/src/form/dto/create-form.dto.ts b/src/form/dto/create-form.dto.ts index 39d4824..3dabaef 100644 --- a/src/form/dto/create-form.dto.ts +++ b/src/form/dto/create-form.dto.ts @@ -25,8 +25,4 @@ export class CreateFormDto extends BaseDto { @IsArray() @IsUUID('4', { each: true }) attachmentIds?: string[]; - - @IsOptional() - @IsUUID('4') - formResultId?: string; } \ No newline at end of file diff --git a/src/form/entity/form.entity.ts b/src/form/entity/form.entity.ts index a21ef51..8f33b38 100644 --- a/src/form/entity/form.entity.ts +++ b/src/form/entity/form.entity.ts @@ -20,7 +20,4 @@ export class Form extends BaseEntity { @Column('simple-array', { nullable: true }) attachmentIds: string[]; - - @Column({ type: 'uuid', nullable: true }) - formResultId: string; } \ No newline at end of file diff --git a/src/form/form.service.ts b/src/form/form.service.ts index a229700..42fe42d 100644 --- a/src/form/form.service.ts +++ b/src/form/form.service.ts @@ -4,18 +4,27 @@ import { Repository } from 'typeorm'; import { Form } from './entity/form.entity'; import { CreateFormDto } from './dto/create-form.dto'; import { UpdateFormDto } from './dto/update-form.dto'; +import { FormResultService } from '../formResult/formResult.service'; @Injectable() export class FormService { constructor( @InjectRepository(Form) private readonly formRepo: Repository
, + private readonly formResultService: FormResultService ) {} async create(data: CreateFormDto): Promise { const form = this.formRepo.create({ ...data, }); + await this.formResultService.create({ + numParticipants: 0, + numQuestions: 0, + numAnswers: 0, + numComplete: 0, + opinions: [], + }); // Create corresponding FormResult instance and schedule statistics return await this.formRepo.save(form); } diff --git a/src/formResult/dto/create-formResult.dto.ts b/src/formResult/dto/create-formResult.dto.ts index 4026639..41fb22f 100644 --- a/src/formResult/dto/create-formResult.dto.ts +++ b/src/formResult/dto/create-formResult.dto.ts @@ -1,8 +1,8 @@ -import { IsNumber, IsString, IsOptional, IsUUID, ValidateNested, IsArray, IsDateString } from 'class-validator'; +import { IsNumber, IsString, IsUUID, ValidateNested, IsArray } from 'class-validator'; import { Type } from 'class-transformer'; import { BaseDto } from '../../_core/dto/base.dto'; -// DTO for Options (remains the same as it's validation-focused) +// DTO for Options class CreateOptionsDto { @IsString() value: string; @@ -11,17 +11,18 @@ class CreateOptionsDto { count: number; } -// DTO for Opinion (remains the same) +// DTO for Opinion class CreateOpinionDto { - @ValidateNested() + @IsArray() + @ValidateNested({ each: true }) @Type(() => CreateOptionsDto) - options: CreateOptionsDto; + options: CreateOptionsDto[]; @IsUUID('4') questionId: string; } -// Main DTO for creating a FormResult (remains largely the same) +// Main DTO for creating a FormResult export class CreateFormResultDto extends BaseDto { @IsNumber() numParticipants: number; @@ -39,4 +40,4 @@ export class CreateFormResultDto extends BaseDto { @ValidateNested({ each: true }) @Type(() => CreateOpinionDto) opinions: CreateOpinionDto[]; -} +} \ No newline at end of file diff --git a/src/formResult/entity/formResult.entity.ts b/src/formResult/entity/formResult.entity.ts index f8a0e32..33310d8 100644 --- a/src/formResult/entity/formResult.entity.ts +++ b/src/formResult/entity/formResult.entity.ts @@ -1,7 +1,5 @@ -import { Entity, Column, ManyToOne, OneToOne } from 'typeorm'; +import { Entity, Column } from 'typeorm'; import { BaseEntity } from '../../_core/entity/_base.entity'; -import { Realm } from '../../realm/entity/realm.entity'; -import { Form } from '../../form/entity/form.entity'; // Options class (embedded) export class Options { @@ -15,11 +13,10 @@ export class Options { // Opinion class (embedded) export class Opinion { @Column(() => Options) - options: Options; + options: Options[]; @Column({ type: 'uuid', - unique: true, }) questionId: string; } @@ -43,4 +40,4 @@ export class FormResult extends BaseEntity { @Column(() => Opinion) opinions: Opinion[]; -} +} \ No newline at end of file diff --git a/src/formResult/formResult.controller.ts b/src/formResult/formResult.controller.ts index fc093b8..2b83dee 100644 --- a/src/formResult/formResult.controller.ts +++ b/src/formResult/formResult.controller.ts @@ -47,7 +47,7 @@ export class FormResultController { @Get(':id/refresh') async getFormStatistics( // updates data in database and then returns it @Param('formId', new ParseUUIDPipe()) formId: string, - ): Promise { + ): Promise { return this.formResultService.getFormStatistics(formId); } } \ No newline at end of file diff --git a/src/formResult/formResult.module.ts b/src/formResult/formResult.module.ts index 3ec99d5..bef075f 100644 --- a/src/formResult/formResult.module.ts +++ b/src/formResult/formResult.module.ts @@ -13,5 +13,6 @@ import { Answer } from '../answer/entity/answer.entity'; ], controllers: [FormResultController], providers: [FormResultService], + exports: [FormResultService], }) export class FormResultModule {} diff --git a/src/formResult/formResult.service.ts b/src/formResult/formResult.service.ts index 9543b82..c34c499 100644 --- a/src/formResult/formResult.service.ts +++ b/src/formResult/formResult.service.ts @@ -3,40 +3,33 @@ import { InjectRepository } from '@nestjs/typeorm'; import { FormResult } from './entity/formResult.entity'; import { CreateFormResultDto } from './dto/create-formResult.dto'; import { UpdateFormResultDto } from './dto/update-formResult.dto'; -import { Answer } from 'src/answer/entity/answer.entity'; -import { Question } from '../question/entity/question.entity'; -import { Form } from '../form/entity/form.entity'; -import { FormSection } from '../formSection/entity/formSection.entity'; -import { FormPage } from '../formPage/entity/formPage.entity'; -import { Repository, In } from 'typeorm'; +import { Repository } from 'typeorm'; +import { Queue } from 'bullmq'; @Injectable() export class FormResultService { + private readonly statisticsQueue: Queue; + constructor( - @InjectRepository(Form) - private readonly formRepository: Repository, @InjectRepository(FormResult) private readonly formResultRepo: Repository, - @InjectRepository(Question) - private readonly questionRepository: Repository, - @InjectRepository(Answer) - private readonly answerRepository: Repository, - @InjectRepository(Form) - @InjectRepository(FormPage) - private readonly formPageRepository: Repository, - @InjectRepository(FormSection) - private readonly formSectionRepository: Repository, - @InjectRepository(Question) - @InjectRepository(Answer) - @InjectRepository(FormResult) - private readonly formResultRepository: Repository, - ) {} + ) { + this.statisticsQueue = new Queue('form-statistics', { + connection: { + host: 'localhost', + port: 6379, + }, + }); + } async create(data: CreateFormResultDto): Promise { const formResult = this.formResultRepo.create({ ...data, }); - return await this.formResultRepo.save(formResult); + const savedFormResult = await this.formResultRepo.save(formResult); + // Schedule statistics update every 10 minutes + await this.scheduleFormStatistics(savedFormResult.formId); + return savedFormResult; } async findAll( @@ -56,7 +49,6 @@ export class FormResultService { const [docs, totalDocs] = await this.formResultRepo.findAndCount({ skip: (page - 1) * limit, take: limit, - // No specific relations needed here unless FormResult links to other entities }); const totalPages = Math.ceil(totalDocs / limit); @@ -76,7 +68,6 @@ export class FormResultService { async findById(id: string): Promise { const formResult = await this.formResultRepo.findOne({ where: { id }, - // No specific relations needed here unless FormResult links to other entities }); if (!formResult) { throw new NotFoundException(`FormResult with ID "${id}" not found`); @@ -106,72 +97,24 @@ export class FormResultService { } } - async getFormStatistics(formId: string): Promise { - // 1. Find the Form entity - const form = await this.formRepository.findOne({ where: { id: formId } }); - if (!form) { - throw new NotFoundException(`Form with ID "${formId}" not found`); - } - - // 2. Compute numQuestions - const pageIds = form.pageIds || []; - const formPages = await this.formPageRepository.find({ where: { id: In(pageIds) } }); - const formSectionIds = formPages.flatMap(page => page.formSectionIds || []); - const formSections = await this.formSectionRepository.find({ where: { id: In(formSectionIds) } }); - const questionIds = formSections.flatMap(section => section.questionIds || []); - const numQuestions = questionIds.length; - - // 3. Compute numParticipants (distinct participantId from answers) - const numParticipantsResult = await this.answerRepository - .createQueryBuilder('answer') - .select('COUNT(DISTINCT answer.participantId)', 'count') - .where('answer.questionId IN (:...questionIds)', { questionIds: questionIds.length ? questionIds : ['none'] }) // Handle empty questionIds - .getRawOne(); - const numParticipants = numParticipantsResult ? Number(numParticipantsResult.count) : 0; - - // 4. Compute numAnswers - const numAnswers = await this.answerRepository.count({ - where: { questionId: In(questionIds.length ? questionIds : ['none']) }, // Handle empty questionIds - }); - - // 5. Compute numCompleteParticipants (participants who answered all questions) - const subQuery = this.answerRepository - .createQueryBuilder('answer') - .select('answer.participantId') - .where('answer.questionId IN (:...questionIds)', { questionIds: questionIds.length ? questionIds : ['none'] }) // Handle empty questionIds - .groupBy('answer.participantId') - .having('COUNT(DISTINCT answer.questionId) = :numQuestions', { numQuestions: numQuestions || 1 }); // Avoid division by zero - - const numCompleteParticipantsResult = await this.answerRepository.manager - .createQueryBuilder() - .select('COUNT(*)', 'count') - .from(`(${subQuery.getQuery()})`, 'subquery') - .setParameters(subQuery.getParameters()) - .getRawOne(); - const numCompleteParticipants = numCompleteParticipantsResult ? Number(numCompleteParticipantsResult.count) : 0; - - // 6. Find or create FormResult for the Form - let formResult = await this.formResultRepository.findOne({ where: { formId } }); + async getFormStatistics(formId: string): Promise { + const formResult = await this.formResultRepo.findOne({ where: { formId } }); if (!formResult) { - formResult = this.formResultRepository.create({ - formId, // Link to Form via formId - numQuestions, - numParticipants, - numAnswers, - numComplete: numCompleteParticipants, - opinions: [], // Initialize as empty or compute if needed - status: 'active', - }); - } else { - formResult.numQuestions = numQuestions; - formResult.numParticipants = numParticipants; - formResult.numAnswers = numAnswers; - formResult.numComplete = numCompleteParticipants; - // Preserve existing opinions or update if needed + throw new NotFoundException(`FormResult with formId "${formId}" not found`); } - - // 7. Save and return the FormResult - return await this.formResultRepository.save(formResult); + return formResult; } -} + async scheduleFormStatistics(formId: string): Promise { + await this.statisticsQueue.upsertJobScheduler( + `form-statistics-${formId}`, + { + every: 10 * 60 * 1000, // 10 minutes in milliseconds + }, + { + name: 'getFormStatistics', + data: { formId }, + }, + ); + } +} \ No newline at end of file diff --git a/src/formResult/formResult.worker.ts b/src/formResult/formResult.worker.ts new file mode 100644 index 0000000..5039f16 --- /dev/null +++ b/src/formResult/formResult.worker.ts @@ -0,0 +1,152 @@ +import { Worker } from 'bullmq'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository, In } from 'typeorm'; +import { FormResult } from './entity/formResult.entity'; +import { Form } from '../form/entity/form.entity'; +import { FormPage } from '../formPage/entity/formPage.entity'; +import { FormSection } from '../formSection/entity/formSection.entity'; +import { Question } from '../question/entity/question.entity'; +import { Answer } from '../answer/entity/answer.entity'; + +export class FormResultWorker { + constructor( + @InjectRepository(Form) + private readonly formRepository: Repository, + @InjectRepository(FormResult) + private readonly formResultRepo: Repository, + @InjectRepository(Question) + private readonly questionRepository: Repository, + @InjectRepository(Answer) + private readonly answerRepository: Repository, + @InjectRepository(FormPage) + private readonly formPageRepository: Repository, + @InjectRepository(FormSection) + private readonly formSectionRepository: Repository, + ) { + const worker = new Worker( + 'form-statistics', + async (job) => { + const { formId } = job.data; + await this.processFormStatistics(formId); + console.log(`Processed form statistics for formId: ${formId}`); + }, + { + connection: { + host: 'localhost', + port: 6379, + }, + }, + ); + + worker.on('completed', (job) => { + console.log(`Job ${job.id} completed`); + }); + + worker.on('failed', (job, err) => { + if (job) { + console.error(`Job ${job.id} failed: ${err.message}`); + } else { + console.error(`Job failed: ${err.message}`); + } + }); + } + + async processFormStatistics(formId: string): Promise { + // 1. Find the Form entity + const form = await this.formRepository.findOne({ where: { id: formId } }); + if (!form) { + throw new Error(`Form with ID "${formId}" not found`); + } + + // 2. Compute numQuestions and questionIds + const pageIds = form.pageIds || []; + const formPages = await this.formPageRepository.find({ where: { id: In(pageIds) } }); + const formSectionIds = formPages.flatMap(page => page.formSectionIds || []); + const formSections = await this.formSectionRepository.find({ where: { id: In(formSectionIds) } }); + const questionIds = formSections.flatMap(section => section.questionIds || []); + const numQuestions = questionIds.length; + + // 3. Compute numParticipants + const numParticipantsResult = await this.answerRepository + .createQueryBuilder('answer') + .select('COUNT(DISTINCT answer.participantId)', 'count') + .where('answer.questionId IN (:...questionIds)', { questionIds: questionIds.length ? questionIds : ['none'] }) + .getRawOne(); + const numParticipants = numParticipantsResult ? Number(numParticipantsResult.count) : 0; + + // 4. Compute numAnswers + const numAnswers = await this.answerRepository.count({ + where: { questionId: In(questionIds.length ? questionIds : ['none']) }, + }); + + // 5. Compute numComplete + const subQuery = this.answerRepository + .createQueryBuilder('answer') + .select('answer.participantId') + .where('answer.questionId IN (:...questionIds)', { questionIds: questionIds.length ? questionIds : ['none'] }) + .groupBy('answer.participantId') + .having('COUNT(DISTINCT answer.questionId) = :numQuestions', { numQuestions: numQuestions || 1 }); + const numCompleteParticipantsResult = await this.answerRepository.manager + .createQueryBuilder() + .select('COUNT(*)', 'count') + .from(`(${subQuery.getQuery()})`, 'subquery') + .setParameters(subQuery.getParameters()) + .getRawOne(); + const numComplete = numCompleteParticipantsResult ? Number(numCompleteParticipantsResult.count) : 0; + + // 6. Compute opinions with question options and their vote counts + const questions = await this.questionRepository.find({ + where: { id: In(questionIds.length ? questionIds : ['none']) }, + relations: ['options'], + }); + + const opinions = await Promise.all( + questions.map(async (question) => { + const optionIds = question.optionIds || []; + const options = await this.answerRepository + .createQueryBuilder('answer') + .select('answer.value', 'value') + .addSelect('COUNT(*)', 'count') + .where('answer.questionId = :questionId', { questionId: question.id }) + .andWhere('answer.value IN (:...optionIds)', { optionIds: optionIds.length ? optionIds : ['none'] }) + .groupBy('answer.value') + .getRawMany(); + + const questionOptions = optionIds.map((optionId) => { + const optionAnswer = options.find((opt) => opt.value === optionId); + return { + value: optionId, + count: optionAnswer ? Number(optionAnswer.count) : 0, + }; + }); + + return { + questionId: question.id, + options: questionOptions, + }; + }), + ); + + // 7. Find or create FormResult + let formResult = await this.formResultRepo.findOne({ where: { formId } }); + if (!formResult) { + formResult = this.formResultRepo.create({ + formId, + numQuestions, + numParticipants, + numAnswers, + numComplete, + opinions, + }); + } else { + formResult.numQuestions = numQuestions; + formResult.numParticipants = numParticipants; + formResult.numAnswers = numAnswers; + formResult.numComplete = numComplete; + formResult.opinions = opinions; + } + + // 8. Save + await this.formResultRepo.save(formResult); + } +} \ No newline at end of file diff --git a/src/formResult/formResultWorker.module.ts b/src/formResult/formResultWorker.module.ts new file mode 100644 index 0000000..f5d60bd --- /dev/null +++ b/src/formResult/formResultWorker.module.ts @@ -0,0 +1,19 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { FormResultModule } from './formResult.module'; +import { FormResultWorker } from './formResult.worker'; +import { FormResult } from './entity/formResult.entity'; +import { Form } from '../form/entity/form.entity'; +import { Question } from '../question/entity/question.entity'; +import { Answer } from '../answer/entity/answer.entity'; +import { FormPage } from '../formPage/entity/formPage.entity'; +import { FormSection } from '../formSection/entity/formSection.entity'; + +@Module({ + imports: [ + FormResultModule, + TypeOrmModule.forFeature([FormResult, Form, Question, Answer, FormPage, FormSection]), + ], + providers: [FormResultWorker], +}) +export class FormResultWorkerModule {} \ No newline at end of file