diff --git a/packages/api/src/root.ts b/packages/api/src/root.ts index 6efc10ca5..eb2509dcf 100644 --- a/packages/api/src/root.ts +++ b/packages/api/src/root.ts @@ -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 diff --git a/packages/api/src/router/board.ts b/packages/api/src/router/board.ts index 3c42ee7a6..8d118018b 100644 --- a/packages/api/src/router/board.ts +++ b/packages/api/src/router/board.ts @@ -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, }); /** diff --git a/packages/api/src/router/board/board-access.ts b/packages/api/src/router/board/board-access.ts index 1e4351cba..5ccc3436f 100644 --- a/packages/api/src/router/board/board-access.ts +++ b/packages/api/src/router/board/board-access.ts @@ -17,6 +17,7 @@ export const throwIfActionForbiddenAsync = async ( ctx: { db: Database; session: Session | null }, boardWhere: SQL, 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", diff --git a/packages/api/src/router/board/items/item-router.ts b/packages/api/src/router/board/items/item-router.ts deleted file mode 100644 index 67701a679..000000000 --- a/packages/api/src/router/board/items/item-router.ts +++ /dev/null @@ -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, - ), - ); - -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 = { - ...item, - advancedOptions: SuperJSON.stringify(item.advancedOptions), - options: SuperJSON.stringify(item.options), - }; - const layoutInserts: InferInsertModel[] = 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 diff --git a/packages/api/src/router/item/item-router.ts b/packages/api/src/router/item/item-router.ts new file mode 100644 index 000000000..d15ce3b83 --- /dev/null +++ b/packages/api/src/router/item/item-router.ts @@ -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, + ), + ); + +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 = { + ...item, + advancedOptions: SuperJSON.stringify(item.advancedOptions), + options: SuperJSON.stringify(item.options), + }; + const layoutInserts: InferInsertModel[] = 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>(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)); + }), +});