Files
homarr/packages/api/src/router/search-engine/search-engine-router.ts
T
Meier Lukasandhomarr-renovate[bot] 5c99622fa8 fix(deps): upgrade zod to v4 and fix breaking changes (#3461)
* fix(deps): update dependency drizzle-zod to ^0.8.2

* chore: update zod to v4 import

* fix: path is no longer available in transform context

* fix: AnyZodObject does no longer exist

* fix: auth env.ts using wrong createEnv and remove unused file env-validation.ts

* fix: required_error no longer exists on z.string

* fix: zod error map is deprecated and replaced with config

* fix: default requires callback now

* fix: migrate zod resolver for mantine

* fix: remove unused form translation file

* fix: wrong enum type

* fix: record now requires two arguments

* fix: add-confirm-password-refinement type issues

* fix: add missing first record argument for entityStateSchema

* fix: migrate superrefine to check

* fix(deps): upgrade zod-form-data to v3

* fix: migrate superRefine to check for mediaUploadSchema

* fix: authProvidersSchema default is array

* fix: use stringbool instead of custom implementation

* fix: record requires first argument

* fix: migrate superRefine to check for certificate router

* fix: confirm pasword refinement is overwriting types

* fix: email optional not working

* fix: migrate intersection to object converter

* fix: safe parse return value rename

* fix: easier access for min and max number value

* fix: migrate superRefine to check for oldmarr import file

* fix: inference of enum shape for old-import board-size wrong

* fix: errors renamed to issues

* chore: address pull request feedback

* fix: zod form requires object

* fix: inference for use-zod-form not working

* fix: remove unnecessary convertion

* fix(deps): upgrade trpc-to-openapi to v3

* fix: build error

* fix: migrate missing zod imports to v4

* fix: migrate zod records to v4

* fix: missing core package dependency in api module

* fix: unable to convert custom zod schema to openapi schema

* fix(deps): upgrade zod to v4

* chore(renovate): enable zod dependency updates

* test: add simple unit test for convertIntersectionToZodObject

---------

Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2025-08-15 18:15:58 +00:00

218 lines
7.4 KiB
TypeScript

import { TRPCError } from "@trpc/server";
import { z } from "zod/v4";
import { createId } from "@homarr/common";
import { asc, eq, like } from "@homarr/db";
import { getServerSettingByKeyAsync, updateServerSettingByKeyAsync } from "@homarr/db/queries";
import { searchEngines, users } from "@homarr/db/schema";
import { createIntegrationAsync } from "@homarr/integrations";
import { logger } from "@homarr/log";
import { byIdSchema, paginatedSchema, searchSchema } from "@homarr/validation/common";
import { searchEngineEditSchema, searchEngineManageSchema } from "@homarr/validation/search-engine";
import { mediaRequestOptionsSchema, mediaRequestRequestSchema } from "@homarr/validation/widgets/media-request";
import { createOneIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure, publicProcedure } from "../../trpc";
export const searchEngineRouter = createTRPCRouter({
getPaginated: protectedProcedure.input(paginatedSchema).query(async ({ input, ctx }) => {
const whereQuery = input.search ? like(searchEngines.name, `%${input.search.trim()}%`) : undefined;
const searchEngineCount = await ctx.db.$count(searchEngines, whereQuery);
const dbSearachEngines = await ctx.db.query.searchEngines.findMany({
limit: input.pageSize,
offset: (input.page - 1) * input.pageSize,
where: whereQuery,
});
return {
items: dbSearachEngines,
totalCount: searchEngineCount,
};
}),
getSelectable: protectedProcedure
.input(z.object({ withIntegrations: z.boolean() }).default({ withIntegrations: true }))
.query(async ({ ctx, input }) => {
return await ctx.db.query.searchEngines
.findMany({
orderBy: asc(searchEngines.name),
where: input.withIntegrations ? undefined : eq(searchEngines.type, "generic"),
columns: {
id: true,
name: true,
},
})
.then((engines) => engines.map((engine) => ({ value: engine.id, label: engine.name })));
}),
byId: protectedProcedure.input(byIdSchema).query(async ({ ctx, input }) => {
const searchEngine = await ctx.db.query.searchEngines.findFirst({
where: eq(searchEngines.id, input.id),
});
if (!searchEngine) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Search engine not found",
});
}
return searchEngine.type === "fromIntegration"
? {
...searchEngine,
type: "fromIntegration" as const,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
integrationId: searchEngine.integrationId!,
}
: {
...searchEngine,
type: "generic" as const,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
urlTemplate: searchEngine.urlTemplate!,
};
}),
getDefaultSearchEngine: publicProcedure.query(async ({ ctx }) => {
const userDefaultId = ctx.session?.user.id
? ((await ctx.db.query.users
.findFirst({
where: eq(users.id, ctx.session.user.id),
columns: {
defaultSearchEngineId: true,
},
})
.then((user) => user?.defaultSearchEngineId)) ?? null)
: null;
if (userDefaultId) {
return await ctx.db.query.searchEngines.findFirst({
where: eq(searchEngines.id, userDefaultId),
with: {
integration: {
columns: {
kind: true,
url: true,
id: true,
},
},
},
});
}
const searchSettings = await getServerSettingByKeyAsync(ctx.db, "search");
if (!searchSettings.defaultSearchEngineId) return null;
const serverDefault = await ctx.db.query.searchEngines.findFirst({
where: eq(searchEngines.id, searchSettings.defaultSearchEngineId),
with: {
integration: {
columns: {
kind: true,
url: true,
id: true,
},
},
},
});
if (serverDefault) return serverDefault;
// Remove the default search engine ID from settings if it does not longer exist
try {
await updateServerSettingByKeyAsync(ctx.db, "search", {
...searchSettings,
defaultSearchEngineId: null,
});
} catch (error) {
logger.warn(
new Error("Failed to update search settings after default search engine not found", { cause: error }),
);
}
return null;
}),
search: protectedProcedure.input(searchSchema).query(async ({ ctx, input }) => {
return await ctx.db.query.searchEngines.findMany({
where: like(searchEngines.short, `${input.query.toLowerCase().trim()}%`),
with: {
integration: {
columns: {
kind: true,
url: true,
id: true,
},
},
},
limit: input.limit,
});
}),
getMediaRequestOptions: protectedProcedure
.concat(createOneIntegrationMiddleware("query", "jellyseerr", "overseerr"))
.input(mediaRequestOptionsSchema)
.query(async ({ ctx, input }) => {
const integration = await createIntegrationAsync(ctx.integration);
return await integration.getSeriesInformationAsync(input.mediaType, input.mediaId);
}),
requestMedia: protectedProcedure
.concat(createOneIntegrationMiddleware("interact", "jellyseerr", "overseerr"))
.input(mediaRequestRequestSchema)
.mutation(async ({ ctx, input }) => {
const integration = await createIntegrationAsync(ctx.integration);
return await integration.requestMediaAsync(input.mediaType, input.mediaId, input.seasons);
}),
create: permissionRequiredProcedure
.requiresPermission("search-engine-create")
.input(searchEngineManageSchema)
.mutation(async ({ ctx, input }) => {
await ctx.db.insert(searchEngines).values({
id: createId(),
name: input.name,
short: input.short.toLowerCase(),
iconUrl: input.iconUrl,
urlTemplate: "urlTemplate" in input ? input.urlTemplate : null,
description: input.description,
type: input.type,
integrationId: "integrationId" in input ? input.integrationId : null,
});
}),
update: permissionRequiredProcedure
.requiresPermission("search-engine-modify-all")
.input(searchEngineEditSchema)
.mutation(async ({ ctx, input }) => {
const searchEngine = await ctx.db.query.searchEngines.findFirst({
where: eq(searchEngines.id, input.id),
});
if (!searchEngine) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Search engine not found",
});
}
await ctx.db
.update(searchEngines)
.set({
name: input.name,
iconUrl: input.iconUrl,
urlTemplate: "urlTemplate" in input ? input.urlTemplate : null,
description: input.description,
integrationId: "integrationId" in input ? input.integrationId : null,
type: input.type,
})
.where(eq(searchEngines.id, input.id));
}),
delete: permissionRequiredProcedure
.requiresPermission("search-engine-full-all")
.input(byIdSchema)
.mutation(async ({ ctx, input }) => {
await ctx.db
.update(users)
.set({
defaultSearchEngineId: null,
})
.where(eq(users.defaultSearchEngineId, input.id));
await ctx.db.delete(searchEngines).where(eq(searchEngines.id, input.id));
}),
});