feat(tasks): integrate zod validation for task creation and updates
All checks were successful
Deploy Flowstate to VPS / deploy (push) Successful in 47s
All checks were successful
Deploy Flowstate to VPS / deploy (push) Successful in 47s
This commit is contained in:
parent
3812afab5a
commit
f99d1f15c4
3 changed files with 64 additions and 23 deletions
|
|
@ -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(),
|
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
32
lib/schemas/task.ts
Normal 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(),
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue