Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .circleci/config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,7 @@ workflows:
context: org-global
filters: &filters-dev
branches:
only: ["develop", "pm-2917", "points", "pm-3270", "engagements"]
only: ["develop", "pm-2917", "points", "pm-3270", "projects-api-v6"]

# Production builds are exectuted only on tagged commits to the
# master branch.
Expand Down
62 changes: 62 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,68 @@ This is the frontend application for creating and managing challenges.
Production configuration is in `config/constants/production.js`
Development configuration is in `config/constants/development.js`

## Project Invitation Flow

### Route handled

`/projects/:projectId/invitation/:action?`

Handled by `ProjectInvitations` container (`src/containers/ProjectInvitations/index.js`).

### Email link format

When `projects-api-v6` sends an invite email to a **known user** (existing Topcoder account), the email contains two action buttons whose links must use this exact format:

| Button | URL |
| --- | --- |
| Join Project | `{WORK_MANAGER_URL}/projects/{projectId}/invitation/accepted?source=email` |
| Decline Invitation | `{WORK_MANAGER_URL}/projects/{projectId}/invitation/refused?source=email` |

- `{WORK_MANAGER_URL}` is the `WORK_MANAGER_URL` env var configured in `projects-api-v6`.
- The `?source=email` query parameter is forwarded in the `PATCH /v6/projects/{projectId}/invites/{inviteId}` body as `{ status, source }`.

### Automatic action behaviour

When a user clicks either link and lands on the route with `:action` set, `ProjectInvitations` automatically calls `updateProjectMemberInvite` without showing the confirmation modal. After success it redirects to:

- `accepted` → `/projects/{projectId}/challenges`
- `refused` → `/projects`

### Manual (modal) flow

When the route is accessed **without** an `:action` segment (e.g., navigating directly to `/projects/{projectId}/invitation`), the container shows a `ConfirmationModal` with **Join project** / **Decline** buttons.

### API call made

Both flows call `PATCH /v6/projects/{projectId}/invites/{inviteId}` via `updateProjectMemberInvite` in `work-manager/src/services/projectMemberInvites.js`, with body `{ status: 'accepted' | 'refused', source?: 'email' }`.

### Env var cross-reference

`WORK_MANAGER_URL` is documented in the `projects-api-v6` README under Environment Variables. Ensure it is set to the deployed work-manager origin (no trailing slash), e.g.:

- Dev: `https://challenges.topcoder-dev.com`
- Prod: `https://work.topcoder.com`

### Sequence diagram

```mermaid
sequenceDiagram
participant User
participant Email
participant WorkManager
participant ProjectInvitations
participant ProjectsAPIv6

ProjectsAPIv6->>Email: sendInviteEmail (POST /invites)
Email-->>User: Join Project button → WORK_MANAGER_URL/projects/{id}/invitation/accepted?source=email
Email-->>User: Decline button → WORK_MANAGER_URL/projects/{id}/invitation/refused?source=email
User->>WorkManager: GET /projects/{id}/invitation/accepted?source=email
WorkManager->>ProjectInvitations: render (automaticAction = 'accepted')
ProjectInvitations->>ProjectsAPIv6: PATCH /v6/projects/{id}/invites/{inviteId} {status:'accepted', source:'email'}
ProjectsAPIv6-->>ProjectInvitations: 200 OK
ProjectInvitations->>WorkManager: redirect /projects/{id}/challenges
```

## Local Deployment Instructions

1. First install dependencies
Expand Down
2 changes: 1 addition & 1 deletion config/constants/development.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ module.exports = {
CHALLENGE_PHASES_URL: `${DEV_API_HOSTNAME}/v6/challenge-phases`,
CHALLENGE_TIMELINES_URL: `${DEV_API_HOSTNAME}/v6/challenge-timelines`,
COPILOTS_URL: 'https://copilots.topcoder-dev.com',
PROJECT_API_URL: `${DEV_API_HOSTNAME}/v5/projects`,
PROJECT_API_URL: `${DEV_API_HOSTNAME}/v6/projects`,

Choose a reason for hiding this comment

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

[❗❗ correctness]
Ensure that all dependencies and integrations using the PROJECT_API_URL are compatible with the new API version v6. This change may affect other parts of the system relying on the previous v5 endpoint.

GROUPS_API_URL: `${DEV_API_HOSTNAME}/v6/groups`,
TERMS_API_URL: `${DEV_API_HOSTNAME}/v5/terms`,
RESOURCES_API_URL: `${DEV_API_HOSTNAME}/v6/resources`,
Expand Down
5 changes: 3 additions & 2 deletions config/constants/local.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ const LOCAL_MEMBER_API = 'http://localhost:3003/v6'
const LOCAL_RESOURCE_API = 'http://localhost:3004/v6'
const LOCAL_REVIEW_API = 'http://localhost:3005/v6'
const LOCAL_SKILLS_API_V5 = 'http://localhost:3006/v5/standardized-skills'
const LOCAL_PROJECTS_API = 'http://localhost:3008/v6/projects'

Choose a reason for hiding this comment

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

[⚠️ security]
Consider using HTTPS instead of HTTP for LOCAL_PROJECTS_API to ensure secure communication, even in local development environments. This can help catch potential issues early and align with production security standards.

// Lookups API available on 3007 if needed in future
// const LOCAL_LOOKUPS_API = 'http://localhost:3007/v6'

Expand Down Expand Up @@ -46,8 +47,8 @@ module.exports = {
// Copilots and other apps remain on dev
COPILOTS_URL: 'https://copilots.topcoder-dev.com',

// Projects API: keep dev unless you run projects locally
PROJECT_API_URL: `${DEV_API_HOSTNAME}/v5/projects`,
// Projects API v6: keep dev default (switch to LOCAL_PROJECTS_API when needed)
PROJECT_API_URL: `${DEV_API_HOSTNAME}/v6/projects`,

// Local groups/resources/review services
GROUPS_API_URL: `${LOCAL_GROUPS_API}/groups`,
Expand Down
2 changes: 1 addition & 1 deletion config/constants/production.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ module.exports = {
CHALLENGE_PHASES_URL: `${PROD_API_HOSTNAME}/v6/challenge-phases`,
CHALLENGE_TIMELINES_URL: `${PROD_API_HOSTNAME}/v6/challenge-timelines`,
COPILOTS_URL: `https://copilots.${DOMAIN}`,
PROJECT_API_URL: `${PROD_API_HOSTNAME}/v5/projects`,
PROJECT_API_URL: `${PROD_API_HOSTNAME}/v6/projects`,

Choose a reason for hiding this comment

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

[❗❗ correctness]
Ensure that all dependent services and clients are updated to use the new v6 endpoint for projects. This change could break integrations if they are still expecting the v5 endpoint.

GROUPS_API_URL: `${PROD_API_HOSTNAME}/v6/groups`,
TERMS_API_URL: `${PROD_API_HOSTNAME}/v5/terms`,
MEMBERS_API_URL: `${PROD_API_HOSTNAME}/v5/members`,
Expand Down
85 changes: 15 additions & 70 deletions src/actions/engagements.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import {
patchEngagement,
deleteEngagement as deleteEngagementAPI
} from '../services/engagements'
import { fetchProjectById } from '../services/projects'
import { fetchSkillsByIds } from '../services/skills'
import {
normalizeEngagement,
Expand All @@ -34,8 +33,6 @@ import {
DELETE_ENGAGEMENT_FAILURE
} from '../config/constants'

const projectNameCache = {}

const getSkillId = (skill) => {
if (!skill) {
return null
Expand Down Expand Up @@ -96,70 +93,6 @@ const withSkillDetails = (engagement, skillsMap) => {
}
}

const getProjectId = (engagement) => {
if (!engagement || !engagement.projectId) {
return null
}
return String(engagement.projectId)
}

const getProjectName = (project) => {
if (!project || typeof project !== 'object') {
return null
}
if (typeof project.name === 'string' && project.name.trim()) {
return project.name
}
if (typeof project.projectName === 'string' && project.projectName.trim()) {
return project.projectName
}
return null
}

const hydrateEngagementProjectNames = async (engagements = []) => {
if (!Array.isArray(engagements) || !engagements.length) {
return []
}

const projectIds = Array.from(new Set(
engagements
.map(getProjectId)
.filter(Boolean)
))

if (!projectIds.length) {
return engagements
}

const uncachedProjectIds = projectIds.filter((projectId) => !projectNameCache[projectId])
if (uncachedProjectIds.length) {
const projectNameEntries = await Promise.all(
uncachedProjectIds.map(async (projectId) => {
try {
const project = await fetchProjectById(projectId)
return [projectId, getProjectName(project)]
} catch (error) {
return [projectId, null]
}
})
)

projectNameEntries.forEach(([projectId, projectName]) => {
if (projectName) {
projectNameCache[projectId] = projectName
}
})
}

return engagements.map((engagement) => {
const projectId = getProjectId(engagement)
return {
...engagement,
projectName: (projectId && projectNameCache[projectId]) || engagement.projectName || null
}
})
}

const hydrateEngagementSkills = async (engagements = []) => {
if (!Array.isArray(engagements) || !engagements.length) {
return []
Expand Down Expand Up @@ -195,9 +128,19 @@ const hydrateEngagementSkills = async (engagements = []) => {
* @param {String} status
* @param {String} filterName
* @param {Boolean} includePrivate
* @param {Array<String>} projectIds
*/
export function loadEngagements (projectId, status = 'all', filterName = '', includePrivate = false) {
export function loadEngagements (projectId, status = 'all', filterName = '', includePrivate = false, projectIds = []) {
const hasProjectIdsArg = arguments.length >= 5

Choose a reason for hiding this comment

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

[⚠️ maintainability]
The projectIds parameter is added to the loadEngagements function, but its usage is conditional on arguments.length >= 5. This check is redundant since projectIds has a default value of an empty array. Consider removing the hasProjectIdsArg check and directly using projectIds to simplify the logic.

return async (dispatch) => {
if (hasProjectIdsArg && Array.isArray(projectIds) && !projectIds.length) {

Choose a reason for hiding this comment

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

[💡 readability]
The check for Array.isArray(projectIds) && !projectIds.length is redundant because projectIds is initialized as an empty array by default. Consider simplifying this condition to just !projectIds.length.

dispatch({
type: LOAD_ENGAGEMENTS_SUCCESS,
engagements: []
})
return
}

dispatch({
type: LOAD_ENGAGEMENTS_PENDING
})
Expand All @@ -215,6 +158,9 @@ export function loadEngagements (projectId, status = 'all', filterName = '', inc
if (includePrivate) {
filters.includePrivate = true
}
if (projectIds && projectIds.length) {
filters.projectIds = projectIds
}

try {
const engagements = []
Expand Down Expand Up @@ -273,8 +219,7 @@ export function loadEngagements (projectId, status = 'all', filterName = '', inc
} while (!totalPages || page <= totalPages)

const hydratedEngagements = await hydrateEngagementSkills(engagements)
const engagementsWithProjectNames = await hydrateEngagementProjectNames(hydratedEngagements)
const normalizedEngagements = normalizeEngagements(engagementsWithProjectNames)
const normalizedEngagements = normalizeEngagements(hydratedEngagements)
dispatch({
type: LOAD_ENGAGEMENTS_SUCCESS,
engagements: normalizedEngagements
Expand Down
28 changes: 28 additions & 0 deletions src/actions/projects.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
LOAD_CHALLENGE_MEMBERS,
LOAD_PROJECT_TYPES,
CREATE_PROJECT,
CLEAR_PROJECT_DETAIL,
LOAD_PROJECT_BILLING_ACCOUNTS,
UPDATE_PROJECT_PENDING,
UPDATE_PROJECT_SUCCESS,
Expand All @@ -36,6 +37,18 @@ import {
} from '../services/projects'
import { checkAdmin, checkManager } from '../util/tc'

/**
* Loads projects with optional filters and enforces membership scoping for
* non-admin/non-manager users.
*
* Backend contract: when `memberOnly` is true, the API must apply
* membership/invite visibility constraints so users only receive projects they
* can access.
*
* @param {string} projectNameOrIdFilter Optional id/keyword filter.
* @param {Object} paramFilters Additional query filters.
* @returns {Function} Redux thunk.
*/
function _loadProjects (projectNameOrIdFilter = '', paramFilters = {}) {
return (dispatch, getState) => {
dispatch({
Expand All @@ -57,6 +70,7 @@ function _loadProjects (projectNameOrIdFilter = '', paramFilters = {}) {
}

if (!checkAdmin(getState().auth.token) && !checkManager(getState().auth.token)) {
// Non-admin users must always be server-scoped to member-visible projects.
filters['memberOnly'] = true
}

Expand Down Expand Up @@ -161,6 +175,20 @@ export function loadProject (projectId, filterMembers = true) {
}
}

/**
* Clears the currently selected project details from Redux state.
* Use this when entering a create-project flow so stale project data is not reused.
*
* @returns {Function} thunk dispatching the clear action
*/
export function clearProjectDetail () {
return (dispatch) => {
return dispatch({
type: CLEAR_PROJECT_DETAIL
})
}
}

/**
* Loads project types
*/
Expand Down
Loading
Loading