diff --git a/packages/api/src/router/board.ts b/packages/api/src/router/board.ts index 8d118018bc..3c42ee7a62 100644 --- a/packages/api/src/router/board.ts +++ b/packages/api/src/router/board.ts @@ -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, }); /** diff --git a/packages/api/src/router/board/items/item-router.ts b/packages/api/src/router/board/items/item-router.ts new file mode 100644 index 0000000000..67701a679a --- /dev/null +++ b/packages/api/src/router/board/items/item-router.ts @@ -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, + ), + ); + +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/group.ts b/packages/api/src/router/group.ts index 13fb4fdd34..bc9cb9a286 100644 --- a/packages/api/src/router/group.ts +++ b/packages/api/src/router/group.ts @@ -6,7 +6,8 @@ import type { Database } from "@homarr/db"; import { and, eq, handleTransactionsAsync, like, not } from "@homarr/db"; import { getMaxGroupPositionAsync } from "@homarr/db/queries"; import { groupMembers, groupPermissions, groups } from "@homarr/db/schema"; -import { everyoneGroup } from "@homarr/definitions"; +import { selectGroupSchema, selectUserSchema } from "@homarr/db/validationSchemas"; +import { everyoneGroup, groupPermissionKeys } from "@homarr/definitions"; import { byIdSchema, paginatedSchema } from "@homarr/validation/common"; import { groupCreateSchema, @@ -22,33 +23,65 @@ import { throwIfCredentialsDisabled } from "./invite/checks"; import { nextOnboardingStepAsync } from "./onboard/onboard-queries"; export const groupRouter = createTRPCRouter({ - getAll: permissionRequiredProcedure.requiresPermission("admin").query(async ({ ctx }) => { - const dbGroups = await ctx.db.query.groups.findMany({ - with: { - members: { - with: { - user: { - columns: { - id: true, - name: true, - email: true, - image: true, + getAll: permissionRequiredProcedure + .requiresPermission("admin") + .input(z.void()) + .output( + z.array( + selectGroupSchema.and( + z.object({ members: z.array(selectUserSchema.pick({ id: true, name: true, email: true, image: true })) }), + ), + ), + ) + .meta({ + openapi: { method: "GET", path: "/api/groups", tags: ["groups"], protect: true, summary: "Retrieve all groups" }, + }) + .query(async ({ ctx }) => { + const dbGroups = await ctx.db.query.groups.findMany({ + with: { + members: { + with: { + user: { + columns: { + id: true, + name: true, + email: true, + image: true, + }, }, }, }, }, - }, - }); + }); - return dbGroups.map((group) => ({ - ...group, - members: group.members.map((member) => member.user), - })); - }), + return dbGroups.map((group) => ({ + ...group, + members: group.members.map((member) => member.user), + })); + }), getPaginated: permissionRequiredProcedure .requiresPermission("admin") .input(paginatedSchema) + .output( + z.object({ + items: z.array( + selectGroupSchema.and( + z.object({ members: z.array(selectUserSchema.pick({ id: true, name: true, email: true, image: true })) }), + ), + ), + totalCount: z.number(), + }), + ) + .meta({ + openapi: { + method: "GET", + path: "/api/groups/paginated", + tags: ["groups"], + protect: true, + summary: "Retrieve groups with pagination", + }, + }) .query(async ({ input, ctx }) => { const whereQuery = input.search ? like(groups.name, `%${input.search.trim()}%`) : undefined; const groupCount = await ctx.db.$count(groups, whereQuery); @@ -84,6 +117,24 @@ export const groupRouter = createTRPCRouter({ getById: permissionRequiredProcedure .requiresPermission("admin") .input(byIdSchema) + .output( + selectGroupSchema.and( + z.object({ + owner: selectUserSchema.pick({ id: true, name: true, image: true, email: true }).nullable(), + members: z.array(selectUserSchema.pick({ id: true, name: true, email: true, image: true, provider: true })), + permissions: z.array(z.enum(groupPermissionKeys)), + }), + ), + ) + .meta({ + openapi: { + method: "GET", + path: "/api/groups/{id}", + tags: ["groups"], + protect: true, + summary: "Retrieve group details", + }, + }) .query(async ({ input, ctx }) => { const group = await ctx.db.query.groups.findFirst({ where: eq(groups.id, input.id), @@ -201,6 +252,10 @@ export const groupRouter = createTRPCRouter({ createGroup: permissionRequiredProcedure .requiresPermission("admin") .input(groupCreateSchema) + .output(z.string()) + .meta({ + openapi: { method: "POST", path: "/api/groups", tags: ["groups"], protect: true, summary: "Create group" }, + }) .mutation(async ({ input, ctx }) => { await checkSimilarNameAndThrowAsync(ctx.db, input.name); @@ -219,6 +274,10 @@ export const groupRouter = createTRPCRouter({ updateGroup: permissionRequiredProcedure .requiresPermission("admin") .input(groupUpdateSchema) + .output(z.void()) + .meta({ + openapi: { method: "PUT", path: "/api/groups/{id}", tags: ["groups"], protect: true, summary: "Update group" }, + }) .mutation(async ({ input, ctx }) => { await throwIfGroupNotFoundAsync(ctx.db, input.id); await throwIfGroupNameIsReservedAsync(ctx.db, input.id); @@ -303,6 +362,10 @@ export const groupRouter = createTRPCRouter({ deleteGroup: permissionRequiredProcedure .requiresPermission("admin") .input(byIdSchema) + .output(z.void()) + .meta({ + openapi: { method: "DELETE", path: "/api/groups/{id}", tags: ["groups"], protect: true, summary: "Delete group" }, + }) .mutation(async ({ input, ctx }) => { await throwIfGroupNotFoundAsync(ctx.db, input.id); await throwIfGroupNameIsReservedAsync(ctx.db, input.id); @@ -312,6 +375,16 @@ export const groupRouter = createTRPCRouter({ addMember: permissionRequiredProcedure .requiresPermission("admin") .input(groupUserSchema) + .output(z.void()) + .meta({ + openapi: { + method: "POST", + path: "/api/groups/{groupId}/members/{userId}", + tags: ["groups"], + protect: true, + summary: "Add member to group", + }, + }) .mutation(async ({ input, ctx }) => { await throwIfGroupNotFoundAsync(ctx.db, input.groupId); await throwIfGroupNameIsReservedAsync(ctx.db, input.groupId); @@ -328,6 +401,8 @@ export const groupRouter = createTRPCRouter({ }); } + // TODO: Create another pr to only allow adding users with provider=credentials + await ctx.db.insert(groupMembers).values({ groupId: input.groupId, userId: input.userId, @@ -336,6 +411,16 @@ export const groupRouter = createTRPCRouter({ removeMember: permissionRequiredProcedure .requiresPermission("admin") .input(groupUserSchema) + .output(z.void()) + .meta({ + openapi: { + method: "DELETE", + path: "/api/groups/{groupId}/members/{userId}", + tags: ["groups"], + protect: true, + summary: "Remove member from group", + }, + }) .mutation(async ({ input, ctx }) => { await throwIfGroupNotFoundAsync(ctx.db, input.groupId); await throwIfGroupNameIsReservedAsync(ctx.db, input.groupId); diff --git a/packages/db/validationSchemas.ts b/packages/db/validationSchemas.ts index da49819e38..0048d577d7 100644 --- a/packages/db/validationSchemas.ts +++ b/packages/db/validationSchemas.ts @@ -1,9 +1,12 @@ -import { createSelectSchema } from "drizzle-zod"; +import { createInsertSchema, createSelectSchema } from "drizzle-zod"; -import { apps, boards, groups, invites, searchEngines, serverSettings, users } from "./schema"; +import { apps, boards, groups, invites, itemLayouts, items, searchEngines, serverSettings, users } from "./schema"; export const selectAppSchema = createSelectSchema(apps); export const selectBoardSchema = createSelectSchema(boards); +export const selectitemSchema = createSelectSchema(items); +export const insertItemSchema = createInsertSchema(items); +export const selectItemLayoutSchema = createSelectSchema(itemLayouts); export const selectGroupSchema = createSelectSchema(groups); export const selectInviteSchema = createSelectSchema(invites); export const selectSearchEnginesSchema = createSelectSchema(searchEngines); diff --git a/packages/widgets/src/bookmarks/index.tsx b/packages/widgets/src/bookmarks/index.tsx index 24b3a618cc..2519db623d 100644 --- a/packages/widgets/src/bookmarks/index.tsx +++ b/packages/widgets/src/bookmarks/index.tsx @@ -1,5 +1,6 @@ import { ActionIcon, Avatar, Group, Stack, Text } from "@mantine/core"; import { IconBookmark, IconX } from "@tabler/icons-react"; +import z from "zod"; import type { RouterOutputs } from "@homarr/api"; import { clientApi } from "@homarr/api/client"; @@ -56,6 +57,7 @@ export const { definition, componentLoader } = createWidgetDefinition("bookmarks isLoading, }; }, + validate: z.array(z.string()), }), })); }, diff --git a/packages/widgets/src/definition.ts b/packages/widgets/src/definition.ts index f7df8c95d0..009d10dbef 100644 --- a/packages/widgets/src/definition.ts +++ b/packages/widgets/src/definition.ts @@ -42,7 +42,9 @@ export interface WidgetDefinition { icon: TablerIcon; supportedIntegrations?: IntegrationKind[]; integrationsRequired?: boolean; - createOptions: (settings: SettingsContextProps) => WidgetOptionsRecord; + createOptions: ( + settings: Pick, + ) => WidgetOptionsRecord; errors?: Partial< Record< DefaultErrorData["code"], diff --git a/packages/widgets/src/index.tsx b/packages/widgets/src/index.tsx index a4e7416494..b7bd8026de 100644 --- a/packages/widgets/src/index.tsx +++ b/packages/widgets/src/index.tsx @@ -115,7 +115,7 @@ export type inferSupportedIntegrationsStrict = (Widget export const reduceWidgetOptionsWithDefaultValues = ( kind: WidgetKind, - settings: SettingsContextProps, + settings: Pick, currentValue: Record = {}, ) => { const definition = widgetImports[kind].definition; diff --git a/packages/widgets/src/options.ts b/packages/widgets/src/options.ts index d0fe2cdfb9..67ba666afc 100644 --- a/packages/widgets/src/options.ts +++ b/packages/widgets/src/options.ts @@ -5,6 +5,7 @@ import { z } from "zod/v4"; import type { ZodType } from "zod/v4"; import type { IntegrationKind } from "@homarr/definitions"; +import { zodEnumFromArray } from "@homarr/validation/enums"; import type { inferSelectOptionValue, SelectOption } from "./_inputs/widget-select-input"; import type { ReleasesRepository } from "./releases/releases-repository"; @@ -35,6 +36,7 @@ export interface SortableItemListInput React.ReactNode; uniqueIdentifier: (item: TItem) => TOptionValue; useData: (values: TOptionValue[]) => { data: TItem[] | undefined; isLoading: boolean; error: unknown }; + validate: z.ZodArray; } interface SelectInput @@ -64,12 +66,13 @@ const optionsFactory = { type: "switch" as const, defaultValue: input?.defaultValue ?? false, withDescription: input?.withDescription ?? false, + validate: z.boolean(), }), text: (input?: TextInput) => ({ type: "text" as const, defaultValue: input?.defaultValue ?? "", withDescription: input?.withDescription ?? false, - validate: input?.validate, + validate: input?.validate ?? z.string(), }), multiSelect: (input: MultiSelectInput) => ({ type: "multiSelect" as const, @@ -77,6 +80,9 @@ const optionsFactory = { options: input.options, searchable: input.searchable ?? false, withDescription: input.withDescription ?? false, + validate: z.array( + zodEnumFromArray(input.options.map((option) => (typeof option === "string" ? option : option.value))), + ), }), select: (input: SelectInput) => ({ type: "select" as const, @@ -84,6 +90,7 @@ const optionsFactory = { options: input.options, searchable: input.searchable ?? false, withDescription: input.withDescription ?? false, + validate: zodEnumFromArray(input.options.map((option) => (typeof option === "string" ? option : option.value))), }), number: (input: NumberInput) => ({ type: "number" as const, @@ -118,19 +125,20 @@ const optionsFactory = { defaultValue: input?.defaultValue ?? [], withDescription: input?.withDescription ?? false, values: [] as string[], - validate: input?.validate, + validate: input?.validate ?? z.array(z.string()), }), - multiReleasesRepositories: (input?: CommonInput & { validate?: ZodType }) => ({ + multiReleasesRepositories: (input: CommonInput & { validate: ZodType }) => ({ type: "multiReleasesRepositories" as const, - defaultValue: input?.defaultValue ?? [], - withDescription: input?.withDescription ?? false, + defaultValue: input.defaultValue ?? [], + withDescription: input.withDescription ?? false, values: [] as ReleasesRepository[], - validate: input?.validate, + validate: input.validate, }), app: () => ({ type: "app" as const, defaultValue: "", withDescription: false, + validate: z.string(), }), sortableItemList: ( input: SortableItemListInput, @@ -142,6 +150,7 @@ const optionsFactory = { uniqueIdentifier: input.uniqueIdentifier, useData: input.useData, withDescription: false, + validate: input.validate, }), };