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:
1 file changed
+132
-2
@@ -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 };
|
||||
}),
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
Reference in new issue
Block a user