feat: add threaded comments, activity feeds, and task dependencies
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user