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
4
fork

Configure Feed

Select the types of activity you want to include in your feed.

at main 370 lines 10 kB view raw
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}