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) { 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`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 = { 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()), })), }), });