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: 39 additions & 0 deletions client/src/components/admin/ProgramJudgingSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,22 @@ export function ProgramJudgingSection({
// Inline score edits made from the RESULTS view (keyed by submission id).
const [resultDrafts, setResultDrafts] = useState<Record<string, { requirements: number; techStack: number; innovation: number }>>({});
const [savingScoreId, setSavingScoreId] = useState<string | null>(null);
const [promotingId, setPromotingId] = useState<string | null>(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,
Expand Down Expand Up @@ -951,6 +967,29 @@ export function ProgramJudgingSection({
</div>
);
})()}
{r.lumaEmail && (
<div className="label-hw-dim mb-2">
·CONTACT: {r.submitterName ? `${r.submitterName} — ` : ""}
<a href={`mailto:${r.lumaEmail}`} className="text-display hover:underline">{r.lumaEmail}</a>
</div>
)}
{canSelectWinners && r.prizeAmount != null && (
<div className="mb-2">
<button
type="button"
onClick={() => promoteWinner(r.submissionId)}
disabled={promotingId === r.submissionId || !!r.promotedProjectId}
title="Add this winner to the central winners + payments panel"
className="font-mono text-[10px] tracking-[0.14em] border border-display bg-display text-shell hover:bg-display-dim disabled:bg-panel-deep disabled:text-label-dim disabled:border-hairline px-3 py-1.5"
>
{r.promotedProjectId
? "✓ IN WINNERS PANEL"
: promotingId === r.submissionId
? "ADDING…"
: "ADD TO WINNERS PANEL ▸"}
</button>
</div>
)}
<div className="label-hw-dim mb-1">·SCORES PER JUDGE</div>
{(r.judgeScores ?? []).length === 0 ? (
<span className="label-hw-dim">No individual scores.</span>
Expand Down
16 changes: 9 additions & 7 deletions client/src/components/admin/WinnersTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ import { api } from "@/lib/api";
interface BountyPrize {
name: string;
amount: number;
currency?: 'USDC' | 'DOT';
currency?: string;
hackathonWonAtId: string;
txHash?: string;
}
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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()}`;
};

Expand Down
13 changes: 11 additions & 2 deletions client/src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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. */
Expand All @@ -309,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;
Expand Down Expand Up @@ -2120,14 +2124,19 @@ 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");
return { status: "success", data: mockJudging.promote(submissionId) };
}
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 ?? {}),
},
);
},

Expand Down
8 changes: 7 additions & 1 deletion server/api/controllers/submission.controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -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' });
}
Expand Down
2 changes: 2 additions & 0 deletions server/api/repositories/project.repository.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 => ({
Expand Down Expand Up @@ -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;
Expand Down
33 changes: 32 additions & 1 deletion server/api/services/__tests__/scoring.service.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
33 changes: 26 additions & 7 deletions server/api/services/scoring.service.js
Original file line number Diff line number Diff line change
Expand Up @@ -206,25 +206,39 @@ 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:
`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_* 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);
Expand Down Expand Up @@ -284,6 +298,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)),
Expand Down Expand Up @@ -311,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,
};
Expand Down
9 changes: 9 additions & 0 deletions supabase/migrations/20260618000000_bounty_prize_currency.sql
Original file line number Diff line number Diff line change
@@ -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';