Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
36 changes: 34 additions & 2 deletions src/engagements/engagements.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
Put,
Query,
Req,
UnauthorizedException,
UseGuards,
} from "@nestjs/common";
import {
Expand All @@ -30,7 +31,12 @@ import {
import { Request } from "express";
import { PermissionsGuard } from "../auth/guards/permissions.guard";
import { Scopes as ScopesDecorator } from "../auth/decorators/scopes.decorator";
import { Scopes as AppScopes, PrivilegedUserRoles } from "../app-constants";
import {
Scopes as AppScopes,
PrivilegedUserRoles,
TalentManagerRoles,
UserRoles,
} from "../app-constants";
import {
CreateEngagementDto,
CreateEngagementDurationDatesDto,
Expand Down Expand Up @@ -58,6 +64,9 @@ export class EngagementsController {
private readonly privilegedRoles = new Set(
PrivilegedUserRoles.map((role) => role.toLowerCase()),
);
private readonly includePrivateRoles = new Set(
[UserRoles.Admin, ...TalentManagerRoles].map((role) => role.toLowerCase()),
);

constructor(private readonly engagementsService: EngagementsService) {}

Expand Down Expand Up @@ -127,7 +136,7 @@ export class EngagementsController {
@Req() req: Request & { authUser?: Record<string, any> },
): Promise<PaginatedResponse<Engagement>> {
if (query.includePrivate) {
this.assertAdminOrPm(req.authUser);
this.assertCanIncludePrivate(req.authUser);
}
return this.engagementsService.findAll(query);
}
Expand Down Expand Up @@ -426,4 +435,27 @@ export class EngagementsController {
);
}
}

private assertCanIncludePrivate(authUser?: Record<string, any>) {
if (!authUser) {
throw new UnauthorizedException(
"Authentication is required to include private engagements.",
);
}

if (authUser.isMachine) {
return;
}

const roles = getUserRoles(authUser);
const isAllowed = roles.some((role) =>
this.includePrivateRoles.has(role?.toLowerCase()),
);

if (!isAllowed) {
throw new UnauthorizedException(
"You are not authorized to include private engagements.",
);
}
}
}
121 changes: 121 additions & 0 deletions src/engagements/engagements.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ describe("EngagementsService", () => {
create: jest.Mock;
findUnique: jest.Mock;
update: jest.Mock;
findMany: jest.Mock;
count: jest.Mock;
};
};
let projectService: { validateProjectExists: jest.Mock };
Expand Down Expand Up @@ -43,6 +45,8 @@ describe("EngagementsService", () => {
create: jest.fn(),
findUnique: jest.fn(),
update: jest.fn(),
findMany: jest.fn(),
count: jest.fn(),
},
};
projectService = {
Expand Down Expand Up @@ -133,6 +137,123 @@ describe("EngagementsService", () => {
);
});

it("does not include assignment details for public engagement listings", async () => {
db.engagement.findMany.mockResolvedValue([
{
id: "eng-1",
projectId: "project-1",
title: "Public engagement",
description: "Public description",
timeZones: ["UTC"],
countries: ["US"],
requiredSkills: ["skill-1"],
anticipatedStart: "IMMEDIATE",
status: "OPEN",
createdAt: new Date("2026-02-11T10:00:00.000Z"),
updatedAt: new Date("2026-02-11T10:00:00.000Z"),
createdBy: "123456",
isPrivate: false,
requiredMemberCount: 2,
_count: {
applications: 3,
},
},
]);
db.engagement.count.mockResolvedValue(1);

const result = await service.findAll({
page: 1,
perPage: 20,
sortBy: "createdAt",
sortOrder: "desc",
} as any);

expect(db.engagement.findMany).toHaveBeenCalledWith(
expect.objectContaining({
include: {
_count: {
select: {
applications: true,
},
},
},
}),
);

expect(result.data[0]).not.toHaveProperty("assignments");
expect(result.data[0]).not.toHaveProperty("assignedMemberId");
expect(result.data[0]).not.toHaveProperty("assignedMemberHandle");
expect(result.data[0]).not.toHaveProperty("assignedMembers");
expect(result.data[0]).not.toHaveProperty("assignedMemberHandles");
});

it("includes assignment details for privileged engagement listings", async () => {
db.engagement.findMany.mockResolvedValue([
{
id: "eng-1",
projectId: "project-1",
title: "Private engagement",
description: "Private description",
timeZones: ["UTC"],
countries: ["US"],
requiredSkills: ["skill-1"],
anticipatedStart: "IMMEDIATE",
status: "OPEN",
createdAt: new Date("2026-02-11T10:00:00.000Z"),
updatedAt: new Date("2026-02-11T10:00:00.000Z"),
createdBy: "123456",
isPrivate: true,
assignments: [
{
id: "assignment-1",
engagementId: "eng-1",
memberId: "100000",
memberHandle: "member1",
status: AssignmentStatus.SELECTED,
createdAt: new Date("2026-02-11T11:00:00.000Z"),
updatedAt: new Date("2026-02-11T11:00:00.000Z"),
agreementRate: "80",
otherRemarks: "Confidential terms",
startDate: new Date("2026-02-12T00:00:00.000Z"),
endDate: new Date("2026-03-12T00:00:00.000Z"),
terminationReason: null,
},
],
_count: {
applications: 1,
},
},
]);
db.engagement.count.mockResolvedValue(1);

const result = await service.findAll({
includePrivate: true,
page: 1,
perPage: 20,
sortBy: "createdAt",
sortOrder: "desc",
} as any);

expect(db.engagement.findMany).toHaveBeenCalledWith(
expect.objectContaining({
include: {
_count: {
select: {
applications: true,
},
},
assignments: true,
},
}),
);

expect(result.data[0]).toHaveProperty("assignments");
expect(result.data[0]).toHaveProperty("assignedMemberId", "100000");
expect(result.data[0]).toHaveProperty("assignedMemberHandle", "member1");
expect(result.data[0]).toHaveProperty("assignedMembers", ["100000"]);
expect(result.data[0]).toHaveProperty("assignedMemberHandles", ["member1"]);
});

it("sets assignment endDate to now when status is terminated", async () => {
const now = new Date("2026-02-11T12:00:00.000Z");
jest.useFakeTimers().setSystemTime(now);
Expand Down
46 changes: 28 additions & 18 deletions src/engagements/engagements.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -389,32 +389,48 @@ export class EngagementsService {
const orderBy: Prisma.EngagementOrderByWithRelationInput = {
[sortBy]: query.sortOrder,
};
const includeAssignments = query.includePrivate === true;

const [data, totalCount] = await Promise.all([
this.db.engagement.findMany({
where,
skip,
take: perPage,
orderBy,
include: {
_count: {
select: {
applications: true,
include: includeAssignments
? {
_count: {
select: {
applications: true,
},
},
assignments: true,
}
: {
_count: {
select: {
applications: true,
},
},
},
},
assignments: true,
},
}),
this.db.engagement.count({ where }),
]);

const totalPages = totalCount ? Math.ceil(totalCount / perPage) : 0;
const engagements = data.map(({ _count, ...engagement }) =>
this.applyAssignmentFields({
const engagements = data.map(({ _count, ...engagement }) => {
const engagementWithCount = {
...engagement,
applicationsCount: _count.applications,
}),
);
} as Engagement & {
assignments?: EngagementAssignment[];
applicationsCount: number;
};

return includeAssignments
? this.applyAssignmentFields(engagementWithCount)
: engagementWithCount;
});
const hydratedEngagements = await this.hydrateCreatorEmails(engagements);

return {
Expand Down Expand Up @@ -1163,14 +1179,8 @@ export class EngagementsService {
status: EngagementStatus.OPEN,
},
orderBy: { createdAt: "desc" },
include: { assignments: true },
});

const engagementsWithFields = engagements.map((engagement) =>
this.applyAssignmentFields(engagement),
);

return this.hydrateCreatorEmails(engagementsWithFields);
return this.hydrateCreatorEmails(engagements);
}

private normalizeAssignmentOfferDetails(details?: AssignmentDetailsDto): {
Expand Down
58 changes: 58 additions & 0 deletions test/auth.e2e-spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,21 @@ const tokenFixtures: Record<string, Record<string, any>> = {
userId: "123456",
roles: [UserRoles.Admin],
},
"talent-manager-user": {
isMachine: false,
userId: "222222",
roles: [UserRoles.TalentManager],
},
"project-manager-user": {
isMachine: false,
userId: "333333",
roles: [UserRoles.ProjectManager],
},
"task-manager-user": {
isMachine: false,
userId: "444444",
roles: [UserRoles.TaskManager],
},
"member-user": {
isMachine: false,
userId: "654321",
Expand Down Expand Up @@ -265,6 +280,49 @@ describe("Authentication & Authorization (e2e)", () => {
});
});

describe("includePrivate Access", () => {
it("allows admin user to request includePrivate=true", async () => {
await request(app.getHttpServer())
.get("/engagements?includePrivate=true")
.set("Authorization", "Bearer admin-user")
.expect(200);
});

it("allows talent manager user to request includePrivate=true", async () => {
await request(app.getHttpServer())
.get("/engagements?includePrivate=true")
.set("Authorization", "Bearer talent-manager-user")
.expect(200);
});

it("allows M2M token to request includePrivate=true", async () => {
await request(app.getHttpServer())
.get("/engagements?includePrivate=true")
.set("Authorization", "Bearer m2m-read")
.expect(200);
});

it("returns 401 for project manager requesting includePrivate=true", async () => {
await request(app.getHttpServer())
.get("/engagements?includePrivate=true")
.set("Authorization", "Bearer project-manager-user")
.expect(401);
});

it("returns 401 for task manager requesting includePrivate=true", async () => {
await request(app.getHttpServer())
.get("/engagements?includePrivate=true")
.set("Authorization", "Bearer task-manager-user")
.expect(401);
});

it("returns 401 for anonymous request with includePrivate=true", async () => {
await request(app.getHttpServer())
.get("/engagements?includePrivate=true")
.expect(401);
});
});

describe("Role-Based Access", () => {
it("returns 401 when token is missing", async () => {
await request(app.getHttpServer())
Expand Down