Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
e758383
chore(types): regenerate supabase types
DeeprajPandey Jan 18, 2026
a934514
feat(db): add judging_notes table for two-pass review
DeeprajPandey Jan 18, 2026
710a2a1
feat(api): add judge portal API endpoints
DeeprajPandey Jan 18, 2026
60eeb07
feat(dashboard): add judge portal page with scoring UI
DeeprajPandey Jan 18, 2026
2a7264e
feat(dashboard): redirect judges and admins to judge portal
DeeprajPandey Jan 18, 2026
954f1b3
feat(dashboard): add hardware filter and improve judge portal UX
DeeprajPandey Jan 18, 2026
1680ff3
fix(db): add ON DELETE SET NULL and NOT NULL constraints to judging_n…
DeeprajPandey Jan 18, 2026
998a29c
fix(api): add submission validation and optimistic locking to judge n…
DeeprajPandey Jan 18, 2026
9514274
refactor(dashboard): improve judge portal code quality
DeeprajPandey Jan 18, 2026
b785754
chore(api): increase default limit to load all submissions
DeeprajPandey Jan 18, 2026
46b318e
feat(dashboard): add client-side pagination to judge portal
DeeprajPandey Jan 18, 2026
8f400c6
feat(dashboard): add Final Decision UI with filter and code cleanup
DeeprajPandey Jan 18, 2026
be4212a
feat(db): add judging_scores table for multi-reviewer support
DeeprajPandey Jan 19, 2026
e03d2c1
chore(types): add judging_scores type definitions
DeeprajPandey Jan 19, 2026
ca9dc26
feat(api): add multi-reviewer aggregation to submissions endpoint
DeeprajPandey Jan 19, 2026
fd404fa
feat(api): add scores endpoint for multi-reviewer judging
DeeprajPandey Jan 19, 2026
9a1679e
refactor(api): simplify notes endpoint to final_decision only
DeeprajPandey Jan 19, 2026
018dfbd
feat(dashboard): rewrite judge portal with split-view multi-reviewer UI
DeeprajPandey Jan 19, 2026
dc583a1
fix(db): add moddatetime extension to judging_scores migration
DeeprajPandey Jan 19, 2026
2870c0c
fix(dashboard): ignore keyboard nav when typing in inputs
DeeprajPandey Jan 19, 2026
255efbd
fix(dashboard): reorder judge scoring form above reviews
DeeprajPandey Jan 19, 2026
e1f1ccf
feat(dashboard): add sticky save bar and success toast to judge portal
DeeprajPandey Jan 19, 2026
d10beee
feat(dashboard): add my-unreviewed quick filter to judge portal
DeeprajPandey Jan 19, 2026
ccbf110
fix(dashboard): add keyboard shortcut hint to judge list
DeeprajPandey Jan 19, 2026
42775e2
fix(dashboard): increase decision button prominence in judge portal
DeeprajPandey Jan 19, 2026
e046fec
feat(dashboard): add collapsible filters to judge portal
DeeprajPandey Jan 19, 2026
3b03bfa
feat(dashboard): UI refinements for judge portal
DeeprajPandey Jan 19, 2026
5e0258b
feat(dashboard): improve project/team display hierarchy
DeeprajPandey Jan 19, 2026
475473f
feat(dashboard): allow admin/judge to view submit page without team
DeeprajPandey Jan 19, 2026
88c4be8
fix(db): add moddatetime extension to judging_notes migration
DeeprajPandey Jan 19, 2026
83f3c22
fix(api): correct submission_status check from 3 to 2
DeeprajPandey Jan 19, 2026
cda068a
feat: add checkbox for user count
DeeprajPandey Jan 23, 2026
cce67e7
feat(api): add bulk decision endpoint for judge portal
DeeprajPandey Jan 23, 2026
ed7df6c
feat(api): add export endpoint for finalists/waitlist
DeeprajPandey Jan 23, 2026
f9363e9
feat(dashboard): add bulk selection, actions, and track summary to ju…
DeeprajPandey Jan 23, 2026
8bcc889
feat(api): add single Tally profile endpoint
DeeprajPandey Jan 23, 2026
1b16a27
feat(api): add batch team profiles endpoint
DeeprajPandey Jan 23, 2026
934395a
feat(dashboard): add member profiles section to judge portal
DeeprajPandey Jan 23, 2026
1424893
feat(dashboard): add not-selected export and bulk action for rejected…
DeeprajPandey Feb 3, 2026
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
98 changes: 98 additions & 0 deletions apps/dashboard/pages/api/judge/bulk-decision.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import 'reflect-metadata';
import { NextApiRequest, NextApiResponse } from 'next';
import { container } from 'tsyringe';
import { HibiscusSupabaseClient } from '@hibiscus/hibiscus-supabase-client';
import { getAuthenticatedUser } from '../../../common/auth';

/**
* Role IDs from the `roles` table:
* 1 = SUPERADMIN
* 7 = JUDGE
*/
const ALLOWED_ROLES = [1, 7];

const VALID_DECISIONS = ['finalist', 'waitlist', 'not_selected'] as const;
type FinalDecision = (typeof VALID_DECISIONS)[number];

interface BulkDecisionRequest {
teamIds: string[];
decision: FinalDecision;
}

/**
* POST /api/judge/bulk-decision
* Bulk update final_decision for multiple teams.
* Only accessible by admins (role=1) and judges (role=7).
*
* Body: { teamIds: string[], decision: 'finalist' | 'waitlist' | 'not_selected' }
*/
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
if (req.method !== 'POST') {
return res.status(405).json({ message: 'Method not allowed' });
}

const user = await getAuthenticatedUser(req);
if (!user) {
return res.status(401).json({ message: 'Unauthorized' });
}

if (!ALLOWED_ROLES.includes(user.role)) {
return res
.status(403)
.json({ message: 'Forbidden: Admin or Judge role required' });
}

const body = req.body as BulkDecisionRequest;

// Validate teamIds
if (!Array.isArray(body.teamIds) || body.teamIds.length === 0) {
return res
.status(400)
.json({ message: 'teamIds must be a non-empty array' });
}

if (body.teamIds.length > 500) {
return res.status(400).json({ message: 'Maximum 500 teams per request' });
}

// Validate decision
if (!VALID_DECISIONS.includes(body.decision)) {
return res.status(400).json({
message: 'decision must be one of: finalist, waitlist, not_selected',
});
}

try {
const hbc = container.resolve(HibiscusSupabaseClient);
hbc.setOptions({ useServiceKey: true });
const supabase = hbc.getClient();

// Build upsert payload for all teams
const payload = body.teamIds.map((teamId) => ({
team_id: teamId,
final_decision: body.decision,
}));

// Bulk upsert
const { error: upsertError } = await supabase
.from('judging_notes')
.upsert(payload, { onConflict: 'team_id' });

if (upsertError) {
console.error('[judge/bulk-decision] Upsert error:', upsertError);
return res.status(500).json({ message: 'Failed to update decisions' });
}

return res.status(200).json({
message: 'Decisions updated successfully',
count: body.teamIds.length,
decision: body.decision,
});
} catch (e) {
console.error('[judge/bulk-decision] Error:', e);
return res.status(500).json({ message: 'Internal server error' });
}
}
191 changes: 191 additions & 0 deletions apps/dashboard/pages/api/judge/export.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
import 'reflect-metadata';
import { NextApiRequest, NextApiResponse } from 'next';
import { container } from 'tsyringe';
import { HibiscusSupabaseClient } from '@hibiscus/hibiscus-supabase-client';
import { getAuthenticatedUser } from '../../../common/auth';

/**
* Role IDs from the `roles` table:
* 1 = SUPERADMIN
* 7 = JUDGE
*/
const ALLOWED_ROLES = [1, 7];

const VALID_DECISIONS = ['finalist', 'waitlist', 'not_selected'] as const;
type FinalDecision = (typeof VALID_DECISIONS)[number];

interface TeamMember {
firstName: string | null;
lastName: string | null;
email: string;
}

interface ExportTeam {
teamId: string;
teamName: string;
projectTitle: string | null;
memberCount: number;
members: TeamMember[];
}

/**
* GET /api/judge/export?decision=finalist|waitlist|not_selected
* Export teams with their members for a given final decision.
* Only accessible by admins (role=1) and judges (role=7).
*
* Returns JSON with team and member details for CSV export.
*/
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
if (req.method !== 'GET') {
return res.status(405).json({ message: 'Method not allowed' });
}

const user = await getAuthenticatedUser(req);
if (!user) {
return res.status(401).json({ message: 'Unauthorized' });
}

if (!ALLOWED_ROLES.includes(user.role)) {
return res
.status(403)
.json({ message: 'Forbidden: Admin or Judge role required' });
}

const decision = req.query.decision as string;

if (!decision || !VALID_DECISIONS.includes(decision as FinalDecision)) {
return res.status(400).json({
message:
'decision query param must be one of: finalist, waitlist, not_selected',
});
}

try {
const hbc = container.resolve(HibiscusSupabaseClient);
hbc.setOptions({ useServiceKey: true });
const supabase = hbc.getClient();

let teamIds: string[];

if (decision === 'not_selected') {
// For not_selected (rejected): get all submitted teams NOT marked as finalist/waitlist
// 1. Get all submitted teams
const { data: submittedTeams, error: submittedError } = await supabase
.from('teams')
.select('team_id')
.eq('submission_status', 2);

if (submittedError) {
console.error('[judge/export] Submitted teams error:', submittedError);
return res.status(500).json({ message: 'Failed to fetch teams' });
}

if (!submittedTeams || submittedTeams.length === 0) {
return res.status(200).json({ teams: [], count: 0, decision });
}

// 2. Get teams that ARE finalists or waitlist (to exclude)
const { data: selectedNotes, error: selectedError } = await supabase
.from('judging_notes')
.select('team_id')
.in('final_decision', ['finalist', 'waitlist']);

if (selectedError) {
console.error('[judge/export] Selected notes error:', selectedError);
return res.status(500).json({ message: 'Failed to fetch decisions' });
}

const selectedTeamIds = new Set(
(selectedNotes || []).map((n) => n.team_id)
);

// 3. Filter to submitted teams NOT in finalist/waitlist
teamIds = submittedTeams
.map((t) => t.team_id)
.filter((id) => !selectedTeamIds.has(id));

if (teamIds.length === 0) {
return res.status(200).json({ teams: [], count: 0, decision });
}
} else {
// For finalist/waitlist: get teams with that specific final_decision
const { data: judgingNotes, error: notesError } = await supabase
.from('judging_notes')
.select('team_id')
.eq('final_decision', decision);

if (notesError) {
console.error('[judge/export] Notes fetch error:', notesError);
return res.status(500).json({ message: 'Failed to fetch decisions' });
}

if (!judgingNotes || judgingNotes.length === 0) {
return res.status(200).json({ teams: [], count: 0, decision });
}

teamIds = judgingNotes.map((n) => n.team_id);
}

// Get team details
const { data: teams, error: teamsError } = await supabase
.from('teams')
.select('team_id, name, project_title')
.in('team_id', teamIds);

if (teamsError) {
console.error('[judge/export] Teams fetch error:', teamsError);
return res.status(500).json({ message: 'Failed to fetch teams' });
}

// Get members for these teams
const { data: members, error: membersError } = await supabase
.from('user_profiles')
.select('team_id, first_name, last_name, email')
.in('team_id', teamIds);

if (membersError) {
console.error('[judge/export] Members fetch error:', membersError);
return res.status(500).json({ message: 'Failed to fetch members' });
}

// Group members by team
const membersByTeam = new Map<string, TeamMember[]>();
for (const member of members || []) {
if (!member.team_id) continue;
const list = membersByTeam.get(member.team_id) || [];
list.push({
firstName: member.first_name,
lastName: member.last_name,
email: member.email,
});
membersByTeam.set(member.team_id, list);
}

// Build export data
const exportTeams: ExportTeam[] = (teams || []).map((team) => {
const teamMembers = membersByTeam.get(team.team_id) || [];
return {
teamId: team.team_id,
teamName: team.name,
projectTitle: team.project_title,
memberCount: teamMembers.length,
members: teamMembers,
};
});

// Sort by team name
exportTeams.sort((a, b) => a.teamName.localeCompare(b.teamName));

return res.status(200).json({
teams: exportTeams,
count: exportTeams.length,
decision,
});
} catch (e) {
console.error('[judge/export] Error:', e);
return res.status(500).json({ message: 'Internal server error' });
}
}
Loading
Loading