used bullmq in formResult

This commit is contained in:
OkaykOrhmn 2025-07-26 11:20:23 +03:30
parent a5f76f01ad
commit 9af4f9dcf8
13 changed files with 507 additions and 114 deletions

278
package-lock.json generated
View File

@ -9,6 +9,7 @@
"version": "0.0.1", "version": "0.0.1",
"license": "UNLICENSED", "license": "UNLICENSED",
"dependencies": { "dependencies": {
"@nestjs/bullmq": "^11.0.3",
"@nestjs/common": "^11.1.3", "@nestjs/common": "^11.1.3",
"@nestjs/config": "^4.0.2", "@nestjs/config": "^4.0.2",
"@nestjs/core": "^11.1.3", "@nestjs/core": "^11.1.3",
@ -16,6 +17,7 @@
"@nestjs/mongoose": "^11.0.3", "@nestjs/mongoose": "^11.0.3",
"@nestjs/platform-express": "^11.1.3", "@nestjs/platform-express": "^11.1.3",
"@nestjs/typeorm": "^11.0.0", "@nestjs/typeorm": "^11.0.0",
"bullmq": "^5.56.7",
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
"class-validator": "^0.14.2", "class-validator": "^0.14.2",
"glob": "^11.0.3", "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": { "node_modules/@isaacs/balanced-match": {
"version": "4.0.1", "version": "4.0.1",
"resolved": "https://registry.npmjs.org/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz", "resolved": "https://registry.npmjs.org/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz",
@ -2043,6 +2050,78 @@
"sparse-bitfield": "^3.0.3" "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": { "node_modules/@napi-rs/nice": {
"version": "1.0.4", "version": "1.0.4",
"resolved": "https://registry.npmjs.org/@napi-rs/nice/-/nice-1.0.4.tgz", "resolved": "https://registry.npmjs.org/@napi-rs/nice/-/nice-1.0.4.tgz",
@ -2343,6 +2422,32 @@
"@tybys/wasm-util": "^0.10.0" "@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": { "node_modules/@nestjs/cli": {
"version": "11.0.7", "version": "11.0.7",
"resolved": "https://registry.npmjs.org/@nestjs/cli/-/cli-11.0.7.tgz", "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", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==" "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": { "node_modules/busboy": {
"version": "1.6.0", "version": "1.6.0",
"resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
@ -5825,6 +5956,14 @@
"node": ">=0.8" "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": { "node_modules/co": {
"version": "4.6.0", "version": "4.6.0",
"resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz",
@ -6029,6 +6168,17 @@
"integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==",
"devOptional": true "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": { "node_modules/cross-spawn": {
"version": "7.0.6", "version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@ -6166,6 +6316,14 @@
"node": ">=0.4.0" "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": { "node_modules/depd": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
@ -6183,6 +6341,15 @@
"node": ">=6" "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": { "node_modules/detect-newline": {
"version": "3.1.0", "version": "3.1.0",
"resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz",
@ -7784,6 +7951,29 @@
"kind-of": "^6.0.2" "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": { "node_modules/ipaddr.js": {
"version": "1.9.1", "version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", "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", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" "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": { "node_modules/lodash.includes": {
"version": "4.3.0", "version": "4.3.0",
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
"integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==" "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": { "node_modules/lodash.isboolean": {
"version": "3.0.3", "version": "3.0.3",
"resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
@ -9148,6 +9348,14 @@
"yallist": "^3.0.2" "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": { "node_modules/magic-string": {
"version": "0.30.17", "version": "0.30.17",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz", "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", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" "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": { "node_modules/multer": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/multer/-/multer-2.0.2.tgz", "resolved": "https://registry.npmjs.org/multer/-/multer-2.0.2.tgz",
@ -9621,8 +9858,7 @@
"node_modules/node-abort-controller": { "node_modules/node-abort-controller": {
"version": "3.1.1", "version": "3.1.1",
"resolved": "https://registry.npmjs.org/node-abort-controller/-/node-abort-controller-3.1.1.tgz", "resolved": "https://registry.npmjs.org/node-abort-controller/-/node-abort-controller-3.1.1.tgz",
"integrity": "sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==", "integrity": "sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ=="
"dev": true
}, },
"node_modules/node-emoji": { "node_modules/node-emoji": {
"version": "1.11.0", "version": "1.11.0",
@ -9633,6 +9869,20 @@
"lodash": "^4.17.21" "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": { "node_modules/node-int64": {
"version": "0.4.0", "version": "0.4.0",
"resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz",
@ -10495,6 +10745,25 @@
"node": ">= 12.13.0" "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": { "node_modules/reflect-metadata": {
"version": "0.2.2", "version": "0.2.2",
"resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz",
@ -11135,6 +11404,11 @@
"node": ">=8" "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": { "node_modules/statuses": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",

View File

@ -20,6 +20,7 @@
"test:e2e": "jest --config ./test/jest-e2e.json" "test:e2e": "jest --config ./test/jest-e2e.json"
}, },
"dependencies": { "dependencies": {
"@nestjs/bullmq": "^11.0.3",
"@nestjs/common": "^11.1.3", "@nestjs/common": "^11.1.3",
"@nestjs/config": "^4.0.2", "@nestjs/config": "^4.0.2",
"@nestjs/core": "^11.1.3", "@nestjs/core": "^11.1.3",
@ -27,6 +28,7 @@
"@nestjs/mongoose": "^11.0.3", "@nestjs/mongoose": "^11.0.3",
"@nestjs/platform-express": "^11.1.3", "@nestjs/platform-express": "^11.1.3",
"@nestjs/typeorm": "^11.0.0", "@nestjs/typeorm": "^11.0.0",
"bullmq": "^5.56.7",
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
"class-validator": "^0.14.2", "class-validator": "^0.14.2",
"glob": "^11.0.3", "glob": "^11.0.3",

View File

@ -15,6 +15,7 @@ import { ParticipanGrouptModule } from './participantGroup/participantGroup.modu
import { RealmModule } from './realm/realm.module'; import { RealmModule } from './realm/realm.module';
import { TypeOrmModule } from '@nestjs/typeorm'; import { TypeOrmModule } from '@nestjs/typeorm';
import { typeOrmConfig } from './config/database.config'; import { typeOrmConfig } from './config/database.config';
import { FormResultWorkerModule } from './formResult/formResultWorker.module';
@Module({ @Module({
imports: [ imports: [
@ -32,7 +33,8 @@ import { typeOrmConfig } from './config/database.config';
FormSectionModule, FormSectionModule,
OptionModule, OptionModule,
ParticipanGrouptModule, ParticipanGrouptModule,
RealmModule RealmModule,
FormResultWorkerModule,
], ],
controllers: [AppController], controllers: [AppController],
providers: [AppService], providers: [AppService],

View File

@ -25,8 +25,4 @@ export class CreateFormDto extends BaseDto {
@IsArray() @IsArray()
@IsUUID('4', { each: true }) @IsUUID('4', { each: true })
attachmentIds?: string[]; attachmentIds?: string[];
@IsOptional()
@IsUUID('4')
formResultId?: string;
} }

View File

@ -20,7 +20,4 @@ export class Form extends BaseEntity {
@Column('simple-array', { nullable: true }) @Column('simple-array', { nullable: true })
attachmentIds: string[]; attachmentIds: string[];
@Column({ type: 'uuid', nullable: true })
formResultId: string;
} }

View File

@ -4,18 +4,27 @@ import { Repository } from 'typeorm';
import { Form } from './entity/form.entity'; import { Form } from './entity/form.entity';
import { CreateFormDto } from './dto/create-form.dto'; import { CreateFormDto } from './dto/create-form.dto';
import { UpdateFormDto } from './dto/update-form.dto'; import { UpdateFormDto } from './dto/update-form.dto';
import { FormResultService } from '../formResult/formResult.service';
@Injectable() @Injectable()
export class FormService { export class FormService {
constructor( constructor(
@InjectRepository(Form) @InjectRepository(Form)
private readonly formRepo: Repository<Form>, private readonly formRepo: Repository<Form>,
private readonly formResultService: FormResultService
) {} ) {}
async create(data: CreateFormDto): Promise<Form> { async create(data: CreateFormDto): Promise<Form> {
const form = this.formRepo.create({ const form = this.formRepo.create({
...data, ...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); return await this.formRepo.save(form);
} }

View File

@ -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 { Type } from 'class-transformer';
import { BaseDto } from '../../_core/dto/base.dto'; import { BaseDto } from '../../_core/dto/base.dto';
// DTO for Options (remains the same as it's validation-focused) // DTO for Options
class CreateOptionsDto { class CreateOptionsDto {
@IsString() @IsString()
value: string; value: string;
@ -11,17 +11,18 @@ class CreateOptionsDto {
count: number; count: number;
} }
// DTO for Opinion (remains the same) // DTO for Opinion
class CreateOpinionDto { class CreateOpinionDto {
@ValidateNested() @IsArray()
@ValidateNested({ each: true })
@Type(() => CreateOptionsDto) @Type(() => CreateOptionsDto)
options: CreateOptionsDto; options: CreateOptionsDto[];
@IsUUID('4') @IsUUID('4')
questionId: string; questionId: string;
} }
// Main DTO for creating a FormResult (remains largely the same) // Main DTO for creating a FormResult
export class CreateFormResultDto extends BaseDto { export class CreateFormResultDto extends BaseDto {
@IsNumber() @IsNumber()
numParticipants: number; numParticipants: number;
@ -39,4 +40,4 @@ export class CreateFormResultDto extends BaseDto {
@ValidateNested({ each: true }) @ValidateNested({ each: true })
@Type(() => CreateOpinionDto) @Type(() => CreateOpinionDto)
opinions: CreateOpinionDto[]; opinions: CreateOpinionDto[];
} }

View File

@ -1,7 +1,5 @@
import { Entity, Column, ManyToOne, OneToOne } from 'typeorm'; import { Entity, Column } from 'typeorm';
import { BaseEntity } from '../../_core/entity/_base.entity'; import { BaseEntity } from '../../_core/entity/_base.entity';
import { Realm } from '../../realm/entity/realm.entity';
import { Form } from '../../form/entity/form.entity';
// Options class (embedded) // Options class (embedded)
export class Options { export class Options {
@ -15,11 +13,10 @@ export class Options {
// Opinion class (embedded) // Opinion class (embedded)
export class Opinion { export class Opinion {
@Column(() => Options) @Column(() => Options)
options: Options; options: Options[];
@Column({ @Column({
type: 'uuid', type: 'uuid',
unique: true,
}) })
questionId: string; questionId: string;
} }
@ -43,4 +40,4 @@ export class FormResult extends BaseEntity {
@Column(() => Opinion) @Column(() => Opinion)
opinions: Opinion[]; opinions: Opinion[];
} }

View File

@ -47,7 +47,7 @@ export class FormResultController {
@Get(':id/refresh') @Get(':id/refresh')
async getFormStatistics( // updates data in database and then returns it async getFormStatistics( // updates data in database and then returns it
@Param('formId', new ParseUUIDPipe()) formId: string, @Param('formId', new ParseUUIDPipe()) formId: string,
): Promise<FormResult> { ): Promise<FormResult | null> {
return this.formResultService.getFormStatistics(formId); return this.formResultService.getFormStatistics(formId);
} }
} }

View File

@ -13,5 +13,6 @@ import { Answer } from '../answer/entity/answer.entity';
], ],
controllers: [FormResultController], controllers: [FormResultController],
providers: [FormResultService], providers: [FormResultService],
exports: [FormResultService],
}) })
export class FormResultModule {} export class FormResultModule {}

View File

@ -3,40 +3,33 @@ import { InjectRepository } from '@nestjs/typeorm';
import { FormResult } from './entity/formResult.entity'; import { FormResult } from './entity/formResult.entity';
import { CreateFormResultDto } from './dto/create-formResult.dto'; import { CreateFormResultDto } from './dto/create-formResult.dto';
import { UpdateFormResultDto } from './dto/update-formResult.dto'; import { UpdateFormResultDto } from './dto/update-formResult.dto';
import { Answer } from 'src/answer/entity/answer.entity'; import { Repository } from 'typeorm';
import { Question } from '../question/entity/question.entity'; import { Queue } from 'bullmq';
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';
@Injectable() @Injectable()
export class FormResultService { export class FormResultService {
private readonly statisticsQueue: Queue;
constructor( constructor(
@InjectRepository(Form)
private readonly formRepository: Repository<Form>,
@InjectRepository(FormResult) @InjectRepository(FormResult)
private readonly formResultRepo: Repository<FormResult>, private readonly formResultRepo: Repository<FormResult>,
@InjectRepository(Question) ) {
private readonly questionRepository: Repository<Question>, this.statisticsQueue = new Queue('form-statistics', {
@InjectRepository(Answer) connection: {
private readonly answerRepository: Repository<Answer>, host: 'localhost',
@InjectRepository(Form) port: 6379,
@InjectRepository(FormPage) },
private readonly formPageRepository: Repository<FormPage>, });
@InjectRepository(FormSection) }
private readonly formSectionRepository: Repository<FormSection>,
@InjectRepository(Question)
@InjectRepository(Answer)
@InjectRepository(FormResult)
private readonly formResultRepository: Repository<FormResult>,
) {}
async create(data: CreateFormResultDto): Promise<FormResult> { async create(data: CreateFormResultDto): Promise<FormResult> {
const formResult = this.formResultRepo.create({ const formResult = this.formResultRepo.create({
...data, ...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( async findAll(
@ -56,7 +49,6 @@ export class FormResultService {
const [docs, totalDocs] = await this.formResultRepo.findAndCount({ const [docs, totalDocs] = await this.formResultRepo.findAndCount({
skip: (page - 1) * limit, skip: (page - 1) * limit,
take: limit, take: limit,
// No specific relations needed here unless FormResult links to other entities
}); });
const totalPages = Math.ceil(totalDocs / limit); const totalPages = Math.ceil(totalDocs / limit);
@ -76,7 +68,6 @@ export class FormResultService {
async findById(id: string): Promise<FormResult | null> { async findById(id: string): Promise<FormResult | null> {
const formResult = await this.formResultRepo.findOne({ const formResult = await this.formResultRepo.findOne({
where: { id }, where: { id },
// No specific relations needed here unless FormResult links to other entities
}); });
if (!formResult) { if (!formResult) {
throw new NotFoundException(`FormResult with ID "${id}" not found`); throw new NotFoundException(`FormResult with ID "${id}" not found`);
@ -106,72 +97,24 @@ export class FormResultService {
} }
} }
async getFormStatistics(formId: string): Promise<FormResult> { async getFormStatistics(formId: string): Promise<FormResult | null> {
// 1. Find the Form entity const formResult = await this.formResultRepo.findOne({ where: { formId } });
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 } });
if (!formResult) { if (!formResult) {
formResult = this.formResultRepository.create({ throw new NotFoundException(`FormResult with formId "${formId}" not found`);
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
} }
return formResult;
// 7. Save and return the FormResult
return await this.formResultRepository.save(formResult);
} }
} async scheduleFormStatistics(formId: string): Promise<void> {
await this.statisticsQueue.upsertJobScheduler(
`form-statistics-${formId}`,
{
every: 10 * 60 * 1000, // 10 minutes in milliseconds
},
{
name: 'getFormStatistics',
data: { formId },
},
);
}
}

View File

@ -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<Form>,
@InjectRepository(FormResult)
private readonly formResultRepo: Repository<FormResult>,
@InjectRepository(Question)
private readonly questionRepository: Repository<Question>,
@InjectRepository(Answer)
private readonly answerRepository: Repository<Answer>,
@InjectRepository(FormPage)
private readonly formPageRepository: Repository<FormPage>,
@InjectRepository(FormSection)
private readonly formSectionRepository: Repository<FormSection>,
) {
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<void> {
// 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);
}
}

View File

@ -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 {}