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 { 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}