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 e749db1438a06bfdd8a9d9095f4d63a36cf9bf73 151 lines 4.5 kB view raw
1import type { AppContext } from "./app-context.js"; 2import type { Agent } from "@atproto/api"; 3import { memberships, forums, roles } from "@atbb/db"; 4import { eq, and, asc } from "drizzle-orm"; 5import { TID } from "@atproto/common-web"; 6 7export async function createMembershipForUser( 8 ctx: AppContext, 9 agent: Agent, 10 did: string 11): Promise<{ created: boolean; uri?: string; cid?: string }> { 12 // Fetch forum metadata (need URI and CID for strongRef) 13 const [forum] = await ctx.db 14 .select() 15 .from(forums) 16 .where(and(eq(forums.rkey, "self"), eq(forums.did, ctx.config.forumDid))) 17 .limit(1); 18 19 if (!forum) { 20 throw new Error("Forum not found"); 21 } 22 23 const forumUri = `at://${forum.did}/space.atbb.forum.forum/${forum.rkey}`; 24 25 // Check if membership already exists 26 const existing = await ctx.db 27 .select() 28 .from(memberships) 29 .where(and(eq(memberships.did, did), eq(memberships.forumUri, forumUri))) 30 .limit(1); 31 32 if (existing.length > 0) { 33 const [membership] = existing; 34 35 // Bootstrap memberships (created by `atbb init`) have no backing PDS 36 // record. Upgrade them by writing a real record to the user's PDS and 37 // updating the DB row with the actual rkey/cid. 38 if (membership.cid === "bootstrap") { 39 return upgradeBootstrapMembership(ctx, agent, did, forumUri, forum.cid, membership.id); 40 } 41 42 return { created: false }; 43 } 44 45 // Look up the default "Member" role to assign on first login. 46 // Wrapped in try-catch so a transient DB error does not prevent membership creation. 47 let defaultRoleRef: { uri: string; cid: string } | null = null; 48 try { 49 const [memberRole] = await ctx.db 50 .select({ rkey: roles.rkey, cid: roles.cid }) 51 .from(roles) 52 .where(and(eq(roles.did, ctx.config.forumDid), eq(roles.name, "Member"))) 53 .orderBy(asc(roles.indexedAt)) 54 .limit(1); 55 56 if (memberRole) { 57 defaultRoleRef = { 58 uri: `at://${ctx.config.forumDid}/space.atbb.forum.role/${memberRole.rkey}`, 59 cid: memberRole.cid, 60 }; 61 } else { 62 ctx.logger.error("Member role not found in DB — creating membership without role. User will have no permissions. Run seedDefaultRoles to fix.", { 63 operation: "createMembershipForUser", 64 did, 65 forumDid: ctx.config.forumDid, 66 }); 67 } 68 } catch (error) { 69 if (error instanceof TypeError || error instanceof ReferenceError || error instanceof SyntaxError) { 70 throw error; 71 } 72 ctx.logger.warn("Member role lookup failed — creating membership without role", { 73 operation: "createMembershipForUser", 74 did, 75 error: error instanceof Error ? error.message : String(error), 76 }); 77 } 78 79 return writeMembershipRecord(agent, did, forumUri, forum.cid, defaultRoleRef); 80} 81 82async function writeMembershipRecord( 83 agent: Agent, 84 did: string, 85 forumUri: string, 86 forumCid: string, 87 defaultRoleRef: { uri: string; cid: string } | null = null 88): Promise<{ created: boolean; uri?: string; cid?: string }> { 89 const rkey = TID.nextStr(); 90 const now = new Date().toISOString(); 91 92 const record: Record<string, unknown> = { 93 $type: "space.atbb.membership", 94 forum: { 95 forum: { uri: forumUri, cid: forumCid }, 96 }, 97 createdAt: now, 98 joinedAt: now, 99 }; 100 101 if (defaultRoleRef) { 102 record.role = { role: { uri: defaultRoleRef.uri, cid: defaultRoleRef.cid } }; 103 } 104 105 const result = await agent.com.atproto.repo.putRecord({ 106 repo: did, 107 collection: "space.atbb.membership", 108 rkey, 109 record, 110 }); 111 112 return { created: true, uri: result.data.uri, cid: result.data.cid }; 113} 114 115async function upgradeBootstrapMembership( 116 ctx: AppContext, 117 agent: Agent, 118 did: string, 119 forumUri: string, 120 forumCid: string, 121 membershipId: bigint 122): Promise<{ created: boolean; uri?: string; cid?: string }> { 123 const rkey = TID.nextStr(); 124 const now = new Date().toISOString(); 125 126 const result = await agent.com.atproto.repo.putRecord({ 127 repo: did, 128 collection: "space.atbb.membership", 129 rkey, 130 record: { 131 $type: "space.atbb.membership", 132 forum: { 133 forum: { uri: forumUri, cid: forumCid }, 134 }, 135 createdAt: now, 136 joinedAt: now, 137 }, 138 }); 139 140 // Update the bootstrap row with PDS-backed values, preserving roleUri 141 await ctx.db 142 .update(memberships) 143 .set({ 144 rkey, 145 cid: result.data.cid, 146 indexedAt: new Date(), 147 }) 148 .where(eq(memberships.id, membershipId)); 149 150 return { created: true, uri: result.data.uri, cid: result.data.cid }; 151}