[FOSSOVERFLOW-25] Added review engagement features#54
[FOSSOVERFLOW-25] Added review engagement features#54alSN0W wants to merge 9 commits intoOpenLake:mainfrom
Conversation
Review engagement
added vote button
fixed displaying the user votes incorrectly issue
Styled Voting buttons and updated migration files
WalkthroughThis PR upgrades Next.js from 13.5.1 to 16.1.6, adds Turbopack configuration, and implements a review voting system. It includes new voting UI components, state management hooks, API endpoints, database migrations, and support for async route parameters. Changes
Sequence DiagramsequenceDiagram
participant User as User
participant VB as VoteButton
participant Hook as useVotes Hook
participant API as Vote API
participant DB as Database
User->>VB: Click upvote/downvote
VB->>Hook: castVote(reviewId, voteType)
Hook->>Hook: Optimistic update<br/>(update votes & counts)
Hook->>VB: Return immediately
VB->>VB: Update UI display
Hook->>API: POST /api/ratings/vote
API->>DB: Check existing vote
alt Vote already exists (same type)
DB->>DB: Delete vote
API->>API: Return action: 'removed'
else Vote exists (different type)
DB->>DB: Update vote_type
API->>API: Return action: 'updated'
else No vote exists
DB->>DB: Insert new vote
API->>API: Return action: 'created'
end
API->>Hook: Return response
alt Server error
Hook->>Hook: Rollback to<br/>previous state
Hook->>VB: Revert UI
Hook->>Hook: Show error toast
else Success
Hook->>Hook: Persist server state
end
Estimated code review effort🎯 4 (Complex) | ⏱️ ~45 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 3 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (3 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing touches
🧪 Generate unit tests (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 (3)
src/migrations/migration.sql (1)
489-510:⚠️ Potential issue | 🟠 Major
handle_new_user()is missingSET search_path = public— inconsistent withupdate_review_votes().This function is declared
SECURITY DEFINER(line 510) but does not pinsearch_path. A user who can manipulatesearch_pathcould shadow thepublic.userstable or theextensionsschema functions (gen_random_bytes,digest), causing the function to operate on attacker-controlled objects with elevated privileges. Apply the same safeguard asupdate_review_votes()(lines 259–260):-$$ LANGUAGE plpgsql SECURITY DEFINER; +$$ LANGUAGE plpgsql SECURITY DEFINER SET search_path = public;The same concern applies to
is_admin()(line 370) andget_anonymous_id()(line 381), which are alsoSECURITY DEFINERwithoutSET search_path.next.config.js (1)
7-17:⚠️ Potential issue | 🟠 MajorCustom webpack config will cause
next buildto fail with Next.js 16's default Turbopack bundler.The project uses Next.js 16.1.6, where Turbopack is the default for both
next devandnext build. Having a customwebpack()config innext.config.jswill cause the build to fail. Turbopack does not recognize or run webpack overrides.Fix by choosing one option:
- Add
--webpackflag to build scripts inpackage.jsonto opt out of Turbopack and use webpack instead- Migrate the webpack config (suppressing
@supabase/realtime-jswarnings) to Turbopack equivalents- Remove the webpack config if no longer needed
package.json (1)
19-19:⚠️ Potential issue | 🟠 MajorUpdate companion packages to match Next.js 16.1.6.
The project uses Next.js 16.1.6, but
@next/swc-wasm-nodejsandeslint-config-nextare pinned to 13.5.1, creating version conflicts. Both packages must be updated to 16.1.6 to maintain compatibility.Proposed fix
- "@next/swc-wasm-nodejs": "13.5.1", + "@next/swc-wasm-nodejs": "16.1.6",- "eslint-config-next": "13.5.1", + "eslint-config-next": "16.1.6",
🤖 Fix all issues with AI agents
In `@package.json`:
- Line 64: The project is using the React 19-only use() API but package.json
still pins React 18; update the dependency versions so React 19 is installed and
types match: bump "react" and "react-dom" to a 19.x range compatible with Next
16 (e.g. ^19.0.0 or newer), and also update the type packages "@types/react" and
"@types/react-dom" to matching 19.x versions; ensure these four entries are
changed together so the use(params) calls (e.g., in
src/app/courses/[courseId]/page.tsx) run against React 19.
In `@src/app/courses/`[courseId]/page.tsx:
- Around line 11-16: The component is using React's experimental use() hook (see
CoursePage and the use import) but the project is on React 18.2.0, so either
upgrade React to 19+ in package.json to keep use(), or remove use() and accept
params synchronously: change CoursePage signature to ({ params }: { params: {
courseId: string } }) and extract const { courseId } = params; remove the use
import, merge imports into a single React import if present, and delete the
empty comment artifact.
In `@src/components/common/VoteButton.tsx`:
- Around line 102-113: The server response handler updates only setCurrentVote
but leaves the displayed voteCount as the optimistic value; modify the success
path in the async response code handling (where setCurrentVote(...) is called)
to also reconcile the displayed count by reading the server-provided value
(e.g., data.new_vote_count or data.vote_count) and calling the state setter that
controls voteCount (the same state variable updated optimistically), falling
back to the existing optimistic calculation if the field is absent; also ensure
the onVote(reviewId, data.vote_type) callback continues to be invoked after
reconciliation.
- Around line 116-120: The rollback in the catch block uses render-time closure
values (currentVote, voteCount) which is fragile; move the saved snapshot
variables oldVote and oldCount out of the try block (declare them immediately
before the try) and change the catch to call setCurrentVote(oldVote) and
setVoteCount(oldCount) so the rollback uses the explicit saved state; update
references in the catch accordingly and keep the optimistic
setCurrentVote/setVoteCount logic inside the try.
In `@src/components/courses/course_page/CoursePageReviews.tsx`:
- Around line 96-109: The current vote fetch in the CoursePageReviews component
builds a long query string from reviewsData (reviewIds) and calls
fetch(`/api/ratings/vote?review_ids=...`), which can exceed URL length limits;
change this to send the IDs in the request body (POST) or batch the IDs into
smaller requests: update the fetch call to POST with JSON body { review_ids:
reviewsData.map(r => r.id) }, adjust the endpoint to accept POST if needed, or
implement chunking of reviewIds and aggregate responses before calling
setUserVotes; ensure error handling and that setUserVotes(votesData.votes || {})
is called after combining batched responses.
- Around line 50-56: The VoteButton is being passed initialVoteType={userVote as
any} and the component key includes userVote which forces unnecessary remounts;
fix by typing userVote in the CourseReviewItem props/signature to the proper
vote union (e.g., VoteType | null or 'up'|'down'|null) so the as any cast can be
removed when passing initialVoteType to VoteButton, and simplify the key to only
use review.id (or remove the custom key if the map already keys by id) so
VoteButton re-renders via props/state instead of being unmounted and remounted.
In `@src/hooks/useVote.ts`:
- Around line 1-332: The useVotes hook is dead code in this PR—neither useVotes
nor its functions (useVotes, castVote, removeVote, toggleVote, refreshVotes) are
consumed by VoteButton or CoursePageReviews; either remove the hook from this PR
or wire it up to replace the duplicated optimistic-vote logic. To fix: if you
want to drop it, delete the useVotes file and any exports/imports, and ensure
VoteButton and CoursePageReviews keep their current behavior; if you want to
adopt it, replace the inline vote logic in VoteButton and the direct-fetch in
CoursePageReviews to call useVotes (import useVotes, derive votes and voteCounts
from the hook, and call castVote/removeVote/toggleVote/refreshVotes), remove
duplicated optimistic/update code from those components, and ensure
props/callbacks use onVoteSuccess/onVoteError as needed.
In `@src/pages/api/ratings/vote/index.ts`:
- Around line 44-112: The current check-then-act flow around existingVote (the
.single() check) plus the subsequent delete/update/insert on
supabase.from('votes') is vulnerable to TOCTOU races; replace it with an atomic
server-side operation by implementing a Postgres function (e.g.,
toggle_vote(review_id UUID, anonymous_id TEXT, vote_type TEXT)) that
encapsulates the logic (check existing row, delete if same type, update if
different, insert if missing) and returns the resulting action and vote_type,
then call it via supabase.rpc('toggle_vote', { review_id, anonymous_id,
vote_type }); remove the separate .single()/delete/update/insert branches and
handle rpc errors/response instead to eliminate the race window.
- Around line 138-145: The review_ids splitting creates an unbounded
reviewIdArray used in the supabase .in('review_id', reviewIdArray) query;
enforce a max cap (e.g., 100) on the number of IDs before querying by validating
review_ids and either returning a 400 error if the client exceeds the limit or
truncating with a clear behavior choice; update the code around review_ids,
reviewIdArray and the supabase .in call to check Array.isArray(reviewIdArray)
length and respond accordingly (reject requests >100 IDs or slice to first 100)
and include a clear error message referencing the limit.
In `@src/utils/supabase/server-pages.ts`:
- Around line 16-19: The current setAll implementation calls
res.setHeader('Set-Cookie', ...) inside the forEach which overwrites previous
cookies so only the last cookie remains; change setAll (the function handling
cookiesToSet) to either accumulate serialized cookie strings into an array and
call res.setHeader('Set-Cookie', cookiesArray) once, or call
res.appendHeader('Set-Cookie', cookieString) for each cookie instead of
setHeader; also replace the manual serialization logic with a proper serializer
(e.g., use cookie.serialize) to ensure boolean flags like HttpOnly and Secure
are emitted correctly (reference the setAll function, cookiesToSet items with
{name, value, options}, and the res.setHeader/res.appendHeader calls).
🧹 Nitpick comments (7)
src/pages/api/ratings/vote/index.ts (1)
211-214: MissingAllowheader on 405 response.Per HTTP spec (RFC 9110), a 405 response should include an
Allowheader listing the permitted methods.Proposed fix
else { + res.setHeader('Allow', 'POST, GET, DELETE'); return res.status(405).json({ error: 'Method not allowed' }); }src/utils/supabase/server-pages.ts (1)
4-7: Missing validation for required environment variables.
process.env.NEXT_PUBLIC_SUPABASE_URL!andprocess.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!use non-null assertions. If either is missing at runtime, Supabase will receiveundefinedand produce confusing downstream errors. The same pattern exists inserver.ts, but since this is a new file, consider adding a guard.src/components/common/VoteButton.tsx (2)
164-173: Redundant branches in vote count styling.The
voteCount < 0branch (line 170–171) and the finalelsebranch (line 172) apply identical classes (text-gray-500 dark:text-gray-400). Simplify by collapsing them.Suggested fix
${ currentVote === 'helpful' ? 'text-orange-500' : currentVote === 'unhelpful' ? 'text-blue-500' : voteCount > 0 ? 'text-gray-700 dark:text-gray-300' - : voteCount < 0 - ? 'text-gray-500 dark:text-gray-400' : 'text-gray-500 dark:text-gray-400' }
36-55: Size config object is recreated on every render.
sizesis a static constant. Move it outside the component to avoid re-creating the object on each render.src/components/courses/course_page/CoursePageReviews.tsx (1)
64-116:useVotehook is never used—VoteButton and CoursePageReviews each roll their own vote logic.
useVote.tsprovidesfetchUserVotes, optimistic updates, rollback, and concurrency guards, yetCoursePageReviewsre-implements vote fetching inline andVoteButtonmanages its own optimistic state. This duplicates logic and increases the risk of behavioral divergence. Consider wiringuseVotes()here to centralize vote state management, or remove the unused hook.src/hooks/useVote.ts (2)
86-90: Suppressed exhaustive-deps warning hides a real dependency gap.
fetchUserVotesis omitted from the dependency array, and theeslint-disablemasks this. IfonVoteError(a dependency offetchUserVotes) changes, the effect won't re-run with the updated callback. Either addfetchUserVotesto the dependency array (which is safe since it's memoized withuseCallback), or acknowledge thatonVoteErrorchanges are intentionally ignored.Suggested fix
useEffect(() => { if (reviewIds.length > 0) { fetchUserVotes(reviewIds); } - }, [reviewIds.join(',')]); // eslint-disable-line react-hooks/exhaustive-deps + }, [reviewIds.join(','), fetchUserVotes]); // eslint-disable-line react-hooks/exhaustive-depsNote: The
eslint-disableis still needed forreviewIds.join(',')(a derived value), but at leastfetchUserVotesis now tracked.
140-212:castVoteandremoveVoteincludevotesandvoteCountsas dependencies, causing callback identity to change on every state update.Every time a vote is cast,
votes/voteCountsstate changes, which recreatescastVote,removeVote, andtoggleVote. This cascading recreation can trigger unnecessary re-renders in consuming components and cause stale references when these callbacks are passed as props.Consider reading state via functional updater or a ref to decouple callback identity from state:
const votesRef = useRef(votes); votesRef.current = votes;Then read
votesRef.currentinside callbacks, removingvotesfrom the dependency arrays. This keeps callback identities stable.
| "input-otp": "^1.2.4", | ||
| "lucide-react": "^0.446.0", | ||
| "next": "13.5.1", | ||
| "next": "^16.1.6", |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
# First, let's check the package.json file to see the current dependency versions
fd package.json | head -1 | xargs cat -nRepository: OpenLake/RateMyCourse
Length of output: 3540
🏁 Script executed:
# Search for "use client" directives in the codebase
rg '"use client"' -t jsx -t ts -t tsxRepository: OpenLake/RateMyCourse
Length of output: 92
🏁 Script executed:
# Search for the use() API pattern in the codebase (React 19)
rg '\buse\(' -t jsx -t ts -t tsx -A 2 | head -100Repository: OpenLake/RateMyCourse
Length of output: 92
🌐 Web query:
Next.js 16 React version requirements minimum compatibility
💡 Result:
Next.js 16.x declares these React peer dependency requirements (so your app must install matching versions):
- react:
^18.2.0or^19.0.0(it also explicitly allows a specific React 19 RC build) [1] - react-dom:
^18.2.0or^19.0.0(same RC allowance) [1]
So the minimum compatible React version is React 18.2.0 (with React 19 also supported). Next.js also recommends at least react@18.2.0 / react-dom@18.2.0. [2]
Sources: [1] [2]
🌐 Web query:
Next.js 16 upgrade guide React 19 requirement
💡 Result:
Next.js 16 expects React 19 (specifically, the App Router “uses the latest React Canary release,” highlighting React 19.2 features) and the official manual-upgrade path is to install react@latest / react-dom@latest alongside next@latest. [1][2]
Practical upgrade steps (v15 → v16):
- Run the official upgrade codemod:
npx@next/codemod@canary upgrade latest[1]
or upgrade manually:npm install next@latest react@latest react-dom@latest(and update@types/react/@types/react-domif you use TS). [1] - Ensure your runtime meets the new baseline: Node.js 20.9+ (Node 18 is not supported). [1][2]
What “React 19 requirement” means for you:
- If your app or dependencies are still pinned to React 18, you’ll need to move them to React 19.x to upgrade cleanly to Next.js 16 (and verify third‑party library compatibility). [1][2]
Sources:
[1] Next.js “Upgrading: Version 16” guide (updated Feb 11, 2026) (nextjs.org)
[2] Next.js 16 release post (“React 19.2”, version requirements) (nextjs.org)
Citations:
- 1: https://nextjs.org/docs/app/guides/upgrading/version-16?utm_source=openai
- 2: https://nextjs.org/blog/next-16?utm_source=openai
🏁 Script executed:
# Search for "use client" directive without file type restriction
rg '"use client"' -A 1 | head -50Repository: OpenLake/RateMyCourse
Length of output: 1878
🏁 Script executed:
# Search for use() function calls in the codebase (React 19 API)
rg '\buse\(' | head -50Repository: OpenLake/RateMyCourse
Length of output: 136
🏁 Script executed:
# Also check if there are any imports of React hooks from React 19 specifically
rg 'from .react' | head -30Repository: OpenLake/RateMyCourse
Length of output: 2069
🌐 Web query:
React use() API version introduced React 18 vs React 19
💡 Result:
- React 19 (stable): The
use(resource)API (imported asimport { use } from 'react') is part of the React 19 API surface and is documented in the React 19.x reference. It lets you read from a Promise or Context during render and integrates with Suspense / Error Boundaries. [1] - React 18 (stable): React 18’s stable release introduced hooks like
useIdanduseTransition(and concurrent rendering features), but it did not include theuse()API as a stable export. [2] - React 18 canary/experimental (pre-19): The React team shipped “canary” builds so frameworks could adopt near-final features before the next major release. React 19 was explicitly positioned as the next major version to bring these newer capabilities to
latest. [3][4]
Answer: use() is a React 19 API (stable in React 19.x), not a React 18 stable API; in the React 18 era it was only available via canary/experimental channels, not react@18.x stable. [1][3][4]
Sources:
[1] React API Reference: use (React v19.2) https://react.dev/reference/react/use
[2] React v18.0 blog post (lists new hooks; no use) https://react.dev/blog/2022/03/29/react-v18
[3] React Canaries announcement https://react.dev/blog/2023/05/03/react-canaries
[4] React Labs Feb 2024 (next major = React 19; canary → latest) https://react.dev/blog/2024/02/15/react-labs-what-we-have-been-working-on-february-2024
The use(params) React 19 API requires upgrading to React 19.
The codebase uses the use() function to read async values (e.g., const { courseId } = use(params); in src/app/courses/[courseId]/page.tsx), which is a React 19 feature not available in React 18. This will fail at runtime with React 18.2.0.
Upgrade react, react-dom, and their type packages to React 19:
Proposed fix
- "react": "18.2.0",
+ "react": "^19.1.0",
"react-day-picker": "^8.10.1",
- "react-dom": "18.2.0",
+ "react-dom": "^19.1.0",Also update the type packages (lines 51–52):
- "@types/react": "18.2.22",
- "@types/react-dom": "18.2.7",
+ "@types/react": "^19.0.0",
+ "@types/react-dom": "^19.0.0",Also applies to: 67-67, 69-69
🤖 Prompt for AI Agents
In `@package.json` at line 64, The project is using the React 19-only use() API
but package.json still pins React 18; update the dependency versions so React 19
is installed and types match: bump "react" and "react-dom" to a 19.x range
compatible with Next 16 (e.g. ^19.0.0 or newer), and also update the type
packages "@types/react" and "@types/react-dom" to matching 19.x versions; ensure
these four entries are changed together so the use(params) calls (e.g., in
src/app/courses/[courseId]/page.tsx) run against React 19.
| import { use } from 'react'; | ||
| // | ||
|
|
||
| export default function CoursePage({ params }: { params: { courseId: string } }) { | ||
|
|
||
| export default function CoursePage({ params }: { params: Promise<{ courseId: string }> }) { | ||
| const { courseId } = use(params); |
There was a problem hiding this comment.
use() hook requires React 19 — will crash with current React 18.2.0.
The use hook was introduced in React 19. With "react": "18.2.0" in package.json, this component will throw a runtime error. This is a downstream effect of the React version mismatch flagged in package.json — upgrading React to 19+ will resolve this.
Also minor cleanup: merge the use import with the existing React import on Line 3, and remove the empty comment artifact on Line 12.
Import cleanup
-import { useEffect, useState } from "react";
+import { use, useEffect, useState } from "react";
import { supabase } from "@/lib/supabase";
import { useCourses } from "@/hooks/useCourses";
import CoursePageHeader from "@/components/courses/course_page/CoursePageHeader";
import CoursePageStats from "@/components/courses/course_page/CoursePageStats";
import CoursePageReviews from "@/components/courses/course_page/CoursePageReviews";
import RateThisCourse from "@/components/courses/course_page/RateThisCourse";
import Example from "@/components/courses/course_page/CoursePageLoader";
-import { use } from 'react';
-//
-🤖 Prompt for AI Agents
In `@src/app/courses/`[courseId]/page.tsx around lines 11 - 16, The component is
using React's experimental use() hook (see CoursePage and the use import) but
the project is on React 18.2.0, so either upgrade React to 19+ in package.json
to keep use(), or remove use() and accept params synchronously: change
CoursePage signature to ({ params }: { params: { courseId: string } }) and
extract const { courseId } = params; remove the use import, merge imports into a
single React import if present, and delete the empty comment artifact.
| const data = await response.json(); | ||
|
|
||
| if (!data.success) { | ||
| throw new Error(data.error || 'Failed to vote'); | ||
| } | ||
|
|
||
| // Update with server response | ||
| setCurrentVote(data.vote_type); | ||
|
|
||
| // Callback | ||
| if (onVote) { | ||
| onVote(reviewId, data.vote_type); |
There was a problem hiding this comment.
Server vote count is never reconciled after a successful API call.
After a successful vote, only currentVote is updated from the server response (line 109), but voteCount still reflects the optimistic calculation. If the server's actual net count differs (e.g., concurrent voters), the displayed count will silently drift. Consider updating voteCount from data.new_vote_count (or equivalent field) if the API returns it, or at least document why reconciliation is intentionally skipped.
🤖 Prompt for AI Agents
In `@src/components/common/VoteButton.tsx` around lines 102 - 113, The server
response handler updates only setCurrentVote but leaves the displayed voteCount
as the optimistic value; modify the success path in the async response code
handling (where setCurrentVote(...) is called) to also reconcile the displayed
count by reading the server-provided value (e.g., data.new_vote_count or
data.vote_count) and calling the state setter that controls voteCount (the same
state variable updated optimistically), falling back to the existing optimistic
calculation if the field is absent; also ensure the onVote(reviewId,
data.vote_type) callback continues to be invoked after reconciliation.
| } catch (error) { | ||
| console.error('Error voting:', error); | ||
| // Rollback on error | ||
| setCurrentVote(currentVote); | ||
| setVoteCount(voteCount); |
There was a problem hiding this comment.
Rollback relies on stale closure values—correct but fragile and confusing.
currentVote and voteCount on lines 119–120 refer to the values captured at render time (i.e., before the optimistic setState calls on lines 88–89), so rollback accidentally works. However, a future refactor (e.g., using functional state updates elsewhere) could break this assumption. Use the explicitly saved oldVote/oldCount variables for clarity and resilience.
Suggested fix
} catch (error) {
console.error('Error voting:', error);
// Rollback on error
- setCurrentVote(currentVote);
- setVoteCount(voteCount);
+ setCurrentVote(oldVote);
+ setVoteCount(oldCount);
} finally {Note: oldVote and oldCount are declared inside the try block (lines 63–64). Move them before the try so they're accessible in catch:
+ const oldVote = currentVote;
+ const oldCount = voteCount;
+
try {
- const oldVote = currentVote;
- const oldCount = voteCount;🤖 Prompt for AI Agents
In `@src/components/common/VoteButton.tsx` around lines 116 - 120, The rollback in
the catch block uses render-time closure values (currentVote, voteCount) which
is fragile; move the saved snapshot variables oldVote and oldCount out of the
try block (declare them immediately before the try) and change the catch to call
setCurrentVote(oldVote) and setVoteCount(oldCount) so the rollback uses the
explicit saved state; update references in the catch accordingly and keep the
optimistic setCurrentVote/setVoteCount logic inside the try.
| <VoteButton | ||
| key={`${review.id}-${userVote || 'no-vote'}`} | ||
| reviewId={review.id} | ||
| initialVoteType={userVote as any} | ||
| initialVoteCount={review.votes || 0} | ||
| size="md" | ||
| /> |
There was a problem hiding this comment.
Unsafe as any cast bypasses type safety; key-based remount is unnecessary.
Two issues in this block:
-
Line 53:
initialVoteType={userVote as any}silently accepts any string (e.g.,"typo"), which could cause subtle bugs. TypeuserVoteproperly instead:In the
CourseReviewItemsignature (line 15), change:-const CourseReviewItem = ({ review, userVote }: { review: any; userVote?: string | null }) => { +const CourseReviewItem = ({ review, userVote }: { review: any; userVote?: VoteType }) => {
Then
as anybecomes unnecessary:-initialVoteType={userVote as any} +initialVoteType={userVote}
-
Line 51: The
keythat includesuserVoteforces a full unmount/remount ofVoteButtonwhenever vote data loads.VoteButtonalready hasuseEffecthooks that sync on prop changes, so this key is redundant and causes unnecessary component recycling.
🤖 Prompt for AI Agents
In `@src/components/courses/course_page/CoursePageReviews.tsx` around lines 50 -
56, The VoteButton is being passed initialVoteType={userVote as any} and the
component key includes userVote which forces unnecessary remounts; fix by typing
userVote in the CourseReviewItem props/signature to the proper vote union (e.g.,
VoteType | null or 'up'|'down'|null) so the as any cast can be removed when
passing initialVoteType to VoteButton, and simplify the key to only use
review.id (or remove the custom key if the map already keys by id) so VoteButton
re-renders via props/state instead of being unmounted and remounted.
| // Fetch user's votes for these reviews | ||
| if (reviewsData && reviewsData.length > 0) { | ||
| const reviewIds = reviewsData.map(r => r.id).join(','); | ||
|
|
||
| try { | ||
| const response = await fetch(`/api/ratings/vote?review_ids=${reviewIds}`); | ||
| const votesData = await response.json(); | ||
|
|
||
| if (votesData.success) { | ||
| setUserVotes(votesData.votes || {}); | ||
| } | ||
| } catch (error) { | ||
| console.error("Error fetching user votes:", error); | ||
| } |
There was a problem hiding this comment.
URL length risk when passing many review IDs as a query parameter.
All review IDs are joined into a single review_ids query parameter (line 98). For courses with a large number of reviews, this could exceed browser or server URL length limits (~2,000–8,000 chars). Consider batching, using a POST request for fetching votes, or paginating reviews.
🤖 Prompt for AI Agents
In `@src/components/courses/course_page/CoursePageReviews.tsx` around lines 96 -
109, The current vote fetch in the CoursePageReviews component builds a long
query string from reviewsData (reviewIds) and calls
fetch(`/api/ratings/vote?review_ids=...`), which can exceed URL length limits;
change this to send the IDs in the request body (POST) or batch the IDs into
smaller requests: update the fetch call to POST with JSON body { review_ids:
reviewsData.map(r => r.id) }, adjust the endpoint to accept POST if needed, or
implement chunking of reviewIds and aggregate responses before calling
setUserVotes; ensure error handling and that setUserVotes(votesData.votes || {})
is called after combining batched responses.
| import { useState, useEffect, useCallback, useRef } from 'react'; | ||
| import { toast } from 'sonner'; | ||
|
|
||
| export type VoteType = 'helpful' | 'unhelpful' | null; | ||
|
|
||
| interface VoteState { | ||
| [reviewId: string]: VoteType; | ||
| } | ||
|
|
||
| interface VoteCounts { | ||
| [reviewId: string]: { | ||
| helpful: number; | ||
| unhelpful: number; | ||
| }; | ||
| } | ||
|
|
||
| interface UseVotesOptions { | ||
| reviewIds?: string[]; | ||
| initialCounts?: VoteCounts; | ||
| onVoteSuccess?: (reviewId: string, voteType: VoteType) => void; | ||
| onVoteError?: (error: Error) => void; | ||
| } | ||
|
|
||
| interface UseVotesReturn { | ||
| votes: VoteState; | ||
| voteCounts: VoteCounts; | ||
| isLoading: boolean; | ||
| castVote: (reviewId: string, voteType: 'helpful' | 'unhelpful') => Promise<void>; | ||
| removeVote: (reviewId: string) => Promise<void>; | ||
| toggleVote: (reviewId: string, voteType: 'helpful' | 'unhelpful') => Promise<void>; | ||
| getUserVote: (reviewId: string) => VoteType; | ||
| refreshVotes: (reviewIds?: string[]) => Promise<void>; | ||
| } | ||
|
|
||
| export function useVotes(options: UseVotesOptions = {}): UseVotesReturn { | ||
| const { reviewIds = [], initialCounts = {}, onVoteSuccess, onVoteError } = options; | ||
|
|
||
| const [votes, setVotes] = useState<VoteState>({}); | ||
| const [voteCounts, setVoteCounts] = useState<VoteCounts>(initialCounts); | ||
| const [isLoading, setIsLoading] = useState(false); | ||
|
|
||
| // Track pending operations to prevent race conditions | ||
| const pendingOperations = useRef<Set<string>>(new Set()); | ||
|
|
||
| // Cache to store previous states for rollback | ||
| const rollbackCache = useRef<Map<string, { | ||
| vote: VoteType; | ||
| counts: { helpful: number; unhelpful: number }; | ||
| }>>(new Map()); | ||
|
|
||
|
|
||
| //Fetch user votes for specified review IDs | ||
|
|
||
| const fetchUserVotes = useCallback(async (ids: string[]) => { | ||
| if (ids.length === 0) return; | ||
|
|
||
| try { | ||
| setIsLoading(true); | ||
| const response = await fetch(`/api/ratings/vote?review_ids=${ids.join(',')}`); | ||
|
|
||
| if (!response.ok) { | ||
| throw new Error('Failed to fetch votes'); | ||
| } | ||
|
|
||
| const data = await response.json(); | ||
|
|
||
| if (data.success) { | ||
| setVotes(prev => ({ | ||
| ...prev, | ||
| ...data.votes, | ||
| })); | ||
| } | ||
| } catch (error) { | ||
| console.error('Error fetching votes:', error); | ||
| if (onVoteError) { | ||
| onVoteError(error instanceof Error ? error : new Error('Failed to fetch votes')); | ||
| } | ||
| } finally { | ||
| setIsLoading(false); | ||
| } | ||
| }, [onVoteError]); | ||
|
|
||
|
|
||
| //Initial fetch on mount or when reviewIds change | ||
|
|
||
| useEffect(() => { | ||
| if (reviewIds.length > 0) { | ||
| fetchUserVotes(reviewIds); | ||
| } | ||
| }, [reviewIds.join(',')]); // eslint-disable-line react-hooks/exhaustive-deps | ||
|
|
||
|
|
||
| //Update vote counts optimistically | ||
|
|
||
| const updateVoteCountsOptimistically = useCallback( | ||
| (reviewId: string, oldVote: VoteType, newVote: VoteType) => { | ||
| setVoteCounts(prev => { | ||
| const current = prev[reviewId] || { helpful: 0, unhelpful: 0 }; | ||
| let helpful = current.helpful; | ||
| let unhelpful = current.unhelpful; | ||
|
|
||
| // Remove old vote | ||
| if (oldVote === 'helpful') helpful--; | ||
| if (oldVote === 'unhelpful') unhelpful--; | ||
|
|
||
| // Add new vote | ||
| if (newVote === 'helpful') helpful++; | ||
| if (newVote === 'unhelpful') unhelpful++; | ||
|
|
||
| return { | ||
| ...prev, | ||
| [reviewId]: { helpful, unhelpful }, | ||
| }; | ||
| }); | ||
| }, | ||
| [] | ||
| ); | ||
|
|
||
|
|
||
| //Rollback optimistic updates on error | ||
|
|
||
| const rollbackVote = useCallback((reviewId: string) => { | ||
| const cached = rollbackCache.current.get(reviewId); | ||
| if (cached) { | ||
| setVotes(prev => ({ | ||
| ...prev, | ||
| [reviewId]: cached.vote, | ||
| })); | ||
| setVoteCounts(prev => ({ | ||
| ...prev, | ||
| [reviewId]: cached.counts, | ||
| })); | ||
| rollbackCache.current.delete(reviewId); | ||
| } | ||
| }, []); | ||
|
|
||
|
|
||
| //Cast or update a vote | ||
|
|
||
| const castVote = useCallback( | ||
| async (reviewId: string, voteType: 'helpful' | 'unhelpful') => { | ||
| // Prevent concurrent operations on the same review | ||
| if (pendingOperations.current.has(reviewId)) { | ||
| return; | ||
| } | ||
|
|
||
| pendingOperations.current.add(reviewId); | ||
|
|
||
| try { | ||
| const oldVote = votes[reviewId] || null; | ||
| const oldCounts = voteCounts[reviewId] || { helpful: 0, unhelpful: 0 }; | ||
|
|
||
| // Save state for potential rollback | ||
| rollbackCache.current.set(reviewId, { | ||
| vote: oldVote, | ||
| counts: oldCounts, | ||
| }); | ||
|
|
||
| // Optimistic update | ||
| setVotes(prev => ({ | ||
| ...prev, | ||
| [reviewId]: voteType, | ||
| })); | ||
| updateVoteCountsOptimistically(reviewId, oldVote, voteType); | ||
|
|
||
| // API call | ||
| const response = await fetch('/api/ratings/vote', { | ||
| method: 'POST', | ||
| headers: { 'Content-Type': 'application/json' }, | ||
| body: JSON.stringify({ review_id: reviewId, vote_type: voteType }), | ||
| }); | ||
|
|
||
| if (!response.ok) { | ||
| throw new Error('Failed to cast vote'); | ||
| } | ||
|
|
||
| const data = await response.json(); | ||
|
|
||
| if (!data.success) { | ||
| throw new Error(data.error || 'Failed to cast vote'); | ||
| } | ||
|
|
||
| // Update state based on server response | ||
| setVotes(prev => ({ | ||
| ...prev, | ||
| [reviewId]: data.vote_type, | ||
| })); | ||
|
|
||
| // Clear rollback cache on success | ||
| rollbackCache.current.delete(reviewId); | ||
|
|
||
| if (onVoteSuccess) { | ||
| onVoteSuccess(reviewId, data.vote_type); | ||
| } | ||
|
|
||
| } catch (error) { | ||
| console.error('Error casting vote:', error); | ||
|
|
||
| // Rollback on error | ||
| rollbackVote(reviewId); | ||
|
|
||
| toast.error('Failed to cast vote. Please try again.'); | ||
|
|
||
| if (onVoteError) { | ||
| onVoteError(error instanceof Error ? error : new Error('Failed to cast vote')); | ||
| } | ||
| } finally { | ||
| pendingOperations.current.delete(reviewId); | ||
| } | ||
| }, | ||
| [votes, voteCounts, updateVoteCountsOptimistically, rollbackVote, onVoteSuccess, onVoteError] | ||
| ); | ||
|
|
||
| //Remove a vote | ||
| const removeVote = useCallback( | ||
| async (reviewId: string) => { | ||
| if (pendingOperations.current.has(reviewId)) { | ||
| return; | ||
| } | ||
|
|
||
| pendingOperations.current.add(reviewId); | ||
|
|
||
| try { | ||
| const oldVote = votes[reviewId] || null; | ||
| const oldCounts = voteCounts[reviewId] || { helpful: 0, unhelpful: 0 }; | ||
|
|
||
| // Save state for rollback | ||
| rollbackCache.current.set(reviewId, { | ||
| vote: oldVote, | ||
| counts: oldCounts, | ||
| }); | ||
|
|
||
| // Optimistic update | ||
| setVotes(prev => ({ | ||
| ...prev, | ||
| [reviewId]: null, | ||
| })); | ||
| updateVoteCountsOptimistically(reviewId, oldVote, null); | ||
|
|
||
| // API call | ||
| const response = await fetch('/api/ratings/vote', { | ||
| method: 'DELETE', | ||
| headers: { 'Content-Type': 'application/json' }, | ||
| body: JSON.stringify({ review_id: reviewId }), | ||
| }); | ||
|
|
||
| if (!response.ok) { | ||
| throw new Error('Failed to remove vote'); | ||
| } | ||
|
|
||
| const data = await response.json(); | ||
|
|
||
| if (!data.success) { | ||
| throw new Error(data.error || 'Failed to remove vote'); | ||
| } | ||
|
|
||
| // Clear rollback cache | ||
| rollbackCache.current.delete(reviewId); | ||
|
|
||
| if (onVoteSuccess) { | ||
| onVoteSuccess(reviewId, null); | ||
| } | ||
|
|
||
| } catch (error) { | ||
| console.error('Error removing vote:', error); | ||
|
|
||
| // Rollback | ||
| rollbackVote(reviewId); | ||
|
|
||
| toast.error('Failed to remove vote. Please try again.'); | ||
|
|
||
| if (onVoteError) { | ||
| onVoteError(error instanceof Error ? error : new Error('Failed to remove vote')); | ||
| } | ||
| } finally { | ||
| pendingOperations.current.delete(reviewId); | ||
| } | ||
| }, | ||
| [votes, voteCounts, updateVoteCountsOptimistically, rollbackVote, onVoteSuccess, onVoteError] | ||
| ); | ||
|
|
||
| //Toggle vote - if same type, remove; otherwise update | ||
|
|
||
| const toggleVote = useCallback( | ||
| async (reviewId: string, voteType: 'helpful' | 'unhelpful') => { | ||
| const currentVote = votes[reviewId]; | ||
|
|
||
| if (currentVote === voteType) { | ||
| // Same vote - remove it | ||
| await removeVote(reviewId); | ||
| } else { | ||
| // Different vote or no vote - cast it | ||
| await castVote(reviewId, voteType); | ||
| } | ||
| }, | ||
| [votes, castVote, removeVote] | ||
| ); | ||
|
|
||
| /** | ||
| * Get user's vote for a specific review | ||
| */ | ||
| const getUserVote = useCallback( | ||
| (reviewId: string): VoteType => { | ||
| return votes[reviewId] || null; | ||
| }, | ||
| [votes] | ||
| ); | ||
|
|
||
| /** | ||
| * Manually refresh votes | ||
| */ | ||
| const refreshVotes = useCallback( | ||
| async (ids?: string[]) => { | ||
| const idsToFetch = ids || reviewIds; | ||
| if (idsToFetch.length > 0) { | ||
| await fetchUserVotes(idsToFetch); | ||
| } | ||
| }, | ||
| [reviewIds, fetchUserVotes] | ||
| ); | ||
|
|
||
| return { | ||
| votes, | ||
| voteCounts, | ||
| isLoading, | ||
| castVote, | ||
| removeVote, | ||
| toggleVote, | ||
| getUserVote, | ||
| refreshVotes, | ||
| }; | ||
| } No newline at end of file |
There was a problem hiding this comment.
🛠️ Refactor suggestion | 🟠 Major
This hook is entirely unused in the current PR.
Neither VoteButton nor CoursePageReviews consume useVotes. VoteButton manages its own optimistic state inline, and CoursePageReviews fetches votes directly. This hook is dead code as shipped. If it's intended for future use, consider removing it from this PR to reduce review surface and merge risk, or wire it up now to avoid the duplicated logic in the other two files.
🤖 Prompt for AI Agents
In `@src/hooks/useVote.ts` around lines 1 - 332, The useVotes hook is dead code in
this PR—neither useVotes nor its functions (useVotes, castVote, removeVote,
toggleVote, refreshVotes) are consumed by VoteButton or CoursePageReviews;
either remove the hook from this PR or wire it up to replace the duplicated
optimistic-vote logic. To fix: if you want to drop it, delete the useVotes file
and any exports/imports, and ensure VoteButton and CoursePageReviews keep their
current behavior; if you want to adopt it, replace the inline vote logic in
VoteButton and the direct-fetch in CoursePageReviews to call useVotes (import
useVotes, derive votes and voteCounts from the hook, and call
castVote/removeVote/toggleVote/refreshVotes), remove duplicated
optimistic/update code from those components, and ensure props/callbacks use
onVoteSuccess/onVoteError as needed.
| const { data: existingVote, error: checkError } = await supabase | ||
| .from('votes') | ||
| .select('id, vote_type') | ||
| .eq('review_id', review_id) | ||
| .eq('anonymous_id', anonymous_id) | ||
| .single(); | ||
|
|
||
| if (checkError && checkError.code !== 'PGRST116') { | ||
| console.error('Error checking existing vote:', checkError); | ||
| return res.status(500).json({ error: 'Failed to check existing vote' }); | ||
| } | ||
|
|
||
| // Case 1: Same vote type - remove vote (toggle off) | ||
| if (existingVote && existingVote.vote_type === vote_type) { | ||
| const { error: deleteError } = await supabase | ||
| .from('votes') | ||
| .delete() | ||
| .eq('id', existingVote.id); | ||
|
|
||
| if (deleteError) { | ||
| console.error('Error deleting vote:', deleteError); | ||
| return res.status(500).json({ error: 'Failed to remove vote' }); | ||
| } | ||
|
|
||
| return res.status(200).json({ | ||
| success: true, | ||
| action: 'removed', | ||
| vote_type: null, | ||
| }); | ||
| } | ||
|
|
||
| // Case 2: Different vote type - update vote | ||
| if (existingVote && existingVote.vote_type !== vote_type) { | ||
| const { error: updateError } = await supabase | ||
| .from('votes') | ||
| .update({ vote_type, created_at: new Date().toISOString() }) | ||
| .eq('id', existingVote.id); | ||
|
|
||
| if (updateError) { | ||
| console.error('Error updating vote:', updateError); | ||
| return res.status(500).json({ error: 'Failed to update vote' }); | ||
| } | ||
|
|
||
| return res.status(200).json({ | ||
| success: true, | ||
| action: 'updated', | ||
| vote_type, | ||
| }); | ||
| } | ||
|
|
||
| // Case 3: New vote - insert | ||
| const { error: insertError } = await supabase | ||
| .from('votes') | ||
| .insert({ | ||
| review_id, | ||
| anonymous_id, | ||
| vote_type, | ||
| }); | ||
|
|
||
| if (insertError) { | ||
| console.error('Error inserting vote:', insertError); | ||
| return res.status(500).json({ error: 'Failed to cast vote' }); | ||
| } | ||
|
|
||
| return res.status(200).json({ | ||
| success: true, | ||
| action: 'created', | ||
| vote_type, | ||
| }); |
There was a problem hiding this comment.
TOCTOU race condition on the check-then-act vote logic.
Between lines 44–49 (checking the existing vote with .single()) and the subsequent delete/update/insert (lines 57–106), a concurrent request from the same user can interleave and produce unexpected results (e.g., duplicate insert attempts or operating on stale data).
The unique constraint on (review_id, anonymous_id) prevents duplicate inserts (good), but the toggle and update paths still operate on data that could be stale. Consider using an UPSERT with ON CONFLICT logic or a Supabase RPC/database function that atomically handles the vote toggle to eliminate the race window.
🤖 Prompt for AI Agents
In `@src/pages/api/ratings/vote/index.ts` around lines 44 - 112, The current
check-then-act flow around existingVote (the .single() check) plus the
subsequent delete/update/insert on supabase.from('votes') is vulnerable to
TOCTOU races; replace it with an atomic server-side operation by implementing a
Postgres function (e.g., toggle_vote(review_id UUID, anonymous_id TEXT,
vote_type TEXT)) that encapsulates the logic (check existing row, delete if same
type, update if different, insert if missing) and returns the resulting action
and vote_type, then call it via supabase.rpc('toggle_vote', { review_id,
anonymous_id, vote_type }); remove the separate .single()/delete/update/insert
branches and handle rpc errors/response instead to eliminate the race window.
| const reviewIdArray = review_ids.split(',').map(id => id.trim()); | ||
|
|
||
| // Batch fetch votes | ||
| const { data: votes, error: fetchError } = await supabase | ||
| .from('votes') | ||
| .select('review_id, vote_type') | ||
| .eq('anonymous_id', anonymous_id) | ||
| .in('review_id', reviewIdArray); |
There was a problem hiding this comment.
Unbounded review_ids array — no limit on query size.
The review_ids parameter is split by comma with no upper bound, allowing a client to send thousands of IDs in one request. This generates a potentially very large SQL IN clause that can degrade database performance or exceed query limits.
Consider adding a reasonable cap (e.g., 100 IDs):
Proposed fix
const anonymous_id = anonData;
const reviewIdArray = review_ids.split(',').map(id => id.trim());
+
+ if (reviewIdArray.length > 100) {
+ return res.status(400).json({ error: 'Too many review_ids. Max 100 allowed.' });
+ }
// Batch fetch votes📝 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 reviewIdArray = review_ids.split(',').map(id => id.trim()); | |
| // Batch fetch votes | |
| const { data: votes, error: fetchError } = await supabase | |
| .from('votes') | |
| .select('review_id, vote_type') | |
| .eq('anonymous_id', anonymous_id) | |
| .in('review_id', reviewIdArray); | |
| const reviewIdArray = review_ids.split(',').map(id => id.trim()); | |
| if (reviewIdArray.length > 100) { | |
| return res.status(400).json({ error: 'Too many review_ids. Max 100 allowed.' }); | |
| } | |
| // Batch fetch votes | |
| const { data: votes, error: fetchError } = await supabase | |
| .from('votes') | |
| .select('review_id, vote_type') | |
| .eq('anonymous_id', anonymous_id) | |
| .in('review_id', reviewIdArray); |
🤖 Prompt for AI Agents
In `@src/pages/api/ratings/vote/index.ts` around lines 138 - 145, The review_ids
splitting creates an unbounded reviewIdArray used in the supabase
.in('review_id', reviewIdArray) query; enforce a max cap (e.g., 100) on the
number of IDs before querying by validating review_ids and either returning a
400 error if the client exceeds the limit or truncating with a clear behavior
choice; update the code around review_ids, reviewIdArray and the supabase .in
call to check Array.isArray(reviewIdArray) length and respond accordingly
(reject requests >100 IDs or slice to first 100) and include a clear error
message referencing the limit.
| setAll(cookiesToSet) { | ||
| cookiesToSet.forEach(({ name, value, options }) => { | ||
| res.setHeader('Set-Cookie', `${name}=${value}; Path=/; ${options ? Object.entries(options).map(([k, v]) => `${k}=${v}`).join('; ') : ''}`) | ||
| }) |
There was a problem hiding this comment.
res.setHeader('Set-Cookie', ...) overwrites previous cookies — only the last cookie survives.
Inside the forEach, each call to res.setHeader('Set-Cookie', ...) replaces the previous value. Supabase auth typically sets multiple cookies (access token, refresh token), so all but the last will be silently dropped, breaking authentication.
Use res.appendHeader or accumulate all cookies and set them as an array:
Proposed fix (array approach)
setAll(cookiesToSet) {
- cookiesToSet.forEach(({ name, value, options }) => {
- res.setHeader('Set-Cookie', `${name}=${value}; Path=/; ${options ? Object.entries(options).map(([k, v]) => `${k}=${v}`).join('; ') : ''}`)
- })
+ const cookies = cookiesToSet.map(({ name, value, options }) => {
+ const optionStr = options
+ ? Object.entries(options)
+ .map(([k, v]) => (v === true ? k : `${k}=${v}`))
+ .join('; ')
+ : '';
+ return `${name}=${value}; Path=/;${optionStr ? ` ${optionStr}` : ''}`;
+ });
+ res.setHeader('Set-Cookie', cookies);
},Additionally, the manual cookie serialization is fragile — boolean flags like HttpOnly and Secure are incorrectly serialized as HttpOnly=true instead of just HttpOnly. Consider using a proper cookie serialization library (e.g., the cookie package) for correctness.
📝 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.
| setAll(cookiesToSet) { | |
| cookiesToSet.forEach(({ name, value, options }) => { | |
| res.setHeader('Set-Cookie', `${name}=${value}; Path=/; ${options ? Object.entries(options).map(([k, v]) => `${k}=${v}`).join('; ') : ''}`) | |
| }) | |
| setAll(cookiesToSet) { | |
| const cookies = cookiesToSet.map(({ name, value, options }) => { | |
| const optionStr = options | |
| ? Object.entries(options) | |
| .map(([k, v]) => (v === true ? k : `${k}=${v}`)) | |
| .join('; ') | |
| : ''; | |
| return `${name}=${value}; Path=/;${optionStr ? ` ${optionStr}` : ''}`; | |
| }); | |
| res.setHeader('Set-Cookie', cookies); | |
| } |
🤖 Prompt for AI Agents
In `@src/utils/supabase/server-pages.ts` around lines 16 - 19, The current setAll
implementation calls res.setHeader('Set-Cookie', ...) inside the forEach which
overwrites previous cookies so only the last cookie remains; change setAll (the
function handling cookiesToSet) to either accumulate serialized cookie strings
into an array and call res.setHeader('Set-Cookie', cookiesArray) once, or call
res.appendHeader('Set-Cookie', cookieString) for each cookie instead of
setHeader; also replace the manual serialization logic with a proper serializer
(e.g., use cookie.serialize) to ensure boolean flags like HttpOnly and Secure
are emitted correctly (reference the setAll function, cookiesToSet items with
{name, value, options}, and the res.setHeader/res.appendHeader calls).
Description
Implemented a complete voting system for course and professor reviews with Reddit-style UI, database triggers for automatic vote counting, and proper RLS security policies.
Users can now vote reviews as "helpful" or "unhelpful" with visual feedback, automatic vote count calculations, and persistent vote states across sessions.
Related Issue
Works on issue#16
Types of changes
Checklist:
Additional Notes
Technical Details:
SECURITY DEFINERonupdate_review_votes()trigger to bypass RLS policiesuseEffecthooks inVoteButtonto handle asynchronous vote data loadingserver-pages.ts) for Pages Router compatibilityDatabase Changes:
votestable with unique constraint on (review_id, anonymous_id)helpful_count - unhelpful_countvotes.review_idfor query performanceSecurity:
get_anonymous_id()functionSummary by CodeRabbit
New Features
Updates