feat(judge): dedicated judge portal, organizer invitations, completeness gate UX#550
Conversation
… and score calculation workflows
…grate submission disqualification into grading modal
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (2)
🚧 Files skipped from review as they are similar to previous changes (2)
📝 WalkthroughWalkthroughAdds a public Judge Portal: APIs, React Query hooks, UI pages (home, hackathon, submissions, scoring, results, invitations), organizer judge management with completeness gating for publishing, score UI components, criterion ID stabilization, and an email post-verify redirect flow. ChangesJudge Portal — Public-Facing Feature
Judging Criteria ID Stabilization & Scoring Logic
Organizer Judging Dashboard — Publishing & Judge Management
Auth / Post-verify redirect flow
🎯 4 (Complex) | ⏱️ ~60 minutes Possibly Related PRs
Suggested Reviewers
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 10
🧹 Nitpick comments (5)
app/judge/invitations/[token]/page.tsx (1)
55-55: 💤 Low valuePrefer a descriptive name over underscore suffix.
The variable
isPending_uses an underscore to avoid shadowing theisPendingfrom the query hook. A more descriptive name likeisInvitationPendingorcanTakeActionwould improve readability without relying on naming conventions.✨ Suggested rename
- const isPending_ = data.status === 'PENDING' && !data.isExpired; + const canTakeAction = data.status === 'PENDING' && !data.isExpired;Then update references on lines 161, etc.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@app/judge/invitations/`[token]/page.tsx at line 55, Rename the ambiguous local boolean isPending_ to a descriptive name (e.g., isInvitationPending or canTakeAction) to avoid shadowing the isPending from the query hook and improve readability; update the declaration (currently "const isPending_ = data.status === 'PENDING' && !data.isExpired") and all usages (references that currently read isPending_, such as the check around line 161) to the new identifier so the code consistently uses the clearer name and no longer relies on an underscore suffix to disambiguate.components/organization/hackathons/judging/OrganizerJudgesPanel.tsx (2)
404-419: 💤 Low valueConsider extracting
formatRelto a shared utility.The relative date formatting logic in
formatRelmay be useful across multiple judge-related components. Consider moving it to a shared utilities file (e.g.,lib/utils/date-format.tsor alongside other judge utilities) to promote reuse and maintainability.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@components/organization/hackathons/judging/OrganizerJudgesPanel.tsx` around lines 404 - 419, The inline relative-date formatter formatRel in OrganizerJudgesPanel.tsx should be extracted to a shared utility so other judge-related components can reuse it: create a new module (e.g., lib/utils/dateFormat.ts) that exports the function (export function formatRel(value: string): string { ... }), move the existing implementation there, replace the local implementation in OrganizerJudgesPanel.tsx with an import of formatRel, and update any other components that implement similar logic to import this shared utility instead of duplicating code.
87-100: 💤 Low valueConsider wrapping
onJudgesChangedinuseCallbackat the call site.The effect depends on
onJudgesChanged, which may not be memoized in the parent. While the effect correctly guards against false triggers with theprev !== nullcheck, it will still re-run on every parent render. Wrapping the callback inuseCallbackat the parent component would improve performance.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@components/organization/hackathons/judging/OrganizerJudgesPanel.tsx` around lines 87 - 100, The effect in OrganizerJudgesPanel relies on the onJudgesChanged prop and will re-run whenever that function identity changes; to avoid unnecessary re-runs, memoize the callback at the parent (where onJudgesChanged is created) using React's useCallback so its identity remains stable unless its true dependencies change, ensuring the useEffect in OrganizerJudgesPanel (which compares accepted IDs via previousAcceptedIdsRef) only re-executes when invitations actually change or when the real dependencies of onJudgesChanged change.components/judge/KeyboardShortcuts.tsx (1)
41-41: 💤 Low valueConsider clarifying the dual trigger behavior.
The dialog toggles on both the custom
triggerKeyand hardcodedShift+/. IftriggerKeyis set to something other than?, users might find having two different triggers confusing. Consider documenting this in the component or making theShift+/conditional.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@components/judge/KeyboardShortcuts.tsx` at line 41, The KeyboardShortcuts component currently toggles on both the prop triggerKey and a hardcoded Shift+/ check (e.key === '/' && e.shiftKey), which can confuse users when triggerKey is not '?'; update the component so the Shift+/ behavior is either documented or made conditional: add a prop (e.g., allowShiftSlash: boolean = true) or only check for Shift+/ when triggerKey === '?' so the handler in the KeyboardShortcuts component uses triggerKey and the new flag (or the triggerKey comparison) instead of always checking (e.key === '/' && e.shiftKey); update the component signature and any usages accordingly and add a short comment describing the dual-trigger behavior if you choose the documentation approach.components/organization/hackathons/judging/InviteJudgeDialog.tsx (1)
18-18: 💤 Low valueConsider enhancing email validation regex.
The current regex
/^[^\s@]+@[^\s@]+\.[^\s@]+$/is permissive and accepts technically invalid emails (e.g.,user@domain..com). While HTML5type="email"provides baseline validation, a more robust pattern could catch edge cases for better UX.📧 Enhanced email validation pattern
-const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; +const EMAIL_RE = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/;🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@components/organization/hackathons/judging/InviteJudgeDialog.tsx` at line 18, The EMAIL_RE constant is too permissive and allows invalid forms like consecutive dots; update the EMAIL_RE definition to a stricter pattern (e.g., add a negative lookahead to forbid ".." and require a valid local/host and TLD) by replacing the existing regex in InviteJudgeDialog.tsx (the EMAIL_RE constant) with a more robust pattern such as one that includes ^(?!.*\.\.) to prevent consecutive dots, enforces allowed characters in local and domain parts, and requires a 2+ letter TLD; keep using the existing HTML5 type="email" input but rely on the improved EMAIL_RE for client-side validation.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@app/`(landing)/hackathons/[slug]/components/header/ActionButtons.tsx:
- Around line 49-56: The code is using an any cast for viewerRole; add a
viewerRole?: 'organizer' | 'judge' | 'participant' | 'guest' to the Hackathon
interface/type in lib/api/hackathons.ts (the Hackathon type) and then remove the
(hackathon as any)?.viewerRole cast in ActionButtons.tsx so viewerRole is
accessed as (hackathon)?.viewerRole; keep the existing hasConflictingRole logic
(viewerRole === 'organizer' || viewerRole === 'judge') unchanged.
In `@app/`(landing)/organizations/[id]/hackathons/[hackathonId]/judging/page.tsx:
- Around line 494-519: The effect that fetches completeness for the publish
dialog leaves stale `completeness` when the dialog reopens or when the fetch
fails; update the useEffect so that when `isPublishDialogOpen` becomes true you
immediately clear the prior state (call `setCompleteness(null)` or similar)
before starting the fetch, and if the fetch resolves with `res.success` false or
the promise rejects ensure you do not keep the old value (i.e., explicitly clear
`completeness` in the `.catch()` and in the non-success branch of `.then()`);
apply the same change to the other identical block that uses
`getJudgingCompleteness` so the dialog always shows a fresh/empty snapshot until
a successful response populates it.
In `@app/auth/verify-email/page.tsx`:
- Around line 21-35: The post-verify redirect reads a stashed string from
window.localStorage (POST_VERIFY_KEY) and currently only checks
stashed.startsWith('/'), which can be bypassed by protocol-relative or malformed
paths; instead parse and validate the value before calling router.push:
construct a URL using the page origin (e.g. new URL(stashed, location.origin))
or reject values that start with '//' or contain backslashes after the initial
slash, then ensure the resolved URL's origin equals location.origin and its
pathname starts with '/' before assigning target and calling router.push; update
the logic around POST_VERIFY_KEY, stashed, and router.push accordingly.
In `@app/judge/`[hackathonId]/page.tsx:
- Around line 139-177: The card currently shows scoring CTAs even when
resultsPublished is true; update the rendering logic to treat a published
results state as finalized by gating any scoring flows on !resultsPublished:
change the title/description branches and the Continue scoring Button so they
only use firstUnscoredId and remaining queue copy when resultsPublished is false
(e.g., use conditions like !resultsPublished && firstUnscoredId before showing
CTA text/button), and ensure when resultsPublished is true the UI always shows
review-only text (e.g., "Review my scores" or final-state copy) instead of
"Continue scoring" or remaining-queue messaging; reference the
variables/resultsPublished, firstUnscoredId, remaining, and the Continue scoring
Button/Link rendering to locate the changes.
In `@app/judge/`[hackathonId]/submissions/[submissionId]/page.tsx:
- Around line 77-80: The page currently computes judgingClosed using
hackathonOverview.judgingEnd but ignores hackathonOverview.resultsPublished,
allowing submitOrError to be invoked via the Cmd/Ctrl+Enter shortcut after
results are published; update the read-only logic to consider both the deadline
and publication by deriving readOnly = judgingClosed ||
!!hackathonOverview?.resultsPublished (or similar) where judgingClosed is
computed, then early-return/disable submit paths by checking readOnly before
invoking submitOrError and also guard the keyboard shortcut handler so it no-ops
when readOnly is true; ensure you update references to judgingClosed/readOnly
and the submitOrError call sites and the shortcut listener to use this combined
check.
In `@components/judge/CountdownBanner.tsx`:
- Around line 36-46: The effect in CountdownBanner currently picks intervalMs
once and never updates as time passes; replace the setInterval approach with a
self-scheduling timer that recalculates getCountdown(deadline) on each tick:
inside useEffect keep the tick function that calls setNow(Date.now()), then
compute parts = getCountdown(deadline) and derive intervalMs from parts.totalMs,
and use window.setTimeout to schedule the next tick with that interval; ensure
the cleanup clears the active timeout id. This uses the existing tick, setNow
and getCountdown symbols and removes the fixed window.setInterval usage so the
frequency updates dynamically as the deadline approaches.
In `@components/judge/DeadlineBadge.tsx`:
- Around line 13-16: The dayDiff function currently uses Math.round which treats
small negative offsets as 0 (e.g., -0.25 -> 0) causing recently-passed deadlines
to display as "Ends today"; update the logic in dayDiff to use Math.floor on the
computed days (i.e., replace Math.round with Math.floor) so any past deadline
yields a negative integer (e.g., -0.1 -> -1) and will correctly render as
overdue.
In `@components/organization/cards/GradeSubmissionModal/index.tsx`:
- Line 105: In GradeSubmissionModal replace the catch parameter type from any to
unknown and make error handling type-safe: change the catch to catch (e:
unknown) and inside the catch branch narrow the type (for example using if (e
instanceof Error) to read e.message, else fall back to String(e) or a default
message) where the code currently reads the error; ensure any direct property
access on the caught value is guarded or cast after an instanceof/type check so
TypeScript rules are satisfied.
In `@components/organization/cards/GradeSubmissionModal/useSubmissionScores.ts`:
- Around line 49-51: getCriterionKey currently returns criterion.id
unconditionally which can be undefined for legacy/incomplete data and will
produce undefined keys in the scores/comments maps (used around where scores and
comments are built and at transformToApiFormat usage). Update getCriterionKey to
return a safe fallback when criterion.id is falsy (e.g., prefer criterion.id but
fall back to a stable string such as `criterion.name`, `criterion.key`, or
generate a deterministic fallback like `criterion-${index}`) so that scores and
comments objects never contain undefined keys; touch the consumer code that
constructs scores/comments (the places referenced near lines 57-59 and 102-104)
to use getCriterionKey consistently. Ensure the fallback is deterministic and
unique per criterion so existing scoring logic and transformToApiFormat continue
to work predictably.
In `@components/organization/hackathons/judging/OrganizerJudgesPanel.tsx`:
- Line 200: The current removal call falls back to judge.id which may be an
assignment ID, causing removeJudge (which expects a userId used in
/judging/judges/${userId}) to fail; update the code to only use judge.userId
(e.g. call onRemoveJudge(judge.userId)) and/or add an explicit guard in the
onRemoveJudge handler to validate presence of userId (reject, show an
error/toast, or disable the remove button) rather than using judge.id; locate
the usage in OrganizerJudgesPanel.tsx (onRemoveJudge, the remove button onClick)
and the removeJudge API call to implement the guard/validation so removal always
supplies a true userId.
---
Nitpick comments:
In `@app/judge/invitations/`[token]/page.tsx:
- Line 55: Rename the ambiguous local boolean isPending_ to a descriptive name
(e.g., isInvitationPending or canTakeAction) to avoid shadowing the isPending
from the query hook and improve readability; update the declaration (currently
"const isPending_ = data.status === 'PENDING' && !data.isExpired") and all
usages (references that currently read isPending_, such as the check around line
161) to the new identifier so the code consistently uses the clearer name and no
longer relies on an underscore suffix to disambiguate.
In `@components/judge/KeyboardShortcuts.tsx`:
- Line 41: The KeyboardShortcuts component currently toggles on both the prop
triggerKey and a hardcoded Shift+/ check (e.key === '/' && e.shiftKey), which
can confuse users when triggerKey is not '?'; update the component so the
Shift+/ behavior is either documented or made conditional: add a prop (e.g.,
allowShiftSlash: boolean = true) or only check for Shift+/ when triggerKey ===
'?' so the handler in the KeyboardShortcuts component uses triggerKey and the
new flag (or the triggerKey comparison) instead of always checking (e.key ===
'/' && e.shiftKey); update the component signature and any usages accordingly
and add a short comment describing the dual-trigger behavior if you choose the
documentation approach.
In `@components/organization/hackathons/judging/InviteJudgeDialog.tsx`:
- Line 18: The EMAIL_RE constant is too permissive and allows invalid forms like
consecutive dots; update the EMAIL_RE definition to a stricter pattern (e.g.,
add a negative lookahead to forbid ".." and require a valid local/host and TLD)
by replacing the existing regex in InviteJudgeDialog.tsx (the EMAIL_RE constant)
with a more robust pattern such as one that includes ^(?!.*\.\.) to prevent
consecutive dots, enforces allowed characters in local and domain parts, and
requires a 2+ letter TLD; keep using the existing HTML5 type="email" input but
rely on the improved EMAIL_RE for client-side validation.
In `@components/organization/hackathons/judging/OrganizerJudgesPanel.tsx`:
- Around line 404-419: The inline relative-date formatter formatRel in
OrganizerJudgesPanel.tsx should be extracted to a shared utility so other
judge-related components can reuse it: create a new module (e.g.,
lib/utils/dateFormat.ts) that exports the function (export function
formatRel(value: string): string { ... }), move the existing implementation
there, replace the local implementation in OrganizerJudgesPanel.tsx with an
import of formatRel, and update any other components that implement similar
logic to import this shared utility instead of duplicating code.
- Around line 87-100: The effect in OrganizerJudgesPanel relies on the
onJudgesChanged prop and will re-run whenever that function identity changes; to
avoid unnecessary re-runs, memoize the callback at the parent (where
onJudgesChanged is created) using React's useCallback so its identity remains
stable unless its true dependencies change, ensuring the useEffect in
OrganizerJudgesPanel (which compares accepted IDs via previousAcceptedIdsRef)
only re-executes when invitations actually change or when the real dependencies
of onJudgesChanged change.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: 62989402-7794-40a9-b7fa-8aaaee504734
📒 Files selected for processing (39)
app/(landing)/hackathons/[slug]/components/header/ActionButtons.tsxapp/(landing)/organizations/[id]/hackathons/[hackathonId]/judging/page.tsxapp/(landing)/organizations/[id]/hackathons/[hackathonId]/participants/page.tsxapp/auth/check-email/page.tsxapp/auth/verify-email/page.tsxapp/globals.cssapp/judge/[hackathonId]/page.tsxapp/judge/[hackathonId]/results/page.tsxapp/judge/[hackathonId]/submissions/[submissionId]/page.tsxapp/judge/[hackathonId]/submissions/page.tsxapp/judge/invitations/[token]/page.tsxapp/judge/invitations/page.tsxapp/judge/layout.tsxapp/judge/page.tsxcomponents/auth/SignupForm.tsxcomponents/judge/CountdownBanner.tsxcomponents/judge/DeadlineBadge.tsxcomponents/judge/HackathonBanner.tsxcomponents/judge/JudgePortalShell.tsxcomponents/judge/KeyboardShortcuts.tsxcomponents/judge/ProgressRing.tsxcomponents/judge/ScoreSlider.tsxcomponents/judge/utils.tscomponents/organization/cards/GradeSubmissionModal/ScoringSection.tsxcomponents/organization/cards/GradeSubmissionModal/TotalScoreCard.tsxcomponents/organization/cards/GradeSubmissionModal/index.tsxcomponents/organization/cards/GradeSubmissionModal/useScoreCalculation.tscomponents/organization/cards/GradeSubmissionModal/useScoreForm.tscomponents/organization/cards/GradeSubmissionModal/useSubmissionScores.tscomponents/organization/cards/Participant.tsxcomponents/organization/hackathons/judging/InviteJudgeDialog.tsxcomponents/organization/hackathons/judging/OrganizerJudgesPanel.tsxhooks/judge/use-judge-queries.tshooks/judge/use-organizer-invitations.tslib/api/hackathons.tslib/api/hackathons/judging.tslib/api/judge.tslib/utils/hackathon-form-transforms.tstypes/hackathon/core.ts
| const viewerRole = (hackathon as any)?.viewerRole as | ||
| | 'organizer' | ||
| | 'judge' | ||
| | 'participant' | ||
| | 'guest' | ||
| | undefined; | ||
| const hasConflictingRole = | ||
| viewerRole === 'organizer' || viewerRole === 'judge'; |
There was a problem hiding this comment.
Avoid 'any' type; add viewerRole to Hackathon type.
The code uses (hackathon as any)?.viewerRole which bypasses TypeScript's type safety. The viewerRole property should be added to the Hackathon type definition in lib/api/hackathons.ts to maintain type safety.
🔧 Recommended fix
Add viewerRole to the Hackathon type in lib/api/hackathons.ts:
export type Hackathon = {
id: string;
name: string;
// ... other fields
+ viewerRole?: 'organizer' | 'judge' | 'participant' | 'guest';
// ... rest of fields
};Then remove the any assertion:
- const viewerRole = (hackathon as any)?.viewerRole as
- | 'organizer'
- | 'judge'
- | 'participant'
- | 'guest'
- | undefined;
+ const viewerRole = hackathon?.viewerRole;As per coding guidelines: "Do not use 'any' type; always search for proper Trustless Work entity types."
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@app/`(landing)/hackathons/[slug]/components/header/ActionButtons.tsx around
lines 49 - 56, The code is using an any cast for viewerRole; add a viewerRole?:
'organizer' | 'judge' | 'participant' | 'guest' to the Hackathon interface/type
in lib/api/hackathons.ts (the Hackathon type) and then remove the (hackathon as
any)?.viewerRole cast in ActionButtons.tsx so viewerRole is accessed as
(hackathon)?.viewerRole; keep the existing hasConflictingRole logic (viewerRole
=== 'organizer' || viewerRole === 'judge') unchanged.
| // Pull the completeness snapshot every time the dialog opens so the | ||
| // organizer sees fresh numbers (a judge may have submitted scores | ||
| // since the page loaded). | ||
| useEffect(() => { | ||
| if (!isPublishDialogOpen) { | ||
| setAcceptPartial(false); | ||
| return; | ||
| } | ||
| let cancelled = false; | ||
| setCompletenessLoading(true); | ||
| getJudgingCompleteness(organizationId, hackathonId) | ||
| .then(res => { | ||
| if (cancelled) return; | ||
| if (res.success && res.data) setCompleteness(res.data); | ||
| }) | ||
| .catch(() => { | ||
| // Non-fatal: the dialog still works, organizer just won't see | ||
| // the incompleteness summary. | ||
| }) | ||
| .finally(() => { | ||
| if (!cancelled) setCompletenessLoading(false); | ||
| }); | ||
| return () => { | ||
| cancelled = true; | ||
| }; | ||
| }, [isPublishDialogOpen, organizationId, hackathonId]); |
There was a problem hiding this comment.
Reset stale completeness state before each preview fetch.
completeness is never cleared when the dialog reopens, and the effect also leaves the old value in place on success: false / rejected requests. That means the warning copy and publish-button gating can reflect a previous snapshot instead of the current judging state.
Suggested fix
useEffect(() => {
if (!isPublishDialogOpen) {
+ setCompleteness(null);
+ setCompletenessLoading(false);
setAcceptPartial(false);
return;
}
let cancelled = false;
+ setCompleteness(null);
setCompletenessLoading(true);
getJudgingCompleteness(organizationId, hackathonId)
.then(res => {
if (cancelled) return;
- if (res.success && res.data) setCompleteness(res.data);
+ if (res.success && res.data) {
+ setCompleteness(res.data);
+ } else {
+ setCompleteness(null);
+ }
})
.catch(() => {
- // Non-fatal: the dialog still works, organizer just won't see
- // the incompleteness summary.
+ if (!cancelled) setCompleteness(null);
})
.finally(() => {
if (!cancelled) setCompletenessLoading(false);
});Also applies to: 1178-1184
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@app/`(landing)/organizations/[id]/hackathons/[hackathonId]/judging/page.tsx
around lines 494 - 519, The effect that fetches completeness for the publish
dialog leaves stale `completeness` when the dialog reopens or when the fetch
fails; update the useEffect so that when `isPublishDialogOpen` becomes true you
immediately clear the prior state (call `setCompleteness(null)` or similar)
before starting the fetch, and if the fetch resolves with `res.success` false or
the promise rejects ensure you do not keep the old value (i.e., explicitly clear
`completeness` in the `.catch()` and in the non-success branch of `.then()`);
apply the same change to the other identical block that uses
`getJudgingCompleteness` so the dialog always shows a fresh/empty snapshot until
a successful response populates it.
| // Honor a post-verify destination stashed by the signup flow | ||
| // (e.g. a judge invitation page). Same-origin paths only. | ||
| let target = '/'; | ||
| if (typeof window !== 'undefined') { | ||
| try { | ||
| const stashed = window.localStorage.getItem(POST_VERIFY_KEY); | ||
| if (stashed && stashed.startsWith('/')) { | ||
| target = stashed; | ||
| } | ||
| window.localStorage.removeItem(POST_VERIFY_KEY); | ||
| } catch { | ||
| // ignore storage errors | ||
| } | ||
| } | ||
| router.push(target); |
There was a problem hiding this comment.
Insufficient open redirect protection.
The validation stashed.startsWith('/') is not sufficient to prevent open redirects. Protocol-relative URLs like //evil.com or edge cases like /\evil.com may bypass this check in some browsers.
🔒 Recommended fix for robust path validation
let target = '/';
if (typeof window !== 'undefined') {
try {
const stashed = window.localStorage.getItem(POST_VERIFY_KEY);
- if (stashed && stashed.startsWith('/')) {
+ if (stashed && stashed.startsWith('/') && !stashed.startsWith('//')) {
target = stashed;
}
window.localStorage.removeItem(POST_VERIFY_KEY);Or use a more robust URL validation:
let target = '/';
if (typeof window !== 'undefined') {
try {
const stashed = window.localStorage.getItem(POST_VERIFY_KEY);
- if (stashed && stashed.startsWith('/')) {
- target = stashed;
- }
+ if (stashed) {
+ try {
+ const url = new URL(stashed, window.location.origin);
+ if (url.origin === window.location.origin) {
+ target = url.pathname + url.search + url.hash;
+ }
+ } catch {
+ // Invalid URL, ignore
+ }
+ }
window.localStorage.removeItem(POST_VERIFY_KEY);📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| // Honor a post-verify destination stashed by the signup flow | |
| // (e.g. a judge invitation page). Same-origin paths only. | |
| let target = '/'; | |
| if (typeof window !== 'undefined') { | |
| try { | |
| const stashed = window.localStorage.getItem(POST_VERIFY_KEY); | |
| if (stashed && stashed.startsWith('/')) { | |
| target = stashed; | |
| } | |
| window.localStorage.removeItem(POST_VERIFY_KEY); | |
| } catch { | |
| // ignore storage errors | |
| } | |
| } | |
| router.push(target); | |
| // Honor a post-verify destination stashed by the signup flow | |
| // (e.g. a judge invitation page). Same-origin paths only. | |
| let target = '/'; | |
| if (typeof window !== 'undefined') { | |
| try { | |
| const stashed = window.localStorage.getItem(POST_VERIFY_KEY); | |
| if (stashed) { | |
| try { | |
| const url = new URL(stashed, window.location.origin); | |
| if (url.origin === window.location.origin) { | |
| target = url.pathname + url.search + url.hash; | |
| } | |
| } catch { | |
| // Invalid URL, ignore | |
| } | |
| } | |
| window.localStorage.removeItem(POST_VERIFY_KEY); | |
| } catch { | |
| // ignore storage errors | |
| } | |
| } | |
| router.push(target); |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@app/auth/verify-email/page.tsx` around lines 21 - 35, The post-verify
redirect reads a stashed string from window.localStorage (POST_VERIFY_KEY) and
currently only checks stashed.startsWith('/'), which can be bypassed by
protocol-relative or malformed paths; instead parse and validate the value
before calling router.push: construct a URL using the page origin (e.g. new
URL(stashed, location.origin)) or reject values that start with '//' or contain
backslashes after the initial slash, then ensure the resolved URL's origin
equals location.origin and its pathname starts with '/' before assigning target
and calling router.push; update the logic around POST_VERIFY_KEY, stashed, and
router.push accordingly.
| <h2 className='mt-1 text-lg font-semibold text-white'> | ||
| {allScored | ||
| ? 'You have scored every submission' | ||
| : firstUnscoredId | ||
| ? `Continue scoring` | ||
| : data.totalSubmissions === 0 | ||
| ? 'Nothing shortlisted yet' | ||
| : 'All scored. Awaiting more.'} | ||
| </h2> | ||
| <p className='mt-1 text-sm text-gray-500'> | ||
| {allScored | ||
| ? 'When the organizer publishes results, you will see the final ranking here.' | ||
| : firstUnscoredId | ||
| ? `${remaining} submission${remaining === 1 ? '' : 's'} left in your queue.` | ||
| : data.totalSubmissions === 0 | ||
| ? 'The organizer has not shortlisted any submissions for judging.' | ||
| : 'You are caught up. Check back later.'} | ||
| </p> | ||
| </div> | ||
| <div className='flex flex-wrap gap-2'> | ||
| {firstUnscoredId ? ( | ||
| <Button | ||
| onClick={() => | ||
| router.push( | ||
| `/judge/${hackathonId}/submissions/${firstUnscoredId}` | ||
| ) | ||
| } | ||
| className='bg-primary hover:bg-primary/90 text-primary-foreground' | ||
| > | ||
| Continue scoring | ||
| <ArrowRight className='ml-2 h-4 w-4' /> | ||
| </Button> | ||
| ) : null} | ||
| <Link | ||
| href={`/judge/${hackathonId}/submissions`} | ||
| className='inline-flex items-center gap-2 rounded-md border border-white/10 bg-transparent px-3 py-2 text-sm text-gray-200 hover:bg-white/5' | ||
| > | ||
| <ClipboardList className='h-4 w-4' /> | ||
| {allScored ? 'Review my scores' : 'See full queue'} |
There was a problem hiding this comment.
Hide scoring CTAs after results are published.
publishJudgingResults({ acceptPartial: true }) makes resultsPublished && firstUnscoredId a valid state now. This card still renders “Continue scoring” plus remaining-queue copy in that case, which contradicts the finalized state and sends judges into a flow that should already be review-only.
Suggested fix
- <h2 className='mt-1 text-lg font-semibold text-white'>
- {allScored
- ? 'You have scored every submission'
- : firstUnscoredId
- ? `Continue scoring`
- : data.totalSubmissions === 0
- ? 'Nothing shortlisted yet'
- : 'All scored. Awaiting more.'}
- </h2>
+ <h2 className='mt-1 text-lg font-semibold text-white'>
+ {data.resultsPublished
+ ? 'Results have been published'
+ : allScored
+ ? 'You have scored every submission'
+ : firstUnscoredId
+ ? 'Continue scoring'
+ : data.totalSubmissions === 0
+ ? 'Nothing shortlisted yet'
+ : 'All scored. Awaiting more.'}
+ </h2>
<p className='mt-1 text-sm text-gray-500'>
- {allScored
+ {data.resultsPublished
+ ? 'Judging is finalized. You can review your scores and the published results below.'
+ : allScored
? 'When the organizer publishes results, you will see the final ranking here.'
: firstUnscoredId
? `${remaining} submission${remaining === 1 ? '' : 's'} left in your queue.`
: data.totalSubmissions === 0
? 'The organizer has not shortlisted any submissions for judging.'
: 'You are caught up. Check back later.'}
</p>
@@
- {firstUnscoredId ? (
+ {!data.resultsPublished && firstUnscoredId ? (
<Button
@@
- {allScored ? 'Review my scores' : 'See full queue'}
+ {data.resultsPublished || allScored ? 'Review my scores' : 'See full queue'}
</Link>🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@app/judge/`[hackathonId]/page.tsx around lines 139 - 177, The card currently
shows scoring CTAs even when resultsPublished is true; update the rendering
logic to treat a published results state as finalized by gating any scoring
flows on !resultsPublished: change the title/description branches and the
Continue scoring Button so they only use firstUnscoredId and remaining queue
copy when resultsPublished is false (e.g., use conditions like !resultsPublished
&& firstUnscoredId before showing CTA text/button), and ensure when
resultsPublished is true the UI always shows review-only text (e.g., "Review my
scores" or final-state copy) instead of "Continue scoring" or remaining-queue
messaging; reference the variables/resultsPublished, firstUnscoredId, remaining,
and the Continue scoring Button/Link rendering to locate the changes.
| const judgingClosed = (() => { | ||
| if (!hackathonOverview?.judgingEnd) return false; | ||
| return getCountdown(hackathonOverview.judgingEnd).isPast; | ||
| })(); |
There was a problem hiding this comment.
Treat published results as read-only and block shortcut submits.
With partial publishing enabled, resultsPublished can flip to true before the deadline. This page only derives readOnly from judgingEnd, and submitOrError() is still reachable through Cmd/Ctrl+Enter, so judges can keep sending score mutations after finalization.
Suggested fix
- const judgingClosed = (() => {
+ const judgingClosed = (() => {
if (!hackathonOverview?.judgingEnd) return false;
return getCountdown(hackathonOverview.judgingEnd).isPast;
})();
@@
- const submitOrError = useCallback(() => {
+ const submitOrError = useCallback(() => {
+ if (readOnly) return;
+
const newErrors: Record<string, string | null> = {};
let valid = true;
@@
}, [
+ readOnly,
criteria,
scores,
comments,
@@
- const readOnly = judgingClosed;
+ const readOnly = judgingClosed || data.resultsPublished;
@@
<textarea
id='overall-comment'
value={overall}
onChange={e => setOverall(e.target.value)}
+ disabled={readOnly}
rows={3}
@@
<input
type='checkbox'
checked={autoAdvance}
onChange={e => setAutoAdvance(e.target.checked)}
+ disabled={readOnly}
className='accent-primary h-3.5 w-3.5'
/>Also applies to: 193-255, 274-277, 537-555, 567-590
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@app/judge/`[hackathonId]/submissions/[submissionId]/page.tsx around lines 77
- 80, The page currently computes judgingClosed using
hackathonOverview.judgingEnd but ignores hackathonOverview.resultsPublished,
allowing submitOrError to be invoked via the Cmd/Ctrl+Enter shortcut after
results are published; update the read-only logic to consider both the deadline
and publication by deriving readOnly = judgingClosed ||
!!hackathonOverview?.resultsPublished (or similar) where judgingClosed is
computed, then early-return/disable submit paths by checking readOnly before
invoking submitOrError and also guard the keyboard shortcut handler so it no-ops
when readOnly is true; ensure you update references to judgingClosed/readOnly
and the submitOrError call sites and the shortcut listener to use this combined
check.
| useEffect(() => { | ||
| if (!deadline) return; | ||
| const tick = () => setNow(Date.now()); | ||
| tick(); | ||
| // Tick every second under an hour for the live HH:MM:SS feel, | ||
| // otherwise every minute to keep this cheap. | ||
| const parts = getCountdown(deadline); | ||
| const intervalMs = parts.totalMs < 60 * 60 * 1000 ? 1000 : 60_000; | ||
| const id = window.setInterval(tick, intervalMs); | ||
| return () => window.clearInterval(id); | ||
| }, [deadline]); |
There was a problem hiding this comment.
Interval frequency doesn't update dynamically as deadline approaches.
The useEffect computes intervalMs once when the deadline changes, but doesn't update it as time passes. If the deadline starts 70 minutes away (1-minute ticking), it will continue with 1-minute ticks even after crossing the 1-hour threshold, until the component remounts or the deadline prop changes.
For judging portals where users may keep the tab open for extended periods, this means they won't see the switch to live 1-second updates when the deadline becomes imminent.
🔧 Proposed fix
Recalculate the interval duration on each tick:
useEffect(() => {
if (!deadline) return;
+ let id: number;
+
const tick = () => {
setNow(Date.now());
- };
- tick();
- // Tick every second under an hour for the live HH:MM:SS feel,
- // otherwise every minute to keep this cheap.
- const parts = getCountdown(deadline);
- const intervalMs = parts.totalMs < 60 * 60 * 1000 ? 1000 : 60_000;
- const id = window.setInterval(tick, intervalMs);
+
+ // Recalculate interval based on current time remaining
+ const parts = getCountdown(deadline);
+ const intervalMs = parts.totalMs < 60 * 60 * 1000 ? 1000 : 60_000;
+
+ if (id) window.clearInterval(id);
+ id = window.setInterval(tick, intervalMs);
+ };
+
+ tick(); // Initial tick
return () => window.clearInterval(id);
}, [deadline]);🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@components/judge/CountdownBanner.tsx` around lines 36 - 46, The effect in
CountdownBanner currently picks intervalMs once and never updates as time
passes; replace the setInterval approach with a self-scheduling timer that
recalculates getCountdown(deadline) on each tick: inside useEffect keep the tick
function that calls setNow(Date.now()), then compute parts =
getCountdown(deadline) and derive intervalMs from parts.totalMs, and use
window.setTimeout to schedule the next tick with that interval; ensure the
cleanup clears the active timeout id. This uses the existing tick, setNow and
getCountdown symbols and removes the fixed window.setInterval usage so the
frequency updates dynamically as the deadline approaches.
| function dayDiff(target: Date): number { | ||
| const ms = target.getTime() - Date.now(); | ||
| return Math.round(ms / (1000 * 60 * 60 * 24)); | ||
| } |
There was a problem hiding this comment.
Fix rounding logic for recently passed deadlines.
Using Math.round causes recently-passed deadlines (within ~12 hours) to display as "Ends today" instead of "overdue". For example, a deadline that passed 6 hours ago would compute days = Math.round(-0.25) = 0, showing "Ends today" rather than "overdue".
🔧 Proposed fix
Use Math.floor for consistent behavior:
function dayDiff(target: Date): number {
const ms = target.getTime() - Date.now();
- return Math.round(ms / (1000 * 60 * 60 * 24));
+ return Math.floor(ms / (1000 * 60 * 60 * 24));
}With Math.floor:
- 0.9 days left → 0 (shows "Ends today")
- -0.1 days past → -1 (shows "1 day overdue")
This ensures any past deadline immediately shows as overdue.
📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| function dayDiff(target: Date): number { | |
| const ms = target.getTime() - Date.now(); | |
| return Math.round(ms / (1000 * 60 * 60 * 24)); | |
| } | |
| function dayDiff(target: Date): number { | |
| const ms = target.getTime() - Date.now(); | |
| return Math.floor(ms / (1000 * 60 * 60 * 24)); | |
| } |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@components/judge/DeadlineBadge.tsx` around lines 13 - 16, The dayDiff
function currently uses Math.round which treats small negative offsets as 0
(e.g., -0.25 -> 0) causing recently-passed deadlines to display as "Ends today";
update the logic in dayDiff to use Math.floor on the computed days (i.e.,
replace Math.round with Math.floor) so any past deadline yields a negative
integer (e.g., -0.1 -> -1) and will correctly render as overdue.
| } else { | ||
| toast.error(res.message || 'Failed to disqualify submission'); | ||
| } | ||
| } catch (e: any) { |
There was a problem hiding this comment.
Replace any with unknown.
The catch clause uses any, which violates the project's TypeScript guidelines. As per coding guidelines, avoid using the any type and use proper types instead.
🔧 Proposed fix
- } catch (e: any) {
+ } catch (e: unknown) {
toast.error(e?.message || 'Failed to disqualify submission');And update the error access to be type-safe:
} catch (e: unknown) {
- toast.error(e?.message || 'Failed to disqualify submission');
+ toast.error(
+ e instanceof Error ? e.message : 'Failed to disqualify submission'
+ );📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| } catch (e: any) { | |
| } catch (e: unknown) { | |
| toast.error( | |
| e instanceof Error ? e.message : 'Failed to disqualify submission' | |
| ); |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@components/organization/cards/GradeSubmissionModal/index.tsx` at line 105, In
GradeSubmissionModal replace the catch parameter type from any to unknown and
make error handling type-safe: change the catch to catch (e: unknown) and inside
the catch branch narrow the type (for example using if (e instanceof Error) to
read e.message, else fall back to String(e) or a default message) where the code
currently reads the error; ensure any direct property access on the caught value
is guarded or cast after an instanceof/type check so TypeScript rules are
satisfied.
| const getCriterionKey = (criterion: JudgingCriterion) => { | ||
| return criterion.id || criterion.name || (criterion as any).title; | ||
| return criterion.id; | ||
| }; |
There was a problem hiding this comment.
Add fallback for undefined criterion.id to prevent runtime errors.
getCriterionKey now returns criterion.id unconditionally. If criterion.id is undefined (e.g., from legacy data or incomplete API responses), this will create undefined keys in the scores and comments objects at lines 57-59, 102-104, which breaks the scoring logic.
While transformToApiFormat (in lib/utils/hackathon-form-transforms.ts) now ensures new criteria have IDs, existing database records or API responses may still have criteria without an id field.
🛡️ Proposed fix to add defensive fallback
const getCriterionKey = (criterion: JudgingCriterion) => {
- return criterion.id;
+ return criterion.id ?? criterion.name ?? criterion.title ?? '';
};This restores the fallback logic while preferring id when available, ensuring backwards compatibility with legacy data.
📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| const getCriterionKey = (criterion: JudgingCriterion) => { | |
| return criterion.id || criterion.name || (criterion as any).title; | |
| return criterion.id; | |
| }; | |
| const getCriterionKey = (criterion: JudgingCriterion) => { | |
| return criterion.id ?? criterion.name ?? criterion.title ?? ''; | |
| }; |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@components/organization/cards/GradeSubmissionModal/useSubmissionScores.ts`
around lines 49 - 51, getCriterionKey currently returns criterion.id
unconditionally which can be undefined for legacy/incomplete data and will
produce undefined keys in the scores/comments maps (used around where scores and
comments are built and at transformToApiFormat usage). Update getCriterionKey to
return a safe fallback when criterion.id is falsy (e.g., prefer criterion.id but
fall back to a stable string such as `criterion.name`, `criterion.key`, or
generate a deterministic fallback like `criterion-${index}`) so that scores and
comments objects never contain undefined keys; touch the consumer code that
constructs scores/comments (the places referenced near lines 57-59 and 102-104)
to use getCriterionKey consistently. Ensure the fallback is deterministic and
unique per criterion so existing scoring logic and transformToApiFormat continue
to work predictably.
| variant='ghost' | ||
| size='sm' | ||
| className='text-red-300/80 hover:bg-red-400/10 hover:text-red-300' | ||
| onClick={() => onRemoveJudge(judge.userId ?? judge.id)} |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Check what the CurrentJudge.id field represents and how onRemoveJudge uses it
rg -B 5 -A 10 "onRemoveJudge.*=>" app/\(landing\)/organizations/Repository: boundlessfi/boundless
Length of output: 2182
🏁 Script executed:
# Find CurrentJudge interface definition
rg "interface CurrentJudge|type CurrentJudge" -A 10Repository: boundlessfi/boundless
Length of output: 1005
🏁 Script executed:
# Look at OrganizerJudgesPanel.tsx to understand the component context
rg "CurrentJudge" components/organization/hackathons/judging/OrganizerJudgesPanel.tsx -B 2 -A 2Repository: boundlessfi/boundless
Length of output: 289
🏁 Script executed:
# Check how judge.id is used elsewhere in the file
rg "judge\.id" components/organization/hackathons/judging/OrganizerJudgesPanel.tsx -B 2 -A 2Repository: boundlessfi/boundless
Length of output: 598
🏁 Script executed:
# Find where currentJudges is fetched/populated
rg "currentJudges|fetchJudges" components/organization/hackathons/judging/OrganizerJudgesPanel.tsx -B 3 -A 3Repository: boundlessfi/boundless
Length of output: 1161
🏁 Script executed:
# Find the setJudgeToRemove usage and how the value is used
rg "judgeToRemove|setJudgeToRemove" app/\(landing\)/organizations/ -B 2 -A 5Repository: boundlessfi/boundless
Length of output: 5234
🏁 Script executed:
# Check judge API types to understand id vs userId
rg "interface.*Judge|export.*Judge.*{" lib/api/judge.ts -A 8Repository: boundlessfi/boundless
Length of output: 195
🏁 Script executed:
# Find handleRemoveJudge function
rg "handleRemoveJudge.*=" app/\(landing\)/organizations/ -B 2 -A 10Repository: boundlessfi/boundless
Length of output: 1426
🏁 Script executed:
# Check judge API/types more carefully
cat lib/api/judge.tsRepository: boundlessfi/boundless
Length of output: 11436
🏁 Script executed:
# Find removeJudge function in judge API
rg "removeJudge|export.*remove" lib/api/judge.ts -B 2 -A 5Repository: boundlessfi/boundless
Length of output: 47
🏁 Script executed:
# Also check if removeJudge is in a hackathons subdirectory
rg "removeJudge|export.*remove" lib/api/hackathons/ -B 2 -A 5Repository: boundlessfi/boundless
Length of output: 1023
🏁 Script executed:
# Get the full removeJudge implementation
rg -A 8 "export const removeJudge = async" lib/api/hackathons/judging.tsRepository: boundlessfi/boundless
Length of output: 370
🏁 Script executed:
# Find where currentJudges is fetched to understand the data structure
rg "currentJudges.*=" app/\(landing\)/organizations/\[id\]/hackathons/\[hackathonId\]/judging/page.tsx -B 3 -A 3Repository: boundlessfi/boundless
Length of output: 1501
🏁 Script executed:
# Find fetchJudges function to see what API it calls and what data it returns
rg "fetchJudges.*=" app/\(landing\)/organizations/\[id\]/hackathons/\[hackathonId\]/judging/page.tsx -B 2 -A 10Repository: boundlessfi/boundless
Length of output: 487
🏁 Script executed:
# Check if there's a getJudges or listJudges API endpoint
rg "export.*Judges|getJudges|listJudges" lib/api/hackathons/judging.ts -A 5Repository: boundlessfi/boundless
Length of output: 307
🏁 Script executed:
# Get the full fetchJudges function to see how it populates judges
rg -A 30 "const fetchJudges = useCallback" app/\(landing\)/organizations/\[id\]/hackathons/\[hackathonId\]/judging/page.tsxRepository: boundlessfi/boundless
Length of output: 1039
🏁 Script executed:
# Check the API response type for getHackathonJudges - what fields does it return?
rg -B 5 -A 15 "export const getHackathonJudges" lib/api/hackathons/judging.tsRepository: boundlessfi/boundless
Length of output: 586
🏁 Script executed:
# Get more of the fetchJudges function to see how judges are populated and what fields they have
rg -A 60 "const fetchJudges = useCallback" app/\(landing\)/organizations/\[id\]/hackathons/\[hackathonId\]/judging/page.tsx | head -80Repository: boundlessfi/boundless
Length of output: 1962
🏁 Script executed:
# Look at the full getHackathonJudges API call in context
cat lib/api/hackathons/judging.ts | grep -A 20 "export const getHackathonJudges"Repository: boundlessfi/boundless
Length of output: 600
🏁 Script executed:
# Search for example judge data or comments about the judges endpoint structure
rg "judging/judges" --type ts --type tsx -B 5 -A 5Repository: boundlessfi/boundless
Length of output: 93
🏁 Script executed:
# Look at how judges are used after being fetched in the parent component
rg -A 40 "judges = judgesRes.data" app/\(landing\)/organizations/\[id\]/hackathons/\[hackathonId\]/judging/page.tsxRepository: boundlessfi/boundless
Length of output: 1300
🏁 Script executed:
# Search for backend API documentation or comments about the judges endpoint
rg "judges" lib/api/hackathons/judging.ts -B 10 -A 10Repository: boundlessfi/boundless
Length of output: 2937
🏁 Script executed:
# Check if there are any TypeScript types defined for the judges response
rg "Judge|judge" lib/api/hackathons/judging.ts | head -40Repository: boundlessfi/boundless
Length of output: 1559
Use judge.userId for removal, or ensure it's always defined.
The removeJudge API explicitly expects a userId parameter (sent via /judging/judges/${userId}), but the code falls back to judge.id when userId is undefined. Without type definitions for the judges endpoint response, there's no guarantee that id represents a user ID. If id is a judge assignment record ID, this fallback will cause the removal to fail. Either ensure userId is always populated from the backend, or handle the missing userId case explicitly.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@components/organization/hackathons/judging/OrganizerJudgesPanel.tsx` at line
200, The current removal call falls back to judge.id which may be an assignment
ID, causing removeJudge (which expects a userId used in
/judging/judges/${userId}) to fail; update the code to only use judge.userId
(e.g. call onRemoveJudge(judge.userId)) and/or add an explicit guard in the
onRemoveJudge handler to validate presence of userId (reject, show an
error/toast, or disable the remove button) rather than using judge.id; locate
the usage in OrganizerJudgesPanel.tsx (onRemoveJudge, the remove button onClick)
and the removeJudge API call to implement the guard/validation so removal always
supplies a true userId.
One semantic conflict: components/auth/SignupForm.tsx had both branches adding email prefill logic for the same use case (invitation deep links landing on signup). Main added a defaultEmail prop; this branch read ?email from URL params. Resolution keeps both: prefer the prop, fall back to the URL, so callers that wire it through and bare deep links both work. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(submissions): improve link validation UX, banner display, and error toasts (#547) - Reconcile frontend link types with backend enum (github, demo, video, document, presentation, other) - Allow up to 5 "other" links per submission with smart type-picking on Add Link and duplicate guards on type change - Add 500-character cap and live counter on the optional Introduction field - Pass banner through initialData on submission edit so the saved banner displays - Add banner field to ParticipantSubmission so the type compiles - Set mock-fill participation type from myTeam to avoid INDIVIDUAL submissions while on a team - Switch submission error toasts to title + description with 8s duration so backend messages are readable - Update SubmissionLinksTab icon mapping for the new link types * fix(submissions): correct logo cropping and remove duplicate category badge on submission card (#549) - Logo overlay on banner uses object-contain so wordmark logos are not cropped - Render the category badge once instead of twice on the explore submissions card * feat(blog): add Boundless x Trustless Work hackathon post and fit cover images (#551) - Add content/blog/boundless-launches-first-hackathon-campaign.mdx for the May 13 hackathon launch announcement. - BlogCard and BlogPostDetails cover images use object-contain so the full banner is visible (previously object-cover cropped wider banners). - Update SHARE icons on the blog details page from the old brand color #99FF2D to the current brand primary #2eedaa. * feat(judge): dedicated judge portal, organizer invitations, completeness gate UX (#550) * feat: implement judge portal with submission evaluation, invitations, and score calculation workflows * feat: add countdown banner, support read-only score sliders, and integrate submission disqualification into grading modal --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Nnaji Benjamin <60315147+Benjtalkshow@users.noreply.github.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
) * fix(submissions): improve link validation UX, banner display, and error toasts (#547) - Reconcile frontend link types with backend enum (github, demo, video, document, presentation, other) - Allow up to 5 "other" links per submission with smart type-picking on Add Link and duplicate guards on type change - Add 500-character cap and live counter on the optional Introduction field - Pass banner through initialData on submission edit so the saved banner displays - Add banner field to ParticipantSubmission so the type compiles - Set mock-fill participation type from myTeam to avoid INDIVIDUAL submissions while on a team - Switch submission error toasts to title + description with 8s duration so backend messages are readable - Update SubmissionLinksTab icon mapping for the new link types * fix(submissions): correct logo cropping and remove duplicate category badge on submission card (#549) - Logo overlay on banner uses object-contain so wordmark logos are not cropped - Render the category badge once instead of twice on the explore submissions card * feat(blog): add Boundless x Trustless Work hackathon post and fit cover images (#551) - Add content/blog/boundless-launches-first-hackathon-campaign.mdx for the May 13 hackathon launch announcement. - BlogCard and BlogPostDetails cover images use object-contain so the full banner is visible (previously object-cover cropped wider banners). - Update SHARE icons on the blog details page from the old brand color #99FF2D to the current brand primary #2eedaa. * feat(judge): dedicated judge portal, organizer invitations, completeness gate UX (#550) * feat: implement judge portal with submission evaluation, invitations, and score calculation workflows * feat: add countdown banner, support read-only score sliders, and integrate submission disqualification into grading modal --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(hackathons): add "hidden until results" submission visibility mode (#555) Surfaces the new HIDDEN_UNTIL_RESULTS option (added in the nestjs PR) in the organizer settings tab. Reorders the three visibility options so the recommended "Shortlisted only" leads, the new "Hidden until results are announced" sits in the middle, and "All submissions" comes last. Rewrites the copy on the "All submissions" choice that incorrectly claimed disqualified projects would be shown -- they never were on the backend, and Phase 2 makes that an explicit guarantee. Aligns the form's default and API-fallback value with the backend default (ACCEPTED_SHORTLISTED, not ALL) so organizers don't see a misleading initial selection. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Nnaji Benjamin <60315147+Benjtalkshow@users.noreply.github.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(submissions): improve link validation UX, banner display, and error toasts (#547) - Reconcile frontend link types with backend enum (github, demo, video, document, presentation, other) - Allow up to 5 "other" links per submission with smart type-picking on Add Link and duplicate guards on type change - Add 500-character cap and live counter on the optional Introduction field - Pass banner through initialData on submission edit so the saved banner displays - Add banner field to ParticipantSubmission so the type compiles - Set mock-fill participation type from myTeam to avoid INDIVIDUAL submissions while on a team - Switch submission error toasts to title + description with 8s duration so backend messages are readable - Update SubmissionLinksTab icon mapping for the new link types * fix(submissions): correct logo cropping and remove duplicate category badge on submission card (#549) - Logo overlay on banner uses object-contain so wordmark logos are not cropped - Render the category badge once instead of twice on the explore submissions card * feat(blog): add Boundless x Trustless Work hackathon post and fit cover images (#551) - Add content/blog/boundless-launches-first-hackathon-campaign.mdx for the May 13 hackathon launch announcement. - BlogCard and BlogPostDetails cover images use object-contain so the full banner is visible (previously object-cover cropped wider banners). - Update SHARE icons on the blog details page from the old brand color #99FF2D to the current brand primary #2eedaa. * feat(judge): dedicated judge portal, organizer invitations, completeness gate UX (#550) * feat: implement judge portal with submission evaluation, invitations, and score calculation workflows * feat: add countdown banner, support read-only score sliders, and integrate submission disqualification into grading modal --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(hackathons): add "hidden until results" submission visibility mode (#555) Surfaces the new HIDDEN_UNTIL_RESULTS option (added in the nestjs PR) in the organizer settings tab. Reorders the three visibility options so the recommended "Shortlisted only" leads, the new "Hidden until results are announced" sits in the middle, and "All submissions" comes last. Rewrites the copy on the "All submissions" choice that incorrectly claimed disqualified projects would be shown -- they never were on the backend, and Phase 2 makes that an explicit guarantee. Aligns the form's default and API-fallback value with the backend default (ACCEPTED_SHORTLISTED, not ALL) so organizers don't see a misleading initial selection. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(hackathons): owner submission visibility, team UX, organizer teams page (#552) Submission visibility on the public submissions tab - Surface the user's own submission first on the hackathon submissions tab, regardless of submissionStatusVisibility setting. Team members (non-leader) see their team's submission too. Find Team tab UX - Stop replacing the whole tab with MyTeamView when the user has a team. Render MyTeamView at the top and the open-teams list below. - Filter the user's own team out of the "Other Teams" list to avoid duplication. - Wire the team count badge on the team-formation tab. Team details - New /hackathons/[slug]/teams/[teamId] page with team header, members list (clickable links to profiles), roles needed, leader card, and contact card. - TeamCard click opens the team details page in a new tab. - MyTeamView team name is now a clickable link to the same page. - Add useTeam hook + query key. Organizer submissions page - Replace raw Buttons on review actions with BoundlessButton (default / outline / destructive) so colors come from the design system. - Remove the "Before Deadline" gating on the Approve action; backend organizer override now allows it. Organizer participants page - Add Solo Participants and Teams metric cards next to the existing Total Participants and Total Submissions cards. Organizer submissions page - Add a metric strip with Total / Solo / Team Submissions sourced from the statistics endpoint. Organizer Teams page (new) - New route /organizations/[id]/hackathons/[hackathonId]/teams. - Stats (Total / Open / Submitted / Not submitted), search by team or leader (server-side), status and submission filters (client-side), server-side pagination matching the participants page pattern, click to view team details in a new tab. - Add "Teams" entry to the existing organizer hackathon sidebar (HackathonSidebar.tsx). Image fit fixes for cropped logos/banners - SubmissionCard banner and logo overlay use object-contain (banner was object-cover, cropping wordmarks and tall banners). - /me/hackathons/submissions: banner, logo overlay, and small table thumbnail all use object-contain. - ProjectSidebarHeader logo uses object-contain. Type changes - lib/api/hackathons/teams.ts Team interface gains optional hasSubmission flag. --------- Co-authored-by: Collins Ikechukwu <collinschristroa@gmail.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(submissions): improve link validation UX, banner display, and error toasts (#547) - Reconcile frontend link types with backend enum (github, demo, video, document, presentation, other) - Allow up to 5 "other" links per submission with smart type-picking on Add Link and duplicate guards on type change - Add 500-character cap and live counter on the optional Introduction field - Pass banner through initialData on submission edit so the saved banner displays - Add banner field to ParticipantSubmission so the type compiles - Set mock-fill participation type from myTeam to avoid INDIVIDUAL submissions while on a team - Switch submission error toasts to title + description with 8s duration so backend messages are readable - Update SubmissionLinksTab icon mapping for the new link types * fix(submissions): correct logo cropping and remove duplicate category badge on submission card (#549) - Logo overlay on banner uses object-contain so wordmark logos are not cropped - Render the category badge once instead of twice on the explore submissions card * feat(blog): add Boundless x Trustless Work hackathon post and fit cover images (#551) - Add content/blog/boundless-launches-first-hackathon-campaign.mdx for the May 13 hackathon launch announcement. - BlogCard and BlogPostDetails cover images use object-contain so the full banner is visible (previously object-cover cropped wider banners). - Update SHARE icons on the blog details page from the old brand color #99FF2D to the current brand primary #2eedaa. * feat(judge): dedicated judge portal, organizer invitations, completeness gate UX (#550) * feat: implement judge portal with submission evaluation, invitations, and score calculation workflows * feat: add countdown banner, support read-only score sliders, and integrate submission disqualification into grading modal --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(hackathons): add "hidden until results" submission visibility mode (#555) Surfaces the new HIDDEN_UNTIL_RESULTS option (added in the nestjs PR) in the organizer settings tab. Reorders the three visibility options so the recommended "Shortlisted only" leads, the new "Hidden until results are announced" sits in the middle, and "All submissions" comes last. Rewrites the copy on the "All submissions" choice that incorrectly claimed disqualified projects would be shown -- they never were on the backend, and Phase 2 makes that an explicit guarantee. Aligns the form's default and API-fallback value with the backend default (ACCEPTED_SHORTLISTED, not ALL) so organizers don't see a misleading initial selection. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(hackathons): owner submission visibility, team UX, organizer teams page (#552) Submission visibility on the public submissions tab - Surface the user's own submission first on the hackathon submissions tab, regardless of submissionStatusVisibility setting. Team members (non-leader) see their team's submission too. Find Team tab UX - Stop replacing the whole tab with MyTeamView when the user has a team. Render MyTeamView at the top and the open-teams list below. - Filter the user's own team out of the "Other Teams" list to avoid duplication. - Wire the team count badge on the team-formation tab. Team details - New /hackathons/[slug]/teams/[teamId] page with team header, members list (clickable links to profiles), roles needed, leader card, and contact card. - TeamCard click opens the team details page in a new tab. - MyTeamView team name is now a clickable link to the same page. - Add useTeam hook + query key. Organizer submissions page - Replace raw Buttons on review actions with BoundlessButton (default / outline / destructive) so colors come from the design system. - Remove the "Before Deadline" gating on the Approve action; backend organizer override now allows it. Organizer participants page - Add Solo Participants and Teams metric cards next to the existing Total Participants and Total Submissions cards. Organizer submissions page - Add a metric strip with Total / Solo / Team Submissions sourced from the statistics endpoint. Organizer Teams page (new) - New route /organizations/[id]/hackathons/[hackathonId]/teams. - Stats (Total / Open / Submitted / Not submitted), search by team or leader (server-side), status and submission filters (client-side), server-side pagination matching the participants page pattern, click to view team details in a new tab. - Add "Teams" entry to the existing organizer hackathon sidebar (HackathonSidebar.tsx). Image fit fixes for cropped logos/banners - SubmissionCard banner and logo overlay use object-contain (banner was object-cover, cropping wordmarks and tall banners). - /me/hackathons/submissions: banner, logo overlay, and small table thumbnail all use object-contain. - ProjectSidebarHeader logo uses object-contain. Type changes - lib/api/hackathons/teams.ts Team interface gains optional hasSubmission flag. * fix(blog): use object-cover so card and detail covers fill the frame (#558) The earlier switch to object-contain was meant to preserve the full image, but it left dark letterbox bands around banners on both the BlogCard grid and the post-details hero. With properly-sized cover banners, object-cover fills the frame without visible cropping. Authors should size cover images for a 2:1 ratio on the card and the responsive heights on the details page. --------- Co-authored-by: Collins Ikechukwu <collinschristroa@gmail.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Frontend half of the judging overhaul. Pairs with backend PR boundlessfi/boundless-nestjs#103 — that one must deploy first.
What ships
New judge portal at `/judge/*`
A separate top-level surface so judges never touch the org dashboard:
Shared judge components
`ProgressRing`, `ScoreSlider` (with `disabled` prop for post-deadline), `DeadlineBadge`, `KeyboardShortcuts`, `HackathonBanner`, `CountdownBanner` (live ticker, hides when far out, switches to closed state past deadline).
Organizer judges panel
`OrganizerJudgesPanel` replaces the legacy "Add from Organization Members" picker. Email-invite flow + pending invitations list with resend (rotates token) / cancel + history disclosure + status pills. React Query–backed; polls every 60s, fires a parent refresh when an invitation transitions to `ACCEPTED`.
Publish dialog with completeness preview
`GET /judging/completeness` fetched on dialog open; lists incomplete judges with outstanding counts. The Publish button stays disabled until the organizer ticks "I understand the results will be published with incomplete judging," at which point the request sends `acceptPartial: true`.
Conflict-of-role gating
`ActionButtons` reads `viewerRole` off the hackathon detail response and shows "You are managing this hackathon" / "You are judging this hackathon" instead of the Join button.
Legacy modal cleanup
`GradeSubmissionModal` math fix: previously displayed `/100` totals only made sense when weights summed to 100; now uses a true weighted average with `/10` display, and a "% weight" badge computed from the actual proportion. Disqualify link added in organizer-override mode, reusing the existing `DisqualifyDialog`.
Signup roundtrip from invitations
`SignupForm` accepts a prefilled email + `callbackUrl` from query params. `/auth/check-email` stashes the destination in localStorage (Better Auth's verification email link doesn't preserve query params); `/auth/verify-email` honors it after a successful verify.
Uniform email shell
New `renderEmailHtml` / `renderEmailText` / `renderPrimaryButton` in the backend; every transactional email this PR's flows touch ships through that shell. Mint button, single uniform footer with the support address and dynamic copyright year.
Deploy ordering
If frontend lands first, the new `/judge/*` pages 404 and the organizer publish dialog errors when it tries to fetch completeness.
What's safe even out of order
Tests
Typecheck clean on every touched judge file. Manual smoke test of the invite → accept → score → publish flow on staging is the recommended 15-minute pre-promote sanity check.
🤖 Generated with Claude Code
Summary by CodeRabbit
New Features
Improvements
UX