Skip to content

Commit d989737

Browse files
authored
Merge pull request #1736 from topcoder-platform/develop
March 2026 prod release
2 parents 17d5c45 + 62107fd commit d989737

58 files changed

Lines changed: 2461 additions & 342 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.circleci/config.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -160,7 +160,7 @@ workflows:
160160
context: org-global
161161
filters: &filters-dev
162162
branches:
163-
only: ["develop", "pm-2917", "points", "pm-3270", "engagements"]
163+
only: ["develop", "pm-2917", "points", "pm-3270", "projects-api-v6"]
164164

165165
# Production builds are exectuted only on tagged commits to the
166166
# master branch.

README.md

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,68 @@ This is the frontend application for creating and managing challenges.
2929
Production configuration is in `config/constants/production.js`
3030
Development configuration is in `config/constants/development.js`
3131

32+
## Project Invitation Flow
33+
34+
### Route handled
35+
36+
`/projects/:projectId/invitation/:action?`
37+
38+
Handled by `ProjectInvitations` container (`src/containers/ProjectInvitations/index.js`).
39+
40+
### Email link format
41+
42+
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:
43+
44+
| Button | URL |
45+
| --- | --- |
46+
| Join Project | `{WORK_MANAGER_URL}/projects/{projectId}/invitation/accepted?source=email` |
47+
| Decline Invitation | `{WORK_MANAGER_URL}/projects/{projectId}/invitation/refused?source=email` |
48+
49+
- `{WORK_MANAGER_URL}` is the `WORK_MANAGER_URL` env var configured in `projects-api-v6`.
50+
- The `?source=email` query parameter is forwarded in the `PATCH /v6/projects/{projectId}/invites/{inviteId}` body as `{ status, source }`.
51+
52+
### Automatic action behaviour
53+
54+
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:
55+
56+
- `accepted``/projects/{projectId}/challenges`
57+
- `refused``/projects`
58+
59+
### Manual (modal) flow
60+
61+
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.
62+
63+
### API call made
64+
65+
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' }`.
66+
67+
### Env var cross-reference
68+
69+
`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.:
70+
71+
- Dev: `https://challenges.topcoder-dev.com`
72+
- Prod: `https://work.topcoder.com`
73+
74+
### Sequence diagram
75+
76+
```mermaid
77+
sequenceDiagram
78+
participant User
79+
participant Email
80+
participant WorkManager
81+
participant ProjectInvitations
82+
participant ProjectsAPIv6
83+
84+
ProjectsAPIv6->>Email: sendInviteEmail (POST /invites)
85+
Email-->>User: Join Project button → WORK_MANAGER_URL/projects/{id}/invitation/accepted?source=email
86+
Email-->>User: Decline button → WORK_MANAGER_URL/projects/{id}/invitation/refused?source=email
87+
User->>WorkManager: GET /projects/{id}/invitation/accepted?source=email
88+
WorkManager->>ProjectInvitations: render (automaticAction = 'accepted')
89+
ProjectInvitations->>ProjectsAPIv6: PATCH /v6/projects/{id}/invites/{inviteId} {status:'accepted', source:'email'}
90+
ProjectsAPIv6-->>ProjectInvitations: 200 OK
91+
ProjectInvitations->>WorkManager: redirect /projects/{id}/challenges
92+
```
93+
3294
## Local Deployment Instructions
3395

3496
1. First install dependencies

config/constants/development.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ module.exports = {
3030
CHALLENGE_PHASES_URL: `${DEV_API_HOSTNAME}/v6/challenge-phases`,
3131
CHALLENGE_TIMELINES_URL: `${DEV_API_HOSTNAME}/v6/challenge-timelines`,
3232
COPILOTS_URL: 'https://copilots.topcoder-dev.com',
33-
PROJECT_API_URL: `${DEV_API_HOSTNAME}/v5/projects`,
33+
PROJECT_API_URL: `${DEV_API_HOSTNAME}/v6/projects`,
3434
GROUPS_API_URL: `${DEV_API_HOSTNAME}/v6/groups`,
3535
TERMS_API_URL: `${DEV_API_HOSTNAME}/v5/terms`,
3636
RESOURCES_API_URL: `${DEV_API_HOSTNAME}/v6/resources`,

config/constants/local.js

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ const LOCAL_MEMBER_API = 'http://localhost:3003/v6'
1212
const LOCAL_RESOURCE_API = 'http://localhost:3004/v6'
1313
const LOCAL_REVIEW_API = 'http://localhost:3005/v6'
1414
const LOCAL_SKILLS_API_V5 = 'http://localhost:3006/v5/standardized-skills'
15+
const LOCAL_PROJECTS_API = 'http://localhost:3008/v6/projects'
1516
// Lookups API available on 3007 if needed in future
1617
// const LOCAL_LOOKUPS_API = 'http://localhost:3007/v6'
1718

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

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

5253
// Local groups/resources/review services
5354
GROUPS_API_URL: `${LOCAL_GROUPS_API}/groups`,

config/constants/production.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ module.exports = {
2929
CHALLENGE_PHASES_URL: `${PROD_API_HOSTNAME}/v6/challenge-phases`,
3030
CHALLENGE_TIMELINES_URL: `${PROD_API_HOSTNAME}/v6/challenge-timelines`,
3131
COPILOTS_URL: `https://copilots.${DOMAIN}`,
32-
PROJECT_API_URL: `${PROD_API_HOSTNAME}/v5/projects`,
32+
PROJECT_API_URL: `${PROD_API_HOSTNAME}/v6/projects`,
3333
GROUPS_API_URL: `${PROD_API_HOSTNAME}/v6/groups`,
3434
TERMS_API_URL: `${PROD_API_HOSTNAME}/v5/terms`,
3535
MEMBERS_API_URL: `${PROD_API_HOSTNAME}/v5/members`,

src/actions/engagements.js

Lines changed: 15 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ import {
77
patchEngagement,
88
deleteEngagement as deleteEngagementAPI
99
} from '../services/engagements'
10-
import { fetchProjectById } from '../services/projects'
1110
import { fetchSkillsByIds } from '../services/skills'
1211
import {
1312
normalizeEngagement,
@@ -34,8 +33,6 @@ import {
3433
DELETE_ENGAGEMENT_FAILURE
3534
} from '../config/constants'
3635

37-
const projectNameCache = {}
38-
3936
const getSkillId = (skill) => {
4037
if (!skill) {
4138
return null
@@ -96,70 +93,6 @@ const withSkillDetails = (engagement, skillsMap) => {
9693
}
9794
}
9895

99-
const getProjectId = (engagement) => {
100-
if (!engagement || !engagement.projectId) {
101-
return null
102-
}
103-
return String(engagement.projectId)
104-
}
105-
106-
const getProjectName = (project) => {
107-
if (!project || typeof project !== 'object') {
108-
return null
109-
}
110-
if (typeof project.name === 'string' && project.name.trim()) {
111-
return project.name
112-
}
113-
if (typeof project.projectName === 'string' && project.projectName.trim()) {
114-
return project.projectName
115-
}
116-
return null
117-
}
118-
119-
const hydrateEngagementProjectNames = async (engagements = []) => {
120-
if (!Array.isArray(engagements) || !engagements.length) {
121-
return []
122-
}
123-
124-
const projectIds = Array.from(new Set(
125-
engagements
126-
.map(getProjectId)
127-
.filter(Boolean)
128-
))
129-
130-
if (!projectIds.length) {
131-
return engagements
132-
}
133-
134-
const uncachedProjectIds = projectIds.filter((projectId) => !projectNameCache[projectId])
135-
if (uncachedProjectIds.length) {
136-
const projectNameEntries = await Promise.all(
137-
uncachedProjectIds.map(async (projectId) => {
138-
try {
139-
const project = await fetchProjectById(projectId)
140-
return [projectId, getProjectName(project)]
141-
} catch (error) {
142-
return [projectId, null]
143-
}
144-
})
145-
)
146-
147-
projectNameEntries.forEach(([projectId, projectName]) => {
148-
if (projectName) {
149-
projectNameCache[projectId] = projectName
150-
}
151-
})
152-
}
153-
154-
return engagements.map((engagement) => {
155-
const projectId = getProjectId(engagement)
156-
return {
157-
...engagement,
158-
projectName: (projectId && projectNameCache[projectId]) || engagement.projectName || null
159-
}
160-
})
161-
}
162-
16396
const hydrateEngagementSkills = async (engagements = []) => {
16497
if (!Array.isArray(engagements) || !engagements.length) {
16598
return []
@@ -195,9 +128,19 @@ const hydrateEngagementSkills = async (engagements = []) => {
195128
* @param {String} status
196129
* @param {String} filterName
197130
* @param {Boolean} includePrivate
131+
* @param {Array<String>} projectIds
198132
*/
199-
export function loadEngagements (projectId, status = 'all', filterName = '', includePrivate = false) {
133+
export function loadEngagements (projectId, status = 'all', filterName = '', includePrivate = false, projectIds = []) {
134+
const hasProjectIdsArg = arguments.length >= 5
200135
return async (dispatch) => {
136+
if (hasProjectIdsArg && Array.isArray(projectIds) && !projectIds.length) {
137+
dispatch({
138+
type: LOAD_ENGAGEMENTS_SUCCESS,
139+
engagements: []
140+
})
141+
return
142+
}
143+
201144
dispatch({
202145
type: LOAD_ENGAGEMENTS_PENDING
203146
})
@@ -215,6 +158,9 @@ export function loadEngagements (projectId, status = 'all', filterName = '', inc
215158
if (includePrivate) {
216159
filters.includePrivate = true
217160
}
161+
if (projectIds && projectIds.length) {
162+
filters.projectIds = projectIds
163+
}
218164

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

275221
const hydratedEngagements = await hydrateEngagementSkills(engagements)
276-
const engagementsWithProjectNames = await hydrateEngagementProjectNames(hydratedEngagements)
277-
const normalizedEngagements = normalizeEngagements(engagementsWithProjectNames)
222+
const normalizedEngagements = normalizeEngagements(hydratedEngagements)
278223
dispatch({
279224
type: LOAD_ENGAGEMENTS_SUCCESS,
280225
engagements: normalizedEngagements

src/actions/projects.js

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
LOAD_CHALLENGE_MEMBERS,
1515
LOAD_PROJECT_TYPES,
1616
CREATE_PROJECT,
17+
CLEAR_PROJECT_DETAIL,
1718
LOAD_PROJECT_BILLING_ACCOUNTS,
1819
UPDATE_PROJECT_PENDING,
1920
UPDATE_PROJECT_SUCCESS,
@@ -36,6 +37,18 @@ import {
3637
} from '../services/projects'
3738
import { checkAdmin, checkManager } from '../util/tc'
3839

40+
/**
41+
* Loads projects with optional filters and enforces membership scoping for
42+
* non-admin/non-manager users.
43+
*
44+
* Backend contract: when `memberOnly` is true, the API must apply
45+
* membership/invite visibility constraints so users only receive projects they
46+
* can access.
47+
*
48+
* @param {string} projectNameOrIdFilter Optional id/keyword filter.
49+
* @param {Object} paramFilters Additional query filters.
50+
* @returns {Function} Redux thunk.
51+
*/
3952
function _loadProjects (projectNameOrIdFilter = '', paramFilters = {}) {
4053
return (dispatch, getState) => {
4154
dispatch({
@@ -57,6 +70,7 @@ function _loadProjects (projectNameOrIdFilter = '', paramFilters = {}) {
5770
}
5871

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

@@ -161,6 +175,20 @@ export function loadProject (projectId, filterMembers = true) {
161175
}
162176
}
163177

178+
/**
179+
* Clears the currently selected project details from Redux state.
180+
* Use this when entering a create-project flow so stale project data is not reused.
181+
*
182+
* @returns {Function} thunk dispatching the clear action
183+
*/
184+
export function clearProjectDetail () {
185+
return (dispatch) => {
186+
return dispatch({
187+
type: CLEAR_PROJECT_DETAIL
188+
})
189+
}
190+
}
191+
164192
/**
165193
* Loads project types
166194
*/

0 commit comments

Comments
 (0)