Skip to content

Comments

feat: enhance profile submissions and solutions list UI#143

Merged
aviralsaxena16 merged 1 commit intoOpenLake:mainfrom
hansikareddy29:feature/past-contests-submission
Feb 16, 2026
Merged

feat: enhance profile submissions and solutions list UI#143
aviralsaxena16 merged 1 commit intoOpenLake:mainfrom
hansikareddy29:feature/past-contests-submission

Conversation

@hansikareddy29
Copy link
Contributor

@hansikareddy29 hansikareddy29 commented Feb 16, 2026

feat: Profile Submissions, Enhanced Solutions List & Codeforces Verification

✨ Changes Introduced

  • Added: Recent Submissions section to the user profile, displaying the last 50 submissions with links to problems.
  • Added: "Verify with Codeforces" button to the submission modal, allowing users to verify past contest submissions directly via the Codeforces API.
  • Added: Interactive Upvoting System in the Solutions List with optimistic UI updates.
  • Added: Solutions List UI to always pin the current user's solution to the top, separated from others by a clear visual gap.
  • Fixed: Upvote Permissions handling by implementing a graceful client-side fallback (with informative error toasts) when server-side permissions are restricted.

🤔 Why This Change?

  • Problem:
    • Users had no way to track their recent activity or easily find their own solutions in a crowded list.
    • There was no way to verify submissions for past contests if the user had already solved them on Codeforces.
    • The upvoting feature was broken due to missing server-side admin privileges.
  • Solution:
    • Implemented a comprehensive profile history view.
    • Integrated a verification API for Codeforces submissions.
    • Refactored the Solutions List to prioritize the user's content and fix upvoting via client-side logic.
  • Impact:
    • Significantly improves user engagement by allowing them to track progress and showcase their solutions.
    • Streamlines the verification process for past contests.

🎥 Loom Video Walkthrough

[Watch the walkthrough on Loom](Screencast from 2026-02-16 23-28-12.webm)

🧪 Testing

  • Tested manually:
    • Profile: Verified that the last 50 submissions load correctly and links redirect to the problem page.
    • Solutions List: Confirmed that my solution appears at the very top, followed by other solutions sorted by upvotes.
    • Upvoting: Verified that clicking upvote updates the count immediately (optimistic UI) and persists (or shows error if unauthorized).
    • Verification: Tested the "Verify with Codeforces" button with a valid submission to ensure it awards credit.
  • Verified no console warnings/errors related to the new features.

📝 Documentation Updates

  • Updated README/docs (N/A for this UI/Feature update)
  • Added code comments where necessary for complex logic (e.g., solution sorting).

✅ Checklist

  • Created a new branch for PR
  • Follows JavaScript Styleguide
  • No console warnings/errors
  • Commit messages follow Git Guidelines
  • Self-reviewed my code
  • Tested manually
  • Code follows project conventions
  • No breaking changes

Summary by CodeRabbit

  • New Features

    • Profile now displays recent contest submissions with language badges and verdict status.
    • Added "View Solutions" button on contest problems to explore community solutions.
    • Solutions modal enables upvoting and viewing submitted code directly.
  • Improvements

    • Enhanced Codeforces verification search scope.
    • Improved visual styling for submissions cards, badges, and interactive modals.

@vercel
Copy link

vercel bot commented Feb 16, 2026

@hansikareddy29 is attempting to deploy a commit to the aviralsaxena16's projects Team on Vercel.

A member of the Team first needs to authorize it.

@github-actions
Copy link

🎉 Thanks for Your Contribution to CanonForces! ☺️

We'll review it as soon as possible. In the meantime, please:

  • ✅ Double-check the file changes.
  • ✅ Ensure that all commits are clean and meaningful.
  • ✅ Link the PR to its related issue (e.g., Closes #123).
  • ✅ Resolve any unaddressed review comments promptly.

💬 Need help or want faster feedback?
Join our Discord 👉 CanonForces Discord

Thanks again for contributing 🙌 – @hansikareddy29!
cc: @aviralsaxena16

@coderabbitai
Copy link

coderabbitai bot commented Feb 16, 2026

Walkthrough

This PR introduces a solutions browsing and upvoting feature for contest problems, displays recent submissions on user profiles, and enhances verification logic for Codeforces submissions. Changes span frontend components, CSS styling, and backend API routes to support fetching, displaying, and interacting with contest submission data.

Changes

Cohort / File(s) Summary
Profile Recent Submissions
src/common/components/Profile/Profile.tsx, src/common/components/Profile/Profile.module.css
Added Firestore query to fetch recent contest submissions (up to 50), rendered as styled submission cards with problem links, dates, language badges, and verdict indicators. Updated CSS for scrollable submission container with card styling, hover effects, and badge styling.
Solutions Modal & Display
src/components/contests/SolutionsListModal.tsx, src/components/contests/SolutionsListModal.module.css
New React component that fetches all solutions for a contest problem from Firestore, displays them in a table, supports Monaco Editor code viewing, handles upvoting with optimistic UI updates, and manages loading/error states. Comprehensive CSS styling for modal overlay, table with sticky headers, solution cards, and interactive elements.
Contest Problems Integration
src/components/contests/ContestProblems.tsx
Integrated Solutions modal with new isSolutionsModalOpen state and handlers to open/close the modal when users click "View Solutions" button per problem card.
Solution Submit Modal Enhancements
src/components/contests/SolutionSubmitModal.tsx, src/components/contests/SolutionSubmitModal.module.css
Added .trim() to Codeforces handle validation and new .verifyButton styling (gradient background, hover effects, disabled state).
Backend API Routes
src/pages/api/upvote.ts, src/pages/api/verify-codeforces.ts, src/pages/api/submit-solution.ts
Created new upvote API endpoint to increment/decrement upvotes and manage upvotedBy array. Increased Codeforces API limit from 50 to 2000 and refactored submission matching logic. Added Firebase Admin initialization guard to submit-solution endpoint.

Sequence Diagram(s)

sequenceDiagram
    participant User
    participant Browser as SolutionsListModal
    participant Firestore
    participant API as Upvote API
    
    User->>Browser: Opens View Solutions
    Browser->>Firestore: Fetch solutions for problem
    Firestore-->>Browser: Return solutions list
    Browser->>Browser: Sort by upvotes, user first
    Browser-->>User: Display solutions table
    
    User->>Browser: Click upvote button
    Browser->>Browser: Update UI optimistically
    Browser->>API: POST /api/upvote
    API->>Firestore: Update upvotes & upvotedBy
    Firestore-->>API: Confirm update
    API-->>Browser: Return 200 success
    
    alt Upvote fails
        API-->>Browser: Return 500 error
        Browser->>Browser: Revert optimistic update
        Browser-->>User: Show error toast
    end
Loading
sequenceDiagram
    participant User
    participant Profile as Profile Component
    participant Firestore
    
    User->>Profile: Load profile page
    Profile->>Firestore: Query recent submissions (userId, limit 50)
    Firestore-->>Profile: Return ContestSubmission[]
    Profile->>Profile: Fallback sort if index unavailable
    Profile-->>User: Render submission cards with links & badges
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Suggested reviewers

  • Jagath-P
  • aviralsaxena16

Poem

🐰 A Hare's Ode to Solutions

Hoppy hoppy, submissions bound,
Solutions blooming all around!
Upvote hearts and code to see,
Firestore queries flowing free,
Recent work on profiles bright! 🌟

🚥 Pre-merge checks | ✅ 2 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Merge Conflict Detection ⚠️ Warning ❌ Merge conflicts detected (7 files):

⚔️ src/common/components/Profile/Profile.module.css (content)
⚔️ src/common/components/Profile/Profile.tsx (content)
⚔️ src/components/contests/ContestProblems.tsx (content)
⚔️ src/components/contests/SolutionSubmitModal.module.css (content)
⚔️ src/components/contests/SolutionSubmitModal.tsx (content)
⚔️ src/pages/api/submit-solution.ts (content)
⚔️ src/pages/api/verify-codeforces.ts (content)

These conflicts must be resolved before merging into main.
Resolve conflicts locally and push changes to this branch.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title 'feat: enhance profile submissions and solutions list UI' directly describes the main changes introduced—enhancing UI for profile submissions and the solutions list—which aligns with the changeset's primary focus on UI additions and improvements.
Description check ✅ Passed The PR description comprehensively covers all required sections: Changes Introduced (with Added/Fixed items), Why This Change (Problem/Solution/Impact), Testing (manual tests documented), Checklist (most items completed), and includes a Loom video walkthrough, successfully following the repository's template structure.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
⚔️ Resolve merge conflicts (beta)
  • Auto-commit resolved conflicts to branch feature/past-contests-submission
  • Post resolved changes as copyable diffs in a comment

Tip

Issue Planner is now in beta. Read the docs and try it out! Share your feedback on Discord.


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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 10

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/components/contests/SolutionSubmitModal.module.css (1)

319-322: ⚠️ Potential issue | 🟡 Minor

.verifyButton is missing from the mobile responsive rules.

On viewports ≤ 768 px the footer switches to flex-direction: column-reverse and both .cancelButton and .submitButton get width: 100%, but .verifyButton is excluded. This will leave the verify button at its intrinsic width while the other two span the full footer, creating a visually inconsistent layout on mobile. Additionally, margin-right: auto (line 243) has no effect in a column flex direction.

Suggested fix
     .cancelButton,
-    .submitButton {
+    .submitButton,
+    .verifyButton {
         width: 100%;
     }
+
+    .verifyButton {
+        margin-right: 0;
+    }
🤖 Fix all issues with AI agents
In `@src/common/components/Profile/Profile.tsx`:
- Around line 666-703: The submission card rendering assumes a `_`-delimited
problemId and hardcodes the verdict; fix both: in the submissions.map block use
a robust extraction for the problem index from sub.problemId (e.g. check if
sub.problemIndex exists and fall back to parsing only when an underscore is
present, or validate split result before using it) so problemLink is only built
when a valid index is available, and replace the hardcoded
"Accepted"/HiCheckCircle in the verdict badge with the real verdict from the
submission object (e.g. use sub.verdict to choose icon/text or filter
submissions earlier if you only want Accepted shown). Ensure you update
references in this render flow (sub.problemId, sub.problemIndex, problemLink,
and the verdict badge where HiCheckCircle is used).
- Around line 103-141: The fallback query inside useEffect's fetchSubmissions
uses qFallback with limit(50), which can return an arbitrary 50 docs (by
document ID) so after in-memory sorting you may miss the actual 50 most-recent
submissions; change the qFallback to remove the limit (or increase it to a much
larger value) when building the query against
collection("contest_submissions")/where("userId","==", user.docId) so you fetch
a superset, then perform the in-memory sort on submittedAt and slice(0,50)
before calling setSubmissions; alternatively ensure the required composite index
is created so the primary q with orderBy("submittedAt","desc") succeeds and the
fallback is never used.

In `@src/components/contests/SolutionsListModal.module.css`:
- Around line 18-26: Rename the keyframe identifiers `@keyframes` fadeIn and
`@keyframes` slideUp to kebab-case (`@keyframes` fade-in and `@keyframes` slide-up)
and update all references to those animations (e.g., any animation or
animation-name declarations that currently use "fadeIn" or "slideUp") to use
"fade-in" and "slide-up" respectively; ensure both the keyframe blocks
(previously named fadeIn/slideUp) and all places where they are applied
(animation, animation-name, or shorthand) are updated to the new kebab-case
names.
- Around line 199-210: The .codeViewer CSS rule uses quoted font names causing
stylelint violations; update the font-family declaration in the .codeViewer
selector to remove quotes from single-word family names (Consolas, Monaco) while
keeping quotes for the multi-word family ("Courier New") so it conforms to the
font-family-name-quotes rule.

In `@src/components/contests/SolutionsListModal.tsx`:
- Around line 103-106: The forEach callbacks in SolutionsListModal.tsx are
implicitly returning the Map because you used concise arrow bodies; change both
callbacks to use block bodies so they don't return a value (e.g., replace
mainSolutions.forEach(s => allSolutionsMap.set(s.id, s)) and
userSolutions.forEach((s: Solution) => allSolutionsMap.set(s.id, s)) with
mainSolutions.forEach(s => { allSolutionsMap.set(s.id, s); }) and
userSolutions.forEach((s: Solution) => { allSolutionsMap.set(s.id, s); }) to
satisfy the useIterableCallbackReturn lint rule.
- Around line 42-154: The useEffect that calls fetchSolutions currently depends
on the whole user object which can be unstable; update the dependency array for
that useEffect to use the stable primitive user?.uid instead of user (keep
contest.contestId and problem.problemId), i.e. change the dependency list
referenced in the useEffect surrounding fetchSolutions to [contest.contestId,
problem.problemId, user?.uid] and adjust any closure usage if needed to
reference userUid (or user?.uid) inside fetchSolutions when checking
ownership/upvotes.
- Around line 194-206: The client currently performs Firestore writes directly
in handleUpvote by calling updateDoc (and using
increment/arrayUnion/arrayRemove), which bypasses the server-side upvote logic
in the /api/upvote route; replace the direct updateDoc calls inside handleUpvote
with a fetch POST to "/api/upvote" (send solutionId and desired action or
isUpvoted), await the response, and only update local state after a successful
response (or implement optimistic update with rollback on error); remove or
comment out the updateDoc branches (isUpvoted true/false) and ensure the request
includes the user's auth token/session so the server can perform the
authoritative Firestore update and validations.

In `@src/pages/api/upvote.ts`:
- Around line 5-41: The handler currently trusts the request body userId;
extract the Firebase ID token from the Authorization header (expecting "Bearer
<token>"), verify it with the Firebase Admin SDK (use admin.auth().verifyIdToken
or equivalent), and confirm decodedToken.uid === userId before proceeding; if
verification fails or UIDs don't match, return a 401/403 response. Update the
handler function that uses adminDb and solutionRef to perform this token check
early (before modifying upvotes) and log/handle token verification errors
appropriately.
- Around line 23-32: The upvote logic can desync because FieldValue.increment(1)
is not idempotent while FieldValue.arrayUnion(userId) is; change to a Firestore
transaction that reads the document via solutionRef, checks whether userId is
already present in the upvotedBy array, and then either adds/removes userId and
sets upvotes to the new upvotedBy.length (or increments/decrements only when
membership changes), ensuring you never decrement below zero; update the
handlers that currently call FieldValue.increment/arrayUnion/arrayRemove so they
use the transaction and atomic reads/writes on solutionRef, upvotedBy and
upvotes to keep them consistent.

In `@src/pages/api/verify-codeforces.ts`:
- Line 29: The apiUrl construction interpolates the raw handle into the
Codeforces URL which can create malformed requests for handles with special
characters; update the code that builds apiUrl (the apiUrl constant in
src/pages/api/verify-codeforces.ts, where handle is used) to pass handle through
encodeURIComponent(handle) before interpolation so the query string is properly
escaped, ensuring the handler function uses the encoded value for the fetch
request.
🧹 Nitpick comments (9)
src/pages/api/submit-solution.ts (1)

54-56: Good defensive guard, but status code is inconsistent with upvote.ts.

This endpoint returns 503 when adminDb is uninitialized, while src/pages/api/upvote.ts (line 17) returns 500 for the same condition. Consider aligning on 503 (Service Unavailable) across both endpoints, since the root cause is a missing server-side configuration, not an unexpected runtime failure.

src/pages/api/verify-codeforces.ts (1)

29-29: Fetching 2000 submissions per verification request may be expensive.

Each Codeforces submission object can be sizable. Fetching 2000 of them on every verification call could cause slow responses and high memory usage on the server, especially under concurrent load. Consider whether a smaller count (e.g., 500) with pagination or a more targeted Codeforces API endpoint (e.g., contest.status filtered by handle) would work. The contest.status API allows filtering by handle and contestId directly, which would be far more efficient.

src/components/contests/ContestProblems.tsx (2)

38-46: Shared selectedProblem state can conflict between modals.

Both handleViewSolutions and handleSubmitSolution write to the same selectedProblem state. When either modal is closed (handleCloseSubmitModal or handleCloseSolutionsModal), selectedProblem is set to null, which would also affect the other modal if it were somehow still referencing it. More importantly, if a future change opens both modals or if event ordering is unexpected, the shared state leads to subtle bugs.

Consider using separate state variables (e.g., selectedProblemForSubmit and selectedProblemForSolutions) or guarding with the respective modal-open boolean.


89-95: Prefer CSS module class over inline styles.

The "View Solutions" button uses styles.viewButton but overrides it heavily with inline style. This makes future theming/maintenance harder and is inconsistent with the rest of the file. Consider defining a dedicated CSS class (e.g., .viewSolutionsButton) in ContestProblems.module.css.

src/pages/api/upvote.ts (1)

28-32: Indentation is inconsistent on line 29.

The await solutionRef.update({ on line 29 is indented with extra spaces compared to the equivalent block on line 24.

Fix indentation
         } else if (action === 'downvote') {
-                await solutionRef.update({
+            await solutionRef.update({
                 upvotes: FieldValue.increment(-1),
                 upvotedBy: FieldValue.arrayRemove(userId)
             });
src/common/components/Profile/Profile.module.css (1)

470-484: Hardcoded green left border assumes all submissions are "Accepted".

border-left: 4px solid #10b981`` is always green. If you later show non-accepted submissions, you'll need a variant class (similar to .verdictSuccess / `.verdictFail` in the SolutionsListModal CSS). The comment on line 476 acknowledges this — just flagging to keep on the radar.

src/components/contests/SolutionsListModal.tsx (3)

120-133: N+1 username resolution — individual getDoc per user.

Each unique userId triggers a separate Firestore read. With up to 50 solutions, this can mean up to 50 round-trips. Firestore's client SDK doesn't support batched reads (getAll), but you can mitigate this by:

  • Caching usernames in a component-level or app-level cache (e.g., React context or a module-scoped Map) so repeat visits don't re-fetch.
  • Denormalizing the username onto the submission document at write time, which eliminates these reads entirely.

254-365: Extract the IIFE render block into a named function or sub-component.

The immediately-invoked function expression spanning 110+ lines inside JSX is hard to follow. Consider extracting renderSolutionsTable, userSolution, and otherSolutions into the component body (or a child component) to improve readability.


258-258: Unused showHeader parameter.

renderSolutionsTable declares showHeader: boolean = true but never uses it, and all call sites rely on the default. Remove it to avoid confusion.

Proposed fix
-                                const renderSolutionsTable = (sols: Solution[], showHeader: boolean = true) => (
+                                const renderSolutionsTable = (sols: Solution[]) => (

Comment on lines +103 to +141
useEffect(() => {
if (user?.docId) {
const fetchSubmissions = async () => {
try {
const q = query(
collection(db, "contest_submissions"),
where("userId", "==", user.docId),
orderBy("submittedAt", "desc"),
limit(50)
);

try {
const querySnapshot = await getDocs(q);
const subs = querySnapshot.docs.map(doc => ({ id: doc.id, ...doc.data() } as ContestSubmission));
setSubmissions(subs);
} catch (indexError: any) {
console.warn("Index missing, falling back to in-memory sort", indexError);
const qFallback = query(
collection(db, "contest_submissions"),
where("userId", "==", user.docId),
limit(50)
);
const querySnapshot = await getDocs(qFallback);
const subs = querySnapshot.docs.map(doc => ({ id: doc.id, ...doc.data() } as ContestSubmission));
subs.sort((a, b) => {
const dateA = a.submittedAt?.toDate?.() || new Date(a.submittedAt);
const dateB = b.submittedAt?.toDate?.() || new Date(b.submittedAt);
return dateB.getTime() - dateA.getTime();
});
setSubmissions(subs.slice(0, 50));
}

} catch (error) {
console.error("Error fetching submissions:", error);
}
};
fetchSubmissions();
}
}, [user?.docId]);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Fallback query may return the wrong 50 submissions.

When the composite index is missing, the fallback (lines 120-124) fetches 50 documents without orderBy, so Firestore returns them in document ID order — not chronologically. You then sort those 50 in memory and slice to 50, but the 50 most recent submissions may not be in that set at all.

If the index isn't available, consider removing limit(50) from the fallback query (or using a much larger limit) and slicing after the in-memory sort. Be mindful of read costs, or better yet, create the required composite index so the fallback path isn't needed.

Proposed fix for fallback
             const qFallback = query(
               collection(db, "contest_submissions"),
               where("userId", "==", user.docId),
-              limit(50) 
             );
             const querySnapshot = await getDocs(qFallback);
             const subs = querySnapshot.docs.map(doc => ({ id: doc.id, ...doc.data() } as ContestSubmission));
             subs.sort((a, b) => {
               const dateA = a.submittedAt?.toDate?.() || new Date(a.submittedAt);
               const dateB = b.submittedAt?.toDate?.() || new Date(b.submittedAt);
               return dateB.getTime() - dateA.getTime();
             });
             setSubmissions(subs.slice(0, 50));
📝 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.

Suggested change
useEffect(() => {
if (user?.docId) {
const fetchSubmissions = async () => {
try {
const q = query(
collection(db, "contest_submissions"),
where("userId", "==", user.docId),
orderBy("submittedAt", "desc"),
limit(50)
);
try {
const querySnapshot = await getDocs(q);
const subs = querySnapshot.docs.map(doc => ({ id: doc.id, ...doc.data() } as ContestSubmission));
setSubmissions(subs);
} catch (indexError: any) {
console.warn("Index missing, falling back to in-memory sort", indexError);
const qFallback = query(
collection(db, "contest_submissions"),
where("userId", "==", user.docId),
limit(50)
);
const querySnapshot = await getDocs(qFallback);
const subs = querySnapshot.docs.map(doc => ({ id: doc.id, ...doc.data() } as ContestSubmission));
subs.sort((a, b) => {
const dateA = a.submittedAt?.toDate?.() || new Date(a.submittedAt);
const dateB = b.submittedAt?.toDate?.() || new Date(b.submittedAt);
return dateB.getTime() - dateA.getTime();
});
setSubmissions(subs.slice(0, 50));
}
} catch (error) {
console.error("Error fetching submissions:", error);
}
};
fetchSubmissions();
}
}, [user?.docId]);
useEffect(() => {
if (user?.docId) {
const fetchSubmissions = async () => {
try {
const q = query(
collection(db, "contest_submissions"),
where("userId", "==", user.docId),
orderBy("submittedAt", "desc"),
limit(50)
);
try {
const querySnapshot = await getDocs(q);
const subs = querySnapshot.docs.map(doc => ({ id: doc.id, ...doc.data() } as ContestSubmission));
setSubmissions(subs);
} catch (indexError: any) {
console.warn("Index missing, falling back to in-memory sort", indexError);
const qFallback = query(
collection(db, "contest_submissions"),
where("userId", "==", user.docId)
);
const querySnapshot = await getDocs(qFallback);
const subs = querySnapshot.docs.map(doc => ({ id: doc.id, ...doc.data() } as ContestSubmission));
subs.sort((a, b) => {
const dateA = a.submittedAt?.toDate?.() || new Date(a.submittedAt);
const dateB = b.submittedAt?.toDate?.() || new Date(b.submittedAt);
return dateB.getTime() - dateA.getTime();
});
setSubmissions(subs.slice(0, 50));
}
} catch (error) {
console.error("Error fetching submissions:", error);
}
};
fetchSubmissions();
}
}, [user?.docId]);
🤖 Prompt for AI Agents
In `@src/common/components/Profile/Profile.tsx` around lines 103 - 141, The
fallback query inside useEffect's fetchSubmissions uses qFallback with
limit(50), which can return an arbitrary 50 docs (by document ID) so after
in-memory sorting you may miss the actual 50 most-recent submissions; change the
qFallback to remove the limit (or increase it to a much larger value) when
building the query against
collection("contest_submissions")/where("userId","==", user.docId) so you fetch
a superset, then perform the in-memory sort on submittedAt and slice(0,50)
before calling setSubmissions; alternatively ensure the required composite index
is created so the primary q with orderBy("submittedAt","desc") succeeds and the
fallback is never used.

Comment on lines +666 to +703
{submissions.length > 0 ? (
submissions.map((sub) => {
const date = sub.submittedAt?.toDate?.() || new Date(sub.submittedAt);
const problemIndex = sub.problemId.split('_').pop();
const problemLink = sub.platform.toLowerCase() === 'codeforces'
? `https://codeforces.com/contest/${sub.contestId}/problem/${problemIndex}`
: null;

return (
<div key={sub.id} className={styles.submissionCard}>
<div className={styles.submissionHeader}>
<span className={styles.problemName}>
{problemLink ? (
<a
href={problemLink}
target="_blank"
rel="noopener noreferrer"
className={styles.problemLink}
>
{sub.problemName}
</a>
) : (
sub.problemName
)}
</span>
<span className={styles.submissionDate}>
{date.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })}
</span>
</div>
<div className={styles.submissionDetails}>
<span className={styles.languageBadge}>{sub.language}</span>
<span className={styles.verdictBadge}>
<HiCheckCircle size={16} /> Accepted
</span>
</div>
</div>
);
})
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Hardcoded "Accepted" verdict and fragile problemId parsing.

Two concerns in the submission rendering:

  1. Line 669: sub.problemId.split('_').pop() assumes a specific _-delimited format. If problemId doesn't contain _, pop() returns the entire string, producing an invalid Codeforces link. Consider adding a fallback or using problem.problemIndex if available on the submission type.

  2. Line 698: Every submission displays <HiCheckCircle /> Accepted regardless of the actual verdict. If non-accepted submissions exist in the data, this is misleading. Either filter to only show accepted submissions in the query, or render the actual verdict from the submission data.

🤖 Prompt for AI Agents
In `@src/common/components/Profile/Profile.tsx` around lines 666 - 703, The
submission card rendering assumes a `_`-delimited problemId and hardcodes the
verdict; fix both: in the submissions.map block use a robust extraction for the
problem index from sub.problemId (e.g. check if sub.problemIndex exists and fall
back to parsing only when an underscore is present, or validate split result
before using it) so problemLink is only built when a valid index is available,
and replace the hardcoded "Accepted"/HiCheckCircle in the verdict badge with the
real verdict from the submission object (e.g. use sub.verdict to choose
icon/text or filter submissions earlier if you only want Accepted shown). Ensure
you update references in this render flow (sub.problemId, sub.problemIndex,
problemLink, and the verdict badge where HiCheckCircle is used).

Comment on lines +18 to +26
@keyframes fadeIn {
from {
opacity: 0;
}

to {
opacity: 1;
}
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

Keyframe names should be kebab-case per project stylelint config.

Stylelint reports fadeIn and slideUp should be kebab-case. Rename them to fade-in and slide-up, and update the references on lines 15 and 40 accordingly.

Proposed fix
-.overlay {
+.overlay {
     ...
-    animation: fadeIn 0.2s ease;
+    animation: fade-in 0.2s ease;
 }

-@keyframes fadeIn {
+@keyframes fade-in {
     from {
         opacity: 0;
     }
     to {
         opacity: 1;
     }
 }

 .modal {
     ...
-    animation: slideUp 0.3s ease;
+    animation: slide-up 0.3s ease;
 }

-@keyframes slideUp {
+@keyframes slide-up {
     from {
         opacity: 0;
         transform: translateY(20px);
     }
     to {
         opacity: 1;
         transform: translateY(0);
     }
 }

Also applies to: 43-53

🧰 Tools
🪛 Stylelint (17.2.0)

[error] 18-18: Expected keyframe name "fadeIn" to be kebab-case (keyframes-name-pattern)

(keyframes-name-pattern)

🤖 Prompt for AI Agents
In `@src/components/contests/SolutionsListModal.module.css` around lines 18 - 26,
Rename the keyframe identifiers `@keyframes` fadeIn and `@keyframes` slideUp to
kebab-case (`@keyframes` fade-in and `@keyframes` slide-up) and update all
references to those animations (e.g., any animation or animation-name
declarations that currently use "fadeIn" or "slideUp") to use "fade-in" and
"slide-up" respectively; ensure both the keyframe blocks (previously named
fadeIn/slideUp) and all places where they are applied (animation,
animation-name, or shorthand) are updated to the new kebab-case names.

Comment on lines +199 to +210
.codeViewer {
background: #1e1e1e;
color: #d4d4d4;
padding: 1rem;
border-radius: 8px;
overflow-x: auto;
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
margin-top: 1rem;
max-height: 300px;
overflow-y: auto;
white-space: pre;
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

Remove quotes from font family names per stylelint config.

Stylelint flags the quotes on "Consolas", "Monaco", and "Courier New". Since this project's stylelint enforces font-family-name-quotes, remove the quotes from single-word names and keep them only for multi-word names like Courier New.

Proposed fix
-    font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
+    font-family: Consolas, Monaco, 'Courier New', monospace;
📝 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.

Suggested change
.codeViewer {
background: #1e1e1e;
color: #d4d4d4;
padding: 1rem;
border-radius: 8px;
overflow-x: auto;
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
margin-top: 1rem;
max-height: 300px;
overflow-y: auto;
white-space: pre;
}
.codeViewer {
background: `#1e1e1e`;
color: `#d4d4d4`;
padding: 1rem;
border-radius: 8px;
overflow-x: auto;
font-family: Consolas, Monaco, 'Courier New', monospace;
margin-top: 1rem;
max-height: 300px;
overflow-y: auto;
white-space: pre;
}
🧰 Tools
🪛 Stylelint (17.2.0)

[error] 205-205: Unexpected quotes around "Consolas" (font-family-name-quotes)

(font-family-name-quotes)


[error] 205-205: Unexpected quotes around "Monaco" (font-family-name-quotes)

(font-family-name-quotes)

🤖 Prompt for AI Agents
In `@src/components/contests/SolutionsListModal.module.css` around lines 199 -
210, The .codeViewer CSS rule uses quoted font names causing stylelint
violations; update the font-family declaration in the .codeViewer selector to
remove quotes from single-word family names (Consolas, Monaco) while keeping
quotes for the multi-word family ("Courier New") so it conforms to the
font-family-name-quotes rule.

Comment on lines +42 to +154
useEffect(() => {
const fetchSolutions = async () => {
try {
const solutionsRef = collection(db, "contest_submissions");

// Main query for all solutions
const q = query(
solutionsRef,
where("contestId", "==", contest.contestId),
where("problemId", "==", problem.problemId),
limit(50)
);

let userSubmissionPromise = Promise.resolve(null as any);
if (user?.uid) {
const userQ = query(
solutionsRef,
where("contestId", "==", contest.contestId),
where("problemId", "==", problem.problemId),
where("userId", "==", user.uid),
limit(1)
);
userSubmissionPromise = getDocs(userQ);
}

const [querySnapshot, userSubmissionSnapshot] = await Promise.all([
getDocs(q),
userSubmissionPromise
]);

// Process main solutions
const mapToSolution = (doc: any): Solution => {
const data = doc.data();
return {
id: doc.id,
userId: data.userId,
language: data.language,
submittedAt: data.submittedAt?.toDate?.() || data.submittedAt,
isVerified: data.isVerified || false,
upvotes: data.upvotes || 0,
code: data.code,
verdict: data.verdict,
upvotedBy: data.upvotedBy || [],
isUpvoted: user ? (data.upvotedBy || []).includes(user.uid) : false
};
};

const mainSolutions = querySnapshot.docs.map(mapToSolution);
const userSolutions = userSubmissionSnapshot && !userSubmissionSnapshot.empty
? userSubmissionSnapshot.docs.map(mapToSolution)
: [];

// Validating user submission status
let hasSubmission = false;
if (userSolutions.length > 0) {
hasSubmission = true;
} else {
hasSubmission = user ? mainSolutions.some(sol => sol.userId === user.uid) : false;
}
setUserHasSubmitted(hasSubmission);

// Combine and deduplicate
const allSolutionsMap = new Map<string, Solution>();
mainSolutions.forEach(s => allSolutionsMap.set(s.id, s));
userSolutions.forEach((s: Solution) => allSolutionsMap.set(s.id, s));

const allSolutions = Array.from(allSolutionsMap.values());

// Sort in memory: User's solution first, then by upvotes
allSolutions.sort((a, b) => {
if (user && a.userId === user.uid) return -1;
if (user && b.userId === user.uid) return 1;
return b.upvotes - a.upvotes;
});

setSolutions(allSolutions);

// Fetch usernames
const uniqueUserIds = Array.from(new Set(allSolutions.map(s => s.userId)));
const userMap: Record<string, string> = {};

await Promise.all(uniqueUserIds.map(async (uid) => {
try {
const userSnap = await getDoc(doc(db, "users", uid));
if (userSnap.exists()) {
const userData = userSnap.data();
userMap[uid] = userData.username || userData.displayName || "Unknown";
}
} catch (e) {
// ignore error
}
}));
setUsernames(userMap);

} catch (err: any) {
console.error("Failed to fetch solutions:", err);
if (err.code === 'permission-denied') {
setError("Missing permissions.");
} else if (err.message && err.message.includes("index")) {
setError(err.message);
} else {
setError("Failed to load: " + err.message);
}
toast.error("Failed to load solutions.");
} finally {
setLoading(false);
}
};

if (contest.contestId && problem.problemId) {
fetchSolutions();
}
}, [contest.contestId, problem.problemId, user]);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

user in the dependency array may cause infinite re-fetching.

If the UserContext value is a new object reference on every render (common with context providers that spread state), this useEffect will re-run every render, triggering an infinite loop of Firestore reads.

Depend on a stable primitive like user?.uid instead:

-    }, [contest.contestId, problem.problemId, user]);
+    }, [contest.contestId, problem.problemId, user?.uid]);
📝 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.

Suggested change
useEffect(() => {
const fetchSolutions = async () => {
try {
const solutionsRef = collection(db, "contest_submissions");
// Main query for all solutions
const q = query(
solutionsRef,
where("contestId", "==", contest.contestId),
where("problemId", "==", problem.problemId),
limit(50)
);
let userSubmissionPromise = Promise.resolve(null as any);
if (user?.uid) {
const userQ = query(
solutionsRef,
where("contestId", "==", contest.contestId),
where("problemId", "==", problem.problemId),
where("userId", "==", user.uid),
limit(1)
);
userSubmissionPromise = getDocs(userQ);
}
const [querySnapshot, userSubmissionSnapshot] = await Promise.all([
getDocs(q),
userSubmissionPromise
]);
// Process main solutions
const mapToSolution = (doc: any): Solution => {
const data = doc.data();
return {
id: doc.id,
userId: data.userId,
language: data.language,
submittedAt: data.submittedAt?.toDate?.() || data.submittedAt,
isVerified: data.isVerified || false,
upvotes: data.upvotes || 0,
code: data.code,
verdict: data.verdict,
upvotedBy: data.upvotedBy || [],
isUpvoted: user ? (data.upvotedBy || []).includes(user.uid) : false
};
};
const mainSolutions = querySnapshot.docs.map(mapToSolution);
const userSolutions = userSubmissionSnapshot && !userSubmissionSnapshot.empty
? userSubmissionSnapshot.docs.map(mapToSolution)
: [];
// Validating user submission status
let hasSubmission = false;
if (userSolutions.length > 0) {
hasSubmission = true;
} else {
hasSubmission = user ? mainSolutions.some(sol => sol.userId === user.uid) : false;
}
setUserHasSubmitted(hasSubmission);
// Combine and deduplicate
const allSolutionsMap = new Map<string, Solution>();
mainSolutions.forEach(s => allSolutionsMap.set(s.id, s));
userSolutions.forEach((s: Solution) => allSolutionsMap.set(s.id, s));
const allSolutions = Array.from(allSolutionsMap.values());
// Sort in memory: User's solution first, then by upvotes
allSolutions.sort((a, b) => {
if (user && a.userId === user.uid) return -1;
if (user && b.userId === user.uid) return 1;
return b.upvotes - a.upvotes;
});
setSolutions(allSolutions);
// Fetch usernames
const uniqueUserIds = Array.from(new Set(allSolutions.map(s => s.userId)));
const userMap: Record<string, string> = {};
await Promise.all(uniqueUserIds.map(async (uid) => {
try {
const userSnap = await getDoc(doc(db, "users", uid));
if (userSnap.exists()) {
const userData = userSnap.data();
userMap[uid] = userData.username || userData.displayName || "Unknown";
}
} catch (e) {
// ignore error
}
}));
setUsernames(userMap);
} catch (err: any) {
console.error("Failed to fetch solutions:", err);
if (err.code === 'permission-denied') {
setError("Missing permissions.");
} else if (err.message && err.message.includes("index")) {
setError(err.message);
} else {
setError("Failed to load: " + err.message);
}
toast.error("Failed to load solutions.");
} finally {
setLoading(false);
}
};
if (contest.contestId && problem.problemId) {
fetchSolutions();
}
}, [contest.contestId, problem.problemId, user]);
useEffect(() => {
const fetchSolutions = async () => {
try {
const solutionsRef = collection(db, "contest_submissions");
// Main query for all solutions
const q = query(
solutionsRef,
where("contestId", "==", contest.contestId),
where("problemId", "==", problem.problemId),
limit(50)
);
let userSubmissionPromise = Promise.resolve(null as any);
if (user?.uid) {
const userQ = query(
solutionsRef,
where("contestId", "==", contest.contestId),
where("problemId", "==", problem.problemId),
where("userId", "==", user.uid),
limit(1)
);
userSubmissionPromise = getDocs(userQ);
}
const [querySnapshot, userSubmissionSnapshot] = await Promise.all([
getDocs(q),
userSubmissionPromise
]);
// Process main solutions
const mapToSolution = (doc: any): Solution => {
const data = doc.data();
return {
id: doc.id,
userId: data.userId,
language: data.language,
submittedAt: data.submittedAt?.toDate?.() || data.submittedAt,
isVerified: data.isVerified || false,
upvotes: data.upvotes || 0,
code: data.code,
verdict: data.verdict,
upvotedBy: data.upvotedBy || [],
isUpvoted: user ? (data.upvotedBy || []).includes(user.uid) : false
};
};
const mainSolutions = querySnapshot.docs.map(mapToSolution);
const userSolutions = userSubmissionSnapshot && !userSubmissionSnapshot.empty
? userSubmissionSnapshot.docs.map(mapToSolution)
: [];
// Validating user submission status
let hasSubmission = false;
if (userSolutions.length > 0) {
hasSubmission = true;
} else {
hasSubmission = user ? mainSolutions.some(sol => sol.userId === user.uid) : false;
}
setUserHasSubmitted(hasSubmission);
// Combine and deduplicate
const allSolutionsMap = new Map<string, Solution>();
mainSolutions.forEach(s => allSolutionsMap.set(s.id, s));
userSolutions.forEach((s: Solution) => allSolutionsMap.set(s.id, s));
const allSolutions = Array.from(allSolutionsMap.values());
// Sort in memory: User's solution first, then by upvotes
allSolutions.sort((a, b) => {
if (user && a.userId === user.uid) return -1;
if (user && b.userId === user.uid) return 1;
return b.upvotes - a.upvotes;
});
setSolutions(allSolutions);
// Fetch usernames
const uniqueUserIds = Array.from(new Set(allSolutions.map(s => s.userId)));
const userMap: Record<string, string> = {};
await Promise.all(uniqueUserIds.map(async (uid) => {
try {
const userSnap = await getDoc(doc(db, "users", uid));
if (userSnap.exists()) {
const userData = userSnap.data();
userMap[uid] = userData.username || userData.displayName || "Unknown";
}
} catch (e) {
// ignore error
}
}));
setUsernames(userMap);
} catch (err: any) {
console.error("Failed to fetch solutions:", err);
if (err.code === 'permission-denied') {
setError("Missing permissions.");
} else if (err.message && err.message.includes("index")) {
setError(err.message);
} else {
setError("Failed to load: " + err.message);
}
toast.error("Failed to load solutions.");
} finally {
setLoading(false);
}
};
if (contest.contestId && problem.problemId) {
fetchSolutions();
}
}, [contest.contestId, problem.problemId, user?.uid]);
🧰 Tools
🪛 Biome (2.3.14)

[error] 105-105: This callback passed to forEach() iterable method should not return a value.

Either remove this return or remove the returned value.

(lint/suspicious/useIterableCallbackReturn)


[error] 106-106: This callback passed to forEach() iterable method should not return a value.

Either remove this return or remove the returned value.

(lint/suspicious/useIterableCallbackReturn)

🤖 Prompt for AI Agents
In `@src/components/contests/SolutionsListModal.tsx` around lines 42 - 154, The
useEffect that calls fetchSolutions currently depends on the whole user object
which can be unstable; update the dependency array for that useEffect to use the
stable primitive user?.uid instead of user (keep contest.contestId and
problem.problemId), i.e. change the dependency list referenced in the useEffect
surrounding fetchSolutions to [contest.contestId, problem.problemId, user?.uid]
and adjust any closure usage if needed to reference userUid (or user?.uid)
inside fetchSolutions when checking ownership/upvotes.

Comment on lines +103 to +106
// Combine and deduplicate
const allSolutionsMap = new Map<string, Solution>();
mainSolutions.forEach(s => allSolutionsMap.set(s.id, s));
userSolutions.forEach((s: Solution) => allSolutionsMap.set(s.id, s));
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

Fix forEach callback returning a value (Biome lint error).

Map.prototype.set() returns the Map, so the arrow functions implicitly return a value. Biome flags this as useIterableCallbackReturn. Use braces to make the return void.

Proposed fix
-                mainSolutions.forEach(s => allSolutionsMap.set(s.id, s));
-                userSolutions.forEach((s: Solution) => allSolutionsMap.set(s.id, s));
+                mainSolutions.forEach(s => { allSolutionsMap.set(s.id, s); });
+                userSolutions.forEach((s: Solution) => { allSolutionsMap.set(s.id, s); });
📝 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.

Suggested change
// Combine and deduplicate
const allSolutionsMap = new Map<string, Solution>();
mainSolutions.forEach(s => allSolutionsMap.set(s.id, s));
userSolutions.forEach((s: Solution) => allSolutionsMap.set(s.id, s));
// Combine and deduplicate
const allSolutionsMap = new Map<string, Solution>();
mainSolutions.forEach(s => { allSolutionsMap.set(s.id, s); });
userSolutions.forEach((s: Solution) => { allSolutionsMap.set(s.id, s); });
🧰 Tools
🪛 Biome (2.3.14)

[error] 105-105: This callback passed to forEach() iterable method should not return a value.

Either remove this return or remove the returned value.

(lint/suspicious/useIterableCallbackReturn)


[error] 106-106: This callback passed to forEach() iterable method should not return a value.

Either remove this return or remove the returned value.

(lint/suspicious/useIterableCallbackReturn)

🤖 Prompt for AI Agents
In `@src/components/contests/SolutionsListModal.tsx` around lines 103 - 106, The
forEach callbacks in SolutionsListModal.tsx are implicitly returning the Map
because you used concise arrow bodies; change both callbacks to use block bodies
so they don't return a value (e.g., replace mainSolutions.forEach(s =>
allSolutionsMap.set(s.id, s)) and userSolutions.forEach((s: Solution) =>
allSolutionsMap.set(s.id, s)) with mainSolutions.forEach(s => {
allSolutionsMap.set(s.id, s); }) and userSolutions.forEach((s: Solution) => {
allSolutionsMap.set(s.id, s); }) to satisfy the useIterableCallbackReturn lint
rule.

Comment on lines +194 to +206
try {
const solutionRef = doc(db, "contest_submissions", solutionId);
if (isUpvoted) {
await updateDoc(solutionRef, {
upvotes: increment(-1),
upvotedBy: arrayRemove(user.uid)
});
} else {
await updateDoc(solutionRef, {
upvotes: increment(1),
upvotedBy: arrayUnion(user.uid)
});
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Client-side Firestore writes bypass the /api/upvote API route.

handleUpvote calls updateDoc directly against Firestore from the browser (lines 195-206), while a dedicated server-side API route exists at src/pages/api/upvote.ts. This creates two independent write paths for the same data:

  • If Firestore security rules allow client writes, the API route is dead code.
  • If rules restrict client writes (as is best practice), these calls will fail at runtime.

Pick one approach: either route upvotes through the API (preferred — allows auth checks, validation, and transactional safety server-side), or remove the API route if client-side writes are intentional.

Sketch: use the API route instead
         try {
-            const solutionRef = doc(db, "contest_submissions", solutionId);
-            if (isUpvoted) {
-                await updateDoc(solutionRef, {
-                    upvotes: increment(-1),
-                    upvotedBy: arrayRemove(user.uid)
-                });
-            } else {
-                await updateDoc(solutionRef, {
-                    upvotes: increment(1),
-                    upvotedBy: arrayUnion(user.uid)
-                });
-            }
+            const res = await fetch('/api/upvote', {
+                method: 'POST',
+                headers: { 'Content-Type': 'application/json' },
+                body: JSON.stringify({
+                    solutionId,
+                    userId: user.uid,
+                    action: isUpvoted ? 'downvote' : 'upvote',
+                }),
+            });
+            if (!res.ok) {
+                throw new Error((await res.json()).message || 'Upvote failed');
+            }
📝 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.

Suggested change
try {
const solutionRef = doc(db, "contest_submissions", solutionId);
if (isUpvoted) {
await updateDoc(solutionRef, {
upvotes: increment(-1),
upvotedBy: arrayRemove(user.uid)
});
} else {
await updateDoc(solutionRef, {
upvotes: increment(1),
upvotedBy: arrayUnion(user.uid)
});
}
try {
const res = await fetch('/api/upvote', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
solutionId,
userId: user.uid,
action: isUpvoted ? 'downvote' : 'upvote',
}),
});
if (!res.ok) {
throw new Error((await res.json()).message || 'Upvote failed');
}
🤖 Prompt for AI Agents
In `@src/components/contests/SolutionsListModal.tsx` around lines 194 - 206, The
client currently performs Firestore writes directly in handleUpvote by calling
updateDoc (and using increment/arrayUnion/arrayRemove), which bypasses the
server-side upvote logic in the /api/upvote route; replace the direct updateDoc
calls inside handleUpvote with a fetch POST to "/api/upvote" (send solutionId
and desired action or isUpvoted), await the response, and only update local
state after a successful response (or implement optimistic update with rollback
on error); remove or comment out the updateDoc branches (isUpvoted true/false)
and ensure the request includes the user's auth token/session so the server can
perform the authoritative Firestore update and validations.

Comment on lines +5 to +41
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method !== 'POST') {
return res.status(405).json({ message: 'Method not allowed' });
}

const { solutionId, userId, action } = req.body;

if (!solutionId || !userId || !action) {
return res.status(400).json({ message: 'Missing required fields' });
}

if (!adminDb) {
return res.status(500).json({ message: 'Firebase Admin not initialized' });
}

try {
const solutionRef = adminDb.collection('contest_submissions').doc(solutionId);

if (action === 'upvote') {
await solutionRef.update({
upvotes: FieldValue.increment(1),
upvotedBy: FieldValue.arrayUnion(userId)
});
} else if (action === 'downvote') {
await solutionRef.update({
upvotes: FieldValue.increment(-1),
upvotedBy: FieldValue.arrayRemove(userId)
});
} else {
return res.status(400).json({ message: 'Invalid action' });
}

return res.status(200).json({ message: 'Success' });
} catch (error: any) {
console.error('Error in /api/upvote:', error);
return res.status(500).json({ message: error.message || 'Internal Server Error' });
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

No authentication — any caller can upvote on behalf of any user.

The endpoint blindly trusts userId from the request body without verifying the caller's identity (e.g., via a session token or Firebase Auth ID token). A malicious client can forge upvotes for arbitrary users.

At minimum, verify the Firebase ID token from the Authorization header and ensure the decoded UID matches the provided userId.

🤖 Prompt for AI Agents
In `@src/pages/api/upvote.ts` around lines 5 - 41, The handler currently trusts
the request body userId; extract the Firebase ID token from the Authorization
header (expecting "Bearer <token>"), verify it with the Firebase Admin SDK (use
admin.auth().verifyIdToken or equivalent), and confirm decodedToken.uid ===
userId before proceeding; if verification fails or UIDs don't match, return a
401/403 response. Update the handler function that uses adminDb and solutionRef
to perform this token check early (before modifying upvotes) and log/handle
token verification errors appropriately.

Comment on lines +23 to +32
if (action === 'upvote') {
await solutionRef.update({
upvotes: FieldValue.increment(1),
upvotedBy: FieldValue.arrayUnion(userId)
});
} else if (action === 'downvote') {
await solutionRef.update({
upvotes: FieldValue.increment(-1),
upvotedBy: FieldValue.arrayRemove(userId)
});
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

upvotes counter will desync from upvotedBy array.

FieldValue.increment(1) is not idempotent, but FieldValue.arrayUnion(userId) is. If a user's upvote request is retried (network glitch, double-click, etc.), the counter increments again while the array stays the same. Over time, upvotes !== upvotedBy.length.

Wrap in a transaction that checks upvotedBy before modifying either field, or derive the count from the array length instead of maintaining a separate counter.

Additionally, FieldValue.increment(-1) can push upvotes below zero if called when upvotes is already 0.

Sketch of a transaction-based approach
     try {
         const solutionRef = adminDb.collection('contest_submissions').doc(solutionId);
-
-        if (action === 'upvote') {
-            await solutionRef.update({
-                upvotes: FieldValue.increment(1),
-                upvotedBy: FieldValue.arrayUnion(userId)
-            });
-        } else if (action === 'downvote') {
-                await solutionRef.update({
-                upvotes: FieldValue.increment(-1),
-                upvotedBy: FieldValue.arrayRemove(userId)
-            });
-        } else {
-            return res.status(400).json({ message: 'Invalid action' });
-        }
+
+        await adminDb.runTransaction(async (transaction) => {
+            const snap = await transaction.get(solutionRef);
+            if (!snap.exists) {
+                throw new Error('Solution not found');
+            }
+            const data = snap.data()!;
+            const upvotedBy: string[] = data.upvotedBy || [];
+            const alreadyUpvoted = upvotedBy.includes(userId);
+
+            if (action === 'upvote' && !alreadyUpvoted) {
+                transaction.update(solutionRef, {
+                    upvotes: (data.upvotes || 0) + 1,
+                    upvotedBy: FieldValue.arrayUnion(userId),
+                });
+            } else if (action === 'downvote' && alreadyUpvoted) {
+                transaction.update(solutionRef, {
+                    upvotes: Math.max(0, (data.upvotes || 0) - 1),
+                    upvotedBy: FieldValue.arrayRemove(userId),
+                });
+            }
+            // else: no-op (idempotent)
+        });
🤖 Prompt for AI Agents
In `@src/pages/api/upvote.ts` around lines 23 - 32, The upvote logic can desync
because FieldValue.increment(1) is not idempotent while
FieldValue.arrayUnion(userId) is; change to a Firestore transaction that reads
the document via solutionRef, checks whether userId is already present in the
upvotedBy array, and then either adds/removes userId and sets upvotes to the new
upvotedBy.length (or increments/decrements only when membership changes),
ensuring you never decrement below zero; update the handlers that currently call
FieldValue.increment/arrayUnion/arrayRemove so they use the transaction and
atomic reads/writes on solutionRef, upvotedBy and upvotes to keep them
consistent.


try {
const apiUrl = `https://codeforces.com/api/user.status?handle=${handle}&from=1&count=50`;
const apiUrl = `https://codeforces.com/api/user.status?handle=${handle}&from=1&count=2000`;
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

handle is not URL-encoded — malformed URL risk.

User-supplied handle is interpolated directly into the URL. If the handle contains special characters (e.g., spaces, &, #), the URL breaks silently or produces incorrect results. Use encodeURIComponent.

Proposed fix
-        const apiUrl = `https://codeforces.com/api/user.status?handle=${handle}&from=1&count=2000`;
+        const apiUrl = `https://codeforces.com/api/user.status?handle=${encodeURIComponent(handle)}&from=1&count=2000`;
📝 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.

Suggested change
const apiUrl = `https://codeforces.com/api/user.status?handle=${handle}&from=1&count=2000`;
const apiUrl = `https://codeforces.com/api/user.status?handle=${encodeURIComponent(handle)}&from=1&count=2000`;
🤖 Prompt for AI Agents
In `@src/pages/api/verify-codeforces.ts` at line 29, The apiUrl construction
interpolates the raw handle into the Codeforces URL which can create malformed
requests for handles with special characters; update the code that builds apiUrl
(the apiUrl constant in src/pages/api/verify-codeforces.ts, where handle is
used) to pass handle through encodeURIComponent(handle) before interpolation so
the query string is properly escaped, ensuring the handler function uses the
encoded value for the fetch request.

@aviralsaxena16 aviralsaxena16 merged commit 63c21cd into OpenLake:main Feb 16, 2026
2 of 3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants