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
at root/atb-56-theme-caching-layer 148 lines 5.0 kB view raw
1import { Hono } from "hono"; 2import type { AppContext } from "../lib/app-context.js"; 3import { boards, categories, posts, users } from "@atbb/db"; 4import { asc, count, eq, and, desc, isNull } from "drizzle-orm"; 5import { serializeBoard, parseBigIntParam, serializePost, serializeDate, getReplyStats } from "./helpers.js"; 6import { handleRouteError } from "../lib/route-errors.js"; 7import { isProgrammingError } from "../lib/errors.js"; 8 9/** 10 * Factory function that creates board routes with access to app context. 11 */ 12export function createBoardsRoutes(ctx: AppContext) { 13 return new Hono() 14 .get("/", async (c) => { 15 try { 16 const allBoards = await ctx.db 17 .select() 18 .from(boards) 19 .leftJoin(categories, eq(boards.categoryId, categories.id)) 20 .orderBy(asc(categories.sortOrder), asc(boards.sortOrder)) 21 .limit(1000); // Defensive limit 22 23 return c.json({ 24 boards: allBoards.map(({ boards: board }) => serializeBoard(board)), 25 }); 26 } catch (error) { 27 return handleRouteError(c, error, "Failed to retrieve boards", { 28 operation: "GET /api/boards", 29 logger: ctx.logger, 30 }); 31 } 32 }) 33 .get("/:id", async (c) => { 34 const raw = c.req.param("id"); 35 const id = parseBigIntParam(raw); 36 if (id === null) { 37 return c.json({ error: "Invalid board ID" }, 400); 38 } 39 40 try { 41 const [board] = await ctx.db 42 .select() 43 .from(boards) 44 .where(eq(boards.id, id)) 45 .limit(1); 46 47 if (!board) { 48 return c.json({ error: "Board not found" }, 404); 49 } 50 51 return c.json(serializeBoard(board)); 52 } catch (error) { 53 return handleRouteError(c, error, "Failed to retrieve board", { 54 operation: "GET /api/boards/:id", 55 logger: ctx.logger, 56 boardId: raw, 57 }); 58 } 59 }) 60 .get("/:id/topics", async (c) => { 61 const { id } = c.req.param(); 62 63 const boardId = parseBigIntParam(id); 64 if (boardId === null) { 65 return c.json({ error: "Invalid board ID format" }, 400); 66 } 67 68 // Parse and validate pagination query params 69 const offsetRaw = parseInt(c.req.query("offset") ?? "0", 10); 70 const limitRaw = parseInt(c.req.query("limit") ?? "25", 10); 71 const offset = isNaN(offsetRaw) || offsetRaw < 0 ? 0 : offsetRaw; 72 const limit = isNaN(limitRaw) || limitRaw < 1 ? 25 : Math.min(limitRaw, 100); 73 74 try { 75 // Check if board exists 76 const [board] = await ctx.db 77 .select() 78 .from(boards) 79 .where(eq(boards.id, boardId)) 80 .limit(1); 81 82 if (!board) { 83 return c.json({ error: "Board not found" }, 404); 84 } 85 86 const topicFilter = and( 87 eq(posts.boardId, boardId), 88 isNull(posts.rootPostId), // Topics only (not replies) 89 eq(posts.bannedByMod, false) 90 ); 91 92 const [countResult, topicResults] = await Promise.all([ 93 ctx.db 94 .select({ count: count() }) 95 .from(posts) 96 .where(topicFilter), 97 ctx.db 98 .select({ 99 post: posts, 100 author: users, 101 }) 102 .from(posts) 103 .leftJoin(users, eq(posts.did, users.did)) 104 .where(topicFilter) 105 .orderBy(desc(posts.createdAt)) 106 .limit(limit) 107 .offset(offset), 108 ]); 109 110 const total = Number(countResult[0]?.count ?? 0); 111 112 // Fetch reply counts and last-reply timestamps for the returned topics. 113 // Fail-open: if the query fails, topics show 0 replies rather than an error page. 114 const topicIds = topicResults.map((r) => r.post.id); 115 let replyStatsMap = new Map<bigint, { replyCount: number; lastReplyAt: Date | null }>(); 116 try { 117 replyStatsMap = await getReplyStats(ctx.db, topicIds); 118 } catch (error) { 119 if (isProgrammingError(error)) throw error; 120 ctx.logger.error("Failed to fetch reply stats for board topic listing - using defaults", { 121 operation: "GET /api/boards/:id/topics - reply stats", 122 boardId: id, 123 error: error instanceof Error ? error.message : String(error), 124 }); 125 } 126 127 return c.json({ 128 topics: topicResults.map(({ post, author }) => { 129 const stats = replyStatsMap.get(post.id) ?? { replyCount: 0, lastReplyAt: null }; 130 return { 131 ...serializePost(post, author), 132 replyCount: stats.replyCount, 133 lastReplyAt: serializeDate(stats.lastReplyAt), 134 }; 135 }), 136 total, 137 offset, 138 limit, 139 }); 140 } catch (error) { 141 return handleRouteError(c, error, "Failed to retrieve topics", { 142 operation: "GET /api/boards/:id/topics", 143 logger: ctx.logger, 144 boardId: id, 145 }); 146 } 147 }); 148}