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

feat(appview): add POST /api/admin/themes/:rkey/duplicate — clone theme with new TID (ATB-58)

+169
+55
apps/appview/src/routes/__tests__/admin.test.ts
··· 3051 3051 }); 3052 3052 }); 3053 3053 3054 + describe("POST /api/admin/themes/:rkey/duplicate", () => { 3055 + beforeEach(async () => { 3056 + await ctx.cleanDatabase(); 3057 + await ctx.db.insert(themes).values({ 3058 + did: ctx.config.forumDid, 3059 + rkey: "3lblsource1aa", 3060 + cid: "bafysource1", 3061 + name: "Neobrutal Light", 3062 + colorScheme: "light", 3063 + tokens: { "color-bg": "#f5f0e8", "color-primary": "#ff5c00" }, 3064 + createdAt: new Date(), 3065 + indexedAt: new Date(), 3066 + }); 3067 + }); 3068 + 3069 + it("calls putRecord with a new rkey and '(Copy)' name", async () => { 3070 + mockPutRecord.mockResolvedValueOnce({ 3071 + data: { uri: "at://did:plc:test-forum/space.atbb.forum.theme/3lblcopy001a", cid: "bafycopy1" }, 3072 + }); 3073 + 3074 + const res = await app.request("/api/admin/themes/3lblsource1aa/duplicate", { 3075 + method: "POST", 3076 + }); 3077 + 3078 + expect(res.status).toBe(201); 3079 + const body = await res.json(); 3080 + expect(body.name).toBe("Neobrutal Light (Copy)"); 3081 + expect(body.rkey).toBeDefined(); 3082 + expect(body.rkey).not.toBe("3lblsource1aa"); 3083 + expect(body.uri).toContain("space.atbb.forum.theme"); 3084 + 3085 + expect(mockPutRecord).toHaveBeenCalledOnce(); 3086 + const putCall = mockPutRecord.mock.calls[0][0]; 3087 + expect(putCall.record.name).toBe("Neobrutal Light (Copy)"); 3088 + expect(putCall.record.colorScheme).toBe("light"); 3089 + expect(putCall.record.tokens).toEqual({ "color-bg": "#f5f0e8", "color-primary": "#ff5c00" }); 3090 + expect(putCall.collection).toBe("space.atbb.forum.theme"); 3091 + }); 3092 + 3093 + it("returns 404 when source rkey does not exist", async () => { 3094 + const res = await app.request("/api/admin/themes/nonexistent/duplicate", { 3095 + method: "POST", 3096 + }); 3097 + expect(res.status).toBe(404); 3098 + }); 3099 + 3100 + it("returns 401 when not authenticated", async () => { 3101 + mockUser = null; 3102 + const res = await app.request("/api/admin/themes/3lblsource1aa/duplicate", { 3103 + method: "POST", 3104 + }); 3105 + expect(res.status).toBe(401); 3106 + }); 3107 + }); 3108 + 3054 3109 describe("PUT /api/admin/theme-policy", () => { 3055 3110 const lightUri = `at://did:plc:test-forum/space.atbb.forum.theme/3lbllight1`; 3056 3111 const darkUri = `at://did:plc:test-forum/space.atbb.forum.theme/3lbldark11`;
+73
apps/appview/src/routes/admin.ts
··· 1289 1289 ); 1290 1290 1291 1291 /** 1292 + * POST /api/admin/themes/:rkey/duplicate 1293 + * 1294 + * Clones an existing theme record with " (Copy)" appended to the name. 1295 + * Uses a fresh TID as the new record key. 1296 + * The firehose indexer will create the DB row asynchronously. 1297 + */ 1298 + app.post( 1299 + "/themes/:rkey/duplicate", 1300 + requireAuth(ctx), 1301 + requirePermission(ctx, "space.atbb.permission.manageThemes"), 1302 + async (c) => { 1303 + const sourceRkey = c.req.param("rkey").trim(); 1304 + 1305 + let source: typeof themes.$inferSelect; 1306 + try { 1307 + const [row] = await ctx.db 1308 + .select() 1309 + .from(themes) 1310 + .where(and(eq(themes.did, ctx.config.forumDid), eq(themes.rkey, sourceRkey))) 1311 + .limit(1); 1312 + 1313 + if (!row) { 1314 + return c.json({ error: "Theme not found" }, 404); 1315 + } 1316 + source = row; 1317 + } catch (error) { 1318 + return handleRouteError(c, error, "Failed to look up source theme", { 1319 + operation: "POST /api/admin/themes/:rkey/duplicate", 1320 + logger: ctx.logger, 1321 + sourceRkey, 1322 + }); 1323 + } 1324 + 1325 + const { agent, error: agentError } = getForumAgentOrError( 1326 + ctx, 1327 + c, 1328 + "POST /api/admin/themes/:rkey/duplicate" 1329 + ); 1330 + if (agentError) return agentError; 1331 + 1332 + const newRkey = TID.nextStr(); 1333 + const newName = `${source.name} (Copy)`; 1334 + const now = new Date().toISOString(); 1335 + 1336 + try { 1337 + const result = await agent.com.atproto.repo.putRecord({ 1338 + repo: ctx.config.forumDid, 1339 + collection: "space.atbb.forum.theme", 1340 + rkey: newRkey, 1341 + record: { 1342 + $type: "space.atbb.forum.theme", 1343 + name: newName, 1344 + colorScheme: source.colorScheme, 1345 + tokens: source.tokens, 1346 + ...(source.cssOverrides && { cssOverrides: source.cssOverrides }), 1347 + ...(source.fontUrls && { fontUrls: source.fontUrls }), 1348 + createdAt: now, 1349 + }, 1350 + }); 1351 + 1352 + return c.json({ uri: result.data.uri, rkey: newRkey, name: newName }, 201); 1353 + } catch (error) { 1354 + return handleRouteError(c, error, "Failed to duplicate theme", { 1355 + operation: "POST /api/admin/themes/:rkey/duplicate", 1356 + logger: ctx.logger, 1357 + sourceRkey, 1358 + newRkey, 1359 + }); 1360 + } 1361 + } 1362 + ); 1363 + 1364 + /** 1292 1365 * PUT /api/admin/theme-policy 1293 1366 * 1294 1367 * Create or update the themePolicy singleton (rkey: "self") on Forum DID's PDS.
+41
bruno/AppView API/Admin Themes/Duplicate Theme.bru
··· 1 + meta { 2 + name: Duplicate Theme 3 + type: http 4 + seq: 4 5 + } 6 + 7 + post { 8 + url: {{appview_url}}/api/admin/themes/{{theme_rkey}}/duplicate 9 + } 10 + 11 + assert { 12 + res.status: eq 201 13 + res.body.uri: isDefined 14 + res.body.rkey: isDefined 15 + res.body.name: isDefined 16 + } 17 + 18 + docs { 19 + Clone an existing theme record with " (Copy)" appended to the name. 20 + A fresh TID is generated as the new record key. 21 + The firehose indexer creates the DB row asynchronously. 22 + 23 + **Requires:** space.atbb.permission.manageThemes 24 + 25 + Path params: 26 + - rkey: Source theme record key (TID) 27 + 28 + Returns (201): 29 + { 30 + "uri": "at://did:plc:.../space.atbb.forum.theme/newrkey123", 31 + "rkey": "newrkey123", 32 + "name": "Original Name (Copy)" 33 + } 34 + 35 + Error codes: 36 + - 401: Not authenticated 37 + - 403: Missing manageThemes permission 38 + - 404: Source theme not found 39 + - 500: ForumAgent not configured (server configuration issue) 40 + - 503: ForumAgent not authenticated or PDS network error 41 + }