fix assistant widget configuration
This commit is contained in:
1 parent
bd3e4c33f3
commit
35673f113e
20 files changed
+762
-12
No files matched your search
@@ -36,6 +36,9 @@ import {
|
||||
getPermissionsWithChildren,
|
||||
getPermissionsWithParents,
|
||||
widgetDefaultSizes,
|
||||
widgetIntegrationLimits,
|
||||
widgetIntegrationSupport,
|
||||
widgetKindsWithOptionalIntegrations,
|
||||
widgetKinds,
|
||||
} from "@homarr/definitions";
|
||||
import { importOldmarrAsync } from "@homarr/old-import";
|
||||
@@ -63,6 +66,7 @@ import { sectionSchema, sharedItemSchema } from "@homarr/validation/shared";
|
||||
import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure, publicProcedure } from "../trpc";
|
||||
import { throwIfActionForbiddenAsync } from "./board/board-access";
|
||||
import { generateResponsiveGridFor } from "./board/grid-algorithm";
|
||||
import { throwIfActionForbiddenAsync as throwIfIntegrationActionForbiddenAsync } from "./integration/integration-access";
|
||||
|
||||
export const boardRouter = createTRPCRouter({
|
||||
exists: permissionRequiredProcedure
|
||||
@@ -1485,7 +1489,7 @@ export const boardRouter = createTRPCRouter({
|
||||
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. To create a formatted dashboard note, use kind 'notebook' with options { content: Tiptap-compatible HTML, showToolbar: boolean, allowReadOnlyCheck: boolean }. Notebook content may use semantic paragraphs, headings, lists, task lists, blockquotes, tables, links, emphasis, and images, but must not contain scripts, styles, iframes, event handlers, or unsafe URL protocols. Returns { itemId }",
|
||||
"Add a widget/app item to a board after configure_widget has reviewed it. Automatically places it in the first empty section at the next free grid position. Use the configure_widget result's boardId, kind, options, and integrationIds exactly. Integration IDs must be accessible to the current user. To create a formatted dashboard note, configure kind 'notebook' with options { content: Tiptap-compatible HTML, showToolbar: boolean, allowReadOnlyCheck: boolean }. Returns { itemId }",
|
||||
},
|
||||
})
|
||||
.input(addItemToBoardSchema)
|
||||
@@ -1493,9 +1497,28 @@ export const boardRouter = createTRPCRouter({
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.boardId), "modify");
|
||||
|
||||
const supportedIntegrations = widgetIntegrationSupport[input.kind];
|
||||
const integrationLimit = widgetIntegrationLimits[input.kind] ?? Infinity;
|
||||
if (input.integrationIds.length > integrationLimit) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: `${input.kind} supports at most ${integrationLimit} integration${integrationLimit === 1 ? "" : "s"}`,
|
||||
});
|
||||
}
|
||||
if (supportedIntegrations === undefined && input.integrationIds.length > 0) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: `${input.kind} does not support integrations` });
|
||||
}
|
||||
if (
|
||||
supportedIntegrations !== undefined &&
|
||||
input.integrationIds.length === 0 &&
|
||||
!widgetKindsWithOptionalIntegrations.has(input.kind)
|
||||
) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: `${input.kind} requires an integration` });
|
||||
}
|
||||
|
||||
if (input.integrationIds.length > 0) {
|
||||
const existing = await ctx.db.query.integrations.findMany({
|
||||
columns: { id: true },
|
||||
columns: { id: true, kind: true },
|
||||
where: inArray(integrations.id, input.integrationIds),
|
||||
});
|
||||
const validIds = new Set(existing.map((row) => row.id));
|
||||
@@ -1503,6 +1526,20 @@ export const boardRouter = createTRPCRouter({
|
||||
if (invalid.length > 0) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: `Invalid integration IDs: ${invalid.join(", ")}` });
|
||||
}
|
||||
|
||||
await Promise.all(
|
||||
input.integrationIds.map((integrationId) =>
|
||||
throwIfIntegrationActionForbiddenAsync(ctx, eq(integrations.id, integrationId), "use"),
|
||||
),
|
||||
);
|
||||
|
||||
const incompatible = existing.filter((integration) => !supportedIntegrations?.includes(integration.kind));
|
||||
if (incompatible.length > 0) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: `${input.kind} does not support integration kind${incompatible.length === 1 ? "" : "s"}: ${incompatible.map((integration) => integration.kind).join(", ")}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const board = await ctx.db.query.boards.findFirst({
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
// @vitest-environment node
|
||||
|
||||
import { parse } from "superjson";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import type { Session } from "@homarr/auth";
|
||||
import { createId } from "@homarr/common";
|
||||
import { boards, integrations, integrationUserPermissions, items, layouts, sections, users } from "@homarr/db/schema";
|
||||
import { createDb } from "@homarr/db/test";
|
||||
|
||||
import { boardRouter } from "../board";
|
||||
|
||||
const createBoardCallerAsync = async () => {
|
||||
const db = createDb();
|
||||
const userId = createId();
|
||||
const boardId = createId();
|
||||
await db.insert(users).values({ id: userId });
|
||||
await db.insert(boards).values({ id: boardId, name: "Home", creatorId: userId });
|
||||
await db.insert(layouts).values({
|
||||
id: createId(),
|
||||
name: "Base",
|
||||
boardId,
|
||||
columnCount: 12,
|
||||
breakpoint: 0,
|
||||
});
|
||||
await db.insert(sections).values({ id: createId(), boardId, kind: "empty", xOffset: 0, yOffset: 0 });
|
||||
const session = {
|
||||
user: { id: userId, permissions: [], colorScheme: "light" },
|
||||
expires: new Date().toISOString(),
|
||||
} satisfies Session;
|
||||
return { db, userId, boardId, caller: boardRouter.createCaller({ db, deviceType: undefined, session }) };
|
||||
};
|
||||
|
||||
describe("board.addItem", () => {
|
||||
test("preserves configured notebook content when placing the widget", async () => {
|
||||
const { db, boardId, caller } = await createBoardCallerAsync();
|
||||
const content = "<h2>Self-host Plex</h2><p>Install Docker and configure your media volumes.</p>";
|
||||
|
||||
const result = await caller.addItem({
|
||||
boardId,
|
||||
kind: "notebook",
|
||||
options: { content, showToolbar: true, allowReadOnlyCheck: true },
|
||||
integrationIds: [],
|
||||
});
|
||||
|
||||
const item = await db.query.items.findFirst({ where: (table, { eq }) => eq(table.id, result.itemId) });
|
||||
expect(item?.kind).toBe("notebook");
|
||||
expect(parse(item?.options ?? "{}")).toEqual({
|
||||
content,
|
||||
showToolbar: true,
|
||||
allowReadOnlyCheck: true,
|
||||
});
|
||||
});
|
||||
|
||||
test("accepts an integration the current user can use", async () => {
|
||||
const { db, userId, boardId, caller } = await createBoardCallerAsync();
|
||||
const integrationId = createId();
|
||||
await db.insert(integrations).values({
|
||||
id: integrationId,
|
||||
name: "Plex",
|
||||
kind: "plex",
|
||||
url: "https://plex.example.com",
|
||||
});
|
||||
await db.insert(integrationUserPermissions).values({ integrationId, userId, permission: "use" });
|
||||
|
||||
await expect(
|
||||
caller.addItem({ boardId, kind: "mediaServer", options: {}, integrationIds: [integrationId] }),
|
||||
).resolves.toMatchObject({ itemId: expect.any(String) });
|
||||
});
|
||||
|
||||
test("rejects an integration the current user cannot use", async () => {
|
||||
const { db, boardId, caller } = await createBoardCallerAsync();
|
||||
const integrationId = createId();
|
||||
await db.insert(integrations).values({
|
||||
id: integrationId,
|
||||
name: "Private Plex",
|
||||
kind: "plex",
|
||||
url: "https://private.example.com",
|
||||
});
|
||||
|
||||
await expect(
|
||||
caller.addItem({ boardId, kind: "mediaServer", options: {}, integrationIds: [integrationId] }),
|
||||
).rejects.toThrow("Integration not found");
|
||||
|
||||
await expect(db.$count(items)).resolves.toBe(0);
|
||||
});
|
||||
|
||||
test("rejects a missing required integration", async () => {
|
||||
const { db, boardId, caller } = await createBoardCallerAsync();
|
||||
|
||||
await expect(caller.addItem({ boardId, kind: "mediaServer", options: {}, integrationIds: [] })).rejects.toThrow(
|
||||
"mediaServer requires an integration",
|
||||
);
|
||||
|
||||
await expect(db.$count(items)).resolves.toBe(0);
|
||||
});
|
||||
|
||||
test("allows an explicitly optional integration to be omitted", async () => {
|
||||
const { boardId, caller } = await createBoardCallerAsync();
|
||||
|
||||
await expect(caller.addItem({ boardId, kind: "calendar", options: {}, integrationIds: [] })).resolves.toMatchObject(
|
||||
{ itemId: expect.any(String) },
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects an incompatible integration kind", async () => {
|
||||
const { db, userId, boardId, caller } = await createBoardCallerAsync();
|
||||
const integrationId = createId();
|
||||
await db.insert(integrations).values({
|
||||
id: integrationId,
|
||||
name: "Sonarr",
|
||||
kind: "sonarr",
|
||||
url: "https://sonarr.example.com",
|
||||
});
|
||||
await db.insert(integrationUserPermissions).values({ integrationId, userId, permission: "use" });
|
||||
|
||||
await expect(
|
||||
caller.addItem({ boardId, kind: "mediaServer", options: {}, integrationIds: [integrationId] }),
|
||||
).rejects.toThrow("mediaServer does not support integration kind: sonarr");
|
||||
|
||||
await expect(db.$count(items)).resolves.toBe(0);
|
||||
});
|
||||
|
||||
test("rejects more integrations than the widget supports", async () => {
|
||||
const { db, userId, boardId, caller } = await createBoardCallerAsync();
|
||||
const navidromeId = createId();
|
||||
const audiobookshelfId = createId();
|
||||
await db.insert(integrations).values([
|
||||
{ id: navidromeId, name: "Navidrome", kind: "navidrome", url: "https://music.example.com" },
|
||||
{
|
||||
id: audiobookshelfId,
|
||||
name: "Audiobookshelf",
|
||||
kind: "audiobookshelf",
|
||||
url: "https://books.example.com",
|
||||
},
|
||||
]);
|
||||
await db.insert(integrationUserPermissions).values([
|
||||
{ integrationId: navidromeId, userId, permission: "use" },
|
||||
{ integrationId: audiobookshelfId, userId, permission: "use" },
|
||||
]);
|
||||
|
||||
await expect(
|
||||
caller.addItem({
|
||||
boardId,
|
||||
kind: "audioStats",
|
||||
options: {},
|
||||
integrationIds: [navidromeId, audiobookshelfId],
|
||||
}),
|
||||
).rejects.toThrow("audioStats supports at most 1 integration");
|
||||
|
||||
await expect(db.$count(items)).resolves.toBe(0);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user