feat: add persistent task operations and API

This commit is contained in:
Mpho10111
2026-08-11 20:54:37 +02:00
parent 63833efb7a
commit 2619178a2e
2 changed files with 288 additions and 0 deletions

View File

@@ -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<string, unknown>;
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<string, unknown>;
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);
}
}

223
lib/tasks.ts Normal file
View File

@@ -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>): 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<TaskInput>) {
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;
}