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
3 changes: 3 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,16 @@
- **UUID v7** as PK for all user-facing entities (items, user_equipment, contributions).
- **Serial** PK for reference data (groups, categories, properties, brands).
- **Slugs** on reference data only (groups, categories, brands) β€” used for URLs and as future i18n translation keys. Items use UUID in URLs.
- **Reference data slugs are canonical lowercase URL tokens** using only `a-z`, `0-9`, and single hyphens between segments.
- **`name`** columns on reference data are English display values. When i18n is added, `slug` maps to a translation key, `name` becomes the fallback.
- **Brand/category FKs from `equipment_items` must not cascade on delete**: use `restrict` so deleting reference data cannot silently remove catalog items, item property values, or user inventories.

### API conventions

- **Public read routes for reference data use `slug`** in detail URLs (`/api/equipment/brands/[slug]`, `/api/equipment/categories/[slug]`).
- **Admin mutation routes for reference data use `id`** in route params (`PATCH`/`DELETE`), because `slug` is editable content and must not be the stable mutation key.
- **All API request inputs use Valibot schemas through h3 validated helpers**: `readValidatedBody` for request bodies, `getValidatedRouterParams` for route params, and `getValidatedQuery` for query strings. Schemas and validator functions live in `server/utils/validation/schemas.ts`; handlers should consume parsed values instead of manually validating raw input.
- **Catalog admin writes that both mutate reference data and log `contributions` must be atomic**: run them through a transaction-capable write path, not separate `dbHttp` calls.
- **Public read detail endpoints stay narrow**: return the entity needed for that route, and fetch related collections with separate read endpoints when a page needs them. Do not expand detail payloads just to save a future frontend request.
- **Shared catalog `returning(...)` shapes use reusable base records, not global endpoint models**: extract common `id`/`name`/`slug` selections into server-only helpers when reused, but keep each endpoint free to return a different response shape later if needed.
- **Protected `/api/*` routes keep mixed auth behavior by caller type**: unauthenticated browser document navigations redirect to `/login?redirectTo=...`, while programmatic API requests (`fetch`/XHR) still receive `401`.
Expand Down
1 change: 1 addition & 0 deletions plan/PLAN.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# Equipment catalog: Implementation plan

[Completed work](plan/completed.md) β€” summary of already implemented features.
[Technical debt](plan/tech-debt.md) β€” accepted follow-up work that is intentionally deferred.

Architecture decisions are captured in `AGENTS.md`.

Expand Down
4 changes: 2 additions & 2 deletions plan/completed.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ Authenticated read-only catalog endpoints for groups, categories, brands, item l

## Admin catalog management

Admin-only Brands and Groups CRUD implemented under `/api/equipment/brands` and `/api/equipment/groups`. Public detail reads stay on `slug`, while admin `PATCH` and `DELETE` use stable numeric `id`. Successful create, update, and delete operations log to `contributions`.
Admin-only Brands, Groups, and Categories CRUD implemented under `/api/equipment/brands`, `/api/equipment/groups`, and `/api/equipment/categories`. Public detail reads stay on `slug`, while admin `PATCH` and `DELETE` use stable numeric `id`. Successful create, update, and delete operations log to `contributions`. Brand/category usage by `equipment_items` is protected at the FK level with `restrict` deletes so reference cleanup cannot cascade into catalog or user inventory records.

## Twitch OAuth

Expand All @@ -28,7 +28,7 @@ Nuxt layout with header, footer, sidebar. Login page, account page, Twitch callb

## Tooling and tests

Migration CLI script (`tools/migrate.ts`). Playwright browser smoke for login rather than API contract coverage. DB-free Vitest coverage for brands admin/read handlers, category read handler, item read handlers, and shared validation schemas. Unit tests for `withMinimumDelay` utility.
Migration CLI script (`tools/migrate.ts`). Playwright browser smoke for login rather than API contract coverage. DB-free Vitest coverage for brands/groups/categories admin handlers, brand/category/item read handlers, and shared validation schemas. Unit tests for `withMinimumDelay` utility.

## Seed data

Expand Down
15 changes: 15 additions & 0 deletions plan/tech-debt.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# Technical debt

Accepted follow-up work that is intentionally deferred after a smaller safe iteration ships.

## API error mapping

- **Map restricted brand/category deletes to `409 Conflict`** β€” deleting a brand or category that is still referenced by `equipment_items` is now blocked by DB `RESTRICT` FKs, but `server/api/equipment/brands/[id].delete.ts` and `server/api/equipment/categories/[id].delete.ts` still turn that database error into a generic `500`. Catch the FK violation and return a domain error like `409 Conflict` with a clear "in use" message.

## Shared length limits

- **Finish replacing remaining hardcoded schema lengths with shared `limits`** β€” `shared/constants.ts` is already the source of truth for runtime validation and part of the Drizzle schema, but some `varchar({ length: ... })` values in `server/database/schema.ts` are still hardcoded. Replace the remaining literals with the same shared `limits` values so request validation and DB constraints cannot silently drift.

## OAuth user creation tests

- **Add unit tests for `createOAuthUser`** β€” `server/utils/oauth/account.ts` now contains non-trivial transaction and error-handling logic, but it has no direct unit coverage. Add focused Vitest tests for the success path that creates the user and OAuth link, the `404` path when the provider is missing, the `500` path when user creation fails or an unexpected error is thrown, and the cleanup path that always closes the DB client in `finally`.
69 changes: 39 additions & 30 deletions server/api/equipment/brands/[id].delete.ts
Original file line number Diff line number Diff line change
@@ -1,49 +1,58 @@
import { eq } from 'drizzle-orm'
import { createError, defineEventHandler, getValidatedRouterParams, setResponseStatus } from 'h3'
import { createError, defineEventHandler, getValidatedRouterParams, isError, setResponseStatus } from 'h3'
import { brands, contributions } from '#server/database/schema'
import { validateAdminUser } from '#server/utils/admin'
import { getRuntimeDatabaseConfig } from '#server/utils/config'
import { createWebSocketClient } from '#server/utils/database'
import { brandBaseSelection, type BrandBaseRecord } from '#server/utils/equipment/base-records'
import { validateBrandIdParams } from '#server/utils/validation/schemas'

export default defineEventHandler(async (event) => {
const { dbHttp } = event.context
const userId = await validateAdminUser(event)
const { id: brandId } = await getValidatedRouterParams(event, validateBrandIdParams)

let deletedBrandRows: BrandBaseRecord[] = []
const databaseConfig = getRuntimeDatabaseConfig(event)
const dbWrite = createWebSocketClient(databaseConfig)

try {
deletedBrandRows = await dbHttp
.delete(brands)
.where(
eq(brandBaseSelection.id, brandId)
)
.returning(brandBaseSelection)
} catch {
await dbWrite.transaction(async (transaction) => {
const deletedBrandRows: BrandBaseRecord[] = await transaction
.delete(brands)
.where(
eq(brandBaseSelection.id, brandId)
)
.returning(brandBaseSelection)

const [currentBrand] = deletedBrandRows

if (currentBrand === undefined) {
throw createError({ status: 404 })
}

await transaction
.insert(contributions)
.values({
userId,
action: 'delete_brand',
targetId: `${currentBrand.id}`,

metadata: {
name: currentBrand.name,
slug: currentBrand.slug
}
})
})
} catch (error) {
if (isError(error)) {
throw error
}

throw createError({
status: 500,
message: 'Failed to delete brand'
})
} finally {
await dbWrite.$client.end()
}

const [currentBrand] = deletedBrandRows

if (currentBrand === undefined) {
throw createError({ status: 404 })
}

await dbHttp
.insert(contributions)
.values({
userId,
action: 'delete_brand',
targetId: `${currentBrand.id}`,

metadata: {
name: currentBrand.name,
slug: currentBrand.slug
}
})

setResponseStatus(event, 204)
})
80 changes: 45 additions & 35 deletions server/api/equipment/brands/[id].patch.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { eq } from 'drizzle-orm'
import { createError, defineEventHandler, getValidatedRouterParams, readValidatedBody } from 'h3'
import { createError, defineEventHandler, getValidatedRouterParams, isError, readValidatedBody } from 'h3'
import { brands, contributions } from '#server/database/schema'
import { validateAdminUser } from '#server/utils/admin'
import { getRuntimeDatabaseConfig } from '#server/utils/config'
import { createWebSocketClient } from '#server/utils/database'
import { brandBaseSelection, type BrandBaseRecord } from '#server/utils/equipment/base-records'

import {
Expand All @@ -10,48 +12,56 @@ import {
} from '#server/utils/validation/schemas'

export default defineEventHandler(async (event) => {
const { dbHttp } = event.context
const userId = await validateAdminUser(event)
const { id: brandId } = await getValidatedRouterParams(event, validateBrandIdParams)
const { name, slug } = await readValidatedBody(event, validateBrandMutationBody)

let updatedBrandRows: BrandBaseRecord[] = []
const databaseConfig = getRuntimeDatabaseConfig(event)
const dbWrite = createWebSocketClient(databaseConfig)

try {
updatedBrandRows = await dbHttp
.update(brands)
.set({
name,
slug
})
.where(
eq(brandBaseSelection.id, brandId)
)
.returning(brandBaseSelection)
} catch {
throw createError({
status: 500,
message: 'Failed to update brand'
})
}
return await dbWrite.transaction(async (transaction) => {
const updatedBrandRows: BrandBaseRecord[] = await transaction
.update(brands)
.set({
name,
slug
})
.where(
eq(brandBaseSelection.id, brandId)
)
.returning(brandBaseSelection)

const [updatedBrand] = updatedBrandRows

if (updatedBrand === undefined) {
throw createError({ status: 404 })
}
const [updatedBrand] = updatedBrandRows

await dbHttp
.insert(contributions)
.values({
userId,
action: 'update_brand',
targetId: `${updatedBrand.id}`,
metadata: {
name: updatedBrand.name,
slug: updatedBrand.slug
if (updatedBrand === undefined) {
throw createError({ status: 404 })
}

await transaction
.insert(contributions)
.values({
userId,
action: 'update_brand',
targetId: `${updatedBrand.id}`,

metadata: {
name: updatedBrand.name,
slug: updatedBrand.slug
}
})

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

return updatedBrand
throw createError({
status: 500,
message: 'Failed to update brand'
})
} finally {
await dbWrite.$client.end()
}
})
Loading
Loading