From 2619178a2ef844a1365acb65f590683ce216af39 Mon Sep 17 00:00:00 2001 From: Mpho10111 Date: Tue, 11 Aug 2026 20:54:37 +0200 Subject: [PATCH] feat: add persistent task operations and API --- app/api/tasks/route.ts | 65 ++++++++++++ lib/tasks.ts | 223 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 288 insertions(+) create mode 100644 lib/tasks.ts diff --git a/app/api/tasks/route.ts b/app/api/tasks/route.ts index e69de29..220074b 100644 --- a/app/api/tasks/route.ts +++ b/app/api/tasks/route.ts @@ -0,0 +1,65 @@ +import { NextRequest, NextResponse } from "next/server"; +import { + archiveTask, + createTask, + listTasks, + updateTask, +} from "@/lib/tasks"; +import type { TaskSort } from "@/types/task"; + +export const runtime = "nodejs"; + +const sortOptions: TaskSort[] = ["createdAt", "topic", "status", "dueDate"]; + +function parseSort(value: string | null): TaskSort { + if (value && sortOptions.includes(value as TaskSort)) { + return value as TaskSort; + } + + return "createdAt"; +} + +function errorResponse(error: unknown, status = 400) { + return NextResponse.json( + { error: error instanceof Error ? error.message : "Something went wrong." }, + { status } + ); +} + +export function GET(request: NextRequest) { + const includeArchived = + request.nextUrl.searchParams.get("includeArchived") === "true"; + const sort = parseSort(request.nextUrl.searchParams.get("sort")); + const tasks = listTasks({ includeArchived, sort }); + + return NextResponse.json({ tasks }); +} + +export async function POST(request: NextRequest) { + try { + const body = (await request.json()) as Record; + const task = createTask(body); + + return NextResponse.json({ task }, { status: 201 }); + } catch (error) { + return errorResponse(error); + } +} + +export async function PATCH(request: NextRequest) { + try { + const body = (await request.json()) as Record; + const id = Number(body.id); + + if (!Number.isInteger(id) || id <= 0) { + throw new Error("A valid task id is required."); + } + + const task = + body.action === "archive" ? archiveTask(id) : updateTask(id, body); + + return NextResponse.json({ task }); + } catch (error) { + return errorResponse(error); + } +} diff --git a/lib/tasks.ts b/lib/tasks.ts new file mode 100644 index 0000000..41a215f --- /dev/null +++ b/lib/tasks.ts @@ -0,0 +1,223 @@ +import type { + Task, + TaskInput, + TaskSort, + TaskStatus, + TaskUpdate, +} from "@/types/task"; +import { getDatabase } from "./db"; + +type TaskRow = { + id: number; + title: string; + description: string; + due_date: string; + topic: string; + status: TaskStatus; + archived_at: string | null; + created_at: string; + updated_at: string; +}; + +const statuses: TaskStatus[] = ["Todo", "In-Progress", "Complete"]; + +function isTaskStatus(value: unknown): value is TaskStatus { + return typeof value === "string" && statuses.includes(value as TaskStatus); +} + +function todayIsoDate() { + const now = new Date(); + const year = now.getFullYear(); + const month = String(now.getMonth() + 1).padStart(2, "0"); + const day = String(now.getDate()).padStart(2, "0"); + + return `${year}-${month}-${day}`; +} + +function isDateInput(value: unknown): value is string { + return typeof value === "string" && /^\d{4}-\d{2}-\d{2}$/.test(value); +} + +function cleanString(value: unknown, field: string) { + if (typeof value !== "string") { + throw new Error(`${field} must be text.`); + } + + return value.trim(); +} + +function normalizeTaskInput(input: Partial): TaskInput { + const title = cleanString(input.title, "Title"); + const description = + typeof input.description === "string" ? input.description.trim() : ""; + const dueDate = input.dueDate; + const topic = cleanString(input.topic, "Topic"); + + if (!title) { + throw new Error("Title is required."); + } + + if (!topic) { + throw new Error("Topic is required."); + } + + if (!isDateInput(dueDate)) { + throw new Error("Due date must use YYYY-MM-DD format."); + } + + return { + title, + description, + dueDate, + topic, + }; +} + +function toTask(row: TaskRow): Task { + return { + id: row.id, + title: row.title, + description: row.description, + dueDate: row.due_date, + topic: row.topic, + status: row.status, + archivedAt: row.archived_at, + createdAt: row.created_at, + updatedAt: row.updated_at, + isOverdue: + !row.archived_at && row.status !== "Complete" && row.due_date < todayIsoDate(), + }; +} + +function rowToTask(row: unknown) { + if (!row) { + return null; + } + + return toTask(row as TaskRow); +} + +function sortClause(sort: TaskSort) { + if (sort === "topic") { + return "topic COLLATE NOCASE ASC, due_date ASC, id ASC"; + } + + if (sort === "status") { + return "CASE status WHEN 'Todo' THEN 1 WHEN 'In-Progress' THEN 2 ELSE 3 END ASC, due_date ASC, id ASC"; + } + + if (sort === "dueDate") { + return "due_date ASC, topic COLLATE NOCASE ASC, id ASC"; + } + + return "created_at DESC, id DESC"; +} + +export function listTasks({ + includeArchived = false, + sort = "createdAt", +}: { + includeArchived?: boolean; + sort?: TaskSort; +} = {}) { + const database = getDatabase(); + const where = includeArchived ? "" : "WHERE archived_at IS NULL"; + const rows = database + .prepare(`SELECT * FROM tasks ${where} ORDER BY ${sortClause(sort)}`) + .all() as TaskRow[]; + + return rows.map(toTask); +} + +export function getTask(id: number) { + const database = getDatabase(); + return rowToTask(database.prepare("SELECT * FROM tasks WHERE id = ?").get(id)); +} + +export function createTask(input: Partial) { + const database = getDatabase(); + const task = normalizeTaskInput(input); + const result = database + .prepare( + `INSERT INTO tasks (title, description, due_date, topic, status) + VALUES (?, ?, ?, ?, 'Todo')` + ) + .run(task.title, task.description, task.dueDate, task.topic); + + const created = getTask(Number(result.lastInsertRowid)); + + if (!created) { + throw new Error("Task could not be created."); + } + + return created; +} + +export function updateTask(id: number, updates: TaskUpdate) { + const current = getTask(id); + + if (!current) { + throw new Error("Task not found."); + } + + const nextStatus = updates.status ?? current.status; + + if (!isTaskStatus(nextStatus)) { + throw new Error("Status must be Todo, In-Progress, or Complete."); + } + + const nextTask = normalizeTaskInput({ + title: updates.title ?? current.title, + description: updates.description ?? current.description, + dueDate: updates.dueDate ?? current.dueDate, + topic: updates.topic ?? current.topic, + }); + + const database = getDatabase(); + database + .prepare( + `UPDATE tasks + SET title = ?, description = ?, due_date = ?, topic = ?, status = ?, updated_at = CURRENT_TIMESTAMP + WHERE id = ?` + ) + .run( + nextTask.title, + nextTask.description, + nextTask.dueDate, + nextTask.topic, + nextStatus, + id + ); + + const updated = getTask(id); + + if (!updated) { + throw new Error("Task could not be updated."); + } + + return updated; +} + +export function archiveTask(id: number) { + if (!getTask(id)) { + throw new Error("Task not found."); + } + + const database = getDatabase(); + database + .prepare( + `UPDATE tasks + SET archived_at = COALESCE(archived_at, CURRENT_TIMESTAMP), + updated_at = CURRENT_TIMESTAMP + WHERE id = ?` + ) + .run(id); + + const archived = getTask(id); + + if (!archived) { + throw new Error("Task could not be archived."); + } + + return archived; +}