feat: enhance profile submissions and solutions list UI#143
Conversation
|
@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. |
🎉 Thanks for Your Contribution to CanonForces!
|
WalkthroughThis 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
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
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
Estimated code review effort🎯 4 (Complex) | ⏱️ ~45 minutes Possibly related PRs
Suggested reviewers
Poem
🚥 Pre-merge checks | ✅ 2 | ❌ 2❌ Failed checks (2 warnings)
✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing touches
🧪 Generate unit tests (beta)
⚔️ Resolve merge conflicts (beta)
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. Comment |
There was a problem hiding this comment.
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
.verifyButtonis missing from the mobile responsive rules.On viewports ≤ 768 px the footer switches to
flex-direction: column-reverseand both.cancelButtonand.submitButtongetwidth: 100%, but.verifyButtonis 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 withupvote.ts.This endpoint returns
503whenadminDbis uninitialized, whilesrc/pages/api/upvote.ts(line 17) returns500for the same condition. Consider aligning on503(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.statusfiltered by handle) would work. Thecontest.statusAPI allows filtering byhandleandcontestIddirectly, which would be far more efficient.src/components/contests/ContestProblems.tsx (2)
38-46: SharedselectedProblemstate can conflict between modals.Both
handleViewSolutionsandhandleSubmitSolutionwrite to the sameselectedProblemstate. When either modal is closed (handleCloseSubmitModalorhandleCloseSolutionsModal),selectedProblemis set tonull, 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.,
selectedProblemForSubmitandselectedProblemForSolutions) or guarding with the respective modal-open boolean.
89-95: Prefer CSS module class over inline styles.The "View Solutions" button uses
styles.viewButtonbut overrides it heavily with inlinestyle. This makes future theming/maintenance harder and is inconsistent with the rest of the file. Consider defining a dedicated CSS class (e.g.,.viewSolutionsButton) inContestProblems.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 — individualgetDocper user.Each unique
userIdtriggers 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, andotherSolutionsinto the component body (or a child component) to improve readability.
258-258: UnusedshowHeaderparameter.
renderSolutionsTabledeclaresshowHeader: boolean = truebut 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[]) => (
| 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]); |
There was a problem hiding this comment.
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.
| 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.
| {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> | ||
| ); | ||
| }) |
There was a problem hiding this comment.
Hardcoded "Accepted" verdict and fragile problemId parsing.
Two concerns in the submission rendering:
-
Line 669:
sub.problemId.split('_').pop()assumes a specific_-delimited format. IfproblemIddoesn't contain_,pop()returns the entire string, producing an invalid Codeforces link. Consider adding a fallback or usingproblem.problemIndexif available on the submission type. -
Line 698: Every submission displays
<HiCheckCircle /> Acceptedregardless 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).
| @keyframes fadeIn { | ||
| from { | ||
| opacity: 0; | ||
| } | ||
|
|
||
| to { | ||
| opacity: 1; | ||
| } | ||
| } |
There was a problem hiding this comment.
🛠️ 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.
| .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; | ||
| } |
There was a problem hiding this comment.
🛠️ 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.
| .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.
| 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]); |
There was a problem hiding this comment.
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.
| 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.
| // 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)); |
There was a problem hiding this comment.
🛠️ 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.
| // 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.
| 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) | ||
| }); | ||
| } |
There was a problem hiding this comment.
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.
| 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.
| 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' }); | ||
| } |
There was a problem hiding this comment.
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.
| 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) | ||
| }); |
There was a problem hiding this comment.
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`; |
There was a problem hiding this comment.
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.
| 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.
feat: Profile Submissions, Enhanced Solutions List & Codeforces Verification
✨ Changes Introduced
🤔 Why This Change?
🎥 Loom Video Walkthrough
[Watch the walkthrough on Loom](Screencast from 2026-02-16 23-28-12.webm)
🧪 Testing
📝 Documentation Updates
✅ Checklist
Summary by CodeRabbit
New Features
Improvements