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 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-02-13 14:56:39 -07:00
parent 426e05d631
commit d227fbff16
2 changed files with 116 additions and 0 deletions

View File

@ -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, unknown>[],
): 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(

View File

@ -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<Response> {
);
}
// 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<string, unknown>[] };
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 });
}