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
This commit is contained in:
@@ -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", {
|
||||
|
||||
@@ -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)
|
||||
|
||||
127
backend/src/routes/comments.ts
Normal file
127
backend/src/routes/comments.ts
Normal 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() }),
|
||||
}
|
||||
);
|
||||
Reference in New Issue
Block a user