Voting
One vote per user per product with unvote toggle, IP-level duplicate detection, denormalized counts.
Every authenticated user can upvote any product, and click again to un-vote. The vote count drives live sorting on the homepage and the top-N dofollow promotion rule.
User flow
- Visitor clicks the vote button on a
ProductCard - If not signed in → server action returns an "auth required" error; UI routes them to sign-in
- If signed in → the server action either inserts or deletes a vote, decrementing/incrementing
products.vote_countin the same transaction - The UI updates with the new count
See lib/actions/vote.ts.
Denormalization
products.vote_count is kept in sync via a transaction:
await db.transaction(async (tx) => {
await tx.insert(votes).values(...)
await tx.update(products).set({
voteCount: sql`GREATEST(vote_count + 1, 0)`, // or -1 for unvote
}).where(eq(products.id, productId))
})GREATEST(..., 0) guards against stray negative counts if an unvote runs against missing data.
Unvote (toggle)
Clicking an already-voted product removes the vote. The action:
- Looks up the existing
(userId, productId)row - If present: deletes it + decrements count
- If absent: inserts + increments
So the button is an idempotent toggle.
Rate limit
The rate limit is per user, configured by two settings (not hardcoded):
votes_per_window— max votes in the window (default from lib/settings-shared.ts)votes_window_seconds— window size in seconds
When triggered, the server action returns a resetMinutes value so the UI can show "try again in N minutes".
IP-level duplicate detection
Every vote stores the client IP (x-forwarded-for). Before inserting, the action checks for an existing vote on the same product from the same IP (by a different user). If found, it rejects with "Already voted from this network".
This prevents the simplest multi-account attack (different Google accounts, same device).
Unique constraint
At the DB level, (user_id, product_id) is unique on the votes table — so even if rate limit and IP check both fail to catch something, the DB rejects duplicates.
Analytics
No vote events are sent to Plausible or any other analytics provider. If you want this:
// After the successful insert, in the server action:
// (Plausible custom event support requires the client-side helper)Vote history
Users' votes are not surfaced on a "my votes" page by default. To add this:
const myVotes = await db.select()
.from(votes)
.innerJoin(products, eq(votes.productId, products.id))
.where(eq(votes.userId, session.user.id))
.orderBy(desc(votes.createdAt))Auditing drift
If the denormalized count ever drifts (rare, but possible if a transaction was killed mid-way):
UPDATE products p
SET vote_count = (SELECT COUNT(*) FROM votes WHERE product_id = p.id);