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
138 changes: 138 additions & 0 deletions src/api/submission/submission.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1199,6 +1199,144 @@ describe('SubmissionService', () => {
expect(other?.review).toBeDefined();
});

it('removes submitter emails from marathon match submissions for regular members', async () => {
challengeApiServiceMock.getChallengeDetail.mockResolvedValueOnce({
id: 'challenge-1',
status: ChallengeStatus.ACTIVE,
type: 'Marathon Match',
legacy: { subTrack: 'MARATHON_MATCH' },
phases: [],
});
resourceApiServiceListMock.getMemberResourcesRoles.mockResolvedValue([
{
roleName: 'Submitter',
roleId: CommonConfig.roles.submitterRoleId,
},
]);

const submissions = [
{
id: 'submission-own',
challengeId: 'challenge-1',
memberId: '1001',
submittedDate: new Date('2026-05-01T12:00:00Z'),
createdAt: new Date('2026-05-01T12:00:00Z'),
updatedAt: new Date('2026-05-01T12:00:00Z'),
type: SubmissionType.CONTEST_SUBMISSION,
status: SubmissionStatus.ACTIVE,
review: [],
reviewSummation: [],
legacyChallengeId: null,
prizeId: null,
},
{
id: 'submission-other',
challengeId: 'challenge-1',
memberId: '1002',
submittedDate: new Date('2026-05-01T11:00:00Z'),
createdAt: new Date('2026-05-01T11:00:00Z'),
updatedAt: new Date('2026-05-01T11:00:00Z'),
type: SubmissionType.CONTEST_SUBMISSION,
status: SubmissionStatus.ACTIVE,
review: [],
reviewSummation: [],
legacyChallengeId: null,
prizeId: null,
},
];

prismaMock.submission.findMany.mockResolvedValue(
submissions.map((entry) => ({ ...entry })),
);
prismaMock.submission.count.mockResolvedValue(submissions.length);
memberPrismaMock.member.findMany.mockResolvedValue([
{
userId: BigInt(1001),
handle: 'regularMember',
email: 'regular@example.com',
maxRating: { rating: 1200 },
},
{
userId: BigInt(1002),
handle: 'otherMember',
email: 'other@example.com',
maxRating: { rating: 1400 },
},
]);

const result = await listService.listSubmission(
{
userId: '1001',
isMachine: false,
roles: [UserRole.User],
} as any,
{ challengeId: 'challenge-1' } as any,
{ page: 1, perPage: 50 } as any,
);

expect(result.data).toHaveLength(2);
expect(result.data[0]).not.toHaveProperty('submitterEmail');
expect(result.data[1]).not.toHaveProperty('submitterEmail');
});

it('keeps submitter emails for challenge manager resources', async () => {
challengeApiServiceMock.getChallengeDetail.mockResolvedValueOnce({
id: 'challenge-1',
status: ChallengeStatus.ACTIVE,
type: 'Marathon Match',
legacy: { subTrack: 'MARATHON_MATCH' },
phases: [],
});
resourceApiServiceListMock.getMemberResourcesRoles.mockResolvedValue([
{
roleName: 'Manager',
roleId: 'manager-role',
},
]);

const submissions = [
{
id: 'submission-1',
challengeId: 'challenge-1',
memberId: '1002',
submittedDate: new Date('2026-05-01T11:00:00Z'),
createdAt: new Date('2026-05-01T11:00:00Z'),
updatedAt: new Date('2026-05-01T11:00:00Z'),
type: SubmissionType.CONTEST_SUBMISSION,
status: SubmissionStatus.ACTIVE,
review: [],
reviewSummation: [],
legacyChallengeId: null,
prizeId: null,
},
];

prismaMock.submission.findMany.mockResolvedValue(
submissions.map((entry) => ({ ...entry })),
);
prismaMock.submission.count.mockResolvedValue(submissions.length);
memberPrismaMock.member.findMany.mockResolvedValue([
{
userId: BigInt(1002),
handle: 'otherMember',
email: 'other@example.com',
maxRating: { rating: 1400 },
},
]);

const result = await listService.listSubmission(
{
userId: 'manager-1',
isMachine: false,
roles: [UserRole.User],
} as any,
{ challengeId: 'challenge-1' } as any,
{ page: 1, perPage: 50 } as any,
);

expect(result.data[0].submitterEmail).toBe('other@example.com');
});

it('masks other reviewers scores while preserving reviewer metadata on active challenges', async () => {
const now = new Date('2025-01-05T10:00:00Z');
resourceApiServiceListMock.getMemberResourcesRoles.mockResolvedValue([
Expand Down
78 changes: 76 additions & 2 deletions src/api/submission/submission.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ type SubmissionBusPayloadSource = Prisma.submissionGetPayload<{

type ChallengeRoleSummary = {
hasCopilot: boolean;
hasManager: boolean;
hasReviewer: boolean;
hasSubmitter: boolean;
reviewerResourceIds: string[];
Expand All @@ -125,6 +126,7 @@ type ReviewVisibilityContext = {

const EMPTY_ROLE_SUMMARY: ChallengeRoleSummary = {
hasCopilot: false,
hasManager: false,
hasReviewer: false,
hasSubmitter: false,
reviewerResourceIds: [],
Expand Down Expand Up @@ -2972,6 +2974,7 @@ export class SubmissionService {
submissions,
reviewVisibilityContext,
);
this.stripSubmitterEmails(authUser, submissions, reviewVisibilityContext);
this.sanitizeMemberVisibleReviewSummationMetadata(
authUser,
submissions,
Expand Down Expand Up @@ -3027,15 +3030,15 @@ export class SubmissionService {
if (isAdmin(authUser)) {
return true;
}
// Copilots on the challenge are allowed
// Copilots or managers on the challenge are allowed
if (challengeId && authUser.userId) {
try {
const resources = await this.resourceApiService.getMemberResourcesRoles(
challengeId,
String(authUser.userId),
);
return resources.some((r) =>
(r.roleName || '').toLowerCase().includes('copilot'),
this.canResourceRoleViewSubmitterEmail(r.roleName),
);
} catch {
return false;
Expand Down Expand Up @@ -3647,6 +3650,7 @@ export class SubmissionService {
}

let hasCopilot = false;
let hasManager = false;
let hasReviewer = false;
let hasSubmitter = false;
const reviewerResourceIds: string[] = [];
Expand All @@ -3656,6 +3660,9 @@ export class SubmissionService {
if (roleName.includes('copilot')) {
hasCopilot = true;
}
if (roleName.includes('manager')) {
hasManager = true;
}
if (
REVIEW_ACCESS_ROLE_KEYWORDS.some((keyword) =>
roleName.includes(keyword),
Expand All @@ -3677,6 +3684,7 @@ export class SubmissionService {

roleSummaryByChallenge.set(challengeId, {
hasCopilot,
hasManager,
hasReviewer,
hasSubmitter,
reviewerResourceIds,
Expand All @@ -3703,6 +3711,7 @@ export class SubmissionService {

const roleSummary = roleSummaryByChallenge.get(challengeId) ?? {
hasCopilot: false,
hasManager: false,
hasReviewer: false,
hasSubmitter: false,
reviewerResourceIds: [],
Expand Down Expand Up @@ -4619,6 +4628,9 @@ export class SubmissionService {
if (Object.prototype.hasOwnProperty.call(submission, 'submitterHandle')) {
delete (submission as any).submitterHandle;
}
if (Object.prototype.hasOwnProperty.call(submission, 'submitterEmail')) {
delete (submission as any).submitterEmail;
}
if (
Object.prototype.hasOwnProperty.call(submission, 'submitterMaxRating')
) {
Expand All @@ -4627,6 +4639,53 @@ export class SubmissionService {
}
}

/**
* Removes submitter email addresses from list results unless the requester is allowed to see them.
* @param authUser Authenticated requester from the JWT.
* @param submissions Submission records being returned by `listSubmission`.
* @param visibilityContext Challenge role information for the requester.
* @returns Nothing; submissions are mutated in place before response DTO creation.
* @throws This method does not throw.
* Used by `listSubmission` after resource roles have been resolved for the returned challenges.
*/
private stripSubmitterEmails(
authUser: JwtUser,
submissions: Array<
{ challengeId?: string | null; submitterEmail?: string | null } & Record<
string,
unknown
>
>,
visibilityContext: ReviewVisibilityContext,
): void {
if (!submissions.length) {
return;
}
if (authUser?.isMachine || isAdmin(authUser)) {
return;
}

for (const submission of submissions) {
if (!Object.prototype.hasOwnProperty.call(submission, 'submitterEmail')) {
continue;
}

const challengeId =
submission.challengeId !== undefined && submission.challengeId !== null
? String(submission.challengeId).trim()
: '';
const roleSummary = challengeId
? visibilityContext.roleSummaryByChallenge.get(challengeId)
: undefined;

if (roleSummary?.hasCopilot || roleSummary?.hasManager) {
continue;
}

delete (submission as any).submitterEmail;
}
}

private stripSubmitterSubmissionDetails(
authUser: JwtUser,
submissions: Array<
Expand Down Expand Up @@ -5180,4 +5239,19 @@ export class SubmissionService {
}
return dto;
}

/**
* Checks whether a challenge resource role can view submitter email addresses.
* @param roleName Resource role name returned by Resource API.
* @returns True when the role is a copilot or manager-style challenge resource role.
* @throws This method does not throw.
* Used by submission list response sanitization and submitter identity access checks.
*/
private canResourceRoleViewSubmitterEmail(roleName?: string | null): boolean {
const normalizedRoleName = String(roleName ?? '').toLowerCase();
return (
normalizedRoleName.includes('copilot') ||
normalizedRoleName.includes('manager')
);
}
}
3 changes: 2 additions & 1 deletion src/dto/submission.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -436,7 +436,8 @@ export class SubmissionResponseDto {
submitterHandle?: string;

@ApiProperty({
description: 'Submitter email (visible to Admin/Copilot/M2M)',
description:
'Submitter email (visible to Admin, M2M, or challenge Copilot/Manager resources)',
required: false,
nullable: true,
})
Expand Down
Loading