@@ -208,7 +209,7 @@
-
{{ item.title }}
+
{{ item.title }}
{{ item.body }}
@@ -236,7 +237,7 @@
:data-testid="`feedback-item-delete-${item.id}`"
class="p-1 text-muted-foreground hover:text-destructive transition-colors"
title="Delete feedback"
- @click="confirmDelete(item)"
+ @click.prevent="confirmDelete(item)"
>
@@ -244,7 +245,7 @@
-
+
diff --git a/server/api/feedback/[id].get.ts b/server/api/feedback/[id].get.ts
deleted file mode 100644
index 112bc80..0000000
--- a/server/api/feedback/[id].get.ts
+++ /dev/null
@@ -1,96 +0,0 @@
-/**
- * Get Feedback Endpoint
- * Retrieves a single feedback item by ID
- *
- * @openapi
- * /api/feedback/{id}:
- * get:
- * tags: [Feedback]
- * summary: Get feedback by ID
- * description: Retrieves detailed information about a specific feedback item
- * operationId: getFeedback
- * parameters:
- * - name: id
- * in: path
- * description: Feedback ID
- * required: true
- * schema:
- * type: string
- * responses:
- * 200:
- * description: Feedback details
- * content:
- * application/json:
- * schema:
- * type: object
- * properties:
- * success:
- * type: boolean
- * example: true
- * data:
- * allOf:
- * - $ref: '#/components/schemas/Feedback'
- * - type: object
- * properties:
- * author:
- * $ref: '#/components/schemas/User'
- * project:
- * $ref: '#/components/schemas/Project'
- * hasVoted:
- * type: boolean
- * description: Whether the current user has voted (if authenticated)
- * 404:
- * description: Feedback not found
- * content:
- * application/json:
- * schema:
- * $ref: '#/components/schemas/Error'
- * 501:
- * description: Not implemented
- * content:
- * application/json:
- * schema:
- * $ref: '#/components/schemas/Error'
- */
-
-import { createErrorResponse, createSuccessResponse, ErrorCode } from '~/server/utils/response'
-import { optionalAuth } from '~/server/utils/auth-middleware'
-import { requireRateLimit, rateLimits } from '~/server/utils/rate-limit'
-
-export default defineEventHandler(async (event) => {
- // Optional authentication - public endpoint
- const session = await optionalAuth(event)
-
- // Rate limiting (relaxed for public read endpoint)
- await requireRateLimit(event, rateLimits.relaxed)
-
- // Get ID from route params
- const id = getRouterParam(event, 'id')
-
- if (!id) {
- throw createError({
- statusCode: 400,
- statusMessage: 'Bad Request',
- data: createErrorResponse(
- ErrorCode.VALIDATION_ERROR,
- 'Feedback ID is required'
- )
- })
- }
-
- // TODO: Implement feedback retrieval
- // 1. Query feedback by ID
- // 2. Include author information
- // 3. Include project information
- // 4. If authenticated, check if user has voted
- // 5. Return feedback with additional metadata
-
- throw createError({
- statusCode: 501,
- statusMessage: 'Not Implemented',
- data: createErrorResponse(
- ErrorCode.NOT_IMPLEMENTED,
- 'Feedback retrieval is not yet implemented'
- )
- })
-})
diff --git a/server/api/feedback/[id]/comments.get.ts b/server/api/feedback/[id]/comments.get.ts
new file mode 100644
index 0000000..289a7f6
--- /dev/null
+++ b/server/api/feedback/[id]/comments.get.ts
@@ -0,0 +1,73 @@
+import { eq, and, asc, isNull } from 'drizzle-orm'
+import { createErrorResponse, createSuccessResponse, ErrorCode } from '~/server/utils/response'
+import { optionalAuth } from '~/server/utils/auth-middleware'
+import { requirePublicProject, requireProjectAccess } from '~/server/utils/project-access'
+import { db } from '~/server/database/drizzle'
+import { feedback, feedbackComment } from '~/server/database/schema/feedback'
+import { user } from '~/server/database/schema/auth'
+
+export default defineEventHandler(async (event) => {
+ const session = await optionalAuth(event)
+
+ const id = getRouterParam(event, 'id')
+ if (!id) {
+ throw createError({
+ statusCode: 400,
+ statusMessage: 'Bad Request',
+ data: createErrorResponse(ErrorCode.VALIDATION_ERROR, 'Feedback ID is required'),
+ })
+ }
+
+ // Verify feedback exists
+ const [fb] = await db.select().from(feedback).where(eq(feedback.id, id)).limit(1)
+ if (!fb) {
+ throw createError({
+ statusCode: 404,
+ statusMessage: 'Not Found',
+ data: createErrorResponse(ErrorCode.NOT_FOUND, 'Feedback not found'),
+ })
+ }
+
+ // Verify access to project
+ let isTeamMember = false
+ if (session?.user) {
+ try {
+ await requireProjectAccess(fb.projectId, session.user.id)
+ isTeamMember = true
+ } catch {
+ // If user doesn't have team access, check if project is public
+ await requirePublicProject(fb.projectId)
+ }
+ } else {
+ await requirePublicProject(fb.projectId)
+ }
+
+ // Build where clause - hide internal comments from non-team-members
+ const conditions = [eq(feedbackComment.feedbackId, id)]
+ if (!isTeamMember) {
+ conditions.push(eq(feedbackComment.isInternal, false))
+ }
+
+ // Fetch comments ordered by creation time
+ const comments = await db
+ .select({
+ comment: feedbackComment,
+ authorUser: {
+ id: user.id,
+ name: user.name,
+ email: user.email,
+ image: user.image,
+ },
+ })
+ .from(feedbackComment)
+ .leftJoin(user, eq(feedbackComment.authorUserId, user.id))
+ .where(and(...conditions))
+ .orderBy(asc(feedbackComment.createdAt))
+
+ const result = comments.map((row) => ({
+ ...row.comment,
+ author: row.comment.authorUserId ? row.authorUser : null,
+ }))
+
+ return createSuccessResponse(result)
+})
diff --git a/server/api/feedback/[id]/comments.post.ts b/server/api/feedback/[id]/comments.post.ts
new file mode 100644
index 0000000..513f268
--- /dev/null
+++ b/server/api/feedback/[id]/comments.post.ts
@@ -0,0 +1,150 @@
+import { z } from 'zod'
+import { eq, sql } from 'drizzle-orm'
+import { createErrorResponse, createSuccessResponse, ErrorCode } from '~/server/utils/response'
+import { optionalAuth } from '~/server/utils/auth-middleware'
+import { requirePublicProject, requireProjectAccess } from '~/server/utils/project-access'
+import { getOrCreateAnonSession } from '~/server/utils/anonymous-session'
+import { validateBody } from '~/server/utils/validation'
+import { db } from '~/server/database/drizzle'
+import { feedback, feedbackComment } from '~/server/database/schema/feedback'
+import { user } from '~/server/database/schema/auth'
+
+const createCommentSchema = z.object({
+ body: z.string().min(1, 'Comment body is required').max(5000, 'Comment too long'),
+ parentCommentId: z.string().optional().nullable(),
+ isInternal: z.boolean().optional().default(false),
+ authorName: z.string().min(1).max(100).optional(),
+ authorEmail: z.string().email().optional(),
+})
+
+export default defineEventHandler(async (event) => {
+ const session = await optionalAuth(event)
+ const body = await validateBody(event, createCommentSchema)
+
+ const id = getRouterParam(event, 'id')
+ if (!id) {
+ throw createError({
+ statusCode: 400,
+ statusMessage: 'Bad Request',
+ data: createErrorResponse(ErrorCode.VALIDATION_ERROR, 'Feedback ID is required'),
+ })
+ }
+
+ // Verify feedback exists and is not locked
+ const [fb] = await db.select().from(feedback).where(eq(feedback.id, id)).limit(1)
+ if (!fb) {
+ throw createError({
+ statusCode: 404,
+ statusMessage: 'Not Found',
+ data: createErrorResponse(ErrorCode.NOT_FOUND, 'Feedback not found'),
+ })
+ }
+
+ if (fb.isLocked) {
+ throw createError({
+ statusCode: 403,
+ statusMessage: 'Forbidden',
+ data: createErrorResponse(ErrorCode.FORBIDDEN, 'This feedback is locked and does not accept new comments'),
+ })
+ }
+
+ // Verify access - internal comments require team membership
+ let isTeamMember = false
+ if (session?.user) {
+ try {
+ await requireProjectAccess(fb.projectId, session.user.id)
+ isTeamMember = true
+ } catch {
+ await requirePublicProject(fb.projectId)
+ }
+ } else {
+ await requirePublicProject(fb.projectId)
+ }
+
+ // Internal comments require team membership
+ if (body.isInternal && !isTeamMember) {
+ throw createError({
+ statusCode: 403,
+ statusMessage: 'Forbidden',
+ data: createErrorResponse(ErrorCode.FORBIDDEN, 'Only team members can post internal comments'),
+ })
+ }
+
+ // Anonymous users must provide a name
+ if (!session?.user && !body.authorName) {
+ throw createError({
+ statusCode: 400,
+ statusMessage: 'Bad Request',
+ data: createErrorResponse(ErrorCode.VALIDATION_ERROR, 'Name is required for anonymous comments'),
+ })
+ }
+
+ // Validate parent comment if provided
+ if (body.parentCommentId) {
+ const [parent] = await db
+ .select()
+ .from(feedbackComment)
+ .where(eq(feedbackComment.id, body.parentCommentId))
+ .limit(1)
+ if (!parent || parent.feedbackId !== id) {
+ throw createError({
+ statusCode: 400,
+ statusMessage: 'Bad Request',
+ data: createErrorResponse(ErrorCode.VALIDATION_ERROR, 'Invalid parent comment'),
+ })
+ }
+ }
+
+ // 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(feedbackComment)
+ .values({
+ id: crypto.randomUUID(),
+ feedbackId: id,
+ parentCommentId: body.parentCommentId || null,
+ body: body.body,
+ 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,
+ isInternal: body.isInternal,
+ createdAt: now,
+ updatedAt: now,
+ })
+ .returning()
+
+ // Increment comment count on the feedback item
+ await db
+ .update(feedback)
+ .set({ commentCount: sql`${feedback.commentCount} + 1`, updatedAt: now })
+ .where(eq(feedback.id, id))
+
+ // Fetch author info for the response
+ let author = null
+ if (session?.user) {
+ const [authorUser] = await db
+ .select({
+ id: user.id,
+ name: user.name,
+ email: user.email,
+ image: user.image,
+ })
+ .from(user)
+ .where(eq(user.id, session.user.id))
+ .limit(1)
+ author = authorUser || null
+ }
+
+ setResponseStatus(event, 201)
+ return createSuccessResponse({
+ ...created,
+ author,
+ })
+})
diff --git a/server/api/feedback/[id]/index.get.ts b/server/api/feedback/[id]/index.get.ts
index 2be4de6..aed5cb2 100644
--- a/server/api/feedback/[id]/index.get.ts
+++ b/server/api/feedback/[id]/index.get.ts
@@ -4,7 +4,8 @@ import { optionalAuth } from '~/server/utils/auth-middleware'
import { requirePublicProject, requireProjectAccess } from '~/server/utils/project-access'
import { getAnonSession } from '~/server/utils/anonymous-session'
import { db } from '~/server/database/drizzle'
-import { feedback, feedbackCategory, vote } from '~/server/database/schema/feedback'
+import { feedback, feedbackCategory, vote, project } from '~/server/database/schema/feedback'
+import { user } from '~/server/database/schema/auth'
export default defineEventHandler(async (event) => {
const session = await optionalAuth(event)
@@ -45,6 +46,34 @@ export default defineEventHandler(async (event) => {
await requirePublicProject(item.feedback.projectId)
}
+ // Get author info if feedback was created by a registered user
+ let author = null
+ if (item.feedback.authorUserId) {
+ const [authorUser] = await db
+ .select({
+ id: user.id,
+ name: user.name,
+ email: user.email,
+ image: user.image,
+ })
+ .from(user)
+ .where(eq(user.id, item.feedback.authorUserId))
+ .limit(1)
+ author = authorUser || null
+ }
+
+ // Get project info
+ const [proj] = await db
+ .select({
+ id: project.id,
+ name: project.name,
+ slug: project.slug,
+ isPublic: project.isPublic,
+ })
+ .from(project)
+ .where(eq(project.id, item.feedback.projectId))
+ .limit(1)
+
// Check if viewer has voted
let hasVoted = false
if (session?.user) {
@@ -72,6 +101,8 @@ export default defineEventHandler(async (event) => {
return createSuccessResponse({
...item.feedback,
category: item.category,
+ author,
+ project: proj,
hasVoted,
isOwn,
})
diff --git a/server/api/feedback/[id]/status.patch.ts b/server/api/feedback/[id]/status.patch.ts
new file mode 100644
index 0000000..108e252
--- /dev/null
+++ b/server/api/feedback/[id]/status.patch.ts
@@ -0,0 +1,49 @@
+import { z } from 'zod'
+import { eq } from 'drizzle-orm'
+import { createErrorResponse, createSuccessResponse, ErrorCode } from '~/server/utils/response'
+import { requireAuth } from '~/server/utils/auth-middleware'
+import { requireProjectAccess } from '~/server/utils/project-access'
+import { validateBody } from '~/server/utils/validation'
+import { db } from '~/server/database/drizzle'
+import { feedback } from '~/server/database/schema/feedback'
+
+const updateStatusSchema = z.object({
+ status: z.enum(['open', 'in_progress', 'planned', 'completed', 'closed', 'declined']),
+})
+
+export default defineEventHandler(async (event) => {
+ const session = await requireAuth(event)
+
+ const id = getRouterParam(event, 'id')
+ if (!id) {
+ throw createError({
+ statusCode: 400,
+ statusMessage: 'Bad Request',
+ data: createErrorResponse(ErrorCode.VALIDATION_ERROR, 'Feedback ID is required'),
+ })
+ }
+
+ const body = await validateBody(event, updateStatusSchema)
+
+ // Verify feedback exists
+ const [fb] = await db.select().from(feedback).where(eq(feedback.id, id)).limit(1)
+ if (!fb) {
+ throw createError({
+ statusCode: 404,
+ statusMessage: 'Not Found',
+ data: createErrorResponse(ErrorCode.NOT_FOUND, 'Feedback not found'),
+ })
+ }
+
+ // Only team members can change status
+ await requireProjectAccess(fb.projectId, session.user.id)
+
+ const now = new Date()
+ const [updated] = await db
+ .update(feedback)
+ .set({ status: body.status, updatedAt: now })
+ .where(eq(feedback.id, id))
+ .returning()
+
+ return createSuccessResponse(updated)
+})