From d227fbff16d9619f0c2ee1d4a916323d65a8c3c2 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Fri, 13 Feb 2026 14:56:39 -0700 Subject: [PATCH] feat: add POST /api/communities/:slug/shapes endpoint Enables external apps (e.g. rNotes) to push shapes to a canvas via REST API. Shapes are added in a single Automerge change and broadcast to connected WebSocket clients for real-time sync. Co-Authored-By: Claude Opus 4.6 --- server/community-store.ts | 35 +++++++++++++++++ server/index.ts | 81 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 116 insertions(+) diff --git a/server/community-store.ts b/server/community-store.ts index 3a82263..a16cc55 100644 --- a/server/community-store.ts +++ b/server/community-store.ts @@ -24,6 +24,7 @@ export interface ShapeData { content?: string; sourceId?: string; targetId?: string; + [key: string]: unknown; // Allow additional shape-specific properties } export interface CommunityDoc { @@ -348,6 +349,40 @@ export function getDocumentData(slug: string): CommunityDoc | null { return JSON.parse(JSON.stringify(doc)); } +/** + * Add multiple shapes in a single Automerge change (for external API calls) + */ +export function addShapes( + slug: string, + shapes: Record[], +): string[] { + const doc = communities.get(slug); + if (!doc) return []; + + const ids: string[] = []; + const newDoc = Automerge.change(doc, `Add ${shapes.length} shapes`, (d) => { + if (!d.shapes) d.shapes = {}; + for (const shape of shapes) { + const id = (shape.id as string) || `shape-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; + ids.push(id); + d.shapes[id] = { + type: (shape.type as string) || 'geo', + id, + x: (shape.x as number) ?? 100, + y: (shape.y as number) ?? 100, + width: (shape.width as number) ?? 300, + height: (shape.height as number) ?? 200, + rotation: (shape.rotation as number) ?? 0, + ...shape, + id, // ensure id override + } as ShapeData; + } + }); + communities.set(slug, newDoc); + saveCommunity(slug); + return ids; +} + // Legacy functions for backward compatibility export function updateShape( diff --git a/server/index.ts b/server/index.ts index 4896f22..3e71ea7 100644 --- a/server/index.ts +++ b/server/index.ts @@ -1,6 +1,7 @@ import { resolve } from "node:path"; import type { ServerWebSocket } from "bun"; import { + addShapes, communityExists, createCommunity, deleteShape, @@ -478,6 +479,86 @@ async function handleAPI(req: Request, url: URL): Promise { ); } + // POST /api/communities/:slug/shapes - Add shapes to a community canvas + if ( + url.pathname.match(/^\/api\/communities\/[^/]+\/shapes$/) && + req.method === "POST" + ) { + const slug = url.pathname.split("/")[3]; + + // Check space access (write required) + const token = extractToken(req.headers); + const access = await evaluateSpaceAccess(slug, token, req.method, { + getSpaceConfig, + }); + + if (!access.allowed) { + return Response.json( + { error: access.reason }, + { status: access.claims ? 403 : 401, headers: corsHeaders } + ); + } + + if (access.readOnly) { + return Response.json( + { error: "Write access required to add shapes" }, + { status: 403, headers: corsHeaders } + ); + } + + try { + // Ensure community is loaded + await loadCommunity(slug); + const data = getDocumentData(slug); + if (!data) { + return Response.json( + { error: "Community not found" }, + { status: 404, headers: corsHeaders } + ); + } + + const body = (await req.json()) as { shapes?: Record[] }; + if (!body.shapes || !Array.isArray(body.shapes) || body.shapes.length === 0) { + return Response.json( + { error: "shapes array is required and must not be empty" }, + { status: 400, headers: corsHeaders } + ); + } + + // Add shapes to the Automerge document + const ids = addShapes(slug, body.shapes); + + // Broadcast sync messages to all connected WebSocket clients + const clients = communityClients.get(slug); + if (clients) { + for (const [peerId, client] of clients) { + if (client.readyState === WebSocket.OPEN) { + const syncMsg = generateSyncMessageForPeer(slug, peerId); + if (syncMsg) { + client.send( + JSON.stringify({ + type: "sync", + data: Array.from(syncMsg), + }) + ); + } + } + } + } + + return Response.json( + { ok: true, ids }, + { status: 201, headers: corsHeaders } + ); + } catch (e) { + console.error("Failed to add shapes:", e); + return Response.json( + { error: "Failed to add shapes" }, + { status: 500, headers: corsHeaders } + ); + } + } + return Response.json({ error: "Not found" }, { status: 404, headers: corsHeaders }); }