-
Notifications
You must be signed in to change notification settings - Fork 0
Add anonymous feedback sessions and vote support #26
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,108 @@ | ||
| /** | ||
| * POST /api/auth/merge-anonymous | ||
| * | ||
| * Called after login/signup to merge any anonymous feedback and votes | ||
| * into the authenticated user's account. Reads the `veerify_anon_session` | ||
| * cookie, transfers ownership of feedback and votes, then deletes the | ||
| * anonymous session and clears the cookie. | ||
| */ | ||
|
|
||
| import { eq, and, sql } from 'drizzle-orm' | ||
| import { createSuccessResponse } from '~/server/utils/response' | ||
| import { requireAuth } from '~/server/utils/auth-middleware' | ||
| import { readAnonToken, clearAnonCookie } from '~/server/utils/anonymous-session' | ||
| import { db } from '~/server/database/drizzle' | ||
| import { anonymousSession, feedback, vote } from '~/server/database/schema/feedback' | ||
|
|
||
| export default defineEventHandler(async (event) => { | ||
| const session = await requireAuth(event) | ||
| const token = readAnonToken(event) | ||
|
|
||
| if (!token) { | ||
| return createSuccessResponse({ merged: false, reason: 'no_anonymous_session' }) | ||
| } | ||
|
|
||
| // Look up the anonymous session | ||
| const [anonRow] = await db | ||
| .select() | ||
| .from(anonymousSession) | ||
| .where(eq(anonymousSession.token, token)) | ||
| .limit(1) | ||
|
|
||
| if (!anonRow) { | ||
| clearAnonCookie(event) | ||
| return createSuccessResponse({ merged: false, reason: 'session_not_found' }) | ||
| } | ||
|
|
||
| const anonId = anonRow.id | ||
| const userId = session.user.id | ||
|
|
||
| // 1. Transfer feedback ownership | ||
| await db | ||
| .update(feedback) | ||
| .set({ | ||
| authorUserId: userId, | ||
| authorSessionId: null, | ||
| updatedAt: new Date(), | ||
| }) | ||
| .where(eq(feedback.authorSessionId, anonId)) | ||
|
|
||
| // 2. Transfer votes — but skip duplicates where the user already voted | ||
| // on the same feedback item. | ||
| // First, find anonymous votes that conflict with existing user votes. | ||
| const anonVotes = await db | ||
| .select({ id: vote.id, feedbackId: vote.feedbackId }) | ||
| .from(vote) | ||
| .where(eq(vote.voterSessionId, anonId)) | ||
|
|
||
| const userVotes = await db | ||
| .select({ feedbackId: vote.feedbackId }) | ||
| .from(vote) | ||
| .where(eq(vote.voterUserId, userId)) | ||
|
|
||
| const userVotedFeedbackIds = new Set(userVotes.map((v) => v.feedbackId)) | ||
|
|
||
| const duplicateVoteIds: string[] = [] | ||
| const transferVoteIds: string[] = [] | ||
|
|
||
| for (const v of anonVotes) { | ||
| if (userVotedFeedbackIds.has(v.feedbackId)) { | ||
| duplicateVoteIds.push(v.id) | ||
| } else { | ||
| transferVoteIds.push(v.id) | ||
| } | ||
| } | ||
|
|
||
| // Delete duplicate votes and decrement feedback vote counts | ||
| for (const dupId of duplicateVoteIds) { | ||
| const dupVote = anonVotes.find((v) => v.id === dupId)! | ||
| await db.delete(vote).where(eq(vote.id, dupId)) | ||
| await db | ||
| .update(feedback) | ||
| .set({ voteCount: sql`${feedback.voteCount} - 1`, updatedAt: new Date() }) | ||
| .where(eq(feedback.id, dupVote.feedbackId)) | ||
| } | ||
|
|
||
| // Transfer non-duplicate votes to the user | ||
| if (transferVoteIds.length > 0) { | ||
| for (const transferId of transferVoteIds) { | ||
| await db | ||
| .update(vote) | ||
| .set({ voterUserId: userId, voterSessionId: null }) | ||
| .where(eq(vote.id, transferId)) | ||
| } | ||
| } | ||
|
|
||
| // 3. Delete the anonymous session | ||
| await db.delete(anonymousSession).where(eq(anonymousSession.id, anonId)) | ||
|
|
||
| // 4. Clear the cookie | ||
| clearAnonCookie(event) | ||
|
|
||
| return createSuccessResponse({ | ||
| merged: true, | ||
| feedbackTransferred: anonVotes.length > 0 || transferVoteIds.length > 0, | ||
| votesTransferred: transferVoteIds.length, | ||
| duplicateVotesRemoved: duplicateVoteIds.length, | ||
| }) | ||
| }) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,11 +1,12 @@ | ||
| import { eq, and, sql } from 'drizzle-orm' | ||
| import { createErrorResponse, createSuccessResponse, ErrorCode } from '~/server/utils/response' | ||
| import { requireAuth } from '~/server/utils/auth-middleware' | ||
| import { optionalAuth } from '~/server/utils/auth-middleware' | ||
| import { getOrCreateAnonSession } from '~/server/utils/anonymous-session' | ||
| import { db } from '~/server/database/drizzle' | ||
| import { feedback, vote } from '~/server/database/schema/feedback' | ||
|
|
||
| export default defineEventHandler(async (event) => { | ||
| const session = await requireAuth(event) | ||
| const session = await optionalAuth(event) | ||
|
|
||
| const id = getRouterParam(event, 'id') | ||
| if (!id) { | ||
|
|
@@ -26,15 +27,24 @@ export default defineEventHandler(async (event) => { | |
| }) | ||
| } | ||
|
|
||
| // Check if user already voted | ||
| const [existingVote] = await db | ||
| .select() | ||
| .from(vote) | ||
| .where(and(eq(vote.feedbackId, id), eq(vote.voterUserId, session.user.id))) | ||
| .limit(1) | ||
| // Determine voter identity: authenticated user or anonymous session | ||
| const userId = session?.user?.id || null | ||
| let anonSessionId: string | null = null | ||
|
|
||
| if (!userId) { | ||
| const anonSession = await getOrCreateAnonSession(event) | ||
| anonSessionId = anonSession.id | ||
| } | ||
|
|
||
| // Build the condition for finding an existing vote | ||
| const voteCondition = userId | ||
| ? and(eq(vote.feedbackId, id), eq(vote.voterUserId, userId)) | ||
| : and(eq(vote.feedbackId, id), eq(vote.voterSessionId, anonSessionId!)) | ||
|
Comment on lines
+40
to
+42
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When Useful? React with 👍 / 👎. |
||
|
|
||
| const [existingVote] = await db.select().from(vote).where(voteCondition).limit(1) | ||
|
|
||
| if (existingVote) { | ||
| // Remove vote | ||
| // Remove vote (toggle off) | ||
| await db.delete(vote).where(eq(vote.id, existingVote.id)) | ||
| await db | ||
| .update(feedback) | ||
|
|
@@ -48,7 +58,8 @@ export default defineEventHandler(async (event) => { | |
| await db.insert(vote).values({ | ||
| id: crypto.randomUUID(), | ||
| feedbackId: id, | ||
| voterUserId: session.user.id, | ||
| voterUserId: userId, | ||
| voterSessionId: anonSessionId, | ||
| createdAt: new Date(), | ||
| }) | ||
| await db | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This handler now uses
optionalAuth, but it never checks whether the target feedback belongs to a public project before accepting an unauthenticated vote. As a result, any unauthenticated request with a known feedback ID from a private project can still mutate vote counts, which violates private-project access expectations.Useful? React with 👍 / 👎.