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; 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 type { PageServerLoad, Actions } from './$types';
import { db } from '$lib/server/db'; import { db } from '$lib/server/db';
import { wishlists, items, savedWishlists, type NewItem, type NewSavedWishlist } from '$lib/db/schema'; import { wishlists, items, savedWishlists, type NewItem, type NewSavedWishlist } from '$lib/db/schema';
import { eq, and } from 'drizzle-orm'; import { eq, and } from 'drizzle-orm';
import { itemSchema, updateItemSchema, wishlistSchema, reorderSchema } from '$lib/server/validation';
export const load: PageServerLoad = async ({ params, locals }) => { export const load: PageServerLoad = async ({ params, locals }) => {
const wishlist = await db.query.wishlists.findFirst({ const wishlist = await db.query.wishlists.findFirst({
@@ -48,30 +49,22 @@ export const load: PageServerLoad = async ({ params, locals }) => {
export const actions: Actions = { export const actions: Actions = {
addItem: async ({ params, request }) => { addItem: async ({ params, request }) => {
const formData = await request.formData(); const formData = await request.formData();
const title = formData.get('title') as string; const rawData = Object.fromEntries(formData);
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()) { const result = itemSchema.safeParse(rawData);
return { success: false, error: 'Title is required' }; if (!result.success) {
return fail(400, { success: false, error: result.error.errors.map(e => e.message).join(', ') });
} }
const wishlist = await db.query.wishlists.findFirst({ const wishlist = await db.query.wishlists.findFirst({
where: eq(wishlists.ownerToken, params.token), where: eq(wishlists.ownerToken, params.token),
with: { with: { items: true }
items: true
}
}); });
if (!wishlist) { if (!wishlist) {
throw error(404, 'Wishlist not found'); throw error(404, 'Wishlist not found');
} }
// Get the max order value and add 1
const maxOrder = wishlist.items.reduce((max, item) => { const maxOrder = wishlist.items.reduce((max, item) => {
const order = Number(item.order) || 0; const order = Number(item.order) || 0;
return order > max ? order : max; return order > max ? order : max;
@@ -79,13 +72,8 @@ export const actions: Actions = {
const newItem: NewItem = { const newItem: NewItem = {
wishlistId: wishlist.id, wishlistId: wishlist.id,
title: title.trim(), ...result.data,
description: description?.trim() || null, price: result.data.price?.toString() || null,
link: link?.trim() || null,
imageUrl: imageUrl?.trim() || null,
price: price ? price.trim() : null,
currency: currency?.trim() || 'DKK',
color: color?.trim() || null,
order: String(maxOrder + 1) order: String(maxOrder + 1)
}; };
await db.insert(items).values(newItem); await db.insert(items).values(newItem);
@@ -95,21 +83,11 @@ export const actions: Actions = {
updateItem: async ({ params, request }) => { updateItem: async ({ params, request }) => {
const formData = await request.formData(); const formData = await request.formData();
const itemId = formData.get('itemId') as string; const rawData = Object.fromEntries(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 (!itemId) { const result = updateItemSchema.safeParse(rawData);
return { success: false, error: 'Item ID is required' }; if (!result.success) {
} return fail(400, { success: false, error: result.error.errors.map(e => e.message).join(', ') });
if (!title?.trim()) {
return { success: false, error: 'Title is required' };
} }
const wishlist = await db.query.wishlists.findFirst({ const wishlist = await db.query.wishlists.findFirst({
@@ -120,19 +98,15 @@ export const actions: Actions = {
throw error(404, 'Wishlist not found'); throw error(404, 'Wishlist not found');
} }
const { id, ...data } = result.data;
await db await db
.update(items) .update(items)
.set({ .set({
title: title.trim(), ...data,
description: description?.trim() || null, price: data.price?.toString() || null,
link: link?.trim() || null,
imageUrl: imageUrl?.trim() || null,
price: price ? price.trim() : null,
currency: currency?.trim() || 'DKK',
color: color?.trim() || null,
updatedAt: new Date() updatedAt: new Date()
}) })
.where(eq(items.id, itemId)); .where(eq(items.id, id));
return { success: true }; return { success: true };
}, },
@@ -162,10 +136,6 @@ export const actions: Actions = {
const formData = await request.formData(); const formData = await request.formData();
const itemsJson = formData.get('items') as string; const itemsJson = formData.get('items') as string;
if (!itemsJson) {
return { success: false, error: 'Items data is required' };
}
const wishlist = await db.query.wishlists.findFirst({ const wishlist = await db.query.wishlists.findFirst({
where: eq(wishlists.ownerToken, params.token) where: eq(wishlists.ownerToken, params.token)
}); });
@@ -174,9 +144,12 @@ export const actions: Actions = {
throw error(404, 'Wishlist not found'); 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 await db
.update(items) .update(items)
.set({ order: String(update.order), updatedAt: new Date() }) .set({ order: String(update.order), updatedAt: new Date() })
@@ -202,11 +175,15 @@ export const actions: Actions = {
updateWishlist: async ({ params, request }) => { updateWishlist: async ({ params, request }) => {
const formData = await request.formData(); const formData = await request.formData();
const color = formData.get('color'); const rawData = Object.fromEntries(formData);
const title = formData.get('title');
const description = formData.get('description'); // Only validate fields that are present
const endDate = formData.get('endDate'); const partialSchema = wishlistSchema.partial();
const theme = formData.get('theme'); 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({ const wishlist = await db.query.wishlists.findFirst({
where: eq(wishlists.ownerToken, params.token) where: eq(wishlists.ownerToken, params.token)
@@ -216,36 +193,13 @@ export const actions: Actions = {
throw error(404, 'Wishlist not found'); throw error(404, 'Wishlist not found');
} }
const updates: Record<string, string | Date | null> = { await db
updatedAt: new Date() .update(wishlists)
}; .set({
...result.data,
if (color !== null) { updatedAt: new Date()
updates.color = color?.toString().trim() || null; })
} .where(eq(wishlists.id, wishlist.id));
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));
return { success: true }; return { success: true };
}, },