Skip to content

Commit 8a2bd1a

Browse files
committed
TSV output to aid MMs
1 parent 931feba commit 8a2bd1a

File tree

1 file changed

+221
-3
lines changed

1 file changed

+221
-3
lines changed

src/api/review-summation/review-summation.controller.ts

Lines changed: 221 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ import {
1111
HttpCode,
1212
HttpStatus,
1313
Req,
14+
Res,
15+
BadRequestException,
1416
} from '@nestjs/common';
1517
import {
1618
ApiOperation,
@@ -38,13 +40,15 @@ import { PaginatedResponse, PaginationDto } from '../../dto/pagination.dto';
3840
import { SortDto } from '../../dto/sort.dto';
3941
import { ReviewSummationService } from './review-summation.service';
4042
import { 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')
4648
export 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-Za-z0-9-_]+/g, '_') || 'export';
498+
}
281499
}

0 commit comments

Comments
 (0)