diff --git a/app/(app)/tasks/TaskForm.tsx b/app/(app)/tasks/TaskForm.tsx
new file mode 100644
index 0000000..57fef31
--- /dev/null
+++ b/app/(app)/tasks/TaskForm.tsx
@@ -0,0 +1,105 @@
+/**
+ * @file tasks/TaskForm.tsx
+ * @description Client component orchestrating modular sub-components for task creation and editing.
+ */
+
+"use client";
+
+import { AlertCircle } from "lucide-react";
+import Link from "next/link";
+import { DbTask } from "@/types/task";
+import { useTaskForm } from "./useTaskForm";
+import TaskHeader from "./task/TaskHeader";
+import TaskBasicInfo from "./task/TaskBasicInfo";
+import TaskOptions from "./task/TaskOptions";
+import TaskAssignees from "./task/TaskAssignees";
+import TaskDateTime from "./task/TaskDateTime";
+
+/**
+ * Properties for the TaskForm component.
+ *
+ * @interface TaskFormProps
+ * @property {{ id: string; email: string }[]} users - The list of available users who can be assigned to the task.
+ * @property {DbTask & { assignees?: { id: string }[] }} [initialData] - Optional initial task data for editing an existing task.
+ * @property {string} [mode] - Optional mode indicator (e.g., create or edit).
+ */
+interface TaskFormProps {
+ users: { id: string; email: string }[];
+ initialData?: DbTask & { assignees?: { id: string }[] };
+ mode?: string;
+ serverError?: string | null;
+}
+
+/**
+ * Renders a complete task form layout combining various sub-components for basic info,
+ * options, assignees, and date/time selections, alongside error alerts and form submission controls.
+ *
+ * @param {TaskFormProps} props - The component props.
+ * @returns {JSX.Element} The rendered task form component.
+ */
+export default function TaskForm({ users, initialData, mode }: TaskFormProps) {
+ const {
+ form,
+ error,
+ isEditMode,
+ todayString,
+ updateField,
+ handleAssigneeToggle,
+ handleSubmit,
+ } = useTaskForm(initialData, mode);
+
+ return (
+
+ );
+}
diff --git a/app/(app)/tasks/page.tsx b/app/(app)/tasks/page.tsx
new file mode 100644
index 0000000..ce3916a
--- /dev/null
+++ b/app/(app)/tasks/page.tsx
@@ -0,0 +1,88 @@
+/**
+ * @file tasks/page.tsx
+ * @description Server component rendering the task creation or edit page with relations.
+ */
+
+import { auth } from "@/auth";
+import { db } from "@/db";
+import { usersTable, tasksTable, taskAssigneesTable } from "@/db/schema";
+import { redirect } from "next/navigation";
+import TaskForm from "./TaskForm";
+import { not, eq, isNull, and } from "drizzle-orm";
+
+/**
+ * Properties for the TaskPage component.
+ *
+ * @interface TaskPageProps
+ * @property {Promise<{ task?: string; id?: string; }>} searchParams - A promise resolving to the search parameters containing mode and task ID.
+ */
+interface TaskPageProps {
+ searchParams: Promise<{
+ task?: string;
+ id?: string;
+ }>;
+}
+
+/**
+ * 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.
+ *
+ * @async
+ * @param {TaskPageProps} props - The component props.
+ * @returns {Promise} The rendered task page component.
+ */
+export default async function TaskPage({ searchParams }: TaskPageProps) {
+ const session = await auth();
+ if (!session?.user?.id) {
+ redirect("/login");
+ }
+
+ const params = await searchParams;
+ const mode = params.task;
+ const taskId = params.id;
+
+ 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),
+ ),
+ );
+
+ const assignedRows = await db
+ .select({ id: taskAssigneesTable.userId })
+ .from(taskAssigneesTable)
+ .where(eq(taskAssigneesTable.taskId, taskId));
+
+ initialData = {
+ ...task,
+ assignees: assignedRows,
+ };
+ }
+
+ const users = await db
+ .select({
+ id: usersTable.id,
+ email: usersTable.email,
+ })
+ .from(usersTable)
+ .where(not(eq(usersTable.id, session.user.id)));
+
+ return (
+
+
+
+ );
+}
diff --git a/app/(app)/tasks/task/TaskAssignees.tsx b/app/(app)/tasks/task/TaskAssignees.tsx
new file mode 100644
index 0000000..1bfa72e
--- /dev/null
+++ b/app/(app)/tasks/task/TaskAssignees.tsx
@@ -0,0 +1,72 @@
+/**
+ * @file tasks/task/TaskAssignees.tsx
+ * @description Component for selecting task assignees.
+ */
+
+/**
+ * Properties for the TaskAssignees component.
+ *
+ * @interface TaskAssigneesProps
+ * @property {Array<{ id: string; email: string }>} users - The list of available users to assign.
+ * @property {string[]} selectedAssignees - An array containing the IDs of currently selected assignees.
+ * @property {(userId: string) => void} onToggle - Callback function triggered when a user selection is toggled.
+ */
+interface TaskAssigneesProps {
+ users: { id: string; email: string }[];
+ selectedAssignees: string[];
+ onToggle: (userId: string) => void;
+}
+
+/**
+ * Renders an interactive list of users allowing selection or deselection of assignees for a task.
+ *
+ * @param {TaskAssigneesProps} props - The component props.
+ * @returns {JSX.Element} The rendered task assignees selector component.
+ */
+export default function TaskAssignees({
+ users,
+ selectedAssignees,
+ onToggle,
+}: TaskAssigneesProps) {
+ return (
+
+
+
+ {users.length === 0 ? (
+
+ No other users found
+
+ ) : (
+ users.map((user) => {
+ const isSelected = selectedAssignees.includes(user.id);
+ return (
+
+ );
+ })
+ )}
+
+
+ );
+}
diff --git a/app/(app)/tasks/task/TaskBasicInfo.tsx b/app/(app)/tasks/task/TaskBasicInfo.tsx
new file mode 100644
index 0000000..5d1a17d
--- /dev/null
+++ b/app/(app)/tasks/task/TaskBasicInfo.tsx
@@ -0,0 +1,67 @@
+/**
+ * @file tasks/task/TaskBasicInfo.tsx
+ * @description Component for title and description input fields.
+ */
+
+/**
+ * Properties for the TaskBasicInfo component.
+ *
+ * @interface TaskBasicInfoProps
+ * @property {string} title - The current title value of the task.
+ * @property {(val: string) => void} setTitle - Callback function to update the task title state.
+ * @property {string} description - The current description value of the task.
+ * @property {(val: string) => void} setDescription - Callback function to update the task description state.
+ */
+interface TaskBasicInfoProps {
+ title: string;
+ setTitle: (val: string) => void;
+ description: string;
+ setDescription: (val: string) => void;
+}
+
+/**
+ * Renders form fields for entering and updating a task's basic information (title and description).
+ *
+ * @param {TaskBasicInfoProps} props - The component props.
+ * @returns {JSX.Element} The rendered task basic information form inputs.
+ */
+export default function TaskBasicInfo({
+ title,
+ setTitle,
+ description,
+ setDescription,
+}: TaskBasicInfoProps) {
+ return (
+
+ {/* Title */}
+
+
+ setTitle(e.target.value)}
+ placeholder="e.g. Redesign Landing Page"
+ className="w-full bg-background border border-border rounded-xl px-4 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-primary/40"
+ />
+
+
+ {/* Description */}
+
+
+
+
+ );
+}
diff --git a/app/(app)/tasks/task/TaskDateTime.tsx b/app/(app)/tasks/task/TaskDateTime.tsx
new file mode 100644
index 0000000..25e50b1
--- /dev/null
+++ b/app/(app)/tasks/task/TaskDateTime.tsx
@@ -0,0 +1,82 @@
+/**
+ * @file tasks/task/TaskDateTime.tsx
+ * @description Component for selecting due date and time.
+ */
+
+import React from "react";
+
+/**
+ * Properties for the TaskDateTime component.
+ *
+ * @interface TaskDateTimeProps
+ * @property {string} dueDate - The currently selected due date string.
+ * @property {(val: string) => void} setDueDate - Callback function to update the due date value.
+ * @property {string} dueTime - The currently selected due time string.
+ * @property {(val: string) => void} setDueTime - Callback function to update the due time value.
+ * @property {string} todayString - The minimum selectable date string (today's date).
+ */
+interface TaskDateTimeProps {
+ dueDate: string;
+ setDueDate: (val: string) => void;
+ dueTime: string;
+ setDueTime: (val: string) => void;
+ todayString: string;
+}
+
+/**
+ * Renders date and time input selection controls for managing task deadlines.
+ *
+ * @param {TaskDateTimeProps} props - The component props.
+ * @returns {JSX.Element} The rendered task date and time component.
+ */
+export default function TaskDateTime({
+ dueDate,
+ setDueDate,
+ dueTime,
+ setDueTime,
+ todayString,
+}: TaskDateTimeProps) {
+ return (
+
+
+
+ setDueDate(e.target.value)}
+ className="w-full bg-background border border-border rounded-xl px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-primary/40 cursor-pointer"
+ />
+
+
+
+
+
+
+
+ );
+}
diff --git a/app/(app)/tasks/task/TaskHeader.tsx b/app/(app)/tasks/task/TaskHeader.tsx
new file mode 100644
index 0000000..5b1dd6e
--- /dev/null
+++ b/app/(app)/tasks/task/TaskHeader.tsx
@@ -0,0 +1,33 @@
+/**
+ * @file tasks/task/TaskHeader.tsx
+ * @description Component rendering the top title header and action buttons for the task view.
+ */
+
+import { Sparkles } from "lucide-react";
+
+/**
+ * Renders the task management header section featuring dynamic title and subtitle text based on edit mode.
+ *
+ * @param {boolean} [isEditMode] - Flag indicating whether the header is displayed in edit mode or creation mode.
+ * @returns {JSX.Element} The rendered task header component.
+ */
+export default function TaskHeader({ isEditMode }: { isEditMode?: boolean }) {
+ return (
+
+
+
+
+ Task Management
+
+
+ {isEditMode ? "Edit Task" : "Create New Task"}
+
+
+ {isEditMode
+ ? "Update your existing task details and assignees."
+ : "Add a new task to your workspace and assign priorities."}
+
+
+
+ );
+}
diff --git a/app/(app)/tasks/task/TaskOptions.tsx b/app/(app)/tasks/task/TaskOptions.tsx
new file mode 100644
index 0000000..0c1603f
--- /dev/null
+++ b/app/(app)/tasks/task/TaskOptions.tsx
@@ -0,0 +1,73 @@
+/**
+ * @file tasks/task/TaskOptions.tsx
+ * @description Component for selecting priority and status using centralized type definitions.
+ */
+
+import { TaskPriority, TaskStatus, COLUMNS } from "@/types/task";
+
+/**
+ * Properties for the TaskOptions component.
+ *
+ * @interface TaskOptionsProps
+ * @property {TaskPriority} priority - The currently selected task priority level.
+ * @property {(val: TaskPriority) => void} setPriority - Callback function to update the task priority.
+ * @property {TaskStatus} status - The currently selected task status.
+ * @property {(val: TaskStatus) => void} setStatus - Callback function to update the task status.
+ */
+interface TaskOptionsProps {
+ priority: TaskPriority;
+ setPriority: (val: TaskPriority) => void;
+ status: TaskStatus;
+ setStatus: (val: TaskStatus) => void;
+}
+
+/**
+ * Renders form option selectors for task priority and initial status dropdowns.
+ *
+ * @param {TaskOptionsProps} props - The component props.
+ * @returns {JSX.Element} The rendered task options component.
+ */
+export default function TaskOptions({
+ priority,
+ setPriority,
+ status,
+ setStatus,
+}: TaskOptionsProps) {
+ return (
+
+ {/* Priority */}
+
+
+
+
+
+ {/* Status (dynamisch aus deinen Kanban-Spalten) */}
+
+
+
+
+
+ );
+}
diff --git a/app/(app)/tasks/useTaskForm.ts b/app/(app)/tasks/useTaskForm.ts
new file mode 100644
index 0000000..4d8ee07
--- /dev/null
+++ b/app/(app)/tasks/useTaskForm.ts
@@ -0,0 +1,121 @@
+/**
+ * @file tasks/useTaskForm.ts
+ * @description Custom hook managing state, validation, and submission logic for task creation and editing.
+ */
+
+import { useState, useTransition } from "react";
+import { useRouter } from "next/navigation";
+import { TaskPriority, TaskStatus, DbTask } from "@/types/task";
+
+/**
+ * Custom React hook that encapsulates form state management, field updates, assignee toggling,
+ * validation rules, and network submission logic for both task creation and editing workflows.
+ *
+ * @param {DbTask & { assignees?: { id: string }[] }} [initialData] - Optional initial task data for editing workflows.
+ * @param {string} [mode] - Operation mode indicator (e.g., "edit").
+ * @returns An object containing form state values, error states, and event handler functions.
+ */
+export function useTaskForm(
+ initialData?: DbTask & { assignees?: { id: string }[] },
+ mode?: string,
+) {
+ const router = useRouter();
+ const [, startTransition] = useTransition();
+ const isEditMode = mode === "edit" && initialData;
+
+ const todayString = new Date().toISOString().split("T")[0];
+ const dueDateObj = initialData?.dueDate
+ ? new Date(initialData.dueDate)
+ : null;
+
+ const [form, setForm] = useState({
+ title: initialData?.title ?? "",
+ description: initialData?.description ?? "",
+ priority: (initialData?.priority ?? "medium") as TaskPriority,
+ status: (initialData?.status ?? "todo") as TaskStatus,
+ assignees: initialData?.assignees?.map((a) => a.id) ?? [],
+ dueDate: dueDateObj ? dueDateObj.toISOString().split("T")[0] : todayString,
+ dueTime: dueDateObj ? dueDateObj.toTimeString().slice(0, 5) : "23:59",
+ });
+
+ const [error, setError] = useState(null);
+
+ /**
+ * Updates a single property within the form state object.
+ *
+ * @param {string} field - The target field name to update.
+ * @param {any} value - The new value for the field.
+ */
+ const updateField = (field: string, value: any) => {
+ setForm((prev) => ({ ...prev, [field]: value }));
+ };
+
+ /**
+ * Toggles the inclusion of a user ID within the assignees selection list.
+ *
+ * @param {string} userId - The unique identifier of the user to toggle.
+ */
+ const handleAssigneeToggle = (userId: string) => {
+ const assignees = form.assignees.includes(userId)
+ ? form.assignees.filter((id) => id !== userId)
+ : [...form.assignees, userId];
+ updateField("assignees", assignees);
+ };
+
+ /**
+ * Validates form inputs, combines date and time values, and submits the payload
+ * via API depending on whether the form is in create or edit mode.
+ *
+ * @async
+ * @param {React.SubmitEvent} e - The form submission event.
+ */
+ const handleSubmit = (e: React.SubmitEvent) => {
+ e.preventDefault();
+ setError(null);
+
+ if (!form.title.trim()) return;
+
+ const finalDate = form.dueDate || todayString;
+ const combinedDateTime = new Date(`${finalDate}T${form.dueTime}`);
+
+ if (!isEditMode && combinedDateTime <= new Date()) {
+ setError("The due date and time must be in the future.");
+ return;
+ }
+
+ startTransition(async () => {
+ try {
+ const response = await fetch("/api/tasks", {
+ method: isEditMode ? "PATCH" : "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ id: initialData?.id,
+ ...form,
+ dueDate: combinedDateTime.toISOString(),
+ }),
+ });
+
+ if (!response.ok) {
+ const errorText = await response.text();
+ throw new Error(errorText || "Failed to save task.");
+ }
+
+ router.push("/dashboard");
+ router.refresh();
+ } catch (err: any) {
+ console.error("Error submitting task:", err);
+ setError(err.message || "Something went wrong. Please try again.");
+ }
+ });
+ };
+
+ return {
+ form,
+ error,
+ isEditMode,
+ todayString,
+ updateField,
+ handleAssigneeToggle,
+ handleSubmit,
+ };
+}
diff --git a/app/api/tasks/route.ts b/app/api/tasks/route.ts
new file mode 100644
index 0000000..d40803f
--- /dev/null
+++ b/app/api/tasks/route.ts
@@ -0,0 +1,97 @@
+/**
+ * @file api/tasks/route.ts
+ * @description API endpoint for creating and updating task records with strict existence checks.
+ */
+
+import { NextResponse } from "next/server";
+import { auth } from "@/auth";
+import { TaskService } from "@/services/task.service";
+import { TaskPayload } from "@/types/task";
+
+/**
+ * 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.
+ *
+ * @async
+ * @param {Request} request - The incoming HTTP request.
+ * @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.
+ */
+async function validateTaskRequest(request: Request, requireId = false) {
+ const session = await auth();
+ if (!session?.user?.id) {
+ return { error: new NextResponse("Unauthorized", { status: 401 }) };
+ }
+
+ const body: TaskPayload & { id?: string } = await request.json();
+
+ if (
+ (requireId && !body.id) ||
+ !body.title ||
+ !body.description ||
+ !body.dueDate ||
+ !body.status ||
+ !body.priority
+ ) {
+ return {
+ error: new NextResponse("Missing required task fields", { status: 400 }),
+ };
+ }
+
+ return { userId: session.user.id, body };
+}
+
+/**
+ * Handles POST requests to create a new task record.
+ * Validates the request data and delegates creation to the task service.
+ *
+ * @async
+ * @param {Request} request - The incoming HTTP request containing task details.
+ * @returns {Promise} A JSON response with the newly created task or an error status.
+ */
+export async function POST(request: Request) {
+ try {
+ const { userId, body, error } = await validateTaskRequest(request, false);
+ if (error) return error;
+
+ const newTask = await TaskService.createTask(userId!, body!);
+ return NextResponse.json(newTask, { status: 201 });
+ } catch (error) {
+ console.error("Error creating task:", error);
+ return new NextResponse("Internal Server Error", { status: 500 });
+ }
+}
+
+/**
+ * Handles PATCH requests to update an existing task record.
+ * Validates user permissions, verifies task existence, and executes authorized updates.
+ *
+ * @async
+ * @param {Request} request - The incoming HTTP request containing updated task fields.
+ * @returns {Promise} A JSON response with the updated task or an error status.
+ */
+export async function PATCH(request: Request) {
+ try {
+ const { userId, body, error } = await validateTaskRequest(request, true);
+ if (error) return error;
+
+ const existingTask = await TaskService.verifyAccess(body!.id!, userId!);
+ if (!existingTask) {
+ return new NextResponse(
+ "Task not found or you do not have permission to edit it.",
+ { status: 404 },
+ );
+ }
+
+ const updatedTask = await TaskService.updateTaskIfAuthorized(
+ body!.id!,
+ userId!,
+ body!,
+ );
+
+ return NextResponse.json(updatedTask, { status: 200 });
+ } catch (error) {
+ console.error("Error updating task:", error);
+ return new NextResponse("Internal Server Error", { status: 500 });
+ }
+}
diff --git a/app/components/layout/Navbar.tsx b/app/components/layout/Navbar.tsx
index 9d49525..39ee62a 100644
--- a/app/components/layout/Navbar.tsx
+++ b/app/components/layout/Navbar.tsx
@@ -7,7 +7,7 @@
import Link from "next/link";
import { usePathname } from "next/navigation";
-import { LayoutDashboard, FileText, Trash2 } from "lucide-react";
+import { LayoutDashboard, FileText, Trash2, FilePenLine } from "lucide-react";
import useSWR from "swr";
const fetcher = (url: string) => fetch(url).then((res) => res.json());
@@ -18,6 +18,7 @@ const fetcher = (url: string) => fetch(url).then((res) => res.json());
const navItems = [
{ name: "Summary", href: "/summary", icon: FileText },
{ name: "Dashboard", href: "/dashboard", icon: LayoutDashboard },
+ { name: "Add Task", href: "/tasks?task=new", icon: FilePenLine },
{ name: "Trash", href: "/trash", icon: Trash2 },
];
diff --git a/next.config.ts b/next.config.ts
index 4a0a9e5..81b4c56 100644
--- a/next.config.ts
+++ b/next.config.ts
@@ -3,7 +3,7 @@ import path from "path";
/** @type {import('next').NextConfig} */
const nextConfig = {
output: "standalone" as const,
- trailingSlash: true,
+ trailingSlash: false,
images: {
unoptimized: true,
},
diff --git a/services/task.service.ts b/services/task.service.ts
index a6f39be..ddf3c21 100644
--- a/services/task.service.ts
+++ b/services/task.service.ts
@@ -5,7 +5,7 @@
import { db } from "@/db";
import { tasksTable, taskAssigneesTable, usersTable } from "@/db/schema";
-import { DbTask, TaskStatus } from "@/types/task";
+import { DbTask, TaskPayload, TaskStatus } from "@/types/task";
import { and, eq, or, exists, isNotNull, isNull, inArray } from "drizzle-orm";
/**
@@ -13,12 +13,12 @@ import { and, eq, or, exists, isNotNull, isNull, inArray } from "drizzle-orm";
*/
export class TaskService {
/**
- * Helper: Generates the SQL condition to check if a user is either the creator or an assignee of a task.
+ * Generates a Drizzle query condition verifying whether a user has access to a task as an owner or an assignee.
*
* @private
* @param {string} userId - The unique identifier of the user.
- * @param {any} [taskIdColumn=tasksTable.id] - The task identifier column reference.
- * @returns {import("drizzle-orm").SQL} The constructed SQL condition.
+ * @param {any} [taskIdColumn=tasksTable.id] - The task identifier column to check against.
+ * @returns {any} The Drizzle OR condition expression.
*/
private static userHasAccessCondition(
userId: string,
@@ -41,12 +41,32 @@ export class TaskService {
}
/**
- * Verifies whether a user has access to a specific task as either the owner or an assignee.
+ * Helper: Synchronizes assignees for a given task (replaces existing ones).
+ *
+ * @private
+ * @async
+ * @param {string} taskId - The unique identifier of the task.
+ * @param {string[]} [assignees] - Optional array of user IDs to assign.
+ * @returns {Promise}
+ */
+ private static async syncAssignees(taskId: string, assignees?: string[]) {
+ await db
+ .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);
+ }
+ }
+
+ /**
+ * Verifies if a user has access to a specific task by ID.
*
* @async
* @param {string} taskId - The unique identifier of the task.
* @param {string} userId - The unique identifier of the user.
- * @returns {Promise} The task object if access is verified, otherwise undefined.
+ * @returns {Promise} The task object if authorized, otherwise undefined.
*/
static async verifyAccess(
taskId: string,
@@ -66,13 +86,13 @@ export class TaskService {
}
/**
- * Updates a task's status if the user is authorized as either the owner or an assignee.
+ * Updates the status of a task if the user is authorized.
*
* @async
- * @param {string} taskId - The unique identifier of the task to update.
- * @param {string} userId - The unique identifier of the user performing the update.
+ * @param {string} taskId - The unique identifier of the task.
+ * @param {string} userId - The unique identifier of the user.
* @param {TaskStatus} status - The new task status to set.
- * @returns {Promise} The updated task object or null if authorization fails.
+ * @returns {Promise} The updated task object or null if unauthorized.
*/
static async updateStatusIfAuthorized(
taskId: string,
@@ -81,10 +101,7 @@ export class TaskService {
) {
const [updatedTask] = await db
.update(tasksTable)
- .set({
- status: status,
- updatedAt: new Date(),
- })
+ .set({ status, updatedAt: new Date() })
.where(
and(
eq(tasksTable.id, taskId),
@@ -97,18 +114,15 @@ export class TaskService {
}
/**
- * Retrieves all active (non-deleted) tasks that a user is authorized to see.
+ * Retrieves all active (non-deleted) tasks for a specific user.
*
* @async
* @param {string} userId - The unique identifier of the user.
- * @returns {Promise>} An array of active tasks with their creator emails.
+ * @returns {Promise>} An array of tasks joined with their creator users.
*/
static async findActiveTasksForUser(userId: string) {
return await db
- .select({
- task: tasksTable,
- user: usersTable,
- })
+ .select({ task: tasksTable, user: usersTable })
.from(tasksTable)
.innerJoin(usersTable, eq(tasksTable.userId, usersTable.id))
.where(
@@ -120,38 +134,31 @@ export class TaskService {
}
/**
- * Retrieves assignee emails for a batch of task identifiers.
+ * Fetches all assignees for a given list of task IDs in a batch query.
*
* @async
* @param {string[]} taskIds - An array of task unique identifiers.
- * @returns {Promise>} An array mapping task IDs to assignee emails.
+ * @returns {Promise>} An array of task assignee and user mapping records.
*/
static async findAssigneesForTasks(taskIds: string[]) {
if (taskIds.length === 0) return [];
-
return await db
- .select({
- taskId: taskAssigneesTable.taskId,
- user: usersTable,
- })
+ .select({ taskId: taskAssigneesTable.taskId, user: usersTable })
.from(taskAssigneesTable)
.innerJoin(usersTable, eq(taskAssigneesTable.userId, usersTable.id))
.where(inArray(taskAssigneesTable.taskId, taskIds));
}
/**
- * Retrieves all soft-deleted tasks belonging to the specified user along with creator info.
+ * Retrieves all soft-deleted tasks in the trash for a specific user.
*
* @async
* @param {string} userId - The unique identifier of the user.
- * @returns {Promise>} An array of soft-deleted tasks in the trash.
+ * @returns {Promise>} An array of deleted tasks joined with users.
*/
static async findTrashTasksForUser(userId: string) {
return await db
- .select({
- task: tasksTable,
- user: usersTable,
- })
+ .select({ task: tasksTable, user: usersTable })
.from(tasksTable)
.innerJoin(usersTable, eq(tasksTable.userId, usersTable.id))
.where(
@@ -160,20 +167,17 @@ export class TaskService {
}
/**
- * Soft-deletes a task by setting its deletion timestamp if the user is the creator.
+ * Soft-deletes a task if the user is authorized as the owner.
*
* @async
* @param {string} taskId - The unique identifier of the task.
- * @param {string} userId - The unique identifier of the user (must be creator).
- * @returns {Promise} The soft-deleted task object or null if unauthorized.
+ * @param {string} userId - The unique identifier of the user.
+ * @returns {Promise} The soft-deleted task record or null if unauthorized.
*/
static async softDeleteIfAuthorized(taskId: string, userId: string) {
const [updatedTask] = await db
.update(tasksTable)
- .set({
- deletedAt: new Date(),
- updatedAt: new Date(),
- })
+ .set({ deletedAt: new Date(), updatedAt: new Date() })
.where(and(eq(tasksTable.id, taskId), eq(tasksTable.userId, userId)))
.returning();
@@ -181,12 +185,12 @@ export class TaskService {
}
/**
- * Permanently deletes a task from the database if it is already soft-deleted and the user is the creator.
+ * Permanently deletes a task from the database if authorized and already soft-deleted.
*
* @async
* @param {string} taskId - The unique identifier of the task.
- * @param {string} userId - The unique identifier of the user (must be creator).
- * @returns {Promise} The permanently deleted task object or null if unauthorized.
+ * @param {string} userId - The unique identifier of the user.
+ * @returns {Promise} The permanently deleted task record or null if unauthorized.
*/
static async permanentlyDeleteIfAuthorized(taskId: string, userId: string) {
const [deletedTask] = await db
@@ -204,23 +208,84 @@ export class TaskService {
}
/**
- * Restores a soft-deleted task by clearing its deletion timestamp if the user is the creator.
+ * Restores a soft-deleted task from the trash if the user is authorized.
*
* @async
* @param {string} taskId - The unique identifier of the task.
- * @param {string} userId - The unique identifier of the user (must be creator).
- * @returns {Promise} The restored task object or null if unauthorized.
+ * @param {string} userId - The unique identifier of the user.
+ * @returns {Promise} The restored task record or null if unauthorized.
*/
static async restoreIfAuthorized(taskId: string, userId: string) {
const [restoredTask] = await db
.update(tasksTable)
- .set({
- deletedAt: null,
- updatedAt: new Date(),
- })
+ .set({ deletedAt: null, updatedAt: new Date() })
.where(and(eq(tasksTable.id, taskId), eq(tasksTable.userId, userId)))
.returning();
return (restoredTask as DbTask | undefined) || null;
}
+
+ /**
+ * Creates a new task and synchronizes its initial assignees.
+ *
+ * @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.
+ * @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();
+
+ await this.syncAssignees(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.
+ */
+ 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(),
+ })
+ .where(
+ and(
+ eq(tasksTable.id, taskId),
+ eq(tasksTable.userId, userId),
+ isNull(tasksTable.deletedAt),
+ ),
+ )
+ .returning();
+
+ if (!updatedTask) return null;
+
+ await this.syncAssignees(taskId, data.assignees);
+ return updatedTask as DbTask;
+ }
}
diff --git a/types/task.ts b/types/task.ts
index 767a4c6..b60093a 100644
--- a/types/task.ts
+++ b/types/task.ts
@@ -39,6 +39,13 @@ export interface ColumnConfig {
color: string;
}
+export interface TaskPayload extends Omit<
+ DbTask,
+ "id" | "userId" | "createdAt" | "updatedAt" | "deletedAt"
+> {
+ assignees?: string[];
+}
+
export interface RouteContext {
params: Promise<{ id: string }>;
}