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
104 changes: 104 additions & 0 deletions server/api/projects/[projectSlug]/categories/[categoryId].delete.ts
Original file line number Diff line number Diff line change
@@ -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 })
})
74 changes: 74 additions & 0 deletions server/api/projects/[projectSlug]/categories/[categoryId].put.ts
Original file line number Diff line number Diff line change
@@ -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)
})
17 changes: 17 additions & 0 deletions server/api/projects/[projectSlug]/categories/index.get.ts
Original file line number Diff line number Diff line change
@@ -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)
})
69 changes: 69 additions & 0 deletions server/api/projects/[projectSlug]/categories/index.post.ts
Original file line number Diff line number Diff line change
@@ -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)
})
68 changes: 36 additions & 32 deletions server/api/teams/[teamId]/projects.post.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading
Loading