feat(tasks): integrate zod validation for task creation and updates
All checks were successful
Deploy Flowstate to VPS / deploy (push) Successful in 47s

This commit is contained in:
Chneemann 2026-08-23 10:54:20 +02:00
parent 3812afab5a
commit f99d1f15c4
No known key found for this signature in database
3 changed files with 64 additions and 23 deletions

View file

@ -6,6 +6,7 @@
import { useState, useTransition } from "react"; import { useState, useTransition } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { TaskPriority, TaskStatus, DbTask } from "@/types/task"; import { TaskPriority, TaskStatus, DbTask } from "@/types/task";
import { taskSchema } from "@/lib/schemas/task";
/** /**
* Custom React hook that encapsulates form state management, field updates, assignee toggling, * Custom React hook that encapsulates form state management, field updates, assignee toggling,
@ -65,18 +66,14 @@ export function useTaskForm(
}; };
/** /**
* Validates form inputs, combines date and time values, and submits the payload * Validates form input against Zod schemas and dispatches POST or PATCH network requests to persist task changes.
* via API depending on whether the form is in create or edit mode.
* *
* @async
* @param {React.SubmitEvent} e - The form submission event. * @param {React.SubmitEvent} e - The form submission event.
*/ */
const handleSubmit = (e: React.SubmitEvent) => { const handleSubmit = (e: React.SubmitEvent) => {
e.preventDefault(); e.preventDefault();
setError(null); setError(null);
if (!form.title.trim()) return;
const finalDate = form.dueDate || todayString; const finalDate = form.dueDate || todayString;
const combinedDateTime = new Date(`${finalDate}T${form.dueTime}`); const combinedDateTime = new Date(`${finalDate}T${form.dueTime}`);
@ -85,15 +82,24 @@ export function useTaskForm(
return; return;
} }
const validationResult = taskSchema.safeParse({
...form,
dueDate: combinedDateTime.toISOString(),
});
if (!validationResult.success) {
setError(validationResult.error.issues[0].message);
return;
}
startTransition(async () => { startTransition(async () => {
try { try {
const response = await fetch("/api/tasks", { const response = await fetch("/api/tasks", {
method: isEditMode ? "PATCH" : "POST", method: isEditMode ? "PATCH" : "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ body: JSON.stringify({
...validationResult.data,
id: initialData?.id, id: initialData?.id,
...form,
dueDate: combinedDateTime.toISOString(),
}), }),
}); });

View file

@ -1,21 +1,21 @@
/** /**
* @file api/tasks/route.ts * @file api/tasks/route.ts
* @description API endpoint for creating and updating task records with strict existence checks. * @description API endpoint for creating and updating task records with strict Zod validation.
*/ */
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { auth } from "@/auth"; import { auth } from "@/auth";
import { TaskService } from "@/services/task.service"; import { TaskService } from "@/services/task.service";
import { TaskPayload } from "@/types/task"; import { taskSchema } from "@/lib/schemas/task";
/** /**
* Validates the incoming task request by checking user authentication, parsing the JSON payload, * Validates the incoming task request by checking user authentication, parsing the JSON payload,
* and ensuring all required task fields (and optional ID if specified) are present. * and running Zod validation against the task schema.
* *
* @async * @async
* @param {Request} request - The incoming HTTP request. * @param {Request} request - The incoming HTTP request.
* @param {boolean} [requireId=false] - Whether a task ID is mandatory in the payload body. * @param {boolean} [requireId=false] - Whether a task ID is mandatory in the payload body.
* @returns {Promise<{ userId?: string; body?: TaskPayload & { id?: string }; error?: NextResponse }>} Validation results containing user ID, parsed body, or a NextResponse error. * @returns {Promise<{ userId?: string; body?: any; error?: NextResponse }>} Validation results containing user ID, parsed body, or a NextResponse error.
*/ */
async function validateTaskRequest(request: Request, requireId = false) { async function validateTaskRequest(request: Request, requireId = false) {
const session = await auth(); const session = await auth();
@ -23,19 +23,22 @@ async function validateTaskRequest(request: Request, requireId = false) {
return { error: new NextResponse("Unauthorized", { status: 401 }) }; return { error: new NextResponse("Unauthorized", { status: 401 }) };
} }
const body: TaskPayload & { id?: string } = await request.json(); const jsonBody = await request.json().catch(() => null);
if (!jsonBody) {
return { error: new NextResponse("Invalid JSON payload", { status: 400 }) };
}
if ( const validationResult = taskSchema.safeParse(jsonBody);
(requireId && !body.id) ||
!body.title || if (!validationResult.success) {
!body.description || const errorMessage = validationResult.error.issues[0].message;
!body.dueDate || return { error: new NextResponse(errorMessage, { status: 400 }) };
!body.status || }
!body.priority
) { const body = validationResult.data;
return {
error: new NextResponse("Missing required task fields", { status: 400 }), if (requireId && !body.id) {
}; return { error: new NextResponse("Missing task ID", { status: 400 }) };
} }
return { userId: session.user.id, body }; return { userId: session.user.id, body };

32
lib/schemas/task.ts Normal file
View file

@ -0,0 +1,32 @@
/**
* @file lib/schemas/task.ts
* @description Zod validation schema for task creation and modification operations, enforcing constraints on title, description, status, priority, dates, and assignees.
*/
import { z } from "zod";
import { taskStatusEnum, taskPriorityEnum } from "@/db/schema";
/**
* Zod validation schema for task entities and form submissions.
*/
export const taskSchema = z.object({
id: z.uuid().optional(),
title: z
.string()
.min(1, "Title is required")
.max(255, "Title is too long (max 255 characters)"),
description: z
.string()
.min(1, "Description is required")
.max(2000, "Description is too long (max 2000 characters)"),
status: z.enum(taskStatusEnum.enumValues),
priority: z.enum(taskPriorityEnum.enumValues),
dueDate: z
.string()
.min(1, "Due date is required")
.transform((val) => new Date(val)),
dueTime: z
.string()
.regex(/^([0-1]?[0-9]|2[0-3]):[0-5][0-9]$/, "Invalid time format"),
assignees: z.array(z.uuid()).optional(),
});