@@ -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+
74115export 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 ,
0 commit comments