wip(api): add items router

This commit is contained in:
Meier Lukas committed 2025-08-31 21:39:05 +02:00
1 parent 7ea71a57b7
commit bd7a9fbd10
7 files changed
+220 -10

No files matched your search

+2
View File
@@ -57,6 +57,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 { boardItemsRouter } from "./board/items/item-router";
export const boardRouter = createTRPCRouter({
exists: permissionRequiredProcedure
@@ -1325,6 +1326,7 @@ export const boardRouter = createTRPCRouter({
const oldmarr = oldmarrConfigSchema.parse(JSON.parse(content));
await importOldmarrAsync(ctx.db, oldmarr, input.configuration);
}),
items: boardItemsRouter,
});
/**
@@ -0,0 +1,192 @@
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