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

7
backend/src/db/index.ts Normal file
View File

@@ -0,0 +1,7 @@
import { drizzle } from "drizzle-orm/postgres-js";
import postgres from "postgres";
import * as schema from "./schema";
const connectionString = process.env.DATABASE_URL!;
const client = postgres(connectionString);
export const db = drizzle(client, { schema });

55
backend/src/db/schema.ts Normal file
View File

@@ -0,0 +1,55 @@
import {
pgTable,
uuid,
text,
integer,
timestamp,
jsonb,
pgEnum,
} from "drizzle-orm/pg-core";
export const taskStatusEnum = pgEnum("task_status", [
"active",
"queued",
"blocked",
"completed",
"cancelled",
]);
export const taskPriorityEnum = pgEnum("task_priority", [
"critical",
"high",
"medium",
"low",
]);
export const taskSourceEnum = pgEnum("task_source", [
"donovan",
"david",
"hammer",
"heartbeat",
"cron",
"other",
]);
export interface ProgressNote {
timestamp: string;
note: string;
}
export const tasks = pgTable("tasks", {
id: uuid("id").defaultRandom().primaryKey(),
title: text("title").notNull(),
description: text("description"),
source: taskSourceEnum("source").notNull().default("donovan"),
status: taskStatusEnum("status").notNull().default("queued"),
priority: taskPriorityEnum("priority").notNull().default("medium"),
position: integer("position").notNull().default(0),
progressNotes: jsonb("progress_notes").$type<ProgressNote[]>().default([]),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
completedAt: timestamp("completed_at", { withTimezone: true }),
});
export type Task = typeof tasks.$inferSelect;
export type NewTask = typeof tasks.$inferInsert;

25
backend/src/index.ts Normal file
View File

@@ -0,0 +1,25 @@
import { Elysia } from "elysia";
import { cors } from "@elysiajs/cors";
import { taskRoutes } from "./routes/tasks";
const PORT = process.env.PORT || 3100;
const app = new Elysia()
.use(cors())
.use(taskRoutes)
.get("/health", () => ({ status: "ok", service: "hammer-queue" }))
.onError(({ error, set }) => {
if (error.message === "Unauthorized") {
set.status = 401;
return { error: "Unauthorized" };
}
if (error.message === "Task not found") {
set.status = 404;
return { error: "Task not found" };
}
set.status = 500;
return { error: "Internal server error" };
})
.listen(PORT);
console.log(`🔨 Hammer Queue API running on port ${PORT}`);

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