feat: task comments/discussion system
All checks were successful
CI/CD / test (push) Successful in 18s
CI/CD / deploy (push) Successful in 2s

- New task_comments table (separate from progress notes)
- Backend: GET/POST/DELETE /api/tasks/:id/comments with session + bearer auth
- TaskComments component on TaskPage (full-page view) with markdown support,
  author avatars, delete own comments, 30s polling
- CompactComments in TaskDetailPanel (side panel) with last 3 + expand
- Comment API functions in frontend lib/api.ts
This commit is contained in:
2026-01-30 00:04:38 +00:00
parent 46002e0854
commit b7ff8437e4
7 changed files with 477 additions and 1 deletions

View File

@@ -103,6 +103,20 @@ export const tasks = pgTable("tasks", {
export type Task = typeof tasks.$inferSelect;
export type NewTask = typeof tasks.$inferInsert;
// ─── Comments ───
export const taskComments = pgTable("task_comments", {
id: uuid("id").defaultRandom().primaryKey(),
taskId: uuid("task_id").notNull().references(() => tasks.id, { onDelete: "cascade" }),
authorId: text("author_id"), // BetterAuth user ID, or "hammer" for API, null for anonymous
authorName: text("author_name").notNull(),
content: text("content").notNull(),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
});
export type TaskComment = typeof taskComments.$inferSelect;
export type NewTaskComment = typeof taskComments.$inferInsert;
// ─── BetterAuth tables ───
export const users = pgTable("users", {

View File

@@ -4,6 +4,7 @@ import { taskRoutes } from "./routes/tasks";
import { adminRoutes } from "./routes/admin";
import { projectRoutes } from "./routes/projects";
import { chatRoutes } from "./routes/chat";
import { commentRoutes } from "./routes/comments";
import { auth } from "./lib/auth";
import { db } from "./db";
import { tasks, users } from "./db/schema";
@@ -115,6 +116,7 @@ const app = new Elysia()
})
.use(taskRoutes)
.use(commentRoutes)
.use(projectRoutes)
.use(adminRoutes)
.use(chatRoutes)

View File

@@ -0,0 +1,127 @@
import { Elysia, t } from "elysia";
import { db } from "../db";
import { taskComments, tasks } from "../db/schema";
import { eq, desc, asc } from "drizzle-orm";
import { auth } from "../lib/auth";
import { parseTaskIdentifier } from "../lib/utils";
const BEARER_TOKEN = process.env.API_BEARER_TOKEN || "hammer-dev-token";
async function requireSessionOrBearer(request: Request, headers: Record<string, string | undefined>) {
const authHeader = headers["authorization"];
if (authHeader === `Bearer ${BEARER_TOKEN}`) {
return { userId: "hammer", userName: "Hammer" };
}
try {
const session = await auth.api.getSession({ headers: request.headers });
if (session?.user) {
return { userId: session.user.id, userName: session.user.name || session.user.email };
}
} catch {}
throw new Error("Unauthorized");
}
async function resolveTaskId(idOrNumber: string): Promise<string | null> {
const parsed = parseTaskIdentifier(idOrNumber);
let result;
if (parsed.type === "number") {
result = await db.select({ id: tasks.id }).from(tasks).where(eq(tasks.taskNumber, parsed.value));
} else {
result = await db.select({ id: tasks.id }).from(tasks).where(eq(tasks.id, parsed.value));
}
return result[0]?.id || null;
}
export const commentRoutes = 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" };
}
set.status = 500;
return { error: "Internal server error" };
})
// GET comments for a task
.get(
"/:id/comments",
async ({ params, request, headers }) => {
await requireSessionOrBearer(request, headers);
const taskId = await resolveTaskId(params.id);
if (!taskId) throw new Error("Task not found");
const comments = await db
.select()
.from(taskComments)
.where(eq(taskComments.taskId, taskId))
.orderBy(asc(taskComments.createdAt));
return comments;
},
{ params: t.Object({ id: t.String() }) }
)
// POST add comment to a task
.post(
"/:id/comments",
async ({ params, body, request, headers }) => {
const user = await requireSessionOrBearer(request, headers);
const taskId = await resolveTaskId(params.id);
if (!taskId) throw new Error("Task not found");
const comment = await db
.insert(taskComments)
.values({
taskId,
authorId: user.userId,
authorName: body.authorName || user.userName,
content: body.content,
})
.returning();
return comment[0];
},
{
params: t.Object({ id: t.String() }),
body: t.Object({
content: t.String(),
authorName: t.Optional(t.String()),
}),
}
)
// DELETE a comment
.delete(
"/:id/comments/:commentId",
async ({ params, request, headers }) => {
const user = await requireSessionOrBearer(request, headers);
const taskId = await resolveTaskId(params.id);
if (!taskId) throw new Error("Task not found");
// Only allow deleting own comments (or bearer token = admin)
const comment = await db
.select()
.from(taskComments)
.where(eq(taskComments.id, params.commentId));
if (!comment[0]) throw new Error("Task not found");
if (comment[0].taskId !== taskId) throw new Error("Task not found");
// Bearer token can delete any, otherwise must be author
const authHeader = headers["authorization"];
if (authHeader !== `Bearer ${BEARER_TOKEN}` && comment[0].authorId !== user.userId) {
throw new Error("Unauthorized");
}
await db.delete(taskComments).where(eq(taskComments.id, params.commentId));
return { success: true };
},
{
params: t.Object({ id: t.String(), commentId: t.String() }),
}
);