- Added estimatedHours column to tasks schema - Backend: create/update support for estimatedHours - New /api/tasks/stats/velocity endpoint: daily completions, weekly velocity, estimate totals - Dashboard: velocity chart with 7-day bar chart, this week count, avg/week, estimate summary - TaskDetailPanel: estimated hours input field - CreateTaskModal: estimated hours in advanced options - TaskCard, KanbanBoard, TaskPage: estimate badge display
505 lines
17 KiB
TypeScript
505 lines
17 KiB
TypeScript
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<string, string | undefined>) {
|
|
const authHeader = headers["authorization"];
|
|
if (!authHeader || authHeader !== `Bearer ${BEARER_TOKEN}`) {
|
|
throw new Error("Unauthorized");
|
|
}
|
|
}
|
|
|
|
async function requireSessionOrBearer(request: Request, headers: Record<string, string | undefined>) {
|
|
// 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<string, string | undefined>) {
|
|
// 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<number>`COALESCE(MAX(${tasks.position}), 0)` })
|
|
.from(tasks);
|
|
// Get next task number
|
|
const maxNum = await db
|
|
.select({ max: sql<number>`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<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;
|
|
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() }),
|
|
}
|
|
);
|