From 39bef6dc0035a8a1311a74f1478daa09ff65a834 Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Tue, 9 Jun 2026 00:18:13 +0200 Subject: [PATCH] feat(api): expose board CRUD via REST/OpenAPI and add addItem endpoint Annotate getAllBoards (GET /api/boards), createBoard (POST /api/boards), and deleteBoard (DELETE /api/boards/{id}) with OpenAPI and MCP metadata. Add new board.addItem mutation (POST /api/boards/items) that places a widget in the first empty section at the next free grid position, with integration ID validation and full-board error handling. Closes #5867 --- packages/api/src/router/board.ts | 134 ++++++++++++++++++++++++++++++- 1 file changed, 132 insertions(+), 2 deletions(-) diff --git a/packages/api/src/router/board.ts b/packages/api/src/router/board.ts index 495d410c7..42e7b9267 100644 --- a/packages/api/src/router/board.ts +++ b/packages/api/src/router/board.ts @@ -19,6 +19,7 @@ import { integrationGroupPermissions, integrationItems, integrationUserPermissions, + integrations, itemLayouts, items, layouts, @@ -39,6 +40,7 @@ import { importOldmarrAsync } from "@homarr/old-import"; import { importJsonFileSchema } from "@homarr/old-import/shared"; import { oldmarrConfigSchema } from "@homarr/old-schema"; import { + addItemToBoardSchema, boardByNameSchema, boardChangeVisibilitySchema, boardCreateSchema, @@ -48,6 +50,7 @@ import { boardSavePartialSettingsSchema, boardSavePermissionsSchema, boardSaveSchema, + boardSummarySchema, } from "@homarr/validation/board"; import { byIdSchema } from "@homarr/validation/common"; import { zodUnionFromArray } from "@homarr/validation/enums"; @@ -114,7 +117,14 @@ export const boardRouter = createTRPCRouter({ where: boardWhere, }); }), - getAllBoards: publicProcedure.query(async ({ ctx }) => { + getAllBoards: publicProcedure + .input(z.void()) + .output(z.array(boardSummarySchema)) + .meta({ + openapi: { method: "GET", path: "/api/boards", tags: ["boards"], protect: true }, + mcp: { enabled: true, description: "List all boards the current user can access. Returns id, name, logoImageUrl, isPublic, creator, isHome and isMobileHome flags" }, + }) + .query(async ({ ctx }) => { const userId = ctx.session?.user.id; const permissionsOfCurrentUserWhenPresent = await ctx.db.query.boardUserPermissions.findMany({ where: eq(boardUserPermissions.userId, userId ?? ""), @@ -262,7 +272,12 @@ export const boardRouter = createTRPCRouter({ }), createBoard: permissionRequiredProcedure .requiresPermission("board-create") + .meta({ + openapi: { method: "POST", path: "/api/boards", tags: ["boards"], protect: true }, + mcp: { enabled: true, description: "Create a new board with a name, column count (1-24), and isPublic flag. Returns { boardId }. Requires board-create permission" }, + }) .input(boardCreateSchema) + .output(z.object({ boardId: z.string() })) .mutation(async ({ ctx, input }) => { const boardId = createId(); @@ -547,7 +562,14 @@ export const boardRouter = createTRPCRouter({ .set({ isPublic: input.visibility === "public" }) .where(eq(boards.id, input.id)); }), - deleteBoard: protectedProcedure.input(z.object({ id: z.string() })).mutation(async ({ ctx, input }) => { + deleteBoard: protectedProcedure + .meta({ + openapi: { method: "DELETE", path: "/api/boards/{id}", tags: ["boards"], protect: true }, + mcp: { enabled: true, description: "Delete a board by its ID. Requires full permission on the board. Use board_getAllBoards to find the board ID" }, + }) + .input(z.object({ id: z.string() })) + .output(z.void()) + .mutation(async ({ ctx, input }) => { await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "full"); await ctx.db.delete(boards).where(eq(boards.id, input.id)); @@ -1327,6 +1349,114 @@ export const boardRouter = createTRPCRouter({ const oldmarr = oldmarrConfigSchema.parse(JSON.parse(content)); await importOldmarrAsync(ctx.db, oldmarr, input.configuration); }), + addItem: protectedProcedure + .meta({ + openapi: { method: "POST", path: "/api/boards/items", tags: ["boards"], protect: true }, + mcp: { + enabled: true, + description: + "Add a widget/app item to a board. Automatically places it in the first empty section at the next free grid position. Provide boardId (from board_getAllBoards), kind (widget type like 'app', 'weather', etc.), optional options map, and optional integrationIds array. Returns { itemId }", + }, + }) + .input(addItemToBoardSchema) + .output(z.object({ itemId: z.string() })) + .mutation(async ({ ctx, input }) => { + await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.boardId), "modify"); + + if (input.integrationIds.length > 0) { + const existing = await ctx.db.query.integrations.findMany({ + columns: { id: true }, + where: inArray(integrations.id, input.integrationIds), + }); + const validIds = new Set(existing.map((row) => row.id)); + const invalid = input.integrationIds.filter((id) => !validIds.has(id)); + if (invalid.length > 0) { + throw new TRPCError({ code: "BAD_REQUEST", message: `Invalid integration IDs: ${invalid.join(", ")}` }); + } + } + + const board = await ctx.db.query.boards.findFirst({ + where: eq(boards.id, input.boardId), + with: { + sections: true, + layouts: true, + items: { with: { layouts: true } }, + }, + }); + + if (!board) { + throw new TRPCError({ code: "NOT_FOUND", message: "Board not found" }); + } + + const emptySection = board.sections + .filter((s) => s.kind === "empty") + .sort((a, b) => (a.yOffset ?? 0) - (b.yOffset ?? 0))[0]; + + if (!emptySection) { + throw new TRPCError({ code: "BAD_REQUEST", message: "Board has no empty section to place items in" }); + } + + const itemId = createId(); + + await ctx.db.insert(items).values({ + id: itemId, + boardId: input.boardId, + kind: input.kind, + options: superjson.stringify(input.options), + advancedOptions: emptySuperJSON, + }); + + const layoutRows: (typeof itemLayouts.$inferInsert)[] = []; + + for (const layout of board.layouts) { + const existingInSection = board.items + .flatMap((item) => item.layouts) + .filter((il) => il.sectionId === emptySection.id && il.layoutId === layout.id); + + const occupied: boolean[][] = []; + for (const il of existingInSection) { + for (let y = il.yOffset; y < il.yOffset + il.height; y++) { + while (occupied.length <= y) occupied.push(Array.from({ length: layout.columnCount }).fill(false)); + for (let x = il.xOffset; x < il.xOffset + il.width; x++) { + occupied[y]![x] = true; + } + } + } + + let placed = false; + for (let y = 0; y < 9999 && !placed; y++) { + if (!occupied[y]) occupied.push(Array.from({ length: layout.columnCount }).fill(false)); + for (let x = 0; x < layout.columnCount && !placed; x++) { + if (!occupied[y]![x]) { + layoutRows.push({ + itemId, + sectionId: emptySection.id, + layoutId: layout.id, + xOffset: x, + yOffset: y, + width: 1, + height: 1, + }); + placed = true; + } + } + } + + if (!placed) { + throw new TRPCError({ code: "BAD_REQUEST", message: "Board section is full, no free grid position available" }); + } + } + + if (layoutRows.length > 0) { + await ctx.db.insert(itemLayouts).values(layoutRows); + } + + if (input.integrationIds.length > 0) { + await ctx.db.insert(integrationItems).values(input.integrationIds.map((integrationId) => ({ itemId, integrationId }))); + } + + return { itemId }; + }), }); /**