feat: add personal todos feature
- New todos table in DB schema (title, description, priority, category, due date, completion) - Full CRUD + toggle API routes at /api/todos - Categories support with filtering - Bulk import endpoint for migration - New TodosPage with inline editing, priority badges, due date display - Add Todos to sidebar navigation - Dark mode support throughout
This commit is contained in:
264
backend/src/routes/todos.ts
Normal file
264
backend/src/routes/todos.ts
Normal file
@@ -0,0 +1,264 @@
|
||||
import { Elysia, t } from "elysia";
|
||||
import { db } from "../db";
|
||||
import { todos } from "../db/schema";
|
||||
import { eq, and, asc, desc, sql } from "drizzle-orm";
|
||||
import { auth } from "../lib/auth";
|
||||
|
||||
const BEARER_TOKEN = process.env.API_BEARER_TOKEN || "hammer-dev-token";
|
||||
|
||||
async function requireSessionOrBearer(request: Request, headers: Record<string, string | undefined>) {
|
||||
const authHeader = headers["authorization"];
|
||||
if (authHeader === `Bearer ${BEARER_TOKEN}`) {
|
||||
// Return a default user ID for bearer token access
|
||||
return { userId: "bearer" };
|
||||
}
|
||||
|
||||
try {
|
||||
const session = await auth.api.getSession({ headers: request.headers });
|
||||
if (session?.user) return { userId: session.user.id };
|
||||
} catch {}
|
||||
throw new Error("Unauthorized");
|
||||
}
|
||||
|
||||
export const todoRoutes = new Elysia({ prefix: "/api/todos" })
|
||||
.onError(({ error, set }) => {
|
||||
const msg = (error as any)?.message || String(error);
|
||||
if (msg === "Unauthorized") {
|
||||
set.status = 401;
|
||||
return { error: "Unauthorized" };
|
||||
}
|
||||
if (msg === "Not found") {
|
||||
set.status = 404;
|
||||
return { error: "Not found" };
|
||||
}
|
||||
console.error("Todo route error:", msg);
|
||||
set.status = 500;
|
||||
return { error: "Internal server error" };
|
||||
})
|
||||
|
||||
// GET all todos for current user
|
||||
.get("/", async ({ request, headers, query }) => {
|
||||
const { userId } = await requireSessionOrBearer(request, headers);
|
||||
|
||||
const conditions = [eq(todos.userId, userId)];
|
||||
|
||||
// Filter by completion
|
||||
if (query.completed === "true") {
|
||||
conditions.push(eq(todos.isCompleted, true));
|
||||
} else if (query.completed === "false") {
|
||||
conditions.push(eq(todos.isCompleted, false));
|
||||
}
|
||||
|
||||
// Filter by category
|
||||
if (query.category) {
|
||||
conditions.push(eq(todos.category, query.category));
|
||||
}
|
||||
|
||||
const userTodos = await db
|
||||
.select()
|
||||
.from(todos)
|
||||
.where(and(...conditions))
|
||||
.orderBy(
|
||||
asc(todos.isCompleted),
|
||||
desc(sql`CASE ${todos.priority} WHEN 'high' THEN 0 WHEN 'medium' THEN 1 WHEN 'low' THEN 2 ELSE 3 END`),
|
||||
asc(todos.sortOrder),
|
||||
desc(todos.createdAt)
|
||||
);
|
||||
|
||||
return userTodos;
|
||||
}, {
|
||||
query: t.Object({
|
||||
completed: t.Optional(t.String()),
|
||||
category: t.Optional(t.String()),
|
||||
}),
|
||||
})
|
||||
|
||||
// GET categories (distinct)
|
||||
.get("/categories", async ({ request, headers }) => {
|
||||
const { userId } = await requireSessionOrBearer(request, headers);
|
||||
|
||||
const result = await db
|
||||
.selectDistinct({ category: todos.category })
|
||||
.from(todos)
|
||||
.where(and(eq(todos.userId, userId), sql`${todos.category} IS NOT NULL AND ${todos.category} != ''`))
|
||||
.orderBy(asc(todos.category));
|
||||
|
||||
return result.map((r) => r.category).filter(Boolean) as string[];
|
||||
})
|
||||
|
||||
// POST create todo
|
||||
.post("/", async ({ body, request, headers }) => {
|
||||
const { userId } = await requireSessionOrBearer(request, headers);
|
||||
|
||||
// Get max sort order
|
||||
const maxOrder = await db
|
||||
.select({ max: sql<number>`COALESCE(MAX(${todos.sortOrder}), 0)` })
|
||||
.from(todos)
|
||||
.where(eq(todos.userId, userId));
|
||||
|
||||
const [todo] = await db
|
||||
.insert(todos)
|
||||
.values({
|
||||
userId,
|
||||
title: body.title,
|
||||
description: body.description || null,
|
||||
priority: body.priority || "none",
|
||||
category: body.category || null,
|
||||
dueDate: body.dueDate ? new Date(body.dueDate) : null,
|
||||
sortOrder: (maxOrder[0]?.max ?? 0) + 1,
|
||||
})
|
||||
.returning();
|
||||
|
||||
return todo;
|
||||
}, {
|
||||
body: t.Object({
|
||||
title: t.String({ minLength: 1 }),
|
||||
description: t.Optional(t.String()),
|
||||
priority: t.Optional(t.Union([
|
||||
t.Literal("high"),
|
||||
t.Literal("medium"),
|
||||
t.Literal("low"),
|
||||
t.Literal("none"),
|
||||
])),
|
||||
category: t.Optional(t.String()),
|
||||
dueDate: t.Optional(t.Union([t.String(), t.Null()])),
|
||||
}),
|
||||
})
|
||||
|
||||
// PATCH update todo
|
||||
.patch("/:id", async ({ params, body, request, headers }) => {
|
||||
const { userId } = await requireSessionOrBearer(request, headers);
|
||||
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(todos)
|
||||
.where(and(eq(todos.id, params.id), eq(todos.userId, userId)));
|
||||
|
||||
if (!existing.length) throw new Error("Not found");
|
||||
|
||||
const updates: Record<string, any> = { updatedAt: new Date() };
|
||||
if (body.title !== undefined) updates.title = body.title;
|
||||
if (body.description !== undefined) updates.description = body.description;
|
||||
if (body.priority !== undefined) updates.priority = body.priority;
|
||||
if (body.category !== undefined) updates.category = body.category || null;
|
||||
if (body.dueDate !== undefined) updates.dueDate = body.dueDate ? new Date(body.dueDate) : null;
|
||||
if (body.sortOrder !== undefined) updates.sortOrder = body.sortOrder;
|
||||
if (body.isCompleted !== undefined) {
|
||||
updates.isCompleted = body.isCompleted;
|
||||
updates.completedAt = body.isCompleted ? new Date() : null;
|
||||
}
|
||||
|
||||
const [updated] = await db
|
||||
.update(todos)
|
||||
.set(updates)
|
||||
.where(eq(todos.id, params.id))
|
||||
.returning();
|
||||
|
||||
return updated;
|
||||
}, {
|
||||
params: t.Object({ id: t.String() }),
|
||||
body: t.Object({
|
||||
title: t.Optional(t.String()),
|
||||
description: t.Optional(t.String()),
|
||||
priority: t.Optional(t.Union([
|
||||
t.Literal("high"),
|
||||
t.Literal("medium"),
|
||||
t.Literal("low"),
|
||||
t.Literal("none"),
|
||||
])),
|
||||
category: t.Optional(t.Union([t.String(), t.Null()])),
|
||||
dueDate: t.Optional(t.Union([t.String(), t.Null()])),
|
||||
isCompleted: t.Optional(t.Boolean()),
|
||||
sortOrder: t.Optional(t.Number()),
|
||||
}),
|
||||
})
|
||||
|
||||
// PATCH toggle complete
|
||||
.patch("/:id/toggle", async ({ params, request, headers }) => {
|
||||
const { userId } = await requireSessionOrBearer(request, headers);
|
||||
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(todos)
|
||||
.where(and(eq(todos.id, params.id), eq(todos.userId, userId)));
|
||||
|
||||
if (!existing.length) throw new Error("Not found");
|
||||
|
||||
const nowCompleted = !existing[0].isCompleted;
|
||||
const [updated] = await db
|
||||
.update(todos)
|
||||
.set({
|
||||
isCompleted: nowCompleted,
|
||||
completedAt: nowCompleted ? new Date() : null,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(todos.id, params.id))
|
||||
.returning();
|
||||
|
||||
return updated;
|
||||
}, {
|
||||
params: t.Object({ id: t.String() }),
|
||||
})
|
||||
|
||||
// DELETE todo
|
||||
.delete("/:id", async ({ params, request, headers }) => {
|
||||
const { userId } = await requireSessionOrBearer(request, headers);
|
||||
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(todos)
|
||||
.where(and(eq(todos.id, params.id), eq(todos.userId, userId)));
|
||||
|
||||
if (!existing.length) throw new Error("Not found");
|
||||
|
||||
await db.delete(todos).where(eq(todos.id, params.id));
|
||||
return { success: true };
|
||||
}, {
|
||||
params: t.Object({ id: t.String() }),
|
||||
})
|
||||
|
||||
// POST bulk import (for migration)
|
||||
.post("/import", async ({ body, request, headers }) => {
|
||||
const { userId } = await requireSessionOrBearer(request, headers);
|
||||
|
||||
const imported = [];
|
||||
for (const item of body.todos) {
|
||||
const [todo] = await db
|
||||
.insert(todos)
|
||||
.values({
|
||||
userId,
|
||||
title: item.title,
|
||||
description: item.description || null,
|
||||
isCompleted: item.isCompleted || false,
|
||||
priority: item.priority || "none",
|
||||
category: item.category || null,
|
||||
dueDate: item.dueDate ? new Date(item.dueDate) : null,
|
||||
completedAt: item.completedAt ? new Date(item.completedAt) : null,
|
||||
sortOrder: item.sortOrder || 0,
|
||||
createdAt: item.createdAt ? new Date(item.createdAt) : new Date(),
|
||||
})
|
||||
.returning();
|
||||
imported.push(todo);
|
||||
}
|
||||
|
||||
return { imported: imported.length, todos: imported };
|
||||
}, {
|
||||
body: t.Object({
|
||||
todos: t.Array(t.Object({
|
||||
title: t.String(),
|
||||
description: t.Optional(t.Union([t.String(), t.Null()])),
|
||||
isCompleted: t.Optional(t.Boolean()),
|
||||
priority: t.Optional(t.Union([
|
||||
t.Literal("high"),
|
||||
t.Literal("medium"),
|
||||
t.Literal("low"),
|
||||
t.Literal("none"),
|
||||
])),
|
||||
category: t.Optional(t.Union([t.String(), t.Null()])),
|
||||
dueDate: t.Optional(t.Union([t.String(), t.Null()])),
|
||||
completedAt: t.Optional(t.Union([t.String(), t.Null()])),
|
||||
sortOrder: t.Optional(t.Number()),
|
||||
createdAt: t.Optional(t.String()),
|
||||
})),
|
||||
}),
|
||||
});
|
||||
Reference in New Issue
Block a user