From a935d615313b6898b13af684ba230033876fb3bf Mon Sep 17 00:00:00 2001 From: Pihkaal Date: Wed, 25 Mar 2026 14:38:40 +0100 Subject: [PATCH] feat: better error messages --- app/components/LinkModal.vue | 4 ++-- app/utils/api.ts | 22 ++++++++++++++++++ server/api/links/[id]/index.patch.ts | 5 ++++ server/api/links/index.post.ts | 6 +++++ server/utils/links.ts | 34 ++++++++++++++++++++++++++++ 5 files changed, 69 insertions(+), 2 deletions(-) create mode 100644 app/utils/api.ts create mode 100644 server/utils/links.ts diff --git a/app/components/LinkModal.vue b/app/components/LinkModal.vue index cbe47ee..5892d91 100644 --- a/app/components/LinkModal.vue +++ b/app/components/LinkModal.vue @@ -38,8 +38,8 @@ const onSubmit = async (event: FormSubmitEvent) => { toast.add({ title: "Link created", color: "success" }); } emit("close", true); - } catch { - toast.add({ title: "Something went wrong", color: "error" }); + } catch (error) { + toast.add({ title: getApiError(error), color: "error" }); } finally { submitting.value = false; } diff --git a/app/utils/api.ts b/app/utils/api.ts new file mode 100644 index 0000000..452c03b --- /dev/null +++ b/app/utils/api.ts @@ -0,0 +1,22 @@ +import { z } from "zod"; + +const apiErrorSchema = z.object({ + data: z.object({ + statusCode: z.number(), + message: z.string(), + }), +}); + +export const getApiError = ( + error: unknown, + defaultMessage: string = "Something went wrong", +): string => { + const parsedError = apiErrorSchema.safeParse(error); + if (parsedError.success) { + if (parsedError.data.data.statusCode === 500) { + return `ERR-500: Internal server error`; + } + return `ERR-${parsedError.data.data.statusCode}: ${parsedError.data.data.message}`; + } + return defaultMessage; +}; diff --git a/server/api/links/[id]/index.patch.ts b/server/api/links/[id]/index.patch.ts index 6b7ded6..bb08fc1 100644 --- a/server/api/links/[id]/index.patch.ts +++ b/server/api/links/[id]/index.patch.ts @@ -17,6 +17,11 @@ export default defineEventHandler(async (event) => { const params = await getValidatedRouterParams(event, paramsSchema.parse); const body = await readValidatedBody(event, bodySchema.parse); + const conflicts = await findConflictingFields(body, params.id); + if (conflicts.length > 0) { + throw createError({ statusCode: 409, message: `A link with this ${joinFields(conflicts)} already exists` }); + } + const [link] = await db .update(tables.links) .set(body) diff --git a/server/api/links/index.post.ts b/server/api/links/index.post.ts index b8df6db..f8951da 100644 --- a/server/api/links/index.post.ts +++ b/server/api/links/index.post.ts @@ -11,6 +11,12 @@ const bodySchema = z.object({ export default defineEventHandler(async (event) => { const body = await readValidatedBody(event, bodySchema.parse); + + const conflicts = await findConflictingFields(body); + if (conflicts.length > 0) { + throw createError({ statusCode: 409, message: `A link with this ${joinFields(conflicts)} already exists` }); + } + const [link] = await db.insert(tables.links).values(body).returning(); return link; }); diff --git a/server/utils/links.ts b/server/utils/links.ts new file mode 100644 index 0000000..6d75124 --- /dev/null +++ b/server/utils/links.ts @@ -0,0 +1,34 @@ +import { db } from "#server/db"; +import * as tables from "#server/db/schema"; +import { and, eq, ne } from "drizzle-orm"; + +const UNIQUE_FIELDS = ["name", "path", "url"] as const; +type UniqueField = (typeof UNIQUE_FIELDS)[number]; + +export const joinFields = (fields: string[]): string => { + if (fields.length <= 1) return fields.join(""); + return `${fields.slice(0, -1).join(", ")} and ${fields.at(-1)}`; +}; + +export const findConflictingFields = async ( + values: Partial>, + excludeId?: number, +): Promise => { + const conflicts: UniqueField[] = []; + + for (const field of UNIQUE_FIELDS) { + const value = values[field]; + if (!value) continue; + + const conflict = await db.query.links.findFirst({ + where: and( + eq(tables.links[field], value), + excludeId !== undefined ? ne(tables.links.id, excludeId) : undefined, + ), + }); + + if (conflict) conflicts.push(field); + } + + return conflicts; +};