From cc93f99eeb444a475e385f459519d2779e5f52b9 Mon Sep 17 00:00:00 2001 From: sacha <23283108+sacha-l@users.noreply.github.com> Date: Thu, 18 Jun 2026 17:47:19 +0200 Subject: [PATCH 1/3] feat(judging): surface submission contact email in the admin results view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds lumaEmail to the admin/judge leaderboard rows (gated; stripped from public results) and shows a ·CONTACT line (name + mailto) in the expanded results row, so admins can reach winners. Already present in the CSV export. --- client/src/components/admin/ProgramJudgingSection.tsx | 6 ++++++ client/src/lib/api.ts | 2 ++ server/api/services/scoring.service.js | 3 +++ 3 files changed, 11 insertions(+) diff --git a/client/src/components/admin/ProgramJudgingSection.tsx b/client/src/components/admin/ProgramJudgingSection.tsx index c0bec9d..e583d13 100644 --- a/client/src/components/admin/ProgramJudgingSection.tsx +++ b/client/src/components/admin/ProgramJudgingSection.tsx @@ -951,6 +951,12 @@ export function ProgramJudgingSection({ ); })()} + {r.lumaEmail && ( +
+ ·CONTACT: {r.submitterName ? `${r.submitterName} — ` : ""} + {r.lumaEmail} +
+ )}
·SCORES PER JUDGE
{(r.judgeScores ?? []).length === 0 ? ( No individual scores. diff --git a/client/src/lib/api.ts b/client/src/lib/api.ts index 7ac45a9..88d7ef8 100644 --- a/client/src/lib/api.ts +++ b/client/src/lib/api.ts @@ -294,6 +294,8 @@ export type ApiLeaderboardRow = { submissionId: string; projectTitle: string; submitterName?: string; + /** Submitter contact email (admin/judge view only; stripped from public results). */ + lumaEmail?: string; githubUrl?: string; videoUrl?: string; /** False when the submitter's Luma email isn't in the signup list. */ diff --git a/server/api/services/scoring.service.js b/server/api/services/scoring.service.js index 55eda14..14fabbd 100644 --- a/server/api/services/scoring.service.js +++ b/server/api/services/scoring.service.js @@ -284,6 +284,9 @@ class ScoringService { submissionId: s.id, projectTitle: s.projectTitle, submitterName: s.submitterName, + // Contact email — admin/judge view only (this endpoint is gated; + // publicResults strips it). Lets admins reach winners. + lumaEmail: s.lumaEmail, githubUrl: s.githubUrl, videoUrl: s.videoUrl, eligible: eligibleSet.has(normalizeEmail(s.lumaEmail)), From 9c5036a181b4df8f27268bce97bd91e976b224bf Mon Sep 17 00:00:00 2001 From: sacha <23283108+sacha-l@users.noreply.github.com> Date: Thu, 18 Jun 2026 18:20:01 +0200 Subject: [PATCH 2/3] feat(winners): promote hackathon winners into the central panel + EUR support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Migration: add bounty_prizes.currency (default USD) so non-USD prizes show correctly (€100 instead of $100). - promoteToProject now supplies the required project fields and creates a bounty_prizes row from the submission's awarded prize (or an explicit override, for the Showcase award), so a promoted winner actually appears in the panel. - WinnersTable sorts by the program event date (Bitrefill 2026 → top) and shows the bounty currency (€/DOT/other). - RESULTS view: an admin 'ADD TO WINNERS PANEL' button per winner (idempotent; shows '✓ IN WINNERS PANEL' once added). Leaderboard rows carry promotedProjectId. 440 server tests pass; client build + lint clean. --- .../admin/ProgramJudgingSection.tsx | 33 +++++++++++++++++++ client/src/components/admin/WinnersTable.tsx | 16 +++++---- client/src/lib/api.ts | 11 +++++-- .../api/controllers/submission.controller.js | 8 ++++- server/api/repositories/project.repository.js | 2 ++ .../__tests__/scoring.service.test.js | 33 ++++++++++++++++++- server/api/services/scoring.service.js | 24 +++++++++++--- .../20260618000000_bounty_prize_currency.sql | 9 +++++ 8 files changed, 121 insertions(+), 15 deletions(-) create mode 100644 supabase/migrations/20260618000000_bounty_prize_currency.sql diff --git a/client/src/components/admin/ProgramJudgingSection.tsx b/client/src/components/admin/ProgramJudgingSection.tsx index e583d13..d4cd5c7 100644 --- a/client/src/components/admin/ProgramJudgingSection.tsx +++ b/client/src/components/admin/ProgramJudgingSection.tsx @@ -89,6 +89,22 @@ export function ProgramJudgingSection({ // Inline score edits made from the RESULTS view (keyed by submission id). const [resultDrafts, setResultDrafts] = useState>({}); const [savingScoreId, setSavingScoreId] = useState(null); + const [promotingId, setPromotingId] = useState(null); + + // Admin: push a winning submission into the central winners/payments panel. + const promoteWinner = async (submissionId: string) => { + setPromotingId(submissionId); + try { + const auth = await getAuth(); + const r = await api.promoteSubmission(programSlug, submissionId, auth); + toast({ title: r.data.alreadyPromoted ? "Already in the winners panel" : "Added to the winners panel" }); + await loadLeaderboard(); + } catch (e) { + toast({ title: "Couldn't promote", description: (e as Error)?.message || "Unknown error", variant: "destructive" }); + } finally { + setPromotingId(null); + } + }; const setResultField = ( id: string, @@ -957,6 +973,23 @@ export function ProgramJudgingSection({ {r.lumaEmail} )} + {canSelectWinners && r.prizeAmount != null && ( +
+ +
+ )}
·SCORES PER JUDGE
{(r.judgeScores ?? []).length === 0 ? ( No individual scores. diff --git a/client/src/components/admin/WinnersTable.tsx b/client/src/components/admin/WinnersTable.tsx index f24a8b7..659d014 100644 --- a/client/src/components/admin/WinnersTable.tsx +++ b/client/src/components/admin/WinnersTable.tsx @@ -52,7 +52,7 @@ import { api } from "@/lib/api"; interface BountyPrize { name: string; amount: number; - currency?: 'USDC' | 'DOT'; + currency?: string; hackathonWonAtId: string; txHash?: string; } @@ -126,12 +126,12 @@ export function WinnersTable({ projects, onRefresh, connectedAddress, signAdminA return true; }); - // Sort: by event date (newest first) - const sortedProjects = [...filteredProjects].sort((a, b) => { - const dateA = a.hackathon?.eventStartedAt ? new Date(a.hackathon.eventStartedAt).getTime() : 0; - const dateB = b.hackathon?.eventStartedAt ? new Date(b.hackathon.eventStartedAt).getTime() : 0; - return dateB - dateA; - }); + // Sort: by event date (newest first). Prefer the program's event date (always + // populated, incl. promoted hackathon submissions), falling back to the legacy + // flat column. So the latest event (e.g. Bitrefill 2026) sorts to the top. + const eventDate = (p: any) => + new Date(p.program?.eventStartsAt ?? p.hackathon?.eventStartedAt ?? 0).getTime() || 0; + const sortedProjects = [...filteredProjects].sort((a, b) => eventDate(b) - eventDate(a)); // Count by type for display const mainTrackCount = winnerProjects.filter(isMainTrackWinner).length; @@ -169,6 +169,8 @@ export function WinnersTable({ projects, onRefresh, connectedAddress, signAdminA // Format amount with correct currency symbol const formatAmount = (amount: number, currency?: string) => { if (currency === 'DOT') return `${amount.toLocaleString()} DOT`; + if (currency === 'EUR') return `€${amount.toLocaleString()}`; + if (currency && currency !== 'USD' && currency !== 'USDC') return `${amount.toLocaleString()} ${currency}`; return `$${amount.toLocaleString()}`; }; diff --git a/client/src/lib/api.ts b/client/src/lib/api.ts index 88d7ef8..c6a10be 100644 --- a/client/src/lib/api.ts +++ b/client/src/lib/api.ts @@ -129,7 +129,7 @@ export type ApiProject = { /** Live/production website URL */ liveUrl?: string; donationAddress?: string; - bountyPrize?: { name: string; amount: number; hackathonWonAtId: string }[]; + bountyPrize?: { name: string; amount: number; currency?: string; hackathonWonAtId: string }[]; techStack?: string[]; categories?: string[]; m2Status?: "building" | "under_review" | "completed"; @@ -311,6 +311,8 @@ export type ApiLeaderboardRow = { judgeScores?: { judgeEmail: string; requirements: number; techStack: number; innovation: number; total: number }[]; /** The viewing judge's own score (null = not yet scored), for inline edit + re-save. */ myScore?: { requirements: number; techStack: number; innovation: number; notes: string } | null; + /** Set once this winner has been pushed into the central winners panel. */ + promotedProjectId?: string | null; /** Current prize on this submission (null = not a winner). */ prizeAmount?: number | null; prizeCurrency?: string | null; @@ -2122,6 +2124,7 @@ export const api = { slug: string, submissionId: string, authHeader: AdminAuthArg, + bounty?: { bountyName?: string; bountyAmount?: number; bountyCurrency?: string }, ): Promise<{ status: string; data: { projectId: string; alreadyPromoted?: boolean } }> => { if (USE_MOCK_DATA) { const { mockJudging } = await import("./mockJudging"); @@ -2129,7 +2132,11 @@ export const api = { } return request( `/programs/${encodeURIComponent(slug)}/submissions/${encodeURIComponent(submissionId)}/promote`, - { method: "POST", headers: adminAuthHeaders(authHeader) }, + { + method: "POST", + headers: { ...adminAuthHeaders(authHeader), "Content-Type": "application/json" }, + body: JSON.stringify(bounty ?? {}), + }, ); }, diff --git a/server/api/controllers/submission.controller.js b/server/api/controllers/submission.controller.js index 6142d96..edf276c 100644 --- a/server/api/controllers/submission.controller.js +++ b/server/api/controllers/submission.controller.js @@ -398,7 +398,13 @@ class SubmissionController { if (!program) { return res.status(404).json({ status: 'error', message: 'Program not found' }); } - const result = await scoringService.promoteToProject(program, submissionId); + // Optional bounty override (e.g. a "Showcase" 100 EUR award); else the + // submission's own awarded prize is used. + const bounty = + req.body && (req.body.bountyName || req.body.bountyAmount != null) + ? { name: req.body.bountyName, amount: req.body.bountyAmount, currency: req.body.bountyCurrency } + : null; + const result = await scoringService.promoteToProject(program, submissionId, bounty); if (result.notFound) { return res.status(404).json({ status: 'error', message: 'Submission not found' }); } diff --git a/server/api/repositories/project.repository.js b/server/api/repositories/project.repository.js index 09a0160..7e5f2d2 100644 --- a/server/api/repositories/project.repository.js +++ b/server/api/repositories/project.repository.js @@ -84,6 +84,7 @@ const transformProject = (row) => { bountyPrize: (row.bounty_prizes || []).map(b => ({ name: b.name, amount: b.amount, + currency: b.currency || 'USD', hackathonWonAtId: b.hackathon_won_at_id })), milestones: (row.milestones || []).map(m => ({ @@ -282,6 +283,7 @@ class ProjectRepository { project_id: projectId, name: b.name, amount: b.amount, + currency: b.currency || 'USD', hackathon_won_at_id: b.hackathonWonAtId }))); if (bountyError) throw bountyError; diff --git a/server/api/services/__tests__/scoring.service.test.js b/server/api/services/__tests__/scoring.service.test.js index 1802e9e..4346946 100644 --- a/server/api/services/__tests__/scoring.service.test.js +++ b/server/api/services/__tests__/scoring.service.test.js @@ -250,14 +250,45 @@ describe('scoringService.promoteToProject', () => { projectName: 'Aurora Pay', projectRepo: 'https://gh', demoUrl: 'https://v', - hackathon: { id: 'bitrefill-2026', name: 'Bitrefill 2026' }, + hackathon: expect.objectContaining({ id: 'bitrefill-2026', name: 'Bitrefill 2026' }), program: { id: 'prog-1' }, + projectState: 'submitted', teamMembers: [expect.objectContaining({ name: 'Ada' })], + bountyPrize: [], // submission had no prize -> no bounty }), ); expect(submissionRepo.setPromotedProject).toHaveBeenCalledWith('sub-1', 'aurora-pay-ab12'); }); + it('creates a bounty_prize from the awarded prize (or an override) when promoting a winner', async () => { + submissionRepo.findById.mockResolvedValue({ + id: 'sub-2', programId: 'prog-1', projectTitle: 'VoiceBuy', submitterName: 'Bo', + lumaEmail: 'bo@x.com', videoUrl: 'https://v', githubUrl: 'https://gh', + prizeAmount: 500, prizeCurrency: 'EUR', prizeLabel: 'Bitrefill giftcard', + }); + projectService.createProject.mockResolvedValue({ id: 'voicebuy-1' }); + + // Default: uses the submission's awarded prize. + await scoringService.promoteToProject(program, 'sub-2'); + expect(projectService.createProject).toHaveBeenCalledWith( + expect.objectContaining({ + bountyPrize: [expect.objectContaining({ name: 'Bitrefill giftcard', amount: 500, currency: 'EUR' })], + }), + ); + + // Override: a Showcase 100 EUR award. + submissionRepo.findById.mockResolvedValue({ + id: 'sub-3', programId: 'prog-1', projectTitle: 'LootDrop', submitterName: 'Cy', + lumaEmail: 'cy@x.com', videoUrl: 'https://v', githubUrl: 'https://gh', + }); + await scoringService.promoteToProject(program, 'sub-3', { name: 'Showcase', amount: 100, currency: 'EUR' }); + expect(projectService.createProject).toHaveBeenLastCalledWith( + expect.objectContaining({ + bountyPrize: [expect.objectContaining({ name: 'Showcase', amount: 100, currency: 'EUR' })], + }), + ); + }); + it('is idempotent — returns the existing project without creating a second', async () => { submissionRepo.findById.mockResolvedValue({ id: 'sub-1', programId: 'prog-1', promotedProjectId: 'existing-1' }); const r = await scoringService.promoteToProject(program, 'sub-1'); diff --git a/server/api/services/scoring.service.js b/server/api/services/scoring.service.js index 14fabbd..ebd212d 100644 --- a/server/api/services/scoring.service.js +++ b/server/api/services/scoring.service.js @@ -206,13 +206,25 @@ class ScoringService { // Luma email + video into the description since the project/team schema has no // email field. The payout wallet is added by an admin later (no wallet is // collected at submission time). - async promoteToProject(program, submissionId) { + // `bounty` lets the caller override the prize that lands on the winners panel + // (e.g. a "Showcase" 100 EUR award); otherwise the submission's own awarded + // prize is used. A submission with no prize promotes without a bounty row. + async promoteToProject(program, submissionId, bounty = null) { const submission = await programSubmissionRepository.findById(submissionId); if (!submission || submission.programId !== program.id) return { notFound: true }; if (submission.promotedProjectId) { return { alreadyPromoted: true, projectId: submission.promotedProjectId }; } + // Bounty for the winners panel: explicit override, else the awarded prize. + const prizeName = bounty?.name || submission.prizeLabel || 'Prize'; + const prizeAmount = bounty?.amount ?? submission.prizeAmount ?? null; + const prizeCurrency = bounty?.currency || submission.prizeCurrency || 'EUR'; + const bountyPrize = + prizeAmount != null + ? [{ name: prizeName, amount: prizeAmount, currency: prizeCurrency, hackathonWonAtId: program.slug }] + : []; + const project = await projectService.createProject({ projectName: submission.projectTitle, description: @@ -220,11 +232,13 @@ class ScoringService { ` Video demo: ${submission.videoUrl}`, projectRepo: submission.githubUrl, demoUrl: submission.videoUrl, - // hackathon_* are NOT NULL on projects; backfill from the program (same - // convention as elsewhere — hackathon_id mirrors the program slug). - hackathon: { id: program.slug, name: program.name }, + // hackathon_*/project_state are NOT NULL on projects; backfill from the + // program (hackathon_id mirrors the program slug; end date from the event). + hackathon: { id: program.slug, name: program.name, endDate: program.eventEndsAt ?? program.eventStartsAt ?? null }, program: { id: program.id }, + projectState: 'submitted', teamMembers: [{ name: submission.submitterName, github: submission.githubUrl }], + bountyPrize, }); await programSubmissionRepository.setPromotedProject(submissionId, project.id); @@ -314,6 +328,8 @@ class ScoringService { prizeAmount: s.prizeAmount ?? null, prizeCurrency: s.prizeCurrency ?? null, prizeLabel: s.prizeLabel ?? null, + // Whether this winner has been pushed into the central winners panel. + promotedProjectId: s.promotedProjectId ?? null, // Payout tracking, so the results table can show + toggle PAID. paid: s.paid ?? false, }; diff --git a/supabase/migrations/20260618000000_bounty_prize_currency.sql b/supabase/migrations/20260618000000_bounty_prize_currency.sql new file mode 100644 index 0000000..8c52224 --- /dev/null +++ b/supabase/migrations/20260618000000_bounty_prize_currency.sql @@ -0,0 +1,9 @@ +-- Bounty prizes were USD-only (no currency column; amounts render with "$"). +-- Hackathon submissions promoted into the winners panel carry non-USD prizes +-- (e.g. Bitrefill = EUR), so record the currency per bounty. Existing rows +-- default to USD, preserving current behavior. +-- +-- Additive + idempotent. + +ALTER TABLE bounty_prizes + ADD COLUMN IF NOT EXISTS currency TEXT NOT NULL DEFAULT 'USD'; From 395a7b01684b76e3ed218c26373b6292c53b29fe Mon Sep 17 00:00:00 2001 From: sacha <23283108+sacha-l@users.noreply.github.com> Date: Thu, 18 Jun 2026 19:50:49 +0200 Subject: [PATCH 3/3] fix(winners): don't embed submitter email in the promoted project description Project descriptions are public, so the promote flow must not leak the submitter's contact email. Contact stays admin-only (judging results view + CSV). publicResults already strips it; the leaderboard endpoint is admin-gated. --- server/api/services/scoring.service.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/server/api/services/scoring.service.js b/server/api/services/scoring.service.js index ebd212d..8c92071 100644 --- a/server/api/services/scoring.service.js +++ b/server/api/services/scoring.service.js @@ -227,9 +227,9 @@ class ScoringService { const project = await projectService.createProject({ projectName: submission.projectTitle, - description: - `Hackathon submission by ${submission.submitterName} (${submission.lumaEmail}).` + - ` Video demo: ${submission.videoUrl}`, + // Do NOT embed the submitter's email here — project descriptions are + // public. Contact stays admin-only (judging view + CSV export). + description: `Hackathon submission by ${submission.submitterName}. Video demo: ${submission.videoUrl}`, projectRepo: submission.githubUrl, demoUrl: submission.videoUrl, // hackathon_*/project_state are NOT NULL on projects; backfill from the