From b7ff8437e4882a78fb8d66cc6d71c19d4b151265 Mon Sep 17 00:00:00 2001 From: Hammer Date: Fri, 30 Jan 2026 00:04:38 +0000 Subject: [PATCH] feat: task comments/discussion system - 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 --- backend/src/db/schema.ts | 14 ++ backend/src/index.ts | 2 + backend/src/routes/comments.ts | 127 ++++++++++++ frontend/src/components/TaskComments.tsx | 207 ++++++++++++++++++++ frontend/src/components/TaskDetailPanel.tsx | 89 ++++++++- frontend/src/lib/api.ts | 36 ++++ frontend/src/pages/TaskPage.tsx | 3 + 7 files changed, 477 insertions(+), 1 deletion(-) create mode 100644 backend/src/routes/comments.ts create mode 100644 frontend/src/components/TaskComments.tsx diff --git a/backend/src/db/schema.ts b/backend/src/db/schema.ts index 1e0ad4f..62b9218 100644 --- a/backend/src/db/schema.ts +++ b/backend/src/db/schema.ts @@ -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", { diff --git a/backend/src/index.ts b/backend/src/index.ts index 788a2a8..cdaa612 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -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) diff --git a/backend/src/routes/comments.ts b/backend/src/routes/comments.ts new file mode 100644 index 0000000..f711b90 --- /dev/null +++ b/backend/src/routes/comments.ts @@ -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) { + 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 { + 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() }), + } + ); diff --git a/frontend/src/components/TaskComments.tsx b/frontend/src/components/TaskComments.tsx new file mode 100644 index 0000000..97fa127 --- /dev/null +++ b/frontend/src/components/TaskComments.tsx @@ -0,0 +1,207 @@ +import { useState, useEffect, useCallback, useRef } from "react"; +import ReactMarkdown from "react-markdown"; +import remarkGfm from "remark-gfm"; +import { fetchComments, addComment, deleteComment, type TaskComment } from "../lib/api"; +import { useCurrentUser } from "../hooks/useCurrentUser"; + +function timeAgo(dateStr: string): string { + const seconds = Math.floor((Date.now() - new Date(dateStr).getTime()) / 1000); + if (seconds < 60) return "just now"; + const minutes = Math.floor(seconds / 60); + if (minutes < 60) return `${minutes}m ago`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}h ago`; + const days = Math.floor(hours / 24); + return `${days}d ago`; +} + +function formatDate(dateStr: string): string { + return new Date(dateStr).toLocaleString(undefined, { + month: "short", day: "numeric", + hour: "2-digit", minute: "2-digit", + }); +} + +// Avatar color based on name +function avatarColor(name: string): string { + const colors = [ + "bg-blue-500", "bg-green-500", "bg-purple-500", "bg-pink-500", + "bg-indigo-500", "bg-teal-500", "bg-cyan-500", "bg-rose-500", + ]; + let hash = 0; + for (let i = 0; i < name.length; i++) hash = name.charCodeAt(i) + ((hash << 5) - hash); + return colors[Math.abs(hash) % colors.length]; +} + +function avatarInitial(name: string): string { + return name.charAt(0).toUpperCase(); +} + +const proseClasses = "prose prose-sm prose-gray dark:prose-invert max-w-none [&_p]:mb-1 [&_p:last-child]:mb-0 [&_ul]:mb-1 [&_ol]:mb-1 [&_li]:mb-0 [&_code]:bg-gray-200 dark:[&_code]:bg-gray-700 [&_code]:px-1 [&_code]:py-0.5 [&_code]:rounded [&_code]:text-xs [&_a]:text-amber-600 dark:[&_a]:text-amber-400 [&_a]:underline"; + +export function TaskComments({ taskId }: { taskId: string }) { + const [comments, setComments] = useState([]); + const [loading, setLoading] = useState(true); + const [commentText, setCommentText] = useState(""); + const [submitting, setSubmitting] = useState(false); + const [deletingId, setDeletingId] = useState(null); + const textareaRef = useRef(null); + const { user } = useCurrentUser(); + + const loadComments = useCallback(async () => { + try { + const data = await fetchComments(taskId); + setComments(data); + } catch (e) { + console.error("Failed to load comments:", e); + } finally { + setLoading(false); + } + }, [taskId]); + + useEffect(() => { + loadComments(); + // Poll for new comments every 30s + const interval = setInterval(loadComments, 30000); + return () => clearInterval(interval); + }, [loadComments]); + + const handleSubmit = async () => { + const text = commentText.trim(); + if (!text) return; + setSubmitting(true); + try { + await addComment(taskId, text); + setCommentText(""); + await loadComments(); + // Reset textarea height + if (textareaRef.current) { + textareaRef.current.style.height = "42px"; + } + } catch (e) { + console.error("Failed to add comment:", e); + } finally { + setSubmitting(false); + } + }; + + const handleDelete = async (commentId: string) => { + setDeletingId(commentId); + try { + await deleteComment(taskId, commentId); + setComments((prev) => prev.filter((c) => c.id !== commentId)); + } catch (e) { + console.error("Failed to delete comment:", e); + } finally { + setDeletingId(null); + } + }; + + const autoResize = () => { + const el = textareaRef.current; + if (!el) return; + el.style.height = "42px"; + el.style.height = Math.min(el.scrollHeight, 160) + "px"; + }; + + return ( +
+

+ 💬 Discussion {comments.length > 0 && ( + ({comments.length}) + )} +

+ + {/* Comment input */} +
+
+ {user && ( +
+ {avatarInitial(user.name)} +
+ )} +
+