feat: complete automatic mobile board UX

This commit is contained in:
ajnart committed 2026-07-28 19:39:13 +02:00
1 parent cf83e0d849
commit 7959c20d88
87 files changed
+2850 -570

No files matched your search

+258 -29
View File
@@ -49,6 +49,7 @@ import {
boardDuplicateSchema,
boardRenameSchema,
boardSaveLayoutSchema,
boardSaveLayoutsSchema,
boardSavePartialSettingsSchema,
boardSavePermissionsSchema,
boardSaveSchema,
@@ -687,7 +688,7 @@ export const boardRouter = createTRPCRouter({
mcp: {
enabled: true,
description:
"Set a board's desktop column count and remove legacy responsive layouts. Requires modify permission. REQUIRED: id (board ID), columnCount (1-24)",
"Set a board's desktop column count while preserving saved responsive layouts. Requires modify permission. REQUIRED: id (board ID), columnCount (1-24)",
},
})
.input(boardSaveLayoutSchema)
@@ -761,12 +762,8 @@ export const boardRouter = createTRPCRouter({
await transaction
.update(schema.layouts)
.set({ name: "Base", columnCount: input.columnCount, breakpoint: 0 })
.set({ columnCount: input.columnCount })
.where(eq(schema.layouts.id, desktopLayout.id));
await transaction
.delete(schema.layouts)
.where(and(eq(schema.layouts.boardId, board.id), not(eq(schema.layouts.id, desktopLayout.id))));
});
},
handleSync(db) {
@@ -811,18 +808,230 @@ export const boardRouter = createTRPCRouter({
transaction
.update(layouts)
.set({ name: "Base", columnCount: input.columnCount, breakpoint: 0 })
.set({ columnCount: input.columnCount })
.where(eq(layouts.id, desktopLayout.id))
.run();
transaction
.delete(layouts)
.where(and(eq(layouts.boardId, board.id), not(eq(layouts.id, desktopLayout.id))))
.run();
});
},
});
}),
saveLayouts: protectedProcedure
.input(boardSaveLayoutsSchema)
.output(boardSaveLayoutsSchema.shape.layouts)
.mutation(async ({ ctx, input }) => {
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "modify");
const board = await getFullBoardWithWhereAsync(ctx.db, eq(boards.id, input.id), ctx.session.user.id);
const addedLayouts = filterAddedItems(input.layouts, board.layouts);
const layoutsToInsert: InferInsertModel<typeof layouts>[] = [];
const itemSectionLayoutsToInsert: InferInsertModel<typeof itemLayouts>[] = [];
const sectionLayoutsToInsert: InferInsertModel<typeof sectionLayouts>[] = [];
const savedLayoutIds = new Map<string, string>();
for (const addedLayout of addedLayouts) {
const layoutId = createId();
savedLayoutIds.set(addedLayout.id, layoutId);
layoutsToInsert.push({
id: layoutId,
name: addedLayout.name,
columnCount: addedLayout.columnCount,
breakpoint: addedLayout.breakpoint,
boardId: board.id,
});
const sortedLayouts = board.layouts.toSorted((layoutA, layoutB) => layoutA.columnCount - layoutB.columnCount);
const layoutToClone =
sortedLayouts.find((layout) => layout.columnCount >= addedLayout.columnCount) ?? sortedLayouts.at(-1);
if (!layoutToClone) {
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "Board must have a layout" });
}
const updatedBoardLayout = getUpdatedBoardLayout(board, {
previous: {
layoutId: layoutToClone.id,
columnCount: layoutToClone.columnCount,
},
current: {
layoutId,
columnCount: addedLayout.columnCount,
},
});
itemSectionLayoutsToInsert.push(...updatedBoardLayout.itemSectionLayouts);
sectionLayoutsToInsert.push(...updatedBoardLayout.sectionLayouts);
}
const updatedLayouts = filterUpdatedItems(input.layouts, board.layouts);
const itemSectionLayoutsToUpdate: InferInsertModel<typeof itemLayouts>[] = [];
const sectionLayoutsToUpdate: InferInsertModel<typeof sectionLayouts>[] = [];
const layoutsToUpdate: typeof input.layouts = [];
for (const updatedLayout of updatedLayouts) {
const dbLayout = board.layouts.find((layout) => layout.id === updatedLayout.id);
if (!dbLayout) continue;
if (dbLayout.columnCount !== updatedLayout.columnCount) {
const updatedBoardLayout = getUpdatedBoardLayout(board, {
previous: {
layoutId: dbLayout.id,
columnCount: dbLayout.columnCount,
},
current: {
layoutId: dbLayout.id,
columnCount: updatedLayout.columnCount,
},
});
itemSectionLayoutsToUpdate.push(...updatedBoardLayout.itemSectionLayouts);
sectionLayoutsToUpdate.push(...updatedBoardLayout.sectionLayouts);
}
layoutsToUpdate.push(updatedLayout);
}
const removedLayoutIds = filterRemovedItems(input.layouts, board.layouts).map((layout) => layout.id);
await handleTransactionsAsync(ctx.db, {
async handleAsync(db, schema) {
await db.transaction(async (transaction) => {
if (layoutsToInsert.length > 0) {
await transaction.insert(schema.layouts).values(layoutsToInsert);
}
if (itemSectionLayoutsToInsert.length > 0) {
await transaction.insert(schema.itemLayouts).values(itemSectionLayoutsToInsert);
}
if (sectionLayoutsToInsert.length > 0) {
await transaction.insert(schema.sectionLayouts).values(sectionLayoutsToInsert);
}
for (const itemSectionLayout of itemSectionLayoutsToUpdate) {
await transaction
.update(schema.itemLayouts)
.set({
height: itemSectionLayout.height,
width: itemSectionLayout.width,
xOffset: itemSectionLayout.xOffset,
yOffset: itemSectionLayout.yOffset,
sectionId: itemSectionLayout.sectionId,
})
.where(
and(
eq(schema.itemLayouts.itemId, itemSectionLayout.itemId),
eq(schema.itemLayouts.layoutId, itemSectionLayout.layoutId),
),
);
}
for (const sectionLayout of sectionLayoutsToUpdate) {
await transaction
.update(schema.sectionLayouts)
.set({
height: sectionLayout.height,
width: sectionLayout.width,
xOffset: sectionLayout.xOffset,
yOffset: sectionLayout.yOffset,
parentSectionId: sectionLayout.parentSectionId,
})
.where(
and(
eq(schema.sectionLayouts.sectionId, sectionLayout.sectionId),
eq(schema.sectionLayouts.layoutId, sectionLayout.layoutId),
),
);
}
for (const layout of layoutsToUpdate) {
await transaction
.update(schema.layouts)
.set({
name: layout.name,
columnCount: layout.columnCount,
breakpoint: layout.breakpoint,
})
.where(eq(schema.layouts.id, layout.id));
}
if (removedLayoutIds.length > 0) {
await transaction.delete(schema.layouts).where(inArray(schema.layouts.id, removedLayoutIds));
}
});
},
handleSync(db) {
db.transaction((transaction) => {
if (layoutsToInsert.length > 0) {
transaction.insert(layouts).values(layoutsToInsert).run();
}
if (itemSectionLayoutsToInsert.length > 0) {
transaction.insert(itemLayouts).values(itemSectionLayoutsToInsert).run();
}
if (sectionLayoutsToInsert.length > 0) {
transaction.insert(sectionLayouts).values(sectionLayoutsToInsert).run();
}
for (const itemSectionLayout of itemSectionLayoutsToUpdate) {
transaction
.update(itemLayouts)
.set({
height: itemSectionLayout.height,
width: itemSectionLayout.width,
xOffset: itemSectionLayout.xOffset,
yOffset: itemSectionLayout.yOffset,
sectionId: itemSectionLayout.sectionId,
})
.where(
and(
eq(itemLayouts.itemId, itemSectionLayout.itemId),
eq(itemLayouts.layoutId, itemSectionLayout.layoutId),
),
)
.run();
}
for (const sectionLayout of sectionLayoutsToUpdate) {
transaction
.update(sectionLayouts)
.set({
height: sectionLayout.height,
width: sectionLayout.width,
xOffset: sectionLayout.xOffset,
yOffset: sectionLayout.yOffset,
parentSectionId: sectionLayout.parentSectionId,
})
.where(
and(
eq(sectionLayouts.sectionId, sectionLayout.sectionId),
eq(sectionLayouts.layoutId, sectionLayout.layoutId),
),
)
.run();
}
for (const layout of layoutsToUpdate) {
transaction
.update(layouts)
.set({
name: layout.name,
columnCount: layout.columnCount,
breakpoint: layout.breakpoint,
})
.where(eq(layouts.id, layout.id))
.run();
}
if (removedLayoutIds.length > 0) {
transaction.delete(layouts).where(inArray(layouts.id, removedLayoutIds)).run();
}
});
},
});
return input.layouts.map((layout) => ({
...layout,
id: savedLayoutIds.get(layout.id) ?? layout.id,
}));
}),
savePartialBoardSettings: protectedProcedure
.meta({
openapi: { method: "PATCH", path: "/api/boards/{id}/settings", tags: ["boards"], protect: true },
@@ -1491,16 +1700,16 @@ export const boardRouter = createTRPCRouter({
}
const itemId = createId();
await ctx.db.insert(items).values({
const itemRow = {
id: itemId,
boardId: input.boardId,
kind: input.kind,
options: superjson.stringify(input.options),
advancedOptions: emptySuperJSON,
});
};
const layoutRows: (typeof itemLayouts.$inferInsert)[] = [];
const defaultSize = widgetDefaultSizes[input.kind as WidgetKind] ?? { width: 1, height: 1 };
for (const layout of board.layouts) {
const existingInSection = board.items
@@ -1519,14 +1728,17 @@ export const boardRouter = createTRPCRouter({
}
}
const defaultSize = widgetDefaultSizes[input.kind as WidgetKind] ?? { width: 1, height: 1 };
const layoutItemSize = {
...defaultSize,
width: Math.min(defaultSize.width, layout.columnCount),
};
const fitsAt = (x: number, y: number) => {
if (x + defaultSize.width > layout.columnCount) return false;
for (let dy = 0; dy < defaultSize.height; dy++) {
if (x + layoutItemSize.width > layout.columnCount) return false;
for (let dy = 0; dy < layoutItemSize.height; dy++) {
const row = occupied[y + dy];
if (!row) continue;
for (let dx = 0; dx < defaultSize.width; dx++) {
for (let dx = 0; dx < layoutItemSize.width; dx++) {
if (row[x + dx]) return false;
}
}
@@ -1544,8 +1756,8 @@ export const boardRouter = createTRPCRouter({
layoutId: layout.id,
xOffset: x,
yOffset: y,
width: defaultSize.width,
height: defaultSize.height,
width: layoutItemSize.width,
height: layoutItemSize.height,
});
placed = true;
}
@@ -1560,15 +1772,32 @@ export const boardRouter = createTRPCRouter({
}
}
if (layoutRows.length > 0) {
await ctx.db.insert(itemLayouts).values(layoutRows);
}
const integrationRows = input.integrationIds.map((integrationId) => ({ itemId, integrationId }));
if (input.integrationIds.length > 0) {
await ctx.db
.insert(integrationItems)
.values(input.integrationIds.map((integrationId) => ({ itemId, integrationId })));
}
await handleTransactionsAsync(ctx.db, {
async handleAsync(db, schema) {
await db.transaction(async (transaction) => {
await transaction.insert(schema.items).values(itemRow);
if (layoutRows.length > 0) {
await transaction.insert(schema.itemLayouts).values(layoutRows);
}
if (integrationRows.length > 0) {
await transaction.insert(schema.integrationItems).values(integrationRows);
}
});
},
handleSync(db) {
db.transaction((transaction) => {
transaction.insert(items).values(itemRow).run();
if (layoutRows.length > 0) {
transaction.insert(itemLayouts).values(layoutRows).run();
}
if (integrationRows.length > 0) {
transaction.insert(integrationItems).values(integrationRows).run();
}
});
},
});
return { itemId };
}),
+14 -4
View File
@@ -19,6 +19,7 @@ import { nextOnboardingStepAsync } from "./onboard/onboard-queries";
const boardServerSettingsSchema = z.object({
homeBoardId: z.string().nullable(),
mobileHomeBoardId: z.string().nullable(),
enableAutomaticMobileLayout: z.boolean(),
enableStatusByDefault: z.boolean(),
forceDisableStatus: z.boolean(),
}) satisfies z.ZodType<ServerSettings["board"]>;
@@ -39,7 +40,7 @@ export const serverSettingsRouter = createTRPCRouter({
mcp: {
enabled: true,
description:
"Get global board defaults, including desktop/mobile home board IDs and status behavior. Requires admin permission",
"Get global board defaults, including desktop/mobile home board IDs, automatic mobile layout mode, and status behavior. Requires admin permission",
},
})
.input(z.void())
@@ -54,7 +55,7 @@ export const serverSettingsRouter = createTRPCRouter({
mcp: {
enabled: true,
description:
"Update global board defaults. Requires admin permission. Optional fields: homeBoardId, mobileHomeBoardId, enableStatusByDefault, forceDisableStatus. Home board IDs must reference public boards or be null",
"Update global board defaults. Requires admin permission. Optional fields: homeBoardId, mobileHomeBoardId, enableAutomaticMobileLayout, enableStatusByDefault, forceDisableStatus. Home board IDs must reference public boards or be null",
},
})
.input(boardServerSettingsUpdateSchema)
@@ -103,10 +104,19 @@ export const serverSettingsRouter = createTRPCRouter({
)
.mutation(async ({ ctx, input }) => {
const current = await getServerSettingByKeyAsync(ctx.db, input.settingsKey);
await updateServerSettingByKeyAsync(ctx.db, input.settingsKey, {
const next = {
...current,
...input.value,
} as ServerSettings[keyof ServerSettings]);
} as ServerSettings[keyof ServerSettings];
const existing = await ctx.db.query.serverSettings.findFirst({
where: eq(serverSettings.settingKey, input.settingsKey),
});
if (existing) {
await updateServerSettingByKeyAsync(ctx.db, input.settingsKey, next);
} else {
await insertServerSettingByKeyAsync(ctx.db, input.settingsKey, next);
}
}),
initSettings: onboardingProcedure
.requiresStep("settings")
+191 -7
View File
@@ -4,7 +4,7 @@ import { describe, expect, it, test, vi } from "vitest";
import type { Session } from "@homarr/auth";
import { createId } from "@homarr/common";
import type { Database, InferInsertModel } from "@homarr/db";
import { and, eq } from "@homarr/db";
import { and, eq, not } from "@homarr/db";
import {
boardGroupPermissions,
boards,
@@ -1382,6 +1382,187 @@ describe("saveGroupBoardPermissions should save group board permissions", () =>
);
});
const createExistingLayout = (id: string) => ({
id,
name: "Base",
columnCount: 10,
breakpoint: 0,
});
const createNewLayout = (columnCount: number) => ({
id: createId(),
name: "New layout",
columnCount,
breakpoint: 1400,
});
describe("saveLayouts should preserve legacy responsive layout editing", () => {
test("should add a layout and clone item and dynamic-section placements", async () => {
const db = createDb();
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
const { boardId, layoutId, sectionId, itemId } = await createFullBoardAsync(db, "default");
const assignments = await createItemsAndSectionsAsync(db, {
boardId,
layoutId,
sectionId,
});
const newLayout = createNewLayout(3);
const savedLayouts = await caller.saveLayouts({
id: boardId,
layouts: [createExistingLayout(layoutId), newLayout],
});
const layout = expectToBeDefined(
await db.query.layouts.findFirst({
where: not(eq(layouts.id, layoutId)),
}),
);
expect(layout).toMatchObject({
name: newLayout.name,
columnCount: newLayout.columnCount,
breakpoint: newLayout.breakpoint,
});
expect(savedLayouts).toEqual([createExistingLayout(layoutId), { ...newLayout, id: layout.id }]);
await expectLayoutForRootLayoutAsync(db, sectionId, layout.id, {
...assignments.inRoot,
a: itemId,
});
await expectLayoutForDynamicSectionAsync(db, assignments.inRoot.f, layout.id, assignments.inDynamicSection);
await caller.saveLayouts({ id: boardId, layouts: savedLayouts });
await expect(db.query.layouts.findMany({ where: eq(layouts.boardId, boardId) })).resolves.toHaveLength(2);
});
test("should update layout metadata and positions", async () => {
const db = createDb();
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
const { boardId, layoutId, sectionId, itemId } = await createFullBoardAsync(db, "default");
const assignments = await createItemsAndSectionsAsync(db, {
boardId,
layoutId,
sectionId,
});
await caller.saveLayouts({
id: boardId,
layouts: [
{
id: layoutId,
name: "Phone",
columnCount: 3,
breakpoint: 480,
},
],
});
await expect(db.query.layouts.findFirst({ where: eq(layouts.id, layoutId) })).resolves.toMatchObject({
name: "Phone",
columnCount: 3,
breakpoint: 480,
});
await expectLayoutForRootLayoutAsync(db, sectionId, layoutId, {
...assignments.inRoot,
a: itemId,
});
await expectLayoutForDynamicSectionAsync(db, assignments.inRoot.f, layoutId, assignments.inDynamicSection);
});
test("should remove layouts omitted from the saved responsive configuration", async () => {
const db = createDb();
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
const { boardId, layoutId } = await createFullBoardAsync(db, "default");
await caller.saveLayouts({
id: boardId,
layouts: [createNewLayout(12)],
});
await expect(db.query.layouts.findFirst({ where: eq(layouts.id, layoutId) })).resolves.toBeUndefined();
});
test("should reject duplicate responsive breakpoints", async () => {
const db = createDb();
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
const { boardId, layoutId } = await createFullBoardAsync(db, "default");
await expect(
caller.saveLayouts({
id: boardId,
layouts: [
createExistingLayout(layoutId),
{
...createNewLayout(4),
breakpoint: 0,
},
],
}),
).rejects.toMatchObject({ code: "BAD_REQUEST" });
});
test("should reject duplicate responsive layout IDs", async () => {
const db = createDb();
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
const { boardId, layoutId } = await createFullBoardAsync(db, "default");
const existingLayout = createExistingLayout(layoutId);
await expect(
caller.saveLayouts({
id: boardId,
layouts: [existingLayout, { ...existingLayout, name: "Phone", breakpoint: 480 }],
}),
).rejects.toMatchObject({ code: "BAD_REQUEST" });
});
test("should reject fractional responsive layout values", async () => {
const db = createDb();
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
const { boardId, layoutId } = await createFullBoardAsync(db, "default");
await expect(
caller.saveLayouts({
id: boardId,
layouts: [{ ...createExistingLayout(layoutId), breakpoint: 480.5 }],
}),
).rejects.toMatchObject({ code: "BAD_REQUEST" });
await expect(
caller.saveLayouts({
id: boardId,
layouts: [{ ...createExistingLayout(layoutId), columnCount: 3.5 }],
}),
).rejects.toMatchObject({ code: "BAD_REQUEST" });
});
});
describe("addItem should preserve every saved layout", () => {
test("should clamp wide widgets to a retained narrow layout", async () => {
const db = createDb();
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
const { boardId, layoutId } = await createFullBoardAsync(db, "default");
const mobileLayoutId = createId();
await db.insert(layouts).values({
id: mobileLayoutId,
name: "Mobile",
columnCount: 1,
breakpoint: 480,
boardId,
});
const { itemId } = await caller.addItem({
boardId,
kind: "mediaMissing",
});
const savedItemLayouts = await db.query.itemLayouts.findMany({
where: eq(itemLayouts.itemId, itemId),
});
expect(savedItemLayouts).toHaveLength(2);
expect(savedItemLayouts).toContainEqual(expect.objectContaining({ layoutId, width: 4 }));
expect(savedItemLayouts).toContainEqual(expect.objectContaining({ layoutId: mobileLayoutId, width: 1 }));
});
});
describe("saveLayout should save the desktop layout", () => {
test("should update position of items when column count changes", async () => {
// Arrange
@@ -1407,7 +1588,7 @@ describe("saveLayout should save the desktop layout", () => {
});
await expectLayoutForDynamicSectionAsync(db, assignments.inRoot.f, layoutId, assignments.inDynamicSection);
});
test("should keep the desktop layout and remove legacy responsive layouts", async () => {
test("should preserve legacy layouts and desktop metadata", async () => {
// Arrange
const db = createDb();
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
@@ -1434,15 +1615,18 @@ describe("saveLayout should save the desktop layout", () => {
// Act
await caller.saveLayout({
id: boardId,
columnCount: 16,
columnCount: 14,
});
// Assert
const savedLayouts = await db.query.layouts.findMany({ where: eq(layouts.boardId, boardId) });
expect(savedLayouts).toEqual([
expect.objectContaining({ id: desktopLayoutId, name: "Base", columnCount: 16, breakpoint: 0 }),
]);
expect(savedLayouts.some((layout) => layout.id === layoutId)).toBe(false);
expect(savedLayouts).toHaveLength(2);
expect(savedLayouts).toContainEqual(
expect.objectContaining({ id: layoutId, name: "Base", columnCount: 10, breakpoint: 0 }),
);
expect(savedLayouts).toContainEqual(
expect.objectContaining({ id: desktopLayoutId, name: "Large", columnCount: 14, breakpoint: 1200 }),
);
});
test("should fail when board not found", async () => {
// Arrange
@@ -56,6 +56,33 @@ describe("getAll server settings", () => {
});
describe("saveSettings", () => {
test("saveSettings should persist settings when defaults are not seeded", async () => {
const db = createDb();
const caller = serverSettingsRouter.createCaller({
db,
deviceType: undefined,
session: defaultSession,
});
await caller.saveSettings({
settingsKey: "board",
value: {
enableAutomaticMobileLayout: false,
},
});
const dbSettings = await db.select().from(serverSettings);
expect(dbSettings).toStrictEqual([
{
settingKey: "board",
value: stringify({
...defaultServerSettings.board,
enableAutomaticMobileLayout: false,
}),
},
]);
});
test("saveSettings should update settings and return true when it updated only one", async () => {
const db = createDb();
const caller = serverSettingsRouter.createCaller({
@@ -103,6 +130,27 @@ describe("board settings API", () => {
await expect(caller.getBoardSettings()).resolves.toStrictEqual(defaultServerSettings.board);
});
test("getBoardSettings should backfill the automatic layout default for legacy values", async () => {
const db = createDb();
const caller = serverSettingsRouter.createCaller({
db,
deviceType: undefined,
session: defaultSession,
});
const legacyBoardSettings = {
homeBoardId: defaultServerSettings.board.homeBoardId,
mobileHomeBoardId: defaultServerSettings.board.mobileHomeBoardId,
enableStatusByDefault: defaultServerSettings.board.enableStatusByDefault,
forceDisableStatus: defaultServerSettings.board.forceDisableStatus,
};
await db.insert(serverSettings).values({
settingKey: "board",
value: stringify(legacyBoardSettings),
});
await expect(caller.getBoardSettings()).resolves.toStrictEqual(defaultServerSettings.board);
});
test("updateBoardSettings should insert settings when defaults are not persisted", async () => {
const db = createDb();
const caller = serverSettingsRouter.createCaller({
@@ -145,6 +193,7 @@ describe("board settings API", () => {
await caller.updateBoardSettings({ homeBoardId: boardId });
const result = await caller.updateBoardSettings({
mobileHomeBoardId: boardId,
enableAutomaticMobileLayout: false,
enableStatusByDefault: false,
});
@@ -152,6 +201,7 @@ describe("board settings API", () => {
...defaultServerSettings.board,
homeBoardId: boardId,
mobileHomeBoardId: boardId,
enableAutomaticMobileLayout: false,
enableStatusByDefault: false,
});
const dbSettings = await db.select().from(serverSettings);
@@ -2,36 +2,53 @@ import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { mediaReleaseRequestHandler } from "@homarr/request-handler/media-release";
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { settleIntegrationQueries } from "../../settle-integrations";
import { getIntegrationQueryProvenance, settleIntegrationQueries } from "../../settle-integrations";
import { createTRPCRouter, publicProcedure } from "../../trpc";
export const mediaReleaseRouter = createTRPCRouter({
getMediaReleases: publicProcedure
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("mediaRelease")))
.query(async ({ ctx }) => {
const results = await settleIntegrationQueries(
ctx.integrations,
async (integration) => {
const innerHandler = mediaReleaseRequestHandler.handler(integration, {});
const { data, timestamp } = await innerHandler.getDataAsync();
const mediaReleasesProcedure = publicProcedure.concat(
createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("mediaRelease")),
);
return {
integration: {
id: integration.id,
name: integration.name,
kind: integration.kind,
updatedAt: timestamp,
},
releases: data,
};
type MediaReleaseIntegration = Parameters<typeof mediaReleaseRequestHandler.handler>[0];
const getMediaReleasesWithProvenanceAsync = async (integrations: MediaReleaseIntegration[]) => {
const results = await settleIntegrationQueries(
integrations,
async (integration) => {
const innerHandler = mediaReleaseRequestHandler.handler(integration, {});
const { data, timestamp, isStale } = await innerHandler.getDataWithProvenanceAsync();
return {
integration: {
id: integration.id,
name: integration.name,
kind: integration.kind,
updatedAt: timestamp,
},
{ throwOnAllFailure: true },
);
return results.flatMap((result) =>
result.releases.map((release) => ({
...release,
integration: result.integration,
})),
);
}),
releases: data,
isStale,
};
},
{ throwOnAllFailure: true },
);
const items = results.flatMap((result) =>
result.releases.map((release) => ({
...release,
integration: result.integration,
})),
);
return {
items,
...getIntegrationQueryProvenance(integrations.length, results),
};
};
export const mediaReleaseRouter = createTRPCRouter({
getMediaReleases: mediaReleasesProcedure.query(async ({ ctx }) => {
return (await getMediaReleasesWithProvenanceAsync(ctx.integrations)).items;
}),
getMediaReleasesWithProvenance: mediaReleasesProcedure.query(async ({ ctx }) => {
return await getMediaReleasesWithProvenanceAsync(ctx.integrations);
}),
});
@@ -3,6 +3,7 @@ import { z } from "zod/v4";
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { createIntegrationAsync } from "@homarr/integrations";
import { mediaRequestStatusConfiguration } from "@homarr/integrations/types";
import type { MediaRequestListInput } from "@homarr/request-handler/media-request-list";
import {
mediaRequestListInputSchema,
mediaRequestListRequestHandler,
@@ -10,11 +11,93 @@ import {
import { mediaRequestStatsRequestHandler } from "@homarr/request-handler/media-request-stats";
import { createManyIntegrationMiddleware, createOneIntegrationMiddleware } from "../../middlewares/integration";
import { settleIntegrationQueries } from "../../settle-integrations";
import { getIntegrationQueryProvenance, settleIntegrationQueries } from "../../settle-integrations";
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc";
const latestRequestsProcedure = publicProcedure
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("mediaRequest")))
.input(mediaRequestListInputSchema);
const requestStatsProcedure = publicProcedure.concat(
createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("mediaRequest")),
);
type MediaRequestIntegration = Parameters<typeof mediaRequestListRequestHandler.handler>[0];
type MediaRequestStatsIntegration = Parameters<typeof mediaRequestStatsRequestHandler.handler>[0];
const getLatestRequestsWithProvenanceAsync = async (
integrations: MediaRequestIntegration[],
input: MediaRequestListInput,
) => {
const results = await settleIntegrationQueries(
integrations,
async (integration) => {
const { data, isStale } = await mediaRequestListRequestHandler
.handler(integration, input)
.getDataWithProvenanceAsync();
return {
integration: { id: integration.id, name: integration.name, kind: integration.kind },
data,
isStale,
};
},
{ throwOnAllFailure: true },
);
const items = results
.flatMap(({ data, integration }) =>
data.map((request) => ({
...request,
integrationId: integration.id,
})),
)
.toSorted((dataA, dataB) => {
if (dataA.status === dataB.status) {
return dataB.createdAt.getTime() - dataA.createdAt.getTime();
}
return (
mediaRequestStatusConfiguration[dataA.status].position - mediaRequestStatusConfiguration[dataB.status].position
);
});
return {
items,
...getIntegrationQueryProvenance(integrations.length, results),
};
};
const getRequestStatsWithProvenanceAsync = async (integrations: MediaRequestStatsIntegration[]) => {
const results = await settleIntegrationQueries(
integrations,
async (integration) => {
const { data, isStale } = await mediaRequestStatsRequestHandler
.handler(integration, {})
.getDataWithProvenanceAsync();
return {
integration: { id: integration.id, name: integration.name, kind: integration.kind },
data,
isStale,
};
},
{ throwOnAllFailure: true },
);
return {
stats: results.flatMap((result) => result.data.stats),
users: results
.flatMap((result) =>
result.data.users.map((user) => ({
...user,
integration: result.integration,
})),
)
.toSorted(({ requestCount: countA }, { requestCount: countB }) => countB - countA),
integrations: results.map((result) => result.integration),
...getIntegrationQueryProvenance(integrations.length, results),
};
};
export const mediaRequestsRouter = createTRPCRouter({
getLatestRequests: publicProcedure
getLatestRequests: latestRequestsProcedure
.meta({
mcp: {
enabled: true,
@@ -22,36 +105,13 @@ export const mediaRequestsRouter = createTRPCRouter({
"Get latest media requests from Overseerr/Jellyseerr with their status (pending, approved, declined, failed, completed). REQUIRED: integrationIds (array of Overseerr/Jellyseerr integration IDs from integration_all). OPTIONAL: statuses (array of statuses to include, must be non-empty) and recentDays (number 0-365; 0 disables the time filter).",
},
})
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("mediaRequest")))
.input(mediaRequestListInputSchema)
.query(async ({ ctx, input }) => {
const results = await settleIntegrationQueries(
ctx.integrations,
async (integration) => {
const { data } = await mediaRequestListRequestHandler.handler(integration, input).getDataAsync();
return { integration: { id: integration.id, name: integration.name, kind: integration.kind }, data };
},
{ throwOnAllFailure: true },
);
return results
.flatMap(({ data, integration }) =>
data.map((request) => ({
...request,
integrationId: integration.id,
})),
)
.toSorted((dataA, dataB) => {
if (dataA.status === dataB.status) {
return dataB.createdAt.getTime() - dataA.createdAt.getTime();
}
return (
mediaRequestStatusConfiguration[dataA.status].position -
mediaRequestStatusConfiguration[dataB.status].position
);
});
return (await getLatestRequestsWithProvenanceAsync(ctx.integrations, input)).items;
}),
getStats: publicProcedure
getLatestRequestsWithProvenance: latestRequestsProcedure.query(async ({ ctx, input }) => {
return await getLatestRequestsWithProvenanceAsync(ctx.integrations, input);
}),
getStats: requestStatsProcedure
.meta({
mcp: {
enabled: true,
@@ -59,30 +119,17 @@ export const mediaRequestsRouter = createTRPCRouter({
"Get media request statistics including total counts and top requesters. REQUIRED: integrationIds (array of Overseerr/Jellyseerr integration IDs from integration_all)",
},
})
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("mediaRequest")))
.query(async ({ ctx }) => {
const results = await settleIntegrationQueries(
ctx.integrations,
async (integration) => {
const { data } = await mediaRequestStatsRequestHandler.handler(integration, {}).getDataAsync();
return { integration: { id: integration.id, name: integration.name, kind: integration.kind }, data };
},
{ throwOnAllFailure: true },
);
const result = await getRequestStatsWithProvenanceAsync(ctx.integrations);
return {
stats: results.flatMap((result) => result.data.stats),
users: results
.map((result) =>
result.data.users.map((user) => ({
...user,
integration: result.integration,
})),
)
.flat()
.toSorted(({ requestCount: countA }, { requestCount: countB }) => countB - countA),
integrations: results.map((result) => result.integration),
stats: result.stats,
users: result.users,
integrations: result.integrations,
};
}),
getStatsWithProvenance: requestStatsProcedure.query(async ({ ctx }) => {
return await getRequestStatsWithProvenanceAsync(ctx.integrations);
}),
answerRequest: protectedProcedure
.meta({
mcp: {
+17 -1
View File
@@ -2,7 +2,7 @@
import { describe, expect, it, vi } from "vitest";
import { settleIntegrationQueries } from "./settle-integrations";
import { getIntegrationQueryProvenance, settleIntegrationQueries } from "./settle-integrations";
const integrations = [
{ id: "first", name: "First", kind: "mock" },
@@ -76,3 +76,19 @@ describe("settleIntegrationQueries", () => {
).resolves.toEqual([]);
});
});
describe("getIntegrationQueryProvenance", () => {
it("counts dropped and stale integration results independently", () => {
expect(getIntegrationQueryProvenance(4, [{ isStale: false }, { isStale: true }, { isStale: true }])).toEqual({
failedIntegrationCount: 1,
staleIntegrationCount: 2,
});
});
it("does not report failures for complete current results", () => {
expect(getIntegrationQueryProvenance(2, [{ isStale: false }, {}])).toEqual({
failedIntegrationCount: 0,
staleIntegrationCount: 0,
});
});
});
+13
View File
@@ -14,6 +14,19 @@ interface Options<TIntegration extends IntegrationLike, TResult> {
throwOnAllFailure?: boolean;
}
export interface IntegrationQueryProvenance {
failedIntegrationCount: number;
staleIntegrationCount: number;
}
export const getIntegrationQueryProvenance = (
selectedIntegrationCount: number,
results: readonly { isStale?: boolean }[],
): IntegrationQueryProvenance => ({
failedIntegrationCount: Math.max(0, selectedIntegrationCount - results.length),
staleIntegrationCount: results.filter((result) => result.isStale === true).length,
});
export async function settleIntegrationQueries<TIntegration extends IntegrationLike, TResult>(
integrations: TIntegration[],
fn: (integration: TIntegration) => Promise<TResult>,
+1
View File
@@ -33,6 +33,7 @@ test("MCP tools should contain expected procedures", () => {
expect(toolNames).toContain("app_byId");
expect(toolNames).toContain("app_create");
expect(toolNames).toContain("board_savePartialBoardSettings");
expect(toolNames).toContain("board_saveLayout");
expect(toolNames).toContain("board_duplicateBoard");
expect(toolNames).toContain("info_getInfo");
expect(toolNames).toContain("invite_getAll");