281 lines
8.8 KiB
TypeScript
281 lines
8.8 KiB
TypeScript
import { Elysia, t } from "elysia";
|
|
import { db } from "../db";
|
|
import { todos } from "../db/schema";
|
|
import { eq, and, asc, desc, sql } from "drizzle-orm";
|
|
import type { 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", debug: msg };
|
|
})
|
|
|
|
// Debug endpoint - test DB connectivity for todos table
|
|
.get("/debug", async () => {
|
|
try {
|
|
const result = await db.execute(sql`SELECT EXISTS (SELECT FROM information_schema.tables WHERE table_name = 'todos')`);
|
|
const enumResult = await db.execute(sql`SELECT EXISTS (SELECT FROM pg_type WHERE typname = 'todo_priority')`);
|
|
return {
|
|
todosTableExists: result,
|
|
todoPriorityEnumExists: enumResult,
|
|
dbConnected: true
|
|
};
|
|
} catch (e: any) {
|
|
return { error: e.message, dbConnected: false };
|
|
}
|
|
})
|
|
|
|
// 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()),
|
|
})),
|
|
}),
|
|
});
|