import { Hono } from "hono"; import { z } from "zod"; import { getDb } from "@exosphere/core/db"; import { eq, and, count, sql, inArray, desc, asc } from "@exosphere/core/db/drizzle"; import { requireAuth, type AuthEnv } from "@exosphere/core/auth"; import { getActiveMemberRole } from "@exosphere/core/sphere"; import { requirePermission, checkPermission } from "@exosphere/core/permissions"; import { putPdsRecord, deletePdsRecord, generateRkey, tidToDate } from "@exosphere/core/pds"; import { resolveDidHandles } from "@exosphere/core/identity"; import type { SphereEnv } from "@exosphere/core/types"; import { featureRequests, featureRequestComments, featureRequestCommentVotes, featureRequestStatuses, } from "../db/schema.ts"; import { createCommentSchema, updateCommentSchema } from "../schemas/comment.ts"; import { insertComment, updateComment, deleteCommentCascade, insertCommentVote, deleteCommentVoteByAuthor, hideComment, unhideComment, } from "../db/operations.ts"; const COMMENT_COLLECTION = "site.exosphere.featureRequest.comment"; const COMMENT_VOTE_COLLECTION = "site.exosphere.featureRequest.commentVote"; const MODERATION_COLLECTION = "site.exosphere.moderation"; const app = new Hono(); /** Verify a comment belongs to the current sphere by joining through its parent FR. */ function findCommentInSphere(commentId: string, sphereId: string) { return getDb() .select() .from(featureRequestComments) .innerJoin(featureRequests, eq(featureRequests.id, featureRequestComments.requestId)) .where(and(eq(featureRequestComments.id, commentId), eq(featureRequests.sphereId, sphereId))) .get(); } // ---- Comments ---- // List comments for a feature request app.get("/:id/comments", async (c) => { const requestId = c.req.param("id"); const sphereId = c.var.sphereId; const sortParam = c.req.query("sort"); const orderParam = c.req.query("order"); const sortBy = sortParam === "votes" ? "votes" : "date"; const orderDir = orderParam === "asc" ? asc : desc; const db = getDb(); // Verify the FR belongs to this sphere const request = db .select({ status: featureRequests.status }) .from(featureRequests) .where(and(eq(featureRequests.id, requestId), eq(featureRequests.sphereId, sphereId))) .get(); if (!request) { return c.json({ comments: [] }); } // When the request is closed (done/not-planned), hide comments posted after the closing date let cutoffId: string | null = null; if ( request.status === "done" || request.status === "not-planned" || request.status === "duplicate" ) { const closedStatus = db .select({ id: featureRequestStatuses.id }) .from(featureRequestStatuses) .where( and( eq(featureRequestStatuses.requestId, requestId), inArray(featureRequestStatuses.status, ["done", "not-planned", "duplicate"]), ), ) .orderBy(sql`${featureRequestStatuses.id} desc`) .limit(1) .get(); cutoffId = closedStatus?.id ?? null; } const notHidden = sql`${featureRequestComments.hiddenAt} is null`; const whereCondition = cutoffId ? and( eq(featureRequestComments.requestId, requestId), sql`${featureRequestComments.id} <= ${cutoffId}`, notHidden, ) : and(eq(featureRequestComments.requestId, requestId), notHidden); const voteCountCol = count(featureRequestCommentVotes.authorDid); const rows = db .select({ id: featureRequestComments.id, requestId: featureRequestComments.requestId, authorDid: featureRequestComments.authorDid, content: featureRequestComments.content, pdsUri: featureRequestComments.pdsUri, updatedAt: featureRequestComments.updatedAt, voteCount: voteCountCol, }) .from(featureRequestComments) .leftJoin( featureRequestCommentVotes, eq(featureRequestCommentVotes.commentId, featureRequestComments.id), ) .where(whereCondition) .groupBy(featureRequestComments.id) .orderBy(sortBy === "votes" ? orderDir(voteCountCol) : orderDir(featureRequestComments.id)) .all(); const handleMap = await resolveDidHandles(rows.map((r) => r.authorDid)); const comments = rows.map((r) => ({ ...r, createdAt: tidToDate(r.id), authorHandle: handleMap.get(r.authorDid) ?? null, })); return c.json({ comments }); }); // Create a comment (one top-level comment per user per request) app.post( "/:id/comments", requireAuth, requirePermission("feature-requests", "comment"), async (c) => { const requestId = c.req.param("id"); const body = await c.req.json(); const result = createCommentSchema.safeParse(body); if (!result.success) { return c.json({ error: z.flattenError(result.error) }, 400); } const { content } = result.data; const sphereId = c.var.sphereId; const sphereVisibility = c.var.sphereVisibility; const db = getDb(); const did = c.var.did; // Check feature request exists, belongs to this sphere, and is visible const existing = db .select({ id: featureRequests.id, pdsUri: featureRequests.pdsUri }) .from(featureRequests) .where( and( eq(featureRequests.id, requestId), eq(featureRequests.sphereId, sphereId), sql`${featureRequests.hiddenAt} is null`, ), ) .get(); if (!existing) { return c.json({ error: "Feature request not found" }, 404); } // Enforce one top-level comment per user per request (ignore moderated comments) const alreadyCommented = db .select({ id: featureRequestComments.id }) .from(featureRequestComments) .where( and( eq(featureRequestComments.requestId, requestId), eq(featureRequestComments.authorDid, did), sql`${featureRequestComments.hiddenAt} is null`, ), ) .get(); if (alreadyCommented) { return c.json({ error: "You have already commented on this request" }, 409); } const id = generateRkey(); let pdsUri: string | null = null; // Write to PDS for public spheres if (sphereVisibility === "public") { const session = c.var.session; pdsUri = await putPdsRecord(session, COMMENT_COLLECTION, id, { subject: existing.pdsUri!, content, }); } insertComment({ id, requestId, authorDid: did, content, pdsUri }); const comment = db .select() .from(featureRequestComments) .where(eq(featureRequestComments.id, id)) .get(); return c.json({ comment: comment ? { ...comment, createdAt: tidToDate(id) } : comment }, 201); }, ); // Update own comment (author only) app.put("/comments/:id", requireAuth, async (c) => { const id = c.req.param("id"); const body = await c.req.json(); const result = updateCommentSchema.safeParse(body); if (!result.success) { return c.json({ error: z.flattenError(result.error) }, 400); } const { content } = result.data; const sphereId = c.var.sphereId; const db = getDb(); const did = c.var.did; const row = findCommentInSphere(id, sphereId); if (!row) { return c.json({ error: "Comment not found" }, 404); } const comment = row.feature_request_comments; if (comment.authorDid !== did) { return c.json({ error: "Forbidden" }, 403); } // Update on PDS if the comment was written there if (comment.pdsUri) { const session = c.var.session; // Look up the parent feature request's pdsUri for the subject field const parent = db .select({ pdsUri: featureRequests.pdsUri }) .from(featureRequests) .where(eq(featureRequests.id, comment.requestId)) .get(); if (!parent?.pdsUri) { return c.json({ error: "Parent feature request not found" }, 404); } await putPdsRecord(session, COMMENT_COLLECTION, id, { subject: parent.pdsUri, content, updatedAt: new Date().toISOString(), }); } updateComment(id, content); const updated = db .select() .from(featureRequestComments) .where(eq(featureRequestComments.id, id)) .get(); return c.json({ comment: updated ? { ...updated, createdAt: tidToDate(id) } : updated }); }); // Delete own comment or admin-moderate a comment app.delete("/comments/:id", requireAuth, async (c) => { const id = c.req.param("id"); const db = getDb(); const did = c.var.did; const sphereId = c.var.sphereId; const sphereOwnerDid = c.var.sphereOwnerDid; const spherePdsUri = c.var.spherePdsUri; const row = findCommentInSphere(id, sphereId); if (!row) { return c.json({ error: "Comment not found" }, 404); } const comment = row.feature_request_comments; const isAuthor = comment.authorDid === did; if (!isAuthor) { // Only users with moderate permission can hide other users' comments const role = getActiveMemberRole(sphereId, did); if (!checkPermission(sphereId, "feature-requests", "moderate", role)) { return c.json({ error: "Forbidden" }, 403); } // Already moderated — no-op if (comment.hiddenAt) { return c.json({ ok: true }); } // Admin moderation — publish moderation record on admin's PDS, hide locally if (comment.pdsUri) { const sphereUri = spherePdsUri ?? `at://${sphereOwnerDid}/site.exosphere.sphere.profile/self`; const session = c.var.session; const pdsUri = await putPdsRecord(session, MODERATION_COLLECTION, id, { sphere: sphereUri, subject: comment.pdsUri, action: "remove", }); if (!pdsUri) { console.warn( "[feature-requests] Moderation PDS record failed for comment %s — hiding locally only", id, ); } } hideComment(id, did); return c.json({ ok: true }); } // Author deleting their own comment — remove from their PDS + delete from DB if (comment.pdsUri) { const session = c.var.session; const res = await session.fetchHandler("/xrpc/com.atproto.repo.deleteRecord", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ repo: session.did, collection: COMMENT_COLLECTION, rkey: id, }), }); if (!res.ok) { console.error( "[feature-requests] PDS comment delete failed:", res.status, await res.text().catch(() => ""), ); } } deleteCommentCascade(id); return c.json({ ok: true }); }); // Admin/owner-only: unhide a moderated comment app.post( "/comments/:id/unhide", requireAuth, requirePermission("feature-requests", "moderate"), async (c) => { const id = c.req.param("id"); const sphereId = c.var.sphereId; const did = c.var.did; const row = findCommentInSphere(id, sphereId); if (!row) { return c.json({ error: "Comment not found" }, 404); } const comment = row.feature_request_comments; if (!comment.hiddenAt) { return c.json({ ok: true }); } // Delete the moderation record from PDS if the current admin is the one who hid it if (comment.moderatedBy === did) { const session = c.var.session; await deletePdsRecord(session, MODERATION_COLLECTION, id); } unhideComment(id); return c.json({ ok: true }); }, ); // ---- Comment Votes ---- // Get current user's comment votes for this sphere (list of comment IDs) app.get("/comments/votes", requireAuth, (c) => { const db = getDb(); const sphereId = c.var.sphereId; const rows = db .select({ commentId: featureRequestCommentVotes.commentId }) .from(featureRequestCommentVotes) .innerJoin( featureRequestComments, eq(featureRequestComments.id, featureRequestCommentVotes.commentId), ) .innerJoin(featureRequests, eq(featureRequests.id, featureRequestComments.requestId)) .where( and( eq(featureRequestCommentVotes.authorDid, c.var.did), eq(featureRequests.sphereId, sphereId), ), ) .all(); return c.json({ votes: rows.map((r) => r.commentId) }); }); // Cast a vote on a comment app.post( "/comments/:id/vote", requireAuth, requirePermission("feature-requests", "vote"), async (c) => { const id = c.req.param("id"); const db = getDb(); const did = c.var.did; const sphereId = c.var.sphereId; const sphereVisibility = c.var.sphereVisibility; const row = findCommentInSphere(id, sphereId); if (!row) { return c.json({ error: "Comment not found" }, 404); } const comment = row.feature_request_comments; const alreadyVoted = db .select({ commentId: featureRequestCommentVotes.commentId }) .from(featureRequestCommentVotes) .where( and( eq(featureRequestCommentVotes.commentId, id), eq(featureRequestCommentVotes.authorDid, did), ), ) .get(); if (alreadyVoted) { return c.json({ error: "Already voted" }, 409); } let votePdsUri: string | null = null; if (sphereVisibility === "public" && comment.pdsUri) { const session = c.var.session; votePdsUri = await putPdsRecord(session, COMMENT_VOTE_COLLECTION, id, { subject: comment.pdsUri, }); } insertCommentVote(id, did, votePdsUri); const result = db .select({ voteCount: count() }) .from(featureRequestCommentVotes) .where(eq(featureRequestCommentVotes.commentId, id)) .get(); return c.json({ voteCount: result?.voteCount ?? 0 }, 201); }, ); // Remove a vote from a comment app.delete( "/comments/:id/vote", requireAuth, requirePermission("feature-requests", "vote"), async (c) => { const id = c.req.param("id"); const db = getDb(); const did = c.var.did; const sphereId = c.var.sphereId; // Verify the comment's FR belongs to this sphere const row = findCommentInSphere(id, sphereId); if (!row) { return c.json({ error: "Comment not found" }, 404); } const vote = db .select({ pdsUri: featureRequestCommentVotes.pdsUri }) .from(featureRequestCommentVotes) .where( and( eq(featureRequestCommentVotes.commentId, id), eq(featureRequestCommentVotes.authorDid, did), ), ) .get(); if (!vote) { return c.json({ error: "Vote not found" }, 404); } if (vote.pdsUri) { const session = c.var.session; const res = await session.fetchHandler("/xrpc/com.atproto.repo.deleteRecord", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ repo: session.did, collection: COMMENT_VOTE_COLLECTION, rkey: id, }), }); if (!res.ok) { console.error( "[feature-requests] PDS comment vote delete failed:", res.status, await res.text().catch(() => ""), ); } } deleteCommentVoteByAuthor(id, did); const result = db .select({ voteCount: count() }) .from(featureRequestCommentVotes) .where(eq(featureRequestCommentVotes.commentId, id)) .get(); return c.json({ voteCount: result?.voteCount ?? 0 }); }, ); export { app as commentsApi };