Skip to content

Commit 1514601

Browse files
committed
PM-2648: Support wrapped PM role lookups for application emails
What was broken PM users could still be missed by copilot application emails when the Identity API returned role lists or role subjects in the legacy wrapped response shape. Root cause The PM recipient lookup only parsed direct array/direct subjects payloads and requested subjects with the newer selector parameter. Legacy Identity responses expose role data under result.content and role subjects under result.content.subjects, so the lookup could resolve no PM subjects and skip the notification. What was changed Updated MemberService role-subject parsing to support direct and legacy wrapped Identity responses, include the legacy fields=subjects expansion parameter, and accept subjectId as well as subjectID/userId when normalizing recipients. Any added/updated tests Added MemberService coverage for legacy wrapped Project Manager role and subject responses to verify PM recipients are still resolved for copilot application notifications.
1 parent 33a0a15 commit 1514601

2 files changed

Lines changed: 157 additions & 14 deletions

File tree

src/shared/services/member.service.spec.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,65 @@ describe('MemberService', () => {
8888
);
8989
});
9090

91+
it('returns subjects from legacy wrapped Identity role responses', async () => {
92+
httpServiceMock.get.mockImplementation(
93+
(url: string, options: { params?: { filter?: string } }) => {
94+
if (
95+
url === 'https://identity.test/roles' &&
96+
options.params?.filter === 'roleName=Project Manager'
97+
) {
98+
return of({
99+
data: {
100+
result: {
101+
content: [{ id: '201', roleName: 'Project Manager' }],
102+
},
103+
},
104+
});
105+
}
106+
107+
if (url === 'https://identity.test/roles/201') {
108+
return of({
109+
data: {
110+
result: {
111+
content: {
112+
subjects: [
113+
{
114+
subjectId: 5001,
115+
handle: 'pm-one',
116+
email: 'PM.One@Topcoder.com',
117+
},
118+
],
119+
},
120+
},
121+
},
122+
});
123+
}
124+
125+
return of({ data: {} });
126+
},
127+
);
128+
129+
const result = await service.getRoleSubjects('Project Manager');
130+
131+
expect(result).toEqual([
132+
{
133+
userId: 5001,
134+
handle: 'pm-one',
135+
email: 'pm.one@topcoder.com',
136+
},
137+
]);
138+
expect(httpServiceMock.get).toHaveBeenCalledWith(
139+
'https://identity.test/roles/201',
140+
expect.objectContaining({
141+
params: expect.objectContaining({
142+
fields: 'subjects',
143+
selector: 'subjects',
144+
perPage: 200,
145+
}),
146+
}),
147+
);
148+
});
149+
91150
it('returns empty list when role list lookup fails', async () => {
92151
httpServiceMock.get.mockImplementation((url: string) => {
93152
if (url === 'https://identity.test/roles') {

src/shared/services/member.service.ts

Lines changed: 98 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -13,15 +13,12 @@ export interface MemberRoleRecord {
1313

1414
type RoleSubjectRecord = {
1515
subjectID?: string | number;
16+
subjectId?: string | number;
1617
userId?: string | number;
1718
handle?: string;
1819
email?: string;
1920
};
2021

21-
type RoleDetailResponse = {
22-
subjects?: RoleSubjectRecord[];
23-
};
24-
2522
/**
2623
* Member and identity enrichment service.
2724
*
@@ -213,9 +210,9 @@ export class MemberService {
213210
* Looks up role members from Identity API by role name.
214211
*
215212
* Resolves `/roles?filter=roleName=<roleName>` and then
216-
* `/roles/:id?selector=subjects&perPage=200`, returning the merged
217-
* `subjects` list
218-
* normalized to `MemberDetail` shape.
213+
* `/roles/:id` with subject expansion, returning the merged `subjects` list
214+
* normalized to `MemberDetail` shape. Supports both direct array responses
215+
* and the legacy `{ result: { content } }` Identity response wrapper.
219216
*
220217
* Role-detail lookups are tolerant to partial failures; one failing role id
221218
* does not prevent subjects from other matching roles from being returned.
@@ -250,9 +247,9 @@ export class MemberService {
250247
}),
251248
);
252249

253-
const roles = Array.isArray(rolesResponse.data)
254-
? (rolesResponse.data as MemberRoleRecord[])
255-
: [];
250+
const roles = this.extractArrayPayload<MemberRoleRecord>(
251+
rolesResponse.data,
252+
);
256253

257254
const roleIds = roles
258255
.filter(
@@ -276,14 +273,14 @@ export class MemberService {
276273
'Content-Type': 'application/json',
277274
},
278275
params: {
276+
fields: 'subjects',
279277
selector: 'subjects',
280278
perPage: 200,
281279
},
282280
}),
283281
);
284282

285-
const payload = roleResponse.data as RoleDetailResponse;
286-
return Array.isArray(payload?.subjects) ? payload.subjects : [];
283+
return this.extractRoleSubjects(roleResponse.data);
287284
}),
288285
);
289286

@@ -310,7 +307,7 @@ export class MemberService {
310307
.trim()
311308
.toLowerCase();
312309
const subjectId = String(
313-
subject.subjectID || subject.userId || '',
310+
subject.subjectID || subject.subjectId || subject.userId || '',
314311
).trim();
315312

316313
const key = email || subjectId;
@@ -319,7 +316,8 @@ export class MemberService {
319316
}
320317

321318
uniqueSubjects.set(key, {
322-
userId: subject.subjectID || subject.userId || null,
319+
userId:
320+
subject.subjectID || subject.subjectId || subject.userId || null,
323321
handle: subject.handle || null,
324322
email,
325323
});
@@ -337,4 +335,90 @@ export class MemberService {
337335
async getRoleSubjectsByRoleName(roleName: string): Promise<MemberDetail[]> {
338336
return this.getRoleSubjects(roleName);
339337
}
338+
339+
/**
340+
* Extracts an array payload from Identity responses.
341+
*
342+
* @param payload Identity API response body in direct or legacy wrapped form.
343+
* @returns Extracted array or an empty array when the shape is unsupported.
344+
*/
345+
private extractArrayPayload<T>(payload: unknown): T[] {
346+
if (Array.isArray(payload)) {
347+
return payload as T[];
348+
}
349+
350+
if (!payload || typeof payload !== 'object') {
351+
return [];
352+
}
353+
354+
const response = payload as {
355+
content?: unknown;
356+
result?: {
357+
content?: unknown;
358+
};
359+
};
360+
361+
if (Array.isArray(response.result?.content)) {
362+
return response.result.content as T[];
363+
}
364+
365+
if (Array.isArray(response.content)) {
366+
return response.content as T[];
367+
}
368+
369+
return [];
370+
}
371+
372+
/**
373+
* Extracts role subjects from Identity role-detail responses.
374+
*
375+
* @param payload Identity role-detail body in direct, legacy wrapped, or list form.
376+
* @returns Role subject records or an empty array when no subjects are present.
377+
*/
378+
private extractRoleSubjects(payload: unknown): RoleSubjectRecord[] {
379+
const directSubjects = this.extractSubjectsFromObject(payload);
380+
if (directSubjects.length > 0) {
381+
return directSubjects;
382+
}
383+
384+
const listPayload = this.extractArrayPayload<RoleSubjectRecord>(payload);
385+
if (listPayload.length > 0) {
386+
return listPayload;
387+
}
388+
389+
if (!payload || typeof payload !== 'object') {
390+
return [];
391+
}
392+
393+
const response = payload as {
394+
content?: unknown;
395+
result?: {
396+
content?: unknown;
397+
};
398+
};
399+
400+
const resultSubjects = this.extractSubjectsFromObject(
401+
response.result?.content,
402+
);
403+
if (resultSubjects.length > 0) {
404+
return resultSubjects;
405+
}
406+
407+
return this.extractSubjectsFromObject(response.content);
408+
}
409+
410+
/**
411+
* Extracts a `subjects` array from an object-like response fragment.
412+
*
413+
* @param payload Response fragment that may contain a `subjects` property.
414+
* @returns Subjects array or an empty array when absent.
415+
*/
416+
private extractSubjectsFromObject(payload: unknown): RoleSubjectRecord[] {
417+
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
418+
return [];
419+
}
420+
421+
const subjects = (payload as { subjects?: unknown }).subjects;
422+
return Array.isArray(subjects) ? (subjects as RoleSubjectRecord[]) : [];
423+
}
340424
}

0 commit comments

Comments
 (0)