Skip to content
79 changes: 79 additions & 0 deletions .circleci/config.yml
Original file line number Diff line number Diff line change
@@ -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

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[⚠️ maintainability]
Using sudo pip3 install can lead to permission issues and conflicts with system packages. Consider using a virtual environment or a tool like pipenv to manage Python dependencies.

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

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[⚠️ correctness]
Cloning a specific branch from a repository without pinning to a commit hash can lead to unexpected changes if the branch is updated. Consider using a commit hash to ensure consistency.

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=<<pipeline.parameters.reset-db>> --build-arg SEED_DATA_ARG=${DEPLOYMENT_ENVIRONMENT} -t ${APPNAME}:latest .

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[💡 performance]
Using --no-cache=true in the Docker build command can significantly increase build times. Ensure this is necessary for your use case, as it forces a full rebuild every time.

- 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

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[💡 style]
The file is missing a newline at the end, which is a common convention to avoid issues with some tools and version control systems.

22 changes: 22 additions & 0 deletions .github/workflows/code_reviewer.yml
Original file line number Diff line number Diff line change
@@ -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

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[💡 style]
Consider adding a newline at the end of the file to adhere to POSIX standards and improve compatibility with various tools and systems.

31 changes: 31 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
3 changes: 1 addition & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
6 changes: 0 additions & 6 deletions src/api/group-membership/groupMembership.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
16 changes: 9 additions & 7 deletions src/api/group/group.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ import {

import { M2MService } from 'src/shared/modules/global/m2m.service';

const ADMIN_GROUP_FIELDS = ['status'];
const ADMIN_GROUP_FIELDS: string[] = [];

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[⚠️ correctness]
The ADMIN_GROUP_FIELDS constant is now an empty array. If this change is intentional, ensure that any logic relying on this constant is updated accordingly. If not, consider restoring the previous values to avoid potential issues with admin field omission.


export const ALLOWED_FIELD_NAMES = [
'id',
Expand All @@ -52,6 +52,7 @@ export const ALLOWED_FIELD_NAMES = [
'domain',
'organizationId',
'oldId',
'status',
];

@Injectable()
Expand Down Expand Up @@ -128,10 +129,6 @@ export class GroupService {

if (criteria.oldId) {
prismaFilter.where.oldId = criteria.oldId;

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[❗❗ correctness]
The removal of the else block that sets prismaFilter.where.oldId to not: null changes the behavior when criteria.oldId is not provided. Ensure this change is intentional and that the new behavior aligns with the expected logic.

} else {
prismaFilter.where.oldId = {
not: null,
};
}
if (criteria.name) {
prismaFilter.where.name = {
Expand Down Expand Up @@ -352,13 +349,18 @@ export class GroupService {
await checkGroupName(dto.name, '', tx);

// create group
const createdBy = authUser.userId ? authUser.userId : '00000000';

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[💡 maintainability]
The createdBy and createdAt assignments have been extracted to variables. This improves readability and maintainability by avoiding repeated expressions. Ensure that these variables are used consistently throughout the method.

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,

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[⚠️ design]
The updatedBy and updatedAt fields are initialized to match the createdBy and createdAt fields on creation. Ensure this behavior is intended and that it aligns with the business logic for newly created groups.

updatedAt: createdAt,
};

const result = await tx.group.create({ data: groupData });
Expand Down
9 changes: 7 additions & 2 deletions src/api/subgroup/subGroup.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,13 +65,18 @@ export class SubGroupService {
await checkGroupName(dto.name, '', tx);

// create group
const createdBy = authUser.userId ? authUser.userId : '00000000';

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[⚠️ maintainability]
The use of a hardcoded fallback value '00000000' for createdBy might not be ideal. Consider using a more meaningful default or handling this case differently to avoid potential confusion or misuse.

const createdAt = new Date().toISOString();

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[⚠️ correctness]
The createdAt value is generated using new Date().toISOString(). Ensure that the server's timezone settings are consistent and that this format aligns with the rest of your system's date handling to prevent potential discrepancies.

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,

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[⚠️ correctness]
Initializing updatedBy and updatedAt to the same values as createdBy and createdAt is logical for creation, but ensure that these fields are properly updated in subsequent operations to reflect actual updates.

updatedAt: createdAt,
};

const subGroup = await tx.group.create({ data: groupData });
Expand Down
53 changes: 49 additions & 4 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[⚠️ correctness]
Returning false for a missing requestOrigin might cause issues if there are legitimate requests without an Origin header, such as server-to-server requests. Consider allowing such requests or handling them differently.

}

if (isAllowedOrigin(requestOrigin)) {
return callback(null, requestOrigin);
}

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[💡 maintainability]
Returning false for origins not in the allow list without logging could make debugging difficult. Consider logging the rejected origin for better traceability.

return callback(null, false);
},
};
app.use(cors(corsConfig));
logger.log('CORS configuration applied');
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion src/shared/enums/userRole.enum.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
* Enum defining user roles for role-based access control
*/
export enum UserRole {
Admin = 'Administrator',
Admin = 'administrator',

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[❗❗ correctness]
Changing the value of Admin from 'Administrator' to 'administrator' could impact any logic that relies on case-sensitive string comparison. Ensure that all usages of this enum value are updated accordingly to prevent potential bugs.

Copilot = 'Copilot',
Reviewer = 'Reviewer',
Submitter = 'Submitter',
Expand Down
39 changes: 31 additions & 8 deletions src/shared/modules/global/jwt.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,15 @@ const TEST_M2M_TOKENS: Record<string, string[]> = {
'm2m-token-groups': [Scope.AllGroups],
};

const SCOPE_SYNONYMS: Record<string, string[]> = {
'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;
Expand Down Expand Up @@ -177,16 +186,30 @@ export class JwtService implements OnModuleInit {
*/
private expandScopes(scopes: string[]): string[] {
const expandedScopes = new Set<string>();
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)) {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[⚠️ performance]
The queue.shift() operation is O(n) in time complexity because it requires shifting all elements in the array. Consider using a different data structure, such as a linked list, for better performance if the queue is expected to be large.

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);
}
Expand Down