From 1e4e60b5def2ea7852816e27de6ea512722dd4cf Mon Sep 17 00:00:00 2001 From: billsedison Date: Sat, 31 May 2025 12:14:27 +0800 Subject: [PATCH] Review Opportunities --- .env.sample | 16 +- mock/mock-api.js | 85 +++ package.json | 10 +- pnpm-lock.yaml | 626 +++++++++++++++++- .../migration.sql | 70 ++ prisma/schema.prisma | 80 +++ src/api/api.module.ts | 14 +- .../reviewApplication.controller.ts | 195 ++++++ .../reviewApplication.service.ts | 269 ++++++++ .../reviewHistory.controller.ts | 46 ++ .../reviewOpportunity.controller.ts | 252 +++++++ .../reviewOpportunity.service.ts | 291 ++++++++ src/dto/common.dto.ts | 65 ++ src/dto/reviewApplication.dto.ts | 121 ++++ src/dto/reviewOpportunity.dto.ts | 254 +++++++ src/shared/config/common.config.ts | 64 ++ src/shared/config/m2m.config.ts | 10 + src/shared/enums/userRole.enum.ts | 9 +- .../modules/global/challenge.service.ts | 66 ++ src/shared/modules/global/eventBus.service.ts | 84 +++ .../modules/global/globalProviders.module.ts | 21 +- src/shared/modules/global/jwt.service.ts | 38 +- src/shared/modules/global/m2m.service.ts | 28 + src/shared/modules/global/member.service.ts | 67 ++ 24 files changed, 2760 insertions(+), 21 deletions(-) create mode 100644 mock/mock-api.js create mode 100644 prisma/migrations/20250525042446_add_review_application/migration.sql create mode 100644 src/api/review-application/reviewApplication.controller.ts create mode 100644 src/api/review-application/reviewApplication.service.ts create mode 100644 src/api/review-history/reviewHistory.controller.ts create mode 100644 src/api/review-opportunity/reviewOpportunity.controller.ts create mode 100644 src/api/review-opportunity/reviewOpportunity.service.ts create mode 100644 src/dto/common.dto.ts create mode 100644 src/dto/reviewApplication.dto.ts create mode 100644 src/dto/reviewOpportunity.dto.ts create mode 100644 src/shared/config/common.config.ts create mode 100644 src/shared/config/m2m.config.ts create mode 100644 src/shared/modules/global/challenge.service.ts create mode 100644 src/shared/modules/global/eventBus.service.ts create mode 100644 src/shared/modules/global/m2m.service.ts create mode 100644 src/shared/modules/global/member.service.ts diff --git a/.env.sample b/.env.sample index 2ecfaf3..655c2e3 100644 --- a/.env.sample +++ b/.env.sample @@ -1 +1,15 @@ -DATABASE_URL="postgresql://johndoe:randompassword@localhost:5432/mydb?schema=public" \ No newline at end of file +DATABASE_URL="postgresql://johndoe:randompassword@localhost:5432/mydb?schema=public" +# API configs +BUS_API_URL="https://api.topcoder-dev.com/v5/bus/events" +CHALLENGE_API_URL="https://api.topcoder-dev.com/v5/challenges/" +MEMBER_API_URL="https://api.topcoder-dev.com/v5/members" +# M2m configs +M2M_AUTH_URL="https://auth0.topcoder-dev.com/oauth/token" +M2M_AUTH_CLIENT_ID="jGIf2pd3f44B1jqvOai30BIKTZanYBfU" +M2M_AUTH_CLIENT_SECRET="ldzqVaVEbqhwjM5KtZ79sG8djZpAVK8Z7qieVcC3vRjI4NirgcinKSBpPwk6mYYP" +M2M_AUTH_DOMAIN="topcoder-dev.auth0.com" +M2M_AUTH_AUDIENCE="https://m2m.topcoder-dev.com/" +M2M_AUTH_PROXY_SEREVR_URL= +#Sendgrid email templates +SENDGRID_ACCEPT_REVIEW_APPLICATION="d-2de72880bd69499e9c16369398d34bb9" +SENDGRID_REJECT_REVIEW_APPLICATION="d-82ed74e778e84d8c9bc02eeda0f44b5e" diff --git a/mock/mock-api.js b/mock/mock-api.js new file mode 100644 index 0000000..6750d87 --- /dev/null +++ b/mock/mock-api.js @@ -0,0 +1,85 @@ +const express = require('express') +const winston = require('winston') +const cors = require('cors') + +const app = express() +app.use(cors()) +app.use(express.json()) +app.set('port', 4000) + +const logger = winston.createLogger({ + transports: [ + new winston.transports.Console({ + level: 'debug', + format: winston.format.combine( + winston.format.colorize(), + winston.format.simple() + ), + }), + ] +}); + +// Event bus +app.post('/eventBus', (req, res) => { + logger.info(`Event Bus received message: ${JSON.stringify(req.body)}`); + res.statusCode = 200; + res.json({}) +}) + +const m2mToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodHRwczovL3RvcGNvZGVyLWRldi5hdXRoMC5jb20vIiwic3ViIjoiakdJZjJwZDNmNDRCMWpxdk9haTMwQklLVFphbllCZlVAY2xpZW50cyIsImF1ZCI6Imh0dHBzOi8vbTJtLnRvcGNvZGVyLWRldi5jb20vIiwiaWF0IjoxNzQ4MDk5NDk4LCJleHAiOjE4NDgxODU4OTgsInNjb3BlIjoid3JpdGU6YnVzX2FwaSxhbGw6Y2hhbGxlbmdlcyIsImd0eSI6ImNsaWVudC1jcmVkZW50aWFscyIsImF6cCI6ImpHSWYycGQzZjQ0QjFqcXZPYWkzMEJJS1RaYW5ZQmZVIn0.h3ksdsdJm5USGF1VgROrpkTtStmCzv5ZA6y8bd8AnGY'; + +const m2mScope = 'write:bus_api,all:challenges'; + +// Auth0 +app.post('/oauth/token', (req, res) => { + logger.info('Getting M2M tokens') + res.json({ + access_token: m2mToken, + scope: m2mScope, + expires_in: 94608000, + token_type: 'Bearer' + }) +}) + +// Member API +app.get('/members', (req, res) => { + logger.info(`Member API receives params: ${JSON.stringify(req.query)}`) + let userIdStr = req.query.userIds + userIdStr = userIdStr.replaceAll('[', '').replaceAll(']', '') + const userIds = userIdStr.split(',') + // return result + const ret = userIds.map(id => ({ + userId: parseInt(id), + email: `${id}@topcoder.com` + })) + res.json(ret) +}) + +// Challenge API +app.get('/challenges/:id', (req, res) => { + // directly challenge details + const id = req.params.id + logger.info(`Getting challenge with id ${id}`) + if (id === '11111111-2222-3333-9999-444444444444') { + res.statusCode = 404 + res.json({}) + return + } + res.json({ + id, + name: `Test Challenge ${id}`, + legacy: { + track: 'DEVELOP', + subTrack: 'CODE' + }, + numOfSubmissions: 2, + legacyId: 30376875, + tags: ['Prisma', 'NestJS'] + }) +}) + + +app.listen(app.get('port'), '0.0.0.0', () => { + logger.info(`Express server listening on port ${app.get('port')}`) +}) + diff --git a/package.json b/package.json index dbc945f..dd4a55d 100644 --- a/package.json +++ b/package.json @@ -21,12 +21,15 @@ "postinstall": "npx prisma generate" }, "dependencies": { + "@nestjs/axios": "^4.0.0", "@nestjs/common": "^11.0.1", "@nestjs/core": "^11.0.1", + "@nestjs/mapped-types": "^2.1.0", "@nestjs/platform-express": "^11.0.1", "@nestjs/swagger": "^11.0.3", "@prisma/client": "^6.3.1", "@types/jsonwebtoken": "^9.0.9", + "axios": "^1.9.0", "class-transformer": "^0.5.1", "class-validator": "^0.14.1", "cors": "^2.8.5", @@ -34,7 +37,8 @@ "jwks-rsa": "^3.2.0", "nanoid": "~5.1.2", "reflect-metadata": "^0.2.2", - "rxjs": "^7.8.1" + "rxjs": "^7.8.1", + "tc-core-library-js": "appirio-tech/tc-core-library-js.git#v3.0.1" }, "devDependencies": { "@eslint/eslintrc": "^3.2.0", @@ -51,6 +55,7 @@ "eslint": "^9.18.0", "eslint-config-prettier": "^10.0.1", "eslint-plugin-prettier": "^5.2.2", + "express": "^5.1.0", "globals": "^15.14.0", "jest": "^29.7.0", "prettier": "^3.4.2", @@ -62,7 +67,8 @@ "ts-node": "^10.9.2", "tsconfig-paths": "^4.2.0", "typescript": "^5.7.3", - "typescript-eslint": "^8.20.0" + "typescript-eslint": "^8.20.0", + "winston": "^3.17.0" }, "prisma": { "seed": "ts-node prisma/migrate.ts" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 74f5600..a81e624 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,12 +8,18 @@ importers: .: dependencies: + '@nestjs/axios': + specifier: ^4.0.0 + version: 4.0.0(@nestjs/common@11.0.10(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(axios@1.9.0)(rxjs@7.8.2) '@nestjs/common': specifier: ^11.0.1 version: 11.0.10(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nestjs/core': specifier: ^11.0.1 version: 11.0.10(@nestjs/common@11.0.10(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.0.10)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/mapped-types': + specifier: ^2.1.0 + version: 2.1.0(@nestjs/common@11.0.10(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2) '@nestjs/platform-express': specifier: ^11.0.1 version: 11.0.10(@nestjs/common@11.0.10(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.0.10) @@ -26,6 +32,9 @@ importers: '@types/jsonwebtoken': specifier: ^9.0.9 version: 9.0.9 + axios: + specifier: ^1.9.0 + version: 1.9.0 class-transformer: specifier: ^0.5.1 version: 0.5.1 @@ -50,6 +59,9 @@ importers: rxjs: specifier: ^7.8.1 version: 7.8.2 + tc-core-library-js: + specifier: appirio-tech/tc-core-library-js.git#v3.0.1 + version: https://codeload.github.com/appirio-tech/tc-core-library-js/tar.gz/1d5111da652289b3f03bbb1a30e145ce07bde957 devDependencies: '@eslint/eslintrc': specifier: ^3.2.0 @@ -93,6 +105,9 @@ importers: eslint-plugin-prettier: specifier: ^5.2.2 version: 5.2.3(@types/eslint@9.6.1)(eslint-config-prettier@10.0.1(eslint@9.21.0))(eslint@9.21.0)(prettier@3.5.2) + express: + specifier: ^5.1.0 + version: 5.1.0 globals: specifier: ^15.14.0 version: 15.15.0 @@ -129,6 +144,9 @@ importers: typescript-eslint: specifier: ^8.20.0 version: 8.25.0(eslint@9.21.0)(typescript@5.7.3) + winston: + specifier: ^3.17.0 + version: 3.17.0 packages: @@ -319,10 +337,17 @@ packages: resolution: {integrity: sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==} engines: {node: '>=0.1.90'} + '@colors/colors@1.6.0': + resolution: {integrity: sha512-Ir+AOibqzrIsL6ajt3Rz3LskB7OiMVHqltZmspbW/TJuTVuyOMirVqAkjfY6JISiLHgyNqicAC8AyHHGzNd/dA==} + engines: {node: '>=0.1.90'} + '@cspotcode/source-map-support@0.8.1': resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} engines: {node: '>=12'} + '@dabh/diagnostics@2.0.3': + resolution: {integrity: sha512-hrlQOIi7hAfzsMqlGSFyVucrx38O+j6wiGOf//H2ecvIEqYN4ADBSS2iLMh5UFyDunCNniUIPk/q3riFv45xRA==} + '@esbuild/aix-ppc64@0.25.0': resolution: {integrity: sha512-O7vun9Sf8DFjH2UtqK8Ku3LkquL9SZL8OLY1T5NZkA34+wG3OQF7cl4Ql8vdNzM6fzBbYfLaiRLIOZ+2FOCgBQ==} engines: {node: '>=18'} @@ -863,6 +888,13 @@ packages: resolution: {integrity: sha512-zM0mVWSXE0a0h9aKACLwKmD6nHcRiKrPpCfvaKqG1CqDEyjEawId0ocXxVzPMCAm6kkWr2P025msfxXEnt8UGQ==} engines: {node: '>= 10'} + '@nestjs/axios@4.0.0': + resolution: {integrity: sha512-1cB+Jyltu/uUPNQrpUimRHEQHrnQrpLzVj6dU3dgn6iDDDdahr10TgHFGTmw5VuJ9GzKZsCLDL78VSwJAs/9JQ==} + peerDependencies: + '@nestjs/common': ^10.0.0 || ^11.0.0 + axios: ^1.3.1 + rxjs: ^7.0.0 + '@nestjs/cli@11.0.4': resolution: {integrity: sha512-EITofIvoxqHt/A5M2VcihyDqmZ0b8s8k8xLI/gzSNqmgkZ4caYOq87LKCENG862jGb0aC7ROXpYnDjxMqnFjOQ==} engines: {node: '>= 20.11'} @@ -1121,6 +1153,10 @@ packages: '@tokenizer/token@0.3.0': resolution: {integrity: sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==} + '@tootallnate/once@1.1.2': + resolution: {integrity: sha512-RbzJvlNzmRq5c3O09UipeuXno4tA1FE6ikOjxZK0tuxVv3412l64l5t1W5pj4+rJq9vpkm/kwiR07aZXnsKPxw==} + engines: {node: '>= 6'} + '@tsconfig/node10@1.0.11': resolution: {integrity: sha512-DcRjDCujK/kCk/cUe8Xz8ZSpm8mS3mNNpta+jGCA6USEDfktlNvm1+IuZ9eTcDbNk41BHwpHHeW+N1lKCz4zOw==} @@ -1163,12 +1199,19 @@ packages: '@types/estree@1.0.6': resolution: {integrity: sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==} + '@types/express-jwt@0.0.42': + resolution: {integrity: sha512-WszgUddvM1t5dPpJ3LhWNH8kfNN8GPIBrAGxgIYXVCEGx6Bx4A036aAuf/r5WH9DIEdlmp7gHOYvSM6U87B0ag==} + '@types/express-serve-static-core@4.19.6': resolution: {integrity: sha512-N4LZ2xG7DatVqhCZzOGb1Yi5lMbXSZcmdLDe9EzSndPV2HpWYWzRbaerl2n27irrm94EPpprqa8KpskPT085+A==} '@types/express-serve-static-core@5.0.6': resolution: {integrity: sha512-3xhRnjJPkULekpSzgtoNYYcTWgEZkp4myc+Saevii5JPnHNvHMRlBSHDbs7Bh1iPPoVTERHEZXyhyLbMEsExsA==} + '@types/express-unless@2.0.3': + resolution: {integrity: sha512-iJbM7nsyBgnxCrCe7VjWIi4nyyhlaKUl7jxeHDpK+KXk3sYrUZViMkgFv9qSZmxDleB8dfpQR9gK5MGNyM/M6w==} + deprecated: This is a stub types definition. express-unless provides its own type definitions, so you do not need this installed. + '@types/express@4.17.21': resolution: {integrity: sha512-ejlPM315qwLpaQlQDTjPdsUFSc6ZsP4AN6AlWnogPjQ7CVi7PYF3YVz+CY3jE2pwYf7E/7HlDAN0rV2GxTG0HQ==} @@ -1235,6 +1278,9 @@ packages: '@types/supertest@6.0.2': resolution: {integrity: sha512-137ypx2lk/wTQbW6An6safu9hXmajAifU/s7szAHLN/FeIm5w7yR0Wkl9fdJMRSHwOn4HLAI0DaB2TOORuhPDg==} + '@types/triple-beam@1.3.5': + resolution: {integrity: sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw==} + '@types/validator@13.12.2': resolution: {integrity: sha512-6SlHBzUW8Jhf3liqrGGXyTJSIFe4nqlJ5A5KaMZ2l/vbM3Wh3KSybots/wfWVzNLK4D1NZluDlSQIbIEPx6oyA==} @@ -1400,6 +1446,10 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + agent-base@6.0.2: + resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} + engines: {node: '>= 6.0.0'} + ajv-formats@2.1.1: resolution: {integrity: sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==} peerDependencies: @@ -1495,6 +1545,15 @@ packages: asynckit@0.4.0: resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + axios@0.21.4: + resolution: {integrity: sha512-ut5vewkiu8jjGBdqpM44XxjuCjq9LAKeHVmoVfHVzy8eHgxxq8SbAVQNovDA8mVi05kP0Ea/n/UzcSHcTJQfNg==} + + axios@0.22.0: + resolution: {integrity: sha512-Z0U3uhqQeg1oNcihswf4ZD57O3NrR1+ZXhxaROaWpDmsDTx7T2HNBV2ulBtie2hwJptu8UvgnJoK+BIqdzh/1w==} + + axios@1.9.0: + resolution: {integrity: sha512-re4CqKTJaURpzbLHtIi6XpDv20/CnpXOtjRY5/CU32L8gU8ek9UIivcfvSWvmKEngmVbrUtPpdDwWDWL7DNHvg==} + b4a@1.6.7: resolution: {integrity: sha512-OnAYlL5b7LEkALw87fUVafQw5rVR9RjwGd4KUwNQ6DrrNmaVaUCgLipfVlzrPQ4tWOR9P0IXGNOx50jYCCdSJg==} @@ -1523,6 +1582,10 @@ packages: peerDependencies: '@babel/core': ^7.0.0 + backoff@2.5.0: + resolution: {integrity: sha512-wC5ihrnUXmR2douXmXLCe5O3zg3GKIyvRi/hi58a/XyRxVI+3/yM0PYueQOZXPXQ9pxBislYkw+sF9b7C/RuMA==} + engines: {node: '>= 0.6'} + balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} @@ -1551,6 +1614,10 @@ packages: resolution: {integrity: sha512-/hPxh61E+ll0Ujp24Ilm64cykicul1ypfwjVttduAiEdtnJFvLePSrIPk+HMImtNv5270wOGCb1Tns2rybMkoQ==} engines: {node: '>=18'} + body-parser@2.2.0: + resolution: {integrity: sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg==} + engines: {node: '>=18'} + brace-expansion@1.1.11: resolution: {integrity: sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==} @@ -1585,6 +1652,11 @@ packages: buffer@5.7.1: resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} + bunyan@1.8.15: + resolution: {integrity: sha512-0tECWShh6wUysgucJcBAoYegf3JJoZWibxdqhTm7OHPeT42qdjkZ29QCMcKwbgU1kiH+auSIasNRXMLWXafXig==} + engines: {'0': node >=0.10.0} + hasBin: true + busboy@1.6.0: resolution: {integrity: sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==} engines: {node: '>=10.16.0'} @@ -1688,16 +1760,34 @@ packages: resolution: {integrity: sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==} engines: {iojs: '>= 1.0.0', node: '>= 0.12.0'} + codependency@2.1.0: + resolution: {integrity: sha512-JIdmYkE8Z6jwH1OUf4a5H5jk9YShPQkaYPUAiN+ktyChmPP77LGbeKrxWGPqdCnpTmt0hRIn8TXBVu01U3HDhg==} + collect-v8-coverage@1.0.2: resolution: {integrity: sha512-lHl4d5/ONEbLlJvaJNtsF/Lz+WvB07u2ycqTYbdrq7UypDXailES4valYb2eWiJFxZlVmpGekfqoxQhzyFdT4Q==} + color-convert@1.9.3: + resolution: {integrity: sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==} + color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} engines: {node: '>=7.0.0'} + color-name@1.1.3: + resolution: {integrity: sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==} + color-name@1.1.4: resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + color-string@1.9.1: + resolution: {integrity: sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==} + + color@3.2.1: + resolution: {integrity: sha512-aBl7dZI9ENN6fUGC7mWpMTPNHmWUSNan9tuWN6ahh5ZLNk9baLJOnSMlrQkHcrfFgz2/RigjUVAjdx36VcemKA==} + + colorspace@1.1.4: + resolution: {integrity: sha512-BgvKJiuVu1igBUF2kEjRCZXol6wiiGbY5ipL/oVPwm0BL9sIpMIzM8IK7vwuxIIzOXMV3Ey5w+vxhm0rR/TN8w==} + combined-stream@1.0.8: resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} engines: {node: '>= 0.8'} @@ -1872,6 +1962,10 @@ packages: resolution: {integrity: sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==} engines: {node: '>=0.3.1'} + dtrace-provider@0.8.8: + resolution: {integrity: sha512-b7Z7cNtHPhH9EJhNNbbeqTcXB8LGFFZhq1PGgEvpeHlzd36bhbdTWoE/Ba/YguqpBSlAPKnARWhVlhunCMwfxg==} + engines: {node: '>=0.10'} + dunder-proto@1.0.1: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} @@ -1903,6 +1997,9 @@ packages: emoji-regex@9.2.2: resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + enabled@2.0.0: + resolution: {integrity: sha512-AKrN98kuwOzMIdAizXGI86UFBoo26CL21UM763y1h/GMSJ4/OHU9k2YlsmBpyScFo/wbLzWQJBMCW4+IO3/+OQ==} + encodeurl@1.0.2: resolution: {integrity: sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==} engines: {node: '>= 0.8'} @@ -2057,10 +2154,17 @@ packages: resolution: {integrity: sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + express-unless@2.1.3: + resolution: {integrity: sha512-wj4tLMyCVYuIIKHGt0FhCtIViBcwzWejX0EjNxveAa6dG+0XBCQhMbx+PnkLkFCxLC69qoFrxds4pIyL88inaQ==} + express@5.0.1: resolution: {integrity: sha512-ORF7g6qGnD+YtUG9yx4DFoqCShNMmUKiXuT5oWMHiOvt/4WFbHC6yCwQMTSBMno7AqntNCAzzcnnjowRkTL9eQ==} engines: {node: '>= 18'} + express@5.1.0: + resolution: {integrity: sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==} + engines: {node: '>= 18'} + ext-list@2.2.2: resolution: {integrity: sha512-u+SQgsubraE6zItfVA0tBuCBhfU9ogSRnsvygI7wht9TS510oLkBRXBsqopeUG/GBOIQyKZO9wjTqIu/sf5zFA==} engines: {node: '>=0.10.0'} @@ -2104,6 +2208,9 @@ packages: fb-watchman@2.0.2: resolution: {integrity: sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==} + fecha@4.2.3: + resolution: {integrity: sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw==} + file-entry-cache@8.0.0: resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} engines: {node: '>=16.0.0'} @@ -2131,6 +2238,10 @@ packages: resolution: {integrity: sha512-MX6Zo2adDViYh+GcxxB1dpO43eypOGUOL12rLCOTMQv/DfIbpSJUy4oQIIZhVZkH9e+bZWKMon0XHFEju16tkQ==} engines: {node: '>= 0.8'} + finalhandler@2.1.0: + resolution: {integrity: sha512-/t88Ty3d5JWQbWYgaOGCCYfXRwV1+be02WqYYlL6h0lEiUAMPM8o8qKGO01YIkOHzka2up08wvgYD0mDiI+q3Q==} + engines: {node: '>= 0.8'} + find-up@4.1.0: resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} engines: {node: '>=8'} @@ -2150,6 +2261,18 @@ packages: flatted@3.3.3: resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==} + fn.name@1.1.0: + resolution: {integrity: sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==} + + follow-redirects@1.15.9: + resolution: {integrity: sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==} + engines: {node: '>=4.0'} + peerDependencies: + debug: '*' + peerDependenciesMeta: + debug: + optional: true + foreground-child@3.3.1: resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} engines: {node: '>=14'} @@ -2246,6 +2369,10 @@ packages: engines: {node: 20 || >=22} hasBin: true + glob@6.0.4: + resolution: {integrity: sha512-MKZeRNyYZAVVVG1oZeLaWie1uweH40m9AZwIwxyPbTSX4hHrVYSzLg0Ro5Z5R7XKkIX+Cc6oD1rqeDJnwsB8/A==} + deprecated: Glob versions prior to v9 are no longer supported + glob@7.2.3: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} deprecated: Glob versions prior to v9 are no longer supported @@ -2310,10 +2437,18 @@ packages: resolution: {integrity: sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==} engines: {node: '>= 0.8'} + http-proxy-agent@4.0.1: + resolution: {integrity: sha512-k0zdNgqWTGA6aeIRVpvfVob4fL52dTfaehylg0Y4UvSySvOq/Y+BOyPrgpUrA7HylqvU8vIZGsRuXmspskV0Tg==} + engines: {node: '>= 6'} + http2-wrapper@2.2.1: resolution: {integrity: sha512-V5nVw1PAOgfI3Lmeaj2Exmeg7fenjhRUgz1lPSezy1CuhPYbgQtbQj4jZfEAEMlaL+vupsvhjqCyjzob0yxsmQ==} engines: {node: '>=10.19.0'} + https-proxy-agent@5.0.1: + resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} + engines: {node: '>= 6'} + human-signals@2.1.0: resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==} engines: {node: '>=10.17.0'} @@ -2367,6 +2502,9 @@ packages: is-arrayish@0.2.1: resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==} + is-arrayish@0.3.2: + resolution: {integrity: sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==} + is-binary-path@2.1.0: resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} engines: {node: '>=8'} @@ -2628,6 +2766,9 @@ packages: json-stable-stringify-without-jsonify@1.0.1: resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + json-stringify-safe@5.0.1: + resolution: {integrity: sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==} + json5@2.2.3: resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} engines: {node: '>=6'} @@ -2639,6 +2780,10 @@ packages: jsonfile@6.1.0: resolution: {integrity: sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==} + jsonwebtoken@8.5.1: + resolution: {integrity: sha512-XjwVfRS6jTMsqYs0EsuJ4LGxXV14zQybNd4L2r0UvbVnSF9Af8x7p5MzbJ90Ioz/9TI41/hTCvznF/loiSzn8w==} + engines: {node: '>=4', npm: '>=1.4.28'} + jsonwebtoken@9.0.2: resolution: {integrity: sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==} engines: {node: '>=12', npm: '>=6'} @@ -2646,6 +2791,9 @@ packages: jwa@1.4.1: resolution: {integrity: sha512-qiLX/xhEEFKUAJ6FiBMbes3w9ATzyk5W7Hvzpa/SLYdxNtng+gcurvrI7TbACjIXlsJyr05/S1oUhZrc63evQA==} + jwks-rsa@1.12.3: + resolution: {integrity: sha512-cFipFDeYYaO9FhhYJcZWX/IyZgc0+g316rcHnDpT2dNRNIE/lMOmWKKqp09TkJoYlNFzrEVODsR4GgXJMgWhnA==} + jwks-rsa@3.2.0: resolution: {integrity: sha512-PwchfHcQK/5PSydeKCs1ylNym0w/SSv8a62DgHJ//7x2ZclCoinlsjAfDxAAbpoTPybOum/Jgy+vkvMmKz89Ww==} engines: {node: '>=14'} @@ -2664,6 +2812,9 @@ packages: resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==} engines: {node: '>=6'} + kuler@2.0.0: + resolution: {integrity: sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A==} + leven@3.1.0: resolution: {integrity: sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==} engines: {node: '>=6'} @@ -2730,6 +2881,10 @@ packages: resolution: {integrity: sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==} engines: {node: '>=10'} + logform@2.7.0: + resolution: {integrity: sha512-TFYA4jnP7PVbmlBIfhlSe+WKxs9dklXMTEGcBCIvLhE/Tn3H6Gk1norupVW7m5Cnd4bLcr08AytbyV/xj7f/kQ==} + engines: {node: '>= 12.0.0'} + lowercase-keys@3.0.0: resolution: {integrity: sha512-ozCC6gdQ+glXOQsveKD0YsDy8DSQFjDTz4zyzEHNV5+JP5D62LmfDZ6o1cycFx9ouG940M5dE8C8CTewdj2YWQ==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -2796,6 +2951,9 @@ packages: resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} engines: {node: '>=8.6'} + millisecond@0.1.2: + resolution: {integrity: sha512-BJ8XtxY+woL+5TkP6uS6XvOArm0JVrX2otkgtWZseHpIax0oOOPW3cnwhOjRqbEJg7YRO/BDF7fO/PTWNT3T9Q==} + mime-db@1.52.0: resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} engines: {node: '>= 0.6'} @@ -2804,6 +2962,10 @@ packages: resolution: {integrity: sha512-oHlN/w+3MQ3rba9rqFr6V/ypF10LSkdwUysQL7GkXoTgIWeV+tcXGA852TBxH+gsh8UWoyhR1hKcoMJTuWflpg==} engines: {node: '>= 0.6'} + mime-db@1.54.0: + resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} + engines: {node: '>= 0.6'} + mime-types@2.1.35: resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} engines: {node: '>= 0.6'} @@ -2812,6 +2974,10 @@ packages: resolution: {integrity: sha512-XqoSHeCGjVClAmoGFG3lVFqQFRIrTVw2OH3axRqAcfaw+gHWIfnASS92AV+Rl/mk0MupgZTRHQOjxY6YVnzK5w==} engines: {node: '>= 0.6'} + mime-types@3.0.1: + resolution: {integrity: sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==} + engines: {node: '>= 0.6'} + mime@2.6.0: resolution: {integrity: sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==} engines: {node: '>=4.0.0'} @@ -2855,6 +3021,9 @@ packages: resolution: {integrity: sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==} hasBin: true + moment@2.30.1: + resolution: {integrity: sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==} + ms@2.0.0: resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==} @@ -2872,6 +3041,13 @@ packages: resolution: {integrity: sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==} engines: {node: ^18.17.0 || >=20.5.0} + mv@2.1.1: + resolution: {integrity: sha512-at/ZndSy3xEGJ8i0ygALh8ru9qy7gWW1cmkaqBN29JmMlIvM//MEO9y1sk/avxuwnPcfhkejkLsuPxH81BrkSg==} + engines: {node: '>=0.8.0'} + + nan@2.22.2: + resolution: {integrity: sha512-DANghxFkS1plDdRsX0X9pm0Z6SJNN6gBdtXfanwoZ8hooC5gosGFSBGRYHUVPz1asKA/kMRqDRdHrluZ61SpBQ==} + nanoid@5.1.2: resolution: {integrity: sha512-b+CiXQCNMUGe0Ri64S9SXFcP9hogjAJ2Rd6GdVxhPLRm7mhGaM7VgOvCAJ1ZshfHbqVDI3uqTI5C8/GaKuLI7g==} engines: {node: ^18 || >=20} @@ -2880,6 +3056,10 @@ packages: natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + ncp@2.0.0: + resolution: {integrity: sha512-zIdGUrPRFTUELUvr3Gmc7KZ2Sw/h1PiVM0Af/oHB6zgnV1ikqSfRk+TOufi79aHYCW3NiOXmr1BP5nWbzojLaA==} + hasBin: true + negotiator@1.0.0: resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} engines: {node: '>= 0.6'} @@ -2926,6 +3106,9 @@ packages: once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + one-time@1.0.0: + resolution: {integrity: sha512-5DXOiRKwuSEcQ/l0kGCF6Q3jcADFv5tSmRaJck/OqkVFcOzutB134KRSfF0xDrL39MNnqxbHBbUUcjZIhTgb2g==} + onetime@5.1.2: resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} engines: {node: '>=6'} @@ -3041,6 +3224,10 @@ packages: resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==} engines: {node: '>=4'} + precond@0.2.3: + resolution: {integrity: sha512-QCYG84SgGyGzqJ/vlMsxeXd/pgL/I94ixdNFyh1PusWmTCyVfPJjZ1K1jvHtsbfnXQs2TSkEP2fR7QiMZAnKFQ==} + engines: {node: '>= 0.6'} + prelude-ls@1.2.1: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} @@ -3079,6 +3266,9 @@ packages: resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} engines: {node: '>= 0.10'} + proxy-from-env@1.1.0: + resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} + punycode@2.3.1: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} @@ -3101,6 +3291,10 @@ packages: resolution: {integrity: sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==} engines: {node: '>=10'} + r7insight_node@2.1.1: + resolution: {integrity: sha512-xx0kgFxSHWY9aG1109uv4w2b+JLwHseSowOWo1bzCTDBpUk3er2rZdtQ90mAjUYbkh6Hus9DAwWvmHsX5pHaIQ==} + engines: {node: ^8.10.0 || ^10.13.0 || >=11.10.1} + randombytes@2.1.0: resolution: {integrity: sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==} @@ -3130,6 +3324,9 @@ packages: resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} engines: {node: '>= 14.18.0'} + reconnect-core@1.3.0: + resolution: {integrity: sha512-+gLKwmyRf2tjl6bLR03DoeWELzyN6LW9Xgr3vh7NXHHwPi0JC0N2TwPyf90oUEBkCRcD+bgQ+s3HORoG3nwHDg==} + reflect-metadata@0.2.2: resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==} @@ -3181,10 +3378,19 @@ packages: resolution: {integrity: sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + rimraf@2.4.5: + resolution: {integrity: sha512-J5xnxTyqaiw06JjMftq7L9ouA448dw/E7dKghkP9WpKNuwmARNNg+Gk8/u5ryb9N/Yo2+z3MCwuqFK/+qPOPfQ==} + deprecated: Rimraf versions prior to v4 are no longer supported + hasBin: true + router@2.1.0: resolution: {integrity: sha512-/m/NSLxeYEgWNtyC+WtNHCF7jbGxOibVWKnn+1Psff4dJGOfoXP+MuC/f2CwSmyiHdOIzYnYFp4W6GxWfekaLA==} engines: {node: '>= 18'} + router@2.2.0: + resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} + engines: {node: '>= 18'} + run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} @@ -3200,6 +3406,13 @@ packages: safe-buffer@5.2.1: resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + safe-json-stringify@1.2.0: + resolution: {integrity: sha512-gH8eh2nZudPQO6TytOvbxnuhYBOvDBBLW52tz5q6X58lJcd/tkmqFR+5Z9adS8aJtURSXWThWy/xJtJwixErvg==} + + safe-stable-stringify@2.5.0: + resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==} + engines: {node: '>=10'} + safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} @@ -3223,6 +3436,10 @@ packages: resolution: {integrity: sha512-LJWA9kSvMolR51oDE6PN3kALBNaUdkxzAGcexw8gjMA8xr5zUqK0JiR3CgARSqanYF3Z1YHvsErb1KDgh+v7Rg==} engines: {node: '>=12'} + semver@5.7.2: + resolution: {integrity: sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==} + hasBin: true + semver@6.3.1: resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} hasBin: true @@ -3236,6 +3453,10 @@ packages: resolution: {integrity: sha512-v67WcEouB5GxbTWL/4NeToqcZiAWEq90N888fczVArY8A79J0L4FD7vj5hm3eUMua5EpoQ59wa/oovY6TLvRUA==} engines: {node: '>= 18'} + send@1.2.0: + resolution: {integrity: sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw==} + engines: {node: '>= 18'} + serialize-javascript@6.0.2: resolution: {integrity: sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==} @@ -3243,6 +3464,10 @@ packages: resolution: {integrity: sha512-A3We5UfEjG8Z7VkDv6uItWw6HY2bBSBJT1KtVESn6EOoOr2jAxNhxWCLY3jDE2WcuHXByWju74ck3ZgLwL8xmA==} engines: {node: '>= 18'} + serve-static@2.2.0: + resolution: {integrity: sha512-61g9pCh0Vnh7IutZjtLGGpTA355+OPn2TyDv/6ivP2h/AdAVX9azsoxmg2/M6nZeQZNYBEwIcsne1mJd9oQItQ==} + engines: {node: '>= 18'} + setprototypeof@1.2.0: resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} @@ -3277,6 +3502,9 @@ packages: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} + simple-swizzle@0.2.2: + resolution: {integrity: sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==} + sisteransi@1.0.5: resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} @@ -3309,6 +3537,9 @@ packages: sprintf-js@1.0.3: resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} + stack-trace@0.0.10: + resolution: {integrity: sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg==} + stack-utils@2.0.6: resolution: {integrity: sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==} engines: {node: '>=10'} @@ -3411,6 +3642,11 @@ packages: tar-stream@3.1.7: resolution: {integrity: sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==} + tc-core-library-js@https://codeload.github.com/appirio-tech/tc-core-library-js/tar.gz/1d5111da652289b3f03bbb1a30e145ce07bde957: + resolution: {tarball: https://codeload.github.com/appirio-tech/tc-core-library-js/tar.gz/1d5111da652289b3f03bbb1a30e145ce07bde957} + version: 3.0.1 + engines: {node: '>= 10'} + terser-webpack-plugin@5.3.11: resolution: {integrity: sha512-RVCsMfuD0+cTt3EwX8hSl2Ks56EbFHWmhluwcqoPKtBnfjiT6olaq7PRIRfhyU8nnC2MrnDrBLfrD/RGE+cVXQ==} engines: {node: '>= 10.13.0'} @@ -3439,6 +3675,9 @@ packages: text-decoder@1.2.3: resolution: {integrity: sha512-3/o9z3X0X0fTupwsYvR03pJ/DjWuqqrfwBgTQzdWDiQSm9KitAyz/9WqsT2JQW7KV2m+bC2ol/zqpW37NHxLaA==} + text-hex@1.0.0: + resolution: {integrity: sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg==} + through@2.3.8: resolution: {integrity: sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==} @@ -3465,6 +3704,10 @@ packages: resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} hasBin: true + triple-beam@1.4.1: + resolution: {integrity: sha512-aZbgViZrg1QNcG+LULa7nhZpJTZSLm/mXnHXnbAbjmN5aSa0y7V+wvv6+4WaBtpISJzThKy+PIPxc1Nq1EJ9mg==} + engines: {node: '>= 14.0.0'} + ts-api-utils@2.0.1: resolution: {integrity: sha512-dnlgjFSVetynI8nzgJ+qF62efpglpWRk8isUEWZGWlJYySCTD6aKvbUDu+zbPeDakk3bg5H4XpitHukgfL1m9w==} engines: {node: '>=18.12'} @@ -3547,6 +3790,10 @@ packages: resolution: {integrity: sha512-gd0sGezQYCbWSbkZr75mln4YBidWUN60+devscpLF5mtRDUpiaTvKpBNrdaCvel1NdR2k6vclXybU5fBd2i+nw==} engines: {node: '>= 0.6'} + type-is@2.0.1: + resolution: {integrity: sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==} + engines: {node: '>= 0.6'} + typedarray@0.0.6: resolution: {integrity: sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==} @@ -3648,6 +3895,14 @@ packages: engines: {node: '>= 8'} hasBin: true + winston-transport@4.9.0: + resolution: {integrity: sha512-8drMJ4rkgaPo1Me4zD/3WLfI/zPdA9o2IipKODunnGDcuqbHwjsbB79ylv04LCGGzU0xQ6vTznOMpQGaLhhm6A==} + engines: {node: '>= 12.0.0'} + + winston@3.17.0: + resolution: {integrity: sha512-DLiFIXYC5fMPxaRg832S6F5mJYvePtmO5G9v9IgUFPhXm9/GkXarH/TUrBAVzhTCzAj9anE/+GjrgXp/54nOgw==} + engines: {node: '>= 12.0.0'} + word-wrap@1.2.5: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} @@ -3939,10 +4194,18 @@ snapshots: '@colors/colors@1.5.0': optional: true + '@colors/colors@1.6.0': {} + '@cspotcode/source-map-support@0.8.1': dependencies: '@jridgewell/trace-mapping': 0.3.9 + '@dabh/diagnostics@2.0.3': + dependencies: + colorspace: 1.1.4 + enabled: 2.0.0 + kuler: 2.0.0 + '@esbuild/aix-ppc64@0.25.0': optional: true @@ -4483,6 +4746,12 @@ snapshots: '@napi-rs/nice-win32-x64-msvc': 1.0.1 optional: true + '@nestjs/axios@4.0.0(@nestjs/common@11.0.10(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(axios@1.9.0)(rxjs@7.8.2)': + dependencies: + '@nestjs/common': 11.0.10(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) + axios: 1.9.0 + rxjs: 7.8.2 + '@nestjs/cli@11.0.4(@swc/cli@0.6.0(@swc/core@1.11.1)(chokidar@4.0.3))(@swc/core@1.11.1)(@types/node@22.13.5)(esbuild@0.25.0)': dependencies: '@angular-devkit/core': 19.1.7(chokidar@4.0.3) @@ -4725,6 +4994,8 @@ snapshots: '@tokenizer/token@0.3.0': {} + '@tootallnate/once@1.1.2': {} + '@tsconfig/node10@1.0.11': {} '@tsconfig/node12@1.0.11': {} @@ -4777,6 +5048,11 @@ snapshots: '@types/estree@1.0.6': {} + '@types/express-jwt@0.0.42': + dependencies: + '@types/express': 5.0.0 + '@types/express-unless': 2.0.3 + '@types/express-serve-static-core@4.19.6': dependencies: '@types/node': 22.13.5 @@ -4791,6 +5067,10 @@ snapshots: '@types/range-parser': 1.2.7 '@types/send': 0.17.4 + '@types/express-unless@2.0.3': + dependencies: + express-unless: 2.1.3 + '@types/express@4.17.21': dependencies: '@types/body-parser': 1.19.5 @@ -4874,6 +5154,8 @@ snapshots: '@types/methods': 1.1.4 '@types/superagent': 8.1.9 + '@types/triple-beam@1.3.5': {} + '@types/validator@13.12.2': {} '@types/yargs-parser@21.0.3': {} @@ -5122,6 +5404,12 @@ snapshots: acorn@8.14.0: {} + agent-base@6.0.2: + dependencies: + debug: 4.4.0 + transitivePeerDependencies: + - supports-color + ajv-formats@2.1.1(ajv@8.17.1): optionalDependencies: ajv: 8.17.1 @@ -5198,6 +5486,26 @@ snapshots: asynckit@0.4.0: {} + axios@0.21.4(debug@4.4.0): + dependencies: + follow-redirects: 1.15.9(debug@4.4.0) + transitivePeerDependencies: + - debug + + axios@0.22.0: + dependencies: + follow-redirects: 1.15.9(debug@4.4.0) + transitivePeerDependencies: + - debug + + axios@1.9.0: + dependencies: + follow-redirects: 1.15.9(debug@4.4.0) + form-data: 4.0.2 + proxy-from-env: 1.1.0 + transitivePeerDependencies: + - debug + b4a@1.6.7: {} babel-jest@29.7.0(@babel/core@7.26.9): @@ -5255,6 +5563,10 @@ snapshots: babel-plugin-jest-hoist: 29.6.3 babel-preset-current-node-syntax: 1.1.0(@babel/core@7.26.9) + backoff@2.5.0: + dependencies: + precond: 0.2.3 + balanced-match@1.0.2: {} bare-events@2.5.4: @@ -5295,6 +5607,20 @@ snapshots: transitivePeerDependencies: - supports-color + body-parser@2.2.0: + dependencies: + bytes: 3.1.2 + content-type: 1.0.5 + debug: 4.4.0 + http-errors: 2.0.0 + iconv-lite: 0.6.3 + on-finished: 2.4.1 + qs: 6.14.0 + raw-body: 3.0.0 + type-is: 2.0.1 + transitivePeerDependencies: + - supports-color + brace-expansion@1.1.11: dependencies: balanced-match: 1.0.2 @@ -5334,6 +5660,13 @@ snapshots: base64-js: 1.5.1 ieee754: 1.2.1 + bunyan@1.8.15: + optionalDependencies: + dtrace-provider: 0.8.8 + moment: 2.30.1 + mv: 2.1.1 + safe-json-stringify: 1.2.0 + busboy@1.6.0: dependencies: streamsearch: 1.1.0 @@ -5433,14 +5766,39 @@ snapshots: co@4.6.0: {} + codependency@2.1.0: + dependencies: + semver: 5.7.2 + collect-v8-coverage@1.0.2: {} + color-convert@1.9.3: + dependencies: + color-name: 1.1.3 + color-convert@2.0.1: dependencies: color-name: 1.1.4 + color-name@1.1.3: {} + color-name@1.1.4: {} + color-string@1.9.1: + dependencies: + color-name: 1.1.4 + simple-swizzle: 0.2.2 + + color@3.2.1: + dependencies: + color-convert: 1.9.3 + color-string: 1.9.1 + + colorspace@1.1.4: + dependencies: + color: 3.2.1 + text-hex: 1.0.0 + combined-stream@1.0.8: dependencies: delayed-stream: 1.0.0 @@ -5578,6 +5936,11 @@ snapshots: diff@4.0.2: {} + dtrace-provider@0.8.8: + dependencies: + nan: 2.22.2 + optional: true + dunder-proto@1.0.1: dependencies: call-bind-apply-helpers: 1.0.2 @@ -5604,6 +5967,8 @@ snapshots: emoji-regex@9.2.2: {} + enabled@2.0.0: {} + encodeurl@1.0.2: {} encodeurl@2.0.0: {} @@ -5792,6 +6157,8 @@ snapshots: jest-message-util: 29.7.0 jest-util: 29.7.0 + express-unless@2.1.3: {} + express@5.0.1: dependencies: accepts: 2.0.0 @@ -5829,6 +6196,38 @@ snapshots: transitivePeerDependencies: - supports-color + express@5.1.0: + dependencies: + accepts: 2.0.0 + body-parser: 2.2.0 + content-disposition: 1.0.0 + content-type: 1.0.5 + cookie: 0.7.1 + cookie-signature: 1.2.2 + debug: 4.4.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + finalhandler: 2.1.0 + fresh: 2.0.0 + http-errors: 2.0.0 + merge-descriptors: 2.0.0 + mime-types: 3.0.0 + on-finished: 2.4.1 + once: 1.4.0 + parseurl: 1.3.3 + proxy-addr: 2.0.7 + qs: 6.14.0 + range-parser: 1.2.1 + router: 2.2.0 + send: 1.1.0 + serve-static: 2.2.0 + statuses: 2.0.1 + type-is: 2.0.1 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + ext-list@2.2.2: dependencies: mime-db: 1.53.0 @@ -5874,6 +6273,8 @@ snapshots: dependencies: bser: 2.1.1 + fecha@4.2.3: {} + file-entry-cache@8.0.0: dependencies: flat-cache: 4.0.1 @@ -5911,6 +6312,17 @@ snapshots: transitivePeerDependencies: - supports-color + finalhandler@2.1.0: + dependencies: + debug: 4.4.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + on-finished: 2.4.1 + parseurl: 1.3.3 + statuses: 2.0.1 + transitivePeerDependencies: + - supports-color + find-up@4.1.0: dependencies: locate-path: 5.0.0 @@ -5932,6 +6344,12 @@ snapshots: flatted@3.3.3: {} + fn.name@1.1.0: {} + + follow-redirects@1.15.9(debug@4.4.0): + optionalDependencies: + debug: 4.4.0 + foreground-child@3.3.1: dependencies: cross-spawn: 7.0.6 @@ -6040,6 +6458,15 @@ snapshots: package-json-from-dist: 1.0.1 path-scurry: 2.0.0 + glob@6.0.4: + dependencies: + inflight: 1.0.6 + inherits: 2.0.4 + minimatch: 3.1.2 + once: 1.4.0 + path-is-absolute: 1.0.1 + optional: true + glob@7.2.3: dependencies: fs.realpath: 1.0.0 @@ -6103,11 +6530,26 @@ snapshots: statuses: 2.0.1 toidentifier: 1.0.1 + http-proxy-agent@4.0.1: + dependencies: + '@tootallnate/once': 1.1.2 + agent-base: 6.0.2 + debug: 4.4.0 + transitivePeerDependencies: + - supports-color + http2-wrapper@2.2.1: dependencies: quick-lru: 5.1.1 resolve-alpn: 1.2.1 + https-proxy-agent@5.0.1: + dependencies: + agent-base: 6.0.2 + debug: 4.4.0 + transitivePeerDependencies: + - supports-color + human-signals@2.1.0: {} iconv-lite@0.4.24: @@ -6153,6 +6595,8 @@ snapshots: is-arrayish@0.2.1: {} + is-arrayish@0.3.2: {} + is-binary-path@2.1.0: dependencies: binary-extensions: 2.3.0 @@ -6583,6 +7027,8 @@ snapshots: json-stable-stringify-without-jsonify@1.0.1: {} + json-stringify-safe@5.0.1: {} + json5@2.2.3: {} jsonc-parser@3.3.1: {} @@ -6593,6 +7039,19 @@ snapshots: optionalDependencies: graceful-fs: 4.2.11 + jsonwebtoken@8.5.1: + dependencies: + jws: 3.2.2 + lodash.includes: 4.3.0 + lodash.isboolean: 3.0.3 + lodash.isinteger: 4.0.4 + lodash.isnumber: 3.0.3 + lodash.isplainobject: 4.0.6 + lodash.isstring: 4.0.1 + lodash.once: 4.1.1 + ms: 2.1.3 + semver: 5.7.2 + jsonwebtoken@9.0.2: dependencies: jws: 3.2.2 @@ -6612,6 +7071,21 @@ snapshots: ecdsa-sig-formatter: 1.0.11 safe-buffer: 5.2.1 + jwks-rsa@1.12.3: + dependencies: + '@types/express-jwt': 0.0.42 + axios: 0.21.4(debug@4.4.0) + debug: 4.4.0 + http-proxy-agent: 4.0.1 + https-proxy-agent: 5.0.1 + jsonwebtoken: 8.5.1 + limiter: 1.1.5 + lru-memoizer: 2.3.0 + ms: 2.1.3 + proxy-from-env: 1.1.0 + transitivePeerDependencies: + - supports-color + jwks-rsa@3.2.0: dependencies: '@types/express': 4.17.21 @@ -6636,6 +7110,8 @@ snapshots: kleur@3.0.3: {} + kuler@2.0.0: {} + leven@3.1.0: {} levn@0.4.1: @@ -6686,6 +7162,15 @@ snapshots: chalk: 4.1.2 is-unicode-supported: 0.1.0 + logform@2.7.0: + dependencies: + '@colors/colors': 1.6.0 + '@types/triple-beam': 1.3.5 + fecha: 4.2.3 + ms: 2.1.3 + safe-stable-stringify: 2.5.0 + triple-beam: 1.4.1 + lowercase-keys@3.0.0: {} lru-cache@11.0.2: {} @@ -6740,10 +7225,14 @@ snapshots: braces: 3.0.3 picomatch: 2.3.1 + millisecond@0.1.2: {} + mime-db@1.52.0: {} mime-db@1.53.0: {} + mime-db@1.54.0: {} + mime-types@2.1.35: dependencies: mime-db: 1.52.0 @@ -6752,6 +7241,10 @@ snapshots: dependencies: mime-db: 1.53.0 + mime-types@3.0.1: + dependencies: + mime-db: 1.54.0 + mime@2.6.0: {} mimic-fn@2.1.0: {} @@ -6784,6 +7277,9 @@ snapshots: dependencies: minimist: 1.2.8 + moment@2.30.1: + optional: true + ms@2.0.0: {} ms@2.1.2: {} @@ -6802,10 +7298,23 @@ snapshots: mute-stream@2.0.0: {} + mv@2.1.1: + dependencies: + mkdirp: 0.5.6 + ncp: 2.0.0 + rimraf: 2.4.5 + optional: true + + nan@2.22.2: + optional: true + nanoid@5.1.2: {} natural-compare@1.4.0: {} + ncp@2.0.0: + optional: true + negotiator@1.0.0: {} neo-async@2.6.2: {} @@ -6840,6 +7349,10 @@ snapshots: dependencies: wrappy: 1.0.2 + one-time@1.0.0: + dependencies: + fn.name: 1.1.0 + onetime@5.1.2: dependencies: mimic-fn: 2.1.0 @@ -6941,6 +7454,8 @@ snapshots: pluralize@8.0.0: {} + precond@0.2.3: {} + prelude-ls@1.2.1: {} prettier-linter-helpers@1.0.0: @@ -6978,6 +7493,8 @@ snapshots: forwarded: 0.2.0 ipaddr.js: 1.9.1 + proxy-from-env@1.1.0: {} + punycode@2.3.1: {} pure-rand@6.1.0: {} @@ -6994,6 +7511,13 @@ snapshots: quick-lru@5.1.1: {} + r7insight_node@2.1.1: + dependencies: + codependency: 2.1.0 + json-stringify-safe: 5.0.1 + lodash: 4.17.21 + reconnect-core: 1.3.0 + randombytes@2.1.0: dependencies: safe-buffer: 5.2.1 @@ -7031,6 +7555,10 @@ snapshots: readdirp@4.1.2: {} + reconnect-core@1.3.0: + dependencies: + backoff: 2.5.0 + reflect-metadata@0.2.2: {} repeat-string@1.6.1: {} @@ -7068,12 +7596,27 @@ snapshots: reusify@1.0.4: {} + rimraf@2.4.5: + dependencies: + glob: 6.0.4 + optional: true + router@2.1.0: dependencies: is-promise: 4.0.0 parseurl: 1.3.3 path-to-regexp: 8.2.0 + router@2.2.0: + dependencies: + debug: 4.4.0 + depd: 2.0.0 + is-promise: 4.0.0 + parseurl: 1.3.3 + path-to-regexp: 8.2.0 + transitivePeerDependencies: + - supports-color + run-parallel@1.2.0: dependencies: queue-microtask: 1.2.3 @@ -7090,6 +7633,11 @@ snapshots: safe-buffer@5.2.1: {} + safe-json-stringify@1.2.0: + optional: true + + safe-stable-stringify@2.5.0: {} + safer-buffer@2.1.2: {} schema-utils@3.3.0: @@ -7115,13 +7663,15 @@ snapshots: dependencies: semver: 7.7.1 + semver@5.7.2: {} + semver@6.3.1: {} semver@7.7.1: {} send@1.1.0: dependencies: - debug: 4.3.6 + debug: 4.4.0 destroy: 1.2.0 encodeurl: 2.0.0 escape-html: 1.0.3 @@ -7136,6 +7686,22 @@ snapshots: transitivePeerDependencies: - supports-color + send@1.2.0: + dependencies: + debug: 4.4.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + fresh: 2.0.0 + http-errors: 2.0.0 + mime-types: 3.0.1 + ms: 2.1.3 + on-finished: 2.4.1 + range-parser: 1.2.1 + statuses: 2.0.1 + transitivePeerDependencies: + - supports-color + serialize-javascript@6.0.2: dependencies: randombytes: 2.1.0 @@ -7149,6 +7715,15 @@ snapshots: transitivePeerDependencies: - supports-color + serve-static@2.2.0: + dependencies: + encodeurl: 2.0.0 + escape-html: 1.0.3 + parseurl: 1.3.3 + send: 1.2.0 + transitivePeerDependencies: + - supports-color + setprototypeof@1.2.0: {} shebang-command@2.0.0: @@ -7189,6 +7764,10 @@ snapshots: signal-exit@4.1.0: {} + simple-swizzle@0.2.2: + dependencies: + is-arrayish: 0.3.2 + sisteransi@1.0.5: {} slash@3.0.0: {} @@ -7217,6 +7796,8 @@ snapshots: sprintf-js@1.0.3: {} + stack-trace@0.0.10: {} + stack-utils@2.0.6: dependencies: escape-string-regexp: 2.0.0 @@ -7333,6 +7914,19 @@ snapshots: fast-fifo: 1.3.2 streamx: 2.22.0 + tc-core-library-js@https://codeload.github.com/appirio-tech/tc-core-library-js/tar.gz/1d5111da652289b3f03bbb1a30e145ce07bde957: + dependencies: + axios: 0.22.0 + bunyan: 1.8.15 + jsonwebtoken: 8.5.1 + jwks-rsa: 1.12.3 + lodash: 4.17.21 + millisecond: 0.1.2 + r7insight_node: 2.1.1 + transitivePeerDependencies: + - debug + - supports-color + terser-webpack-plugin@5.3.11(@swc/core@1.11.1)(esbuild@0.25.0)(webpack@5.98.0(@swc/core@1.11.1)(esbuild@0.25.0)): dependencies: '@jridgewell/trace-mapping': 0.3.25 @@ -7362,6 +7956,8 @@ snapshots: dependencies: b4a: 1.6.7 + text-hex@1.0.0: {} + through@2.3.8: {} tmp@0.0.33: @@ -7383,6 +7979,8 @@ snapshots: tree-kill@1.2.2: {} + triple-beam@1.4.1: {} + ts-api-utils@2.0.1(typescript@5.7.3): dependencies: typescript: 5.7.3 @@ -7471,6 +8069,12 @@ snapshots: media-typer: 1.1.0 mime-types: 3.0.0 + type-is@2.0.1: + dependencies: + content-type: 1.0.5 + media-typer: 1.1.0 + mime-types: 3.0.0 + typedarray@0.0.6: {} typescript-eslint@8.25.0(eslint@9.21.0)(typescript@5.7.3): @@ -7579,6 +8183,26 @@ snapshots: dependencies: isexe: 2.0.0 + winston-transport@4.9.0: + dependencies: + logform: 2.7.0 + readable-stream: 3.6.2 + triple-beam: 1.4.1 + + winston@3.17.0: + dependencies: + '@colors/colors': 1.6.0 + '@dabh/diagnostics': 2.0.3 + async: 3.2.6 + is-stream: 2.0.1 + logform: 2.7.0 + one-time: 1.0.0 + readable-stream: 3.6.2 + safe-stable-stringify: 2.5.0 + stack-trace: 0.0.10 + triple-beam: 1.4.1 + winston-transport: 4.9.0 + word-wrap@1.2.5: {} wrap-ansi@6.2.0: diff --git a/prisma/migrations/20250525042446_add_review_application/migration.sql b/prisma/migrations/20250525042446_add_review_application/migration.sql new file mode 100644 index 0000000..45deb09 --- /dev/null +++ b/prisma/migrations/20250525042446_add_review_application/migration.sql @@ -0,0 +1,70 @@ +-- CreateEnum +CREATE TYPE "ReviewOpportunityStatus" AS ENUM ('OPEN', 'CLOSED', 'CANCELLED'); + +-- CreateEnum +CREATE TYPE "ReviewApplicationStatus" AS ENUM ('PENDING', 'APPROVED', 'REJECTED', 'CANCELLED'); + +-- CreateEnum +CREATE TYPE "ReviewOpportunityType" AS ENUM ('REGULAR_REVIEW', 'COMPONENT_DEV_REVIEW', 'SPEC_REVIEW', 'ITERATIVE_REVIEW', 'SCENARIOS_REVIEW'); + +-- CreateEnum +CREATE TYPE "ReviewApplicationRole" AS ENUM ('PRIMARY_REVIEWER', 'SECONDARY_REVIEWER', 'PRIMARY_FAILURE_REVIEWER', 'ACCURACY_REVIEWER', 'STRESS_REVIEWER', 'FAILURE_REVIEWER', 'SPECIFICATION_REVIEWER', 'ITERATIVE_REVIEWER', 'REVIEWER'); + +-- CreateTable +CREATE TABLE "reviewOpportunity" ( + "id" VARCHAR(14) NOT NULL DEFAULT nanoid(), + "challengeId" TEXT NOT NULL, + "status" "ReviewOpportunityStatus" NOT NULL, + "type" "ReviewOpportunityType" NOT NULL, + "openPositions" INTEGER NOT NULL, + "startDate" TIMESTAMP(3) NOT NULL, + "duration" INTEGER NOT NULL, + "basePayment" DOUBLE PRECISION NOT NULL, + "incrementalPayment" DOUBLE PRECISION NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "createdBy" TEXT NOT NULL, + "updatedAt" TIMESTAMP(3) NOT NULL, + "updatedBy" TEXT NOT NULL, + + CONSTRAINT "reviewOpportunity_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "reviewApplication" ( + "id" VARCHAR(14) NOT NULL DEFAULT nanoid(), + "userId" TEXT NOT NULL, + "handle" TEXT NOT NULL, + "opportunityId" TEXT NOT NULL, + "role" "ReviewApplicationRole" NOT NULL, + "status" "ReviewApplicationStatus" NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "createdBy" TEXT NOT NULL, + "updatedAt" TIMESTAMP(3) NOT NULL, + "updatedBy" TEXT NOT NULL, + + CONSTRAINT "reviewApplication_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "reviewOpportunity_id_idx" ON "reviewOpportunity"("id"); + +-- CreateIndex +CREATE INDEX "reviewOpportunity_challengeId_idx" ON "reviewOpportunity"("challengeId"); + +-- CreateIndex +CREATE UNIQUE INDEX "reviewOpportunity_challengeId_type_key" ON "reviewOpportunity"("challengeId", "type"); + +-- CreateIndex +CREATE INDEX "reviewApplication_id_idx" ON "reviewApplication"("id"); + +-- CreateIndex +CREATE INDEX "reviewApplication_userId_idx" ON "reviewApplication"("userId"); + +-- CreateIndex +CREATE INDEX "reviewApplication_opportunityId_idx" ON "reviewApplication"("opportunityId"); + +-- CreateIndex +CREATE UNIQUE INDEX "reviewApplication_opportunityId_userId_role_key" ON "reviewApplication"("opportunityId", "userId", "role"); + +-- AddForeignKey +ALTER TABLE "reviewApplication" ADD CONSTRAINT "reviewApplication_opportunityId_fkey" FOREIGN KEY ("opportunityId") REFERENCES "reviewOpportunity"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 50bb426..2110987 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -301,3 +301,83 @@ model contactRequest { @@index([resourceId]) // Index for filtering by resource (requester) @@index([challengeId]) // Index for filtering by challenge } + +enum ReviewOpportunityStatus { + OPEN + CLOSED + CANCELLED +} + +enum ReviewApplicationStatus { + PENDING + APPROVED + REJECTED + CANCELLED +} + +enum ReviewOpportunityType { + REGULAR_REVIEW + COMPONENT_DEV_REVIEW + SPEC_REVIEW + ITERATIVE_REVIEW + SCENARIOS_REVIEW +} + +enum ReviewApplicationRole { + PRIMARY_REVIEWER + SECONDARY_REVIEWER + PRIMARY_FAILURE_REVIEWER + ACCURACY_REVIEWER + STRESS_REVIEWER + FAILURE_REVIEWER + SPECIFICATION_REVIEWER + ITERATIVE_REVIEWER + REVIEWER +} + +model reviewOpportunity { + id String @id @default(dbgenerated("nanoid()")) @db.VarChar(14) + challengeId String + status ReviewOpportunityStatus + type ReviewOpportunityType + openPositions Int + startDate DateTime + duration Int + basePayment Float + incrementalPayment Float + + applications reviewApplication[] + + createdAt DateTime @default(now()) + createdBy String + updatedAt DateTime @updatedAt + updatedBy String + + @@unique([challengeId, type]) + + @@index([id]) // Index for direct ID lookups + @@index([challengeId]) // Index for filtering by challenge +} + + +model reviewApplication { + id String @id @default(dbgenerated("nanoid()")) @db.VarChar(14) + userId String + handle String + opportunityId String + role ReviewApplicationRole + status ReviewApplicationStatus + + opportunity reviewOpportunity @relation(fields: [opportunityId], references: [id], onDelete: Cascade) + + createdAt DateTime @default(now()) + createdBy String + updatedAt DateTime @updatedAt + updatedBy String + + @@unique([opportunityId, userId, role]) + + @@index([id]) // Index for direct ID lookups + @@index([userId]) + @@index([opportunityId]) +} diff --git a/src/api/api.module.ts b/src/api/api.module.ts index 7d22f8b..3e48276 100644 --- a/src/api/api.module.ts +++ b/src/api/api.module.ts @@ -1,4 +1,5 @@ import { Module } from '@nestjs/common'; +import { HttpModule } from '@nestjs/axios'; import { GlobalProvidersModule } from 'src/shared/modules/global/globalProviders.module'; import { HealthCheckController } from './health-check/healthCheck.controller'; import { ScorecardController } from './scorecard/scorecard.controller'; @@ -6,9 +7,15 @@ import { AppealController } from './appeal/appeal.controller'; import { ContactRequestsController } from './contact/contactRequests.controller'; import { ReviewController } from './review/review.controller'; import { ProjectResultController } from './project-result/projectResult.controller'; +import { ReviewOpportunityController } from './review-opportunity/reviewOpportunity.controller'; +import { ReviewApplicationController } from './review-application/reviewApplication.controller'; +import { ReviewOpportunityService } from './review-opportunity/reviewOpportunity.service'; +import { ReviewApplicationService } from './review-application/reviewApplication.service'; +import { ReviewHistoryController } from './review-history/reviewHistory.controller'; +import { ChallengeApiService } from 'src/shared/modules/global/challenge.service'; @Module({ - imports: [GlobalProvidersModule], + imports: [HttpModule, GlobalProvidersModule], controllers: [ HealthCheckController, ScorecardController, @@ -16,7 +23,10 @@ import { ProjectResultController } from './project-result/projectResult.controll ContactRequestsController, ReviewController, ProjectResultController, + ReviewOpportunityController, + ReviewApplicationController, + ReviewHistoryController ], - providers: [], + providers: [ReviewOpportunityService, ReviewApplicationService, ChallengeApiService], }) export class ApiModule {} diff --git a/src/api/review-application/reviewApplication.controller.ts b/src/api/review-application/reviewApplication.controller.ts new file mode 100644 index 0000000..ffefb41 --- /dev/null +++ b/src/api/review-application/reviewApplication.controller.ts @@ -0,0 +1,195 @@ +import { Body, Controller, ForbiddenException, Get, Param, Patch, Post, Req } from '@nestjs/common'; +import { + ApiBearerAuth, + ApiBody, + ApiOperation, + ApiParam, + ApiResponse, + ApiTags, +} from '@nestjs/swagger'; +import { OkResponse, ResponseDto } from 'src/dto/common.dto'; +import { + CreateReviewApplicationDto, + ReviewApplicationResponseDto, +} from 'src/dto/reviewApplication.dto'; +import { UserRole } from 'src/shared/enums/userRole.enum'; +import { Roles } from 'src/shared/guards/tokenRoles.guard'; +import { isAdmin, JwtUser } from 'src/shared/modules/global/jwt.service'; +import { ReviewApplicationService } from './reviewApplication.service'; + +@ApiTags('Review Application') +@Controller('/api/review-applications') +export class ReviewApplicationController { + constructor(private readonly service: ReviewApplicationService) {} + + @ApiOperation({ + summary: 'Create review application', + description: 'Roles: Reviewer', + }) + @ApiBody({ + description: 'Review application data', + type: CreateReviewApplicationDto, + }) + @ApiResponse({ + status: 201, + description: 'Review application details', + type: ResponseDto, + }) + @ApiResponse({ status: 400, description: 'Bad Request' }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) + @ApiResponse({ status: 500, description: 'Internal Error' }) + @Post() + @ApiBearerAuth() + @Roles(UserRole.Reviewer) + async create(@Req() req: Request, @Body() dto: CreateReviewApplicationDto) { + const authUser: JwtUser = req['user'] as JwtUser; + return OkResponse(await this.service.create(authUser, dto)); + } + + @ApiOperation({ + summary: 'List pending review application', + description: 'Roles: Admin', + }) + @ApiResponse({ + status: 200, + description: 'Review application details', + type: ResponseDto, + }) + @ApiResponse({ status: 400, description: 'Bad Request' }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) + @ApiResponse({ status: 403, description: 'Forbidden' }) + @ApiResponse({ status: 500, description: 'Internal Error' }) + @Get() + @ApiBearerAuth() + @Roles(UserRole.Admin) + async searchPending() { + return OkResponse(await this.service.listPending()); + } + + @ApiOperation({ + summary: 'Get applications by user ID', + description: 'Roles: Admin | Reviewer', + }) + @ApiParam({ + name: 'userId', + description: 'user id', + }) + @ApiResponse({ + status: 200, + description: 'Review application details', + type: ResponseDto, + }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) + @ApiResponse({ status: 403, description: 'Forbidden' }) + @ApiResponse({ status: 500, description: 'Internal Error' }) + @Get('/user/:userId') + @ApiBearerAuth() + @Roles(UserRole.Admin, UserRole.Reviewer) + async getByUserId(@Req() req: Request, @Param('userId') userId: string) { + // Check user permission. Only admin and user himself can access + const authUser: JwtUser = req['user'] as JwtUser; + if (authUser.userId !== userId && !isAdmin(authUser)) { + throw new ForbiddenException('You cannot check this user\'s review applications') + } + return OkResponse(await this.service.listByUser(userId)); + } + + @ApiOperation({ + summary: 'Get applications by opportunity ID', + description: + 'All users should be able to see full list. Including anonymous.', + }) + @ApiParam({ + name: 'opportunityId', + description: 'review opportunity id', + }) + @ApiResponse({ + status: 200, + description: 'Review application details', + type: ResponseDto, + }) + @ApiResponse({ status: 404, description: 'Not Found' }) + @ApiResponse({ status: 500, description: 'Internal Error' }) + @Get('/opportunity/:opportunityId') + async getByOpportunityId(@Param('opportunityId') opportunityId: string) { + return OkResponse(await this.service.listByOpportunity(opportunityId)); + } + + @ApiOperation({ + summary: 'Approve review application by id', + description: 'Only admin can access.', + }) + @ApiParam({ + name: 'id', + description: 'review application id', + }) + @ApiResponse({ + status: 200, + description: 'Review application details', + type: ResponseDto, + }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) + @ApiResponse({ status: 403, description: 'Forbidden' }) + @ApiResponse({ status: 404, description: 'Not Found' }) + @ApiResponse({ status: 500, description: 'Internal Error' }) + @Roles(UserRole.Admin) + @Patch('/:id/accept') + async approveApplication(@Req() req: Request, @Param('id') id: string) { + const authUser: JwtUser = req['user'] as JwtUser; + await this.service.approve(authUser, id); + return OkResponse({}); + } + + @ApiOperation({ + summary: 'Reject review application by id', + description: 'Only admin can access.', + }) + @ApiParam({ + name: 'id', + description: 'review application id', + }) + @ApiResponse({ + status: 200, + description: 'Review application details', + type: ResponseDto, + }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) + @ApiResponse({ status: 403, description: 'Forbidden' }) + @ApiResponse({ status: 404, description: 'Not Found' }) + @ApiResponse({ status: 500, description: 'Internal Error' }) + @Roles(UserRole.Admin) + @Patch('/:id/reject') + async rejectApplication(@Req() req: Request, @Param('id') id: string) { + const authUser: JwtUser = req['user'] as JwtUser; + await this.service.reject(authUser, id); + return OkResponse({}); + } + + @ApiOperation({ + summary: 'Reject all pending applications for an opportunity', + description: 'Only admin can access.', + }) + @ApiParam({ + name: 'opportunityId', + description: 'review opportunity id', + }) + @ApiResponse({ + status: 200, + description: 'Review application details', + type: ResponseDto, + }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) + @ApiResponse({ status: 403, description: 'Forbidden' }) + @ApiResponse({ status: 404, description: 'Not Found' }) + @ApiResponse({ status: 500, description: 'Internal Error' }) + @Roles(UserRole.Admin) + @Patch('/opportunity/:opportunityId/reject-all') + async rejectAllPending( + @Req() req: Request, + @Param('opportunityId') opportunityId: string, + ) { + const authUser: JwtUser = req['user'] as JwtUser; + await this.service.rejectAllPending(authUser, opportunityId); + return OkResponse({}); + } +} diff --git a/src/api/review-application/reviewApplication.service.ts b/src/api/review-application/reviewApplication.service.ts new file mode 100644 index 0000000..6488552 --- /dev/null +++ b/src/api/review-application/reviewApplication.service.ts @@ -0,0 +1,269 @@ +import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common'; +import { + convertRoleName, + CreateReviewApplicationDto, + ReviewApplicationResponseDto, + ReviewApplicationRoleOpportunityTypeMap, + ReviewApplicationStatus, +} from 'src/dto/reviewApplication.dto'; +import { CommonConfig } from 'src/shared/config/common.config'; +import { ChallengeApiService } from 'src/shared/modules/global/challenge.service'; +import { EventBusSendEmailPayload, EventBusService } from 'src/shared/modules/global/eventBus.service'; +import { JwtUser } from 'src/shared/modules/global/jwt.service'; +import { MemberService } from 'src/shared/modules/global/member.service'; +import { PrismaService } from 'src/shared/modules/global/prisma.service'; + +@Injectable() +export class ReviewApplicationService { + constructor( + private readonly prisma: PrismaService, + private readonly challengeService: ChallengeApiService, + private readonly memberService: MemberService, + private readonly eventBusService: EventBusService + ) {} + + /** + * Create review application + * @param authUser auth user + * @param dto create data + * @returns response dto + */ + async create( + authUser: JwtUser, + dto: CreateReviewApplicationDto, + ): Promise { + const userId = authUser.userId as string; + const handle = authUser.handle as string; + // make sure review opportunity exists + const opportunity = await this.prisma.reviewOpportunity.findUnique({ + where: { id: dto.opportunityId} + }) + if (!opportunity || !opportunity.id) { + throw new BadRequestException('Opportunity doesn\'t exist'); + } + // make sure application role matches + if (ReviewApplicationRoleOpportunityTypeMap[dto.role] !== opportunity.type) { + throw new BadRequestException('Review application role doesn\'t match opportunity type'); + } + // check existing + const existing = await this.prisma.reviewApplication.findMany({ + where: { + userId, + opportunityId: dto.opportunityId, + role: dto.role + } + }); + if (existing && existing.length > 0) { + throw new ConflictException('Reviewer has submitted application before.'); + } + const entity = await this.prisma.reviewApplication.create({ + data: { + role: dto.role, + opportunityId: dto.opportunityId, + status: ReviewApplicationStatus.PENDING, + userId, + handle, + createdBy: userId, + updatedBy: userId, + }, + }); + return this.buildResponse(entity); + } + + /** + * Get all pending review applications. + * @returns All pending applications + */ + async listPending(): Promise { + const entityList = await this.prisma.reviewApplication.findMany({ + where: { status: ReviewApplicationStatus.PENDING }, + }); + return entityList.map((e) => this.buildResponse(e)); + } + + /** + * Get all review applications of specific user + * @param userId user id + * @returns all applications of this user + */ + async listByUser(userId: string): Promise { + const entityList = await this.prisma.reviewApplication.findMany({ + where: { userId }, + }); + return entityList.map((e) => this.buildResponse(e)); + } + + /** + * Get all review applications of a review opportunity + * @param opportunityId opportunity id + * @returns all applications + */ + async listByOpportunity( + opportunityId: string, + ): Promise { + const entityList = await this.prisma.reviewApplication.findMany({ + where: { opportunityId }, + }); + return entityList.map((e) => this.buildResponse(e)); + } + + /** + * Approve a review application. + * @param authUser auth user + * @param id review application id + */ + async approve(authUser: JwtUser, id: string): Promise { + const entity = await this.checkExists(id); + await this.prisma.reviewApplication.update({ + where: { id }, + data: { + status: ReviewApplicationStatus.APPROVED, + updatedBy: authUser.userId ?? '', + }, + }); + // send email + await this.sendEmails([entity], ReviewApplicationStatus.APPROVED); + } + + /** + * Reject a review application. + * @param authUser auth user + * @param id review application id + */ + async reject(authUser: JwtUser, id: string): Promise { + const entity = await this.checkExists(id); + await this.prisma.reviewApplication.update({ + where: { id }, + data: { + status: ReviewApplicationStatus.REJECTED, + updatedBy: authUser.userId ?? '', + }, + }); + // send email + await this.sendEmails([entity], ReviewApplicationStatus.REJECTED); + } + + /** + * Reject all pending applications of specific opportunity + * @param authUser auth user + * @param opportunityId opportunity id + */ + async rejectAllPending( + authUser: JwtUser, + opportunityId: string, + ): Promise { + // select all pending + const entityList = await this.prisma.reviewApplication.findMany({ + where: { opportunityId, status: ReviewApplicationStatus.PENDING }, + include: { opportunity: true } + }); + // update all pending + await this.prisma.reviewApplication.updateMany({ + where: { opportunityId, status: ReviewApplicationStatus.PENDING }, + data: { + status: ReviewApplicationStatus.REJECTED, + updatedBy: authUser.userId ?? '', + }, + }); + // send emails to these users + await this.sendEmails(entityList, ReviewApplicationStatus.REJECTED); + } + + /** + * Get user approved review application list within date range. + * @param userId user id + * @param range date range in days. 180 days default. + * @returns application list + */ + async getHistory(userId: string, range: number = 180) { + // calculate begin date + const beginDate = new Date(); + beginDate.setDate(beginDate.getDate() - range); + const entityList = await this.prisma.reviewApplication.findMany({ + where: { + userId, + status: ReviewApplicationStatus.APPROVED, + createdAt: { + gte: beginDate + } + } + }); + return entityList.map((e) => this.buildResponse(e)); + } + + /** + * Send emails to appliers + * @param entityList review application entity list + * @param status application status + */ + private async sendEmails(entityList, status: ReviewApplicationStatus) { + // All review application has same review opportunity and same challenge id. + const challengeId = entityList[0].opportunity.challengeId; + // get member id list + const userIds = entityList.map(e => e.userId); + // Get challenge data and member emails. + const [challengeData, memberInfoList] = await Promise.all([ + this.challengeService.getChallengeDetail(challengeId), + this.memberService.getUserEmails(userIds) + ]); + // Get sendgrid template id + const sendgridTemplateId = status === ReviewApplicationStatus.APPROVED ? + CommonConfig.sendgridConfig.acceptEmailTemplate : + CommonConfig.sendgridConfig.rejectEmailTemplate; + // build userId -> email map + const userEmailMap = new Map(); + memberInfoList.forEach(e => userEmailMap.set(e.userId, e.email)); + // prepare challenge data + const challengeName = challengeData.name; + const challengeUrl = CommonConfig.apis.onlineReviewUrlBase + challengeData.legacyId; + // build event bus message payload + const eventBusPayloads: EventBusSendEmailPayload[] = []; + for (let entity of entityList) { + const payload:EventBusSendEmailPayload = new EventBusSendEmailPayload(); + payload.sendgrid_template_id = sendgridTemplateId; + payload.recipients = [userEmailMap.get(entity.userId)]; + payload.data = { + handle: entity.handle, + reviewPhaseStart: entity.startDate, + challengeUrl, + challengeName + }; + eventBusPayloads.push(payload); + } + // send all emails + await Promise.all(eventBusPayloads.map(e => this.eventBusService.sendEmail(e))); + } + + /** + * Make sure review application exists. + * @param id review application id + * @returns entity if exists + */ + private async checkExists(id: string) { + const entity = await this.prisma.reviewApplication.findUnique({ + where: { id }, + include: { opportunity: true } + }); + if (!entity || !entity.id) { + throw new NotFoundException('Review application not found.'); + } + return entity; + } + + /** + * Convert prisma entity to response dto. + * @param entity prisma entity + * @returns response dto + */ + private buildResponse(entity): ReviewApplicationResponseDto { + const ret = new ReviewApplicationResponseDto(); + ret.id = entity.id; + ret.userId = entity.userId; + ret.handle = entity.handle; + ret.opportunityId = entity.opportunityId; + ret.role = convertRoleName(entity.role); + ret.status = entity.status; + ret.applicationDate = entity.createdAt; + return ret; + } +} diff --git a/src/api/review-history/reviewHistory.controller.ts b/src/api/review-history/reviewHistory.controller.ts new file mode 100644 index 0000000..47902b0 --- /dev/null +++ b/src/api/review-history/reviewHistory.controller.ts @@ -0,0 +1,46 @@ +import { Controller, ForbiddenException, Get, Param, Query, Req } from "@nestjs/common"; +import { ApiBearerAuth, ApiOperation, ApiQuery, ApiResponse, ApiTags } from "@nestjs/swagger"; +import { ReviewApplicationService } from "../review-application/reviewApplication.service"; +import { OkResponse, ResponseDto } from "src/dto/common.dto"; +import { ReviewApplicationResponseDto } from "src/dto/reviewApplication.dto"; +import { Roles } from "src/shared/guards/tokenRoles.guard"; +import { UserRole } from "src/shared/enums/userRole.enum"; +import { isAdmin, JwtUser } from "src/shared/modules/global/jwt.service"; + +@ApiTags('Review History') +@Controller('/api/review-history') +export class ReviewHistoryController { + + constructor(private readonly service: ReviewApplicationService) {} + + @ApiOperation({ + summary: 'Get user review history', + description: 'Only allows this user or admin to check review history.', + }) + @ApiQuery({ + name: 'range', + description: 'Data range in days of review history. ', + type: 'number', + example: 180, + }) + @ApiResponse({ + status: 200, + description: 'Review history', + type: ResponseDto, + }) + @ApiResponse({ status: 400, description: 'Bad Request' }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) + @ApiResponse({ status: 403, description: 'Unauthorized' }) + @ApiResponse({ status: 500, description: 'Internal Error' }) + @ApiBearerAuth() + @Roles(UserRole.Reviewer, UserRole.Admin) + @Get('/:userId') + async getHistory(@Req() req: Request, @Param('userId') userId: string, @Query('range') range: number): Promise> { + // Check user permission + const authUser: JwtUser = req['user'] as JwtUser; + if (authUser.userId !== userId && !isAdmin(authUser)) { + throw new ForbiddenException('You cannot check this user\'s review history') + } + return OkResponse(await this.service.getHistory(userId, range)); + } +} diff --git a/src/api/review-opportunity/reviewOpportunity.controller.ts b/src/api/review-opportunity/reviewOpportunity.controller.ts new file mode 100644 index 0000000..63e711c --- /dev/null +++ b/src/api/review-opportunity/reviewOpportunity.controller.ts @@ -0,0 +1,252 @@ +import { + Body, + Controller, + Get, + Param, + Patch, + Post, + Query, + Req, +} from '@nestjs/common'; +import { + ApiBearerAuth, + ApiBody, + ApiOperation, + ApiParam, + ApiQuery, + ApiResponse, + ApiTags, +} from '@nestjs/swagger'; +import { OkResponse, ResponseDto } from 'src/dto/common.dto'; +import { + CreateReviewOpportunityDto, + QueryReviewOpportunityDto, + ReviewOpportunityResponseDto, + UpdateReviewOpportunityDto, +} from 'src/dto/reviewOpportunity.dto'; +import { UserRole } from 'src/shared/enums/userRole.enum'; +import { Roles } from 'src/shared/guards/tokenRoles.guard'; +import { JwtUser } from 'src/shared/modules/global/jwt.service'; +import { ReviewOpportunityService } from './reviewOpportunity.service'; + +@ApiTags('Review Opportunity') +@Controller('/api/review-opportunities') +export class ReviewOpportunityController { + constructor(private readonly service: ReviewOpportunityService) {} + + @ApiOperation({ + summary: 'Search review opportunity', + description: + 'Any user should be able to see opportunity. Including anonymous.', + }) + @ApiQuery({ + name: 'paymentFrom', + description: 'payment min value', + type: 'number', + example: 0.0, + required: false + }) + @ApiQuery({ + name: 'paymentTo', + description: 'payment max value', + type: 'number', + example: 200.0, + required: false + }) + @ApiQuery({ + name: 'startDateFrom', + description: 'Start date min value', + type: 'string', + example: '2022-05-22T12:34:56', + required: false + }) + @ApiQuery({ + name: 'startDateTo', + description: 'Start date max value', + type: 'string', + example: '2022-05-22T12:34:56', + required: false + }) + @ApiQuery({ + name: 'durationFrom', + description: 'duration min value (seconds)', + type: 'number', + example: 86400, + required: false + }) + @ApiQuery({ + name: 'durationTo', + description: 'duration max value (seconds)', + type: 'number', + example: 86400, + required: false + }) + @ApiQuery({ + name: 'numSubmissionsFrom', + description: 'min number of submissions', + type: 'number', + example: 1, + required: false + }) + @ApiQuery({ + name: 'numSubmissionsTo', + description: 'max number of submissions', + type: 'number', + example: 5, + required: false + }) + @ApiQuery({ + name: 'tracks', + description: 'Challenge tracks', + type: 'array', + example: ['CODE'], + required: false + }) + @ApiQuery({ + name: 'skills', + description: 'Skills of challenges', + type: 'array', + example: ['TypeScript'], + required: false + }) + @ApiQuery({ + name: 'sortBy', + description: 'sorting field', + enum: ['basePayment', 'duration', 'startDate'], + type: 'string', + example: 'basePayment', + default: 'startDate', + required: false + }) + @ApiQuery({ + name: 'sortOrder', + description: 'sorting order', + enum: ['asc', 'desc'], + type: 'string', + example: 'asc', + default: 'asc', + required: false + }) + @ApiQuery({ + name: 'limit', + description: 'pagination limit', + type: 'number', + example: 10, + default: 10, + required: false + }) + @ApiQuery({ + name: 'limit', + description: 'pagination offset', + type: 'number', + example: 0, + default: 0, + required: false + }) + @ApiResponse({ + status: 200, + description: 'Review opportunity list', + type: ResponseDto, + }) + @ApiResponse({ status: 400, description: 'Bad Request' }) + @ApiResponse({ status: 500, description: 'Internal Error' }) + @Get() + async search(@Query() dto: QueryReviewOpportunityDto) { + return await this.service.search(dto); + } + + @ApiOperation({ + summary: 'Create review opportunity', + description: 'Roles: Admin | Copilot', + }) + @ApiBody({ + description: 'Review opportunity data', + type: CreateReviewOpportunityDto, + }) + @ApiResponse({ + status: 200, + description: 'Review opportunity details', + type: ResponseDto, + }) + @ApiResponse({ status: 400, description: 'Bad Request' }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) + @ApiResponse({ status: 403, description: 'Forbidden' }) + @ApiResponse({ status: 500, description: 'Internal Error' }) + @Post() + @ApiBearerAuth() + @Roles(UserRole.Admin, UserRole.Copilot) + async create(@Req() req: Request, @Body() dto: CreateReviewOpportunityDto) { + const authUser: JwtUser = req['user'] as JwtUser; + return OkResponse(await this.service.create(authUser, dto)); + } + + @ApiOperation({ + summary: 'Get review opportunity by id', + description: + 'Any user should be able to see opportunity. Including anonymous.', + }) + @ApiParam({ + name: 'id', + description: 'review opportunity id', + }) + @ApiResponse({ + status: 200, + description: 'Review opportunity details', + type: ResponseDto, + }) + @ApiResponse({ status: 500, description: 'Internal Error' }) + @Get('/:id') + async getById(@Param('id') id: string) { + return OkResponse(await this.service.get(id)); + } + + @ApiOperation({ + summary: 'Update review opportunity by id', + description: + 'Any user should be able to see opportunity. Including anonymous.', + }) + @ApiBody({ + description: 'Review opportunity data', + type: UpdateReviewOpportunityDto, + }) + @ApiResponse({ + status: 201, + description: 'Review opportunity details', + type: ResponseDto, + }) + @ApiResponse({ status: 400, description: 'Bad Request' }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) + @ApiResponse({ status: 403, description: 'Forbidden' }) + @ApiResponse({ status: 500, description: 'Internal Error' }) + @Patch('/:id') + @ApiBearerAuth() + @Roles(UserRole.Admin, UserRole.Copilot) + async updateById( + @Req() req: Request, + @Param('id') id: string, + @Body() dto: UpdateReviewOpportunityDto, + ) { + const authUser: JwtUser = req['user'] as JwtUser; + return OkResponse(await this.service.update(authUser, id, dto)); + } + + @ApiOperation({ + summary: 'Get review opportunity by challenge id', + description: + 'Any user should be able to see opportunity. Including anonymous.', + }) + @ApiParam({ + name: 'challengeId', + description: 'challenge id', + }) + @ApiResponse({ + status: 200, + description: 'Review opportunity list', + type: ResponseDto, + }) + @ApiResponse({ status: 500, description: 'Internal Error' }) + @Get('/challenge/:challengeId') + async getByChallengeId(@Param('challengeId') challengeId: string) { + return OkResponse(await this.service.getByChallengeId(challengeId)); + } +} diff --git a/src/api/review-opportunity/reviewOpportunity.service.ts b/src/api/review-opportunity/reviewOpportunity.service.ts new file mode 100644 index 0000000..6f3a8ba --- /dev/null +++ b/src/api/review-opportunity/reviewOpportunity.service.ts @@ -0,0 +1,291 @@ +import { BadRequestException, ConflictException, Injectable, Logger, NotFoundException } from '@nestjs/common'; +import { + convertRoleName, + ReviewApplicationRole, + ReviewApplicationRoleIds, +} from 'src/dto/reviewApplication.dto'; +import { + CreateReviewOpportunityDto, + QueryReviewOpportunityDto, + ReviewOpportunityResponseDto, + ReviewOpportunityStatus, + UpdateReviewOpportunityDto, +} from 'src/dto/reviewOpportunity.dto'; +import { CommonConfig } from 'src/shared/config/common.config'; +import { + ChallengeApiService, + ChallengeData, +} from 'src/shared/modules/global/challenge.service'; +import { JwtUser } from 'src/shared/modules/global/jwt.service'; +import { PrismaService } from 'src/shared/modules/global/prisma.service'; + +@Injectable() +export class ReviewOpportunityService { + + private readonly logger: Logger = new Logger(ReviewOpportunityService.name); + + constructor( + private readonly prisma: PrismaService, + private readonly challengeService: ChallengeApiService, + ) {} + + + /** + * Search Review Opportunities + * @param dto query dto + */ + async search(dto: QueryReviewOpportunityDto) { + // filter data with payment, duration and start date + const prismaFilter = { + include: { applications: true }, + where: { AND: [{ + status: ReviewOpportunityStatus.OPEN + }] as any[] } + } + if (dto.paymentFrom) { + prismaFilter.where.AND.push({ basePayment: { gte: dto.paymentFrom } }) + } + if (dto.paymentTo) { + prismaFilter.where.AND.push({ basePayment: { lte: dto.paymentTo } }) + } + if (dto.durationFrom) { + prismaFilter.where.AND.push({ duration: { gte: dto.durationFrom } }) + } + if (dto.durationTo) { + prismaFilter.where.AND.push({ duration: { lte: dto.durationTo } }) + } + if (dto.startDateFrom) { + prismaFilter.where.AND.push({ startDate: { gte: dto.startDateFrom } }) + } + if (dto.startDateTo) { + prismaFilter.where.AND.push({ startDate: { lte: dto.startDateTo } }) + } + // query data from db + const entityList = await this.prisma.reviewOpportunity.findMany(prismaFilter); + // build result with challenge data + let responseList = await this.assembleList(entityList); + // filter with challenge fields + if (dto.numSubmissionsFrom) { + responseList = responseList.filter(r => (r.submissions ?? 0) >= (dto.numSubmissionsFrom ?? 0)) + } + if (dto.numSubmissionsTo) { + responseList = responseList.filter(r => (r.submissions ?? 0) <= (dto.numSubmissionsTo ?? 0)) + } + if (dto.tracks && dto.tracks.length > 0) { + responseList = responseList.filter(r => r.challengeData && dto.tracks?.includes(r.challengeData['track'] as string)) + } + if (dto.skills && dto.skills.length > 0) { + responseList = responseList.filter(r => r.challengeData && + (r.challengeData['technologies'] as string[]).some(e => dto.skills?.includes(e))) + } + // sort list + responseList = [...responseList].sort((a, b) => { + return dto.sortOrder === 'asc' ? (a[dto.sortBy] - b[dto.sortBy]) : (a[dto.sortBy] - b[dto.sortBy]); + }); + // pagination + const start = Math.max(0, dto.offset as number); + const end = Math.min(responseList.length, start + (dto.limit as number)); + responseList = responseList.slice(start, end); + // return result + return responseList; + } + + /** + * Create review opportunity. + * @param authUser auth user + * @param dto dto + * @returns response + */ + async create( + authUser: JwtUser, + dto: CreateReviewOpportunityDto, + ): Promise { + // make sure challenge exists first + let challengeData: ChallengeData; + try { + challengeData = await this.challengeService.getChallengeDetail( + dto.challengeId, + ); + } catch (e) { + // challenge doesn't exist. Return 400 + this.logger.error('Can\'t get challenge:', e) + throw new BadRequestException("Challenge doesn't exist"); + } + // check existing + const existing = await this.prisma.reviewOpportunity.findMany({ + where: { + challengeId: dto.challengeId, + type: dto.type + } + }); + if (existing && existing.length > 0) { + throw new ConflictException('Review opportunity exists for challenge and type'); + } + const entity = await this.prisma.reviewOpportunity.create({ + data: { + ...dto, + createdBy: authUser.userId ?? '', + updatedBy: authUser.userId ?? '', + }, + }); + return await this.buildResponse(entity, challengeData); + } + + /** + * Get opportunity by id + * @param id opportunity id + * @returns response dto + */ + async get(id: string) { + const entity = await this.checkExists(id); + return await this.assembleResult(entity); + } + + /** + * Update review opportunity by id + * @param authUser auth user + * @param id opportunity id + * @param dto update dto + */ + async update(authUser: JwtUser, id: string, dto: UpdateReviewOpportunityDto) { + const updatedBy = authUser.userId ?? ''; + await this.checkExists(id); + const entity = await this.prisma.reviewOpportunity.update({ + where: { id }, + data: { + ...dto, + updatedBy, + }, + }); + return await this.assembleResult(entity); + } + + /** + * Get review opportunities by challenge id + * @param challengeId challenge id + * @returns review opportunity list + */ + async getByChallengeId( + challengeId: string, + ): Promise { + const entityList = await this.prisma.reviewOpportunity.findMany({ + where: { challengeId }, + include: { applications: true }, + }); + return await this.assembleList(entityList); + } + + /** + * Check review opportunity exists or not. + * @param id review opportunity id + * @returns existing record + */ + private async checkExists(id: string) { + const existing = await this.prisma.reviewOpportunity.findUnique({ + where: { id }, + include: { applications: true }, + }); + if (!existing || !existing.id) { + throw new NotFoundException('Review opportunity not found'); + } + return existing; + } + + /** + * Get challenge data list and put all data into response. + * @param entityList prisma data list + * @returns response list + */ + private async assembleList( + entityList, + ): Promise { + // get challenge id and remove duplicated + const challengeIdList = [...new Set(entityList.map((e) => e.challengeId))]; + // get all challenge data + const challengeList = await this.challengeService.getChallenges( + challengeIdList as string[], + ); + // build challenge id -> challenge data map + const challengeMap = new Map(); + challengeList.forEach((c) => challengeMap.set(c.id, c)); + // build response list. + return entityList.map((e) => { + return this.buildResponse(e, challengeMap.get(e.challengeId)) + } + + ); + } + + /** + * Get challenge data and put all data into response. + * @param entity prisma entity + * @returns response dto + */ + private async assembleResult(entity): Promise { + const challengeData = await this.challengeService.getChallengeDetail( + entity.challengeId, + ); + return this.buildResponse(entity, challengeData); + } + + /** + * Put all data into response dto. + * @param entity prisma entity + * @param challengeData challenge data from api + * @returns response dto + */ + private buildResponse( + entity, + challengeData: ChallengeData, + ): ReviewOpportunityResponseDto { + const ret = new ReviewOpportunityResponseDto(); + ret.id = entity.id; + ret.challengeId = entity.challengeId; + ret.type = entity.type; + ret.status = entity.status; + ret.openPositions = entity.openPositions; + ret.startDate = entity.startDate; + ret.duration = entity.duration; + ret.basePayment = entity.basePayment; + ret.incrementalPayment = entity.incrementalPayment; + ret.submissions = challengeData.numOfSubmissions ?? 0; + ret.challengeName = challengeData.name; + ret.challengeData = { + id: challengeData.legacyId, + title: challengeData.name, + track: challengeData.legacy?.track || '', + subTrack: challengeData.legacy?.subTrack || '', + technologies: challengeData.tags || [], + version: '1.0', + platforms: [''], + }; + + // review applications + if (entity.applications && entity.applications.length > 0) { + ret.applications = entity.applications.map((e) => ({ + id: e.id, + opportunityId: entity.id, + userId: e.userId, + handle: e.handle, + role: convertRoleName(e.role), + status: e.status, + applicationDate: e.createdBy, + })); + } + + // payments + ret.payments = []; + const paymentConfig = CommonConfig.reviewPaymentConfig; + const rolePaymentMap = paymentConfig[entity.type]; + for (const role of Object.keys(rolePaymentMap)) { + if (rolePaymentMap[role]) { + ret.payments.push({ + role: convertRoleName(role as ReviewApplicationRole), + roleId: ReviewApplicationRoleIds[role], + payment: entity.basePayment * rolePaymentMap[role], + }); + } + } + return ret; + } +} diff --git a/src/dto/common.dto.ts b/src/dto/common.dto.ts new file mode 100644 index 0000000..8ef32f3 --- /dev/null +++ b/src/dto/common.dto.ts @@ -0,0 +1,65 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class ResultDto { + @ApiProperty({ + description: 'success or fail', + example: true, + }) + success: boolean; + + @ApiProperty({ + description: 'status code', + example: 200, + }) + status: number; + + @ApiProperty({ + description: 'returned data', + }) + content: T | null; +} + +export class ResponseDto { + @ApiProperty({ + description: 'Api Response', + }) + result: ResultDto; +} + +/** + * Build successful response + * @param data return data + * @param status response status + * @returns Response to return from API + */ +export const OkResponse = function ( + data: T, + status?: number, +): ResponseDto { + const ret = new ResponseDto(); + const result = new ResultDto(); + result.success = true; + result.status = status ?? 200; + result.content = data; + ret.result = result; + return ret; +}; + +/** + * Build error response + * @param message error message if any + * @param status status code if any + * @returns Error response to return from API + */ +export const FailResponse = function ( + message?: string, + status?: number, +): ResponseDto { + const ret = new ResponseDto(); + const result = new ResultDto(); + result.success = true; + result.status = status ?? 500; + result.content = message ?? 'Internal Error'; + ret.result = result; + return ret; +}; diff --git a/src/dto/reviewApplication.dto.ts b/src/dto/reviewApplication.dto.ts new file mode 100644 index 0000000..865f978 --- /dev/null +++ b/src/dto/reviewApplication.dto.ts @@ -0,0 +1,121 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsIn, IsNotEmpty, IsOptional, IsString } from 'class-validator'; +import { ReviewOpportunityType } from './reviewOpportunity.dto'; + +export enum ReviewApplicationStatus { + PENDING = 'PENDING', + APPROVED = 'APPROVED', + REJECTED = 'REJECTED', + CANCELLED = 'CANCELLED', +} + +export enum ReviewApplicationRole { + PRIMARY_REVIEWER = 'PRIMARY_REVIEWER', + SECONDARY_REVIEWER = 'SECONDARY_REVIEWER', + PRIMARY_FAILURE_REVIEWER = 'PRIMARY_FAILURE_REVIEWER', + ACCURACY_REVIEWER = 'ACCURACY_REVIEWER', + STRESS_REVIEWER = 'STRESS_REVIEWER', + FAILURE_REVIEWER = 'FAILURE_REVIEWER', + SPECIFICATION_REVIEWER = 'SPECIFICATION_REVIEWER', + ITERATIVE_REVIEWER = 'ITERATIVE_REVIEWER', + REVIEWER = 'REVIEWER', +} + +// read from review_application_role_lu +export const ReviewApplicationRoleIds: Record = { + PRIMARY_REVIEWER: 1, + SECONDARY_REVIEWER: 2, + PRIMARY_FAILURE_REVIEWER: 3, + ACCURACY_REVIEWER: 4, + STRESS_REVIEWER: 5, + FAILURE_REVIEWER: 6, + SPECIFICATION_REVIEWER: 7, + ITERATIVE_REVIEWER: 8, + REVIEWER: 9, +}; + +// read from review_application_role_lu.review_auction_type_id +export const ReviewApplicationRoleOpportunityTypeMap: Record< + ReviewApplicationRole, + ReviewOpportunityType +> = { + PRIMARY_REVIEWER: ReviewOpportunityType.REGULAR_REVIEW, + SECONDARY_REVIEWER: ReviewOpportunityType.REGULAR_REVIEW, + PRIMARY_FAILURE_REVIEWER: ReviewOpportunityType.COMPONENT_DEV_REVIEW, + ACCURACY_REVIEWER: ReviewOpportunityType.COMPONENT_DEV_REVIEW, + STRESS_REVIEWER: ReviewOpportunityType.COMPONENT_DEV_REVIEW, + FAILURE_REVIEWER: ReviewOpportunityType.COMPONENT_DEV_REVIEW, + SPECIFICATION_REVIEWER: ReviewOpportunityType.SPEC_REVIEW, + ITERATIVE_REVIEWER: ReviewOpportunityType.ITERATIVE_REVIEW, + REVIEWER: ReviewOpportunityType.SCENARIOS_REVIEW, +}; + +const allReviewApplicationRole = Object.values(ReviewApplicationRole); + +/** + * Convert review application role enum to string value. Eg, 'ITERATIVE_REVIEWER' => 'Iterative Reviewer' + * @param role ReviewApplicationRole value + * @returns role name displayed on frontend pages + */ +export const convertRoleName = (role: ReviewApplicationRole): string => { + return role + .toLowerCase() + .split('_') + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(''); +}; + +export class CreateReviewApplicationDto { + @ApiProperty({ + description: 'Review Opportunity id', + }) + @IsString() + @IsNotEmpty() + opportunityId: string; + + @ApiPropertyOptional({ + description: 'Review application role', + enum: allReviewApplicationRole, + example: ReviewApplicationRole.REVIEWER, + }) + @IsOptional() + @IsIn(allReviewApplicationRole) + role: ReviewApplicationRole; +} + +export class ReviewApplicationResponseDto { + @ApiProperty({ + description: 'Review application id', + }) + id: string; + + @ApiProperty({ + description: 'Review Opportunity id', + }) + opportunityId: string; + + @ApiProperty({ + description: 'user id', + }) + userId: string; + + @ApiProperty({ + description: 'user handle', + }) + handle: string; + + @ApiProperty({ + description: 'Review Application Role', + }) + role: string; + + @ApiProperty({ + description: 'Review Application Status', + }) + status: ReviewApplicationStatus; + + @ApiProperty({ + description: 'Review Application create time', + }) + applicationDate: string; +} diff --git a/src/dto/reviewOpportunity.dto.ts b/src/dto/reviewOpportunity.dto.ts new file mode 100644 index 0000000..ad8ccca --- /dev/null +++ b/src/dto/reviewOpportunity.dto.ts @@ -0,0 +1,254 @@ +import { ApiProperty, ApiPropertyOptional, OmitType, PartialType } from '@nestjs/swagger'; +import { + IsArray, + IsDateString, + IsIn, + IsNotEmpty, + IsNumber, + IsOptional, + IsPositive, + IsString, + IsUUID, + Min, +} from 'class-validator'; +import { ReviewApplicationResponseDto } from './reviewApplication.dto'; +import { Transform } from 'class-transformer'; + +export enum ReviewOpportunityStatus { + OPEN = 'OPEN', + CLOSED = 'CLOSED', + CANCELLED = 'CANCELLED', +} + +const opportunityAllStatus = [ + ReviewOpportunityStatus.OPEN, + ReviewOpportunityStatus.CLOSED, + ReviewOpportunityStatus.CANCELLED, +]; + +export enum ReviewOpportunityType { + REGULAR_REVIEW = 'REGULAR_REVIEW', + COMPONENT_DEV_REVIEW = 'COMPONENT_DEV_REVIEW', + SPEC_REVIEW = 'SPEC_REVIEW', + ITERATIVE_REVIEW = 'ITERATIVE_REVIEW', + SCENARIOS_REVIEW = 'SCENARIOS_REVIEW', +} + +const opportunityAllType = [ + ReviewOpportunityType.REGULAR_REVIEW, + ReviewOpportunityType.COMPONENT_DEV_REVIEW, + ReviewOpportunityType.SPEC_REVIEW, + ReviewOpportunityType.ITERATIVE_REVIEW, + ReviewOpportunityType.SCENARIOS_REVIEW, +]; + +export class CreateReviewOpportunityDto { + @ApiProperty({ + description: 'Challenge id', + }) + @IsString() + @IsNotEmpty() + @IsUUID() + challengeId: string; + + @ApiPropertyOptional({ + description: 'Review Opportunity Status', + enum: opportunityAllStatus, + example: ReviewOpportunityStatus.OPEN, + }) + @IsOptional() + @IsIn(opportunityAllStatus) + status: ReviewOpportunityStatus = ReviewOpportunityStatus.OPEN; + + @ApiPropertyOptional({ + description: 'Review Opportunity Type', + enum: opportunityAllType, + example: ReviewOpportunityType.REGULAR_REVIEW, + }) + @IsOptional() + @IsIn(opportunityAllType) + type: ReviewOpportunityType = ReviewOpportunityType.REGULAR_REVIEW; + + @ApiProperty({ + description: 'Number of open positions', + example: 2, + }) + @IsNumber() + @IsPositive() + openPositions: number; + + @ApiProperty({ + description: 'Review phase start time', + example: '2025-05-30T12:34:56Z', + }) + @IsDateString() + @IsNotEmpty() + startDate: string; + + @ApiProperty({ + description: 'Review phase duration(seconds)', + example: '86400', + }) + @IsNumber() + @IsPositive() + duration: number; + + @ApiProperty({ + description: 'Payment for reviewer if there is 1 submission.', + example: '180.0', + }) + @IsNumber() + @IsPositive() + basePayment: number; + + @ApiProperty({ + description: 'Review payment for each extra submission.', + example: '50.0', + }) + @IsNumber() + incrementalPayment: number; +} + + +export class UpdateReviewOpportunityDto extends PartialType( + OmitType(CreateReviewOpportunityDto, ['challengeId', 'type']) +) {} + + +export class ReviewPaymentDto { + @ApiProperty({ + description: 'Review application role name', + example: 'Iterative Reviewer', + }) + role: string; + + @ApiProperty({ + description: 'Review application role id', + example: 8, + }) + roleId: number; + + @ApiProperty({ + description: + 'Review payment. Should be base payment if there is 1 submission.', + example: 180.0, + }) + payment: number; +} + +export class ReviewOpportunityResponseDto extends CreateReviewOpportunityDto { + @ApiProperty({ + description: 'Review opportunity id', + }) + id: string; + + @ApiProperty({ + description: 'Current submission count of this challenge', + }) + submissions: number | null; + + @ApiProperty({ + description: 'Challenge name', + }) + challengeName: string | null; + + @ApiProperty({ + description: + 'Challenge data including id, title, track, subTrack, technologies, platforms', + }) + challengeData: Record | null; + + @ApiProperty({ + description: 'Review applications on this opportunity', + }) + applications: ReviewApplicationResponseDto[] | null; + + @ApiProperty({ + description: 'Review payments', + }) + payments: ReviewPaymentDto[] | null; +} + +export class QueryReviewOpportunityDto { + @Transform(({ value }) => parseInt(value, 10)) + @IsNumber() + @Min(0) + @IsOptional() + paymentFrom: number | undefined; + + @Transform(({ value }) => parseInt(value, 10)) + @IsNumber() + @Min(0) + @IsOptional() + paymentTo: number | undefined; + + @ApiProperty({ + description: 'Start time min value', + example: '2025-05-30T12:34:56Z', + }) + @IsDateString() + @IsOptional() + startDateFrom: string | undefined; + + @ApiProperty({ + description: 'Start time max value', + example: '2025-05-30T12:34:56Z', + }) + @IsDateString() + @IsOptional() + startDateTo: string | undefined; + + @Transform(({ value }) => parseInt(value, 10)) + @IsNumber() + @IsPositive() + @IsOptional() + durationFrom: number | undefined; + + @Transform(({ value }) => parseInt(value, 10)) + @IsNumber() + @IsPositive() + @IsOptional() + durationTo: number | undefined; + + @Transform(({ value }) => parseInt(value, 10)) + @IsNumber() + @IsPositive() + @IsOptional() + numSubmissionsFrom: number | undefined; + + @Transform(({ value }) => parseInt(value, 10)) + @IsNumber() + @IsPositive() + @IsOptional() + numSubmissionsTo: number | undefined; + + @IsArray() + @IsOptional() + tracks: string[] | undefined; + + @IsArray() + @IsOptional() + skills: string[] | undefined; + + @IsIn(['basePayment', 'duration', 'startDate']) + @IsString() + @IsOptional() + sortBy: string = 'startDate'; + + @IsIn(['asc', 'desc']) + @IsString() + @IsOptional() + sortOrder: string = 'asc'; + + @Transform(({ value }) => parseInt(value, 10)) + @IsNumber() + @IsPositive() + @IsOptional() + limit: number | undefined = 10; + + @Transform(({ value }) => parseInt(value, 10)) + @IsNumber() + @Min(0) + @IsOptional() + offset: number | undefined = 0; +} diff --git a/src/shared/config/common.config.ts b/src/shared/config/common.config.ts new file mode 100644 index 0000000..69d1126 --- /dev/null +++ b/src/shared/config/common.config.ts @@ -0,0 +1,64 @@ +import { ReviewApplicationRole } from '@prisma/client'; +import { ReviewOpportunityType } from 'src/dto/reviewOpportunity.dto'; + +// Build payment config for each review opportunity config. +const paymentConfig: Record> = {}; + +paymentConfig[ReviewOpportunityType.REGULAR_REVIEW] = {}; +paymentConfig[ReviewOpportunityType.REGULAR_REVIEW][ + ReviewApplicationRole.PRIMARY_REVIEWER +] = 1; +paymentConfig[ReviewOpportunityType.REGULAR_REVIEW][ + ReviewApplicationRole.SECONDARY_REVIEWER +] = 0.8; + +paymentConfig[ReviewOpportunityType.ITERATIVE_REVIEW] = {}; +paymentConfig[ReviewOpportunityType.ITERATIVE_REVIEW][ + ReviewApplicationRole.ITERATIVE_REVIEWER +] = 1; + +paymentConfig[ReviewOpportunityType.SPEC_REVIEW] = {}; +paymentConfig[ReviewOpportunityType.SPEC_REVIEW][ + ReviewApplicationRole.SPECIFICATION_REVIEWER +] = 1; + +paymentConfig[ReviewOpportunityType.SCENARIOS_REVIEW] = {}; +paymentConfig[ReviewOpportunityType.SCENARIOS_REVIEW][ + ReviewApplicationRole.REVIEWER +] = 1; + +paymentConfig[ReviewOpportunityType.COMPONENT_DEV_REVIEW] = {}; +paymentConfig[ReviewOpportunityType.COMPONENT_DEV_REVIEW][ + ReviewApplicationRole.PRIMARY_FAILURE_REVIEWER +] = 1; +paymentConfig[ReviewOpportunityType.COMPONENT_DEV_REVIEW][ + ReviewApplicationRole.FAILURE_REVIEWER +] = 0.8; +paymentConfig[ReviewOpportunityType.COMPONENT_DEV_REVIEW][ + ReviewApplicationRole.ACCURACY_REVIEWER +] = 0.8; +paymentConfig[ReviewOpportunityType.COMPONENT_DEV_REVIEW][ + ReviewApplicationRole.STRESS_REVIEWER +] = 0.8; + +export const CommonConfig = { + // API URLs + apis: { + busApiUrl: process.env.BUS_API_URL ?? 'http://localhost:4000/eventBus', + challengeApiUrl: + process.env.CHALLENGE_API_URL ?? 'http://localhost:4000/challenges/', + memberApiUrl: process.env.MEMBER_API_URL ?? 'http://localhost:4000/members', + onlineReviewUrlBase: 'https://software.topcoder.com/review/actions/ViewProjectDetails?pid=' + }, + // configs of payment for each review type + reviewPaymentConfig: paymentConfig, + // sendgrid templates configs + sendgridConfig: { + acceptEmailTemplate: + process.env.SENDGRID_ACCEPT_REVIEW_APPLICATION ?? + 'd-2de72880bd69499e9c16369398d34bb9', + rejectEmailTemplate: + process.env.SENDGRID_REJECT_REVIEW_APPLICATION ?? + 'd-82ed74e778e84d8c9bc02eeda0f44b5e', + }, +}; diff --git a/src/shared/config/m2m.config.ts b/src/shared/config/m2m.config.ts new file mode 100644 index 0000000..13b8b63 --- /dev/null +++ b/src/shared/config/m2m.config.ts @@ -0,0 +1,10 @@ +export const M2mConfig = { + auth0: { + url: process.env.M2M_AUTH_URL ?? 'http://localhost:4000/oauth/token', + domain: process.env.M2M_AUTH_DOMAIN ?? 'topcoder-dev.auth0.com', + audience: process.env.M2M_AUTH_AUDIENCE ?? 'https://m2m.topcoder-dev.com/', + proxyUrl: process.env.M2M_AUTH_PROXY_SEREVR_URL, + clientId: process.env.M2M_AUTH_CLIENT_ID, + clientSecret: process.env.M2M_AUTH_CLIENT_SECRET, + }, +}; diff --git a/src/shared/enums/userRole.enum.ts b/src/shared/enums/userRole.enum.ts index 44c2c2f..948c8c5 100644 --- a/src/shared/enums/userRole.enum.ts +++ b/src/shared/enums/userRole.enum.ts @@ -2,8 +2,9 @@ * Enum defining user roles for role-based access control */ export enum UserRole { - Admin = 'Admin', - Copilot = 'Copilot', - Reviewer = 'Reviewer', + Admin = 'administrator', + Copilot = 'copilot', + Reviewer = 'reviewer', Submitter = 'Submitter', -} \ No newline at end of file + User = 'Topcoder User' +} diff --git a/src/shared/modules/global/challenge.service.ts b/src/shared/modules/global/challenge.service.ts new file mode 100644 index 0000000..802152a --- /dev/null +++ b/src/shared/modules/global/challenge.service.ts @@ -0,0 +1,66 @@ +import { HttpService } from '@nestjs/axios'; +import { firstValueFrom } from 'rxjs'; +import { plainToInstance } from 'class-transformer'; +import { validateOrReject } from 'class-validator'; +import { AxiosError } from 'axios'; +import { M2MService } from './m2m.service'; +import { Injectable, Logger } from '@nestjs/common'; +import { CommonConfig } from 'src/shared/config/common.config'; + +export class ChallengeData { + id: string; + name: string; + legacy?: { + track?: string | undefined; + subTrack?: string | undefined; + }; + numOfSubmissions?: number | undefined; + track: string; + legacyId: number; + tags?: string[] | undefined; +} + +@Injectable() +export class ChallengeApiService { + private readonly logger: Logger = new Logger(ChallengeApiService.name); + + constructor( + private readonly m2mService: M2MService, + private readonly httpService: HttpService, + ) {} + + async getChallenges(challengeIds: string[]): Promise { + // Get all challenge details at once. + const results = await Promise.all( + challengeIds.map((id) => this.getChallengeDetail(id)), + ); + return results; + } + + async getChallengeDetail(challengeId: string): Promise { + // Get M2m token + const token = await this.m2mService.getM2MToken(); + // Send request to challenge api + const url = CommonConfig.apis.challengeApiUrl + challengeId; + + try { + const response = await firstValueFrom( + this.httpService.get(url, { + headers: { + Authorization: 'Bearer ' + token, + }, + }), + ); + const challenge = plainToInstance(ChallengeData, response.data); + await validateOrReject(challenge); + return challenge; + } catch (e) { + if (e instanceof AxiosError) { + this.logger.error(`Http Error: ${e.message}`, e.response?.data); + throw new Error('Cannot get data from Challenge API.'); + } + this.logger.error(`Data validation error: ${e}`); + throw new Error('Malformed data returned from Challenge API'); + } + } +} diff --git a/src/shared/modules/global/eventBus.service.ts b/src/shared/modules/global/eventBus.service.ts new file mode 100644 index 0000000..0600acc --- /dev/null +++ b/src/shared/modules/global/eventBus.service.ts @@ -0,0 +1,84 @@ +import { + HttpStatus, + Injectable, + InternalServerErrorException, + Logger, +} from '@nestjs/common'; +import { M2MService } from './m2m.service'; +import { CommonConfig } from 'src/shared/config/common.config'; +import { HttpService } from '@nestjs/axios'; +import { firstValueFrom } from 'rxjs'; + +/** + * Event bus message. + */ +class EventBusMessage { + topic: string; + originator: string; + 'mime-type': string = 'application/json'; + timestamp: string = new Date().toISOString(); + payload: T; +} + +// event bus send email payload +export class EventBusSendEmailPayload { + data: { + handle: string; + reviewPhaseStart: string; + challengeUrl: string; + challengeName: string; + }; + from: Record = { + email: 'Topcoder ' + }; + version: string = 'v3'; + sendgrid_template_id: string; + recipients: string[]; +} + +@Injectable() +export class EventBusService { + private readonly logger: Logger = new Logger(EventBusService.name); + + constructor( + private readonly m2mService: M2MService, + private readonly httpService: HttpService, + ) {} + + /** + * Send email message to Event bus. + * @param payload send email payload + */ + async sendEmail(payload: EventBusSendEmailPayload): Promise { + // Get M2m token + const token = await this.m2mService.getM2MToken(); + // build event bus message + const msg = new EventBusMessage(); + msg.topic = 'external.action.email'; + // TODO: Maybe we should update this value. + msg.originator = 'ap-review-microservice'; + msg.payload = payload; + // send message to event bus + const url = CommonConfig.apis.busApiUrl; + try { + const response = await firstValueFrom( + this.httpService.post(url, msg, { + headers: { + Authorization: 'Bearer ' + token, + }, + }), + ); + if ( + response.status !== HttpStatus.OK && + response.status !== HttpStatus.NO_CONTENT + ) { + throw new Error(`Event bus status code: ${response.status}`); + } + } catch (e) { + this.logger.error(`Event bus failed with error: ${e.message}`); + throw new InternalServerErrorException( + 'Sending message to event bus failed.', + ); + } + } +} diff --git a/src/shared/modules/global/globalProviders.module.ts b/src/shared/modules/global/globalProviders.module.ts index 049b0d9..a763d15 100644 --- a/src/shared/modules/global/globalProviders.module.ts +++ b/src/shared/modules/global/globalProviders.module.ts @@ -1,15 +1,21 @@ import { Global, Module } from '@nestjs/common'; import { APP_GUARD } from '@nestjs/core'; +import { HttpModule } from '@nestjs/axios'; import { PrismaService } from './prisma.service'; import { TokenRolesGuard } from '../../guards/tokenRoles.guard'; import { JwtService } from './jwt.service'; import { LoggerService } from './logger.service'; import { PrismaErrorService } from './prisma-error.service'; +import { M2MService } from './m2m.service'; +import { ChallengeApiService } from './challenge.service'; +import { EventBusService } from './eventBus.service'; +import { MemberService } from './member.service'; // Global module for providing global providers // Add any provider you want to be global here @Global() @Module({ + imports: [HttpModule], providers: [ { provide: APP_GUARD, @@ -24,7 +30,20 @@ import { PrismaErrorService } from './prisma-error.service'; }, }, PrismaErrorService, + M2MService, + ChallengeApiService, + EventBusService, + MemberService, + ], + exports: [ + PrismaService, + JwtService, + LoggerService, + PrismaErrorService, + M2MService, + ChallengeApiService, + EventBusService, + MemberService, ], - exports: [PrismaService, JwtService, LoggerService, PrismaErrorService], }) export class GlobalProvidersModule {} diff --git a/src/shared/modules/global/jwt.service.ts b/src/shared/modules/global/jwt.service.ts index bfc0c51..0828d9a 100644 --- a/src/shared/modules/global/jwt.service.ts +++ b/src/shared/modules/global/jwt.service.ts @@ -1,4 +1,8 @@ -import { Injectable, OnModuleInit, UnauthorizedException } from '@nestjs/common'; +import { + Injectable, + OnModuleInit, + UnauthorizedException, +} from '@nestjs/common'; import { decode, verify, VerifyOptions, Secret } from 'jsonwebtoken'; import * as jwksClient from 'jwks-rsa'; import { ALL_SCOPE_MAPPINGS, Scope } from '../../enums/scopes.enum'; @@ -7,8 +11,14 @@ import { AuthConfig } from '../../config/auth.config'; export interface JwtUser { userId?: string; + handle?: string; roles?: UserRole[]; scopes?: string[]; + isMachine: boolean; +} + +export const isAdmin = (user: JwtUser): boolean => { + return user.isMachine || (user.roles ?? []).includes(UserRole.Admin); } // Map for testing tokens, will be removed in production @@ -69,14 +79,14 @@ export class JwtService implements OnModuleInit { try { // First check if it's a test token if (TOKEN_ROLE_MAP[token]) { - return { roles: TOKEN_ROLE_MAP[token] as UserRole[] }; + return { roles: TOKEN_ROLE_MAP[token] as UserRole[], isMachine: false }; } // Check if it's a test M2M token if (TEST_M2M_TOKENS[token]) { const rawScopes = TEST_M2M_TOKENS[token]; const scopes = this.expandScopes(rawScopes); - return { scopes }; + return { scopes, isMachine: false }; } let decodedToken: any; @@ -118,21 +128,29 @@ export class JwtService implements OnModuleInit { throw new UnauthorizedException('Invalid token'); } - const user: JwtUser = {}; + const user: JwtUser = {isMachine: false}; // Check for M2M token from Auth0 if (decodedToken.scope) { const scopeString = decodedToken.scope as string; const rawScopes = scopeString.split(' '); user.scopes = this.expandScopes(rawScopes); - } - - // Check for roles in a user token - if (decodedToken.roles) { - user.roles = decodedToken.roles as UserRole[]; user.userId = decodedToken.sub; + user.isMachine = true; + } else { + // Check for roles, userId and handle in a user token + for (const key of Object.keys(decodedToken)) { + if (key.endsWith('handle')) { + user.handle = decodedToken[key] as string; + } + if (key.endsWith('userId')) { + user.userId = decodedToken[key] as string; + } + if (key.endsWith('roles')) { + user.roles = decodedToken[key] as UserRole[] + } + } } - return user; } catch (error) { console.error('Token validation failed:', error); diff --git a/src/shared/modules/global/m2m.service.ts b/src/shared/modules/global/m2m.service.ts new file mode 100644 index 0000000..92ec219 --- /dev/null +++ b/src/shared/modules/global/m2m.service.ts @@ -0,0 +1,28 @@ +import { Injectable } from '@nestjs/common'; +import * as core from 'tc-core-library-js'; +import { M2mConfig } from 'src/shared/config/m2m.config'; + +/** + * Service to get M2M token with auth0 configs + */ +@Injectable() +export class M2MService { + private readonly m2m; + + constructor() { + const config = M2mConfig.auth0; + this.m2m = core.auth.m2m({ + AUTH0_URL: config.url, + AUTH0_AUDIENCE: config.audience, + AUTH0_PROXY_SERVER_URL: config.proxyUrl, + }); + } + + async getM2MToken() { + const config = M2mConfig.auth0; + return (await this.m2m.getMachineToken( + config.clientId, + config.clientSecret, + )) as string; + } +} diff --git a/src/shared/modules/global/member.service.ts b/src/shared/modules/global/member.service.ts new file mode 100644 index 0000000..dd664df --- /dev/null +++ b/src/shared/modules/global/member.service.ts @@ -0,0 +1,67 @@ +import { + Injectable, + InternalServerErrorException, + Logger, +} from '@nestjs/common'; +import { M2MService } from './m2m.service'; +import { HttpService } from '@nestjs/axios'; +import { CommonConfig } from 'src/shared/config/common.config'; +import { firstValueFrom } from 'rxjs'; +import { plainToInstance } from 'class-transformer'; +import { validateOrReject } from 'class-validator'; +import { AxiosError } from 'axios'; + +export class MemberInfo { + userId: number; + email: string; +} + +@Injectable() +export class MemberService { + private readonly logger: Logger = new Logger(MemberService.name); + + constructor( + private readonly m2mService: M2MService, + private readonly httpService: HttpService, + ) {} + + /** + * Get user emails from Member API + * @param userIds user id list + * @returns user info list + */ + async getUserEmails(userIds: string[]) { + const token = await this.m2mService.getM2MToken(); + // construct URL of member API. Eg, https://api.topcoder-dev.com/v5/members?fields=email,userId&userIds=[123456] + const url = + CommonConfig.apis.memberApiUrl + + `?fields=email,userId&userIds=[${userIds.join(',')}]`; + // send request + try { + const response = await firstValueFrom( + this.httpService.get(url, { + headers: { + Authorization: 'Bearer ' + token, + }, + }), + ); + const infoList = plainToInstance(MemberInfo, response.data); + await Promise.all(infoList.map((e) => validateOrReject(e))); + return infoList; + } catch (e) { + if (e instanceof AxiosError) { + this.logger.error( + `Can't get member info: ${e.message}`, + e.response?.data, + ); + throw new InternalServerErrorException( + 'Cannot get data from Member API.', + ); + } + this.logger.error(`Member Data validation error: ${e}`); + throw new InternalServerErrorException( + 'Malformed data returned from Member API', + ); + } + } +}