From fad19a9aa0fb725b3fd63dfd964b9059c433fb5a Mon Sep 17 00:00:00 2001 From: Rasmus Q Date: Mon, 16 Mar 2026 10:56:58 +0000 Subject: [PATCH] add: Zod validation schemas for form data and refactor server actions to use them --- src/lib/server/validation.ts | 39 ++++++ .../wishlist/[token]/edit/+page.server.ts | 126 ++++++------------ 2 files changed, 79 insertions(+), 86 deletions(-) diff --git a/src/lib/server/validation.ts b/src/lib/server/validation.ts index b7877ef..c7dd90a 100644 --- a/src/lib/server/validation.ts +++ b/src/lib/server/validation.ts @@ -90,3 +90,42 @@ export function sanitizeToken(token: string | null | undefined): string { } return trimmed; } + +// Zod schemas for type-safe form validation +import { z } from 'zod'; + +export const currencySchema = z.enum(['DKK', 'EUR', 'USD', 'SEK', 'NOK', 'GBP']); + +export const itemSchema = z.object({ + title: z.string().min(1, 'Title is required').max(255), + description: z.string().max(2000).optional().nullable(), + link: z.string().url('Invalid URL').optional().nullable(), + imageUrl: z.string().url('Invalid image URL').optional().nullable(), + price: z.coerce.number().nonnegative().optional().nullable(), + currency: currencySchema.default('DKK'), + color: z.string().optional().nullable(), + order: z.coerce.number().int().nonnegative().optional() +}); + +export const updateItemSchema = itemSchema.extend({ + id: z.string().min(1, 'Item ID is required') +}); + +export const wishlistSchema = z.object({ + title: z.string().min(1, 'Title is required').max(255), + description: z.string().max(2000).optional().nullable(), + color: z.string().optional().nullable(), + theme: z.string().optional().nullable().transform((val) => val || 'none'), + endDate: z.coerce.date().optional().nullable() +}); + +export const reorderSchema = z.array( + z.object({ + id: z.string(), + order: z.number().int().nonnegative() + }) +); + +export type ItemFormData = z.infer; +export type UpdateItemFormData = z.infer; +export type WishlistFormData = z.infer; diff --git a/src/routes/wishlist/[token]/edit/+page.server.ts b/src/routes/wishlist/[token]/edit/+page.server.ts index 977b3bc..81cadec 100644 --- a/src/routes/wishlist/[token]/edit/+page.server.ts +++ b/src/routes/wishlist/[token]/edit/+page.server.ts @@ -1,8 +1,9 @@ -import { error } from '@sveltejs/kit'; +import { error, fail } from '@sveltejs/kit'; import type { PageServerLoad, Actions } from './$types'; import { db } from '$lib/server/db'; import { wishlists, items, savedWishlists, type NewItem, type NewSavedWishlist } from '$lib/db/schema'; import { eq, and } from 'drizzle-orm'; +import { itemSchema, updateItemSchema, wishlistSchema, reorderSchema } from '$lib/server/validation'; export const load: PageServerLoad = async ({ params, locals }) => { const wishlist = await db.query.wishlists.findFirst({ @@ -48,30 +49,22 @@ export const load: PageServerLoad = async ({ params, locals }) => { export const actions: Actions = { addItem: async ({ params, request }) => { const formData = await request.formData(); - const title = formData.get('title') as string; - const description = formData.get('description') as string; - const link = formData.get('link') as string; - const imageUrl = formData.get('imageUrl') as string; - const price = formData.get('price') as string; - const currency = formData.get('currency') as string; - const color = formData.get('color') as string; - - if (!title?.trim()) { - return { success: false, error: 'Title is required' }; + const rawData = Object.fromEntries(formData); + + const result = itemSchema.safeParse(rawData); + if (!result.success) { + return fail(400, { success: false, error: result.error.errors.map(e => e.message).join(', ') }); } const wishlist = await db.query.wishlists.findFirst({ where: eq(wishlists.ownerToken, params.token), - with: { - items: true - } + with: { items: true } }); if (!wishlist) { throw error(404, 'Wishlist not found'); } - // Get the max order value and add 1 const maxOrder = wishlist.items.reduce((max, item) => { const order = Number(item.order) || 0; return order > max ? order : max; @@ -79,13 +72,8 @@ export const actions: Actions = { const newItem: NewItem = { wishlistId: wishlist.id, - title: title.trim(), - description: description?.trim() || null, - link: link?.trim() || null, - imageUrl: imageUrl?.trim() || null, - price: price ? price.trim() : null, - currency: currency?.trim() || 'DKK', - color: color?.trim() || null, + ...result.data, + price: result.data.price?.toString() || null, order: String(maxOrder + 1) }; await db.insert(items).values(newItem); @@ -95,21 +83,11 @@ export const actions: Actions = { updateItem: async ({ params, request }) => { const formData = await request.formData(); - const itemId = formData.get('itemId') as string; - const title = formData.get('title') as string; - const description = formData.get('description') as string; - const link = formData.get('link') as string; - const imageUrl = formData.get('imageUrl') as string; - const price = formData.get('price') as string; - const currency = formData.get('currency') as string; - const color = formData.get('color') as string; - - if (!itemId) { - return { success: false, error: 'Item ID is required' }; - } - - if (!title?.trim()) { - return { success: false, error: 'Title is required' }; + const rawData = Object.fromEntries(formData); + + const result = updateItemSchema.safeParse(rawData); + if (!result.success) { + return fail(400, { success: false, error: result.error.errors.map(e => e.message).join(', ') }); } const wishlist = await db.query.wishlists.findFirst({ @@ -120,19 +98,15 @@ export const actions: Actions = { throw error(404, 'Wishlist not found'); } + const { id, ...data } = result.data; await db .update(items) .set({ - title: title.trim(), - description: description?.trim() || null, - link: link?.trim() || null, - imageUrl: imageUrl?.trim() || null, - price: price ? price.trim() : null, - currency: currency?.trim() || 'DKK', - color: color?.trim() || null, + ...data, + price: data.price?.toString() || null, updatedAt: new Date() }) - .where(eq(items.id, itemId)); + .where(eq(items.id, id)); return { success: true }; }, @@ -162,10 +136,6 @@ export const actions: Actions = { const formData = await request.formData(); const itemsJson = formData.get('items') as string; - if (!itemsJson) { - return { success: false, error: 'Items data is required' }; - } - const wishlist = await db.query.wishlists.findFirst({ where: eq(wishlists.ownerToken, params.token) }); @@ -174,9 +144,12 @@ export const actions: Actions = { throw error(404, 'Wishlist not found'); } - const updates = JSON.parse(itemsJson) as Array<{ id: string; order: number }>; + const result = reorderSchema.safeParse(JSON.parse(itemsJson || '[]')); + if (!result.success) { + return fail(400, { success: false, error: 'Invalid reorder data' }); + } - for (const update of updates) { + for (const update of result.data) { await db .update(items) .set({ order: String(update.order), updatedAt: new Date() }) @@ -202,11 +175,15 @@ export const actions: Actions = { updateWishlist: async ({ params, request }) => { const formData = await request.formData(); - const color = formData.get('color'); - const title = formData.get('title'); - const description = formData.get('description'); - const endDate = formData.get('endDate'); - const theme = formData.get('theme'); + const rawData = Object.fromEntries(formData); + + // Only validate fields that are present + const partialSchema = wishlistSchema.partial(); + const result = partialSchema.safeParse(rawData); + + if (!result.success) { + return fail(400, { success: false, error: result.error.errors.map(e => e.message).join(', ') }); + } const wishlist = await db.query.wishlists.findFirst({ where: eq(wishlists.ownerToken, params.token) @@ -216,36 +193,13 @@ export const actions: Actions = { throw error(404, 'Wishlist not found'); } - const updates: Record = { - updatedAt: new Date() - }; - - if (color !== null) { - updates.color = color?.toString().trim() || null; - } - - if (title !== null) { - const titleStr = title?.toString().trim(); - if (!titleStr) { - return { success: false, error: 'Title is required' }; - } - updates.title = titleStr; - } - - if (description !== null) { - updates.description = description?.toString().trim() || null; - } - - if (endDate !== null) { - const endDateStr = endDate?.toString().trim(); - updates.endDate = endDateStr ? new Date(endDateStr) : null; - } - - if (theme !== null) { - updates.theme = theme?.toString().trim() || 'none'; - } - - await db.update(wishlists).set(updates).where(eq(wishlists.id, wishlist.id)); + await db + .update(wishlists) + .set({ + ...result.data, + updatedAt: new Date() + }) + .where(eq(wishlists.id, wishlist.id)); return { success: true }; },