From 60a40b74b8eddbe579924e68d25abbe343d88287 Mon Sep 17 00:00:00 2001 From: Chneemann Date: Thu, 13 Aug 2026 11:45:19 +0200 Subject: [PATCH] feat(tasks): implement strict UUID validation, server-side existence checks, and secure authorization for task updates --- app/(app)/tasks/page.tsx | 54 ++++++----------- app/api/tasks/route.ts | 6 ++ services/task.service.ts | 123 ++++++++++++++++++++++++++------------- 3 files changed, 106 insertions(+), 77 deletions(-) diff --git a/app/(app)/tasks/page.tsx b/app/(app)/tasks/page.tsx index ce3916a..1c70b38 100644 --- a/app/(app)/tasks/page.tsx +++ b/app/(app)/tasks/page.tsx @@ -5,10 +5,11 @@ import { auth } from "@/auth"; import { db } from "@/db"; -import { usersTable, tasksTable, taskAssigneesTable } from "@/db/schema"; +import { usersTable } from "@/db/schema"; import { redirect } from "next/navigation"; import TaskForm from "./TaskForm"; -import { not, eq, isNull, and } from "drizzle-orm"; +import { not, eq } from "drizzle-orm"; +import { TaskService } from "@/services/task.service"; /** * Properties for the TaskPage component. @@ -24,64 +25,43 @@ interface TaskPageProps { } /** - * Renders the task creation or edit page, verifying user authentication, - * fetching existing task data and assignees if in edit mode, loading available users, - * and passing the context down to the task form component. + * Renders the task creation or editing page after checking user session authentication, + * validating edit mode parameters and UUID formats, fetching initial task data and assignable users, + * and loading the task form component. * * @async - * @param {TaskPageProps} props - The component props. + * @param {TaskPageProps} props - The component props containing search parameters. * @returns {Promise} The rendered task page component. */ export default async function TaskPage({ searchParams }: TaskPageProps) { const session = await auth(); - if (!session?.user?.id) { - redirect("/login"); - } + if (!session?.user?.id) redirect("/login"); const params = await searchParams; - const mode = params.task; - const taskId = params.id; - + const UUID_REGEX = + /^[0-9a-f]{8}-[0-9a-f]{4}-[4][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; let initialData = undefined; - if (mode === "edit" && taskId) { - const [task] = await db - .select() - .from(tasksTable) - .where( - and( - eq(tasksTable.id, taskId), - eq(tasksTable.userId, session.user.id), - isNull(tasksTable.deletedAt), - ), - ); + if (params.task === "edit" && params.id) { + if (!UUID_REGEX.test(params.id)) redirect("/dashboard"); - const assignedRows = await db - .select({ id: taskAssigneesTable.userId }) - .from(taskAssigneesTable) - .where(eq(taskAssigneesTable.taskId, taskId)); + initialData = await TaskService.getEditableTask(params.id, session.user.id); - initialData = { - ...task, - assignees: assignedRows, - }; + if (!initialData) redirect("/dashboard"); } const users = await db - .select({ - id: usersTable.id, - email: usersTable.email, - }) + .select({ id: usersTable.id, email: usersTable.email }) .from(usersTable) .where(not(eq(usersTable.id, session.user.id))); return (
); diff --git a/app/api/tasks/route.ts b/app/api/tasks/route.ts index d40803f..9809da8 100644 --- a/app/api/tasks/route.ts +++ b/app/api/tasks/route.ts @@ -75,6 +75,12 @@ export async function PATCH(request: Request) { const { userId, body, error } = await validateTaskRequest(request, true); if (error) return error; + const UUID_REGEX = + /^[0-9a-f]{8}-[0-9a-f]{4}-[4][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + if (body.id && !UUID_REGEX.test(body.id)) { + return new NextResponse("Invalid Task ID format", { status: 400 }); + } + const existingTask = await TaskService.verifyAccess(body!.id!, userId!); if (!existingTask) { return new NextResponse( diff --git a/services/task.service.ts b/services/task.service.ts index ddf3c21..cd38726 100644 --- a/services/task.service.ts +++ b/services/task.service.ts @@ -41,22 +41,28 @@ export class TaskService { } /** - * Helper: Synchronizes assignees for a given task (replaces existing ones). + * Helper: Synchronizes the assigned users for a specified task within a transaction. * * @private * @async + * @param {any} tx - The Drizzle transaction instance. * @param {string} taskId - The unique identifier of the task. - * @param {string[]} [assignees] - Optional array of user IDs to assign. + * @param {string[]} [assignees] - An optional list of user IDs to assign. * @returns {Promise} */ - private static async syncAssignees(taskId: string, assignees?: string[]) { - await db + private static async syncAssignees( + tx: any, + taskId: string, + assignees?: string[], + ) { + await tx .delete(taskAssigneesTable) .where(eq(taskAssigneesTable.taskId, taskId)); if (assignees && assignees.length > 0) { - const values = assignees.map((userId) => ({ taskId, userId })); - await db.insert(taskAssigneesTable).values(values); + const uniqueAssignees = [...new Set(assignees)]; + const values = uniqueAssignees.map((userId) => ({ taskId, userId })); + await tx.insert(taskAssigneesTable).values(values); } } @@ -226,66 +232,103 @@ export class TaskService { } /** - * Creates a new task and synchronizes its initial assignees. + * Creates a new task and synchronizes its assignees within a database transaction. * * @async * @param {string} userId - The unique identifier of the user creating the task. - * @param {TaskPayload} data - The task creation payload containing title, description, priority, status, due date, and optional assignees. + * @param {TaskPayload} data - The payload containing task details. * @returns {Promise} The newly created task record. */ static async createTask(userId: string, data: TaskPayload) { - const [newTask] = await db - .insert(tasksTable) - .values({ - title: data.title, - description: data.description ?? "", - priority: data.priority, - status: data.status, - dueDate: new Date(data.dueDate), - userId, - }) - .returning(); + return await db.transaction(async (tx) => { + const [newTask] = await tx + .insert(tasksTable) + .values({ + title: data.title, + description: data.description ?? "", + priority: data.priority, + status: data.status, + dueDate: new Date(data.dueDate), + userId, + }) + .returning(); - await this.syncAssignees(newTask.id, data.assignees); - return newTask as DbTask; + await this.syncAssignees(tx, newTask.id, data.assignees); + return newTask as DbTask; + }); } /** * Updates an existing task and synchronizes its assignees if the user is authorized. * * @async - * @param {string} taskId - The unique identifier of the task. - * @param {string} userId - The unique identifier of the user. - * @param {TaskPayload} data - The update payload containing new task properties. - * @returns {Promise} The updated task record or null if unauthorized. + * @param {string} taskId - The unique identifier of the task to update. + * @param {string} userId - The unique identifier of the user performing the update. + * @param {TaskPayload} data - The payload containing updated task details. + * @returns {Promise} The updated task record or null if unauthorized/not found. */ static async updateTaskIfAuthorized( taskId: string, userId: string, data: TaskPayload, ) { - const [updatedTask] = await db - .update(tasksTable) - .set({ - title: data.title, - description: data.description, - priority: data.priority, - status: data.status, - dueDate: new Date(data.dueDate), - updatedAt: new Date(), - }) + return await db.transaction(async (tx) => { + const [updatedTask] = await tx + .update(tasksTable) + .set({ + title: data.title, + description: data.description, + priority: data.priority, + status: data.status, + dueDate: new Date(data.dueDate), + updatedAt: new Date(), + }) + .where( + and( + eq(tasksTable.id, taskId), + eq(tasksTable.userId, userId), + isNull(tasksTable.deletedAt), + ), + ) + .returning(); + + if (!updatedTask) { + tx.rollback(); + return null; + } + + await this.syncAssignees(tx, taskId, data.assignees); + return updatedTask as DbTask; + }); + } + + /** + * Retrieves an editable task along with its assignees if the user owns it and it isn't deleted. + * + * @async + * @param {string} taskId - The unique identifier of the task. + * @param {string} userId - The unique identifier of the user. + * @returns {Promise} The task record with assignees or null if not found. + */ + static async getEditableTask(taskId: string, userId: string) { + const [task] = await db + .select() + .from(tasksTable) .where( and( eq(tasksTable.id, taskId), eq(tasksTable.userId, userId), isNull(tasksTable.deletedAt), ), - ) - .returning(); + ); - if (!updatedTask) return null; + if (!task) return null; - await this.syncAssignees(taskId, data.assignees); - return updatedTask as DbTask; + const assignees = await db + .select({ id: taskAssigneesTable.userId }) + .from(taskAssigneesTable) + .where(eq(taskAssigneesTable.taskId, taskId)); + + return { ...task, assignees }; } }