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
This commit is contained in:
Thomas Camlong committed 2026-06-09 00:18:25 +02:00
1 parent e1cf9f167c
commit 39bef6dc00
1 file changed
+132 -2
+132 -2
View File
@@ -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<boolean>({ 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<boolean>({ 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 };
}),
});
/**