From c8e75120a72a56df05ac07307d609358f4c80849 Mon Sep 17 00:00:00 2001 From: Agustin Kassis Date: Tue, 30 Jun 2026 11:47:33 -0300 Subject: [PATCH 1/2] feat(voting): judges' scores + combined final result (70% popular / 30% judges) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add judges to the hackathon voting flow: - Admin uploads judges' scores as CSV (`project,judge1,judge2,…`); the per-project judge score is the average of the judges. The backend matches projects by name, self-encrypts the scores (NIP-44, to La Crypta's own pubkey) and publishes a signed kind-30078 judges event (`d=lacrypta.dev:judges:`) — kept secret until the final result. - At close, the judges event is decrypted and merged with the popular tally into a final score using a *share* model: 100 × (0.70·popShare + 0.30·judgeShare), each dimension normalised by its total (projects without a judge score count as 0). - The closed result publishes a full ranked table (1°…last) showing each judge's score, the average, the popular votes and the final score that set the position. New: lib/judgesCsv.ts (parser + matcher), components/voting/FinalResultsTable.tsx, JudgesDoc/FinalRow + computeFinalRanking in lib/voting.ts, the upload-judges admin action + close merge in the voting route. tsc clean. Co-Authored-By: Claude Opus 4.8 --- app/api/hackathons/[id]/voting/route.ts | 138 ++++++++++++- app/hackathons/[id]/VotingSection.tsx | 254 +++++++++++++++++++++++- components/voting/FinalResultsTable.tsx | 105 ++++++++++ lib/judgesCsv.ts | 129 ++++++++++++ lib/voting.ts | 147 ++++++++++++++ lib/votingCache.ts | 57 ++++++ 6 files changed, 821 insertions(+), 9 deletions(-) create mode 100644 components/voting/FinalResultsTable.tsx create mode 100644 lib/judgesCsv.ts diff --git a/app/api/hackathons/[id]/voting/route.ts b/app/api/hackathons/[id]/voting/route.ts index 93e74c8..b37fc36 100644 --- a/app/api/hackathons/[id]/voting/route.ts +++ b/app/api/hackathons/[id]/voting/route.ts @@ -16,29 +16,38 @@ import { isDevMode } from "@/lib/devMode"; import { devPubkeyForPubkey } from "@/lib/devImpersonation"; import { nostrVotingTag } from "@/lib/nostrCacheTags"; import { + fetchJudgesEventFromRelays, fetchVotingPeriodFromRelays, getCachedVotingPeriod, } from "@/lib/votingCache"; import { + JUDGES_KIND, + JUDGES_T_TAG, VOTE_ENC, VOTING_KIND, VOTING_SCHEMA_VERSION, VOTING_T_TAG, buildEligibleVoters, + computeFinalRanking, computeVotingRanking, isVotingTestNamespace, + judgesDTag, parseBallotContent, + parseJudgesDoc, + serializeJudgesDoc, serializeVotingPeriod, tallyDecryptedBallots, voteDTag, votingPeriodDTag, type DecryptedBallot, + type JudgesDoc, type VotingEligibleVoter, type VotingPeriod, type VotingProjectRef, type VotingResults, type VotingWinner, } from "@/lib/voting"; +import { matchJudgesCsv, type JudgesCsv } from "@/lib/judgesCsv"; const OPEN_ACTION = "open-voting"; /** Legacy single-shot close (kept as an alias for close-confirm). */ @@ -52,6 +61,8 @@ const CLOSE_ACTIONS = new Set([ CLOSE_PREVIEW_ACTION, CLOSE_CONFIRM_ACTION, ]); +/** Admin uploads judges' CSV scores → server self-encrypts + publishes them. */ +const UPLOAD_JUDGES_ACTION = "upload-judges"; const PROFILE_KIND = 0; const PROFILE_SOURCE_NPUB = "npub1rujdpkd8mwezrvpqd2rx2zphfaztqrtsfg6w3vdnljdghs2q8qrqtt9u68"; @@ -497,6 +508,28 @@ async function decryptBallotContent( } } +/** Fetches + self-decrypts the judges' scores event (signed by La Crypta and + * NIP-44 encrypted to its own pubkey). Returns null if absent/unreadable. */ +async function fetchJudgesDoc( + secret: Uint8Array, + hackathonId: string, +): Promise { + const ev = await fetchJudgesEventFromRelays(hackathonId); + if (!ev) return null; + try { + const { getPublicKey } = await import("nostr-tools/pure"); + const nip44 = await import("nostr-tools/nip44"); + const selfPub = getPublicKey(secret); + const plaintext = nip44.decrypt( + ev.content, + nip44.getConversationKey(secret, selfPub), + ); + return parseJudgesDoc(plaintext); + } catch { + return null; + } +} + export type ClosePreview = { tally: VotingResults; winners: VotingWinner[]; @@ -593,8 +626,20 @@ async function buildClosePreview( total: Object.values(allocations).reduce((s, n) => s + n, 0), })); + // Merge judges' scores (if uploaded): 70% popular share + 30% judges share. + const judgesDoc = await fetchJudgesDoc(secret, hackathonId); + const finalRanking = judgesDoc + ? computeFinalRanking(results.tally, judgesDoc) + : null; + return { - tally: { ...results, winners }, + tally: { + ...results, + winners, + ...(finalRanking + ? { judges: finalRanking.judges, final: finalRanking.rows } + : {}), + }, winners, countedBallotIds: results.countedBallotIds ?? [], perVoter, @@ -635,11 +680,16 @@ export async function POST( // Normalize slug → canonical id so voting data keys off "zaps", not "gaming". const id = hackathon.id; - let body: { request?: SignedEvent; ballots?: SignedEvent[] }; + let body: { + request?: SignedEvent; + ballots?: SignedEvent[]; + judges?: JudgesCsv; + }; try { body = (await req.json()) as { request?: SignedEvent; ballots?: SignedEvent[]; + judges?: JudgesCsv; }; } catch { return jsonError("Body JSON invalido."); @@ -664,13 +714,95 @@ export async function POST( return jsonError("Request expirado.", 401); } const action = requestTagValue(request, "action") ?? ""; - if (action !== OPEN_ACTION && !CLOSE_ACTIONS.has(action)) { + if ( + action !== OPEN_ACTION && + action !== UPLOAD_JUDGES_ACTION && + !CLOSE_ACTIONS.has(action) + ) { return jsonError("Request no autorizado para administrar la votación.", 401); } if (requestTagValue(request, "h") !== id) { return jsonError("El request no corresponde a este hackatón.", 401); } + // ── Upload judges' CSV: match → self-encrypt → publish (admin-gated) ── + if (action === UPLOAD_JUDGES_ACTION) { + const csv = body.judges; + if ( + !csv || + !Array.isArray(csv.judges) || + csv.judges.length === 0 || + !Array.isArray(csv.rows) + ) { + return jsonError("Falta el CSV de jueces (judges/rows)."); + } + const projects = await votableProjects(id); + const match = matchJudgesCsv(csv, projects.map((p) => ({ id: p.id, name: p.name }))); + if (Object.keys(match.scores).length === 0) { + return NextResponse.json( + { + error: "Ningún proyecto del CSV coincidió con los proyectos votables.", + unmatched: match.unmatched, + invalid: match.invalid, + }, + { status: 422 }, + ); + } + + const doc: JudgesDoc = { + version: VOTING_SCHEMA_VERSION, + hackathonId: id, + judges: match.judges, + scores: match.scores, + }; + + const { getPublicKey } = await import("nostr-tools/pure"); + const nip44 = await import("nostr-tools/nip44"); + const selfPub = getPublicKey(secret); + const ciphertext = nip44.encrypt( + serializeJudgesDoc(doc), + nip44.getConversationKey(secret, selfPub), + ); + + const existingJudges = await fetchJudgesEventFromRelays(id); + const createdAt = Math.max( + Math.floor(Date.now() / 1000), + (existingJudges?.created_at ?? 0) + 1, + ); + const signed = finalizeEvent( + { + kind: JUDGES_KIND, + created_at: createdAt, + content: ciphertext, + tags: [ + ["d", judgesDTag(id)], + ["t", JUDGES_T_TAG], + ["h", id], + ["enc", VOTE_ENC], + ["client", "La Crypta Dev"], + ], + }, + secret, + ) as SignedEvent; + + const relays = await publishToRelays(signed, DEFAULT_RELAYS); + if (!relays.some((r) => r.ok)) { + return NextResponse.json( + { error: "Ningún relay aceptó el evento de jueces.", relays }, + { status: 502 }, + ); + } + return NextResponse.json({ + ok: true, + judges: match.judges, + matched: match.matched.map((m) => ({ projectId: m.projectId, name: m.name, scores: m.scores })), + unmatched: match.unmatched, + invalid: match.invalid, + eventId: signed.id, + relays, + }); + } + // ── Close step 1: decrypt + tally + return preview (admin-gated, no publish) ── if (action === CLOSE_PREVIEW_ACTION) { const existing = await fetchVotingPeriodFromRelays(id); diff --git a/app/hackathons/[id]/VotingSection.tsx b/app/hackathons/[id]/VotingSection.tsx index fc014e2..d825fd8 100644 --- a/app/hackathons/[id]/VotingSection.tsx +++ b/app/hackathons/[id]/VotingSection.tsx @@ -9,6 +9,7 @@ import { useMemo, useRef, useState, + type ChangeEvent, type ReactNode, } from "react"; import { createPortal } from "react-dom"; @@ -17,6 +18,7 @@ import { CheckCircle2, ChevronDown, Coins, + Gavel, Loader2, Lock, Megaphone, @@ -25,6 +27,7 @@ import { Plus, Radio, Trophy, + Upload, Vote, X, Users, @@ -53,10 +56,17 @@ import { subscribeToVotingPeriod, } from "@/lib/votingClient"; import LiveTally from "@/components/voting/LiveTally"; +import FinalResultsTable from "@/components/voting/FinalResultsTable"; import { useAdminLiveTally, type AdminVoterAllocation, } from "@/lib/useAdminLiveTally"; +import { + matchJudgesCsv, + parseJudgesCsv, + type JudgesCsv, + type JudgesMatch, +} from "@/lib/judgesCsv"; /** Shape of the close-preview the backend returns (decrypted, admin-only). */ type ClosePreviewData = { @@ -72,6 +82,16 @@ type ClosePreviewData = { rejected: { pubkey: string; reason: string }[]; }; +/** Server response after uploading the judges' CSV. */ +type JudgesUploadResult = { + ok?: boolean; + judges?: string[]; + matched?: { projectId: string; name: string; scores: number[] }[]; + unmatched?: string[]; + invalid?: string[]; + eventId?: string; +}; + type Pubkeys = { adminPubkey: string | null; publisherPubkey: string | null }; type VoterRow = { @@ -664,12 +684,21 @@ function VotingSectionInner() { {period.status === "closed" && results && ( <> - {results.winners && results.winners.length > 0 && ( - 0 ? ( + + ) : ( + results.winners && + results.winners.length > 0 && ( + + ) )} )} @@ -1208,7 +1237,49 @@ function useAdminVoting( } }, [auth, busy, hackathonId, onPeriod, push, signRequest]); - return { step, busy, runAction, closePreview, closeConfirm }; + /** Upload judges' CSV: the backend matches, self-encrypts and publishes the + * judges event (kept secret until the final result is published at close). */ + const uploadJudges = useCallback( + async (csv: JudgesCsv): Promise => { + if (!auth || busy) return null; + setStep("publishing"); + try { + const request = await signRequest("upload-judges"); + const res = await fetch(`/api/hackathons/${hackathonId}/voting`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ request, judges: csv }), + }); + const data = (await res.json().catch(() => ({}))) as JudgesUploadResult & { + error?: string; + }; + if (!res.ok || !data.ok) { + throw new Error( + data.error || "No se pudieron guardar los votos de jueces.", + ); + } + push({ + kind: "success", + title: "Jueces guardados", + description: `${data.matched?.length ?? 0} proyecto(s) · evento encriptado publicado.`, + }); + return data; + } catch (error) { + push({ + kind: "error", + title: "Error al guardar jueces", + description: + error instanceof Error ? error.message : "Error desconocido.", + }); + return null; + } finally { + setStep("idle"); + } + }, + [auth, busy, hackathonId, push, signRequest], + ); + + return { step, busy, runAction, closePreview, closeConfirm, uploadJudges }; } type AdminVoting = ReturnType; @@ -1220,7 +1291,8 @@ function AdminVotingControls({ period: VotingPeriod | null; admin: AdminVoting; }) { - const { step, busy, runAction, closePreview, closeConfirm } = admin; + const { step, busy, runAction, closePreview, closeConfirm, uploadJudges } = + admin; const [preview, setPreview] = useState(null); useScrollLock(!!preview); @@ -1287,6 +1359,10 @@ function AdminVotingControls({ )} + {period?.status === "open" && ( + + )} + {preview && period && ( + xs.length ? xs.reduce((s, n) => s + n, 0) / xs.length : 0; + +function JudgesUpload({ + period, + busy, + onUpload, +}: { + period: VotingPeriod; + busy: boolean; + onUpload: (csv: JudgesCsv) => Promise; +}) { + const [text, setText] = useState(""); + const [parsed, setParsed] = useState(null); + const [match, setMatch] = useState(null); + const [error, setError] = useState(null); + const [saved, setSaved] = useState(null); + + const projects = useMemo( + () => period.projects.map((p) => ({ id: p.id, name: p.name })), + [period.projects], + ); + + const apply = useCallback( + (raw: string) => { + setText(raw); + setSaved(null); + if (!raw.trim()) { + setParsed(null); + setMatch(null); + setError(null); + return; + } + try { + const csv = parseJudgesCsv(raw); + setParsed(csv); + setMatch(matchJudgesCsv(csv, projects)); + setError(null); + } catch (e) { + setParsed(null); + setMatch(null); + setError(e instanceof Error ? e.message : "CSV inválido."); + } + }, + [projects], + ); + + async function onFile(e: ChangeEvent) { + const file = e.target.files?.[0]; + if (!file) return; + apply(await file.text()); + } + + async function submit() { + if (!parsed) return; + const res = await onUpload(parsed); + if (res) setSaved(res); + } + + const canSubmit = + !!parsed && !!match && Object.keys(match.scores).length > 0 && !busy; + + return ( +
+
+ + + Votos de jueces (CSV) + +
+

+ Formato: project,juez1,juez2,…. El promedio de los jueces se + guarda en un evento encriptado firmado + por La Crypta y se combina al cerrar (30% jueces · 70% popular). +

+ +
+ + +
+ +