diff --git a/client/src/components/admin/ProgramJudgingSection.tsx b/client/src/components/admin/ProgramJudgingSection.tsx index 607b683..c6fec47 100644 --- a/client/src/components/admin/ProgramJudgingSection.tsx +++ b/client/src/components/admin/ProgramJudgingSection.tsx @@ -76,6 +76,32 @@ export function ProgramJudgingSection({ const [publishedAt, setPublishedAt] = useState(resultsPublishedAt); const [publishing, setPublishing] = useState(false); const [expanded, setExpanded] = useState>({}); + 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]); @@ -388,7 +414,17 @@ export function ProgramJudgingSection({
·JUDGING
-
+
+ +
{(["score", "results"] as Tab[]).map((t) => (
diff --git a/client/src/lib/api.ts b/client/src/lib/api.ts index 18e3bd9..eb8242a 100644 --- a/client/src/lib/api.ts +++ b/client/src/lib/api.ts @@ -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 => { + 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 ( diff --git a/server/Dockerfile b/server/Dockerfile index 8d5cf81..bd79798 100644 --- a/server/Dockerfile +++ b/server/Dockerfile @@ -1,8 +1,12 @@ # Use Node.js LTS version FROM node:20-alpine -# Install build dependencies for native modules -RUN apk add --no-cache python3 make g++ +# Install build dependencies for native modules. Retry on transient Alpine +# mirror/network failures (apk exit 15) so a flaky CDN can't break a deploy. +RUN for i in 1 2 3 4 5; do \ + apk add --no-cache python3 make g++ && break; \ + echo "apk add failed (attempt $i), retrying in 5s..."; sleep 5; \ + done # Set working directory WORKDIR /app diff --git a/server/api/controllers/submission.controller.js b/server/api/controllers/submission.controller.js index e66bc58..cad56a8 100644 --- a/server/api/controllers/submission.controller.js +++ b/server/api/controllers/submission.controller.js @@ -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'; @@ -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 { diff --git a/server/api/routes/program.routes.js b/server/api/routes/program.routes.js index 83e4bda..99c4516 100644 --- a/server/api/routes/program.routes.js +++ b/server/api/routes/program.routes.js @@ -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'), diff --git a/server/api/utils/__tests__/submissions-csv.test.js b/server/api/utils/__tests__/submissions-csv.test.js new file mode 100644 index 0000000..3019bd0 --- /dev/null +++ b/server/api/utils/__tests__/submissions-csv.test.js @@ -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'); + }); +}); diff --git a/server/api/utils/submissions-csv.js b/server/api/utils/submissions-csv.js new file mode 100644 index 0000000..fc71a33 --- /dev/null +++ b/server/api/utils/submissions-csv.js @@ -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'; +}