Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 11 additions & 5 deletions pages/p/[orgSlug]/[projectSlug].vue
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,7 @@
v-for="item in feedbackItems"
:key="item.id"
class="rounded-lg border bg-card hover:shadow-sm transition-shadow"
:class="item.isOwn ? 'ring-2 ring-primary/30' : ''"
>
<div class="flex items-start gap-4 p-4">
<!-- Vote Button -->
Expand Down Expand Up @@ -162,7 +163,11 @@
{{ item.body }}
</p>
<div class="flex items-center gap-4 text-xs text-muted-foreground">
<span v-if="item.authorName">
<span v-if="item.isOwn" class="text-primary font-medium">
<Icon name="lucide:star" class="w-3 h-3 inline" />
Your submission
</span>
<span v-else-if="item.authorName">
<Icon name="lucide:user" class="w-3 h-3 inline" />
{{ item.authorName }}
</span>
Expand Down Expand Up @@ -267,6 +272,9 @@
type="email"
placeholder="you@example.com"
/>
<p class="text-xs text-muted-foreground">
Provide your email to receive updates on comments and status changes.
</p>
</div>
</div>
<DialogFooter>
Expand Down Expand Up @@ -428,16 +436,14 @@ export default {
},

async handleVote(item) {
// Voting requires auth — redirect or show message
try {
const response = await $fetch(`/api/feedback/${item.id}/vote`, { method: 'POST' })
const data = response?.data
item.voteCount = data.voteCount
item.hasVoted = data.voted
} catch (err) {
if (err?.statusCode === 401) {
alert('Please sign in to vote on feedback.')
}
console.error('Error voting:', err)
alert('Failed to vote. Please try again.')
}
},

Expand Down
108 changes: 108 additions & 0 deletions server/api/auth/merge-anonymous.post.ts
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,
})
})
31 changes: 21 additions & 10 deletions server/api/feedback/[id]/vote.post.ts
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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Gate anonymous voting to public feedback items

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 👍 / 👎.


const id = getRouterParam(event, 'id')
if (!id) {
Expand All @@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Check session vote when authenticated before inserting vote

When session.user is present, voteCondition only looks at voterUserId, so an existing anonymous vote from the same browser (voterSessionId) is ignored. If a person votes anonymously and then authenticates before the merge step runs, this branch inserts a second vote row and increments feedback.voteCount again, allowing the same person to be counted twice for one feedback item.

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)
Expand All @@ -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
Expand Down
29 changes: 21 additions & 8 deletions server/api/feedback/index.get.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { z } from 'zod'
import { eq, and, desc, asc, count, sql } from 'drizzle-orm'
import { createErrorResponse, createSuccessResponse, ErrorCode } from '~/server/utils/response'
import { optionalAuth } from '~/server/utils/auth-middleware'
import { getAnonSession } from '~/server/utils/anonymous-session'
import { validateQuery } from '~/server/utils/validation'
import { db } from '~/server/database/drizzle'
import { feedback, feedbackCategory, vote } from '~/server/database/schema/feedback'
Expand All @@ -20,6 +21,7 @@ const listFeedbackQuerySchema = z.object({

export default defineEventHandler(async (event) => {
const session = await optionalAuth(event)
const anonSession = !session?.user ? await getAnonSession(event) : null
const query = validateQuery(event, listFeedbackQuerySchema)

// Build conditions
Expand Down Expand Up @@ -61,23 +63,34 @@ export default defineEventHandler(async (event) => {
.limit(query.limit)
.offset(offset)

// If authenticated, check which items the user has voted on
let userVotes: Set<string> = new Set()
if (session?.user) {
const feedbackIds = items.map((i) => i.feedback.id)
if (feedbackIds.length > 0) {
// Check which items the viewer has voted on (authenticated user or anonymous session)
let voterVotes: Set<string> = new Set()
const feedbackIds = items.map((i) => i.feedback.id)
if (feedbackIds.length > 0) {
if (session?.user) {
const votes = await db
.select({ feedbackId: vote.feedbackId })
.from(vote)
.where(and(eq(vote.voterUserId, session.user.id)))
userVotes = new Set(votes.map((v) => v.feedbackId))
.where(eq(vote.voterUserId, session.user.id))
voterVotes = new Set(votes.map((v) => v.feedbackId))
} else if (anonSession) {
const votes = await db
.select({ feedbackId: vote.feedbackId })
.from(vote)
.where(eq(vote.voterSessionId, anonSession.id))
voterVotes = new Set(votes.map((v) => v.feedbackId))
}
}

const result = items.map((item) => ({
...item.feedback,
category: item.category,
hasVoted: session?.user ? userVotes.has(item.feedback.id) : undefined,
hasVoted: voterVotes.has(item.feedback.id),
isOwn: session?.user
? item.feedback.authorUserId === session.user.id
: anonSession
? item.feedback.authorSessionId === anonSession.id
: false,
}))

return createSuccessResponse({
Expand Down
9 changes: 9 additions & 0 deletions server/api/feedback/index.post.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { z } from 'zod'
import { eq } from 'drizzle-orm'
import { createErrorResponse, createSuccessResponse, ErrorCode } from '~/server/utils/response'
import { optionalAuth } from '~/server/utils/auth-middleware'
import { getOrCreateAnonSession } from '~/server/utils/anonymous-session'
import { validateBody } from '~/server/utils/validation'
import { db } from '~/server/database/drizzle'
import { feedback, project, feedbackCategory } from '~/server/database/schema/feedback'
Expand Down Expand Up @@ -64,6 +65,13 @@ export default defineEventHandler(async (event) => {
})
}

// For anonymous users, get or create an anonymous session
let anonSessionId: string | null = null
if (!session?.user) {
const anonSession = await getOrCreateAnonSession(event)
anonSessionId = anonSession.id
}

const now = new Date()
const [created] = await db
.insert(feedback)
Expand All @@ -75,6 +83,7 @@ export default defineEventHandler(async (event) => {
body: body.body,
status: 'open',
authorUserId: session?.user?.id || null,
authorSessionId: anonSessionId,
authorName: session?.user ? session.user.name : body.authorName || null,
authorEmail: session?.user ? session.user.email : body.authorEmail || null,
voteCount: 0,
Expand Down
31 changes: 30 additions & 1 deletion server/api/public/[orgSlug]/[projectSlug]/feedback.get.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import { z } from 'zod'
import { eq, and, desc, asc, count } from 'drizzle-orm'
import { createErrorResponse, createSuccessResponse, ErrorCode } from '~/server/utils/response'
import { optionalAuth } from '~/server/utils/auth-middleware'
import { getAnonSession } from '~/server/utils/anonymous-session'
import { validateQuery } from '~/server/utils/validation'
import { db } from '~/server/database/drizzle'
import { project, feedback, feedbackCategory } from '~/server/database/schema/feedback'
import { project, feedback, feedbackCategory, vote } from '~/server/database/schema/feedback'
import { organization } from '~/server/database/schema/auth'

const querySchema = z.object({
Expand All @@ -27,6 +29,8 @@ export default defineEventHandler(async (event) => {
})
}

const session = await optionalAuth(event)
const anonSession = !session?.user ? await getAnonSession(event) : null
const query = validateQuery(event, querySchema)

// Resolve org + project
Expand Down Expand Up @@ -81,6 +85,25 @@ export default defineEventHandler(async (event) => {
.limit(query.limit)
.offset(offset)

// Check which items the viewer has voted on (authenticated user or anonymous session)
let voterVotes: Set<string> = new Set()
const feedbackIds = items.map((i) => i.feedback.id)
if (feedbackIds.length > 0) {
if (session?.user) {
const votes = await db
.select({ feedbackId: vote.feedbackId })
.from(vote)
.where(eq(vote.voterUserId, session.user.id))
voterVotes = new Set(votes.map((v) => v.feedbackId))
} else if (anonSession) {
const votes = await db
.select({ feedbackId: vote.feedbackId })
.from(vote)
.where(eq(vote.voterSessionId, anonSession.id))
voterVotes = new Set(votes.map((v) => v.feedbackId))
}
}

const result = items.map((item) => ({
id: item.feedback.id,
title: item.feedback.title,
Expand All @@ -91,6 +114,12 @@ export default defineEventHandler(async (event) => {
authorName: item.feedback.authorName,
isPinned: item.feedback.isPinned,
createdAt: item.feedback.createdAt,
hasVoted: voterVotes.has(item.feedback.id),
isOwn: session?.user
? item.feedback.authorUserId === session.user.id
: anonSession
? item.feedback.authorSessionId === anonSession.id
: false,
category: item.category
? {
id: item.category.id,
Expand Down
9 changes: 9 additions & 0 deletions server/api/public/[orgSlug]/[projectSlug]/feedback.post.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { z } from 'zod'
import { eq, and } from 'drizzle-orm'
import { createErrorResponse, createSuccessResponse, ErrorCode } from '~/server/utils/response'
import { optionalAuth } from '~/server/utils/auth-middleware'
import { getOrCreateAnonSession } from '~/server/utils/anonymous-session'
import { validateBody } from '~/server/utils/validation'
import { db } from '~/server/database/drizzle'
import { project, feedback, feedbackCategory } from '~/server/database/schema/feedback'
Expand Down Expand Up @@ -79,6 +80,13 @@ export default defineEventHandler(async (event) => {
})
}

// For anonymous users, get or create an anonymous session
let anonSessionId: string | null = null
if (!session?.user) {
const anonSession = await getOrCreateAnonSession(event)
anonSessionId = anonSession.id
}

const now = new Date()
const [created] = await db
.insert(feedback)
Expand All @@ -90,6 +98,7 @@ export default defineEventHandler(async (event) => {
body: body.body,
status: 'open',
authorUserId: session?.user?.id || null,
authorSessionId: anonSessionId,
authorName: session?.user ? session.user.name : body.authorName || null,
authorEmail: session?.user ? session.user.email : body.authorEmail || null,
voteCount: 0,
Expand Down
Loading
Loading