diff --git a/.circleci/config.yml b/.circleci/config.yml index 0f720be..5fe383e 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -72,6 +72,7 @@ workflows: - pm-3454 - use-pnpm - PM-3458 + - pm-3318 # Production builds are exectuted only on tagged commits to the # master branch. diff --git a/config/default.js b/config/default.js index 1e8e825..0125996 100644 --- a/config/default.js +++ b/config/default.js @@ -122,6 +122,12 @@ module.exports = { MAMBO_PRIVATE_KEY: process.env.MAMBO_PRIVATE_KEY, MAMBO_DOMAIN_URL: process.env.MAMBO_DOMAIN_URL, MAMBO_DEFAULT_SITE: process.env.MAMBO_DEFAULT_SITE, + + // Learning Paths API + LEARNING_PATHS_API_URL: process.env.LEARNING_PATHS_API_URL || 'https://api.topcoder-dev.com/v5/learning-paths', + + // Gamification API + GAMIFICATION_API_URL: process.env.GAMIFICATION_API_URL || 'https://api.topcoder-dev.com/v5/gamification', HASHING_KEYS: { USERFLOW: process.env.USERFLOW_PRIVATE_KEY diff --git a/package.json b/package.json index a7fc91b..7a288bf 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "description": "TopCoder Member V6 API", "main": "app.js", "scripts": { - "postinstall": "prisma generate --schema=prisma/schema.prisma && prisma generate --schema=prisma/identity-schema.prisma", + "postinstall": "prisma generate --schema=prisma/schema.prisma", "start": "node app.js", "start:dev": "nodemon app.js", "lint": "standard", @@ -36,6 +36,8 @@ "@topcoder/resource-api-v6": "topcoder-platform/resource-api-v6.git#develop", "@topcoder/standardized-skills-api": "topcoder-platform/standardized-skills-api.git#develop", "@topcoder/tc-finance-api": "topcoder-platform/tc-finance-api.git#dev", + "@topcoder/tc-identity-service": "topcoder-platform/identity-api-v6.git#develop", + "react-pdf-html": "^2.1.5", "@topcoder/engagements-api-v6": "topcoder-platform/engagements-api-v6.git#develop", "aws-sdk": "^2.466.0", "axios": "^0.27.2", @@ -55,6 +57,8 @@ "lodash": "^4.17.19", "mime-types": "^2.1.35", "moment": "^2.27.0", + "moment-timezone": "^0.5.43", + "city-timezones": "^1.3.2", "mysql2": "^3.14.3", "prisma": "^6.10.1", "qs": "^6.14.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3029081..a4efa71 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -32,6 +32,9 @@ importers: '@topcoder/tc-finance-api': specifier: topcoder-platform/tc-finance-api.git#dev version: tc-payments-api@https://codeload.github.com/topcoder-platform/tc-finance-api/tar.gz/51e60ab5670dc4cf95c060dae67de4e71cb995b8(prisma@6.19.2(typescript@5.9.3))(typescript@5.9.3) + '@topcoder/tc-identity-service': + specifier: topcoder-platform/identity-api-v6.git#develop + version: tc-identity-service@https://codeload.github.com/topcoder-platform/identity-api-v6/tar.gz/6ca58772f0edd8e03429e25968d1b5875011c86a(keyv@5.6.0)(typescript@5.9.3) aws-sdk: specifier: ^2.466.0 version: 2.1693.0 @@ -44,6 +47,9 @@ importers: body-parser: specifier: ^1.15.1 version: 1.20.4 + city-timezones: + specifier: ^1.3.2 + version: 1.3.3 config: specifier: ^3.0.1 version: 3.3.12 @@ -86,6 +92,9 @@ importers: moment: specifier: ^2.27.0 version: 2.30.1 + moment-timezone: + specifier: ^0.5.43 + version: 0.5.48 mysql2: specifier: ^3.14.3 version: 3.16.2 @@ -98,6 +107,9 @@ importers: react: specifier: ^18.3.1 version: 18.3.1 + react-pdf-html: + specifier: ^2.1.5 + version: 2.1.5(@react-pdf/renderer@3.4.5(react@18.3.1))(react@18.3.1) request: specifier: ^2.88.2 version: 2.88.2 @@ -827,6 +839,9 @@ packages: '@types/node': optional: true + '@ioredis/commands@1.5.0': + resolution: {integrity: sha512-eUgLqrMf8nJkZxT24JvVRrQya1vZkQh8BBeYNwGDqa5I0VUi8ACx7uFvAaLxintokpTenkK6DASvo/bvNbBGow==} + '@isaacs/balanced-match@4.0.1': resolution: {integrity: sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==} engines: {node: 20 || >=22} @@ -890,6 +905,9 @@ packages: peerDependencies: jsep: ^0.4.0||^1.0.0 + '@keyv/serialize@1.1.1': + resolution: {integrity: sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==} + '@lukeed/csprng@1.1.0': resolution: {integrity: sha512-Z7C/xXCiGWsg0KuKsHTKJxbWhpI3Vs5GwLfOean7MGyVFGqdRgBbAjOCh6u4bbjPc/8MJ2pZmK/0DLdCbivLDA==} engines: {node: '>=8'} @@ -907,6 +925,22 @@ packages: axios: ^1.3.1 rxjs: ^6.0.0 || ^7.0.0 + '@nestjs/axios@4.0.1': + resolution: {integrity: sha512-68pFJgu+/AZbWkGu65Z3r55bTsCPlgyKaV4BSG8yUAD72q1PPuyVRgUwFv6BxdnibTUHlyxm06FmYWNC+bjN7A==} + peerDependencies: + '@nestjs/common': ^10.0.0 || ^11.0.0 + axios: ^1.3.1 + rxjs: ^7.0.0 + + '@nestjs/cache-manager@3.1.0': + resolution: {integrity: sha512-pEIqYZrBcE8UdkJmZRduurvoUfdU+3kRPeO1R2muiMbZnRuqlki5klFFNllO9LyYWzrx98bd1j0PSPKSJk1Wbw==} + peerDependencies: + '@nestjs/common': ^9.0.0 || ^10.0.0 || ^11.0.0 + '@nestjs/core': ^9.0.0 || ^10.0.0 || ^11.0.0 + cache-manager: '>=6' + keyv: '>=5' + rxjs: ^7.8.1 + '@nestjs/cli@11.0.16': resolution: {integrity: sha512-P0H+Vcjki6P5160E5QnMt3Q0X5FTg4PZkP99Ig4lm/4JWqfw32j3EXv3YBTJ2DmxLwOQ/IS9F7dzKpMAgzKTGg==} engines: {node: '>= 20.11'} @@ -957,6 +991,11 @@ packages: '@nestjs/websockets': optional: true + '@nestjs/jwt@11.0.2': + resolution: {integrity: sha512-rK8aE/3/Ma45gAWfCksAXUNbOoSOUudU0Kn3rT39htPF7wsYXtKfjALKeKKJbFrIWbLjsbqfXX5bIJNvgBugGA==} + peerDependencies: + '@nestjs/common': ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0 + '@nestjs/mapped-types@2.1.0': resolution: {integrity: sha512-W+n+rM69XsFdwORF11UqJahn4J3xi4g/ZEOlJNL6KoW5ygWSmBB2p0S2BZ4FQeS/NDH72e6xIcu35SfJnE8bXw==} peerDependencies: @@ -970,6 +1009,12 @@ packages: class-validator: optional: true + '@nestjs/passport@11.0.5': + resolution: {integrity: sha512-ulQX6mbjlws92PIM15Naes4F4p2JoxGnIJuUsdXQPT+Oo2sqQmENEZXM7eYuimocfHnKlcfZOuyzbA33LwUlOQ==} + peerDependencies: + '@nestjs/common': ^10.0.0 || ^11.0.0 + passport: ^0.5.0 || ^0.6.0 || ^0.7.0 + '@nestjs/platform-express@11.1.12': resolution: {integrity: sha512-GYK/vHI0SGz5m8mxr7v3Urx8b9t78Cf/dj5aJMZlGd9/1D9OI1hAl00BaphjEXINUJ/BQLxIlF2zUjrYsd6enQ==} peerDependencies: @@ -1534,6 +1579,9 @@ packages: '@types/triple-beam@1.3.5': resolution: {integrity: sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw==} + '@types/uuid@10.0.0': + resolution: {integrity: sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==} + '@types/validator@13.15.10': resolution: {integrity: sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==} @@ -1854,6 +1902,10 @@ packages: bcrypt-pbkdf@1.0.2: resolution: {integrity: sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==} + bcryptjs@3.0.3: + resolution: {integrity: sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==} + hasBin: true + bidi-js@1.0.3: resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==} @@ -1875,6 +1927,9 @@ packages: resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==} engines: {node: '>=18'} + boolbase@1.0.0: + resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} + bowser@2.13.1: resolution: {integrity: sha512-OHawaAbjwx6rqICCKgSG0SAnT05bzd7ppyKLVUITZpANBaaMFBAsaNkto3LoQ31tyFP5kNujE8Cdx85G9VzOkw==} @@ -1942,6 +1997,13 @@ packages: magicast: optional: true + cache-manager-ioredis@2.1.0: + resolution: {integrity: sha512-TCxbp9ceuFveTKWuNaCX8QjoC41rAlHen4s63u9Yd+iXlw3efYmimc/u935PKPxSdhkXpnMes4mxtK3/yb0L4g==} + engines: {node: '>=6.0.0'} + + cache-manager@6.4.3: + resolution: {integrity: sha512-VV5eq/QQ5rIVix7/aICO4JyvSeEv9eIQuKL5iFwgM2BrcYoE0A/D1mNsAHJAsB0WEbNdBlKkn6Tjz6fKzh/cKQ==} + caching-transform@4.0.0: resolution: {integrity: sha512-kpqOvwXnjjN44D89K5ccQC+RUrsy7jB/XLlRrx0D7/2HNcTPqzsb6XgYoErwko6QsV184CA2YgS1fxDiiDZMWA==} engines: {node: '>=8'} @@ -2043,6 +2105,9 @@ packages: citty@0.2.0: resolution: {integrity: sha512-8csy5IBFI2ex2hTVpaHN2j+LNE199AgiI7y4dMintrr8i0lQiFn+0AWMZrWdHKIgMOer65f8IThysYhoReqjWA==} + city-timezones@1.3.3: + resolution: {integrity: sha512-tyH1Tje3mee1mWkjerhx/8CLOfTJn6A5L6swAqLRceoToj9bvKNkfcKESoxG9rApXBKxKeZQUQbbzYcoRSJbZw==} + cjs-module-lexer@1.4.3: resolution: {integrity: sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==} @@ -2101,6 +2166,10 @@ packages: resolution: {integrity: sha512-J4Xj5f5wq/4jAvcdgoGsL3G103BtWpZrMo8NEinRltN+xpTZdI+M38pyQqhuFU/P792xkMFvnKSf+Lm81U1bxw==} engines: {node: ^4.7 || >=6.9 || >=7.3 || >=8.2.1} + cluster-key-slot@1.1.2: + resolution: {integrity: sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==} + engines: {node: '>=0.10.0'} + codependency@2.1.0: resolution: {integrity: sha512-JIdmYkE8Z6jwH1OUf4a5H5jk9YShPQkaYPUAiN+ktyChmPP77LGbeKrxWGPqdCnpTmt0hRIn8TXBVu01U3HDhg==} @@ -2255,6 +2324,17 @@ packages: crypto-randomuuid@1.0.0: resolution: {integrity: sha512-/RC5F4l1SCqD/jazwUF6+t34Cd8zTSAGZ7rvvZu1whZUhD2a5MOGKjSGowoGcpj/cbVZk1ZODIooJEQQq3nNAA==} + css-select@5.2.2: + resolution: {integrity: sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==} + + css-tree@1.1.3: + resolution: {integrity: sha512-tRpdppF7TRazZrjJ6v3stzv93qxRcSsFmW6cX0Zm2NVKpxE1WV1HblnghVv9TreireHkqI/VDEsfolRF1p6y7Q==} + engines: {node: '>=8.0.0'} + + css-what@6.2.2: + resolution: {integrity: sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==} + engines: {node: '>= 6'} + cssfilter@0.0.10: resolution: {integrity: sha512-FAaLDaplstoRsDR8XGYH51znUN0UY7nMc6Z9/fvE8EXGwvJE9hu7W2vHwx1+bd6gCYnln9nLbzxFTrcO9YQDZw==} @@ -2292,6 +2372,11 @@ packages: resolution: {integrity: sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==} engines: {node: '>= 0.4'} + date-fns-tz@3.2.0: + resolution: {integrity: sha512-sg8HqoTEulcbbbVXeg84u5UnlsQa8GS5QXMqjjYIhS4abEVVKIUwe0/l/UhrZdKaL/W5eWZNlbTeEIiOXTcsBQ==} + peerDependencies: + date-fns: ^3.0.0 || ^4.0.0 + date-fns@4.1.0: resolution: {integrity: sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==} @@ -2388,6 +2473,10 @@ packages: resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} engines: {node: '>=0.4.0'} + denque@1.5.1: + resolution: {integrity: sha512-XwE+iZ4D6ZUB7mfYRMb5wByE8L74HCn30FBN7sWnXksWc1LO1bPDl67pBR9o/kC4z/xSNAwkMYcGgqDV3BE3Hw==} + engines: {node: '>=0.10'} + denque@2.1.0: resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==} engines: {node: '>=0.10'} @@ -2437,6 +2526,19 @@ packages: resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} engines: {node: '>=0.10.0'} + dom-serializer@2.0.0: + resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} + + domelementtype@2.3.0: + resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==} + + domhandler@5.0.3: + resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==} + engines: {node: '>= 4'} + + domutils@3.2.2: + resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==} + dotenv-expand@12.0.1: resolution: {integrity: sha512-LaKRbou8gt0RNID/9RoI+J2rvXsBRPMV7p+ElHlPhcSARbCPDYcYG2s1TIzAfWv4YSgyY5taidWzzs31lNV3yQ==} engines: {node: '>=12'} @@ -2522,6 +2624,10 @@ packages: resolution: {integrity: sha512-LgQMM4WXU3QI+SYgEc2liRgznaD5ojbmY3sb8LxyguVkIg5FxdpTkvk72te2R38/TGKxH634oLxXRGY6d7AP+Q==} engines: {node: '>=10.13.0'} + entities@4.5.0: + resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} + engines: {node: '>=0.12'} + error-ex@1.3.4: resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==} @@ -3198,6 +3304,14 @@ packages: resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} engines: {node: '>= 0.4'} + ioredis@4.31.0: + resolution: {integrity: sha512-tVrCrc4LWJwX82GD79dZ0teZQGq+5KJEGpXJRgzHOrhHtLgF9ME6rTwDV5+HN5bjnvmtrnS8ioXhflY16sy2HQ==} + engines: {node: '>=6'} + + ioredis@5.9.2: + resolution: {integrity: sha512-tAAg/72/VxOUW7RQSX1pIxJVucYKcjFjfvj60L57jrZpYCHC3XN0WCQ3sNYL4Gmvv+7GPvTAjc+KSdeNuE8oWQ==} + engines: {node: '>=12.22.0'} + ip-address@10.1.0: resolution: {integrity: sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==} engines: {node: '>= 12'} @@ -3589,6 +3703,9 @@ packages: jws@4.0.1: resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==} + keyv@5.6.0: + resolution: {integrity: sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==} + koalas@1.0.2: resolution: {integrity: sha512-RYhBbYaTTTHId3l6fnMZc3eGQNW6FVCqMG6AMwA5I1Mafr6AflaXeoi6x3xQuATRotGYRLk6+1ELZH4dstFNOA==} engines: {node: '>=0.10.0'} @@ -3643,12 +3760,21 @@ packages: lodash.clonedeep@4.5.0: resolution: {integrity: sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==} + lodash.defaults@4.2.0: + resolution: {integrity: sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==} + + lodash.flatten@4.4.0: + resolution: {integrity: sha512-C5N2Z3DgnnKr0LOpv/hKCgKdb7ZZwafIrsesve6lmzvZIRZRGaZ/l6Q8+2W7NaT+ZwO3fFlSCzCzrDCFdJfZ4g==} + lodash.flattendeep@4.4.0: resolution: {integrity: sha512-uHaJFihxmJcEX3kT4I23ABqKKalJ/zDrDg0lsFtc1h+3uw49SIJ5beyhx5ExVRti3AvKoOJngIj7xz3oylPdWQ==} lodash.includes@4.3.0: resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==} + lodash.isarguments@3.1.0: + resolution: {integrity: sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==} + lodash.isboolean@3.0.3: resolution: {integrity: sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==} @@ -3744,6 +3870,9 @@ packages: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} + mdn-data@2.0.14: + resolution: {integrity: sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow==} + media-engine@1.0.3: resolution: {integrity: sha512-aa5tG6sDoK+k70B9iEX1NeyfT8ObCKhNDs6lJVpwF6r8vhUfuKMslIcirq6HIUYuuUYLefcEQOn9bSBOvawtwg==} @@ -3969,6 +4098,9 @@ packages: resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==} hasBin: true + node-html-parser@6.1.13: + resolution: {integrity: sha512-qIsTMOY4C/dAa5Q5vsobRpOOvPfC4pB61UVW2uSwZNUp0QU/jCekTal1vMmbO0DgdHeLUJpv/ARmDqErVxA3Sg==} + node-preload@0.2.1: resolution: {integrity: sha512-RM5oyBy45cLEoHqCeh+MNuFAxO0vTFBLskvQbOKnEE7YTTSN4tbN8QWDIPQ6L+WvKsB/qLEGpYe2ZZ9d4W9OIQ==} engines: {node: '>=8'} @@ -3991,6 +4123,9 @@ packages: normalize-svg-path@1.1.0: resolution: {integrity: sha512-r9KHKG2UUeB5LoTouwDzBy2VxXlHsiM6fyLQvnJa0S5hrhzqElH/CH7TUGhT1fVvIYBIKf3OpY4YJ4CK+iaqHg==} + nth-check@2.1.1: + resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} + nyc@17.1.0: resolution: {integrity: sha512-U42vQ4czpKa0QdI1hu950XuNhYqgoM+ZF1HT+VuUHL9hPfDPVvNQyltmMqdE9bUHMVa+8yNbc3QKTj8zQhlVxQ==} engines: {node: '>=18'} @@ -4092,6 +4227,10 @@ packages: resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} engines: {node: '>=10'} + p-map@2.1.0: + resolution: {integrity: sha512-y3b8Kpd8OAN444hxfBbFfj1FY/RjtTd8tzYwhUqNYXx0fXx2iX4maP4Qr6qhIKbQXI02wTLAda4fYUbDagTUFw==} + engines: {node: '>=6'} + p-map@3.0.0: resolution: {integrity: sha512-d3qXVTF/s+W+CdJ5A29wywV2n8CQQYahlgz2bFiA+4eVNJbHJodPZ+/gXwPGh0bOqA+j8S+6+ckmvLGPk1QpxQ==} engines: {node: '>=8'} @@ -4140,6 +4279,17 @@ packages: resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} engines: {node: '>= 0.8'} + passport-jwt@4.0.1: + resolution: {integrity: sha512-UCKMDYhNuGOBE9/9Ycuoyh7vP6jpeTp/+sfMJl7nLff/t6dps+iaeE0hhNkKN8/HZHcJ7lCdOyDxHdDoxoSvdQ==} + + passport-strategy@1.0.0: + resolution: {integrity: sha512-CB97UUvDKJde2V0KDWWB3lyf6PC3FaZP7YxZ2G8OAtn9p4HI9j9JLP9qjOGZFvyl8uwNT8qM+hGnz/n16NI7oA==} + engines: {node: '>= 0.4.0'} + + passport@0.7.0: + resolution: {integrity: sha512-cPLl+qZpSc+ireUvt+IzqbED1cHHkDoVYMo30jbJIdOOjQ1MQYZBPiNvmi8UM6lJuOpTPXJGZQk0DtC4y61MYQ==} + engines: {node: '>= 0.4.0'} + path-exists@3.0.0: resolution: {integrity: sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ==} engines: {node: '>=4'} @@ -4194,6 +4344,9 @@ packages: pathval@1.1.1: resolution: {integrity: sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==} + pause@0.0.1: + resolution: {integrity: sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg==} + peek-readable@4.1.0: resolution: {integrity: sha512-ZI3LnwUv5nOGbQzD9c2iDG6toheuXSZP5esSHBjopsXH4dg19soufvpUGA3uohi5anFtGb2lhAVdHzH6R/Evvg==} engines: {node: '>=8'} @@ -4425,6 +4578,13 @@ packages: react-is@18.3.1: resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} + react-pdf-html@2.1.5: + resolution: {integrity: sha512-KhmiTUcnUNbLVdZbxMcV/+Rp+PyBt+eVJHu425eNwjSsDUtytvcwS/SBdBbbpM0c4+MnnOQBOs3AiColUesM8A==} + engines: {node: '>=16.0.0'} + peerDependencies: + '@react-pdf/renderer': '>=3.4.4' + react: '>=16' + react@18.3.1: resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==} engines: {node: '>=0.10.0'} @@ -4460,6 +4620,14 @@ packages: reconnect-core@1.3.0: resolution: {integrity: sha512-+gLKwmyRf2tjl6bLR03DoeWELzyN6LW9Xgr3vh7NXHHwPi0JC0N2TwPyf90oUEBkCRcD+bgQ+s3HORoG3nwHDg==} + redis-errors@1.2.0: + resolution: {integrity: sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==} + engines: {node: '>=4'} + + redis-parser@3.0.0: + resolution: {integrity: sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==} + engines: {node: '>=4'} + reflect-metadata@0.1.14: resolution: {integrity: sha512-ZhYeb6nRaXCfhnndflDK8qI6ZQ/YcWZCISRAWICW9XYqMUwjZM9Z0DveWX/ABN01oxSHwVxKQmxeYZSsm0jh5A==} @@ -4844,6 +5012,9 @@ packages: resolution: {integrity: sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==} engines: {node: '>=10'} + standard-as-callback@2.1.0: + resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==} + standard-engine@9.0.0: resolution: {integrity: sha512-ZfNfCWZ2Xq67VNvKMPiVMKHnMdvxYzvZkf1AH8/cw2NLDBm5LRsxMqvEJpsjLI/dUosZ3Z1d6JlHDp5rAvvk2w==} @@ -5014,6 +5185,10 @@ packages: version: 3.0.1 engines: {node: '>= 14'} + tc-identity-service@https://codeload.github.com/topcoder-platform/identity-api-v6/tar.gz/6ca58772f0edd8e03429e25968d1b5875011c86a: + resolution: {tarball: https://codeload.github.com/topcoder-platform/identity-api-v6/tar.gz/6ca58772f0edd8e03429e25968d1b5875011c86a} + version: 1.0.0 + tc-payments-api@https://codeload.github.com/topcoder-platform/tc-finance-api/tar.gz/51e60ab5670dc4cf95c060dae67de4e71cb995b8: resolution: {tarball: https://codeload.github.com/topcoder-platform/tc-finance-api/tar.gz/51e60ab5670dc4cf95c060dae67de4e71cb995b8} version: 0.0.1 @@ -6757,6 +6932,8 @@ snapshots: optionalDependencies: '@types/node': 25.1.0 + '@ioredis/commands@1.5.0': {} + '@isaacs/balanced-match@4.0.1': {} '@isaacs/brace-expansion@5.0.0': @@ -6831,6 +7008,8 @@ snapshots: dependencies: jsep: 1.4.0 + '@keyv/serialize@1.1.1': {} + '@lukeed/csprng@1.1.0': {} '@microsoft/tsdoc@0.16.0': {} @@ -6846,6 +7025,20 @@ snapshots: axios: 1.13.4 rxjs: 7.8.2 + '@nestjs/axios@4.0.1(@nestjs/common@11.1.12(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(axios@1.13.4)(rxjs@7.8.2)': + dependencies: + '@nestjs/common': 11.1.12(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2) + axios: 1.13.4 + rxjs: 7.8.2 + + '@nestjs/cache-manager@3.1.0(@nestjs/common@11.1.12(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.12)(cache-manager@6.4.3)(keyv@5.6.0)(rxjs@7.8.2)': + dependencies: + '@nestjs/common': 11.1.12(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.12(@nestjs/common@11.1.12(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.12)(reflect-metadata@0.2.2)(rxjs@7.8.2) + cache-manager: 6.4.3 + keyv: 5.6.0 + rxjs: 7.8.2 + '@nestjs/cli@11.0.16(@types/node@25.1.0)': dependencies: '@angular-devkit/core': 19.2.19(chokidar@4.0.3) @@ -6924,6 +7117,12 @@ snapshots: optionalDependencies: '@nestjs/platform-express': 11.1.12(@nestjs/common@11.1.12(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.12) + '@nestjs/jwt@11.0.2(@nestjs/common@11.1.12(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))': + dependencies: + '@nestjs/common': 11.1.12(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@types/jsonwebtoken': 9.0.10 + jsonwebtoken: 9.0.3 + '@nestjs/mapped-types@2.1.0(@nestjs/common@11.1.12(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.1.14)': dependencies: '@nestjs/common': 11.1.12(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2) @@ -6940,6 +7139,11 @@ snapshots: class-transformer: 0.5.1 class-validator: 0.14.3 + '@nestjs/passport@11.0.5(@nestjs/common@11.1.12(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(passport@0.7.0)': + dependencies: + '@nestjs/common': 11.1.12(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2) + passport: 0.7.0 + '@nestjs/platform-express@11.1.12(@nestjs/common@11.1.12(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.12)': dependencies: '@nestjs/common': 11.1.12(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2) @@ -7747,6 +7951,8 @@ snapshots: '@types/triple-beam@1.3.5': {} + '@types/uuid@10.0.0': {} + '@types/validator@13.15.10': {} '@types/webidl-conversions@7.0.3': {} @@ -8090,6 +8296,8 @@ snapshots: dependencies: tweetnacl: 0.14.5 + bcryptjs@3.0.3: {} + bidi-js@1.0.3: dependencies: require-from-string: 2.0.2 @@ -8135,6 +8343,8 @@ snapshots: transitivePeerDependencies: - supports-color + boolbase@1.0.0: {} + bowser@2.13.1: {} brace-expansion@1.1.12: @@ -8220,6 +8430,16 @@ snapshots: pkg-types: 2.3.0 rc9: 2.1.2 + cache-manager-ioredis@2.1.0: + dependencies: + ioredis: 4.31.0 + transitivePeerDependencies: + - supports-color + + cache-manager@6.4.3: + dependencies: + keyv: 5.6.0 + caching-transform@4.0.0: dependencies: hasha: 5.2.2 @@ -8340,6 +8560,8 @@ snapshots: citty@0.2.0: {} + city-timezones@1.3.3: {} + cjs-module-lexer@1.4.3: {} class-transformer@0.5.1: {} @@ -8400,6 +8622,8 @@ snapshots: emitter-listener: 1.1.2 semver: 5.7.2 + cluster-key-slot@1.1.2: {} + codependency@2.1.0: dependencies: semver: 5.7.2 @@ -8544,6 +8768,21 @@ snapshots: crypto-randomuuid@1.0.0: {} + css-select@5.2.2: + dependencies: + boolbase: 1.0.0 + css-what: 6.2.2 + domhandler: 5.0.3 + domutils: 3.2.2 + nth-check: 2.1.1 + + css-tree@1.1.3: + dependencies: + mdn-data: 2.0.14 + source-map: 0.6.1 + + css-what@6.2.2: {} + cssfilter@0.0.10: {} csv-generate@4.5.0: {} @@ -8583,6 +8822,10 @@ snapshots: es-errors: 1.3.0 is-data-view: 1.0.2 + date-fns-tz@3.2.0(date-fns@4.1.0): + dependencies: + date-fns: 4.1.0 + date-fns@4.1.0: {} dd-trace@2.46.0: @@ -8714,6 +8957,8 @@ snapshots: delayed-stream@1.0.0: {} + denque@1.5.1: {} + denque@2.1.0: {} depd@2.0.0: {} @@ -8748,9 +8993,27 @@ snapshots: dependencies: esutils: 2.0.3 + dom-serializer@2.0.0: + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + entities: 4.5.0 + + domelementtype@2.3.0: {} + + domhandler@5.0.3: + dependencies: + domelementtype: 2.3.0 + + domutils@3.2.2: + dependencies: + dom-serializer: 2.0.0 + domelementtype: 2.3.0 + domhandler: 5.0.3 + dotenv-expand@12.0.1: dependencies: - dotenv: 16.4.7 + dotenv: 16.6.1 dotenv@16.4.7: {} @@ -8869,6 +9132,8 @@ snapshots: graceful-fs: 4.2.11 tapable: 2.3.0 + entities@4.5.0: {} + error-ex@1.3.4: dependencies: is-arrayish: 0.2.1 @@ -9729,6 +9994,36 @@ snapshots: hasown: 2.0.2 side-channel: 1.1.0 + ioredis@4.31.0: + dependencies: + '@ioredis/commands': 1.5.0 + cluster-key-slot: 1.1.2 + debug: 4.4.3(supports-color@5.5.0) + denque: 1.5.1 + lodash.defaults: 4.2.0 + lodash.flatten: 4.4.0 + lodash.isarguments: 3.1.0 + p-map: 2.1.0 + redis-errors: 1.2.0 + redis-parser: 3.0.0 + standard-as-callback: 2.1.0 + transitivePeerDependencies: + - supports-color + + ioredis@5.9.2: + dependencies: + '@ioredis/commands': 1.5.0 + cluster-key-slot: 1.1.2 + debug: 4.4.3(supports-color@5.5.0) + denque: 2.1.0 + lodash.defaults: 4.2.0 + lodash.isarguments: 3.1.0 + redis-errors: 1.2.0 + redis-parser: 3.0.0 + standard-as-callback: 2.1.0 + transitivePeerDependencies: + - supports-color + ip-address@10.1.0: {} ip-regex@2.1.0: {} @@ -10161,6 +10456,10 @@ snapshots: jwa: 2.0.1 safe-buffer: 5.2.1 + keyv@5.6.0: + dependencies: + '@keyv/serialize': 1.1.1 + koalas@1.0.2: {} kuler@2.0.0: {} @@ -10214,10 +10513,16 @@ snapshots: lodash.clonedeep@4.5.0: {} + lodash.defaults@4.2.0: {} + + lodash.flatten@4.4.0: {} + lodash.flattendeep@4.4.0: {} lodash.includes@4.3.0: {} + lodash.isarguments@3.1.0: {} + lodash.isboolean@3.0.3: {} lodash.isinteger@4.0.4: {} @@ -10301,6 +10606,8 @@ snapshots: math-intrinsics@1.1.0: {} + mdn-data@2.0.14: {} + media-engine@1.0.3: {} media-typer@0.3.0: {} @@ -10515,6 +10822,11 @@ snapshots: node-gyp-build@4.8.4: {} + node-html-parser@6.1.13: + dependencies: + css-select: 5.2.2 + he: 1.2.0 + node-preload@0.2.1: dependencies: process-on-spawn: 1.1.0 @@ -10547,6 +10859,10 @@ snapshots: dependencies: svg-arc-to-cubic-bezier: 3.2.0 + nth-check@2.1.1: + dependencies: + boolbase: 1.0.0 + nyc@17.1.0: dependencies: '@istanbuljs/load-nyc-config': 1.1.0 @@ -10686,6 +11002,8 @@ snapshots: dependencies: p-limit: 3.1.0 + p-map@2.1.0: {} + p-map@3.0.0: dependencies: aggregate-error: 3.1.0 @@ -10731,6 +11049,19 @@ snapshots: parseurl@1.3.3: {} + passport-jwt@4.0.1: + dependencies: + jsonwebtoken: 9.0.3 + passport-strategy: 1.0.0 + + passport-strategy@1.0.0: {} + + passport@0.7.0: + dependencies: + passport-strategy: 1.0.0 + pause: 0.0.1 + utils-merge: 1.0.1 + path-exists@3.0.0: {} path-exists@4.0.0: {} @@ -10769,6 +11100,8 @@ snapshots: pathval@1.1.1: {} + pause@0.0.1: {} + peek-readable@4.1.0: {} perfect-debounce@1.0.0: {} @@ -10984,6 +11317,13 @@ snapshots: react-is@18.3.1: {} + react-pdf-html@2.1.5(@react-pdf/renderer@3.4.5(react@18.3.1))(react@18.3.1): + dependencies: + '@react-pdf/renderer': 3.4.5(react@18.3.1) + css-tree: 1.1.3 + node-html-parser: 6.1.13 + react: 18.3.1 + react@18.3.1: dependencies: loose-envify: 1.4.0 @@ -11027,6 +11367,12 @@ snapshots: dependencies: backoff: 2.5.0 + redis-errors@1.2.0: {} + + redis-parser@3.0.0: + dependencies: + redis-errors: 1.2.0 + reflect-metadata@0.1.14: {} reflect-metadata@0.2.2: {} @@ -11506,6 +11852,8 @@ snapshots: dependencies: escape-string-regexp: 2.0.0 + standard-as-callback@2.1.0: {} + standard-engine@9.0.0: dependencies: deglob: 2.1.1 @@ -11759,6 +12107,58 @@ snapshots: - debug - supports-color + tc-identity-service@https://codeload.github.com/topcoder-platform/identity-api-v6/tar.gz/6ca58772f0edd8e03429e25968d1b5875011c86a(keyv@5.6.0)(typescript@5.9.3): + dependencies: + '@nestjs/axios': 4.0.1(@nestjs/common@11.1.12(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(axios@1.13.4)(rxjs@7.8.2) + '@nestjs/cache-manager': 3.1.0(@nestjs/common@11.1.12(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.12)(cache-manager@6.4.3)(keyv@5.6.0)(rxjs@7.8.2) + '@nestjs/common': 11.1.12(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/config': 4.0.2(@nestjs/common@11.1.12(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(rxjs@7.8.2) + '@nestjs/core': 11.1.12(@nestjs/common@11.1.12(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.12)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/jwt': 11.0.2(@nestjs/common@11.1.12(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2)) + '@nestjs/passport': 11.0.5(@nestjs/common@11.1.12(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(passport@0.7.0) + '@nestjs/platform-express': 11.1.12(@nestjs/common@11.1.12(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.12) + '@nestjs/swagger': 11.2.5(@nestjs/common@11.1.12(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.12)(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2) + '@prisma/client': 6.19.2(prisma@6.19.2(typescript@5.9.3))(typescript@5.9.3) + '@topcoder-platform/topcoder-bus-api-wrapper': https://codeload.github.com/topcoder-platform/tc-bus-api-wrapper/tar.gz/297a9c0adcdb97661257e7825bee9c3f5578b833 + '@types/express': 5.0.6 + '@types/uuid': 10.0.0 + axios: 1.13.4 + bcryptjs: 3.0.3 + cache-manager: 6.4.3 + cache-manager-ioredis: 2.1.0 + class-transformer: 0.5.1 + class-validator: 0.14.3 + cors: 2.8.6 + csv: 6.4.1 + csv-stringify: 6.6.0 + date-fns: 4.1.0 + date-fns-tz: 3.2.0(date-fns@4.1.0) + dotenv: 16.6.1 + express: 5.2.1 + ioredis: 5.9.2 + jsonwebtoken: 9.0.3 + jwks-rsa: 3.2.2 + lodash: 4.17.23 + passport: 0.7.0 + passport-jwt: 4.0.1 + prisma: 6.19.2(typescript@5.9.3) + reflect-metadata: 0.2.2 + rxjs: 7.8.2 + swagger-ui-express: 5.0.1(express@5.2.1) + tc-core-library-js: https://codeload.github.com/topcoder-platform/tc-core-library-js/tar.gz/1075136355e1e1c4779f2138a30f3ffbd718bfa4 + trolleyhq: 1.1.0 + uuid: 11.1.0 + winston: 3.19.0 + transitivePeerDependencies: + - '@fastify/static' + - '@nestjs/microservices' + - '@nestjs/websockets' + - debug + - keyv + - magicast + - supports-color + - typescript + tc-payments-api@https://codeload.github.com/topcoder-platform/tc-finance-api/tar.gz/51e60ab5670dc4cf95c060dae67de4e71cb995b8(prisma@6.19.2(typescript@5.9.3))(typescript@5.9.3): dependencies: '@nestjs/common': 11.1.12(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2) diff --git a/prisma/identity-schema.prisma b/prisma/identity-schema.prisma deleted file mode 100644 index 85f1b52..0000000 --- a/prisma/identity-schema.prisma +++ /dev/null @@ -1,36 +0,0 @@ -generator identityClient { - provider = "prisma-client-js" - output = "./generated/identity-client" -} - -datasource identitydb { - provider = "postgresql" - url = env("IDENTITY_DB_URL") - schemas = ["identity"] -} - -model user { - user_id Decimal @id(map: "u175_45") @default(dbgenerated("nextval('sequence_user_seq'::regclass)")) @identitydb.Decimal(10, 0) - handle String @identitydb.VarChar(50) - handle_lower String? @identitydb.VarChar(50) - modify_date DateTime? @default(now()) @identitydb.Timestamp(6) - status String @identitydb.VarChar(3) - - @@map("user") - @@schema("identity") - @@index([handle]) - @@index([handle_lower], map: "user_lower_handle_idx") - @@index([status, handle_lower]) -} - -model email { - email_id Decimal @id(map: "u110_23") @default(dbgenerated("nextval('sequence_email_seq'::regclass)")) @identitydb.Decimal(10, 0) - user_id Decimal? @identitydb.Decimal(10, 0) - address String? @identitydb.VarChar(100) - modify_date DateTime? @default(now()) @identitydb.Timestamp(6) - - @@map("email") - @@schema("identity") - @@index([user_id], map: "email_user_id_idx") - @@index([address]) -} diff --git a/src/common/helper.js b/src/common/helper.js index 42c709d..f581595 100644 --- a/src/common/helper.js +++ b/src/common/helper.js @@ -568,6 +568,12 @@ async function getMemberGroups (memberId) { * @returns {Promise} */ const getM2MToken = () => { + if (!config.AUTH0_URL) { + throw new Error('AUTH0_URL is not configured. Please set AUTH0_URL environment variable or add it to config.') + } + if (!config.AUTH0_CLIENT_ID || !config.AUTH0_CLIENT_SECRET) { + throw new Error('AUTH0_CLIENT_ID and AUTH0_CLIENT_SECRET must be configured for M2M authentication.') + } return m2m.getMachineToken( config.AUTH0_CLIENT_ID, config.AUTH0_CLIENT_SECRET diff --git a/src/common/htmlUtils.js b/src/common/htmlUtils.js new file mode 100644 index 0000000..2fcf188 --- /dev/null +++ b/src/common/htmlUtils.js @@ -0,0 +1,31 @@ +/** + * Convert HTML to plain text (strip tags, decode entities). + * Used for achievements/badge content in PDF so unregistered fonts (e.g. Roboto) are never passed to the renderer. + * @param {string} html - HTML string + * @returns {string} plain text + */ +function htmlToText (html) { + if (!html || typeof html !== 'string') return '' + + let out = html + // Strip all tags: replace with space so words don't glue together + .replace(/<[^>]*>/g, ' ') + // Collapse whitespace + .replace(/\s+/g, ' ') + .trim() + + // Decode common HTML entities + out = out + .replace(/ /gi, ' ') + .replace(/</gi, '<') + .replace(/>/gi, '>') + .replace(/"/gi, '"') + .replace(/'/gi, "'") + .replace(/&/gi, '&') + + return out +} + +module.exports = { + htmlToText +} diff --git a/src/common/identityPrisma.js b/src/common/identityPrisma.js index b05cde2..a2bb2cb 100644 --- a/src/common/identityPrisma.js +++ b/src/common/identityPrisma.js @@ -1,21 +1,30 @@ const config = require('config') const errors = require('./errors') -const { PrismaClient: IdentityPrismaClient } = require('../../prisma/generated/identity-client') +const { PrismaClient: IdentityExternalPrismaClient } = require('@topcoder/tc-identity-service/packages/identity-prisma-client') let identityClient +const clientOptions = { + transactionOptions: { + timeout: config.MEMBER_SERVICE_PRISMA_TIMEOUT, + }, + log: [ + { level: 'query', emit: 'event' }, + { level: 'info', emit: 'event' }, + { level: 'warn', emit: 'event' }, + { level: 'error', emit: 'event' } + ] +} + function getIdentityClient () { if (!config.IDENTITY_DB_URL) { throw new errors.BadRequestError('IDENTITY_DB_URL is not configured') } if (!identityClient) { - identityClient = new IdentityPrismaClient({ - datasources: { - identitydb: { - url: config.IDENTITY_DB_URL - } - } + identityClient = new IdentityExternalPrismaClient({ + ...clientOptions, + datasources: { db: { url: config.IDENTITY_DB_URL } } }) } diff --git a/src/common/profileTemplate.js b/src/common/profileTemplate.js index 9b87ac3..22585bf 100644 --- a/src/common/profileTemplate.js +++ b/src/common/profileTemplate.js @@ -2,44 +2,583 @@ * PDF Template for Member Profile */ const React = require('react') +const path = require('path') const { Document, Page, Text, - StyleSheet + View, + StyleSheet, + Image } = require('@react-pdf/renderer') +const { Html } = require('react-pdf-html') +const { htmlToText } = require('./htmlUtils') // Define styles const styles = StyleSheet.create({ page: { - padding: 30, - fontSize: 12, - fontFamily: 'Helvetica' + padding: 40, + fontSize: 11, + fontFamily: 'Arial', + backgroundColor: '#FFFFFF', + color: '#000000' }, - title: { - fontSize: 24, - marginBottom: 20, + // Header styles + header: { + marginBottom: 20 + }, + headerTop: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'flex-start', + marginBottom: 15, + borderBottomWidth: 1, + borderBottomColor: '#AAAAAA', + paddingBottom: 10 + }, + logo: { + width: 120, + height: 30, + objectFit: 'contain' + }, + generatedOn: { + fontSize: 9, + color: '#666666', + textAlign: 'right' + }, + memberName: { + fontSize: 28, + fontWeight: 700, + textAlign: 'center', + marginBottom: 5, + color: '#000000', + textTransform: 'uppercase' + }, + memberTitle: { + fontSize: 14, + textAlign: 'center', + marginBottom: 10, + color: '#000000' + }, + personalInfo: { + fontSize: 10, + textAlign: 'center', + marginBottom: 5, + color: '#000000' + }, + handleInfo: { + fontSize: 10, + textAlign: 'center', + marginBottom: 15, + color: '#000000' + }, + statusBar: { + backgroundColor: '#000000', + padding: 8, + marginBottom: 20 + }, + statusBarText: { + color: '#FFFFFF', + fontSize: 10, + fontWeight: 700, + textAlign: 'center' + }, + // Section styles + section: { + marginBottom: 10 + }, + sectionHeader: { + fontSize: 14, + fontWeight: 'bold', + color: '#227681', + marginBottom: 5 + }, + sectionUnderline: { + height: 1, + backgroundColor: '#AAAAAA', + marginBottom: 10 + }, + // Biography + biographyText: { + fontSize: 11, + lineHeight: 1.5, + marginBottom: 5, + color: '#000000' + }, + // Skills + skillsSubsection: { + marginBottom: 10 + }, + skillsSubsectionTitle: { + fontSize: 11, + fontWeight: 'bold', + marginBottom: 5, + color: '#000000' + }, + skillsList: { + fontSize: 10, + marginLeft: 10, + marginBottom: 5, + color: '#000000' + }, + skillsLabel: { + fontWeight: 'bold' + }, + // Languages + languagesText: { + fontSize: 10, + color: '#000000' + }, + // Topcoder Activity + activityItem: { + fontSize: 10, + marginBottom: 5, + color: '#000000' + }, + activityLabel: { + fontWeight: 'bold' + }, + // Education/Experience items + itemTitle: { + fontSize: 11, + fontWeight: 'bold', + marginBottom: 2, + color: '#000000' + }, + itemSubtitle: { + fontSize: 10, + marginBottom: 2, + color: '#000000' + }, + itemDate: { + fontSize: 10, + textAlign: 'right', + color: '#000000' + }, + itemRow: { + flexDirection: 'row', + justifyContent: 'space-between', + marginBottom: 5 + }, + itemDescription: { + fontSize: 10, + marginTop: 5, + marginBottom: 5, + lineHeight: 1.4, + color: '#000000' + }, + itemSkills: { + fontSize: 10, + marginTop: 5, + fontStyle: 'italic', + color: '#000000' + }, + bulletPoint: { + fontSize: 10, + marginLeft: 15, + marginBottom: 3, + lineHeight: 1.4, + color: '#000000' + }, + // Certifications + certificationItem: { + fontSize: 10, + marginBottom: 3, + color: '#000000' + }, + certificationLabel: { fontWeight: 'bold' } }) +/** + * Create a section header with underline + */ +function createSectionHeader (title) { + return React.createElement( + View, + { style: { marginBottom: 0 } }, + React.createElement( + Text, + { style: styles.sectionHeader }, + title + ), + React.createElement( + View, + { style: styles.sectionUnderline } + ) + ) +} + +/** + * Create skills subsection + */ +function createSkillsSubsection (title, verified, notVerified) { + const items = [] + + if (verified.length > 0) { + items.push( + React.createElement( + Text, + { key: 'verified-label', style: styles.skillsList }, + React.createElement(Text, { style: styles.skillsLabel }, '• Verified Skills: '), + verified.join(', ') + ) + ) + } + + if (notVerified.length > 0) { + items.push( + React.createElement( + Text, + { key: 'not-verified-label', style: styles.skillsList }, + React.createElement(Text, { style: styles.skillsLabel }, '• Not Verified Skills: '), + notVerified.join(', ') + ) + ) + } + + if (items.length === 0) { + return null + } + + return React.createElement( + View, + { style: styles.skillsSubsection }, + React.createElement( + Text, + { style: styles.skillsSubsectionTitle }, + title + ), + ...items + ) +} + /** * Build the PDF template for member profile - * @param {Object} memberData the member profile data + * @param {Object} pdfData the aggregated PDF data * @returns {Object} React element tree */ -function buildProfileTemplate (memberData) { +function buildProfileTemplate (pdfData) { + const { member, workExperience, education, languages, basicInfo, skills, topcoderActivity, certifications, courses } = pdfData + + const children = [] + + // Header Section + children.push( + React.createElement( + View, + { key: 'header', style: styles.header }, + React.createElement( + View, + { style: styles.headerTop }, + React.createElement( + Image, + { + src: path.join(__dirname, '../images/topcoder-logo.png'), + style: styles.logo + } + ), + React.createElement( + View, + { style: { alignItems: 'flex-end' } }, + React.createElement( + Text, + { style: styles.generatedOn }, + 'Generated on' + ), + React.createElement( + Text, + { style: styles.generatedOn }, + member.generatedOn + ) + ) + ), + React.createElement( + Text, + { style: styles.memberName }, + `${member.firstName || ''} ${member.lastName || ''}`.trim() || member.handle + ), + basicInfo?.shortBio ? React.createElement( + Text, + { style: styles.memberTitle }, + basicInfo.shortBio + ) : null, + (member.addresses && member.addresses.length > 0) || basicInfo?.currentLocation || member.email ? React.createElement( + Text, + { style: styles.personalInfo }, + [ + basicInfo?.currentLocation || (() => { + if (member.addresses && member.addresses.length > 0) { + const city = member.addresses[0].city || '' + const stateCode = member.addresses[0].stateCode || '' + const country = member.country || '' + const parts = [city, stateCode, country].filter(Boolean) + return parts.length > 0 ? parts.join(', ') : '' + } + return member.country || '' + })(), + member.timezone ? `Timezone: ${member.timezone}` : null, + member.email + ].filter(Boolean).join(' | ') + ) : null, + React.createElement( + Text, + { style: styles.handleInfo }, + `Topcoder Handle: ${member.handle}${member.createdAt ? ` | Member Since ${new Date(member.createdAt).getFullYear()}` : ''}` + ), + member.statusBarText ? React.createElement( + View, + { style: styles.statusBar }, + React.createElement( + Text, + { style: styles.statusBarText }, + member.statusBarText + ) + ) : null + ) + ) + + // Biography Section + const biography = member.description || basicInfo?.shortBio + if (biography) { + children.push( + React.createElement( + View, + { key: 'biography-section', style: styles.section }, + createSectionHeader('BIOGRAPHY'), + React.createElement( + Text, + { key: 'biography-text', style: styles.biographyText }, + biography + ) + ) + ) + } + + // Technical Skills Section + const hasSkills = skills.principal.verified.length > 0 || skills.principal.notVerified.length > 0 || + skills.additional.verified.length > 0 || skills.additional.notVerified.length > 0 + if (hasSkills) { + const skillsContent = [ + createSectionHeader('TECHNICAL SKILLS') + ] + + const principalSubsection = createSkillsSubsection( + 'Principal Skills:', + skills.principal.verified, + skills.principal.notVerified + ) + if (principalSubsection) { + skillsContent.push(principalSubsection) + } + + const additionalSubsection = createSkillsSubsection( + 'Additional Skills:', + skills.additional.verified, + skills.additional.notVerified + ) + if (additionalSubsection) { + skillsContent.push(additionalSubsection) + } + + children.push( + React.createElement( + View, + { key: 'skills-section', style: styles.section }, + ...skillsContent + ) + ) + } + + // Languages Section + if (languages && languages.length > 0) { + children.push( + React.createElement( + View, + { key: 'languages-section', style: styles.section }, + createSectionHeader('LANGUAGES'), + React.createElement( + Text, + { key: 'languages-text', style: styles.languagesText }, + languages.join(', ') + ) + ) + ) + } + + // Experience Section + if (workExperience && workExperience.length > 0) { + const experienceContent = [createSectionHeader('EXPERIENCE')] + + workExperience.forEach((work, index) => { + const dateRange = [work.startDate, work.endDate].filter(Boolean).join(' - ') + experienceContent.push( + React.createElement( + View, + { key: `work-${index}`, style: { marginBottom: 15 } }, + React.createElement( + View, + { style: styles.itemRow }, + React.createElement( + Text, + { style: styles.itemTitle }, + work.position + ), + dateRange ? React.createElement( + Text, + { style: styles.itemDate }, + dateRange + ) : null + ), + React.createElement( + Text, + { style: styles.itemSubtitle }, + work.company + ), + work.description ? React.createElement( + Html, + { style: styles.itemDescription }, + work.description + ) : null, + work.skills && work.skills.length > 0 ? React.createElement( + Text, + { style: styles.itemSkills }, + `Skills: ${work.skills.join(', ')}` + ) : null + ) + ) + }) + + children.push( + React.createElement( + View, + { key: 'experience-section', style: styles.section }, + ...experienceContent + ) + ) + } + + // Topcoder Activity Section + if (topcoderActivity.specialRole || topcoderActivity.achievements) { + const activityContent = [createSectionHeader('TOPCODER ACTIVITY')] + + if (topcoderActivity.specialRole) { + activityContent.push( + React.createElement( + Text, + { key: 'special-role', style: styles.activityItem }, + React.createElement(Text, { style: styles.activityLabel }, topcoderActivity.specialRole) + ) + ) + } + + if (topcoderActivity.achievements) { + const achievements = topcoderActivity.achievements + const plainText = typeof achievements === 'string' && htmlToText(achievements) + activityContent.push( + React.createElement( + Text, + { key: 'achievements', style: styles.activityItem }, + plainText + ) + ) + } + + children.push( + React.createElement( + View, + { key: 'activity-section', style: styles.section }, + ...activityContent + ) + ) + } + + // Education Section + if (education && education.length > 0) { + const educationContent = [createSectionHeader('EDUCATION')] + + education.forEach((edu, index) => { + educationContent.push( + React.createElement( + View, + { key: `edu-${index}`, style: { marginBottom: 10 } }, + React.createElement( + View, + { style: styles.itemRow }, + React.createElement( + Text, + { style: styles.itemTitle }, + edu.degree + ), + edu.endYear ? React.createElement( + Text, + { style: styles.itemDate }, + edu.endYear + ) : null + ), + React.createElement( + Text, + { style: styles.itemSubtitle }, + edu.college + ) + ) + ) + }) + + children.push( + React.createElement( + View, + { key: 'education-section', style: styles.section }, + ...educationContent + ) + ) + } + + // Certifications & Courses Section + if ((certifications && certifications.length > 0) || (courses && courses.length > 0)) { + const certContent = [createSectionHeader('CERTIFICATIONS & COURSES')] + + if (certifications && certifications.length > 0) { + const certificationsText = certifications.join(', ') + certContent.push( + React.createElement( + Text, + { key: 'certifications', style: styles.certificationItem }, + React.createElement(Text, { style: styles.certificationLabel }, 'Certifications: '), + certificationsText + ) + ) + } + + if (courses && courses.length > 0) { + const coursesText = courses.join(', ') + certContent.push( + React.createElement( + Text, + { key: 'courses', style: [styles.certificationItem, { marginTop: 5 }] }, + React.createElement(Text, { style: styles.certificationLabel }, 'Courses: '), + coursesText + ) + ) + } + + children.push( + React.createElement( + View, + { key: 'certifications-section', style: styles.section }, + ...certContent + ) + ) + } + return React.createElement( Document, {}, React.createElement( Page, { size: 'A4', style: styles.page }, - React.createElement( - Text, - { style: styles.title }, - `Member Profile: ${memberData.handle || 'N/A'}` - ) + ...children ) ) } diff --git a/src/controllers/MemberController.js b/src/controllers/MemberController.js index 17db9f2..26986e1 100644 --- a/src/controllers/MemberController.js +++ b/src/controllers/MemberController.js @@ -110,7 +110,7 @@ async function confirmProfileData (req, res) { async function downloadProfile (req, res) { const pdfStream = await service.downloadProfile(req.authUser, req.params.handle) res.setHeader('Content-Type', 'application/pdf') - res.setHeader('Content-Disposition', `attachment; filename="profile-${req.params.handle}.pdf"`) + res.setHeader('Content-Disposition', `attachment; filename="topcoder-profile-${req.params.handle}.pdf"`) pdfStream.pipe(res) } diff --git a/src/fonts/arial/ARIAL.TTF b/src/fonts/arial/ARIAL.TTF new file mode 100644 index 0000000..8682d94 Binary files /dev/null and b/src/fonts/arial/ARIAL.TTF differ diff --git a/src/fonts/arial/ARIALBD 1.TTF b/src/fonts/arial/ARIALBD 1.TTF new file mode 100644 index 0000000..a6037e6 Binary files /dev/null and b/src/fonts/arial/ARIALBD 1.TTF differ diff --git a/src/fonts/arial/ARIALBD.TTF b/src/fonts/arial/ARIALBD.TTF new file mode 100644 index 0000000..a6037e6 Binary files /dev/null and b/src/fonts/arial/ARIALBD.TTF differ diff --git a/src/fonts/arial/ARIALBI 1.TTF b/src/fonts/arial/ARIALBI 1.TTF new file mode 100644 index 0000000..6a1fa0f Binary files /dev/null and b/src/fonts/arial/ARIALBI 1.TTF differ diff --git a/src/fonts/arial/ARIALBI.TTF b/src/fonts/arial/ARIALBI.TTF new file mode 100644 index 0000000..6a1fa0f Binary files /dev/null and b/src/fonts/arial/ARIALBI.TTF differ diff --git a/src/fonts/arial/ARIALBLACKITALIC.TTF b/src/fonts/arial/ARIALBLACKITALIC.TTF new file mode 100644 index 0000000..c5771b4 Binary files /dev/null and b/src/fonts/arial/ARIALBLACKITALIC.TTF differ diff --git a/src/fonts/arial/ARIALI 1.TTF b/src/fonts/arial/ARIALI 1.TTF new file mode 100644 index 0000000..3801997 Binary files /dev/null and b/src/fonts/arial/ARIALI 1.TTF differ diff --git a/src/fonts/arial/ARIALI.TTF b/src/fonts/arial/ARIALI.TTF new file mode 100644 index 0000000..3801997 Binary files /dev/null and b/src/fonts/arial/ARIALI.TTF differ diff --git a/src/fonts/arial/ARIALLGT.TTF b/src/fonts/arial/ARIALLGT.TTF new file mode 100644 index 0000000..c9ad85c Binary files /dev/null and b/src/fonts/arial/ARIALLGT.TTF differ diff --git a/src/fonts/arial/ARIALLGTITL.TTF b/src/fonts/arial/ARIALLGTITL.TTF new file mode 100644 index 0000000..9f40ed7 Binary files /dev/null and b/src/fonts/arial/ARIALLGTITL.TTF differ diff --git a/src/fonts/arial/ARIALN.TTF b/src/fonts/arial/ARIALN.TTF new file mode 100644 index 0000000..94907a3 Binary files /dev/null and b/src/fonts/arial/ARIALN.TTF differ diff --git a/src/fonts/arial/ARIALNB.TTF b/src/fonts/arial/ARIALNB.TTF new file mode 100644 index 0000000..62437f0 Binary files /dev/null and b/src/fonts/arial/ARIALNB.TTF differ diff --git a/src/fonts/arial/ARIALNBI.TTF b/src/fonts/arial/ARIALNBI.TTF new file mode 100644 index 0000000..d3f019a Binary files /dev/null and b/src/fonts/arial/ARIALNBI.TTF differ diff --git a/src/fonts/arial/ARIALNI.TTF b/src/fonts/arial/ARIALNI.TTF new file mode 100644 index 0000000..4acd468 Binary files /dev/null and b/src/fonts/arial/ARIALNI.TTF differ diff --git a/src/fonts/arial/ARIBLK.TTF b/src/fonts/arial/ARIBLK.TTF new file mode 100644 index 0000000..e7ae345 Binary files /dev/null and b/src/fonts/arial/ARIBLK.TTF differ diff --git a/src/fonts/arial/ArialCE.ttf b/src/fonts/arial/ArialCE.ttf new file mode 100644 index 0000000..5fad610 Binary files /dev/null and b/src/fonts/arial/ArialCE.ttf differ diff --git a/src/fonts/arial/ArialCEBoldItalic.ttf b/src/fonts/arial/ArialCEBoldItalic.ttf new file mode 100644 index 0000000..5f8059e Binary files /dev/null and b/src/fonts/arial/ArialCEBoldItalic.ttf differ diff --git a/src/fonts/arial/ArialCEItalic.ttf b/src/fonts/arial/ArialCEItalic.ttf new file mode 100644 index 0000000..cb70cc2 Binary files /dev/null and b/src/fonts/arial/ArialCEItalic.ttf differ diff --git a/src/fonts/arial/ArialCEMTBlack.ttf b/src/fonts/arial/ArialCEMTBlack.ttf new file mode 100644 index 0000000..1451126 Binary files /dev/null and b/src/fonts/arial/ArialCEMTBlack.ttf differ diff --git a/src/fonts/arial/ArialMdm.ttf b/src/fonts/arial/ArialMdm.ttf new file mode 100644 index 0000000..3222b81 Binary files /dev/null and b/src/fonts/arial/ArialMdm.ttf differ diff --git a/src/fonts/arial/ArialMdmItl.ttf b/src/fonts/arial/ArialMdmItl.ttf new file mode 100644 index 0000000..97a97d1 Binary files /dev/null and b/src/fonts/arial/ArialMdmItl.ttf differ diff --git a/src/fonts/arial/arialceb.ttf b/src/fonts/arial/arialceb.ttf new file mode 100644 index 0000000..2fe8f00 Binary files /dev/null and b/src/fonts/arial/arialceb.ttf differ diff --git a/src/images/topcoder-logo.png b/src/images/topcoder-logo.png new file mode 100644 index 0000000..3f8982a Binary files /dev/null and b/src/images/topcoder-logo.png differ diff --git a/src/services/MemberService.js b/src/services/MemberService.js index 1473695..2f73b99 100644 --- a/src/services/MemberService.js +++ b/src/services/MemberService.js @@ -19,6 +19,7 @@ const fileType = require('file-type') const fileTypeChecker = require('file-type-checker') const sharp = require('sharp') const { bufferContainsScript } = require('../common/image') +const { htmlToText } = require('../common/htmlUtils') const prismaHelper = require('../common/prismaHelper') const prismaManager = require('../common/prisma') const { Prisma } = prismaManager @@ -31,6 +32,9 @@ const academyPrisma = prismaManager.getAcademyClient() const resourcesPrisma = prismaManager.getResourcesClient() const engagementsPrisma = prismaManager.getEngagementsClient() const profilePDFService = require('./ProfilePDFService') +const request = require('request') +const cityTimezones = require('city-timezones') +const moment = require('moment-timezone') const MEMBER_FIELDS = ['userId', 'handle', 'handleLower', 'firstName', 'lastName', 'tracks', 'status', 'addresses', 'description', 'email', 'country', 'homeCountryCode', 'competitionCountryCode', 'photoURL', 'verified', 'maxRating', @@ -1163,6 +1167,446 @@ confirmProfileData.schema = { handle: Joi.string().required() } +/** + * Fetch gamification achievements for a member + * @param {Number} userId the member userId + * @returns {Promise} formatted achievements string + */ +async function fetchGamificationAchievements (userId) { + try { + if (!config.GAMIFICATION_API_URL) { + logger.warn(`GAMIFICATION_API_URL is not configured for user ${userId}`) + return '' + } + let token + try { + token = await helper.getM2MToken() + } catch (tokenError) { + logger.warn(`Cannot get M2M token for gamification API for user ${userId}: ${tokenError.message}. Achievements will be empty.`) + return '' + } + + if (!token) { + logger.warn(`M2M token is null/undefined for gamification API for user ${userId}`) + return '' + } + + const gamificationUrl = `${config.GAMIFICATION_API_URL}/badges/assigned/${userId}` + + if (!gamificationUrl || typeof gamificationUrl !== 'string' || !userId) { + logger.error(`Invalid gamification URL for user ${userId}: gamificationUrl=${gamificationUrl}, userId=${userId}`) + return '' + } + + const finalGamificationUrl = String(gamificationUrl || '').trim() + if (!finalGamificationUrl || finalGamificationUrl === 'undefined' || finalGamificationUrl.includes('undefined') || finalGamificationUrl.length === 0) { + logger.error(`Invalid final gamification URL for user ${userId}: finalUrl="${finalGamificationUrl}", baseUrl="${gamificationApiUrl}", userId=${userId}`) + return '' + } + + return new Promise((resolve, reject) => { + try { + request({ + url: finalGamificationUrl, + headers: { + Authorization: `Bearer ${token}` + } + }, (error, response, body) => { + if (error) { + logger.warn(`Failed to fetch gamification achievements for user ${userId}: ${error.message}`) + resolve('') + return + } + if (response.statusCode !== 200) { + logger.warn(`Gamification API returned status ${response.statusCode} for user ${userId}`) + resolve('') + return + } + try { + const data = JSON.parse(body) + // Format achievements: count multiples and join with " | " + // Response structure: { rows: [...], count: ... } + const achievementMap = {} + const badges = data.rows || [] + + logger.debug(`Gamification API response for user ${userId}: rows count=${badges.length}, hasRows=${!!data.rows}`) + + badges.forEach(badge => { + const orgBadge = badge.org_badge + if (orgBadge && orgBadge.badge_name) { + // Check if badge is active - handle both boolean and string values + const isActive = orgBadge.active === true || orgBadge.active === 'true' || String(orgBadge.active).toLowerCase() === 'true' + // Check status - case insensitive + const isActiveStatus = orgBadge.badge_status && String(orgBadge.badge_status).toLowerCase() === 'active' + + logger.debug(`Badge: ${orgBadge.badge_name}, active=${orgBadge.active} (${typeof orgBadge.active}), status=${orgBadge.badge_status}, isActive=${isActive}, isActiveStatus=${isActiveStatus}`) + + if (isActive && isActiveStatus) { + const name = htmlToText(orgBadge.badge_name) + achievementMap[name] = (achievementMap[name] || 0) + 1 + } else { + logger.debug(`Badge ${orgBadge.badge_name} filtered out: isActive=${isActive}, isActiveStatus=${isActiveStatus}`) + } + } else { + logger.debug(`Badge missing org_badge or badge_name:`, { hasOrgBadge: !!badge.org_badge, hasBadgeName: !!(badge.org_badge && badge.org_badge.badge_name) }) + } + }) + + logger.debug(`Achievement map for user ${userId}:`, achievementMap) + + const achievements = Object.entries(achievementMap) + .map(([name, count]) => count > 1 ? `${count}x ${name}` : name) + .join(' | ') + + logger.debug(`Final achievements string for user ${userId}: "${achievements}"`) + resolve(achievements) + } catch (parseError) { + logger.warn(`Failed to parse gamification response for user ${userId}: ${parseError.message}, body: ${body?.substring(0, 200)}`) + resolve('') + } + }) + } catch (requestError) { + logger.error(`Error creating gamification request for user ${userId}: ${requestError.message}`) + resolve('') + } + }) + } catch (error) { + logger.warn(`Error fetching gamification achievements for user ${userId}: ${error.message}`) + return '' + } +} + +/** + * Fetch completed certifications and courses from learning-paths-api + * @param {Number} userId the member userId + * @returns {Promise} object with certifications and courses arrays + */ +async function fetchCertificationsAndCourses (userId) { + try { + if (!config.LEARNING_PATHS_API_URL) { + logger.warn(`LEARNING_PATHS_API_URL is not configured for user ${userId}`) + return { certifications: [], courses: [] } + } + let token + try { + token = await helper.getM2MToken() + } catch (tokenError) { + logger.warn(`Cannot get M2M token for user ${userId}: ${tokenError.message}. Certifications and courses will be empty.`) + return { certifications: [], courses: [] } + } + + if (!token) { + logger.warn(`M2M token is null/undefined for user ${userId}`) + return { certifications: [], courses: [] } + } + + const learningPathsUrl = `${config.LEARNING_PATHS_API_URL}/completed-certifications/${userId}` + + if (!learningPathsUrl || typeof learningPathsUrl !== 'string' || !userId) { + logger.error(`Invalid learning-paths URL for user ${userId}: learningPathsUrl=${learningPathsUrl}, userId=${userId}`) + return { certifications: [], courses: [] } + } + + // Double-check URL is valid before making request + const finalUrl = String(learningPathsUrl || '').trim() + if (!finalUrl || finalUrl === 'undefined' || finalUrl.includes('undefined') || finalUrl.length === 0) { + logger.error(`Invalid final URL constructed for user ${userId}: finalUrl="${finalUrl}", baseUrl="${learningPathsApiUrl}", userId=${userId}`) + return { certifications: [], courses: [] } + } + + return new Promise((resolve, reject) => { + try { + request({ + url: finalUrl, + headers: { + Authorization: `Bearer ${token}` + } + }, (error, response, body) => { + if (error) { + logger.warn(`Failed to fetch certifications for user ${userId}: ${error.message}`) + resolve({ certifications: [], courses: [] }) + return + } + if (response.statusCode !== 200) { + logger.warn(`Learning-paths API returned status ${response.statusCode} for user ${userId}`) + resolve({ certifications: [], courses: [] }) + return + } + try { + const data = JSON.parse(body) + + // Process certifications + const certifications = (data.enrollments || []) + .filter(e => e.status === 'completed' && e.topcoderCertification) + .map(e => `${e.topcoderCertification.title} - Topcoder Academy`) + + // Process courses + const courses = (data.courses || []) + .map(c => { + const title = c.certificationTitle || c.certification || 'Course' + return `${title} - Topcoder Academy` + }) + + resolve({ certifications, courses }) + } catch (parseError) { + logger.warn(`Failed to parse learning-paths response for user ${userId}: ${parseError.message}`) + resolve({ certifications: [], courses: [] }) + } + }) + } catch (requestError) { + logger.error(`Error creating request for user ${userId}: ${requestError.message}`) + resolve({ certifications: [], courses: [] }) + } + }) + } catch (error) { + logger.warn(`Error fetching certifications for user ${userId}: ${error.message}`) + return { certifications: [], courses: [] } + } +} + +/** + * Get member timezone based on city + * @param {Object} memberData the member data + * @returns {String|null} timezone abbreviation or null if not found + */ +function getMemberTimezone (memberData) { + const city = memberData.addresses?.[0]?.city + if (!city) return null + + const cityTimezoneData = cityTimezones.lookupViaCity(city) + let memberTimezone = null + + if (cityTimezoneData?.length) { + memberTimezone = cityTimezoneData[0].timezone + } + + // Validate timezone exists + if (memberTimezone && moment.tz.zone(memberTimezone)) { + // Get abbreviation for display + return moment.tz(new Date(), memberTimezone).zoneAbbr() + } + + return null +} + +/** + * Get skill names by their IDs + * @param {Array} skillIds array of skill UUIDs + * @returns {Promise} map of skillId -> skillName + */ +async function getSkillNamesByIds (skillIds) { + if (!skillIds || skillIds.length === 0) { + return {} + } + + const skills = await skillsPrisma.skill.findMany({ + where: { id: { in: skillIds } }, + select: { id: true, name: true } + }) + + const skillMap = {} + skills.forEach(skill => { + skillMap[skill.id] = skill.name + }) + + return skillMap +} + +/** + * Get member roles from identity database (role_assignment + role tables) + * @param {Number} userId the member userId + * @returns {Promise} array of role names + */ +async function getMemberRoles (userId) { + try { + if (!config.IDENTITY_DB_URL) { + logger.warn('IDENTITY_DB_URL is not configured; cannot fetch member roles') + return [] + } + const identityPrisma = identityPrismaManager.getIdentityClient() + const assignments = await identityPrisma.roleAssignment.findMany({ + where: { subjectId: Number(userId), subjectType: 1 }, + include: { role: true } + }) + return (assignments || []) + .filter(a => a.role && a.role.name) + .map(a => a.role.name) + } catch (err) { + logger.warn(`Failed to fetch roles for user ${userId}: ${err.message}`) + return [] + } +} + +/** + * Aggregate all data needed for PDF generation + * @param {Object} currentUser the user who performs operation + * @param {String} handle the member handle + * @returns {Promise} aggregated PDF data + */ +async function aggregatePDFData (currentUser, handle) { + // Get base member data + const memberData = await getMember(currentUser, handle, {}) + const userId = helper.bigIntToNumber(memberData.userId) + + // Fetch traits (work, education, languages, basicInfo, personalization) + const traits = await memberTraitService.getTraits(currentUser, handle, {}) + const workTraits = traits.find(t => t.traitId === 'work')?.traits?.data || [] + const educationTraits = traits.find(t => t.traitId === 'education')?.traits?.data || [] + const languageTraits = traits.find(t => t.traitId === 'languages')?.traits?.data || [] + const basicInfoTraits = traits.find(t => t.traitId === 'basic_info')?.traits?.data || [] + + // Collect all skill GUIDs from work experiences for batch lookup + const allSkillIds = [] + workTraits.forEach(work => { + if (work.associatedSkills && work.associatedSkills.length > 0) { + allSkillIds.push(...work.associatedSkills) + } + }) + + // Batch lookup all skill names + const skillNameMap = await getSkillNamesByIds([...new Set(allSkillIds)]) + + // Extract personalization trait to get shortBio (profileSelfTitle) + const personalizationTrait = traits.find(t => t.traitId === 'personalization') + const personalizationData = personalizationTrait?.traits?.data?.[0] || {} + const shortBio = personalizationData.profileSelfTitle || null + + // Fetch skills from standardized-skills-api + const skills = await getMemberSkills(memberData.userId) + + // Separate skills by display mode and verification status + const principalSkills = { verified: [], notVerified: [] } + const additionalSkills = { verified: [], notVerified: [] } + + skills.forEach(skill => { + const isPrincipal = skill.displayMode?.name === 'principal' + const isVerified = skill.levels?.some(level => level.name === 'verified') + const skillName = skill.name + + if (isPrincipal) { + if (isVerified) { + principalSkills.verified.push(skillName) + } else { + principalSkills.notVerified.push(skillName) + } + } else { + if (isVerified) { + additionalSkills.verified.push(skillName) + } else { + additionalSkills.notVerified.push(skillName) + } + } + }) + + const specialRoles = [] + const roleMap = { + 'copilot': 'Copilot', + 'administrator': 'Administrator', + 'Talent Manager': 'Talent Manager', + 'Gamification Admin': 'Gamification Admin', + 'Self-Service Customer': 'Self-Service Customer', + 'Topcoder User': 'Topcoder User', + 'TCA Admin': 'TCA Admin', + 'Payment Admin': 'Payment Admin', + 'Payment Viewer': 'Payment Viewer', + 'PaymentProvider Admin': 'PaymentProvider Admin', + 'PaymentProvider Viewer': 'PaymentProvider Viewer', + 'TaxForm Admin': 'TaxForm Admin', + 'TaxForm Viewer': 'TaxForm Viewer', + 'Topcoder Staff': 'Topcoder Staff', + 'Project Manager': 'Project Manager', + 'Connect Manager': 'Connect Manager', + } + + const currentUserId = currentUser && (currentUser.userId || currentUser.sub) + const isSelf = currentUserId && String(currentUserId) === String(userId) + + if (currentUser && (isSelf || helper.hasAdminRole(currentUser))) { + const memberRoles = await getMemberRoles(userId) + memberRoles.forEach(role => { + const roleName = roleMap[role.toLowerCase()] + if (roleName && !specialRoles.includes(roleName)) { + specialRoles.push(roleName) + } + }) + } + + // Fetch gamification achievements + const achievements = await fetchGamificationAchievements(userId) + + // Fetch certifications and courses + const { certifications, courses } = await fetchCertificationsAndCourses(userId) + + // Build status bar text + const statusBarItems = [] + if (memberData.status === 'ACTIVE') { + statusBarItems.push('ACTIVE') + } + if (memberData.availableForGigs === true) { + statusBarItems.push('OPEN TO WORK') + } + const statusBarText = statusBarItems.join(' • ') + + // Format dates + const formatDate = (date) => { + if (!date) return null + const d = new Date(date) + const month = String(d.getMonth() + 1).padStart(2, '0') + const year = d.getFullYear() + return `${month}/${year}` + } + + // Get member timezone + const timezone = getMemberTimezone(memberData) + + return { + // Member basic info + member: { + ...memberData, + statusBarText, + generatedOn: new Date().toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' }), + timezone: timezone + }, + // Work experience + workExperience: workTraits.map(work => ({ + position: work.position, + company: work.companyName, + startDate: formatDate(work.startDate), + endDate: formatDate(work.endDate), + description: work.description, + skills: (work.associatedSkills || []) + .map(skillId => skillNameMap[skillId]) + .filter(Boolean) // Remove any undefined (GUIDs without names) + })), + // Education + education: educationTraits.map(edu => ({ + degree: edu.degree, + college: edu.collegeName, + endYear: edu.endYear ? String(edu.endYear) : null + })), + // Languages + languages: languageTraits.map(lang => lang.language).filter(Boolean), + // Basic info (including shortBio from personalization) + basicInfo: { + ...(basicInfoTraits[0] || {}), + shortBio: shortBio + }, + // Skills + skills: { + principal: principalSkills, + additional: additionalSkills + }, + // Topcoder activity + topcoderActivity: { + specialRole: specialRoles.length > 0 ? `Topcoder Special Role: ${specialRoles.join(', ')}` : null, + achievements: achievements + }, + // Certifications and courses + certifications, + courses + } +} + /** * Download member profile as PDF * @param {Object} currentUser the user who performs operation @@ -1178,11 +1622,11 @@ async function downloadProfile (currentUser, handle) { throw new errors.ForbiddenError('You are not allowed to download this member profile.') } - // Fetch full member data for PDF - const memberData = await getMember(currentUser, handle, {}) + // Aggregate all PDF data + const pdfData = await aggregatePDFData(currentUser, handle) // Generate PDF stream - const pdfStream = await profilePDFService.generatePDF(memberData) + const pdfStream = await profilePDFService.generatePDF(pdfData) return pdfStream } diff --git a/src/services/ProfilePDFService.js b/src/services/ProfilePDFService.js index eba9e59..142b5e6 100644 --- a/src/services/ProfilePDFService.js +++ b/src/services/ProfilePDFService.js @@ -2,8 +2,24 @@ * Service for generating member profile PDFs */ const ReactPDF = require('@react-pdf/renderer') +const { Font } = require('@react-pdf/renderer') +const path = require('path') const { buildProfileTemplate } = require('../common/profileTemplate') +// Register Arial fonts if not already registered +const registeredFamilies = Font.getRegisteredFontFamilies() +if (!registeredFamilies.includes('Arial')) { + Font.register({ + family: 'Arial', + fonts: [ + { src: path.join(__dirname, '../fonts/arial/ARIAL.TTF') }, + { src: path.join(__dirname, '../fonts/arial/ARIALBD.TTF'), fontWeight: 'bold' }, + { src: path.join(__dirname, '../fonts/arial/ARIALI.TTF'), fontStyle: 'italic' }, + { src: path.join(__dirname, '../fonts/arial/ARIALBI.TTF'), fontWeight: 'bold', fontStyle: 'italic' } + ] + }) +} + /** * Generate PDF stream for member profile * @param {Object} memberData the member profile data