add: Zod validation schemas for form data and refactor server actions to use them

This commit is contained in:
Rasmus Q
2026-03-16 10:56:58 +00:00
parent bc680fb60b
commit fad19a9aa0
2 changed files with 79 additions and 86 deletions

View File

@@ -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<typeof itemSchema>;
export type UpdateItemFormData = z.infer<typeof updateItemSchema>;
export type WishlistFormData = z.infer<typeof wishlistSchema>;

View File

@@ -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<string, string | Date | null> = {
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 };
},