Exosphere is a set of small, modular, self-hostable community tools built on the AT Protocol.
app.exosphere.site
1import { Hono } from "hono";
2import { z } from "zod";
3import { getDb } from "@exosphere/core/db";
4import { eq, and, count, sql, inArray, desc, asc } from "@exosphere/core/db/drizzle";
5import { requireAuth, type AuthEnv } from "@exosphere/core/auth";
6import { getActiveMemberRole } from "@exosphere/core/sphere";
7import { requirePermission, checkPermission } from "@exosphere/core/permissions";
8import { putPdsRecord, deletePdsRecord, generateRkey, tidToDate } from "@exosphere/core/pds";
9import { resolveDidHandles } from "@exosphere/core/identity";
10import type { SphereEnv } from "@exosphere/core/types";
11import {
12 featureRequests,
13 featureRequestComments,
14 featureRequestCommentVotes,
15 featureRequestStatuses,
16} from "../db/schema.ts";
17import { createCommentSchema, updateCommentSchema } from "../schemas/comment.ts";
18import {
19 insertComment,
20 updateComment,
21 deleteCommentCascade,
22 insertCommentVote,
23 deleteCommentVoteByAuthor,
24 hideComment,
25 unhideComment,
26} from "../db/operations.ts";
27
28const COMMENT_COLLECTION = "site.exosphere.featureRequest.comment";
29const COMMENT_VOTE_COLLECTION = "site.exosphere.featureRequest.commentVote";
30const MODERATION_COLLECTION = "site.exosphere.moderation";
31
32const app = new Hono<AuthEnv & SphereEnv>();
33
34/** Verify a comment belongs to the current sphere by joining through its parent FR. */
35function findCommentInSphere(commentId: string, sphereId: string) {
36 return getDb()
37 .select()
38 .from(featureRequestComments)
39 .innerJoin(featureRequests, eq(featureRequests.id, featureRequestComments.requestId))
40 .where(and(eq(featureRequestComments.id, commentId), eq(featureRequests.sphereId, sphereId)))
41 .get();
42}
43
44// ---- Comments ----
45
46// List comments for a feature request
47app.get("/:id/comments", async (c) => {
48 const requestId = c.req.param("id");
49 const sphereId = c.var.sphereId;
50 const sortParam = c.req.query("sort");
51 const orderParam = c.req.query("order");
52 const sortBy = sortParam === "votes" ? "votes" : "date";
53 const orderDir = orderParam === "asc" ? asc : desc;
54 const db = getDb();
55
56 // Verify the FR belongs to this sphere
57 const request = db
58 .select({ status: featureRequests.status })
59 .from(featureRequests)
60 .where(and(eq(featureRequests.id, requestId), eq(featureRequests.sphereId, sphereId)))
61 .get();
62
63 if (!request) {
64 return c.json({ comments: [] });
65 }
66
67 // When the request is closed (done/not-planned), hide comments posted after the closing date
68 let cutoffId: string | null = null;
69 if (
70 request.status === "done" ||
71 request.status === "not-planned" ||
72 request.status === "duplicate"
73 ) {
74 const closedStatus = db
75 .select({ id: featureRequestStatuses.id })
76 .from(featureRequestStatuses)
77 .where(
78 and(
79 eq(featureRequestStatuses.requestId, requestId),
80 inArray(featureRequestStatuses.status, ["done", "not-planned", "duplicate"]),
81 ),
82 )
83 .orderBy(sql`${featureRequestStatuses.id} desc`)
84 .limit(1)
85 .get();
86 cutoffId = closedStatus?.id ?? null;
87 }
88
89 const notHidden = sql`${featureRequestComments.hiddenAt} is null`;
90 const whereCondition = cutoffId
91 ? and(
92 eq(featureRequestComments.requestId, requestId),
93 sql`${featureRequestComments.id} <= ${cutoffId}`,
94 notHidden,
95 )
96 : and(eq(featureRequestComments.requestId, requestId), notHidden);
97
98 const voteCountCol = count(featureRequestCommentVotes.authorDid);
99 const rows = db
100 .select({
101 id: featureRequestComments.id,
102 requestId: featureRequestComments.requestId,
103 authorDid: featureRequestComments.authorDid,
104 content: featureRequestComments.content,
105 pdsUri: featureRequestComments.pdsUri,
106 updatedAt: featureRequestComments.updatedAt,
107 voteCount: voteCountCol,
108 })
109 .from(featureRequestComments)
110 .leftJoin(
111 featureRequestCommentVotes,
112 eq(featureRequestCommentVotes.commentId, featureRequestComments.id),
113 )
114 .where(whereCondition)
115 .groupBy(featureRequestComments.id)
116 .orderBy(sortBy === "votes" ? orderDir(voteCountCol) : orderDir(featureRequestComments.id))
117 .all();
118
119 const handleMap = await resolveDidHandles(rows.map((r) => r.authorDid));
120 const comments = rows.map((r) => ({
121 ...r,
122 createdAt: tidToDate(r.id),
123 authorHandle: handleMap.get(r.authorDid) ?? null,
124 }));
125
126 return c.json({ comments });
127});
128
129// Create a comment (one top-level comment per user per request)
130app.post(
131 "/:id/comments",
132 requireAuth,
133 requirePermission("feature-requests", "comment"),
134 async (c) => {
135 const requestId = c.req.param("id");
136 const body = await c.req.json();
137 const result = createCommentSchema.safeParse(body);
138 if (!result.success) {
139 return c.json({ error: z.flattenError(result.error) }, 400);
140 }
141
142 const { content } = result.data;
143 const sphereId = c.var.sphereId;
144 const sphereVisibility = c.var.sphereVisibility;
145 const db = getDb();
146 const did = c.var.did;
147
148 // Check feature request exists, belongs to this sphere, and is visible
149 const existing = db
150 .select({ id: featureRequests.id, pdsUri: featureRequests.pdsUri })
151 .from(featureRequests)
152 .where(
153 and(
154 eq(featureRequests.id, requestId),
155 eq(featureRequests.sphereId, sphereId),
156 sql`${featureRequests.hiddenAt} is null`,
157 ),
158 )
159 .get();
160 if (!existing) {
161 return c.json({ error: "Feature request not found" }, 404);
162 }
163
164 // Enforce one top-level comment per user per request (ignore moderated comments)
165 const alreadyCommented = db
166 .select({ id: featureRequestComments.id })
167 .from(featureRequestComments)
168 .where(
169 and(
170 eq(featureRequestComments.requestId, requestId),
171 eq(featureRequestComments.authorDid, did),
172 sql`${featureRequestComments.hiddenAt} is null`,
173 ),
174 )
175 .get();
176 if (alreadyCommented) {
177 return c.json({ error: "You have already commented on this request" }, 409);
178 }
179
180 const id = generateRkey();
181 let pdsUri: string | null = null;
182
183 // Write to PDS for public spheres
184 if (sphereVisibility === "public") {
185 const session = c.var.session;
186 pdsUri = await putPdsRecord(session, COMMENT_COLLECTION, id, {
187 subject: existing.pdsUri!,
188 content,
189 });
190 }
191
192 insertComment({ id, requestId, authorDid: did, content, pdsUri });
193
194 const comment = db
195 .select()
196 .from(featureRequestComments)
197 .where(eq(featureRequestComments.id, id))
198 .get();
199
200 return c.json({ comment: comment ? { ...comment, createdAt: tidToDate(id) } : comment }, 201);
201 },
202);
203
204// Update own comment (author only)
205app.put("/comments/:id", requireAuth, async (c) => {
206 const id = c.req.param("id");
207 const body = await c.req.json();
208 const result = updateCommentSchema.safeParse(body);
209 if (!result.success) {
210 return c.json({ error: z.flattenError(result.error) }, 400);
211 }
212
213 const { content } = result.data;
214 const sphereId = c.var.sphereId;
215 const db = getDb();
216 const did = c.var.did;
217
218 const row = findCommentInSphere(id, sphereId);
219 if (!row) {
220 return c.json({ error: "Comment not found" }, 404);
221 }
222 const comment = row.feature_request_comments;
223 if (comment.authorDid !== did) {
224 return c.json({ error: "Forbidden" }, 403);
225 }
226
227 // Update on PDS if the comment was written there
228 if (comment.pdsUri) {
229 const session = c.var.session;
230
231 // Look up the parent feature request's pdsUri for the subject field
232 const parent = db
233 .select({ pdsUri: featureRequests.pdsUri })
234 .from(featureRequests)
235 .where(eq(featureRequests.id, comment.requestId))
236 .get();
237
238 if (!parent?.pdsUri) {
239 return c.json({ error: "Parent feature request not found" }, 404);
240 }
241
242 await putPdsRecord(session, COMMENT_COLLECTION, id, {
243 subject: parent.pdsUri,
244 content,
245 updatedAt: new Date().toISOString(),
246 });
247 }
248
249 updateComment(id, content);
250
251 const updated = db
252 .select()
253 .from(featureRequestComments)
254 .where(eq(featureRequestComments.id, id))
255 .get();
256
257 return c.json({ comment: updated ? { ...updated, createdAt: tidToDate(id) } : updated });
258});
259
260// Delete own comment or admin-moderate a comment
261app.delete("/comments/:id", requireAuth, async (c) => {
262 const id = c.req.param("id");
263 const db = getDb();
264 const did = c.var.did;
265 const sphereId = c.var.sphereId;
266 const sphereOwnerDid = c.var.sphereOwnerDid;
267 const spherePdsUri = c.var.spherePdsUri;
268
269 const row = findCommentInSphere(id, sphereId);
270 if (!row) {
271 return c.json({ error: "Comment not found" }, 404);
272 }
273 const comment = row.feature_request_comments;
274
275 const isAuthor = comment.authorDid === did;
276
277 if (!isAuthor) {
278 // Only users with moderate permission can hide other users' comments
279 const role = getActiveMemberRole(sphereId, did);
280 if (!checkPermission(sphereId, "feature-requests", "moderate", role)) {
281 return c.json({ error: "Forbidden" }, 403);
282 }
283
284 // Already moderated — no-op
285 if (comment.hiddenAt) {
286 return c.json({ ok: true });
287 }
288
289 // Admin moderation — publish moderation record on admin's PDS, hide locally
290 if (comment.pdsUri) {
291 const sphereUri = spherePdsUri ?? `at://${sphereOwnerDid}/site.exosphere.sphere.profile/self`;
292 const session = c.var.session;
293 const pdsUri = await putPdsRecord(session, MODERATION_COLLECTION, id, {
294 sphere: sphereUri,
295 subject: comment.pdsUri,
296 action: "remove",
297 });
298 if (!pdsUri) {
299 console.warn(
300 "[feature-requests] Moderation PDS record failed for comment %s — hiding locally only",
301 id,
302 );
303 }
304 }
305
306 hideComment(id, did);
307
308 return c.json({ ok: true });
309 }
310
311 // Author deleting their own comment — remove from their PDS + delete from DB
312 if (comment.pdsUri) {
313 const session = c.var.session;
314 const res = await session.fetchHandler("/xrpc/com.atproto.repo.deleteRecord", {
315 method: "POST",
316 headers: { "Content-Type": "application/json" },
317 body: JSON.stringify({
318 repo: session.did,
319 collection: COMMENT_COLLECTION,
320 rkey: id,
321 }),
322 });
323
324 if (!res.ok) {
325 console.error(
326 "[feature-requests] PDS comment delete failed:",
327 res.status,
328 await res.text().catch(() => ""),
329 );
330 }
331 }
332
333 deleteCommentCascade(id);
334
335 return c.json({ ok: true });
336});
337
338// Admin/owner-only: unhide a moderated comment
339app.post(
340 "/comments/:id/unhide",
341 requireAuth,
342 requirePermission("feature-requests", "moderate"),
343 async (c) => {
344 const id = c.req.param("id");
345 const sphereId = c.var.sphereId;
346 const did = c.var.did;
347
348 const row = findCommentInSphere(id, sphereId);
349 if (!row) {
350 return c.json({ error: "Comment not found" }, 404);
351 }
352 const comment = row.feature_request_comments;
353 if (!comment.hiddenAt) {
354 return c.json({ ok: true });
355 }
356
357 // Delete the moderation record from PDS if the current admin is the one who hid it
358 if (comment.moderatedBy === did) {
359 const session = c.var.session;
360 await deletePdsRecord(session, MODERATION_COLLECTION, id);
361 }
362
363 unhideComment(id);
364
365 return c.json({ ok: true });
366 },
367);
368
369// ---- Comment Votes ----
370
371// Get current user's comment votes for this sphere (list of comment IDs)
372app.get("/comments/votes", requireAuth, (c) => {
373 const db = getDb();
374 const sphereId = c.var.sphereId;
375 const rows = db
376 .select({ commentId: featureRequestCommentVotes.commentId })
377 .from(featureRequestCommentVotes)
378 .innerJoin(
379 featureRequestComments,
380 eq(featureRequestComments.id, featureRequestCommentVotes.commentId),
381 )
382 .innerJoin(featureRequests, eq(featureRequests.id, featureRequestComments.requestId))
383 .where(
384 and(
385 eq(featureRequestCommentVotes.authorDid, c.var.did),
386 eq(featureRequests.sphereId, sphereId),
387 ),
388 )
389 .all();
390 return c.json({ votes: rows.map((r) => r.commentId) });
391});
392
393// Cast a vote on a comment
394app.post(
395 "/comments/:id/vote",
396 requireAuth,
397 requirePermission("feature-requests", "vote"),
398 async (c) => {
399 const id = c.req.param("id");
400 const db = getDb();
401 const did = c.var.did;
402 const sphereId = c.var.sphereId;
403 const sphereVisibility = c.var.sphereVisibility;
404
405 const row = findCommentInSphere(id, sphereId);
406 if (!row) {
407 return c.json({ error: "Comment not found" }, 404);
408 }
409 const comment = row.feature_request_comments;
410
411 const alreadyVoted = db
412 .select({ commentId: featureRequestCommentVotes.commentId })
413 .from(featureRequestCommentVotes)
414 .where(
415 and(
416 eq(featureRequestCommentVotes.commentId, id),
417 eq(featureRequestCommentVotes.authorDid, did),
418 ),
419 )
420 .get();
421 if (alreadyVoted) {
422 return c.json({ error: "Already voted" }, 409);
423 }
424
425 let votePdsUri: string | null = null;
426
427 if (sphereVisibility === "public" && comment.pdsUri) {
428 const session = c.var.session;
429 votePdsUri = await putPdsRecord(session, COMMENT_VOTE_COLLECTION, id, {
430 subject: comment.pdsUri,
431 });
432 }
433
434 insertCommentVote(id, did, votePdsUri);
435
436 const result = db
437 .select({ voteCount: count() })
438 .from(featureRequestCommentVotes)
439 .where(eq(featureRequestCommentVotes.commentId, id))
440 .get();
441
442 return c.json({ voteCount: result?.voteCount ?? 0 }, 201);
443 },
444);
445
446// Remove a vote from a comment
447app.delete(
448 "/comments/:id/vote",
449 requireAuth,
450 requirePermission("feature-requests", "vote"),
451 async (c) => {
452 const id = c.req.param("id");
453 const db = getDb();
454 const did = c.var.did;
455 const sphereId = c.var.sphereId;
456
457 // Verify the comment's FR belongs to this sphere
458 const row = findCommentInSphere(id, sphereId);
459 if (!row) {
460 return c.json({ error: "Comment not found" }, 404);
461 }
462
463 const vote = db
464 .select({ pdsUri: featureRequestCommentVotes.pdsUri })
465 .from(featureRequestCommentVotes)
466 .where(
467 and(
468 eq(featureRequestCommentVotes.commentId, id),
469 eq(featureRequestCommentVotes.authorDid, did),
470 ),
471 )
472 .get();
473 if (!vote) {
474 return c.json({ error: "Vote not found" }, 404);
475 }
476
477 if (vote.pdsUri) {
478 const session = c.var.session;
479 const res = await session.fetchHandler("/xrpc/com.atproto.repo.deleteRecord", {
480 method: "POST",
481 headers: { "Content-Type": "application/json" },
482 body: JSON.stringify({
483 repo: session.did,
484 collection: COMMENT_VOTE_COLLECTION,
485 rkey: id,
486 }),
487 });
488
489 if (!res.ok) {
490 console.error(
491 "[feature-requests] PDS comment vote delete failed:",
492 res.status,
493 await res.text().catch(() => ""),
494 );
495 }
496 }
497
498 deleteCommentVoteByAuthor(id, did);
499
500 const result = db
501 .select({ voteCount: count() })
502 .from(featureRequestCommentVotes)
503 .where(eq(featureRequestCommentVotes.commentId, id))
504 .get();
505
506 return c.json({ voteCount: result?.voteCount ?? 0 });
507 },
508);
509
510export { app as commentsApi };