Initial scaffold: Hammer Queue task dashboard

- Backend: Elysia + Bun + Drizzle ORM + PostgreSQL
- Frontend: React + Vite + TypeScript + Tailwind CSS
- Task CRUD API with bearer token auth for writes
- Public read-only dashboard with auto-refresh
- Task states: active, queued, blocked, completed, cancelled
- Reorder support for queue management
- Progress notes per task
- Docker Compose for local dev and Dokploy deployment
This commit is contained in:
2026-01-28 22:55:16 +00:00
commit 0a8d5486bb
36 changed files with 2210 additions and 0 deletions

204
backend/src/routes/tasks.ts Normal file
View File

@@ -0,0 +1,204 @@
import { Elysia, t } from "elysia";
import { db } from "../db";
import { tasks, type ProgressNote } from "../db/schema";
import { eq, asc, desc, sql, inArray } from "drizzle-orm";
const BEARER_TOKEN = process.env.API_BEARER_TOKEN || "hammer-dev-token";
// Status sort order: active first, then queued, blocked, completed, cancelled
const statusOrder = sql`CASE
WHEN ${tasks.status} = 'active' THEN 0
WHEN ${tasks.status} = 'queued' THEN 1
WHEN ${tasks.status} = 'blocked' THEN 2
WHEN ${tasks.status} = 'completed' THEN 3
WHEN ${tasks.status} = 'cancelled' THEN 4
ELSE 5 END`;
function requireAuth(headers: Record<string, string | undefined>) {
const auth = headers["authorization"];
if (!auth || auth !== `Bearer ${BEARER_TOKEN}`) {
throw new Error("Unauthorized");
}
}
export const taskRoutes = new Elysia({ prefix: "/api/tasks" })
// GET all tasks - public (read-only dashboard)
.get("/", async () => {
const allTasks = await db
.select()
.from(tasks)
.orderBy(statusOrder, asc(tasks.position), desc(tasks.createdAt));
return allTasks;
})
// POST create task - requires auth
.post(
"/",
async ({ body, headers }) => {
requireAuth(headers);
// Get max position for queued tasks
const maxPos = await db
.select({ max: sql<number>`COALESCE(MAX(${tasks.position}), 0)` })
.from(tasks);
const newTask = await db
.insert(tasks)
.values({
title: body.title,
description: body.description,
source: body.source || "donovan",
status: body.status || "queued",
priority: body.priority || "medium",
position: (maxPos[0]?.max ?? 0) + 1,
progressNotes: [],
})
.returning();
return newTask[0];
},
{
body: t.Object({
title: t.String(),
description: t.Optional(t.String()),
source: t.Optional(
t.Union([
t.Literal("donovan"),
t.Literal("david"),
t.Literal("hammer"),
t.Literal("heartbeat"),
t.Literal("cron"),
t.Literal("other"),
])
),
status: t.Optional(
t.Union([
t.Literal("active"),
t.Literal("queued"),
t.Literal("blocked"),
t.Literal("completed"),
t.Literal("cancelled"),
])
),
priority: t.Optional(
t.Union([
t.Literal("critical"),
t.Literal("high"),
t.Literal("medium"),
t.Literal("low"),
])
),
}),
}
)
// PATCH update task - requires auth
.patch(
"/:id",
async ({ params, body, headers }) => {
requireAuth(headers);
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.source !== undefined) updates.source = body.source;
if (body.status !== undefined) {
updates.status = body.status;
if (body.status === "completed" || body.status === "cancelled") {
updates.completedAt = new Date();
}
// If setting to active, deactivate any currently active task
if (body.status === "active") {
await db
.update(tasks)
.set({ status: "queued", updatedAt: new Date() })
.where(eq(tasks.status, "active"));
}
}
if (body.priority !== undefined) updates.priority = body.priority;
if (body.position !== undefined) updates.position = body.position;
const updated = await db
.update(tasks)
.set(updates)
.where(eq(tasks.id, params.id))
.returning();
if (!updated.length) throw new Error("Task not found");
return updated[0];
},
{
params: t.Object({ id: t.String() }),
body: t.Object({
title: t.Optional(t.String()),
description: t.Optional(t.String()),
source: t.Optional(t.String()),
status: t.Optional(t.String()),
priority: t.Optional(t.String()),
position: t.Optional(t.Number()),
}),
}
)
// POST add progress note - requires auth
.post(
"/:id/notes",
async ({ params, body, headers }) => {
requireAuth(headers);
const existing = await db
.select()
.from(tasks)
.where(eq(tasks.id, params.id));
if (!existing.length) throw new Error("Task not found");
const currentNotes = (existing[0].progressNotes || []) as ProgressNote[];
const newNote: ProgressNote = {
timestamp: new Date().toISOString(),
note: body.note,
};
currentNotes.push(newNote);
const updated = await db
.update(tasks)
.set({ progressNotes: currentNotes, updatedAt: new Date() })
.where(eq(tasks.id, params.id))
.returning();
return updated[0];
},
{
params: t.Object({ id: t.String() }),
body: t.Object({ note: t.String() }),
}
)
// PATCH reorder tasks - requires auth
.patch(
"/reorder",
async ({ body, headers }) => {
requireAuth(headers);
// body.ids is an ordered array of task IDs
const updates = body.ids.map((id: string, index: number) =>
db
.update(tasks)
.set({ position: index, updatedAt: new Date() })
.where(eq(tasks.id, id))
);
await Promise.all(updates);
return { success: true };
},
{
body: t.Object({ ids: t.Array(t.String()) }),
}
)
// DELETE task - requires auth
.delete(
"/:id",
async ({ params, headers }) => {
requireAuth(headers);
const deleted = await db
.delete(tasks)
.where(eq(tasks.id, params.id))
.returning();
if (!deleted.length) throw new Error("Task not found");
return { success: true };
},
{
params: t.Object({ id: t.String() }),
}
);