feat: add threaded comments, activity feeds, and task dependencies

This commit is contained in:
2026-08-10 21:59:10 +00:00
parent 1059512888
commit a3e4d3c868
35 changed files with 10283 additions and 578 deletions
+4
View File
@@ -18,11 +18,13 @@ import { dashboardRoutes } from "./routes/dashboard";
import { agentRoutes } from "./routes/agents";
import { webhookRoutes } from "./routes/webhooks";
import { canvasRoutes } from "./routes/canvas";
import { commentRoutes } from "./routes/comments";
import { dailyNoteRoutes } from "./routes/daily-notes";
import { tagRoutes } from "./routes/tags";
import { customFieldRoutes } from "./routes/custom-fields";
import { errorLogRoutes } from "./routes/error-log";
import { analyticsRoutes } from "./routes/analytics";
import { activityRoutes } from "./routes/activity";
import { importExportRoutes } from "./routes/import-export";
import { notificationRoutes } from "./routes/notifications";
import { healthHandler } from "./routes/health";
@@ -54,11 +56,13 @@ app.route("/api/dashboard", dashboardRoutes);
app.route("/api/agents", agentRoutes);
app.route("/api/webhooks", webhookRoutes);
app.route("/api/canvas", canvasRoutes);
app.route("/api/comments", commentRoutes);
app.route("/api/daily-notes", dailyNoteRoutes);
app.route("/api/tags", tagRoutes);
app.route("/api/custom-fields", customFieldRoutes);
app.route("/api/error-log", errorLogRoutes);
app.route("/api/analytics", analyticsRoutes);
app.route("/api/activity", activityRoutes);
app.route("/api/notifications", notificationRoutes);
app.route("/api", importExportRoutes);
app.route("/api", realtimeRoutes);
+104
View File
@@ -0,0 +1,104 @@
import { Hono } from "hono";
import {
db,
activityFeed,
tasks,
projects,
habits,
notes,
canvases,
dailyNotes,
calendarEvents,
} from "@project-e/db";
import { and, desc, eq, sql } from "drizzle-orm";
import type { AnyPgColumn, AnyPgTable } from "drizzle-orm/pg-core";
import { requireAuth, requireWorkspaceAccess, createErrorResponse, AuthError, isUuid } from "../middleware/auth";
import { z } from "zod";
export const activityRoutes = new Hono();
// Entity types that participate in the generic activity feed / comments API.
// The comments table stores the same entityType strings, so keep this enum and
// the entityWorkspaceLookups map below in sync.
const entityTypeEnum = z.enum(["task", "project", "habit", "note", "canvas", "daily_note", "calendar_event"]);
interface EntityWorkspaceLookup {
table: AnyPgTable;
idColumn: AnyPgColumn;
workspaceColumn: AnyPgColumn;
}
// Maps an entityType (as recorded in activity_feed) to the table + column that
// holds its owning workspace/domain. All of these tables use domain_id.
const entityWorkspaceLookups: Record<string, EntityWorkspaceLookup> = {
task: { table: tasks, idColumn: tasks.id, workspaceColumn: tasks.domainId },
project: { table: projects, idColumn: projects.id, workspaceColumn: projects.domainId },
habit: { table: habits, idColumn: habits.id, workspaceColumn: habits.domainId },
note: { table: notes, idColumn: notes.id, workspaceColumn: notes.domainId },
canvas: { table: canvases, idColumn: canvases.id, workspaceColumn: canvases.domainId },
daily_note: { table: dailyNotes, idColumn: dailyNotes.id, workspaceColumn: dailyNotes.domainId },
calendar_event: { table: calendarEvents, idColumn: calendarEvents.id, workspaceColumn: calendarEvents.domainId },
};
async function resolveEntityWorkspaceId(entityType: string, entityId: string): Promise<string | null> {
const lookup = entityWorkspaceLookups[entityType];
if (!lookup) return null;
const [row] = await db
.select({ workspaceId: lookup.workspaceColumn })
.from(lookup.table)
.where(eq(lookup.idColumn, entityId))
.limit(1);
// AnyPgColumn erases the concrete column type, so the selected value is unknown.
return (row?.workspaceId as string | undefined) ?? null;
}
// GET /api/activity?entityType=&entityId=&limit= — Activity feed for one entity.
activityRoutes.get("/", async (c) => {
try {
await requireAuth(c);
const entityType = c.req.query("entityType") || "";
const entityId = c.req.query("entityId") || "";
const entityTypeResult = entityTypeEnum.safeParse(entityType);
if (!entityTypeResult.success) {
return c.json(createErrorResponse("VALIDATION_ERROR", "Invalid entityType"), 400);
}
if (!isUuid(entityId)) {
return c.json(createErrorResponse("NOT_FOUND", "Resource not found"), 404);
}
const domainId = await resolveEntityWorkspaceId(entityType, entityId);
if (!domainId) {
return c.json(createErrorResponse("NOT_FOUND", "Entity not found"), 404);
}
await requireWorkspaceAccess(c, domainId);
const limit = Math.min(Math.max(parseInt(c.req.query("limit") || "50", 10) || 50, 1), 200);
const conditions = [
eq(activityFeed.entityType, entityType),
eq(activityFeed.entityId, entityId),
eq(activityFeed.workspaceId, domainId),
];
const [items, countResult] = await Promise.all([
db.select()
.from(activityFeed)
.where(and(...conditions))
.orderBy(desc(activityFeed.createdAt))
.limit(limit),
db.select({ count: sql<number>`count(*)` })
.from(activityFeed)
.where(and(...conditions)),
]);
return c.json({ items, totalItems: Number(countResult[0]?.count || 0) });
} catch (error) {
if (error instanceof AuthError) {
return c.json({ error: { code: error.code, message: error.message } }, error.status as any);
}
console.error("[activity] GET error:", error);
return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to get activity" } }, 500);
}
});
+222
View File
@@ -0,0 +1,222 @@
import { Hono } from "hono";
import {
db,
comments,
tasks,
projects,
habits,
notes,
canvases,
dailyNotes,
calendarEvents,
} from "@project-e/db";
import { and, asc, eq, inArray, isNull } from "drizzle-orm";
import type { AnyPgColumn, AnyPgTable } from "drizzle-orm/pg-core";
import { requireAuth, requireWorkspaceAccess, createErrorResponse, AuthError, isUuid } from "../middleware/auth";
import { recordActivity } from "../middleware/activity";
import { z } from "zod";
export const commentRoutes = new Hono();
// Entity types that support comments. Keep in sync with entityWorkspaceLookups.
const entityTypeEnum = z.enum(["task", "project", "habit", "note", "canvas", "daily_note", "calendar_event"]);
interface EntityWorkspaceLookup {
table: AnyPgTable;
idColumn: AnyPgColumn;
workspaceColumn: AnyPgColumn;
}
// Maps an entityType (as recorded in comments.entityType) to the table + column
// that holds its owning workspace/domain. All of these tables use domain_id.
const entityWorkspaceLookups: Record<string, EntityWorkspaceLookup> = {
task: { table: tasks, idColumn: tasks.id, workspaceColumn: tasks.domainId },
project: { table: projects, idColumn: projects.id, workspaceColumn: projects.domainId },
habit: { table: habits, idColumn: habits.id, workspaceColumn: habits.domainId },
note: { table: notes, idColumn: notes.id, workspaceColumn: notes.domainId },
canvas: { table: canvases, idColumn: canvases.id, workspaceColumn: canvases.domainId },
daily_note: { table: dailyNotes, idColumn: dailyNotes.id, workspaceColumn: dailyNotes.domainId },
calendar_event: { table: calendarEvents, idColumn: calendarEvents.id, workspaceColumn: calendarEvents.domainId },
};
async function resolveEntityWorkspaceId(entityType: string, entityId: string): Promise<string | null> {
const lookup = entityWorkspaceLookups[entityType];
if (!lookup) return null;
const [row] = await db
.select({ workspaceId: lookup.workspaceColumn })
.from(lookup.table)
.where(eq(lookup.idColumn, entityId))
.limit(1);
// AnyPgColumn erases the concrete column type, so the selected value is unknown.
return (row?.workspaceId as string | undefined) ?? null;
}
const createCommentSchema = z.object({
entityType: entityTypeEnum,
entityId: z.string().uuid(),
content: z.string().min(1, "Content is required"),
parentId: z.string().uuid().optional(),
});
// GET /api/comments?entityType=&entityId= — Flat list of non-deleted comments
// for an entity, ordered oldest-first. The frontend assembles the reply tree
// from parentId.
commentRoutes.get("/", async (c) => {
try {
await requireAuth(c);
const entityType = c.req.query("entityType") || "";
const entityId = c.req.query("entityId") || "";
const entityTypeResult = entityTypeEnum.safeParse(entityType);
if (!entityTypeResult.success) {
return c.json(createErrorResponse("VALIDATION_ERROR", "Invalid entityType"), 400);
}
if (!isUuid(entityId)) {
return c.json(createErrorResponse("NOT_FOUND", "Resource not found"), 404);
}
const domainId = await resolveEntityWorkspaceId(entityType, entityId);
if (!domainId) {
return c.json(createErrorResponse("NOT_FOUND", "Entity not found"), 404);
}
await requireWorkspaceAccess(c, domainId);
const items = await db.select()
.from(comments)
.where(and(
eq(comments.entityType, entityType),
eq(comments.entityId, entityId),
eq(comments.workspaceId, domainId),
isNull(comments.deletedAt),
))
.orderBy(asc(comments.createdAt));
return c.json({ items });
} catch (error) {
if (error instanceof AuthError) {
return c.json({ error: { code: error.code, message: error.message } }, error.status as any);
}
console.error("[comments] GET error:", error);
return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to get comments" } }, 500);
}
});
// POST /api/comments — Create a comment (or a reply via parentId).
commentRoutes.post("/", async (c) => {
try {
const user = await requireAuth(c);
const body = await c.req.json();
const data = createCommentSchema.parse(body);
const domainId = await resolveEntityWorkspaceId(data.entityType, data.entityId);
if (!domainId) {
return c.json(createErrorResponse("NOT_FOUND", "Entity not found"), 404);
}
await requireWorkspaceAccess(c, domainId);
if (data.parentId) {
const [parent] = await db.select({
id: comments.id,
entityType: comments.entityType,
entityId: comments.entityId,
deletedAt: comments.deletedAt,
})
.from(comments)
.where(eq(comments.id, data.parentId))
.limit(1);
if (!parent || parent.deletedAt !== null || parent.entityType !== data.entityType || parent.entityId !== data.entityId) {
return c.json(createErrorResponse("VALIDATION_ERROR", "Parent comment not found or does not belong to this entity"), 400);
}
}
const [newComment] = await db.insert(comments).values({
entityType: data.entityType,
entityId: data.entityId,
workspaceId: domainId,
parentId: data.parentId ?? null,
author: user.name,
content: data.content.trim(),
}).returning();
await recordActivity({
actor: user.name,
action: "commented",
entityType: data.entityType,
entityId: data.entityId,
changes: { commentId: newComment.id },
workspaceId: domainId,
});
return c.json(newComment, 201);
} catch (error) {
if (error instanceof AuthError) {
return c.json({ error: { code: error.code, message: error.message } }, error.status as any);
}
if (error instanceof z.ZodError) {
return c.json({ error: { code: "VALIDATION_ERROR", message: "Invalid input", details: error.issues } }, 400);
}
console.error("[comments] POST error:", error);
return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to create comment" } }, 500);
}
});
// DELETE /api/comments/:id — Soft-delete a comment and all of its descendants.
commentRoutes.delete("/:id", async (c) => {
try {
const user = await requireAuth(c);
const id = c.req.param("id");
if (!isUuid(id)) {
return c.json(createErrorResponse("NOT_FOUND", "Resource not found"), 404);
}
const [comment] = await db.select()
.from(comments)
.where(eq(comments.id, id))
.limit(1);
if (!comment) {
return c.json(createErrorResponse("NOT_FOUND", "Comment not found"), 404);
}
await requireWorkspaceAccess(c, comment.workspaceId);
// Soft-delete the comment and every descendant. Comment volume is low, so a
// loop over parentId is fine.
const deletedAt = new Date();
const idsToDelete = [id];
let frontier: string[] = [id];
while (frontier.length > 0) {
const children = await db.select({ id: comments.id })
.from(comments)
.where(inArray(comments.parentId, frontier));
frontier = children
.map(child => child.id)
.filter(childId => !idsToDelete.includes(childId));
idsToDelete.push(...frontier);
}
await db.update(comments)
.set({ deletedAt })
.where(inArray(comments.id, idsToDelete));
await recordActivity({
actor: user.name,
action: "deleted_comment",
entityType: comment.entityType,
entityId: comment.entityId,
changes: { commentId: id },
workspaceId: comment.workspaceId,
});
return c.body(null, 204);
} catch (error) {
if (error instanceof AuthError) {
return c.json({ error: { code: error.code, message: error.message } }, error.status as any);
}
console.error("[comments] DELETE error:", error);
return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to delete comment" } }, 500);
}
});
+8 -4
View File
@@ -311,15 +311,19 @@ habitRoutes.get("/:id", async (c) => {
await requireWorkspaceAccess(c, habit.domainId);
// Fetch recent completions (last 30 days)
const thirtyDaysAgo = new Date();
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
// Fetch recent completions (default last 30 days; ?days= extends up to a
// year so the frontend heatmap can show longer history)
const url = new URL(c.req.url);
const daysParam = url.searchParams.get("days");
const days = Math.min(365, Math.max(1, parseInt(daysParam || "30") || 30));
const since = new Date();
since.setDate(since.getDate() - days);
const recentCompletions = await db.select()
.from(habitCompletions)
.where(and(
eq(habitCompletions.habitId, id),
gte(habitCompletions.date, thirtyDaysAgo),
gte(habitCompletions.date, since),
))
.orderBy(desc(habitCompletions.date));
+124 -86
View File
@@ -714,6 +714,130 @@ taskRoutes.delete("/:id/tags/:tagId", async (c) => {
}
});
// POST /api/tasks/:id/dependencies — Make this task depend on another task
taskRoutes.post("/:id/dependencies", async (c) => {
try {
const user = await requireAuth(c);
const id = c.req.param("id");
if (!isUuid(id)) {
return c.json({ error: { code: "NOT_FOUND", message: "Resource not found" } }, 404);
}
const body = await c.req.json();
const { dependsOnTaskId } = z.object({
dependsOnTaskId: z.string().uuid("Invalid task id"),
}).parse(body);
const [task] = await db.select({ id: tasks.id, domainId: tasks.domainId })
.from(tasks)
.where(and(eq(tasks.id, id), isNull(tasks.deletedAt)))
.limit(1);
if (!task) {
return c.json({ error: { code: "NOT_FOUND", message: "Task not found" } }, 404);
}
await requireWorkspaceAccess(c, task.domainId);
// A task cannot depend on itself
if (dependsOnTaskId === id) {
return c.json({ error: { code: "VALIDATION_ERROR", message: "A task cannot depend on itself" } }, 400);
}
const [depTask] = await db.select({ id: tasks.id, domainId: tasks.domainId })
.from(tasks)
.where(and(eq(tasks.id, dependsOnTaskId), isNull(tasks.deletedAt)))
.limit(1);
if (!depTask) {
return c.json({ error: { code: "NOT_FOUND", message: "Dependency task not found" } }, 404);
}
if (depTask.domainId !== task.domainId) {
return c.json({ error: { code: "FORBIDDEN", message: "Dependency task does not belong to this workspace" } }, 403);
}
// Cycle guard: walk the dependency chain (X depends on Y, Y on Z, ...) from
// dependsOnTaskId; reaching id means adding this edge would create a cycle.
let currentId: string | null = dependsOnTaskId;
const visited = new Set<string>([id]);
while (currentId) {
if (visited.has(currentId)) {
return c.json({ error: { code: "VALIDATION_ERROR", message: "Circular dependency detected" } }, 400);
}
visited.add(currentId);
const [next] = await db.select({ dependsOnTaskId: taskDependencies.dependsOnTaskId })
.from(taskDependencies)
.where(eq(taskDependencies.taskId, currentId))
.limit(1);
currentId = next?.dependsOnTaskId ?? null;
}
// Junction table has a composite PK — ignore duplicate edges
await db.insert(taskDependencies).values({ taskId: id, dependsOnTaskId }).onConflictDoNothing();
await recordActivity({
actor: user.name,
action: "dependency_added",
entityType: "task",
entityId: id,
changes: { dependsOnTaskId },
workspaceId: task.domainId,
});
await enqueueWebhooks({ workspaceId: task.domainId, event: "task.updated", entityType: "task", entityId: id, data: { dependsOnTaskId } });
return c.json({ success: true }, 201);
} catch (error) {
if (error instanceof AuthError) {
return c.json({ error: { code: error.code, message: error.message } }, error.status as any);
}
if (error instanceof z.ZodError) {
return c.json({ error: { code: "VALIDATION_ERROR", message: "Invalid input", details: error.issues } }, 400);
}
console.error("[tasks] POST /:id/dependencies error:", error);
return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to add dependency" } }, 500);
}
});
// DELETE /api/tasks/:id/dependencies/:depId — Remove a dependency
taskRoutes.delete("/:id/dependencies/:depId", async (c) => {
try {
const user = await requireAuth(c);
const id = c.req.param("id");
if (!isUuid(id)) {
return c.json({ error: { code: "NOT_FOUND", message: "Resource not found" } }, 404);
}
const depId = c.req.param("depId");
const [task] = await db.select({ id: tasks.id, domainId: tasks.domainId })
.from(tasks)
.where(and(eq(tasks.id, id), isNull(tasks.deletedAt)))
.limit(1);
if (!task) {
return c.json({ error: { code: "NOT_FOUND", message: "Task not found" } }, 404);
}
await requireWorkspaceAccess(c, task.domainId);
// Junction table has no deleted_at — hard delete is correct here
await db.delete(taskDependencies).where(and(eq(taskDependencies.taskId, id), eq(taskDependencies.dependsOnTaskId, depId)));
await recordActivity({
actor: user.name,
action: "dependency_removed",
entityType: "task",
entityId: id,
changes: { removedDependsOnTaskId: depId },
workspaceId: task.domainId,
});
return c.body(null, 204);
} catch (error) {
if (error instanceof AuthError) {
return c.json({ error: { code: error.code, message: error.message } }, error.status as any);
}
console.error("[tasks] DELETE /:id/dependencies/:depId error:", error);
return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to remove dependency" } }, 500);
}
});
// POST /api/tasks/:id/status — Change task status (Kanban drag)
taskRoutes.post("/:id/status", async (c) => {
try {
@@ -813,92 +937,6 @@ taskRoutes.get("/:id/history", async (c) => {
}
});
// GET /api/tasks/:id/comments — Comment thread (stored in activity feed as entityType=comment)
taskRoutes.get("/:id/comments", async (c) => {
try {
const user = await requireAuth(c);
const id = c.req.param("id");
if (!isUuid(id)) {
return c.json({ error: { code: "NOT_FOUND", message: "Resource not found" } }, 404);
}
const [task] = await db.select({ id: tasks.id, domainId: tasks.domainId })
.from(tasks)
.where(and(eq(tasks.id, id), isNull(tasks.deletedAt)))
.limit(1);
if (!task) {
return c.json({ error: { code: "NOT_FOUND", message: "Task not found" } }, 404);
}
await requireWorkspaceAccess(c, task.domainId);
const comments = await db.select()
.from(activityFeed)
.where(and(
eq(activityFeed.entityId, id),
eq(activityFeed.entityType, "comment"),
eq(activityFeed.workspaceId, task.domainId),
))
.orderBy(asc(activityFeed.createdAt));
return c.json({ items: comments, totalItems: comments.length });
} catch (error) {
if (error instanceof AuthError) {
return c.json({ error: { code: error.code, message: error.message } }, error.status as any);
}
console.error("[tasks] GET /:id/comments error:", error);
return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to get comments" } }, 500);
}
});
// POST /api/tasks/:id/comments — Add a comment
taskRoutes.post("/:id/comments", async (c) => {
try {
const user = await requireAuth(c);
const id = c.req.param("id");
if (!isUuid(id)) {
return c.json({ error: { code: "NOT_FOUND", message: "Resource not found" } }, 404);
}
const body = await c.req.json();
const { content } = z.object({
content: z.string().min(1, "Content is required"),
}).parse(body);
// Verify task exists
const [task] = await db.select({ id: tasks.id, domainId: tasks.domainId })
.from(tasks)
.where(and(eq(tasks.id, id), isNull(tasks.deletedAt)))
.limit(1);
if (!task) {
return c.json({ error: { code: "NOT_FOUND", message: "Task not found" } }, 404);
}
await requireWorkspaceAccess(c, task.domainId);
await recordActivity({
actor: user.name,
action: "commented",
entityType: "comment",
entityId: id,
changes: { content },
workspaceId: task.domainId,
});
return c.json({ success: true }, 201);
} catch (error) {
if (error instanceof AuthError) {
return c.json({ error: { code: error.code, message: error.message } }, error.status as any);
}
if (error instanceof z.ZodError) {
return c.json({ error: { code: "VALIDATION_ERROR", message: "Invalid input", details: error.issues } }, 400);
}
console.error("[tasks] POST /:id/comments error:", error);
return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to add comment" } }, 500);
}
});
// GET /api/tasks/:id/attachments — File attachments metadata
taskRoutes.get("/:id/attachments", async (c) => {
try {