Files
hammer-queue/backend/src/routes/todos.ts
Hammer cbfeb6db70
All checks were successful
CI/CD / test (push) Successful in 23s
CI/CD / deploy (push) Successful in 2s
Add debug endpoint for todos DB diagnostics
2026-01-30 05:02:06 +00:00

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