diff --git a/.circleci/config.yml b/.circleci/config.yml new file mode 100644 index 0000000..373f521 --- /dev/null +++ b/.circleci/config.yml @@ -0,0 +1,79 @@ +version: 2.1 + +parameters: + reset-db: + type: boolean + default: false +defaults: &defaults + docker: + - image: cimg/python:3.13.2-browsers +install_dependency: &install_dependency + name: Installation of build and deployment dependencies. + command: | + sudo apt update + sudo apt install -y jq python3-pip + sudo pip3 install awscli --upgrade +install_deploysuite: &install_deploysuite + name: Installation of install_deploysuite. + command: | + git clone --branch v1.4.19 https://github.com/topcoder-platform/tc-deploy-scripts ../buildscript + cp ./../buildscript/master_deploy.sh . + cp ./../buildscript/buildenv.sh . + cp ./../buildscript/awsconfiguration.sh . + cp ./../buildscript/psvar-processor.sh . + +builddeploy_steps: &builddeploy_steps + - checkout + - setup_remote_docker + - run: *install_dependency + - run: *install_deploysuite + - run: docker buildx build --no-cache=true --build-arg RESET_DB_ARG=<> --build-arg SEED_DATA_ARG=${DEPLOYMENT_ENVIRONMENT} -t ${APPNAME}:latest . + - run: + name: Running MasterScript. + command: | + ./awsconfiguration.sh $DEPLOY_ENV + source awsenvconf + ./psvar-processor.sh -t appenv -p /config/${APPNAME}/deployvar + source deployvar_env + ./master_deploy.sh -d ECS -e $DEPLOY_ENV -t latest -j /config/${APPNAME}/appvar,/config/common/global-appvar -i ${APPNAME} -p FARGATE + +jobs: + # Build & Deploy against development backend + "build-dev": + !!merge <<: *defaults + environment: + DEPLOY_ENV: "DEV" + LOGICAL_ENV: "dev" + APPNAME: "groups-api-v6" + DEPLOYMENT_ENVIRONMENT: 'dev' + steps: *builddeploy_steps + + "build-prod": + !!merge <<: *defaults + environment: + DEPLOY_ENV: "PROD" + LOGICAL_ENV: "prod" + APPNAME: "groups-api-v6" + DEPLOYMENT_ENVIRONMENT: 'prod' + steps: *builddeploy_steps + +workflows: + version: 2 + build: + jobs: + # Development builds are executed on "develop" branch only. + - "build-dev": + context: org-global + filters: + branches: + only: + - develop + + # Production builds are exectuted only on tagged commits to the + # master branch. + - "build-prod": + context: org-global + filters: + branches: + only: + - master \ No newline at end of file diff --git a/.github/workflows/code_reviewer.yml b/.github/workflows/code_reviewer.yml new file mode 100644 index 0000000..82c7862 --- /dev/null +++ b/.github/workflows/code_reviewer.yml @@ -0,0 +1,22 @@ +name: AI PR Reviewer + +on: + pull_request: + types: + - opened + - synchronize +permissions: + pull-requests: write +jobs: + tc-ai-pr-review: + runs-on: ubuntu-latest + steps: + - name: Checkout Repo + uses: actions/checkout@v3 + + - name: TC AI PR Reviewer + uses: topcoder-platform/tc-ai-pr-reviewer@master + with: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # The GITHUB_TOKEN is there by default so you just need to keep it like it is and not necessarily need to add it as secret as it will throw an error. [More Details](https://docs.github.com/en/actions/security-guides/automatic-token-authentication#about-the-github_token-secret) + LAB45_API_KEY: ${{ secrets.LAB45_API_KEY }} + exclude: '**/*.json, **/*.md, **/*.jpg, **/*.png, **/*.jpeg, **/*.bmp, **/*.webp' # Optional: exclude patterns separated by commas \ No newline at end of file diff --git a/README.md b/README.md index aedf077..1380d88 100644 --- a/README.md +++ b/README.md @@ -83,3 +83,34 @@ For a read-only scope M2M token, use: - then you can use Postman to test all apis - Swagger docs are accessible at `http://localhost:3000/api-docs` + +**Downstream Usage** + +- This service is consumed by multiple Topcoder apps. Below is a quick map of where and how it’s called to help with debugging. + +**platform-ui** + +- Admin pages manage groups and memberships using v6 endpoints: + - List/search groups: `GET /v6/groups?page=1&perPage=10000` (optionally filter by `name`, or by `memberId` + `membershipType=user`). See platform-ui/src/apps/admin/src/lib/services/groups.service.ts. + - Fetch group by id: `GET /v6/groups/{id}` (optional `fields` query). See platform-ui/src/apps/admin/src/lib/services/groups.service.ts. + - List group members: `GET /v6/groups/{id}/members?page&perPage`. See platform-ui/src/apps/admin/src/lib/services/groups.service.ts. + - Create group: `POST /v6/groups`. See platform-ui/src/apps/admin/src/lib/services/groups.service.ts. + - Add member: `POST /v6/groups/{id}/members` with `{ membershipType: 'user'|'group', memberId }`. See platform-ui/src/apps/admin/src/lib/services/groups.service.ts. + - Remove member: `DELETE /v6/groups/{id}/members/{memberId}`. See platform-ui/src/apps/admin/src/lib/services/groups.service.ts. +- Local dev proxy maps both `/v5/groups` and `/v6/groups` to this service on port 3001. See platform-ui/src/config/environments/local.env.ts. + +**community-app** + +- Used server-side to expand community metadata group IDs to include descendants (group trees). The code acquires an M2M token and calls the groups service helper, which in turn queries the Groups API for a group’s tree of IDs. See community-app/src/server/services/communities.js and community-app/src/server/services/communities.js. +- Community App requires M2M credentials with access to Groups API for this logic. See community-app/README.md. +- Equivalent v6 endpoint for tree expansion is: `GET /v6/groups/{id}?flattenGroupIdTree=true` (also supports `includeSubGroups`, `includeParentGroup`, `oneLevel`). + +**work-manager** + +- Populates group selectors and filters challenge visibility: + - Search groups by name for autocomplete: `GET /v6/groups?name={query}&perPage={large}`. See work-manager/src/components/ChallengeEditor/Groups-Field/index.js. + - Load current user’s groups when creating/editing a challenge: `GET /v6/groups?membershipType=user&memberId={tcUserId}&perPage={large}`. See work-manager/src/actions/challenges.js. + - Fetch group detail by id: `GET /v6/groups/{id}`. See work-manager/src/services/challenges.js. +- API base configuration points to v6 in dev/local and v5 in prod (for backward compatibility): + - Dev: work-manager/config/constants/development.js. + - Local: work-manager/config/constants/local.js and work-manager/config/constants/local.js. diff --git a/package.json b/package.json index e5381bd..c48bfd4 100644 --- a/package.json +++ b/package.json @@ -18,8 +18,7 @@ "test:cov": "jest --coverage", "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", "test:e2e": "jest --config ./test/jest-e2e.json", - "postinstall": "npx prisma generate", - "seed-data": "npx prisma db seed" + "postinstall": "npx prisma generate" }, "dependencies": { "@nestjs/axios": "^4.0.0", diff --git a/src/api/group-membership/groupMembership.service.ts b/src/api/group-membership/groupMembership.service.ts index de549f8..fdd6835 100644 --- a/src/api/group-membership/groupMembership.service.ts +++ b/src/api/group-membership/groupMembership.service.ts @@ -214,12 +214,6 @@ export class GroupMembershipService { let memberOldId; - if (!group.oldId || group.oldId.length <= 0) { - throw new ForbiddenException( - 'Parent group is not ready yet, try after sometime', - ); - } - const memberId = dto.memberId ? dto.memberId : (dto.universalUID as string); diff --git a/src/api/group/group.service.ts b/src/api/group/group.service.ts index 061845c..6effb5d 100644 --- a/src/api/group/group.service.ts +++ b/src/api/group/group.service.ts @@ -37,7 +37,7 @@ import { import { M2MService } from 'src/shared/modules/global/m2m.service'; -const ADMIN_GROUP_FIELDS = ['status']; +const ADMIN_GROUP_FIELDS: string[] = []; export const ALLOWED_FIELD_NAMES = [ 'id', @@ -52,6 +52,7 @@ export const ALLOWED_FIELD_NAMES = [ 'domain', 'organizationId', 'oldId', + 'status', ]; @Injectable() @@ -128,10 +129,6 @@ export class GroupService { if (criteria.oldId) { prismaFilter.where.oldId = criteria.oldId; - } else { - prismaFilter.where.oldId = { - not: null, - }; } if (criteria.name) { prismaFilter.where.name = { @@ -352,13 +349,18 @@ export class GroupService { await checkGroupName(dto.name, '', tx); // create group + const createdBy = authUser.userId ? authUser.userId : '00000000'; + const createdAt = new Date().toISOString(); const groupData = { ...dto, domain: dto.domain || '', ssoId: dto.ssoId || '', organizationId: dto.organizationId || '', - createdBy: authUser.userId ? authUser.userId : '00000000', - createdAt: new Date().toISOString(), + createdBy, + createdAt, + // Initialize updated fields to match created fields on creation + updatedBy: createdBy, + updatedAt: createdAt, }; const result = await tx.group.create({ data: groupData }); diff --git a/src/api/subgroup/subGroup.service.ts b/src/api/subgroup/subGroup.service.ts index 6770c60..b1b96c9 100644 --- a/src/api/subgroup/subGroup.service.ts +++ b/src/api/subgroup/subGroup.service.ts @@ -65,13 +65,18 @@ export class SubGroupService { await checkGroupName(dto.name, '', tx); // create group + const createdBy = authUser.userId ? authUser.userId : '00000000'; + const createdAt = new Date().toISOString(); const groupData = { ...dto, domain: dto.domain || '', ssoId: dto.ssoId || '', organizationId: dto.organizationId || '', - createdBy: authUser.userId ? authUser.userId : '00000000', - createdAt: new Date().toISOString(), + createdBy, + createdAt, + // Initialize updated fields to match created fields on creation + updatedBy: createdBy, + updatedAt: createdAt, }; const subGroup = await tx.group.create({ data: groupData }); diff --git a/src/main.ts b/src/main.ts index 7618377..88967f1 100644 --- a/src/main.ts +++ b/src/main.ts @@ -22,14 +22,59 @@ async function bootstrap() { app.setGlobalPrefix(`${apiVer}`); // CORS related settings + const topcoderOriginPatterns = [ + /^https?:\/\/([\w-]+\.)*topcoder\.com(?::\d+)?$/i, + /^https?:\/\/([\w-]+\.)*topcoder-dev\.com(?::\d+)?$/i, + ]; + + const allowList: (string | RegExp)[] = [ + 'http://localhost:3000', + /\.localhost:3000$/, + ]; + + if (process.env.CORS_ALLOWED_ORIGIN) { + try { + allowList.push(new RegExp(process.env.CORS_ALLOWED_ORIGIN)); + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + logger.warn( + `Invalid CORS_ALLOWED_ORIGIN pattern (${process.env.CORS_ALLOWED_ORIGIN}): ${errorMessage}`, + ); + } + } + + const isAllowedOrigin = (origin: string): boolean => { + if ( + allowList.some((allowedOrigin) => { + if (allowedOrigin instanceof RegExp) { + return allowedOrigin.test(origin); + } + return allowedOrigin === origin; + }) + ) { + return true; + } + + return topcoderOriginPatterns.some((pattern) => pattern.test(origin)); + }; + const corsConfig: cors.CorsOptions = { allowedHeaders: 'Origin, X-Requested-With, Content-Type, Accept, Authorization, Access-Control-Allow-Origin, Access-Control-Allow-Headers,currentOrg,overrideOrg,x-atlassian-cloud-id,x-api-key,x-orgid', credentials: true, - origin: process.env.CORS_ALLOWED_ORIGIN - ? new RegExp(process.env.CORS_ALLOWED_ORIGIN) - : ['http://localhost:3000', /\.localhost:3000$/], methods: 'GET, POST, OPTIONS, PUT, DELETE, PATCH', + origin: (requestOrigin, callback) => { + if (!requestOrigin) { + return callback(null, false); + } + + if (isAllowedOrigin(requestOrigin)) { + return callback(null, requestOrigin); + } + + return callback(null, false); + }, }; app.use(cors(corsConfig)); logger.log('CORS configuration applied'); @@ -147,7 +192,7 @@ async function bootstrap() { const document = SwaggerModule.createDocument(app, config, { include: [ApiModule], }); - SwaggerModule.setup(`/api-docs`, app, document); + SwaggerModule.setup(`/v6/groups/api-docs`, app, document); logger.log('Swagger documentation configured'); // Add an event handler to log uncaught promise rejections and prevent the server from crashing diff --git a/src/shared/enums/userRole.enum.ts b/src/shared/enums/userRole.enum.ts index 347c9a8..d0ed7b1 100644 --- a/src/shared/enums/userRole.enum.ts +++ b/src/shared/enums/userRole.enum.ts @@ -2,7 +2,7 @@ * Enum defining user roles for role-based access control */ export enum UserRole { - Admin = 'Administrator', + Admin = 'administrator', Copilot = 'Copilot', Reviewer = 'Reviewer', Submitter = 'Submitter', diff --git a/src/shared/modules/global/jwt.service.ts b/src/shared/modules/global/jwt.service.ts index 57df419..60d04b7 100644 --- a/src/shared/modules/global/jwt.service.ts +++ b/src/shared/modules/global/jwt.service.ts @@ -35,6 +35,15 @@ const TEST_M2M_TOKENS: Record = { 'm2m-token-groups': [Scope.AllGroups], }; +const SCOPE_SYNONYMS: Record = { + 'read:group': [Scope.ReadGroups], + [Scope.ReadGroups]: ['read:group'], + 'write:group': [Scope.WriteGroups], + [Scope.WriteGroups]: ['write:group'], + 'all:group': [Scope.AllGroups], + [Scope.AllGroups]: ['all:group'], +}; + @Injectable() export class JwtService implements OnModuleInit { private jwksClientInstance: jwksClient.JwksClient; @@ -177,16 +186,30 @@ export class JwtService implements OnModuleInit { */ private expandScopes(scopes: string[]): string[] { const expandedScopes = new Set(); + const queue = [...scopes]; - // Add all original scopes - scopes.forEach((scope) => expandedScopes.add(scope)); - - // Expand all "all:*" scopes - scopes.forEach((scope) => { - if (ALL_SCOPE_MAPPINGS[scope]) { - ALL_SCOPE_MAPPINGS[scope].forEach((s) => expandedScopes.add(s)); + while (queue.length > 0) { + const scope = queue.shift(); + if (!scope || expandedScopes.has(scope)) { + continue; } - }); + + expandedScopes.add(scope); + + const synonyms = SCOPE_SYNONYMS[scope] ?? []; + synonyms.forEach((alias) => { + if (!expandedScopes.has(alias)) { + queue.push(alias); + } + }); + + const mappedScopes = ALL_SCOPE_MAPPINGS[scope] ?? []; + mappedScopes.forEach((alias) => { + if (!expandedScopes.has(alias)) { + queue.push(alias); + } + }); + } return Array.from(expandedScopes); }