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
49 changes: 28 additions & 21 deletions client/src/components/admin/ProgramJudgingSection.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Fragment, useCallback, useEffect, useMemo, useState } from "react";
import { Loader2, ExternalLink, Lock, Trophy, Plus, ChevronRight, ChevronDown } from "lucide-react";
import { Loader2, ExternalLink, Lock, Trophy, Plus, ChevronRight, ChevronDown, Play } from "lucide-react";
import {
api,
type AdminAuthArg,
Expand All @@ -11,6 +11,7 @@ import {
import { prizeTiersFor } from "@/lib/constants";
import { useToast } from "@/hooks/use-toast";
import { cn } from "@/lib/utils";
import { ProjectReviewModal } from "@/components/admin/ProjectReviewModal";

type Draft = { requirements: number; techStack: number; innovation: number; notes: string };
type Tab = "score" | "results";
Expand Down Expand Up @@ -61,6 +62,8 @@ export function ProgramJudgingSection({
}) {
const { toast } = useToast();
const [tab, setTab] = useState<Tab>("score");
// The demo currently previewed in the in-app review modal (null = closed).
const [review, setReview] = useState<{ url: string; title: string } | null>(null);
const [view, setView] = useState<ApiJudgeView | null>(null);
const [board, setBoard] = useState<ApiLeaderboard | null>(null);
const [drafts, setDrafts] = useState<Record<string, Draft>>({});
Expand Down Expand Up @@ -202,7 +205,7 @@ export function ProgramJudgingSection({
try {
const auth = await getAuth();
await api.submitBallot(programSlug, auth);
toast({ title: "Scores submitted", description: "Your ballot is locked." });
toast({ title: "Scores submitted", description: "Your scores now count. You can still revise and re-save them." });
await loadSubmissions();
} catch (e) {
toast({
Expand Down Expand Up @@ -336,9 +339,13 @@ export function ProgramJudgingSection({
{!s.eligible && (
<span className="label-hw text-destructive" title="This email is not in the Luma signup list">·NOT IN LUMA</span>
)}
<a href={s.videoUrl} target="_blank" rel="noreferrer" className="label-hw-dim hover:text-display inline-flex items-center gap-1">
VIDEO <ExternalLink className="h-3 w-3" />
</a>
<button
type="button"
onClick={() => setReview({ url: s.videoUrl, title: s.projectTitle })}
className="label-hw-dim hover:text-display inline-flex items-center gap-1"
>
VIDEO <Play className="h-3 w-3" />
</button>
<a href={s.githubUrl} target="_blank" rel="noreferrer" className="label-hw-dim hover:text-display inline-flex items-center gap-1">
GITHUB <ExternalLink className="h-3 w-3" />
</a>
Expand All @@ -357,7 +364,6 @@ export function ProgramJudgingSection({
type="number"
min={0}
max={BOUNDS[field]}
disabled={locked}
value={d[field]}
onChange={(e) => setField(s.id, field, e.target.value)}
className="w-full font-mono text-[13px] bg-panel-deep border border-hairline text-display px-2 py-1.5 focus:outline-none focus:border-display disabled:opacity-50"
Expand All @@ -368,7 +374,6 @@ export function ProgramJudgingSection({
</div>
<textarea
rows={2}
disabled={locked}
value={d.notes}
placeholder="Notes (optional)"
onChange={(e) => setField(s.id, "notes", e.target.value)}
Expand Down Expand Up @@ -411,8 +416,7 @@ export function ProgramJudgingSection({
<>
{locked && (
<div className="lcd p-2.5 mb-3 flex flex-wrap items-center gap-2">
<Lock className="h-3.5 w-3.5 text-display" aria-hidden="true" />
<span className="label-hw text-display">SCORES SUBMITTED · LOCKED</span>
<span className="label-hw text-display">·SUBMITTED — YOUR SCORES COUNT. YOU CAN STILL REVISE + SAVE.</span>
{view.ballotProgress && (
<span className="label-hw-dim">
· {view.ballotProgress.submitted} of {view.ballotProgress.total} judges in
Expand Down Expand Up @@ -467,22 +471,18 @@ export function ProgramJudgingSection({
) : (
<div className="space-y-5">
{claimedGroups.map((g) => {
const groupScored = g.subs.every((s) => s.myScore !== null);
return (
<div key={g.batchNumber} className="space-y-3">
<div className="flex items-center justify-between gap-2">
<span className="label-hw text-display">·BATCH {g.batchNumber} ({g.subs.length})</span>
{!locked && (
<button
type="button"
onClick={() => saveBatch(g.batchNumber, g.subs)}
disabled={savingBatch === g.batchNumber}
className="font-mono text-[10px] tracking-[0.14em] border border-display bg-display text-shell hover:bg-display-dim disabled:opacity-50 px-3 py-1"
>
{savingBatch === g.batchNumber ? "SAVING…" : "SAVE BATCH"}
</button>
)}
{locked && groupScored && <span className="label-hw-dim">SAVED</span>}
<button
type="button"
onClick={() => saveBatch(g.batchNumber, g.subs)}
disabled={savingBatch === g.batchNumber}
className="font-mono text-[10px] tracking-[0.14em] border border-display bg-display text-shell hover:bg-display-dim disabled:opacity-50 px-3 py-1"
>
{savingBatch === g.batchNumber ? "SAVING…" : "SAVE BATCH"}
</button>
</div>
{g.subs.map(renderCard)}
</div>
Expand Down Expand Up @@ -678,6 +678,13 @@ export function ProgramJudgingSection({
</div>
</>
)}

<ProjectReviewModal
open={!!review}
url={review?.url ?? null}
title={review?.title}
onOpenChange={(v) => { if (!v) setReview(null); }}
/>
</section>
);
}
69 changes: 69 additions & 0 deletions client/src/components/admin/ProjectReviewModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { useMemo } from "react";
import { ExternalLink } from "lucide-react";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { detectDemoType, getEmbedUrl } from "@/lib/demoUtils";

/**
* In-app demo preview for the judging flow: opens a project's video/demo link in
* a modal (embedded iframe via demoUtils) so judges don't pile up browser tabs,
* with an "open in new window" escape hatch. GitHub stays a plain new-tab link in
* the caller (it refuses iframe embedding via X-Frame-Options).
*/
export function ProjectReviewModal({
url,
title,
open,
onOpenChange,
}: {
url: string | null;
title?: string;
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
const { isDirectVideo, embedUrl } = useMemo(() => {
if (!url) return { isDirectVideo: false, embedUrl: null as string | null };
const type = detectDemoType(url);
return { isDirectVideo: type === "video", embedUrl: getEmbedUrl(url, type) };
}, [url]);

return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-3xl">
<DialogHeader>
<DialogTitle className="font-mono text-sm tracking-tight pr-8">
{title || "PROJECT DEMO"}
</DialogTitle>
</DialogHeader>
<div className="w-full aspect-video bg-panel-deep border border-hairline">
{isDirectVideo && url ? (
<video src={url} controls className="w-full h-full" />
) : embedUrl ? (
<iframe
src={embedUrl}
title="Project demo"
className="w-full h-full"
allow="fullscreen; accelerometer; clipboard-write; encrypted-media; picture-in-picture"
allowFullScreen
/>
) : (
<div className="flex h-full items-center justify-center p-4 text-center">
<p className="label-hw-dim">This link can't be previewed inline. Use "open in new window".</p>
</div>
)}
</div>
{url && (
<div className="flex justify-end">
<a
href={url}
target="_blank"
rel="noreferrer"
className="label-hw-dim hover:text-display inline-flex items-center gap-1"
>
OPEN IN NEW WINDOW <ExternalLink className="h-3 w-3" />
</a>
</div>
)}
</DialogContent>
</Dialog>
);
}
22 changes: 14 additions & 8 deletions server/api/controllers/__tests__/submission.controller.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -174,17 +174,21 @@ describe('SubmissionController.submit (public)', () => {
});

describe('SubmissionController.upsertScore (judge)', () => {
it('409s when the judge ballot is already submitted (locked)', async () => {
ballotRepo.isSubmitted.mockResolvedValue(true);
it('lets a judge re-save a score after submitting (editable, not locked)', async () => {
ballotRepo.isSubmitted.mockResolvedValue(true); // already submitted
submissionRepo.findById.mockResolvedValue({ id: 's1', programId: 'bitrefill' });
scoreRepo.upsert.mockResolvedValue({ id: 'sc1' });
const req = {
params: { slug: 'bitrefill', submissionId: 's1' },
user: { email: 'judge@x.com', programId: 'bitrefill', canJudge: true },
body: { requirements: 2, techStack: 5, innovation: 5 },
};
const res = mockRes();
await submissionController.upsertScore(req, res);
expect(res.status).toHaveBeenCalledWith(409);
expect(scoreRepo.upsert).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(200);
expect(scoreRepo.upsert).toHaveBeenCalledWith(
expect.objectContaining({ judgeEmail: 'judge@x.com', submissionId: 's1' }),
);
});

it('saves a valid score for the verified judge email (not a body field)', async () => {
Expand Down Expand Up @@ -331,12 +335,14 @@ describe('SubmissionController.saveScores (judge bulk)', () => {
body: { scores },
});

it('409s when the ballot is locked', async () => {
ballotRepo.isSubmitted.mockResolvedValue(true);
it('lets a judge bulk re-save after submitting (editable, not locked)', async () => {
ballotRepo.isSubmitted.mockResolvedValue(true); // already submitted
submissionRepo.listByProgramId.mockResolvedValue([{ id: 's1' }]);
scoreRepo.upsertMany.mockResolvedValue([{ id: 'a' }]);
const res = mockRes();
await submissionController.saveScores(baseReq([{ submissionId: 's1', requirements: 1, techStack: 1, innovation: 1 }]), res);
expect(res.status).toHaveBeenCalledWith(409);
expect(submissionRepo.listByProgramId).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(200);
expect(scoreRepo.upsertMany).toHaveBeenCalled();
});

it('400s an unknown submission in the batch', async () => {
Expand Down
20 changes: 5 additions & 15 deletions server/api/controllers/submission.controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -177,14 +177,9 @@ class SubmissionController {
const judgeEmail = judgeIdentity(req);
const { submissionId } = req.params;

// Locked once the judge has submitted their ballot (reopen is iteration 2).
if (await programJudgeBallotRepository.isSubmitted(programId, judgeEmail)) {
return res.status(409).json({
status: 'error',
message: 'Your scores are submitted and locked. Ask an admin to reopen them to edit.',
});
}

// A judge can revise their own scores at any time, including after
// submitting — the leaderboard (submitted ballots only) reflects the
// latest values. Submitting is "count me in", not a freeze.
const submission = await programSubmissionRepository.findById(submissionId);
if (!submission || submission.programId !== programId) {
return res.status(404).json({ status: 'error', message: 'Submission not found' });
Expand Down Expand Up @@ -277,13 +272,8 @@ class SubmissionController {
if (!programId) return res.status(404).json({ status: 'error', message: 'Program not found' });
const judgeEmail = judgeIdentity(req);

if (await programJudgeBallotRepository.isSubmitted(programId, judgeEmail)) {
return res.status(409).json({
status: 'error',
message: 'Your scores are submitted and locked. Ask an admin to reopen them to edit.',
});
}

// Editable after submit too: a judge may re-save (revise) their own
// scores anytime; submitted ballots still count and reflect the latest.
const scores = req.body?.scores;
if (!Array.isArray(scores) || scores.length === 0) {
return res.status(400).json({ status: 'error', message: 'scores must be a non-empty array' });
Expand Down
2 changes: 1 addition & 1 deletion server/sim/SIMULATION_REPORT.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ Supabase fake.
15. Winner selection is platform-admin only: a per-program admin is rejected (403).
16. Platform admin assigned prizes: Aurora Pay 500 EUR, Nimbus Wallet 200 EUR, Comet Bridge 100 EUR (Bitrefill giftcards).
17. Results published: public page shows all 3 submissions, winners first, with no Luma email exposed.
18. After submitting, a judge can no longer edit scores (409, locked).
18. After submitting, a judge can still revise and re-save scores (200); the submitted ballot reflects the latest values.

## What works

Expand Down
6 changes: 3 additions & 3 deletions server/sim/__tests__/judging-journey.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -441,12 +441,12 @@ describe('Bitrefill judging — basic user journeys', () => {
expect((await publicResults()).body.data.published).toBe(false);
});

it('locks a judge ballot after submission (no edits, 409)', async () => {
it('lets a judge revise scores after submitting (editable, not locked)', async () => {
const j = judge(JUDGES[0]);
const subs = (await j.list()).body.data.submissions;
const r = await j.score(subs[0].id, { requirements: 0, techStack: 0, innovation: 0 });
expect(r.status).toBe(409);
note('After submitting, a judge can no longer edit scores (409, locked).');
expect(r.status).toBe(200);
note('After submitting, a judge can still revise and re-save scores (200); the submitted ballot reflects the latest values.');
});
});

Expand Down