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

refactor: split routes/helpers.ts into focused modules (#89)

Break the 675-line monolithic helpers file into three focused modules:
- helpers/serialize.ts — serialization functions and DB row type aliases
- helpers/validate.ts — input validation and parameter parsing
- helpers/queries.ts — database query helpers (bans, mod status, etc.)

helpers.ts becomes a barrel re-export, so zero consumer changes needed.
This reduces merge conflicts since team members working on admin routes
won't collide with changes to serialization or query helpers.

Also fix admin modlog route: replace drizzle-orm aliased self-joins
(which generate invalid SQL for SQLite) with a batch handle lookup.
This fixes 9 pre-existing test failures in the modlog endpoint.

https://claude.ai/code/session_0119eQacx3ejToSd9c6QEc98

Co-authored-by: Claude <noreply@anthropic.com>

authored by

Malpercio
Claude
and committed by
GitHub
de51e863 f94f86fb

+792 -691
+24 -21
apps/appview/src/routes/admin.ts
··· 4 4 import { requireAuth } from "../middleware/auth.js"; 5 5 import { requirePermission, requireAnyPermission, getUserRole } from "../middleware/permissions.js"; 6 6 import { memberships, roles, rolePermissions, users, forums, backfillProgress, backfillErrors, categories, boards, posts, modActions, themes, themePolicies } from "@atbb/db"; 7 - import { eq, and, sql, asc, desc, count, or } from "drizzle-orm"; 8 - import { alias } from "drizzle-orm/pg-core"; 7 + import { eq, and, sql, asc, desc, count, inArray, or } from "drizzle-orm"; 9 8 import { isProgrammingError } from "../lib/errors.js"; 10 9 import { BackfillStatus } from "../lib/backfill-manager.js"; 11 10 import { CursorManager } from "../lib/cursor-manager.js"; ··· 1532 1531 1533 1532 const clampedLimit = Math.min(limitVal, 100); 1534 1533 1535 - const moderatorUser = alias(users, "moderator_user"); 1536 - const subjectUser = alias(users, "subject_user"); 1537 - 1538 1534 try { 1539 1535 const [countResult, actions] = await Promise.all([ 1540 1536 ctx.db ··· 1542 1538 .from(modActions) 1543 1539 .where(eq(modActions.did, ctx.config.forumDid)), 1544 1540 ctx.db 1545 - .select({ 1546 - id: modActions.id, 1547 - action: modActions.action, 1548 - moderatorDid: modActions.createdBy, 1549 - moderatorHandle: moderatorUser.handle, 1550 - subjectDid: modActions.subjectDid, 1551 - subjectHandle: subjectUser.handle, 1552 - subjectPostUri: modActions.subjectPostUri, 1553 - reason: modActions.reason, 1554 - createdAt: modActions.createdAt, 1555 - }) 1541 + .select() 1556 1542 .from(modActions) 1557 1543 .where(eq(modActions.did, ctx.config.forumDid)) 1558 - .leftJoin(moderatorUser, eq(modActions.createdBy, moderatorUser.did)) 1559 - .leftJoin(subjectUser, eq(modActions.subjectDid, subjectUser.did)) 1560 1544 .orderBy(desc(modActions.createdAt)) 1561 1545 .limit(clampedLimit) 1562 1546 .offset(offsetVal), ··· 1564 1548 1565 1549 const total = Number(countResult[0]?.total ?? 0); 1566 1550 1551 + // Resolve handles in a single batch query instead of aliased self-joins 1552 + // (drizzle-orm's alias() generates invalid SQL for SQLite) 1553 + const dids = new Set<string>(); 1554 + for (const a of actions) { 1555 + if (a.createdBy) dids.add(a.createdBy); 1556 + if (a.subjectDid) dids.add(a.subjectDid); 1557 + } 1558 + 1559 + const handleMap = new Map<string, string>(); 1560 + if (dids.size > 0) { 1561 + const userRows = await ctx.db 1562 + .select({ did: users.did, handle: users.handle }) 1563 + .from(users) 1564 + .where(inArray(users.did, [...dids])); 1565 + for (const u of userRows) { 1566 + if (u.handle) handleMap.set(u.did, u.handle); 1567 + } 1568 + } 1569 + 1567 1570 return c.json({ 1568 1571 actions: actions.map((a) => ({ 1569 1572 id: a.id.toString(), 1570 1573 action: a.action, 1571 - moderatorDid: a.moderatorDid, 1572 - moderatorHandle: a.moderatorHandle ?? a.moderatorDid, 1574 + moderatorDid: a.createdBy, 1575 + moderatorHandle: handleMap.get(a.createdBy) ?? a.createdBy, 1573 1576 subjectDid: a.subjectDid ?? null, 1574 - subjectHandle: a.subjectHandle ?? null, 1577 + subjectHandle: a.subjectDid ? (handleMap.get(a.subjectDid) ?? null) : null, 1575 1578 subjectPostUri: a.subjectPostUri ?? null, 1576 1579 reason: a.reason ?? null, 1577 1580 createdAt: a.createdAt.toISOString(),
+9 -670
apps/appview/src/routes/helpers.ts
··· 1 - import { users, forums, posts, categories, boards, modActions } from "@atbb/db"; 2 - import type { Database } from "@atbb/db"; 3 - import type { Logger } from "@atbb/logger"; 4 - import { eq, and, inArray, desc, count, max } from "drizzle-orm"; 5 - import { UnicodeString } from "@atproto/api"; 6 - import { parseAtUri } from "../lib/at-uri.js"; 7 - 8 1 /** 9 - * Parse a route parameter as BigInt. 10 - * Returns null if the value cannot be parsed. 11 - */ 12 - export function parseBigIntParam(value: string): bigint | null { 13 - try { 14 - return BigInt(value); 15 - } catch (error) { 16 - // BigInt throws RangeError or SyntaxError for invalid input 17 - if (error instanceof RangeError || error instanceof SyntaxError) { 18 - return null; 19 - } 20 - // Re-throw unexpected errors 21 - throw error; 22 - } 23 - } 24 - 25 - /** 26 - * Type helper for user rows from database queries 27 - */ 28 - export type UserRow = typeof users.$inferSelect; 29 - 30 - /** 31 - * Serialize author data for API responses. 32 - * Returns null if no author is provided. 33 - */ 34 - export function serializeAuthor(author: UserRow | null) { 35 - if (!author) return null; 36 - return { 37 - did: author.did, 38 - handle: author.handle, 39 - }; 40 - } 41 - 42 - /** 43 - * Safely serialize a BigInt to string. 44 - * Returns null if value is null or undefined (avoids fabricating data). 45 - */ 46 - export function serializeBigInt(value: bigint | null | undefined): string | null { 47 - if (value === null || value === undefined) { 48 - return null; 49 - } 50 - return value.toString(); 51 - } 52 - 53 - /** 54 - * Safely serialize a Date to ISO string. 55 - * Returns null if value is null, undefined, or not a valid Date. 56 - */ 57 - export function serializeDate(value: Date | null | undefined): string | null { 58 - if (!value || !(value instanceof Date)) { 59 - return null; 60 - } 61 - return value.toISOString(); 62 - } 63 - 64 - /** 65 - * Validate post text according to lexicon constraints. 66 - * - Max 300 graphemes (user-perceived characters) 67 - * - Non-empty after trimming whitespace 68 - */ 69 - export function validatePostText(text: unknown): { 70 - valid: boolean; 71 - trimmed?: string; 72 - error?: string; 73 - } { 74 - // Type guard: ensure text is a string 75 - if (typeof text !== "string") { 76 - return { valid: false, error: "Text is required and must be a string" }; 77 - } 78 - 79 - const trimmed = text.trim(); 80 - 81 - if (trimmed.length === 0) { 82 - return { valid: false, error: "Text cannot be empty" }; 83 - } 84 - 85 - const graphemeLength = new UnicodeString(trimmed).graphemeLength; 86 - if (graphemeLength > 300) { 87 - return { 88 - valid: false, 89 - error: "Text must be 300 characters or less", 90 - }; 91 - } 92 - 93 - return { valid: true, trimmed }; 94 - } 95 - 96 - /** 97 - * Validate topic title according to lexicon constraints. 98 - * - Max 120 graphemes (user-perceived characters) 99 - * - Non-empty after trimming whitespace 100 - */ 101 - export function validateTopicTitle(title: unknown): { 102 - valid: boolean; 103 - trimmed?: string; 104 - error?: string; 105 - } { 106 - if (typeof title !== "string") { 107 - return { valid: false, error: "Title is required and must be a string" }; 108 - } 109 - 110 - const trimmed = title.trim(); 111 - 112 - if (trimmed.length === 0) { 113 - return { valid: false, error: "Title cannot be empty" }; 114 - } 115 - 116 - const graphemeLength = new UnicodeString(trimmed).graphemeLength; 117 - if (graphemeLength > 120) { 118 - return { 119 - valid: false, 120 - error: "Title must be 120 characters or less", 121 - }; 122 - } 123 - 124 - return { valid: true, trimmed }; 125 - } 126 - 127 - /** 128 - * Look up forum by AT-URI. 129 - * Returns null if forum doesn't exist. 130 - * 131 - * @param db Database instance 132 - * @param uri AT-URI like "at://did:plc:abc/space.atbb.forum.forum/self" 133 - */ 134 - export async function getForumByUri( 135 - db: Database, 136 - uri: string 137 - ): Promise<{ did: string; rkey: string; cid: string } | null> { 138 - const parsed = parseAtUri(uri); 139 - if (!parsed) { 140 - return null; 141 - } 142 - 143 - const { did, rkey } = parsed; 144 - 145 - const [forum] = await db 146 - .select({ 147 - did: forums.did, 148 - rkey: forums.rkey, 149 - cid: forums.cid, 150 - }) 151 - .from(forums) 152 - .where(and(eq(forums.did, did), eq(forums.rkey, rkey))) 153 - .limit(1); 154 - 155 - return forum ?? null; 156 - } 157 - 158 - export type PostRow = typeof posts.$inferSelect; 159 - 160 - /** 161 - * Type helper for category rows from database queries 162 - */ 163 - export type CategoryRow = typeof categories.$inferSelect; 164 - 165 - /** 166 - * Type helper for forum rows from database queries 167 - */ 168 - export type ForumRow = typeof forums.$inferSelect; 169 - 170 - /** 171 - * Look up multiple posts by ID in a single query. 172 - * Excludes deleted posts. 173 - * Returns a Map for O(1) lookup. 174 - */ 175 - export async function getPostsByIds( 176 - db: Database, 177 - ids: bigint[] 178 - ): Promise<Map<bigint, PostRow>> { 179 - if (ids.length === 0) { 180 - return new Map(); 181 - } 182 - 183 - const results = await db 184 - .select() 185 - .from(posts) 186 - .where(and(inArray(posts.id, ids), eq(posts.bannedByMod, false))); 187 - 188 - return new Map(results.map((post) => [post.id, post])); 189 - } 190 - 191 - /** 192 - * Validate that a parent post belongs to the same thread as the root. 193 - * 194 - * Rules: 195 - * - Parent can BE the root (replying directly to topic) 196 - * - Parent can be a reply in the same thread (parent.rootPostId === rootId) 197 - * - Parent cannot belong to a different thread 198 - */ 199 - export function validateReplyParent( 200 - root: { id: bigint; rootPostId: bigint | null }, 201 - parent: { id: bigint; rootPostId: bigint | null }, 202 - rootId: bigint 203 - ): { valid: boolean; error?: string } { 204 - // Parent IS the root (replying to topic) 205 - if (parent.id === rootId && parent.rootPostId === null) { 206 - return { valid: true }; 207 - } 208 - 209 - // Parent is a reply in the same thread 210 - if (parent.rootPostId === rootId) { 211 - return { valid: true }; 212 - } 213 - 214 - // Parent belongs to a different thread 215 - return { 216 - valid: false, 217 - error: "Parent post does not belong to this thread", 218 - }; 219 - } 220 - 221 - /** 222 - * Serialize a post (topic or reply) and its author for API responses. 223 - * Produces the JSON shape used in GET /api/topics/:id for both the 224 - * topic post and each reply. 225 - * 226 - * For topic posts (thread starters): forumUri is set, parentPostId is null. 227 - * For replies: parentPostId is set, forumUri may also be set. 228 - * 229 - * @returns Response shape: 230 - * ```json 231 - * { 232 - * "id": "1234", // BigInt → string 233 - * "did": "did:plc:...", 234 - * "rkey": "3lbk7...", 235 - * "title": "Topic title" | null, 236 - * "text": "Post content", 237 - * "forumUri": "at://..." | null, 238 - * "boardUri": "at://..." | null, 239 - * "boardId": "456" | null, // BigInt → string 240 - * "parentPostId": "123" | null, // BigInt → string 241 - * "createdAt": "2025-01-15T12:00:00.000Z", // ISO 8601 242 - * "author": { "did": "did:plc:...", "handle": "user.test" } | null 243 - * } 244 - * ``` 245 - */ 246 - export function serializePost(post: PostRow, author: UserRow | null) { 247 - return { 248 - id: serializeBigInt(post.id), 249 - did: post.did, 250 - rkey: post.rkey, 251 - title: post.title ?? null, 252 - text: post.text, 253 - forumUri: post.forumUri ?? null, 254 - boardUri: post.boardUri ?? null, 255 - boardId: serializeBigInt(post.boardId), 256 - parentPostId: serializeBigInt(post.parentPostId), 257 - createdAt: serializeDate(post.createdAt), 258 - author: serializeAuthor(author), 259 - }; 260 - } 261 - 262 - /** 263 - * Serialize a category row for API responses. 264 - * Produces the JSON shape used in GET /api/categories. 2 + * Barrel re-export for route helpers. 265 3 * 266 - * @returns Response shape: 267 - * ```json 268 - * { 269 - * "id": "1234", // BigInt → string 270 - * "did": "did:plc:...", 271 - * "uri": "at://did:plc:.../space.atbb.forum.category/...", // computed AT URI 272 - * "name": "General Discussion", 273 - * "description": "A place for..." | null, 274 - * "slug": "general" | null, 275 - * "sortOrder": 1 | null, 276 - * "forumId": "10" | null, // BigInt → string 277 - * "createdAt": "2025-01-15T12:00:00.000Z", // ISO 8601 278 - * "indexedAt": "2025-01-15T12:00:00.000Z" // ISO 8601 279 - * } 280 - * ``` 281 - */ 282 - export function serializeCategory(cat: CategoryRow) { 283 - return { 284 - id: serializeBigInt(cat.id), 285 - did: cat.did, 286 - uri: `at://${cat.did}/space.atbb.forum.category/${cat.rkey}`, 287 - name: cat.name, 288 - description: cat.description, 289 - slug: cat.slug, 290 - sortOrder: cat.sortOrder, 291 - forumId: serializeBigInt(cat.forumId), 292 - createdAt: serializeDate(cat.createdAt), 293 - indexedAt: serializeDate(cat.indexedAt), 294 - }; 295 - } 296 - 297 - /** 298 - * Serialize a forum row for API responses. 299 - * Produces the JSON shape used in GET /api/forum. 4 + * The actual implementations live in helpers/ subdirectory: 5 + * - helpers/serialize.ts — serialization functions and DB row type aliases 6 + * - helpers/validate.ts — input validation and parameter parsing 7 + * - helpers/queries.ts — database query helpers (bans, mod status, etc.) 300 8 * 301 - * @returns Response shape: 302 - * ```json 303 - * { 304 - * "id": "1234", // BigInt → string 305 - * "did": "did:plc:...", 306 - * "name": "My Forum", 307 - * "description": "Forum description" | null, 308 - * "indexedAt": "2025-01-15T12:00:00.000Z" // ISO 8601 309 - * } 310 - * ``` 311 - */ 312 - export function serializeForum(forum: ForumRow) { 313 - return { 314 - id: serializeBigInt(forum.id), 315 - did: forum.did, 316 - name: forum.name, 317 - description: forum.description, 318 - indexedAt: serializeDate(forum.indexedAt), 319 - }; 320 - } 321 - 322 - /** 323 - * Type helper for board rows from database queries 9 + * All consumers can continue importing from "./helpers.js" unchanged. 324 10 */ 325 - export type BoardRow = typeof boards.$inferSelect; 326 - 327 - /** 328 - * Look up board by AT-URI. 329 - * Returns null if board doesn't exist. 330 - * 331 - * @param db Database instance 332 - * @param uri AT-URI like "at://did:plc:abc/space.atbb.forum.board/3lbk9board" 333 - */ 334 - export async function getBoardByUri( 335 - db: Database, 336 - uri: string 337 - ): Promise<{ cid: string } | null> { 338 - const parsed = parseAtUri(uri); 339 - if (!parsed) { 340 - return null; 341 - } 342 - 343 - const { did, rkey } = parsed; 344 - 345 - const [board] = await db 346 - .select({ 347 - cid: boards.cid, 348 - }) 349 - .from(boards) 350 - .where(and(eq(boards.did, did), eq(boards.rkey, rkey))) 351 - .limit(1); 352 - 353 - return board ?? null; 354 - } 355 - 356 - /** 357 - * Serialize a board row for API responses. 358 - * Produces the JSON shape used in GET /api/boards. 359 - * 360 - * @returns Response shape: 361 - * ```json 362 - * { 363 - * "id": "1234", // BigInt → string 364 - * "did": "did:plc:...", 365 - * "uri": "at://did:plc:.../space.atbb.forum.board/...", // computed AT URI 366 - * "name": "General Discussion", 367 - * "description": "A place for..." | null, 368 - * "slug": "general" | null, 369 - * "sortOrder": 1 | null, 370 - * "categoryId": "10" | null, // BigInt → string 371 - * "categoryUri": "at://..." , 372 - * "createdAt": "2025-01-15T12:00:00.000Z", // ISO 8601 373 - * "indexedAt": "2025-01-15T12:00:00.000Z" // ISO 8601 374 - * } 375 - * ``` 376 - */ 377 - export function serializeBoard(board: BoardRow) { 378 - return { 379 - id: serializeBigInt(board.id), 380 - did: board.did, 381 - uri: `at://${board.did}/space.atbb.forum.board/${board.rkey}`, 382 - name: board.name, 383 - description: board.description, 384 - slug: board.slug, 385 - sortOrder: board.sortOrder, 386 - categoryId: serializeBigInt(board.categoryId), 387 - categoryUri: board.categoryUri, 388 - createdAt: serializeDate(board.createdAt), 389 - indexedAt: serializeDate(board.indexedAt), 390 - }; 391 - } 392 - 393 - /** 394 - * Query active bans for a list of user DIDs. 395 - * A user is banned if their most recent modAction is "ban" (not "unban"). 396 - * 397 - * @param db Database instance 398 - * @param dids Array of user DIDs to check 399 - * @returns Set of banned DIDs (subset of input) 400 - */ 401 - export async function getActiveBans( 402 - db: Database, 403 - dids: string[], 404 - logger?: Logger 405 - ): Promise<Set<string>> { 406 - if (dids.length === 0) { 407 - return new Set(); 408 - } 409 - 410 - try { 411 - // Query ban/unban actions for these DIDs only (not other action types like mute) 412 - // We need the most recent ban/unban action per DID to determine current state 413 - const actions = await db 414 - .select({ 415 - subjectDid: modActions.subjectDid, 416 - action: modActions.action, 417 - createdAt: modActions.createdAt, 418 - }) 419 - .from(modActions) 420 - .where( 421 - and( 422 - inArray(modActions.subjectDid, dids), 423 - inArray(modActions.action, [ 424 - "space.atbb.modAction.ban", 425 - "space.atbb.modAction.unban", 426 - ]) 427 - ) 428 - ) 429 - .orderBy(desc(modActions.createdAt)) 430 - .limit(dids.length * 100); // Defensive limit: at most 100 actions per user 431 - 432 - // Group by subjectDid and take most recent ban/unban action 433 - const mostRecentByDid = new Map<string, string>(); 434 - for (const row of actions) { 435 - if (row.subjectDid && !mostRecentByDid.has(row.subjectDid)) { 436 - mostRecentByDid.set(row.subjectDid, row.action); 437 - } 438 - } 439 - 440 - // A user is banned if most recent ban/unban action is "ban" 441 - const banned = new Set<string>(); 442 - for (const [did, action] of mostRecentByDid) { 443 - if (action === "space.atbb.modAction.ban") { 444 - banned.add(did); 445 - } 446 - } 447 - 448 - return banned; 449 - } catch (error) { 450 - logger?.error("Failed to query active bans", { 451 - operation: "getActiveBans", 452 - didCount: dids.length, 453 - error: error instanceof Error ? error.message : String(error), 454 - }); 455 - throw error; // Let caller decide fail policy 456 - } 457 - } 458 - 459 - /** 460 - * Query moderation status for a topic (lock/pin). 461 - * 462 - * @param db Database instance 463 - * @param topicId Internal post ID of the topic (root post) 464 - * @returns { locked: boolean, pinned: boolean } 465 - */ 466 - export async function getTopicModStatus( 467 - db: Database, 468 - topicId: bigint, 469 - logger?: Logger 470 - ): Promise<{ locked: boolean; pinned: boolean }> { 471 - try { 472 - // Look up the topic to get its AT-URI 473 - const [topic] = await db 474 - .select({ 475 - did: posts.did, 476 - rkey: posts.rkey, 477 - }) 478 - .from(posts) 479 - .where(eq(posts.id, topicId)) 480 - .limit(1); 481 - 482 - if (!topic) { 483 - return { locked: false, pinned: false }; 484 - } 485 - 486 - const topicUri = `at://${topic.did}/space.atbb.post/${topic.rkey}`; 487 - 488 - // Query only lock/unlock/pin/unpin actions for this topic URI 489 - const actions = await db 490 - .select({ 491 - action: modActions.action, 492 - createdAt: modActions.createdAt, 493 - }) 494 - .from(modActions) 495 - .where( 496 - and( 497 - eq(modActions.subjectPostUri, topicUri), 498 - inArray(modActions.action, [ 499 - "space.atbb.modAction.lock", 500 - "space.atbb.modAction.unlock", 501 - "space.atbb.modAction.pin", 502 - "space.atbb.modAction.unpin", 503 - ]) 504 - ) 505 - ) 506 - .orderBy(desc(modActions.createdAt)) 507 - .limit(100); 508 - 509 - if (actions.length === 0) { 510 - return { locked: false, pinned: false }; 511 - } 512 - 513 - // Lock and pin are independent states - check most recent action for each 514 - // Find most recent lock/unlock action 515 - const mostRecentLockAction = actions.find( 516 - (a) => 517 - a.action === "space.atbb.modAction.lock" || 518 - a.action === "space.atbb.modAction.unlock" 519 - ); 520 - 521 - // Find most recent pin/unpin action 522 - const mostRecentPinAction = actions.find( 523 - (a) => 524 - a.action === "space.atbb.modAction.pin" || 525 - a.action === "space.atbb.modAction.unpin" 526 - ); 527 - 528 - return { 529 - locked: 530 - mostRecentLockAction?.action === "space.atbb.modAction.lock" || false, 531 - pinned: 532 - mostRecentPinAction?.action === "space.atbb.modAction.pin" || false, 533 - }; 534 - } catch (error) { 535 - logger?.error("Failed to query topic moderation status", { 536 - operation: "getTopicModStatus", 537 - topicId: topicId.toString(), 538 - error: error instanceof Error ? error.message : String(error), 539 - }); 540 - throw error; // Let caller decide fail policy 541 - } 542 - } 543 - 544 - /** 545 - * Query reply counts and last-reply timestamps for a list of topic post IDs. 546 - * Only non-moderated replies (bannedByMod = false) are counted. 547 - * Returns a Map from topic ID to { replyCount, lastReplyAt }. 548 - */ 549 - export async function getReplyStats( 550 - db: Database, 551 - topicIds: bigint[] 552 - ): Promise<Map<bigint, { replyCount: number; lastReplyAt: Date | null }>> { 553 - if (topicIds.length === 0) { 554 - return new Map(); 555 - } 556 - 557 - const rows = await db 558 - .select({ 559 - rootPostId: posts.rootPostId, 560 - replyCount: count(), 561 - lastReplyAt: max(posts.createdAt), 562 - }) 563 - .from(posts) 564 - .where( 565 - and( 566 - inArray(posts.rootPostId, topicIds), 567 - eq(posts.bannedByMod, false) 568 - ) 569 - ) 570 - .groupBy(posts.rootPostId); 571 - 572 - const result = new Map<bigint, { replyCount: number; lastReplyAt: Date | null }>(); 573 - for (const row of rows) { 574 - if (row.rootPostId !== null) { 575 - result.set(row.rootPostId, { 576 - replyCount: Number(row.replyCount), 577 - lastReplyAt: row.lastReplyAt ?? null, 578 - }); 579 - } 580 - } 581 - return result; 582 - } 583 - 584 - /** 585 - * Query which posts in a list are currently hidden by moderator action. 586 - * A post is hidden if its most recent modAction is "delete" (not "undelete"). 587 - * 588 - * @param db Database instance 589 - * @param postIds Array of post IDs to check 590 - * @returns Set of hidden post IDs (subset of input) 591 - */ 592 - export async function getHiddenPosts( 593 - db: Database, 594 - postIds: bigint[], 595 - logger?: Logger 596 - ): Promise<Set<bigint>> { 597 - if (postIds.length === 0) { 598 - return new Set(); 599 - } 600 - 601 - try { 602 - // Look up URIs for these post IDs 603 - const postRecords = await db 604 - .select({ 605 - id: posts.id, 606 - did: posts.did, 607 - rkey: posts.rkey, 608 - }) 609 - .from(posts) 610 - .where(inArray(posts.id, postIds)) 611 - .limit(1000); // Prevent memory exhaustion 612 - 613 - if (postRecords.length === 0) { 614 - return new Set(); 615 - } 616 - 617 - // Build URI->ID mapping 618 - const uriToId = new Map<string, bigint>(); 619 - const uris: string[] = []; 620 - for (const post of postRecords) { 621 - const uri = `at://${post.did}/space.atbb.post/${post.rkey}`; 622 - uriToId.set(uri, post.id); 623 - uris.push(uri); 624 - } 625 - 626 - // Query only delete/undelete actions for these URIs 627 - const actions = await db 628 - .select({ 629 - subjectPostUri: modActions.subjectPostUri, 630 - action: modActions.action, 631 - createdAt: modActions.createdAt, 632 - }) 633 - .from(modActions) 634 - .where( 635 - and( 636 - inArray(modActions.subjectPostUri, uris), 637 - inArray(modActions.action, [ 638 - "space.atbb.modAction.delete", 639 - "space.atbb.modAction.undelete", 640 - ]) 641 - ) 642 - ) 643 - .orderBy(desc(modActions.createdAt)) 644 - .limit(uris.length * 10); // At most 10 delete/undelete actions per post 645 - 646 - // Group by URI and take most recent 647 - const mostRecentByUri = new Map<string, string>(); 648 - for (const row of actions) { 649 - if (row.subjectPostUri && !mostRecentByUri.has(row.subjectPostUri)) { 650 - mostRecentByUri.set(row.subjectPostUri, row.action); 651 - } 652 - } 653 - 654 - // A post is hidden if most recent delete/undelete action is "delete" 655 - const hidden = new Set<bigint>(); 656 - for (const [uri, action] of mostRecentByUri) { 657 - if (action === "space.atbb.modAction.delete") { 658 - const postId = uriToId.get(uri); 659 - if (postId !== undefined) { 660 - hidden.add(postId); 661 - } 662 - } 663 - } 664 - 665 - return hidden; 666 - } catch (error) { 667 - logger?.error("Failed to query hidden posts", { 668 - operation: "getHiddenPosts", 669 - postIdCount: postIds.length, 670 - error: error instanceof Error ? error.message : String(error), 671 - }); 672 - throw error; // Let caller decide fail policy 673 - } 674 - } 11 + export * from "./helpers/serialize.js"; 12 + export * from "./helpers/validate.js"; 13 + export * from "./helpers/queries.js";
+370
apps/appview/src/routes/helpers/queries.ts
··· 1 + import { forums, posts, boards, modActions } from "@atbb/db"; 2 + import type { Database } from "@atbb/db"; 3 + import type { Logger } from "@atbb/logger"; 4 + import { eq, and, inArray, desc, count, max } from "drizzle-orm"; 5 + import { parseAtUri } from "../../lib/at-uri.js"; 6 + import 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 + */ 15 + export 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 + */ 46 + export 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 + */ 73 + export 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 + */ 97 + export 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 + */ 162 + export 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 + */ 245 + export 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 + */ 288 + export 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 + }
+195
apps/appview/src/routes/helpers/serialize.ts
··· 1 + import { users, forums, posts, categories, boards } from "@atbb/db"; 2 + 3 + /** 4 + * Type helper for user rows from database queries 5 + */ 6 + export type UserRow = typeof users.$inferSelect; 7 + 8 + export type PostRow = typeof posts.$inferSelect; 9 + 10 + /** 11 + * Type helper for category rows from database queries 12 + */ 13 + export type CategoryRow = typeof categories.$inferSelect; 14 + 15 + /** 16 + * Type helper for forum rows from database queries 17 + */ 18 + export type ForumRow = typeof forums.$inferSelect; 19 + 20 + /** 21 + * Type helper for board rows from database queries 22 + */ 23 + export type BoardRow = typeof boards.$inferSelect; 24 + 25 + /** 26 + * Serialize author data for API responses. 27 + * Returns null if no author is provided. 28 + */ 29 + export function serializeAuthor(author: UserRow | null) { 30 + if (!author) return null; 31 + return { 32 + did: author.did, 33 + handle: author.handle, 34 + }; 35 + } 36 + 37 + /** 38 + * Safely serialize a BigInt to string. 39 + * Returns null if value is null or undefined (avoids fabricating data). 40 + */ 41 + export function serializeBigInt(value: bigint | null | undefined): string | null { 42 + if (value === null || value === undefined) { 43 + return null; 44 + } 45 + return value.toString(); 46 + } 47 + 48 + /** 49 + * Safely serialize a Date to ISO string. 50 + * Returns null if value is null, undefined, or not a valid Date. 51 + */ 52 + export function serializeDate(value: Date | null | undefined): string | null { 53 + if (!value || !(value instanceof Date)) { 54 + return null; 55 + } 56 + return value.toISOString(); 57 + } 58 + 59 + /** 60 + * Serialize a post (topic or reply) and its author for API responses. 61 + * Produces the JSON shape used in GET /api/topics/:id for both the 62 + * topic post and each reply. 63 + * 64 + * For topic posts (thread starters): forumUri is set, parentPostId is null. 65 + * For replies: parentPostId is set, forumUri may also be set. 66 + * 67 + * @returns Response shape: 68 + * ```json 69 + * { 70 + * "id": "1234", // BigInt → string 71 + * "did": "did:plc:...", 72 + * "rkey": "3lbk7...", 73 + * "title": "Topic title" | null, 74 + * "text": "Post content", 75 + * "forumUri": "at://..." | null, 76 + * "boardUri": "at://..." | null, 77 + * "boardId": "456" | null, // BigInt → string 78 + * "parentPostId": "123" | null, // BigInt → string 79 + * "createdAt": "2025-01-15T12:00:00.000Z", // ISO 8601 80 + * "author": { "did": "did:plc:...", "handle": "user.test" } | null 81 + * } 82 + * ``` 83 + */ 84 + export function serializePost(post: PostRow, author: UserRow | null) { 85 + return { 86 + id: serializeBigInt(post.id), 87 + did: post.did, 88 + rkey: post.rkey, 89 + title: post.title ?? null, 90 + text: post.text, 91 + forumUri: post.forumUri ?? null, 92 + boardUri: post.boardUri ?? null, 93 + boardId: serializeBigInt(post.boardId), 94 + parentPostId: serializeBigInt(post.parentPostId), 95 + createdAt: serializeDate(post.createdAt), 96 + author: serializeAuthor(author), 97 + }; 98 + } 99 + 100 + /** 101 + * Serialize a category row for API responses. 102 + * Produces the JSON shape used in GET /api/categories. 103 + * 104 + * @returns Response shape: 105 + * ```json 106 + * { 107 + * "id": "1234", // BigInt → string 108 + * "did": "did:plc:...", 109 + * "uri": "at://did:plc:.../space.atbb.forum.category/...", // computed AT URI 110 + * "name": "General Discussion", 111 + * "description": "A place for..." | null, 112 + * "slug": "general" | null, 113 + * "sortOrder": 1 | null, 114 + * "forumId": "10" | null, // BigInt → string 115 + * "createdAt": "2025-01-15T12:00:00.000Z", // ISO 8601 116 + * "indexedAt": "2025-01-15T12:00:00.000Z" // ISO 8601 117 + * } 118 + * ``` 119 + */ 120 + export function serializeCategory(cat: CategoryRow) { 121 + return { 122 + id: serializeBigInt(cat.id), 123 + did: cat.did, 124 + uri: `at://${cat.did}/space.atbb.forum.category/${cat.rkey}`, 125 + name: cat.name, 126 + description: cat.description, 127 + slug: cat.slug, 128 + sortOrder: cat.sortOrder, 129 + forumId: serializeBigInt(cat.forumId), 130 + createdAt: serializeDate(cat.createdAt), 131 + indexedAt: serializeDate(cat.indexedAt), 132 + }; 133 + } 134 + 135 + /** 136 + * Serialize a forum row for API responses. 137 + * Produces the JSON shape used in GET /api/forum. 138 + * 139 + * @returns Response shape: 140 + * ```json 141 + * { 142 + * "id": "1234", // BigInt → string 143 + * "did": "did:plc:...", 144 + * "name": "My Forum", 145 + * "description": "Forum description" | null, 146 + * "indexedAt": "2025-01-15T12:00:00.000Z" // ISO 8601 147 + * } 148 + * ``` 149 + */ 150 + export function serializeForum(forum: ForumRow) { 151 + return { 152 + id: serializeBigInt(forum.id), 153 + did: forum.did, 154 + name: forum.name, 155 + description: forum.description, 156 + indexedAt: serializeDate(forum.indexedAt), 157 + }; 158 + } 159 + 160 + /** 161 + * Serialize a board row for API responses. 162 + * Produces the JSON shape used in GET /api/boards. 163 + * 164 + * @returns Response shape: 165 + * ```json 166 + * { 167 + * "id": "1234", // BigInt → string 168 + * "did": "did:plc:...", 169 + * "uri": "at://did:plc:.../space.atbb.forum.board/...", // computed AT URI 170 + * "name": "General Discussion", 171 + * "description": "A place for..." | null, 172 + * "slug": "general" | null, 173 + * "sortOrder": 1 | null, 174 + * "categoryId": "10" | null, // BigInt → string 175 + * "categoryUri": "at://..." , 176 + * "createdAt": "2025-01-15T12:00:00.000Z", // ISO 8601 177 + * "indexedAt": "2025-01-15T12:00:00.000Z" // ISO 8601 178 + * } 179 + * ``` 180 + */ 181 + export function serializeBoard(board: BoardRow) { 182 + return { 183 + id: serializeBigInt(board.id), 184 + did: board.did, 185 + uri: `at://${board.did}/space.atbb.forum.board/${board.rkey}`, 186 + name: board.name, 187 + description: board.description, 188 + slug: board.slug, 189 + sortOrder: board.sortOrder, 190 + categoryId: serializeBigInt(board.categoryId), 191 + categoryUri: board.categoryUri, 192 + createdAt: serializeDate(board.createdAt), 193 + indexedAt: serializeDate(board.indexedAt), 194 + }; 195 + }
+111
apps/appview/src/routes/helpers/validate.ts
··· 1 + import { UnicodeString } from "@atproto/api"; 2 + 3 + /** 4 + * Parse a route parameter as BigInt. 5 + * Returns null if the value cannot be parsed. 6 + */ 7 + export function parseBigIntParam(value: string): bigint | null { 8 + try { 9 + return BigInt(value); 10 + } catch (error) { 11 + // BigInt throws RangeError or SyntaxError for invalid input 12 + if (error instanceof RangeError || error instanceof SyntaxError) { 13 + return null; 14 + } 15 + // Re-throw unexpected errors 16 + throw error; 17 + } 18 + } 19 + 20 + /** 21 + * Validate post text according to lexicon constraints. 22 + * - Max 300 graphemes (user-perceived characters) 23 + * - Non-empty after trimming whitespace 24 + */ 25 + export function validatePostText(text: unknown): { 26 + valid: boolean; 27 + trimmed?: string; 28 + error?: string; 29 + } { 30 + // Type guard: ensure text is a string 31 + if (typeof text !== "string") { 32 + return { valid: false, error: "Text is required and must be a string" }; 33 + } 34 + 35 + const trimmed = text.trim(); 36 + 37 + if (trimmed.length === 0) { 38 + return { valid: false, error: "Text cannot be empty" }; 39 + } 40 + 41 + const graphemeLength = new UnicodeString(trimmed).graphemeLength; 42 + if (graphemeLength > 300) { 43 + return { 44 + valid: false, 45 + error: "Text must be 300 characters or less", 46 + }; 47 + } 48 + 49 + return { valid: true, trimmed }; 50 + } 51 + 52 + /** 53 + * Validate topic title according to lexicon constraints. 54 + * - Max 120 graphemes (user-perceived characters) 55 + * - Non-empty after trimming whitespace 56 + */ 57 + export function validateTopicTitle(title: unknown): { 58 + valid: boolean; 59 + trimmed?: string; 60 + error?: string; 61 + } { 62 + if (typeof title !== "string") { 63 + return { valid: false, error: "Title is required and must be a string" }; 64 + } 65 + 66 + const trimmed = title.trim(); 67 + 68 + if (trimmed.length === 0) { 69 + return { valid: false, error: "Title cannot be empty" }; 70 + } 71 + 72 + const graphemeLength = new UnicodeString(trimmed).graphemeLength; 73 + if (graphemeLength > 120) { 74 + return { 75 + valid: false, 76 + error: "Title must be 120 characters or less", 77 + }; 78 + } 79 + 80 + return { valid: true, trimmed }; 81 + } 82 + 83 + /** 84 + * Validate that a parent post belongs to the same thread as the root. 85 + * 86 + * Rules: 87 + * - Parent can BE the root (replying directly to topic) 88 + * - Parent can be a reply in the same thread (parent.rootPostId === rootId) 89 + * - Parent cannot belong to a different thread 90 + */ 91 + export function validateReplyParent( 92 + root: { id: bigint; rootPostId: bigint | null }, 93 + parent: { id: bigint; rootPostId: bigint | null }, 94 + rootId: bigint 95 + ): { valid: boolean; error?: string } { 96 + // Parent IS the root (replying to topic) 97 + if (parent.id === rootId && parent.rootPostId === null) { 98 + return { valid: true }; 99 + } 100 + 101 + // Parent is a reply in the same thread 102 + if (parent.rootPostId === rootId) { 103 + return { valid: true }; 104 + } 105 + 106 + // Parent belongs to a different thread 107 + return { 108 + valid: false, 109 + error: "Parent post does not belong to this thread", 110 + }; 111 + }
+83
docs/plans/2026-03-03-refactoring-opportunities.md
··· 1 + # Refactoring Opportunities 2 + 3 + Date: 2026-03-03 4 + 5 + ## Overview 6 + 7 + Analysis of the appview codebase identifying refactoring opportunities, focused on code organization, shared abstractions, and maintainability. 8 + 9 + --- 10 + 11 + ## 1. Break Up `routes/helpers.ts` (675 lines) 12 + 13 + **Status: Complete** (shipped in #89) 14 + 15 + The monolithic helpers file mixes serialization, validation, DB queries, and type exports. Split into focused modules under `routes/helpers/`: 16 + 17 + | New File | Contents | Lines | 18 + |----------|----------|-------| 19 + | `helpers/serialize.ts` | `serializeAuthor`, `serializeBigInt`, `serializeDate`, `serializePost`, `serializeCategory`, `serializeForum`, `serializeBoard` + type aliases (`UserRow`, `PostRow`, `CategoryRow`, `ForumRow`, `BoardRow`) | ~180 | 20 + | `helpers/validate.ts` | `validatePostText`, `validateTopicTitle`, `validateReplyParent`, `parseBigIntParam` | ~90 | 21 + | `helpers/queries.ts` | `getForumByUri`, `getBoardByUri`, `getPostsByIds`, `getActiveBans`, `getTopicModStatus`, `getReplyStats`, `getHiddenPosts` | ~360 | 22 + 23 + `routes/helpers.ts` becomes a barrel re-export (`export * from "./helpers/*.js"`), so **zero consumer changes needed**. 24 + 25 + --- 26 + 27 + ## 2. Extract `buildAtUri()` Helper 28 + 29 + **Status: Not started** 30 + 31 + AT URIs are constructed inline 13+ times across 4 route files: 32 + 33 + ```typescript 34 + `at://${did}/space.atbb.post/${rkey}` 35 + `at://${did}/space.atbb.forum.category/${rkey}` 36 + `at://${did}/space.atbb.forum.board/${rkey}` 37 + ``` 38 + 39 + **Files affected:** 40 + - `routes/helpers.ts` (2 instances) - serializeCategory, serializeBoard 41 + - `routes/mod.ts` (4 instances) - post URI for modAction queries 42 + - `routes/admin.ts` (3 instances) - role URI validation + SQL concat 43 + - `routes/posts.ts` (2 instances) - root/parent URIs for reply write 44 + - `routes/topics.ts` (1 instance) - forum URI construction 45 + - `lib/membership.ts` (1 instance) - forum URI construction 46 + 47 + **Proposed location:** `lib/at-uri.ts` (alongside existing `parseAtUri`) 48 + 49 + ```typescript 50 + export function buildAtUri(did: string, collection: string, rkey: string): string { 51 + return `at://${did}/${collection}/${rkey}`; 52 + } 53 + ``` 54 + 55 + --- 56 + 57 + ## 3. Deduplicate Error Classification in Web App 58 + 59 + **Status: Not started** 60 + 61 + `apps/web/src/lib/errors.ts` duplicates error classification already in `@atbb/atproto`. The web app could import from the shared package instead. 62 + 63 + --- 64 + 65 + ## 4. Large Files Worth Monitoring 66 + 67 + These aren't urgent but worth watching as they grow: 68 + 69 + | File | Lines | Concern | 70 + |------|-------|---------| 71 + | `lib/indexer.ts` | 1,129 | Could split by collection type | 72 + | `lib/create-app.ts` | 1,190 | Route assembly + middleware | 73 + | `lib/cookie-session-store.ts` | 1,533 | OAuth complexity | 74 + | `lib/oauth-stores.ts` | 2,980 | Multiple store implementations | 75 + | `lib/ttl-store.ts` | 3,876 | Generic store with TTL | 76 + 77 + --- 78 + 79 + ## 5. Validation Sharing Opportunity 80 + 81 + **Status: Not started** 82 + 83 + `validatePostText` and `validateTopicTitle` live only in appview. If the web app ever adds client-side validation, these could move to `@atbb/atproto`. Low priority until needed.