221 lines
7.6 KiB
TypeScript
221 lines
7.6 KiB
TypeScript
import { Hono } from "hono";
|
|
import {
|
|
db,
|
|
comments,
|
|
tasks,
|
|
projects,
|
|
habits,
|
|
notes,
|
|
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", "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 },
|
|
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);
|
|
}
|
|
});
|