Skip to content

Commit 4033b72

Browse files
authored
Merge pull request #291 from topcoder-platform/PM-5231-1
Updates for MM submission list performance
2 parents b3ede6f + 87a5567 commit 4033b72

3 files changed

Lines changed: 214 additions & 1 deletion

File tree

src/api/submission/submission.service.spec.ts

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1190,6 +1190,72 @@ describe('SubmissionService', () => {
11901190
]);
11911191
});
11921192

1193+
it('filters latest submissions before pagination when isLatest=true', async () => {
1194+
const latestSubmission = {
1195+
id: 'submission-new',
1196+
challengeId: 'challenge-1',
1197+
memberId: 'member-1',
1198+
submittedDate: new Date('2024-01-02T12:00:00Z'),
1199+
createdAt: new Date('2024-01-02T12:00:00Z'),
1200+
updatedAt: new Date('2024-01-02T12:00:00Z'),
1201+
type: SubmissionType.CONTEST_SUBMISSION,
1202+
status: SubmissionStatus.ACTIVE,
1203+
review: [],
1204+
reviewSummation: [],
1205+
legacyChallengeId: null,
1206+
prizeId: null,
1207+
};
1208+
1209+
prismaMock.$queryRaw.mockResolvedValue([{ id: 'submission-new' }]);
1210+
prismaMock.submission.findMany.mockResolvedValue([
1211+
{ ...latestSubmission },
1212+
]);
1213+
prismaMock.submission.count.mockResolvedValue(1);
1214+
1215+
const result = await listService.listSubmission(
1216+
{ isMachine: false } as any,
1217+
{ challengeId: 'challenge-1', isLatest: 'true' } as any,
1218+
{ page: 1, perPage: 50 } as any,
1219+
);
1220+
1221+
expect(prismaMock.$queryRaw).toHaveBeenCalled();
1222+
expect(prismaMock.submission.findMany).toHaveBeenCalledWith(
1223+
expect.objectContaining({
1224+
where: expect.objectContaining({
1225+
challengeId: 'challenge-1',
1226+
id: { in: ['submission-new'] },
1227+
}),
1228+
skip: 0,
1229+
take: 50,
1230+
}),
1231+
);
1232+
expect(prismaMock.submission.count).toHaveBeenCalledWith({
1233+
where: expect.objectContaining({
1234+
challengeId: 'challenge-1',
1235+
id: { in: ['submission-new'] },
1236+
}),
1237+
});
1238+
expect(result.meta.totalCount).toBe(1);
1239+
expect(result.data).toEqual([
1240+
expect.objectContaining({
1241+
id: 'submission-new',
1242+
isLatest: true,
1243+
}),
1244+
]);
1245+
});
1246+
1247+
it('requires challengeId when filtering by isLatest', async () => {
1248+
await expect(
1249+
listService.listSubmission(
1250+
{ isMachine: false } as any,
1251+
{ isLatest: 'true' } as any,
1252+
{ page: 1, perPage: 50 } as any,
1253+
),
1254+
).rejects.toBeInstanceOf(BadRequestException);
1255+
1256+
expect(prismaMock.submission.findMany).not.toHaveBeenCalled();
1257+
});
1258+
11931259
it('enriches reviews with review type names when typeId is present', async () => {
11941260
const submissions = [
11951261
{

src/api/submission/submission.service.ts

Lines changed: 138 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,47 @@ type ActiveChallengeRow = {
7171
id: string | null;
7272
};
7373

74+
/**
75+
* Parses optional boolean-like query parameters used by submission listing filters.
76+
*
77+
* @param value - Raw query parameter value from Nest's query DTO.
78+
* @param fieldName - Query field name used in validation error messages.
79+
* @returns A boolean when the query parameter is present, otherwise null.
80+
* @throws BadRequestException when the value is not a supported boolean token.
81+
*/
82+
function parseOptionalBooleanQuery(
83+
value: unknown,
84+
fieldName: string,
85+
): boolean | null {
86+
if (value === undefined || value === null || value === '') {
87+
return null;
88+
}
89+
if (typeof value === 'boolean') {
90+
return value;
91+
}
92+
if (typeof value !== 'string' && typeof value !== 'number') {
93+
throw new BadRequestException({
94+
message: `${fieldName} must be true or false`,
95+
code: 'INVALID_BOOLEAN_QUERY_PARAMETER',
96+
details: { fieldName },
97+
});
98+
}
99+
100+
const normalized = value.toString().trim().toLowerCase();
101+
if (normalized === 'true' || normalized === '1') {
102+
return true;
103+
}
104+
if (normalized === 'false' || normalized === '0') {
105+
return false;
106+
}
107+
108+
throw new BadRequestException({
109+
message: `${fieldName} must be true or false`,
110+
code: 'INVALID_BOOLEAN_QUERY_PARAMETER',
111+
details: { fieldName, value },
112+
});
113+
}
114+
74115
export type SubmissionScanRetryOptions = {
75116
now?: Date;
76117
limit?: number;
@@ -3195,6 +3236,10 @@ export class SubmissionService {
31953236
if (queryDto.submissionPhaseId) {
31963237
submissionWhereClause.submissionPhaseId = queryDto.submissionPhaseId;
31973238
}
3239+
const isLatestFilter = parseOptionalBooleanQuery(
3240+
queryDto.isLatest,
3241+
'isLatest',
3242+
);
31983243

31993244
const isPrivilegedRequester = authUser?.isMachine || isAdmin(authUser);
32003245
const requesterUserId =
@@ -3287,6 +3332,14 @@ export class SubmissionService {
32873332
}
32883333
}
32893334

3335+
if (isLatestFilter !== null) {
3336+
const latestSubmissionIds =
3337+
await this.findLatestSubmissionIdsForQuery(queryDto);
3338+
whereClause.id = isLatestFilter
3339+
? { in: latestSubmissionIds }
3340+
: { notIn: latestSubmissionIds };
3341+
}
3342+
32903343
// find entities by filters
32913344
let submissions = await this.prisma.submission.findMany({
32923345
where: whereClause,
@@ -3403,7 +3456,13 @@ export class SubmissionService {
34033456
totalCount = submissions.length;
34043457
}
34053458

3406-
await this.populateLatestSubmissionFlags(submissions);
3459+
if (isLatestFilter !== null) {
3460+
for (const submission of submissions) {
3461+
(submission as any).isLatest = isLatestFilter;
3462+
}
3463+
} else {
3464+
await this.populateLatestSubmissionFlags(submissions);
3465+
}
34073466
this.stripSubmitterSubmissionDetails(
34083467
authUser,
34093468
submissions,
@@ -4793,6 +4852,84 @@ export class SubmissionService {
47934852
}
47944853
}
47954854

4855+
/**
4856+
* Finds submission ids that are latest within each challenge/member pair for
4857+
* the supplied submission-list filters. The result is used to constrain the
4858+
* main list and count queries before pagination, so callers can request a
4859+
* compact member-level view without fetching historical attempts first.
4860+
*
4861+
* @param queryDto - Submission list filters from the request query string.
4862+
* @returns Submission ids that represent the latest attempt per member.
4863+
* @throws BadRequestException when isLatest is requested without challengeId.
4864+
*/
4865+
private async findLatestSubmissionIdsForQuery(
4866+
queryDto: SubmissionQueryDto,
4867+
): Promise<string[]> {
4868+
if (!queryDto.challengeId) {
4869+
throw new BadRequestException({
4870+
message: 'isLatest filtering requires challengeId',
4871+
code: 'LATEST_SUBMISSION_FILTER_REQUIRES_CHALLENGE',
4872+
details: { fieldName: 'challengeId' },
4873+
});
4874+
}
4875+
4876+
const filters: Prisma.Sql[] = [
4877+
Prisma.sql`"challengeId" = ${queryDto.challengeId}`,
4878+
Prisma.sql`"memberId" IS NOT NULL`,
4879+
];
4880+
4881+
if (queryDto.type) {
4882+
filters.push(Prisma.sql`"type" = ${queryDto.type}`);
4883+
}
4884+
if (queryDto.url) {
4885+
filters.push(Prisma.sql`"url" = ${queryDto.url}`);
4886+
}
4887+
if (queryDto.memberId) {
4888+
filters.push(Prisma.sql`"memberId" = ${String(queryDto.memberId)}`);
4889+
}
4890+
if (queryDto.legacySubmissionId) {
4891+
filters.push(
4892+
Prisma.sql`"legacySubmissionId" = ${queryDto.legacySubmissionId}`,
4893+
);
4894+
}
4895+
if (queryDto.legacyUploadId) {
4896+
filters.push(Prisma.sql`"legacyUploadId" = ${queryDto.legacyUploadId}`);
4897+
}
4898+
if (queryDto.submissionPhaseId) {
4899+
filters.push(
4900+
Prisma.sql`"submissionPhaseId" = ${queryDto.submissionPhaseId}`,
4901+
);
4902+
}
4903+
4904+
const whereSql = filters.reduce(
4905+
(combined, filter) => Prisma.sql`${combined} AND ${filter}`,
4906+
);
4907+
4908+
const latestEntries = await this.prisma.$queryRaw<Array<{ id: string }>>(
4909+
Prisma.sql`
4910+
SELECT "id"
4911+
FROM (
4912+
SELECT
4913+
"id",
4914+
ROW_NUMBER() OVER (
4915+
PARTITION BY "challengeId", "memberId"
4916+
ORDER BY "submittedDate" DESC NULLS LAST,
4917+
"createdAt" DESC,
4918+
"updatedAt" DESC NULLS LAST,
4919+
"id" DESC
4920+
) AS row_num
4921+
FROM "submission"
4922+
WHERE ${whereSql}
4923+
) ranked
4924+
WHERE row_num = 1
4925+
`,
4926+
);
4927+
4928+
return latestEntries
4929+
.map((entry) => String(entry.id ?? '').trim())
4930+
.filter((id) => id.length > 0);
4931+
}
4932+
47964933
private async getActiveSubmitterRestrictedChallengeIds(
47974934
userId: string,
47984935
challengeId?: string,

src/dto/submission.dto.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,16 @@ export class SubmissionQueryDto {
9797
@IsString()
9898
@IsNotEmpty()
9999
submissionPhaseId?: string;
100+
101+
@ApiProperty({
102+
name: 'isLatest',
103+
description:
104+
'When true, only the latest submission per challenge/member pair is returned. When false, latest submissions are excluded.',
105+
required: false,
106+
})
107+
@IsOptional()
108+
@IsIn(['true', 'false', '1', '0', 'TRUE', 'FALSE'])
109+
isLatest?: string;
100110
}
101111

102112
export class SubmissionRequestBaseDto {

0 commit comments

Comments
 (0)