import { Elysia, t } from "elysia"; import { db } from "../db"; import { tasks, type ProgressNote, type Subtask } from "../db/schema"; import { eq, asc, desc, sql, inArray, or } from "drizzle-orm"; import { auth } from "../lib/auth"; const BEARER_TOKEN = process.env.API_BEARER_TOKEN || "hammer-dev-token"; const CLAWDBOT_HOOK_URL = process.env.CLAWDBOT_HOOK_URL || "https://hammer.donovankelly.xyz/hooks/agent"; const CLAWDBOT_HOOK_TOKEN = process.env.CLAWDBOT_HOOK_TOKEN || ""; // Fire webhook to Clawdbot when a task is activated async function notifyTaskActivated(task: { id: string; title: string; description: string | null; source: string; priority: string }) { if (!CLAWDBOT_HOOK_URL || !CLAWDBOT_HOOK_TOKEN) { console.warn("CLAWDBOT_HOOK_URL or CLAWDBOT_HOOK_TOKEN not set — skipping webhook"); return; } if (!CLAWDBOT_HOOK_URL.startsWith("https://")) { console.warn("CLAWDBOT_HOOK_URL must use HTTPS — skipping webhook"); return; } try { const message = `🔨 Task activated in Hammer Dashboard:\n\nTitle: ${task.title}\nPriority: ${task.priority}\nSource: ${task.source}\nID: ${task.id}\n${task.description ? `\nDescription: ${task.description}` : ""}\n\nStart working on this task. Post progress notes to the dashboard API as you work:\ncurl -s -H "Authorization: Bearer $HAMMER_QUEUE_API_KEY" -H "Content-Type: application/json" -X POST "https://dash.donovankelly.xyz/api/tasks/${task.id}/notes" -d '{"note":"your update here"}'`; await fetch(CLAWDBOT_HOOK_URL, { method: "POST", headers: { "Content-Type": "application/json", "Authorization": `Bearer ${CLAWDBOT_HOOK_TOKEN}`, }, body: JSON.stringify({ message, name: "HammerQueue", sessionKey: `hook:queue:${task.id}`, deliver: true, channel: "telegram", }), }); console.log(`Webhook fired for task ${task.id}: ${task.title}`); } catch (err) { console.error("Failed to fire webhook:", err); } } // 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 requireBearerAuth(headers: Record) { const authHeader = headers["authorization"]; if (!authHeader || authHeader !== `Bearer ${BEARER_TOKEN}`) { throw new Error("Unauthorized"); } } async function requireSessionOrBearer(request: Request, headers: Record) { // Check bearer token first const authHeader = headers["authorization"]; if (authHeader === `Bearer ${BEARER_TOKEN}`) return; // Check session try { const session = await auth.api.getSession({ headers: request.headers }); if (session) return; } catch { // Session check failed } throw new Error("Unauthorized"); } async function requireAdmin(request: Request, headers: Record) { // Bearer token = admin access const authHeader = headers["authorization"]; if (authHeader === `Bearer ${BEARER_TOKEN}`) return; // Check session + role try { const session = await auth.api.getSession({ headers: request.headers }); if (session?.user && (session.user as any).role === "admin") return; } catch {} throw new Error("Unauthorized"); } // Resolve a task by UUID or sequential number (e.g. "5" or "HQ-5") async function resolveTask(idOrNumber: string) { // Strip "HQ-" prefix if present const cleaned = idOrNumber.replace(/^HQ-/i, ""); const asNumber = parseInt(cleaned, 10); let result; if (!isNaN(asNumber) && String(asNumber) === cleaned) { // Lookup by task_number result = await db.select().from(tasks).where(eq(tasks.taskNumber, asNumber)); } else { // Lookup by UUID result = await db.select().from(tasks).where(eq(tasks.id, cleaned)); } return result[0] || null; } export const taskRoutes = new Elysia({ prefix: "/api/tasks" }) .onError(({ error, set }) => { const msg = (error as any)?.message || String(error); if (msg === "Unauthorized") { set.status = 401; return { error: "Unauthorized" }; } if (msg === "Task not found") { set.status = 404; return { error: "Task not found" }; } console.error("Task route error:", msg); set.status = 500; return { error: "Internal server error" }; }) // GET all tasks - requires session or bearer auth .get("/", async ({ request, headers }) => { await requireSessionOrBearer(request, headers); const allTasks = await db .select() .from(tasks) .orderBy(statusOrder, asc(tasks.position), desc(tasks.createdAt)); return allTasks; }) // POST create task - requires session or bearer auth .post( "/", async ({ body, request, headers }) => { await requireSessionOrBearer(request, headers); // Get max position for queued tasks const maxPos = await db .select({ max: sql`COALESCE(MAX(${tasks.position}), 0)` }) .from(tasks); // Get next task number const maxNum = await db .select({ max: sql`COALESCE(MAX(${tasks.taskNumber}), 0)` }) .from(tasks); const nextNumber = (maxNum[0]?.max ?? 0) + 1; 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, taskNumber: nextNumber, projectId: body.projectId || null, dueDate: body.dueDate ? new Date(body.dueDate) : null, estimatedHours: body.estimatedHours ?? null, tags: body.tags || [], subtasks: [], 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"), ]) ), projectId: t.Optional(t.Union([t.String(), t.Null()])), dueDate: t.Optional(t.Union([t.String(), t.Null()])), estimatedHours: t.Optional(t.Union([t.Number(), t.Null()])), tags: t.Optional(t.Array(t.String())), }), } ) // GET stats - velocity and estimates - requires auth .get("/stats/velocity", async ({ request, headers }) => { await requireSessionOrBearer(request, headers); const allTasks = await db.select().from(tasks); // Build daily completion counts for last 14 days const dailyCompletions: { date: string; count: number }[] = []; for (let i = 13; i >= 0; i--) { const d = new Date(); d.setDate(d.getDate() - i); const dateStr = d.toISOString().split("T")[0]; const count = allTasks.filter(t => { if (!t.completedAt) return false; const completedDate = new Date(t.completedAt).toISOString().split("T")[0]; return completedDate === dateStr; }).length; dailyCompletions.push({ date: dateStr, count }); } // Estimate totals const activeAndQueued = allTasks.filter(t => t.status === "active" || t.status === "queued"); const totalEstimated = activeAndQueued.reduce((sum, t) => sum + (t.estimatedHours || 0), 0); const estimatedCount = activeAndQueued.filter(t => t.estimatedHours).length; const unestimatedCount = activeAndQueued.length - estimatedCount; // Completed this week (Mon-Sun) const now = new Date(); const dayOfWeek = now.getDay(); const mondayOffset = dayOfWeek === 0 ? 6 : dayOfWeek - 1; const monday = new Date(now); monday.setDate(monday.getDate() - mondayOffset); monday.setHours(0, 0, 0, 0); const completedThisWeek = allTasks.filter(t => { if (!t.completedAt) return false; return new Date(t.completedAt) >= monday; }).length; // Average tasks completed per week (last 4 weeks) const fourWeeksAgo = new Date(); fourWeeksAgo.setDate(fourWeeksAgo.getDate() - 28); const completedLast4Weeks = allTasks.filter(t => { if (!t.completedAt) return false; return new Date(t.completedAt) >= fourWeeksAgo; }).length; const avgPerWeek = Math.round((completedLast4Weeks / 4) * 10) / 10; return { dailyCompletions, completedThisWeek, avgPerWeek, totalEstimatedHours: totalEstimated, estimatedTaskCount: estimatedCount, unestimatedTaskCount: unestimatedCount, }; }) // GET single task by ID or number - requires session or bearer auth .get( "/:id", async ({ params, request, headers }) => { await requireSessionOrBearer(request, headers); const task = await resolveTask(params.id); if (!task) throw new Error("Task not found"); return task; }, { params: t.Object({ id: t.String() }) } ) // PATCH update task - requires session or bearer auth .patch( "/:id", async ({ params, body, request, headers }) => { await requireSessionOrBearer(request, headers); const task = await resolveTask(params.id); if (!task) throw new Error("Task not found"); const updates: Record = { 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; if (body.assigneeId !== undefined) updates.assigneeId = body.assigneeId; if (body.assigneeName !== undefined) updates.assigneeName = body.assigneeName; if (body.projectId !== undefined) updates.projectId = body.projectId; if (body.dueDate !== undefined) updates.dueDate = body.dueDate ? new Date(body.dueDate) : null; if (body.estimatedHours !== undefined) updates.estimatedHours = body.estimatedHours; if (body.tags !== undefined) updates.tags = body.tags; if (body.subtasks !== undefined) updates.subtasks = body.subtasks; if (body.progressNotes !== undefined) updates.progressNotes = body.progressNotes; const updated = await db .update(tasks) .set(updates) .where(eq(tasks.id, task.id)) .returning(); if (!updated.length) throw new Error("Task not found"); // Fire webhook if task was just activated if (body.status === "active") { notifyTaskActivated(updated[0]); } 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()), assigneeId: t.Optional(t.Union([t.String(), t.Null()])), assigneeName: t.Optional(t.Union([t.String(), t.Null()])), projectId: t.Optional(t.Union([t.String(), t.Null()])), dueDate: t.Optional(t.Union([t.String(), t.Null()])), estimatedHours: t.Optional(t.Union([t.Number(), t.Null()])), tags: t.Optional(t.Array(t.String())), subtasks: t.Optional(t.Array(t.Object({ id: t.String(), title: t.String(), completed: t.Boolean(), completedAt: t.Optional(t.String()), createdAt: t.String(), }))), progressNotes: t.Optional(t.Array(t.Object({ timestamp: t.String(), note: t.String(), }))), }), } ) // POST add subtask - requires auth .post( "/:id/subtasks", async ({ params, body, request, headers }) => { await requireSessionOrBearer(request, headers); const task = await resolveTask(params.id); if (!task) throw new Error("Task not found"); const currentSubtasks = (task.subtasks || []) as Subtask[]; const newSubtask: Subtask = { id: `st-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`, title: body.title, completed: false, createdAt: new Date().toISOString(), }; currentSubtasks.push(newSubtask); const updated = await db .update(tasks) .set({ subtasks: currentSubtasks, updatedAt: new Date() }) .where(eq(tasks.id, task.id)) .returning(); return updated[0]; }, { params: t.Object({ id: t.String() }), body: t.Object({ title: t.String() }), } ) // PATCH toggle subtask - requires auth .patch( "/:id/subtasks/:subtaskId", async ({ params, body, request, headers }) => { await requireSessionOrBearer(request, headers); const task = await resolveTask(params.id); if (!task) throw new Error("Task not found"); const currentSubtasks = (task.subtasks || []) as Subtask[]; const subtask = currentSubtasks.find((s) => s.id === params.subtaskId); if (!subtask) throw new Error("Task not found"); if (body.completed !== undefined) { subtask.completed = body.completed; subtask.completedAt = body.completed ? new Date().toISOString() : undefined; } if (body.title !== undefined) { subtask.title = body.title; } const updated = await db .update(tasks) .set({ subtasks: currentSubtasks, updatedAt: new Date() }) .where(eq(tasks.id, task.id)) .returning(); return updated[0]; }, { params: t.Object({ id: t.String(), subtaskId: t.String() }), body: t.Object({ completed: t.Optional(t.Boolean()), title: t.Optional(t.String()), }), } ) // DELETE subtask - requires auth .delete( "/:id/subtasks/:subtaskId", async ({ params, request, headers }) => { await requireSessionOrBearer(request, headers); const task = await resolveTask(params.id); if (!task) throw new Error("Task not found"); const currentSubtasks = (task.subtasks || []) as Subtask[]; const filtered = currentSubtasks.filter((s) => s.id !== params.subtaskId); const updated = await db .update(tasks) .set({ subtasks: filtered, updatedAt: new Date() }) .where(eq(tasks.id, task.id)) .returning(); return updated[0]; }, { params: t.Object({ id: t.String(), subtaskId: t.String() }), } ) // POST add progress note - requires auth .post( "/:id/notes", async ({ params, body, request, headers }) => { await requireSessionOrBearer(request, headers); const task = await resolveTask(params.id); if (!task) throw new Error("Task not found"); const currentNotes = (task.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, task.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, request, headers }) => { await requireSessionOrBearer(request, 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, request, headers }) => { await requireSessionOrBearer(request, headers); const task = await resolveTask(params.id); if (!task) throw new Error("Task not found"); await db.delete(tasks).where(eq(tasks.id, task.id)); return { success: true }; }, { params: t.Object({ id: t.String() }), } );