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
223 changes: 195 additions & 28 deletions client/src/components/admin/ProgramJudgingSection.tsx

Large diffs are not rendered by default.

24 changes: 24 additions & 0 deletions client/src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,10 @@ export type ApiSubmissionRow = ApiSubmission & {
myScore: ApiScore | null;
/** Which batch (of batchSize) this submission falls in. */
batchNumber?: number;
/** Emails of judges who have saved a score for this submission. */
scoredBy?: string[];
/** Every judge's score for this submission so far (for the live overview). */
scores?: Array<{ judgeEmail: string; requirements: number; techStack: number; innovation: number; total: number }>;
};

/** Coverage of one batch: how many judges claimed it + whether this judge has. */
Expand All @@ -269,6 +273,8 @@ export type ApiBatchInfo = {
size: number;
claimCount: number;
claimedByMe: boolean;
/** Emails of judges currently working on (claimed) this batch. */
claimedBy?: string[];
};

export type ApiJudgeView = {
Expand Down Expand Up @@ -2059,6 +2065,24 @@ export const api = {
});
},

/** Admin: delete a submission (e.g. a test entry). Scores cascade. */
deleteSubmission: async (
slug: string,
submissionId: string,
authHeader: AdminAuthArg,
): Promise<{ status: string }> => {
if (USE_MOCK_DATA) {
const { mockJudging } = await import("./mockJudging");
mockJudging.deleteSubmission?.(submissionId);
return { status: "success" };
}
await request(`/programs/${encodeURIComponent(slug)}/submissions/${encodeURIComponent(submissionId)}`, {
method: "DELETE",
headers: adminAuthHeaders(authHeader),
});
return { status: "success" };
},

/** Judge/admin: finalize the ballot (requires every submission scored). */
submitBallot: async (
slug: string,
Expand Down
37 changes: 36 additions & 1 deletion server/api/controllers/__tests__/submission.controller.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ vi.mock('../../repositories/program.repository.js', () => ({
default: { setResultsPublished: vi.fn() },
}));
vi.mock('../../repositories/program-submission.repository.js', () => ({
default: { create: vi.fn(), findById: vi.fn(), findByEmail: vi.fn(), updateSubmission: vi.fn(), setPaid: vi.fn(), setPrize: vi.fn(), listByProgramId: vi.fn(), countByProgramId: vi.fn() },
default: { create: vi.fn(), findById: vi.fn(), findByEmail: vi.fn(), updateSubmission: vi.fn(), setPaid: vi.fn(), setPrize: vi.fn(), listByProgramId: vi.fn(), countByProgramId: vi.fn(), delete: vi.fn() },
}));
vi.mock('../../services/submission-confirmation.service.js', () => ({
default: { send: vi.fn() },
Expand Down Expand Up @@ -589,3 +589,38 @@ describe('SubmissionController.publicResults (public)', () => {
expect(res.json).toHaveBeenCalledWith({ status: 'success', data: { published: false, submissions: [] } });
});
});

describe('SubmissionController.deleteSubmission (admin)', () => {
beforeEach(() => vi.clearAllMocks());

it('204s and deletes a submission that belongs to the program', async () => {
programService.findBySlug.mockResolvedValue(PROGRAM);
submissionRepo.findById.mockResolvedValue({ id: 's1', programId: 'bitrefill', lumaEmail: 'x@y.com', projectTitle: 'T' });
submissionRepo.delete.mockResolvedValue();
const req = { params: { slug: 'bitrefill', submissionId: 's1' }, user: { address: 'admin' } };
const res = mockRes();
await submissionController.deleteSubmission(req, res);
expect(submissionRepo.delete).toHaveBeenCalledWith('s1');
expect(res.status).toHaveBeenCalledWith(204);
});

it('404s (no delete) when the submission belongs to a DIFFERENT program (IDOR)', async () => {
programService.findBySlug.mockResolvedValue(PROGRAM); // id: 'bitrefill'
submissionRepo.findById.mockResolvedValue({ id: 'other', programId: 'another-prog' });
const req = { params: { slug: 'bitrefill', submissionId: 'other' }, user: { address: 'admin' } };
const res = mockRes();
await submissionController.deleteSubmission(req, res);
expect(res.status).toHaveBeenCalledWith(404);
expect(submissionRepo.delete).not.toHaveBeenCalled();
});

it('404s when the submission does not exist', async () => {
programService.findBySlug.mockResolvedValue(PROGRAM);
submissionRepo.findById.mockResolvedValue(null);
const req = { params: { slug: 'bitrefill', submissionId: 'ghost' }, user: { address: 'admin' } };
const res = mockRes();
await submissionController.deleteSubmission(req, res);
expect(res.status).toHaveBeenCalledWith(404);
expect(submissionRepo.delete).not.toHaveBeenCalled();
});
});
32 changes: 32 additions & 0 deletions server/api/controllers/submission.controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,38 @@ class SubmissionController {
}
}

// Admin: hard-delete a submission (e.g. a test entry). Scores cascade.
// Route-gated by requireProgramAdmin. Intended for cleanup BEFORE judging —
// deleting after scoring starts shifts batch membership (created_at order).
async deleteSubmission(req, res) {
try {
const { slug, submissionId } = req.params;
const program = await programService.findBySlug(slug);
if (!program) {
return res.status(404).json({ status: 'error', message: 'Program not found' });
}
// Program-scope check (IDOR): a submission from program B must not be
// deletable via program A's slug.
const existing = await programSubmissionRepository.findById(submissionId);
if (!existing || existing.programId !== program.id) {
return res.status(404).json({ status: 'error', message: 'Submission not found' });
}
await programSubmissionRepository.delete(submissionId);
res.status(204).end();
auditLog.logSafe({
programId: program.id,
actor: { chain: req.user?.chain, wallet: req.user?.address, email: req.user?.email },
action: 'submission.delete',
targetType: 'submission',
targetId: submissionId,
metadata: { lumaEmail: existing.lumaEmail, projectTitle: existing.projectTitle },
});
} catch (error) {
console.error('❌ Error deleting submission:', error);
res.status(500).json({ status: 'error', message: 'Failed to delete submission' });
}
}

// Judge/admin: upsert this judge's score for one submission (draft save).
async upsertScore(req, res) {
try {
Expand Down
6 changes: 6 additions & 0 deletions server/api/repositories/program-submission.repository.js
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,12 @@ class ProgramSubmissionRepository {
if (error) throw error;
return count ?? 0;
}

// Hard-delete a submission. submission_scores rows cascade (ON DELETE CASCADE).
async delete(id) {
const { error } = await supabase.from('program_submissions').delete().eq('id', id);
if (error) throw error;
}
}

export default new ProgramSubmissionRepository();
8 changes: 5 additions & 3 deletions server/api/repositories/submission-score.repository.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,11 @@ const transform = (row) => {
submissionId: row.submission_id,
programId: row.program_id,
judgeEmail: row.judge_email,
requirements: row.requirements_score,
techStack: row.tech_stack_score,
innovation: row.innovation_score,
// NUMERIC columns come back from PostgREST as strings ("4.3"); coerce so all
// score arithmetic (totals, averages) stays numeric, not string-concat.
requirements: Number(row.requirements_score),
techStack: Number(row.tech_stack_score),
innovation: Number(row.innovation_score),
notes: row.notes,
createdAt: row.created_at,
updatedAt: row.updated_at,
Expand Down
6 changes: 6 additions & 0 deletions server/api/routes/program.routes.js
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,12 @@ router.post('/:slug/submissions', submissionLimiter, submissionController.submit
router.get('/:slug/submissions', requireProgramJudge('slug'), submissionController.list);
// Download all submissions + their feedback survey as CSV (judge/admin).
router.get('/:slug/submissions.csv', requireProgramJudge('slug'), submissionController.exportCsv);
// Admin-only: delete a submission (e.g. a test entry). Judges (requireProgramJudge) cannot.
router.delete(
'/:slug/submissions/:submissionId',
requireProgramAdmin('slug'),
submissionController.deleteSubmission,
);
router.put(
'/:slug/submissions/:submissionId/score',
requireProgramJudge('slug'),
Expand Down
33 changes: 29 additions & 4 deletions server/api/services/scoring.service.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ class ScoringService {
// `eligible` is advisory: a submission whose email isn't in program_signups is
// flagged but still scoreable.
async listForJudge(programId, judgeEmail) {
const [submissions, myScores, signupEmails, registeredJudges, submittedBallots, ballot, myBatches, allClaims] =
const [submissions, myScores, signupEmails, registeredJudges, submittedBallots, ballot, myBatches, allClaims, allScores] =
await Promise.all([
programSubmissionRepository.listByProgramId(programId),
submissionScoreRepository.listByJudge(programId, judgeEmail),
Expand All @@ -38,6 +38,7 @@ class ScoringService {
programJudgeBallotRepository.find(programId, judgeEmail),
programJudgeBatchRepository.listByJudge(programId, judgeEmail),
programJudgeBatchRepository.listByProgram(programId),
submissionScoreRepository.listByProgramId(programId),
]);

// listEmailsByProgramId returns a Set of raw signup emails; lowercase for a
Expand All @@ -60,17 +61,39 @@ class ScoringService {
// Batch coverage overview: how many judges have claimed each batch + whether
// this judge has. Lets the panel show "Claim next 10" and per-batch coverage.
const claimedSet = new Set(myBatches);
const claimCounts = new Map();
for (const c of allClaims) claimCounts.set(c.batchNumber, (claimCounts.get(c.batchNumber) || 0) + 1);
// Who has claimed each batch (judges shown as "working on" it) and who has
// saved a score for each submission. Both additive — the write path is
// unchanged.
const claimedByBatch = new Map();
for (const c of allClaims) {
if (!claimedByBatch.has(c.batchNumber)) claimedByBatch.set(c.batchNumber, []);
claimedByBatch.get(c.batchNumber).push(c.judgeEmail);
}
const scoredBySubmission = new Map();
const scoresBySubmission = new Map();
for (const sc of allScores) {
if (!scoredBySubmission.has(sc.submissionId)) scoredBySubmission.set(sc.submissionId, []);
scoredBySubmission.get(sc.submissionId).push(sc.judgeEmail);
if (!scoresBySubmission.has(sc.submissionId)) scoresBySubmission.set(sc.submissionId, []);
scoresBySubmission.get(sc.submissionId).push({
judgeEmail: sc.judgeEmail,
requirements: sc.requirements,
techStack: sc.techStack,
innovation: sc.innovation,
total: Math.round((sc.requirements + sc.techStack + sc.innovation) * 10) / 10,
});
}
const count = batchCountFor(submissions.length);
const batches = [];
for (let n = 1; n <= count; n += 1) {
const start = (n - 1) * BATCH_SIZE;
const claimedBy = claimedByBatch.get(n) || [];
batches.push({
batchNumber: n,
size: Math.min(BATCH_SIZE, submissions.length - start),
claimCount: claimCounts.get(n) || 0,
claimCount: claimedBy.length,
claimedByMe: claimedSet.has(n),
claimedBy,
});
}

Expand All @@ -86,6 +109,8 @@ class ScoringService {
eligible: eligibleSet.has(normalizeEmail(s.lumaEmail)),
myScore: scoreBySubmission.get(s.id) || null,
batchNumber: byBatch.get(s.id),
scoredBy: scoredBySubmission.get(s.id) || [],
scores: scoresBySubmission.get(s.id) || [],
})),
};
}
Expand Down
13 changes: 11 additions & 2 deletions server/api/utils/__tests__/submission.validator.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -179,9 +179,18 @@ describe('validateScore', () => {
expect(validateScore({ requirements: 2, techStack: 5, innovation: -1 }).ok).toBe(false);
});

it('rejects non-integer values', () => {
expect(validateScore({ requirements: 1.5, techStack: 5, innovation: 5 }).ok).toBe(false);
it('accepts in-range decimal scores, rounded to 1 decimal place', () => {
const r = validateScore({ requirements: 1.5, techStack: 4.3, innovation: 5 });
expect(r.ok).toBe(true);
expect(r.value).toMatchObject({ requirements: 1.5, techStack: 4.3, innovation: 5 });
// 4.34 rounds to 4.3 (matches NUMERIC(3,1) storage)
expect(validateScore({ requirements: 0, techStack: 4.34, innovation: 0 }).value.techStack).toBe(4.3);
});

it('rejects non-numeric values', () => {
expect(validateScore({ requirements: '2', techStack: 5, innovation: 5 }).ok).toBe(false);
expect(validateScore({ requirements: NaN, techStack: 5, innovation: 5 }).ok).toBe(false);
expect(validateScore({ techStack: 5, innovation: 5 }).ok).toBe(false); // missing requirements
});

it('rejects overly long notes', () => {
Expand Down
20 changes: 13 additions & 7 deletions server/api/utils/submission.validator.js
Original file line number Diff line number Diff line change
Expand Up @@ -181,20 +181,26 @@ export const SCORE_BOUNDS = {
export const MAX_TOTAL_SCORE =
SCORE_BOUNDS.requirements.max + SCORE_BOUNDS.techStack.max + SCORE_BOUNDS.innovation.max;

const isIntInRange = (v, { min, max }) =>
typeof v === 'number' && Number.isInteger(v) && v >= min && v <= max;
const isNumInRange = (v, { min, max }) =>
typeof v === 'number' && Number.isFinite(v) && v >= min && v <= max;

// Scores accept one decimal place (e.g. 4.3). Round half-up to 1dp so the
// validator and the NUMERIC(3,1) column agree on the stored value.
const roundTo1dp = (v) => Math.round(v * 10) / 10;

export function validateScore(body = {}) {
const { requirements, techStack, innovation } = body;
for (const [key, value] of Object.entries({ requirements, techStack, innovation })) {
if (!isIntInRange(value, SCORE_BOUNDS[key])) {
const out = {};
for (const key of ['requirements', 'techStack', 'innovation']) {
const value = body[key];
if (!isNumInRange(value, SCORE_BOUNDS[key])) {
const { min, max } = SCORE_BOUNDS[key];
return { ok: false, error: `${key} must be an integer between ${min} and ${max}` };
return { ok: false, error: `${key} must be a number between ${min} and ${max}` };
}
out[key] = roundTo1dp(value);
}
const notes = typeof body.notes === 'string' ? body.notes.trim() : '';
if (notes.length > 5000) {
return { ok: false, error: 'Notes must be 5000 characters or fewer' };
}
return { ok: true, value: { requirements, techStack, innovation, notes } };
return { ok: true, value: { ...out, notes } };
}
14 changes: 14 additions & 0 deletions supabase/migrations/20260617000000_submission_scores_decimal.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
-- Allow decimal judge scores (e.g. 4.3/5).
--
-- Widen the three score columns from INTEGER to NUMERIC(3,1) (one decimal place).
-- This is non-destructive and backward-compatible: existing integer scores are
-- preserved (5 -> 5.0), the BETWEEN range CHECK constraints stay valid for
-- numerics, and the previously-deployed integer-only code keeps writing fine.
-- Safe to run while the old build is still serving.
--
-- Idempotent-ish: re-running ALTER TYPE to the same type is a cheap no-op.

ALTER TABLE submission_scores
ALTER COLUMN requirements_score TYPE NUMERIC(3,1),
ALTER COLUMN tech_stack_score TYPE NUMERIC(3,1),
ALTER COLUMN innovation_score TYPE NUMERIC(3,1);