feat: improve item router

This commit is contained in:
Meier Lukas committed 2025-12-10 14:53:17 +01:00
1 parent 5332f0d5c0
commit 56aae88853
5 files changed
+377 -197

No files matched your search

+2
View File
@@ -11,6 +11,7 @@ import { importRouter } from "./router/import/import-router";
import { infoRouter } from "./router/info";
import { integrationRouter } from "./router/integration/integration-router";
import { inviteRouter } from "./router/invite";
import { itemsRouter } from "./router/item/item-router";
import { kubernetesRouter } from "./router/kubernetes/router/kubernetes-router";
import { locationRouter } from "./router/location";
import { logRouter } from "./router/log";
@@ -49,6 +50,7 @@ export const appRouter = createTRPCRouter({
updateChecker: updateCheckerRouter,
certificates: certificateRouter,
info: infoRouter,
item: itemsRouter,
});
// export type definition of API
-2
View File
@@ -57,7 +57,6 @@ 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 { boardItemsRouter } from "./board/items/item-router";
export const boardRouter = createTRPCRouter({
exists: permissionRequiredProcedure
@@ -1326,7 +1325,6 @@ export const boardRouter = createTRPCRouter({
const oldmarr = oldmarrConfigSchema.parse(JSON.parse(content));
await importOldmarrAsync(ctx.db, oldmarr, input.configuration);
}),
items: boardItemsRouter,
});
/**
@@ -17,6 +17,7 @@ export const throwIfActionForbiddenAsync = async (
ctx: { db: Database; session: Session | null },
boardWhere: SQL<unknown>,
permission: BoardPermission,
error?: TRPCError,
) => {
const { db, session } = ctx;
const groupsOfCurrentUser = await db.query.groupMembers.findMany({
@@ -40,7 +41,7 @@ export const throwIfActionForbiddenAsync = async (
});
if (!board) {
notAllowed();
notAllowed(error);
}
const { hasViewAccess, hasChangeAccess, hasFullAccess } = constructBoardPermissions(board, session);
@@ -57,14 +58,18 @@ export const throwIfActionForbiddenAsync = async (
return; // As view access is required and user has view access, allow
}
notAllowed();
notAllowed(error);
};
/**
* This method returns NOT_FOUND to prevent snooping on board existence
* A function is used to use the method without return statement
*/
function notAllowed(): never {
function notAllowed(error?: TRPCError): never {
if (error) {
throw error;
}
throw new TRPCError({
code: "NOT_FOUND",
message: "Board not found",
@@ -1,192 +0,0 @@
import { TRPCError } from "@trpc/server";
import SuperJSON from "superjson";
import z from "zod";
import { createId, objectEntries } from "@homarr/common";
import { and, eq } from "@homarr/db";
import type { InferInsertModel } from "@homarr/db";
import { createDbInsertCollectionWithoutTransaction } from "@homarr/db/collection";
import type { itemLayouts } from "@homarr/db/schema";
import { items } from "@homarr/db/schema";
import { selectItemLayoutSchema, selectitemSchema } from "@homarr/db/validationSchemas";
import type { WidgetKind } from "@homarr/definitions";
import { widgetKinds } from "@homarr/definitions";
import { zodEnumFromArray } from "@homarr/validation/enums";
import { itemAdvancedOptionsSchema } from "@homarr/validation/shared";
import { reduceWidgetOptionsWithDefaultValues, widgetImports } from "../../../../../widgets/src";
import type { WidgetOptionDefinition } from "../../../../../widgets/src/options";
import { createTRPCRouter, protectedProcedure } from "../../../trpc";
const createItemOptionsSchema = (kind: WidgetKind): z.ZodObject =>
z.object(
objectEntries(
widgetImports[kind].definition.createOptions({
enableStatusByDefault: true,
firstDayOfWeek: 1,
forceDisableStatus: false,
}),
).reduce(
(previous, [key, value]: [string, WidgetOptionDefinition]) => {
previous[key] = value.validate;
return previous;
},
{} as Record<string, z.ZodType>,
),
);
export const boardItemsRouter = createTRPCRouter({
getAll: protectedProcedure
.input(
z.object({
boardId: z.string(),
}),
)
.output(
z.array(
z.union([
...widgetKinds.map((kind) =>
z
.object({
kind: z.literal(kind),
options: createItemOptionsSchema(kind),
advancedOptions: itemAdvancedOptionsSchema,
layouts: z.array(selectItemLayoutSchema.omit({ itemId: true })),
})
.and(
selectitemSchema.pick({
id: true,
boardId: true,
}),
),
),
]),
),
)
.meta({
openapi: {
method: "GET",
path: "/api/boards/{boardId}/items",
tags: ["boardItems"],
protect: true,
summary: "Retrieve all items for board",
},
})
.query(async ({ ctx, input }) => {
const items = await ctx.db.query.items.findMany({
where: (fields, { eq }) => eq(fields.boardId, input.boardId),
with: {
layouts: true,
},
});
return items.map((item) => ({
...item,
options: reduceWidgetOptionsWithDefaultValues(
item.kind,
{
enableStatusByDefault: true,
firstDayOfWeek: 1,
forceDisableStatus: false,
},
SuperJSON.parse(item.options),
),
advancedOptions: itemAdvancedOptionsSchema.parse(SuperJSON.parse(item.advancedOptions)),
layouts: item.layouts.map(({ itemId: _, ...layout }) => layout),
}));
}),
createItem: protectedProcedure
.input(
z.object({
boardId: z.string(),
kind: zodEnumFromArray(widgetKinds),
options: z.record(z.string(), z.unknown().optional()),
advancedOptions: itemAdvancedOptionsSchema,
layouts: z.array(selectItemLayoutSchema.omit({ itemId: true })),
}),
)
.output(
z.union([
...widgetKinds.map((kind) =>
z
.object({
kind: z.literal(kind),
options: createItemOptionsSchema(kind),
advancedOptions: itemAdvancedOptionsSchema,
layouts: z.array(selectItemLayoutSchema.omit({ itemId: true })),
})
.and(
selectitemSchema.pick({
id: true,
boardId: true,
}),
),
),
]),
)
.meta({
openapi: {
method: "POST",
path: "/api/boards/{boardId}/items",
tags: ["boardItems"],
protect: true,
summary: "Create item on board",
description: "Options available can be viewed in response options object. Input options are optional",
},
})
.mutation(async ({ ctx, input }) => {
const itemOptionsSchema = createItemOptionsSchema(input.kind);
const fullOptions = reduceWidgetOptionsWithDefaultValues(
input.kind,
{ enableStatusByDefault: true, firstDayOfWeek: 1, forceDisableStatus: false },
input.options,
);
const result = await itemOptionsSchema.safeParseAsync(fullOptions);
if (!result.success) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Failed to parse item options",
cause: result.error,
});
}
const item = {
id: createId(),
boardId: input.boardId,
kind: input.kind,
advancedOptions: input.advancedOptions,
options: result.data,
};
const itemInsert: InferInsertModel<typeof items> = {
...item,
advancedOptions: SuperJSON.stringify(item.advancedOptions),
options: SuperJSON.stringify(item.options),
};
const layoutInserts: InferInsertModel<typeof itemLayouts>[] = input.layouts.map((layout) => ({
...layout,
itemId: item.id,
}));
const insertCollection = createDbInsertCollectionWithoutTransaction(["items", "itemLayouts"]);
insertCollection.items.push(itemInsert);
insertCollection.itemLayouts.push(...layoutInserts);
await insertCollection.insertAllAsync(ctx.db);
// TODO: Add validations
return {
...item,
layouts: layoutInserts,
};
}),
deleteItem: protectedProcedure
.input(z.object({ boardId: z.string(), itemId: z.string() }))
.output(z.void())
.meta({ openapi: { method: "DELETE", path: "/api/boards/{boardId}/items/{itemId}" } })
.mutation(async ({ ctx, input }) => {
await ctx.db.delete(items).where(and(eq(items.id, input.itemId), eq(items.boardId, input.boardId)));
}),
});
// TODO: Add check if you are allowed to do this
// TODO: Maybe move to /api/items instead so we don't have to pass boardId for deletion & modification
+367
View File
@@ -0,0 +1,367 @@
import { TRPCError } from "@trpc/server";
import SuperJSON from "superjson";
import z from "zod";
import { createId, objectEntries } from "@homarr/common";
import type { InferInsertModel } from "@homarr/db";
import { and, eq, handleTransactionsAsync } from "@homarr/db";
import { createDbInsertCollectionWithoutTransaction } from "@homarr/db/collection";
import { itemLayouts, items } from "@homarr/db/schema";
import { selectItemLayoutSchema, selectitemSchema } from "@homarr/db/validationSchemas";
import type { WidgetKind } from "@homarr/definitions";
import { widgetKinds } from "@homarr/definitions";
import { byIdSchema } from "@homarr/validation/common";
import { zodEnumFromArray } from "@homarr/validation/enums";
import { itemAdvancedOptionsSchema } from "@homarr/validation/shared";
import { reduceWidgetOptionsWithDefaultValues, widgetImports } from "../../../../widgets/src";
import type { WidgetOptionDefinition } from "../../../../widgets/src/options";
import { convertIntersectionToZodObject } from "../../schema-merger";
import { createTRPCRouter, protectedProcedure } from "../../trpc";
import { throwIfActionForbiddenAsync } from "../board/board-access";
const createItemOptionsSchema = (kind: WidgetKind): z.ZodObject =>
z.object(
objectEntries(
widgetImports[kind].definition.createOptions({
enableStatusByDefault: true,
firstDayOfWeek: 1,
forceDisableStatus: false,
}),
).reduce(
(previous, [key, value]: [string, WidgetOptionDefinition]) => {
previous[key] = value.validate;
return previous;
},
{} as Record<string, z.ZodType>,
),
);
const outputItemSchema = z.union([
...widgetKinds.map((kind) =>
z
.object({
kind: z.literal(kind),
options: createItemOptionsSchema(kind),
advancedOptions: itemAdvancedOptionsSchema,
layouts: z.array(selectItemLayoutSchema.omit({ itemId: true })),
})
.and(
selectitemSchema.pick({
id: true,
boardId: true,
}),
),
),
]);
const manageItemSchema = z.object({
boardId: z.string(),
kind: zodEnumFromArray(widgetKinds),
options: z.record(z.string(), z.unknown().optional()),
advancedOptions: itemAdvancedOptionsSchema,
layouts: z.array(selectItemLayoutSchema.omit({ itemId: true })),
});
export const itemsRouter = createTRPCRouter({
getAll: protectedProcedure
.input(
z.object({
boardId: z.string(),
}),
)
.output(z.array(outputItemSchema))
.meta({
openapi: {
method: "GET",
path: "/api/boards/{boardId}/items",
tags: ["items"],
protect: true,
summary: "Retrieve all items for board",
},
})
.query(async ({ ctx, input }) => {
const board = await ctx.db.query.boards.findFirst({
where: (fields, { eq }) => eq(fields.id, input.boardId),
with: {
items: {
with: {
layouts: true,
},
},
},
});
const error = new TRPCError({
code: "NOT_FOUND",
message: "Board not found",
});
if (!board) throw error;
await throwIfActionForbiddenAsync(ctx, eq(items.boardId, board.id), "view", error);
return board.items.map((item) => ({
...item,
options: reduceWidgetOptionsWithDefaultValues(
item.kind,
{
enableStatusByDefault: true,
firstDayOfWeek: 1,
forceDisableStatus: false,
},
SuperJSON.parse(item.options),
),
advancedOptions: itemAdvancedOptionsSchema.parse(SuperJSON.parse(item.advancedOptions)),
layouts: item.layouts.map(({ itemId: _, ...layout }) => layout),
}));
}),
createItem: protectedProcedure
.input(manageItemSchema)
.output(outputItemSchema)
.meta({
openapi: {
method: "POST",
path: "/api/items",
tags: ["items"],
protect: true,
summary: "Create item on board",
description: "Options available can be viewed in response options object. Input options are optional",
},
})
.mutation(async ({ ctx, input }) => {
const board = await ctx.db.query.boards.findFirst({
where: (fields, { eq }) => eq(fields.id, input.boardId),
});
const error = new TRPCError({
code: "NOT_FOUND",
message: "Board not found",
});
if (!board) throw error;
await throwIfActionForbiddenAsync(ctx, eq(items.boardId, board.id), "modify", error);
const itemOptionsSchema = createItemOptionsSchema(input.kind);
const fullOptions = reduceWidgetOptionsWithDefaultValues(
input.kind,
{ enableStatusByDefault: true, firstDayOfWeek: 1, forceDisableStatus: false },
input.options,
);
const result = await itemOptionsSchema.safeParseAsync(fullOptions);
if (!result.success) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Failed to parse item options",
cause: result.error,
});
}
const item = {
id: createId(),
boardId: input.boardId,
kind: input.kind,
advancedOptions: input.advancedOptions,
options: result.data,
};
const itemInsert: InferInsertModel<typeof items> = {
...item,
advancedOptions: SuperJSON.stringify(item.advancedOptions),
options: SuperJSON.stringify(item.options),
};
const layoutInserts: InferInsertModel<typeof itemLayouts>[] = input.layouts.map((layout) => ({
...layout,
itemId: item.id,
}));
const insertCollection = createDbInsertCollectionWithoutTransaction(["items", "itemLayouts"]);
insertCollection.items.push(itemInsert);
insertCollection.itemLayouts.push(...layoutInserts);
await insertCollection.insertAllAsync(ctx.db);
// TODO: Add validations
return {
...item,
layouts: layoutInserts,
};
}),
getItem: protectedProcedure
.input(byIdSchema)
.output(outputItemSchema)
.meta({
openapi: {
method: "GET",
path: "/api/items/{id}",
tags: ["items"],
protect: true,
summary: "Retrieve item from a board",
},
})
.query(async ({ ctx, input }) => {
const item = await ctx.db.query.items.findFirst({
where: (fields, { eq }) => eq(fields.id, input.id),
with: {
layouts: true,
},
});
const error = new TRPCError({
code: "NOT_FOUND",
message: "Item not found",
});
if (!item) throw error;
await throwIfActionForbiddenAsync(ctx, eq(items.boardId, item.boardId), "view", error);
return {
...item,
options: reduceWidgetOptionsWithDefaultValues(
item.kind,
{
enableStatusByDefault: true,
firstDayOfWeek: 1,
forceDisableStatus: false,
},
SuperJSON.parse(item.options),
),
advancedOptions: itemAdvancedOptionsSchema.parse(SuperJSON.parse(item.advancedOptions)),
layouts: item.layouts.map(({ itemId: _, ...layout }) => layout),
};
}),
updateItem: protectedProcedure
.input(convertIntersectionToZodObject(manageItemSchema.omit({ boardId: true, kind: true }).and(byIdSchema)))
.output(outputItemSchema)
.meta({
openapi: {
method: "PUT",
path: "/api/items/{id}",
tags: ["items"],
protect: true,
summary: "Update an item on the board",
},
})
.mutation(async ({ ctx, input }) => {
const item = await ctx.db.query.items.findFirst({
where: (fields, { eq }) => eq(fields.id, input.id),
with: {
layouts: true,
},
});
const error = new TRPCError({
code: "NOT_FOUND",
message: "Item not found",
});
if (!item) throw error;
await throwIfActionForbiddenAsync(ctx, eq(items.boardId, item.boardId), "modify", error);
const previousOptions = SuperJSON.parse<Record<string, unknown>>(item.options);
const combinedOptions = {
...previousOptions,
...input.options,
};
const itemOptionsSchema = createItemOptionsSchema(item.kind);
const fullOptions = reduceWidgetOptionsWithDefaultValues(
item.kind,
{ enableStatusByDefault: true, firstDayOfWeek: 1, forceDisableStatus: false },
combinedOptions,
);
const optionsResult = await itemOptionsSchema.safeParseAsync(fullOptions);
if (!optionsResult.success) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Failed to parse item options",
cause: optionsResult.error,
});
}
await handleTransactionsAsync(ctx.db, {
async handleAsync(db, schema) {
await db.transaction(async (transaction) => {
await transaction
.update(schema.items)
.set({
advancedOptions: SuperJSON.stringify(input.advancedOptions),
options: SuperJSON.stringify(optionsResult),
})
.where(eq(schema.items.id, input.id));
for (const itemLayout of input.layouts) {
await transaction
.update(schema.itemLayouts)
.set({
height: itemLayout.height,
width: itemLayout.width,
sectionId: itemLayout.sectionId,
xOffset: itemLayout.xOffset,
yOffset: itemLayout.yOffset,
})
.where(
and(eq(schema.itemLayouts.itemId, input.id), eq(schema.itemLayouts.layoutId, itemLayout.layoutId)),
);
}
});
},
handleSync(db) {
db.transaction((transaction) => {
transaction
.update(items)
.set({
advancedOptions: SuperJSON.stringify(input.advancedOptions),
options: SuperJSON.stringify(optionsResult),
})
.where(eq(items.id, input.id));
for (const itemLayout of input.layouts) {
transaction
.update(itemLayouts)
.set({
height: itemLayout.height,
width: itemLayout.width,
sectionId: itemLayout.sectionId,
xOffset: itemLayout.xOffset,
yOffset: itemLayout.yOffset,
})
.where(and(eq(itemLayouts.itemId, input.id), eq(itemLayouts.layoutId, itemLayout.layoutId)));
}
});
},
});
return {
id: input.id,
kind: item.kind,
boardId: item.boardId,
advancedOptions: input.advancedOptions,
options: optionsResult,
layouts: input.layouts,
};
}),
deleteItem: protectedProcedure
.input(byIdSchema)
.output(z.void())
.meta({
openapi: {
method: "DELETE",
path: "/api/items/{id}",
tags: ["items"],
protect: true,
summary: "Delete an item from board",
},
})
.mutation(async ({ ctx, input }) => {
const item = await ctx.db.query.items.findFirst({
where: (fields, { eq }) => eq(fields.id, input.id),
});
const error = new TRPCError({
code: "NOT_FOUND",
message: "Item not found",
});
if (!item) throw error;
await throwIfActionForbiddenAsync(ctx, eq(items.boardId, item.boardId), "modify", error);
await ctx.db.delete(items).where(eq(items.id, input.id));
}),
});