@@ -11,6 +11,8 @@ import {
1111 HttpCode ,
1212 HttpStatus ,
1313 Req ,
14+ Res ,
15+ BadRequestException ,
1416} from '@nestjs/common' ;
1517import {
1618 ApiOperation ,
@@ -38,13 +40,15 @@ import { PaginatedResponse, PaginationDto } from '../../dto/pagination.dto';
3840import { SortDto } from '../../dto/sort.dto' ;
3941import { ReviewSummationService } from './review-summation.service' ;
4042import { JwtUser } from 'src/shared/modules/global/jwt.service' ;
41- import { Request } from 'express' ;
43+ import { Request , Response } from 'express' ;
4244
4345@ApiTags ( 'ReviewSummations' )
4446@ApiBearerAuth ( )
4547@Controller ( '/reviewSummations' )
4648export class ReviewSummationController {
4749 private readonly logger : LoggerService ;
50+ private static readonly TSV_CONTENT_TYPE = 'text/tab-separated-values' ;
51+ private static readonly TSV_FILENAME_PREFIX = 'review-summations' ;
4852
4953 constructor ( private readonly service : ReviewSummationService ) {
5054 this . logger = LoggerService . forRoot ( ReviewSummationController . name ) ;
@@ -150,22 +154,60 @@ export class ReviewSummationController {
150154 description : 'List of review summations.' ,
151155 type : [ ReviewSummationResponseDto ] ,
152156 } )
157+ @ApiResponse ( {
158+ status : 200 ,
159+ description :
160+ 'Tab-delimited representation when Accept includes text/tab-separated-values.' ,
161+ content : {
162+ 'text/tab-separated-values' : {
163+ schema : {
164+ type : 'string' ,
165+ example :
166+ 'submissionId\tsubmitterId\tsubmitterHandle\taggregateScore\tisFinal\tisProvisional\tisExample\treviewedDate\tcreatedAt\tupdatedAt\tscore\ttestcase\nsid123\t123456\tmember\t99.5\ttrue\tfalse\tfalse\t2024-02-01T10:00:00.000Z\t2024-02-02T12:00:00.000Z\t2024-02-02T13:00:00.000Z\t99.5\tSample test case' ,
167+ } ,
168+ } ,
169+ } ,
170+ } )
153171 async listReviewSummations (
154172 @Req ( ) req : Request ,
173+ @Res ( { passthrough : true } ) res : Response ,
155174 @Query ( ) queryDto : ReviewSummationQueryDto ,
156175 @Query ( ) paginationDto ?: PaginationDto ,
157176 @Query ( ) sortDto ?: SortDto ,
158- ) : Promise < PaginatedResponse < ReviewSummationResponseDto > > {
177+ ) : Promise < PaginatedResponse < ReviewSummationResponseDto > | string > {
159178 this . logger . log (
160179 `Getting review summations with filters - ${ JSON . stringify ( queryDto ) } ` ,
161180 ) ;
162181 const authUser : JwtUser = req [ 'user' ] as JwtUser ;
163- return this . service . searchSummation (
182+ const results = await this . service . searchSummation (
164183 authUser ,
165184 queryDto ,
166185 paginationDto ,
167186 sortDto ,
168187 ) ;
188+
189+ if ( ! this . requestWantsTabSeparated ( req ) ) {
190+ return results ;
191+ }
192+
193+ const challengeId = ( queryDto . challengeId ?? '' ) . trim ( ) ;
194+ if ( ! challengeId ) {
195+ throw new BadRequestException ( {
196+ message :
197+ 'challengeId is required when requesting tab-delimited review summations.' ,
198+ code : 'TSV_CHALLENGE_ID_REQUIRED' ,
199+ } ) ;
200+ }
201+
202+ const payload = this . buildReviewSummationTsv ( results ) ;
203+ const safeChallengeSlug = this . buildFilenameSlug ( challengeId ) ;
204+ const filename = `${ ReviewSummationController . TSV_FILENAME_PREFIX } -${ safeChallengeSlug } .tsv` ;
205+ res . setHeader (
206+ 'Content-Type' ,
207+ `${ ReviewSummationController . TSV_CONTENT_TYPE } ; charset=utf-8` ,
208+ ) ;
209+ res . setHeader ( 'Content-Disposition' , `attachment; filename="${ filename } "` ) ;
210+ return payload ;
169211 }
170212
171213 @Get ( '/:reviewSummationId' )
@@ -278,4 +320,180 @@ export class ReviewSummationController {
278320 message : `Review type ${ reviewSummationId } deleted successfully.` ,
279321 } ;
280322 }
323+
324+ private requestWantsTabSeparated ( req : Request ) : boolean {
325+ const acceptHeader = Array . isArray ( req . headers . accept )
326+ ? req . headers . accept . join ( ',' )
327+ : ( req . headers . accept ?? '' ) ;
328+ if ( acceptHeader ) {
329+ const lowered = acceptHeader
330+ . split ( ',' )
331+ . map ( ( value ) => value . trim ( ) . toLowerCase ( ) ) ;
332+ const matchesHeader = lowered . some ( ( value ) =>
333+ value . startsWith (
334+ ReviewSummationController . TSV_CONTENT_TYPE . toLowerCase ( ) ,
335+ ) ,
336+ ) ;
337+ if ( matchesHeader ) {
338+ return true ;
339+ }
340+ }
341+
342+ const formatParam = req . query [ 'format' ] ;
343+ if (
344+ typeof formatParam === 'string' &&
345+ formatParam . trim ( ) . toLowerCase ( ) === 'tsv'
346+ ) {
347+ return true ;
348+ }
349+
350+ return false ;
351+ }
352+
353+ private buildReviewSummationTsv (
354+ payload : PaginatedResponse < ReviewSummationResponseDto > ,
355+ ) : string {
356+ const headers = [
357+ 'submissionId' ,
358+ 'submitterId' ,
359+ 'submitterHandle' ,
360+ 'aggregateScore' ,
361+ 'isFinal' ,
362+ 'isProvisional' ,
363+ 'isExample' ,
364+ 'reviewedDate' ,
365+ 'createdAt' ,
366+ 'updatedAt' ,
367+ 'score' ,
368+ 'testcase' ,
369+ ] ;
370+
371+ const lines = [ headers . join ( '\t' ) ] ;
372+
373+ payload . data . forEach ( ( entry ) => {
374+ const scoreRows = this . extractTestScoreEntries ( entry . metadata ) ;
375+ if ( ! scoreRows . length ) {
376+ lines . push (
377+ this . toTabSeparatedRow ( entry , {
378+ score : '' ,
379+ testcase : '' ,
380+ } ) ,
381+ ) ;
382+ return ;
383+ }
384+
385+ scoreRows . forEach ( ( scoreEntry ) => {
386+ lines . push ( this . toTabSeparatedRow ( entry , scoreEntry ) ) ;
387+ } ) ;
388+ } ) ;
389+
390+ return lines . join ( '\n' ) ;
391+ }
392+
393+ private toTabSeparatedRow (
394+ entry : ReviewSummationResponseDto ,
395+ metadataEntry : { score : unknown ; testcase : unknown } ,
396+ ) : string {
397+ const values : Array < unknown > = [
398+ entry . submissionId ,
399+ entry . submitterId ,
400+ entry . submitterHandle ,
401+ entry . aggregateScore ,
402+ entry . isFinal ,
403+ entry . isProvisional ,
404+ entry . isExample ,
405+ entry . reviewedDate ,
406+ entry . createdAt ,
407+ entry . updatedAt ,
408+ metadataEntry . score ,
409+ metadataEntry . testcase ,
410+ ] ;
411+
412+ return values
413+ . map ( ( value ) => this . sanitizeTabSeparatedValue ( value ) )
414+ . join ( '\t' ) ;
415+ }
416+
417+ private extractTestScoreEntries (
418+ metadata : ReviewSummationResponseDto [ 'metadata' ] ,
419+ ) : Array < { score : unknown ; testcase : unknown } > {
420+ if ( metadata === null || metadata === undefined ) {
421+ return [ ] ;
422+ }
423+
424+ const results : Array < { score : unknown ; testcase : unknown } > = [ ] ;
425+
426+ const visit = ( value : unknown , inTestsScope = false ) => {
427+ if ( Array . isArray ( value ) ) {
428+ value . forEach ( ( entry ) => visit ( entry , inTestsScope ) ) ;
429+ return ;
430+ }
431+
432+ if ( ! value || typeof value !== 'object' ) {
433+ return ;
434+ }
435+
436+ const record = value as Record < string , unknown > ;
437+ const hasScore = Object . prototype . hasOwnProperty . call ( record , 'score' ) ;
438+ const hasTestCase =
439+ Object . prototype . hasOwnProperty . call ( record , 'testcase' ) ||
440+ Object . prototype . hasOwnProperty . call ( record , 'testCase' ) ;
441+
442+ if ( inTestsScope && ( hasScore || hasTestCase ) ) {
443+ results . push ( {
444+ score : record [ 'score' ] ?? null ,
445+ testcase : record [ 'testcase' ] ?? record [ 'testCase' ] ?? null ,
446+ } ) ;
447+ }
448+
449+ Object . entries ( record ) . forEach ( ( [ key , child ] ) => {
450+ const normalizedKey = key . toLowerCase ( ) ;
451+ const isTestKey =
452+ normalizedKey === 'tests' || normalizedKey === 'testscores' ;
453+ visit ( child , inTestsScope || isTestKey ) ;
454+ } ) ;
455+ } ;
456+
457+ visit ( metadata ) ;
458+ return results ;
459+ }
460+
461+ private sanitizeTabSeparatedValue ( value : unknown ) : string {
462+ if ( value === null || value === undefined ) {
463+ return '' ;
464+ }
465+
466+ if ( value instanceof Date ) {
467+ return value . toISOString ( ) ;
468+ }
469+
470+ if ( typeof value === 'object' ) {
471+ try {
472+ return JSON . stringify ( value ) ;
473+ } catch ( error ) {
474+ this . logger . warn ( `Failed to stringify TSV value: ${ error } ` ) ;
475+ return '' ;
476+ }
477+ }
478+
479+ if ( typeof value === 'function' ) {
480+ // Functions shouldn't appear in tab-separated exports; fall back to empty string.
481+ this . logger . warn (
482+ 'Encountered function value while sanitizing TSV export' ,
483+ ) ;
484+ return '' ;
485+ }
486+
487+ if ( typeof value === 'symbol' ) {
488+ return value . toString ( ) ;
489+ }
490+
491+ const primitiveValue = value as string | number | boolean | bigint ;
492+ const asString = String ( primitiveValue ) ;
493+ return asString . replace ( / [ \t \n \r ] + / g, ' ' ) ;
494+ }
495+
496+ private buildFilenameSlug ( challengeId : string ) : string {
497+ return challengeId . replace ( / [ ^ A - Z a - z 0 - 9 - _ ] + / g, '_' ) || 'export' ;
498+ }
281499}
0 commit comments