From b69b341bb9d9370f53d39a2a0f37dccd2d75d631 Mon Sep 17 00:00:00 2001 From: whereishammer Date: Sun, 13 Jul 2025 20:37:44 +0800 Subject: [PATCH 1/2] Update Code with Postgres Schema Part 2 Final Fix --- .gitignore | 44 + ReadMe.md | 9 +- Verification.md | 1 + app-constants.js | 2 +- config/default.js | 16 +- docs/swagger.yaml | 2468 +++++++++++++++++ mock/mock-api.js | 35 +- .../migration.sql | 6 - prisma/schema.prisma | 2 - src/common/LookerApi.js | 108 - src/common/LookerAuth.js | 83 - src/common/helper.js | 253 +- src/common/image.js | 10 +- src/common/logger.js | 4 +- src/common/prismaHelper.js | 342 +++ src/controllers/HealthController.js | 2 +- src/controllers/SearchController.js | 43 + src/controllers/StatisticsController.js | 73 + src/routes.js | 74 +- src/scripts/clear-tables.js | 96 +- src/scripts/config.js | 13 +- src/scripts/download.js | 208 +- src/scripts/member-jwt.js | 76 +- src/scripts/seed-data.js | 448 ++- src/services/MemberService.js | 206 +- src/services/MemberTraitService.js | 85 +- src/services/SearchService.js | 467 ++++ src/services/StatisticsService.js | 397 +++ test/testHelper.js | 8 +- test/unit/MemberService.test.js | 9 +- test/unit/MemberTraitService.test.js | 8 +- 31 files changed, 4867 insertions(+), 729 deletions(-) create mode 100644 .gitignore create mode 100644 docs/swagger.yaml rename prisma/migrations/{20250620091128_init => 20250701115244_init}/migration.sql (99%) delete mode 100644 src/common/LookerApi.js delete mode 100644 src/common/LookerAuth.js create mode 100644 src/common/prismaHelper.js create mode 100644 src/controllers/SearchController.js create mode 100644 src/controllers/StatisticsController.js create mode 100644 src/services/SearchService.js create mode 100644 src/services/StatisticsService.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0634fc4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,44 @@ +# can be used locally to config some env variables and after apply them using `source .env` +.env +### Node ### +# Logs +logs +*.log +npm-debug.log* +.DS_Store +.tern-port +*# + +dist/ + +# Runtime data +pids +*.pid +*.seed + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage + +# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (http://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules +jspm_packages + +# Optional npm cache directory +.npm + + +.DS_Store +.idea +.vscode/ diff --git a/ReadMe.md b/ReadMe.md index 1fc8581..0952b37 100644 --- a/ReadMe.md +++ b/ReadMe.md @@ -138,6 +138,8 @@ To make local development easier, I create a mock server at `mock`. You can start it with `node mock/mock-api.js` and it will listen to port `4000` +This mock service will simulate request and responses for other APIs like auth0 and event bus API. + ## Local Configs Please run following commands to set necessary configs: @@ -147,13 +149,11 @@ export AUTH0_URL="http://localhost:4000/v5/auth0" export BUSAPI_URL="http://localhost:4000/v5" export AUTH0_CLIENT_ID=xyz export AUTH0_CLIENT_SECRET=xyz -export LOOKER_API_BASE_URL="http://localhost:4000/v5/looker" -export LOOKER_API_CLIENT_ID=xyz -export LOOKER_API_CLIENT_SECRET=xyz export USERFLOW_PRIVATE_KEY=mysecret +export GROUPS_API_URL="http://localhost:4000/v5/groups" ``` -These commands will set auth0, event bus pi and looker api to local mock server. +These commands will set auth0 and event bus api to local mock server. ## Local Deployment @@ -172,7 +172,6 @@ Make sure you have followed above steps to - setup local mock api and set local configs - it will really call service and mock api -Unit tests use `aws-sdk-mock` to mock S3 operations. So you can safely run tests without S3 configs. Then you can run: ```bash diff --git a/Verification.md b/Verification.md index d1c2a19..472302c 100644 --- a/Verification.md +++ b/Verification.md @@ -54,6 +54,7 @@ Just be careful about the schemas used for different kind of trait. There are some changes to prisma schema. - Add memberTraits.hobby +- Update memberSkill.displayMode to optional - Remove displayMode.memberSkills @ignore - Add stats fields as discussed in forum diff --git a/app-constants.js b/app-constants.js index ebea51e..5ad131e 100644 --- a/app-constants.js +++ b/app-constants.js @@ -2,7 +2,7 @@ * App constants */ const ADMIN_ROLES = ['administrator', 'admin'] -const SEARCH_BY_EMAIL_ROLES = ADMIN_ROLES.concat('tgadmin'); +const SEARCH_BY_EMAIL_ROLES = ADMIN_ROLES.concat('tgadmin') const AUTOCOMPLETE_ROLES = ['copilot', 'administrator', 'admin', 'Connect Copilot', 'Connect Account Manager', 'Connect Admin', 'Account Executive'] const EVENT_ORIGINATOR = 'topcoder-member-api' diff --git a/config/default.js b/config/default.js index 73e1bc0..d4ea797 100644 --- a/config/default.js +++ b/config/default.js @@ -70,7 +70,6 @@ module.exports = { } }, - // Member identifiable info fields, copilots, admins, or M2M can get these fields // Anyone in the constants.AUTOCOMPLETE_ROLES will have access to these fields COMMUNICATION_SECURE_FIELDS: process.env.COMMUNICATION_SECURE_FIELDS @@ -120,20 +119,7 @@ module.exports = { MAMBO_DOMAIN_URL: process.env.MAMBO_DOMAIN_URL, MAMBO_DEFAULT_SITE: process.env.MAMBO_DEFAULT_SITE, - // Looker API access config - LOOKER: { - API_BASE_URL: process.env.LOOKER_API_BASE_URL, - API_CLIENT_ID: process.env.LOOKER_API_CLIENT_ID, - API_CLIENT_SECRET: process.env.LOOKER_API_CLIENT_SECRET, - EMBED_KEY: process.env.LOOKER_EMBED_KEY, - HOST: process.env.LOOKER_HOST, - SESSION_LENGTH: 1800, - TOKEN: process.env.LOOKER_API_TOKEN || 'TOKEN', - //24 hours, in milliseconds - CACHE_DURATION: 1000 * 60 * 60 * 24 - }, - HASHING_KEYS: { - USERFLOW: process.env.USERFLOW_PRIVATE_KEY, + USERFLOW: process.env.USERFLOW_PRIVATE_KEY } } diff --git a/docs/swagger.yaml b/docs/swagger.yaml new file mode 100644 index 0000000..5fe2d52 --- /dev/null +++ b/docs/swagger.yaml @@ -0,0 +1,2468 @@ +swagger: '2.0' +info: + version: V5 + title: Topcoder Member API + description: > + Services that provide access and interaction with all sorts of member profile details. + + + # Pagination + + + - Requests that return multiple items will be paginated to 20 items by + default. + + + - You can specify further pages with the `page` parameter (1 based). + + + - You can also set a custom page size up to 100 with the `perPage` + parameter. + + + - Pagination response data is included in http headers. + + + - By default, the response header contains links with `next`, `last`, + `first`, `prev` resource links. + + # Roles + + Each API endpoint has described minimal role to access. Only users with + specific roles can access those endpoints (where required). For insufficient + role 403 Forbidden HTTP response will be returned. + + + ## User roles: + + + `administrator` - administrator + + `admin` - administrator + + `connect manager` - connect manager + + `connect admin` - connect administrator + + `copilot` - copilot + + `client manager` - client manager + + `connect account manager` - connect account manager + + `connect copilot manager` - connect copilot manager + + # Personally Identifiable Information + + All endpoints that return member information must filter the response data + to exclude the personally identifiable information (properties from a + configurable array) unless the caller is a machine (M2M token), has + administrative privileges or is the member himself. + + # Secure Member Fields + + ## Member Secure Fields + + Member identifiable info fields, only admin, M2M, or member himself can get these fields - `addresses`, `createdBy`, `updatedBy` + + ## Member Communication Fields + + Member fields used for communication are accessible by managers, copilots, and admins. These include: `firstName`, `lastName`, `email` + + ## Member Traits Secure Fields + + Member traits identifiable info fields, only admin, M2M, or member himself can fetch these fields - `createdBy`, `updatedBy` + + ## Search Secure Fields + + Member search identifiable info fields, only admin, M2M, or member himself can get these fields - `firstName`, `lastName`, `email`, `addresses`, `createdBy`, `updatedBy` + + ## Statistics Secure Fields + + Member statistics identifiable info fields, only admin, M2M, or member himself can fetch these fields - `createdBy`,`updatedBy` + + ## Miscellaneous Secure Fields + + Miscellaneous identifiable info fields, only admin, M2M, or member himself can fetch these fields - `createdBy`,`updatedBy` + + + # Status codes: + + The HTTP status codes to communicate with the API consumer. + + + `200 OK` - Response to a successful GET, PUT, PATCH or DELETE. + + + `201 Created` - When a resource is successfully created with POST. + + + `400 Bad Request` - Malformed request; request body validation errors. + + + `401 Unauthorized` - When no or invalid authentication details are + provided. + + + `403 Forbidden` - User authenticated, but doesn't have access to the + resource. + + + `404 Not Found` - When a non-existent resource is requested. + + + `500 Server Error` - Something went wrong on the API end. + + + # Authorization: + + Authorization is when an entity proves a right to access. + + ## HTTP Authentication Schemes + + Bearer Authentication (also called token authentication) is an HTTP authentication scheme that involves security tokens called bearer tokens. We will be using Auth0 tokens that could be JWT or M2M + + + The client must send this token in the Authorization header when making requests to protected resources: + + + `Authorization: Bearer ` + + + ## Authorization via M2M tokens + + There are various areas in topcoder or third parties that need to communicate with each other like: service to service, daemon to backend, CLI client to internal service, IoT tools, for such scenarios we use machine to machine. + + + These M2M tokens are from Auth0, and based on scopes grant access to our functionality. Here are few scopes that should be adhered to: `create:user_profiles`, `read:user_profiles`, `update:user_profiles`, `delete:user_profiles`, `all:user_profiles` + + + ## Authorization via JWT tokens + + User based security tokens should be supported. Logic filtering of data should be achieved via the user based token and role. At least support for role admin vs user should be designed. + + + ## Important Note: + + + Tokens can be passed via headers.authorization / query.authToken, recommended way is to pass via headers.authorization. + + + While passing Authorize bearer token make sure to add `Bearer ` + license: + name: MIT + url: 'http://github.com/gruntjs/grunt/blob/master/LICENSE-MIT' +host: api.topcoder.com +basePath: /v5 +schemes: + - https + - http +securityDefinitions: + bearer: + type: apiKey + name: Authorization + in: header +produces: + - application/json +consumes: + - application/json +paths: + '/members/health': + get: + tags: + - Health + description: Get member distribution statistics + responses: + '200': + description: OK + schema: + $ref: '#/definitions/Health' + '400': + description: Bad request data + schema: + $ref: '#/definitions/ErrorModel' + '404': + description: Not found + schema: + $ref: '#/definitions/ErrorModel' + '500': + description: Internal server error + schema: + $ref: '#/definitions/ErrorModel' + '/members/{handle}': + get: + tags: + - Basic + description: + Get member profile with a specified member handle. You may filter on member response fields. + + + The authentication credential is optional, will allow secured fields in the response. + security: + - bearer: [] + parameters: + - in: path + name: handle + required: true + type: string + - name: fields + in: query + required: false + type: string + description: > + fields=fieldName1,fieldName2,...,fieldN + + + parameter for choosing which fields of Member profile that will be included in response. By default, all will be returned. + + + The following fields are allowed + + + userId - Select the field + + + handle - Select the field handle + + + handleLower - Select the field handleLower + + + firstName - Select the field firstName + + + lastName - Select the field lastName + + + tracks - Select the field tracks + + + status - Select the field status + + + addresses - Select the field addresses + + + description - Select the field description + + + email - Select the field email + + + homeCountryCode - Select the field homeCountryCode + + + competitionCountryCode - Select the field competitionCountryCode + + + photoURL - Select the field photoURL + + + maxRating - Select the field maxRating + + + createdAt - Select the field createdAt + + + createdBy - Select the field createdBy + + + updatedAt - Select the field updatedAt + + + updatedBy - Select the field updatedBy + + + verified - Select the field verified + + The following fields are secured, without a valid token will not be in response + + + firstName + + + lastName + + + addresses + + + email + + + createdBy + + + updatedBy + + responses: + '200': + description: OK + schema: + $ref: '#/definitions/MemberProfile' + '400': + description: Bad request data + schema: + $ref: '#/definitions/ErrorModel' + '401': + description: Miss or wrong authentication credentials + schema: + $ref: '#/definitions/ErrorModel' + '403': + description: No permission + schema: + $ref: '#/definitions/ErrorModel' + '404': + description: Not found + schema: + $ref: '#/definitions/ErrorModel' + '500': + description: Internal server error + schema: + $ref: '#/definitions/ErrorModel' + put: + tags: + - Basic + description: + Update the member profile by handle. + + + If the email has been changed, email change process will start and a verification email will be sent to the new and old email address. + security: + - bearer: [] + parameters: + - in: path + name: handle + required: true + type: string + - in: query + name: verifyUrl + required: false + type: string + - in: body + name: body + required: true + schema: + $ref: '#/definitions/MemberProfile' + responses: + '200': + description: OK + schema: + $ref: '#/definitions/MemberProfile' + '400': + description: Bad request data + schema: + $ref: '#/definitions/ErrorModel' + '401': + description: Miss or wrong authentication credentials + schema: + $ref: '#/definitions/ErrorModel' + '403': + description: No permission + schema: + $ref: '#/definitions/ErrorModel' + '404': + description: Not found + schema: + $ref: '#/definitions/ErrorModel' + '500': + description: Internal server error + schema: + $ref: '#/definitions/ErrorModel' + '/members/{handle}/verify': + get: + tags: + - Basic + description: + Verify members new email address. + + + When the email has been changed, email change process will start and a verification email will be sent to the new and old email address. The link from the email will redirect member to this api. + + + Both the old and new email address need to verified by the member to sucessfully complete the process. + security: + - bearer: [] + parameters: + - in: path + name: handle + required: true + type: string + - in: query + name: token + type: string + required: true + description: the email verification token + responses: + '200': + description: OK + schema: + $ref: '#/definitions/EmailVerificationResult' + '400': + description: Bad request data + schema: + $ref: '#/definitions/ErrorModel' + '401': + description: Miss or wrong authentication credentials + schema: + $ref: '#/definitions/ErrorModel' + '403': + description: No permission + schema: + $ref: '#/definitions/ErrorModel' + '500': + description: Internal server error + schema: + $ref: '#/definitions/ErrorModel' + '/members/{handle}/photo': + post: + tags: + - Basic + description: Upload members photo. The service will save the file from the request into AWS S3 and will update the member profile to change the members photo URL. + security: + - bearer: [] + consumes: + - multipart/form-data + parameters: + - in: path + name: handle + required: true + type: string + - in: formData + name: photo + type: file + required: true + description: The image file to upload + responses: + '200': + description: OK, it returns the uploaded photo URL + schema: + $ref: '#/definitions/UploadPhotoResult' + '400': + description: Bad request data + schema: + $ref: '#/definitions/ErrorModel' + '401': + description: Miss or wrong authentication credentials + schema: + $ref: '#/definitions/ErrorModel' + '403': + description: No permission + schema: + $ref: '#/definitions/ErrorModel' + '404': + description: Not found + schema: + $ref: '#/definitions/ErrorModel' + '500': + description: Internal server error + schema: + $ref: '#/definitions/ErrorModel' + /members: + get: + tags: + - Search + description: + Search members by userId, userIds, handle, handles, handleLower, handlesLower and retrive member profile details with stats and skills. + + + While searching with handle a like match will be conducted where as handleLower will conduct and exact match from the data store + + + Authentication credential is optional. If API is called by Admin or M2M, then secured fields will be included otherwise excluded from the response. + security: + - bearer: [] + parameters: + - name: userId + in: query + required: false + type: string + description: filter by userId + - name: userIds + in: query + required: false + type: string + description: filter by userIds. Example - [userId1,userId2,userId3,...,userIdN] + - name: handle + in: query + required: false + type: string + description: filter by handle. This will return like search. + - name: handles + in: query + required: false + type: string + description: filter by handles. This will return like search. Example - ["handle1","handle2","handle3",...,"handleN"] + - name: handleLower + in: query + required: false + type: string + description: filter by handle. This will return an exact search. + - name: handlesLower + in: query + required: false + type: string + description: filter by handles. This will return an exact search. Example - ["handle1","handle2","handle3",...,"handleN"] + - name: sort + in: query + required: false + type: string + description: sort by asc or desc + - name: fields + in: query + required: false + type: string + description: > + fields=fieldName1,fieldName2,...,fieldN + + + parameter for choosing which fields of members profile that will be included in response. + + + userId - Select the field userId + + + handle - Select the field handle + + + handleLower - Select the field handleLower + + + firstName - Select the field firstName + + + lastName - Select the field lastName + + + status - Select the field status + + + addresses - Select the field addresses + + + photoURL - Select the field photoURL + + + homeCountryCode - Select the field homeCountryCode + + + competitionCountryCode - Select the field competitionCountryCode + + + description - Select the field description + + + email - Select the field email + + + tracks - Select the field tracks + + + maxRating - Select the field maxRating + + + wins - Select the field wins + + + createdAt - Select the field createdAt + + + createdBy - Select the field createdBy + + + updatedAt - Select the field updatedAt + + + updatedBy - Select the field updatedBy + + + skills - Select the field skills + + + stats - Select the field stats + + + The following fields are secured, without a valid token will not be in response + + + firstName + + + lastName + + + email + + + addresses + + + createdBy + + + updatedBy + - $ref: '#/parameters/page' + - $ref: '#/parameters/perPage' + responses: + '200': + description: OK + schema: + type: array + items: + $ref: '#/definitions/MemberSearchDataItem' + headers: + X-Next-Page: + type: integer + description: The index of the next page + X-Page: + type: integer + description: The index of the current page (starting at 1) + X-Per-Page: + type: integer + description: The number of items to list per page + X-Prev-Page: + type: integer + description: The index of the previous page + X-Total: + type: integer + description: The total number of items + X-Total-Pages: + type: integer + description: The total number of pages + Link: + type: string + description: Pagination link header. + '400': + description: Bad request data + schema: + $ref: '#/definitions/ErrorModel' + '403': + description: No permission to access the API + schema: + $ref: '#/definitions/ErrorModel' + '500': + description: Internal server error + schema: + $ref: '#/definitions/ErrorModel' + "/members/autocomplete": + get: + tags: + - Search + description: + Search members by term, the term is mactehd with handle. Based on which userId, handle, firstName, lastName of members profile are provided. + + This services is best used for autocomplete features, where response payload is light and fast in searching through members. + + The service does a like macth and sorts based on string coming before, or after, or is the same as the given string in sort order. + parameters: + - name: term + in: query + required: false + type: string + description: filter by term. term is mactehd with member handle. + - name: fields + in: query + required: false + type: string + description: > + fields=fieldName1,fieldName2,...,fieldN + + + parameter for choosing which fields of members profile that will be included in response. + + + userId - Select the field userId + + + handle - Select the field handle + + + firstName - Select the field firstName + + + lastName - Select the field lastName + - $ref: '#/parameters/page' + - $ref: '#/parameters/perPage' + responses: + '200': + description: OK + schema: + type: array + items: + $ref: '#/definitions/MemberSearchAutocompleteDataItem' + headers: + X-Next-Page: + type: integer + description: The index of the next page + X-Page: + type: integer + description: The index of the current page (starting at 1) + X-Per-Page: + type: integer + description: The number of items to list per page + X-Prev-Page: + type: integer + description: The index of the previous page + X-Total: + type: integer + description: The total number of items + X-Total-Pages: + type: integer + description: The total number of pages + Link: + type: string + description: Pagination link header. + '400': + description: Bad request data + schema: + $ref: '#/definitions/ErrorModel' + '403': + description: No permission to access the API + schema: + $ref: '#/definitions/ErrorModel' + '500': + description: Internal server error + schema: + $ref: '#/definitions/ErrorModel' + '/members/searchBySkills': + get: + tags: + - Search + description: + Search members by EMSI skill id(s) provided. This API is used for the talent search. By default, the results are sorted by the number of challenges won. + parameters: + - name: skillID + in: query + required: true + type: string + description: > + skillId=skillID1&skillId=skillID2 + EMSI skill id(s) to use when filtering members. Members who match all of the skill IDs provided will be returned. + - name: fields + in: query + required: false + type: string + description: > + fields=fieldName1,fieldName2,...,fieldN + + + parameter for choosing which fields of members profile that will be included in response. + + + userId - Select the field userId + + + handle - Select the field handle + + + firstName - Select the field firstName + + + lastName - Select the field lastName + - $ref: '#/parameters/page' + - $ref: '#/parameters/perPage' + - name: sortOrder + in: query + required: false + type: string + description: sort by asc or desc + - name: sortBy + in: query + required: false + type: string + description: Field to sort by. Options include 'userId', 'country', 'handle', 'firstName', 'lastName', 'numberOfChallengesWon', 'numberOfChallengesPlaced' + responses: + '200': + description: OK + schema: + type: array + items: + $ref: '#/definitions/MemberSearchDataItem' + headers: + X-Next-Page: + type: integer + description: The index of the next page + X-Page: + type: integer + description: The index of the current page (starting at 1) + X-Per-Page: + type: integer + description: The number of items to list per page + X-Prev-Page: + type: integer + description: The index of the previous page + X-Total: + type: integer + description: The total number of items + X-Total-Pages: + type: integer + description: The total number of pages + Link: + type: string + description: Pagination link header. + '400': + description: Bad request data + schema: + $ref: '#/definitions/ErrorModel' + '403': + description: No permission to access the API + schema: + $ref: '#/definitions/ErrorModel' + '500': + description: Internal server error + schema: + $ref: '#/definitions/ErrorModel' + '/members/{handle}/traits': + get: + tags: + - Traits + description: get member profile traits + security: + - bearer: [] + parameters: + - in: path + name: handle + required: true + type: string + - name: traitIds + in: query + required: false + type: string + description: | + traitIds value separated with comma + + basic_id + + work + + skill + + education + + communities + + languages + + hobby + + organization + + device + + software + + service_provider + + subscription + + personalization + + connect_info + + - name: fields + in: query + required: false + type: string + description: > + fields=fieldName1,fieldName2,...,fieldN + + + parameter for choosing which fields of Member Profile Trait that will be included in response. By default, all will be returned. + + + userId - Select the field userId + + + traitId - Select the field traitId + + + categoryName - Select the field categoryName + + + traits - Select the field traits + + + createdAt - Select the field createdAt + + + createdBy - Select the field createdBy + + + updatedAt - Select the field updatedAt + + + updatedBy - Select the field updatedBy + + + The following fields are secured, without a valid token will not be in response + + + createdBy + + + updatedBy + + responses: + '200': + description: OK + schema: + type: array + items: + $ref: '#/definitions/MemberProfileTrait' + '400': + description: Bad request data + schema: + $ref: '#/definitions/ErrorModel' + '403': + description: No permission + schema: + $ref: '#/definitions/ErrorModel' + '404': + description: Not found + schema: + $ref: '#/definitions/ErrorModel' + '500': + description: Internal server error + schema: + $ref: '#/definitions/ErrorModel' + post: + tags: + - Traits + description: create member profile traits + security: + - bearer: [] + parameters: + - in: path + name: handle + required: true + type: string + - in: body + name: body + required: true + schema: + type: array + items: + $ref: '#/definitions/MemberProfileTrait' + responses: + '200': + description: OK + schema: + type: array + items: + $ref: '#/definitions/MemberProfileTrait' + '400': + description: Bad request data + schema: + $ref: '#/definitions/ErrorModel' + '403': + description: No permission + schema: + $ref: '#/definitions/ErrorModel' + '404': + description: Not found + schema: + $ref: '#/definitions/ErrorModel' + '500': + description: Internal server error + schema: + $ref: '#/definitions/ErrorModel' + put: + tags: + - Traits + description: update member profile traits + security: + - bearer: [] + parameters: + - in: path + name: handle + required: true + type: string + - in: body + name: body + required: true + schema: + type: array + items: + $ref: '#/definitions/MemberProfileTrait' + responses: + '200': + description: OK + schema: + type: array + items: + $ref: '#/definitions/MemberProfileTrait' + '400': + description: Bad request data + schema: + $ref: '#/definitions/ErrorModel' + '403': + description: No permission + schema: + $ref: '#/definitions/ErrorModel' + '404': + description: Not found + schema: + $ref: '#/definitions/ErrorModel' + '500': + description: Internal server error + schema: + $ref: '#/definitions/ErrorModel' + delete: + tags: + - Traits + description: delete member profile traits + security: + - bearer: [] + parameters: + - in: path + name: handle + required: true + type: string + - name: traitIds + in: query + required: false + type: string + description: | + traitIds value separated with comma + + basic_id + + work + + skill + + education + + communities + + languages + + hobby + + organization + + device + + software + + service_provider + + subscription + + personalization + + connect_info + responses: + '200': + description: 'OK, response is empty' + '400': + description: Bad request data + schema: + $ref: '#/definitions/ErrorModel' + '403': + description: No permission + schema: + $ref: '#/definitions/ErrorModel' + '404': + description: Not found + schema: + $ref: '#/definitions/ErrorModel' + '500': + description: Internal server error + schema: + $ref: '#/definitions/ErrorModel' + '/members/{handle}/stats': + get: + tags: + - Statistics + description: get member stats + parameters: + - in: path + name: handle + required: true + type: string + - name: groupIds + in: query + required: false + type: string + description: comma separated group ids (returns public stats if group ids is not provided) + - name: fields + in: query + required: false + type: string + description: > + fields=fieldName1,fieldName2,...,fieldN + + parameter for choosing which fields of MemberStats that will be included in response. + + + + userId - Select the field userId + + + handle - Select the field handle + + + handleLower - Select the field handleLower + + + maxRating - Select the field maxRating + + + challenges - Select the field challenges + + + wins - Select the field wins + + + develop - Select the field develop + + + design - Select the field design + + + dataScience - Select the field dataScience + + + copilot - Select the field copilot + + + updatedAt - Select the field updatedAt + + + createdAt - Select the field createdAt + + + createdBy - Select the field createdBy + + + updatedBy - Select the field updatedBy + responses: + '200': + description: OK + schema: + $ref: '#/definitions/MemberStats' + '400': + description: Bad request data + schema: + $ref: '#/definitions/ErrorModel' + '403': + description: No permission to access the API + schema: + $ref: '#/definitions/ErrorModel' + '404': + description: Not found + schema: + $ref: '#/definitions/ErrorModel' + '500': + description: Internal server error + schema: + $ref: '#/definitions/ErrorModel' + '/members/{handle}/stats/history': + get: + tags: + - Statistics + description: Get member history statistics, by default only returns the public topcoder history statistics. + parameters: + - in: path + name: handle + required: true + type: string + - name: groupIds + in: query + required: false + type: string + description: comma separated group ids (returns members public history statistics if groupIds is not provided) + - name: fields + in: query + required: false + type: string + description: > + fields=fieldName1,fieldName2,...,fieldN + + parameter for choosing which fields of Member History Stats that will be included in response. + + + userId - Select the field userId + + + groupId - Select the field groupId + + + handle - Select the field handle + + + handleLower - Select the field handleLower + + + DEVELOP - Select the field DEVELOP + + + DATA_SCIENCE - Select the field DATA_SCIENCE + + + createdAt - Select the field createdAt + + + createdBy - Select the field createdBy + + + updatedAt - Select the field updatedAt + + + updatedBy - Select the field updatedBy + responses: + '200': + description: OK + schema: + $ref: '#/definitions/MemberHistoryStats' + '400': + description: Bad request data + schema: + $ref: '#/definitions/ErrorModel' + '404': + description: Not found + schema: + $ref: '#/definitions/ErrorModel' + '500': + description: Internal server error + schema: + $ref: '#/definitions/ErrorModel' + '/members/{handle}/skills': + get: + tags: + - Statistics + description: get member skills. + parameters: + - in: path + name: handle + required: true + type: string + responses: + '200': + description: OK + schema: + $ref: '#/definitions/MemberSkills' + '400': + description: Bad request data + schema: + $ref: '#/definitions/ErrorModel' + '401': + description: Miss or wrong authentication credentials + schema: + $ref: '#/definitions/ErrorModel' + '403': + description: No permission + schema: + $ref: '#/definitions/ErrorModel' + '404': + description: Not found + schema: + $ref: '#/definitions/ErrorModel' + '500': + description: Internal server error + schema: + $ref: '#/definitions/ErrorModel' + post: + tags: + - Statistics + description: create member skills + security: + - bearer: [] + parameters: + - in: path + name: handle + required: true + type: string + - in: body + name: body + required: true + schema: + $ref: '#/definitions/MemberSkillsRequest' + responses: + '200': + description: OK + schema: + $ref: '#/definitions/MemberSkills' + '400': + description: Bad request data + schema: + $ref: '#/definitions/ErrorModel' + '401': + description: Miss or wrong authentication credentials + schema: + $ref: '#/definitions/ErrorModel' + '403': + description: No permission + schema: + $ref: '#/definitions/ErrorModel' + '404': + description: Not found + schema: + $ref: '#/definitions/ErrorModel' + '500': + description: Internal server error + schema: + $ref: '#/definitions/ErrorModel' + patch: + tags: + - Statistics + description: update member entered skills + security: + - bearer: [] + parameters: + - in: path + name: handle + required: true + type: string + - in: body + name: body + required: true + schema: + $ref: '#/definitions/MemberSkillsRequest' + responses: + '200': + description: OK + schema: + $ref: '#/definitions/MemberSkills' + '400': + description: Bad request data + schema: + $ref: '#/definitions/ErrorModel' + '401': + description: Miss or wrong authentication credentials + schema: + $ref: '#/definitions/ErrorModel' + '403': + description: No permission + schema: + $ref: '#/definitions/ErrorModel' + '404': + description: Not found + schema: + $ref: '#/definitions/ErrorModel' + '500': + description: Internal server error + schema: + $ref: '#/definitions/ErrorModel' + '/members/stats/distribution': + get: + tags: + - Statistics + description: Get member distribution statistics + parameters: + - name: track + in: query + required: false + type: string + description: filter by track + - name: subTrack + in: query + required: false + type: string + description: filter by sub track + - name: fields + in: query + required: false + type: string + description: > + fields=fieldName1,fieldName2,...,fieldN - parameter for choosing which fields of Member Distribution Stats that will be included in response. + + + track - Select the field track + + + subTrack - Select the field subTrack + + + distribution - Select the field distribution + + + updatedAt - Select the field updatedAt + + + createdAt - Select the field createdAt + + + createdBy - Select the field createdBy + + + updatedBy - Select the field updatedBy + responses: + '200': + description: OK + schema: + $ref: '#/definitions/MemberDistributionStats' + '400': + description: Bad request data + schema: + $ref: '#/definitions/ErrorModel' + '404': + description: Not found + schema: + $ref: '#/definitions/ErrorModel' + '500': + description: Internal server error + schema: + $ref: '#/definitions/ErrorModel' + '/members/{handle}/financial': + get: + tags: + - Miscellaneous + description: Get member financial information + security: + - bearer: [] + parameters: + - in: path + name: handle + required: true + type: string + - name: fields + in: query + required: false + type: string + description: > + fields=fieldName1,fieldName2,...,fieldN + + parameter for choosing which fields of MemberFinancial that will be included in response. + + + userId - Select the field userId + + + amount - Select the field amount + + + status - Select the field status + + + updatedAt - Select the field updatedAt + + + createdAt - Select the field createdAt + + + createdBy - Select the field createdBy + + + updatedBy - Select the field updatedBy + responses: + '200': + description: OK + schema: + $ref: '#/definitions/MemberFinancial' + '400': + description: Bad request data + schema: + $ref: '#/definitions/ErrorModel' + '401': + description: Miss or wrong authentication credentials + schema: + $ref: '#/definitions/ErrorModel' + '403': + description: No permission + schema: + $ref: '#/definitions/ErrorModel' + '404': + description: Not found + schema: + $ref: '#/definitions/ErrorModel' + '500': + description: Internal server error + schema: + $ref: '#/definitions/ErrorModel' + '/members/{handle}/gamification/rewards': + get: + tags: + - Gamification + description: Get member gamification rewards + security: + - bearer: [] + parameters: + - in: path + name: handle + required: true + type: string + - name: site + in: query + required: false + type: string + description: The site for which to make the request. Defaults to `topcoder` if not specified. + - name: tags + in: query + required: false + type: string + description: > + tags=tag1,tag2,...,tagN + + The tags by which to filter the activities retrieved. Leave empty for no tag filtering. + - name: tagsJoin + in: query + required: false + type: string + description: > + Whether the tags should be found using hasAnyOf / hasAllOf. + + + hasAnyOf + + + hasAllOf + responses: + '200': + description: OK + schema: + type: array + items: + $ref: '#/definitions/MemberRewards' + '400': + description: Bad request data + schema: + $ref: '#/definitions/ErrorModel' + '401': + description: Miss or wrong authentication credentials + schema: + $ref: '#/definitions/ErrorModel' + '403': + description: No permission + schema: + $ref: '#/definitions/ErrorModel' + '404': + description: Not found + schema: + $ref: '#/definitions/ErrorModel' + '500': + description: Internal server error + schema: + $ref: '#/definitions/ErrorModel' + + + + + + + + + + + + + +definitions: + Health: + type: object + properties: + checksRun: + type: number + MemberProfile: + type: object + properties: + userId: + type: integer + handle: + type: string + handleLower: + type: string + firstName: + type: string + lastName: + type: string + tracks: + type: array + items: + type: string + status: + type: string + verified: + type: boolean + addresses: + type: array + items: + type: object + properties: + streetAddr1: + type: string + streetAddr2: + type: string + city: + type: string + zip: + type: string + stateCode: + type: string + type: + type: string + updatedAt: + type: string + format: date-time + description: 'ISO-8601 formatted date times (YYYY-MM-DDTHH:mm:ss.sssZ)' + createdAt: + type: string + format: date-time + description: 'ISO-8601 formatted date times (YYYY-MM-DDTHH:mm:ss.sssZ)' + createdBy: + type: string + updatedBy: + type: string + description: + type: string + email: + type: string + homeCountryCode: + type: string + competitionCountryCode: + type: string + photoURL: + type: string + maxRating: + type: object + properties: + rating: + type: number + format: int64 + track: + type: string + subTrack: + type: string + createdAt: + type: string + format: date-time + description: 'ISO-8601 formatted date times (YYYY-MM-DDTHH:mm:ss.sssZ)' + createdBy: + type: string + updatedAt: + type: string + format: date-time + description: 'ISO-8601 formatted date times (YYYY-MM-DDTHH:mm:ss.sssZ)' + updatedBy: + type: string + EmailVerificationResult: + type: object + properties: + emailChangeCompleted: + type: boolean + verifiedEmail: + type: string + description: One of (old|new) + UploadPhotoResult: + type: object + properties: + photoURL: + type: string + MemberSearchDataItem: + properties: + userId: + type: number + firstName: + type: string + lastName: + type: string + createdAt: + type: number + photoURL: + type: string + description: + type: string + handle: + type: string + maxRating: + properties: + rating: + type: number + subTrack: + type: string + track: + type: string + type: object + competitionCountryCode: + type: string + tracks: + type: array + items: + type: string + skills: + $ref: '#/definitions/MemberSkills' + stats: + $ref: '#/definitions/MemberStats' + MemberSearchAutocompleteDataItem: + properties: + handle: + type: string + userId: + type: number + firstName: + type: string + lastName: + type: string + MemberProfileTrait: + properties: + traitId: + type: string + categoryName: + type: string + createdAt: + type: string + format: date-time + createdBy: + type: number + format: int64 + updatedAt: + type: string + format: date-time + updatedBy: + type: number + format: int64 + traits: + $ref: '#/definitions/MemberProfileTraitData' + MemberProfileTraitData: + description: >- + the type should be one of the MemberProfileBasicInfo, MemberProfileWork, + MemberProfileSkill, MemberProfileCommunities and MemberProfileEducation, etc.. + properties: + data: + type: array + items: + type: object + MemberProfileBasicInfo: + properties: + handle: + type: string + firstName: + type: string + lastName: + type: string + shortBio: + type: string + gender: + type: string + tshirtSize: + type: string + country: + type: string + primaryInterestInTopcoder: + type: string + MemberProfileEducation: + properties: + type: + type: string + schoolCollegeName: + type: string + major: + type: string + timePeriodFrom: + type: string + format: date-time + timePeriodTo: + type: string + format: date-time + graduated: + type: boolean + MemberProfileCommunities: + properties: + cognitive: + type: boolean + blockchain: + type: boolean + MemberProfileWork: + properties: + company: + type: string + position: + type: string + industry: + type: string + cityTown: + type: string + timePeriodFrom: + type: string + format: date-time + timePeriodTo: + type: string + format: date-time + MemberStats: + type: object + properties: + userId: + type: integer + handle: + type: string + handleLower: + type: string + maxRating: + type: object + properties: + rating: + type: number + format: int64 + track: + type: string + subTrack: + type: string + challenges: + type: number + format: int64 + wins: + type: number + format: int64 + develop: + type: object + properties: + challenges: + type: number + format: int64 + wins: + type: number + format: int64 + subTracks: + type: array + items: + type: object + properties: + id: + type: number + format: int64 + name: + type: string + challenges: + type: number + format: int64 + wins: + type: number + format: int64 + rank: + type: object + properties: + rating: + type: number + format: int64 + activePercentile: + type: number + format: double + activeRank: + type: number + format: int64 + activeCountryRank: + type: number + format: int64 + activeSchoolRank: + type: number + format: int64 + overallPercentile: + type: number + format: double + overallRank: + type: number + format: int64 + overallCountryRank: + type: number + format: int64 + overallSchoolRank: + type: number + format: int64 + volatility: + type: number + format: int64 + reliability: + type: number + format: double + maxRating: + type: number + format: int64 + minRating: + type: number + format: int64 + submissions: + type: object + properties: + numInquiries: + type: number + format: int64 + submissions: + type: number + format: int64 + submissionRate: + type: number + format: double + passedScreening: + type: number + format: int64 + screeningSuccessRate: + type: number + format: double + passedReview: + type: number + format: int64 + reviewSuccessRate: + type: number + format: double + appeals: + type: number + format: int64 + appealSuccessRate: + type: number + format: double + maxScore: + type: number + format: double + minScore: + type: number + format: double + avgScore: + type: number + format: double + avgPlacement: + type: number + format: double + winPercent: + type: number + format: double + mostRecentEventDate: + type: string + format: date-time + description: 'ISO-8601 formatted date times (YYYY-MM-DDTHH:mm:ss.sssZ)' + mostRecentSubmission: + type: string + format: date-time + description: 'ISO-8601 formatted date times (YYYY-MM-DDTHH:mm:ss.sssZ)' + mostRecentEventDate: + type: string + format: date-time + description: 'ISO-8601 formatted date times (YYYY-MM-DDTHH:mm:ss.sssZ)' + mostRecentSubmission: + type: string + format: date-time + description: 'ISO-8601 formatted date times (YYYY-MM-DDTHH:mm:ss.sssZ)' + design: + type: object + properties: + challenges: + type: number + format: int64 + wins: + type: number + format: int64 + subTracks: + type: array + items: + type: object + properties: + id: + type: number + format: int64 + name: + type: string + numInquiries: + type: number + format: int64 + challenges: + type: number + format: int64 + wins: + type: number + format: int64 + winPercent: + type: number + format: double + avgPlacement: + type: number + format: double + submissions: + type: number + format: int64 + submissionRate: + type: number + format: double + passedScreening: + type: number + format: int64 + screeningSuccessRate: + type: number + format: double + mostRecentEventDate: + type: string + format: date-time + description: 'ISO-8601 formatted date times (YYYY-MM-DDTHH:mm:ss.sssZ)' + mostRecentSubmission: + type: string + format: date-time + description: 'ISO-8601 formatted date times (YYYY-MM-DDTHH:mm:ss.sssZ)' + mostRecentEventDate: + type: string + format: date-time + description: 'ISO-8601 formatted date times (YYYY-MM-DDTHH:mm:ss.sssZ)' + mostRecentSubmission: + type: string + format: date-time + description: 'ISO-8601 formatted date times (YYYY-MM-DDTHH:mm:ss.sssZ)' + dataScience: + type: object + properties: + challenges: + type: number + format: int64 + wins: + type: number + format: int64 + srm: + type: object + properties: + challenges: + type: number + format: int64 + wins: + type: number + format: int64 + rank: + type: object + properties: + rating: + type: number + format: int64 + percentile: + type: number + format: double + rank: + type: number + format: int64 + countryRank: + type: number + format: int64 + schoolRank: + type: number + format: int64 + volatility: + type: number + format: double + maximumRating: + type: number + format: int64 + minimumRating: + type: number + format: int64 + defaultLanguage: + type: string + competitions: + type: number + format: int64 + mostRecentEventName: + type: string + mostRecentEventDate: + type: string + format: date-time + description: 'ISO-8601 formatted date times (YYYY-MM-DDTHH:mm:ss.sssZ)' + challengeDetails: + type: array + items: + type: object + properties: + levelName: + type: string + challenges: + type: number + format: int64 + failedChallenges: + type: number + format: int64 + division1: + type: array + items: + type: object + properties: + levelName: + type: string + problemsSubmitted: + type: number + format: int64 + problemsFailed: + type: number + format: int64 + problemsSysByTest: + type: number + format: int64 + division2: + type: array + items: + type: object + properties: + levelName: + type: string + problemsSubmitted: + type: number + format: int64 + problemsFailed: + type: number + format: int64 + problemsSysByTest: + type: number + format: int64 + mostRecentEventName: + type: string + mostRecentEventDate: + type: string + format: date-time + description: 'ISO-8601 formatted date times (YYYY-MM-DDTHH:mm:ss.sssZ)' + mostRecentSubmission: + type: string + format: date-time + description: 'ISO-8601 formatted date times (YYYY-MM-DDTHH:mm:ss.sssZ)' + marathonMatch: + type: object + properties: + challenges: + type: number + format: int64 + wins: + type: number + format: int64 + rank: + type: object + properties: + rating: + type: number + format: int64 + competitions: + type: number + format: int64 + avgRank: + type: number + format: int64 + avgNumSubmissions: + type: number + format: int64 + bestRank: + type: number + format: int64 + topFiveFinishes: + type: number + format: int64 + topTenFinishes: + type: number + format: int64 + rank: + type: number + format: int64 + percentile: + type: number + format: double + volatility: + type: number + format: double + minimumRating: + type: number + format: int64 + maximumRating: + type: number + format: int64 + countryRank: + type: number + format: int64 + schoolRank: + type: number + format: int64 + defaultLanguage: + type: string + mostRecentEventName: + type: string + mostRecentEventDate: + type: string + format: date-time + description: 'ISO-8601 formatted date times (YYYY-MM-DDTHH:mm:ss.sssZ)' + mostRecentEventName: + type: string + mostRecentEventDate: + type: string + format: date-time + description: 'ISO-8601 formatted date times (YYYY-MM-DDTHH:mm:ss.sssZ)' + mostRecentSubmission: + type: string + format: date-time + description: 'ISO-8601 formatted date times (YYYY-MM-DDTHH:mm:ss.sssZ)' + mostRecentEventName: + type: string + mostRecentEventDate: + type: string + format: date-time + description: 'ISO-8601 formatted date times (YYYY-MM-DDTHH:mm:ss.sssZ)' + mostRecentSubmission: + type: string + format: date-time + description: 'ISO-8601 formatted date times (YYYY-MM-DDTHH:mm:ss.sssZ)' + copilot: + type: object + properties: + contests: + type: number + format: int64 + projects: + type: number + format: int64 + failures: + type: number + format: int64 + reposts: + type: number + format: int64 + activeContests: + type: number + format: int64 + activeProjects: + type: number + format: int64 + fulfillment: + type: number + format: double + updatedAt: + type: string + format: date-time + description: 'ISO-8601 formatted date times (YYYY-MM-DDTHH:mm:ss.sssZ)' + createdAt: + type: string + format: date-time + description: 'ISO-8601 formatted date times (YYYY-MM-DDTHH:mm:ss.sssZ)' + createdBy: + type: string + updatedBy: + type: string + MemberHistoryStats: + type: object + properties: + userId: + type: integer + groupId: + type: integer + handle: + type: string + handleLower: + type: string + DEVELOP: + type: object + properties: + subTracks: + type: array + items: + type: object + properties: + id: + type: number + format: int64 + name: + type: string + history: + type: array + items: + type: object + properties: + challengeId: + type: number + format: int64 + challengeName: + type: string + ratingDate: + type: string + format: date-time + description: >- + ISO-8601 formatted date times + (YYYY-MM-DDTHH:mm:ss.sssZ) + newRating: + type: number + format: int64 + DATA_SCIENCE: + type: object + properties: + SRM: + type: object + properties: + history: + type: array + items: + type: object + properties: + challengeId: + type: number + format: int64 + challengeName: + type: string + date: + type: string + format: date-time + description: 'ISO-8601 formatted date times (YYYY-MM-DDTHH:mm:ss.sssZ)' + rating: + type: number + format: double + placement: + type: number + format: int64 + percentile: + type: number + format: double + MARATHON_MATCH: + type: object + properties: + history: + type: array + items: + type: object + properties: + challengeId: + type: number + format: int64 + challengeName: + type: string + date: + type: string + format: date-time + description: 'ISO-8601 formatted date times (YYYY-MM-DDTHH:mm:ss.sssZ)' + rating: + type: number + format: double + placement: + type: number + format: int64 + percentile: + type: number + format: double + updatedAt: + type: string + format: date-time + description: 'ISO-8601 formatted date times (YYYY-MM-DDTHH:mm:ss.sssZ)' + createdAt: + type: string + format: date-time + description: 'ISO-8601 formatted date times (YYYY-MM-DDTHH:mm:ss.sssZ)' + createdBy: + type: string + updatedBy: + type: string + MemberSkillsRequest: + type: object + properties: + key: + type: object + properties: + skillId: + type: string + format: uuid + description: skill id + displayModeId: + type: string + format: uuid + description: skill display mode id + levels: + type: array + items: + type: string + format: uuid + description: skill level id + MemberSkills: + type: object + properties: + id: + type: string + format: uuid + name: + type: string + example: 'Advanced Mathematics' + category: + type: object + properties: + id: + type: string + format: uuid + name: + type: string + example: 'Mathematics and Statistics' + displayMode: + type: object + properties: + id: + type: string + format: uuid + name: + type: string + example: 'additional' + levels: + type: array + items: + type: object + properties: + id: + type: string + format: uuid + name: + type: string + example: 'verified' + description: + type: string + example: 'Skill verified by proof of capability' + + MemberDistributionStats: + type: object + properties: + track: + type: string + subTrack: + type: string + distribution: + type: object + properties: + ratingRange0To099: + type: number + format: int64 + ratingRange100To199: + type: number + format: int64 + ratingRange200To299: + type: number + format: int64 + ratingRange300To399: + type: number + format: int64 + ratingRange400To499: + type: number + format: int64 + ratingRange500To599: + type: number + format: int64 + ratingRange600To699: + type: number + format: int64 + ratingRange700To799: + type: number + format: int64 + ratingRange800To899: + type: number + format: int64 + ratingRange900To999: + type: number + format: int64 + ratingRange1000To1099: + type: number + format: int64 + ratingRange1100To1199: + type: number + format: int64 + ratingRange1200To1299: + type: number + format: int64 + ratingRange1300To1399: + type: number + format: int64 + ratingRange1400To1499: + type: number + format: int64 + ratingRange1500To1599: + type: number + format: int64 + ratingRange1600To1699: + type: number + format: int64 + ratingRange1700To1799: + type: number + format: int64 + ratingRange1800To1899: + type: number + format: int64 + ratingRange1900To1999: + type: number + format: int64 + ratingRange2000To2099: + type: number + format: int64 + ratingRange2100To2199: + type: number + format: int64 + ratingRange2200To2299: + type: number + format: int64 + ratingRange2300To2399: + type: number + format: int64 + ratingRange2400To2499: + type: number + format: int64 + ratingRange2500To2599: + type: number + format: int64 + ratingRange2600To2699: + type: number + format: int64 + ratingRange2700To2799: + type: number + format: int64 + ratingRange2800To2899: + type: number + format: int64 + ratingRange2900To2999: + type: number + format: int64 + ratingRange3000To3099: + type: number + format: int64 + ratingRange3100To3199: + type: number + format: int64 + ratingRange3200To3299: + type: number + format: int64 + ratingRange3300To3399: + type: number + format: int64 + ratingRange3400To3499: + type: number + format: int64 + ratingRange3500To3599: + type: number + format: int64 + ratingRange3600To3699: + type: number + format: int64 + ratingRange3700To3799: + type: number + format: int64 + ratingRange3800To3899: + type: number + format: int64 + ratingRange3900To3999: + type: number + format: int64 + updatedAt: + type: string + format: date-time + description: 'ISO-8601 formatted date times (YYYY-MM-DDTHH:mm:ss.sssZ)' + createdAt: + type: string + format: date-time + description: 'ISO-8601 formatted date times (YYYY-MM-DDTHH:mm:ss.sssZ)' + createdBy: + type: string + updatedBy: + type: string + MemberFinancial: + type: object + properties: + userId: + type: number + format: int64 + amount: + type: number + format: double + status: + type: string + updatedAt: + type: string + format: date-time + description: 'ISO-8601 formatted date times (YYYY-MM-DDTHH:mm:ss.sssZ)' + createdAt: + type: string + format: date-time + description: 'ISO-8601 formatted date times (YYYY-MM-DDTHH:mm:ss.sssZ)' + createdBy: + type: string + updatedBy: + type: string + MemberRewards: + type: object + properties: + awardedOn: + type: string + expiryOn: + type: string + isExpired: + type: boolean + id: + type: string + awarded: + type: object + properties: + awardedType: + type: string + message: + type: string + name: + type: string + type: + type: string + reward: + type: object + properties: + active: + type: boolean + attrs: + type: object + id: + type: string + imageUrl: + type: string + mimeType: + type: string + message: + type: string + hint: + type: string + ErrorModel: + type: object + properties: + message: + type: string + description: the error message +parameters: + page: + name: page + in: query + description: The page number. + required: false + type: integer + default: 1 + perPage: + name: perPage + in: query + description: The number of items to list per page. + required: false + type: integer + default: 50 + maximum: 100 diff --git a/mock/mock-api.js b/mock/mock-api.js index 847e27c..bb0323f 100644 --- a/mock/mock-api.js +++ b/mock/mock-api.js @@ -1,13 +1,14 @@ const express = require('express') const cors = require('cors') const winston = require('winston') -const _ = require('lodash') const app = express() app.set('port', 4000) app.use(cors()) +app.use(express.json()) + const logFormat = winston.format.printf(({ level, message }) => { return `${new Date().toISOString()} [${level}]: ${message}` }) @@ -21,26 +22,33 @@ winston.add(logConsole) app.post('/v5/auth0', (req, res) => { winston.info('Received Auth0 request') // return config/test.js#M2M_FULL_ACCESS_TOKEN - res.status(200).json({ access_token: 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJodHRwczovL3RvcGNvZGVyLWRldi5hdXRoMC5jb20vIiwic3ViIjoiZW5qdzE4MTBlRHozWFR3U08yUm4yWTljUVRyc3BuM0JAY2xpZW50cyIsImF1ZCI6Imh0dHBzOi8vbTJtLnRvcGNvZGVyLWRldi5jb20vIiwiaWF0IjoxNTUwOTA2Mzg4LCJleHAiOjE2ODA5OTI3ODgsImF6cCI6ImVuancxODEwZUR6M1hUd1NPMlJuMlk5Y1FUcnNwbjNCIiwic2NvcGUiOiJhbGw6bWVtYmVycyIsImd0eSI6ImNsaWVudC1jcmVkZW50aWFscyJ9.Eo_cyyPBQfpWp_8-NSFuJI5MvkEV3UJZ3ONLcFZedoA' }) + res.status(200).json({ access_token: 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJodHRwczovL3RvcGNvZGVyLWRldi5hdXRoMC5jb20vIiwic3ViIjoiZW5qdzE4MTBlRHozWFR3U08yUm4yWTljUVRyc3BuM0JAY2xpZW50cyIsImF1ZCI6Imh0dHBzOi8vbTJtLnRvcGNvZGVyLWRldi5jb20vIiwiaWF0IjoxNTUwOTA2Mzg4LCJleHAiOjE2ODA5OTI3ODgsImF6cCI6ImVuancxODEwZUR6M1hUd1NPMlJuMlk5Y1FUcnNwbjNCIiwic2NvcGUiOiJhbGw6bWVtYmVycyIsImd0eSI6ImNsaWVudC1jcmVkZW50aWFscyJ9.Eo_cyyPBQfpWp_8-NSFuJI5MvkEV3UJZ3ONLcFZedoA' }) }) // mock event bus app.post('/v5/bus/events', (req, res) => { - winston.info('Received bus events'); - res.status(200).json({}); -}); + winston.info('Received bus events') + winston.info(JSON.stringify(req.body, null, 2)) + res.status(200).json({}) +}) +// mock group API +const allGroups = ['20000000', '20000001'] -app.post('/v5/looker/login', (req, res) => { - winston.info('Returning looker access token') - res.status(200).json({ - access_token: 'fake-token' - }) +app.get('/v5/groups/:groupId', (req, res) => { + const groupId = req.params.groupId + winston.info(`Received group request for ${groupId}`) + if (allGroups.includes(groupId)) { + res.json({ id: groupId }) + } else { + res.status(404).json(null) + } }) -app.post('/v5/looker/queries/*', (req, res) => { - winston.info('Querying looker API') - res.status(200).json({}) +app.get('/v5/groups/memberGroups/:memberId', (req, res) => { + const memberId = req.params.memberId + winston.info(`Received member groups request for ${memberId}`) + res.json(allGroups) }) app.use((req, res) => { @@ -57,4 +65,3 @@ app.use((err, req, res, next) => { app.listen(app.get('port'), '0.0.0.0', () => { winston.info(`Express server listening on port ${app.get('port')}`) }) - diff --git a/prisma/migrations/20250620091128_init/migration.sql b/prisma/migrations/20250701115244_init/migration.sql similarity index 99% rename from prisma/migrations/20250620091128_init/migration.sql rename to prisma/migrations/20250701115244_init/migration.sql index 905f5e2..03805b4 100644 --- a/prisma/migrations/20250620091128_init/migration.sql +++ b/prisma/migrations/20250701115244_init/migration.sql @@ -729,9 +729,6 @@ CREATE INDEX "memberHistoryStats_userId_idx" ON "memberHistoryStats"("userId"); -- CreateIndex CREATE INDEX "memberHistoryStats_groupId_idx" ON "memberHistoryStats"("groupId"); --- CreateIndex -CREATE UNIQUE INDEX "memberHistoryStats_userId_isPrivate_key" ON "memberHistoryStats"("userId", "isPrivate"); - -- CreateIndex CREATE INDEX "memberDevelopHistoryStats_historyStatsId_idx" ON "memberDevelopHistoryStats"("historyStatsId"); @@ -741,9 +738,6 @@ CREATE INDEX "memberDataScienceHistoryStats_historyStatsId_idx" ON "memberDataSc -- CreateIndex CREATE INDEX "memberStats_userId_idx" ON "memberStats"("userId"); --- CreateIndex -CREATE UNIQUE INDEX "memberStats_userId_isPrivate_key" ON "memberStats"("userId", "isPrivate"); - -- CreateIndex CREATE INDEX "memberCopilotStats_memberStatsId_idx" ON "memberCopilotStats"("memberStatsId"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index fa684fd..54d4e70 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -195,7 +195,6 @@ model memberHistoryStats { updatedAt DateTime? @updatedAt updatedBy String? - @@unique([userId, isPrivate]) @@index([userId]) @@index([groupId]) } @@ -268,7 +267,6 @@ model memberStats { updatedBy String? @@index([userId]) - @@unique([userId, isPrivate]) } model memberCopilotStats { diff --git a/src/common/LookerApi.js b/src/common/LookerApi.js deleted file mode 100644 index 504f600..0000000 --- a/src/common/LookerApi.js +++ /dev/null @@ -1,108 +0,0 @@ -/** - * This module contains the looker api methods. - * Code copy from https://github.com/topcoder-platform/onboarding-processor/src/common/LookerApi - */ -const config = require('config') -const axios = require('axios') - -const LookAuth = require('./LookerAuth') - -/** - * Create LookApi instance - * @param {Object} logger the logger object - * @returns the LookApi instance - */ -function LookApi (logger) { - this.BASE_URL = config.LOOKER.API_BASE_URL - this.formatting = 'json' - this.logger = logger - this.query_timezone = 'America/New_York' - this.lookAuth = new LookAuth(logger) - this.cachedDate = null - this.cachedData = null -} - -/** - * Find the verification status for a give member ID - * @param {String} memberId the member ID to look for verification status - * @returns Whether or not the given memberID is verified - */ -LookApi.prototype.isMemberVerified = async function (memberId) { - const isProd = process.env.NODE_ENV === 'production' - const view = isProd ? 'member_verification' : 'member_verification_dev' - const memberIdField = view + '.user_id' - const statusField = view + '.status' - - if(!this.cachedData || Date.now() - this.cachedDate > config.LOOKER.CACHE_DURATION){ - console.log("Refreshing cached looker verification data") - try{ - const fields = [`${view}.user_id`, `${view}.verification_mode`, `${view}.status`, `${view}.matched_on`, `${view}.verification_date`] - const filters = {} - const lookerData = await this.runQueryWithFilter('member_profile', view, fields, filters) - - this.cachedData = {} - for(i = 0; i { - const newReq = axios.post(endpoint, body, { - headers: { - 'Content-Type': 'application/json', - Authorization: `token ${token}` - } - }) - return newReq - }).then((res) => { - this.logger.debug(res.data) - return res.data - }).catch((err) => this.logger.error(err)) -} - -module.exports = LookApi diff --git a/src/common/LookerAuth.js b/src/common/LookerAuth.js deleted file mode 100644 index 2824dfe..0000000 --- a/src/common/LookerAuth.js +++ /dev/null @@ -1,83 +0,0 @@ -/** - * This module contains the looker api auth methods. - * Code copy from https://github.com/topcoder-platform/onboarding-processor/src/common/LookerAuth - */ -const config = require('config') -const axios = require('axios') - -const NEXT_5_MINS = 5 * 60 * 1000 - -/** - * Create LookAuth instance - * @param {Object} logger the logger object - * @returns the LookAuth instance - */ -function LookAuth (logger) { - // load credentials from config - this.BASE_URL = config.LOOKER.API_BASE_URL - this.CLIENT_ID = config.LOOKER.API_CLIENT_ID - this.CLIENT_SECRET = config.LOOKER.API_CLIENT_SECRET - const token = config.LOOKER.TOKEN - - this.logger = logger - - // Token is stringified and saved as string. It has 4 properties, access_token, expires_in and type, timestamp - if (token) { - this.lastToken = JSON.stringify(token) - } -} - -/** - * Get access token - * @returns access token - */ -LookAuth.prototype.getToken = async function () { - const res = await new Promise((resolve) => { - if (!this.isExpired()) { - resolve(this.lastToken.access_token) - } else { - resolve('') - } - }) - if (res === '') { - return this.login() - } - return res -} - -/** - * Login to get access token - * @returns access token - */ -LookAuth.prototype.login = async function () { - this.logger.debug('login to get access token') - const loginUrl = `${this.BASE_URL}/login?client_id=${this.CLIENT_ID}&client_secret=${this.CLIENT_SECRET}` - const res = await axios.post(loginUrl, {}, { headers: { 'Content-Type': 'application/json' } }) - this.lastToken = res.data - this.lastToken.timestamp = new Date().getTime() - return this.lastToken.access_token -} - -/** - * Check the token being expired or not - * @returns true if the token is expired - */ -LookAuth.prototype.isExpired = function () { - // If no token is present, assume the token has expired - if (!this.lastToken) { - return true - } - - const tokenTimestamp = this.lastToken.timestamp - const expiresIn = this.lastToken.expires_in - const currentTimestamp = new Date().getTime() - - // If the token will good for next 5 minutes - if ((tokenTimestamp + expiresIn + NEXT_5_MINS) > currentTimestamp) { - return false - } - // Token is good, and can be used to make the next call. - return true -} - -module.exports = LookAuth diff --git a/src/common/helper.js b/src/common/helper.js index 377d186..004999b 100644 --- a/src/common/helper.js +++ b/src/common/helper.js @@ -8,7 +8,8 @@ const AWS = require('aws-sdk') const config = require('config') const busApi = require('topcoder-bus-api-wrapper') const querystring = require('querystring') -const prisma = require('./prisma').getClient(); +const request = require('request') +const prisma = require('./prisma').getClient() // Color schema for Ratings const RATING_COLORS = [{ @@ -41,10 +42,10 @@ if (config.AMAZON.AWS_ACCESS_KEY_ID && config.AMAZON.AWS_SECRET_ACCESS_KEY) { } AWS.config.update(awsConfig) -let s3; +let s3 // lazy loading to allow mock tests -function getS3() { +function getS3 () { if (!s3) { s3 = new AWS.S3() } @@ -147,6 +148,38 @@ function hasAutocompleteRole (authUser) { return false } +/** + * Check if exists. + * + * @param {Array} source the array in which to search for the term + * @param {Array | String} term the term to search + * @returns {Boolean} whether the term is in the source + */ +function checkIfExists (source, term) { + let terms + + if (!_.isArray(source)) { + throw new Error('Source argument should be an array') + } + + source = source.map(s => s.toLowerCase()) + + if (_.isString(term)) { + terms = term.toLowerCase().split(' ') + } else if (_.isArray(term)) { + terms = term.map(t => t.toLowerCase()) + } else { + throw new Error('Term argument should be either a string or an array') + } + + for (let i = 0; i < terms.length; i++) { + if (source.includes(terms[i])) { + return true + } + } + + return false +} /** * Get member by handle @@ -157,15 +190,15 @@ async function getMemberByHandle (handle) { const ret = await prisma.member.findUnique({ where: { handleLower: handle.trim().toLowerCase() - } - }); + }, + include: { maxRating: true } + }) if (!ret || !ret.userId) { - throw new errors.NotFoundError(`Member with handle: "${handle}" doesn't exist`); + throw new errors.NotFoundError(`Member with handle: "${handle}" doesn't exist`) } - return ret; + return ret } - /** * Upload photo to S3 * @param {Buffer} data the file data @@ -304,37 +337,93 @@ function canManageMember (currentUser, member) { (currentUser.handle && currentUser.handle.toLowerCase() === member.handleLower.toLowerCase())) } -function cleanUpStatistics (stats, fields) { - // cleanup - convert string to object - for (let count = 0; count < stats.length; count++) { - if (stats[count].hasOwnProperty('maxRating')) { - if (typeof stats[count].maxRating === 'string') { - stats[count].maxRating = JSON.parse(stats[count].maxRating) - } - // set the rating color - stats[count].maxRating.ratingColor = this.getRatingColor(stats[count].maxRating.rating) - } - if (stats[count].hasOwnProperty('DATA_SCIENCE')) { - if (typeof stats[count].DATA_SCIENCE === 'string') { - stats[count].DATA_SCIENCE = JSON.parse(stats[count].DATA_SCIENCE) +function cleanupSkills (memberEnteredSkill, member) { + if (memberEnteredSkill.hasOwnProperty('userHandle')) { + memberEnteredSkill.handle = memberEnteredSkill.userHandle + } + if (!memberEnteredSkill.hasOwnProperty('userId')) { + memberEnteredSkill.userId = bigIntToNumber(member.userId) + } + if (!memberEnteredSkill.hasOwnProperty('handle')) { + memberEnteredSkill.handle = member.handle + } + if (!memberEnteredSkill.hasOwnProperty('handleLower')) { + memberEnteredSkill.handleLower = member.handleLower + } + return memberEnteredSkill +} + +function mergeSkills (memberEnteredSkill, memberAggregatedSkill, allTags) { + // process skills in member entered skill + if (memberEnteredSkill.hasOwnProperty('skills')) { + let tempSkill = {} + _.forIn(memberEnteredSkill.skills, (value, key) => { + if (!value.hidden) { + var tag = this.findTagById(allTags, Number(key)) + if (tag) { + value.tagName = tag.name + if (!value.hasOwnProperty('sources')) { + value.sources = [ 'USER_ENTERED' ] + } + if (!value.hasOwnProperty('score')) { + value.score = 0 + } + tempSkill[key] = value + } } + }) + // process skills in member aggregated skill + if (memberAggregatedSkill.skills) { + tempSkill = mergeAggregatedSkill(memberAggregatedSkill, allTags, tempSkill) } - if (stats[count].hasOwnProperty('DESIGN')) { - if (typeof stats[count].DESIGN === 'string') { - stats[count].DESIGN = JSON.parse(stats[count].DESIGN) - } + memberEnteredSkill.skills = tempSkill + } else { + // process skills in member aggregated skill + if (memberAggregatedSkill.hasOwnProperty('skills')) { + let tempSkill = {} + memberEnteredSkill.skills = mergeAggregatedSkill(memberAggregatedSkill, allTags, tempSkill) + } else { + memberEnteredSkill.skills = {} } - if (stats[count].hasOwnProperty('DEVELOP')) { - if (typeof stats[count].DEVELOP === 'string') { - stats[count].DEVELOP = JSON.parse(stats[count].DEVELOP) + } + return memberEnteredSkill +} + +function mergeAggregatedSkill (memberAggregatedSkill, allTags, tempSkill) { + for (var key in memberAggregatedSkill.skills) { + var value = memberAggregatedSkill.skills[key] + if (!value.hidden) { + var tag = findTagById(allTags, Number(key)) + if (tag) { + if (value.hasOwnProperty('sources')) { + if (value.sources.includes('CHALLENGE')) { + if (tempSkill[key]) { + value.tagName = tag.name + if (!value.hasOwnProperty('score')) { + value.score = tempSkill[key].score + } else { + if (value.score <= tempSkill[key].score) { + value.score = tempSkill[key].score + } + } + value.sources.push(tempSkill[key].sources[0]) + } else { + value.tagName = tag.name + if (!value.hasOwnProperty('score')) { + value.score = 0 + } + } + tempSkill[key] = value + } + } } } - // select fields if provided - if (fields) { - stats[count] = _.pick(stats[count], fields) - } } - return stats + return tempSkill +} + +function findTagById (data, id) { + return _.find(data, { 'id': id }) } function getRatingColor (rating) { @@ -343,8 +432,84 @@ function getRatingColor (rating) { return RATING_COLORS[i].color || 'black' } -function paginate (array, page_size, page_number) { - return array.slice(page_number * page_size, page_number * page_size + page_size) +function paginate (array, pageSize, pageNumber) { + return array.slice(pageNumber * pageSize, pageNumber * pageSize + pageSize) +} + +async function parseGroupIds (groupIds) { + const idArray = _.filter(_.map(_.split(groupIds, ','), id => _.trim(id)), _.size) + const newIdArray = [] + for (const id of idArray) { + if (_.isInteger(_.toNumber(id))) { + newIdArray.push(id) + } else { + try { + const { oldId } = await getGroupId(id) + if (!_.isNil(oldId)) { + newIdArray.push(oldId) + } + } catch (err) { } + } + } + return _.filter(_.uniq(newIdArray), _.size) +} + +async function getGroupId (id) { + const token = await getM2MToken() + return new Promise(function (resolve, reject) { + request({ url: `${config.GROUPS_API_URL}/${id}`, + headers: { + Authorization: `Bearer ${token}` + } }, + function (error, response, body) { + if (response.statusCode === 200) { + resolve(JSON.parse(body)) + } else { + reject(error) + } + } + ) + }) +} + +async function getAllowedGroupIds (currentUser, subjectUser, groupIds) { + // always load public stats if no groupId is provided + if (_.isUndefined(groupIds) || _.isEmpty(groupIds)) { + return [config.PUBLIC_GROUP_ID] + } + + // if caller is anonymous user return public group. + if (_.isUndefined(currentUser)) { + return groupIds.split(',').indexOf(config.PUBLIC_GROUP_ID) !== -1 ? [config.PUBLIC_GROUP_ID] : [] + } + const groups = await parseGroupIds(groupIds) + + // admins and members themselves should be able to view all stats from all the groups. + if (canManageMember(currentUser, subjectUser)) { + return groups + } + const currentUserGroups = await getMemberGroups(currentUser.userId) + currentUserGroups.push(config.PUBLIC_GROUP_ID) + const commonGroups = _.intersection(groups, currentUserGroups) + return _.difference(commonGroups, config.PRIVATE_GROUP_IDS) +} + +async function getMemberGroups (memberId) { + const token = await getM2MToken() + return new Promise(function (resolve, reject) { + request({ url: `${config.GROUPS_API_URL}/memberGroups/${memberId}`, + headers: { + Authorization: `Bearer ${token}` + } }, + function (error, response, body) { + if (response.statusCode === 200) { + resolve(JSON.parse(body)) + } else { + reject(error) + } + } + ) + }) } /* @@ -377,7 +542,7 @@ const getParamsFromQueryAsArray = async (query, parameterName) => { return paramsArray } -function secureMemberAddressData(member) { +function secureMemberAddressData (member) { if (member.addresses) { member.addresses = _.map(member.addresses, (address) => _.omit(address, config.ADDRESS_SECURE_FIELDS)) } @@ -385,14 +550,14 @@ function secureMemberAddressData(member) { return member } -function truncateLastName(member) { +function truncateLastName (member) { if (member.lastName) { - member.lastName = member.lastName.substring(0,1) + member.lastName = member.lastName.substring(0, 1) } return member } -function bigIntToNumber(value) { +function bigIntToNumber (value) { if (value) { return Number(value) } @@ -402,6 +567,7 @@ function bigIntToNumber(value) { module.exports = { wrapExpress, autoWrapExpress, + checkIfExists, hasAdminRole, hasAutocompleteRole, hasSearchByEmailRole, @@ -411,9 +577,16 @@ module.exports = { parseCommaSeparatedString, setResHeaders, canManageMember, - cleanUpStatistics, + cleanupSkills, + mergeSkills, + mergeAggregatedSkill, + findTagById, getRatingColor, paginate, + parseGroupIds, + getGroupId, + getAllowedGroupIds, + getMemberGroups, getM2MToken, getParamsFromQueryAsArray, secureMemberAddressData, diff --git a/src/common/image.js b/src/common/image.js index 215baf7..c96cce9 100644 --- a/src/common/image.js +++ b/src/common/image.js @@ -1,14 +1,14 @@ -function bufferContainsScript(buffer) { - const str = buffer.toString('utf8').toLowerCase(); +function bufferContainsScript (buffer) { + const str = buffer.toString('utf8').toLowerCase() return ( str.includes(' _.omit(d, + ['id', 'userId', ...auditFields])) + } + member.verified = member.verified || false +} + +/** + * Build skill list data with data from db + * @param {Array} skillList skill list from db + * @returns skill list in response + */ +function buildMemberSkills (skillList) { + if (!skillList || skillList.length === 0) { + return [] + } + return _.map(skillList, item => { + const ret = _.pick(item.skill, ['id', 'name']) + ret.category = _.pick(item.skill.category, ['id', 'name']) + if (item.displayMode) { + ret.displayMode = _.pick(item.displayMode, ['id', 'name']) + } + // set levels + if (item.levels && item.levels.length > 0) { + ret.levels = _.map(item.levels, + lvl => _.pick(lvl.skillLevel, ['id', 'name', 'description'])) + } + return ret + }) +} + +/** + * Build prisma filter with member search query + * @param {Object} query request query parameters + * @returns member filter used in prisma + */ +function buildSearchMemberFilter (query) { + const handles = _.isArray(query.handles) ? query.handles : [] + const handlesLower = _.isArray(query.handlesLower) ? query.handlesLower : [] + const userIds = _.isArray(query.userIds) ? query.userIds : [] + + const filterList = [] + filterList.push({ status: 'ACTIVE' }) + if (query.userId) { + filterList.push({ userId: query.userId }) + } + if (query.handleLower) { + filterList.push({ handleLower: query.handleLower }) + } + if (query.handle) { + filterList.push({ handle: query.handle }) + } + if (query.email) { + filterList.push({ email: query.email }) + } + if (userIds.length > 0) { + filterList.push({ userId: { in: userIds } }) + } + if (handlesLower.length > 0) { + filterList.push({ handleLower: { in: handlesLower } }) + } + if (handles.length > 0) { + filterList.push({ handle: { in: handles } }) + } + + const prismaFilter = { + where: { AND: filterList } + } + return prismaFilter +} + +/** + * Convert db data to response structure for member stats + * @param {Object} member member data + * @param {Object} statsData stats data from db + * @param {Array} fields fields return in response + * @returns Member stats response + */ +function buildStatsResponse (member, statsData, fields) { + const item = { + userId: helper.bigIntToNumber(member.userId), + groupId: helper.bigIntToNumber(statsData.groupId), + handle: member.handle, + handleLower: member.handleLower, + challenges: statsData.challenges, + wins: statsData.wins + } + if (member.maxRating) { + item.maxRating = _.pick(member.maxRating, ['rating', 'track', 'subTrack', 'ratingColor']) + } + if (statsData.design) { + item.DESIGN = { + challenges: helper.bigIntToNumber(statsData.design.challenges), + wins: helper.bigIntToNumber(statsData.design.wins), + mostRecentSubmission: statsData.design.mostRecentSubmission + ? statsData.design.mostRecentSubmission.getTime() : null, + mostRecentEventDate: statsData.design.mostRecentEventDate + ? statsData.design.mostRecentEventDate.getTime() : null, + subTracks: [] + } + const items = _.get(statsData, 'design.items', []) + if (items.length > 0) { + item.DESIGN.subTracks = _.map(items, t => ({ + ..._.pick(t, designBasicFields), + challenges: helper.bigIntToNumber(t.challenges), + wins: helper.bigIntToNumber(t.wins), + id: t.subTrackId, + mostRecentSubmission: t.mostRecentSubmission + ? t.mostRecentSubmission.getTime() : null, + mostRecentEventDate: t.mostRecentEventDate + ? t.mostRecentEventDate.getTime() : null + })) + } + } + if (statsData.develop) { + item.DEVELOP = { + challenges: helper.bigIntToNumber(statsData.develop.challenges), + wins: helper.bigIntToNumber(statsData.develop.wins), + mostRecentSubmission: statsData.develop.mostRecentSubmission + ? statsData.develop.mostRecentSubmission.getTime() : null, + mostRecentEventDate: statsData.develop.mostRecentEventDate + ? statsData.develop.mostRecentEventDate.getTime() : null, + subTracks: [] + } + const items = _.get(statsData, 'develop.items', []) + if (items.length > 0) { + item.DEVELOP.subTracks = _.map(items, t => ({ + challenges: helper.bigIntToNumber(t.challenges), + wins: helper.bigIntToNumber(t.wins), + id: t.subTrackId, + name: t.name, + mostRecentSubmission: t.mostRecentSubmission ? t.mostRecentSubmission.getTime() : null, + mostRecentEventDate: t.mostRecentEventDate ? t.mostRecentEventDate.getTime() : null, + submissions: { + ..._.pick(t, developSubmissionFields), + ..._.mapValues(_.pick(t, developSubmissionBigIntFields), v => helper.bigIntToNumber(v)) + }, + rank: _.pick(t, developRankFields) + })) + } + } + if (statsData.copilot) { + item.COPILOT = _.pick(statsData.copilot, copilotFields) + } + if (statsData.dataScience) { + item.DATA_SCIENCE = { + challenges: helper.bigIntToNumber(statsData.dataScience.challenges), + wins: helper.bigIntToNumber(statsData.dataScience.wins), + mostRecentSubmission: statsData.dataScience.mostRecentSubmission + ? statsData.dataScience.mostRecentSubmission.getTime() : null, + mostRecentEventDate: statsData.dataScience.mostRecentEventDate + ? statsData.dataScience.mostRecentEventDate.getTime() : null, + mostRecentEventName: statsData.dataScience.mostRecentEventName + } + if (statsData.dataScience.srm) { + const srmData = statsData.dataScience.srm + item.DATA_SCIENCE.SRM = { + challenges: helper.bigIntToNumber(srmData.challenges), + wins: helper.bigIntToNumber(srmData.wins), + mostRecentSubmission: srmData.mostRecentSubmission + ? srmData.mostRecentSubmission.getTime() : null, + mostRecentEventDate: srmData.mostRecentEventDate + ? srmData.mostRecentEventDate.getTime() : null, + mostRecentEventName: srmData.mostRecentEventName, + rank: _.pick(srmData, srmRankFields) + } + if (srmData.challengeDetails && srmData.challengeDetails.length > 0) { + item.DATA_SCIENCE.SRM.challengeDetails = _.map(srmData.challengeDetails, + t => _.pick(t, ['challenges', 'levelName', 'failedChallenges'])) + } + if (srmData.divisions && srmData.divisions.length > 0) { + const div1Data = _.filter(srmData.divisions, t => t.divisionName === 'division1') + const div2Data = _.filter(srmData.divisions, t => t.divisionName === 'division2') + if (div1Data.length > 0) { + item.DATA_SCIENCE.SRM.division1 = _.map(div1Data, t => _.pick(t, srmDivisionFields)) + } + if (div2Data.length > 0) { + item.DATA_SCIENCE.SRM.division2 = _.map(div2Data, t => _.pick(t, srmDivisionFields)) + } + } + } + if (statsData.dataScience.marathon) { + const marathonData = statsData.dataScience.marathon + item.DATA_SCIENCE.MARATHON_MATCH = { + challenges: helper.bigIntToNumber(marathonData.challenges), + wins: helper.bigIntToNumber(marathonData.wins), + mostRecentSubmission: marathonData.mostRecentSubmission + ? marathonData.mostRecentSubmission.getTime() : null, + mostRecentEventDate: marathonData.mostRecentEventDate + ? marathonData.mostRecentEventDate.getTime() : null, + mostRecentEventName: marathonData.mostRecentEventName, + rank: _.pick(marathonData, marathonRankFields) + } + } + } + + return fields ? _.pick(item, fields) : item +} + +/** + * Convert prisma data to response structure for member stats history + * @param {Object} member member data + * @param {Object} historyStats stats history + * @param {Array} fields fields to return in response + * @returns response + */ +function buildStatsHistoryResponse (member, historyStats, fields) { + const item = { + userId: helper.bigIntToNumber(member.userId), + groupId: helper.bigIntToNumber(historyStats.groupId), + handle: member.handle, + handleLower: member.handleLower + } + // collect develop data + if (historyStats.develop && historyStats.develop.length > 0) { + item.DEVELOP = { subTracks: [] } + // group by subTrackId + const subTrackGroupData = _.groupBy(historyStats.develop, 'subTrackId') + // for each sub track, build history data + _.forEach(subTrackGroupData, (trackHistory, subTrackId) => { + const subTrackItem = { + id: subTrackId, + name: trackHistory[0].subTrack + } + subTrackItem.history = _.map(trackHistory, h => ({ + ..._.pick(h, ['challengeName', 'newRating']), + challengeId: helper.bigIntToNumber(h.challengeId), + ratingDate: h.ratingDate ? h.ratingDate.getTime() : null + })) + item.DEVELOP.subTracks.push(subTrackItem) + }) + } + // collect data sciencedata + if (historyStats.dataScience && historyStats.dataScience.length > 0) { + item.DATA_SCIENCE = {} + const srmHistory = _.filter(historyStats.dataScience, t => t.subTrack === 'SRM') + const marathonHistory = _.filter(historyStats.dataScience, t => t.subTrack === 'MARATHON_MATCH') + if (srmHistory.length > 0) { + item.DATA_SCIENCE.SRM = {} + item.DATA_SCIENCE.SRM.history = _.map(srmHistory, h => ({ + ..._.pick(h, ['challengeName', 'rating', 'placement', 'percentile']), + challengeId: helper.bigIntToNumber(h.challengeId), + date: h.date ? h.date.getTime() : null + })) + } + if (marathonHistory.length > 0) { + item.DATA_SCIENCE.MARATHON_MATCH = {} + item.DATA_SCIENCE.MARATHON_MATCH.history = _.map(marathonHistory, h => ({ + ..._.pick(h, ['challengeName', 'rating', 'placement', 'percentile']), + challengeId: helper.bigIntToNumber(h.challengeId), + date: h.date ? h.date.getTime() : null + })) + } + } + return fields ? _.pick(item, fields) : item +} + +// include parameters used to get all member stats +const statsIncludeParams = { + design: { include: { items: true } }, + develop: { include: { items: true } }, + dataScience: { include: { + srm: { include: { challengeDetails: true, divisions: true } }, + marathon: true + } }, + copilot: true +} + +// include parameters used to get all member skills +const skillsIncludeParams = { + levels: { include: { skillLevel: true } }, + skill: { include: { category: true } }, + displayMode: true +} + +module.exports = { + convertMember, + buildMemberSkills, + buildStatsResponse, + buildSearchMemberFilter, + buildStatsHistoryResponse, + statsIncludeParams, + skillsIncludeParams +} diff --git a/src/controllers/HealthController.js b/src/controllers/HealthController.js index e638b40..eb3561c 100644 --- a/src/controllers/HealthController.js +++ b/src/controllers/HealthController.js @@ -19,7 +19,7 @@ async function checkHealth (req, res) { checksRun += 1 const timestampMS = new Date().getTime() try { - await service.getMember(null, "Ghostar", { }) + await service.getMember(null, 'Ghostar', { }) } catch (e) { throw new errors.ServiceUnavailableError(`There is database operation error, ${e.message}`) } diff --git a/src/controllers/SearchController.js b/src/controllers/SearchController.js new file mode 100644 index 0000000..dd39a45 --- /dev/null +++ b/src/controllers/SearchController.js @@ -0,0 +1,43 @@ +/** + * Controller for search endpoints + */ +const helper = require('../common/helper') +const service = require('../services/SearchService') + +/** + * Search members + * @param {Object} req the request + * @param {Object} res the response + */ +async function searchMembers (req, res) { + const result = await service.searchMembers(req.authUser, req.query) + helper.setResHeaders(req, res, result) + res.send(result.result) +} + +/** + * Search members + * @param {Object} req the request + * @param {Object} res the response + */ +async function autocomplete (req, res) { + const result = await service.autocomplete(req.authUser, req.query) + helper.setResHeaders(req, res, result) + res.send(result.result) +} + +/** + * Search members with additional parameters, like skills + * @param {Object} req the request + * @param {Object} res the response + */ +async function searchMembersBySkills (req, res) { + const result = await service.searchMembersBySkills(req.authUser, req.query) + helper.setResHeaders(req, res, result) + res.send(result.result) +} +module.exports = { + searchMembers, + searchMembersBySkills, + autocomplete +} diff --git a/src/controllers/StatisticsController.js b/src/controllers/StatisticsController.js new file mode 100644 index 0000000..420baba --- /dev/null +++ b/src/controllers/StatisticsController.js @@ -0,0 +1,73 @@ +/** + * Controller for statistics endpoints + */ +const service = require('../services/StatisticsService') + +/** + * Get distribution statistics + * @param {Object} req the request + * @param {Object} res the response + */ +async function getDistribution (req, res) { + const result = await service.getDistribution(req.query) + res.send(result) +} + +/** + * Get member history statistics + * @param {Object} req the request + * @param {Object} res the response + */ +async function getHistoryStats (req, res) { + const result = await service.getHistoryStats(req.authUser, req.params.handle, req.query) + res.send(result) +} + +/** + * Get member statistics + * @param {Object} req the request + * @param {Object} res the response + */ +async function getMemberStats (req, res) { + const result = await service.getMemberStats(req.authUser, req.params.handle, req.query, true) + res.send(result) +} + +/** + * Get member skills + * @param {Object} req the request + * @param {Object} res the response + */ +async function getMemberSkills (req, res) { + const result = await service.getMemberSkills(req.params.handle) + res.send(result) +} + +/** + * Create member skills + * @param {Object} req the request + * @param {Object} res the response + */ +async function createMemberSkills (req, res) { + const result = await service.createMemberSkills(req.authUser, req.params.handle, req.body) + res.send(result) +} + +/** + * Partially update member skills + * @param {Object} req the request + * @param {Object} res the response + */ +async function partiallyUpdateMemberSkills (req, res) { + const result = await service.partiallyUpdateMemberSkills(req.authUser, req.params.handle, req.body) + res.send(result) +} + +module.exports = { + getDistribution, + getHistoryStats, + getMemberStats, + getMemberSkills, + createMemberSkills, + partiallyUpdateMemberSkills +} diff --git a/src/routes.js b/src/routes.js index 2e8b288..88c6c1b 100644 --- a/src/routes.js +++ b/src/routes.js @@ -1,8 +1,6 @@ /** * Contains all routes */ - -const constants = require('../app-constants') const { SCOPES: { MEMBERS } } = require('config') @@ -14,6 +12,32 @@ module.exports = { method: 'checkHealth' } }, + '/members': { + get: { + controller: 'SearchController', + method: 'searchMembers', + auth: 'jwt', + allowNoToken: true, + scopes: [MEMBERS.READ, MEMBERS.ALL] + } + }, + '/members/searchBySkills': { + get: { + controller: 'SearchController', + method: 'searchMembersBySkills', + auth: 'jwt', + allowNoToken: true, + scopes: [MEMBERS.READ, MEMBERS.ALL] + } + }, + '/members/autocomplete': { + get: { + controller: 'SearchController', + method: 'autocomplete', + auth: 'jwt', + scopes: [MEMBERS.READ, MEMBERS.ALL] + } + }, '/members/uid-signature': { get: { controller: 'MemberController', @@ -88,4 +112,50 @@ module.exports = { scopes: [MEMBERS.DELETE, MEMBERS.ALL] } }, + '/members/stats/distribution': { + get: { + controller: 'StatisticsController', + method: 'getDistribution' + } + }, + '/members/:handle/stats/history': { + get: { + controller: 'StatisticsController', + method: 'getHistoryStats', + auth: 'jwt', + allowNoToken: true, + scopes: [MEMBERS.READ, MEMBERS.ALL] + } + }, + '/members/:handle/stats': { + get: { + controller: 'StatisticsController', + method: 'getMemberStats', + auth: 'jwt', + allowNoToken: true, + scopes: [MEMBERS.READ, MEMBERS.ALL] + } + }, + '/members/:handle/skills': { + get: { + controller: 'StatisticsController', + method: 'getMemberSkills', + auth: 'jwt', + allowNoToken: true, + scopes: [MEMBERS.READ, MEMBERS.ALL] + }, + post: { + controller: 'StatisticsController', + method: 'createMemberSkills', + auth: 'jwt', + scopes: [MEMBERS.CREATE, MEMBERS.ALL] + }, + patch: { + controller: 'StatisticsController', + method: 'partiallyUpdateMemberSkills', + auth: 'jwt', + // access: constants.ADMIN_ROLES, + scopes: [MEMBERS.UPDATE, MEMBERS.ALL] + } + } } diff --git a/src/scripts/clear-tables.js b/src/scripts/clear-tables.js index 51da5b6..2bda079 100644 --- a/src/scripts/clear-tables.js +++ b/src/scripts/clear-tables.js @@ -1,62 +1,62 @@ -const prisma = require('../common/prisma').getClient(); +const prisma = require('../common/prisma').getClient() -async function main() { - console.log('Clearing address and financial data'); +async function main () { + console.log('Clearing address and financial data') // delete address and financial data - await prisma.memberAddress.deleteMany(); - await prisma.memberFinancial.deleteMany(); + await prisma.memberAddress.deleteMany() + await prisma.memberFinancial.deleteMany() // delete stats - console.log('Clearing member stats data'); - await prisma.memberCopilotStats.deleteMany(); - await prisma.memberMarathonStats.deleteMany(); - await prisma.memberDesignStatsItem.deleteMany(); - await prisma.memberDesignStats.deleteMany(); - await prisma.memberDevelopStatsItem.deleteMany(); - await prisma.memberDevelopStats.deleteMany(); - await prisma.memberSrmChallengeDetail.deleteMany(); - await prisma.memberSrmDivisionDetail.deleteMany(); - await prisma.memberSrmStats.deleteMany(); - await prisma.memberStats.deleteMany(); - await prisma.memberDataScienceStats.deleteMany(); + console.log('Clearing member stats data') + await prisma.memberCopilotStats.deleteMany() + await prisma.memberMarathonStats.deleteMany() + await prisma.memberDesignStatsItem.deleteMany() + await prisma.memberDesignStats.deleteMany() + await prisma.memberDevelopStatsItem.deleteMany() + await prisma.memberDevelopStats.deleteMany() + await prisma.memberSrmChallengeDetail.deleteMany() + await prisma.memberSrmDivisionDetail.deleteMany() + await prisma.memberSrmStats.deleteMany() + await prisma.memberStats.deleteMany() + await prisma.memberDataScienceStats.deleteMany() // delete stats history - console.log('Clearing member stats history data'); - await prisma.memberDataScienceHistoryStats.deleteMany(); - await prisma.memberDevelopHistoryStats.deleteMany(); - await prisma.memberHistoryStats.deleteMany(); + console.log('Clearing member stats history data') + await prisma.memberDataScienceHistoryStats.deleteMany() + await prisma.memberDevelopHistoryStats.deleteMany() + await prisma.memberHistoryStats.deleteMany() // delete traits - console.log('Clearing member traits data'); - await prisma.memberTraitBasicInfo.deleteMany(); - await prisma.memberTraitCommunity.deleteMany(); - await prisma.memberTraitDevice.deleteMany(); - await prisma.memberTraitEducation.deleteMany(); - await prisma.memberTraitLanguage.deleteMany(); - await prisma.memberTraitOnboardChecklist.deleteMany(); - await prisma.memberTraitPersonalization.deleteMany(); - await prisma.memberTraitServiceProvider.deleteMany(); - await prisma.memberTraitSoftware.deleteMany(); - await prisma.memberTraitWork.deleteMany(); - await prisma.memberTraits.deleteMany(); + console.log('Clearing member traits data') + await prisma.memberTraitBasicInfo.deleteMany() + await prisma.memberTraitCommunity.deleteMany() + await prisma.memberTraitDevice.deleteMany() + await prisma.memberTraitEducation.deleteMany() + await prisma.memberTraitLanguage.deleteMany() + await prisma.memberTraitOnboardChecklist.deleteMany() + await prisma.memberTraitPersonalization.deleteMany() + await prisma.memberTraitServiceProvider.deleteMany() + await prisma.memberTraitSoftware.deleteMany() + await prisma.memberTraitWork.deleteMany() + await prisma.memberTraits.deleteMany() // delete member skills - console.log('Clearing member skills data'); - await prisma.memberSkillLevel.deleteMany(); - await prisma.memberSkill.deleteMany(); + console.log('Clearing member skills data') + await prisma.memberSkillLevel.deleteMany() + await prisma.memberSkill.deleteMany() // delete member - console.log('Clearing maxRating and member data'); - await prisma.memberMaxRating.deleteMany(); - await prisma.member.deleteMany(); + console.log('Clearing maxRating and member data') + await prisma.memberMaxRating.deleteMany() + await prisma.member.deleteMany() // delete skills - console.log('Clearing skill data'); - await prisma.skillLevel.deleteMany(); - await prisma.skill.deleteMany(); - await prisma.skillCategory.deleteMany(); - await prisma.displayMode.deleteMany(); + console.log('Clearing skill data') + await prisma.skillLevel.deleteMany() + await prisma.skill.deleteMany() + await prisma.skillCategory.deleteMany() + await prisma.displayMode.deleteMany() // delete distribution - console.log('Clearing rating distribution data'); - await prisma.distributionStats.deleteMany(); + console.log('Clearing rating distribution data') + await prisma.distributionStats.deleteMany() - console.log('All done'); + console.log('All done') } -main(); +main() diff --git a/src/scripts/config.js b/src/scripts/config.js index 9f6b817..c56d6e1 100644 --- a/src/scripts/config.js +++ b/src/scripts/config.js @@ -1,5 +1,4 @@ - module.exports = { apiUrl: 'https://api.topcoder-dev.com/v5/members', fileLocation: '../member_data', @@ -11,7 +10,7 @@ module.exports = { // marathon 'sullyper', 'wleite', - //design + // design '5y5', 'iamtong', // develop @@ -27,5 +26,13 @@ module.exports = { 'Wendell', // admin 'Ghostar' + ], + distributions: [ + { track: 'DEVELOP', subTrack: 'CODE' }, + { track: 'DEVELOP', subTrack: 'ASSEMBLY_COMPETITION' }, + { track: 'DEVELOP', subTrack: 'DEVELOPMENT' }, + { track: 'DEVELOP', subTrack: 'DESIGN' }, + { track: 'DATA_SCIENCE', subTrack: 'MARATHON_MATCH' }, + { track: 'DATA_SCIENCE', subTrack: 'SRM' } ] -}; +} diff --git a/src/scripts/download.js b/src/scripts/download.js index 780e7a1..300ee66 100644 --- a/src/scripts/download.js +++ b/src/scripts/download.js @@ -1,105 +1,195 @@ -const https = require('https'); -const fs = require('fs'); -const path = require('path'); -const config = require('./config'); +const https = require('https') +const fs = require('fs') +const path = require('path') +const config = require('./config') -const BASE_URL = config.apiUrl; -const OUTPUT_DIR = config.fileLocation; -const DELAY_BETWEEN_REQUESTS = 500; -const MAX_RETRIES = 3; +const BASE_URL = config.apiUrl +const OUTPUT_DIR = config.fileLocation +const DELAY_BETWEEN_REQUESTS = 500 +const MAX_RETRIES = 3 -const handleList = config.handleList; +const handleList = config.handleList + +const distributions = config.distributions +const distributionDir = path.join(OUTPUT_DIR, 'distribution') + +const statsHistoryDir = path.join(OUTPUT_DIR, 'statsHistory') if (!fs.existsSync(OUTPUT_DIR)) { - fs.mkdirSync(OUTPUT_DIR, { recursive: true }); + fs.mkdirSync(OUTPUT_DIR, { recursive: true }) +} + +if (!fs.existsSync(distributionDir)) { + fs.mkdirSync(distributionDir, { recursive: true }) +} + +if (!fs.existsSync(statsHistoryDir)) { + fs.mkdirSync(statsHistoryDir, { recursive: true }) } -function sleep(ms) { - return new Promise(resolve => setTimeout(resolve, ms)); +function sleep (ms) { + return new Promise(resolve => setTimeout(resolve, ms)) } -async function fetchMemberData(handle, retryCount = 0) { - const url = `${BASE_URL}?page=1&perPage=10&handle=${encodeURIComponent(handle)}`; - +async function fetchData (url, retryCount = 0) { return new Promise((resolve, reject) => { - console.log(`Fetching data for handle: ${handle}`); + console.log(`Fetching data for url: ${url}`) https.get(url, (res) => { if (res.statusCode !== 200) { if (retryCount < MAX_RETRIES) { - const delay = Math.pow(2, retryCount) * 1000; - console.log(`Rate limited. Retrying handle ${handle} in ${delay}ms...`); + const delay = Math.pow(2, retryCount) * 1000 + console.log(`Rate limited. Retrying in ${delay}ms...`) setTimeout(() => { - fetchMemberData(handle, retryCount + 1) + fetchData(url, retryCount + 1) .then(resolve) - .catch(reject); - }, delay); - return; + .catch(reject) + }, delay) + return } - reject(new Error(`Failed to fetch handle ${handle}. Status code: ${res.statusCode}`)); - return; + reject(new Error(`Failed to fetch from ${url}. Status code: ${res.statusCode}`)) + return } - - let data = ''; + + let data = '' res.on('data', (chunk) => { - data += chunk; - }); - + data += chunk + }) + res.on('end', () => { try { - const result = JSON.parse(data); - resolve(result); + const result = JSON.parse(data) + resolve(result) } catch (err) { - reject(err); + reject(err) } - }); + }) }).on('error', (err) => { if (retryCount < MAX_RETRIES) { - const delay = Math.pow(2, retryCount) * 1000; - console.log(`Error fetching handle ${handle}. Retrying in ${delay}ms...`); + const delay = Math.pow(2, retryCount) * 1000 + console.log(`Error fetching from ${url}. Retrying in ${delay}ms...`) setTimeout(() => { - fetchMemberData(handle, retryCount + 1) + fetchData(url, retryCount + 1) .then(resolve) - .catch(reject); - }, delay); + .catch(reject) + }, delay) } else { - reject(err); + reject(err) } - }); - }); + }) + }) +} + +async function fetchMemberData (handle, retryCount = 0) { + const url = `${BASE_URL}?page=1&perPage=10&handle=${encodeURIComponent(handle)}` + return fetchData(url, retryCount) } -function saveMemberData(handle, data) { - const filename = path.join(OUTPUT_DIR, `${handle}.json`); - const content = JSON.stringify(data, null, 2); - +function saveMemberData (handle, data) { + const filename = path.join(OUTPUT_DIR, `${handle}.json`) + const content = JSON.stringify(data, null, 2) + return new Promise((resolve, reject) => { fs.writeFile(filename, content, (err) => { if (err) { - reject(err); + reject(err) } else { - console.log(`Saved data for ${handle} to ${filename}`); - resolve(); + console.log(`Saved data for ${handle} to ${filename}`) + resolve() } - }); - }); + }) + }) } -async function processHandleList() { +async function processHandleList () { for (const handle of handleList) { try { - const memberData = await fetchMemberData(handle); - - await saveMemberData(handle, memberData); - + const memberData = await fetchMemberData(handle) + + await saveMemberData(handle, memberData) + if (handle !== handleList[handleList.length - 1]) { - await sleep(DELAY_BETWEEN_REQUESTS); + await sleep(DELAY_BETWEEN_REQUESTS) + } + } catch (err) { + console.error(`Error processing handle ${handle}:`, err.message) + } + } + console.log('All handles processed!') +} + +async function fetchDistributionData (track, subTrack, retryCount = 0) { + const url = `${BASE_URL}/stats/distribution?subTrack=${subTrack}&track=${track}` + return fetchData(url, retryCount) +} + +async function saveDistributionData (track, subTrack, data) { + const filename = path.join(distributionDir, `${track}_${subTrack}.json`) + const content = JSON.stringify(data, null, 2) + + return new Promise((resolve, reject) => { + fs.writeFile(filename, content, (err) => { + if (err) { + reject(err) + } else { + console.log(`Saved data for ${track} - ${subTrack} to ${filename}`) + resolve() + } + }) + }) +} + +async function processDistribution () { + for (const track of distributions) { + try { + const distData = await fetchDistributionData(track.track, track.subTrack) + await saveDistributionData(track.track, track.subTrack, distData) + await sleep(DELAY_BETWEEN_REQUESTS) + } catch (err) { + console.error(`Error processing distribution for track ${track.subTrack}:`, err.message) + } + } + console.log('All track distribution processed!') +} + +async function fetchMemberStatsHistory (handle, retryCount = 0) { + const url = `${BASE_URL}/${handle}/stats/history` + return fetchData(url, retryCount) +} + +async function saveStatsHistory (handle, jsonData) { + const filename = path.join(statsHistoryDir, `${handle}.json`) + const content = JSON.stringify(jsonData, null, 2) + + return new Promise((resolve, reject) => { + fs.writeFile(filename, content, (err) => { + if (err) { + reject(err) + } else { + console.log(`Saved data for stats history ${handle} to ${filename}`) + resolve() } + }) + }) +} + +async function processStatsHistory () { + for (const handle of handleList) { + try { + const jsonData = await fetchMemberStatsHistory(handle) + await saveStatsHistory(handle, jsonData) + await sleep(DELAY_BETWEEN_REQUESTS) } catch (err) { - console.error(`Error processing handle ${handle}:`, err.message); + console.error(`Error processing stats history for member ${handle}:`, err.message) } } - console.log('All handles processed!'); + console.log('All member stats history processed!') +} + +async function main () { + await processHandleList() + await processDistribution() + await processStatsHistory() } -processHandleList(); +main() diff --git a/src/scripts/member-jwt.js b/src/scripts/member-jwt.js index c3c2ba8..a9daad8 100644 --- a/src/scripts/member-jwt.js +++ b/src/scripts/member-jwt.js @@ -5,62 +5,56 @@ const iss = 'https://api.topcoder-dev.com' const exp = 1980992788 const m2mPayload = { - "iss": iss, - "sub": "enjw1810eDz3XTwSO2Rn2Y9cQTrspn3B@clients", - "aud": "https://m2m.topcoder-dev.com/", - "iat": 1550906388, - "exp": exp, - "azp": "enjw1810eDz3XTwSO2Rn2Y9cQTrspn3B", - "scope": "all:members", - "gty": "client-credentials" + 'iss': iss, + 'sub': 'enjw1810eDz3XTwSO2Rn2Y9cQTrspn3B@clients', + 'aud': 'https://m2m.topcoder-dev.com/', + 'iat': 1550906388, + 'exp': exp, + 'azp': 'enjw1810eDz3XTwSO2Rn2Y9cQTrspn3B', + 'scope': 'all:user_profiles', + 'gty': 'client-credentials' } console.log('------------ Full M2M Token ------------') console.log(jwt.sign(m2mPayload, secret)) - const adminPayload = { - "roles": [ - "Topcoder User", - "Connect Support", - "administrator", - "testRole", - "aaa", - "tony_test_1", - "Connect Manager", - "Connect Admin", - "copilot", - "Connect Copilot Manager" + 'roles': [ + 'Topcoder User', + 'Connect Support', + 'administrator', + 'testRole', + 'aaa', + 'tony_test_1', + 'Connect Manager', + 'Connect Admin', + 'copilot', + 'Connect Copilot Manager' ], - "iss": iss, - "handle": "TonyJ", - "exp": exp, - "userId": "8547899", - "iat": 1549791611, - "email": "tjefts+fix@topcoder.com", - "jti": "f94d1e26-3d0e-46ca-8115-8754544a08f1" + 'iss': iss, + 'handle': 'TonyJ', + 'exp': exp, + 'userId': '8547899', + 'iat': 1549791611, + 'email': 'tjefts+fix@topcoder.com', + 'jti': 'f94d1e26-3d0e-46ca-8115-8754544a08f1' } - console.log('------------ Admin Token ------------') console.log(jwt.sign(adminPayload, secret)) - const userPayload = { - "roles": [ - "Topcoder User" + 'roles': [ + 'Topcoder User' ], - "iss": iss, - "handle": "phead", - "exp": exp, - "userId": "22742764", - "iat": 1549799569, - "email": "email@domain.com.z", - "jti": "9c4511c5-c165-4a1b-899e-b65ad0e02b55" + 'iss': iss, + 'handle': 'phead', + 'exp': exp, + 'userId': '22742764', + 'iat': 1549799569, + 'email': 'email@domain.com.z', + 'jti': '9c4511c5-c165-4a1b-899e-b65ad0e02b55' } console.log('------------ User Token ------------') console.log(jwt.sign(userPayload, secret)) - - - diff --git a/src/scripts/seed-data.js b/src/scripts/seed-data.js index 29f49a5..c7b9530 100644 --- a/src/scripts/seed-data.js +++ b/src/scripts/seed-data.js @@ -1,12 +1,16 @@ -const path = require('path'); -const fs = require('fs'); -const _ = require('lodash'); -const { v4: uuidv4 } = require('uuid'); -const config = require('./config'); -const prisma = require('../common/prisma').getClient(); +const path = require('path') +const fs = require('fs') +const _ = require('lodash') +const { v4: uuidv4 } = require('uuid') +const config = require('./config') +const prisma = require('../common/prisma').getClient() -const OUTPUT_DIR = config.fileLocation; -const handleList = config.handleList; +const OUTPUT_DIR = config.fileLocation +const handleList = config.handleList + +const distributions = config.distributions +const distributionDir = path.join(OUTPUT_DIR, 'distribution') +const statsHistoryDir = path.join(OUTPUT_DIR, 'statsHistory') const memberBasicData = [ 'userId', @@ -33,92 +37,93 @@ const memberBasicData = [ 'availableForGigs', 'skillScoreDeduction', 'namesAndHandleAppearance' -]; +] -const createdBy = 'migration'; +const createdBy = 'migration' -function readDate(milliseconds) { - return milliseconds ? new Date(milliseconds) : null; +function readDate (milliseconds) { + return milliseconds ? new Date(milliseconds) : null } -function buildMemberData(memberData, prismaData) { +function buildMemberData (memberData, prismaData) { // pick basic data - _.assign(prismaData, _.pick(memberData, memberBasicData)); + _.assign(prismaData, _.pick(memberData, memberBasicData)) // set status - prismaData.status = memberData.status; + prismaData.status = memberData.status // set mock emails - prismaData.email = `${memberData.handle}@topcoder.com`; + prismaData.email = `${memberData.handle}@topcoder.com` // set createdAt, updatedAt - prismaData.createdAt = readDate(memberData.createdAt); - prismaData.updatedAt = readDate(memberData.updatedAt); - prismaData.createdBy = memberData.createdBy ?? createdBy; + prismaData.createdAt = readDate(memberData.createdAt) + prismaData.updatedAt = readDate(memberData.updatedAt) + prismaData.createdBy = memberData.createdBy || createdBy // set max rating const maxRatingData = { ...memberData.maxRating, createdBy: createdBy - }; - maxRatingData.track = maxRatingData.track ?? ''; - maxRatingData.subTrack = maxRatingData.subTrack ?? ''; - prismaData.maxRating = { create: maxRatingData }; + } + maxRatingData.track = maxRatingData.track || '' + maxRatingData.subTrack = maxRatingData.subTrack || '' + prismaData.maxRating = { create: maxRatingData } const addressList = _.map(_.get(memberData, 'addresses', []), t => ({ ...t, - type: t.type ?? '', + type: t.type || '', createdAt: prismaData.createdAt, - createdBy, - })); - if (addressList.length > 0) { + createdBy + })) + if (addressList.length > 0) { prismaData.addresses = { create: addressList - }; + } } } - -async function createSkillData(memberData) { +async function createSkillData (memberData) { // use upsert to create skill, skillLevel, displayMode, skillCategory if (!memberData.skills || memberData.skills.length === 0) { - return; + return } for (let skillData of memberData.skills) { await prisma.skillCategory.upsert({ create: { id: skillData.category.id, name: skillData.category.name, createdBy }, update: { name: skillData.category.name }, where: { id: skillData.category.id } - }); + }) if (_.get(skillData, 'displayMode.id')) { await prisma.displayMode.upsert({ create: { id: skillData.displayMode.id, name: skillData.displayMode.name, createdBy }, - update: { name: skillData.displayMode.name, }, + update: { name: skillData.displayMode.name }, where: { id: skillData.displayMode.id } - }); + }) } for (let level of skillData.levels) { await prisma.skillLevel.upsert({ create: { id: level.id, name: level.name, description: level.description, createdBy }, - update: { name: level.name, description: level.description, }, + update: { name: level.name, description: level.description }, where: { id: level.id } - }); + }) } await prisma.skill.upsert({ - create: { - id: skillData.id, name: skillData.name, createdBy, + create: { + id: skillData.id, + name: skillData.name, + createdBy, category: { connect: { id: skillData.category.id } } }, update: { name: skillData.name }, where: { id: skillData.id } - }); + }) } } -function buildDevelopStatsData(jsonData) { +function buildDevelopStatsData (jsonData) { const ret = { challenges: jsonData.challenges, wins: jsonData.wins, mostRecentSubmission: readDate(jsonData.mostRecentSubmission), mostRecentEventDate: readDate(jsonData.mostRecentEventDate), - createdBy, - }; - const itemData = jsonData.subTracks; + createdBy + } + const itemData = jsonData.subTracks const items = _.map(itemData, t => ({ name: t.name, subTrackId: t.id, @@ -128,12 +133,12 @@ function buildDevelopStatsData(jsonData) { mostRecentEventDate: readDate(t.mostRecentEventDate), ...t.submissions, ...t.rank, - createdBy, - })); + createdBy + })) if (items.length > 0) { - ret.items = { create: items }; + ret.items = { create: items } } - return ret; + return ret } const designStatsItemFields = [ @@ -141,139 +146,139 @@ const designStatsItemFields = [ 'avgPlacement', 'screeningSuccessRate', 'submissionRate', 'winPercent' ] -function buildDesignStatsData(jsonData) { +function buildDesignStatsData (jsonData) { const ret = { challenges: jsonData.challenges, wins: jsonData.wins, mostRecentSubmission: readDate(jsonData.mostRecentSubmission), mostRecentEventDate: readDate(jsonData.mostRecentEventDate), createdBy - }; - const itemData = jsonData.subTracks; + } + const itemData = jsonData.subTracks const items = _.map(itemData, t => ({ subTrackId: t.id, mostRecentSubmission: readDate(t.mostRecentSubmission), mostRecentEventDate: readDate(t.mostRecentEventDate), ..._.pick(t, designStatsItemFields), - createdBy, - })); + createdBy + })) if (items.length > 0) { - ret.items = { create: items }; + ret.items = { create: items } } - return ret; + return ret } -function buildSrmData(jsonData) { +function buildSrmData (jsonData) { // missing 'mostRecentEventName' const prismaData = { ..._.pick(jsonData, ['challenges', 'wins', 'mostRecentEventName']), mostRecentSubmission: readDate(jsonData.mostRecentSubmission), mostRecentEventDate: readDate(jsonData.mostRecentEventDate), ...jsonData.rank, - createdBy, - }; + createdBy + } if (jsonData.challengeDetails && jsonData.challengeDetails.length > 0) { const items = _.map(jsonData.challengeDetails, t => ({ ...t, - createdBy, - })); - prismaData.challengeDetails = { create: items }; + createdBy + })) + prismaData.challengeDetails = { create: items } } // check division data if (jsonData.division2 && jsonData.division2.length > 0) { let items = _.map(jsonData.division2, t => ({ ...t, divisionName: 'division2', - createdBy, - })); + createdBy + })) if (jsonData.division1 && jsonData.division1.length > 0) { const newItems = _.map(jsonData.division1, t => ({ ...t, divisionName: 'division1', - createdBy, - })); - items = _.concat(items, newItems); + createdBy + })) + items = _.concat(items, newItems) } - prismaData.divisions = { create: items }; + prismaData.divisions = { create: items } } - return prismaData; + return prismaData } -function buildMarathonData(jsonData) { +function buildMarathonData (jsonData) { // missing 'mostRecentEventName' return { ..._.pick(jsonData, ['challenges', 'wins', 'mostRecentEventName']), mostRecentSubmission: readDate(jsonData.mostRecentSubmission), mostRecentEventDate: readDate(jsonData.mostRecentEventDate), ...jsonData.rank, - createdBy, - }; + createdBy + } } -async function createSkills(memberData) { +async function createSkills (memberData) { // set skills - const memberSkillData = []; - const memberSkillLevels = []; + const memberSkillData = [] + const memberSkillLevels = [] if (!memberData.skills || memberData.skills.length === 0) { - return; + return } for (let skillData of memberData.skills) { - const memberSkillId = uuidv4(); + const memberSkillId = uuidv4() const memberSkill = { id: memberSkillId, userId: memberData.userId, skillId: skillData.id, - createdBy, - }; + createdBy + } if (skillData.displayMode) { - memberSkill.displayModeId = skillData.displayMode.id; + memberSkill.displayModeId = skillData.displayMode.id } - memberSkillData.push(memberSkill); + memberSkillData.push(memberSkill) for (let level of skillData.levels) { memberSkillLevels.push({ memberSkillId, skillLevelId: level.id, - createdBy, - }); + createdBy + }) } } await prisma.memberSkill.createMany({ data: memberSkillData - }); + }) await prisma.memberSkillLevel.createMany({ data: memberSkillLevels }) } -async function createStats(memberData, maxRatingId) { - let statsData = {}; +async function createStats (memberData, maxRatingId) { + let statsData = {} if (memberData.stats && memberData.stats.length > 0) { - statsData = memberData.stats[0]; + statsData = memberData.stats[0] } if (!statsData.userId) { - return; + return } const prismaData = { member: { connect: { userId: memberData.userId } }, maxRating: { connect: { id: maxRatingId } }, challenges: statsData.challenges, wins: statsData.wins, - createdBy, - }; + createdBy + } if (_.get(statsData, 'COPILOT.contests')) { prismaData.copilot = { create: { ...statsData.COPILOT, createdBy } - }; + } } if (_.get(statsData, 'DEVELOP.challenges')) { - const developData = buildDevelopStatsData(statsData.DEVELOP); - prismaData.develop = { create: developData }; + const developData = buildDevelopStatsData(statsData.DEVELOP) + prismaData.develop = { create: developData } } if (_.get(statsData, 'DESIGN.challenges')) { - const designData = buildDesignStatsData(statsData.DESIGN); - prismaData.design = { create: designData }; + const designData = buildDesignStatsData(statsData.DESIGN) + prismaData.design = { create: designData } } if (_.get(statsData, 'DATA_SCIENCE.challenges')) { const dataScienceData = { @@ -282,57 +287,258 @@ async function createStats(memberData, maxRatingId) { // mostRecentEventName: statsData.DATA_SCIENCE.mostRecentEventName, mostRecentSubmission: readDate(statsData.DATA_SCIENCE.mostRecentSubmission), mostRecentEventDate: readDate(statsData.DATA_SCIENCE.mostRecentEventDate), - createdBy, - }; + createdBy + } if (_.get(statsData, 'DATA_SCIENCE.SRM.challenges')) { - const jsonData = statsData.DATA_SCIENCE.SRM; - dataScienceData.srm = { create: buildSrmData(jsonData) }; + const jsonData = statsData.DATA_SCIENCE.SRM + dataScienceData.srm = { create: buildSrmData(jsonData) } } if (_.get(statsData, 'DATA_SCIENCE.MARATHON_MATCH.challenges')) { - const jsonData = statsData.DATA_SCIENCE.MARATHON_MATCH; - dataScienceData.marathon = { create: buildMarathonData(jsonData) }; + const jsonData = statsData.DATA_SCIENCE.MARATHON_MATCH + dataScienceData.marathon = { create: buildMarathonData(jsonData) } } - prismaData.dataScience = { create: dataScienceData }; + prismaData.dataScience = { create: dataScienceData } } await prisma.memberStats.create({ data: prismaData - }); + }) } -async function importMember(handle) { - console.log(`Import member data for ${handle}`); - const filename = path.join(OUTPUT_DIR, `${handle}.json`); - const rawData = fs.readFileSync(filename, 'utf8'); - const dataList = JSON.parse(rawData); - const memberData = _.find(dataList, t => t.handle === handle); +async function importMember (handle) { + console.log(`Import member data for ${handle}`) + const filename = path.join(OUTPUT_DIR, `${handle}.json`) + const rawData = fs.readFileSync(filename, 'utf8') + const dataList = JSON.parse(rawData) + const memberData = _.find(dataList, t => t.handle === handle) if (!memberData) { - console.log(`Can\'t find member data for user ${handle}`); - return; + console.log(`Can't find member data for user ${handle}`) + return } // get skill data and create them - await createSkillData(memberData); + await createSkillData(memberData) // build prisma data structure for this member - const prismaData = {}; - buildMemberData(memberData, prismaData); + const prismaData = {} + buildMemberData(memberData, prismaData) const member = await prisma.member.create({ data: prismaData, include: { maxRating: true } - }); - - await createStats(memberData, member.maxRating.id); - await createSkills(memberData); - console.log(`Import member data complete for ${handle}`); + }) + + await createStats(memberData, member.maxRating.id) + await createSkills(memberData) + console.log(`Import member data complete for ${handle}`) +} + +async function importDistributions () { + const statsList = [] + for (let track of distributions) { + const filename = path.join(distributionDir, `${track.track}_${track.subTrack}.json`) + const rawData = fs.readFileSync(filename, 'utf8') + const data = JSON.parse(rawData) + // convert from json to db format + const item = _.pick(data, ['track', 'subTrack']) + _.forEach(data.distribution, (value, key) => { + item[key] = value + }) + item.createdBy = createdBy + item.createdAt = new Date() + statsList.push(item) + } + console.log('Importing distribution stats') + await prisma.distributionStats.createMany({ + data: statsList + }) + console.log('Importing distribution stats complete') +} + +async function importStatsHistory () { + for (let handle of handleList) { + console.log(`Import stats history for member ${handle}`) + const filename = path.join(statsHistoryDir, `${handle}.json`) + const rawData = fs.readFileSync(filename, 'utf8') + const data = JSON.parse(rawData) + if (!data || data.length === 0) { + continue + } + const statsData = data[0] + const prismaData = { + userId: statsData.userId, + groupId: statsData.groupId, + isPrivate: false, + createdBy, + createdAt: new Date() + } + // handle develop stats history + if (statsData.DEVELOP && statsData.DEVELOP.subTracks && + statsData.DEVELOP.subTracks.length > 0) { + const devItems = [] + _.forEach(statsData.DEVELOP.subTracks, t => { + const subTrackId = t.id + const subTrack = t.name + _.forEach(t.history, h => { + devItems.push({ + subTrackId, + subTrack, + createdBy, + ..._.pick(h, ['challengeId', 'challengeName', 'newRating']), + ratingDate: new Date(h.ratingDate) + }) + }) + }) + prismaData.develop = { + createMany: { data: devItems } + } + } + + // handle data science stats history + const dataScienceItems = [] + const srmHistory = _.get(statsData, 'DATA_SCIENCE.SRM.history', []) + const marathonHistory = _.get(statsData, 'DATA_SCIENCE.MARATHON_MATCH.history', []) + if (srmHistory.length > 0) { + _.forEach(srmHistory, t => { + dataScienceItems.push({ + subTrack: 'SRM', + subTrackId: 0, + createdBy, + ..._.pick(t, ['challengeId', 'challengeName', 'rating', 'placement', 'percentile']), + date: new Date(t.date) + }) + }) + } + if (marathonHistory.length > 0) { + _.forEach(marathonHistory, t => { + dataScienceItems.push({ + subTrack: 'MARATHON_MATCH', + subTrackId: 0, + createdBy, + ..._.pick(t, ['challengeId', 'challengeName', 'rating', 'placement', 'percentile']), + date: new Date(t.date) + }) + }) + } + if (dataScienceItems.length > 0) { + prismaData.dataScience = { + createMany: { data: dataScienceItems } + } + } + + await prisma.memberHistoryStats.create({ + data: prismaData + }) + } + console.log('Importing stats history complete') +} + +/** + * This function will create mock data for member stats history. + */ +async function mockPrivateStatsHistory () { + console.log('Creating mock stats history data for ACRush') + await prisma.memberHistoryStats.create({ + data: { + userId: 19849563, + groupId: 20000001, + isPrivate: true, + createdBy, + createdAt: new Date(), + develop: { + createMany: { + data: [{ + subTrackId: 999, + subTrack: 'secret track', + challengeId: 99999, + challengeName: 'Secret Challenge', + newRating: 3000, + ratingDate: new Date(), + createdBy + }] + } + }, + dataScience: { + createMany: { + data: [{ + challengeId: 99998, + challengeName: 'Secret SRM', + date: new Date(), + rating: 2999, + placement: 1, + percentile: 100, + subTrack: 'SRM', + subTrackId: 0, + createdBy + }, { + challengeId: 99997, + challengeName: 'Secret Marathon', + date: new Date(), + rating: 2998, + placement: 1, + percentile: 100, + subTrack: 'MARATHON_MATCH', + subTrackId: 0, + createdBy + }] + } + } + } + }) +} + +async function mockPrivateStats () { + console.log('Creating mock stats data for ACRush') + await prisma.memberStats.create({ + data: { + userId: 19849563, + groupId: 20000001, + challenges: 1000, + wins: 1000, + isPrivate: true, + createdBy, + createdAt: new Date(), + develop: { + create: { + challenges: 999, + wins: 999, + createdBy + } + }, + dataScience: { + create: { + challenges: 999, + wins: 999, + createdBy + } + }, + copilot: { + create: { + contests: 100, + projects: 100, + failures: 0, + reposts: 0, + activeContests: 1, + activeProjects: 1, + fulfillment: 100, + createdBy + } + } + } + }) } -async function main() { +async function main () { for (let handle of handleList) { - await importMember(handle); + await importMember(handle) } - console.log('All done'); + await importDistributions() + await importStatsHistory() + // create mock data for private stats history + await mockPrivateStatsHistory() + // create mock data for private stats + await mockPrivateStats() + console.log('All done') } -main(); +main() diff --git a/src/services/MemberService.js b/src/services/MemberService.js index e0408d0..2bcd4e8 100644 --- a/src/services/MemberService.js +++ b/src/services/MemberService.js @@ -11,29 +11,23 @@ const helper = require('../common/helper') const logger = require('../common/logger') const errors = require('../common/errors') const constants = require('../../app-constants') -const LookerApi = require('../common/LookerApi') const memberTraitService = require('./MemberTraitService') const mime = require('mime-types') -const fileType = require('file-type'); +const fileType = require('file-type') const fileTypeChecker = require('file-type-checker') const sharp = require('sharp') const { bufferContainsScript } = require('../common/image') -const prisma = require('../common/prisma').getClient(); - -const lookerService = new LookerApi(logger) +const prismaHelper = require('../common/prismaHelper') +const prisma = require('../common/prisma').getClient() const MEMBER_FIELDS = ['userId', 'handle', 'handleLower', 'firstName', 'lastName', 'tracks', 'status', 'addresses', 'description', 'email', 'homeCountryCode', 'competitionCountryCode', 'photoURL', 'verified', 'maxRating', - 'createdAt', 'createdBy', 'updatedAt', 'updatedBy', 'loginCount', 'lastLoginDate', 'skills', 'availableForGigs', + 'createdAt', 'createdBy', 'updatedAt', 'updatedBy', 'loginCount', 'lastLoginDate', 'skills', 'availableForGigs', 'skillScoreDeduction', 'namesAndHandleAppearance'] const INTERNAL_MEMBER_FIELDS = ['newEmail', 'emailVerifyToken', 'emailVerifyTokenDate', 'newEmailVerifyToken', 'newEmailVerifyTokenDate', 'handleSuggest'] -const auditFields = [ - 'createdAt', 'createdBy', 'updatedAt', 'updatedBy' -]; - /** * Clean member fields according to current user. * @param {Object} currentUser the user who performs operation @@ -47,22 +41,22 @@ function cleanMember (currentUser, member, selectFields) { response = _.pick(response, selectFields) } - if(response.addresses){ + if (response.addresses) { response.addresses.forEach((address) => { - if(address.stateCode===null){ - address.stateCode="" + if (address.stateCode === null) { + address.stateCode = '' } - if(address.streetAddr1===null){ - address.streetAddr1="" + if (address.streetAddr1 === null) { + address.streetAddr1 = '' } - if(address.streetAddr2===null){ - address.streetAddr2="" + if (address.streetAddr2 === null) { + address.streetAddr2 = '' } - if(address.city===null){ - address.city="" + if (address.city === null) { + address.city = '' } - if(address.zip===null){ - address.zip="" + if (address.zip === null) { + address.zip = '' } }) } @@ -93,49 +87,15 @@ function omitMemberAttributes (currentUser, mb) { * Get member skills with user id * @param {BigInt} userId prisma BigInt userId */ -async function getMemberSkills(userId) { +async function getMemberSkills (userId) { const skillList = await prisma.memberSkill.findMany({ where: { userId: userId }, - include: { - levels: { include: { skillLevel: true } }, - skill: { include: { category: true } }, - displayMode: true - } - }); + include: prismaHelper.skillsIncludeParams + }) // convert to response format - return _.map(skillList, item => { - const ret = _.pick(item.skill, ['id', 'name']); - ret.category = _.pick(item.skill.category, ['id', 'name']); - if (item.displayMode) { - ret.displayMode = _.pick(item.displayMode, ['id', 'name']); - } - // set levels - if (item.levels && item.levels.length > 0) { - ret.levels = _.map(item.levels, - lvl => _.pick(lvl.skillLevel, ['id', 'name', 'description'])); - } - return ret; - }); -} - -/** - * Convert member prisma data to response - * @param {Object} member prisma member data - */ -function convertPrisma(member) { - member.userId = helper.bigIntToNumber(member.userId); - member.createdAt = member.createdAt.getTime(); - member.updatedAt = member.updatedAt.getTime(); - if (member.maxRating) { - member.maxRating = _.omit(member.maxRating, - ['id', 'userId', ...auditFields]) - } - if (member.addresses) { - member.addresses = _.map(member.addresses, d => _.omit(d, - ['id', 'userId', ...auditFields])) - } + return prismaHelper.buildMemberSkills(skillList) } /** @@ -163,22 +123,17 @@ async function getMember (currentUser, handle, query) { } // To keep original business logic, let's use findMany - const member = await prisma.member.findUnique(prismaFilter); + const member = await prisma.member.findUnique(prismaFilter) if (!member || !member.userId) { throw new errors.NotFoundError(`Member with handle: "${handle}" doesn't exist`) } // convert members data structure to response - convertPrisma(member); + prismaHelper.convertMember(member) // get member skills if (_.includes(selectFields, 'skills')) { member.skills = await getMemberSkills(member.userId) } - try{ - member.verified = await lookerService.isMemberVerified(member.userId) - } catch (e) { - console.log("Error when contacting Looker: " + JSON.stringify(e)) - } // clean member fields according to current user return cleanMember(currentUser, member, selectFields) } @@ -199,33 +154,33 @@ getMember.schema = { * @returns {Object} the member profile data */ async function getProfileCompleteness (currentUser, handle, query) { - // Don't pass the query parameter to the trait service - we want *all* traits and member data + // Don't pass the query parameter to the trait service - we want *all* traits and member data // to come back for calculation of the completeness const memberTraits = await memberTraitService.getTraits(currentUser, handle, {}) // Avoid getting the member stats, since we don't need them here, and performance is // better without them - const memberFields = {'fields': 'userId,handle,handleLower,photoURL,description,skills,verified,availableForGigs'} + const memberFields = { 'fields': 'userId,handle,handleLower,photoURL,description,skills,verified,availableForGigs' } const member = await getMember(currentUser, handle, memberFields) - //Used for calculating the percentComplete + // Used for calculating the percentComplete let completeItems = 0 // Magic number - 6 total items for profile "completeness" // TODO: Bump this back up to 7 once verification is implemented const totalItems = 6 - response = {} + let response = {} response.userId = member.userId response.handle = member.handle - data = {} + let data = {} // We use this to hold the items not completed, and then randomly pick one // to use when showing the "toast" to prompt the user to complete an item in their profile - showToast = [] - //Set default values + let showToast = [] + // Set default values // TODO: Turn this back on once we have verification flow implemented elsewhere - //data.verified = false + // data.verified = false data.skills = false data.gigAvailability = false @@ -234,41 +189,39 @@ async function getProfileCompleteness (currentUser, handle, query) { data.workHistory = false data.education = false - if(member.availableForGigs != null){ + if (member.availableForGigs != null) { completeItems += 1 data.gigAvailability = true } _.forEach(memberTraits, (item) => { - if(item.traitId=="education" && item.traits.data.length > 0 && data.education == false){ + if (item.traitId === 'education' && item.traits.data.length > 0 && data.education === false) { completeItems += 1 data.education = true } - if(item.traitId=="work" && item.traits.data.length > 0 && data.workHistory==false){ + if (item.traitId === 'work' && item.traits.data.length > 0 && !data.workHistory === false) { completeItems += 1 data.workHistory = true } - }) // Push on the incomplete traits for picking a random toast to show - if(!data.education){ - showToast.push("education") + if (!data.education) { + showToast.push('education') } - if(!data.workHistory){ - showToast.push("workHistory") + if (!data.workHistory) { + showToast.push('workHistory') } - if(!data.gigAvailability){ - showToast.push("gigAvailability") + if (!data.gigAvailability) { + showToast.push('gigAvailability') } // TODO: Do we use the short bio or the "description" field of the member object? - if(member.description && data.bio==false) { + if (member.description && !data.bio) { completeItems += 1 data.bio = true - } - else{ - showToast.push("bio") + } else { + showToast.push('bio') } // TODO: Turn this back on once verification is implemented @@ -280,32 +233,29 @@ async function getProfileCompleteness (currentUser, handle, query) { // showToast.push("verified") // } - //Must have at least 3 skills entered - if(member.skills && member.skills.length >= 3 ){ + // Must have at least 3 skills entered + if (member.skills && member.skills.length >= 3) { completeItems += 1 - data.skills=true - } - else{ - showToast.push("skills") + data.skills = true + } else { + showToast.push('skills') } - if(member.photoURL){ + if (member.photoURL) { completeItems += 1 data.profilePicture = true - } - else{ - showToast.push("profilePicture") + } else { + showToast.push('profilePicture') } // Calculate the percent complete and round to 2 decimal places data.percentComplete = Math.round(completeItems / totalItems * 100) / 100 - response.data=data + response.data = data // Pick a random, unfinished item to show in the toast after the user logs in - if(showToast.length > 0 && !query.toast){ + if (showToast.length > 0 && !query.toast) { response.showToast = showToast[Math.floor(Math.random() * showToast.length)] - } - else if(query.toast){ + } else if (query.toast) { response.showToast = query.toast } @@ -328,14 +278,14 @@ getProfileCompleteness.schema = { * @returns {Object} uid_signature: user's hashed userId */ async function getMemberUserIdSignature (currentUser, query) { - const hashingSecret = config.HASHING_KEYS[(query.type || '').toUpperCase()]; + const hashingSecret = config.HASHING_KEYS[(query.type || '').toUpperCase()] - const userid_hash = crypto - .createHmac('sha256', hashingSecret) - .update(currentUser.userId) - .digest('hex'); + const userIdHash = crypto + .createHmac('sha256', hashingSecret) + .update(currentUser.userId) + .digest('hex') - return { uid_signature: userid_hash }; + return { uid_signature: userIdHash } } getMemberUserIdSignature.schema = { @@ -369,7 +319,7 @@ async function updateMember (currentUser, handle, query, data) { if (emailChanged) { const emailCount = await prisma.member.count({ where: { email: data.email } - }); + }) if (emailCount > 0) { throw new errors.EmailRegisteredError(`Email "${data.email}" is already registered`) } @@ -391,7 +341,7 @@ async function updateMember (currentUser, handle, query, data) { // clear current addresses await tx.memberAddress.deleteMany({ where: { userId: member.userId } - }); + }) // create new addresses await tx.memberAddress.createMany({ data: _.map(data.addresses, t => ({ @@ -402,18 +352,19 @@ async function updateMember (currentUser, handle, query, data) { }) } // clear addresses so it doesn't affect prisma.udpate - delete data.addresses; + delete data.addresses - return await tx.member.update({ + return tx.member.update({ where: { userId: member.userId }, data, include: { addresses: true } - }); - }); - - // convert prisma data to response format - convertPrisma(result); + }) + }) + // convert prisma data to response format + prismaHelper.convertMember(result) + // send data to event bus + await helper.postBusEvent(constants.TOPICS.MemberUpdated, result) if (emailChanged) { // send email verification to old email await helper.postBusEvent(constants.TOPICS.EmailChanged, { @@ -516,10 +467,12 @@ async function verifyEmail (currentUser, handle, query) { member.updatedAt = new Date() member.updatedBy = currentUser.userId || currentUser.sub // update member in db - await prisma.member.update({ + const result = await prisma.member.update({ where: { userId: member.userId }, data: member }) + prismaHelper.convertMember(result) + await helper.postBusEvent(constants.TOPICS.MemberUpdated, result) return { emailChangeCompleted, verifiedEmail } } @@ -557,16 +510,16 @@ async function uploadPhoto (currentUser, handle, files) { } characters.`) } // mime type validation - const type = await fileType.fromBuffer(file.data); - const fileContentType = type.mime; + const type = await fileType.fromBuffer(file.data) + const fileContentType = type.mime if (!fileContentType || !fileContentType.startsWith('image/')) { throw new errors.BadRequestError('The photo should be an image file.') } // content type validation const isImage = fileTypeChecker.validateFileType( file.data, - ['jpeg', 'png'], - ); + ['jpeg', 'png'] + ) if (!isImage) { throw new errors.BadRequestError('The photo should be an image file, either jpg, jpeg or png.') } @@ -578,18 +531,18 @@ async function uploadPhoto (currentUser, handle, files) { } const sanitizedBuffer = await sharp(file.data) - .toBuffer(); + .toBuffer() if (bufferContainsScript(sanitizedBuffer)) { throw new errors.BadRequestError('Sanitized photo should not contain any scripts or iframes.') } - + // upload photo to S3 // const photoURL = await helper.uploadPhotoToS3(file.data, file.mimetype, file.name) const photoURL = await helper.uploadPhotoToS3(sanitizedBuffer, file.mimetype, fileName) // update member's photoURL - await prisma.member.update({ + const result = await prisma.member.update({ where: { userId: member.userId }, data: { photoURL, @@ -597,6 +550,9 @@ async function uploadPhoto (currentUser, handle, files) { updatedBy: currentUser.userId || currentUser.sub } }) + prismaHelper.convertMember(result) + // post bus event + await helper.postBusEvent(constants.TOPICS.MemberUpdated, result) return { photoURL } } diff --git a/src/services/MemberTraitService.js b/src/services/MemberTraitService.js index c015888..358cc81 100644 --- a/src/services/MemberTraitService.js +++ b/src/services/MemberTraitService.js @@ -16,10 +16,10 @@ const TRAIT_IDS = ['basic_info', 'education', 'work', 'communities', 'languages' const TRAIT_FIELDS = ['userId', 'traitId', 'categoryName', 'traits', 'createdAt', 'updatedAt', 'createdBy', 'updatedBy'] -const DeviceType = ['Console', 'Desktop', 'Laptop', 'Smartphone', 'Tablet', 'Wearable']; -const SoftwareType = ['DeveloperTools', 'Browser', 'Productivity', 'GraphAndDesign', 'Utilities']; -const ServiceProviderType = ['InternetServiceProvider', 'MobileCarrier', 'Television', 'FinancialInstitution', 'Other']; -const WorkIndustryType = ['Banking', 'ConsumerGoods', 'Energy', 'Entertainment', 'HealthCare', 'Pharma', 'PublicSector', 'TechAndTechnologyService', 'Telecoms', 'TravelAndHospitality']; +const DeviceType = ['Console', 'Desktop', 'Laptop', 'Smartphone', 'Tablet', 'Wearable'] +const SoftwareType = ['DeveloperTools', 'Browser', 'Productivity', 'GraphAndDesign', 'Utilities'] +const ServiceProviderType = ['InternetServiceProvider', 'MobileCarrier', 'Television', 'FinancialInstitution', 'Other'] +const WorkIndustryType = ['Banking', 'ConsumerGoods', 'Energy', 'Entertainment', 'HealthCare', 'Pharma', 'PublicSector', 'TechAndTechnologyService', 'Telecoms', 'TravelAndHospitality'] /** * Used to generate prisma query parameters @@ -52,7 +52,7 @@ const traitIdModelMap = { const auditFields = [ 'createdAt', 'updatedAt', 'createdBy', 'updatedBy' -]; +] /** * Convert prisma data to response format @@ -61,14 +61,14 @@ const auditFields = [ * @param {Array} traitIds trait id list * @returns trait data in response */ -function convertPrismaToRes(traitData, userId, traitIds = TRAIT_IDS) { +function convertPrismaToRes (traitData, userId, traitIds = TRAIT_IDS) { // reverse traitIdPrismaMap - const prismaTraitIdMap = {}; + const prismaTraitIdMap = {} for (let key of Object.keys(traitIdPrismaMap)) { prismaTraitIdMap[traitIdPrismaMap[key]] = key } // read from prisma data - const ret = []; + const ret = [] for (let key of Object.keys(prismaTraitIdMap)) { if (!traitData[key] || traitData[key].length === 0) { continue @@ -91,7 +91,7 @@ function convertPrismaToRes(traitData, userId, traitIds = TRAIT_IDS) { } traitItem.traits.data = _.map(prismaValues, t => _.omit(t, ['id', 'memberTraitId', ...auditFields])) - + ret.push(traitItem) } // handle subscription and hobby fields @@ -120,7 +120,7 @@ function convertPrismaToRes(traitData, userId, traitIds = TRAIT_IDS) { }) } // handle special data - if (_.includes(traitIds, 'personalization') && + if (_.includes(traitIds, 'personalization') && !_.isEmpty(traitData.personalization) ) { const collectInfo = {} @@ -139,8 +139,8 @@ function convertPrismaToRes(traitData, userId, traitIds = TRAIT_IDS) { }) } _.forEach(ret, r => { - r.createdAt = r.createdAt?.getTime() - r.updatedAt = r.updatedAt?.getTime() + r.createdAt = r.createdAt ? r.createdAt.getTime() : null + r.updatedAt = r.updatedAt ? r.updatedAt.getTime() : null }) return ret } @@ -151,18 +151,20 @@ function convertPrismaToRes(traitData, userId, traitIds = TRAIT_IDS) { * @param {Array} traitIds string array * @returns member trait prisma data */ -async function queryTraits(userId, traitIds=TRAIT_IDS) { +async function queryTraits (userId, traitIds = TRAIT_IDS) { // build prisma query const prismaFilter = { where: { userId }, include: {} - }; + } // for each trait id, get prisma model and put it into "include" - _.forEach(_.pick(traitIdPrismaMap, traitIds), t => prismaFilter.include[t] = true); + _.forEach(_.pick(traitIdPrismaMap, traitIds), t => { + prismaFilter.include[t] = true + }) const traitData = await prisma.memberTraits.findUnique(prismaFilter) if (!traitData) { // trait data not found. Directly return. - return { id: null, data: []} + return { id: null, data: [] } } // convert trait data to response format return { @@ -237,7 +239,6 @@ getTraits.schema = { }) } - /** * Build prisma data for creating/updating traits * @param {Object} data query data @@ -245,8 +246,8 @@ getTraits.schema = { * @param {Array} result result * @returns prisma data */ -function buildTraitPrismaData(data, operatorId, result) { - const prismaData = {}; +function buildTraitPrismaData (data, operatorId, result) { + const prismaData = {} _.forEach(data, (item) => { const traitId = item.traitId const modelKey = traitIdPrismaMap[traitId] @@ -256,7 +257,7 @@ function buildTraitPrismaData(data, operatorId, result) { t.updatedBy = operatorId }) prismaData[modelKey] = { - createMany: { + createMany: { data: item.traits.data } } @@ -277,7 +278,7 @@ function buildTraitPrismaData(data, operatorId, result) { } }) prismaData['personalization'] = { - createMany: { + createMany: { data: valuePairs } } @@ -287,7 +288,6 @@ function buildTraitPrismaData(data, operatorId, result) { return prismaData } - /** * Create member traits. * @param {Object} currentUser the user who performs operation @@ -327,6 +327,21 @@ async function createTraits (currentUser, handle, data) { data: prismaData }) } + // send data to event bus + for (let item of data) { + const trait = { ...item } + trait.userId = helper.bigIntToNumber(member.userId) + trait.createdBy = Number(currentUser.userId || config.TC_WEBSERVICE_USERID) + if (trait.traits) { + trait.traits = { 'traitId': trait.traitId, 'data': trait.traits.data } + } else { + trait.traits = { 'traitId': trait.traitId, 'data': [] } + } + // convert date time + trait.createdAt = new Date().getTime() + // post bus event + await helper.postBusEvent(constants.TOPICS.MemberTraitCreated, trait) + } // merge result existingTraits = _.concat(existingTraits, data) @@ -394,7 +409,7 @@ const traitSchemas = { })) } -const traitSchemaSwitch = _.map(_.keys(traitSchemas, +const traitSchemaSwitch = _.map(_.keys(traitSchemas, k => ({ is: k, then: traitSchemas[k] }))) createTraits.schema = { @@ -513,7 +528,7 @@ async function removeTraits (currentUser, handle, query) { }) existingTraits = _.filter(existingTraits, t => !traitIds.includes(t.traitId)) - + await updateSkillScoreDeduction(currentUser, member, existingTraits) // post bus event if (memberProfileTraitIds.length > 0) { @@ -547,36 +562,36 @@ async function updateSkillScoreDeduction (currentUser, member, existingTraits) { let education = false let traits = [] - if(existingTraits){ + if (existingTraits) { traits = existingTraits } else { traits = await getTraits(currentUser, member.handle, {}) } - let education_trait = _.find(traits, function(trait){ return trait.traitId == "education"}) + let educationTrait = _.find(traits, function (trait) { return trait.traitId === 'education' }) - if(education_trait && education == false){ + if (educationTrait && education === false) { education = true } - let work_trait = _.find(traits, function(trait){ return trait.traitId == "work"}) + let workTrait = _.find(traits, function (trait) { return trait.traitId === 'work' }) - if(work_trait && workHistory==false){ + if (workTrait && workHistory === false) { workHistory = true } // TAL-77 : missing experience, reduce match by 2% - if(!workHistory) { + if (!workHistory) { skillScoreDeduction = skillScoreDeduction - 0.02 } // TAL-77 : missing education, reduce match by 2% - if(!education) { + if (!education) { skillScoreDeduction = skillScoreDeduction - 0.02 - } - - // Only update if the value is new or has changed - if(member.skillScoreDeduction === null || member.skillScoreDeduction != skillScoreDeduction){ + } + + // Only update if the value is new or has changed + if (member.skillScoreDeduction === null || member.skillScoreDeduction !== skillScoreDeduction) { await prisma.member.update({ where: { userId: member.userId }, data: { diff --git a/src/services/SearchService.js b/src/services/SearchService.js new file mode 100644 index 0000000..0e5e1ba --- /dev/null +++ b/src/services/SearchService.js @@ -0,0 +1,467 @@ +/** + * This service provides operations of statistics. + */ + +const _ = require('lodash') +const Joi = require('joi') +const config = require('config') +const Prisma = require('@prisma/client') +const helper = require('../common/helper') +const logger = require('../common/logger') +const errors = require('../common/errors') +const prismaHelper = require('../common/prismaHelper') +const prisma = require('../common/prisma').getClient() + +const MEMBER_FIELDS = ['userId', 'handle', 'handleLower', 'firstName', 'lastName', + 'status', 'addresses', 'photoURL', 'homeCountryCode', 'competitionCountryCode', + 'description', 'email', 'tracks', 'maxRating', 'wins', 'createdAt', 'createdBy', + 'updatedAt', 'updatedBy', 'skills', 'stats', 'verified', 'loginCount', 'lastLoginDate', + 'numberOfChallengesWon', 'skillScore', 'numberOfChallengesPlaced', 'availableForGigs', 'namesAndHandleAppearance'] + +const MEMBER_SORT_BY_FIELDS = ['userId', 'country', 'handle', 'firstName', 'lastName', + 'numberOfChallengesWon', 'numberOfChallengesPlaced', 'skillScore'] + +const MEMBER_AUTOCOMPLETE_FIELDS = ['userId', 'handle', 'handleLower', + 'status', 'email', 'createdAt', 'updatedAt'] + +var MEMBER_STATS_FIELDS = ['userId', 'handle', 'handleLower', 'maxRating', + 'numberOfChallengesWon', 'numberOfChallengesPlaced', + 'challenges', 'wins', 'DEVELOP', 'DESIGN', 'DATA_SCIENCE', 'COPILOT'] + +function omitMemberAttributes (currentUser, query, allowedValues) { + // validate and parse fields param + let fields = helper.parseCommaSeparatedString(query.fields, allowedValues) || allowedValues + // if current user is not admin and not M2M, then exclude the admin/M2M only fields + if (!currentUser || (!currentUser.isMachine && !helper.hasAdminRole(currentUser))) { + fields = _.without(fields, ...config.MEMBER_SECURE_FIELDS) + } + // If the current user does not have an autocompleterole, remove the communication fields + if (!currentUser || (!currentUser.isMachine && !helper.hasAutocompleteRole(currentUser))) { + fields = _.without(fields, ...config.COMMUNICATION_SECURE_FIELDS) + } + return fields +} +/** + * Search members. + * @param {Object} currentUser the user who performs operation + * @param {Object} query the query parameters + * @returns {Object} the search result + */ +async function searchMembers (currentUser, query) { + const fields = omitMemberAttributes(currentUser, query, MEMBER_FIELDS) + + if (query.email != null && query.email.length > 0) { + if (currentUser == null) { + throw new errors.UnauthorizedError('Authentication token is required to query users by email') + } + if (!helper.hasSearchByEmailRole(currentUser)) { + throw new errors.BadRequestError('Admin role is required to query users by email') + } + } + + // search for the members based on query + const prismaFilter = prismaHelper.buildSearchMemberFilter(query) + const searchData = await fillMembers(prismaFilter, query, fields) + + // secure address data + const canManageMember = currentUser && (currentUser.isMachine || helper.hasAdminRole(currentUser)) + if (!canManageMember) { + searchData.result = _.map(searchData.result, res => helper.secureMemberAddressData(res)) + searchData.result = _.map(searchData.result, res => helper.truncateLastName(res)) + } + + return searchData +} + +searchMembers.schema = { + currentUser: Joi.any(), + query: Joi.object().keys({ + handleLower: Joi.string(), + handlesLower: Joi.array(), + handle: Joi.string(), + handles: Joi.array(), + email: Joi.string(), + userId: Joi.number(), + userIds: Joi.array(), + term: Joi.string(), + fields: Joi.string(), + page: Joi.page(), + perPage: Joi.perPage(), + sort: Joi.sort() + }) +} + +async function addStats (results) { + if (!results || results.length === 0) { + return [] + } + const userIds = _.map(results, 'userId') + // get member stats + const memberStatsList = await prisma.memberStats.findMany({ + where: { userId: { in: userIds } }, + // include all tracks + include: prismaHelper.statsIncludeParams + }) + // merge overall members and stats + const mbrsSkillsStatsKeys = _.keyBy(memberStatsList, 'userId') + const resultsWithStats = _.map(results, item => { + item.numberOfChallengesWon = 0 + item.numberOfChallengesPlaced = 0 + if (mbrsSkillsStatsKeys[item.userId]) { + item.stats = [] + const statsData = prismaHelper.buildStatsResponse(item, mbrsSkillsStatsKeys[item.userId], MEMBER_STATS_FIELDS) + if (statsData.wins > item.numberOfChallengesWon) { + item.numberOfChallengesWon = statsData.wins + } + item.numberOfChallengesPlaced = statsData.challenges + // clean up stats fields and filter on stats fields + item.stats.push(statsData) + } else { + item.stats = [] + } + return item + }) + + return resultsWithStats +} + +/** + * Get member skills and put skills into results + * @param {Array} results member list + */ +async function addSkills (results) { + if (!results || results.length === 0) { + return + } + const userIds = _.map(results, 'userId') + // get member skills + const allSkillList = await prisma.memberSkill.findMany({ + where: { userId: { in: userIds } }, + include: prismaHelper.skillsIncludeParams + }) + // group by user id + const skillGroupData = _.groupBy(allSkillList, 'userId') + // convert data and put into results + _.forEach(results, member => { + // find skill data + const skillList = skillGroupData[member.userId] + member.skills = prismaHelper.buildMemberSkills(skillList) + }) +} + +async function addSkillScore (results, query) { + // Pull out availableForGigs to add to the search results, for talent search + let resultsWithScores = _.map(results, function (item) { + if (!item.skills) { + item.skillScore = 0 + return item + } + let score = 0.0 + let foundSkills = _.filter(item.skills, function (skill) { return query.skillIds.includes(skill.id) }) + for (const skill of foundSkills) { + let challengeWin = false + let selfPicked = false + + for (const level of skill.levels) { + if (level.name === 'verified') { + challengeWin = true + } else if (level.name === 'self-declared') { + selfPicked = true + } + } + + if (challengeWin) { + score = score + 1.0 + } else if (selfPicked) { + score = score + 0.5 + } + } + item.skillScore = Math.round(score / query.skillIds.length * 100) / 100 + + if (item.availableForGigs == null) { + // Deduct 1% if availableForGigs is not set on the user. + item.skillScore = item.skillScore - 0.01 + } + + if (item.description == null || item.description === '') { + // Deduct 1% if the description is not set on the user. + item.skillScore = item.skillScore - 0.01 + } + + if (item.photoURL == null || item.photoURL === '') { + // Deduct 4% if the photoURL is not set on the user. + item.skillScore = item.skillScore - 0.04 + } + + // Use the pre-calculated skillScoreDeduction on the user profile + if (item.skillScoreDeduction != null) { + item.skillScore = item.skillScore + item.skillScoreDeduction + } else { + // The default skill score deduction is -4%, if it's not set on the user. + item.skillScore = item.skillScore - 0.04 + } + + // 1696118400000 is the epoch value for Oct 1, 2023, which is when we deployed the change to set the last login date when a user logs in + // So, we use this as the baseline for the user if they don't have a last login date. + + let lastLoginDate = 1696118400000 + if (item.lastLoginDate) { + lastLoginDate = item.lastLoginDate + } + + let loginDiff = Date.now() - lastLoginDate + // For diff calculation (30 days, 24 hours, 60 minutes, 60 seconds, 1000 milliseconds) + let monthLength = 30 * 24 * 60 * 60 * 1000 + + // If logged in > 5 month ago + if (loginDiff > (5 * monthLength)) { + item.skillScore = item.skillScore - 0.5 + } else if (loginDiff > (4 * monthLength)) { + // Logged in more than 4 months ago, but less than 5 + item.skillScore = item.skillScore - 0.4 + } else if (loginDiff > (3 * monthLength)) { + // Logged in more than 3 months ago, but less than 4 + item.skillScore = item.skillScore - 0.3 + } else if (loginDiff > (2 * monthLength)) { + // Logged in more than 2 months ago, but less than 3 + item.skillScore = item.skillScore - 0.2 + } else if (loginDiff > (1 * monthLength)) { + // Logged in more than 1 month ago, but less than 2 + item.skillScore = item.skillScore - 0.1 + } + if (item.skillScore < 0) { + item.skillScore = 0 + } + item.skillScore = Math.round(item.skillScore * 100) / 100 + // Default names and handle appearance + // https://topcoder.atlassian.net/browse/MP-325 + if (!item.namesAndHandleAppearance) { + item.namesAndHandleAppearance = 'namesAndHandle' + } + + return item + }) + + return resultsWithScores +} + +// The default search order, used by general handle searches +function handleSearchOrder (results, query) { + // Sort the results for default searching + results = _.orderBy(results, [query.sortBy, 'handleLower'], [query.sortOrder]) + return results +} + +// The skill search order, which has a secondary sort of the number of +// Topcoder-verified skills, in descending order (where level.name===verified) +function skillSearchOrder (results, query) { + results = _.orderBy(results, [query.sortBy, function (member) { + const challengeWinSkills = _.filter(member.skills, + function (skill) { + skill.levels.forEach(level => { + if (level.name === 'verified') { + return true + } + }) + }) + return challengeWinSkills.length + }], [query.sortOrder, 'desc']) + return results +} + +async function fillMembers (prismaFilter, query, fields, skillSearch = false) { + // get the total + let total = await prisma.member.count(prismaFilter) + + let results = [] + if (total === 0) { + return { total: total, page: query.page, perPage: query.perPage, result: [] } + } + + // get member data + results = await prisma.member.findMany({ + ...prismaFilter, + include: { + maxRating: true, + addresses: true + }, + // sort by handle with given order + skip: (query.page - 1) * query.perPage, + take: query.perPage, + orderBy: [{ + handle: query.sortOrder + }] + }) + + // convert to response format + _.forEach(results, r => prismaHelper.convertMember(r)) + + // Include the stats by default, but allow them to be ignored with ?includeStats=false + // This is for performance reasons - pulling the stats is a bit of a resource hog + if (!query.includeStats || query.includeStats === 'true') { + results = await addStats(results, query) + } + + // add skills data + await addSkills(results) + + // Sort in slightly different secondary orders, depending on if + // this is a skill search or handle search + if (skillSearch) { + _.remove(results, (result) => (result.availableForGigs != null && result.availableForGigs === false)) + results = await addSkillScore(results, query) + results = skillSearchOrder(results, query) + } else { + results = handleSearchOrder(results, query) + } + + if (skillSearch) { + // omit verified flag + results = _.map(results, r => _.omit(r, 'verified')) + } + + // filter member based on fields + results = _.map(results, (item) => _.pick(item, fields)) + + return { total: total, page: query.page, perPage: query.perPage, result: results } +} + +/** + * Search member with skill id list. Only return member id. + * @param {Array} skillIds skill id array + * @returns member id list + */ +async function searchMemberIdWithSkillIds (skillIds) { + if (!skillIds || skillIds.length === 0) { + return [] + } + const members = await prisma.$queryRaw` + SELECT m."userId" + FROM "member" m + JOIN "memberSkill" ms ON m."userId" = ms."userId" + WHERE ms."skillId"::text IN (${Prisma.join(skillIds)}) + GROUP BY m."userId" + HAVING COUNT(DISTINCT ms."skillId") = ${skillIds.length} + ` + return _.map(members, 'userId') +} + +// TODO - use some caching approach to replace these in-memory objects +/** + * Search members by the given search query + * + * @param query The search query by which to search members + * + * @returns {Promise<[]>} The array of members matching the given query + */ +const searchMembersBySkills = async (currentUser, query) => { + try { + let skillIds = await helper.getParamsFromQueryAsArray(query, 'id') + query.skillIds = skillIds + if (_.isEmpty(skillIds)) { + return { total: 0, page: query.page, perPage: query.perPage, result: [] } + } + // NOTE, we remove stats only because it's too much data at the current time for the talent search app + // We can add stats back in at some point in the future if we want to expand the information shown on the + // talent search app. + const fields = omitMemberAttributes(currentUser, query, _.without(MEMBER_FIELDS, 'stats')) + // build search member filter. Make sure member has every skill id in skillIds + const memberIds = await searchMemberIdWithSkillIds(skillIds) + const prismaFilter = { + where: { userId: { in: memberIds } } + } + // build result + let response = await fillMembers(prismaFilter, query, fields, true) + + // secure address data + const canManageMember = currentUser && (currentUser.isMachine || helper.hasAdminRole(currentUser)) + if (!canManageMember) { + response.result = _.map(response.result, res => helper.secureMemberAddressData(res)) + response.result = _.map(response.result, res => helper.truncateLastName(res)) + } + + return response + } catch (e) { + logger.error('ERROR WHEN SEARCHING') + logger.error(e) + return { total: 0, page: query.page, perPage: query.perPage, result: [] } + } +} + +searchMembersBySkills.schema = { + currentUser: Joi.any(), + query: Joi.object().keys({ + id: Joi.alternatives().try(Joi.string(), Joi.array().items(Joi.string())), + page: Joi.page(), + perPage: Joi.perPage(), + includeStats: Joi.string(), + sortBy: Joi.string().valid(MEMBER_SORT_BY_FIELDS).default('skillScore'), + sortOrder: Joi.string().valid('asc', 'desc').default('desc') + }) +} + +/** + * members autocomplete. + * @param {Object} currentUser the user who performs operation + * @param {Object} query the query parameters + * @returns {Object} the autocomplete result + */ +async function autocomplete (currentUser, query) { + const fields = omitMemberAttributes(currentUser, query, MEMBER_AUTOCOMPLETE_FIELDS) + + if (!query.term || query.term.length === 0) { + return { total: 0, page: query.page, perPage: query.perPage, result: [] } + } + const term = query.term.toLowerCase() + const prismaFilter = { + where: { + handleLower: { startsWith: term }, + status: 'ACTIVE' + } + } + const total = await prisma.member.count(prismaFilter) + if (total === 0) { + return { total: 0, page: query.page, perPage: query.perPage, result: [] } + } + const selectFields = {} + _.forEach(fields, f => { + selectFields[f] = true + }) + + let records = await prisma.member.findMany({ + ...prismaFilter, + select: selectFields, + skip: (query.page - 1) * query.perPage, + take: query.perPage, + orderBy: { handle: query.sortOrder } + }) + records = _.map(records, item => { + const t = _.pick(item, fields) + if (t.userId) { + t.userId = helper.bigIntToNumber(t.userId) + } + return t + }) + + return { total, page: query.page, perPage: query.perPage, result: records } +} + +autocomplete.schema = { + currentUser: Joi.any(), + query: Joi.object().keys({ + term: Joi.string(), + fields: Joi.string(), + page: Joi.page(), + perPage: Joi.perPage(), + size: Joi.size(), + sortOrder: Joi.string().valid('asc', 'desc').default('desc') + }) +} + +module.exports = { + searchMembers, + searchMembersBySkills, + autocomplete +} + +logger.buildService(module.exports) diff --git a/src/services/StatisticsService.js b/src/services/StatisticsService.js new file mode 100644 index 0000000..4bd8a8c --- /dev/null +++ b/src/services/StatisticsService.js @@ -0,0 +1,397 @@ +/** + * This service provides operations of statistics. + */ + +const _ = require('lodash') +const Joi = require('joi') +const config = require('config') +const helper = require('../common/helper') +const logger = require('../common/logger') +const errors = require('../common/errors') +const constants = require('../../app-constants') +const prisma = require('../common/prisma').getClient() +const prismaHelper = require('../common/prismaHelper') +const string = require('joi/lib/types/string') +const { v4: uuidv4 } = require('uuid') + +const DISTRIBUTION_FIELDS = ['track', 'subTrack', 'distribution', 'createdAt', 'updatedAt', + 'createdBy', 'updatedBy'] + +const HISTORY_STATS_FIELDS = ['userId', 'groupId', 'handle', 'handleLower', 'DEVELOP', 'DATA_SCIENCE', + 'createdAt', 'updatedAt', 'createdBy', 'updatedBy'] + +const MEMBER_STATS_FIELDS = ['userId', 'groupId', 'handle', 'handleLower', 'maxRating', + 'challenges', 'wins', 'DEVELOP', 'DESIGN', 'DATA_SCIENCE', 'COPILOT', 'createdAt', + 'updatedAt', 'createdBy', 'updatedBy'] + +const MEMBER_SKILL_FIELDS = ['userId', 'handle', 'handleLower', 'skills', + 'createdAt', 'updatedAt', 'createdBy', 'updatedBy'] + +/** + * Get distribution statistics. + * @param {Object} query the query parameters + * @returns {Object} the distribution statistics + */ +async function getDistribution (query) { + // validate and parse query parameter + const fields = helper.parseCommaSeparatedString(query.fields, DISTRIBUTION_FIELDS) || DISTRIBUTION_FIELDS + + // find matched distribution records + const prismaFilter = { where: {} } + if (query.track || query.subTrack) { + prismaFilter.where = { AND: [] } + if (query.track) { + prismaFilter.where.AND.push({ + track: { contains: query.track.toUpperCase() } + }) + } + if (query.subTrack) { + prismaFilter.where.AND.push({ + subTrack: { contains: query.subTrack.toUpperCase() } + }) + } + } + const items = await prisma.distributionStats.findMany(prismaFilter) + if (!items || items.length === 0) { + throw new errors.NotFoundError(`No member distribution statistics is found.`) + } + // convert result to response structure + const records = [] + _.forEach(items, t => { + const r = _.pick(t, DISTRIBUTION_FIELDS) + r.distribution = {} + _.forEach(t, (value, key) => { + if (key.startsWith('ratingRange')) { + r.distribution[key] = value + } + }) + records.push(r) + }) + + // aggregate the statistics + let result = { track: query.track, subTrack: query.subTrack, distribution: {} } + _.forEach(records, (record) => { + if (record.distribution) { + // sum the statistics + _.forIn(record.distribution, (value, key) => { + if (!result.distribution[key]) { + result.distribution[key] = 0 + } + result.distribution[key] += Number(value) + }) + // use earliest createdAt + if (record.createdAt && (!result.createdAt || new Date(record.createdAt) < result.createdAt)) { + result.createdAt = new Date(record.createdAt) + result.createdBy = record.createdBy + } + // use latest updatedAt + if (record.updatedAt && (!result.updatedAt || new Date(record.updatedAt) > result.updatedAt)) { + result.updatedAt = new Date(record.updatedAt) + result.updatedBy = record.updatedBy + } + } + }) + // select fields if provided + if (fields) { + result = _.pick(result, fields) + } + return result +} + +getDistribution.schema = { + query: Joi.object().keys({ + track: Joi.string(), + subTrack: Joi.string(), + fields: Joi.string() + }) +} + +/** + * Get history statistics. + * @param {String} handle the member handle + * @param {Object} query the query parameters + * @returns {Object} the history statistics + */ +async function getHistoryStats (currentUser, handle, query) { + let overallStat = [] + // validate and parse query parameter + const fields = helper.parseCommaSeparatedString(query.fields, HISTORY_STATS_FIELDS) || HISTORY_STATS_FIELDS + // get member by handle + const member = await helper.getMemberByHandle(handle) + const groupIds = await helper.getAllowedGroupIds(currentUser, member, query.groupIds) + + for (const groupId of groupIds) { + let statsDb + if (groupId === config.PUBLIC_GROUP_ID) { + // get statistics by member user id from db + statsDb = await prisma.memberHistoryStats.findFirst({ + where: { userId: member.userId, isPrivate: false }, + include: { develop: true, dataScience: true } + }) + if (!_.isNil(statsDb)) { + statsDb.groupId = _.toNumber(groupId) + } + } else { + // get statistics private by member user id from db + statsDb = await prisma.memberHistoryStats.findFirst({ + where: { userId: member.userId, groupId, isPrivate: true }, + include: { develop: true, dataScience: true } + }) + } + if (!_.isNil(statsDb)) { + overallStat.push(statsDb) + } + } + // build stats history response + let result = _.map(overallStat, t => prismaHelper.buildStatsHistoryResponse(member, t, fields)) + // remove identifiable info fields if user is not admin, not M2M and not member himself + if (!helper.canManageMember(currentUser, member)) { + result = _.map(result, (item) => _.omit(item, config.STATISTICS_SECURE_FIELDS)) + } + return result +} + +getHistoryStats.schema = { + currentUser: Joi.any(), + handle: Joi.string().required(), + query: Joi.object().keys({ + groupIds: Joi.string(), + fields: Joi.string() + }) +} + +/** + * Get member statistics. + * @param {String} handle the member handle + * @param {Object} query the query parameters + * @returns {Object} the member statistics + */ +async function getMemberStats (currentUser, handle, query, throwError) { + let stats = [] + // validate and parse query parameter + const fields = helper.parseCommaSeparatedString(query.fields, MEMBER_STATS_FIELDS) || MEMBER_STATS_FIELDS + // get member by handle + const member = await helper.getMemberByHandle(handle) + const groupIds = await helper.getAllowedGroupIds(currentUser, member, query.groupIds) + + const includeParams = prismaHelper.statsIncludeParams + + for (const groupId of groupIds) { + let stat + if (groupId === config.PUBLIC_GROUP_ID) { + // get statistics by member user id from db + stat = await prisma.memberStats.findFirst({ + where: { userId: member.userId, isPrivate: false }, + include: includeParams + }) + if (!_.isNil(stat)) { + stat = _.assign(stat, { groupId: _.toNumber(groupId) }) + } + } else { + // get statistics private by member user id from db + stat = await prisma.memberStats.findFirst({ + where: { userId: member.userId, isPrivate: true, groupId }, + include: includeParams + }) + } + if (!_.isNil(stat)) { + stats.push(stat) + } + } + let result = _.map(stats, t => prismaHelper.buildStatsResponse(member, t, fields)) + // remove identifiable info fields if user is not admin, not M2M and not member himself + if (!helper.canManageMember(currentUser, member)) { + result = _.map(result, (item) => _.omit(item, config.STATISTICS_SECURE_FIELDS)) + } + return result +} + +getMemberStats.schema = { + currentUser: Joi.any(), + handle: Joi.string().required(), + query: Joi.object().keys({ + groupIds: Joi.string(), + fields: Joi.string() + }), + throwError: Joi.boolean() +} + +/** + * Get member skills. + * @param {String} handle the member handle + * @param {Object} query the query parameters + * @returns {Object} the member skills + */ +async function getMemberSkills (handle) { + // validate member + const member = await helper.getMemberByHandle(handle) + const skillList = await prisma.memberSkill.findMany({ + where: { + userId: member.userId + }, + include: prismaHelper.skillsIncludeParams + }) + // convert to response format + return prismaHelper.buildMemberSkills(skillList) +} + +getMemberSkills.schema = { + currentUser: Joi.any(), + handle: Joi.string().required() +} + +/** + * Check create/update member skill data + * @param {Object} data request body + */ +async function validateMemberSkillData(data) { + // Check displayMode + if (data.displayModeId) { + const modeCount = await prisma.displayMode.count({ + where: { id: data.displayModeId } + }) + if (modeCount <= 0) { + throw new errors.BadRequestError(`Display mode ${data.displayModeId} does not exist`) + } + } + if (data.levels && data.levels.length > 0) { + const levelCount = await prisma.skillLevel.count({ + where: { id: { in: data.levels } } + }) + if (levelCount < data.levels.length) { + throw new errors.BadRequestError(`Please make sure skill level exists`) + } + } +} + + +async function createMemberSkills (currentUser, handle, data) { + // get member by handle + const member = await helper.getMemberByHandle(handle) + // check authorization + if (!helper.canManageMember(currentUser, member)) { + throw new errors.ForbiddenError('You are not allowed to update the member skills.') + } + + // validate request + const existingCount = await prisma.memberSkill.count({ + where: { userId: member.userId, skillId: data.skillId } + }) + if (existingCount > 0) { + throw new errors.BadRequestError('This member skill exists') + } + await validateMemberSkillData(data) + + // save to db + const createdBy = currentUser.handle || currentUser.sub + const memberSkillData = { + id: uuidv4(), + userId: member.userId, + skillId: data.skillId, + createdBy, + } + if (data.displayModeId) { + memberSkillData.displayModeId = data.displayModeId + } + if (data.levels && data.levels.length > 0) { + memberSkillData.levels = { + createMany: { data: + _.map(data.levels, levelId => ({ + skillLevelId: levelId, + createdBy + })) + } + } + } + await prisma.memberSkill.create({ data: memberSkillData }) + + // get skills by member handle + const memberSkill = await this.getMemberSkills(handle) + return memberSkill +} + +createMemberSkills.schema = { + currentUser: Joi.any(), + handle: Joi.string().required(), + data: Joi.object().keys({ + skillId: Joi.string().uuid().required(), + displayModeId: Joi.string().uuid(), + levels: Joi.array().items(Joi.string().uuid()) + }).required() +} + +/** + * Partially update member skills. + * @param {Object} currentUser the user who performs operation + * @param {String} handle the member handle + * @param {Object} data the skills data to update + * @returns {Object} the updated member skills + */ +async function partiallyUpdateMemberSkills (currentUser, handle, data) { + // get member by handle + const member = await helper.getMemberByHandle(handle) + // check authorization + if (!helper.canManageMember(currentUser, member)) { + throw new errors.ForbiddenError('You are not allowed to update the member skills.') + } + + // validate request + const existing = await prisma.memberSkill.findFirst({ + where: { userId: member.userId, skillId: data.skillId } + }) + if (!existing || !existing.id) { + throw new errors.NotFoundError('Member skill not found') + } + await validateMemberSkillData(data) + + const updatedBy = currentUser.handle || currentUser.sub + const memberSkillData = { + updatedBy, + } + if (data.displayModeId) { + memberSkillData.displayModeId = data.displayModeId + } + if (data.levels && data.levels.length > 0) { + await prisma.memberSkillLevel.deleteMany({ + where: { memberSkillId: existing.id } + }) + memberSkillData.levels = { + createMany: { data: + _.map(data.levels, levelId => ({ + skillLevelId: levelId, + createdBy: updatedBy, + updatedBy + })) + } + } + } + await prisma.memberSkill.update({ + data: memberSkillData, + where: { id: existing.id } + }) + + // get skills by member handle + const memberSkill = await this.getMemberSkills(handle) + return memberSkill +} + +partiallyUpdateMemberSkills.schema = { + currentUser: Joi.any(), + handle: Joi.string().required(), + data: Joi.object().keys({ + skillId: Joi.string().uuid().required(), + displayModeId: Joi.string().uuid(), + levels: Joi.array().items(Joi.string().uuid()) + }).required() +} + +module.exports = { + getDistribution, + getHistoryStats, + getMemberStats, + getMemberSkills, + createMemberSkills, + partiallyUpdateMemberSkills +} + +logger.buildService(module.exports) diff --git a/test/testHelper.js b/test/testHelper.js index 7bbe8a6..26a907b 100644 --- a/test/testHelper.js +++ b/test/testHelper.js @@ -1,8 +1,6 @@ /** * This file defines common helper methods used for tests */ -const config = require('config') -const helper = require('../src/common/helper') const _ = require('lodash') const prisma = require('../src/common/prisma').getClient() @@ -11,7 +9,7 @@ const member1 = { rating: 1000, track: 'dev', subTrack: 'code', - ratingColor: '', + ratingColor: '' }, userId: 123, firstName: 'first name', @@ -96,7 +94,7 @@ const member2 = { updatedBy: 'test2' } -function testDataToPrisma(data) { +function testDataToPrisma (data) { const ret = _.omit(data, ['addresses', 'maxRating']) ret.maxRating = { create: { @@ -139,7 +137,7 @@ async function createData () { async function clearData () { // remove data in DB const memberIds = [member1.userId, member2.userId] - const filter = { where: { userId : { in: memberIds } } } + const filter = { where: { userId: { in: memberIds } } } await prisma.memberTraits.deleteMany(filter) await prisma.memberAddress.deleteMany(filter) diff --git a/test/unit/MemberService.test.js b/test/unit/MemberService.test.js index 31ae106..c9531f8 100644 --- a/test/unit/MemberService.test.js +++ b/test/unit/MemberService.test.js @@ -12,7 +12,6 @@ const awsMock = require('aws-sdk-mock') const service = require('../../src/services/MemberService') const testHelper = require('../testHelper') - const should = chai.should() const photoContent = fs.readFileSync(path.join(__dirname, '../photo.png')) @@ -30,12 +29,12 @@ describe('member service unit tests', () => { // mock S3 before creating S3 instance awsMock.mock('S3', 'getObject', (params, callback) => { - callback(null, { Body: Buffer.from(photoContent) }); - }); + callback(null, { Body: Buffer.from(photoContent) }) + }) awsMock.mock('S3', 'upload', (params, callback) => { - callback(null); - }); + callback(null) + }) }) after(async () => { diff --git a/test/unit/MemberTraitService.test.js b/test/unit/MemberTraitService.test.js index 7400952..c37c73d 100644 --- a/test/unit/MemberTraitService.test.js +++ b/test/unit/MemberTraitService.test.js @@ -99,7 +99,7 @@ describe('member trait service unit tests', () => { data: [{ industry: 'Banking', companyName: 'JP Morgan', - position: 'Manager' + position: 'Manager' }] } } @@ -171,12 +171,12 @@ describe('member trait service unit tests', () => { await service.createTraits({ isMachine: true, sub: 'sub1' }, member1.handle, [{ traitId: 'work', categoryName: 'category', - traits: { + traits: { traitId: 'work', - data: [{ + data: [{ industry: 'Banking', companyName: 'JP Morgan', - position: 'Manager' + position: 'Manager' }] }, other: 123 }]) From b10735e619eaedf44303cce0397291de7dda4230 Mon Sep 17 00:00:00 2001 From: whereishammer Date: Sun, 13 Jul 2025 20:46:31 +0800 Subject: [PATCH 2/2] Add Postman collection --- docs/Member API.postman_collection.json | 926 ++++++++++++++++++++++++ 1 file changed, 926 insertions(+) create mode 100644 docs/Member API.postman_collection.json diff --git a/docs/Member API.postman_collection.json b/docs/Member API.postman_collection.json new file mode 100644 index 0000000..88e73c9 --- /dev/null +++ b/docs/Member API.postman_collection.json @@ -0,0 +1,926 @@ +{ + "info": { + "_postman_id": "12c468e8-dbc4-4cc2-b187-36144423a29c", + "name": "Member API", + "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json", + "_exporter_id": "28573934" + }, + "item": [ + { + "name": "Member", + "item": [ + { + "name": "Get Member by handle", + "request": { + "auth": { + "type": "noauth" + }, + "method": "GET", + "header": [], + "url": { + "raw": "http://localhost:3000/v6/members/phead", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "3000", + "path": [ + "v6", + "members", + "phead" + ] + } + }, + "response": [] + }, + { + "name": "Get Member profile completeness", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlcyI6WyJUb3Bjb2RlciBVc2VyIiwiQ29ubmVjdCBTdXBwb3J0IiwiYWRtaW5pc3RyYXRvciIsInRlc3RSb2xlIiwiYWFhIiwidG9ueV90ZXN0XzEiLCJDb25uZWN0IE1hbmFnZXIiLCJDb25uZWN0IEFkbWluIiwiY29waWxvdCIsIkNvbm5lY3QgQ29waWxvdCBNYW5hZ2VyIl0sImlzcyI6Imh0dHBzOi8vYXBpLnRvcGNvZGVyLWRldi5jb20iLCJoYW5kbGUiOiJUb255SiIsImV4cCI6MTk4MDk5Mjc4OCwidXNlcklkIjoiODU0Nzg5OSIsImlhdCI6MTU0OTc5MTYxMSwiZW1haWwiOiJ0amVmdHMrZml4QHRvcGNvZGVyLmNvbSIsImp0aSI6ImY5NGQxZTI2LTNkMGUtNDZjYS04MTE1LTg3NTQ1NDRhMDhmMSJ9.q_Db9Gw8bn54xlythrZZUrJQyak-XrdOwPsj6ddgZ4M", + "type": "string" + } + ] + }, + "method": "GET", + "header": [], + "url": { + "raw": "http://localhost:3000/v6/members/phead/profileCompleteness", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "3000", + "path": [ + "v6", + "members", + "phead", + "profileCompleteness" + ] + } + }, + "response": [] + }, + { + "name": "Get Member Signature", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlcyI6WyJUb3Bjb2RlciBVc2VyIl0sImlzcyI6Imh0dHBzOi8vYXBpLnRvcGNvZGVyLWRldi5jb20iLCJoYW5kbGUiOiJwaGVhZCIsImV4cCI6MTk4MDk5Mjc4OCwidXNlcklkIjoiMjI3NDI3NjQiLCJpYXQiOjE1NDk3OTk1NjksImVtYWlsIjoiZW1haWxAZG9tYWluLmNvbS56IiwianRpIjoiOWM0NTExYzUtYzE2NS00YTFiLTg5OWUtYjY1YWQwZTAyYjU1In0.kOPm331fsiXj2y_7by2ohjoaGWIGon0TxKL1znbpijU", + "type": "string" + } + ] + }, + "method": "GET", + "header": [], + "url": { + "raw": "http://localhost:3000/v6/members/uid-signature?type=userflow", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "3000", + "path": [ + "v6", + "members", + "uid-signature" + ], + "query": [ + { + "key": "type", + "value": "userflow" + } + ] + } + }, + "response": [] + }, + { + "name": "Update Member", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlcyI6WyJUb3Bjb2RlciBVc2VyIl0sImlzcyI6Imh0dHBzOi8vYXBpLnRvcGNvZGVyLWRldi5jb20iLCJoYW5kbGUiOiJwaGVhZCIsImV4cCI6MTk4MDk5Mjc4OCwidXNlcklkIjoiMjI3NDI3NjQiLCJpYXQiOjE1NDk3OTk1NjksImVtYWlsIjoiZW1haWxAZG9tYWluLmNvbS56IiwianRpIjoiOWM0NTExYzUtYzE2NS00YTFiLTg5OWUtYjY1YWQwZTAyYjU1In0.kOPm331fsiXj2y_7by2ohjoaGWIGon0TxKL1znbpijU", + "type": "string" + } + ] + }, + "method": "PUT", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"availableForGigs\": true,\r\n \"namesAndHandleAppearance\": \"handleOnly\"\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "http://localhost:3000/v6/members/phead", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "3000", + "path": [ + "v6", + "members", + "phead" + ] + } + }, + "response": [] + }, + { + "name": "Update Member - Set new Email", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlcyI6WyJUb3Bjb2RlciBVc2VyIl0sImlzcyI6Imh0dHBzOi8vYXBpLnRvcGNvZGVyLWRldi5jb20iLCJoYW5kbGUiOiJwaGVhZCIsImV4cCI6MTk4MDk5Mjc4OCwidXNlcklkIjoiMjI3NDI3NjQiLCJpYXQiOjE1NDk3OTk1NjksImVtYWlsIjoiZW1haWxAZG9tYWluLmNvbS56IiwianRpIjoiOWM0NTExYzUtYzE2NS00YTFiLTg5OWUtYjY1YWQwZTAyYjU1In0.kOPm331fsiXj2y_7by2ohjoaGWIGon0TxKL1znbpijU", + "type": "string" + } + ] + }, + "method": "PUT", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"email\": \"new-email@topcoder.com\"\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "http://localhost:3000/v6/members/phead", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "3000", + "path": [ + "v6", + "members", + "phead" + ] + } + }, + "response": [] + }, + { + "name": "Member Verify Email - Old Email token", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlcyI6WyJUb3Bjb2RlciBVc2VyIl0sImlzcyI6Imh0dHBzOi8vYXBpLnRvcGNvZGVyLWRldi5jb20iLCJoYW5kbGUiOiJwaGVhZCIsImV4cCI6MTk4MDk5Mjc4OCwidXNlcklkIjoiMjI3NDI3NjQiLCJpYXQiOjE1NDk3OTk1NjksImVtYWlsIjoiZW1haWxAZG9tYWluLmNvbS56IiwianRpIjoiOWM0NTExYzUtYzE2NS00YTFiLTg5OWUtYjY1YWQwZTAyYjU1In0.kOPm331fsiXj2y_7by2ohjoaGWIGon0TxKL1znbpijU", + "type": "string" + } + ] + }, + "method": "GET", + "header": [], + "url": { + "raw": "http://localhost:3000/v6/members/phead/verify?token=23e8a52a-098b-4856-88ce-f474a00ebf86", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "3000", + "path": [ + "v6", + "members", + "phead", + "verify" + ], + "query": [ + { + "key": "token", + "value": "23e8a52a-098b-4856-88ce-f474a00ebf86" + } + ] + } + }, + "response": [] + }, + { + "name": "Member Verify Email - New Email token", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlcyI6WyJUb3Bjb2RlciBVc2VyIl0sImlzcyI6Imh0dHBzOi8vYXBpLnRvcGNvZGVyLWRldi5jb20iLCJoYW5kbGUiOiJwaGVhZCIsImV4cCI6MTk4MDk5Mjc4OCwidXNlcklkIjoiMjI3NDI3NjQiLCJpYXQiOjE1NDk3OTk1NjksImVtYWlsIjoiZW1haWxAZG9tYWluLmNvbS56IiwianRpIjoiOWM0NTExYzUtYzE2NS00YTFiLTg5OWUtYjY1YWQwZTAyYjU1In0.kOPm331fsiXj2y_7by2ohjoaGWIGon0TxKL1znbpijU", + "type": "string" + } + ] + }, + "method": "GET", + "header": [], + "url": { + "raw": "http://localhost:3000/v6/members/phead/verify?token=d20ee9e4-c793-41f0-b5e9-81a222e667a6", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "3000", + "path": [ + "v6", + "members", + "phead", + "verify" + ], + "query": [ + { + "key": "token", + "value": "d20ee9e4-c793-41f0-b5e9-81a222e667a6" + } + ] + } + }, + "response": [] + } + ] + }, + { + "name": "Member Trait", + "item": [ + { + "name": "Get Member Traits", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlcyI6WyJUb3Bjb2RlciBVc2VyIiwiQ29ubmVjdCBTdXBwb3J0IiwiYWRtaW5pc3RyYXRvciIsInRlc3RSb2xlIiwiYWFhIiwidG9ueV90ZXN0XzEiLCJDb25uZWN0IE1hbmFnZXIiLCJDb25uZWN0IEFkbWluIiwiY29waWxvdCIsIkNvbm5lY3QgQ29waWxvdCBNYW5hZ2VyIl0sImlzcyI6Imh0dHBzOi8vYXBpLnRvcGNvZGVyLWRldi5jb20iLCJoYW5kbGUiOiJUb255SiIsImV4cCI6MTk4MDk5Mjc4OCwidXNlcklkIjoiODU0Nzg5OSIsImlhdCI6MTU0OTc5MTYxMSwiZW1haWwiOiJ0amVmdHMrZml4QHRvcGNvZGVyLmNvbSIsImp0aSI6ImY5NGQxZTI2LTNkMGUtNDZjYS04MTE1LTg3NTQ1NDRhMDhmMSJ9.q_Db9Gw8bn54xlythrZZUrJQyak-XrdOwPsj6ddgZ4M", + "type": "string" + } + ] + }, + "method": "GET", + "header": [], + "url": { + "raw": "http://localhost:3000/v6/members/phead/traits", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "3000", + "path": [ + "v6", + "members", + "phead", + "traits" + ] + } + }, + "response": [] + }, + { + "name": "Create Member Traits", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlcyI6WyJUb3Bjb2RlciBVc2VyIl0sImlzcyI6Imh0dHBzOi8vYXBpLnRvcGNvZGVyLWRldi5jb20iLCJoYW5kbGUiOiJwaGVhZCIsImV4cCI6MTk4MDk5Mjc4OCwidXNlcklkIjoiMjI3NDI3NjQiLCJpYXQiOjE1NDk3OTk1NjksImVtYWlsIjoiZW1haWxAZG9tYWluLmNvbS56IiwianRpIjoiOWM0NTExYzUtYzE2NS00YTFiLTg5OWUtYjY1YWQwZTAyYjU1In0.kOPm331fsiXj2y_7by2ohjoaGWIGon0TxKL1znbpijU", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "[{\r\n \"traitId\": \"work\",\r\n \"categoryName\": \"Work\",\r\n \"traits\": {\r\n \"traitId\": \"work\",\r\n \"data\": [{\r\n \"industry\": \"Banking\",\r\n \"companyName\": \"JP Morgan\",\r\n \"position\": \"Manager\"\r\n }, {\r\n \"industry\": \"TechAndTechnologyService\",\r\n \"companyName\": \"OpenAI\",\r\n \"position\": \"Manager\"\r\n }]\r\n }\r\n}]", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "http://localhost:3000/v6/members/phead/traits", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "3000", + "path": [ + "v6", + "members", + "phead", + "traits" + ] + } + }, + "response": [] + }, + { + "name": "Create Member Traits - subscription", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlcyI6WyJUb3Bjb2RlciBVc2VyIl0sImlzcyI6Imh0dHBzOi8vYXBpLnRvcGNvZGVyLWRldi5jb20iLCJoYW5kbGUiOiJwaGVhZCIsImV4cCI6MTk4MDk5Mjc4OCwidXNlcklkIjoiMjI3NDI3NjQiLCJpYXQiOjE1NDk3OTk1NjksImVtYWlsIjoiZW1haWxAZG9tYWluLmNvbS56IiwianRpIjoiOWM0NTExYzUtYzE2NS00YTFiLTg5OWUtYjY1YWQwZTAyYjU1In0.kOPm331fsiXj2y_7by2ohjoaGWIGon0TxKL1znbpijU", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "[{\r\n \"traitId\": \"subscription\",\r\n \"categoryName\": \"Subscription\",\r\n \"traits\": {\r\n \"traitId\": \"subscription\",\r\n \"data\": [\"OpenAI\", \"ChatGPT\"]\r\n }\r\n}]", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "http://localhost:3000/v6/members/phead/traits", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "3000", + "path": [ + "v6", + "members", + "phead", + "traits" + ] + } + }, + "response": [] + }, + { + "name": "Create Member Traits - Personalization", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlcyI6WyJUb3Bjb2RlciBVc2VyIl0sImlzcyI6Imh0dHBzOi8vYXBpLnRvcGNvZGVyLWRldi5jb20iLCJoYW5kbGUiOiJwaGVhZCIsImV4cCI6MTk4MDk5Mjc4OCwidXNlcklkIjoiMjI3NDI3NjQiLCJpYXQiOjE1NDk3OTk1NjksImVtYWlsIjoiZW1haWxAZG9tYWluLmNvbS56IiwianRpIjoiOWM0NTExYzUtYzE2NS00YTFiLTg5OWUtYjY1YWQwZTAyYjU1In0.kOPm331fsiXj2y_7by2ohjoaGWIGon0TxKL1znbpijU", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "[{\r\n \"traitId\": \"personalization\",\r\n \"categoryName\": \"Personalization\",\r\n \"traits\": {\r\n \"traitId\": \"personalization\",\r\n \"data\": [{\r\n \"public\": true\r\n }, {\r\n \"random-key\": \"random-value\"\r\n }]\r\n }\r\n}]", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "http://localhost:3000/v6/members/phead/traits", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "3000", + "path": [ + "v6", + "members", + "phead", + "traits" + ] + } + }, + "response": [] + }, + { + "name": "Update Member Traits", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlcyI6WyJUb3Bjb2RlciBVc2VyIl0sImlzcyI6Imh0dHBzOi8vYXBpLnRvcGNvZGVyLWRldi5jb20iLCJoYW5kbGUiOiJwaGVhZCIsImV4cCI6MTk4MDk5Mjc4OCwidXNlcklkIjoiMjI3NDI3NjQiLCJpYXQiOjE1NDk3OTk1NjksImVtYWlsIjoiZW1haWxAZG9tYWluLmNvbS56IiwianRpIjoiOWM0NTExYzUtYzE2NS00YTFiLTg5OWUtYjY1YWQwZTAyYjU1In0.kOPm331fsiXj2y_7by2ohjoaGWIGon0TxKL1znbpijU", + "type": "string" + } + ] + }, + "method": "PUT", + "header": [], + "body": { + "mode": "raw", + "raw": "[{\r\n \"traitId\": \"work\",\r\n \"categoryName\": \"Work\",\r\n \"traits\": {\r\n \"traitId\": \"work\",\r\n \"data\": [{\r\n \"industry\": \"Banking\",\r\n \"companyName\": \"JP Morgan 2\",\r\n \"position\": \"Manager 2\"\r\n }, {\r\n \"industry\": \"TechAndTechnologyService\",\r\n \"companyName\": \"OpenAI 2\",\r\n \"position\": \"Manager 2\"\r\n }]\r\n }\r\n}]", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "http://localhost:3000/v6/members/phead/traits", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "3000", + "path": [ + "v6", + "members", + "phead", + "traits" + ] + } + }, + "response": [] + }, + { + "name": "Delete Member Traits", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlcyI6WyJUb3Bjb2RlciBVc2VyIl0sImlzcyI6Imh0dHBzOi8vYXBpLnRvcGNvZGVyLWRldi5jb20iLCJoYW5kbGUiOiJwaGVhZCIsImV4cCI6MTk4MDk5Mjc4OCwidXNlcklkIjoiMjI3NDI3NjQiLCJpYXQiOjE1NDk3OTk1NjksImVtYWlsIjoiZW1haWxAZG9tYWluLmNvbS56IiwianRpIjoiOWM0NTExYzUtYzE2NS00YTFiLTg5OWUtYjY1YWQwZTAyYjU1In0.kOPm331fsiXj2y_7by2ohjoaGWIGon0TxKL1znbpijU", + "type": "string" + } + ] + }, + "method": "DELETE", + "header": [], + "url": { + "raw": "http://localhost:3000/v6/members/phead/traits?traitIds=work", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "3000", + "path": [ + "v6", + "members", + "phead", + "traits" + ], + "query": [ + { + "key": "traitIds", + "value": "work" + } + ] + } + }, + "response": [] + } + ] + }, + { + "name": "Health", + "item": [ + { + "name": "Health", + "request": { + "auth": { + "type": "noauth" + }, + "method": "GET", + "header": [], + "url": { + "raw": "http://localhost:3000/v6/members/health", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "3000", + "path": [ + "v6", + "members", + "health" + ] + } + }, + "response": [] + } + ] + }, + { + "name": "Distribution Stats", + "item": [ + { + "name": "Get distribution stats", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "http://localhost:3000/v6/members/stats/distribution?track=DATA_SCIENCE&subTrack=SRM", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "3000", + "path": [ + "v6", + "members", + "stats", + "distribution" + ], + "query": [ + { + "key": "track", + "value": "DATA_SCIENCE" + }, + { + "key": "subTrack", + "value": "SRM" + } + ] + } + }, + "response": [] + } + ] + }, + { + "name": "Member Statistics", + "item": [ + { + "name": "Get History Stats", + "request": { + "auth": { + "type": "noauth" + }, + "method": "GET", + "header": [], + "url": { + "raw": "http://localhost:3000/v6/members/ACRush/stats/history", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "3000", + "path": [ + "v6", + "members", + "ACRush", + "stats", + "history" + ] + } + }, + "response": [] + }, + { + "name": "Get History Stats for private groups", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlcyI6WyJUb3Bjb2RlciBVc2VyIl0sImlzcyI6Imh0dHBzOi8vYXBpLnRvcGNvZGVyLWRldi5jb20iLCJoYW5kbGUiOiJwaGVhZCIsImV4cCI6MTk4MDk5Mjc4OCwidXNlcklkIjoiMjI3NDI3NjQiLCJpYXQiOjE1NDk3OTk1NjksImVtYWlsIjoiZW1haWxAZG9tYWluLmNvbS56IiwianRpIjoiOWM0NTExYzUtYzE2NS00YTFiLTg5OWUtYjY1YWQwZTAyYjU1In0.kOPm331fsiXj2y_7by2ohjoaGWIGon0TxKL1znbpijU", + "type": "string" + } + ] + }, + "method": "GET", + "header": [], + "url": { + "raw": "http://localhost:3000/v6/members/ACRush/stats/history?groupIds=20000000,20000001", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "3000", + "path": [ + "v6", + "members", + "ACRush", + "stats", + "history" + ], + "query": [ + { + "key": "groupIds", + "value": "20000000,20000001" + } + ] + } + }, + "response": [] + }, + { + "name": "Get Stats", + "request": { + "auth": { + "type": "noauth" + }, + "method": "GET", + "header": [], + "url": { + "raw": "http://localhost:3000/v6/members/ACRush/stats", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "3000", + "path": [ + "v6", + "members", + "ACRush", + "stats" + ] + } + }, + "response": [] + }, + { + "name": "Get Stats for private group", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlcyI6WyJUb3Bjb2RlciBVc2VyIl0sImlzcyI6Imh0dHBzOi8vYXBpLnRvcGNvZGVyLWRldi5jb20iLCJoYW5kbGUiOiJwaGVhZCIsImV4cCI6MTk4MDk5Mjc4OCwidXNlcklkIjoiMjI3NDI3NjQiLCJpYXQiOjE1NDk3OTk1NjksImVtYWlsIjoiZW1haWxAZG9tYWluLmNvbS56IiwianRpIjoiOWM0NTExYzUtYzE2NS00YTFiLTg5OWUtYjY1YWQwZTAyYjU1In0.kOPm331fsiXj2y_7by2ohjoaGWIGon0TxKL1znbpijU", + "type": "string" + } + ] + }, + "method": "GET", + "header": [], + "url": { + "raw": "http://localhost:3000/v6/members/ACRush/stats?groupIds=20000000,20000001", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "3000", + "path": [ + "v6", + "members", + "ACRush", + "stats" + ], + "query": [ + { + "key": "groupIds", + "value": "20000000,20000001" + } + ] + } + }, + "response": [] + }, + { + "name": "Get Skills", + "request": { + "auth": { + "type": "noauth" + }, + "method": "GET", + "header": [], + "url": { + "raw": "http://localhost:3000/v6/members/ACRush/skills", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "3000", + "path": [ + "v6", + "members", + "ACRush", + "skills" + ] + } + }, + "response": [] + }, + { + "name": "Create Skills", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlcyI6WyJUb3Bjb2RlciBVc2VyIiwiQ29ubmVjdCBTdXBwb3J0IiwiYWRtaW5pc3RyYXRvciIsInRlc3RSb2xlIiwiYWFhIiwidG9ueV90ZXN0XzEiLCJDb25uZWN0IE1hbmFnZXIiLCJDb25uZWN0IEFkbWluIiwiY29waWxvdCIsIkNvbm5lY3QgQ29waWxvdCBNYW5hZ2VyIl0sImlzcyI6Imh0dHBzOi8vYXBpLnRvcGNvZGVyLWRldi5jb20iLCJoYW5kbGUiOiJUb255SiIsImV4cCI6MTk4MDk5Mjc4OCwidXNlcklkIjoiODU0Nzg5OSIsImlhdCI6MTU0OTc5MTYxMSwiZW1haWwiOiJ0amVmdHMrZml4QHRvcGNvZGVyLmNvbSIsImp0aSI6ImY5NGQxZTI2LTNkMGUtNDZjYS04MTE1LTg3NTQ1NDRhMDhmMSJ9.q_Db9Gw8bn54xlythrZZUrJQyak-XrdOwPsj6ddgZ4M", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"skillId\": \"65c724e7-1f9e-4396-a432-44fa7ecb1998\",\r\n \"displayModeId\": \"1555aa05-a764-4f0b-b3e0-51b824382abb\",\r\n \"levels\": [\r\n \"0f27234f-d89e-4b07-9ea1-649afbb29841\"\r\n ]\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "http://localhost:3000/v6/members/jiangliwu/skills", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "3000", + "path": [ + "v6", + "members", + "jiangliwu", + "skills" + ] + } + }, + "response": [] + }, + { + "name": "Update Skills", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlcyI6WyJUb3Bjb2RlciBVc2VyIiwiQ29ubmVjdCBTdXBwb3J0IiwiYWRtaW5pc3RyYXRvciIsInRlc3RSb2xlIiwiYWFhIiwidG9ueV90ZXN0XzEiLCJDb25uZWN0IE1hbmFnZXIiLCJDb25uZWN0IEFkbWluIiwiY29waWxvdCIsIkNvbm5lY3QgQ29waWxvdCBNYW5hZ2VyIl0sImlzcyI6Imh0dHBzOi8vYXBpLnRvcGNvZGVyLWRldi5jb20iLCJoYW5kbGUiOiJUb255SiIsImV4cCI6MTk4MDk5Mjc4OCwidXNlcklkIjoiODU0Nzg5OSIsImlhdCI6MTU0OTc5MTYxMSwiZW1haWwiOiJ0amVmdHMrZml4QHRvcGNvZGVyLmNvbSIsImp0aSI6ImY5NGQxZTI2LTNkMGUtNDZjYS04MTE1LTg3NTQ1NDRhMDhmMSJ9.q_Db9Gw8bn54xlythrZZUrJQyak-XrdOwPsj6ddgZ4M", + "type": "string" + } + ] + }, + "method": "PATCH", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"skillId\": \"65c724e7-1f9e-4396-a432-44fa7ecb1998\",\r\n \"displayModeId\": \"1555aa05-a764-4f0b-b3e0-51b824382abb\",\r\n \"levels\": [\r\n \"0f27234f-d89e-4b07-9ea1-649afbb29841\"\r\n ]\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "http://localhost:3000/v6/members/jiangliwu/skills", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "3000", + "path": [ + "v6", + "members", + "jiangliwu", + "skills" + ] + } + }, + "response": [] + } + ] + }, + { + "name": "Search", + "item": [ + { + "name": "Search Member", + "request": { + "auth": { + "type": "noauth" + }, + "method": "GET", + "header": [], + "url": { + "raw": "http://localhost:3000/v6/members?handle=phead&perPage=2&page=1", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "3000", + "path": [ + "v6", + "members" + ], + "query": [ + { + "key": "handle", + "value": "phead" + }, + { + "key": "perPage", + "value": "2" + }, + { + "key": "page", + "value": "1" + } + ] + } + }, + "response": [] + }, + { + "name": "Search by skill", + "request": { + "auth": { + "type": "noauth" + }, + "method": "GET", + "header": [], + "url": { + "raw": "http://localhost:3000/v6/members/searchBySkills?id=2b45af95-18d4-416c-b576-ca107c33762f&id=f9d0be22-6713-4757-a94a-081fe9232034&id=65c724e7-1f9e-4396-a432-44fa7ecb1998", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "3000", + "path": [ + "v6", + "members", + "searchBySkills" + ], + "query": [ + { + "key": "id", + "value": "2b45af95-18d4-416c-b576-ca107c33762f" + }, + { + "key": "id", + "value": "f9d0be22-6713-4757-a94a-081fe9232034" + }, + { + "key": "id", + "value": "65c724e7-1f9e-4396-a432-44fa7ecb1998" + } + ] + } + }, + "response": [] + }, + { + "name": "Autocomplete", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlcyI6WyJUb3Bjb2RlciBVc2VyIiwiQ29ubmVjdCBTdXBwb3J0IiwiYWRtaW5pc3RyYXRvciIsInRlc3RSb2xlIiwiYWFhIiwidG9ueV90ZXN0XzEiLCJDb25uZWN0IE1hbmFnZXIiLCJDb25uZWN0IEFkbWluIiwiY29waWxvdCIsIkNvbm5lY3QgQ29waWxvdCBNYW5hZ2VyIl0sImlzcyI6Imh0dHBzOi8vYXBpLnRvcGNvZGVyLWRldi5jb20iLCJoYW5kbGUiOiJUb255SiIsImV4cCI6MTk4MDk5Mjc4OCwidXNlcklkIjoiODU0Nzg5OSIsImlhdCI6MTU0OTc5MTYxMSwiZW1haWwiOiJ0amVmdHMrZml4QHRvcGNvZGVyLmNvbSIsImp0aSI6ImY5NGQxZTI2LTNkMGUtNDZjYS04MTE1LTg3NTQ1NDRhMDhmMSJ9.q_Db9Gw8bn54xlythrZZUrJQyak-XrdOwPsj6ddgZ4M", + "type": "string" + } + ] + }, + "method": "GET", + "header": [], + "url": { + "raw": "http://localhost:3000/v6/members/autocomplete?term=p", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "3000", + "path": [ + "v6", + "members", + "autocomplete" + ], + "query": [ + { + "key": "term", + "value": "p" + } + ] + } + }, + "response": [] + } + ] + } + ] +} \ No newline at end of file