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 type { AtpAgent } from "@atproto/api";
2import type { Database } from "@atbb/db";
3import { boards } from "@atbb/db";
4import { eq, and } from "drizzle-orm";
5import { deriveSlug } from "../slug.js";
6
7interface CreateBoardInput {
8 name: string;
9 description?: string;
10 slug?: string;
11 sortOrder?: number;
12 categoryUri: string; // AT URI: at://did/space.atbb.forum.category/rkey
13 categoryId: bigint; // DB FK
14 categoryCid: string; // CID for the category strongRef
15}
16
17interface CreateBoardResult {
18 created: boolean;
19 skipped: boolean;
20 uri?: string;
21 cid?: string;
22 existingName?: string;
23}
24
25/**
26 * Create a space.atbb.forum.board record on the Forum DID's PDS
27 * and insert it into the database.
28 * Idempotent: skips if a board with the same name in the same category exists.
29 */
30export async function createBoard(
31 db: Database,
32 agent: AtpAgent,
33 forumDid: string,
34 input: CreateBoardInput
35): Promise<CreateBoardResult> {
36 // Check if board with this name already exists in the category
37 const [existing] = await db
38 .select()
39 .from(boards)
40 .where(
41 and(
42 eq(boards.did, forumDid),
43 eq(boards.name, input.name),
44 eq(boards.categoryUri, input.categoryUri)
45 )
46 )
47 .limit(1);
48
49 if (existing) {
50 return {
51 created: false,
52 skipped: true,
53 uri: `at://${existing.did}/space.atbb.forum.board/${existing.rkey}`,
54 cid: existing.cid,
55 existingName: existing.name,
56 };
57 }
58
59 const slug = input.slug ?? deriveSlug(input.name);
60 const now = new Date();
61
62 const response = await agent.com.atproto.repo.createRecord({
63 repo: forumDid,
64 collection: "space.atbb.forum.board",
65 record: {
66 $type: "space.atbb.forum.board",
67 name: input.name,
68 ...(input.description && { description: input.description }),
69 slug,
70 ...(input.sortOrder !== undefined && { sortOrder: input.sortOrder }),
71 // categoryRef shape: { category: strongRef }
72 category: {
73 category: {
74 uri: input.categoryUri,
75 cid: input.categoryCid,
76 },
77 },
78 createdAt: now.toISOString(),
79 },
80 });
81
82 const rkey = response.data.uri.split("/").pop()!;
83
84 await db.insert(boards).values({
85 did: forumDid,
86 rkey,
87 cid: response.data.cid,
88 name: input.name,
89 description: input.description ?? null,
90 slug,
91 sortOrder: input.sortOrder ?? null,
92 categoryId: input.categoryId,
93 categoryUri: input.categoryUri,
94 createdAt: now,
95 indexedAt: now,
96 });
97
98 return {
99 created: true,
100 skipped: false,
101 uri: response.data.uri,
102 cid: response.data.cid,
103 };
104}