From 9bce253a72cf8c5cbec2c0115f61a145a43ea6b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20Fr=C3=B8yland?= <81354124+Andreas-Froyland@users.noreply.github.com> Date: Tue, 10 Feb 2026 11:35:37 +0100 Subject: [PATCH] Add project-scoped feedback category management APIs --- .../categories/[categoryId].delete.ts | 104 ++++++++++++++++++ .../categories/[categoryId].put.ts | 74 +++++++++++++ .../[projectSlug]/categories/index.get.ts | 17 +++ .../[projectSlug]/categories/index.post.ts | 69 ++++++++++++ server/api/teams/[teamId]/projects.post.ts | 68 ++++++------ server/utils/project-categories.ts | 63 +++++++++++ tests/e2e/team-primary-workspace.spec.ts | 96 ++++++++++++++++ 7 files changed, 459 insertions(+), 32 deletions(-) create mode 100644 server/api/projects/[projectSlug]/categories/[categoryId].delete.ts create mode 100644 server/api/projects/[projectSlug]/categories/[categoryId].put.ts create mode 100644 server/api/projects/[projectSlug]/categories/index.get.ts create mode 100644 server/api/projects/[projectSlug]/categories/index.post.ts create mode 100644 server/utils/project-categories.ts diff --git a/server/api/projects/[projectSlug]/categories/[categoryId].delete.ts b/server/api/projects/[projectSlug]/categories/[categoryId].delete.ts new file mode 100644 index 0000000..4c104f4 --- /dev/null +++ b/server/api/projects/[projectSlug]/categories/[categoryId].delete.ts @@ -0,0 +1,104 @@ +import { and, count, eq } from 'drizzle-orm' +import { z } from 'zod' +import { db } from '~/server/database/drizzle' +import { feedback, feedbackCategory } from '~/server/database/schema/feedback' +import { createErrorResponse, createSuccessResponse, ErrorCode } from '~/server/utils/response' +import { requireProjectCategoryAccess } from '~/server/utils/project-categories' + +const deleteCategorySchema = z.object({ + replacementCategoryId: z.string().min(1).optional().nullable(), +}) + +export default defineEventHandler(async (event) => { + const { project } = await requireProjectCategoryAccess(event) + const categoryId = getRouterParam(event, 'categoryId') + + if (!categoryId) { + throw createError({ + statusCode: 400, + statusMessage: 'Bad Request', + data: createErrorResponse(ErrorCode.VALIDATION_ERROR, 'Category ID is required'), + }) + } + + const rawBody = (await readBody(event).catch(() => ({}))) ?? {} + const parsedBody = deleteCategorySchema.safeParse(rawBody) + if (!parsedBody.success) { + throw createError({ + statusCode: 400, + statusMessage: 'Validation failed', + data: createErrorResponse( + ErrorCode.VALIDATION_ERROR, + 'Request validation failed', + parsedBody.error.issues + ), + }) + } + const body = parsedBody.data + + const [category] = await db + .select() + .from(feedbackCategory) + .where(and(eq(feedbackCategory.id, categoryId), eq(feedbackCategory.projectId, project.id))) + .limit(1) + + if (!category) { + throw createError({ + statusCode: 404, + statusMessage: 'Not Found', + data: createErrorResponse(ErrorCode.NOT_FOUND, 'Category not found'), + }) + } + + if (category.isDefault) { + throw createError({ + statusCode: 403, + statusMessage: 'Forbidden', + data: createErrorResponse(ErrorCode.FORBIDDEN, 'Default categories cannot be deleted'), + }) + } + + const [usage] = await db + .select({ count: count() }) + .from(feedback) + .where(and(eq(feedback.projectId, project.id), eq(feedback.categoryId, category.id))) + + const usageCount = usage?.count ?? 0 + if (usageCount > 0 && !body.replacementCategoryId) { + throw createError({ + statusCode: 409, + statusMessage: 'Conflict', + data: createErrorResponse( + ErrorCode.CONFLICT, + 'Category is used by feedback items. Provide replacementCategoryId before deletion.' + ), + }) + } + + if (usageCount > 0 && body.replacementCategoryId) { + const [replacement] = await db + .select() + .from(feedbackCategory) + .where(and(eq(feedbackCategory.id, body.replacementCategoryId), eq(feedbackCategory.projectId, project.id))) + .limit(1) + + if (!replacement || replacement.id === category.id) { + throw createError({ + statusCode: 400, + statusMessage: 'Bad Request', + data: createErrorResponse(ErrorCode.VALIDATION_ERROR, 'Replacement category is invalid'), + }) + } + + await db + .update(feedback) + .set({ categoryId: replacement.id }) + .where(and(eq(feedback.projectId, project.id), eq(feedback.categoryId, category.id))) + } + + await db + .delete(feedbackCategory) + .where(and(eq(feedbackCategory.id, category.id), eq(feedbackCategory.projectId, project.id))) + + return createSuccessResponse({ id: category.id, deleted: true }) +}) diff --git a/server/api/projects/[projectSlug]/categories/[categoryId].put.ts b/server/api/projects/[projectSlug]/categories/[categoryId].put.ts new file mode 100644 index 0000000..2b957b0 --- /dev/null +++ b/server/api/projects/[projectSlug]/categories/[categoryId].put.ts @@ -0,0 +1,74 @@ +import { and, eq } from 'drizzle-orm' +import { z } from 'zod' +import { db } from '~/server/database/drizzle' +import { feedbackCategory } from '~/server/database/schema/feedback' +import { createErrorResponse, createSuccessResponse, ErrorCode } from '~/server/utils/response' +import { validateBody } from '~/server/utils/validation' +import { requireProjectCategoryAccess, toCategorySlug } from '~/server/utils/project-categories' + +const updateCategorySchema = z.object({ + name: z.string().trim().min(1).max(80).optional(), + icon: z.string().trim().max(32).nullable().optional(), + color: z.string().trim().regex(/^#[0-9a-fA-F]{6}$/).nullable().optional(), + description: z.string().trim().max(200).nullable().optional(), + sortOrder: z.number().int().min(0).optional(), +}) + +export default defineEventHandler(async (event) => { + const { project } = await requireProjectCategoryAccess(event) + const categoryId = getRouterParam(event, 'categoryId') + if (!categoryId) { + throw createError({ + statusCode: 400, + statusMessage: 'Bad Request', + data: createErrorResponse(ErrorCode.VALIDATION_ERROR, 'Category ID is required'), + }) + } + + const body = await validateBody(event, updateCategorySchema) + + const [current] = await db + .select() + .from(feedbackCategory) + .where(and(eq(feedbackCategory.id, categoryId), eq(feedbackCategory.projectId, project.id))) + .limit(1) + + if (!current) { + throw createError({ + statusCode: 404, + statusMessage: 'Not Found', + data: createErrorResponse(ErrorCode.NOT_FOUND, 'Category not found'), + }) + } + + let slug = current.slug + if (body.name && body.name !== current.name) { + const baseSlug = toCategorySlug(body.name) + const siblingSlugs = await db + .select({ slug: feedbackCategory.slug }) + .from(feedbackCategory) + .where(eq(feedbackCategory.projectId, project.id)) + + const used = new Set(siblingSlugs.filter((item) => item.slug !== current.slug).map((item) => item.slug)) + slug = baseSlug + let suffix = 2 + while (used.has(slug)) { + slug = `${baseSlug}-${suffix++}` + } + } + + const [updated] = await db + .update(feedbackCategory) + .set({ + name: body.name ?? current.name, + slug, + icon: body.icon === undefined ? current.icon : body.icon, + color: body.color === undefined ? current.color : body.color, + description: body.description === undefined ? current.description : body.description, + sortOrder: body.sortOrder ?? current.sortOrder, + }) + .where(and(eq(feedbackCategory.id, categoryId), eq(feedbackCategory.projectId, project.id))) + .returning() + + return createSuccessResponse(updated) +}) diff --git a/server/api/projects/[projectSlug]/categories/index.get.ts b/server/api/projects/[projectSlug]/categories/index.get.ts new file mode 100644 index 0000000..fd6b6a4 --- /dev/null +++ b/server/api/projects/[projectSlug]/categories/index.get.ts @@ -0,0 +1,17 @@ +import { asc, eq } from 'drizzle-orm' +import { db } from '~/server/database/drizzle' +import { feedbackCategory } from '~/server/database/schema/feedback' +import { createSuccessResponse } from '~/server/utils/response' +import { requireProjectCategoryAccess } from '~/server/utils/project-categories' + +export default defineEventHandler(async (event) => { + const { project } = await requireProjectCategoryAccess(event) + + const categories = await db + .select() + .from(feedbackCategory) + .where(eq(feedbackCategory.projectId, project.id)) + .orderBy(asc(feedbackCategory.sortOrder), asc(feedbackCategory.createdAt)) + + return createSuccessResponse(categories) +}) diff --git a/server/api/projects/[projectSlug]/categories/index.post.ts b/server/api/projects/[projectSlug]/categories/index.post.ts new file mode 100644 index 0000000..9cf4a83 --- /dev/null +++ b/server/api/projects/[projectSlug]/categories/index.post.ts @@ -0,0 +1,69 @@ +import { desc, eq } from 'drizzle-orm' +import { z } from 'zod' +import { db } from '~/server/database/drizzle' +import { feedbackCategory } from '~/server/database/schema/feedback' +import { createErrorResponse, createSuccessResponse, ErrorCode } from '~/server/utils/response' +import { validateBody } from '~/server/utils/validation' +import { requireProjectCategoryAccess, toCategorySlug } from '~/server/utils/project-categories' + +const createCategorySchema = z.object({ + name: z.string().trim().min(1).max(80), + icon: z.string().trim().max(32).optional().nullable(), + color: z.string().trim().regex(/^#[0-9a-fA-F]{6}$/).optional().nullable(), + description: z.string().trim().max(200).optional().nullable(), + sortOrder: z.number().int().min(0).optional(), +}) + +export default defineEventHandler(async (event) => { + const { project } = await requireProjectCategoryAccess(event) + const body = await validateBody(event, createCategorySchema) + + const baseSlug = toCategorySlug(body.name) + if (!baseSlug) { + throw createError({ + statusCode: 400, + statusMessage: 'Bad Request', + data: createErrorResponse(ErrorCode.VALIDATION_ERROR, 'Category name is invalid'), + }) + } + + const categories = await db + .select({ slug: feedbackCategory.slug }) + .from(feedbackCategory) + .where(eq(feedbackCategory.projectId, project.id)) + + let slug = baseSlug + const existing = new Set(categories.map((category) => category.slug)) + let suffix = 2 + while (existing.has(slug)) { + slug = `${baseSlug}-${suffix++}` + } + + const [lastCategory] = await db + .select({ sortOrder: feedbackCategory.sortOrder }) + .from(feedbackCategory) + .where(eq(feedbackCategory.projectId, project.id)) + .orderBy(desc(feedbackCategory.sortOrder)) + .limit(1) + + const nextOrder = body.sortOrder ?? (lastCategory?.sortOrder ?? -1) + 1 + + const [created] = await db + .insert(feedbackCategory) + .values({ + id: crypto.randomUUID(), + projectId: project.id, + name: body.name, + slug, + icon: body.icon || null, + color: body.color || '#6b7280', + description: body.description || null, + sortOrder: nextOrder, + isDefault: false, + createdAt: new Date(), + }) + .returning() + + setResponseStatus(event, 201) + return createSuccessResponse(created) +}) diff --git a/server/api/teams/[teamId]/projects.post.ts b/server/api/teams/[teamId]/projects.post.ts index fcb0979..71e3154 100644 --- a/server/api/teams/[teamId]/projects.post.ts +++ b/server/api/teams/[teamId]/projects.post.ts @@ -72,41 +72,45 @@ export default defineEventHandler(async (event) => { const now = new Date() const projectId = crypto.randomUUID() - const [created] = await db - .insert(project) - .values({ - id: projectId, - organizationId: selectedTeam.organizationId, - teamId: selectedTeam.id, - slug: body.slug, - name: body.name, - description: body.description || null, - customDomain: body.customDomain || null, - isPublic: true, - createdAt: now, - updatedAt: now, - }) - .returning() - const defaultCategories = [ - { name: 'Feature Request', slug: 'feature-request', icon: 'lucide:lightbulb', color: '#10b981', isDefault: true }, - { name: 'Bug Report', slug: 'bug-report', icon: 'lucide:bug', color: '#ef4444', isDefault: false }, - { name: 'Improvement', slug: 'improvement', icon: 'lucide:trending-up', color: '#8b5cf6', isDefault: false }, + { name: 'Bug', slug: 'bug', icon: '🐛', color: '#ef4444', isDefault: true, description: 'Something isn’t working' }, + { name: 'Feature', slug: 'feature', icon: '✨', color: '#10b981', isDefault: true, description: 'A new capability or improvement' }, ] - await db.insert(feedbackCategory).values( - defaultCategories.map((category, index) => ({ - id: crypto.randomUUID(), - projectId, - name: category.name, - slug: category.slug, - icon: category.icon, - color: category.color, - isDefault: category.isDefault, - sortOrder: index, - createdAt: now, - })) - ) + const created = await db.transaction(async (tx) => { + const [newProject] = await tx + .insert(project) + .values({ + id: projectId, + organizationId: selectedTeam.organizationId, + teamId: selectedTeam.id, + slug: body.slug, + name: body.name, + description: body.description || null, + customDomain: body.customDomain || null, + isPublic: true, + createdAt: now, + updatedAt: now, + }) + .returning() + + await tx.insert(feedbackCategory).values( + defaultCategories.map((category, index) => ({ + id: crypto.randomUUID(), + projectId, + name: category.name, + slug: category.slug, + icon: category.icon, + color: category.color, + description: category.description, + isDefault: category.isDefault, + sortOrder: index, + createdAt: now, + })) + ) + + return newProject + }) setResponseStatus(event, 201) return createSuccessResponse(created) diff --git a/server/utils/project-categories.ts b/server/utils/project-categories.ts new file mode 100644 index 0000000..b33f8b1 --- /dev/null +++ b/server/utils/project-categories.ts @@ -0,0 +1,63 @@ +import { and, eq } from 'drizzle-orm' +import type { H3Event } from 'h3' +import { db } from '~/server/database/drizzle' +import { project } from '~/server/database/schema/feedback' +import { teamMember } from '~/server/database/schema/auth' +import { requireAuthWithResolvedTeam } from './team-context' +import { createErrorResponse, ErrorCode } from './response' + +export function toCategorySlug(name: string) { + return name + .toLowerCase() + .trim() + .replace(/[^a-z0-9\s-]/g, '') + .replace(/\s+/g, '-') + .replace(/-+/g, '-') + .replace(/^-|-$/g, '') +} + +export async function requireProjectCategoryAccess(event: H3Event) { + const { session, activeTeam } = await requireAuthWithResolvedTeam(event) + + const projectSlug = getRouterParam(event, 'projectSlug') + if (!projectSlug) { + throw createError({ + statusCode: 400, + statusMessage: 'Bad Request', + data: createErrorResponse(ErrorCode.VALIDATION_ERROR, 'Project slug is required'), + }) + } + + const [selectedProject] = await db + .select() + .from(project) + .where(and(eq(project.slug, projectSlug), eq(project.teamId, activeTeam.id))) + .limit(1) + + if (!selectedProject) { + throw createError({ + statusCode: 404, + statusMessage: 'Not Found', + data: createErrorResponse(ErrorCode.NOT_FOUND, 'Project not found'), + }) + } + + const [membership] = await db + .select() + .from(teamMember) + .where(and(eq(teamMember.teamId, selectedProject.teamId), eq(teamMember.userId, session.user.id))) + .limit(1) + + if (!membership) { + throw createError({ + statusCode: 403, + statusMessage: 'Forbidden', + data: createErrorResponse(ErrorCode.FORBIDDEN, 'You are not a member of this project team'), + }) + } + + return { + session, + project: selectedProject, + } +} diff --git a/tests/e2e/team-primary-workspace.spec.ts b/tests/e2e/team-primary-workspace.spec.ts index 7780c85..8de8e84 100644 --- a/tests/e2e/team-primary-workspace.spec.ts +++ b/tests/e2e/team-primary-workspace.spec.ts @@ -108,3 +108,99 @@ test('authenticated user can access products UI workflow', async ({ request, pag await expect(page.getByRole('button', { name: 'New Product' })).toBeVisible() await expect(page.getByText('Manage your products and their public feedback pages')).toBeVisible() }) + +test('project categories API supports create/update/reorder/delete with reassignment rules', async ({ request }) => { + const sessionCookie = await signInAndGetSessionCookie(request) + + const activeTeamResponse = await request.get('/api/teams/active', { + headers: withAuthHeaders(sessionCookie), + }) + const activeTeamPayload = await activeTeamResponse.json() + expect(activeTeamResponse.ok()).toBeTruthy() + const teamId = activeTeamPayload.data.id as string + + const projectSlug = `e2e-cats-${Date.now()}` + const createProjectResponse = await request.post(`/api/teams/${teamId}/projects`, { + headers: withAuthHeaders(sessionCookie), + data: { + name: 'Categories Product', + slug: projectSlug, + description: null, + customDomain: null, + }, + }) + expect(createProjectResponse.status()).toBe(201) + const createProjectPayload = await createProjectResponse.json() + const projectId = createProjectPayload?.data?.id as string + + const listDefaultsResponse = await request.get(`/api/projects/${projectSlug}/categories`, { + headers: withAuthHeaders(sessionCookie), + }) + const listDefaultsPayload = await listDefaultsResponse.json() + expect(listDefaultsResponse.ok()).toBeTruthy() + const defaults = listDefaultsPayload.data as Array<{ id: string; name: string; slug: string }> + expect(defaults.map((c) => c.name)).toEqual(['Bug', 'Feature']) + + const createCategoryResponse = await request.post(`/api/projects/${projectSlug}/categories`, { + headers: withAuthHeaders(sessionCookie), + data: { + name: 'UX', + icon: '🎨', + color: '#7c3aed', + description: 'Interface and usability feedback', + }, + }) + const createCategoryPayload = await createCategoryResponse.json() + expect(createCategoryResponse.status()).toBe(201) + const customCategoryId = createCategoryPayload.data.id as string + + const updateCategoryResponse = await request.put(`/api/projects/${projectSlug}/categories/${customCategoryId}`, { + headers: withAuthHeaders(sessionCookie), + data: { + name: 'User Experience', + sortOrder: 0, + color: '#9333ea', + }, + }) + const updateCategoryPayload = await updateCategoryResponse.json() + expect(updateCategoryResponse.ok()).toBeTruthy() + expect(updateCategoryPayload.data.name).toBe('User Experience') + expect(updateCategoryPayload.data.sortOrder).toBe(0) + + const createFeedbackResponse = await request.post('/api/feedback', { + headers: withAuthHeaders(sessionCookie, '/feedback'), + data: { + title: 'Navigation feels confusing', + body: 'Users cannot find settings quickly', + projectId, + categoryId: customCategoryId, + }, + }) + const createFeedbackPayload = await createFeedbackResponse.json() + expect(createFeedbackResponse.status()).toBe(201) + expect(createFeedbackPayload?.data?.categoryId).toBe(customCategoryId) + + const deleteWithoutReplacement = await request.delete(`/api/projects/${projectSlug}/categories/${customCategoryId}`, { + headers: withAuthHeaders(sessionCookie), + data: {}, + }) + expect(deleteWithoutReplacement.status()).toBe(409) + + const bugCategory = defaults.find((category) => category.slug === 'bug') + expect(bugCategory?.id).toBeTruthy() + if (!bugCategory) throw new Error('Expected default bug category to exist') + + const deleteWithReplacement = await request.delete(`/api/projects/${projectSlug}/categories/${customCategoryId}`, { + headers: withAuthHeaders(sessionCookie), + data: { + replacementCategoryId: bugCategory.id, + }, + }) + expect(deleteWithReplacement.ok()).toBeTruthy() + + const deleteDefault = await request.delete(`/api/projects/${projectSlug}/categories/${bugCategory.id}`, { + headers: withAuthHeaders(sessionCookie), + data: {}, + }) + expect(deleteDefault.status()).toBe(403) +})