Skip to content

Commit 86c7a31

Browse files
committed
Fixes from QA
1 parent b43995e commit 86c7a31

8 files changed

Lines changed: 289 additions & 27 deletions

docs/api-usage-analysis.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,7 @@
9595
| PATCH | `/v5/projects/copilots/requests/:copilotRequestId` | `platform-ui` | none | `{data:{...partial editable fields...}}` | Updated request object |
9696
| POST | `/v5/projects/:projectId/copilots/requests/:copilotRequestId/approve` | `platform-ui` | none | `{type}` | Created copilot opportunity object |
9797
| GET | `/v5/projects/copilots/opportunities` | `platform-ui`, `community-app` | `page`, `pageSize`, `sort`; `community-app` also sends `noGrouping=true` | none | Opportunity list derived from request `data`; public endpoint |
98-
| GET | `/v5/projects/copilot/opportunity/:id` | `platform-ui` | none | none | Single opportunity details; includes flattened request fields, plus `members` and `canApplyAsCopilot` |
98+
| GET | `/v5/projects/copilot/opportunity/:id` | `platform-ui` | none | none | Single opportunity details; includes flattened request fields, plus `members`, `canApplyAsCopilot`, and admin/manager-only `project` metadata |
9999
| POST | `/v5/projects/copilots/opportunity/:id/apply` | `platform-ui` | none | `{notes}` | Created (or existing) copilot application object |
100100
| GET | `/v5/projects/copilots/opportunity/:id/applications` | `platform-ui` | Optional `sort` | none | For admin/PM: full applications (`id,userId,status,notes,existingMembership,...`); for non-admin: reduced fields (`userId,status,createdAt`) |
101101
| POST | `/v5/projects/copilots/opportunity/:id/assign` | `platform-ui` | none | `{applicationId: string}` | `{id: applicationId}` and side effects (member/requests/opportunity state transitions) |

src/api/copilot/copilot-opportunity.controller.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ export class CopilotOpportunityController {
6969
@ApiOperation({
7070
summary: 'List copilot opportunities',
7171
description:
72-
'Lists available copilot opportunities. This endpoint is accessible to authenticated users, including copilots.',
72+
'Lists available copilot opportunities. This endpoint is accessible to authenticated users, including copilots. Admin and manager callers also receive minimal nested project metadata for v5 compatibility.',
7373
})
7474
@ApiQuery({ name: 'page', required: false, type: Number })
7575
@ApiQuery({ name: 'pageSize', required: false, type: Number })
@@ -119,7 +119,7 @@ export class CopilotOpportunityController {
119119
@ApiOperation({
120120
summary: 'Get copilot opportunity',
121121
description:
122-
'Returns one copilot opportunity with flattened request data and apply eligibility context for /projects/copilots/opportunity/:id.',
122+
'Returns one copilot opportunity with flattened request data and apply eligibility context for /projects/copilots/opportunity/:id. Admin and manager callers also receive minimal nested project metadata for v5 compatibility.',
123123
})
124124
@ApiParam({ name: 'id', required: true, type: String })
125125
@ApiResponse({ status: 200, type: CopilotOpportunityResponseDto })
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
import {
2+
CopilotOpportunityStatus,
3+
CopilotOpportunityType,
4+
} from '@prisma/client';
5+
import { UserRole } from 'src/shared/enums/userRole.enum';
6+
import { JwtUser } from 'src/shared/modules/global/jwt.service';
7+
import { CopilotOpportunityService } from './copilot-opportunity.service';
8+
9+
describe('CopilotOpportunityService', () => {
10+
const prismaMock = {
11+
copilotOpportunity: {
12+
findFirst: jest.fn(),
13+
findMany: jest.fn(),
14+
},
15+
projectMember: {
16+
findMany: jest.fn(),
17+
},
18+
};
19+
20+
const permissionServiceMock = {};
21+
const notificationServiceMock = {};
22+
23+
const pmUser: JwtUser = {
24+
userId: '1001',
25+
roles: [UserRole.PROJECT_MANAGER],
26+
isMachine: false,
27+
};
28+
29+
const regularUser: JwtUser = {
30+
userId: '3001',
31+
roles: [UserRole.TOPCODER_USER],
32+
isMachine: false,
33+
};
34+
35+
const baseOpportunity = {
36+
id: BigInt(21),
37+
projectId: BigInt(100),
38+
copilotRequestId: BigInt(11),
39+
status: CopilotOpportunityStatus.active,
40+
type: CopilotOpportunityType.dev,
41+
createdAt: new Date('2026-03-01T00:00:00.000Z'),
42+
updatedAt: new Date('2026-03-02T00:00:00.000Z'),
43+
copilotRequest: {
44+
data: {
45+
opportunityTitle: 'Need a dev copilot',
46+
projectType: 'dev',
47+
},
48+
},
49+
project: {
50+
id: BigInt(100),
51+
name: 'Demo Project',
52+
members: [
53+
{
54+
userId: BigInt(3001),
55+
},
56+
],
57+
},
58+
};
59+
60+
let service: CopilotOpportunityService;
61+
62+
beforeEach(() => {
63+
jest.clearAllMocks();
64+
prismaMock.projectMember.findMany.mockResolvedValue([]);
65+
service = new CopilotOpportunityService(
66+
prismaMock as any,
67+
permissionServiceMock as any,
68+
notificationServiceMock as any,
69+
);
70+
});
71+
72+
it('includes nested project metadata for project managers when fetching one opportunity', async () => {
73+
prismaMock.copilotOpportunity.findFirst.mockResolvedValue(baseOpportunity);
74+
75+
const response = await service.getOpportunity('21', pmUser);
76+
77+
expect(response.projectId).toBe('100');
78+
expect(response.project).toEqual({
79+
name: 'Demo Project',
80+
});
81+
expect(response.members).toEqual(['3001']);
82+
expect(response.canApplyAsCopilot).toBe(true);
83+
});
84+
85+
it('omits nested project metadata for regular users while preserving membership eligibility checks', async () => {
86+
prismaMock.copilotOpportunity.findFirst.mockResolvedValue(baseOpportunity);
87+
88+
const response = await service.getOpportunity('21', regularUser);
89+
90+
expect(response.projectId).toBeUndefined();
91+
expect(response.project).toBeUndefined();
92+
expect(response.members).toEqual(['3001']);
93+
expect(response.canApplyAsCopilot).toBe(false);
94+
});
95+
96+
it('includes nested project metadata in list results for project managers', async () => {
97+
prismaMock.copilotOpportunity.findMany.mockResolvedValue([baseOpportunity]);
98+
99+
const response = await service.listOpportunities({}, pmUser);
100+
101+
expect(response.data).toHaveLength(1);
102+
expect(response.data[0].projectId).toBe('100');
103+
expect(response.data[0].project).toEqual({
104+
name: 'Demo Project',
105+
});
106+
});
107+
});

src/api/copilot/copilot-opportunity.service.ts

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@ export class CopilotOpportunityService {
8282
* Lists opportunities with pagination and status-priority grouping.
8383
* Uses a two-phase fetch: opportunities first, then membership lookup to compute canApplyAsCopilot.
8484
* Default ordering groups by status priority active -> canceled -> completed unless noGrouping is true.
85+
* Admin/manager responses also include minimal project metadata for v5 compatibility.
8586
*
8687
* @param query Pagination, sort, and noGrouping parameters.
8788
* @param user Authenticated JWT user.
@@ -156,6 +157,7 @@ export class CopilotOpportunityService {
156157
/**
157158
* Returns a single opportunity with eligibility context.
158159
* canApplyAsCopilot is true when the user is not already a member of the project.
160+
* Admin/manager responses also include minimal project metadata for v5 compatibility.
159161
*
160162
* @param opportunityId Opportunity id path value.
161163
* @param user Authenticated JWT user.
@@ -169,6 +171,7 @@ export class CopilotOpportunityService {
169171
): Promise<CopilotOpportunityResponseDto> {
170172
// TODO [SECURITY]: No permission check is applied; any authenticated user can access any opportunity by id.
171173
const parsedOpportunityId = parseNumericId(opportunityId, 'Opportunity');
174+
const includeProject = isAdminOrManager(user);
172175

173176
const opportunity = await this.prisma.copilotOpportunity.findFirst({
174177
where: {
@@ -213,7 +216,7 @@ export class CopilotOpportunityService {
213216
opportunity,
214217
canApplyAsCopilot,
215218
members,
216-
isAdminOrManager(user),
219+
includeProject,
217220
);
218221
}
219222

@@ -521,14 +524,14 @@ export class CopilotOpportunityService {
521524
* @param input Opportunity row with relations.
522525
* @param canApplyAsCopilot Whether caller can apply.
523526
* @param members Optional member userId list.
524-
* @param includeProjectId Whether to include projectId in response.
527+
* @param includeProjectDetails Whether to include admin/manager project metadata.
525528
* @returns Formatted opportunity response.
526529
*/
527530
private formatOpportunity(
528531
input: OpportunityWithRelations,
529532
canApplyAsCopilot: boolean,
530533
members: string[] | undefined,
531-
includeProjectId: boolean,
534+
includeProjectDetails: boolean,
532535
): CopilotOpportunityResponseDto {
533536
const normalized = normalizeEntity(input) as Record<string, any>;
534537
const requestData = getCopilotRequestData(
@@ -549,8 +552,26 @@ export class CopilotOpportunityService {
549552
...requestData,
550553
};
551554

552-
if (includeProjectId && normalized.projectId) {
553-
response.projectId = String(normalized.projectId);
555+
const projectId =
556+
normalized.projectId !== undefined && normalized.projectId !== null
557+
? String(normalized.projectId)
558+
: normalized.project?.id !== undefined &&
559+
normalized.project?.id !== null
560+
? String(normalized.project.id)
561+
: undefined;
562+
563+
if (includeProjectDetails && projectId) {
564+
response.projectId = projectId;
565+
}
566+
567+
if (
568+
includeProjectDetails &&
569+
projectId &&
570+
typeof normalized.project?.name === 'string'
571+
) {
572+
response.project = {
573+
name: normalized.project.name,
574+
};
554575
}
555576

556577
return response;

src/api/copilot/dto/copilot-opportunity.dto.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,18 @@ import { CopilotSkillDto } from './copilot-request.dto';
1515
* DTOs for listing and responding with copilot opportunities.
1616
*/
1717

18+
/**
19+
* Minimal project metadata included in admin/manager opportunity responses.
20+
*/
21+
export class CopilotOpportunityProjectDto {
22+
@ApiProperty()
23+
name: string;
24+
}
25+
1826
/**
1927
* Flattened response merging opportunity fields with request data.
2028
* canApplyAsCopilot indicates whether the current user is eligible to apply.
29+
* Admin/manager callers also receive minimal nested project metadata.
2130
*/
2231
export class CopilotOpportunityResponseDto {
2332
@ApiProperty()
@@ -94,6 +103,9 @@ export class CopilotOpportunityResponseDto {
94103

95104
@ApiPropertyOptional({ type: [String] })
96105
members?: string[];
106+
107+
@ApiPropertyOptional({ type: () => CopilotOpportunityProjectDto })
108+
project?: CopilotOpportunityProjectDto;
97109
}
98110

99111
/**

src/api/project/dto/project-list-query.dto.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -119,7 +119,8 @@ export class ProjectListQueryDto extends PaginationDto {
119119
memberOnly?: boolean;
120120

121121
@ApiPropertyOptional({
122-
description: 'Keyword for text search over name/description',
122+
description:
123+
'Keyword for text search over name/description; accepts plain text and quoted phrases.',
123124
})
124125
@IsOptional()
125126
@IsString()
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import { buildProjectWhereClause } from './project.utils';
2+
3+
describe('buildProjectWhereClause', () => {
4+
it('normalizes quoted keyword phrases into safe search filters', () => {
5+
const where = buildProjectWhereClause(
6+
{
7+
keyword: '"my ba"',
8+
} as any,
9+
{
10+
userId: '123',
11+
isMachine: false,
12+
} as any,
13+
true,
14+
);
15+
16+
expect(where).toEqual(
17+
expect.objectContaining({
18+
deletedAt: null,
19+
AND: [
20+
{
21+
OR: [
22+
{
23+
name: {
24+
search: 'my & ba',
25+
},
26+
},
27+
{
28+
description: {
29+
search: 'my & ba',
30+
},
31+
},
32+
{
33+
name: {
34+
contains: 'my ba',
35+
mode: 'insensitive',
36+
},
37+
},
38+
{
39+
description: {
40+
contains: 'my ba',
41+
mode: 'insensitive',
42+
},
43+
},
44+
],
45+
},
46+
],
47+
}),
48+
);
49+
});
50+
51+
it('does not append invalid full-text filters for empty quoted keywords', () => {
52+
const where = buildProjectWhereClause(
53+
{
54+
keyword: '""',
55+
} as any,
56+
{
57+
userId: '123',
58+
isMachine: false,
59+
} as any,
60+
true,
61+
);
62+
63+
expect(where).toEqual({
64+
deletedAt: null,
65+
});
66+
});
67+
});

0 commit comments

Comments
 (0)