add: Zod validation schemas for form data and refactor server actions to use them
This commit is contained in:
@@ -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>;
|
||||
|
||||
@@ -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 };
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user