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:
204
backend/src/routes/tasks.ts
Normal file
204
backend/src/routes/tasks.ts
Normal 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() }),
|
||||
}
|
||||
);
|
||||
Reference in New Issue
Block a user