From d6c9749624fe5047875d2be7a4712e32bae34d78 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Thu, 20 Nov 2025 12:15:08 +1100 Subject: [PATCH 1/2] Fixes for local issues seen with JWT validation, in case it starts to affect dev and prod --- src/shared/config/auth.config.ts | 2 +- src/shared/guards/tokenRoles.guard.ts | 122 ++++++++ src/shared/modules/global/jwt.service.ts | 295 +++++++++++++++++- .../tokenRequestValidator.middleware.ts | 87 +++++- 4 files changed, 490 insertions(+), 16 deletions(-) diff --git a/src/shared/config/auth.config.ts b/src/shared/config/auth.config.ts index 1e2866c..e6bfa83 100644 --- a/src/shared/config/auth.config.ts +++ b/src/shared/config/auth.config.ts @@ -8,7 +8,7 @@ export const AuthConfig = { // JSON array string of allowed issuers validIssuers: process.env.VALID_ISSUERS || - '["https://testsachin.topcoder-dev.com/","https://test-sachin-rs256.auth0.com/","https://api.topcoder.com","https://api.topcoder-dev.com","https://topcoder-dev.auth0.com/", "https://auth.topcoder-dev.com/"]', + '["https://testsachin.topcoder-dev.com/","https://test-sachin-rs256.auth0.com/","https://api.topcoder.com","https://api.topcoder-dev.com","https://topcoder-dev.auth0.com/","https://auth.topcoder-dev.com/","https://topcoder.auth0.com/","https://auth.topcoder.com/"]', // Legacy JWT configuration (kept for backward compatibility) jwt: { diff --git a/src/shared/guards/tokenRoles.guard.ts b/src/shared/guards/tokenRoles.guard.ts index de56012..acb9067 100644 --- a/src/shared/guards/tokenRoles.guard.ts +++ b/src/shared/guards/tokenRoles.guard.ts @@ -7,8 +7,10 @@ import { SetMetadata, } from '@nestjs/common'; import { Reflector } from '@nestjs/core'; +import { Request } from 'express'; import { JwtService } from '../modules/global/jwt.service'; import { SCOPES_KEY } from '../decorators/scopes.decorator'; +import { LoggerService } from '../modules/global/logger.service'; import { UserRole } from '../enums/userRole.enum'; export const ROLES_KEY = 'roles'; @@ -16,12 +18,19 @@ export const Roles = (...roles: UserRole[]) => SetMetadata(ROLES_KEY, roles); @Injectable() export class TokenRolesGuard implements CanActivate { + private readonly logger = LoggerService.forRoot(TokenRolesGuard.name); + constructor( private reflector: Reflector, private jwtService: JwtService, ) {} canActivate(context: ExecutionContext): boolean { + const handler = context.getHandler?.(); + const controllerClass = context.getClass?.(); + const controllerName = controllerClass?.name || 'UnknownController'; + const handlerName = handler?.name || 'UnknownHandler'; + // Get required roles and scopes from decorators const requiredRoles = this.reflector.get(ROLES_KEY, context.getHandler()) || []; @@ -31,18 +40,49 @@ export class TokenRolesGuard implements CanActivate { // If no roles or scopes are required, allow access if (requiredRoles.length === 0 && requiredScopes.length === 0) { + this.logger.log({ + message: 'No roles or scopes required, allowing request', + controllerName, + handlerName, + }); return true; } const request = context.switchToHttp().getRequest(); + const requestMeta = this.buildRequestLogMeta( + request, + controllerName, + handlerName, + ); + + this.logger.log({ + message: 'Evaluating token roles guard', + ...requestMeta, + requiredRoles, + requiredScopes, + }); try { const user = request['user']; if (!user && (requiredRoles.length || requiredScopes.length)) { + this.logger.warn({ + message: 'Rejecting request due to missing user payload', + ...requestMeta, + hasUser: false, + }); throw new UnauthorizedException('Missing or invalid token!'); } + this.logger.log({ + message: 'User payload extracted from request', + ...requestMeta, + userId: user?.userId, + isMachine: Boolean(user?.isMachine), + roles: user?.roles, + scopes: user?.scopes, + }); + const normalizedRequiredRoles = requiredRoles.map((role) => String(role).trim().toLowerCase(), ); @@ -51,10 +91,22 @@ export class TokenRolesGuard implements CanActivate { if (normalizedRequiredRoles.length > 0) { const normalizedUserRoles = this.normalizeUserRoles(user.roles); + this.logger.log({ + message: 'Checking role-based permissions', + ...requestMeta, + normalizedRequiredRoles, + normalizedUserRoles, + }); + const hasRole = normalizedRequiredRoles.some((role) => normalizedUserRoles.includes(role), ); if (hasRole) { + this.logger.log({ + message: 'Access granted via role-based authorization', + ...requestMeta, + normalizedRequiredRoles, + }); return true; } @@ -66,6 +118,13 @@ export class TokenRolesGuard implements CanActivate { user, ) ) { + this.logger.log({ + message: + 'Access granted via submission list challenge fallback rule', + ...requestMeta, + challengeId: request?.query?.challengeId, + userId: user?.userId, + }); return true; } } @@ -75,7 +134,20 @@ export class TokenRolesGuard implements CanActivate { const hasScope = requiredScopes.some((scope) => user.scopes ? user.scopes.includes(scope) : false, ); + + this.logger.log({ + message: 'Checking scope-based permissions', + ...requestMeta, + requiredScopes, + tokenScopes: user.scopes, + hasScope, + }); + if (hasScope) { + this.logger.log({ + message: 'Access granted via scope-based authorization', + ...requestMeta, + }); return true; } } @@ -89,10 +161,22 @@ export class TokenRolesGuard implements CanActivate { requiredRoles.length > 0 && requiredScopes.length === 0 ) { + this.logger.warn({ + message: + 'M2M token detected without role permissions for role-only endpoint', + ...requestMeta, + tokenScopes: user.scopes, + }); throw new ForbiddenException('M2M token not allowed for this endpoint'); } // Access denied - neither roles nor scopes match + this.logger.warn({ + message: 'Access denied due to insufficient permissions', + ...requestMeta, + requiredRoles, + requiredScopes, + }); throw new ForbiddenException('Insufficient permissions'); } catch (error) { if ( @@ -101,6 +185,15 @@ export class TokenRolesGuard implements CanActivate { ) { throw error; } + this.logger.error( + { + message: 'Unexpected error validating token roles', + ...requestMeta, + error: + error instanceof Error ? error.message : String(error ?? 'error'), + }, + error instanceof Error ? error.stack : undefined, + ); throw new UnauthorizedException('Invalid token'); } } @@ -192,4 +285,33 @@ export class TokenRolesGuard implements CanActivate { return Array.from(normalizedRoles); } + + private buildRequestLogMeta( + request: any, + controllerName: string, + handlerName: string, + ) { + const headers = (request?.headers || {}) as Record< + string, + string | string[] | undefined + >; + const correlationIdCandidate = + headers['x-request-id'] || + headers['x-correlation-id'] || + headers['x-trace-id']; + const method = + typeof request?.method === 'string' + ? request.method.toUpperCase() + : undefined; + + return { + controllerName, + handlerName, + method, + path: request?.originalUrl || request?.url || request?.path, + correlationId: Array.isArray(correlationIdCandidate) + ? correlationIdCandidate[0] + : correlationIdCandidate, + }; + } } diff --git a/src/shared/modules/global/jwt.service.ts b/src/shared/modules/global/jwt.service.ts index 579f257..150a0fa 100644 --- a/src/shared/modules/global/jwt.service.ts +++ b/src/shared/modules/global/jwt.service.ts @@ -3,9 +3,12 @@ import { OnModuleInit, UnauthorizedException, } from '@nestjs/common'; +import { createHash } from 'crypto'; +import * as jwt from 'jsonwebtoken'; import { ALL_SCOPE_MAPPINGS } from '../../enums/scopes.enum'; import { UserRole } from '../../enums/userRole.enum'; import { AuthConfig } from '../../config/auth.config'; +import { LoggerService } from './logger.service'; // tc-core-library-js is CommonJS only, import via require // eslint-disable-next-line @typescript-eslint/no-require-imports @@ -43,16 +46,20 @@ export const isAdmin = (user: JwtUser): boolean => { @Injectable() export class JwtService implements OnModuleInit { + private static readonly SLOW_VALIDATION_WARNING_INTERVAL_MS = 5000; private jwtAuthenticator: any; + private readonly logger = LoggerService.forRoot('JwtService'); /** * Initialize the tc-core-library-js JWT authenticator */ onModuleInit() { + this.logger.log('Initializing tc-core JWT authenticator'); this.jwtAuthenticator = tcCore.middleware.jwtAuthenticator({ AUTH_SECRET: AuthConfig.authSecret, VALID_ISSUERS: AuthConfig.validIssuers, }); + this.logger.log('JWT authenticator initialized'); } /** @@ -61,6 +68,25 @@ export class JwtService implements OnModuleInit { * @returns The user information extracted from the token */ async validateToken(token: string): Promise { + const startedAt = Date.now(); + const tokenHash = this.anonymizeToken(token); + const tokenMetadata = this.decodeTokenMetadata(token); + const validIssuers = this.getValidIssuersList(); + const hasAuthSecret = Boolean(AuthConfig.authSecret); + this.logger.log({ + message: 'Starting JWT validation', + tokenHash, + hasAuthSecret, + validIssuerCount: validIssuers.length, + validIssuersSourceType: Array.isArray(AuthConfig.validIssuers) + ? 'array' + : typeof AuthConfig.validIssuers, + alg: tokenMetadata?.alg, + kid: tokenMetadata?.kid, + iss: tokenMetadata?.iss, + aud: tokenMetadata?.aud, + sub: tokenMetadata?.sub, + }); try { // Use tc-core-library-js for JWT validation const payload = await new Promise((resolve, reject) => { @@ -73,21 +99,79 @@ export class JwtService implements OnModuleInit { }, }; + const waitLogger = setInterval(() => { + this.logger.warn({ + message: 'Awaiting tc-core jwtAuthenticator callback', + tokenHash, + waitedMs: Date.now() - startedAt, + iss: tokenMetadata?.iss, + kid: tokenMetadata?.kid, + }); + }, JwtService.SLOW_VALIDATION_WARNING_INTERVAL_MS); + + const clearWaitLogger = () => { + clearInterval(waitLogger); + }; + + const sendUnauthorized = ( + fallbackMessage: string, + detail?: unknown, + statusCode?: number, + ) => { + clearWaitLogger(); + const detailMessage = + this.extractErrorMessage(detail) ?? fallbackMessage; + this.logger.error({ + message: 'tc-core jwtAuthenticator reported unauthorized response', + tokenHash, + statusCode, + detailMessage, + detail: this.safeSerialize(detail), + }); + return reject(new UnauthorizedException(detailMessage)); + }; + const res = { - status: (number: number) => { - if (number === 403) { - return reject(new UnauthorizedException('Token expired')); - } + status: (statusCode: number) => { return { - json: () => {}, + json: (body?: unknown) => + sendUnauthorized( + this.extractErrorMessage(body) ?? + (statusCode === 403 ? 'Token expired' : 'Invalid token'), + body, + statusCode, + ), }; }, - send: () => {}, + json: (body?: unknown) => + sendUnauthorized( + this.extractErrorMessage(body) ?? 'Invalid token', + body, + ), + send: (...args: any[]) => { + const statusCode = + typeof args[0] === 'number' ? args[0] : undefined; + return sendUnauthorized( + this.extractErrorMessage( + typeof statusCode === 'number' ? args[1] : args[0], + ) ?? (statusCode === 403 ? 'Token expired' : 'Invalid token'), + typeof statusCode === 'number' ? args[1] : args[0], + statusCode, + ); + }, }; const next = (error?: any) => { + clearWaitLogger(); if (error) { - console.error('JWT validation failed:', error); + this.logger.error( + { + message: 'tc-core jwtAuthenticator rejected token', + tokenHash, + error: error instanceof Error ? error.message : String(error), + }, + error instanceof Error ? error.stack : undefined, + ); return reject(new UnauthorizedException('Invalid token')); } @@ -102,10 +186,43 @@ export class JwtService implements OnModuleInit { }; // Call the tc-core-library-js authenticator - this.jwtAuthenticator(req, res, next); + try { + this.logger.log({ + message: 'Invoking tc-core jwtAuthenticator', + tokenHash, + validIssuersPreview: validIssuers.slice(0, 5), + validIssuersProvided: validIssuers.length, + }); + this.jwtAuthenticator(req, res, next); + this.logger.log({ + message: + 'tc-core jwtAuthenticator returned control, awaiting callback', + tokenHash, + }); + } catch (invocationError) { + clearWaitLogger(); + this.logger.error( + { + message: 'tc-core jwtAuthenticator threw synchronously', + tokenHash, + error: + invocationError instanceof Error + ? invocationError.message + : String(invocationError), + }, + invocationError instanceof Error + ? invocationError.stack + : undefined, + ); + return reject(new UnauthorizedException('Invalid token')); + } }); - console.log(`Decoded token: ${JSON.stringify(payload)}`); + this.logger.log({ + message: 'Token decoded successfully', + tokenHash, + hasScopes: Boolean(payload?.scopes || payload?.scope), + }); const user: JwtUser = { isMachine: false }; // Check for M2M token (has scopes) @@ -142,9 +259,25 @@ export class JwtService implements OnModuleInit { } } + this.logger.log({ + message: 'JWT validation completed', + tokenHash, + userId: user.userId, + isMachine: user.isMachine, + hasRoles: Array.isArray(user.roles), + hasScopes: Array.isArray(user.scopes) && user.scopes.length > 0, + durationMs: Date.now() - startedAt, + }); return user; } catch (error) { - console.error('Token validation failed:', error); + this.logger.error( + { + message: 'Token validation failed', + tokenHash, + error: error instanceof Error ? error.message : String(error), + }, + error instanceof Error ? error.stack : undefined, + ); throw new UnauthorizedException('Invalid token'); } } @@ -169,4 +302,146 @@ export class JwtService implements OnModuleInit { return Array.from(expandedScopes); } + + private anonymizeToken(token: string): string { + return createHash('sha256').update(token).digest('hex').slice(0, 16); + } + + private safeSerialize(value: unknown): string | undefined { + if (value === undefined) { + return undefined; + } + try { + return JSON.stringify(value); + } catch { + return undefined; + } + } + + private extractErrorMessage(body: unknown): string | undefined { + if (!body) { + return undefined; + } + if (typeof body === 'string' && body.trim().length > 0) { + return body; + } + if (typeof body === 'object') { + const container = body as Record; + const directMessage = container.message; + if ( + typeof directMessage === 'string' && + directMessage.trim().length > 0 + ) { + return directMessage; + } + + const result = container.result; + if (result && typeof result === 'object') { + const content = (result as Record).content; + if (content && typeof content === 'object') { + const nestedMessage = (content as Record).message; + if ( + typeof nestedMessage === 'string' && + nestedMessage.trim().length > 0 + ) { + return nestedMessage; + } + } + } + } + return undefined; + } + + private decodeTokenMetadata(token: string): + | { + alg?: string; + kid?: string; + iss?: string; + sub?: string; + aud?: string; + } + | undefined { + try { + const decodedRaw = jwt.decode(token, { complete: true }); + const decoded = decodedRaw as + | (jwt.Jwt & { + payload?: Record | string; + }) + | null; + + if (!decoded || typeof decoded !== 'object') { + return undefined; + } + + const header = decoded.header || {}; + const payload = + typeof decoded.payload === 'object' && decoded.payload !== null + ? decoded.payload + : {}; + + const alg = typeof header['alg'] === 'string' ? header['alg'] : undefined; + const kid = typeof header['kid'] === 'string' ? header['kid'] : undefined; + + const iss = + typeof payload['iss'] === 'string' && payload['iss'].length > 0 + ? payload['iss'] + : undefined; + + const sub = + typeof payload['sub'] === 'string' && payload['sub'].length > 0 + ? payload['sub'] + : typeof payload['userId'] === 'string' && + payload['userId'].length > 0 + ? payload['userId'] + : undefined; + + const audienceRaw = payload['aud']; + const aud = Array.isArray(audienceRaw) + ? audienceRaw + .filter((entry): entry is string => typeof entry === 'string') + .join(',') + : typeof audienceRaw === 'string' + ? audienceRaw + : undefined; + + return { alg, kid, iss, sub, aud }; + } catch { + return undefined; + } + } + + private getValidIssuersList(): string[] { + const raw = AuthConfig.validIssuers; + if (!raw) { + return []; + } + + const normalize = (issuer?: unknown) => + typeof issuer === 'string' ? issuer.trim() : ''; + + if (Array.isArray(raw)) { + return raw.map(normalize).filter((issuer) => issuer.length > 0); + } + + if (typeof raw === 'string') { + try { + const parsed = JSON.parse(raw) as unknown; + if (Array.isArray(parsed)) { + return parsed.map(normalize).filter((issuer) => issuer.length > 0); + } + } catch { + const splitIssuers = raw + .split(',') + .map((issuer) => issuer.trim()) + .filter((issuer) => issuer.length > 0); + if (splitIssuers.length > 0) { + return splitIssuers; + } + } + const singleIssuer = raw.trim(); + return singleIssuer.length > 0 ? [singleIssuer] : []; + } + + return []; + } } diff --git a/src/shared/request/tokenRequestValidator.middleware.ts b/src/shared/request/tokenRequestValidator.middleware.ts index 312849f..a9aeb2f 100644 --- a/src/shared/request/tokenRequestValidator.middleware.ts +++ b/src/shared/request/tokenRequestValidator.middleware.ts @@ -3,7 +3,9 @@ import { NestMiddleware, UnauthorizedException, } from '@nestjs/common'; -import { JwtService } from '../modules/global/jwt.service'; +import { Request, Response } from 'express'; +import { createHash } from 'crypto'; +import { JwtService, JwtUser } from '../modules/global/jwt.service'; import { LoggerService } from '../modules/global/logger.service'; @Injectable() @@ -14,15 +16,41 @@ export class TokenValidatorMiddleware implements NestMiddleware { this.logger = LoggerService.forRoot('Auth/TokenValidatorMiddleware'); } - async use(request: any, res: Response, next: (error?: any) => void) { + async use( + request: Request & { user?: JwtUser; idTokenVerified?: boolean }, + res: Response, + next: (error?: any) => void, + ) { + const meta = this.buildRequestMeta(request); const authHeader = request.headers.authorization; - if (!authHeader) { + const normalizedAuthHeader = Array.isArray(authHeader) + ? authHeader[0] + : authHeader; + + this.logger.log({ + message: 'Token validator middleware invoked', + ...meta, + hasAuthHeader: Boolean(normalizedAuthHeader), + authHeaderArrayLength: Array.isArray(authHeader) + ? authHeader.length + : undefined, + }); + + if (!normalizedAuthHeader) { + this.logger.log({ + message: 'No authorization header found, skipping token validation', + ...meta, + }); return next(); } - const [type, idToken] = request.headers.authorization.split(' ') ?? []; + const [type, idToken] = normalizedAuthHeader.split(' ') ?? []; if (type !== 'Bearer') { + this.logger.log({ + message: 'Authorization header present but not a Bearer token', + ...meta, + }); return next(); } @@ -31,10 +59,26 @@ export class TokenValidatorMiddleware implements NestMiddleware { } let decoded: any; + const tokenHash = this.anonymizeToken(idToken); + + this.logger.log({ + message: 'Validating bearer token', + ...meta, + tokenHash, + }); + try { decoded = await this.jwtService.validateToken(idToken); } catch (error) { - this.logger.error('Error verifying JWT', error); + this.logger.error( + { + message: 'Error verifying JWT', + ...meta, + tokenHash, + error: error instanceof Error ? error.message : String(error), + }, + error instanceof Error ? error.stack : undefined, + ); throw new UnauthorizedException('Invalid or expired JWT!'); } @@ -42,6 +86,39 @@ export class TokenValidatorMiddleware implements NestMiddleware { request['user'] = decoded; request.idTokenVerified = true; + this.logger.log({ + message: 'Token successfully validated and attached to request', + ...meta, + tokenHash, + userId: decoded?.userId, + isMachine: Boolean(decoded?.isMachine), + hasRoles: Array.isArray(decoded?.roles), + hasScopes: Array.isArray(decoded?.scopes), + }); + return next(); } + + private buildRequestMeta(request: Request) { + const headers = request.headers || {}; + const correlationIdCandidate = + headers['x-request-id'] || + headers['x-correlation-id'] || + headers['x-trace-id']; + + return { + method: request.method, + url: request.originalUrl || request.url, + correlationId: Array.isArray(correlationIdCandidate) + ? correlationIdCandidate[0] + : correlationIdCandidate, + }; + } + + private anonymizeToken(token?: string): string | undefined { + if (!token) { + return undefined; + } + return createHash('sha256').update(token).digest('hex').slice(0, 16); + } } From b6fd7f5190e733dafe62d7b9575e8dfe28d88d0c Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Sun, 23 Nov 2025 07:37:41 +1100 Subject: [PATCH 2/2] Better performance when calculating 'isLatest', which should help with the submissions endpoint --- .../review-summation.controller.ts | 33 ++++++++- src/api/submission/submission.service.ts | 72 ++++++++++++------- 2 files changed, 79 insertions(+), 26 deletions(-) diff --git a/src/api/review-summation/review-summation.controller.ts b/src/api/review-summation/review-summation.controller.ts index 45660f9..07778bc 100644 --- a/src/api/review-summation/review-summation.controller.ts +++ b/src/api/review-summation/review-summation.controller.ts @@ -179,6 +179,7 @@ export class ReviewSummationController { `Getting review summations with filters - ${JSON.stringify(queryDto)}`, ); const authUser: JwtUser = req['user'] as JwtUser; + const wantsTabSeparated = this.requestWantsTabSeparated(req); const results = await this.service.searchSummation( authUser, queryDto, @@ -186,7 +187,7 @@ export class ReviewSummationController { sortDto, ); - if (!this.requestWantsTabSeparated(req)) { + if (!wantsTabSeparated) { return results; } @@ -199,7 +200,13 @@ export class ReviewSummationController { }); } - const payload = this.buildReviewSummationTsv(results); + const completeResults = await this.loadAllReviewSummationsForExport( + authUser, + queryDto, + sortDto, + results, + ); + const payload = this.buildReviewSummationTsv(completeResults); const safeChallengeSlug = this.buildFilenameSlug(challengeId); const filename = `${ReviewSummationController.TSV_FILENAME_PREFIX}-${safeChallengeSlug}.tsv`; res.setHeader( @@ -321,6 +328,28 @@ export class ReviewSummationController { }; } + private async loadAllReviewSummationsForExport( + authUser: JwtUser, + queryDto: ReviewSummationQueryDto, + sortDto: SortDto | undefined, + initialResults: PaginatedResponse, + ): Promise> { + const totalCount = + initialResults.meta?.totalCount ?? initialResults.data.length; + const currentCount = initialResults.data.length; + + if (!Number.isFinite(totalCount) || totalCount <= currentCount) { + return initialResults; + } + + return this.service.searchSummation( + authUser, + queryDto, + { page: 1, perPage: totalCount }, + sortDto, + ); + } + private requestWantsTabSeparated(req: Request): boolean { const acceptHeader = Array.isArray(req.headers.accept) ? req.headers.accept.join(',') diff --git a/src/api/submission/submission.service.ts b/src/api/submission/submission.service.ts index 99a1fa3..420e892 100644 --- a/src/api/submission/submission.service.ts +++ b/src/api/submission/submission.service.ts @@ -3085,34 +3085,58 @@ export class SubmissionService { return; } - const latestIds = new Set(); - await Promise.all( - Array.from(uniquePairs.values()).map( - async ({ challengeId, memberId }) => { - const latest = await this.prisma.submission.findFirst({ - where: { - challengeId, - memberId, - }, - orderBy: [ - { submittedDate: 'desc' }, - { createdAt: 'desc' }, - { updatedAt: 'desc' }, - ], - select: { id: true }, - }); - - if (latest?.id) { - latestIds.add(latest.id); - } - }, + const challengeIds = Array.from( + new Set( + Array.from(uniquePairs.values()).map((entry) => entry.challengeId), ), ); + const memberIds = Array.from( + new Set(Array.from(uniquePairs.values()).map((entry) => entry.memberId)), + ); - for (const submission of submissions) { - if (latestIds.has(submission.id)) { - (submission as any).isLatest = true; + if (!challengeIds.length || !memberIds.length) { + return; + } + + try { + // Use a single windowed query to locate the latest submission per (challengeId, memberId) + const latestEntries = await this.prisma.$queryRaw< + Array<{ id: string }> + >(Prisma.sql` + SELECT "id" + FROM ( + SELECT + "id", + ROW_NUMBER() OVER ( + PARTITION BY "challengeId", "memberId" + ORDER BY "submittedDate" DESC NULLS LAST, + "createdAt" DESC, + "updatedAt" DESC NULLS LAST + ) AS row_num + FROM "submission" + WHERE "challengeId" IN (${Prisma.join(challengeIds)}) + AND "memberId" IN (${Prisma.join(memberIds)}) + ) ranked + WHERE row_num = 1 + `); + + const latestIds = new Set( + latestEntries + .map((entry) => String(entry.id ?? '').trim()) + .filter((id) => id.length > 0), + ); + + for (const submission of submissions) { + if (latestIds.has(submission.id)) { + (submission as any).isLatest = true; + } } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + this.logger.warn( + `[populateLatestSubmissionFlags] Failed to resolve latest submissions via bulk query: ${message}`, + ); + throw error; } }