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