diff --git a/src/api/submission/submission.service.spec.ts b/src/api/submission/submission.service.spec.ts index 7fc4f853..1448d9ce 100644 --- a/src/api/submission/submission.service.spec.ts +++ b/src/api/submission/submission.service.spec.ts @@ -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([ diff --git a/src/api/submission/submission.service.ts b/src/api/submission/submission.service.ts index 9db1576f..66ae9d08 100644 --- a/src/api/submission/submission.service.ts +++ b/src/api/submission/submission.service.ts @@ -112,6 +112,7 @@ type SubmissionBusPayloadSource = Prisma.submissionGetPayload<{ type ChallengeRoleSummary = { hasCopilot: boolean; + hasManager: boolean; hasReviewer: boolean; hasSubmitter: boolean; reviewerResourceIds: string[]; @@ -125,6 +126,7 @@ type ReviewVisibilityContext = { const EMPTY_ROLE_SUMMARY: ChallengeRoleSummary = { hasCopilot: false, + hasManager: false, hasReviewer: false, hasSubmitter: false, reviewerResourceIds: [], @@ -2972,6 +2974,7 @@ export class SubmissionService { submissions, reviewVisibilityContext, ); + this.stripSubmitterEmails(authUser, submissions, reviewVisibilityContext); this.sanitizeMemberVisibleReviewSummationMetadata( authUser, submissions, @@ -3027,7 +3030,7 @@ 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( @@ -3035,7 +3038,7 @@ export class SubmissionService { String(authUser.userId), ); return resources.some((r) => - (r.roleName || '').toLowerCase().includes('copilot'), + this.canResourceRoleViewSubmitterEmail(r.roleName), ); } catch { return false; @@ -3647,6 +3650,7 @@ export class SubmissionService { } let hasCopilot = false; + let hasManager = false; let hasReviewer = false; let hasSubmitter = false; const reviewerResourceIds: string[] = []; @@ -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), @@ -3677,6 +3684,7 @@ export class SubmissionService { roleSummaryByChallenge.set(challengeId, { hasCopilot, + hasManager, hasReviewer, hasSubmitter, reviewerResourceIds, @@ -3703,6 +3711,7 @@ export class SubmissionService { const roleSummary = roleSummaryByChallenge.get(challengeId) ?? { hasCopilot: false, + hasManager: false, hasReviewer: false, hasSubmitter: false, reviewerResourceIds: [], @@ -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') ) { @@ -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< @@ -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') + ); + } } diff --git a/src/dto/submission.dto.ts b/src/dto/submission.dto.ts index 4e71237b..893bd586 100644 --- a/src/dto/submission.dto.ts +++ b/src/dto/submission.dto.ts @@ -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, })