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;
|
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 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 result = itemSchema.safeParse(rawData);
|
||||||
const imageUrl = formData.get('imageUrl') as string;
|
if (!result.success) {
|
||||||
const price = formData.get('price') as string;
|
return fail(400, { success: false, error: result.error.errors.map(e => e.message).join(', ') });
|
||||||
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 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 result = updateItemSchema.safeParse(rawData);
|
||||||
const link = formData.get('link') as string;
|
if (!result.success) {
|
||||||
const imageUrl = formData.get('imageUrl') as string;
|
return fail(400, { success: false, error: result.error.errors.map(e => e.message).join(', ') });
|
||||||
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 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 };
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user