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

View File

@@ -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<TaskComment[]>([]);
const [loading, setLoading] = useState(true);
const [commentText, setCommentText] = useState("");
const [submitting, setSubmitting] = useState(false);
const [deletingId, setDeletingId] = useState<string | null>(null);
const textareaRef = useRef<HTMLTextAreaElement>(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 (
<div className="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-800 shadow-sm p-5">
<h2 className="text-sm font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-wider mb-3">
💬 Discussion {comments.length > 0 && (
<span className="text-gray-300 dark:text-gray-600 ml-1">({comments.length})</span>
)}
</h2>
{/* Comment input */}
<div className="mb-4">
<div className="flex gap-3">
{user && (
<div className={`w-8 h-8 rounded-full ${avatarColor(user.name)} flex items-center justify-center text-white text-sm font-bold shrink-0 mt-1`}>
{avatarInitial(user.name)}
</div>
)}
<div className="flex-1">
<textarea
ref={textareaRef}
value={commentText}
onChange={(e) => { setCommentText(e.target.value); autoResize(); }}
placeholder="Leave a comment..."
className="w-full text-sm border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-amber-200 dark:focus:ring-amber-800 focus:border-amber-300 dark:focus:border-amber-700 resize-none placeholder:text-gray-400 dark:placeholder:text-gray-500"
style={{ minHeight: "42px" }}
onKeyDown={(e) => {
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
handleSubmit();
}
}}
/>
<div className="flex items-center justify-between mt-1.5">
<span className="text-[10px] text-gray-400 dark:text-gray-500">Markdown supported · +Enter to submit</span>
<button
onClick={handleSubmit}
disabled={!commentText.trim() || submitting}
className="text-xs px-3 py-1.5 bg-amber-500 text-white rounded-lg font-medium hover:bg-amber-600 transition disabled:opacity-50 disabled:cursor-not-allowed"
>
{submitting ? "Posting..." : "Comment"}
</button>
</div>
</div>
</div>
</div>
{/* Comments list */}
{loading ? (
<div className="text-center text-gray-400 dark:text-gray-500 py-6 text-sm">Loading comments...</div>
) : comments.length === 0 ? (
<div className="text-sm text-gray-400 dark:text-gray-500 italic py-6 text-center border-2 border-dashed border-gray-100 dark:border-gray-800 rounded-lg">
No comments yet be the first to chime in
</div>
) : (
<div className="space-y-4">
{comments.map((comment) => {
const isOwn = user && comment.authorId === user.id;
const isHammer = comment.authorId === "hammer";
return (
<div key={comment.id} className="flex gap-3 group">
<div className={`w-8 h-8 rounded-full flex items-center justify-center text-sm font-bold shrink-0 mt-0.5 ${
isHammer
? "bg-amber-500 text-white"
: `${avatarColor(comment.authorName)} text-white`
}`}>
{isHammer ? "🔨" : avatarInitial(comment.authorName)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-0.5">
<span className={`text-sm font-semibold ${
isHammer ? "text-amber-700 dark:text-amber-400" : "text-gray-800 dark:text-gray-200"
}`}>
{comment.authorName}
</span>
<span className="text-[10px] text-gray-400 dark:text-gray-500" title={formatDate(comment.createdAt)}>
{timeAgo(comment.createdAt)}
</span>
{isOwn && (
<button
onClick={() => handleDelete(comment.id)}
disabled={deletingId === comment.id}
className="opacity-0 group-hover:opacity-100 text-gray-300 dark:text-gray-600 hover:text-red-400 dark:hover:text-red-400 transition text-xs"
title="Delete comment"
>
{deletingId === comment.id ? "..." : "×"}
</button>
)}
</div>
<div className={`text-sm text-gray-700 dark:text-gray-300 leading-relaxed ${proseClasses}`}>
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{comment.content}
</ReactMarkdown>
</div>
</div>
</div>
);
})}
</div>
)}
</div>
);
}

View File

@@ -1,6 +1,6 @@
import { useState, useEffect, useRef } from "react";
import type { Task, TaskStatus, TaskPriority, TaskSource, Project, Recurrence } from "../lib/types";
import { updateTask, fetchProjects, addProgressNote, addSubtask, toggleSubtask, deleteSubtask, deleteTask } from "../lib/api";
import { updateTask, fetchProjects, addProgressNote, addSubtask, toggleSubtask, deleteSubtask, deleteTask, fetchComments, addComment, type TaskComment } from "../lib/api";
import { useToast } from "./Toast";
const priorityColors: Record<TaskPriority, string> = {
@@ -241,6 +241,90 @@ interface TaskDetailPanelProps {
token: string;
}
function CompactComments({ taskId }: { taskId: string }) {
const [comments, setComments] = useState<TaskComment[]>([]);
const [text, setText] = useState("");
const [submitting, setSubmitting] = useState(false);
const [expanded, setExpanded] = useState(false);
useEffect(() => {
fetchComments(taskId).then(setComments).catch(() => {});
}, [taskId]);
const handleSubmit = async () => {
if (!text.trim()) return;
setSubmitting(true);
try {
const comment = await addComment(taskId, text.trim());
setComments((prev) => [...prev, comment]);
setText("");
} catch (e) {
console.error("Failed to add comment:", e);
} finally {
setSubmitting(false);
}
};
const recentComments = expanded ? comments : comments.slice(-3);
return (
<div className="border-t border-gray-100 dark:border-gray-800 pt-4 mt-4">
<div className="flex items-center justify-between mb-2">
<h3 className="text-xs font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-wider">
💬 Discussion {comments.length > 0 && <span className="text-gray-300 dark:text-gray-600">({comments.length})</span>}
</h3>
{comments.length > 3 && !expanded && (
<button onClick={() => setExpanded(true)} className="text-[10px] text-amber-600 dark:text-amber-400 hover:text-amber-700 dark:hover:text-amber-300 font-medium">
Show all ({comments.length})
</button>
)}
</div>
{/* Comments */}
{recentComments.length > 0 && (
<div className="space-y-2 mb-3">
{recentComments.map((c) => (
<div key={c.id} className="text-xs">
<span className={`font-semibold ${c.authorId === "hammer" ? "text-amber-700 dark:text-amber-400" : "text-gray-700 dark:text-gray-300"}`}>
{c.authorName}
</span>
<span className="text-gray-400 dark:text-gray-500 ml-1.5">
{timeAgo(c.createdAt)}
</span>
<p className="text-gray-600 dark:text-gray-400 mt-0.5 leading-relaxed">{c.content}</p>
</div>
))}
</div>
)}
{/* Input */}
<div className="flex gap-2">
<input
type="text"
value={text}
onChange={(e) => setText(e.target.value)}
placeholder="Add a comment..."
className="flex-1 text-xs border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 rounded-lg px-2.5 py-1.5 focus:outline-none focus:ring-1 focus:ring-amber-200 dark:focus:ring-amber-800 placeholder:text-gray-400 dark:placeholder:text-gray-500"
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSubmit();
}
}}
disabled={submitting}
/>
<button
onClick={handleSubmit}
disabled={!text.trim() || submitting}
className="text-xs px-2.5 py-1.5 bg-amber-500 text-white rounded-lg font-medium hover:bg-amber-600 transition disabled:opacity-50 disabled:cursor-not-allowed shrink-0"
>
{submitting ? "..." : "Post"}
</button>
</div>
</div>
);
}
export function TaskDetailPanel({ task, onClose, onStatusChange, onTaskUpdated, hasToken, token }: TaskDetailPanelProps) {
const actions = statusActions[task.status] || [];
const isActive = task.status === "active";
@@ -986,6 +1070,9 @@ export function TaskDetailPanel({ task, onClose, onStatusChange, onTaskUpdated,
</div>
)}
</div>
{/* Quick Comments */}
<CompactComments taskId={task.id} />
</div>
{/* Save / Cancel Bar */}

View File

@@ -167,6 +167,42 @@ export async function addProgressNote(taskId: string, note: string): Promise<Tas
return res.json();
}
// ─── Comments API ───
export interface TaskComment {
id: string;
taskId: string;
authorId: string | null;
authorName: string;
content: string;
createdAt: string;
}
export async function fetchComments(taskId: string): Promise<TaskComment[]> {
const res = await fetch(`${BASE}/${taskId}/comments`, { credentials: "include" });
if (!res.ok) throw new Error("Failed to fetch comments");
return res.json();
}
export async function addComment(taskId: string, content: string): Promise<TaskComment> {
const res = await fetch(`${BASE}/${taskId}/comments`, {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ content }),
});
if (!res.ok) throw new Error("Failed to add comment");
return res.json();
}
export async function deleteComment(taskId: string, commentId: string): Promise<void> {
const res = await fetch(`${BASE}/${taskId}/comments/${commentId}`, {
method: "DELETE",
credentials: "include",
});
if (!res.ok) throw new Error("Failed to delete comment");
}
// Admin API
export async function fetchUsers(): Promise<any[]> {
const res = await fetch("/api/admin/users", { credentials: "include" });

View File

@@ -5,6 +5,7 @@ import remarkGfm from "remark-gfm";
import type { Task, TaskStatus, Project } from "../lib/types";
import { updateTask, fetchProjects, addProgressNote, addSubtask, toggleSubtask, deleteSubtask, deleteTask } from "../lib/api";
import { useToast } from "../components/Toast";
import { TaskComments } from "../components/TaskComments";
const priorityColors: Record<string, string> = {
critical: "bg-red-500 text-white",
@@ -598,6 +599,8 @@ export function TaskPage() {
</div>
)}
</div>
{/* Comments / Discussion */}
<TaskComments taskId={task.id} />
</div>
{/* Sidebar */}