feat(tasks): implement strict UUID validation, server-side existence checks, and secure authorization for task updates
This commit is contained in:
parent
cf72de43d9
commit
60a40b74b8
3 changed files with 106 additions and 77 deletions
|
|
@ -5,10 +5,11 @@
|
||||||
|
|
||||||
import { auth } from "@/auth";
|
import { auth } from "@/auth";
|
||||||
import { db } from "@/db";
|
import { db } from "@/db";
|
||||||
import { usersTable, tasksTable, taskAssigneesTable } from "@/db/schema";
|
import { usersTable } from "@/db/schema";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import TaskForm from "./TaskForm";
|
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.
|
* Properties for the TaskPage component.
|
||||||
|
|
@ -24,64 +25,43 @@ interface TaskPageProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Renders the task creation or edit page, verifying user authentication,
|
* Renders the task creation or editing page after checking user session authentication,
|
||||||
* fetching existing task data and assignees if in edit mode, loading available users,
|
* validating edit mode parameters and UUID formats, fetching initial task data and assignable users,
|
||||||
* and passing the context down to the task form component.
|
* and loading the task form component.
|
||||||
*
|
*
|
||||||
* @async
|
* @async
|
||||||
* @param {TaskPageProps} props - The component props.
|
* @param {TaskPageProps} props - The component props containing search parameters.
|
||||||
* @returns {Promise<JSX.Element>} The rendered task page component.
|
* @returns {Promise<JSX.Element>} The rendered task page component.
|
||||||
*/
|
*/
|
||||||
export default async function TaskPage({ searchParams }: TaskPageProps) {
|
export default async function TaskPage({ searchParams }: TaskPageProps) {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
if (!session?.user?.id) {
|
if (!session?.user?.id) redirect("/login");
|
||||||
redirect("/login");
|
|
||||||
}
|
|
||||||
|
|
||||||
const params = await searchParams;
|
const params = await searchParams;
|
||||||
const mode = params.task;
|
const UUID_REGEX =
|
||||||
const taskId = params.id;
|
/^[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;
|
let initialData = undefined;
|
||||||
|
|
||||||
if (mode === "edit" && taskId) {
|
if (params.task === "edit" && params.id) {
|
||||||
const [task] = await db
|
if (!UUID_REGEX.test(params.id)) redirect("/dashboard");
|
||||||
.select()
|
|
||||||
.from(tasksTable)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(tasksTable.id, taskId),
|
|
||||||
eq(tasksTable.userId, session.user.id),
|
|
||||||
isNull(tasksTable.deletedAt),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
const assignedRows = await db
|
initialData = await TaskService.getEditableTask(params.id, session.user.id);
|
||||||
.select({ id: taskAssigneesTable.userId })
|
|
||||||
.from(taskAssigneesTable)
|
|
||||||
.where(eq(taskAssigneesTable.taskId, taskId));
|
|
||||||
|
|
||||||
initialData = {
|
if (!initialData) redirect("/dashboard");
|
||||||
...task,
|
|
||||||
assignees: assignedRows,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const users = await db
|
const users = await db
|
||||||
.select({
|
.select({ id: usersTable.id, email: usersTable.email })
|
||||||
id: usersTable.id,
|
|
||||||
email: usersTable.email,
|
|
||||||
})
|
|
||||||
.from(usersTable)
|
.from(usersTable)
|
||||||
.where(not(eq(usersTable.id, session.user.id)));
|
.where(not(eq(usersTable.id, session.user.id)));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto pb-12">
|
<div className="mx-auto pb-12">
|
||||||
<TaskForm
|
<TaskForm
|
||||||
key={`${mode}-${taskId ?? "new"}`}
|
key={`${params.task}-${params.id ?? "new"}`}
|
||||||
users={users}
|
users={users}
|
||||||
initialData={initialData}
|
initialData={initialData}
|
||||||
mode={mode}
|
mode={params.task}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -75,6 +75,12 @@ export async function PATCH(request: Request) {
|
||||||
const { userId, body, error } = await validateTaskRequest(request, true);
|
const { userId, body, error } = await validateTaskRequest(request, true);
|
||||||
if (error) return error;
|
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!);
|
const existingTask = await TaskService.verifyAccess(body!.id!, userId!);
|
||||||
if (!existingTask) {
|
if (!existingTask) {
|
||||||
return new NextResponse(
|
return new NextResponse(
|
||||||
|
|
|
||||||
|
|
@ -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
|
* @private
|
||||||
* @async
|
* @async
|
||||||
|
* @param {any} tx - The Drizzle transaction instance.
|
||||||
* @param {string} taskId - The unique identifier of the task.
|
* @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<void>}
|
* @returns {Promise<void>}
|
||||||
*/
|
*/
|
||||||
private static async syncAssignees(taskId: string, assignees?: string[]) {
|
private static async syncAssignees(
|
||||||
await db
|
tx: any,
|
||||||
|
taskId: string,
|
||||||
|
assignees?: string[],
|
||||||
|
) {
|
||||||
|
await tx
|
||||||
.delete(taskAssigneesTable)
|
.delete(taskAssigneesTable)
|
||||||
.where(eq(taskAssigneesTable.taskId, taskId));
|
.where(eq(taskAssigneesTable.taskId, taskId));
|
||||||
|
|
||||||
if (assignees && assignees.length > 0) {
|
if (assignees && assignees.length > 0) {
|
||||||
const values = assignees.map((userId) => ({ taskId, userId }));
|
const uniqueAssignees = [...new Set(assignees)];
|
||||||
await db.insert(taskAssigneesTable).values(values);
|
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
|
* @async
|
||||||
* @param {string} userId - The unique identifier of the user creating the task.
|
* @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<DbTask>} The newly created task record.
|
* @returns {Promise<DbTask>} The newly created task record.
|
||||||
*/
|
*/
|
||||||
static async createTask(userId: string, data: TaskPayload) {
|
static async createTask(userId: string, data: TaskPayload) {
|
||||||
const [newTask] = await db
|
return await db.transaction(async (tx) => {
|
||||||
.insert(tasksTable)
|
const [newTask] = await tx
|
||||||
.values({
|
.insert(tasksTable)
|
||||||
title: data.title,
|
.values({
|
||||||
description: data.description ?? "",
|
title: data.title,
|
||||||
priority: data.priority,
|
description: data.description ?? "",
|
||||||
status: data.status,
|
priority: data.priority,
|
||||||
dueDate: new Date(data.dueDate),
|
status: data.status,
|
||||||
userId,
|
dueDate: new Date(data.dueDate),
|
||||||
})
|
userId,
|
||||||
.returning();
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
await this.syncAssignees(newTask.id, data.assignees);
|
await this.syncAssignees(tx, newTask.id, data.assignees);
|
||||||
return newTask as DbTask;
|
return newTask as DbTask;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Updates an existing task and synchronizes its assignees if the user is authorized.
|
* Updates an existing task and synchronizes its assignees if the user is authorized.
|
||||||
*
|
*
|
||||||
* @async
|
* @async
|
||||||
* @param {string} taskId - The unique identifier of the task.
|
* @param {string} taskId - The unique identifier of the task to update.
|
||||||
* @param {string} userId - The unique identifier of the user.
|
* @param {string} userId - The unique identifier of the user performing the update.
|
||||||
* @param {TaskPayload} data - The update payload containing new task properties.
|
* @param {TaskPayload} data - The payload containing updated task details.
|
||||||
* @returns {Promise<DbTask | null>} The updated task record or null if unauthorized.
|
* @returns {Promise<DbTask | null>} The updated task record or null if unauthorized/not found.
|
||||||
*/
|
*/
|
||||||
static async updateTaskIfAuthorized(
|
static async updateTaskIfAuthorized(
|
||||||
taskId: string,
|
taskId: string,
|
||||||
userId: string,
|
userId: string,
|
||||||
data: TaskPayload,
|
data: TaskPayload,
|
||||||
) {
|
) {
|
||||||
const [updatedTask] = await db
|
return await db.transaction(async (tx) => {
|
||||||
.update(tasksTable)
|
const [updatedTask] = await tx
|
||||||
.set({
|
.update(tasksTable)
|
||||||
title: data.title,
|
.set({
|
||||||
description: data.description,
|
title: data.title,
|
||||||
priority: data.priority,
|
description: data.description,
|
||||||
status: data.status,
|
priority: data.priority,
|
||||||
dueDate: new Date(data.dueDate),
|
status: data.status,
|
||||||
updatedAt: new Date(),
|
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<any | null>} 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(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(tasksTable.id, taskId),
|
eq(tasksTable.id, taskId),
|
||||||
eq(tasksTable.userId, userId),
|
eq(tasksTable.userId, userId),
|
||||||
isNull(tasksTable.deletedAt),
|
isNull(tasksTable.deletedAt),
|
||||||
),
|
),
|
||||||
)
|
);
|
||||||
.returning();
|
|
||||||
|
|
||||||
if (!updatedTask) return null;
|
if (!task) return null;
|
||||||
|
|
||||||
await this.syncAssignees(taskId, data.assignees);
|
const assignees = await db
|
||||||
return updatedTask as DbTask;
|
.select({ id: taskAssigneesTable.userId })
|
||||||
|
.from(taskAssigneesTable)
|
||||||
|
.where(eq(taskAssigneesTable.taskId, taskId));
|
||||||
|
|
||||||
|
return { ...task, assignees };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue