feat(tasks): implement unified task creation and editing mode with query parameters and service layer integration
This commit is contained in:
parent
1e7e40b8a6
commit
cf72de43d9
13 changed files with 863 additions and 52 deletions
105
app/(app)/tasks/TaskForm.tsx
Normal file
105
app/(app)/tasks/TaskForm.tsx
Normal 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
88
app/(app)/tasks/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
72
app/(app)/tasks/task/TaskAssignees.tsx
Normal file
72
app/(app)/tasks/task/TaskAssignees.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
67
app/(app)/tasks/task/TaskBasicInfo.tsx
Normal file
67
app/(app)/tasks/task/TaskBasicInfo.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
82
app/(app)/tasks/task/TaskDateTime.tsx
Normal file
82
app/(app)/tasks/task/TaskDateTime.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
33
app/(app)/tasks/task/TaskHeader.tsx
Normal file
33
app/(app)/tasks/task/TaskHeader.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
73
app/(app)/tasks/task/TaskOptions.tsx
Normal file
73
app/(app)/tasks/task/TaskOptions.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
121
app/(app)/tasks/useTaskForm.ts
Normal file
121
app/(app)/tasks/useTaskForm.ts
Normal 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
97
app/api/tasks/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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 },
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }>;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue