Skip to content
Draft
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
455 changes: 455 additions & 0 deletions app/pages/admin/equipment/brands/index.vue

Large diffs are not rendered by default.

7 changes: 7 additions & 0 deletions app/pages/admin/index.vue
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
<template>
<PageContent page-title="Admin">
<div :class="$style.component">
<ActionPanel
icon="hugeicons:tag-01"
subtitle="Add, rename, and remove brands used by the gear catalog."
title="Manage brands"
:to="appRoutes.adminEquipmentBrands"
/>

<ActionPanel
icon="hugeicons:task-edit-01"
subtitle="Check and correct pending gear before a publish decision."
Expand Down
1 change: 1 addition & 0 deletions app/utils/navigation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ const appRoutes = {
account: '/account',
accountSubmissions: '/account/submissions',
admin: '/admin',
adminEquipmentBrands: '/admin/equipment/brands',
adminEquipmentPhotoSubmissions: '/admin/equipment/photo-submissions',
adminEquipmentSubmissions: '/admin/equipment/submissions',
gearLibrary: '/gear-library',
Expand Down
20 changes: 9 additions & 11 deletions server/api/equipment/brands/[id].delete.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import { eq } from 'drizzle-orm'
import { createError, defineEventHandler, getValidatedRouterParams, isError, setResponseStatus } from 'h3'
import { createError, defineEventHandler, getValidatedRouterParams, setResponseStatus } from 'h3'
import { brands, contributions } from '#server/database/schema'
import { validateAdminUser } from '#server/utils/admin'
import { createWebSocketClientFromEvent } from '#server/utils/config'
import { brandBaseSelection } from '#server/utils/equipment/base-records'
import { throwBrandWriteError } from '#server/utils/equipment/brand-write-errors'
import { validateBrandIdParams } from '#server/utils/validation/schemas'

export default defineEventHandler(async (event) => {
export default defineEventHandler(async (event): Promise<void> => {
const userId = await validateAdminUser(event)
const { id: brandId } = await getValidatedRouterParams(event, validateBrandIdParams)
const dbWebsocket = createWebSocketClientFromEvent(event)
Expand Down Expand Up @@ -38,16 +39,13 @@ export default defineEventHandler(async (event) => {
})
})
} catch (error) {
if (isError(error)) {
throw error
}

throw createError({
status: 500,
message: 'Failed to delete brand'
})
throwBrandWriteError(error, 'delete')
} finally {
await dbWebsocket.$client.end()
try {
await dbWebsocket.$client.end()
} catch (error) {
console.error('Failed to close brand write database client', { error })
}
}

setResponseStatus(event, 204)
Expand Down
22 changes: 10 additions & 12 deletions server/api/equipment/brands/[id].patch.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import { eq } from 'drizzle-orm'
import { createError, defineEventHandler, getValidatedRouterParams, isError, readValidatedBody } from 'h3'
import { createError, defineEventHandler, getValidatedRouterParams, readValidatedBody } from 'h3'
import { brands, contributions } from '#server/database/schema'
import { validateAdminUser } from '#server/utils/admin'
import { createWebSocketClientFromEvent } from '#server/utils/config'
import { brandBaseSelection } from '#server/utils/equipment/base-records'
import { brandBaseSelection, type BrandBaseRecord } from '#server/utils/equipment/base-records'
import { throwBrandWriteError } from '#server/utils/equipment/brand-write-errors'
import { validateBrandIdParams, validateBrandMutationBody } from '#server/utils/validation/schemas'

export default defineEventHandler(async (event) => {
export default defineEventHandler(async (event): Promise<BrandBaseRecord> => {
const userId = await validateAdminUser(event)
const { id: brandId } = await getValidatedRouterParams(event, validateBrandIdParams)
const { name, slug } = await readValidatedBody(event, validateBrandMutationBody)
Expand Down Expand Up @@ -45,15 +46,12 @@ export default defineEventHandler(async (event) => {
return updatedBrand
})
} catch (error) {
if (isError(error)) {
throw error
}

throw createError({
status: 500,
message: 'Failed to update brand'
})
throwBrandWriteError(error, 'update')
} finally {
await dbWebsocket.$client.end()
try {
await dbWebsocket.$client.end()
} catch (error) {
console.error('Failed to close brand write database client', { error })
}
}
})
75 changes: 70 additions & 5 deletions server/api/equipment/brands/__tests__/create.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -218,37 +218,77 @@ describe('post /api/equipment/brands', () => {
expect(createWebSocketClientMock).not.toHaveBeenCalled()
})

it('should return 500 when brand slug already exists', async () => {
it.each([
['name', 'brands_name_key', 'Brand name already exists'],
['slug', 'brands_slug_key', 'Brand slug already exists']
])('should return 409 when brand %s already exists', async (_field, constraint, statusMessage) => {
const databaseError = Object.assign(new Error('duplicate value'), {
code: '23505',
constraint
})

const { dbWrite } = createDb()

dbWrite.transaction.mockRejectedValue(new Error('duplicate slug'))
dbWrite.transaction.mockRejectedValue(new Error('query failed', { cause: databaseError }))
createWebSocketClientMock.mockReturnValue(dbWrite)

const event = createTestEvent({})

await expect(createBrandHandler(event)).rejects.toMatchObject({
message: 'Failed to create brand',
statusCode: 500
statusCode: 409,
statusMessage
})

expect(dbWrite.$client.end).toHaveBeenCalledTimes(1)
})

it('should return 500 when brand creation fails', async () => {
const failure = new Error('insert failed')

const { dbWrite } = createDb({
insertError: new Error('insert failed')
insertError: failure
})

createWebSocketClientMock.mockReturnValue(dbWrite)

const event = createTestEvent({})

const errorLog = vi.spyOn(console, 'error').mockImplementation((message) => {
expect(message).toBe('Failed to create brand')
})

await expect(createBrandHandler(event)).rejects.toMatchObject({
message: 'Failed to create brand',
statusCode: 500
})

expect(dbWrite.$client.end).toHaveBeenCalledTimes(1)
expect(errorLog).toHaveBeenCalledWith('Failed to create brand', { error: failure })
})

it('should not expose an unrelated database constraint', async () => {
const failure = Object.assign(new Error('duplicate contribution'), {
code: '23505',
constraint: 'contributions_other_key'
})

const { dbWrite } = createDb()

dbWrite.transaction.mockRejectedValue(failure)
createWebSocketClientMock.mockReturnValue(dbWrite)

const errorLog = vi.spyOn(console, 'error').mockImplementation((message) => {
expect(message).toBe('Failed to create brand')
})

const event = createTestEvent({})

await expect(createBrandHandler(event)).rejects.toMatchObject({
statusCode: 500,
statusMessage: 'Failed to create brand'
})

expect(errorLog).toHaveBeenCalledWith('Failed to create brand', { error: failure })
})

it('should return 500 when contribution logging fails after brand creation', async () => {
Expand All @@ -274,4 +314,29 @@ describe('post /api/equipment/brands', () => {
expect(setResponseStatusMock).not.toHaveBeenCalled()
expect(dbWrite.$client.end).toHaveBeenCalledTimes(1)
})

it('should retain a committed creation when closing the client fails', async () => {
const createdBrand = {
id: 12,
name: 'MSR',
slug: 'msr'
}

const { dbWrite } = createDb({ createdBrand })
const closeError = new Error('close failed')

dbWrite.$client.end.mockRejectedValue(closeError)
createWebSocketClientMock.mockReturnValue(dbWrite)

const errorLog = vi.spyOn(console, 'error').mockImplementation((message) => {
expect(message).toBe('Failed to close brand write database client')
})

const event = createTestEvent({})
const result = await createBrandHandler(event)

expect(result).toStrictEqual(createdBrand)
expect(setResponseStatusMock).toHaveBeenCalledWith(event, 201)
expect(errorLog).toHaveBeenCalledWith('Failed to close brand write database client', { error: closeError })
})
})
38 changes: 34 additions & 4 deletions server/api/equipment/brands/__tests__/update-delete.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -338,17 +338,25 @@ describe('patch /api/equipment/brands/[id]', () => {
expect(dbWrite.$client.end).toHaveBeenCalledTimes(1)
})

it('should return 500 when brand slug already exists', async () => {
it.each([
['name', 'brands_name_key', 'Brand name already exists'],
['slug', 'brands_slug_key', 'Brand slug already exists']
])('should return 409 when brand %s already exists', async (_field, constraint, statusMessage) => {
const { dbWrite } = createPatchDb({})

dbWrite.transaction.mockRejectedValue(new Error('duplicate slug'))
const databaseError = Object.assign(new Error('duplicate value'), {
code: '23505',
constraint
})

dbWrite.transaction.mockRejectedValue(new Error('query failed', { cause: databaseError }))
createWebSocketClientMock.mockReturnValue(dbWrite)

const event = createTestEvent({})

await expect(updateBrandHandler(event)).rejects.toMatchObject({
message: 'Failed to update brand',
statusCode: 500
statusCode: 409,
statusMessage
})

expect(dbWrite.$client.end).toHaveBeenCalledTimes(1)
Expand Down Expand Up @@ -468,6 +476,28 @@ describe('delete /api/equipment/brands/[id]', () => {
expect(dbWrite.$client.end).toHaveBeenCalledTimes(1)
})

it('should return 409 when equipment uses the brand', async () => {
const { dbWrite } = createDeleteDb({})

const databaseError = Object.assign(new Error('foreign key violation'), {
code: '23503',
constraint: 'equipment_items_brandId_brands_id_fkey'
})

dbWrite.transaction.mockRejectedValue(new Error('query failed', { cause: databaseError }))
createWebSocketClientMock.mockReturnValue(dbWrite)

const event = createTestEvent({})

await expect(deleteBrandHandler(event)).rejects.toMatchObject({
statusCode: 409,
statusMessage: 'Brand is used by equipment'
})

expect(setResponseStatusMock).not.toHaveBeenCalled()
expect(dbWrite.$client.end).toHaveBeenCalledTimes(1)
})

it.each([
'msr',
'12-msr'
Expand Down
22 changes: 10 additions & 12 deletions server/api/equipment/brands/index.post.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import { createError, defineEventHandler, isError, readValidatedBody, setResponseStatus } from 'h3'
import { createError, defineEventHandler, readValidatedBody, setResponseStatus } from 'h3'
import { brands, contributions } from '#server/database/schema'
import { validateAdminUser } from '#server/utils/admin'
import { createWebSocketClientFromEvent } from '#server/utils/config'
import { brandBaseSelection } from '#server/utils/equipment/base-records'
import { brandBaseSelection, type BrandBaseRecord } from '#server/utils/equipment/base-records'
import { throwBrandWriteError } from '#server/utils/equipment/brand-write-errors'
import { validateBrandMutationBody } from '#server/utils/validation/schemas'

export default defineEventHandler(async (event) => {
export default defineEventHandler(async (event): Promise<BrandBaseRecord> => {
const userId = await validateAdminUser(event)
const { name, slug } = await readValidatedBody(event, validateBrandMutationBody)
const dbWebsocket = createWebSocketClientFromEvent(event)
Expand Down Expand Up @@ -47,15 +48,12 @@ export default defineEventHandler(async (event) => {

return createdBrand
} catch (error) {
if (isError(error)) {
throw error
}

throw createError({
status: 500,
message: 'Failed to create brand'
})
throwBrandWriteError(error, 'create')
} finally {
await dbWebsocket.$client.end()
try {
await dbWebsocket.$client.end()
} catch (error) {
console.error('Failed to close brand write database client', { error })
}
}
})
74 changes: 74 additions & 0 deletions server/utils/equipment/brand-write-errors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { createError, isError } from 'h3'

type BrandWriteAction = 'create' | 'update' | 'delete'

function getDatabaseError(error: unknown): { code: string; constraint: string; } | null {
let current = error

for (let depth = 0; depth < 4; depth += 1) {
if (current === null || typeof current !== 'object') {
return null
}

const code: unknown = Reflect.get(current, 'code')
const constraint: unknown = Reflect.get(current, 'constraint')

if (typeof code === 'string' && typeof constraint === 'string') {
return {
code,
constraint
}
}

current = Reflect.get(current, 'cause')
}

return null
}

/** Maps only known brand constraints to public errors and keeps other failures private. */
function throwBrandWriteError(error: unknown, action: BrandWriteAction): never {
if (isError(error)) {
if (error.statusCode >= 500) {
console.error(`Failed to ${action} brand`, { error })
}

throw error
}

const databaseError = getDatabaseError(error)

if (databaseError?.code === '23505') {
if (databaseError.constraint === 'brands_name_key') {
throw createError({
status: 409,
statusMessage: 'Brand name already exists'
})
}

if (databaseError.constraint === 'brands_slug_key') {
throw createError({
status: 409,
statusMessage: 'Brand slug already exists'
})
}
}

if (action === 'delete'
&& databaseError?.code === '23503'
&& databaseError.constraint === 'equipment_items_brandId_brands_id_fkey') {
throw createError({
status: 409,
statusMessage: 'Brand is used by equipment'
})
}

console.error(`Failed to ${action} brand`, { error })

throw createError({
status: 500,
statusMessage: `Failed to ${action} brand`
})
}

export { throwBrandWriteError }
Loading
Loading