WIP! A BB-style forum, on the ATmosphere!
We're still working... we'll be back soon when we have something to show off!
node
typescript
hono
htmx
atproto
1import { forums, posts, boards, modActions } from "@atbb/db";
2import type { Database } from "@atbb/db";
3import type { Logger } from "@atbb/logger";
4import { eq, and, inArray, desc, count, max } from "drizzle-orm";
5import { parseAtUri } from "../../lib/at-uri.js";
6import type { PostRow } from "./serialize.js";
7
8/**
9 * Look up forum by AT-URI.
10 * Returns null if forum doesn't exist.
11 *
12 * @param db Database instance
13 * @param uri AT-URI like "at://did:plc:abc/space.atbb.forum.forum/self"
14 */
15export async function getForumByUri(
16 db: Database,
17 uri: string
18): Promise<{ did: string; rkey: string; cid: string } | null> {
19 const parsed = parseAtUri(uri);
20 if (!parsed) {
21 return null;
22 }
23
24 const { did, rkey } = parsed;
25
26 const [forum] = await db
27 .select({
28 did: forums.did,
29 rkey: forums.rkey,
30 cid: forums.cid,
31 })
32 .from(forums)
33 .where(and(eq(forums.did, did), eq(forums.rkey, rkey)))
34 .limit(1);
35
36 return forum ?? null;
37}
38
39/**
40 * Look up board by AT-URI.
41 * Returns null if board doesn't exist.
42 *
43 * @param db Database instance
44 * @param uri AT-URI like "at://did:plc:abc/space.atbb.forum.board/3lbk9board"
45 */
46export async function getBoardByUri(
47 db: Database,
48 uri: string
49): Promise<{ cid: string } | null> {
50 const parsed = parseAtUri(uri);
51 if (!parsed) {
52 return null;
53 }
54
55 const { did, rkey } = parsed;
56
57 const [board] = await db
58 .select({
59 cid: boards.cid,
60 })
61 .from(boards)
62 .where(and(eq(boards.did, did), eq(boards.rkey, rkey)))
63 .limit(1);
64
65 return board ?? null;
66}
67
68/**
69 * Look up multiple posts by ID in a single query.
70 * Excludes deleted posts.
71 * Returns a Map for O(1) lookup.
72 */
73export async function getPostsByIds(
74 db: Database,
75 ids: bigint[]
76): Promise<Map<bigint, PostRow>> {
77 if (ids.length === 0) {
78 return new Map();
79 }
80
81 const results = await db
82 .select()
83 .from(posts)
84 .where(and(inArray(posts.id, ids), eq(posts.bannedByMod, false)));
85
86 return new Map(results.map((post) => [post.id, post]));
87}
88
89/**
90 * Query active bans for a list of user DIDs.
91 * A user is banned if their most recent modAction is "ban" (not "unban").
92 *
93 * @param db Database instance
94 * @param dids Array of user DIDs to check
95 * @returns Set of banned DIDs (subset of input)
96 */
97export async function getActiveBans(
98 db: Database,
99 dids: string[],
100 logger?: Logger
101): Promise<Set<string>> {
102 if (dids.length === 0) {
103 return new Set();
104 }
105
106 try {
107 // Query ban/unban actions for these DIDs only (not other action types like mute)
108 // We need the most recent ban/unban action per DID to determine current state
109 const actions = await db
110 .select({
111 subjectDid: modActions.subjectDid,
112 action: modActions.action,
113 createdAt: modActions.createdAt,
114 })
115 .from(modActions)
116 .where(
117 and(
118 inArray(modActions.subjectDid, dids),
119 inArray(modActions.action, [
120 "space.atbb.modAction.ban",
121 "space.atbb.modAction.unban",
122 ])
123 )
124 )
125 .orderBy(desc(modActions.createdAt))
126 .limit(dids.length * 100); // Defensive limit: at most 100 actions per user
127
128 // Group by subjectDid and take most recent ban/unban action
129 const mostRecentByDid = new Map<string, string>();
130 for (const row of actions) {
131 if (row.subjectDid && !mostRecentByDid.has(row.subjectDid)) {
132 mostRecentByDid.set(row.subjectDid, row.action);
133 }
134 }
135
136 // A user is banned if most recent ban/unban action is "ban"
137 const banned = new Set<string>();
138 for (const [did, action] of mostRecentByDid) {
139 if (action === "space.atbb.modAction.ban") {
140 banned.add(did);
141 }
142 }
143
144 return banned;
145 } catch (error) {
146 logger?.error("Failed to query active bans", {
147 operation: "getActiveBans",
148 didCount: dids.length,
149 error: error instanceof Error ? error.message : String(error),
150 });
151 throw error; // Let caller decide fail policy
152 }
153}
154
155/**
156 * Query moderation status for a topic (lock/pin).
157 *
158 * @param db Database instance
159 * @param topicId Internal post ID of the topic (root post)
160 * @returns { locked: boolean, pinned: boolean }
161 */
162export async function getTopicModStatus(
163 db: Database,
164 topicId: bigint,
165 logger?: Logger
166): Promise<{ locked: boolean; pinned: boolean }> {
167 try {
168 // Look up the topic to get its AT-URI
169 const [topic] = await db
170 .select({
171 did: posts.did,
172 rkey: posts.rkey,
173 })
174 .from(posts)
175 .where(eq(posts.id, topicId))
176 .limit(1);
177
178 if (!topic) {
179 return { locked: false, pinned: false };
180 }
181
182 const topicUri = `at://${topic.did}/space.atbb.post/${topic.rkey}`;
183
184 // Query only lock/unlock/pin/unpin actions for this topic URI
185 const actions = await db
186 .select({
187 action: modActions.action,
188 createdAt: modActions.createdAt,
189 })
190 .from(modActions)
191 .where(
192 and(
193 eq(modActions.subjectPostUri, topicUri),
194 inArray(modActions.action, [
195 "space.atbb.modAction.lock",
196 "space.atbb.modAction.unlock",
197 "space.atbb.modAction.pin",
198 "space.atbb.modAction.unpin",
199 ])
200 )
201 )
202 .orderBy(desc(modActions.createdAt))
203 .limit(100);
204
205 if (actions.length === 0) {
206 return { locked: false, pinned: false };
207 }
208
209 // Lock and pin are independent states - check most recent action for each
210 // Find most recent lock/unlock action
211 const mostRecentLockAction = actions.find(
212 (a) =>
213 a.action === "space.atbb.modAction.lock" ||
214 a.action === "space.atbb.modAction.unlock"
215 );
216
217 // Find most recent pin/unpin action
218 const mostRecentPinAction = actions.find(
219 (a) =>
220 a.action === "space.atbb.modAction.pin" ||
221 a.action === "space.atbb.modAction.unpin"
222 );
223
224 return {
225 locked:
226 mostRecentLockAction?.action === "space.atbb.modAction.lock" || false,
227 pinned:
228 mostRecentPinAction?.action === "space.atbb.modAction.pin" || false,
229 };
230 } catch (error) {
231 logger?.error("Failed to query topic moderation status", {
232 operation: "getTopicModStatus",
233 topicId: topicId.toString(),
234 error: error instanceof Error ? error.message : String(error),
235 });
236 throw error; // Let caller decide fail policy
237 }
238}
239
240/**
241 * Query reply counts and last-reply timestamps for a list of topic post IDs.
242 * Only non-moderated replies (bannedByMod = false) are counted.
243 * Returns a Map from topic ID to { replyCount, lastReplyAt }.
244 */
245export async function getReplyStats(
246 db: Database,
247 topicIds: bigint[]
248): Promise<Map<bigint, { replyCount: number; lastReplyAt: Date | null }>> {
249 if (topicIds.length === 0) {
250 return new Map();
251 }
252
253 const rows = await db
254 .select({
255 rootPostId: posts.rootPostId,
256 replyCount: count(),
257 lastReplyAt: max(posts.createdAt),
258 })
259 .from(posts)
260 .where(
261 and(
262 inArray(posts.rootPostId, topicIds),
263 eq(posts.bannedByMod, false)
264 )
265 )
266 .groupBy(posts.rootPostId);
267
268 const result = new Map<bigint, { replyCount: number; lastReplyAt: Date | null }>();
269 for (const row of rows) {
270 if (row.rootPostId !== null) {
271 result.set(row.rootPostId, {
272 replyCount: Number(row.replyCount),
273 lastReplyAt: row.lastReplyAt ?? null,
274 });
275 }
276 }
277 return result;
278}
279
280/**
281 * Query which posts in a list are currently hidden by moderator action.
282 * A post is hidden if its most recent modAction is "delete" (not "undelete").
283 *
284 * @param db Database instance
285 * @param postIds Array of post IDs to check
286 * @returns Set of hidden post IDs (subset of input)
287 */
288export async function getHiddenPosts(
289 db: Database,
290 postIds: bigint[],
291 logger?: Logger
292): Promise<Set<bigint>> {
293 if (postIds.length === 0) {
294 return new Set();
295 }
296
297 try {
298 // Look up URIs for these post IDs
299 const postRecords = await db
300 .select({
301 id: posts.id,
302 did: posts.did,
303 rkey: posts.rkey,
304 })
305 .from(posts)
306 .where(inArray(posts.id, postIds))
307 .limit(1000); // Prevent memory exhaustion
308
309 if (postRecords.length === 0) {
310 return new Set();
311 }
312
313 // Build URI->ID mapping
314 const uriToId = new Map<string, bigint>();
315 const uris: string[] = [];
316 for (const post of postRecords) {
317 const uri = `at://${post.did}/space.atbb.post/${post.rkey}`;
318 uriToId.set(uri, post.id);
319 uris.push(uri);
320 }
321
322 // Query only delete/undelete actions for these URIs
323 const actions = await db
324 .select({
325 subjectPostUri: modActions.subjectPostUri,
326 action: modActions.action,
327 createdAt: modActions.createdAt,
328 })
329 .from(modActions)
330 .where(
331 and(
332 inArray(modActions.subjectPostUri, uris),
333 inArray(modActions.action, [
334 "space.atbb.modAction.delete",
335 "space.atbb.modAction.undelete",
336 ])
337 )
338 )
339 .orderBy(desc(modActions.createdAt))
340 .limit(uris.length * 10); // At most 10 delete/undelete actions per post
341
342 // Group by URI and take most recent
343 const mostRecentByUri = new Map<string, string>();
344 for (const row of actions) {
345 if (row.subjectPostUri && !mostRecentByUri.has(row.subjectPostUri)) {
346 mostRecentByUri.set(row.subjectPostUri, row.action);
347 }
348 }
349
350 // A post is hidden if most recent delete/undelete action is "delete"
351 const hidden = new Set<bigint>();
352 for (const [uri, action] of mostRecentByUri) {
353 if (action === "space.atbb.modAction.delete") {
354 const postId = uriToId.get(uri);
355 if (postId !== undefined) {
356 hidden.add(postId);
357 }
358 }
359 }
360
361 return hidden;
362 } catch (error) {
363 logger?.error("Failed to query hidden posts", {
364 operation: "getHiddenPosts",
365 postIdCount: postIds.length,
366 error: error instanceof Error ? error.message : String(error),
367 });
368 throw error; // Let caller decide fail policy
369 }
370}