feat(tasks): implement unified task creation and editing mode with query parameters and service layer integration

This commit is contained in:
Chneemann 2026-08-13 10:34:33 +02:00
parent 1e7e40b8a6
commit cf72de43d9
No known key found for this signature in database
13 changed files with 863 additions and 52 deletions

View file

@ -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 (
<form onSubmit={handleSubmit} className="space-y-6 mx-auto">
<TaskHeader isEditMode={!!isEditMode} />
{error && (
<div className="flex items-center gap-2 bg-rose-500/10 border border-rose-500/20 text-rose-500 p-4 rounded-xl text-sm">
<AlertCircle size={16} />
<span>{error}</span>
</div>
)}
<div className="bg-card/40 border border-border/80 p-6 rounded-2xl shadow-sm space-y-4">
<TaskBasicInfo
title={form.title}
setTitle={(v) => updateField("title", v)}
description={form.description}
setDescription={(v) => updateField("description", v)}
/>
<TaskOptions
priority={form.priority}
setPriority={(v) => updateField("priority", v)}
status={form.status}
setStatus={(v) => updateField("status", v)}
/>
<TaskAssignees
users={users}
selectedAssignees={form.assignees}
onToggle={handleAssigneeToggle}
/>
<TaskDateTime
dueDate={form.dueDate}
setDueDate={(v) => updateField("dueDate", v)}
dueTime={form.dueTime}
setDueTime={(v) => updateField("dueTime", v)}
todayString={todayString}
/>
</div>
<div className="flex items-center justify-end gap-3">
<Link
href="/dashboard"
className="px-4 py-2.5 rounded-xl font-medium text-sm border border-border hover:bg-background-muted transition-colors"
>
Cancel
</Link>
<button
type="submit"
className="inline-flex items-center gap-2 px-5 py-2.5 rounded-xl font-medium text-sm text-black bg-primary hover:bg-primary-hover active:scale-95 transition-all duration-200 cursor-pointer disabled:opacity-50"
>
{isEditMode ? "Save Changes" : "Create Task"}
</button>
</div>
</form>
);
}

88
app/(app)/tasks/page.tsx Normal file
View file

@ -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<JSX.Element>} 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 (
<div className="mx-auto pb-12">
<TaskForm
key={`${mode}-${taskId ?? "new"}`}
users={users}
initialData={initialData}
mode={mode}
/>
</div>
);
}

View file

@ -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 (
<div className="space-y-2 pt-2 border-t border-border/60">
<label className="text-xs font-bold uppercase tracking-wider text-foreground-muted">
Assignees
</label>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2 max-h-40 overflow-y-auto p-2 border border-border rounded-xl bg-background">
{users.length === 0 ? (
<span className="text-xs text-foreground-muted p-2">
No other users found
</span>
) : (
users.map((user) => {
const isSelected = selectedAssignees.includes(user.id);
return (
<button
key={user.id}
type="button"
onClick={() => onToggle(user.id)}
className={`flex items-center justify-between px-3 py-2 rounded-lg text-xs font-medium transition-all text-left cursor-pointer ${
isSelected
? "bg-primary/10 border border-primary/40 text-foreground"
: "bg-card/40 border border-border/40 hover:bg-background-muted text-foreground-muted"
}`}
>
<span className="truncate">{user.email}</span>
<span
className={`w-3.5 h-3.5 rounded-full border flex items-center justify-center text-[10px] ${
isSelected
? "bg-primary border-primary text-background font-bold"
: "border-border"
}`}
>
{isSelected ? "✓" : ""}
</span>
</button>
);
})
)}
</div>
</div>
);
}

View file

@ -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 (
<div className="space-y-4">
{/* Title */}
<div className="space-y-2">
<label className="text-xs font-bold uppercase tracking-wider text-foreground-muted">
Title *
</label>
<input
type="text"
required
value={title}
onChange={(e) => 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"
/>
</div>
{/* Description */}
<div className="space-y-2">
<label className="text-xs font-bold uppercase tracking-wider text-foreground-muted">
Description *
</label>
<textarea
rows={4}
required
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Add details about this task..."
className="w-full bg-background border border-border rounded-xl p-4 text-sm focus:outline-none focus:ring-2 focus:ring-primary/40 resize-none"
/>
</div>
</div>
);
}

View file

@ -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 (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 pt-2 border-t border-border/60">
<div className="space-y-2">
<label className="text-xs font-bold uppercase tracking-wider text-foreground-muted">
Due Date *
</label>
<input
type="date"
required
min={todayString}
value={dueDate}
onChange={(e) => 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"
/>
</div>
<div className="space-y-2">
<label className="text-xs font-bold uppercase tracking-wider text-foreground-muted">
Due Time *
</label>
<select
required
value={dueTime}
onChange={(e) => setDueTime(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"
>
<option value="08:00">08:00</option>
<option value="09:00">09:00</option>
<option value="10:00">10:00</option>
<option value="11:00">11:00</option>
<option value="12:00">12:00 (Noon)</option>
<option value="13:00">13:00</option>
<option value="14:00">14:00</option>
<option value="15:00">15:00</option>
<option value="16:00">16:00</option>
<option value="17:00">17:00 (End of workday)</option>
<option value="18:00">18:00</option>
<option value="20:00">20:00</option>
<option value="23:59">23:59 (End of day)</option>
</select>
</div>
</div>
);
}

View file

@ -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 (
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<div className="flex items-center gap-2 text-primary font-medium text-xs uppercase tracking-wider mb-1">
<Sparkles size={14} />
Task Management
</div>
<h1 className="text-3xl font-extrabold tracking-tight">
{isEditMode ? "Edit Task" : "Create New Task"}
</h1>
<p className="text-sm text-foreground-muted mt-1">
{isEditMode
? "Update your existing task details and assignees."
: "Add a new task to your workspace and assign priorities."}
</p>
</div>
</div>
);
}

View file

@ -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 (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{/* Priority */}
<div className="space-y-2">
<label className="text-xs font-bold uppercase tracking-wider text-foreground-muted">
Priority
</label>
<select
value={priority}
onChange={(e) => setPriority(e.target.value as TaskPriority)}
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"
>
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
</select>
</div>
{/* Status (dynamisch aus deinen Kanban-Spalten) */}
<div className="space-y-2">
<label className="text-xs font-bold uppercase tracking-wider text-foreground-muted">
Initial Status
</label>
<select
value={status}
onChange={(e) => setStatus(e.target.value as TaskStatus)}
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"
>
{COLUMNS.map((col) => (
<option key={col.id} value={col.id}>
{col.title}
</option>
))}
</select>
</div>
</div>
);
}

View file

@ -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<string | null>(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,
};
}

97
app/api/tasks/route.ts Normal file
View file

@ -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<NextResponse>} 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<NextResponse>} 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 });
}
}

View file

@ -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 },
];

View file

@ -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,
},

View file

@ -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<void>}
*/
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<DbTask | undefined>} The task object if access is verified, otherwise undefined.
* @returns {Promise<DbTask | undefined>} 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<DbTask | null>} The updated task object or null if authorization fails.
* @returns {Promise<DbTask | null>} 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<Array<{ task: DbTask; creatorEmail: string }>>} An array of active tasks with their creator emails.
* @returns {Promise<Array<{ task: DbTask; user: any }>>} 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<Array<{ taskId: string; email: string }>>} An array mapping task IDs to assignee emails.
* @returns {Promise<Array<{ taskId: string; user: any }>>} 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<Array<{ task: DbTask; creatorEmail: string }>>} An array of soft-deleted tasks in the trash.
* @returns {Promise<Array<{ task: DbTask; user: any }>>} 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<DbTask | null>} The soft-deleted task object or null if unauthorized.
* @param {string} userId - The unique identifier of the user.
* @returns {Promise<DbTask | null>} 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<DbTask | null>} The permanently deleted task object or null if unauthorized.
* @param {string} userId - The unique identifier of the user.
* @returns {Promise<DbTask | null>} 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<DbTask | null>} The restored task object or null if unauthorized.
* @param {string} userId - The unique identifier of the user.
* @returns {Promise<DbTask | null>} 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<DbTask>} 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<DbTask | null>} 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;
}
}

View file

@ -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 }>;
}