Skip to content
Open
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
129 changes: 128 additions & 1 deletion app/(api)/_actions/teams/reportMissingTeam.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
'use server';

import { HttpError } from '@utils/response/Errors';
import { auth } from '@/auth';
import { HttpError, NotAuthenticatedError } from '@utils/response/Errors';
import { GetManySubmissions } from '@datalib/submissions/getSubmissions';
import { UpdateSubmission } from '@datalib/submissions/updateSubmission';
import { UpdateTeam } from '@datalib/teams/updateTeam';
import Submission from '@typeDefs/submission';
import Team from '@typeDefs/team';
import { GetTeam } from '@datalib/teams/getTeam';

export async function reportMissingProject(judge_id: string, team_id: string) {
try {
Expand Down Expand Up @@ -90,3 +93,127 @@ export async function reportMissingProject(judge_id: string, team_id: string) {
return { ok: false, body: null, error: error.message };
}
}

export async function restoreMissingTeam(team_id: string) {
try {
// This action mutates queue order for multiple judges, so keep it admin-only.
const session = await auth();
if (!session || session.user.role !== 'admin') {
throw new NotAuthenticatedError('Access Denied.');
}

// Pull current team state so we can restore based on authoritative reports.
const teamRes = JSON.parse(JSON.stringify(await GetTeam(team_id)));
if (!teamRes.ok || !teamRes.body) {
throw new Error(teamRes.error ?? `Team with id: ${team_id} not found.`);
}

const team = teamRes.body as Team;
const reports = team.reports ?? [];
// Reports can contain duplicates; restore logic should touch each judge once.
const reporterJudgeIds = [
...new Set(
reports
.map((report) => String(report.judge_id ?? ''))
.filter((judge_id) => judge_id.length > 0)
),
];
const requeueResults: { judge_id: string; reorderResCount: number }[] = [];

for (const judge_id of reporterJudgeIds) {
// Rebuild this judge's queue from submissions so we can safely reindex.
const submissionsRes = JSON.parse(
JSON.stringify(
await GetManySubmissions({
judge_id: {
'*convertId': {
id: judge_id,
},
},
})
)
);

if (!submissionsRes.ok) {
throw new Error(submissionsRes.error ?? '');
}

// Convert ids to strings for reliable equality checks and update calls.
const submissions: Submission[] = (submissionsRes.body ?? []).map(
(sub: Submission) => ({
...sub,
judge_id: String(sub.judge_id),
team_id: String(sub.team_id),
})
);

const targetSubmission = submissions.find(
(sub: Submission) => sub.team_id === team_id
);

// Only move queue position if this judge actually has an unscored target
// submission. This preserves existing judged work and avoids requeueing
// judges where the team is not actionable anymore.
if (!targetSubmission || targetSubmission.is_scored) {
continue;
}

// "Restore" policy: put team at end, then normalize all queue positions.
targetSubmission.queuePosition =
Math.max(
...submissions.map((sub: Submission) => sub.queuePosition ?? 0)
) + 1;

const reorderedSubmissions = submissions
.sort(
(a: Submission, b: Submission) =>
(a.queuePosition ?? 0) - (b.queuePosition ?? 0)
)
.map((sub: Submission, index: number) => ({
...sub,
queuePosition: index,
}));

const reorderResList = await Promise.all(
reorderedSubmissions.map((sub: Submission) =>
UpdateSubmission(sub.judge_id, sub.team_id, {
$set: { queuePosition: sub.queuePosition },
})
)
);

if (!reorderResList.every((res: any) => res.ok)) {
throw new Error(
`Not all submission order updates succeeded\n${JSON.stringify(
reorderResList
)}`
);
}

requeueResults.push({
judge_id,
reorderResCount: reorderResList.length,
});
}

// Once queues are restored, clear reports and only set active when needed.
const teamUpdate = team.active
? { $set: { reports: [] } }
: { $set: { reports: [], active: true } };

const updateTeamRes = await UpdateTeam(team_id, teamUpdate);

if (!updateTeamRes.ok) {
throw new Error(updateTeamRes.error ?? '');
}

return {
ok: true,
body: { updateTeamRes, requeueResults },
error: null,
};
} catch (e) {
const error = e as HttpError;
return { ok: false, body: null, error: error.message };
}
}
28 changes: 27 additions & 1 deletion app/(pages)/admin/_components/Teams/TeamCard.module.scss
Original file line number Diff line number Diff line change
Expand Up @@ -81,4 +81,30 @@
border-radius: 8px;
background: var(--cow, linear-gradient(180deg, var(--text-error) 0%, var(--text-error) 100%));
box-shadow: 0px 4px 8px 4px rgba(195, 194, 194, 0.08);
}
}

.restore_button {
width: fit-content;
border: none;
border-radius: 999px;
padding: 8px 14px;
background: #7a1b20;
color: white;
font-size: 0.85rem;
font-weight: 700;
margin-top: 4px;
cursor: pointer;
}

.restore_button:disabled {
opacity: 0.65;
cursor: not-allowed;
}

.restore_helper_text {
max-width: 380px;
color: #5f5f66;
font-size: 0.8rem;
line-height: 1.25rem;
margin-top: 2px;
}
21 changes: 21 additions & 0 deletions app/(pages)/admin/_components/Teams/TeamCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,16 @@ interface TeamCardProps {
team: TeamWithJudges;
onEditClick?: () => void;
editable?: boolean;
onRestoreMissingTeam?: (team_id: string) => void | Promise<void>;
restoringMissingTeam?: boolean;
}

export default function TeamCard({
team,
onEditClick = () => {},
editable = true,
onRestoreMissingTeam = () => {},
restoringMissingTeam = false,
}: TeamCardProps) {
const reports = team.reports ?? [];
return (
Expand Down Expand Up @@ -63,6 +67,23 @@ export default function TeamCard({
</div>
))}
</div>
{editable && reports.length > 0 && (
<>
<button
className={styles.restore_button}
onClick={() => onRestoreMissingTeam(team._id ?? '')}
disabled={restoringMissingTeam}
>
{restoringMissingTeam
? 'Marking as present...'
: 'Mark Team as Present'}
</button>
<p className={styles.restore_helper_text}>
Restores this team to all missing judges&apos; queues and clears
missing reports.
</p>
</>
)}
</div>
</div>
);
Expand Down
27 changes: 27 additions & 0 deletions app/(pages)/admin/teams/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import User from '@typeDefs/user';
import BarChart from '../_components/BarChart/BarChart';
import TeamForm from '../_components/Teams/TeamForm';
import useFormContext from '../_hooks/useFormContext';
import { restoreMissingTeam } from '@actions/teams/reportMissingTeam';
import styles from './page.module.scss';

interface TeamWithJudges extends Team {
Expand All @@ -23,6 +24,7 @@ export default function Teams() {
const { data, setData } = useFormContext();
const isEditing = Boolean(data._id);
const [reportedTeamsDisplay, setReportedTeamsDisplay] = useState(false);
const [restoringTeamId, setRestoringTeamId] = useState<string | null>(null);

if (loading) {
return 'loading...';
Expand Down Expand Up @@ -65,6 +67,29 @@ export default function Teams() {
listContainer?.scrollTo({ top: 0, behavior: 'smooth' });
};

const handleRestoreMissingTeam = async (team_id: string) => {
if (!team_id) return;

const confirmed = window.confirm(
"Are you sure this team is present now? This will restore the team to all missing judges' queues and clear missing reports."
);
if (!confirmed) return;

try {
setRestoringTeamId(team_id);
const restoreRes = await restoreMissingTeam(team_id);
if (!restoreRes.ok) {
throw new Error(restoreRes.error ?? 'Failed to restore missing team.');
}
await getTeams();
} catch (e) {
const error = e as Error;
alert(error.message);
} finally {
setRestoringTeamId(null);
}
};

return (
<div className={styles.container}>
<h1 className={styles.page_title}>Team Manager</h1>
Expand Down Expand Up @@ -140,6 +165,8 @@ export default function Teams() {
<TeamCard
team={team}
onEditClick={() => handleEditTeam(team)}
onRestoreMissingTeam={handleRestoreMissingTeam}
restoringMissingTeam={restoringTeamId === team._id}
/>
</div>
))}
Expand Down
Loading
Loading